mirror of
https://github.com/henrygd/beszel.git
synced 2026-03-25 06:56:17 +01:00
Compare commits
12 Commits
v0.17.0
...
952-collec
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d71714cbba | ||
|
|
35329abcbd | ||
|
|
ee7741c3ab | ||
|
|
ab0803b2da | ||
|
|
96196a353c | ||
|
|
2a8796c38d | ||
|
|
c8d4f7427d | ||
|
|
8d41a797d3 | ||
|
|
570e1cbf40 | ||
|
|
4c9b00a066 | ||
|
|
7d1f8bb180 | ||
|
|
3a6caeb06e |
@@ -22,6 +22,14 @@ import (
|
|||||||
gossh "golang.org/x/crypto/ssh"
|
gossh "golang.org/x/crypto/ssh"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// StaticInfoIntervalMs defines the cache time threshold for including static system info
|
||||||
|
// Requests with cache time >= this value will include static info (reduces bandwidth)
|
||||||
|
// Note: uint16 max is 65535, so we can't use 15 minutes directly. The hub will make
|
||||||
|
// periodic requests at this interval.
|
||||||
|
StaticInfoIntervalMs uint16 = 60_001 // Just above the standard 60s interval
|
||||||
|
)
|
||||||
|
|
||||||
type Agent struct {
|
type Agent struct {
|
||||||
sync.Mutex // Used to lock agent while collecting data
|
sync.Mutex // Used to lock agent while collecting data
|
||||||
debug bool // true if LOG_LEVEL is set to debug
|
debug bool // true if LOG_LEVEL is set to debug
|
||||||
@@ -37,7 +45,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 dashboard data)
|
||||||
|
staticSystemInfo system.StaticInfo // Static system info (collected at longer intervals)
|
||||||
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
|
||||||
@@ -164,6 +173,14 @@ func (a *Agent) gatherStats(cacheTimeMs uint16) *system.CombinedData {
|
|||||||
}
|
}
|
||||||
// slog.Info("System data", "data", data, "cacheTimeMs", cacheTimeMs)
|
// slog.Info("System data", "data", data, "cacheTimeMs", cacheTimeMs)
|
||||||
|
|
||||||
|
// Include static info for requests with longer intervals (e.g., 15 min)
|
||||||
|
// This reduces bandwidth by only sending static data occasionally
|
||||||
|
if cacheTimeMs >= StaticInfoIntervalMs {
|
||||||
|
staticInfoCopy := a.staticSystemInfo
|
||||||
|
data.StaticInfo = &staticInfoCopy
|
||||||
|
slog.Debug("Including static info", "cacheTimeMs", cacheTimeMs)
|
||||||
|
}
|
||||||
|
|
||||||
if a.dockerManager != nil {
|
if a.dockerManager != nil {
|
||||||
if containerStats, err := a.dockerManager.getDockerStats(cacheTimeMs); err == nil {
|
if containerStats, err := a.dockerManager.getDockerStats(cacheTimeMs); err == nil {
|
||||||
data.Containers = containerStats
|
data.Containers = containerStats
|
||||||
@@ -225,7 +242,11 @@ 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
|
cpuModel := ""
|
||||||
|
if len(a.staticSystemInfo.Cpus) > 0 {
|
||||||
|
cpuModel = a.staticSystemInfo.Cpus[0].Model
|
||||||
|
}
|
||||||
|
fingerprint = a.staticSystemInfo.Hostname + cpuModel
|
||||||
}
|
}
|
||||||
|
|
||||||
// hash fingerprint
|
// hash fingerprint
|
||||||
|
|||||||
@@ -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.staticSystemInfo.Hostname
|
||||||
serverAddr := client.agent.connectionManager.serverOptions.Addr
|
serverAddr := client.agent.connectionManager.serverOptions.Addr
|
||||||
_, response.Port, _ = net.SplitHostPort(serverAddr)
|
_, response.Port, _ = net.SplitHostPort(serverAddr)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -564,7 +564,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
|
a.staticSystemInfo.Podman = true
|
||||||
manager.goodDockerVersion = true
|
manager.goodDockerVersion = true
|
||||||
return manager
|
return manager
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1053,53 +1053,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 +1212,3 @@ func TestAnsiEscapePattern(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -552,11 +552,8 @@ func createTestCombinedData() *system.CombinedData {
|
|||||||
},
|
},
|
||||||
Info: system.Info{
|
Info: system.Info{
|
||||||
Hostname: "test-host",
|
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{
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
255
agent/system.go
255
agent/system.go
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
|
"runtime"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -12,7 +13,9 @@ import (
|
|||||||
"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/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
"github.com/jaypipes/ghw/pkg/block"
|
||||||
|
ghwnet "github.com/jaypipes/ghw/pkg/net"
|
||||||
|
ghwpci "github.com/jaypipes/ghw/pkg/pci"
|
||||||
"github.com/shirou/gopsutil/v4/cpu"
|
"github.com/shirou/gopsutil/v4/cpu"
|
||||||
"github.com/shirou/gopsutil/v4/host"
|
"github.com/shirou/gopsutil/v4/host"
|
||||||
"github.com/shirou/gopsutil/v4/load"
|
"github.com/shirou/gopsutil/v4/load"
|
||||||
@@ -28,41 +31,76 @@ 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) initializeSystemInfo() {
|
||||||
a.systemInfo.AgentVersion = beszel.Version
|
hostname, _ := os.Hostname()
|
||||||
a.systemInfo.Hostname, _ = os.Hostname()
|
a.staticSystemInfo.Hostname = hostname
|
||||||
|
a.staticSystemInfo.AgentVersion = beszel.Version
|
||||||
|
|
||||||
platform, _, version, _ := host.PlatformInformation()
|
platform, family, version, _ := host.PlatformInformation()
|
||||||
|
|
||||||
|
var osFamily, osVersion, osKernel string
|
||||||
|
var osType system.Os
|
||||||
if platform == "darwin" {
|
if platform == "darwin" {
|
||||||
a.systemInfo.KernelVersion = version
|
osKernel = version
|
||||||
a.systemInfo.Os = system.Darwin
|
osFamily = "macOS" // macOS is the family name for Darwin
|
||||||
|
osVersion = 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)
|
osKernel = strings.Replace(platform, "Microsoft ", "", 1) + " " + version
|
||||||
a.systemInfo.Os = system.Windows
|
osFamily = family
|
||||||
|
osVersion = version
|
||||||
|
osType = system.Windows
|
||||||
} else if platform == "freebsd" {
|
} else if platform == "freebsd" {
|
||||||
a.systemInfo.Os = system.Freebsd
|
osKernel = version
|
||||||
a.systemInfo.KernelVersion = version
|
osFamily = family
|
||||||
|
osVersion = version
|
||||||
} else {
|
} else {
|
||||||
a.systemInfo.Os = system.Linux
|
osFamily = family
|
||||||
|
osVersion = version
|
||||||
|
osKernel = ""
|
||||||
|
osRelease := readOsRelease()
|
||||||
|
if pretty, ok := osRelease["PRETTY_NAME"]; ok {
|
||||||
|
osFamily = pretty
|
||||||
|
}
|
||||||
|
if name, ok := osRelease["NAME"]; ok {
|
||||||
|
osFamily = name
|
||||||
|
}
|
||||||
|
if versionId, ok := osRelease["VERSION_ID"]; ok {
|
||||||
|
osVersion = versionId
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
if osKernel == "" {
|
||||||
if a.systemInfo.KernelVersion == "" {
|
osKernel, _ = host.KernelVersion()
|
||||||
a.systemInfo.KernelVersion, _ = host.KernelVersion()
|
|
||||||
}
|
}
|
||||||
|
a.staticSystemInfo.KernelVersion = osKernel
|
||||||
|
a.staticSystemInfo.Os = osType
|
||||||
|
a.staticSystemInfo.Oses = []system.OsInfo{{
|
||||||
|
Family: osFamily,
|
||||||
|
Version: osVersion,
|
||||||
|
Kernel: osKernel,
|
||||||
|
}}
|
||||||
|
|
||||||
// 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
|
arch := runtime.GOARCH
|
||||||
}
|
totalCores := 0
|
||||||
// cores / threads
|
totalThreads := 0
|
||||||
a.systemInfo.Cores, _ = cpu.Counts(false)
|
for _, cpuInfo := range info {
|
||||||
if threads, err := cpu.Counts(true); err == nil {
|
totalCores += int(cpuInfo.Cores)
|
||||||
if threads > 0 && threads < a.systemInfo.Cores {
|
totalThreads++
|
||||||
// in lxc logical cores reflects container limits, so use that as cores if lower
|
|
||||||
a.systemInfo.Cores = threads
|
|
||||||
} else {
|
|
||||||
a.systemInfo.Threads = threads
|
|
||||||
}
|
}
|
||||||
|
modelName := info[0].ModelName
|
||||||
|
if idx := strings.Index(modelName, "@"); idx > 0 {
|
||||||
|
modelName = strings.TrimSpace(modelName[:idx])
|
||||||
|
}
|
||||||
|
cpu := system.CpuInfo{
|
||||||
|
Model: modelName,
|
||||||
|
SpeedGHz: fmt.Sprintf("%.2f GHz", info[0].Mhz/1000),
|
||||||
|
Arch: arch,
|
||||||
|
Cores: totalCores,
|
||||||
|
Threads: totalThreads,
|
||||||
|
}
|
||||||
|
a.staticSystemInfo.Cpus = []system.CpuInfo{cpu}
|
||||||
|
a.staticSystemInfo.Threads = totalThreads
|
||||||
|
slog.Debug("CPU info populated", "cpus", a.staticSystemInfo.Cpus)
|
||||||
}
|
}
|
||||||
|
|
||||||
// zfs
|
// zfs
|
||||||
@@ -71,6 +109,41 @@ func (a *Agent) initializeSystemInfo() {
|
|||||||
} else {
|
} else {
|
||||||
a.zfs = true
|
a.zfs = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Collect disk info (model/vendor)
|
||||||
|
a.staticSystemInfo.Disks = getDiskInfo()
|
||||||
|
|
||||||
|
// Collect network interface info
|
||||||
|
a.staticSystemInfo.Networks = getNetworkInfo()
|
||||||
|
|
||||||
|
// Collect total memory and store in staticSystemInfo.Memory
|
||||||
|
if v, err := mem.VirtualMemory(); err == nil {
|
||||||
|
total := fmt.Sprintf("%d GB", int((float64(v.Total)/(1024*1024*1024))+0.5))
|
||||||
|
a.staticSystemInfo.Memory = []system.MemoryInfo{{Total: total}}
|
||||||
|
slog.Debug("Memory info populated", "memory", a.staticSystemInfo.Memory)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// readPrettyName reads the PRETTY_NAME from /etc/os-release
|
||||||
|
func readPrettyName() string {
|
||||||
|
file, err := os.Open("/etc/os-release")
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(file)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
if strings.HasPrefix(line, "PRETTY_NAME=") {
|
||||||
|
// Remove the prefix and any surrounding quotes
|
||||||
|
prettyName := strings.TrimPrefix(line, "PRETTY_NAME=")
|
||||||
|
prettyName = strings.Trim(prettyName, `"`)
|
||||||
|
return prettyName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns current info, stats about the host system
|
// Returns current info, stats about the host system
|
||||||
@@ -205,6 +278,7 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
|
|||||||
a.systemInfo.LoadAvg15 = systemStats.LoadAvg[2]
|
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
|
// TODO: in future release, remove MB bandwidth values in favor of bytes
|
||||||
a.systemInfo.Bandwidth = twoDecimals(systemStats.NetworkSent + systemStats.NetworkRecv)
|
a.systemInfo.Bandwidth = twoDecimals(systemStats.NetworkSent + systemStats.NetworkRecv)
|
||||||
@@ -239,3 +313,136 @@ func getARCSize() (uint64, error) {
|
|||||||
|
|
||||||
return 0, fmt.Errorf("failed to parse size field")
|
return 0, fmt.Errorf("failed to parse size field")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getDiskInfo() []system.DiskInfo {
|
||||||
|
blockInfo, err := block.New()
|
||||||
|
if err != nil {
|
||||||
|
slog.Debug("Failed to get block info with ghw", "err", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var disks []system.DiskInfo
|
||||||
|
for _, disk := range blockInfo.Disks {
|
||||||
|
disks = append(disks, system.DiskInfo{
|
||||||
|
Name: disk.Name,
|
||||||
|
Model: disk.Model,
|
||||||
|
Vendor: disk.Vendor,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return disks
|
||||||
|
}
|
||||||
|
|
||||||
|
func getNetworkInfo() []system.NetworkInfo {
|
||||||
|
netInfo, err := ghwnet.New()
|
||||||
|
if err != nil {
|
||||||
|
slog.Debug("Failed to get network info with ghw", "err", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
pciInfo, err := ghwpci.New()
|
||||||
|
if err != nil {
|
||||||
|
slog.Debug("Failed to get PCI info with ghw", "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var networks []system.NetworkInfo
|
||||||
|
for _, nic := range netInfo.NICs {
|
||||||
|
if nic.IsVirtual {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var vendor, model string
|
||||||
|
if nic.PCIAddress != nil && pciInfo != nil {
|
||||||
|
for _, dev := range pciInfo.Devices {
|
||||||
|
if dev.Address == *nic.PCIAddress {
|
||||||
|
if dev.Vendor != nil {
|
||||||
|
vendor = dev.Vendor.Name
|
||||||
|
}
|
||||||
|
if dev.Product != nil {
|
||||||
|
model = dev.Product.Name
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
networks = append(networks, system.NetworkInfo{
|
||||||
|
Name: nic.Name,
|
||||||
|
Vendor: vendor,
|
||||||
|
Model: model,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return networks
|
||||||
|
}
|
||||||
|
|
||||||
|
// getInterfaceCapabilitiesFromGhw uses ghw library to get interface capabilities
|
||||||
|
func getInterfaceCapabilitiesFromGhw(nic *ghwnet.NIC) string {
|
||||||
|
// Use the speed information from ghw if available
|
||||||
|
if nic.Speed != "" {
|
||||||
|
return nic.Speed
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no speed info from ghw, try to get interface type from name
|
||||||
|
return getInterfaceTypeFromName(nic.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getInterfaceTypeFromName tries to determine interface type from name
|
||||||
|
func getInterfaceTypeFromName(ifaceName string) string {
|
||||||
|
// Common interface naming patterns
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(ifaceName, "eth"):
|
||||||
|
return "Ethernet"
|
||||||
|
case strings.HasPrefix(ifaceName, "en"):
|
||||||
|
return "Ethernet"
|
||||||
|
case strings.HasPrefix(ifaceName, "wlan"):
|
||||||
|
return "WiFi"
|
||||||
|
case strings.HasPrefix(ifaceName, "wl"):
|
||||||
|
return "WiFi"
|
||||||
|
case strings.HasPrefix(ifaceName, "usb"):
|
||||||
|
return "USB"
|
||||||
|
case strings.HasPrefix(ifaceName, "tun"):
|
||||||
|
return "Tunnel"
|
||||||
|
case strings.HasPrefix(ifaceName, "tap"):
|
||||||
|
return "TAP"
|
||||||
|
case strings.HasPrefix(ifaceName, "br"):
|
||||||
|
return "Bridge"
|
||||||
|
case strings.HasPrefix(ifaceName, "bond"):
|
||||||
|
return "Bond"
|
||||||
|
case strings.HasPrefix(ifaceName, "veth"):
|
||||||
|
return "Virtual Ethernet"
|
||||||
|
case strings.HasPrefix(ifaceName, "docker"):
|
||||||
|
return "Docker"
|
||||||
|
case strings.HasPrefix(ifaceName, "lo"):
|
||||||
|
return "Loopback"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readOsRelease() map[string]string {
|
||||||
|
file, err := os.Open("/etc/os-release")
|
||||||
|
if err != nil {
|
||||||
|
return map[string]string{}
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
release := make(map[string]string)
|
||||||
|
scanner := bufio.NewScanner(file)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
if i := strings.Index(line, "="); i > 0 {
|
||||||
|
key := line[:i]
|
||||||
|
val := strings.Trim(line[i+1:], `"`)
|
||||||
|
release[key] = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return release
|
||||||
|
}
|
||||||
|
|
||||||
|
func getMemoryInfo() []system.MemoryInfo {
|
||||||
|
var total string
|
||||||
|
if v, err := mem.VirtualMemory(); err == nil {
|
||||||
|
total = fmt.Sprintf("%d GB", int((float64(v.Total)/(1024*1024*1024))+0.5))
|
||||||
|
}
|
||||||
|
return []system.MemoryInfo{{
|
||||||
|
Total: total,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
5
go.mod
5
go.mod
@@ -9,6 +9,7 @@ require (
|
|||||||
github.com/fxamacker/cbor/v2 v2.9.0
|
github.com/fxamacker/cbor/v2 v2.9.0
|
||||||
github.com/gliderlabs/ssh v0.3.8
|
github.com/gliderlabs/ssh v0.3.8
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/jaypipes/ghw v0.17.0
|
||||||
github.com/lxzan/gws v1.8.9
|
github.com/lxzan/gws v1.8.9
|
||||||
github.com/nicholas-fedor/shoutrrr v0.12.1
|
github.com/nicholas-fedor/shoutrrr v0.12.1
|
||||||
github.com/pocketbase/dbx v1.11.0
|
github.com/pocketbase/dbx v1.11.0
|
||||||
@@ -24,6 +25,7 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/StackExchange/wmi v1.2.1 // indirect
|
||||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
|
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
|
||||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
|
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
@@ -41,11 +43,14 @@ require (
|
|||||||
github.com/godbus/dbus/v5 v5.2.0 // indirect
|
github.com/godbus/dbus/v5 v5.2.0 // indirect
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
|
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
|
github.com/jaypipes/pcidb v1.0.1 // indirect
|
||||||
github.com/klauspost/compress v1.18.1 // indirect
|
github.com/klauspost/compress v1.18.1 // indirect
|
||||||
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect
|
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
|||||||
11
go.sum
11
go.sum
@@ -2,6 +2,8 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
|||||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||||
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
|
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
|
||||||
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||||
|
github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDOSA=
|
||||||
|
github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8=
|
||||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
|
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
|
||||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
|
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
|
||||||
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=
|
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=
|
||||||
@@ -41,6 +43,7 @@ github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
|||||||
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
||||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
|
github.com/go-ole/go-ole v1.2.5/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||||
@@ -68,6 +71,10 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
|
|||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
github.com/jarcoal/httpmock v1.4.1 h1:0Ju+VCFuARfFlhVXFc2HxlcQkfB+Xq12/EotHko+x2A=
|
github.com/jarcoal/httpmock v1.4.1 h1:0Ju+VCFuARfFlhVXFc2HxlcQkfB+Xq12/EotHko+x2A=
|
||||||
github.com/jarcoal/httpmock v1.4.1/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0=
|
github.com/jarcoal/httpmock v1.4.1/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0=
|
||||||
|
github.com/jaypipes/ghw v0.17.0 h1:EVLJeNcy5z6GK/Lqby0EhBpynZo+ayl8iJWY0kbEUJA=
|
||||||
|
github.com/jaypipes/ghw v0.17.0/go.mod h1:In8SsaDqlb1oTyrbmTC14uy+fbBMvp+xdqX51MidlD8=
|
||||||
|
github.com/jaypipes/pcidb v1.0.1 h1:WB2zh27T3nwg8AE8ei81sNRb9yWBii3JGNJtT7K9Oic=
|
||||||
|
github.com/jaypipes/pcidb v1.0.1/go.mod h1:6xYUz/yYEyOkIkUt2t2J2folIuZ4Yg6uByCGFXMCeE4=
|
||||||
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||||
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
|
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
|
||||||
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
|
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
|
||||||
@@ -83,6 +90,8 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP
|
|||||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
|
||||||
|
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
github.com/nicholas-fedor/shoutrrr v0.12.1 h1:8NjY+I3K7cGHy89ncnaPGUA0ex44XbYK3SAFJX9YMI8=
|
github.com/nicholas-fedor/shoutrrr v0.12.1 h1:8NjY+I3K7cGHy89ncnaPGUA0ex44XbYK3SAFJX9YMI8=
|
||||||
@@ -91,6 +100,8 @@ github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns
|
|||||||
github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
|
github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
|
||||||
github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
|
github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
|
||||||
github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
|
github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
|
||||||
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
387
internal/alerts/alerts_battery_test.go
Normal file
387
internal/alerts/alerts_battery_test.go
Normal 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{
|
||||||
|
Hostname: "test-host",
|
||||||
|
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{
|
||||||
|
Hostname: "test-host",
|
||||||
|
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{
|
||||||
|
Hostname: "test-host",
|
||||||
|
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{
|
||||||
|
Hostname: "test-host",
|
||||||
|
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{
|
||||||
|
Hostname: "test-host",
|
||||||
|
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{
|
||||||
|
Hostname: "test-host",
|
||||||
|
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")
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -115,6 +115,37 @@ const (
|
|||||||
Freebsd
|
Freebsd
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type DiskInfo struct {
|
||||||
|
Name string `json:"n"`
|
||||||
|
Model string `json:"m,omitempty"`
|
||||||
|
Vendor string `json:"v,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type NetworkInfo struct {
|
||||||
|
Name string `json:"n"`
|
||||||
|
Vendor string `json:"v,omitempty"`
|
||||||
|
Model string `json:"m,omitempty"`
|
||||||
|
Speed string `json:"s,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MemoryInfo struct {
|
||||||
|
Total string `json:"t,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CpuInfo struct {
|
||||||
|
Model string `json:"m"`
|
||||||
|
SpeedGHz string `json:"s"`
|
||||||
|
Arch string `json:"a"`
|
||||||
|
Cores int `json:"c"`
|
||||||
|
Threads int `json:"t"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OsInfo struct {
|
||||||
|
Family string `json:"f"`
|
||||||
|
Version string `json:"v"`
|
||||||
|
Kernel string `json:"k"`
|
||||||
|
}
|
||||||
|
|
||||||
type ConnectionType = uint8
|
type ConnectionType = uint8
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -123,31 +154,41 @@ const (
|
|||||||
ConnectionTypeWebSocket
|
ConnectionTypeWebSocket
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// StaticInfo contains system information that rarely or never changes
|
||||||
|
// This is collected at a longer interval (e.g., 10-15 minutes) to reduce bandwidth
|
||||||
|
type StaticInfo struct {
|
||||||
|
Hostname string `json:"h" cbor:"0,keyasint"`
|
||||||
|
KernelVersion string `json:"k,omitempty" cbor:"1,keyasint,omitempty"`
|
||||||
|
Threads int `json:"t,omitempty" cbor:"2,keyasint,omitempty"`
|
||||||
|
AgentVersion string `json:"v" cbor:"3,keyasint"`
|
||||||
|
Podman bool `json:"p,omitempty" cbor:"4,keyasint,omitempty"`
|
||||||
|
Os Os `json:"os" cbor:"5,keyasint"`
|
||||||
|
Disks []DiskInfo `json:"d,omitempty" cbor:"6,omitempty"`
|
||||||
|
Networks []NetworkInfo `json:"n,omitempty" cbor:"7,omitempty"`
|
||||||
|
Memory []MemoryInfo `json:"m" cbor:"8"`
|
||||||
|
Cpus []CpuInfo `json:"c" cbor:"9"`
|
||||||
|
Oses []OsInfo `json:"o,omitempty" cbor:"10,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Info contains frequently-changing system snapshot data for the dashboard
|
||||||
type Info struct {
|
type Info struct {
|
||||||
Hostname string `json:"h" cbor:"0,keyasint"`
|
Uptime uint64 `json:"u" cbor:"0,keyasint"`
|
||||||
KernelVersion string `json:"k,omitempty" cbor:"1,keyasint,omitempty"`
|
Cpu float64 `json:"cpu" cbor:"1,keyasint"`
|
||||||
Cores int `json:"c" cbor:"2,keyasint"`
|
MemPct float64 `json:"mp" cbor:"2,keyasint"`
|
||||||
Threads int `json:"t,omitempty" cbor:"3,keyasint,omitempty"`
|
DiskPct float64 `json:"dp" cbor:"3,keyasint"`
|
||||||
CpuModel string `json:"m" cbor:"4,keyasint"`
|
Bandwidth float64 `json:"b" cbor:"4,keyasint"`
|
||||||
Uptime uint64 `json:"u" cbor:"5,keyasint"`
|
GpuPct float64 `json:"g,omitempty" cbor:"5,keyasint,omitempty"`
|
||||||
Cpu float64 `json:"cpu" cbor:"6,keyasint"`
|
DashboardTemp float64 `json:"dt,omitempty" cbor:"6,keyasint,omitempty"`
|
||||||
MemPct float64 `json:"mp" cbor:"7,keyasint"`
|
LoadAvg1 float64 `json:"l1,omitempty" cbor:"7,keyasint,omitempty"`
|
||||||
DiskPct float64 `json:"dp" cbor:"8,keyasint"`
|
LoadAvg5 float64 `json:"l5,omitempty" cbor:"8,keyasint,omitempty"`
|
||||||
Bandwidth float64 `json:"b" cbor:"9,keyasint"`
|
LoadAvg15 float64 `json:"l15,omitempty" cbor:"9,keyasint,omitempty"`
|
||||||
AgentVersion string `json:"v" cbor:"10,keyasint"`
|
BandwidthBytes uint64 `json:"bb" cbor:"10,keyasint"`
|
||||||
Podman bool `json:"p,omitempty" cbor:"11,keyasint,omitempty"`
|
|
||||||
GpuPct float64 `json:"g,omitempty" cbor:"12,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"`
|
|
||||||
LoadAvg5 float64 `json:"l5,omitempty" cbor:"16,keyasint,omitempty"`
|
|
||||||
LoadAvg15 float64 `json:"l15,omitempty" cbor:"17,keyasint,omitempty"`
|
|
||||||
BandwidthBytes uint64 `json:"bb" cbor:"18,keyasint"`
|
|
||||||
// TODO: remove load fields in future release in favor of load avg array
|
// 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]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Final data structure to return to the hub
|
// Final data structure to return to the hub
|
||||||
@@ -156,4 +197,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"`
|
||||||
|
StaticInfo *StaticInfo `json:"static_info,omitempty" cbor:"4,keyasint,omitempty"` // Collected at longer intervals
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ type System struct {
|
|||||||
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
|
||||||
|
staticInfo *system.StaticInfo // cached static system info, fetched once per connection
|
||||||
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
|
||||||
@@ -114,8 +115,22 @@ func (sys *System) update() error {
|
|||||||
sys.handlePaused()
|
sys.handlePaused()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
data, err := sys.fetchDataFromAgent(common.DataRequestOptions{CacheTimeMs: uint16(interval)})
|
|
||||||
|
// Determine which cache time to use based on whether we need static info
|
||||||
|
cacheTimeMs := uint16(interval)
|
||||||
|
if sys.staticInfo == nil {
|
||||||
|
// Request with a cache time that signals the agent to include static info
|
||||||
|
// We use 60001ms (just above the standard interval) since uint16 max is 65535
|
||||||
|
cacheTimeMs = 60_001
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := sys.fetchDataFromAgent(common.DataRequestOptions{CacheTimeMs: cacheTimeMs})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
// If we received static info, cache it
|
||||||
|
if data.StaticInfo != nil {
|
||||||
|
sys.staticInfo = data.StaticInfo
|
||||||
|
sys.manager.hub.Logger().Debug("Cached static system info", "system", sys.Id)
|
||||||
|
}
|
||||||
_, err = sys.createRecords(data)
|
_, err = sys.createRecords(data)
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
@@ -136,6 +151,11 @@ func (sys *System) handlePaused() {
|
|||||||
|
|
||||||
// createRecords updates the system record and adds system_stats and container_stats records
|
// createRecords updates the system record and adds system_stats and container_stats records
|
||||||
func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error) {
|
func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error) {
|
||||||
|
// Build complete info combining dynamic and static data
|
||||||
|
completeInfo := sys.buildCompleteInfo(data)
|
||||||
|
|
||||||
|
sys.manager.hub.Logger().Debug("Creating records - complete info", "info", completeInfo)
|
||||||
|
|
||||||
systemRecord, err := sys.getRecord()
|
systemRecord, err := sys.getRecord()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -186,7 +206,7 @@ func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error
|
|||||||
// 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", completeInfo)
|
||||||
if err := txApp.SaveNoValidate(systemRecord); err != nil {
|
if err := txApp.SaveNoValidate(systemRecord); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -203,6 +223,70 @@ func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error
|
|||||||
return systemRecord, err
|
return systemRecord, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildCompleteInfo combines the dynamic Info with cached StaticInfo to create a complete system info structure
|
||||||
|
// This is needed because we've split the original Info structure for bandwidth optimization
|
||||||
|
func (sys *System) buildCompleteInfo(data *system.CombinedData) map[string]interface{} {
|
||||||
|
info := make(map[string]interface{})
|
||||||
|
|
||||||
|
// Add dynamic fields from data.Info
|
||||||
|
if data.Info.Uptime > 0 {
|
||||||
|
info["u"] = data.Info.Uptime
|
||||||
|
}
|
||||||
|
info["cpu"] = data.Info.Cpu
|
||||||
|
info["mp"] = data.Info.MemPct
|
||||||
|
info["dp"] = data.Info.DiskPct
|
||||||
|
info["b"] = data.Info.Bandwidth
|
||||||
|
info["bb"] = data.Info.BandwidthBytes
|
||||||
|
if data.Info.GpuPct > 0 {
|
||||||
|
info["g"] = data.Info.GpuPct
|
||||||
|
}
|
||||||
|
if data.Info.DashboardTemp > 0 {
|
||||||
|
info["dt"] = data.Info.DashboardTemp
|
||||||
|
}
|
||||||
|
if data.Info.LoadAvg1 > 0 || data.Info.LoadAvg5 > 0 || data.Info.LoadAvg15 > 0 {
|
||||||
|
info["l1"] = data.Info.LoadAvg1
|
||||||
|
info["l5"] = data.Info.LoadAvg5
|
||||||
|
info["l15"] = data.Info.LoadAvg15
|
||||||
|
info["la"] = data.Info.LoadAvg
|
||||||
|
}
|
||||||
|
if data.Info.ConnectionType > 0 {
|
||||||
|
info["ct"] = data.Info.ConnectionType
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add static fields from cached staticInfo
|
||||||
|
if sys.staticInfo != nil {
|
||||||
|
info["h"] = sys.staticInfo.Hostname
|
||||||
|
if sys.staticInfo.KernelVersion != "" {
|
||||||
|
info["k"] = sys.staticInfo.KernelVersion
|
||||||
|
}
|
||||||
|
if sys.staticInfo.Threads > 0 {
|
||||||
|
info["t"] = sys.staticInfo.Threads
|
||||||
|
}
|
||||||
|
info["v"] = sys.staticInfo.AgentVersion
|
||||||
|
if sys.staticInfo.Podman {
|
||||||
|
info["p"] = true
|
||||||
|
}
|
||||||
|
info["os"] = sys.staticInfo.Os
|
||||||
|
if len(sys.staticInfo.Cpus) > 0 {
|
||||||
|
info["c"] = sys.staticInfo.Cpus
|
||||||
|
}
|
||||||
|
if len(sys.staticInfo.Memory) > 0 {
|
||||||
|
info["m"] = sys.staticInfo.Memory
|
||||||
|
}
|
||||||
|
if len(sys.staticInfo.Disks) > 0 {
|
||||||
|
info["d"] = sys.staticInfo.Disks
|
||||||
|
}
|
||||||
|
if len(sys.staticInfo.Networks) > 0 {
|
||||||
|
info["n"] = sys.staticInfo.Networks
|
||||||
|
}
|
||||||
|
if len(sys.staticInfo.Oses) > 0 {
|
||||||
|
info["o"] = sys.staticInfo.Oses
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|
||||||
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
|
||||||
@@ -602,6 +686,7 @@ func (sys *System) closeSSHConnection() {
|
|||||||
sys.client.Close()
|
sys.client.Close()
|
||||||
sys.client = nil
|
sys.client = nil
|
||||||
}
|
}
|
||||||
|
sys.staticInfo = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// closeWebSocketConnection closes the WebSocket connection but keeps the system in the manager
|
// closeWebSocketConnection closes the WebSocket connection but keeps the system in the manager
|
||||||
@@ -611,6 +696,7 @@ func (sys *System) closeWebSocketConnection() {
|
|||||||
if sys.WsConn != nil {
|
if sys.WsConn != nil {
|
||||||
sys.WsConn.Close(nil)
|
sys.WsConn.Close(nil)
|
||||||
}
|
}
|
||||||
|
sys.staticInfo = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractAgentVersion extracts the beszel version from SSH server version string
|
// extractAgentVersion extracts the beszel version from SSH server version string
|
||||||
|
|||||||
@@ -78,7 +78,8 @@ func init() {
|
|||||||
"GPU",
|
"GPU",
|
||||||
"LoadAvg1",
|
"LoadAvg1",
|
||||||
"LoadAvg5",
|
"LoadAvg5",
|
||||||
"LoadAvg15"
|
"LoadAvg15",
|
||||||
|
"Battery"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -24,6 +24,7 @@ export default defineConfig({
|
|||||||
"tr",
|
"tr",
|
||||||
"ru",
|
"ru",
|
||||||
"sl",
|
"sl",
|
||||||
|
"sr",
|
||||||
"sv",
|
"sv",
|
||||||
"uk",
|
"uk",
|
||||||
"vi",
|
"vi",
|
||||||
|
|||||||
18
internal/site/package-lock.json
generated
18
internal/site/package-lock.json
generated
@@ -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": {
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -77,4 +77,4 @@
|
|||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@esbuild/linux-arm64": "^0.21.5"
|
"@esbuild/linux-arm64": "^0.21.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ import {
|
|||||||
ClockArrowUp,
|
ClockArrowUp,
|
||||||
CpuIcon,
|
CpuIcon,
|
||||||
GlobeIcon,
|
GlobeIcon,
|
||||||
|
HardDriveIcon,
|
||||||
LayoutGridIcon,
|
LayoutGridIcon,
|
||||||
MonitorIcon,
|
MonitorIcon,
|
||||||
|
ServerIcon,
|
||||||
XIcon,
|
XIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { subscribeKeys } from "nanostores"
|
import { subscribeKeys } from "nanostores"
|
||||||
@@ -66,7 +68,7 @@ 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 { AppleIcon, ChartAverage, ChartMax, EthernetIcon, FreeBsdIcon, Rows, TuxIcon, WebSocketIcon, WindowsIcon } 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 { Separator } from "../ui/separator"
|
||||||
@@ -333,6 +335,20 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
|||||||
})
|
})
|
||||||
}, [system, chartTime])
|
}, [system, chartTime])
|
||||||
|
|
||||||
|
// Helper to format hardware info (disk/nic) with vendor and model
|
||||||
|
const formatHardwareInfo = useCallback((item: { n: string; v?: string; m?: string }) => {
|
||||||
|
const vendor = item.v && item.v.toLowerCase() !== 'unknown' ? item.v : null
|
||||||
|
const model = item.m && item.m.toLowerCase() !== 'unknown' ? item.m : null
|
||||||
|
if (vendor && model) {
|
||||||
|
return `${item.n}: ${vendor} ${model}`
|
||||||
|
} else if (model) {
|
||||||
|
return `${item.n}: ${model}`
|
||||||
|
} else if (vendor) {
|
||||||
|
return `${item.n}: ${vendor}`
|
||||||
|
}
|
||||||
|
return item.n
|
||||||
|
}, [])
|
||||||
|
|
||||||
// values for system info bar
|
// values for system info bar
|
||||||
const systemInfo = useMemo(() => {
|
const systemInfo = useMemo(() => {
|
||||||
if (!system.info) {
|
if (!system.info) {
|
||||||
@@ -366,6 +382,11 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
|||||||
} else {
|
} else {
|
||||||
uptime = secondsToString(system.info.u, "day")
|
uptime = secondsToString(system.info.u, "day")
|
||||||
}
|
}
|
||||||
|
// Extract CPU and Memory info from arrays
|
||||||
|
const cpuInfo = system.info.c && system.info.c.length > 0 ? system.info.c[0] : undefined
|
||||||
|
const memoryInfo = system.info.m && system.info.m.length > 0 ? system.info.m[0] : undefined
|
||||||
|
const osData = system.info.o && system.info.o.length > 0 ? system.info.o[0] : undefined
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{ value: getHostDisplayValue(system), Icon: GlobeIcon },
|
{ value: getHostDisplayValue(system), Icon: GlobeIcon },
|
||||||
{
|
{
|
||||||
@@ -376,19 +397,43 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
|||||||
hide: system.info.h === system.host || system.info.h === system.name,
|
hide: system.info.h === system.host || system.info.h === system.name,
|
||||||
},
|
},
|
||||||
{ value: uptime, Icon: ClockArrowUp, label: t`Uptime`, hide: !system.info.u },
|
{ value: uptime, Icon: ClockArrowUp, label: t`Uptime`, hide: !system.info.u },
|
||||||
osInfo[system.info.os ?? Os.Linux],
|
osData ? {
|
||||||
{
|
value: `${osData.f} ${osData.v}`.trim(),
|
||||||
value: `${system.info.m} (${system.info.c}c${system.info.t ? `/${system.info.t}t` : ""})`,
|
Icon: osInfo[system.info.os ?? Os.Linux]?.Icon ?? TuxIcon,
|
||||||
|
label: osData.k ? `Kernel: ${osData.k}` : undefined,
|
||||||
|
} : osInfo[system.info.os ?? Os.Linux],
|
||||||
|
cpuInfo ? {
|
||||||
|
value: cpuInfo.m,
|
||||||
Icon: CpuIcon,
|
Icon: CpuIcon,
|
||||||
hide: !system.info.m,
|
hide: !cpuInfo.m,
|
||||||
},
|
label: [
|
||||||
] as {
|
(cpuInfo.c || cpuInfo.t) ? `Cores / Threads: ${cpuInfo.c || '?'} / ${cpuInfo.t || cpuInfo.c || '?'}` : null,
|
||||||
|
cpuInfo.a ? `Arch: ${cpuInfo.a}` : null,
|
||||||
|
cpuInfo.s ? `Speed: ${cpuInfo.s}` : null,
|
||||||
|
].filter(Boolean).join('\n'),
|
||||||
|
} : undefined,
|
||||||
|
memoryInfo ? {
|
||||||
|
value: memoryInfo.t,
|
||||||
|
Icon: ServerIcon,
|
||||||
|
label: "Total Memory",
|
||||||
|
} : undefined,
|
||||||
|
system.info.d && system.info.d.length > 0 ? {
|
||||||
|
value: `${system.info.d.length} ${system.info.d.length === 1 ? t`Disk` : t`Disks`}`,
|
||||||
|
Icon: HardDriveIcon,
|
||||||
|
label: system.info.d.map(formatHardwareInfo).join('\n'),
|
||||||
|
} : undefined,
|
||||||
|
system.info.n && system.info.n.length > 0 ? {
|
||||||
|
value: `${system.info.n.length} ${system.info.n.length === 1 ? t`NIC` : t`NICs`}`,
|
||||||
|
Icon: EthernetIcon,
|
||||||
|
label: system.info.n.map(formatHardwareInfo).join('\n'),
|
||||||
|
} : undefined,
|
||||||
|
].filter(Boolean) as {
|
||||||
value: string | number | undefined
|
value: string | number | undefined
|
||||||
label?: string
|
label?: string
|
||||||
Icon: React.ElementType
|
Icon: React.ElementType
|
||||||
hide?: boolean
|
hide?: boolean
|
||||||
}[]
|
}[]
|
||||||
}, [system, t])
|
}, [system, t, formatHardwareInfo])
|
||||||
|
|
||||||
/** 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(() => {
|
||||||
|
|||||||
@@ -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()}>
|
||||||
|
|||||||
@@ -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])
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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: "Українська",
|
||||||
|
|||||||
1692
internal/site/src/locales/sr/sr.po
Normal file
1692
internal/site/src/locales/sr/sr.po
Normal file
File diff suppressed because it is too large
Load Diff
37
internal/site/src/types.d.ts
vendored
37
internal/site/src/types.d.ts
vendored
@@ -27,12 +27,32 @@ export interface SystemRecord extends RecordModel {
|
|||||||
host: string
|
host: string
|
||||||
status: "up" | "down" | "paused" | "pending"
|
status: "up" | "down" | "paused" | "pending"
|
||||||
port: string
|
port: string
|
||||||
info: SystemInfo
|
info: systemInfo
|
||||||
v: string
|
v: string
|
||||||
updated: string
|
updated: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SystemInfo {
|
export interface CpuInfo {
|
||||||
|
m: string
|
||||||
|
s: string
|
||||||
|
a: string
|
||||||
|
c: number
|
||||||
|
t: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OsInfo {
|
||||||
|
f: string
|
||||||
|
v: string
|
||||||
|
k: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NetworkLocationInfo {
|
||||||
|
ip?: string
|
||||||
|
isp?: string
|
||||||
|
asn?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface systemInfo {
|
||||||
/** hostname */
|
/** hostname */
|
||||||
h: string
|
h: string
|
||||||
/** kernel **/
|
/** kernel **/
|
||||||
@@ -61,6 +81,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 */
|
||||||
@@ -73,6 +95,16 @@ export interface SystemInfo {
|
|||||||
g?: number
|
g?: number
|
||||||
/** dashboard display temperature */
|
/** dashboard display temperature */
|
||||||
dt?: number
|
dt?: number
|
||||||
|
/** disks info (array of block devices with model/vendor/serial) */
|
||||||
|
d?: { n: string; m?: string; v?: string; serial?: string }[]
|
||||||
|
/** networks info (array of network interfaces with vendor/model/capabilities) */
|
||||||
|
n?: { n: string; v?: string; m?: string; s?: string }[]
|
||||||
|
/** memory info (array with total property) */
|
||||||
|
m?: { t: string }[]
|
||||||
|
/** cpu info (array of cpu objects) */
|
||||||
|
c?: CpuInfo[]
|
||||||
|
/** os info (array of os objects) */
|
||||||
|
o?: OsInfo[]
|
||||||
/** operating system */
|
/** operating system */
|
||||||
os?: Os
|
os?: Os
|
||||||
/** connection type */
|
/** connection type */
|
||||||
@@ -331,6 +363,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>>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
Reference in New Issue
Block a user