Compare commits

...

19 Commits

Author SHA1 Message Date
henrygd
330d375997 change to atomic.bool for fetching details / smart 2025-12-18 15:02:59 -05:00
henrygd
8627e3ee97 updates 2025-12-18 12:34:11 -05:00
henrygd
5d04ee5a65 consolidate info bar data 2025-12-17 19:03:31 -05:00
henrygd
d93067ec34 updates 2025-12-17 17:32:59 -05:00
henrygd
82bd953941 add arch 2025-12-16 18:33:32 -05:00
henrygd
996444abeb update 2025-12-16 17:45:26 -05:00
henrygd
aef4baff5e rm index 2025-12-15 18:59:25 -05:00
henrygd
3dea061e93 progress 2025-12-15 18:29:51 -05:00
henrygd
35329abcbd agent install: improve freebsd checksum verification (#1526) 2025-12-12 15:32:55 -05:00
henrygd
ee7741c3ab add --url and --token command line args for agent (#1524) 2025-12-12 13:58:44 -05:00
David Davis
ab0803b2da Upgrade react to address CVE-2025-55182
More info: https://react.dev/blog/2025/12/03/critical-security-vulnerability-in-react-server-components
2025-12-12 07:42:29 -05:00
henrygd
96196a353c smart: fallback to nvme namespace path if base controller path fails (#1504) 2025-12-10 14:09:20 -05:00
henrygd
2a8796c38d update hub install script to support freebsd 2025-12-09 15:08:59 -05:00
Zero2A11
c8d4f7427d fix: When there is no client, LoaderCircle will always transfer (#1511) 2025-12-09 11:01:13 -05:00
henrygd
8d41a797d3 add battery charge to systems table 2025-12-08 18:20:51 -05:00
henrygd
570e1cbf40 pin alpine version to 3.22 2025-12-08 18:14:14 -05:00
henrygd
4c9b00a066 add low battery alert (#1507) 2025-12-08 15:19:46 -05:00
henrygd
7d1f8bb180 raise smartctl timeout to 15 seconds (#1465) 2025-12-05 14:59:11 -05:00
henrygd
3a6caeb06e add serbian 2025-12-05 14:21:50 -05:00
44 changed files with 3555 additions and 535 deletions

View File

@@ -17,6 +17,7 @@ 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/internal/common"
"github.com/henrygd/beszel/internal/entities/system" "github.com/henrygd/beszel/internal/entities/system"
"github.com/shirou/gopsutil/v4/host" "github.com/shirou/gopsutil/v4/host"
gossh "golang.org/x/crypto/ssh" gossh "golang.org/x/crypto/ssh"
@@ -37,7 +38,8 @@ type Agent struct {
netInterfaceDeltaTrackers map[uint16]*deltatracker.DeltaTracker[string, uint64] // Per-cache-time NIC delta trackers netInterfaceDeltaTrackers map[uint16]*deltatracker.DeltaTracker[string, uint64] // Per-cache-time NIC delta trackers
dockerManager *dockerManager // Manages Docker API requests dockerManager *dockerManager // Manages Docker API requests
sensorConfig *SensorConfig // Sensors config sensorConfig *SensorConfig // Sensors config
systemInfo system.Info // Host system info systemInfo system.Info // Host system info (dynamic)
systemDetails system.Details // Host system details (static, once-per-connection)
gpuManager *GPUManager // Manages GPU data gpuManager *GPUManager // Manages GPU data
cache *systemDataCache // Cache for system stats based on cache time cache *systemDataCache // Cache for system stats based on cache time
connectionManager *ConnectionManager // Channel to signal connection events connectionManager *ConnectionManager // Channel to signal connection events
@@ -97,8 +99,11 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
slog.Debug(beszel.Version) slog.Debug(beszel.Version)
// initialize docker manager
agent.dockerManager = newDockerManager()
// initialize system info // initialize system info
agent.initializeSystemInfo() agent.refreshStaticInfo()
// initialize connection manager // initialize connection manager
agent.connectionManager = newConnectionManager(agent) agent.connectionManager = newConnectionManager(agent)
@@ -112,9 +117,6 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
// initialize net io stats // initialize net io stats
agent.initializeNetIoStats() agent.initializeNetIoStats()
// initialize docker manager
agent.dockerManager = newDockerManager(agent)
agent.systemdManager, err = newSystemdManager() agent.systemdManager, err = newSystemdManager()
if err != nil { if err != nil {
slog.Debug("Systemd", "err", err) slog.Debug("Systemd", "err", err)
@@ -133,7 +135,7 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
// if debugging, print stats // if debugging, print stats
if agent.debug { if agent.debug {
slog.Debug("Stats", "data", agent.gatherStats(0)) slog.Debug("Stats", "data", agent.gatherStats(common.DataRequestOptions{CacheTimeMs: 60_000, IncludeDetails: true}))
} }
return agent, nil return agent, nil
@@ -148,10 +150,11 @@ func GetEnv(key string) (value string, exists bool) {
return os.LookupEnv(key) return os.LookupEnv(key)
} }
func (a *Agent) gatherStats(cacheTimeMs uint16) *system.CombinedData { func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedData {
a.Lock() a.Lock()
defer a.Unlock() defer a.Unlock()
cacheTimeMs := options.CacheTimeMs
data, isCached := a.cache.Get(cacheTimeMs) data, isCached := a.cache.Get(cacheTimeMs)
if isCached { if isCached {
slog.Debug("Cached data", "cacheTimeMs", cacheTimeMs) slog.Debug("Cached data", "cacheTimeMs", cacheTimeMs)
@@ -162,6 +165,12 @@ func (a *Agent) gatherStats(cacheTimeMs uint16) *system.CombinedData {
Stats: a.getSystemStats(cacheTimeMs), Stats: a.getSystemStats(cacheTimeMs),
Info: a.systemInfo, Info: a.systemInfo,
} }
// Include static info only when requested
if options.IncludeDetails {
data.Details = &a.systemDetails
}
// slog.Info("System data", "data", data, "cacheTimeMs", cacheTimeMs) // slog.Info("System data", "data", data, "cacheTimeMs", cacheTimeMs)
if a.dockerManager != nil { if a.dockerManager != nil {
@@ -225,7 +234,7 @@ func (a *Agent) getFingerprint() string {
// if no fingerprint is found, generate one // if no fingerprint is found, generate one
fingerprint, err := host.HostID() fingerprint, err := host.HostID()
if err != nil || fingerprint == "" { if err != nil || fingerprint == "" {
fingerprint = a.systemInfo.Hostname + a.systemInfo.CpuModel fingerprint = a.systemDetails.Hostname + a.systemDetails.CpuModel
} }
// hash fingerprint // hash fingerprint

View File

@@ -22,7 +22,7 @@ func createTestCacheData() *system.CombinedData {
DiskTotal: 100000, DiskTotal: 100000,
}, },
Info: system.Info{ Info: system.Info{
Hostname: "test-host", AgentVersion: "0.12.0",
}, },
Containers: []*container.Stats{ Containers: []*container.Stats{
{ {
@@ -128,7 +128,7 @@ func TestCacheMultipleIntervals(t *testing.T) {
Mem: 16384, Mem: 16384,
}, },
Info: system.Info{ Info: system.Info{
Hostname: "test-host-2", AgentVersion: "0.12.0",
}, },
Containers: []*container.Stats{}, Containers: []*container.Stats{},
} }
@@ -171,7 +171,7 @@ func TestCacheOverwrite(t *testing.T) {
Mem: 32768, Mem: 32768,
}, },
Info: system.Info{ Info: system.Info{
Hostname: "updated-host", AgentVersion: "0.12.0",
}, },
Containers: []*container.Stats{}, Containers: []*container.Stats{},
} }

View File

@@ -201,7 +201,7 @@ func (client *WebSocketClient) handleAuthChallenge(msg *common.HubRequest[cbor.R
if authRequest.NeedSysInfo { if authRequest.NeedSysInfo {
response.Name, _ = GetEnv("SYSTEM_NAME") response.Name, _ = GetEnv("SYSTEM_NAME")
response.Hostname = client.agent.systemInfo.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

@@ -60,6 +60,7 @@ type dockerManager struct {
decoder *json.Decoder // Reusable JSON decoder that reads from buf decoder *json.Decoder // Reusable JSON decoder that reads from buf
apiStats *container.ApiStats // Reusable API stats object apiStats *container.ApiStats // Reusable API stats object
excludeContainers []string // Patterns to exclude containers by name excludeContainers []string // Patterns to exclude containers by name
usingPodman bool // Whether the Docker Engine API is running on Podman
// Cache-time-aware tracking for CPU stats (similar to cpu.go) // Cache-time-aware tracking for CPU stats (similar to cpu.go)
// Maps cache time intervals to container-specific CPU usage tracking // Maps cache time intervals to container-specific CPU usage tracking
@@ -478,7 +479,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(a *Agent) *dockerManager { func newDockerManager() *dockerManager {
dockerHost, exists := 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
@@ -564,7 +565,7 @@ func newDockerManager(a *Agent) *dockerManager {
// If using podman, return client // If using podman, return client
if strings.Contains(dockerHost, "podman") { if strings.Contains(dockerHost, "podman") {
a.systemInfo.Podman = true manager.usingPodman = true
manager.goodDockerVersion = true manager.goodDockerVersion = true
return manager return manager
} }
@@ -746,3 +747,23 @@ func decodeDockerLogStream(reader io.Reader, builder *strings.Builder) error {
totalBytesRead += int(n) totalBytesRead += int(n)
} }
} }
// GetHostInfo fetches the system info from Docker
func (dm *dockerManager) GetHostInfo() (info container.HostInfo, err error) {
resp, err := dm.client.Get("http://localhost/info")
if err != nil {
return info, err
}
defer resp.Body.Close()
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
slog.Error("Failed to decode Docker version response", "error", err)
return info, err
}
return info, nil
}
func (dm *dockerManager) IsPodman() bool {
return dm.usingPodman
}

View File

@@ -802,6 +802,24 @@ func TestNetworkRateCalculationFormula(t *testing.T) {
} }
} }
func TestGetHostInfo(t *testing.T) {
data, err := os.ReadFile("test-data/system_info.json")
require.NoError(t, err)
var info container.HostInfo
err = json.Unmarshal(data, &info)
require.NoError(t, err)
assert.Equal(t, "6.8.0-31-generic", info.KernelVersion)
assert.Equal(t, "Ubuntu 24.04 LTS", info.OperatingSystem)
// assert.Equal(t, "24.04", info.OSVersion)
// assert.Equal(t, "linux", info.OSType)
// assert.Equal(t, "x86_64", info.Architecture)
assert.EqualValues(t, 4, info.NCPU)
assert.EqualValues(t, 2095882240, info.MemTotal)
// assert.Equal(t, "27.0.1", info.ServerVersion)
}
func TestDeltaTrackerCacheTimeIsolation(t *testing.T) { func TestDeltaTrackerCacheTimeIsolation(t *testing.T) {
// Test that different cache times have separate DeltaTracker instances // Test that different cache times have separate DeltaTracker instances
dm := &dockerManager{ dm := &dockerManager{
@@ -1053,53 +1071,6 @@ func TestDecodeDockerLogStreamMemoryProtection(t *testing.T) {
}) })
} }
func TestAllocateBuffer(t *testing.T) {
tests := []struct {
name string
currentCap int
needed int
expectedCap int
shouldRealloc bool
}{
{
name: "buffer has enough capacity",
currentCap: 1024,
needed: 512,
expectedCap: 1024,
shouldRealloc: false,
},
{
name: "buffer needs reallocation",
currentCap: 512,
needed: 1024,
expectedCap: 1024,
shouldRealloc: true,
},
{
name: "buffer needs exact size",
currentCap: 1024,
needed: 1024,
expectedCap: 1024,
shouldRealloc: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
current := make([]byte, 0, tt.currentCap)
result := allocateBuffer(current, tt.needed)
assert.Equal(t, tt.needed, len(result))
assert.GreaterOrEqual(t, cap(result), tt.expectedCap)
if tt.shouldRealloc {
// If reallocation was needed, capacity should be at least the needed size
assert.GreaterOrEqual(t, cap(result), tt.needed)
}
})
}
}
func TestShouldExcludeContainer(t *testing.T) { func TestShouldExcludeContainer(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -1259,4 +1230,3 @@ func TestAnsiEscapePattern(t *testing.T) {
}) })
} }
} }

View File

@@ -94,7 +94,7 @@ func (h *GetDataHandler) Handle(hctx *HandlerContext) error {
var options common.DataRequestOptions var options common.DataRequestOptions
_ = cbor.Unmarshal(hctx.Request.Data, &options) _ = cbor.Unmarshal(hctx.Request.Data, &options)
sysStats := hctx.Agent.gatherStats(options.CacheTimeMs) sysStats := hctx.Agent.gatherStats(options)
return hctx.SendResponse(sysStats, hctx.RequestID) return hctx.SendResponse(sysStats, hctx.RequestID)
} }

View File

@@ -202,7 +202,7 @@ func (a *Agent) handleSSHRequest(w io.Writer, req *common.HubRequest[cbor.RawMes
// handleLegacyStats serves the legacy one-shot stats payload for older hubs // handleLegacyStats serves the legacy one-shot stats payload for older hubs
func (a *Agent) handleLegacyStats(w io.Writer, hubVersion semver.Version) error { func (a *Agent) handleLegacyStats(w io.Writer, hubVersion semver.Version) error {
stats := a.gatherStats(60_000) stats := a.gatherStats(common.DataRequestOptions{CacheTimeMs: 60_000})
return a.writeToSession(w, stats, hubVersion) return a.writeToSession(w, stats, hubVersion)
} }

View File

@@ -513,7 +513,7 @@ func TestWriteToSessionEncoding(t *testing.T) {
err = json.Unmarshal([]byte(encodedData), &decodedJson) err = json.Unmarshal([]byte(encodedData), &decodedJson)
assert.Error(t, err, "Should not be valid JSON data") assert.Error(t, err, "Should not be valid JSON data")
assert.Equal(t, testData.Info.Hostname, decodedCbor.Info.Hostname) assert.Equal(t, testData.Details.Hostname, decodedCbor.Details.Hostname)
assert.Equal(t, testData.Stats.Cpu, decodedCbor.Stats.Cpu) assert.Equal(t, testData.Stats.Cpu, decodedCbor.Stats.Cpu)
} else { } else {
// Should be JSON - try to decode as JSON // Should be JSON - try to decode as JSON
@@ -526,7 +526,7 @@ func TestWriteToSessionEncoding(t *testing.T) {
assert.Error(t, err, "Should not be valid CBOR data") assert.Error(t, err, "Should not be valid CBOR data")
// Verify the decoded JSON data matches our test data // Verify the decoded JSON data matches our test data
assert.Equal(t, testData.Info.Hostname, decodedJson.Info.Hostname) assert.Equal(t, testData.Details.Hostname, decodedJson.Details.Hostname)
assert.Equal(t, testData.Stats.Cpu, decodedJson.Stats.Cpu) assert.Equal(t, testData.Stats.Cpu, decodedJson.Stats.Cpu)
// Verify it looks like JSON (starts with '{' and contains readable field names) // Verify it looks like JSON (starts with '{' and contains readable field names)
@@ -551,12 +551,8 @@ func createTestCombinedData() *system.CombinedData {
DiskPct: 50.0, DiskPct: 50.0,
}, },
Info: system.Info{ Info: system.Info{
Hostname: "test-host",
Cores: 8,
CpuModel: "Test CPU Model",
Uptime: 3600, Uptime: 3600,
AgentVersion: "0.12.0", AgentVersion: "0.12.0",
Os: system.Linux,
}, },
Containers: []*container.Stats{ Containers: []*container.Stats{
{ {

View File

@@ -10,6 +10,7 @@ import (
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
"path/filepath"
"runtime" "runtime"
"strconv" "strconv"
"strings" "strings"
@@ -430,7 +431,7 @@ func (sm *SmartManager) CollectSmart(deviceInfo *DeviceInfo) error {
// Check if we have any existing data for this device // Check if we have any existing data for this device
hasExistingData := sm.hasDataForDevice(deviceInfo.Name) hasExistingData := sm.hasDataForDevice(deviceInfo.Name)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel() defer cancel()
// Try with -n standby first if we have existing data // Try with -n standby first if we have existing data
@@ -445,7 +446,7 @@ func (sm *SmartManager) CollectSmart(deviceInfo *DeviceInfo) error {
return nil return nil
} }
// No cached data, need to collect initial data by bypassing standby // No cached data, need to collect initial data by bypassing standby
ctx2, cancel2 := context.WithTimeout(context.Background(), 2*time.Second) ctx2, cancel2 := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel2() defer cancel2()
args = sm.smartctlArgs(deviceInfo, false) args = sm.smartctlArgs(deviceInfo, false)
cmd = exec.CommandContext(ctx2, sm.binPath, args...) cmd = exec.CommandContext(ctx2, sm.binPath, args...)
@@ -454,6 +455,34 @@ func (sm *SmartManager) CollectSmart(deviceInfo *DeviceInfo) error {
hasValidData := sm.parseSmartOutput(deviceInfo, output) hasValidData := sm.parseSmartOutput(deviceInfo, output)
// If NVMe controller path failed, try namespace path as fallback.
// NVMe controllers (/dev/nvme0) don't always support SMART queries. See github.com/henrygd/beszel/issues/1504
if !hasValidData && err != nil && isNvmeControllerPath(deviceInfo.Name) {
controllerPath := deviceInfo.Name
namespacePath := controllerPath + "n1"
if !sm.isExcludedDevice(namespacePath) {
deviceInfo.Name = namespacePath
ctx3, cancel3 := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel3()
args = sm.smartctlArgs(deviceInfo, false)
cmd = exec.CommandContext(ctx3, sm.binPath, args...)
output, err = cmd.CombinedOutput()
hasValidData = sm.parseSmartOutput(deviceInfo, output)
// Auto-exclude the controller path so future scans don't re-add it
if hasValidData {
sm.Lock()
if sm.excludedDevices == nil {
sm.excludedDevices = make(map[string]struct{})
}
sm.excludedDevices[controllerPath] = struct{}{}
sm.Unlock()
slog.Debug("auto-excluded NVMe controller path", "path", controllerPath)
}
}
}
if !hasValidData { if !hasValidData {
if err != nil { if err != nil {
slog.Info("smartctl failed", "device", deviceInfo.Name, "err", err) slog.Info("smartctl failed", "device", deviceInfo.Name, "err", err)
@@ -957,6 +986,27 @@ func (sm *SmartManager) detectSmartctl() (string, error) {
return "", errors.New("smartctl not found") return "", errors.New("smartctl not found")
} }
// isNvmeControllerPath checks if the path matches an NVMe controller pattern
// like /dev/nvme0, /dev/nvme1, etc. (without namespace suffix like n1)
func isNvmeControllerPath(path string) bool {
base := filepath.Base(path)
if !strings.HasPrefix(base, "nvme") {
return false
}
suffix := strings.TrimPrefix(base, "nvme")
if suffix == "" {
return false
}
// Controller paths are just "nvme" + digits (e.g., nvme0, nvme1)
// Namespace paths have "n" after the controller number (e.g., nvme0n1)
for _, c := range suffix {
if c < '0' || c > '9' {
return false
}
}
return true
}
// NewSmartManager creates and initializes a new SmartManager // NewSmartManager creates and initializes a new SmartManager
func NewSmartManager() (*SmartManager, error) { func NewSmartManager() (*SmartManager, error) {
sm := &SmartManager{ sm := &SmartManager{

View File

@@ -780,3 +780,36 @@ func TestFilterExcludedDevices(t *testing.T) {
}) })
} }
} }
func TestIsNvmeControllerPath(t *testing.T) {
tests := []struct {
path string
expected bool
}{
// Controller paths (should return true)
{"/dev/nvme0", true},
{"/dev/nvme1", true},
{"/dev/nvme10", true},
{"nvme0", true},
// Namespace paths (should return false)
{"/dev/nvme0n1", false},
{"/dev/nvme1n1", false},
{"/dev/nvme0n1p1", false},
{"nvme0n1", false},
// Non-NVMe paths (should return false)
{"/dev/sda", false},
{"/dev/sda1", false},
{"/dev/hda", false},
{"", false},
{"/dev/nvme", false},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
result := isNvmeControllerPath(tt.path)
assert.Equal(t, tt.expected, result, "path: %s", tt.path)
})
}
}

View File

@@ -2,15 +2,18 @@ package agent
import ( import (
"bufio" "bufio"
"errors"
"fmt" "fmt"
"log/slog" "log/slog"
"os" "os"
"runtime"
"strconv" "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/internal/entities/container"
"github.com/henrygd/beszel/internal/entities/system" "github.com/henrygd/beszel/internal/entities/system"
"github.com/shirou/gopsutil/v4/cpu" "github.com/shirou/gopsutil/v4/cpu"
@@ -27,41 +30,79 @@ type prevDisk struct {
} }
// Sets initial / non-changing values about the host system // Sets initial / non-changing values about the host system
func (a *Agent) initializeSystemInfo() { func (a *Agent) refreshStaticInfo() {
a.systemInfo.AgentVersion = beszel.Version a.systemInfo.AgentVersion = beszel.Version
a.systemInfo.Hostname, _ = os.Hostname()
// get host info from Docker if available
var hostInfo container.HostInfo
if a.dockerManager != nil {
a.systemDetails.Podman = a.dockerManager.IsPodman()
hostInfo, _ = a.dockerManager.GetHostInfo()
}
a.systemDetails.Hostname, _ = os.Hostname()
if arch, err := host.KernelArch(); err == nil {
a.systemDetails.Arch = arch
} else {
a.systemDetails.Arch = runtime.GOARCH
}
platform, _, version, _ := host.PlatformInformation() platform, _, version, _ := host.PlatformInformation()
if platform == "darwin" { if platform == "darwin" {
a.systemInfo.KernelVersion = version a.systemDetails.Os = system.Darwin
a.systemInfo.Os = system.Darwin a.systemDetails.OsName = fmt.Sprintf("macOS %s", version)
} else if strings.Contains(platform, "indows") { } else if strings.Contains(platform, "indows") {
a.systemInfo.KernelVersion = fmt.Sprintf("%s %s", strings.Replace(platform, "Microsoft ", "", 1), version) a.systemDetails.Os = system.Windows
a.systemInfo.Os = system.Windows a.systemDetails.OsName = strings.Replace(platform, "Microsoft ", "", 1)
a.systemDetails.Kernel = version
} else if platform == "freebsd" { } else if platform == "freebsd" {
a.systemInfo.Os = system.Freebsd a.systemDetails.Os = system.Freebsd
a.systemInfo.KernelVersion = version a.systemDetails.Kernel, _ = host.KernelVersion()
if prettyName, err := getOsPrettyName(); err == nil {
a.systemDetails.OsName = prettyName
} else {
a.systemDetails.OsName = "FreeBSD"
}
} else { } else {
a.systemInfo.Os = system.Linux a.systemDetails.Os = system.Linux
} a.systemDetails.OsName = hostInfo.OperatingSystem
if a.systemDetails.OsName == "" {
if a.systemInfo.KernelVersion == "" { if prettyName, err := getOsPrettyName(); err == nil {
a.systemInfo.KernelVersion, _ = host.KernelVersion() a.systemDetails.OsName = prettyName
} else {
a.systemDetails.OsName = platform
}
}
a.systemDetails.Kernel = hostInfo.KernelVersion
if a.systemDetails.Kernel == "" {
a.systemDetails.Kernel, _ = host.KernelVersion()
}
} }
// cpu model // cpu model
if info, err := cpu.Info(); err == nil && len(info) > 0 { if info, err := cpu.Info(); err == nil && len(info) > 0 {
a.systemInfo.CpuModel = info[0].ModelName a.systemDetails.CpuModel = info[0].ModelName
} }
// cores / threads // cores / threads
a.systemInfo.Cores, _ = cpu.Counts(false) cores, _ := cpu.Counts(false)
if threads, err := cpu.Counts(true); err == nil { threads := hostInfo.NCPU
if threads > 0 && threads < a.systemInfo.Cores { if threads == 0 {
// in lxc logical cores reflects container limits, so use that as cores if lower threads, _ = cpu.Counts(true)
a.systemInfo.Cores = threads }
} else { // in lxc, logical cores reflects container limits, so use that as cores if lower
a.systemInfo.Threads = threads if threads > 0 && threads < cores {
cores = threads
}
a.systemDetails.Cores = cores
a.systemDetails.Threads = threads
// total memory
a.systemDetails.MemoryTotal = hostInfo.MemTotal
if a.systemDetails.MemoryTotal == 0 {
if v, err := mem.VirtualMemory(); err == nil {
a.systemDetails.MemoryTotal = v.Total
} }
} }
@@ -195,20 +236,16 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
} }
} }
// update base system info // update system info
a.systemInfo.ConnectionType = a.connectionManager.ConnectionType a.systemInfo.ConnectionType = a.connectionManager.ConnectionType
a.systemInfo.Cpu = systemStats.Cpu a.systemInfo.Cpu = systemStats.Cpu
a.systemInfo.LoadAvg = systemStats.LoadAvg a.systemInfo.LoadAvg = systemStats.LoadAvg
// TODO: remove these in future release in favor of load avg array
a.systemInfo.LoadAvg1 = systemStats.LoadAvg[0]
a.systemInfo.LoadAvg5 = systemStats.LoadAvg[1]
a.systemInfo.LoadAvg15 = systemStats.LoadAvg[2]
a.systemInfo.MemPct = systemStats.MemPct a.systemInfo.MemPct = systemStats.MemPct
a.systemInfo.DiskPct = systemStats.DiskPct a.systemInfo.DiskPct = systemStats.DiskPct
a.systemInfo.Battery = systemStats.Battery
a.systemInfo.Uptime, _ = host.Uptime() a.systemInfo.Uptime, _ = host.Uptime()
// TODO: in future release, remove MB bandwidth values in favor of bytes
a.systemInfo.Bandwidth = twoDecimals(systemStats.NetworkSent + systemStats.NetworkRecv)
a.systemInfo.BandwidthBytes = systemStats.Bandwidth[0] + systemStats.Bandwidth[1] a.systemInfo.BandwidthBytes = systemStats.Bandwidth[0] + systemStats.Bandwidth[1]
a.systemInfo.Threads = a.systemDetails.Threads
slog.Debug("sysinfo", "data", a.systemInfo) slog.Debug("sysinfo", "data", a.systemInfo)
return systemStats return systemStats
@@ -239,3 +276,24 @@ func getARCSize() (uint64, error) {
return 0, fmt.Errorf("failed to parse size field") return 0, fmt.Errorf("failed to parse size field")
} }
// getOsPrettyName attempts to get the pretty OS name from /etc/os-release on Linux systems
func getOsPrettyName() (string, error) {
file, err := os.Open("/etc/os-release")
if err != nil {
return "", err
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
if after, ok := strings.CutPrefix(line, "PRETTY_NAME="); ok {
value := after
value = strings.Trim(value, `"`)
return value, nil
}
}
return "", errors.New("pretty name not found")
}

View File

@@ -0,0 +1,17 @@
{
"ID": "7TRN:IPZB:QYBB:VPBQ:UMPP:KARE:6ZNR:XE6T:7EWV:PKF4:ZOJD:TPYS",
"Containers": 14,
"ContainersRunning": 3,
"ContainersPaused": 1,
"ContainersStopped": 10,
"Images": 508,
"Driver": "overlay2",
"KernelVersion": "6.8.0-31-generic",
"OperatingSystem": "Ubuntu 24.04 LTS",
"OSVersion": "24.04",
"OSType": "linux",
"Architecture": "x86_64",
"NCPU": 4,
"MemTotal": 2095882240,
"ServerVersion": "27.0.1"
}

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.17.0" Version = "0.18.0-beta.1"
// AppName is the name of the application. // AppName is the name of the application.
AppName = "beszel" AppName = "beszel"
) )

View File

@@ -49,6 +49,7 @@ type SystemAlertStats struct {
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"`
} }
type SystemAlertGPUData struct { type SystemAlertGPUData struct {

View File

@@ -0,0 +1,387 @@
//go:build testing
// +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"
)
// TestBatteryAlertLogic tests that battery alerts trigger when value drops BELOW threshold
// (opposite of other alerts like CPU, Memory, etc. which trigger when exceeding threshold)
func TestBatteryAlertLogic(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup()
// Create a system
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
require.NoError(t, err)
systemRecord := systems[0]
// Create a battery alert with threshold of 20% and min of 1 minute (immediate trigger)
batteryAlert, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{
"name": "Battery",
"system": systemRecord.Id,
"user": user.Id,
"value": 20, // threshold: 20%
"min": 1, // 1 minute (immediate trigger for testing)
})
require.NoError(t, err)
// Verify alert is not triggered initially
assert.False(t, batteryAlert.GetBool("triggered"), "Alert should not be triggered initially")
// Create system stats with battery at 50% (above threshold - should NOT trigger)
statsHigh := system.Stats{
Cpu: 10,
MemPct: 30,
DiskPct: 40,
Battery: [2]uint8{50, 1}, // 50% battery, discharging
}
statsHighJSON, _ := json.Marshal(statsHigh)
_, err = beszelTests.CreateRecord(hub, "system_stats", map[string]any{
"system": systemRecord.Id,
"type": "1m",
"stats": string(statsHighJSON),
})
require.NoError(t, err)
// Create CombinedData for the alert handler
combinedDataHigh := &system.CombinedData{
Stats: statsHigh,
Info: system.Info{
AgentVersion: "0.12.0",
Cpu: 10,
MemPct: 30,
DiskPct: 40,
},
}
// Simulate system update time
systemRecord.Set("updated", time.Now().UTC())
err = hub.SaveNoValidate(systemRecord)
require.NoError(t, err)
// Handle system alerts with high battery
am := hub.GetAlertManager()
err = am.HandleSystemAlerts(systemRecord, combinedDataHigh)
require.NoError(t, err)
// Verify alert is still NOT triggered (battery 50% is above threshold 20%)
batteryAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": batteryAlert.Id})
require.NoError(t, err)
assert.False(t, batteryAlert.GetBool("triggered"), "Alert should NOT be triggered when battery (50%%) is above threshold (20%%)")
// Now create stats with battery at 15% (below threshold - should trigger)
statsLow := system.Stats{
Cpu: 10,
MemPct: 30,
DiskPct: 40,
Battery: [2]uint8{15, 1}, // 15% battery, discharging
}
statsLowJSON, _ := json.Marshal(statsLow)
_, err = beszelTests.CreateRecord(hub, "system_stats", map[string]any{
"system": systemRecord.Id,
"type": "1m",
"stats": string(statsLowJSON),
})
require.NoError(t, err)
combinedDataLow := &system.CombinedData{
Stats: statsLow,
Info: system.Info{
AgentVersion: "0.12.0",
Cpu: 10,
MemPct: 30,
DiskPct: 40,
},
}
// Update system timestamp
systemRecord.Set("updated", time.Now().UTC())
err = hub.SaveNoValidate(systemRecord)
require.NoError(t, err)
// Handle system alerts with low battery
err = am.HandleSystemAlerts(systemRecord, combinedDataLow)
require.NoError(t, err)
// Wait for the alert to be processed
time.Sleep(20 * time.Millisecond)
// Verify alert IS triggered (battery 15% is below threshold 20%)
batteryAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": batteryAlert.Id})
require.NoError(t, err)
assert.True(t, batteryAlert.GetBool("triggered"), "Alert SHOULD be triggered when battery (15%%) drops below threshold (20%%)")
// Now test resolution: battery goes back above threshold
statsRecovered := system.Stats{
Cpu: 10,
MemPct: 30,
DiskPct: 40,
Battery: [2]uint8{25, 1}, // 25% battery, discharging
}
statsRecoveredJSON, _ := json.Marshal(statsRecovered)
_, err = beszelTests.CreateRecord(hub, "system_stats", map[string]any{
"system": systemRecord.Id,
"type": "1m",
"stats": string(statsRecoveredJSON),
})
require.NoError(t, err)
combinedDataRecovered := &system.CombinedData{
Stats: statsRecovered,
Info: system.Info{
AgentVersion: "0.12.0",
Cpu: 10,
MemPct: 30,
DiskPct: 40,
},
}
// Update system timestamp
systemRecord.Set("updated", time.Now().UTC())
err = hub.SaveNoValidate(systemRecord)
require.NoError(t, err)
// Handle system alerts with recovered battery
err = am.HandleSystemAlerts(systemRecord, combinedDataRecovered)
require.NoError(t, err)
// Wait for the alert to be processed
time.Sleep(20 * time.Millisecond)
// Verify alert is now resolved (battery 25% is above threshold 20%)
batteryAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": batteryAlert.Id})
require.NoError(t, err)
assert.False(t, batteryAlert.GetBool("triggered"), "Alert should be resolved when battery (25%%) goes above threshold (20%%)")
}
// TestBatteryAlertNoBattery verifies that systems without battery data don't trigger alerts
func TestBatteryAlertNoBattery(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup()
// Create a system
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
require.NoError(t, err)
systemRecord := systems[0]
// Create a battery alert
batteryAlert, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{
"name": "Battery",
"system": systemRecord.Id,
"user": user.Id,
"value": 20,
"min": 1,
})
require.NoError(t, err)
// Create stats with NO battery data (Battery[0] = 0)
statsNoBattery := system.Stats{
Cpu: 10,
MemPct: 30,
DiskPct: 40,
Battery: [2]uint8{0, 0}, // No battery
}
combinedData := &system.CombinedData{
Stats: statsNoBattery,
Info: system.Info{
AgentVersion: "0.12.0",
Cpu: 10,
MemPct: 30,
DiskPct: 40,
},
}
// Simulate system update time
systemRecord.Set("updated", time.Now().UTC())
err = hub.SaveNoValidate(systemRecord)
require.NoError(t, err)
// Handle system alerts
am := hub.GetAlertManager()
err = am.HandleSystemAlerts(systemRecord, combinedData)
require.NoError(t, err)
// Wait a moment for processing
time.Sleep(20 * time.Millisecond)
// Verify alert is NOT triggered (no battery data should skip the alert)
batteryAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": batteryAlert.Id})
require.NoError(t, err)
assert.False(t, batteryAlert.GetBool("triggered"), "Alert should NOT be triggered when system has no battery")
}
// TestBatteryAlertAveragedSamples tests battery alerts with min > 1 (averaging multiple samples)
// This ensures the inverted threshold logic works correctly across averaged time windows
func TestBatteryAlertAveragedSamples(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup()
// Create a system
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
require.NoError(t, err)
systemRecord := systems[0]
// Create a battery alert with threshold of 25% and min of 2 minutes (requires averaging)
batteryAlert, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{
"name": "Battery",
"system": systemRecord.Id,
"user": user.Id,
"value": 25, // threshold: 25%
"min": 2, // 2 minutes - requires averaging
})
require.NoError(t, err)
// Verify alert is not triggered initially
assert.False(t, batteryAlert.GetBool("triggered"), "Alert should not be triggered initially")
am := hub.GetAlertManager()
now := time.Now().UTC()
// Create system_stats records with low battery (below threshold)
// The alert has min=2 minutes, so alert.time = now - 2 minutes
// For the alert to be valid, alert.time must be AFTER the oldest record's created time
// So we need records older than (now - 2 min), plus records within the window
// Records at: now-3min (oldest, before window), now-90s, now-60s, now-30s
recordTimes := []time.Duration{
-180 * time.Second, // 3 min ago - this makes the oldest record before alert.time
-90 * time.Second,
-60 * time.Second,
-30 * time.Second,
}
for _, offset := range recordTimes {
statsLow := system.Stats{
Cpu: 10,
MemPct: 30,
DiskPct: 40,
Battery: [2]uint8{15, 1}, // 15% battery (below 25% threshold)
}
statsLowJSON, _ := json.Marshal(statsLow)
recordTime := now.Add(offset)
record, err := beszelTests.CreateRecord(hub, "system_stats", map[string]any{
"system": systemRecord.Id,
"type": "1m",
"stats": string(statsLowJSON),
})
require.NoError(t, err)
// Update created time to simulate historical records - use SetRaw with formatted string
record.SetRaw("created", recordTime.Format(types.DefaultDateLayout))
err = hub.SaveNoValidate(record)
require.NoError(t, err)
}
// Create combined data with low battery
combinedDataLow := &system.CombinedData{
Stats: system.Stats{
Cpu: 10,
MemPct: 30,
DiskPct: 40,
Battery: [2]uint8{15, 1},
},
Info: system.Info{
AgentVersion: "0.12.0",
Cpu: 10,
MemPct: 30,
DiskPct: 40,
},
}
// Update system timestamp
systemRecord.Set("updated", now)
err = hub.SaveNoValidate(systemRecord)
require.NoError(t, err)
// Handle system alerts - should trigger because average battery is below threshold
err = am.HandleSystemAlerts(systemRecord, combinedDataLow)
require.NoError(t, err)
// Wait for alert processing
time.Sleep(20 * time.Millisecond)
// Verify alert IS triggered (average battery 15% is below threshold 25%)
batteryAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": batteryAlert.Id})
require.NoError(t, err)
assert.True(t, batteryAlert.GetBool("triggered"),
"Alert SHOULD be triggered when average battery (15%%) is below threshold (25%%) over min period")
// Now add records with high battery to test resolution
// Use a new time window 2 minutes later
newNow := now.Add(2 * time.Minute)
// Records need to span before the alert time window (newNow - 2 min)
recordTimesHigh := []time.Duration{
-180 * time.Second, // 3 min before newNow - makes oldest record before alert.time
-90 * time.Second,
-60 * time.Second,
-30 * time.Second,
}
for _, offset := range recordTimesHigh {
statsHigh := system.Stats{
Cpu: 10,
MemPct: 30,
DiskPct: 40,
Battery: [2]uint8{50, 1}, // 50% battery (above 25% threshold)
}
statsHighJSON, _ := json.Marshal(statsHigh)
recordTime := newNow.Add(offset)
record, err := beszelTests.CreateRecord(hub, "system_stats", map[string]any{
"system": systemRecord.Id,
"type": "1m",
"stats": string(statsHighJSON),
})
require.NoError(t, err)
record.SetRaw("created", recordTime.Format(types.DefaultDateLayout))
err = hub.SaveNoValidate(record)
require.NoError(t, err)
}
// Create combined data with high battery
combinedDataHigh := &system.CombinedData{
Stats: system.Stats{
Cpu: 10,
MemPct: 30,
DiskPct: 40,
Battery: [2]uint8{50, 1},
},
Info: system.Info{
AgentVersion: "0.12.0",
Cpu: 10,
MemPct: 30,
DiskPct: 40,
},
}
// Update system timestamp to the new time window
systemRecord.Set("updated", newNow)
err = hub.SaveNoValidate(systemRecord)
require.NoError(t, err)
// Handle system alerts - should resolve because average battery is now above threshold
err = am.HandleSystemAlerts(systemRecord, combinedDataHigh)
require.NoError(t, err)
// Wait for alert processing
time.Sleep(20 * time.Millisecond)
// Verify alert is resolved (average battery 50% is above threshold 25%)
batteryAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": batteryAlert.Id})
require.NoError(t, err)
assert.False(t, batteryAlert.GetBool("triggered"),
"Alert should be resolved when average battery (50%%) is above threshold (25%%) over min period")
}

View File

@@ -66,17 +66,30 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
unit = "" unit = ""
case "GPU": case "GPU":
val = data.Info.GpuPct val = data.Info.GpuPct
case "Battery":
if data.Stats.Battery[0] == 0 {
continue
}
val = float64(data.Stats.Battery[0])
} }
triggered := alertRecord.GetBool("triggered") triggered := alertRecord.GetBool("triggered")
threshold := alertRecord.GetFloat("value") threshold := alertRecord.GetFloat("value")
// Battery alert has inverted logic: trigger when value is BELOW threshold
lowAlert := isLowAlert(name)
// CONTINUE // CONTINUE
// IF alert is not triggered and curValue is less than threshold // For normal alerts: IF not triggered and curValue <= threshold, OR triggered and curValue > threshold
// OR alert is triggered and curValue is greater than threshold // For low alerts (Battery): IF not triggered and curValue >= threshold, OR triggered and curValue < threshold
if (!triggered && val <= threshold) || (triggered && val > threshold) { if lowAlert {
// log.Printf("Skipping alert %s: val %f | threshold %f | triggered %v\n", name, val, threshold, triggered) if (!triggered && val >= threshold) || (triggered && val < threshold) {
continue continue
}
} else {
if (!triggered && val <= threshold) || (triggered && val > threshold) {
continue
}
} }
min := max(1, cast.ToUint8(alertRecord.Get("min"))) min := max(1, cast.ToUint8(alertRecord.Get("min")))
@@ -94,7 +107,11 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
// send alert immediately if min is 1 - no need to sum up values. // send alert immediately if min is 1 - no need to sum up values.
if min == 1 { if min == 1 {
alert.triggered = val > threshold if lowAlert {
alert.triggered = val < threshold
} else {
alert.triggered = val > threshold
}
go am.sendSystemAlert(alert) go am.sendSystemAlert(alert)
continue continue
} }
@@ -219,6 +236,8 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
} }
} }
alert.val += maxUsage alert.val += maxUsage
case "Battery":
alert.val += float64(stats.Battery[0])
default: default:
continue continue
} }
@@ -256,12 +275,24 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
// log.Printf("%s: val %f | count %d | min-count %f | threshold %f\n", alert.name, alert.val, alert.count, minCount, alert.threshold) // log.Printf("%s: val %f | count %d | min-count %f | threshold %f\n", alert.name, alert.val, alert.count, minCount, alert.threshold)
// pass through alert if count is greater than or equal to minCount // pass through alert if count is greater than or equal to minCount
if float32(alert.count) >= minCount { if float32(alert.count) >= minCount {
if !alert.triggered && alert.val > alert.threshold { // Battery alert has inverted logic: trigger when value is BELOW threshold
alert.triggered = true lowAlert := isLowAlert(alert.name)
go am.sendSystemAlert(alert) if lowAlert {
} else if alert.triggered && alert.val <= alert.threshold { if !alert.triggered && alert.val < alert.threshold {
alert.triggered = false alert.triggered = true
go am.sendSystemAlert(alert) go am.sendSystemAlert(alert)
} else if alert.triggered && alert.val >= alert.threshold {
alert.triggered = false
go am.sendSystemAlert(alert)
}
} else {
if !alert.triggered && alert.val > alert.threshold {
alert.triggered = true
go am.sendSystemAlert(alert)
} else if alert.triggered && alert.val <= alert.threshold {
alert.triggered = false
go am.sendSystemAlert(alert)
}
} }
} }
} }
@@ -288,10 +319,19 @@ func (am *AlertManager) sendSystemAlert(alert SystemAlertData) {
} }
var subject string var subject string
lowAlert := isLowAlert(alert.name)
if alert.triggered { if alert.triggered {
subject = fmt.Sprintf("%s %s above threshold", systemName, titleAlertName) if lowAlert {
subject = fmt.Sprintf("%s %s below threshold", systemName, titleAlertName)
} else {
subject = fmt.Sprintf("%s %s above threshold", systemName, titleAlertName)
}
} else { } else {
subject = fmt.Sprintf("%s %s below threshold", systemName, titleAlertName) if lowAlert {
subject = fmt.Sprintf("%s %s above threshold", systemName, titleAlertName)
} else {
subject = fmt.Sprintf("%s %s below threshold", systemName, titleAlertName)
}
} }
minutesLabel := "minute" minutesLabel := "minute"
if alert.min > 1 { if alert.min > 1 {
@@ -316,3 +356,7 @@ func (am *AlertManager) sendSystemAlert(alert SystemAlertData) {
LinkText: "View " + systemName, LinkText: "View " + systemName,
}) })
} }
func isLowAlert(name string) bool {
return name == "Battery"
}

