Compare commits

..

1 Commits

Author SHA1 Message Date
henrygd
c561aef409 gate apple gpu collectors + revert readme change 2026-02-18 14:53:09 -05:00
132 changed files with 889 additions and 5530 deletions

View File

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

View File

@@ -6,6 +6,7 @@ package agent
import ( import (
"log/slog" "log/slog"
"os"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -13,7 +14,6 @@ import (
"github.com/gliderlabs/ssh" "github.com/gliderlabs/ssh"
"github.com/henrygd/beszel" "github.com/henrygd/beszel"
"github.com/henrygd/beszel/agent/deltatracker" "github.com/henrygd/beszel/agent/deltatracker"
"github.com/henrygd/beszel/agent/utils"
"github.com/henrygd/beszel/internal/common" "github.com/henrygd/beszel/internal/common"
"github.com/henrygd/beszel/internal/entities/system" "github.com/henrygd/beszel/internal/entities/system"
gossh "golang.org/x/crypto/ssh" 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) slog.Info("Data directory", "path", agent.dataDir)
} }
agent.memCalc, _ = utils.GetEnv("MEM_CALC") agent.memCalc, _ = GetEnv("MEM_CALC")
agent.sensorConfig = agent.newSensorConfig() agent.sensorConfig = agent.newSensorConfig()
// Parse disk usage cache duration (e.g., "15m", "1h") to avoid waking sleeping disks // Parse disk usage cache duration (e.g., "15m", "1h") to avoid waking sleeping disks
if diskUsageCache, exists := utils.GetEnv("DISK_USAGE_CACHE"); exists { if diskUsageCache, exists := GetEnv("DISK_USAGE_CACHE"); exists {
if duration, err := time.ParseDuration(diskUsageCache); err == nil { if duration, err := time.ParseDuration(diskUsageCache); err == nil {
agent.diskUsageCacheDuration = duration agent.diskUsageCacheDuration = duration
slog.Info("DISK_USAGE_CACHE", "duration", 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 // Set up slog with a log level determined by the LOG_LEVEL env var
if logLevelStr, exists := utils.GetEnv("LOG_LEVEL"); exists { if logLevelStr, exists := GetEnv("LOG_LEVEL"); exists {
switch strings.ToLower(logLevelStr) { switch strings.ToLower(logLevelStr) {
case "debug": case "debug":
agent.debug = true agent.debug = true
@@ -103,7 +103,7 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
agent.refreshSystemDetails() agent.refreshSystemDetails()
// SMART_INTERVAL env var to update smart data at this interval // SMART_INTERVAL env var to update smart data at this interval
if smartIntervalEnv, exists := utils.GetEnv("SMART_INTERVAL"); exists { if smartIntervalEnv, exists := GetEnv("SMART_INTERVAL"); exists {
if duration, err := time.ParseDuration(smartIntervalEnv); err == nil && duration > 0 { if duration, err := time.ParseDuration(smartIntervalEnv); err == nil && duration > 0 {
agent.systemDetails.SmartInterval = duration agent.systemDetails.SmartInterval = duration
slog.Info("SMART_INTERVAL", "duration", duration) slog.Info("SMART_INTERVAL", "duration", duration)
@@ -148,6 +148,15 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
return agent, nil 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 { func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedData {
a.Lock() a.Lock()
defer a.Unlock() defer a.Unlock()
@@ -204,7 +213,7 @@ func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedD
data.Stats.ExtraFs[key] = stats data.Stats.ExtraFs[key] = stats
// Add percentages to Info struct for dashboard // Add percentages to Info struct for dashboard
if stats.DiskTotal > 0 { if stats.DiskTotal > 0 {
pct := utils.TwoDecimals((stats.DiskUsed / stats.DiskTotal) * 100) pct := twoDecimals((stats.DiskUsed / stats.DiskTotal) * 100)
data.Info.ExtraFsPct[key] = pct data.Info.ExtraFsPct[key] = pct
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,7 +8,6 @@ import (
"strings" "strings"
"time" "time"
"github.com/henrygd/beszel/agent/utils"
"github.com/henrygd/beszel/internal/entities/system" "github.com/henrygd/beszel/internal/entities/system"
"github.com/shirou/gopsutil/v4/disk" "github.com/shirou/gopsutil/v4/disk"
@@ -38,7 +37,7 @@ func isDockerSpecialMountpoint(mountpoint string) bool {
// Sets up the filesystems to monitor for disk usage and I/O. // Sets up the filesystems to monitor for disk usage and I/O.
func (a *Agent) initializeDiskInfo() { func (a *Agent) initializeDiskInfo() {
filesystem, _ := utils.GetEnv("FILESYSTEM") filesystem, _ := GetEnv("FILESYSTEM")
efPath := "/extra-filesystems" efPath := "/extra-filesystems"
hasRoot := false hasRoot := false
isWindows := runtime.GOOS == "windows" isWindows := runtime.GOOS == "windows"
@@ -79,21 +78,14 @@ func (a *Agent) initializeDiskInfo() {
if _, exists := a.fsStats[key]; !exists { if _, exists := a.fsStats[key]; !exists {
if root { if root {
slog.Info("Detected root device", "name", key) slog.Info("Detected root device", "name", key)
// Try to map root device to a diskIoCounters entry. First // Check if root device is in /proc/diskstats. Do not guess a
// checks for an exact key match, then uses findIoDevice for // fallback device for root: that can misattribute root I/O to a
// normalized / prefix-based matching (e.g. nda0p2 → nda0), // different disk while usage remains tied to root mountpoint.
// and finally falls back to the FILESYSTEM env var.
if _, ioMatch = diskIoCounters[key]; !ioMatch { if _, ioMatch = diskIoCounters[key]; !ioMatch {
if matchedKey, match := findIoDevice(key, diskIoCounters); match {
key = matchedKey
ioMatch = true
} else if filesystem != "" {
if matchedKey, match := findIoDevice(filesystem, diskIoCounters); match { if matchedKey, match := findIoDevice(filesystem, diskIoCounters); match {
key = matchedKey key = matchedKey
ioMatch = true ioMatch = true
} } else {
}
if !ioMatch {
slog.Warn("Root I/O unmapped; set FILESYSTEM", "device", device, "mountpoint", mountpoint) slog.Warn("Root I/O unmapped; set FILESYSTEM", "device", device, "mountpoint", mountpoint)
} }
} }
@@ -122,28 +114,20 @@ func (a *Agent) initializeDiskInfo() {
// Use FILESYSTEM env var to find root filesystem // Use FILESYSTEM env var to find root filesystem
if filesystem != "" { if filesystem != "" {
for _, p := range partitions { for _, p := range partitions {
if filesystemMatchesPartitionSetting(filesystem, p) { if strings.HasSuffix(p.Device, filesystem) || p.Mountpoint == filesystem {
addFsStat(p.Device, p.Mountpoint, true) addFsStat(p.Device, p.Mountpoint, true)
hasRoot = true hasRoot = true
break break
} }
} }
if !hasRoot { if !hasRoot {
// FILESYSTEM may name a physical disk absent from partitions (e.g.
// ZFS lists dataset paths like zroot/ROOT/default, not block devices).
// Try matching directly against diskIoCounters.
if ioKey, match := findIoDevice(filesystem, diskIoCounters); match {
a.fsStats[ioKey] = &system.FsStats{Root: true, Mountpoint: rootMountPoint}
hasRoot = true
} else {
slog.Warn("Partition details not found", "filesystem", filesystem) slog.Warn("Partition details not found", "filesystem", filesystem)
} }
} }
}
// Add EXTRA_FILESYSTEMS env var values to fsStats // Add EXTRA_FILESYSTEMS env var values to fsStats
if extraFilesystems, exists := utils.GetEnv("EXTRA_FILESYSTEMS"); exists { if extraFilesystems, exists := GetEnv("EXTRA_FILESYSTEMS"); exists {
for fsEntry := range strings.SplitSeq(extraFilesystems, ",") { for _, fsEntry := range strings.Split(extraFilesystems, ",") {
// Parse custom name from format: device__customname // Parse custom name from format: device__customname
fs, customName := parseFilesystemEntry(fsEntry) fs, customName := parseFilesystemEntry(fsEntry)
@@ -203,182 +187,30 @@ func (a *Agent) initializeDiskInfo() {
} }
} }
// If no root filesystem set, try the most active I/O device as a last // If no root filesystem set, use fallback
// resort (e.g. ZFS where dataset names are unrelated to disk names).
if !hasRoot { if !hasRoot {
rootKey := mostActiveIoDevice(diskIoCounters) rootKey := filepath.Base(rootMountPoint)
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 { if _, exists := a.fsStats[rootKey]; exists {
rootKey = "root" 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.fsStats[rootKey] = &system.FsStats{Root: true, Mountpoint: rootMountPoint}
} }
a.pruneDuplicateRootExtraFilesystems()
a.initializeDiskIoStats(diskIoCounters) a.initializeDiskIoStats(diskIoCounters)
} }
// Removes extra filesystems that mirror root usage (https://github.com/henrygd/beszel/issues/1428). // Returns matching device from /proc/diskstats.
func (a *Agent) pruneDuplicateRootExtraFilesystems() { // bool is true if a match was found.
var rootMountpoint string
for _, stats := range a.fsStats {
if stats != nil && stats.Root {
rootMountpoint = stats.Mountpoint
break
}
}
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) { func findIoDevice(filesystem string, diskIoCounters map[string]disk.IOCountersStat) (string, bool) {
filesystem = normalizeDeviceName(filesystem)
if filesystem == "" {
return "", false
}
candidates := []ioMatchCandidate{}
for _, d := range diskIoCounters { for _, d := range diskIoCounters {
if normalizeDeviceName(d.Name) == filesystem || (d.Label != "" && normalizeDeviceName(d.Label) == filesystem) { if d.Name == filesystem || (d.Label != "" && d.Label == filesystem) {
return d.Name, true 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 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. // Sets start values for disk I/O stats.
func (a *Agent) initializeDiskIoStats(diskIoCounters map[string]disk.IOCountersStat) { func (a *Agent) initializeDiskIoStats(diskIoCounters map[string]disk.IOCountersStat) {
for device, stats := range a.fsStats { for device, stats := range a.fsStats {
@@ -413,12 +245,12 @@ func (a *Agent) updateDiskUsage(systemStats *system.Stats) {
continue continue
} }
if d, err := disk.Usage(stats.Mountpoint); err == nil { if d, err := disk.Usage(stats.Mountpoint); err == nil {
stats.DiskTotal = utils.BytesToGigabytes(d.Total) stats.DiskTotal = bytesToGigabytes(d.Total)
stats.DiskUsed = utils.BytesToGigabytes(d.Used) stats.DiskUsed = bytesToGigabytes(d.Used)
if stats.Root { if stats.Root {
systemStats.DiskTotal = utils.BytesToGigabytes(d.Total) systemStats.DiskTotal = bytesToGigabytes(d.Total)
systemStats.DiskUsed = utils.BytesToGigabytes(d.Used) systemStats.DiskUsed = bytesToGigabytes(d.Used)
systemStats.DiskPct = utils.TwoDecimals(d.UsedPercent) systemStats.DiskPct = twoDecimals(d.UsedPercent)
} }
} else { } else {
// reset stats if error (likely unmounted) // reset stats if error (likely unmounted)
@@ -471,8 +303,8 @@ func (a *Agent) updateDiskIo(cacheTimeMs uint16, systemStats *system.Stats) {
diskIORead := (d.ReadBytes - prev.readBytes) * 1000 / msElapsed diskIORead := (d.ReadBytes - prev.readBytes) * 1000 / msElapsed
diskIOWrite := (d.WriteBytes - prev.writeBytes) * 1000 / msElapsed diskIOWrite := (d.WriteBytes - prev.writeBytes) * 1000 / msElapsed
readMbPerSecond := utils.BytesToMegabytes(float64(diskIORead)) readMbPerSecond := bytesToMegabytes(float64(diskIORead))
writeMbPerSecond := utils.BytesToMegabytes(float64(diskIOWrite)) writeMbPerSecond := bytesToMegabytes(float64(diskIOWrite))
// validate values // validate values
if readMbPerSecond > 50_000 || writeMbPerSecond > 50_000 { if readMbPerSecond > 50_000 || writeMbPerSecond > 50_000 {

View File

@@ -1,4 +1,5 @@
//go:build testing //go:build testing
// +build testing
package agent package agent
@@ -116,7 +117,7 @@ func TestFindIoDevice(t *testing.T) {
assert.Equal(t, "sda", device) assert.Equal(t, "sda", device)
}) })
t.Run("returns no match when not found", func(t *testing.T) { t.Run("returns no fallback when not found", func(t *testing.T) {
ioCounters := map[string]disk.IOCountersStat{ ioCounters := map[string]disk.IOCountersStat{
"sda": {Name: "sda"}, "sda": {Name: "sda"},
"sdb": {Name: "sdb"}, "sdb": {Name: "sdb"},
@@ -126,106 +127,6 @@ func TestFindIoDevice(t *testing.T) {
assert.False(t, ok) assert.False(t, ok)
assert.Equal(t, "", device) 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) { func TestIsDockerSpecialMountpoint(t *testing.T) {
@@ -472,37 +373,3 @@ func TestDiskUsageCaching(t *testing.T) {
"lastDiskUsageUpdate should be refreshed when cache expires") "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,7 +1,6 @@
package agent package agent
import ( import (
"bufio"
"bytes" "bytes"
"context" "context"
"encoding/binary" "encoding/binary"
@@ -21,7 +20,6 @@ import (
"time" "time"
"github.com/henrygd/beszel/agent/deltatracker" "github.com/henrygd/beszel/agent/deltatracker"
"github.com/henrygd/beszel/agent/utils"
"github.com/henrygd/beszel/internal/entities/container" "github.com/henrygd/beszel/internal/entities/container"
"github.com/blang/semver" "github.com/blang/semver"
@@ -30,7 +28,6 @@ import (
// ansiEscapePattern matches ANSI escape sequences (colors, cursor movement, etc.) // ansiEscapePattern matches ANSI escape sequences (colors, cursor movement, etc.)
// This includes CSI sequences like \x1b[...m and simple escapes like \x1b[K // 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 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 ( const (
// Docker API timeout in milliseconds // Docker API timeout in milliseconds
@@ -337,12 +334,12 @@ func validateCpuPercentage(cpuPct float64, containerName string) error {
// updateContainerStatsValues updates the final stats values // updateContainerStatsValues updates the final stats values
func updateContainerStatsValues(stats *container.Stats, cpuPct float64, usedMemory uint64, sent_delta, recv_delta uint64, readTime time.Time) { func updateContainerStatsValues(stats *container.Stats, cpuPct float64, usedMemory uint64, sent_delta, recv_delta uint64, readTime time.Time) {
stats.Cpu = utils.TwoDecimals(cpuPct) stats.Cpu = twoDecimals(cpuPct)
stats.Mem = utils.BytesToMegabytes(float64(usedMemory)) stats.Mem = bytesToMegabytes(float64(usedMemory))
stats.Bandwidth = [2]uint64{sent_delta, recv_delta} stats.Bandwidth = [2]uint64{sent_delta, recv_delta}
// TODO(0.19+): stop populating NetworkSent/NetworkRecv (deprecated in 0.18.3) // TODO(0.19+): stop populating NetworkSent/NetworkRecv (deprecated in 0.18.3)
stats.NetworkSent = utils.BytesToMegabytes(float64(sent_delta)) stats.NetworkSent = bytesToMegabytes(float64(sent_delta))
stats.NetworkRecv = utils.BytesToMegabytes(float64(recv_delta)) stats.NetworkRecv = bytesToMegabytes(float64(recv_delta))
stats.PrevReadTime = readTime stats.PrevReadTime = readTime
} }
@@ -488,7 +485,7 @@ func (dm *dockerManager) deleteContainerStatsSync(id string) {
// Creates a new http client for Docker or Podman API // Creates a new http client for Docker or Podman API
func newDockerManager() *dockerManager { func newDockerManager() *dockerManager {
dockerHost, exists := utils.GetEnv("DOCKER_HOST") dockerHost, exists := GetEnv("DOCKER_HOST")
if exists { if exists {
// return nil if set to empty string // return nil if set to empty string
if dockerHost == "" { if dockerHost == "" {
@@ -524,7 +521,7 @@ func newDockerManager() *dockerManager {
// configurable timeout // configurable timeout
timeout := time.Millisecond * time.Duration(dockerTimeoutMs) timeout := time.Millisecond * time.Duration(dockerTimeoutMs)
if t, set := utils.GetEnv("DOCKER_TIMEOUT"); set { if t, set := GetEnv("DOCKER_TIMEOUT"); set {
timeout, err = time.ParseDuration(t) timeout, err = time.ParseDuration(t)
if err != nil { if err != nil {
slog.Error(err.Error()) slog.Error(err.Error())
@@ -541,7 +538,7 @@ func newDockerManager() *dockerManager {
// Read container exclusion patterns from environment variable // Read container exclusion patterns from environment variable
var excludeContainers []string var excludeContainers []string
if excludeStr, set := utils.GetEnv("EXCLUDE_CONTAINERS"); set && excludeStr != "" { if excludeStr, set := GetEnv("EXCLUDE_CONTAINERS"); set && excludeStr != "" {
parts := strings.SplitSeq(excludeStr, ",") parts := strings.SplitSeq(excludeStr, ",")
for part := range parts { for part := range parts {
trimmed := strings.TrimSpace(part) trimmed := strings.TrimSpace(part)
@@ -652,34 +649,9 @@ func getDockerHost() string {
return scheme + socks[0] 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 // getContainerInfo fetches the inspection data for a container
func (dm *dockerManager) getContainerInfo(ctx context.Context, containerID string) ([]byte, error) { func (dm *dockerManager) getContainerInfo(ctx context.Context, containerID string) ([]byte, error) {
endpoint, err := buildDockerContainerEndpoint(containerID, "json", nil) endpoint := fmt.Sprintf("http://localhost/containers/%s/json", containerID)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -710,15 +682,7 @@ func (dm *dockerManager) getContainerInfo(ctx context.Context, containerID strin
// getLogs fetches the logs for a container // getLogs fetches the logs for a container
func (dm *dockerManager) getLogs(ctx context.Context, containerID string) (string, error) { func (dm *dockerManager) getLogs(ctx context.Context, containerID string) (string, error) {
query := url.Values{ endpoint := fmt.Sprintf("http://localhost/containers/%s/logs?stdout=1&stderr=1&tail=%d", containerID, dockerLogsTail)
"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) req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil { if err != nil {
return "", err return "", err
@@ -736,17 +700,8 @@ func (dm *dockerManager) getLogs(ctx context.Context, containerID string) (strin
} }
var builder strings.Builder var builder strings.Builder
contentType := resp.Header.Get("Content-Type") multiplexed := resp.Header.Get("Content-Type") == "application/vnd.docker.multiplexed-stream"
multiplexed := strings.HasSuffix(contentType, "multiplexed-stream") if err := decodeDockerLogStream(resp.Body, &builder, multiplexed); err != nil {
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 return "", err
} }
@@ -758,23 +713,6 @@ func (dm *dockerManager) getLogs(ctx context.Context, containerID string) (strin
return logs, nil 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 { func decodeDockerLogStream(reader io.Reader, builder *strings.Builder, multiplexed bool) error {
if !multiplexed { if !multiplexed {
_, err := io.Copy(builder, io.LimitReader(reader, maxTotalLogSize)) _, err := io.Copy(builder, io.LimitReader(reader, maxTotalLogSize))

View File

@@ -1,4 +1,5 @@
//go:build testing //go:build testing
// +build testing
package agent package agent
@@ -8,7 +9,6 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io"
"net" "net"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@@ -18,7 +18,6 @@ import (
"time" "time"
"github.com/henrygd/beszel/agent/deltatracker" "github.com/henrygd/beszel/agent/deltatracker"
"github.com/henrygd/beszel/agent/utils"
"github.com/henrygd/beszel/internal/entities/container" "github.com/henrygd/beszel/internal/entities/container"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -26,37 +25,6 @@ import (
var defaultCacheTimeMs = uint16(60_000) 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 // cycleCpuDeltas cycles the CPU tracking data for a specific cache time interval
func (dm *dockerManager) cycleCpuDeltas(cacheTimeMs uint16) { func (dm *dockerManager) cycleCpuDeltas(cacheTimeMs uint16) {
// Clear the CPU tracking maps for this cache time interval // Clear the CPU tracking maps for this cache time interval
@@ -148,72 +116,6 @@ 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) { func TestValidateCpuPercentage(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -299,6 +201,48 @@ func TestUpdateContainerStatsValues(t *testing.T) {
assert.Equal(t, testTime, stats.PrevReadTime) 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) { func TestInitializeCpuTracking(t *testing.T) {
dm := &dockerManager{ dm := &dockerManager{
lastCpuContainer: make(map[uint16]map[string]uint64), lastCpuContainer: make(map[uint16]map[string]uint64),
@@ -864,50 +808,14 @@ func TestContainerStatsEndToEndWithRealData(t *testing.T) {
updateContainerStatsValues(testStats, cpuPct, usedMemory, 1000000, 500000, testTime) updateContainerStatsValues(testStats, cpuPct, usedMemory, 1000000, 500000, testTime)
assert.Equal(t, cpuPct, testStats.Cpu) assert.Equal(t, cpuPct, testStats.Cpu)
assert.Equal(t, utils.BytesToMegabytes(float64(usedMemory)), testStats.Mem) assert.Equal(t, bytesToMegabytes(float64(usedMemory)), testStats.Mem)
assert.Equal(t, [2]uint64{1000000, 500000}, testStats.Bandwidth) assert.Equal(t, [2]uint64{1000000, 500000}, testStats.Bandwidth)
// Deprecated fields still populated for backward compatibility with older hubs // Deprecated fields still populated for backward compatibility with older hubs
assert.Equal(t, utils.BytesToMegabytes(1000000), testStats.NetworkSent) assert.Equal(t, bytesToMegabytes(1000000), testStats.NetworkSent)
assert.Equal(t, utils.BytesToMegabytes(500000), testStats.NetworkRecv) assert.Equal(t, bytesToMegabytes(500000), testStats.NetworkRecv)
assert.Equal(t, testTime, testStats.PrevReadTime) 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) { func TestEdgeCasesWithRealData(t *testing.T) {
// Test with minimal container stats // Test with minimal container stats
minimalStats := &container.ApiStats{ minimalStats := &container.ApiStats{
@@ -1149,13 +1057,13 @@ func TestConstantsAndUtilityFunctions(t *testing.T) {
assert.Equal(t, 5*1024*1024, maxTotalLogSize) // 5MB assert.Equal(t, 5*1024*1024, maxTotalLogSize) // 5MB
// Test utility functions // Test utility functions
assert.Equal(t, 1.5, utils.TwoDecimals(1.499)) assert.Equal(t, 1.5, twoDecimals(1.499))
assert.Equal(t, 1.5, utils.TwoDecimals(1.5)) assert.Equal(t, 1.5, twoDecimals(1.5))
assert.Equal(t, 1.5, utils.TwoDecimals(1.501)) assert.Equal(t, 1.5, twoDecimals(1.501))
assert.Equal(t, 1.0, utils.BytesToMegabytes(1048576)) // 1 MB assert.Equal(t, 1.0, bytesToMegabytes(1048576)) // 1 MB
assert.Equal(t, 0.5, utils.BytesToMegabytes(524288)) // 512 KB assert.Equal(t, 0.5, bytesToMegabytes(524288)) // 512 KB
assert.Equal(t, 0.0, utils.BytesToMegabytes(0)) assert.Equal(t, 0.0, bytesToMegabytes(0))
} }
func TestDecodeDockerLogStream(t *testing.T) { func TestDecodeDockerLogStream(t *testing.T) {

View File

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

View File

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

View File

@@ -15,7 +15,6 @@ import (
"sync" "sync"
"time" "time"
"github.com/henrygd/beszel/agent/utils"
"github.com/henrygd/beszel/internal/entities/system" "github.com/henrygd/beszel/internal/entities/system"
) )
@@ -292,8 +291,8 @@ func (gm *GPUManager) parseAmdData(output []byte) bool {
} }
gpu := gm.GpuDataMap[id] gpu := gm.GpuDataMap[id]
gpu.Temperature, _ = strconv.ParseFloat(v.Temperature, 64) gpu.Temperature, _ = strconv.ParseFloat(v.Temperature, 64)
gpu.MemoryUsed = utils.BytesToMegabytes(memoryUsage) gpu.MemoryUsed = bytesToMegabytes(memoryUsage)
gpu.MemoryTotal = utils.BytesToMegabytes(totalMemory) gpu.MemoryTotal = bytesToMegabytes(totalMemory)
gpu.Usage += usage gpu.Usage += usage
gpu.Power += power gpu.Power += power
gpu.Count++ gpu.Count++
@@ -367,16 +366,16 @@ func (gm *GPUManager) calculateGPUAverage(id string, gpu *system.GPUData, cacheK
gpuAvg := *gpu gpuAvg := *gpu
deltaUsage, deltaPower, deltaPowerPkg := gm.calculateDeltas(gpu, lastSnapshot) deltaUsage, deltaPower, deltaPowerPkg := gm.calculateDeltas(gpu, lastSnapshot)
gpuAvg.Power = utils.TwoDecimals(deltaPower / float64(deltaCount)) gpuAvg.Power = twoDecimals(deltaPower / float64(deltaCount))
if gpu.Engines != nil { if gpu.Engines != nil {
// make fresh map for averaged engine metrics to avoid mutating // make fresh map for averaged engine metrics to avoid mutating
// the accumulator map stored in gm.GpuDataMap // the accumulator map stored in gm.GpuDataMap
gpuAvg.Engines = make(map[string]float64, len(gpu.Engines)) gpuAvg.Engines = make(map[string]float64, len(gpu.Engines))
gpuAvg.Usage = gm.calculateIntelGPUUsage(&gpuAvg, gpu, lastSnapshot, deltaCount) gpuAvg.Usage = gm.calculateIntelGPUUsage(&gpuAvg, gpu, lastSnapshot, deltaCount)
gpuAvg.PowerPkg = utils.TwoDecimals(deltaPowerPkg / float64(deltaCount)) gpuAvg.PowerPkg = twoDecimals(deltaPowerPkg / float64(deltaCount))
} else { } else {
gpuAvg.Usage = utils.TwoDecimals(deltaUsage / float64(deltaCount)) gpuAvg.Usage = twoDecimals(deltaUsage / float64(deltaCount))
} }
gm.lastAvgData[id] = gpuAvg gm.lastAvgData[id] = gpuAvg
@@ -411,17 +410,17 @@ func (gm *GPUManager) calculateIntelGPUUsage(gpuAvg, gpu *system.GPUData, lastSn
} else { } else {
deltaEngine = engine deltaEngine = engine
} }
gpuAvg.Engines[name] = utils.TwoDecimals(deltaEngine / float64(deltaCount)) gpuAvg.Engines[name] = twoDecimals(deltaEngine / float64(deltaCount))
maxEngineUsage = max(maxEngineUsage, deltaEngine/float64(deltaCount)) maxEngineUsage = max(maxEngineUsage, deltaEngine/float64(deltaCount))
} }
return utils.TwoDecimals(maxEngineUsage) return twoDecimals(maxEngineUsage)
} }
// updateInstantaneousValues updates values that should reflect current state, not averages // updateInstantaneousValues updates values that should reflect current state, not averages
func (gm *GPUManager) updateInstantaneousValues(gpuAvg *system.GPUData, gpu *system.GPUData) { func (gm *GPUManager) updateInstantaneousValues(gpuAvg *system.GPUData, gpu *system.GPUData) {
gpuAvg.Temperature = utils.TwoDecimals(gpu.Temperature) gpuAvg.Temperature = twoDecimals(gpu.Temperature)
gpuAvg.MemoryUsed = utils.TwoDecimals(gpu.MemoryUsed) gpuAvg.MemoryUsed = twoDecimals(gpu.MemoryUsed)
gpuAvg.MemoryTotal = utils.TwoDecimals(gpu.MemoryTotal) gpuAvg.MemoryTotal = twoDecimals(gpu.MemoryTotal)
} }
// storeSnapshot saves the current GPU state for this cache key // storeSnapshot saves the current GPU state for this cache key
@@ -688,7 +687,7 @@ func (gm *GPUManager) resolveLegacyCollectorPriority(caps gpuCapabilities) []col
priorities := make([]collectorSource, 0, 4) priorities := make([]collectorSource, 0, 4)
if caps.hasNvidiaSmi && !caps.hasTegrastats { if caps.hasNvidiaSmi && !caps.hasTegrastats {
if nvml, _ := utils.GetEnv("NVML"); nvml == "true" { if nvml, _ := GetEnv("NVML"); nvml == "true" {
priorities = append(priorities, collectorSourceNVML, collectorSourceNvidiaSMI) priorities = append(priorities, collectorSourceNVML, collectorSourceNvidiaSMI)
} else { } else {
priorities = append(priorities, collectorSourceNvidiaSMI) priorities = append(priorities, collectorSourceNvidiaSMI)
@@ -696,7 +695,7 @@ func (gm *GPUManager) resolveLegacyCollectorPriority(caps gpuCapabilities) []col
} }
if caps.hasRocmSmi { if caps.hasRocmSmi {
if val, _ := utils.GetEnv("AMD_SYSFS"); val == "true" { if val, _ := GetEnv("AMD_SYSFS"); val == "true" {
priorities = append(priorities, collectorSourceAmdSysfs) priorities = append(priorities, collectorSourceAmdSysfs)
} else { } else {
priorities = append(priorities, collectorSourceRocmSMI) priorities = append(priorities, collectorSourceRocmSMI)
@@ -709,16 +708,8 @@ func (gm *GPUManager) resolveLegacyCollectorPriority(caps gpuCapabilities) []col
priorities = append(priorities, collectorSourceIntelGpuTop) priorities = append(priorities, collectorSourceIntelGpuTop)
} }
// Apple collectors are currently opt-in only for testing. // Apple collectors are currently opt-in only.
// Enable them with GPU_COLLECTOR=macmon or GPU_COLLECTOR=powermetrics. // 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. // Keep nvtop as a last resort only when no vendor collector exists.
if len(priorities) == 0 && caps.hasNvtop { if len(priorities) == 0 && caps.hasNvtop {
@@ -729,7 +720,7 @@ func (gm *GPUManager) resolveLegacyCollectorPriority(caps gpuCapabilities) []col
// NewGPUManager creates and initializes a new GPUManager // NewGPUManager creates and initializes a new GPUManager
func NewGPUManager() (*GPUManager, error) { func NewGPUManager() (*GPUManager, error) {
if skipGPU, _ := utils.GetEnv("SKIP_GPU"); skipGPU == "true" { if skipGPU, _ := GetEnv("SKIP_GPU"); skipGPU == "true" {
return nil, nil return nil, nil
} }
var gm GPUManager var gm GPUManager
@@ -746,7 +737,7 @@ func NewGPUManager() (*GPUManager, error) {
} }
// if GPU_COLLECTOR is set, start user-defined collectors. // if GPU_COLLECTOR is set, start user-defined collectors.
if collectorConfig, ok := utils.GetEnv("GPU_COLLECTOR"); ok && strings.TrimSpace(collectorConfig) != "" { if collectorConfig, ok := GetEnv("GPU_COLLECTOR"); ok && strings.TrimSpace(collectorConfig) != "" {
priorities := parseCollectorPriority(collectorConfig) priorities := parseCollectorPriority(collectorConfig)
if gm.startCollectorsByPriority(priorities, caps) == 0 { if gm.startCollectorsByPriority(priorities, caps) == 0 {
return nil, fmt.Errorf("no configured GPU collectors are available") return nil, fmt.Errorf("no configured GPU collectors are available")

View File

@@ -13,7 +13,6 @@ import (
"sync" "sync"
"time" "time"
"github.com/henrygd/beszel/agent/utils"
"github.com/henrygd/beszel/internal/entities/system" "github.com/henrygd/beszel/internal/entities/system"
) )
@@ -33,8 +32,8 @@ func (gm *GPUManager) hasAmdSysfs() bool {
return false return false
} }
for _, vendorPath := range cards { for _, vendorPath := range cards {
vendor, err := utils.ReadStringFileLimited(vendorPath, 64) vendor, err := os.ReadFile(vendorPath)
if err == nil && vendor == "0x1002" { if err == nil && strings.TrimSpace(string(vendor)) == "0x1002" {
return true return true
} }
} }
@@ -88,11 +87,12 @@ func (gm *GPUManager) collectAmdStats() error {
// isAmdGpu checks whether a DRM card path belongs to AMD vendor ID 0x1002. // isAmdGpu checks whether a DRM card path belongs to AMD vendor ID 0x1002.
func isAmdGpu(cardPath string) bool { func isAmdGpu(cardPath string) bool {
vendor, err := utils.ReadStringFileLimited(filepath.Join(cardPath, "device/vendor"), 64) vendorPath := filepath.Join(cardPath, "device/vendor")
vendor, err := os.ReadFile(vendorPath)
if err != nil { if err != nil {
return false return false
} }
return vendor == "0x1002" return strings.TrimSpace(string(vendor)) == "0x1002"
} }
// updateAmdGpuData reads GPU metrics from sysfs and updates the GPU data map. // updateAmdGpuData reads GPU metrics from sysfs and updates the GPU data map.
@@ -103,8 +103,10 @@ func (gm *GPUManager) updateAmdGpuData(cardPath string) bool {
// Read all sysfs values first (no lock needed - these can be slow) // Read all sysfs values first (no lock needed - these can be slow)
usage, usageErr := readSysfsFloat(filepath.Join(devicePath, "gpu_busy_percent")) usage, usageErr := readSysfsFloat(filepath.Join(devicePath, "gpu_busy_percent"))
memUsed, memUsedErr := readSysfsFloat(filepath.Join(devicePath, "mem_info_vram_used")) vramUsed, memUsedErr := readSysfsFloat(filepath.Join(devicePath, "mem_info_vram_used"))
memTotal, _ := readSysfsFloat(filepath.Join(devicePath, "mem_info_vram_total")) vramTotal, _ := readSysfsFloat(filepath.Join(devicePath, "mem_info_vram_total"))
memUsed := vramUsed
memTotal := vramTotal
// if gtt is present, add it to the memory used and total (https://github.com/henrygd/beszel/issues/1569#issuecomment-3837640484) // 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 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 { if gttTotal, err := readSysfsFloat(filepath.Join(devicePath, "mem_info_gtt_total")); err == nil {
@@ -144,8 +146,8 @@ func (gm *GPUManager) updateAmdGpuData(cardPath string) bool {
if usageErr == nil { if usageErr == nil {
gpu.Usage += usage gpu.Usage += usage
} }
gpu.MemoryUsed = utils.BytesToMegabytes(memUsed) gpu.MemoryUsed = bytesToMegabytes(memUsed)
gpu.MemoryTotal = utils.BytesToMegabytes(memTotal) gpu.MemoryTotal = bytesToMegabytes(memTotal)
gpu.Temperature = temp gpu.Temperature = temp
gpu.Power += power gpu.Power += power
gpu.Count++ gpu.Count++
@@ -154,11 +156,11 @@ func (gm *GPUManager) updateAmdGpuData(cardPath string) bool {
// readSysfsFloat reads and parses a numeric value from a sysfs file. // readSysfsFloat reads and parses a numeric value from a sysfs file.
func readSysfsFloat(path string) (float64, error) { func readSysfsFloat(path string) (float64, error) {
val, err := utils.ReadStringFileLimited(path, 64) val, err := os.ReadFile(path)
if err != nil { if err != nil {
return 0, err return 0, err
} }
return strconv.ParseFloat(val, 64) return strconv.ParseFloat(strings.TrimSpace(string(val)), 64)
} }
// normalizeHexID normalizes hex IDs by trimming spaces, lowercasing, and dropping 0x. // normalizeHexID normalizes hex IDs by trimming spaces, lowercasing, and dropping 0x.
@@ -241,10 +243,7 @@ func getCachedAmdgpuName(deviceID, revisionID string) (name string, found bool,
// normalizeAmdgpuName trims standard suffixes from AMDGPU product names. // normalizeAmdgpuName trims standard suffixes from AMDGPU product names.
func normalizeAmdgpuName(name string) string { func normalizeAmdgpuName(name string) string {
for _, suffix := range []string{" Graphics", " Series"} { return strings.TrimSuffix(strings.TrimSpace(name), " Graphics")
name = strings.TrimSuffix(name, suffix)
}
return name
} }
// cacheAmdgpuName stores a resolved AMDGPU name in the lookup cache. // cacheAmdgpuName stores a resolved AMDGPU name in the lookup cache.
@@ -273,16 +272,16 @@ func cacheMissingAmdgpuName(deviceID, revisionID string) {
// Falls back to showing the raw device ID if not found in the lookup table. // Falls back to showing the raw device ID if not found in the lookup table.
func getAmdGpuName(devicePath string) string { func getAmdGpuName(devicePath string) string {
// Try product_name first (works for some enterprise GPUs) // Try product_name first (works for some enterprise GPUs)
if prod, err := utils.ReadStringFileLimited(filepath.Join(devicePath, "product_name"), 128); err == nil { if prod, err := os.ReadFile(filepath.Join(devicePath, "product_name")); err == nil {
return prod return strings.TrimSpace(string(prod))
} }
// Read PCI device ID and look it up // Read PCI device ID and look it up
if deviceID, err := utils.ReadStringFileLimited(filepath.Join(devicePath, "device"), 64); err == nil { if deviceID, err := os.ReadFile(filepath.Join(devicePath, "device")); err == nil {
id := normalizeHexID(deviceID) id := normalizeHexID(string(deviceID))
revision := "" revision := ""
if rev, revErr := utils.ReadStringFileLimited(filepath.Join(devicePath, "revision"), 64); revErr == nil { if revBytes, revErr := os.ReadFile(filepath.Join(devicePath, "revision")); revErr == nil {
revision = normalizeHexID(rev) revision = normalizeHexID(string(revBytes))
} }
if name, found, done := getCachedAmdgpuName(id, revision); found { if name, found, done := getCachedAmdgpuName(id, revision); found {

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
//go:build testing //go:build testing
// +build testing
package agent package agent
@@ -10,7 +11,6 @@ import (
"testing" "testing"
"time" "time"
"github.com/henrygd/beszel/agent/utils"
"github.com/henrygd/beszel/internal/entities/system" "github.com/henrygd/beszel/internal/entities/system"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -266,8 +266,8 @@ func TestParseNvtopData(t *testing.T) {
assert.Equal(t, 48.0, g0.Temperature) assert.Equal(t, 48.0, g0.Temperature)
assert.Equal(t, 5.0, g0.Usage) assert.Equal(t, 5.0, g0.Usage)
assert.Equal(t, 13.0, g0.Power) assert.Equal(t, 13.0, g0.Power)
assert.Equal(t, utils.BytesToMegabytes(349372416), g0.MemoryUsed) assert.Equal(t, bytesToMegabytes(349372416), g0.MemoryUsed)
assert.Equal(t, utils.BytesToMegabytes(4294967296), g0.MemoryTotal) assert.Equal(t, bytesToMegabytes(4294967296), g0.MemoryTotal)
assert.Equal(t, 1.0, g0.Count) assert.Equal(t, 1.0, g0.Count)
g1, ok := gm.GpuDataMap["n1"] g1, ok := gm.GpuDataMap["n1"]
@@ -276,8 +276,8 @@ func TestParseNvtopData(t *testing.T) {
assert.Equal(t, 48.0, g1.Temperature) assert.Equal(t, 48.0, g1.Temperature)
assert.Equal(t, 12.0, g1.Usage) assert.Equal(t, 12.0, g1.Usage)
assert.Equal(t, 9.0, g1.Power) assert.Equal(t, 9.0, g1.Power)
assert.Equal(t, utils.BytesToMegabytes(1213784064), g1.MemoryUsed) assert.Equal(t, bytesToMegabytes(1213784064), g1.MemoryUsed)
assert.Equal(t, utils.BytesToMegabytes(16929173504), g1.MemoryTotal) assert.Equal(t, bytesToMegabytes(16929173504), g1.MemoryTotal)
assert.Equal(t, 1.0, g1.Count) assert.Equal(t, 1.0, g1.Count)
} }

View File

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

View File

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

View File

@@ -1,233 +0,0 @@
//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
}

View File

@@ -1,103 +0,0 @@
//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)
}
}

View File

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

View File

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

View File

@@ -261,39 +261,6 @@ 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) { func TestEnsureNetworkInterfacesMap(t *testing.T) {
var a Agent var a Agent
var stats system.Stats var stats system.Stats
@@ -416,6 +383,8 @@ func TestApplyNetworkTotals(t *testing.T) {
totalBytesSent uint64 totalBytesSent uint64
totalBytesRecv uint64 totalBytesRecv uint64
expectReset bool expectReset bool
expectedNetworkSent float64
expectedNetworkRecv float64
expectedBandwidthSent uint64 expectedBandwidthSent uint64
expectedBandwidthRecv uint64 expectedBandwidthRecv uint64
}{ }{
@@ -426,6 +395,8 @@ func TestApplyNetworkTotals(t *testing.T) {
totalBytesSent: 10000000, totalBytesSent: 10000000,
totalBytesRecv: 20000000, totalBytesRecv: 20000000,
expectReset: false, expectReset: false,
expectedNetworkSent: 0.95, // ~1 MB/s rounded to 2 decimals
expectedNetworkRecv: 1.91, // ~2 MB/s rounded to 2 decimals
expectedBandwidthSent: 1000000, expectedBandwidthSent: 1000000,
expectedBandwidthRecv: 2000000, expectedBandwidthRecv: 2000000,
}, },
@@ -453,6 +424,18 @@ func TestApplyNetworkTotals(t *testing.T) {
totalBytesRecv: 20000000, totalBytesRecv: 20000000,
expectReset: true, 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", name: "Zero values",
bytesSentPerSecond: 0, bytesSentPerSecond: 0,
@@ -460,6 +443,8 @@ func TestApplyNetworkTotals(t *testing.T) {
totalBytesSent: 0, totalBytesSent: 0,
totalBytesRecv: 0, totalBytesRecv: 0,
expectReset: false, expectReset: false,
expectedNetworkSent: 0.0,
expectedNetworkRecv: 0.0,
expectedBandwidthSent: 0, expectedBandwidthSent: 0,
expectedBandwidthRecv: 0, expectedBandwidthRecv: 0,
}, },
@@ -496,10 +481,14 @@ func TestApplyNetworkTotals(t *testing.T) {
// Should have reset network tracking state - maps cleared and stats zeroed // 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.netIoStats, cacheTimeMs, "cache entry should be cleared after reset")
assert.NotContains(t, a.netInterfaceDeltaTrackers, cacheTimeMs, "tracker should be cleared on 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[0])
assert.Zero(t, systemStats.Bandwidth[1]) assert.Zero(t, systemStats.Bandwidth[1])
} else { } else {
// Should have applied stats // 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.expectedBandwidthSent, systemStats.Bandwidth[0])
assert.Equal(t, tt.expectedBandwidthRecv, systemStats.Bandwidth[1]) assert.Equal(t, tt.expectedBandwidthRecv, systemStats.Bandwidth[1])

View File

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

View File

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

View File

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

View File

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

View File

@@ -18,7 +18,6 @@ import (
"sync" "sync"
"time" "time"
"github.com/henrygd/beszel/agent/utils"
"github.com/henrygd/beszel/internal/entities/smart" "github.com/henrygd/beszel/internal/entities/smart"
) )
@@ -157,7 +156,7 @@ func (sm *SmartManager) ScanDevices(force bool) error {
currentDevices := sm.devicesSnapshot() currentDevices := sm.devicesSnapshot()
var configuredDevices []*DeviceInfo var configuredDevices []*DeviceInfo
if configuredRaw, ok := utils.GetEnv("SMART_DEVICES"); ok { if configuredRaw, ok := GetEnv("SMART_DEVICES"); ok {
slog.Info("SMART_DEVICES", "value", configuredRaw) slog.Info("SMART_DEVICES", "value", configuredRaw)
config := strings.TrimSpace(configuredRaw) config := strings.TrimSpace(configuredRaw)
if config == "" { if config == "" {
@@ -200,13 +199,6 @@ func (sm *SmartManager) ScanDevices(force bool) error {
hasValidScan = true 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 := mergeDeviceLists(currentDevices, scannedDevices, configuredDevices)
finalDevices = sm.filterExcludedDevices(finalDevices) finalDevices = sm.filterExcludedDevices(finalDevices)
sm.updateSmartDevices(finalDevices) sm.updateSmartDevices(finalDevices)
@@ -223,7 +215,7 @@ func (sm *SmartManager) ScanDevices(force bool) error {
} }
func (sm *SmartManager) parseConfiguredDevices(config string) ([]*DeviceInfo, error) { func (sm *SmartManager) parseConfiguredDevices(config string) ([]*DeviceInfo, error) {
splitChar, _ := utils.GetEnv("SMART_DEVICES_SEPARATOR") splitChar := os.Getenv("SMART_DEVICES_SEPARATOR")
if splitChar == "" { if splitChar == "" {
splitChar = "," splitChar = ","
} }
@@ -261,7 +253,7 @@ func (sm *SmartManager) parseConfiguredDevices(config string) ([]*DeviceInfo, er
} }
func (sm *SmartManager) refreshExcludedDevices() { func (sm *SmartManager) refreshExcludedDevices() {
rawValue, _ := utils.GetEnv("EXCLUDE_SMART") rawValue, _ := GetEnv("EXCLUDE_SMART")
sm.excludedDevices = make(map[string]struct{}) sm.excludedDevices = make(map[string]struct{})
for entry := range strings.SplitSeq(rawValue, ",") { for entry := range strings.SplitSeq(rawValue, ",") {
@@ -458,12 +450,6 @@ func (sm *SmartManager) CollectSmart(deviceInfo *DeviceInfo) error {
return errNoValidSmartData 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 // eMMC health is not exposed via SMART on Linux, but the kernel provides
// wear / EOL indicators via sysfs. Prefer that path when available. // wear / EOL indicators via sysfs. Prefer that path when available.
if deviceInfo != nil { if deviceInfo != nil {
@@ -490,7 +476,7 @@ func (sm *SmartManager) CollectSmart(deviceInfo *DeviceInfo) error {
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
// Check if device is in standby (exit status 2) // Check if device is in standby (exit status 2)
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok && exitErr.ExitCode() == 2 { if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 2 {
if hasExistingData { if hasExistingData {
// Device is in standby and we have cached data, keep using cache // Device is in standby and we have cached data, keep using cache
return nil return nil
@@ -871,18 +857,15 @@ func (sm *SmartManager) parseSmartForSata(output []byte) (bool, int) {
smartData.FirmwareVersion = data.FirmwareVersion smartData.FirmwareVersion = data.FirmwareVersion
smartData.Capacity = data.UserCapacity.Bytes smartData.Capacity = data.UserCapacity.Bytes
smartData.Temperature = data.Temperature.Current 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.SmartStatus = getSmartStatus(smartData.Temperature, data.SmartStatus.Passed)
smartData.DiskName = data.Device.Name smartData.DiskName = data.Device.Name
smartData.DiskType = data.Device.Type 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 // update SmartAttributes
smartData.Attributes = make([]*smart.SmartAttribute, 0, len(data.AtaSmartAttributes.Table)) smartData.Attributes = make([]*smart.SmartAttribute, 0, len(data.AtaSmartAttributes.Table))
for _, attr := range data.AtaSmartAttributes.Table { for _, attr := range data.AtaSmartAttributes.Table {
@@ -917,20 +900,23 @@ 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 // findAtaDeviceStatisticsEntry centralizes ATA devstat lookups so additional
// metrics can be pulled from the same structure in the future. // metrics can be pulled from the same structure in the future.
func findAtaDeviceStatisticsValue(data *smart.SmartInfoForSata, ataDeviceStats *smart.AtaDeviceStatistics, entryNumber uint8, entryName string, minValue, maxValue int64) *int64 { func findAtaDeviceStatisticsEntry(stats smart.AtaDeviceStatistics, pageNumber uint8, entryName string) *smart.AtaDeviceStatisticsEntry {
if len(ataDeviceStats.Pages) == 0 { for pageIdx := range stats.Pages {
if len(data.AtaDeviceStatistics) == 0 { page := &stats.Pages[pageIdx]
return nil if page.Number != pageNumber {
}
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 continue
} }
for entryIdx := range page.Table { for entryIdx := range page.Table {
@@ -938,10 +924,7 @@ func findAtaDeviceStatisticsValue(data *smart.SmartInfoForSata, ataDeviceStats *
if !strings.EqualFold(entry.Name, entryName) { if !strings.EqualFold(entry.Name, entryName) {
continue continue
} }
if entry.Value == nil || *entry.Value < minValue || *entry.Value > maxValue { return entry
return nil
}
return entry.Value
} }
} }
return nil return nil
@@ -1163,12 +1146,10 @@ func NewSmartManager() (*SmartManager, error) {
slog.Debug("smartctl", "path", path, "err", err) slog.Debug("smartctl", "path", path, "err", err)
if err != nil { if err != nil {
// Keep the previous fail-fast behavior unless this Linux host exposes // Keep the previous fail-fast behavior unless this Linux host exposes
// eMMC or mdraid health via sysfs, in which case smartctl is optional. // eMMC health via sysfs, in which case smartctl is optional.
if runtime.GOOS == "linux" { if runtime.GOOS == "linux" && len(scanEmmcDevices()) > 0 {
if len(scanEmmcDevices()) > 0 || len(scanMdraidDevices()) > 0 {
return sm, nil return sm, nil
} }
}
return nil, err return nil, err
} }
sm.smartctlPath = path sm.smartctlPath = path

View File

@@ -1,4 +1,5 @@
//go:build testing //go:build testing
// +build testing
package agent package agent
@@ -121,78 +122,6 @@ func TestParseSmartForSataDeviceStatisticsTemperature(t *testing.T) {
assert.Equal(t, uint8(22), deviceData.Temperature) 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) { func TestParseSmartForSataParentheticalRawValue(t *testing.T) {
jsonPayload := []byte(`{ jsonPayload := []byte(`{
"smartctl": {"exit_status": 0}, "smartctl": {"exit_status": 0},
@@ -799,182 +728,6 @@ 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) { func TestRefreshExcludedDevices(t *testing.T) {
tests := []struct { tests := []struct {
name string name string

View File

@@ -7,13 +7,12 @@ import (
"log/slog" "log/slog"
"os" "os"
"runtime" "runtime"
"strconv"
"strings" "strings"
"time" "time"
"github.com/henrygd/beszel" "github.com/henrygd/beszel"
"github.com/henrygd/beszel/agent/battery" "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/container"
"github.com/henrygd/beszel/internal/entities/system" "github.com/henrygd/beszel/internal/entities/system"
@@ -108,7 +107,7 @@ func (a *Agent) refreshSystemDetails() {
} }
// zfs // zfs
if _, err := zfs.ARCSize(); err != nil { if _, err := getARCSize(); err != nil {
slog.Debug("Not monitoring ZFS ARC", "err", err) slog.Debug("Not monitoring ZFS ARC", "err", err)
} else { } else {
a.zfs = true a.zfs = true
@@ -128,13 +127,13 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
// cpu metrics // cpu metrics
cpuMetrics, err := getCpuMetrics(cacheTimeMs) cpuMetrics, err := getCpuMetrics(cacheTimeMs)
if err == nil { if err == nil {
systemStats.Cpu = utils.TwoDecimals(cpuMetrics.Total) systemStats.Cpu = twoDecimals(cpuMetrics.Total)
systemStats.CpuBreakdown = []float64{ systemStats.CpuBreakdown = []float64{
utils.TwoDecimals(cpuMetrics.User), twoDecimals(cpuMetrics.User),
utils.TwoDecimals(cpuMetrics.System), twoDecimals(cpuMetrics.System),
utils.TwoDecimals(cpuMetrics.Iowait), twoDecimals(cpuMetrics.Iowait),
utils.TwoDecimals(cpuMetrics.Steal), twoDecimals(cpuMetrics.Steal),
utils.TwoDecimals(cpuMetrics.Idle), twoDecimals(cpuMetrics.Idle),
} }
} else { } else {
slog.Error("Error getting cpu metrics", "err", err) slog.Error("Error getting cpu metrics", "err", err)
@@ -158,8 +157,8 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
// memory // memory
if v, err := mem.VirtualMemory(); err == nil { if v, err := mem.VirtualMemory(); err == nil {
// swap // swap
systemStats.Swap = utils.BytesToGigabytes(v.SwapTotal) systemStats.Swap = bytesToGigabytes(v.SwapTotal)
systemStats.SwapUsed = utils.BytesToGigabytes(v.SwapTotal - v.SwapFree - v.SwapCached) systemStats.SwapUsed = bytesToGigabytes(v.SwapTotal - v.SwapFree - v.SwapCached)
// cache + buffers value for default mem calculation // cache + buffers value for default mem calculation
// note: gopsutil automatically adds SReclaimable to v.Cached // note: gopsutil automatically adds SReclaimable to v.Cached
cacheBuff := v.Cached + v.Buffers - v.Shared cacheBuff := v.Cached + v.Buffers - v.Shared
@@ -179,16 +178,16 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
// } // }
// subtract ZFS ARC size from used memory and add as its own category // subtract ZFS ARC size from used memory and add as its own category
if a.zfs { if a.zfs {
if arcSize, _ := zfs.ARCSize(); arcSize > 0 && arcSize < v.Used { if arcSize, _ := getARCSize(); arcSize > 0 && arcSize < v.Used {
v.Used = v.Used - arcSize v.Used = v.Used - arcSize
v.UsedPercent = float64(v.Used) / float64(v.Total) * 100.0 v.UsedPercent = float64(v.Used) / float64(v.Total) * 100.0
systemStats.MemZfsArc = utils.BytesToGigabytes(arcSize) systemStats.MemZfsArc = bytesToGigabytes(arcSize)
} }
} }
systemStats.Mem = utils.BytesToGigabytes(v.Total) systemStats.Mem = bytesToGigabytes(v.Total)
systemStats.MemBuffCache = utils.BytesToGigabytes(cacheBuff) systemStats.MemBuffCache = bytesToGigabytes(cacheBuff)
systemStats.MemUsed = utils.BytesToGigabytes(v.Used) systemStats.MemUsed = bytesToGigabytes(v.Used)
systemStats.MemPct = utils.TwoDecimals(v.UsedPercent) systemStats.MemPct = twoDecimals(v.UsedPercent)
} }
// disk usage // disk usage
@@ -251,6 +250,32 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
return systemStats 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 // getOsPrettyName attempts to get the pretty OS name from /etc/os-release on Linux systems
func getOsPrettyName() (string, error) { func getOsPrettyName() (string, error) {
file, err := os.Open("/etc/os-release") file, err := os.Open("/etc/os-release")

View File

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

15
agent/utils.go Normal file
View File

@@ -0,0 +1,15 @@
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
}

View File

@@ -1,88 +0,0 @@
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
}

View File

@@ -1,165 +0,0 @@
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)
})
}

View File

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

View File

@@ -1,34 +0,0 @@
//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

@@ -1,9 +0,0 @@
//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 ( const (
// Version is the current version of the application. // Version is the current version of the application.
Version = "0.18.4" Version = "0.18.3"
// AppName is the name of the application. // AppName is the name of the application.
AppName = "beszel" AppName = "beszel"
) )

30
go.mod
View File

@@ -1,6 +1,6 @@
module github.com/henrygd/beszel module github.com/henrygd/beszel
go 1.26.1 go 1.25.7
require ( require (
github.com/blang/semver v3.5.1+incompatible github.com/blang/semver v3.5.1+incompatible
@@ -11,17 +11,17 @@ require (
github.com/gliderlabs/ssh v0.3.8 github.com/gliderlabs/ssh v0.3.8
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/lxzan/gws v1.8.9 github.com/lxzan/gws v1.8.9
github.com/nicholas-fedor/shoutrrr v0.13.2 github.com/nicholas-fedor/shoutrrr v0.13.1
github.com/pocketbase/dbx v1.12.0 github.com/pocketbase/dbx v1.11.0
github.com/pocketbase/pocketbase v0.36.4 github.com/pocketbase/pocketbase v0.36.2
github.com/shirou/gopsutil/v4 v4.26.1 github.com/shirou/gopsutil/v4 v4.26.1
github.com/spf13/cast v1.10.0 github.com/spf13/cast v1.10.0
github.com/spf13/cobra v1.10.2 github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.10 github.com/spf13/pflag v1.0.10
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
golang.org/x/crypto v0.48.0 golang.org/x/crypto v0.47.0
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa golang.org/x/exp v0.0.0-20260112195511-716be5621a96
golang.org/x/sys v0.41.0 golang.org/x/sys v0.40.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
@@ -42,8 +42,8 @@ require (
github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/klauspost/compress v1.18.4 // indirect github.com/klauspost/compress v1.18.3 // indirect
github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 // indirect github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // 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/tklauser/numcpus v0.11.0 // indirect
github.com/x448/float16 v0.8.4 // indirect github.com/x448/float16 v0.8.4 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect
golang.org/x/image v0.36.0 // indirect golang.org/x/image v0.35.0 // indirect
golang.org/x/net v0.50.0 // indirect golang.org/x/net v0.49.0 // indirect
golang.org/x/oauth2 v0.35.0 // indirect golang.org/x/oauth2 v0.34.0 // indirect
golang.org/x/sync v0.19.0 // indirect golang.org/x/sync v0.19.0 // indirect
golang.org/x/term v0.40.0 // indirect golang.org/x/term v0.39.0 // indirect
golang.org/x/text v0.34.0 // indirect golang.org/x/text v0.33.0 // indirect
howett.net/plist v1.0.1 // indirect howett.net/plist v1.0.1 // indirect
modernc.org/libc v1.67.6 // indirect modernc.org/libc v1.67.6 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.45.0 // indirect modernc.org/sqlite v1.44.3 // 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 h1:0Ju+VCFuARfFlhVXFc2HxlcQkfB+Xq12/EotHko+x2A=
github.com/jarcoal/httpmock v1.4.1/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0= 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/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 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 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 h1:PTw+yKnXcOFCR6+8hHTyWBeQ/P4Nb7dd4/0ohEcWQuM= github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 h1:PwQumkgq4/acIiZhtifTV5OUqqiP82UAl0h87xj/l9k=
github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/lxzan/gws v1.8.9 h1:VU3SGUeWlQrEwfUSfokcZep8mdg/BrUF+y73YYshdBM= github.com/lxzan/gws v1.8.9 h1:VU3SGUeWlQrEwfUSfokcZep8mdg/BrUF+y73YYshdBM=
github.com/lxzan/gws v1.8.9/go.mod h1:d9yHaR1eDTBHagQC6KY7ycUOaz5KWeqQtP3xu7aMK8Y= github.com/lxzan/gws v1.8.9/go.mod h1:d9yHaR1eDTBHagQC6KY7ycUOaz5KWeqQtP3xu7aMK8Y=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= 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/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 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nicholas-fedor/shoutrrr v0.13.2 h1:hfsYBIqSFYGg92pZP5CXk/g7/OJIkLYmiUnRl+AD1IA= github.com/nicholas-fedor/shoutrrr v0.13.1 h1:llEoHNbnMM4GfQ9+2Ns3n6ssvNfi3NPWluM0AQiicoY=
github.com/nicholas-fedor/shoutrrr v0.13.2/go.mod h1:ZqzV3gY/Wj6AvWs1etlO7+yKbh4iptSbeL8avBpMQbA= github.com/nicholas-fedor/shoutrrr v0.13.1/go.mod h1:kU4cFJpEAtTzl3iV0l+XUXmM90OlC5T01b7roM4/pYM=
github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI= github.com/onsi/ginkgo/v2 v2.27.3 h1:ICsZJ8JoYafeXFFlFAG75a7CxMsJHwgKwtO+82SE9L8=
github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE= github.com/onsi/ginkgo/v2 v2.27.3/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28= github.com/onsi/gomega v1.38.3 h1:eTX+W6dobAYfFeGC2PV6RwXRu/MyT+cQguijutvkpSM=
github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg= github.com/onsi/gomega v1.38.3/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pocketbase/dbx v1.12.0 h1:/oLErM+A0b4xI0PWTGPqSDVjzix48PqI/bng2l0PzoA= github.com/pocketbase/dbx v1.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU=
github.com/pocketbase/dbx v1.12.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs= github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
github.com/pocketbase/pocketbase v0.36.4 h1:zTjRZbp2WfTOJJfb+pFRWa200UaQwxZYt8RzkFMlAZ4= github.com/pocketbase/pocketbase v0.36.2 h1:mzrxnvXKc3yxKlvZdbwoYXkH8kfIETteD0hWdgj0VI4=
github.com/pocketbase/pocketbase v0.36.4/go.mod h1:9CiezhRudd9FZGa5xZa53QZBTNxc5vvw/FGG+diAECI= github.com/pocketbase/pocketbase v0.36.2/go.mod h1:71vSF8whUDzC8mcLFE10+Qatf9JQdeOGIRWawOuLLKM=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
@@ -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 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0= golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc= golang.org/x/image v0.35.0 h1:LKjiHdgMtO8z7Fh18nGY6KDcoEtVfsgLDPeLyguqb7I=
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4= golang.org/x/image v0.35.0/go.mod h1:MwPLTVgvxSASsxdLzKrl8BRFuyqMyGhLwmC+TO1Sybk=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= 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/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-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.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.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 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.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 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 h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 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/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.45.0 h1:r51cSGzKpbptxnby+EIIz5fop4VuE4qFoVEjNvWoObs= modernc.org/sqlite v1.44.3 h1:+39JvV/HWMcYslAwRxHb8067w+2zowvFOUrOWIy9PjY=
modernc.org/sqlite v1.45.0/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= modernc.org/sqlite v1.44.3/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

View File

@@ -40,22 +40,16 @@ type UserNotificationSettings struct {
Webhooks []string `json:"webhooks"` 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 { type SystemAlertStats struct {
Cpu float64 `json:"cpu"` Cpu float64 `json:"cpu"`
Mem float64 `json:"mp"` Mem float64 `json:"mp"`
Disk float64 `json:"dp"` Disk float64 `json:"dp"`
Bandwidth [2]uint64 `json:"b"` NetSent float64 `json:"ns"`
NetRecv float64 `json:"nr"`
GPU map[string]SystemAlertGPUData `json:"g"` GPU map[string]SystemAlertGPUData `json:"g"`
Temperatures map[string]float32 `json:"t"` Temperatures map[string]float32 `json:"t"`
LoadAvg [3]float64 `json:"la"` LoadAvg [3]float64 `json:"la"`
Battery [2]uint8 `json:"bat"` Battery [2]uint8 `json:"bat"`
ExtraFs map[string]SystemAlertFsStats `json:"efs"`
} }
type SystemAlertGPUData struct { type SystemAlertGPUData struct {
@@ -265,14 +259,13 @@ func (am *AlertManager) SendShoutrrrAlert(notificationUrl, title, message, link,
} }
// Add link // Add link
switch scheme { if scheme == "ntfy" {
case "ntfy":
queryParams.Add("Actions", fmt.Sprintf("view, %s, %s", linkText, link)) queryParams.Add("Actions", fmt.Sprintf("view, %s, %s", linkText, link))
case "lark": } else if scheme == "lark" {
queryParams.Add("link", link) queryParams.Add("link", link)
case "bark": } else if scheme == "bark" {
queryParams.Add("url", link) queryParams.Add("url", link)
default: } else {
message += "\n\n" + link message += "\n\n" + link
} }

View File

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

View File

@@ -1,155 +0,0 @@
//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,4 +1,5 @@
//go:build testing //go:build testing
// +build testing
package alerts_test package alerts_test

View File

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

View File

@@ -11,6 +11,7 @@ import (
"github.com/pocketbase/dbx" "github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/types" "github.com/pocketbase/pocketbase/tools/types"
"github.com/spf13/cast"
) )
func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *system.CombinedData) error { func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *system.CombinedData) error {
@@ -37,7 +38,7 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
case "Memory": case "Memory":
val = data.Info.MemPct val = data.Info.MemPct
case "Bandwidth": case "Bandwidth":
val = float64(data.Info.BandwidthBytes) / (1024 * 1024) val = data.Info.Bandwidth
unit = " MB/s" unit = " MB/s"
case "Disk": case "Disk":
maxUsedPct := data.Info.DiskPct maxUsedPct := data.Info.DiskPct
@@ -91,7 +92,7 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
} }
} }
min := max(1, uint8(alertRecord.GetInt("min"))) min := max(1, cast.ToUint8(alertRecord.Get("min")))
alert := SystemAlertData{ alert := SystemAlertData{
systemRecord: systemRecord, systemRecord: systemRecord,
@@ -191,25 +192,23 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
case "Memory": case "Memory":
alert.val += stats.Mem alert.val += stats.Mem
case "Bandwidth": case "Bandwidth":
alert.val += float64(stats.Bandwidth[0]+stats.Bandwidth[1]) / (1024 * 1024) alert.val += stats.NetSent + stats.NetRecv
case "Disk": case "Disk":
if alert.mapSums == nil { if alert.mapSums == nil {
alert.mapSums = make(map[string]float32, len(stats.ExtraFs)+1) alert.mapSums = make(map[string]float32, len(data.Stats.ExtraFs)+1)
} }
// add root disk // add root disk
if _, ok := alert.mapSums["root"]; !ok { if _, ok := alert.mapSums["root"]; !ok {
alert.mapSums["root"] = 0.0 alert.mapSums["root"] = 0.0
} }
alert.mapSums["root"] += float32(stats.Disk) alert.mapSums["root"] += float32(stats.Disk)
// add extra disks from historical record // add extra disks
for key, fs := range stats.ExtraFs { for key, fs := range data.Stats.ExtraFs {
if fs.DiskTotal > 0 {
if _, ok := alert.mapSums[key]; !ok { if _, ok := alert.mapSums[key]; !ok {
alert.mapSums[key] = 0.0 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": case "Temperature":
if alert.mapSums == nil { if alert.mapSums == nil {
alert.mapSums = make(map[string]float32, len(stats.Temperatures)) alert.mapSums = make(map[string]float32, len(stats.Temperatures))

View File

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

View File

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

View File

@@ -9,7 +9,6 @@ import (
"github.com/henrygd/beszel" "github.com/henrygd/beszel"
"github.com/henrygd/beszel/agent" "github.com/henrygd/beszel/agent"
"github.com/henrygd/beszel/agent/health" "github.com/henrygd/beszel/agent/health"
"github.com/henrygd/beszel/agent/utils"
"github.com/spf13/pflag" "github.com/spf13/pflag"
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
) )
@@ -117,12 +116,12 @@ func (opts *cmdOptions) loadPublicKeys() ([]ssh.PublicKey, error) {
} }
// Try environment variable // Try environment variable
if key, ok := utils.GetEnv("KEY"); ok && key != "" { if key, ok := agent.GetEnv("KEY"); ok && key != "" {
return agent.ParseKeys(key) return agent.ParseKeys(key)
} }
// Try key file // Try key file
keyFile, ok := utils.GetEnv("KEY_FILE") keyFile, ok := agent.GetEnv("KEY_FILE")
if !ok { 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") 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

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

View File

@@ -12,9 +12,8 @@ import (
type Stats struct { type Stats struct {
Cpu float64 `json:"cpu" cbor:"0,keyasint"` Cpu float64 `json:"cpu" cbor:"0,keyasint"`
MaxCpu float64 `json:"cpum,omitempty" cbor:"-"` MaxCpu float64 `json:"cpum,omitempty" cbor:"1,keyasint,omitempty"`
Mem float64 `json:"m" cbor:"2,keyasint"` Mem float64 `json:"m" cbor:"2,keyasint"`
MaxMem float64 `json:"mm,omitempty" cbor:"-"`
MemUsed float64 `json:"mu" cbor:"3,keyasint"` MemUsed float64 `json:"mu" cbor:"3,keyasint"`
MemPct float64 `json:"mp" cbor:"4,keyasint"` MemPct float64 `json:"mp" cbor:"4,keyasint"`
MemBuffCache float64 `json:"mb" cbor:"5,keyasint"` MemBuffCache float64 `json:"mb" cbor:"5,keyasint"`
@@ -24,25 +23,26 @@ type Stats struct {
DiskTotal float64 `json:"d" cbor:"9,keyasint"` DiskTotal float64 `json:"d" cbor:"9,keyasint"`
DiskUsed float64 `json:"du" cbor:"10,keyasint"` DiskUsed float64 `json:"du" cbor:"10,keyasint"`
DiskPct float64 `json:"dp" cbor:"11,keyasint"` DiskPct float64 `json:"dp" cbor:"11,keyasint"`
DiskReadPs float64 `json:"dr,omitzero" cbor:"12,keyasint,omitzero"` DiskReadPs float64 `json:"dr" cbor:"12,keyasint"`
DiskWritePs float64 `json:"dw,omitzero" cbor:"13,keyasint,omitzero"` DiskWritePs float64 `json:"dw" cbor:"13,keyasint"`
MaxDiskReadPs float64 `json:"drm,omitempty" cbor:"-"` MaxDiskReadPs float64 `json:"drm,omitempty" cbor:"14,keyasint,omitempty"`
MaxDiskWritePs float64 `json:"dwm,omitempty" cbor:"-"` MaxDiskWritePs float64 `json:"dwm,omitempty" cbor:"15,keyasint,omitempty"`
NetworkSent float64 `json:"ns,omitzero" cbor:"16,keyasint,omitzero"` NetworkSent float64 `json:"ns,omitzero" cbor:"16,keyasint,omitzero"`
NetworkRecv float64 `json:"nr,omitzero" cbor:"17,keyasint,omitzero"` NetworkRecv float64 `json:"nr,omitzero" cbor:"17,keyasint,omitzero"`
MaxNetworkSent float64 `json:"nsm,omitempty" cbor:"-"` MaxNetworkSent float64 `json:"nsm,omitempty" cbor:"18,keyasint,omitempty"`
MaxNetworkRecv float64 `json:"nrm,omitempty" cbor:"-"` MaxNetworkRecv float64 `json:"nrm,omitempty" cbor:"19,keyasint,omitempty"`
Temperatures map[string]float64 `json:"t,omitempty" cbor:"20,keyasint,omitempty"` Temperatures map[string]float64 `json:"t,omitempty" cbor:"20,keyasint,omitempty"`
ExtraFs map[string]*FsStats `json:"efs,omitempty" cbor:"21,keyasint,omitempty"` ExtraFs map[string]*FsStats `json:"efs,omitempty" cbor:"21,keyasint,omitempty"`
GPUData map[string]GPUData `json:"g,omitempty" cbor:"22,keyasint,omitempty"` GPUData map[string]GPUData `json:"g,omitempty" cbor:"22,keyasint,omitempty"`
// LoadAvg1 float64 `json:"l1,omitempty" cbor:"23,keyasint,omitempty"` LoadAvg1 float64 `json:"l1,omitempty" cbor:"23,keyasint,omitempty"`
// LoadAvg5 float64 `json:"l5,omitempty" cbor:"24,keyasint,omitempty"` LoadAvg5 float64 `json:"l5,omitempty" cbor:"24,keyasint,omitempty"`
// LoadAvg15 float64 `json:"l15,omitempty" cbor:"25,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] 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] MaxBandwidth [2]uint64 `json:"bm,omitzero" cbor:"27,keyasint,omitzero"` // [sent bytes, recv bytes]
// TODO: remove other load fields in future release in favor of load avg array // TODO: remove other load fields in future release in favor of load avg array
LoadAvg [3]float64 `json:"la,omitempty" cbor:"28,keyasint"` LoadAvg [3]float64 `json:"la,omitempty" cbor:"28,keyasint"`
Battery [2]uint8 `json:"bat,omitzero" cbor:"29,keyasint,omitzero"` // [percent, charge state, current] Battery [2]uint8 `json:"bat,omitzero" cbor:"29,keyasint,omitzero"` // [percent, charge state, current]
MaxMem float64 `json:"mm,omitempty" cbor:"30,keyasint,omitempty"`
NetworkInterfaces map[string][4]uint64 `json:"ni,omitempty" cbor:"31,keyasint,omitempty"` // [upload bytes, download bytes, total upload, total download] NetworkInterfaces map[string][4]uint64 `json:"ni,omitempty" cbor:"31,keyasint,omitempty"` // [upload bytes, download bytes, total upload, total download]
DiskIO [2]uint64 `json:"dio,omitzero" cbor:"32,keyasint,omitzero"` // [read bytes, write bytes] 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] MaxDiskIO [2]uint64 `json:"diom,omitzero" cbor:"-"` // [max read bytes, max write bytes]
@@ -90,8 +90,8 @@ type FsStats struct {
TotalWrite uint64 `json:"-"` TotalWrite uint64 `json:"-"`
DiskReadPs float64 `json:"r" cbor:"2,keyasint"` DiskReadPs float64 `json:"r" cbor:"2,keyasint"`
DiskWritePs float64 `json:"w" cbor:"3,keyasint"` DiskWritePs float64 `json:"w" cbor:"3,keyasint"`
MaxDiskReadPS float64 `json:"rm,omitempty" cbor:"-"` MaxDiskReadPS float64 `json:"rm,omitempty" cbor:"4,keyasint,omitempty"`
MaxDiskWritePS float64 `json:"wm,omitempty" cbor:"-"` MaxDiskWritePS float64 `json:"wm,omitempty" cbor:"5,keyasint,omitempty"`
// TODO: remove DiskReadPs and DiskWritePs in future release in favor of DiskReadBytes and DiskWriteBytes // TODO: remove DiskReadPs and DiskWritePs in future release in favor of DiskReadBytes and DiskWriteBytes
DiskReadBytes uint64 `json:"rb" cbor:"6,keyasint,omitempty"` DiskReadBytes uint64 `json:"rb" cbor:"6,keyasint,omitempty"`
DiskWriteBytes uint64 `json:"wb" cbor:"7,keyasint,omitempty"` DiskWriteBytes uint64 `json:"wb" cbor:"7,keyasint,omitempty"`
@@ -135,17 +135,17 @@ type Info struct {
Cpu float64 `json:"cpu" cbor:"6,keyasint"` Cpu float64 `json:"cpu" cbor:"6,keyasint"`
MemPct float64 `json:"mp" cbor:"7,keyasint"` MemPct float64 `json:"mp" cbor:"7,keyasint"`
DiskPct float64 `json:"dp" cbor:"8,keyasint"` DiskPct float64 `json:"dp" cbor:"8,keyasint"`
Bandwidth float64 `json:"b,omitzero" cbor:"9,keyasint"` // deprecated in favor of BandwidthBytes Bandwidth float64 `json:"b" cbor:"9,keyasint"`
AgentVersion string `json:"v" cbor:"10,keyasint"` AgentVersion string `json:"v" cbor:"10,keyasint"`
Podman bool `json:"p,omitempty" cbor:"11,keyasint,omitempty"` // deprecated - moved to Details struct Podman bool `json:"p,omitempty" cbor:"11,keyasint,omitempty"` // deprecated - moved to Details struct
GpuPct float64 `json:"g,omitempty" cbor:"12,keyasint,omitempty"` GpuPct float64 `json:"g,omitempty" cbor:"12,keyasint,omitempty"`
DashboardTemp float64 `json:"dt,omitempty" cbor:"13,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 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 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 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 LoadAvg15 float64 `json:"l15,omitempty" cbor:"17,keyasint,omitempty"` // deprecated - use `la` array instead
BandwidthBytes uint64 `json:"bb" cbor:"18,keyasint"` BandwidthBytes uint64 `json:"bb" cbor:"18,keyasint"`
LoadAvg [3]float64 `json:"la,omitempty" cbor:"19,keyasint"` LoadAvg [3]float64 `json:"la,omitempty" cbor:"19,keyasint"`
ConnectionType ConnectionType `json:"ct,omitempty" cbor:"20,keyasint,omitempty,omitzero"` ConnectionType ConnectionType `json:"ct,omitempty" cbor:"20,keyasint,omitempty,omitzero"`
ExtraFsPct map[string]float64 `json:"efs,omitempty" cbor:"21,keyasint,omitempty"` 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) fmt.Println(color + text + colorReset)
} }
func ColorPrintf(color, format string, args ...any) { func ColorPrintf(color, format string, args ...interface{}) {
fmt.Printf(color+format+colorReset+"\n", args...) fmt.Printf(color+format+colorReset+"\n", args...)
} }

View File

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

View File

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

View File

@@ -1,39 +1,35 @@
// 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 package expirymap
import ( import (
"sync" "reflect"
"time" "time"
"github.com/pocketbase/pocketbase/tools/store" "github.com/pocketbase/pocketbase/tools/store"
) )
type val[T comparable] struct { type val[T any] struct {
value T value T
expires time.Time expires time.Time
} }
type ExpiryMap[T comparable] struct { type ExpiryMap[T any] struct {
store store.Store[string, val[T]] store *store.Store[string, *val[T]]
stopChan chan struct{} cleanupInterval time.Duration
stopOnce sync.Once
} }
// New creates a new expiry map with custom cleanup interval // New creates a new expiry map with custom cleanup interval
func New[T comparable](cleanupInterval time.Duration) *ExpiryMap[T] { func New[T any](cleanupInterval time.Duration) *ExpiryMap[T] {
m := &ExpiryMap[T]{ m := &ExpiryMap[T]{
store: *store.New(map[string]val[T]{}), store: store.New(map[string]*val[T]{}),
stopChan: make(chan struct{}), cleanupInterval: cleanupInterval,
} }
go m.startCleaner(cleanupInterval) m.startCleaner()
return m return m
} }
// Set stores a value with the given TTL // Set stores a value with the given TTL
func (m *ExpiryMap[T]) Set(key string, value T, ttl time.Duration) { 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, value: value,
expires: time.Now().Add(ttl), expires: time.Now().Add(ttl),
}) })
@@ -59,7 +55,7 @@ func (m *ExpiryMap[T]) GetOk(key string) (T, bool) {
// GetByValue retrieves a value by value // GetByValue retrieves a value by value
func (m *ExpiryMap[T]) GetByValue(val T) (key string, value T, ok bool) { func (m *ExpiryMap[T]) GetByValue(val T) (key string, value T, ok bool) {
for key, v := range m.store.GetAll() { for key, v := range m.store.GetAll() {
if v.value == val { if reflect.DeepEqual(v.value, val) {
// check if expired // check if expired
if v.expires.Before(time.Now()) { if v.expires.Before(time.Now()) {
m.store.Remove(key) m.store.Remove(key)
@@ -79,7 +75,7 @@ func (m *ExpiryMap[T]) Remove(key string) {
// RemovebyValue removes a value by value // RemovebyValue removes a value by value
func (m *ExpiryMap[T]) RemovebyValue(value T) (T, bool) { func (m *ExpiryMap[T]) RemovebyValue(value T) (T, bool) {
for key, val := range m.store.GetAll() { for key, val := range m.store.GetAll() {
if val.value == value { if reflect.DeepEqual(val.value, value) {
m.store.Remove(key) m.store.Remove(key)
return val.value, true return val.value, true
} }
@@ -88,23 +84,13 @@ func (m *ExpiryMap[T]) RemovebyValue(value T) (T, bool) {
} }
// startCleaner runs the background cleanup process // startCleaner runs the background cleanup process
func (m *ExpiryMap[T]) startCleaner(interval time.Duration) { func (m *ExpiryMap[T]) startCleaner() {
tick := time.Tick(interval) go func() {
for { tick := time.Tick(m.cleanupInterval)
select { for range tick {
case <-tick:
m.cleanup() 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 // cleanup removes all expired entries
@@ -116,12 +102,3 @@ 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 //go:build testing
// +build testing
package expirymap package expirymap
import ( import (
"testing" "testing"
"testing/synctest"
"time" "time"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -178,33 +178,6 @@ 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) { func TestExpiryMap_ZeroValues(t *testing.T) {
em := New[string](time.Hour) em := New[string](time.Hour)
@@ -501,52 +474,3 @@ func TestExpiryMap_ValueOperations_Integration(t *testing.T) {
assert.Equal(t, "unique", value) assert.Equal(t, "unique", value)
assert.Equal(t, "key2", key) 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

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

View File

@@ -9,7 +9,6 @@ import (
"net/url" "net/url"
"os" "os"
"path" "path"
"regexp"
"strings" "strings"
"time" "time"
@@ -42,8 +41,6 @@ type Hub struct {
appURL string appURL string
} }
var containerIDPattern = regexp.MustCompile(`^[a-fA-F0-9]{12,64}$`)
// NewHub creates a new Hub instance with default configuration // NewHub creates a new Hub instance with default configuration
func NewHub(app core.App) *Hub { func NewHub(app core.App) *Hub {
hub := &Hub{} hub := &Hub{}
@@ -464,9 +461,6 @@ func (h *Hub) containerRequestHandler(e *core.RequestEvent, fetchFunc func(*syst
if systemID == "" || containerID == "" { if systemID == "" || containerID == "" {
return e.JSON(http.StatusBadRequest, map[string]string{"error": "system and container parameters are required"}) 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) system, err := h.sm.GetSystem(systemID)
if err != nil { if err != nil {

View File

@@ -1,4 +1,5 @@
//go:build testing //go:build testing
// +build testing
package hub_test package hub_test
@@ -544,7 +545,7 @@ func TestApiRoutesAuthentication(t *testing.T) {
{ {
Name: "GET /containers/logs - with auth but invalid system should fail", Name: "GET /containers/logs - with auth but invalid system should fail",
Method: http.MethodGet, Method: http.MethodGet,
URL: "/api/beszel/containers/logs?system=invalid-system&container=0123456789ab", URL: "/api/beszel/containers/logs?system=invalid-system&container=test-container",
Headers: map[string]string{ Headers: map[string]string{
"Authorization": userToken, "Authorization": userToken,
}, },
@@ -552,39 +553,6 @@ func TestApiRoutesAuthentication(t *testing.T) {
ExpectedContent: []string{"system not found"}, ExpectedContent: []string{"system not found"},
TestAppFactory: testAppFactory, 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 // Auth Optional Routes - Should work without authentication
{ {

View File

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

View File

@@ -48,6 +48,7 @@ type System struct {
detailsFetched atomic.Bool // True if static system details have been fetched and saved detailsFetched atomic.Bool // True if static system details have been fetched and saved
smartFetching atomic.Bool // True if SMART devices are currently being fetched smartFetching atomic.Bool // True if SMART devices are currently being fetched
smartInterval time.Duration // Interval for periodic SMART data updates 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 { func (sm *SystemManager) NewSystem(systemId string) *System {
@@ -133,34 +134,19 @@ func (sys *System) update() error {
return err return err
} }
// ensure deprecated fields from older agents are migrated to current fields
migrateDeprecatedFields(data, !sys.detailsFetched.Load())
// create system records // create system records
_, err = sys.createRecords(data) _, 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 // Fetch and save SMART devices when system first comes online or at intervals
if backgroundSmartFetchEnabled() && sys.detailsFetched.Load() { if backgroundSmartFetchEnabled() {
if sys.smartInterval <= 0 { if sys.smartInterval <= 0 {
sys.smartInterval = time.Hour sys.smartInterval = time.Hour
} }
lastFetch, _ := sys.manager.smartFetchMap.GetOk(sys.Id) lastFetch := sys.lastSmartFetch.Load()
if time.Since(time.UnixMilli(lastFetch-1e4)) >= sys.smartInterval && sys.smartFetching.CompareAndSwap(false, true) { if time.Since(time.UnixMilli(lastFetch)) >= sys.smartInterval && sys.smartFetching.CompareAndSwap(false, true) {
go func() { go func() {
defer sys.smartFetching.Store(false) defer sys.smartFetching.Store(false)
sys.manager.smartFetchMap.Set(sys.Id, time.Now().UnixMilli(), sys.smartInterval+time.Minute) sys.lastSmartFetch.Store(time.Now().UnixMilli())
_ = sys.FetchAndSaveSmartDevices() _ = sys.FetchAndSaveSmartDevices()
}() }()
} }
@@ -235,6 +221,11 @@ func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error
if err := createSystemDetailsRecord(txApp, data.Details, sys.Id); err != nil { if err := createSystemDetailsRecord(txApp, data.Details, sys.Id); err != nil {
return err 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) // update system record (do this last because it triggers alerts and we need above records to be inserted first)
@@ -712,50 +703,3 @@ func getJitter() <-chan time.Time {
msDelay := (interval * minPercent / 100) + rand.Intn(interval*jitterRange/100) msDelay := (interval * minPercent / 100) + rand.Intn(interval*jitterRange/100)
return time.After(time.Duration(msDelay) * time.Millisecond) return time.After(time.Duration(msDelay) * time.Millisecond)
} }
// 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,7 +8,6 @@ import (
"github.com/henrygd/beszel/internal/hub/ws" "github.com/henrygd/beszel/internal/hub/ws"
"github.com/henrygd/beszel/internal/entities/system" "github.com/henrygd/beszel/internal/entities/system"
"github.com/henrygd/beszel/internal/hub/expirymap"
"github.com/henrygd/beszel/internal/common" "github.com/henrygd/beszel/internal/common"
@@ -44,7 +43,6 @@ type SystemManager struct {
hub hubLike // Hub interface for database and alert operations hub hubLike // Hub interface for database and alert operations
systems *store.Store[string, *System] // Thread-safe store of active systems systems *store.Store[string, *System] // Thread-safe store of active systems
sshConfig *ssh.ClientConfig // SSH client configuration for system connections 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. // hubLike defines the interface requirements for the hub dependency.
@@ -62,7 +60,6 @@ func NewSystemManager(hub hubLike) *SystemManager {
return &SystemManager{ return &SystemManager{
systems: store.New(map[string]*System{}), systems: store.New(map[string]*System{}),
hub: hub, hub: hub,
smartFetchMap: expirymap.New[int64](time.Hour),
} }
} }

View File

@@ -1,159 +0,0 @@
//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,4 +1,5 @@
//go:build !testing //go:build !testing
// +build !testing
package systems package systems

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -16,16 +16,19 @@ import { useYAxisWidth } from "./hooks"
export default memo(function LoadAverageChart({ chartData }: { chartData: ChartData }) { export default memo(function LoadAverageChart({ chartData }: { chartData: ChartData }) {
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth() const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
const keys: { color: string; label: string }[] = [ const keys: { legacy: keyof SystemStats; color: string; label: string }[] = [
{ {
legacy: "l1",
color: "hsl(271, 81%, 60%)", // Purple color: "hsl(271, 81%, 60%)", // Purple
label: t({ message: `1 min`, comment: "Load average" }), label: t({ message: `1 min`, comment: "Load average" }),
}, },
{ {
legacy: "l5",
color: "hsl(217, 91%, 60%)", // Blue color: "hsl(217, 91%, 60%)", // Blue
label: t({ message: `5 min`, comment: "Load average" }), label: t({ message: `5 min`, comment: "Load average" }),
}, },
{ {
legacy: "l15",
color: "hsl(25, 95%, 53%)", // Orange color: "hsl(25, 95%, 53%)", // Orange
label: t({ message: `15 min`, comment: "Load average" }), label: t({ message: `15 min`, comment: "Load average" }),
}, },
@@ -63,10 +66,18 @@ export default memo(function LoadAverageChart({ chartData }: { chartData: ChartD
/> />
} }
/> />
{keys.map(({ color, label }, i) => ( {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 <Line
key={label} key={label}
dataKey={(value: { stats: SystemStats }) => value.stats?.la?.[i]} dataKey={dataKey}
name={label} name={label}
type="monotoneX" type="monotoneX"
dot={false} dot={false}
@@ -74,7 +85,8 @@ export default memo(function LoadAverageChart({ chartData }: { chartData: ChartD
stroke={color} stroke={color}
isAnimationActive={false} isAnimationActive={false}
/> />
))} )
})}
<ChartLegend content={<ChartLegendContent />} /> <ChartLegend content={<ChartLegendContent />} />
</LineChart> </LineChart>
</ChartContainer> </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) { export function copyDockerRun(port = "45876", publicKey: string, token: string) {
copyToClipboard( 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,10 +32,7 @@ export function LangToggle() {
className={cn("px-2.5 flex gap-2.5 cursor-pointer", lang === i18n.locale && "bg-accent/70 font-medium")} className={cn("px-2.5 flex gap-2.5 cursor-pointer", lang === i18n.locale && "bg-accent/70 font-medium")}
onClick={() => dynamicActivate(lang)} onClick={() => dynamicActivate(lang)}
> >
<span> <span>{e}</span> {label}
{e || <code className="font-mono bg-muted text-[.65em] w-5 h-4 grid place-items-center">{lang}</code>}
</span>{" "}
{label}
</DropdownMenuItem> </DropdownMenuItem>
))} ))}
</DropdownMenuContent> </DropdownMenuContent>

View File

@@ -70,16 +70,7 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
<SelectContent> <SelectContent>
{languages.map(([lang, label, e]) => ( {languages.map(([lang, label, e]) => (
<SelectItem key={lang} value={lang}> <SelectItem key={lang} value={lang}>
<span className="me-2.5"> <span className="me-2.5">{e}</span>
{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} {label}
</SelectItem> </SelectItem>
))} ))}

View File

@@ -1,6 +1,7 @@
import { t } from "@lingui/core/macro" import { t } from "@lingui/core/macro"
import { Trans } from "@lingui/react/macro" import { Trans } from "@lingui/react/macro"
import { redirectPage } from "@nanostores/router" import { redirectPage } from "@nanostores/router"
import clsx from "clsx"
import { LoaderCircleIcon, SendIcon } from "lucide-react" import { LoaderCircleIcon, SendIcon } from "lucide-react"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { $router } from "@/components/router" import { $router } from "@/components/router"
@@ -9,7 +10,6 @@ import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator" import { Separator } from "@/components/ui/separator"
import { toast } from "@/components/ui/use-toast" import { toast } from "@/components/ui/use-toast"
import { isAdmin, pb } from "@/lib/api" import { isAdmin, pb } from "@/lib/api"
import { cn } from "@/lib/utils"
interface HeartbeatStatus { interface HeartbeatStatus {
enabled: boolean enabled: boolean
@@ -37,10 +37,10 @@ export default function HeartbeatSettings() {
setIsLoading(true) setIsLoading(true)
const res = await pb.send<HeartbeatStatus>("/api/beszel/heartbeat-status", {}) const res = await pb.send<HeartbeatStatus>("/api/beszel/heartbeat-status", {})
setStatus(res) setStatus(res)
} catch (error: unknown) { } catch (error: any) {
toast({ toast({
title: t`Error`, title: t`Error`,
description: (error as Error).message, description: error.message,
variant: "destructive", variant: "destructive",
}) })
} finally { } finally {
@@ -66,10 +66,10 @@ export default function HeartbeatSettings() {
variant: "destructive", variant: "destructive",
}) })
} }
} catch (error: unknown) { } catch (error: any) {
toast({ toast({
title: t`Error`, title: t`Error`,
description: (error as Error).message, description: error.message,
variant: "destructive", variant: "destructive",
}) })
} finally { } finally {
@@ -77,6 +77,8 @@ export default function HeartbeatSettings() {
} }
} }
const TestIcon = isTesting ? LoaderCircleIcon : SendIcon
return ( return (
<div> <div>
<div> <div>
@@ -92,26 +94,12 @@ export default function HeartbeatSettings() {
</div> </div>
<Separator className="my-4" /> <Separator className="my-4" />
{status?.enabled ? ( {isLoading ? (
<EnabledState status={status} isTesting={isTesting} sendTestHeartbeat={sendTestHeartbeat} /> <div className="flex items-center gap-2 text-muted-foreground py-4">
) : ( <LoaderCircleIcon className="h-4 w-4 animate-spin" />
<NotEnabledState isLoading={isLoading} /> <Trans>Loading heartbeat status...</Trans>
)}
</div> </div>
) ) : status?.enabled ? (
}
function EnabledState({
status,
isTesting,
sendTestHeartbeat,
}: {
status: HeartbeatStatus
isTesting: boolean
sendTestHeartbeat: () => void
}) {
const TestIcon = isTesting ? LoaderCircleIcon : SendIcon
return (
<div className="space-y-5"> <div className="space-y-5">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Badge variant="success"> <Badge variant="success">
@@ -140,7 +128,7 @@ function EnabledState({
onClick={sendTestHeartbeat} onClick={sendTestHeartbeat}
disabled={isTesting} disabled={isTesting}
> >
<TestIcon className={cn("size-4", isTesting && "animate-spin")} /> <TestIcon className={clsx("h-4 w-4", isTesting && "animate-spin")} />
<Trans>Send test heartbeat</Trans> <Trans>Send test heartbeat</Trans>
</Button> </Button>
</div> </div>
@@ -153,25 +141,21 @@ function EnabledState({
</h4> </h4>
<p className="text-sm text-muted-foreground leading-relaxed mb-2"> <p className="text-sm text-muted-foreground leading-relaxed mb-2">
<Trans> <Trans>
When using POST, each heartbeat includes a JSON payload with system status summary, list of down systems, When using POST, each heartbeat includes a JSON payload with system status summary, list of down
and triggered alerts. systems, and triggered alerts.
</Trans> </Trans>
</p> </p>
<p className="text-sm text-muted-foreground leading-relaxed"> <p className="text-sm text-muted-foreground leading-relaxed">
<Trans> <Trans>
The overall status is <code className="bg-muted rounded-sm px-1 text-primary">ok</code> when all systems are The overall status is <code className="bg-muted rounded-sm px-1 text-primary">ok</code> when all systems
up, <code className="bg-muted rounded-sm px-1 text-primary">warn</code> when alerts are triggered, and{" "} are up, <code className="bg-muted rounded-sm px-1 text-primary">warn</code> when alerts are triggered,
<code className="bg-muted rounded-sm px-1 text-primary">error</code> when any system is down. and <code className="bg-muted rounded-sm px-1 text-primary">error</code> when any system is down.
</Trans> </Trans>
</p> </p>
</div> </div>
</div> </div>
) ) : (
} <div className="grid gap-4">
function NotEnabledState({ isLoading }: { isLoading?: boolean }) {
return (
<div className={cn("grid gap-4", isLoading && "animate-pulse")}>
<div> <div>
<p className="text-sm text-muted-foreground leading-relaxed mb-3"> <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> <Trans>Set the following environment variables on your Beszel hub to enable heartbeat monitoring:</Trans>
@@ -194,6 +178,8 @@ function NotEnabledState({ isLoading }: { isLoading?: boolean }) {
<Trans>After setting the environment variables, restart your Beszel hub for changes to take effect.</Trans> <Trans>After setting the environment variables, restart your Beszel hub for changes to take effect.</Trans>
</p> </p>
</div> </div>
)}
</div>
) )
} }
@@ -201,14 +187,14 @@ function ConfigItem({ label, value, mono }: { label: string; value: string; mono
return ( return (
<div> <div>
<p className="text-sm font-medium mb-0.5">{label}</p> <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> <p className={clsx("text-sm text-muted-foreground break-all", mono && "font-mono")}>{value}</p>
</div> </div>
) )
} }
function EnvVarItem({ name, description, example }: { name: string; description: string; example: string }) { function EnvVarItem({ name, description, example }: { name: string; description: string; example: string }) {
return ( return (
<div className="bg-muted/50 rounded-md px-3 py-2.5 grid gap-1.5"> <div className="bg-muted/50 rounded-md px-3 py-2 grid gap-1.5">
<code className="text-sm font-mono text-primary font-medium leading-tight">{name}</code> <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-sm text-muted-foreground">{description}</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">

View File

@@ -90,13 +90,6 @@ export default function SettingsLayout() {
icon: AlertOctagonIcon, icon: AlertOctagonIcon,
preload: alertsHistoryDataTableSettingsImport, preload: alertsHistoryDataTableSettingsImport,
}, },
{
title: t`Heartbeat`,
href: getPagePath($router, "settings", { name: "heartbeat" }),
icon: HeartPulseIcon,
admin: true,
preload: heartbeatSettingsImport,
},
{ {
title: t`YAML Config`, title: t`YAML Config`,
href: getPagePath($router, "settings", { name: "config" }), href: getPagePath($router, "settings", { name: "config" }),
@@ -104,6 +97,13 @@ export default function SettingsLayout() {
admin: true, admin: true,
preload: configYamlSettingsImport, preload: configYamlSettingsImport,
}, },
{
title: t`Heartbeat`,
href: getPagePath($router, "settings", { name: "heartbeat" }),
icon: HeartPulseIcon,
admin: true,
preload: heartbeatSettingsImport,
},
] ]
const page = useStore($router) const page = useStore($router)

View File

@@ -593,7 +593,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
if (showMax) { if (showMax) {
return data?.stats?.bm?.[0] ?? (data?.stats?.nsm ?? 0) * 1024 * 1024 return data?.stats?.bm?.[0] ?? (data?.stats?.nsm ?? 0) * 1024 * 1024
} }
return data?.stats?.b?.[0] ?? (data?.stats?.ns ?? 0) * 1024 * 1024 return data?.stats?.b?.[0] ?? data?.stats?.ns * 1024 * 1024
}, },
color: 5, color: 5,
opacity: 0.2, opacity: 0.2,
@@ -604,7 +604,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
if (showMax) { if (showMax) {
return data?.stats?.bm?.[1] ?? (data?.stats?.nrm ?? 0) * 1024 * 1024 return data?.stats?.bm?.[1] ?? (data?.stats?.nrm ?? 0) * 1024 * 1024
} }
return data?.stats?.b?.[1] ?? (data?.stats?.nr ?? 0) * 1024 * 1024 return data?.stats?.b?.[1] ?? data?.stats?.nr * 1024 * 1024
}, },
color: 2, color: 2,
opacity: 0.2, opacity: 0.2,
@@ -654,7 +654,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
)} )}
{/* Load Average chart */} {/* Load Average chart */}
{chartData.agentVersion?.minor > 12 && ( {chartData.agentVersion?.minor >= 12 && (
<ChartCard <ChartCard
empty={dataEmpty} empty={dataEmpty}
grid={grid} grid={grid}

View File

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

View File

@@ -33,6 +33,7 @@ import {
decimalString, decimalString,
formatBytes, formatBytes,
formatTemperature, formatTemperature,
getMeterState,
parseSemVer, parseSemVer,
secondsToUptimeString, secondsToUptimeString,
} from "@/lib/utils" } from "@/lib/utils"
@@ -80,10 +81,6 @@ const STATUS_COLORS = {
[SystemStatus.Pending]: "bg-yellow-500", [SystemStatus.Pending]: "bg-yellow-500",
} as const } 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" * @param viewMode - "table" or "grid"
* @returns - Column definitions for the systems table * @returns - Column definitions for the systems table
@@ -198,24 +195,36 @@ export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<Syste
}, },
{ {
id: "loadAverage", id: "loadAverage",
accessorFn: ({ info }) => info.la?.reduce((acc, curr) => acc + curr, 0), 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
},
name: () => t({ message: "Load Avg", comment: "Short label for load average" }), name: () => t({ message: "Load Avg", comment: "Short label for load average" }),
size: 0, size: 0,
Icon: HourglassIcon, Icon: HourglassIcon,
header: sortableHeader, header: sortableHeader,
cell(info: CellContext<SystemRecord, unknown>) { cell(info: CellContext<SystemRecord, unknown>) {
const { info: sysInfo, status } = info.row.original const { info: sysInfo, status } = info.row.original
const { major, minor } = parseSemVer(sysInfo.v) // agent version
const { colorWarn = 65, colorCrit = 90 } = useStore($userSettings, { keys: ["colorWarn", "colorCrit"] }) const { minor, patch } = parseSemVer(sysInfo.v)
const loadAverages = sysInfo.la || [] 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 max = Math.max(...loadAverages) const max = Math.max(...loadAverages)
if (max === 0 && (status === SystemStatus.Paused || (major < 1 && minor < 13))) { if (max === 0 && (status === SystemStatus.Paused || minor < 12)) {
return null return null
} }
const normalizedLoad = max / (sysInfo.t ?? 1) const normalizedLoad = max / (sysInfo.t ?? 1)
const threshold = getMeterStateByThresholds(normalizedLoad * 100, colorWarn, colorCrit) const threshold = getMeterState(normalizedLoad * 100)
return ( return (
<div className="flex items-center gap-[.35em] w-full tabular-nums tracking-tight"> <div className="flex items-center gap-[.35em] w-full tabular-nums tracking-tight">
@@ -235,20 +244,19 @@ export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<Syste
}, },
}, },
{ {
accessorFn: ({ info, status }) => (status !== SystemStatus.Up ? undefined : info.bb), accessorFn: ({ info }) => info.bb || (info.b || 0) * 1024 * 1024 || undefined,
id: "net", id: "net",
name: () => t`Net`, name: () => t`Net`,
size: 0, size: 0,
Icon: EthernetIcon, Icon: EthernetIcon,
header: sortableHeader, header: sortableHeader,
sortUndefined: "last",
cell(info) { cell(info) {
const val = info.getValue() as number | undefined const sys = info.row.original
if (val === undefined) { const userSettings = useStore($userSettings, { keys: ["unitNet"] })
if (sys.status === SystemStatus.Paused) {
return null return null
} }
const userSettings = useStore($userSettings, { keys: ["unitNet"] }) const { value, unit } = formatBytes((info.getValue() || 0) as number, true, userSettings.unitNet, false)
const { value, unit } = formatBytes(val, true, userSettings.unitNet, false)
return ( return (
<span className="tabular-nums whitespace-nowrap"> <span className="tabular-nums whitespace-nowrap">
{decimalString(value, value >= 100 ? 1 : 2)} {unit} {decimalString(value, value >= 100 ? 1 : 2)} {unit}
@@ -455,9 +463,8 @@ function sortableHeader(context: HeaderContext<SystemRecord, unknown>) {
} }
function TableCellWithMeter(info: CellContext<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 val = Number(info.getValue()) || 0
const threshold = getMeterStateByThresholds(val, colorWarn, colorCrit) const threshold = getMeterState(val)
const meterClass = cn( const meterClass = cn(
"h-full", "h-full",
(info.row.original.status !== SystemStatus.Up && STATUS_COLORS.paused) || (info.row.original.status !== SystemStatus.Up && STATUS_COLORS.paused) ||
@@ -476,7 +483,6 @@ function TableCellWithMeter(info: CellContext<SystemRecord, unknown>) {
} }
function DiskCellWithMultiple(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 { info: sysInfo, status, id } = info.row.original
const extraFs = Object.entries(sysInfo.efs ?? {}) const extraFs = Object.entries(sysInfo.efs ?? {})
@@ -490,7 +496,7 @@ function DiskCellWithMultiple(info: CellContext<SystemRecord, unknown>) {
extraFs.sort((a, b) => b[1] - a[1]) extraFs.sort((a, b) => b[1] - a[1])
function getIndicatorColor(pct: number) { function getIndicatorColor(pct: number) {
const threshold = getMeterStateByThresholds(pct, colorWarn, colorCrit) const threshold = getMeterState(pct)
return ( return (
(status !== SystemStatus.Up && STATUS_COLORS.paused) || (status !== SystemStatus.Up && STATUS_COLORS.paused) ||
(threshold === MeterState.Good && STATUS_COLORS.up) || (threshold === MeterState.Good && STATUS_COLORS.up) ||
@@ -508,9 +514,7 @@ function DiskCellWithMultiple(info: CellContext<SystemRecord, unknown>) {
const extraDiskIndicators = const extraDiskIndicators =
status !== SystemStatus.Up status !== SystemStatus.Up
? [] ? []
: [...new Set(extraFs.map(([, pct]) => getMeterStateByThresholds(pct, colorWarn, colorCrit)))] : [...new Set(extraFs.map(([, pct]) => getMeterState(pct)))].sort().map((state) => stateColors[state])
.sort()
.map((state) => stateColors[state])
return ( return (
<Tooltip> <Tooltip>

View File

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

View File

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

View File

@@ -6,7 +6,7 @@ import { useEffect, useState } from "react"
import { twMerge } from "tailwind-merge" import { twMerge } from "tailwind-merge"
import { toast } from "@/components/ui/use-toast" import { toast } from "@/components/ui/use-toast"
import type { ChartTimeData, FingerprintRecord, SemVer, SystemRecord } from "@/types" import type { ChartTimeData, FingerprintRecord, SemVer, SystemRecord } from "@/types"
import { HourFormat, Unit } from "./enums" import { HourFormat, MeterState, Unit } from "./enums"
import { $copyContent, $userSettings } from "./stores" import { $copyContent, $userSettings } from "./stores"
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
@@ -210,6 +210,7 @@ export function useBrowserStorage<T>(key: string, defaultValue: T, storageInterf
const [value, setValue] = useState(() => { const [value, setValue] = useState(() => {
return getStorageValue(key, defaultValue, storageInterface) return getStorageValue(key, defaultValue, storageInterface)
}) })
// biome-ignore lint/correctness/useExhaustiveDependencies: storageInterface won't change
useEffect(() => { useEffect(() => {
storageInterface?.setItem(key, JSON.stringify(value)) storageInterface?.setItem(key, JSON.stringify(value))
}, [key, value]) }, [key, value])
@@ -393,6 +394,12 @@ export function compareSemVer(a: SemVer, b: SemVer) {
return a.patch - b.patch 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 // 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 { export function debounce<T extends (...args: any[]) => any>(func: T, wait: number): (...args: Parameters<T>) => void {
let timeout: ReturnType<typeof setTimeout> let timeout: ReturnType<typeof setTimeout>

View File

@@ -93,7 +93,6 @@ msgstr "إجراءات"
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/heartbeat.tsx
#: src/components/routes/settings/quiet-hours.tsx #: src/components/routes/settings/quiet-hours.tsx
msgid "Active" msgid "Active"
msgstr "نشط" msgstr "نشط"
@@ -141,10 +140,6 @@ msgstr "مسؤول"
msgid "After" msgid "After"
msgstr "بعد" 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 #: src/components/systems-table/systems-table-columns.tsx
msgid "Agent" msgid "Agent"
msgstr "وكيل" msgstr "وكيل"
@@ -355,10 +350,6 @@ msgstr "تحقق من {email} للحصول على رابط إعادة التعي
msgid "Check logs for more details." msgid "Check logs for more details."
msgstr "تحقق من السجلات لمزيد من التفاصيل." msgstr "تحقق من السجلات لمزيد من التفاصيل."
#: src/components/routes/settings/heartbeat.tsx
msgid "Check your monitoring service"
msgstr "تحقق من خدمة المراقبة الخاصة بك"
#: src/components/routes/settings/notifications.tsx #: src/components/routes/settings/notifications.tsx
msgid "Check your notification service" msgid "Check your notification service"
msgstr "تحقق من خدمة الإشعارات الخاصة بك" msgstr "تحقق من خدمة الإشعارات الخاصة بك"
@@ -652,14 +643,6 @@ msgstr "فارغة"
msgid "End Time" msgid "End Time"
msgstr "وقت النهاية" 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 #: src/components/login/login.tsx
msgid "Enter email address to reset password" msgid "Enter email address to reset password"
msgstr "أدخل عنوان البريد الإشباكي لإعادة تعيين كلمة المرور" msgstr "أدخل عنوان البريد الإشباكي لإعادة تعيين كلمة المرور"
@@ -679,9 +662,6 @@ msgstr "مؤقت"
#: src/components/login/auth-form.tsx #: src/components/login/auth-form.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/config-yaml.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/notifications.tsx
#: src/components/routes/settings/quiet-hours.tsx #: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx #: src/components/routes/settings/quiet-hours.tsx
@@ -690,10 +670,6 @@ msgstr "مؤقت"
msgid "Error" msgid "Error"
msgstr "خطأ" msgstr "خطأ"
#: src/components/routes/settings/heartbeat.tsx
msgid "Example:"
msgstr "مثال:"
#. placeholder {0}: alert.value #. placeholder {0}: alert.value
#. placeholder {1}: info.unit #. placeholder {1}: info.unit
#. placeholder {2}: alert.min #. placeholder {2}: alert.min
@@ -751,10 +727,6 @@ msgstr "فشل في المصادقة"
msgid "Failed to save settings" msgid "Failed to save settings"
msgstr "فشل في حفظ الإعدادات" msgstr "فشل في حفظ الإعدادات"
#: src/components/routes/settings/heartbeat.tsx
msgid "Failed to send heartbeat"
msgstr "فشل في إرسال نبضة القلب"
#: src/components/routes/settings/notifications.tsx #: src/components/routes/settings/notifications.tsx
msgid "Failed to send test notification" msgid "Failed to send test notification"
msgstr "فشل في إرسال إشعار الاختبار" msgstr "فشل في إرسال إشعار الاختبار"
@@ -834,18 +806,6 @@ msgstr "شبكة"
msgid "Health" msgid "Health"
msgstr "الصحة" 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/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Button to copy install command" msgctxt "Button to copy install command"
@@ -856,14 +816,6 @@ msgstr "أمر Homebrew"
msgid "Host / IP" msgid "Host / IP"
msgstr "مضيف / 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 #. Context: Battery state
#: src/lib/i18n.ts #: src/lib/i18n.ts
msgid "Idle" msgid "Idle"
@@ -882,10 +834,6 @@ msgstr "صورة"
msgid "Inactive" msgid "Inactive"
msgstr "غير نشط" msgstr "غير نشط"
#: src/components/routes/settings/heartbeat.tsx
msgid "Interval"
msgstr "الفاصل الزمني"
#: src/components/login/auth-form.tsx #: src/components/login/auth-form.tsx
msgid "Invalid email address." msgid "Invalid email address."
msgstr "عنوان البريد الإشباكي غير صالح." msgstr "عنوان البريد الإشباكي غير صالح."
@@ -1162,10 +1110,6 @@ msgstr "متوقف مؤقتا"
msgid "Paused ({pausedSystemsLength})" msgid "Paused ({pausedSystemsLength})"
msgstr "متوقف مؤقتا ({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
#: src/components/routes/system/cpu-sheet.tsx #: src/components/routes/system/cpu-sheet.tsx
msgid "Per-core average utilization" msgid "Per-core average utilization"
@@ -1363,10 +1307,6 @@ msgstr "بحث"
msgid "Search for systems or settings..." msgid "Search for systems or settings..."
msgstr "البحث عن الأنظمة أو الإعدادات..." msgstr "البحث عن الأنظمة أو الإعدادات..."
#: src/components/routes/settings/heartbeat.tsx
msgid "Seconds between pings (default: 60)"
msgstr "ثواني بين ال pings (الافتراضي: 60)"
#: src/components/alerts/alerts-sheet.tsx #: src/components/alerts/alerts-sheet.tsx
msgid "See <0>notification settings</0> to configure how you receive alerts." msgid "See <0>notification settings</0> to configure how you receive alerts."
msgstr "راجع <0>إعدادات الإشعارات</0> لتكوين كيفية تلقي التنبيهات." msgstr "راجع <0>إعدادات الإشعارات</0> لتكوين كيفية تلقي التنبيهات."
@@ -1375,18 +1315,6 @@ msgstr "راجع <0>إعدادات الإشعارات</0> لتكوين كيفي
msgid "Select {foo}" msgid "Select {foo}"
msgstr "تحديد {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 #: src/components/routes/system.tsx
msgid "Sent" msgid "Sent"
msgstr "تم الإرسال" msgstr "تم الإرسال"
@@ -1407,10 +1335,6 @@ msgstr "الخدمات"
msgid "Set percentage thresholds for meter colors." msgid "Set percentage thresholds for meter colors."
msgstr "تعيين عتبات النسبة المئوية لألوان العداد." 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/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/navbar.tsx #: src/components/navbar.tsx
@@ -1528,18 +1452,10 @@ msgstr "درجات حرارة مستشعرات النظام"
msgid "Test <0>URL</0>" msgid "Test <0>URL</0>"
msgstr "اختبار <0>URL</0>" msgstr "اختبار <0>URL</0>"
#: src/components/routes/settings/heartbeat.tsx
msgid "Test heartbeat"
msgstr "اختبار نبضة القلب"
#: src/components/routes/settings/notifications.tsx #: src/components/routes/settings/notifications.tsx
msgid "Test notification sent" msgid "Test notification sent"
msgstr "تم إرسال إشعار الاختبار" 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 #: src/components/login/forgot-pass-form.tsx
msgid "Then log into the backend and reset your user account password in the users table." msgid "Then log into the backend and reset your user account password in the users table."
msgstr "ثم قم بتسجيل الدخول إلى الواجهة الخلفية وأعد تعيين كلمة مرور حساب المستخدم الخاص بك في جدول المستخدمين." msgstr "ثم قم بتسجيل الدخول إلى الواجهة الخلفية وأعد تعيين كلمة مرور حساب المستخدم الخاص بك في جدول المستخدمين."
@@ -1726,7 +1642,6 @@ msgid "Upload"
msgstr "رفع" msgstr "رفع"
#: src/components/routes/system/info-bar.tsx #: src/components/routes/system/info-bar.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Uptime" msgid "Uptime"
msgstr "مدة التشغيل" msgstr "مدة التشغيل"
@@ -1801,10 +1716,6 @@ msgstr "إشعارات Webhook / Push"
msgid "When enabled, this token allows agents to self-register without prior system creation." msgid "When enabled, this token allows agents to self-register without prior system creation."
msgstr "عند التفعيل، يسمح هذا الرمز المميز للوكلاء بالتسجيل الذاتي دون إنشاء نظام مسبق." 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/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Button to copy install command" msgctxt "Button to copy install command"

View File

@@ -93,7 +93,6 @@ msgstr "Действия"
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/heartbeat.tsx
#: src/components/routes/settings/quiet-hours.tsx #: src/components/routes/settings/quiet-hours.tsx
msgid "Active" msgid "Active"
msgstr "Активен" msgstr "Активен"
@@ -141,10 +140,6 @@ msgstr "Администратор"
msgid "After" msgid "After"
msgstr "След" 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 #: src/components/systems-table/systems-table-columns.tsx
msgid "Agent" msgid "Agent"
msgstr "Агент" msgstr "Агент"
@@ -236,7 +231,7 @@ msgstr "Bandwidth на мрежата"
#. Battery label in systems table header #. Battery label in systems table header
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Bat" msgid "Bat"
msgstr "Бат" msgstr ""
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
#: src/lib/alerts.ts #: src/lib/alerts.ts
@@ -355,10 +350,6 @@ msgstr "Провери {email} за линк за нулиране."
msgid "Check logs for more details." msgid "Check logs for more details."
msgstr "Провери log-овете за повече информация." msgstr "Провери log-овете за повече информация."
#: src/components/routes/settings/heartbeat.tsx
msgid "Check your monitoring service"
msgstr "Проверете вашата услуга за мониторинг"
#: src/components/routes/settings/notifications.tsx #: src/components/routes/settings/notifications.tsx
msgid "Check your notification service" msgid "Check your notification service"
msgstr "Провери услугата си за удостоверяване" msgstr "Провери услугата си за удостоверяване"
@@ -630,7 +621,7 @@ msgstr "Редактирай"
#: src/components/add-system.tsx #: src/components/add-system.tsx
#: src/components/routes/settings/quiet-hours.tsx #: src/components/routes/settings/quiet-hours.tsx
msgid "Edit {foo}" msgid "Edit {foo}"
msgstr "Редактиране на {foo}" msgstr ""
#: src/components/login/auth-form.tsx #: src/components/login/auth-form.tsx
#: src/components/login/forgot-pass-form.tsx #: src/components/login/forgot-pass-form.tsx
@@ -652,14 +643,6 @@ msgstr "Празна"
msgid "End Time" msgid "End Time"
msgstr "Крайно време" 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 #: src/components/login/login.tsx
msgid "Enter email address to reset password" msgid "Enter email address to reset password"
msgstr "Въведи имейл адрес за да нулираш паролата" msgstr "Въведи имейл адрес за да нулираш паролата"
@@ -679,9 +662,6 @@ msgstr "Ефимерен"
#: src/components/login/auth-form.tsx #: src/components/login/auth-form.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/config-yaml.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/notifications.tsx
#: src/components/routes/settings/quiet-hours.tsx #: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx #: src/components/routes/settings/quiet-hours.tsx
@@ -690,10 +670,6 @@ msgstr "Ефимерен"
msgid "Error" msgid "Error"
msgstr "Грешка" msgstr "Грешка"
#: src/components/routes/settings/heartbeat.tsx
msgid "Example:"
msgstr "Пример:"
#. placeholder {0}: alert.value #. placeholder {0}: alert.value
#. placeholder {1}: info.unit #. placeholder {1}: info.unit
#. placeholder {2}: alert.min #. placeholder {2}: alert.min
@@ -703,7 +679,7 @@ msgstr "Надвишава {0}{1} в последните {2, plural, one {# м
#: src/components/systemd-table/systemd-table.tsx #: src/components/systemd-table/systemd-table.tsx
msgid "Exec main PID" msgid "Exec main PID"
msgstr "PID на главния изпълнителен процес" msgstr ""
#: src/components/routes/settings/config-yaml.tsx #: src/components/routes/settings/config-yaml.tsx
msgid "Existing systems not defined in <0>config.yml</0> will be deleted. Please make regular backups." msgid "Existing systems not defined in <0>config.yml</0> will be deleted. Please make regular backups."
@@ -751,10 +727,6 @@ msgstr "Неуспешно удостоверяване"
msgid "Failed to save settings" msgid "Failed to save settings"
msgstr "Неуспешно запазване на настройки" msgstr "Неуспешно запазване на настройки"
#: src/components/routes/settings/heartbeat.tsx
msgid "Failed to send heartbeat"
msgstr "Неуспешно изпращане на heartbeat"
#: src/components/routes/settings/notifications.tsx #: src/components/routes/settings/notifications.tsx
msgid "Failed to send test notification" msgid "Failed to send test notification"
msgstr "Неуспешно изпрати тестова нотификация" msgstr "Неуспешно изпрати тестова нотификация"
@@ -834,18 +806,6 @@ msgstr "Мрежово"
msgid "Health" msgid "Health"
msgstr "Здраве" 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/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Button to copy install command" msgctxt "Button to copy install command"
@@ -856,14 +816,6 @@ msgstr "Команда Homebrew"
msgid "Host / IP" msgid "Host / IP"
msgstr "Хост / 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 #. Context: Battery state
#: src/lib/i18n.ts #: src/lib/i18n.ts
msgid "Idle" msgid "Idle"
@@ -882,10 +834,6 @@ msgstr "Образ"
msgid "Inactive" msgid "Inactive"
msgstr "Неактивен" msgstr "Неактивен"
#: src/components/routes/settings/heartbeat.tsx
msgid "Interval"
msgstr "Интервал"
#: src/components/login/auth-form.tsx #: src/components/login/auth-form.tsx
msgid "Invalid email address." msgid "Invalid email address."
msgstr "Невалиден имейл адрес." msgstr "Невалиден имейл адрес."
@@ -966,7 +914,7 @@ msgstr "Търсиш къде да създадеш тревоги? Натисн
#: src/components/systemd-table/systemd-table.tsx #: src/components/systemd-table/systemd-table.tsx
msgid "Main PID" msgid "Main PID"
msgstr "Главен PID" msgstr ""
#: src/components/routes/settings/layout.tsx #: src/components/routes/settings/layout.tsx
msgid "Manage display and notification preferences." msgid "Manage display and notification preferences."
@@ -1162,10 +1110,6 @@ msgstr "На пауза"
msgid "Paused ({pausedSystemsLength})" msgid "Paused ({pausedSystemsLength})"
msgstr "На пауза ({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
#: src/components/routes/system/cpu-sheet.tsx #: src/components/routes/system/cpu-sheet.tsx
msgid "Per-core average utilization" msgid "Per-core average utilization"
@@ -1363,10 +1307,6 @@ msgstr "Търси"
msgid "Search for systems or settings..." msgid "Search for systems or settings..."
msgstr "Търси за системи или настройки..." msgstr "Търси за системи или настройки..."
#: src/components/routes/settings/heartbeat.tsx
msgid "Seconds between pings (default: 60)"
msgstr "Секунди между пинговете (по подразбиране: 60)"
#: src/components/alerts/alerts-sheet.tsx #: src/components/alerts/alerts-sheet.tsx
msgid "See <0>notification settings</0> to configure how you receive alerts." msgid "See <0>notification settings</0> to configure how you receive alerts."
msgstr "Виж <0>настройките за нотификациите</0> за да конфигурираш как получаваш тревоги." msgstr "Виж <0>настройките за нотификациите</0> за да конфигурираш как получаваш тревоги."
@@ -1375,18 +1315,6 @@ msgstr "Виж <0>настройките за нотификациите</0> з
msgid "Select {foo}" msgid "Select {foo}"
msgstr "Избери {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 #: src/components/routes/system.tsx
msgid "Sent" msgid "Sent"
msgstr "Изпратени" msgstr "Изпратени"
@@ -1407,10 +1335,6 @@ msgstr "Услуги"
msgid "Set percentage thresholds for meter colors." msgid "Set percentage thresholds for meter colors."
msgstr "Задайте процентни прагове за цветовете на измервателните уреди." 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/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/navbar.tsx #: src/components/navbar.tsx
@@ -1528,18 +1452,10 @@ msgstr "Температири на системни сензори"
msgid "Test <0>URL</0>" msgid "Test <0>URL</0>"
msgstr "Тествай <0>URL</0>" msgstr "Тествай <0>URL</0>"
#: src/components/routes/settings/heartbeat.tsx
msgid "Test heartbeat"
msgstr "Тестов heartbeat"
#: src/components/routes/settings/notifications.tsx #: src/components/routes/settings/notifications.tsx
msgid "Test notification sent" msgid "Test notification sent"
msgstr "Тестова нотификация изпратена" 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 #: src/components/login/forgot-pass-form.tsx
msgid "Then log into the backend and reset your user account password in the users table." msgid "Then log into the backend and reset your user account password in the users table."
msgstr "След това влез в backend-а и нулирай паролата за потребителския акаунт в таблицата за потребители." msgstr "След това влез в backend-а и нулирай паролата за потребителския акаунт в таблицата за потребители."
@@ -1726,9 +1642,8 @@ msgid "Upload"
msgstr "Качване" msgstr "Качване"
#: src/components/routes/system/info-bar.tsx #: src/components/routes/system/info-bar.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Uptime" msgid "Uptime"
msgstr "Uptime" msgstr "Време на работа"
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
@@ -1801,10 +1716,6 @@ msgstr "Webhook / Пуш нотификации"
msgid "When enabled, this token allows agents to self-register without prior system creation." msgid "When enabled, this token allows agents to self-register without prior system creation."
msgstr "Когато е активиран, този символ позволява на агентите да се регистрират сами без предварително създаване на система." 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/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Button to copy install command" msgctxt "Button to copy install command"

View File

@@ -93,7 +93,6 @@ msgstr "Akce"
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/heartbeat.tsx
#: src/components/routes/settings/quiet-hours.tsx #: src/components/routes/settings/quiet-hours.tsx
msgid "Active" msgid "Active"
msgstr "Aktivní" msgstr "Aktivní"
@@ -141,10 +140,6 @@ msgstr "Administrátor"
msgid "After" msgid "After"
msgstr "Po" msgstr "Po"
#: src/components/routes/settings/heartbeat.tsx
msgid "After setting the environment variables, restart your Beszel hub for changes to take effect."
msgstr "Po nastavení proměnných prostředí restartujte hub Beszel, aby se změny projevily."
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Agent" msgid "Agent"
msgstr "Agent" msgstr "Agent"
@@ -236,7 +231,7 @@ msgstr "Přenos"
#. Battery label in systems table header #. Battery label in systems table header
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Bat" msgid "Bat"
msgstr "Bat" msgstr ""
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
#: src/lib/alerts.ts #: src/lib/alerts.ts
@@ -355,10 +350,6 @@ msgstr "Zkontrolujte {email} pro odkaz na obnovení."
msgid "Check logs for more details." msgid "Check logs for more details."
msgstr "Pro více informací zkontrolujte logy." msgstr "Pro více informací zkontrolujte logy."
#: src/components/routes/settings/heartbeat.tsx
msgid "Check your monitoring service"
msgstr "Zkontrolujte svou monitorovací službu"
#: src/components/routes/settings/notifications.tsx #: src/components/routes/settings/notifications.tsx
msgid "Check your notification service" msgid "Check your notification service"
msgstr "Zkontrolujte službu upozornění" msgstr "Zkontrolujte službu upozornění"
@@ -565,11 +556,11 @@ msgstr "Vybíjení"
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Disk" msgid "Disk"
msgstr "Disk" msgstr ""
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
msgid "Disk I/O" msgid "Disk I/O"
msgstr "Disk I/O" msgstr ""
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Disk unit" msgid "Disk unit"
@@ -652,14 +643,6 @@ msgstr "Prázdná"
msgid "End Time" msgid "End Time"
msgstr "Čas ukončení" msgstr "Čas ukončení"
#: src/components/routes/settings/heartbeat.tsx
msgid "Endpoint URL"
msgstr "URL koncového bodu"
#: src/components/routes/settings/heartbeat.tsx
msgid "Endpoint URL to ping (required)"
msgstr "URL koncového bodu pro ping (vyžadováno)"
#: src/components/login/login.tsx #: src/components/login/login.tsx
msgid "Enter email address to reset password" msgid "Enter email address to reset password"
msgstr "Zadejte e-mailovou adresu pro obnovu hesla" msgstr "Zadejte e-mailovou adresu pro obnovu hesla"
@@ -679,9 +662,6 @@ msgstr "Efemérní"
#: src/components/login/auth-form.tsx #: src/components/login/auth-form.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/config-yaml.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/notifications.tsx
#: src/components/routes/settings/quiet-hours.tsx #: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx #: src/components/routes/settings/quiet-hours.tsx
@@ -690,10 +670,6 @@ msgstr "Efemérní"
msgid "Error" msgid "Error"
msgstr "Chyba" msgstr "Chyba"
#: src/components/routes/settings/heartbeat.tsx
msgid "Example:"
msgstr "Příklad:"
#. placeholder {0}: alert.value #. placeholder {0}: alert.value
#. placeholder {1}: info.unit #. placeholder {1}: info.unit
#. placeholder {2}: alert.min #. placeholder {2}: alert.min
@@ -751,10 +727,6 @@ msgstr "Ověření se nezdařilo"
msgid "Failed to save settings" msgid "Failed to save settings"
msgstr "Nepodařilo se uložit nastavení" msgstr "Nepodařilo se uložit nastavení"
#: src/components/routes/settings/heartbeat.tsx
msgid "Failed to send heartbeat"
msgstr "Nepodařilo se odeslat heartbeat"
#: src/components/routes/settings/notifications.tsx #: src/components/routes/settings/notifications.tsx
msgid "Failed to send test notification" msgid "Failed to send test notification"
msgstr "Nepodařilo se odeslat testovací oznámení" msgstr "Nepodařilo se odeslat testovací oznámení"
@@ -783,7 +755,7 @@ msgstr "Otisk"
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
msgid "Firmware" msgid "Firmware"
msgstr "Firmware" msgstr ""
#: src/components/alerts/alerts-sheet.tsx #: src/components/alerts/alerts-sheet.tsx
msgid "For <0>{min}</0> {min, plural, one {minute} other {minutes}}" msgid "For <0>{min}</0> {min, plural, one {minute} other {minutes}}"
@@ -834,18 +806,6 @@ msgstr "Mřížka"
msgid "Health" msgid "Health"
msgstr "Zdraví" msgstr "Zdraví"
#: src/components/routes/settings/layout.tsx
msgid "Heartbeat"
msgstr "Heartbeat"
#: src/components/routes/settings/heartbeat.tsx
msgid "Heartbeat Monitoring"
msgstr "Monitorování heartbeat"
#: src/components/routes/settings/heartbeat.tsx
msgid "Heartbeat sent successfully"
msgstr "Heartbeat úspěšně odeslán"
#: src/components/add-system.tsx #: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Button to copy install command" msgctxt "Button to copy install command"
@@ -856,14 +816,6 @@ msgstr "Homebrew příkaz"
msgid "Host / IP" msgid "Host / IP"
msgstr "Hostitel / IP" msgstr "Hostitel / IP"
#: src/components/routes/settings/heartbeat.tsx
msgid "HTTP Method"
msgstr "HTTP metoda"
#: src/components/routes/settings/heartbeat.tsx
msgid "HTTP method: POST, GET, or HEAD (default: POST)"
msgstr "HTTP metoda: POST, GET nebo HEAD (výchozí: POST)"
#. Context: Battery state #. Context: Battery state
#: src/lib/i18n.ts #: src/lib/i18n.ts
msgid "Idle" msgid "Idle"
@@ -882,10 +834,6 @@ msgstr "Obraz"
msgid "Inactive" msgid "Inactive"
msgstr "Neaktivní" msgstr "Neaktivní"
#: src/components/routes/settings/heartbeat.tsx
msgid "Interval"
msgstr "Interval"
#: src/components/login/auth-form.tsx #: src/components/login/auth-form.tsx
msgid "Invalid email address." msgid "Invalid email address."
msgstr "Neplatná e-mailová adresa." msgstr "Neplatná e-mailová adresa."
@@ -910,7 +858,7 @@ msgstr "Životní cyklus"
#: src/components/systemd-table/systemd-table.tsx #: src/components/systemd-table/systemd-table.tsx
#: src/components/systemd-table/systemd-table.tsx #: src/components/systemd-table/systemd-table.tsx
msgid "limit" msgid "limit"
msgstr "limit" msgstr ""
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
msgid "Load Average" msgid "Load Average"
@@ -1010,7 +958,7 @@ msgstr "Využití paměti docker kontejnerů"
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
msgid "Model" msgid "Model"
msgstr "Model" msgstr ""
#: src/components/add-system.tsx #: src/components/add-system.tsx
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
@@ -1162,10 +1110,6 @@ msgstr "Pozastaveno"
msgid "Paused ({pausedSystemsLength})" msgid "Paused ({pausedSystemsLength})"
msgstr "Pozastaveno ({pausedSystemsLength})" msgstr "Pozastaveno ({pausedSystemsLength})"
#: src/components/routes/settings/heartbeat.tsx
msgid "Payload format"
msgstr "Formát payloadu"
#: src/components/routes/system/cpu-sheet.tsx #: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/cpu-sheet.tsx #: src/components/routes/system/cpu-sheet.tsx
msgid "Per-core average utilization" msgid "Per-core average utilization"
@@ -1218,7 +1162,7 @@ msgstr "Přihlaste se prosím k vašemu účtu"
#: src/components/add-system.tsx #: src/components/add-system.tsx
msgid "Port" msgid "Port"
msgstr "Port" msgstr ""
#. Power On Time #. Power On Time
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
@@ -1363,10 +1307,6 @@ msgstr "Hledat"
msgid "Search for systems or settings..." msgid "Search for systems or settings..."
msgstr "Hledat systémy nebo nastavení..." msgstr "Hledat systémy nebo nastavení..."
#: src/components/routes/settings/heartbeat.tsx
msgid "Seconds between pings (default: 60)"
msgstr "Sekundy mezi pingy (výchozí: 60)"
#: src/components/alerts/alerts-sheet.tsx #: src/components/alerts/alerts-sheet.tsx
msgid "See <0>notification settings</0> to configure how you receive alerts." msgid "See <0>notification settings</0> to configure how you receive alerts."
msgstr "Podívejte se na <0>nastavení upozornění</0> pro nastavení toho, jak přijímáte upozornění." msgstr "Podívejte se na <0>nastavení upozornění</0> pro nastavení toho, jak přijímáte upozornění."
@@ -1375,18 +1315,6 @@ msgstr "Podívejte se na <0>nastavení upozornění</0> pro nastavení toho, jak
msgid "Select {foo}" msgid "Select {foo}"
msgstr "Vybrat {foo}" msgstr "Vybrat {foo}"
#: src/components/routes/settings/heartbeat.tsx
msgid "Send a single heartbeat ping to verify your endpoint is working."
msgstr "Odešlete jeden heartbeat ping pro ověření funkčnosti vašeho koncového bodu."
#: 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 "Odesílejte periodické odchozí pingy na externí monitorovací službu, abyste mohli monitorovat Beszel bez jeho vystavení internetu."
#: src/components/routes/settings/heartbeat.tsx
msgid "Send test heartbeat"
msgstr "Odeslat testovací heartbeat"
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
msgid "Sent" msgid "Sent"
msgstr "Odeslat" msgstr "Odeslat"
@@ -1407,10 +1335,6 @@ msgstr "Služby"
msgid "Set percentage thresholds for meter colors." msgid "Set percentage thresholds for meter colors."
msgstr "Nastavte procentuální prahové hodnoty pro barvy měřičů." msgstr "Nastavte procentuální prahové hodnoty pro barvy měřičů."
#: src/components/routes/settings/heartbeat.tsx
msgid "Set the following environment variables on your Beszel hub to enable heartbeat monitoring:"
msgstr "Pro povolení monitorování heartbeat nastavte na hubu Beszel následující proměnné prostředí:"
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/navbar.tsx #: src/components/navbar.tsx
@@ -1528,18 +1452,10 @@ msgstr "Teploty systémových senzorů"
msgid "Test <0>URL</0>" msgid "Test <0>URL</0>"
msgstr "Testovat <0>URL</0>" msgstr "Testovat <0>URL</0>"
#: src/components/routes/settings/heartbeat.tsx
msgid "Test heartbeat"
msgstr "Testovat heartbeat"
#: src/components/routes/settings/notifications.tsx #: src/components/routes/settings/notifications.tsx
msgid "Test notification sent" msgid "Test notification sent"
msgstr "Testovací oznámení odesláno" msgstr "Testovací oznámení odesláno"
#: 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 "Celkový stav je <0>ok</0>, když jsou všechny systémy v provozu, <1>warn</1>, když jsou spuštěny výstrahy, a <2>error</2>, když je některý systém mimo provoz."
#: src/components/login/forgot-pass-form.tsx #: src/components/login/forgot-pass-form.tsx
msgid "Then log into the backend and reset your user account password in the users table." msgid "Then log into the backend and reset your user account password in the users table."
msgstr "Poté se přihlaste do backendu a obnovte heslo k uživatelskému účtu v tabulce uživatelů." msgstr "Poté se přihlaste do backendu a obnovte heslo k uživatelskému účtu v tabulce uživatelů."
@@ -1581,7 +1497,7 @@ msgstr "Přepnout motiv"
#: src/components/add-system.tsx #: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Token" msgid "Token"
msgstr "Token" msgstr ""
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/routes/settings/layout.tsx #: src/components/routes/settings/layout.tsx
@@ -1726,9 +1642,8 @@ msgid "Upload"
msgstr "Odeslání" msgstr "Odeslání"
#: src/components/routes/system/info-bar.tsx #: src/components/routes/system/info-bar.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Uptime" msgid "Uptime"
msgstr "Uptime" msgstr "Doba provozu"
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
@@ -1801,10 +1716,6 @@ msgstr "Webhook / Push oznámení"
msgid "When enabled, this token allows agents to self-register without prior system creation." msgid "When enabled, this token allows agents to self-register without prior system creation."
msgstr "Pokud je povoleno, umožňuje tento token agentům samo-registraci bez předchozího vytvoření systému." msgstr "Pokud je povoleno, umožňuje tento token agentům samo-registraci bez předchozího vytvoření systému."
#: 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 "Při použití metody POST obsahuje každý heartbeat JSON payload se souhrnem stavu systému, seznamem nefunkčních systémů a spuštěnými výstrahami."
#: src/components/add-system.tsx #: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Button to copy install command" msgctxt "Button to copy install command"

View File

@@ -93,7 +93,6 @@ msgstr "Handlinger"
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/heartbeat.tsx
#: src/components/routes/settings/quiet-hours.tsx #: src/components/routes/settings/quiet-hours.tsx
msgid "Active" msgid "Active"
msgstr "Aktiv" msgstr "Aktiv"
@@ -141,10 +140,6 @@ msgstr "Administrator"
msgid "After" msgid "After"
msgstr "Efter" msgstr "Efter"
#: src/components/routes/settings/heartbeat.tsx
msgid "After setting the environment variables, restart your Beszel hub for changes to take effect."
msgstr "Efter indstilling af miljøvariablerne skal du genstarte din Beszel-hub for at ændringerne kan træde i kraft."
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Agent" msgid "Agent"
msgstr "Agent" msgstr "Agent"
@@ -236,7 +231,7 @@ msgstr "Båndbredde"
#. Battery label in systems table header #. Battery label in systems table header
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Bat" msgid "Bat"
msgstr "Bat" msgstr ""
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
#: src/lib/alerts.ts #: src/lib/alerts.ts
@@ -355,10 +350,6 @@ msgstr "Tjek {email} for et nulstillingslink."
msgid "Check logs for more details." msgid "Check logs for more details."
msgstr "Tjek logfiler for flere detaljer." msgstr "Tjek logfiler for flere detaljer."
#: src/components/routes/settings/heartbeat.tsx
msgid "Check your monitoring service"
msgstr "Tjek din overvågningstjeneste"
#: src/components/routes/settings/notifications.tsx #: src/components/routes/settings/notifications.tsx
msgid "Check your notification service" msgid "Check your notification service"
msgstr "Tjek din notifikationstjeneste" msgstr "Tjek din notifikationstjeneste"
@@ -652,14 +643,6 @@ msgstr "Tom"
msgid "End Time" msgid "End Time"
msgstr "Sluttid" msgstr "Sluttid"
#: src/components/routes/settings/heartbeat.tsx
msgid "Endpoint URL"
msgstr "Endpoint-URL"
#: src/components/routes/settings/heartbeat.tsx
msgid "Endpoint URL to ping (required)"
msgstr "Endpoint-URL til ping (påkrævet)"
#: src/components/login/login.tsx #: src/components/login/login.tsx
msgid "Enter email address to reset password" msgid "Enter email address to reset password"
msgstr "Indtast emailadresse for at nulstille adgangskoden" msgstr "Indtast emailadresse for at nulstille adgangskoden"
@@ -679,9 +662,6 @@ msgstr "Efemer"
#: src/components/login/auth-form.tsx #: src/components/login/auth-form.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/config-yaml.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/notifications.tsx
#: src/components/routes/settings/quiet-hours.tsx #: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx #: src/components/routes/settings/quiet-hours.tsx
@@ -690,10 +670,6 @@ msgstr "Efemer"
msgid "Error" msgid "Error"
msgstr "Fejl" msgstr "Fejl"
#: src/components/routes/settings/heartbeat.tsx
msgid "Example:"
msgstr "Eksempel:"
#. placeholder {0}: alert.value #. placeholder {0}: alert.value
#. placeholder {1}: info.unit #. placeholder {1}: info.unit
#. placeholder {2}: alert.min #. placeholder {2}: alert.min
@@ -751,10 +727,6 @@ msgstr "Kunne ikke godkende"
msgid "Failed to save settings" msgid "Failed to save settings"
msgstr "Kunne ikke gemme indstillinger" msgstr "Kunne ikke gemme indstillinger"
#: src/components/routes/settings/heartbeat.tsx
msgid "Failed to send heartbeat"
msgstr "Kunne ikke sende heartbeat"
#: src/components/routes/settings/notifications.tsx #: src/components/routes/settings/notifications.tsx
msgid "Failed to send test notification" msgid "Failed to send test notification"
msgstr "Afsendelse af testnotifikation mislykkedes" msgstr "Afsendelse af testnotifikation mislykkedes"
@@ -834,18 +806,6 @@ msgstr "Gitter"
msgid "Health" msgid "Health"
msgstr "Sundhed" msgstr "Sundhed"
#: src/components/routes/settings/layout.tsx
msgid "Heartbeat"
msgstr "Heartbeat"
#: src/components/routes/settings/heartbeat.tsx
msgid "Heartbeat Monitoring"
msgstr "Heartbeat-overvågning"
#: src/components/routes/settings/heartbeat.tsx
msgid "Heartbeat sent successfully"
msgstr "Heartbeat sendt succesfuldt"
#: src/components/add-system.tsx #: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Button to copy install command" msgctxt "Button to copy install command"
@@ -856,14 +816,6 @@ msgstr "Homebrew-kommando"
msgid "Host / IP" msgid "Host / IP"
msgstr "Vært / IP" msgstr "Vært / IP"
#: src/components/routes/settings/heartbeat.tsx
msgid "HTTP Method"
msgstr "HTTP-metode"
#: src/components/routes/settings/heartbeat.tsx
msgid "HTTP method: POST, GET, or HEAD (default: POST)"
msgstr "HTTP-metode: POST, GET eller HEAD (standard: POST)"
#. Context: Battery state #. Context: Battery state
#: src/lib/i18n.ts #: src/lib/i18n.ts
msgid "Idle" msgid "Idle"
@@ -882,10 +834,6 @@ msgstr "Billede"
msgid "Inactive" msgid "Inactive"
msgstr "Inaktiv" msgstr "Inaktiv"
#: src/components/routes/settings/heartbeat.tsx
msgid "Interval"
msgstr "Interval"
#: src/components/login/auth-form.tsx #: src/components/login/auth-form.tsx
msgid "Invalid email address." msgid "Invalid email address."
msgstr "Ugyldig email adresse." msgstr "Ugyldig email adresse."
@@ -1162,10 +1110,6 @@ msgstr "Sat på pause"
msgid "Paused ({pausedSystemsLength})" msgid "Paused ({pausedSystemsLength})"
msgstr "Sat på pause ({pausedSystemsLength})" msgstr "Sat på pause ({pausedSystemsLength})"
#: src/components/routes/settings/heartbeat.tsx
msgid "Payload format"
msgstr "Payload-format"
#: src/components/routes/system/cpu-sheet.tsx #: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/cpu-sheet.tsx #: src/components/routes/system/cpu-sheet.tsx
msgid "Per-core average utilization" msgid "Per-core average utilization"
@@ -1177,7 +1121,7 @@ msgstr "Procentdel af tid brugt i hver tilstand"
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Permanent" msgid "Permanent"
msgstr "Permanent" msgstr ""
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Persistence" msgid "Persistence"
@@ -1363,10 +1307,6 @@ msgstr "Søg"
msgid "Search for systems or settings..." msgid "Search for systems or settings..."
msgstr "Søg efter systemer eller indstillinger..." msgstr "Søg efter systemer eller indstillinger..."
#: src/components/routes/settings/heartbeat.tsx
msgid "Seconds between pings (default: 60)"
msgstr "Sekunder mellem pings (standard: 60)"
#: src/components/alerts/alerts-sheet.tsx #: src/components/alerts/alerts-sheet.tsx
msgid "See <0>notification settings</0> to configure how you receive alerts." msgid "See <0>notification settings</0> to configure how you receive alerts."
msgstr "Se <0>meddelelsesindstillinger</0> for at konfigurere, hvordan du modtager alarmer." msgstr "Se <0>meddelelsesindstillinger</0> for at konfigurere, hvordan du modtager alarmer."
@@ -1375,18 +1315,6 @@ msgstr "Se <0>meddelelsesindstillinger</0> for at konfigurere, hvordan du modtag
msgid "Select {foo}" msgid "Select {foo}"
msgstr "Vælg {foo}" msgstr "Vælg {foo}"
#: src/components/routes/settings/heartbeat.tsx
msgid "Send a single heartbeat ping to verify your endpoint is working."
msgstr "Send et enkelt heartbeat-ping for at bekræfte, at dit endpoint fungerer."
#: 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 "Send periodiske udgående pings til en ekstern overvågningstjeneste, så du kan overvåge Beszel uden at eksponere det for internettet."
#: src/components/routes/settings/heartbeat.tsx
msgid "Send test heartbeat"
msgstr "Send test-heartbeat"
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
msgid "Sent" msgid "Sent"
msgstr "Sendt" msgstr "Sendt"
@@ -1407,10 +1335,6 @@ msgstr "Tjenester"
msgid "Set percentage thresholds for meter colors." msgid "Set percentage thresholds for meter colors."
msgstr "Indstil procentvise tærskler for målerfarver." msgstr "Indstil procentvise tærskler for målerfarver."
#: src/components/routes/settings/heartbeat.tsx
msgid "Set the following environment variables on your Beszel hub to enable heartbeat monitoring:"
msgstr "Indstil følgende miljøvariabler på din Beszel-hub for at aktivere heartbeat-overvågning:"
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/navbar.tsx #: src/components/navbar.tsx
@@ -1528,18 +1452,10 @@ msgstr "Temperaturer i systemsensorer"
msgid "Test <0>URL</0>" msgid "Test <0>URL</0>"
msgstr "Test <0>URL</0>" msgstr "Test <0>URL</0>"
#: src/components/routes/settings/heartbeat.tsx
msgid "Test heartbeat"
msgstr "Test-heartbeat"
#: src/components/routes/settings/notifications.tsx #: src/components/routes/settings/notifications.tsx
msgid "Test notification sent" msgid "Test notification sent"
msgstr "Test notifikation sendt" msgstr "Test notifikation sendt"
#: 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 "Den overordnede status er <0>ok</0>, når alle systemer kører, <1>warn</1>, når alarmer udløses, og <2>error</2>, når et system er nede."
#: src/components/login/forgot-pass-form.tsx #: src/components/login/forgot-pass-form.tsx
msgid "Then log into the backend and reset your user account password in the users table." msgid "Then log into the backend and reset your user account password in the users table."
msgstr "Log derefter ind på backend og nulstil adgangskoden til din brugerkonto i tabellen brugere." msgstr "Log derefter ind på backend og nulstil adgangskoden til din brugerkonto i tabellen brugere."
@@ -1726,7 +1642,6 @@ msgid "Upload"
msgstr "Overfør" msgstr "Overfør"
#: src/components/routes/system/info-bar.tsx #: src/components/routes/system/info-bar.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Uptime" msgid "Uptime"
msgstr "Oppetid" msgstr "Oppetid"
@@ -1801,10 +1716,6 @@ msgstr "Webhook / Push notifikationer"
msgid "When enabled, this token allows agents to self-register without prior system creation." msgid "When enabled, this token allows agents to self-register without prior system creation."
msgstr "Når aktiveret, tillader denne token agenter at registrere sig selv uden forudgående systemoprettelse." msgstr "Når aktiveret, tillader denne token agenter at registrere sig selv uden forudgående systemoprettelse."
#: 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 "Når du bruger POST, inkluderer hvert heartbeat en JSON-payload med resumé af systemstatus, liste over systemer, der er nede, og udløste alarmer."
#: src/components/add-system.tsx #: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Button to copy install command" msgctxt "Button to copy install command"

View File

@@ -93,7 +93,6 @@ msgstr "Aktionen"
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/heartbeat.tsx
#: src/components/routes/settings/quiet-hours.tsx #: src/components/routes/settings/quiet-hours.tsx
msgid "Active" msgid "Active"
msgstr "Aktiv" msgstr "Aktiv"
@@ -141,10 +140,6 @@ msgstr "Admin"
msgid "After" msgid "After"
msgstr "Nach" msgstr "Nach"
#: src/components/routes/settings/heartbeat.tsx
msgid "After setting the environment variables, restart your Beszel hub for changes to take effect."
msgstr "Starten Sie nach dem Festlegen der Umgebungsvariablen Ihren Beszel-Hub neu, damit die Änderungen wirksam werden."
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Agent" msgid "Agent"
msgstr "Agent" msgstr "Agent"
@@ -236,7 +231,7 @@ msgstr "Bandbreite"
#. Battery label in systems table header #. Battery label in systems table header
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Bat" msgid "Bat"
msgstr "Bat" msgstr ""
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
#: src/lib/alerts.ts #: src/lib/alerts.ts
@@ -355,10 +350,6 @@ msgstr "Überprüfe {email} auf einen Link zum Zurücksetzen."
msgid "Check logs for more details." msgid "Check logs for more details."
msgstr "Überprüfe die Protokolle für weitere Details." msgstr "Überprüfe die Protokolle für weitere Details."
#: src/components/routes/settings/heartbeat.tsx
msgid "Check your monitoring service"
msgstr "Überprüfen Sie Ihren Überwachungsdienst"
#: src/components/routes/settings/notifications.tsx #: src/components/routes/settings/notifications.tsx
msgid "Check your notification service" msgid "Check your notification service"
msgstr "Überprüfe deinen Benachrichtigungsdienst" msgstr "Überprüfe deinen Benachrichtigungsdienst"
@@ -652,14 +643,6 @@ msgstr "Leer"
msgid "End Time" msgid "End Time"
msgstr "Endzeit" msgstr "Endzeit"
#: src/components/routes/settings/heartbeat.tsx
msgid "Endpoint URL"
msgstr "Endpunkt-URL"
#: src/components/routes/settings/heartbeat.tsx
msgid "Endpoint URL to ping (required)"
msgstr "Endpunkt-URL zum Pingen (erforderlich)"
#: src/components/login/login.tsx #: src/components/login/login.tsx
msgid "Enter email address to reset password" msgid "Enter email address to reset password"
msgstr "E-Mail-Adresse eingeben, um das Passwort zurückzusetzen" msgstr "E-Mail-Adresse eingeben, um das Passwort zurückzusetzen"
@@ -679,9 +662,6 @@ msgstr "Flüchtig"
#: src/components/login/auth-form.tsx #: src/components/login/auth-form.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/config-yaml.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/notifications.tsx
#: src/components/routes/settings/quiet-hours.tsx #: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx #: src/components/routes/settings/quiet-hours.tsx
@@ -690,10 +670,6 @@ msgstr "Flüchtig"
msgid "Error" msgid "Error"
msgstr "Fehler" msgstr "Fehler"
#: src/components/routes/settings/heartbeat.tsx
msgid "Example:"
msgstr "Beispiel:"
#. placeholder {0}: alert.value #. placeholder {0}: alert.value
#. placeholder {1}: info.unit #. placeholder {1}: info.unit
#. placeholder {2}: alert.min #. placeholder {2}: alert.min
@@ -751,10 +727,6 @@ msgstr "Authentifizierung fehlgeschlagen"
msgid "Failed to save settings" msgid "Failed to save settings"
msgstr "Einstellungen konnten nicht gespeichert werden" msgstr "Einstellungen konnten nicht gespeichert werden"
#: src/components/routes/settings/heartbeat.tsx
msgid "Failed to send heartbeat"
msgstr "Heartbeat konnte nicht gesendet werden"
#: src/components/routes/settings/notifications.tsx #: src/components/routes/settings/notifications.tsx
msgid "Failed to send test notification" msgid "Failed to send test notification"
msgstr "Testbenachrichtigung konnte nicht gesendet werden" msgstr "Testbenachrichtigung konnte nicht gesendet werden"
@@ -834,18 +806,6 @@ msgstr "Raster"
msgid "Health" msgid "Health"
msgstr "Gesundheit" msgstr "Gesundheit"
#: src/components/routes/settings/layout.tsx
msgid "Heartbeat"
msgstr "Heartbeat"
#: src/components/routes/settings/heartbeat.tsx
msgid "Heartbeat Monitoring"
msgstr "Heartbeat-Überwachung"
#: src/components/routes/settings/heartbeat.tsx
msgid "Heartbeat sent successfully"
msgstr "Heartbeat erfolgreich gesendet"
#: src/components/add-system.tsx #: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Button to copy install command" msgctxt "Button to copy install command"
@@ -856,14 +816,6 @@ msgstr "Homebrew-Befehl"
msgid "Host / IP" msgid "Host / IP"
msgstr "Host / IP" msgstr "Host / IP"
#: src/components/routes/settings/heartbeat.tsx
msgid "HTTP Method"
msgstr "HTTP-Methode"
#: src/components/routes/settings/heartbeat.tsx
msgid "HTTP method: POST, GET, or HEAD (default: POST)"
msgstr "HTTP-Methode: POST, GET oder HEAD (Standard: POST)"
#. Context: Battery state #. Context: Battery state
#: src/lib/i18n.ts #: src/lib/i18n.ts
msgid "Idle" msgid "Idle"
@@ -882,10 +834,6 @@ msgstr "Image"
msgid "Inactive" msgid "Inactive"
msgstr "Inaktiv" msgstr "Inaktiv"
#: src/components/routes/settings/heartbeat.tsx
msgid "Interval"
msgstr "Intervall"
#: src/components/login/auth-form.tsx #: src/components/login/auth-form.tsx
msgid "Invalid email address." msgid "Invalid email address."
msgstr "Ungültige E-Mail-Adresse." msgstr "Ungültige E-Mail-Adresse."
@@ -1148,7 +1096,7 @@ msgstr "Anfrage zum Zurücksetzen des Passworts erhalten"
#: src/components/routes/settings/quiet-hours.tsx #: src/components/routes/settings/quiet-hours.tsx
msgid "Past" msgid "Past"
msgstr "Vergangen" msgstr ""
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Pause" msgid "Pause"
@@ -1162,10 +1110,6 @@ msgstr "Pausiert"
msgid "Paused ({pausedSystemsLength})" msgid "Paused ({pausedSystemsLength})"
msgstr "Pausiert ({pausedSystemsLength})" msgstr "Pausiert ({pausedSystemsLength})"
#: src/components/routes/settings/heartbeat.tsx
msgid "Payload format"
msgstr "Payload-Format"
#: src/components/routes/system/cpu-sheet.tsx #: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/cpu-sheet.tsx #: src/components/routes/system/cpu-sheet.tsx
msgid "Per-core average utilization" msgid "Per-core average utilization"
@@ -1177,7 +1121,7 @@ msgstr "Prozentsatz der Zeit in jedem Zustand"
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Permanent" msgid "Permanent"
msgstr "Permanent" msgstr ""
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Persistence" msgid "Persistence"
@@ -1304,7 +1248,7 @@ msgstr "Fortsetzen"
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgctxt "Root disk label" msgctxt "Root disk label"
msgid "Root" msgid "Root"
msgstr "Root" msgstr ""
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Rotate token" msgid "Rotate token"
@@ -1363,10 +1307,6 @@ msgstr "Suche"
msgid "Search for systems or settings..." msgid "Search for systems or settings..."
msgstr "Nach Systemen oder Einstellungen suchen..." msgstr "Nach Systemen oder Einstellungen suchen..."
#: src/components/routes/settings/heartbeat.tsx
msgid "Seconds between pings (default: 60)"
msgstr "Sekunden zwischen Pings (Standard: 60)"
#: src/components/alerts/alerts-sheet.tsx #: src/components/alerts/alerts-sheet.tsx
msgid "See <0>notification settings</0> to configure how you receive alerts." msgid "See <0>notification settings</0> to configure how you receive alerts."
msgstr "Siehe <0>Benachrichtigungseinstellungen</0>, um zu konfigurieren, wie du Warnungen erhältst." msgstr "Siehe <0>Benachrichtigungseinstellungen</0>, um zu konfigurieren, wie du Warnungen erhältst."
@@ -1375,18 +1315,6 @@ msgstr "Siehe <0>Benachrichtigungseinstellungen</0>, um zu konfigurieren, wie du
msgid "Select {foo}" msgid "Select {foo}"
msgstr "Auswählen {foo}" msgstr "Auswählen {foo}"
#: src/components/routes/settings/heartbeat.tsx
msgid "Send a single heartbeat ping to verify your endpoint is working."
msgstr "Senden Sie einen einzelnen Heartbeat-Ping, um zu überprüfen, ob Ihr Endpunkt funktioniert."
#: 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 "Senden Sie regelmäßige ausgehende Pings an einen externen Überwachungsdienst, damit Sie Beszel überwachen können, ohne es dem Internet auszusetzen."
#: src/components/routes/settings/heartbeat.tsx
msgid "Send test heartbeat"
msgstr "Test-Heartbeat senden"
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
msgid "Sent" msgid "Sent"
msgstr "Gesendet" msgstr "Gesendet"
@@ -1407,10 +1335,6 @@ msgstr "Dienste"
msgid "Set percentage thresholds for meter colors." msgid "Set percentage thresholds for meter colors."
msgstr "Prozentuale Schwellenwerte für Zählerfarben festlegen." msgstr "Prozentuale Schwellenwerte für Zählerfarben festlegen."
#: src/components/routes/settings/heartbeat.tsx
msgid "Set the following environment variables on your Beszel hub to enable heartbeat monitoring:"
msgstr "Legen Sie die folgenden Umgebungsvariablen auf Ihrem Beszel-Hub fest, um die Heartbeat-Überwachung zu aktivieren:"
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/navbar.tsx #: src/components/navbar.tsx
@@ -1528,18 +1452,10 @@ msgstr "Temperaturen der Systemsensoren"
msgid "Test <0>URL</0>" msgid "Test <0>URL</0>"
msgstr "Test <0>URL</0>" msgstr "Test <0>URL</0>"
#: src/components/routes/settings/heartbeat.tsx
msgid "Test heartbeat"
msgstr "Test-Heartbeat"
#: src/components/routes/settings/notifications.tsx #: src/components/routes/settings/notifications.tsx
msgid "Test notification sent" msgid "Test notification sent"
msgstr "Testbenachrichtigung gesendet" msgstr "Testbenachrichtigung gesendet"
#: 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 "Der Gesamtstatus ist <0>ok</0>, wenn alle Systeme in Betrieb sind, <1>warn</1>, wenn Warnungen ausgelöst werden, und <2>error</2>, wenn ein System ausgefallen ist."
#: src/components/login/forgot-pass-form.tsx #: src/components/login/forgot-pass-form.tsx
msgid "Then log into the backend and reset your user account password in the users table." msgid "Then log into the backend and reset your user account password in the users table."
msgstr "Melde dich dann im Backend an und setze dein Benutzerkontopasswort in der Benutzertabelle zurück." msgstr "Melde dich dann im Backend an und setze dein Benutzerkontopasswort in der Benutzertabelle zurück."
@@ -1726,7 +1642,6 @@ msgid "Upload"
msgstr "Hochladen" msgstr "Hochladen"
#: src/components/routes/system/info-bar.tsx #: src/components/routes/system/info-bar.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Uptime" msgid "Uptime"
msgstr "Betriebszeit" msgstr "Betriebszeit"
@@ -1801,10 +1716,6 @@ msgstr "Webhook / Push-Benachrichtigungen"
msgid "When enabled, this token allows agents to self-register without prior system creation." msgid "When enabled, this token allows agents to self-register without prior system creation."
msgstr "Wenn aktiviert, ermöglicht dieser Token Agenten die Selbstregistrierung ohne vorherige Systemerstellung." msgstr "Wenn aktiviert, ermöglicht dieser Token Agenten die Selbstregistrierung ohne vorherige Systemerstellung."
#: 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 "Bei Verwendung von POST enthält jeder Heartbeat eine JSON-Payload mit einer Zusammenfassung des Systemstatus, einer Liste der ausgefallenen Systeme und ausgelösten Warnungen."
#: src/components/add-system.tsx #: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Button to copy install command" msgctxt "Button to copy install command"

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