View File

@@ -17,9 +17,8 @@ import (
type cmdOptions struct { type cmdOptions struct {
key string // key is the public key(s) for SSH authentication. key string // key is the public key(s) for SSH authentication.
listen string // listen is the address or port to listen on. listen string // listen is the address or port to listen on.
// TODO: add hubURL and token hubURL string // hubURL is the URL of the Beszel hub.
// hubURL string // hubURL is the URL of the hub to use. token string // token is the token to use for authentication.
// token string // token is the token to use for authentication.
} }
// parse parses the command line flags and populates the config struct. // parse parses the command line flags and populates the config struct.
@@ -47,13 +46,13 @@ func (opts *cmdOptions) parse() bool {
// pflag.CommandLine.ParseErrorsWhitelist.UnknownFlags = true // pflag.CommandLine.ParseErrorsWhitelist.UnknownFlags = true
pflag.StringVarP(&opts.key, "key", "k", "", "Public key(s) for SSH authentication") pflag.StringVarP(&opts.key, "key", "k", "", "Public key(s) for SSH authentication")
pflag.StringVarP(&opts.listen, "listen", "l", "", "Address or port to listen on") pflag.StringVarP(&opts.listen, "listen", "l", "", "Address or port to listen on")
// pflag.StringVarP(&opts.hubURL, "hub-url", "u", "", "URL of the hub to use") pflag.StringVarP(&opts.hubURL, "url", "u", "", "URL of the Beszel hub")
// pflag.StringVarP(&opts.token, "token", "t", "", "Token to use for authentication") pflag.StringVarP(&opts.token, "token", "t", "", "Token to use for authentication")
chinaMirrors := pflag.BoolP("china-mirrors", "c", false, "Use mirror for update (gh.beszel.dev) instead of GitHub") chinaMirrors := pflag.BoolP("china-mirrors", "c", false, "Use mirror for update (gh.beszel.dev) instead of GitHub")
help := pflag.BoolP("help", "h", false, "Show this help message") help := pflag.BoolP("help", "h", false, "Show this help message")
// Convert old single-dash long flags to double-dash for backward compatibility // Convert old single-dash long flags to double-dash for backward compatibility
flagsToConvert := []string{"key", "listen"} flagsToConvert := []string{"key", "listen", "url", "token"}
for i, arg := range os.Args { for i, arg := range os.Args {
for _, flag := range flagsToConvert { for _, flag := range flagsToConvert {
singleDash := "-" + flag singleDash := "-" + flag
@@ -95,6 +94,13 @@ func (opts *cmdOptions) parse() bool {
return true return true
} }
// Set environment variables from CLI flags (if provided)
if opts.hubURL != "" {
os.Setenv("HUB_URL", opts.hubURL)
}
if opts.token != "" {
os.Setenv("TOKEN", opts.token)
}
return false return false
} }

View File

@@ -58,8 +58,8 @@ type FingerprintResponse struct {
} }
type DataRequestOptions struct { type DataRequestOptions struct {
CacheTimeMs uint16 `cbor:"0,keyasint"` CacheTimeMs uint16 `cbor:"0,keyasint"`
// ResourceType uint8 `cbor:"1,keyasint,omitempty,omitzero"` IncludeDetails bool `cbor:"1,keyasint"`
} }
type ContainerLogsRequest struct { type ContainerLogsRequest struct {

View File

@@ -17,7 +17,7 @@ RUN rm -rf /tmp/*
# -------------------------- # --------------------------
# Final image: default scratch-based agent # Final image: default scratch-based agent
# -------------------------- # --------------------------
FROM alpine:latest FROM alpine:3.22
COPY --from=builder /agent /agent COPY --from=builder /agent /agent
RUN apk add --no-cache smartmontools RUN apk add --no-cache smartmontools

View File

@@ -16,7 +16,7 @@ RUN CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-
# Final image # Final image
# Note: must cap_add: [CAP_PERFMON] and mount /dev/dri/ as volume # Note: must cap_add: [CAP_PERFMON] and mount /dev/dri/ as volume
# -------------------------- # --------------------------
FROM alpine:edge FROM alpine:3.22
COPY --from=builder /agent /agent COPY --from=builder /agent /agent

View File

@@ -34,6 +34,17 @@ type ApiStats struct {
MemoryStats MemoryStats `json:"memory_stats"` MemoryStats MemoryStats `json:"memory_stats"`
} }
// Docker system info from /info
type HostInfo struct {
OperatingSystem string `json:"OperatingSystem"`
KernelVersion string `json:"KernelVersion"`
NCPU int `json:"NCPU"`
MemTotal uint64 `json:"MemTotal"`
// OSVersion string `json:"OSVersion"`
// OSType string `json:"OSType"`
// Architecture string `json:"Architecture"`
}
func (s *ApiStats) CalculateCpuPercentLinux(prevCpuContainer uint64, prevCpuSystem uint64) float64 { func (s *ApiStats) CalculateCpuPercentLinux(prevCpuContainer uint64, prevCpuSystem uint64) float64 {
cpuDelta := s.CPUStats.CPUUsage.TotalUsage - prevCpuContainer cpuDelta := s.CPUStats.CPUUsage.TotalUsage - prevCpuContainer
systemDelta := s.CPUStats.SystemUsage - prevCpuSystem systemDelta := s.CPUStats.SystemUsage - prevCpuSystem

View File

@@ -123,31 +123,48 @@ const (
ConnectionTypeWebSocket ConnectionTypeWebSocket
) )
// Core system data that is needed in All Systems table
type Info struct { type Info struct {
Hostname string `json:"h" cbor:"0,keyasint"` Hostname string `json:"h,omitempty" cbor:"0,keyasint,omitempty"` // deprecated - moved to Details struct
KernelVersion string `json:"k,omitempty" cbor:"1,keyasint,omitempty"` KernelVersion string `json:"k,omitempty" cbor:"1,keyasint,omitempty"` // deprecated - moved to Details struct
Cores int `json:"c" cbor:"2,keyasint"` Cores int `json:"c,omitzero" cbor:"2,keyasint,omitzero"` // deprecated - moved to Details struct
CpuModel string `json:"m,omitempty" cbor:"4,keyasint,omitempty"` // deprecated - moved to Details struct
Podman bool `json:"p,omitempty" cbor:"11,keyasint,omitempty"` // deprecated - moved to Details struct
Os Os `json:"os,omitempty" cbor:"14,keyasint,omitempty"` // deprecated - moved to Details struct
// Threads is needed in Info struct to calculate load average thresholds
Threads int `json:"t,omitempty" cbor:"3,keyasint,omitempty"` Threads int `json:"t,omitempty" cbor:"3,keyasint,omitempty"`
CpuModel string `json:"m" cbor:"4,keyasint"`
Uptime uint64 `json:"u" cbor:"5,keyasint"` Uptime uint64 `json:"u" cbor:"5,keyasint"`
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" cbor:"9,keyasint"` 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"`
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" cbor:"14,keyasint"` LoadAvg1 float64 `json:"l1,omitempty" cbor:"15,keyasint,omitempty"` // deprecated - use `la` array instead
LoadAvg1 float64 `json:"l1,omitempty" cbor:"15,keyasint,omitempty"` LoadAvg5 float64 `json:"l5,omitempty" cbor:"16,keyasint,omitempty"` // deprecated - use `la` array instead
LoadAvg5 float64 `json:"l5,omitempty" cbor:"16,keyasint,omitempty"` LoadAvg15 float64 `json:"l15,omitempty" cbor:"17,keyasint,omitempty"` // deprecated - use `la` array instead
LoadAvg15 float64 `json:"l15,omitempty" cbor:"17,keyasint,omitempty"`
BandwidthBytes uint64 `json:"bb" cbor:"18,keyasint"` BandwidthBytes uint64 `json:"bb" cbor:"18,keyasint"`
// TODO: remove load fields in future release in favor of load avg array
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"`
Services []uint16 `json:"sv,omitempty" cbor:"22,keyasint,omitempty"` // [totalServices, numFailedServices] Services []uint16 `json:"sv,omitempty" cbor:"22,keyasint,omitempty"` // [totalServices, numFailedServices]
Battery [2]uint8 `json:"bat,omitzero" cbor:"23,keyasint,omitzero"` // [percent, charge state]
}
// Data that does not change during process lifetime and is not needed in All Systems table
type Details struct {
Hostname string `cbor:"0,keyasint"`
Kernel string `cbor:"1,keyasint,omitempty"`
Cores int `cbor:"2,keyasint"`
Threads int `cbor:"3,keyasint"`
CpuModel string `cbor:"4,keyasint"`
Os Os `cbor:"5,keyasint"`
OsName string `cbor:"6,keyasint"`
Arch string `cbor:"7,keyasint"`
Podman bool `cbor:"8,keyasint,omitempty"`
MemoryTotal uint64 `cbor:"9,keyasint"`
} }
// Final data structure to return to the hub // Final data structure to return to the hub
@@ -156,4 +173,5 @@ type CombinedData struct {
Info Info `json:"info" cbor:"1,keyasint"` Info Info `json:"info" cbor:"1,keyasint"`
Containers []*container.Stats `json:"container" cbor:"2,keyasint"` Containers []*container.Stats `json:"container" cbor:"2,keyasint"`
SystemdServices []*systemd.Service `json:"systemd,omitempty" cbor:"3,keyasint,omitempty"` SystemdServices []*systemd.Service `json:"systemd,omitempty" cbor:"3,keyasint,omitempty"`
Details *Details `cbor:"4,keyasint,omitempty"`
} }

View File

@@ -9,7 +9,7 @@ import (
"math/rand" "math/rand"
"net" "net"
"strings" "strings"
"sync" "sync/atomic"
"time" "time"
"github.com/henrygd/beszel/internal/common" "github.com/henrygd/beszel/internal/common"
@@ -29,19 +29,21 @@ import (
) )
type System struct { type System struct {
Id string `db:"id"` Id string `db:"id"`
Host string `db:"host"` Host string `db:"host"`
Port string `db:"port"` Port string `db:"port"`
Status string `db:"status"` Status string `db:"status"`
manager *SystemManager // Manager that this system belongs to manager *SystemManager // Manager that this system belongs to
client *ssh.Client // SSH client for fetching data client *ssh.Client // SSH client for fetching data
data *system.CombinedData // system data from agent data *system.CombinedData // system data from agent
ctx context.Context // Context for stopping the updater ctx context.Context // Context for stopping the updater
cancel context.CancelFunc // Stops and removes system from updater cancel context.CancelFunc // Stops and removes system from updater
WsConn *ws.WsConn // Handler for agent WebSocket connection WsConn *ws.WsConn // Handler for agent WebSocket connection
agentVersion semver.Version // Agent version agentVersion semver.Version // Agent version
updateTicker *time.Ticker // Ticker for updating the system updateTicker *time.Ticker // Ticker for updating the system
smartOnce sync.Once // Once for fetching and saving smart devices detailsFetched atomic.Bool // True if static system details have been fetched and saved
smartFetched atomic.Bool // True if SMART devices have been fetched and saved
smartFetching atomic.Bool // True if SMART devices are currently being fetched
} }
func (sm *SystemManager) NewSystem(systemId string) *System { func (sm *SystemManager) NewSystem(systemId string) *System {
@@ -114,7 +116,14 @@ func (sys *System) update() error {
sys.handlePaused() sys.handlePaused()
return nil return nil
} }
data, err := sys.fetchDataFromAgent(common.DataRequestOptions{CacheTimeMs: uint16(interval)}) options := common.DataRequestOptions{
CacheTimeMs: uint16(interval),
}
// fetch system details if not already fetched
if !sys.detailsFetched.Load() {
options.IncludeDetails = true
}
data, err := sys.fetchDataFromAgent(options)
if err == nil { if err == nil {
_, err = sys.createRecords(data) _, err = sys.createRecords(data)
} }
@@ -142,12 +151,11 @@ func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error
} }
hub := sys.manager.hub hub := sys.manager.hub
err = hub.RunInTransaction(func(txApp core.App) error { err = hub.RunInTransaction(func(txApp core.App) error {
// add system_stats and container_stats records // add system_stats record
systemStatsCollection, err := txApp.FindCachedCollectionByNameOrId("system_stats") systemStatsCollection, err := txApp.FindCachedCollectionByNameOrId("system_stats")
if err != nil { if err != nil {
return err return err
} }
systemStatsRecord := core.NewRecord(systemStatsCollection) systemStatsRecord := core.NewRecord(systemStatsCollection)
systemStatsRecord.Set("system", systemRecord.Id) systemStatsRecord.Set("system", systemRecord.Id)
systemStatsRecord.Set("stats", data.Stats) systemStatsRecord.Set("stats", data.Stats)
@@ -155,14 +163,14 @@ func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error
if err := txApp.SaveNoValidate(systemStatsRecord); err != nil { if err := txApp.SaveNoValidate(systemStatsRecord); err != nil {
return err return err
} }
// add containers and container_stats records
if len(data.Containers) > 0 { if len(data.Containers) > 0 {
// add / update containers records
if data.Containers[0].Id != "" { if data.Containers[0].Id != "" {
if err := createContainerRecords(txApp, data.Containers, sys.Id); err != nil { if err := createContainerRecords(txApp, data.Containers, sys.Id); err != nil {
return err return err
} }
} }
// add new container_stats record
containerStatsCollection, err := txApp.FindCachedCollectionByNameOrId("container_stats") containerStatsCollection, err := txApp.FindCachedCollectionByNameOrId("container_stats")
if err != nil { if err != nil {
return err return err
@@ -183,9 +191,16 @@ func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error
} }
} }
// add system details record
if data.Details != nil {
if err := createSystemDetailsRecord(txApp, data.Details, sys.Id); err != nil {
return err
}
sys.detailsFetched.Store(true)
}
// 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)
systemRecord.Set("status", up) systemRecord.Set("status", up)
systemRecord.Set("info", data.Info) systemRecord.Set("info", data.Info)
if err := txApp.SaveNoValidate(systemRecord); err != nil { if err := txApp.SaveNoValidate(systemRecord); err != nil {
return err return err
@@ -195,14 +210,44 @@ func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error
// Fetch and save SMART devices when system first comes online // Fetch and save SMART devices when system first comes online
if err == nil { if err == nil {
sys.smartOnce.Do(func() { if !sys.smartFetched.Load() && sys.smartFetching.CompareAndSwap(false, true) {
go sys.FetchAndSaveSmartDevices() go func() {
}) defer sys.smartFetching.Store(false)
if err := sys.FetchAndSaveSmartDevices(); err == nil {
sys.smartFetched.Store(true)
}
}()
}
} }
return systemRecord, err return systemRecord, err
} }
func createSystemDetailsRecord(app core.App, data *system.Details, systemId string) error {
collectionName := "system_details"
params := dbx.Params{
"id": systemId,
"system": systemId,
"hostname": data.Hostname,
"kernel": data.Kernel,
"cores": data.Cores,
"threads": data.Threads,
"cpu": data.CpuModel,
"os": data.Os,
"os_name": data.OsName,
"arch": data.Arch,
"memory": data.MemoryTotal,
"podman": data.Podman,
"updated": time.Now().UTC(),
}
result, err := app.DB().Update(collectionName, params, dbx.HashExp{"id": systemId}).Execute()
rowsAffected, _ := result.RowsAffected()
if err != nil || rowsAffected == 0 {
_, err = app.DB().Insert(collectionName, params).Execute()
}
return err
}
func createSystemdStatsRecords(app core.App, data []*systemd.Service, systemId string) error { func createSystemdStatsRecords(app core.App, data []*systemd.Service, systemId string) error {
if len(data) == 0 { if len(data) == 0 {
return nil return nil
@@ -350,7 +395,8 @@ func (sys *System) fetchStringFromAgentViaSSH(action common.WebSocketAction, req
if err := session.Shell(); err != nil { if err := session.Shell(); err != nil {
return false, err return false, err
} }
req := common.HubRequest[any]{Action: action, Data: requestData} reqDataBytes, _ := cbor.Marshal(requestData)
req := common.HubRequest[cbor.RawMessage]{Action: action, Data: reqDataBytes}
_ = cbor.NewEncoder(stdin).Encode(req) _ = cbor.NewEncoder(stdin).Encode(req)
_ = stdin.Close() _ = stdin.Close()
var resp common.AgentResponse var resp common.AgentResponse
@@ -414,7 +460,8 @@ func (sys *System) FetchSystemdInfoFromAgent(serviceName string) (systemd.Servic
return false, err return false, err
} }
req := common.HubRequest[any]{Action: common.GetSystemdInfo, Data: common.SystemdInfoRequest{ServiceName: serviceName}} reqDataBytes, _ := cbor.Marshal(common.SystemdInfoRequest{ServiceName: serviceName})
req := common.HubRequest[cbor.RawMessage]{Action: common.GetSystemdInfo, Data: reqDataBytes}
if err := cbor.NewEncoder(stdin).Encode(req); err != nil { if err := cbor.NewEncoder(stdin).Encode(req); err != nil {
return false, err return false, err
} }
@@ -462,7 +509,8 @@ func (sys *System) fetchDataViaSSH(options common.DataRequestOptions) (*system.C
*sys.data = system.CombinedData{} *sys.data = system.CombinedData{}
if sys.agentVersion.GTE(beszel.MinVersionAgentResponse) && stdinErr == nil { if sys.agentVersion.GTE(beszel.MinVersionAgentResponse) && stdinErr == nil {
req := common.HubRequest[any]{Action: common.GetData, Data: options} reqDataBytes, _ := cbor.Marshal(options)
req := common.HubRequest[cbor.RawMessage]{Action: common.GetData, Data: reqDataBytes}
_ = cbor.NewEncoder(stdin).Encode(req) _ = cbor.NewEncoder(stdin).Encode(req)
_ = stdin.Close() _ = stdin.Close()

View File

@@ -266,18 +266,20 @@ func testOld(t *testing.T, hub *tests.TestHub) {
// Create test system data // Create test system data
testData := &system.CombinedData{ testData := &system.CombinedData{
Details: &system.Details{
Hostname: "data-test.example.com",
Kernel: "5.15.0-generic",
Cores: 4,
Threads: 8,
CpuModel: "Test CPU",
},
Info: system.Info{ Info: system.Info{
Hostname: "data-test.example.com", Uptime: 3600,
KernelVersion: "5.15.0-generic", Cpu: 25.5,
Cores: 4, MemPct: 40.2,
Threads: 8, DiskPct: 60.0,
CpuModel: "Test CPU", Bandwidth: 100.0,
Uptime: 3600, AgentVersion: "1.0.0",
Cpu: 25.5,
MemPct: 40.2,
DiskPct: 60.0,
Bandwidth: 100.0,
AgentVersion: "1.0.0",
}, },
Stats: system.Stats{ Stats: system.Stats{
Cpu: 25.5, Cpu: 25.5,

View File

@@ -78,7 +78,8 @@ func init() {
"GPU", "GPU",
"LoadAvg1", "LoadAvg1",
"LoadAvg5", "LoadAvg5",
"LoadAvg15" "LoadAvg15",
"Battery"
] ]
}, },
{ {
@@ -1438,6 +1439,184 @@ func init() {
"type": "base", "type": "base",
"updateRule": null, "updateRule": null,
"viewRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id" "viewRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id"
},
{
"createRule": "",
"deleteRule": "",
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"hidden": false,
"id": "text3208210256",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"cascadeDelete": true,
"collectionId": "2hz5ncl8tizk5nx",
"hidden": false,
"id": "relation3377271179",
"maxSelect": 1,
"minSelect": 0,
"name": "system",
"presentable": false,
"required": true,
"system": false,
"type": "relation"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text3847340049",
"max": 0,
"min": 0,
"name": "hostname",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"hidden": false,
"id": "number1789936913",
"max": null,
"min": null,
"name": "os",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text2818598173",
"max": 0,
"min": 0,
"name": "os_name",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text1574083243",
"max": 0,
"min": 0,
"name": "kernel",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text3128971310",
"max": 0,
"min": 0,
"name": "cpu",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text4161937994",
"max": 0,
"min": 0,
"name": "arch",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"hidden": false,
"id": "number4245036687",
"max": null,
"min": null,
"name": "cores",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "number1871592925",
"max": null,
"min": null,
"name": "threads",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "number3933025333",
"max": null,
"min": null,
"name": "memory",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "bool2200265312",
"name": "podman",
"presentable": false,
"required": false,
"system": false,
"type": "bool"
},
{
"hidden": false,
"id": "autodate3332085495",
"name": "updated",
"onCreate": true,
"onUpdate": true,
"presentable": false,
"system": false,
"type": "autodate"
}
],
"id": "pbc_3116237454",
"indexes": [],
"listRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id",
"name": "system_details",
"system": false,
"type": "base",
"updateRule": "",
"viewRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id"
} }
]` ]`

View File

@@ -33,10 +33,7 @@
"noUnusedFunctionParameters": "error", "noUnusedFunctionParameters": "error",
"noUnusedPrivateClassMembers": "error", "noUnusedPrivateClassMembers": "error",
"useExhaustiveDependencies": { "useExhaustiveDependencies": {
"level": "warn", "level": "off"
"options": {
"reportUnnecessaryDependencies": false
}
}, },
"useUniqueElementIds": "off", "useUniqueElementIds": "off",
"noUnusedVariables": "error" "noUnusedVariables": "error"

View File

@@ -24,6 +24,7 @@ export default defineConfig({
"tr", "tr",
"ru", "ru",
"sl", "sl",
"sr",
"sv", "sv",
"uk", "uk",
"vi", "vi",

View File

@@ -39,8 +39,8 @@
"lucide-react": "^0.452.0", "lucide-react": "^0.452.0",
"nanostores": "^0.11.4", "nanostores": "^0.11.4",
"pocketbase": "^0.26.2", "pocketbase": "^0.26.2",
"react": "^19.1.1", "react": "^19.1.2",
"react-dom": "^19.1.1", "react-dom": "^19.1.2",
"recharts": "^2.15.4", "recharts": "^2.15.4",
"shiki": "^3.13.0", "shiki": "^3.13.0",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
@@ -5745,9 +5745,9 @@
} }
}, },
"node_modules/react": { "node_modules/react": {
"version": "19.1.1", "version": "19.1.2",
"resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.1.2.tgz",
"integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", "integrity": "sha512-MdWVitvLbQULD+4DP8GYjZUrepGW7d+GQkNVqJEzNxE+e9WIa4egVFE/RDfVb1u9u/Jw7dNMmPB4IqxzbFYJ0w==",
"license": "MIT", "license": "MIT",
"peer": true, "peer": true,
"engines": { "engines": {
@@ -5755,16 +5755,16 @@
} }
}, },
"node_modules/react-dom": { "node_modules/react-dom": {
"version": "19.1.1", "version": "19.1.2",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.2.tgz",
"integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==", "integrity": "sha512-dEoydsCp50i7kS1xHOmPXq4zQYoGWedUsvqv9H6zdif2r7yLHygyfP9qou71TulRN0d6ng9EbRVsQhSqfUc19g==",
"license": "MIT", "license": "MIT",
"peer": true, "peer": true,
"dependencies": { "dependencies": {
"scheduler": "^0.26.0" "scheduler": "^0.26.0"
}, },
"peerDependencies": { "peerDependencies": {
"react": "^19.1.1" "react": "^19.1.2"
} }
}, },
"node_modules/react-is": { "node_modules/react-is": {

View File

@@ -1,7 +1,7 @@
{ {
"name": "beszel", "name": "beszel",
"private": true, "private": true,
"version": "0.17.0", "version": "0.18.0-beta.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host", "dev": "vite --host",
@@ -46,8 +46,8 @@
"lucide-react": "^0.452.0", "lucide-react": "^0.452.0",
"nanostores": "^0.11.4", "nanostores": "^0.11.4",
"pocketbase": "^0.26.2", "pocketbase": "^0.26.2",
"react": "^19.1.1", "react": "^19.1.2",
"react-dom": "^19.1.1", "react-dom": "^19.1.2",
"recharts": "^2.15.4", "recharts": "^2.15.4",
"shiki": "^3.13.0", "shiki": "^3.13.0",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",

View File

@@ -61,6 +61,11 @@ export const ActiveAlerts = () => {
<AlertDescription> <AlertDescription>
{alert.name === "Status" ? ( {alert.name === "Status" ? (
<Trans>Connection is down</Trans> <Trans>Connection is down</Trans>
) : info.invert ? (
<Trans>
Below {alert.value}
{info.unit} in last <Plural value={alert.min} one="# minute" other="# minutes" />
</Trans>
) : ( ) : (
<Trans> <Trans>
Exceeds {alert.value} Exceeds {alert.value}

View File

@@ -245,13 +245,23 @@ export function AlertContent({
{!singleDescription && ( {!singleDescription && (
<div> <div>
<p id={`v${name}`} className="text-sm block h-8"> <p id={`v${name}`} className="text-sm block h-8">
<Trans> {alertData.invert ? (
Average exceeds{" "} <Trans>
<strong className="text-foreground"> Average drops below{" "}
{value} <strong className="text-foreground">
{alertData.unit} {value}
</strong> {alertData.unit}
</Trans> </strong>
</Trans>
) : (
<Trans>
Average exceeds{" "}
<strong className="text-foreground">
{value}
{alertData.unit}
</strong>
</Trans>
)}
</p> </p>
<div className="flex gap-3"> <div className="flex gap-3">
<Slider <Slider

View File

@@ -55,8 +55,11 @@ export default function ContainersTable({ systemId }: { systemId?: string }) {
filter: systemId ? pb.filter("system={:system}", { system: systemId }) : undefined, filter: systemId ? pb.filter("system={:system}", { system: systemId }) : undefined,
}) })
.then( .then(
({ items }) => ({ items }) => {
items.length && if (items.length === 0) {
setData([]);
return;
}
setData((curItems) => { setData((curItems) => {
const lastUpdated = Math.max(items[0].updated, items.at(-1)?.updated ?? 0) const lastUpdated = Math.max(items[0].updated, items.at(-1)?.updated ?? 0)
const containerIds = new Set() const containerIds = new Set()
@@ -74,6 +77,7 @@ export default function ContainersTable({ systemId }: { systemId?: string }) {
} }
return newItems return newItems
}) })
}
) )
} }
@@ -333,12 +337,12 @@ function ContainerSheet({
setLogsDisplay("") setLogsDisplay("")
setInfoDisplay("") setInfoDisplay("")
if (!container) return if (!container) return
;(async () => { ;(async () => {
const [logsHtml, infoHtml] = await Promise.all([getLogsHtml(container), getInfoHtml(container)]) const [logsHtml, infoHtml] = await Promise.all([getLogsHtml(container), getInfoHtml(container)])
setLogsDisplay(logsHtml) setLogsDisplay(logsHtml)
setInfoDisplay(infoHtml) setInfoDisplay(infoHtml)
setTimeout(scrollLogsToBottom, 20) setTimeout(scrollLogsToBottom, 20)
})() })()
}, [container]) }, [container])
return ( return (

View File

@@ -3,15 +3,7 @@ import { Trans, useLingui } from "@lingui/react/macro"
import { useStore } from "@nanostores/react" import { useStore } from "@nanostores/react"
import { getPagePath } from "@nanostores/router" import { getPagePath } from "@nanostores/router"
import { timeTicks } from "d3-time" import { timeTicks } from "d3-time"
import { import { XIcon } from "lucide-react"
ChevronRightSquareIcon,
ClockArrowUp,
CpuIcon,
GlobeIcon,
LayoutGridIcon,
MonitorIcon,
XIcon,
} from "lucide-react"
import { subscribeKeys } from "nanostores" import { subscribeKeys } from "nanostores"
import React, { type JSX, lazy, memo, useCallback, useEffect, useMemo, useRef, useState } from "react" import React, { type JSX, lazy, memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
import AreaChartDefault, { type DataPoint } from "@/components/charts/area-chart" import AreaChartDefault, { type DataPoint } from "@/components/charts/area-chart"
@@ -24,7 +16,7 @@ import MemChart from "@/components/charts/mem-chart"
import SwapChart from "@/components/charts/swap-chart" import SwapChart from "@/components/charts/swap-chart"
import TemperatureChart from "@/components/charts/temperature-chart" import TemperatureChart from "@/components/charts/temperature-chart"
import { getPbTimestamp, pb } from "@/lib/api" import { getPbTimestamp, pb } from "@/lib/api"
import { ChartType, ConnectionType, connectionTypeLabels, Os, SystemStatus, Unit } from "@/lib/enums" import { ChartType, Os, SystemStatus, Unit } from "@/lib/enums"
import { batteryStateTranslations } from "@/lib/i18n" import { batteryStateTranslations } from "@/lib/i18n"
import { import {
$allSystemsById, $allSystemsById,
@@ -44,8 +36,6 @@ import {
compareSemVer, compareSemVer,
decimalString, decimalString,
formatBytes, formatBytes,
secondsToString,
getHostDisplayValue,
listen, listen,
parseSemVer, parseSemVer,
toFixedFloat, toFixedFloat,
@@ -61,20 +51,18 @@ import type {
SystemStats, SystemStats,
SystemStatsRecord, SystemStatsRecord,
} from "@/types" } from "@/types"
import ChartTimeSelect from "../charts/chart-time-select"
import { $router, navigate } from "../router" import { $router, navigate } from "../router"
import Spinner from "../spinner" import Spinner from "../spinner"
import { Button } from "../ui/button" import { Button } from "../ui/button"
import { Card, CardDescription, CardHeader, CardTitle } from "../ui/card" import { Card, CardDescription, CardHeader, CardTitle } from "../ui/card"
import { AppleIcon, ChartAverage, ChartMax, FreeBsdIcon, Rows, TuxIcon, WebSocketIcon, WindowsIcon } from "../ui/icons" import { ChartAverage, ChartMax } from "../ui/icons"
import { Input } from "../ui/input" import { Input } from "../ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select"
import { Separator } from "../ui/separator"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip"
import NetworkSheet from "./system/network-sheet" import NetworkSheet from "./system/network-sheet"
import CpuCoresSheet from "./system/cpu-sheet" import CpuCoresSheet from "./system/cpu-sheet"
import LineChartDefault from "../charts/line-chart" import LineChartDefault from "../charts/line-chart"
import { pinnedAxisDomain } from "../ui/chart" import { pinnedAxisDomain } from "../ui/chart"
import InfoBar from "./system/info-bar"
type ChartTimeData = { type ChartTimeData = {
time: number time: number
@@ -154,8 +142,8 @@ async function getStats<T extends SystemStatsRecord | ContainerStatsRecord>(
}) })
} }
function dockerOrPodman(str: string, system: SystemRecord): string { function dockerOrPodman(str: string, isPodman: boolean): string {
if (system.info.p) { if (isPodman) {
return str.replace("docker", "podman").replace("Docker", "Podman") return str.replace("docker", "podman").replace("Docker", "Podman")
} }
return str return str
@@ -178,6 +166,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
const isLongerChart = !["1m", "1h"].includes(chartTime) // true if chart time is not 1m or 1h const isLongerChart = !["1m", "1h"].includes(chartTime) // true if chart time is not 1m or 1h
const userSettings = $userSettings.get() const userSettings = $userSettings.get()
const chartWrapRef = useRef<HTMLDivElement>(null) const chartWrapRef = useRef<HTMLDivElement>(null)
const [isPodman, setIsPodman] = useState(system.info?.p ?? false)
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -217,7 +206,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
// subscribe to realtime metrics if chart time is 1m // subscribe to realtime metrics if chart time is 1m
// biome-ignore lint/correctness/useExhaustiveDependencies: not necessary // biome-ignore lint/correctness/useExhaustiveDependencies: not necessary
useEffect(() => { useEffect(() => {
let unsub = () => { } let unsub = () => {}
if (!system.id || chartTime !== "1m") { if (!system.id || chartTime !== "1m") {
return return
} }
@@ -333,62 +322,9 @@ export default memo(function SystemDetail({ id }: { id: string }) {
}) })
}, [system, chartTime]) }, [system, chartTime])
// values for system info bar useEffect(() => {
const systemInfo = useMemo(() => { setIsPodman(system.info?.p ?? false)
if (!system.info) { }, [system.info?.p])
return []
}
const osInfo = {
[Os.Linux]: {
Icon: TuxIcon,
value: system.info.k,
label: t({ comment: "Linux kernel", message: "Kernel" }),
},
[Os.Darwin]: {
Icon: AppleIcon,
value: `macOS ${system.info.k}`,
},
[Os.Windows]: {
Icon: WindowsIcon,
value: system.info.k,
},
[Os.FreeBSD]: {
Icon: FreeBsdIcon,
value: system.info.k,
},
}
let uptime: string
if (system.info.u < 3600) {
uptime = secondsToString(system.info.u, "minute")
} else if (system.info.u < 360000) {
uptime = secondsToString(system.info.u, "hour")
} else {
uptime = secondsToString(system.info.u, "day")
}
return [
{ value: getHostDisplayValue(system), Icon: GlobeIcon },
{
value: system.info.h,
Icon: MonitorIcon,
label: "Hostname",
// hide if hostname is same as host or name
hide: system.info.h === system.host || system.info.h === system.name,
},
{ value: uptime, Icon: ClockArrowUp, label: t`Uptime`, hide: !system.info.u },
osInfo[system.info.os ?? Os.Linux],
{
value: `${system.info.m} (${system.info.c}c${system.info.t ? `/${system.info.t}t` : ""})`,
Icon: CpuIcon,
hide: !system.info.m,
},
] as {
value: string | number | undefined
label?: string
Icon: React.ElementType
hide?: boolean
}[]
}, [system, t])
/** Space for tooltip if more than 10 sensors and no containers table */ /** Space for tooltip if more than 10 sensors and no containers table */
useEffect(() => { useEffect(() => {
@@ -458,113 +394,11 @@ export default memo(function SystemDetail({ id }: { id: string }) {
const hasGpuPowerData = lastGpuVals.some((gpu) => gpu.p !== undefined || gpu.pp !== undefined) const hasGpuPowerData = lastGpuVals.some((gpu) => gpu.p !== undefined || gpu.pp !== undefined)
const hasGpuEnginesData = lastGpuVals.some((gpu) => gpu.e !== undefined) const hasGpuEnginesData = lastGpuVals.some((gpu) => gpu.e !== undefined)
let translatedStatus: string = system.status
if (system.status === SystemStatus.Up) {
translatedStatus = t({ message: "Up", comment: "Context: System is up" })
} else if (system.status === SystemStatus.Down) {
translatedStatus = t({ message: "Down", comment: "Context: System is down" })
}
return ( return (
<> <>
<div ref={chartWrapRef} className="grid gap-4 mb-14 overflow-x-clip"> <div ref={chartWrapRef} className="grid gap-4 mb-14 overflow-x-clip">
{/* system info */} {/* system info */}
<Card> <InfoBar system={system} chartData={chartData} grid={grid} setGrid={setGrid} setIsPodman={setIsPodman} />
<div className="grid xl:flex gap-4 px-4 sm:px-6 pt-3 sm:pt-4 pb-5">
<div>
<h1 className="text-[1.6rem] font-semibold mb-1.5">{system.name}</h1>
<div className="flex flex-wrap items-center gap-3 gap-y-2 text-sm opacity-90">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="capitalize flex gap-2 items-center">
<span className={cn("relative flex h-3 w-3")}>
{system.status === SystemStatus.Up && (
<span
className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"
style={{ animationDuration: "1.5s" }}
></span>
)}
<span
className={cn("relative inline-flex rounded-full h-3 w-3", {
"bg-green-500": system.status === SystemStatus.Up,
"bg-red-500": system.status === SystemStatus.Down,
"bg-primary/40": system.status === SystemStatus.Paused,
"bg-yellow-500": system.status === SystemStatus.Pending,
})}
></span>
</span>
{translatedStatus}
</div>
</TooltipTrigger>
{system.info.ct && (
<TooltipContent>
<div className="flex gap-1 items-center">
{system.info.ct === ConnectionType.WebSocket ? (
<WebSocketIcon className="size-4" />
) : (
<ChevronRightSquareIcon className="size-4" strokeWidth={2} />
)}
{connectionTypeLabels[system.info.ct as ConnectionType]}
</div>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
{systemInfo.map(({ value, label, Icon, hide }) => {
if (hide || !value) {
return null
}
const content = (
<div className="flex gap-1.5 items-center">
<Icon className="h-4 w-4" /> {value}
</div>
)
return (
<div key={value} className="contents">
<Separator orientation="vertical" className="h-4 bg-primary/30" />
{label ? (
<TooltipProvider>
<Tooltip delayDuration={150}>
<TooltipTrigger asChild>{content}</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
content
)}
</div>
)
})}
</div>
</div>
<div className="xl:ms-auto flex items-center gap-2 max-sm:-mb-1">
<ChartTimeSelect className="w-full xl:w-40" agentVersion={chartData.agentVersion} />
<TooltipProvider delayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label={t`Toggle grid`}
variant="outline"
size="icon"
className="hidden xl:flex p-0 text-primary"
onClick={() => setGrid(!grid)}
>
{grid ? (
<LayoutGridIcon className="h-[1.2rem] w-[1.2rem] opacity-75" />
) : (
<Rows className="h-[1.3rem] w-[1.3rem] opacity-75" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>{t`Toggle grid`}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
</Card>
{/* <Tabs defaultValue="overview" className="w-full"> {/* <Tabs defaultValue="overview" className="w-full">
<TabsList className="w-full h-11"> <TabsList className="w-full h-11">
@@ -576,7 +410,6 @@ export default memo(function SystemDetail({ id }: { id: string }) {
</TabsContent> </TabsContent>
</Tabs> */} </Tabs> */}
{/* main charts */} {/* main charts */}
<div className="grid xl:grid-cols-2 gap-4"> <div className="grid xl:grid-cols-2 gap-4">
<ChartCard <ChartCard
@@ -612,7 +445,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
<ChartCard <ChartCard
empty={dataEmpty} empty={dataEmpty}
grid={grid} grid={grid}
title={dockerOrPodman(t`Docker CPU Usage`, system)} title={dockerOrPodman(t`Docker CPU Usage`, isPodman)}
description={t`Average CPU utilization of containers`} description={t`Average CPU utilization of containers`}
cornerEl={containerFilterBar} cornerEl={containerFilterBar}
> >
@@ -639,8 +472,8 @@ export default memo(function SystemDetail({ id }: { id: string }) {
<ChartCard <ChartCard
empty={dataEmpty} empty={dataEmpty}
grid={grid} grid={grid}
title={dockerOrPodman(t`Docker Memory Usage`, system)} title={dockerOrPodman(t`Docker Memory Usage`, isPodman)}
description={dockerOrPodman(t`Memory usage of docker containers`, system)} description={dockerOrPodman(t`Memory usage of docker containers`, isPodman)}
cornerEl={containerFilterBar} cornerEl={containerFilterBar}
> >
<ContainerChart <ContainerChart
@@ -760,8 +593,8 @@ export default memo(function SystemDetail({ id }: { id: string }) {
<ChartCard <ChartCard
empty={dataEmpty} empty={dataEmpty}
grid={grid} grid={grid}
title={dockerOrPodman(t`Docker Network I/O`, system)} title={dockerOrPodman(t`Docker Network I/O`, isPodman)}
description={dockerOrPodman(t`Network traffic of docker containers`, system)} description={dockerOrPodman(t`Network traffic of docker containers`, isPodman)}
cornerEl={containerFilterBar} cornerEl={containerFilterBar}
> >
<ContainerChart <ContainerChart
@@ -800,10 +633,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
{/* Temperature chart */} {/* Temperature chart */}
{systemStats.at(-1)?.stats.t && ( {systemStats.at(-1)?.stats.t && (
<div <div ref={temperatureChartRef} className={cn("odd:last-of-type:col-span-full", { "col-span-full": !grid })}>
ref={temperatureChartRef}
className={cn("odd:last-of-type:col-span-full", { "col-span-full": !grid })}
>
<ChartCard <ChartCard
empty={dataEmpty} empty={dataEmpty}
grid={grid} grid={grid}
@@ -965,7 +795,9 @@ export default memo(function SystemDetail({ id }: { id: string }) {
label: t`Write`, label: t`Write`,
dataKey: ({ stats }) => { dataKey: ({ stats }) => {
if (showMax) { if (showMax) {
return stats?.efs?.[extraFsName]?.wbm || (stats?.efs?.[extraFsName]?.wm ?? 0) * 1024 * 1024 return (
stats?.efs?.[extraFsName]?.wbm || (stats?.efs?.[extraFsName]?.wm ?? 0) * 1024 * 1024
)
} }
return stats?.efs?.[extraFsName]?.wb || (stats?.efs?.[extraFsName]?.w ?? 0) * 1024 * 1024 return stats?.efs?.[extraFsName]?.wb || (stats?.efs?.[extraFsName]?.w ?? 0) * 1024 * 1024
}, },
@@ -1003,9 +835,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
</div> </div>
)} )}
{compareSemVer(chartData.agentVersion, parseSemVer("0.15.0")) >= 0 && ( {compareSemVer(chartData.agentVersion, parseSemVer("0.15.0")) >= 0 && <LazySmartTable systemId={system.id} />}
<LazySmartTable systemId={system.id} />
)}
{containerData.length > 0 && compareSemVer(chartData.agentVersion, parseSemVer("0.14.0")) >= 0 && ( {containerData.length > 0 && compareSemVer(chartData.agentVersion, parseSemVer("0.14.0")) >= 0 && (
<LazyContainersTable systemId={system.id} /> <LazyContainersTable systemId={system.id} />
@@ -1061,13 +891,10 @@ function FilterBar({ store = $containerFilter }: { store?: typeof $containerFilt
return () => clearTimeout(handle) return () => clearTimeout(handle)
}, [inputValue, storeValue, store]) }, [inputValue, storeValue, store])
const handleChange = useCallback( const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
(e: React.ChangeEvent<HTMLInputElement>) => { const value = e.target.value
const value = e.target.value setInputValue(value)
setInputValue(value) }, [])
},
[]
)
const handleClear = useCallback(() => { const handleClear = useCallback(() => {
setInputValue("") setInputValue("")

View File

@@ -0,0 +1,259 @@
import { plural } from "@lingui/core/macro"
import { useLingui } from "@lingui/react/macro"
import {
AppleIcon,
ChevronRightSquareIcon,
ClockArrowUp,
CpuIcon,
GlobeIcon,
LayoutGridIcon,
MemoryStickIcon,
MonitorIcon,
Rows,
} from "lucide-react"
import { useEffect, useMemo, useState } from "react"
import ChartTimeSelect from "@/components/charts/chart-time-select"
import { Button } from "@/components/ui/button"
import { Card } from "@/components/ui/card"
import { FreeBsdIcon, TuxIcon, WebSocketIcon, WindowsIcon } from "@/components/ui/icons"
import { Separator } from "@/components/ui/separator"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { pb } from "@/lib/api"
import { ConnectionType, connectionTypeLabels, Os, SystemStatus } from "@/lib/enums"
import { cn, formatBytes, getHostDisplayValue, secondsToString, toFixedFloat } from "@/lib/utils"
import type { ChartData, SystemDetailsRecord, SystemRecord } from "@/types"
export default function InfoBar({
system,
chartData,
grid,
setGrid,
setIsPodman,
}: {
system: SystemRecord
chartData: ChartData
grid: boolean
setGrid: (grid: boolean) => void
setIsPodman: (isPodman: boolean) => void
}) {
const { t } = useLingui()
const [details, setDetails] = useState<SystemDetailsRecord | null>(null)
// Fetch system_details on mount / when system changes
useEffect(() => {
let active = true
setDetails(null)
// skip fetching system details if agent is older version which includes details in Info struct
if (!system.id || system.info?.m) {
return
}
pb.collection<SystemDetailsRecord>("system_details")
.getOne(system.id, {
fields: "hostname,kernel,cores,threads,cpu,os,os_name,arch,memory,podman",
headers: {
"Cache-Control": "public, max-age=60",
},
})
.then((details) => {
if (active) {
setDetails(details)
setIsPodman(details.podman)
}
})
.catch(() => {})
return () => {
active = false
}
}, [system.id])
// values for system info bar - use details with fallback to system.info
const systemInfo = useMemo(() => {
if (!system.info) {
return []
}
// Use details if available, otherwise fall back to system.info
const hostname = details?.hostname ?? system.info.h
const kernel = details?.kernel ?? system.info.k
const cores = details?.cores ?? system.info.c
const threads = details?.threads ?? system.info.t ?? 0
const cpuModel = details?.cpu ?? system.info.m
const os = details?.os ?? system.info.os ?? Os.Linux
const osName = details?.os_name
const arch = details?.arch
const memory = details?.memory
const osInfo = {
[Os.Linux]: {
Icon: TuxIcon,
// show kernel in tooltip if os name is available, otherwise show the kernel
value: osName || kernel,
label: osName ? kernel : undefined,
},
[Os.Darwin]: {
Icon: AppleIcon,
value: osName || `macOS ${kernel}`,
},
[Os.Windows]: {
Icon: WindowsIcon,
value: osName || kernel,
label: osName ? kernel : undefined,
},
[Os.FreeBSD]: {
Icon: FreeBsdIcon,
value: osName || kernel,
label: osName ? kernel : undefined,
},
}
let uptime: string
if (system.info.u < 3600) {
uptime = secondsToString(system.info.u, "minute")
} else if (system.info.u < 360000) {
uptime = secondsToString(system.info.u, "hour")
} else {
uptime = secondsToString(system.info.u, "day")
}
const info = [
{ value: getHostDisplayValue(system), Icon: GlobeIcon },
{
value: hostname,
Icon: MonitorIcon,
label: "Hostname",
// hide if hostname is same as host or name
hide: hostname === system.host || hostname === system.name,
},
{ value: uptime, Icon: ClockArrowUp, label: t`Uptime`, hide: !system.info.u },
osInfo[os],
{
value: cpuModel,
Icon: CpuIcon,
hide: !cpuModel,
label: `${plural(cores, { one: "# core", other: "# cores" })} / ${plural(threads, { one: "# thread", other: "# threads" })}${arch ? ` / ${arch}` : ""}`,
},
] as {
value: string | number | undefined
label?: string
Icon: React.ElementType
hide?: boolean
}[]
if (memory) {
const memValue = formatBytes(memory, false, undefined, false)
info.push({
value: `${toFixedFloat(memValue.value, memValue.value >= 10 ? 1 : 2)} ${memValue.unit}`,
Icon: MemoryStickIcon,
hide: !memory,
label: t`Memory`,
})
}
return info
}, [system, details, t])
let translatedStatus: string = system.status
if (system.status === SystemStatus.Up) {
translatedStatus = t({ message: "Up", comment: "Context: System is up" })
} else if (system.status === SystemStatus.Down) {
translatedStatus = t({ message: "Down", comment: "Context: System is down" })
}
return (
<Card>
<div className="grid xl:flex gap-4 px-4 sm:px-6 pt-3 sm:pt-4 pb-5">
<div>
<h1 className="text-[1.6rem] font-semibold mb-1.5">{system.name}</h1>
<div className="flex flex-wrap items-center gap-3 gap-y-2 text-sm opacity-90">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="capitalize flex gap-2 items-center">
<span className={cn("relative flex h-3 w-3")}>
{system.status === SystemStatus.Up && (
<span
className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"
style={{ animationDuration: "1.5s" }}
></span>
)}
<span
className={cn("relative inline-flex rounded-full h-3 w-3", {
"bg-green-500": system.status === SystemStatus.Up,
"bg-red-500": system.status === SystemStatus.Down,
"bg-primary/40": system.status === SystemStatus.Paused,
"bg-yellow-500": system.status === SystemStatus.Pending,
})}
></span>
</span>
{translatedStatus}
</div>
</TooltipTrigger>
{system.info.ct && (
<TooltipContent>
<div className="flex gap-1 items-center">
{system.info.ct === ConnectionType.WebSocket ? (
<WebSocketIcon className="size-4" />
) : (
<ChevronRightSquareIcon className="size-4" strokeWidth={2} />
)}
{connectionTypeLabels[system.info.ct as ConnectionType]}
</div>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
{systemInfo.map(({ value, label, Icon, hide }) => {
if (hide || !value) {
return null
}
const content = (
<div className="flex gap-1.5 items-center">
<Icon className="h-4 w-4" /> {value}
</div>
)
return (
<div key={value} className="contents">
<Separator orientation="vertical" className="h-4 bg-primary/30" />
{label ? (
<TooltipProvider>
<Tooltip delayDuration={150}>
<TooltipTrigger asChild>{content}</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
content
)}
</div>
)
})}
</div>
</div>
<div className="xl:ms-auto flex items-center gap-2 max-sm:-mb-1">
<ChartTimeSelect className="w-full xl:w-40" agentVersion={chartData.agentVersion} />
<TooltipProvider delayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label={t`Toggle grid`}
variant="outline"
size="icon"
className="hidden xl:flex p-0 text-primary"
onClick={() => setGrid(!grid)}
>
{grid ? (
<LayoutGridIcon className="h-[1.2rem] w-[1.2rem] opacity-75" />
) : (
<Rows className="h-[1.3rem] w-[1.3rem] opacity-75" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>{t`Toggle grid`}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
</Card>
)
}

View File

@@ -233,7 +233,7 @@ export const columns: ColumnDef<DiskInfo>[] = [
if (!cycles && cycles !== 0) { if (!cycles && cycles !== 0) {
return <div className="text-muted-foreground ms-1.5">N/A</div> return <div className="text-muted-foreground ms-1.5">N/A</div>
} }
return <span className="ms-1.5">{cycles}</span> return <span className="ms-1.5">{cycles.toLocaleString()}</span>
}, },
}, },
{ {
@@ -329,41 +329,41 @@ export default function DisksTable({ systemId }: { systemId?: string }) {
? { fields: SMART_DEVICE_FIELDS, filter: pb.filter("system = {:system}", { system: systemId }) } ? { fields: SMART_DEVICE_FIELDS, filter: pb.filter("system = {:system}", { system: systemId }) }
: { fields: SMART_DEVICE_FIELDS } : { fields: SMART_DEVICE_FIELDS }
; (async () => { ;(async () => {
try { try {
unsubscribe = await pb.collection("smart_devices").subscribe( unsubscribe = await pb.collection("smart_devices").subscribe(
"*", "*",
(event) => { (event) => {
const record = event.record as SmartDeviceRecord const record = event.record as SmartDeviceRecord
setSmartDevices((currentDevices) => { setSmartDevices((currentDevices) => {
const devices = currentDevices ?? [] const devices = currentDevices ?? []
const matchesSystemScope = !systemId || record.system === systemId const matchesSystemScope = !systemId || record.system === systemId
if (event.action === "delete") { if (event.action === "delete") {
return devices.filter((device) => device.id !== record.id) return devices.filter((device) => device.id !== record.id)
} }
if (!matchesSystemScope) { if (!matchesSystemScope) {
// Record moved out of scope; ensure it disappears locally. // Record moved out of scope; ensure it disappears locally.
return devices.filter((device) => device.id !== record.id) return devices.filter((device) => device.id !== record.id)
} }
const existingIndex = devices.findIndex((device) => device.id === record.id) const existingIndex = devices.findIndex((device) => device.id === record.id)
if (existingIndex === -1) { if (existingIndex === -1) {
return [record, ...devices] return [record, ...devices]
} }
const next = [...devices] const next = [...devices]
next[existingIndex] = record next[existingIndex] = record
return next return next
}) })
}, },
pbOptions pbOptions
) )
} catch (error) { } catch (error) {
console.error("Failed to subscribe to SMART device updates:", error) console.error("Failed to subscribe to SMART device updates:", error)
} }
})() })()
return () => { return () => {
unsubscribe?.() unsubscribe?.()
@@ -421,14 +421,14 @@ export default function DisksTable({ systemId }: { systemId?: string }) {
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="size-8" className="size-10"
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()} onMouseDown={(event) => event.stopPropagation()}
> >
<span className="sr-only"> <span className="sr-only">
<Trans>Open menu</Trans> <Trans>Open menu</Trans>
</span> </span>
<MoreHorizontalIcon className="size-4" /> <MoreHorizontalIcon className="w-5" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" onClick={(event) => event.stopPropagation()}> <DropdownMenuContent align="end" onClick={(event) => event.stopPropagation()}>

View File

@@ -1,4 +1,4 @@
/** biome-ignore-all lint/correctness/useHookAtTopLevel: <explanation> */ /** biome-ignore-all lint/correctness/useHookAtTopLevel: Hooks live inside memoized column definitions */
import { t } from "@lingui/core/macro" import { t } from "@lingui/core/macro"
import { Trans, useLingui } from "@lingui/react/macro" import { Trans, useLingui } from "@lingui/react/macro"
import { useStore } from "@nanostores/react" import { useStore } from "@nanostores/react"
@@ -24,7 +24,7 @@ import {
import { memo, useMemo, useRef, useState } from "react" import { memo, useMemo, useRef, useState } from "react"
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip" import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"
import { isReadOnlyUser, pb } from "@/lib/api" import { isReadOnlyUser, pb } from "@/lib/api"
import { ConnectionType, connectionTypeLabels, MeterState, SystemStatus } from "@/lib/enums" import { BatteryState, ConnectionType, connectionTypeLabels, MeterState, SystemStatus } from "@/lib/enums"
import { $longestSystemNameLen, $userSettings } from "@/lib/stores" import { $longestSystemNameLen, $userSettings } from "@/lib/stores"
import { import {
cn, cn,
@@ -35,6 +35,7 @@ import {
getMeterState, getMeterState,
parseSemVer, parseSemVer,
} from "@/lib/utils" } from "@/lib/utils"
import { batteryStateTranslations } from "@/lib/i18n"
import type { SystemRecord } from "@/types" import type { SystemRecord } from "@/types"
import { SystemDialog } from "../add-system" import { SystemDialog } from "../add-system"
import AlertButton from "../alerts/alert-button" import AlertButton from "../alerts/alert-button"
@@ -58,7 +59,18 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "../ui/dropdown-menu" } from "../ui/dropdown-menu"
import { EthernetIcon, GpuIcon, HourglassIcon, ThermometerIcon, WebSocketIcon } from "../ui/icons" import {
BatteryMediumIcon,
EthernetIcon,
GpuIcon,
HourglassIcon,
ThermometerIcon,
WebSocketIcon,
BatteryHighIcon,
BatteryLowIcon,
PlugChargingIcon,
BatteryFullIcon,
} from "../ui/icons"
const STATUS_COLORS = { const STATUS_COLORS = {
[SystemStatus.Up]: "bg-green-500", [SystemStatus.Up]: "bg-green-500",
@@ -261,6 +273,52 @@ export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<Syste
) )
}, },
}, },
{
accessorFn: ({ info }) => info.bat?.[0],
id: "battery",
name: () => t({ message: "Bat", comment: "Battery label in systems table header" }),
size: 70,
Icon: BatteryMediumIcon,
header: sortableHeader,
hideSort: true,
cell(info) {
const [pct, state] = info.row.original.info.bat ?? []
if (pct === undefined) {
return null
}
const iconColor = pct < 10 ? "text-red-500" : pct < 25 ? "text-yellow-500" : "text-muted-foreground"
let Icon = PlugChargingIcon
if (state !== BatteryState.Charging) {
if (pct < 25) {
Icon = BatteryLowIcon
} else if (pct < 75) {
Icon = BatteryMediumIcon
} else if (pct < 95) {
Icon = BatteryHighIcon
} else {
Icon = BatteryFullIcon
}
}
const stateLabel =
state !== undefined ? (batteryStateTranslations[state as BatteryState]?.() ?? undefined) : undefined
return (
<Link
tabIndex={-1}
href={getPagePath($router, "system", { id: info.row.original.id })}
className="flex items-center gap-1 tabular-nums tracking-tight relative z-10"
title={stateLabel}
>
<Icon className={cn("size-3.5", iconColor)} />
<span className="min-w-10">{pct}%</span>
</Link>
)
},
},
{ {
accessorFn: ({ info }) => info.sv?.[0], accessorFn: ({ info }) => info.sv?.[0],
id: "services", id: "services",
@@ -599,5 +657,5 @@ export const ActionsButton = memo(({ system }: { system: SystemRecord }) => {
</AlertDialog> </AlertDialog>
</> </>
) )
}, [id, status, host, name, t, deleteOpen, editOpen]) }, [id, status, host, name, system, t, deleteOpen, editOpen])
}) })

View File

@@ -131,6 +131,7 @@ export function HourglassIcon(props: SVGProps<SVGSVGElement>) {
) )
} }
// Apache 2.0 https://github.com/Templarian/MaterialDesign/blob/master/LICENSE
export function WebSocketIcon(props: SVGProps<SVGSVGElement>) { export function WebSocketIcon(props: SVGProps<SVGSVGElement>) {
return ( return (
<svg viewBox="0 0 256 193" {...props} fill="currentColor"> <svg viewBox="0 0 256 193" {...props} fill="currentColor">
@@ -139,3 +140,48 @@ export function WebSocketIcon(props: SVGProps<SVGSVGElement>) {
</svg> </svg>
) )
} }
// Apache 2.0 https://github.com/Templarian/MaterialDesign/blob/master/LICENSE
export function BatteryMediumIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" {...props} fill="currentColor">
<path d="M16 13H8V6h8m.67-2H15V2H9v2H7.33A1.33 1.33 0 0 0 6 5.33v15.34C6 21.4 6.6 22 7.33 22h9.34A1.33 1.33 0 0 0 18 20.67V5.33C18 4.6 17.4 4 16.67 4" />
</svg>
)
}
// Apache 2.0 https://github.com/Templarian/MaterialDesign/blob/master/LICENSE
export function BatteryLowIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" {...props} fill="currentColor">
<path d="M16 20H8V6h8m.67-2H15V2H9v2H7.33C6.6 4 6 4.6 6 5.33v15.34C6 21.4 6.6 22 7.33 22h9.34c.74 0 1.33-.59 1.33-1.33V5.33C18 4.6 17.4 4 16.67 4M15 16H9v3h6zm0-4.5H9v3h6z" />
</svg>
)
}
// Apache 2.0 https://github.com/Templarian/MaterialDesign/blob/master/LICENSE
export function BatteryHighIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" {...props} fill="currentColor">
<path d="M16 9H8V6h8m.67-2H15V2H9v2H7.33A1.33 1.33 0 0 0 6 5.33v15.34C6 21.4 6.6 22 7.33 22h9.34A1.33 1.33 0 0 0 18 20.67V5.33C18 4.6 17.4 4 16.67 4" />
</svg>
)
}
// Apache 2.0 https://github.com/Templarian/MaterialDesign/blob/master/LICENSE
export function BatteryFullIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" {...props} fill="currentColor">
<path d="M16.67 4H15V2H9v2H7.33A1.33 1.33 0 0 0 6 5.33v15.34C6 21.4 6.6 22 7.33 22h9.34A1.33 1.33 0 0 0 18 20.67V5.33C18 4.6 17.4 4 16.67 4" />
</svg>
)
}
// https://github.com/phosphor-icons/core (MIT license)
export function PlugChargingIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 256 256" {...props} fill="currentColor">
<path d="M224,48H180V16a12,12,0,0,0-24,0V48H100V16a12,12,0,0,0-24,0V48H32.55C24.4,48,20,54.18,20,60A12,12,0,0,0,32,72H44v92a44.05,44.05,0,0,0,44,44h28v32a12,12,0,0,0,24,0V208h28a44.05,44.05,0,0,0,44-44V72h12a12,12,0,0,0,0-24ZM188,164a20,20,0,0,1-20,20H88a20,20,0,0,1-20-20V72H188Zm-85.86-29.17a12,12,0,0,1-1.38-11l12-32a12,12,0,1,1,22.48,8.42L129.32,116H144a12,12,0,0,1,11.24,16.21l-12,32a12,12,0,0,1-22.48-8.42L126.68,140H112A12,12,0,0,1,102.14,134.83Z" />
</svg>
)
}

View File

@@ -1,10 +1,11 @@
import { t } from "@lingui/core/macro" import { t } from "@lingui/core/macro"
import { CpuIcon, HardDriveIcon, HourglassIcon, MemoryStickIcon, ServerIcon, ThermometerIcon } from "lucide-react" import { CpuIcon, HardDriveIcon, MemoryStickIcon, ServerIcon } from "lucide-react"
import type { RecordSubscription } from "pocketbase" import type { RecordSubscription } from "pocketbase"
import { EthernetIcon, GpuIcon } from "@/components/ui/icons" import { EthernetIcon, GpuIcon } from "@/components/ui/icons"
import { $alerts } from "@/lib/stores" import { $alerts } from "@/lib/stores"
import type { AlertInfo, AlertRecord } from "@/types" import type { AlertInfo, AlertRecord } from "@/types"
import { pb } from "./api" import { pb } from "./api"
import { ThermometerIcon, BatteryMediumIcon, HourglassIcon } from "@/components/ui/icons"
/** Alert info for each alert type */ /** Alert info for each alert type */
export const alertInfo: Record<string, AlertInfo> = { export const alertInfo: Record<string, AlertInfo> = {
@@ -83,6 +84,14 @@ export const alertInfo: Record<string, AlertInfo> = {
step: 0.1, step: 0.1,
desc: () => t`Triggers when 15 minute load average exceeds a threshold`, desc: () => t`Triggers when 15 minute load average exceeds a threshold`,
}, },
Battery: {
name: () => t`Battery`,
unit: "%",
icon: BatteryMediumIcon,
desc: () => t`Triggers when battery charge drops below a threshold`,
start: 20,
invert: true,
},
} as const } as const
/** Helper to manage user alerts */ /** Helper to manage user alerts */

View File

@@ -94,11 +94,6 @@ export default [
label: "Português", label: "Português",
e: "🇧🇷", e: "🇧🇷",
}, },
{
lang: "tr",
label: "Türkçe",
e: "🇹🇷",
},
{ {
lang: "ru", lang: "ru",
label: "Русский", label: "Русский",
@@ -109,11 +104,21 @@ export default [
label: "Slovenščina", label: "Slovenščina",
e: "🇸🇮", e: "🇸🇮",
}, },
{
lang: "sr",
label: "Српски",
e: "🇷🇸",
},
{ {
lang: "sv", lang: "sv",
label: "Svenska", label: "Svenska",
e: "🇸🇪", e: "🇸🇪",
}, },
{
lang: "tr",
label: "Türkçe",
e: "🇹🇷",
},
{ {
lang: "uk", lang: "uk",
label: "Українська", label: "Українська",

File diff suppressed because it is too large Load Diff

View File

@@ -61,6 +61,8 @@ export interface SystemInfo {
mp: number mp: number
/** disk percent */ /** disk percent */
dp: number dp: number
/** battery percent and state */
bat?: [number, BatteryState]
/** bandwidth (mb) */ /** bandwidth (mb) */
b: number b: number
/** bandwidth bytes */ /** bandwidth bytes */
@@ -331,6 +333,7 @@ export interface AlertInfo {
start?: number start?: number
/** Single value description (when there's only one value, like status) */ /** Single value description (when there's only one value, like status) */
singleDesc?: () => string singleDesc?: () => string
invert?: boolean
} }
export type AlertMap = Record<string, Map<string, AlertRecord>> export type AlertMap = Record<string, Map<string, AlertRecord>>
@@ -377,6 +380,19 @@ export interface SmartAttribute {
wf?: string wf?: string
} }
export interface SystemDetailsRecord extends RecordModel {
system: string
hostname: string
kernel: string
cores: number
threads: number
cpu: string
os: Os
os_name: string
memory: number
podman: boolean
}
export interface SmartDeviceRecord extends RecordModel { export interface SmartDeviceRecord extends RecordModel {
id: string id: string
system: string system: string

View File

@@ -1,3 +1,9 @@
## 0.18.0
- Remove `la1`, `la5`, `la15` fields from `Info` struct in favor of `la` array.
- Remove `MB` bandwidth values in favor of bytes.
## 0.17.0 ## 0.17.0
- Add quiet hours to silence alerts during specific time periods. (#265) - Add quiet hours to silence alerts during specific time periods. (#265)

View File

@@ -504,10 +504,11 @@ KEY=$(echo "$KEY" | tr -d '\n')
# Verify checksum # Verify checksum
if command -v sha256sum >/dev/null; then if command -v sha256sum >/dev/null; then
CHECK_CMD="sha256sum" CHECK_CMD="sha256sum"
elif command -v md5 >/dev/null; then elif command -v sha256 >/dev/null; then
CHECK_CMD="md5 -q" # FreeBSD uses 'sha256' instead of 'sha256sum', with different output format
CHECK_CMD="sha256 -q"
else else
echo "No MD5 checksum utility found" echo "No SHA256 checksum utility found"
exit 1 exit 1
fi fi

View File

@@ -1,22 +1,8 @@
#!/bin/bash #!/bin/sh
# Check if running as root is_freebsd() {
if [ "$(id -u)" != "0" ]; then [ "$(uname -s)" = "FreeBSD" ]
if command -v sudo >/dev/null 2>&1; then }
exec sudo "$0" "$@"
else
echo "This script must be run as root. Please either:"
echo "1. Run this script as root (su root)"
echo "2. Install sudo and run with sudo"
exit 1
fi
fi
# Define default values
version=0.0.1
PORT=8090 # Default port
GITHUB_PROXY_URL="https://ghfast.top/" # Default proxy URL
AUTO_UPDATE_FLAG="false" # default to no auto-updates, "true" means enable
# Function to ensure the proxy URL ends with a / # Function to ensure the proxy URL ends with a /
ensure_trailing_slash() { ensure_trailing_slash() {
@@ -30,14 +16,155 @@ ensure_trailing_slash() {
fi fi
} }
# Ensure the proxy URL ends with a / # Generate FreeBSD rc service content
GITHUB_PROXY_URL=$(ensure_trailing_slash "$GITHUB_PROXY_URL") generate_freebsd_rc_service() {
cat <<'EOF'
#!/bin/sh
# PROVIDE: beszel_hub
# REQUIRE: DAEMON NETWORKING
# BEFORE: LOGIN
# KEYWORD: shutdown
# Add the following lines to /etc/rc.conf to configure Beszel Hub:
#
# beszel_hub_enable (bool): Set to YES to enable Beszel Hub
# Default: YES
# beszel_hub_port (str): Port to listen on
# Default: 8090
# beszel_hub_user (str): Beszel Hub daemon user
# Default: beszel
# beszel_hub_bin (str): Path to the beszel binary
# Default: /usr/local/sbin/beszel
# beszel_hub_data (str): Path to the beszel data directory
# Default: /usr/local/etc/beszel/beszel_data
# beszel_hub_flags (str): Extra flags passed to beszel command invocation
# Default:
. /etc/rc.subr
name="beszel_hub"
rcvar=beszel_hub_enable
load_rc_config $name
: ${beszel_hub_enable:="YES"}
: ${beszel_hub_port:="8090"}
: ${beszel_hub_user:="beszel"}
: ${beszel_hub_flags:=""}
: ${beszel_hub_bin:="/usr/local/sbin/beszel"}
: ${beszel_hub_data:="/usr/local/etc/beszel/beszel_data"}
logfile="/var/log/${name}.log"
pidfile="/var/run/${name}.pid"
procname="/usr/sbin/daemon"
start_precmd="${name}_prestart"
start_cmd="${name}_start"
stop_cmd="${name}_stop"
extra_commands="upgrade"
upgrade_cmd="beszel_hub_upgrade"
beszel_hub_prestart()
{
if [ ! -d "${beszel_hub_data}" ]; then
echo "Creating data directory ${beszel_hub_data}"
mkdir -p "${beszel_hub_data}"
chown "${beszel_hub_user}:${beszel_hub_user}" "${beszel_hub_data}"
fi
}
beszel_hub_start()
{
echo "Starting ${name}"
cd "$(dirname "${beszel_hub_data}")" || exit 1
/usr/sbin/daemon -f \
-P "${pidfile}" \
-o "${logfile}" \
-u "${beszel_hub_user}" \
"${beszel_hub_bin}" serve --http "0.0.0.0:${beszel_hub_port}" ${beszel_hub_flags}
}
beszel_hub_stop()
{
pid="$(check_pidfile "${pidfile}" "${procname}")"
if [ -n "${pid}" ]; then
echo "Stopping ${name} (pid=${pid})"
kill -- "-${pid}"
wait_for_pids "${pid}"
else
echo "${name} isn't running"
fi
}
beszel_hub_upgrade()
{
echo "Upgrading ${name}"
if command -v sudo >/dev/null; then
sudo -u "${beszel_hub_user}" -- "${beszel_hub_bin}" update
else
su -m "${beszel_hub_user}" -c "${beszel_hub_bin} update"
fi
}
run_rc_command "$1"
EOF
}
# Detect system architecture
detect_architecture() {
arch=$(uname -m)
case "$arch" in
x86_64)
arch="amd64"
;;
armv7l)
arch="arm"
;;
aarch64)
arch="arm64"
;;
esac
echo "$arch"
}
# Build sudo args by properly quoting everything
build_sudo_args() {
QUOTED_ARGS=""
while [ $# -gt 0 ]; do
if [ -n "$QUOTED_ARGS" ]; then
QUOTED_ARGS="$QUOTED_ARGS "
fi
QUOTED_ARGS="$QUOTED_ARGS'$(echo "$1" | sed "s/'/'\\\\''/g")'"
shift
done
echo "$QUOTED_ARGS"
}
# Check if running as root and re-execute with sudo if needed
if [ "$(id -u)" != "0" ]; then
if command -v sudo >/dev/null 2>&1; then
SUDO_ARGS=$(build_sudo_args "$@")
eval "exec sudo $0 $SUDO_ARGS"
else
echo "This script must be run as root. Please either:"
echo "1. Run this script as root (su root)"
echo "2. Install sudo and run with sudo"
exit 1
fi
fi
# Define default values
PORT=8090
GITHUB_PROXY_URL="https://ghfast.top/"
AUTO_UPDATE_FLAG="false"
UNINSTALL=false
# Parse command line arguments # Parse command line arguments
while [ $# -gt 0 ]; do while [ $# -gt 0 ]; do
case "$1" in case "$1" in
-u) -u)
UNINSTALL="true" UNINSTALL=true
shift shift
;; ;;
-h|--help) -h|--help)
@@ -72,37 +199,75 @@ while [ $# -gt 0 ]; do
esac esac
done done
if [ "$UNINSTALL" = "true" ]; then # Ensure the proxy URL ends with a /
# Stop and disable the Beszel Hub service GITHUB_PROXY_URL=$(ensure_trailing_slash "$GITHUB_PROXY_URL")
echo "Stopping and disabling the Beszel Hub service..."
systemctl stop beszel-hub.service
systemctl disable beszel-hub.service
# Remove the systemd service file # Set paths based on operating system
echo "Removing the systemd service file..." if is_freebsd; then
rm -f /etc/systemd/system/beszel-hub.service HUB_DIR="/usr/local/etc/beszel"
BIN_PATH="/usr/local/sbin/beszel"
else
HUB_DIR="/opt/beszel"
BIN_PATH="/opt/beszel/beszel"
fi
# Remove the update timer and service if they exist # Uninstall process
echo "Removing the daily update service and timer..." if [ "$UNINSTALL" = true ]; then
systemctl stop beszel-hub-update.timer 2>/dev/null if is_freebsd; then
systemctl disable beszel-hub-update.timer 2>/dev/null echo "Stopping and disabling the Beszel Hub service..."
rm -f /etc/systemd/system/beszel-hub-update.service service beszel-hub stop 2>/dev/null
rm -f /etc/systemd/system/beszel-hub-update.timer sysrc beszel_hub_enable="NO" 2>/dev/null
# Reload the systemd daemon echo "Removing the FreeBSD service files..."
echo "Reloading the systemd daemon..." rm -f /usr/local/etc/rc.d/beszel-hub
systemctl daemon-reload
# Remove the Beszel Hub binary and data echo "Removing the daily update cron job..."
echo "Removing the Beszel Hub binary and data..." rm -f /etc/cron.d/beszel-hub
rm -rf /opt/beszel
# Remove the dedicated user echo "Removing log files..."
echo "Removing the dedicated user..." rm -f /var/log/beszel_hub.log
userdel beszel 2>/dev/null
echo "The Beszel Hub has been uninstalled successfully!" echo "Removing the Beszel Hub binary and data..."
exit 0 rm -f "$BIN_PATH"
rm -rf "$HUB_DIR"
echo "Removing the dedicated user..."
pw user del beszel 2>/dev/null
echo "The Beszel Hub has been uninstalled successfully!"
exit 0
else
# Stop and disable the Beszel Hub service
echo "Stopping and disabling the Beszel Hub service..."
systemctl stop beszel-hub.service
systemctl disable beszel-hub.service
# Remove the systemd service file
echo "Removing the systemd service file..."
rm -f /etc/systemd/system/beszel-hub.service
# Remove the update timer and service if they exist
echo "Removing the daily update service and timer..."
systemctl stop beszel-hub-update.timer 2>/dev/null
systemctl disable beszel-hub-update.timer 2>/dev/null
rm -f /etc/systemd/system/beszel-hub-update.service
rm -f /etc/systemd/system/beszel-hub-update.timer
# Reload the systemd daemon
echo "Reloading the systemd daemon..."
systemctl daemon-reload
# Remove the Beszel Hub binary and data
echo "Removing the Beszel Hub binary and data..."
rm -rf "$HUB_DIR"
# Remove the dedicated user
echo "Removing the dedicated user..."
userdel beszel 2>/dev/null
echo "The Beszel Hub has been uninstalled successfully!"
exit 0
fi
fi fi
# Function to check if a package is installed # Function to check if a package is installed
@@ -111,7 +276,12 @@ package_installed() {
} }
# Check for package manager and install necessary packages if not installed # Check for package manager and install necessary packages if not installed
if package_installed apt-get; then if package_installed pkg && is_freebsd; then
if ! package_installed tar || ! package_installed curl; then
pkg update
pkg install -y gtar curl
fi
elif package_installed apt-get; then
if ! package_installed tar || ! package_installed curl; then if ! package_installed tar || ! package_installed curl; then
apt-get update apt-get update
apt-get install -y tar curl apt-get install -y tar curl
@@ -129,28 +299,91 @@ else
fi fi
# Create a dedicated user for the service if it doesn't exist # Create a dedicated user for the service if it doesn't exist
if ! id -u beszel >/dev/null 2>&1; then echo "Creating a dedicated user for the Beszel Hub service..."
echo "Creating a dedicated user for the Beszel Hub service..." if is_freebsd; then
useradd -M -s /bin/false beszel if ! id -u beszel >/dev/null 2>&1; then
pw user add beszel -d /nonexistent -s /usr/sbin/nologin -c "beszel user"
fi
else
if ! id -u beszel >/dev/null 2>&1; then
useradd -M -s /bin/false beszel
fi
fi fi
# Create the directory for the Beszel Hub
echo "Creating the directory for the Beszel Hub..."
mkdir -p "$HUB_DIR/beszel_data"
chown -R beszel:beszel "$HUB_DIR"
chmod 755 "$HUB_DIR"
# Download and install the Beszel Hub # Download and install the Beszel Hub
echo "Downloading and installing the Beszel Hub..." echo "Downloading and installing the Beszel Hub..."
curl -sL "${GITHUB_PROXY_URL}https://github.com/henrygd/beszel/releases/latest/download/beszel_$(uname -s)_$(uname -m | sed 's/x86_64/amd64/' | sed 's/armv7l/arm/' | sed 's/aarch64/arm64/').tar.gz" | tar -xz -O beszel | tee ./beszel >/dev/null && chmod +x beszel
mkdir -p /opt/beszel/beszel_data
mv ./beszel /opt/beszel/beszel
chown -R beszel:beszel /opt/beszel
# Create the systemd service OS=$(uname -s | tr '[:upper:]' '[:lower:]')
printf "Creating the systemd service for the Beszel Hub...\n\n" ARCH=$(detect_architecture)
tee /etc/systemd/system/beszel-hub.service <<EOF FILE_NAME="beszel_${OS}_${ARCH}.tar.gz"
curl -sL "${GITHUB_PROXY_URL}https://github.com/henrygd/beszel/releases/latest/download/$FILE_NAME" | tar -xz -O beszel | tee ./beszel >/dev/null
chmod +x ./beszel
mv ./beszel "$BIN_PATH"
chown beszel:beszel "$BIN_PATH"
if is_freebsd; then
echo "Creating FreeBSD rc service..."
# Create the rc service file
generate_freebsd_rc_service > /usr/local/etc/rc.d/beszel-hub
# Set proper permissions for the rc script
chmod 755 /usr/local/etc/rc.d/beszel-hub
# Configure the port
sysrc beszel_hub_port="$PORT"
# Enable and start the service
echo "Enabling and starting the Beszel Hub service..."
sysrc beszel_hub_enable="YES"
service beszel-hub restart
# Check if service started successfully
sleep 2
if ! service beszel-hub status | grep -q "is running"; then
echo "Error: The Beszel Hub service failed to start. Checking logs..."
tail -n 20 /var/log/beszel_hub.log
exit 1
fi
# Auto-update service for FreeBSD
if [ "$AUTO_UPDATE_FLAG" = "true" ]; then
echo "Setting up daily automatic updates for beszel-hub..."
# Create cron job in /etc/cron.d
cat >/etc/cron.d/beszel-hub <<EOF
# Beszel Hub daily update job
12 8 * * * root $BIN_PATH update >/dev/null 2>&1
EOF
chmod 644 /etc/cron.d/beszel-hub
printf "\nDaily updates have been enabled via /etc/cron.d.\n"
fi
# Check service status
if ! service beszel-hub status >/dev/null 2>&1; then
echo "Error: The Beszel Hub service is not running."
service beszel-hub status
exit 1
fi
else
# Original systemd service installation code
printf "Creating the systemd service for the Beszel Hub...\n\n"
tee /etc/systemd/system/beszel-hub.service <<EOF
[Unit] [Unit]
Description=Beszel Hub Service Description=Beszel Hub Service
After=network.target After=network.target
[Service] [Service]
ExecStart=/opt/beszel/beszel serve --http "0.0.0.0:$PORT" ExecStart=$BIN_PATH serve --http "0.0.0.0:$PORT"
WorkingDirectory=/opt/beszel WorkingDirectory=$HUB_DIR
User=beszel User=beszel
Restart=always Restart=always
RestartSec=5 RestartSec=5
@@ -159,39 +392,39 @@ RestartSec=5
WantedBy=multi-user.target WantedBy=multi-user.target
EOF EOF
# Load and start the service # Load and start the service
printf "\nLoading and starting the Beszel Hub service...\n" printf "\nLoading and starting the Beszel Hub service...\n"
systemctl daemon-reload systemctl daemon-reload
systemctl enable beszel-hub.service systemctl enable beszel-hub.service
systemctl start beszel-hub.service systemctl start beszel-hub.service
# Wait for the service to start or fail # Wait for the service to start or fail
sleep 2 sleep 2
# Check if the service is running # Check if the service is running
if [ "$(systemctl is-active beszel-hub.service)" != "active" ]; then if [ "$(systemctl is-active beszel-hub.service)" != "active" ]; then
echo "Error: The Beszel Hub service is not running." echo "Error: The Beszel Hub service is not running."
echo "$(systemctl status beszel-hub.service)" echo "$(systemctl status beszel-hub.service)"
exit 1 exit 1
fi fi
# Enable auto-update if flag is set to true # Enable auto-update if flag is set to true
if [ "$AUTO_UPDATE_FLAG" = "true" ]; then if [ "$AUTO_UPDATE_FLAG" = "true" ]; then
echo "Setting up daily automatic updates for beszel-hub..." echo "Setting up daily automatic updates for beszel-hub..."
# Create systemd service for the daily update # Create systemd service for the daily update
cat >/etc/systemd/system/beszel-hub-update.service <<EOF cat >/etc/systemd/system/beszel-hub-update.service <<EOF
[Unit] [Unit]
Description=Update beszel-hub if needed Description=Update beszel-hub if needed
Wants=beszel-hub.service Wants=beszel-hub.service
[Service] [Service]
Type=oneshot Type=oneshot
ExecStart=/opt/beszel/beszel update ExecStart=$BIN_PATH update
EOF EOF
# Create systemd timer for the daily update # Create systemd timer for the daily update
cat >/etc/systemd/system/beszel-hub-update.timer <<EOF cat >/etc/systemd/system/beszel-hub-update.timer <<EOF
[Unit] [Unit]
Description=Run beszel-hub update daily Description=Run beszel-hub update daily
@@ -204,10 +437,11 @@ RandomizedDelaySec=4h
WantedBy=timers.target WantedBy=timers.target
EOF EOF
systemctl daemon-reload systemctl daemon-reload
systemctl enable --now beszel-hub-update.timer systemctl enable --now beszel-hub-update.timer
printf "\nDaily updates have been enabled.\n" printf "\nDaily updates have been enabled.\n"
fi
fi fi
echo "The Beszel Hub has been installed and configured successfully! It is now accessible on port $PORT." echo "The Beszel Hub has been installed and configured successfully! It is now accessible on port $PORT."