mirror of
https://github.com/henrygd/beszel.git
synced 2026-03-27 16:06:16 +01:00
Compare commits
19 Commits
v0.10.2
...
755-xpu-sm
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14f7480915 | ||
|
|
aab5725d82 | ||
|
|
e94a1cd421 | ||
|
|
73c1a1b208 | ||
|
|
0526c88ce0 | ||
|
|
a2e9056a00 | ||
|
|
fd4ac60908 | ||
|
|
330e4c67f3 | ||
|
|
5d840bd473 | ||
|
|
54e3f3eba1 | ||
|
|
d79111fce4 | ||
|
|
93c3c7b9d8 | ||
|
|
410d236f89 | ||
|
|
9a8071c314 | ||
|
|
80df0efccd | ||
|
|
3f1f4c7596 | ||
|
|
04ac688be4 | ||
|
|
ace83172ff | ||
|
|
e8b864b515 |
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -3,7 +3,7 @@ name: Make release and binaries
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- '*'
|
- 'v*'
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ builds:
|
|||||||
- linux
|
- linux
|
||||||
- darwin
|
- darwin
|
||||||
- freebsd
|
- freebsd
|
||||||
|
- openbsd
|
||||||
- windows
|
- windows
|
||||||
goarch:
|
goarch:
|
||||||
- amd64
|
- amd64
|
||||||
@@ -39,6 +40,8 @@ builds:
|
|||||||
ignore:
|
ignore:
|
||||||
- goos: freebsd
|
- goos: freebsd
|
||||||
goarch: arm
|
goarch: arm
|
||||||
|
- goos: openbsd
|
||||||
|
goarch: arm
|
||||||
- goos: windows
|
- goos: windows
|
||||||
goarch: arm
|
goarch: arm
|
||||||
- goos: darwin
|
- goos: darwin
|
||||||
@@ -47,7 +50,7 @@ builds:
|
|||||||
goarch: riscv64
|
goarch: riscv64
|
||||||
|
|
||||||
archives:
|
archives:
|
||||||
- id: beszel
|
- id: beszel-agent
|
||||||
format: tar.gz
|
format: tar.gz
|
||||||
builds:
|
builds:
|
||||||
- beszel-agent
|
- beszel-agent
|
||||||
@@ -59,7 +62,7 @@ archives:
|
|||||||
- goos: windows
|
- goos: windows
|
||||||
format: zip
|
format: zip
|
||||||
|
|
||||||
- id: beszel-agent
|
- id: beszel
|
||||||
format: tar.gz
|
format: tar.gz
|
||||||
builds:
|
builds:
|
||||||
- beszel
|
- beszel
|
||||||
@@ -111,6 +114,65 @@ nfpms:
|
|||||||
# https://github.com/goreleaser/goreleaser/issues/5487
|
# https://github.com/goreleaser/goreleaser/issues/5487
|
||||||
#config: ../supplemental/debian/config.sh
|
#config: ../supplemental/debian/config.sh
|
||||||
|
|
||||||
|
scoops:
|
||||||
|
- ids: [beszel-agent]
|
||||||
|
name: beszel-agent
|
||||||
|
repository:
|
||||||
|
owner: henrygd
|
||||||
|
name: beszel-scoops
|
||||||
|
homepage: 'https://beszel.dev'
|
||||||
|
description: 'Agent for Beszel, a lightweight server monitoring platform.'
|
||||||
|
license: MIT
|
||||||
|
|
||||||
|
# # Needs choco installed, so doesn't build on linux / default gh workflow :(
|
||||||
|
# chocolateys:
|
||||||
|
# - title: Beszel Agent
|
||||||
|
# ids: [beszel-agent]
|
||||||
|
# package_source_url: https://github.com/henrygd/beszel-chocolatey
|
||||||
|
# owners: henrygd
|
||||||
|
# authors: henrygd
|
||||||
|
# summary: 'Agent for Beszel, a lightweight server monitoring platform.'
|
||||||
|
# description: |
|
||||||
|
# Beszel is a lightweight server monitoring platform that includes Docker statistics, historical data, and alert functions.
|
||||||
|
|
||||||
|
# It has a friendly web interface, simple configuration, and is ready to use out of the box. It supports automatic backup, multi-user, OAuth authentication, and API access.
|
||||||
|
# license_url: https://github.com/henrygd/beszel/blob/main/LICENSE
|
||||||
|
# project_url: https://beszel.dev
|
||||||
|
# project_source_url: https://github.com/henrygd/beszel
|
||||||
|
# docs_url: https://beszel.dev/guide/getting-started
|
||||||
|
# icon_url: https://cdn.jsdelivr.net/gh/selfhst/icons/png/beszel.png
|
||||||
|
# bug_tracker_url: https://github.com/henrygd/beszel/issues
|
||||||
|
# copyright: 2025 henrygd
|
||||||
|
# tags: foss cross-platform admin monitoring
|
||||||
|
# require_license_acceptance: false
|
||||||
|
# release_notes: 'https://github.com/henrygd/beszel/releases/tag/v{{ .Version }}'
|
||||||
|
|
||||||
|
brews:
|
||||||
|
- ids: [beszel-agent]
|
||||||
|
name: beszel-agent
|
||||||
|
repository:
|
||||||
|
owner: henrygd
|
||||||
|
name: homebrew-beszel
|
||||||
|
homepage: 'https://beszel.dev'
|
||||||
|
description: 'Agent for Beszel, a lightweight server monitoring platform.'
|
||||||
|
license: MIT
|
||||||
|
extra_install: |
|
||||||
|
(bin/"beszel-agent-launcher").write <<~EOS
|
||||||
|
#!/bin/bash
|
||||||
|
set -a
|
||||||
|
if [ -f "$HOME/.config/beszel/beszel-agent.env" ]; then
|
||||||
|
source "$HOME/.config/beszel/beszel-agent.env"
|
||||||
|
fi
|
||||||
|
set +a
|
||||||
|
exec #{bin}/beszel-agent "$@"
|
||||||
|
EOS
|
||||||
|
(bin/"beszel-agent-launcher").chmod 0755
|
||||||
|
service: |
|
||||||
|
run ["#{bin}/beszel-agent-launcher"]
|
||||||
|
log_path "#{Dir.home}/.cache/beszel/beszel-agent.log"
|
||||||
|
error_log_path "#{Dir.home}/.cache/beszel/beszel-agent.log"
|
||||||
|
keep_alive true
|
||||||
|
|
||||||
release:
|
release:
|
||||||
draft: true
|
draft: true
|
||||||
|
|
||||||
|
|||||||
@@ -4,42 +4,36 @@ package agent
|
|||||||
import (
|
import (
|
||||||
"beszel"
|
"beszel"
|
||||||
"beszel/internal/entities/system"
|
"beszel/internal/entities/system"
|
||||||
"context"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/shirou/gopsutil/v4/common"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
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
|
||||||
zfs bool // true if system has arcstats
|
zfs bool // true if system has arcstats
|
||||||
memCalc string // Memory calculation formula
|
memCalc string // Memory calculation formula
|
||||||
fsNames []string // List of filesystem device names being monitored
|
fsNames []string // List of filesystem device names being monitored
|
||||||
fsStats map[string]*system.FsStats // Keeps track of disk stats for each filesystem
|
fsStats map[string]*system.FsStats // Keeps track of disk stats for each filesystem
|
||||||
netInterfaces map[string]struct{} // Stores all valid network interfaces
|
netInterfaces map[string]struct{} // Stores all valid network interfaces
|
||||||
netIoStats system.NetIoStats // Keeps track of bandwidth usage
|
netIoStats system.NetIoStats // Keeps track of bandwidth usage
|
||||||
dockerManager *dockerManager // Manages Docker API requests
|
dockerManager *dockerManager // Manages Docker API requests
|
||||||
sensorsContext context.Context // Sensors context to override sys location
|
sensorConfig *SensorConfig // Sensors config
|
||||||
sensorsWhitelist map[string]struct{} // List of sensors to monitor
|
systemInfo system.Info // Host system info
|
||||||
primarySensor string // Value of PRIMARY_SENSOR env var
|
gpuManager *GPUManager // Manages GPU data
|
||||||
systemInfo system.Info // Host system info
|
cache *SessionCache // Cache for system stats based on primary session ID
|
||||||
gpuManager *GPUManager // Manages GPU data
|
|
||||||
cache *SessionCache // Cache for system stats based on primary session ID
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAgent() *Agent {
|
func NewAgent() *Agent {
|
||||||
agent := &Agent{
|
agent := &Agent{
|
||||||
sensorsContext: context.Background(),
|
fsStats: make(map[string]*system.FsStats),
|
||||||
fsStats: make(map[string]*system.FsStats),
|
cache: NewSessionCache(69 * time.Second),
|
||||||
cache: NewSessionCache(69 * time.Second),
|
|
||||||
}
|
}
|
||||||
agent.memCalc, _ = GetEnv("MEM_CALC")
|
agent.memCalc, _ = GetEnv("MEM_CALC")
|
||||||
agent.primarySensor, _ = GetEnv("PRIMARY_SENSOR")
|
agent.sensorConfig = agent.newSensorConfig()
|
||||||
// Set up slog with a log level determined by the LOG_LEVEL env var
|
// Set up slog with a log level determined by the LOG_LEVEL env var
|
||||||
if logLevelStr, exists := GetEnv("LOG_LEVEL"); exists {
|
if logLevelStr, exists := GetEnv("LOG_LEVEL"); exists {
|
||||||
switch strings.ToLower(logLevelStr) {
|
switch strings.ToLower(logLevelStr) {
|
||||||
@@ -55,24 +49,6 @@ func NewAgent() *Agent {
|
|||||||
|
|
||||||
slog.Debug(beszel.Version)
|
slog.Debug(beszel.Version)
|
||||||
|
|
||||||
// Set sensors context (allows overriding sys location for sensors)
|
|
||||||
if sysSensors, exists := GetEnv("SYS_SENSORS"); exists {
|
|
||||||
slog.Info("SYS_SENSORS", "path", sysSensors)
|
|
||||||
agent.sensorsContext = context.WithValue(agent.sensorsContext,
|
|
||||||
common.EnvKey, common.EnvMap{common.HostSysEnvKey: sysSensors},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set sensors whitelist
|
|
||||||
if sensors, exists := GetEnv("SENSORS"); exists {
|
|
||||||
agent.sensorsWhitelist = make(map[string]struct{})
|
|
||||||
for sensor := range strings.SplitSeq(sensors, ",") {
|
|
||||||
if sensor != "" {
|
|
||||||
agent.sensorsWhitelist[sensor] = struct{}{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// initialize system info / docker manager
|
// initialize system info / docker manager
|
||||||
agent.initializeSystemInfo()
|
agent.initializeSystemInfo()
|
||||||
agent.initializeDiskInfo()
|
agent.initializeDiskInfo()
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -36,7 +37,12 @@ func (a *Agent) initializeDiskInfo() {
|
|||||||
|
|
||||||
// Helper function to add a filesystem to fsStats if it doesn't exist
|
// Helper function to add a filesystem to fsStats if it doesn't exist
|
||||||
addFsStat := func(device, mountpoint string, root bool) {
|
addFsStat := func(device, mountpoint string, root bool) {
|
||||||
key := filepath.Base(device)
|
var key string
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
key = device
|
||||||
|
} else {
|
||||||
|
key = filepath.Base(device)
|
||||||
|
}
|
||||||
var ioMatch bool
|
var ioMatch bool
|
||||||
if _, exists := a.fsStats[key]; !exists {
|
if _, exists := a.fsStats[key]; !exists {
|
||||||
if root {
|
if root {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"beszel/internal/entities/system"
|
"beszel/internal/entities/system"
|
||||||
"bufio"
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"encoding/csv"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
@@ -21,11 +22,13 @@ const (
|
|||||||
nvidiaSmiCmd = "nvidia-smi"
|
nvidiaSmiCmd = "nvidia-smi"
|
||||||
rocmSmiCmd = "rocm-smi"
|
rocmSmiCmd = "rocm-smi"
|
||||||
tegraStatsCmd = "tegrastats"
|
tegraStatsCmd = "tegrastats"
|
||||||
|
xpuSmiCmd = "xpu-smi"
|
||||||
|
|
||||||
// Polling intervals
|
// Polling intervals
|
||||||
nvidiaSmiInterval = "4" // in seconds
|
nvidiaSmiInterval = "4" // in seconds
|
||||||
tegraStatsInterval = "3700" // in milliseconds
|
tegraStatsInterval = "3700" // in milliseconds
|
||||||
rocmSmiInterval = 4300 * time.Millisecond
|
rocmSmiInterval = 4300 * time.Millisecond
|
||||||
|
xpuSmiInterval = 4
|
||||||
|
|
||||||
// Command retry and timeout constants
|
// Command retry and timeout constants
|
||||||
retryWaitTime = 5 * time.Second
|
retryWaitTime = 5 * time.Second
|
||||||
@@ -41,10 +44,11 @@ const (
|
|||||||
// GPUManager manages data collection for GPUs (either Nvidia or AMD)
|
// GPUManager manages data collection for GPUs (either Nvidia or AMD)
|
||||||
type GPUManager struct {
|
type GPUManager struct {
|
||||||
sync.Mutex
|
sync.Mutex
|
||||||
nvidiaSmi bool
|
nvidiaSmi bool
|
||||||
rocmSmi bool
|
rocmSmi bool
|
||||||
tegrastats bool
|
tegrastats bool
|
||||||
GpuDataMap map[string]*system.GPUData
|
intelXpuSmi bool
|
||||||
|
GpuDataMap map[string]*system.GPUData
|
||||||
}
|
}
|
||||||
|
|
||||||
// RocmSmiJson represents the JSON structure of rocm-smi output
|
// RocmSmiJson represents the JSON structure of rocm-smi output
|
||||||
@@ -125,14 +129,13 @@ func (gm *GPUManager) getJetsonParser() func(output []byte) bool {
|
|||||||
// TODO: Maybe use VDD_IN for Nano / NX and add a total system power chart
|
// TODO: Maybe use VDD_IN for Nano / NX and add a total system power chart
|
||||||
powerPattern := regexp.MustCompile(`(GPU_SOC|CPU_GPU_CV) (\d+)mW`)
|
powerPattern := regexp.MustCompile(`(GPU_SOC|CPU_GPU_CV) (\d+)mW`)
|
||||||
|
|
||||||
|
// jetson devices have only one gpu so we'll just initialize here
|
||||||
|
gpuData := &system.GPUData{Name: "GPU"}
|
||||||
|
gm.GpuDataMap["0"] = gpuData
|
||||||
|
|
||||||
return func(output []byte) bool {
|
return func(output []byte) bool {
|
||||||
gm.Lock()
|
gm.Lock()
|
||||||
defer gm.Unlock()
|
defer gm.Unlock()
|
||||||
// we get gpu name from the intitial run of nvidia-smi, so return if it hasn't been initialized
|
|
||||||
gpuData, ok := gm.GpuDataMap["0"]
|
|
||||||
if !ok {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
// Parse RAM usage
|
// Parse RAM usage
|
||||||
ramMatches := ramPattern.FindSubmatch(output)
|
ramMatches := ramPattern.FindSubmatch(output)
|
||||||
if ramMatches != nil {
|
if ramMatches != nil {
|
||||||
@@ -161,6 +164,59 @@ func (gm *GPUManager) getJetsonParser() func(output []byte) bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (gm *GPUManager) parseIntelData(output []byte) bool {
|
||||||
|
gm.Lock()
|
||||||
|
defer gm.Unlock()
|
||||||
|
reader := csv.NewReader(bytes.NewReader(output))
|
||||||
|
records, err := reader.ReadAll()
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("Failed to parse Intel GPU data", "err", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
header := []string{"Timestamp", "DeviceId", "GPU Power (W)", "GPU Frequency (MHz)", "GPU Memory Utilization (%)", "GPU Memory Used (MiB)"}
|
||||||
|
gpuData := &system.GPUData{Name: "GPU"}
|
||||||
|
gm.GpuDataMap["0"] = gpuData
|
||||||
|
|
||||||
|
for _, record := range records {
|
||||||
|
if strings.Join(record, ",") == strings.Join(header, ",") {
|
||||||
|
slog.Debug("Skipping header", "header", record)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var memoryUtilization *float64
|
||||||
|
var memoryUsed *float64
|
||||||
|
for i, field := range header {
|
||||||
|
if field == "Timestamp" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
stripped := strings.TrimSpace(record[i])
|
||||||
|
value, err := strconv.ParseFloat(stripped, 64)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("Failed to parse field", "field", field, "value", stripped, "err", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
switch field {
|
||||||
|
case "GPU Power (W)":
|
||||||
|
gpuData.Power += value
|
||||||
|
case "GPU Frequency (MHz)":
|
||||||
|
gpuData.Usage += value
|
||||||
|
case "GPU Memory Utilization (%)":
|
||||||
|
memoryUtilization = &value
|
||||||
|
case "GPU Memory Used (MiB)":
|
||||||
|
memoryUsed = &value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if memoryUtilization != nil && memoryUsed != nil {
|
||||||
|
gpuData.MemoryUsed = *memoryUsed
|
||||||
|
gpuData.MemoryTotal = (*memoryUsed / *memoryUtilization) * 100 // convert to total memory
|
||||||
|
}
|
||||||
|
}
|
||||||
|
gpuData.Count++
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// parseNvidiaData parses the output of nvidia-smi and updates the GPUData map
|
// parseNvidiaData parses the output of nvidia-smi and updates the GPUData map
|
||||||
func (gm *GPUManager) parseNvidiaData(output []byte) bool {
|
func (gm *GPUManager) parseNvidiaData(output []byte) bool {
|
||||||
gm.Lock()
|
gm.Lock()
|
||||||
@@ -184,12 +240,6 @@ func (gm *GPUManager) parseNvidiaData(output []byte) bool {
|
|||||||
if _, ok := gm.GpuDataMap[id]; !ok {
|
if _, ok := gm.GpuDataMap[id]; !ok {
|
||||||
name := strings.TrimPrefix(fields[1], "NVIDIA ")
|
name := strings.TrimPrefix(fields[1], "NVIDIA ")
|
||||||
gm.GpuDataMap[id] = &system.GPUData{Name: strings.TrimSuffix(name, " Laptop GPU")}
|
gm.GpuDataMap[id] = &system.GPUData{Name: strings.TrimSuffix(name, " Laptop GPU")}
|
||||||
// check if tegrastats is active - if so we will only use nvidia-smi to get gpu name
|
|
||||||
// - nvidia-smi does not provide metrics for tegra / jetson devices
|
|
||||||
// this will end the nvidia-smi collector
|
|
||||||
if gm.tegrastats {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// update gpu data
|
// update gpu data
|
||||||
gpu := gm.GpuDataMap[id]
|
gpu := gm.GpuDataMap[id]
|
||||||
@@ -283,11 +333,16 @@ func (gm *GPUManager) detectGPUs() error {
|
|||||||
}
|
}
|
||||||
if _, err := exec.LookPath(tegraStatsCmd); err == nil {
|
if _, err := exec.LookPath(tegraStatsCmd); err == nil {
|
||||||
gm.tegrastats = true
|
gm.tegrastats = true
|
||||||
|
gm.nvidiaSmi = false
|
||||||
}
|
}
|
||||||
if gm.nvidiaSmi || gm.rocmSmi || gm.tegrastats {
|
fmt.Println("Looking for gpus")
|
||||||
|
if _, err := exec.LookPath(xpuSmiCmd); err == nil {
|
||||||
|
gm.intelXpuSmi = true
|
||||||
|
}
|
||||||
|
if gm.nvidiaSmi || gm.rocmSmi || gm.tegrastats || gm.intelXpuSmi {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return fmt.Errorf("no GPU found - install nvidia-smi, rocm-smi, or tegrastats")
|
return fmt.Errorf("no GPU found - install nvidia-smi, rocm-smi, intel_gpu_top, or tegrastats")
|
||||||
}
|
}
|
||||||
|
|
||||||
// startCollector starts the appropriate GPU data collector based on the command
|
// startCollector starts the appropriate GPU data collector based on the command
|
||||||
@@ -297,9 +352,11 @@ func (gm *GPUManager) startCollector(command string) {
|
|||||||
}
|
}
|
||||||
switch command {
|
switch command {
|
||||||
case nvidiaSmiCmd:
|
case nvidiaSmiCmd:
|
||||||
collector.cmdArgs = []string{"-l", nvidiaSmiInterval,
|
collector.cmdArgs = []string{
|
||||||
|
"-l", nvidiaSmiInterval,
|
||||||
"--query-gpu=index,name,temperature.gpu,memory.used,memory.total,utilization.gpu,power.draw",
|
"--query-gpu=index,name,temperature.gpu,memory.used,memory.total,utilization.gpu,power.draw",
|
||||||
"--format=csv,noheader,nounits"}
|
"--format=csv,noheader,nounits",
|
||||||
|
}
|
||||||
collector.parse = gm.parseNvidiaData
|
collector.parse = gm.parseNvidiaData
|
||||||
go collector.start()
|
go collector.start()
|
||||||
case tegraStatsCmd:
|
case tegraStatsCmd:
|
||||||
@@ -322,6 +379,10 @@ func (gm *GPUManager) startCollector(command string) {
|
|||||||
time.Sleep(rocmSmiInterval)
|
time.Sleep(rocmSmiInterval)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
case xpuSmiCmd:
|
||||||
|
collector.cmdArgs = []string{"dump", "-d", "-1", "-m", "1,2,5,18", "-i", strconv.Itoa(xpuSmiInterval)}
|
||||||
|
collector.parse = gm.parseIntelData
|
||||||
|
go collector.start()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -342,6 +403,9 @@ func NewGPUManager() (*GPUManager, error) {
|
|||||||
if gm.tegrastats {
|
if gm.tegrastats {
|
||||||
gm.startCollector(tegraStatsCmd)
|
gm.startCollector(tegraStatsCmd)
|
||||||
}
|
}
|
||||||
|
if gm.intelXpuSmi {
|
||||||
|
gm.startCollector(xpuSmiCmd)
|
||||||
|
}
|
||||||
|
|
||||||
return &gm, nil
|
return &gm, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -251,14 +251,13 @@ func TestParseJetsonData(t *testing.T) {
|
|||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
input string
|
input string
|
||||||
gm *GPUManager
|
|
||||||
wantMetrics *system.GPUData
|
wantMetrics *system.GPUData
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "valid data",
|
name: "valid data",
|
||||||
input: "11-14-2024 22:54:33 RAM 4300/30698MB GR3D_FREQ 45% tj@52.468C VDD_GPU_SOC 2171mW",
|
input: "11-14-2024 22:54:33 RAM 4300/30698MB GR3D_FREQ 45% tj@52.468C VDD_GPU_SOC 2171mW",
|
||||||
wantMetrics: &system.GPUData{
|
wantMetrics: &system.GPUData{
|
||||||
Name: "Jetson",
|
Name: "GPU",
|
||||||
MemoryUsed: 4300.0,
|
MemoryUsed: 4300.0,
|
||||||
MemoryTotal: 30698.0,
|
MemoryTotal: 30698.0,
|
||||||
Usage: 45.0,
|
Usage: 45.0,
|
||||||
@@ -271,7 +270,7 @@ func TestParseJetsonData(t *testing.T) {
|
|||||||
name: "more valid data",
|
name: "more valid data",
|
||||||
input: "11-15-2024 08:38:09 RAM 6185/7620MB (lfb 8x2MB) SWAP 851/3810MB (cached 1MB) CPU [15%@729,11%@729,14%@729,13%@729,11%@729,8%@729] EMC_FREQ 43%@2133 GR3D_FREQ 63%@[621] NVDEC off NVJPG off NVJPG1 off VIC off OFA off APE 200 cpu@53.968C soc2@52.437C soc0@50.75C gpu@53.343C tj@53.968C soc1@51.656C VDD_IN 12479mW/12479mW VDD_CPU_GPU_CV 4667mW/4667mW VDD_SOC 2817mW/2817mW",
|
input: "11-15-2024 08:38:09 RAM 6185/7620MB (lfb 8x2MB) SWAP 851/3810MB (cached 1MB) CPU [15%@729,11%@729,14%@729,13%@729,11%@729,8%@729] EMC_FREQ 43%@2133 GR3D_FREQ 63%@[621] NVDEC off NVJPG off NVJPG1 off VIC off OFA off APE 200 cpu@53.968C soc2@52.437C soc0@50.75C gpu@53.343C tj@53.968C soc1@51.656C VDD_IN 12479mW/12479mW VDD_CPU_GPU_CV 4667mW/4667mW VDD_SOC 2817mW/2817mW",
|
||||||
wantMetrics: &system.GPUData{
|
wantMetrics: &system.GPUData{
|
||||||
Name: "Jetson",
|
Name: "GPU",
|
||||||
MemoryUsed: 6185.0,
|
MemoryUsed: 6185.0,
|
||||||
MemoryTotal: 7620.0,
|
MemoryTotal: 7620.0,
|
||||||
Usage: 63.0,
|
Usage: 63.0,
|
||||||
@@ -284,7 +283,7 @@ func TestParseJetsonData(t *testing.T) {
|
|||||||
name: "missing temperature",
|
name: "missing temperature",
|
||||||
input: "11-14-2024 22:54:33 RAM 4300/30698MB GR3D_FREQ 45% VDD_GPU_SOC 2171mW",
|
input: "11-14-2024 22:54:33 RAM 4300/30698MB GR3D_FREQ 45% VDD_GPU_SOC 2171mW",
|
||||||
wantMetrics: &system.GPUData{
|
wantMetrics: &system.GPUData{
|
||||||
Name: "Jetson",
|
Name: "GPU",
|
||||||
MemoryUsed: 4300.0,
|
MemoryUsed: 4300.0,
|
||||||
MemoryTotal: 30698.0,
|
MemoryTotal: 30698.0,
|
||||||
Usage: 45.0,
|
Usage: 45.0,
|
||||||
@@ -292,32 +291,18 @@ func TestParseJetsonData(t *testing.T) {
|
|||||||
Count: 1,
|
Count: 1,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "no gpu defined by nvidia-smi",
|
|
||||||
input: "11-14-2024 22:54:33 RAM 4300/30698MB GR3D_FREQ 45% VDD_GPU_SOC 2171mW",
|
|
||||||
gm: &GPUManager{
|
|
||||||
GpuDataMap: map[string]*system.GPUData{},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
if tt.gm != nil {
|
gm := &GPUManager{
|
||||||
// should return if no gpu set by nvidia-smi
|
GpuDataMap: make(map[string]*system.GPUData),
|
||||||
assert.Empty(t, tt.gm.GpuDataMap)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
tt.gm = &GPUManager{
|
parser := gm.getJetsonParser()
|
||||||
GpuDataMap: map[string]*system.GPUData{
|
|
||||||
"0": {Name: "Jetson"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
parser := tt.gm.getJetsonParser()
|
|
||||||
valid := parser([]byte(tt.input))
|
valid := parser([]byte(tt.input))
|
||||||
assert.Equal(t, true, valid)
|
assert.Equal(t, true, valid)
|
||||||
|
|
||||||
got := tt.gm.GpuDataMap["0"]
|
got := gm.GpuDataMap["0"]
|
||||||
require.NotNil(t, got)
|
require.NotNil(t, got)
|
||||||
assert.Equal(t, tt.wantMetrics.Name, got.Name)
|
assert.Equal(t, tt.wantMetrics.Name, got.Name)
|
||||||
assert.InDelta(t, tt.wantMetrics.MemoryUsed, got.MemoryUsed, 0.01)
|
assert.InDelta(t, tt.wantMetrics.MemoryUsed, got.MemoryUsed, 0.01)
|
||||||
@@ -443,7 +428,7 @@ echo "test"`
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
wantNvidiaSmi: true,
|
wantNvidiaSmi: false,
|
||||||
wantRocmSmi: true,
|
wantRocmSmi: true,
|
||||||
wantTegrastats: true,
|
wantTegrastats: true,
|
||||||
wantErr: false,
|
wantErr: false,
|
||||||
|
|||||||
143
beszel/internal/agent/sensors.go
Normal file
143
beszel/internal/agent/sensors.go
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"beszel/internal/entities/system"
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"path"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/shirou/gopsutil/v4/common"
|
||||||
|
"github.com/shirou/gopsutil/v4/sensors"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SensorConfig struct {
|
||||||
|
context context.Context
|
||||||
|
sensors map[string]struct{}
|
||||||
|
primarySensor string
|
||||||
|
isBlacklist bool
|
||||||
|
hasWildcards bool
|
||||||
|
skipCollection bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Agent) newSensorConfig() *SensorConfig {
|
||||||
|
primarySensor, _ := GetEnv("PRIMARY_SENSOR")
|
||||||
|
sysSensors, _ := GetEnv("SYS_SENSORS")
|
||||||
|
sensorsEnvVal, sensorsSet := GetEnv("SENSORS")
|
||||||
|
skipCollection := sensorsSet && sensorsEnvVal == ""
|
||||||
|
|
||||||
|
return a.newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal, skipCollection)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newSensorConfigWithEnv creates a SensorConfig with the provided environment variables
|
||||||
|
// sensorsSet indicates if the SENSORS environment variable was explicitly set (even to empty string)
|
||||||
|
func (a *Agent) newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal string, skipCollection bool) *SensorConfig {
|
||||||
|
config := &SensorConfig{
|
||||||
|
context: context.Background(),
|
||||||
|
primarySensor: primarySensor,
|
||||||
|
skipCollection: skipCollection,
|
||||||
|
sensors: make(map[string]struct{}),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set sensors context (allows overriding sys location for sensors)
|
||||||
|
if sysSensors != "" {
|
||||||
|
slog.Info("SYS_SENSORS", "path", sysSensors)
|
||||||
|
config.context = context.WithValue(config.context,
|
||||||
|
common.EnvKey, common.EnvMap{common.HostSysEnvKey: sysSensors},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handle blacklist
|
||||||
|
if strings.HasPrefix(sensorsEnvVal, "-") {
|
||||||
|
config.isBlacklist = true
|
||||||
|
sensorsEnvVal = sensorsEnvVal[1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
for sensor := range strings.SplitSeq(sensorsEnvVal, ",") {
|
||||||
|
sensor = strings.TrimSpace(sensor)
|
||||||
|
if sensor != "" {
|
||||||
|
config.sensors[sensor] = struct{}{}
|
||||||
|
if strings.Contains(sensor, "*") {
|
||||||
|
config.hasWildcards = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateTemperatures updates the agent with the latest sensor temperatures
|
||||||
|
func (a *Agent) updateTemperatures(systemStats *system.Stats) {
|
||||||
|
// skip if sensors whitelist is set to empty string
|
||||||
|
if a.sensorConfig.skipCollection {
|
||||||
|
slog.Debug("Skipping temperature collection")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// reset high temp
|
||||||
|
a.systemInfo.DashboardTemp = 0
|
||||||
|
|
||||||
|
// get sensor data
|
||||||
|
temps, _ := sensors.TemperaturesWithContext(a.sensorConfig.context)
|
||||||
|
slog.Debug("Temperature", "sensors", temps)
|
||||||
|
|
||||||
|
// return if no sensors
|
||||||
|
if len(temps) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
systemStats.Temperatures = make(map[string]float64, len(temps))
|
||||||
|
for i, sensor := range temps {
|
||||||
|
// skip if temperature is unreasonable
|
||||||
|
if sensor.Temperature <= 0 || sensor.Temperature >= 200 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sensorName := sensor.SensorKey
|
||||||
|
if _, ok := systemStats.Temperatures[sensorName]; ok {
|
||||||
|
// if key already exists, append int to key
|
||||||
|
sensorName = sensorName + "_" + strconv.Itoa(i)
|
||||||
|
}
|
||||||
|
// skip if not in whitelist or blacklist
|
||||||
|
if !isValidSensor(sensorName, a.sensorConfig) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// set dashboard temperature
|
||||||
|
if a.sensorConfig.primarySensor == "" {
|
||||||
|
a.systemInfo.DashboardTemp = max(a.systemInfo.DashboardTemp, sensor.Temperature)
|
||||||
|
} else if a.sensorConfig.primarySensor == sensorName {
|
||||||
|
a.systemInfo.DashboardTemp = sensor.Temperature
|
||||||
|
}
|
||||||
|
systemStats.Temperatures[sensorName] = twoDecimals(sensor.Temperature)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isValidSensor checks if a sensor is valid based on the sensor name and the sensor config
|
||||||
|
func isValidSensor(sensorName string, config *SensorConfig) bool {
|
||||||
|
// if no sensors configured, everything is valid
|
||||||
|
if len(config.sensors) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exact match - return true if whitelist, false if blacklist
|
||||||
|
if _, exactMatch := config.sensors[sensorName]; exactMatch {
|
||||||
|
return !config.isBlacklist
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no wildcards, return true if blacklist, false if whitelist
|
||||||
|
if !config.hasWildcards {
|
||||||
|
return config.isBlacklist
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for wildcard patterns
|
||||||
|
for pattern := range config.sensors {
|
||||||
|
if !strings.Contains(pattern, "*") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if match, _ := path.Match(pattern, sensorName); match {
|
||||||
|
return !config.isBlacklist
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return config.isBlacklist
|
||||||
|
}
|
||||||
374
beszel/internal/agent/sensors_test.go
Normal file
374
beszel/internal/agent/sensors_test.go
Normal file
@@ -0,0 +1,374 @@
|
|||||||
|
//go:build testing
|
||||||
|
// +build testing
|
||||||
|
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/shirou/gopsutil/v4/common"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIsValidSensor(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
sensorName string
|
||||||
|
config *SensorConfig
|
||||||
|
expectedValid bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Whitelist - sensor in list",
|
||||||
|
sensorName: "cpu_temp",
|
||||||
|
config: &SensorConfig{
|
||||||
|
sensors: map[string]struct{}{"cpu_temp": {}},
|
||||||
|
isBlacklist: false,
|
||||||
|
},
|
||||||
|
expectedValid: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Whitelist - sensor not in list",
|
||||||
|
sensorName: "gpu_temp",
|
||||||
|
config: &SensorConfig{
|
||||||
|
sensors: map[string]struct{}{"cpu_temp": {}},
|
||||||
|
isBlacklist: false,
|
||||||
|
},
|
||||||
|
expectedValid: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Blacklist - sensor in list",
|
||||||
|
sensorName: "cpu_temp",
|
||||||
|
config: &SensorConfig{
|
||||||
|
sensors: map[string]struct{}{"cpu_temp": {}},
|
||||||
|
isBlacklist: true,
|
||||||
|
},
|
||||||
|
expectedValid: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Blacklist - sensor not in list",
|
||||||
|
sensorName: "gpu_temp",
|
||||||
|
config: &SensorConfig{
|
||||||
|
sensors: map[string]struct{}{"cpu_temp": {}},
|
||||||
|
isBlacklist: true,
|
||||||
|
},
|
||||||
|
expectedValid: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Whitelist with wildcard - matching pattern",
|
||||||
|
sensorName: "core_0_temp",
|
||||||
|
config: &SensorConfig{
|
||||||
|
sensors: map[string]struct{}{"core_*_temp": {}},
|
||||||
|
isBlacklist: false,
|
||||||
|
hasWildcards: true,
|
||||||
|
},
|
||||||
|
expectedValid: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Whitelist with wildcard - non-matching pattern",
|
||||||
|
sensorName: "gpu_temp",
|
||||||
|
config: &SensorConfig{
|
||||||
|
sensors: map[string]struct{}{"core_*_temp": {}},
|
||||||
|
isBlacklist: false,
|
||||||
|
hasWildcards: true,
|
||||||
|
},
|
||||||
|
expectedValid: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Blacklist with wildcard - matching pattern",
|
||||||
|
sensorName: "core_0_temp",
|
||||||
|
config: &SensorConfig{
|
||||||
|
sensors: map[string]struct{}{"core_*_temp": {}},
|
||||||
|
isBlacklist: true,
|
||||||
|
hasWildcards: true,
|
||||||
|
},
|
||||||
|
expectedValid: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Blacklist with wildcard - non-matching pattern",
|
||||||
|
sensorName: "gpu_temp",
|
||||||
|
config: &SensorConfig{
|
||||||
|
sensors: map[string]struct{}{"core_*_temp": {}},
|
||||||
|
isBlacklist: true,
|
||||||
|
hasWildcards: true,
|
||||||
|
},
|
||||||
|
expectedValid: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "No sensors configured",
|
||||||
|
sensorName: "any_temp",
|
||||||
|
config: &SensorConfig{
|
||||||
|
sensors: map[string]struct{}{},
|
||||||
|
isBlacklist: false,
|
||||||
|
hasWildcards: false,
|
||||||
|
skipCollection: false,
|
||||||
|
},
|
||||||
|
expectedValid: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Mixed patterns in whitelist - exact match",
|
||||||
|
sensorName: "cpu_temp",
|
||||||
|
config: &SensorConfig{
|
||||||
|
sensors: map[string]struct{}{"cpu_temp": {}, "core_*_temp": {}},
|
||||||
|
isBlacklist: false,
|
||||||
|
hasWildcards: true,
|
||||||
|
},
|
||||||
|
expectedValid: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Mixed patterns in whitelist - wildcard match",
|
||||||
|
sensorName: "core_1_temp",
|
||||||
|
config: &SensorConfig{
|
||||||
|
sensors: map[string]struct{}{"cpu_temp": {}, "core_*_temp": {}},
|
||||||
|
isBlacklist: false,
|
||||||
|
hasWildcards: true,
|
||||||
|
},
|
||||||
|
expectedValid: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Mixed patterns in blacklist - exact match",
|
||||||
|
sensorName: "cpu_temp",
|
||||||
|
config: &SensorConfig{
|
||||||
|
sensors: map[string]struct{}{"cpu_temp": {}, "core_*_temp": {}},
|
||||||
|
isBlacklist: true,
|
||||||
|
hasWildcards: true,
|
||||||
|
},
|
||||||
|
expectedValid: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Mixed patterns in blacklist - wildcard match",
|
||||||
|
sensorName: "core_1_temp",
|
||||||
|
config: &SensorConfig{
|
||||||
|
sensors: map[string]struct{}{"cpu_temp": {}, "core_*_temp": {}},
|
||||||
|
isBlacklist: true,
|
||||||
|
hasWildcards: true,
|
||||||
|
},
|
||||||
|
expectedValid: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := isValidSensor(tt.sensorName, tt.config)
|
||||||
|
assert.Equal(t, tt.expectedValid, result, "isValidSensor(%q, config) returned unexpected result", tt.sensorName)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewSensorConfigWithEnv(t *testing.T) {
|
||||||
|
agent := &Agent{}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
primarySensor string
|
||||||
|
sysSensors string
|
||||||
|
sensors string
|
||||||
|
skipCollection bool
|
||||||
|
expectedConfig *SensorConfig
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Empty configuration",
|
||||||
|
primarySensor: "",
|
||||||
|
sysSensors: "",
|
||||||
|
sensors: "",
|
||||||
|
expectedConfig: &SensorConfig{
|
||||||
|
context: context.Background(),
|
||||||
|
primarySensor: "",
|
||||||
|
sensors: map[string]struct{}{},
|
||||||
|
isBlacklist: false,
|
||||||
|
hasWildcards: false,
|
||||||
|
skipCollection: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Explicitly set to empty string",
|
||||||
|
primarySensor: "",
|
||||||
|
sysSensors: "",
|
||||||
|
sensors: "",
|
||||||
|
skipCollection: true,
|
||||||
|
expectedConfig: &SensorConfig{
|
||||||
|
context: context.Background(),
|
||||||
|
primarySensor: "",
|
||||||
|
sensors: map[string]struct{}{},
|
||||||
|
isBlacklist: false,
|
||||||
|
hasWildcards: false,
|
||||||
|
skipCollection: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Primary sensor only - should create sensor map",
|
||||||
|
primarySensor: "cpu_temp",
|
||||||
|
sysSensors: "",
|
||||||
|
sensors: "",
|
||||||
|
expectedConfig: &SensorConfig{
|
||||||
|
context: context.Background(),
|
||||||
|
primarySensor: "cpu_temp",
|
||||||
|
sensors: map[string]struct{}{},
|
||||||
|
isBlacklist: false,
|
||||||
|
hasWildcards: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Whitelist sensors",
|
||||||
|
primarySensor: "cpu_temp",
|
||||||
|
sysSensors: "",
|
||||||
|
sensors: "cpu_temp,gpu_temp",
|
||||||
|
expectedConfig: &SensorConfig{
|
||||||
|
context: context.Background(),
|
||||||
|
primarySensor: "cpu_temp",
|
||||||
|
sensors: map[string]struct{}{
|
||||||
|
"cpu_temp": {},
|
||||||
|
"gpu_temp": {},
|
||||||
|
},
|
||||||
|
isBlacklist: false,
|
||||||
|
hasWildcards: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Blacklist sensors",
|
||||||
|
primarySensor: "cpu_temp",
|
||||||
|
sysSensors: "",
|
||||||
|
sensors: "-cpu_temp,gpu_temp",
|
||||||
|
expectedConfig: &SensorConfig{
|
||||||
|
context: context.Background(),
|
||||||
|
primarySensor: "cpu_temp",
|
||||||
|
sensors: map[string]struct{}{
|
||||||
|
"cpu_temp": {},
|
||||||
|
"gpu_temp": {},
|
||||||
|
},
|
||||||
|
isBlacklist: true,
|
||||||
|
hasWildcards: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Sensors with wildcard",
|
||||||
|
primarySensor: "cpu_temp",
|
||||||
|
sysSensors: "",
|
||||||
|
sensors: "cpu_*,gpu_temp",
|
||||||
|
expectedConfig: &SensorConfig{
|
||||||
|
context: context.Background(),
|
||||||
|
primarySensor: "cpu_temp",
|
||||||
|
sensors: map[string]struct{}{
|
||||||
|
"cpu_*": {},
|
||||||
|
"gpu_temp": {},
|
||||||
|
},
|
||||||
|
isBlacklist: false,
|
||||||
|
hasWildcards: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Sensors with whitespace",
|
||||||
|
primarySensor: "cpu_temp",
|
||||||
|
sysSensors: "",
|
||||||
|
sensors: "cpu_*, gpu_temp",
|
||||||
|
expectedConfig: &SensorConfig{
|
||||||
|
context: context.Background(),
|
||||||
|
primarySensor: "cpu_temp",
|
||||||
|
sensors: map[string]struct{}{
|
||||||
|
"cpu_*": {},
|
||||||
|
"gpu_temp": {},
|
||||||
|
},
|
||||||
|
isBlacklist: false,
|
||||||
|
hasWildcards: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "With SYS_SENSORS path",
|
||||||
|
primarySensor: "cpu_temp",
|
||||||
|
sysSensors: "/custom/path",
|
||||||
|
sensors: "cpu_temp",
|
||||||
|
expectedConfig: &SensorConfig{
|
||||||
|
primarySensor: "cpu_temp",
|
||||||
|
sensors: map[string]struct{}{
|
||||||
|
"cpu_temp": {},
|
||||||
|
},
|
||||||
|
isBlacklist: false,
|
||||||
|
hasWildcards: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := agent.newSensorConfigWithEnv(tt.primarySensor, tt.sysSensors, tt.sensors, tt.skipCollection)
|
||||||
|
|
||||||
|
// Check primary sensor
|
||||||
|
assert.Equal(t, tt.expectedConfig.primarySensor, result.primarySensor)
|
||||||
|
|
||||||
|
// Check sensor map
|
||||||
|
if tt.expectedConfig.sensors == nil {
|
||||||
|
assert.Nil(t, result.sensors)
|
||||||
|
} else {
|
||||||
|
assert.Equal(t, len(tt.expectedConfig.sensors), len(result.sensors))
|
||||||
|
for sensor := range tt.expectedConfig.sensors {
|
||||||
|
_, exists := result.sensors[sensor]
|
||||||
|
assert.True(t, exists, "Sensor %s should exist in the result", sensor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check flags
|
||||||
|
assert.Equal(t, tt.expectedConfig.isBlacklist, result.isBlacklist)
|
||||||
|
assert.Equal(t, tt.expectedConfig.hasWildcards, result.hasWildcards)
|
||||||
|
|
||||||
|
// Check context
|
||||||
|
if tt.sysSensors != "" {
|
||||||
|
// Verify context contains correct values
|
||||||
|
envMap, ok := result.context.Value(common.EnvKey).(common.EnvMap)
|
||||||
|
require.True(t, ok, "Context should contain EnvMap")
|
||||||
|
sysPath, ok := envMap[common.HostSysEnvKey]
|
||||||
|
require.True(t, ok, "EnvMap should contain HostSysEnvKey")
|
||||||
|
assert.Equal(t, tt.sysSensors, sysPath)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewSensorConfig(t *testing.T) {
|
||||||
|
// Save original environment variables
|
||||||
|
originalPrimary, hasPrimary := os.LookupEnv("BESZEL_AGENT_PRIMARY_SENSOR")
|
||||||
|
originalSys, hasSys := os.LookupEnv("BESZEL_AGENT_SYS_SENSORS")
|
||||||
|
originalSensors, hasSensors := os.LookupEnv("BESZEL_AGENT_SENSORS")
|
||||||
|
|
||||||
|
// Restore environment variables after the test
|
||||||
|
defer func() {
|
||||||
|
// Clean up test environment variables
|
||||||
|
os.Unsetenv("BESZEL_AGENT_PRIMARY_SENSOR")
|
||||||
|
os.Unsetenv("BESZEL_AGENT_SYS_SENSORS")
|
||||||
|
os.Unsetenv("BESZEL_AGENT_SENSORS")
|
||||||
|
|
||||||
|
// Restore original values if they existed
|
||||||
|
if hasPrimary {
|
||||||
|
os.Setenv("BESZEL_AGENT_PRIMARY_SENSOR", originalPrimary)
|
||||||
|
}
|
||||||
|
if hasSys {
|
||||||
|
os.Setenv("BESZEL_AGENT_SYS_SENSORS", originalSys)
|
||||||
|
}
|
||||||
|
if hasSensors {
|
||||||
|
os.Setenv("BESZEL_AGENT_SENSORS", originalSensors)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Set test environment variables
|
||||||
|
os.Setenv("BESZEL_AGENT_PRIMARY_SENSOR", "test_primary")
|
||||||
|
os.Setenv("BESZEL_AGENT_SYS_SENSORS", "/test/path")
|
||||||
|
os.Setenv("BESZEL_AGENT_SENSORS", "test_sensor1,test_*,test_sensor3")
|
||||||
|
|
||||||
|
agent := &Agent{}
|
||||||
|
result := agent.newSensorConfig()
|
||||||
|
|
||||||
|
// Verify results
|
||||||
|
assert.Equal(t, "test_primary", result.primarySensor)
|
||||||
|
assert.NotNil(t, result.sensors)
|
||||||
|
assert.Equal(t, 3, len(result.sensors))
|
||||||
|
assert.True(t, result.hasWildcards)
|
||||||
|
assert.False(t, result.isBlacklist)
|
||||||
|
|
||||||
|
// Check that sys sensors path is in context
|
||||||
|
envMap, ok := result.context.Value(common.EnvKey).(common.EnvMap)
|
||||||
|
require.True(t, ok, "Context should contain EnvMap")
|
||||||
|
sysPath, ok := envMap[common.HostSysEnvKey]
|
||||||
|
require.True(t, ok, "EnvMap should contain HostSysEnvKey")
|
||||||
|
assert.Equal(t, "/test/path", sysPath)
|
||||||
|
}
|
||||||
@@ -16,7 +16,6 @@ import (
|
|||||||
"github.com/shirou/gopsutil/v4/host"
|
"github.com/shirou/gopsutil/v4/host"
|
||||||
"github.com/shirou/gopsutil/v4/mem"
|
"github.com/shirou/gopsutil/v4/mem"
|
||||||
psutilNet "github.com/shirou/gopsutil/v4/net"
|
psutilNet "github.com/shirou/gopsutil/v4/net"
|
||||||
"github.com/shirou/gopsutil/v4/sensors"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Sets initial / non-changing values about the host system
|
// Sets initial / non-changing values about the host system
|
||||||
@@ -200,16 +199,24 @@ func (a *Agent) getSystemStats() system.Stats {
|
|||||||
if systemStats.Temperatures == nil {
|
if systemStats.Temperatures == nil {
|
||||||
systemStats.Temperatures = make(map[string]float64, len(gpuData))
|
systemStats.Temperatures = make(map[string]float64, len(gpuData))
|
||||||
}
|
}
|
||||||
|
highestTemp := 0.0
|
||||||
for _, gpu := range gpuData {
|
for _, gpu := range gpuData {
|
||||||
if gpu.Temperature > 0 {
|
if gpu.Temperature > 0 {
|
||||||
systemStats.Temperatures[gpu.Name] = gpu.Temperature
|
systemStats.Temperatures[gpu.Name] = gpu.Temperature
|
||||||
if a.primarySensor == gpu.Name {
|
if a.sensorConfig.primarySensor == gpu.Name {
|
||||||
a.systemInfo.DashboardTemp = gpu.Temperature
|
a.systemInfo.DashboardTemp = gpu.Temperature
|
||||||
}
|
}
|
||||||
|
if gpu.Temperature > highestTemp {
|
||||||
|
highestTemp = gpu.Temperature
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// update high gpu percent for dashboard
|
// update high gpu percent for dashboard
|
||||||
a.systemInfo.GpuPct = max(a.systemInfo.GpuPct, gpu.Usage)
|
a.systemInfo.GpuPct = max(a.systemInfo.GpuPct, gpu.Usage)
|
||||||
}
|
}
|
||||||
|
// use highest temp for dashboard temp if dashboard temp is unset
|
||||||
|
if a.systemInfo.DashboardTemp == 0 {
|
||||||
|
a.systemInfo.DashboardTemp = highestTemp
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,52 +231,6 @@ func (a *Agent) getSystemStats() system.Stats {
|
|||||||
return systemStats
|
return systemStats
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Agent) updateTemperatures(systemStats *system.Stats) {
|
|
||||||
// skip if sensors whitelist is set to empty string
|
|
||||||
if a.sensorsWhitelist != nil && len(a.sensorsWhitelist) == 0 {
|
|
||||||
slog.Debug("Skipping temperature collection")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// reset high temp
|
|
||||||
a.systemInfo.DashboardTemp = 0
|
|
||||||
|
|
||||||
// get sensor data
|
|
||||||
temps, _ := sensors.TemperaturesWithContext(a.sensorsContext)
|
|
||||||
slog.Debug("Temperature", "sensors", temps)
|
|
||||||
|
|
||||||
// return if no sensors
|
|
||||||
if len(temps) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
systemStats.Temperatures = make(map[string]float64, len(temps))
|
|
||||||
for i, sensor := range temps {
|
|
||||||
// skip if temperature is unreasonable
|
|
||||||
if sensor.Temperature <= 0 || sensor.Temperature >= 200 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
sensorName := sensor.SensorKey
|
|
||||||
if _, ok := systemStats.Temperatures[sensorName]; ok {
|
|
||||||
// if key already exists, append int to key
|
|
||||||
sensorName = sensorName + "_" + strconv.Itoa(i)
|
|
||||||
}
|
|
||||||
// skip if not in whitelist
|
|
||||||
if a.sensorsWhitelist != nil {
|
|
||||||
if _, nameInWhitelist := a.sensorsWhitelist[sensorName]; !nameInWhitelist {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// set dashboard temperature
|
|
||||||
if a.primarySensor == "" {
|
|
||||||
a.systemInfo.DashboardTemp = max(a.systemInfo.DashboardTemp, sensor.Temperature)
|
|
||||||
} else if a.primarySensor == sensorName {
|
|
||||||
a.systemInfo.DashboardTemp = sensor.Temperature
|
|
||||||
}
|
|
||||||
systemStats.Temperatures[sensorName] = twoDecimals(sensor.Temperature)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Returns the size of the ZFS ARC memory cache in bytes
|
// Returns the size of the ZFS ARC memory cache in bytes
|
||||||
func getARCSize() (uint64, error) {
|
func getARCSize() (uint64, error) {
|
||||||
file, err := os.Open("/proc/spl/kstat/zfs/arcstats")
|
file, err := os.Open("/proc/spl/kstat/zfs/arcstats")
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
import { memo, useEffect, useMemo } from "react"
|
import { memo, useEffect, useMemo } from "react"
|
||||||
import { $systems } from "@/lib/stores"
|
import { $systems } from "@/lib/stores"
|
||||||
import { getHostDisplayValue, isAdmin, listen } from "@/lib/utils"
|
import { getHostDisplayValue, isAdmin, listen } from "@/lib/utils"
|
||||||
import { $router, basePath, navigate } from "./router"
|
import { $router, basePath, navigate, prependBasePath } from "./router"
|
||||||
import { Trans } from "@lingui/react/macro"
|
import { Trans } from "@lingui/react/macro"
|
||||||
import { t } from "@lingui/core/macro"
|
import { t } from "@lingui/core/macro"
|
||||||
import { getPagePath } from "@nanostores/router"
|
import { getPagePath } from "@nanostores/router"
|
||||||
@@ -133,7 +133,7 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
|
|||||||
keywords={["pocketbase"]}
|
keywords={["pocketbase"]}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
window.open("/_/", "_blank")
|
window.open(prependBasePath("/_/"), "_blank")
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<UsersIcon className="me-2 h-4 w-4" />
|
<UsersIcon className="me-2 h-4 w-4" />
|
||||||
@@ -147,7 +147,7 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
|
|||||||
<CommandItem
|
<CommandItem
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
window.open("/_/#/logs", "_blank")
|
window.open(prependBasePath("/_/#/logs"), "_blank")
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<LogsIcon className="me-2 h-4 w-4" />
|
<LogsIcon className="me-2 h-4 w-4" />
|
||||||
@@ -161,7 +161,7 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
|
|||||||
<CommandItem
|
<CommandItem
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
window.open("/_/#/settings/backups", "_blank")
|
window.open(prependBasePath("/_/#/settings/backups"), "_blank")
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DatabaseBackupIcon className="me-2 h-4 w-4" />
|
<DatabaseBackupIcon className="me-2 h-4 w-4" />
|
||||||
@@ -176,7 +176,7 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
|
|||||||
keywords={["email"]}
|
keywords={["email"]}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
window.open("/_/#/settings/mail", "_blank")
|
window.open(prependBasePath("/_/#/settings/mail"), "_blank")
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MailIcon className="me-2 h-4 w-4" />
|
<MailIcon className="me-2 h-4 w-4" />
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import { Separator } from "../ui/separator"
|
|||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip"
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip"
|
||||||
import { Button } from "../ui/button"
|
import { Button } from "../ui/button"
|
||||||
import { Input } from "../ui/input"
|
import { Input } from "../ui/input"
|
||||||
import { ChartAverage, ChartMax, Rows, TuxIcon } from "../ui/icons"
|
import { ChartAverage, ChartMax, Rows, TuxIcon, WindowsIcon } from "../ui/icons"
|
||||||
import { useIntersectionObserver } from "@/lib/use-intersection-observer"
|
import { useIntersectionObserver } from "@/lib/use-intersection-observer"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select"
|
||||||
import { timeTicks } from "d3-time"
|
import { timeTicks } from "d3-time"
|
||||||
@@ -251,6 +251,12 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
if (!system.info) {
|
if (!system.info) {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
let version = system.info.k ?? ""
|
||||||
|
const buildIndex = version.indexOf(" Build")
|
||||||
|
const isWindows = buildIndex !== -1
|
||||||
|
if (isWindows) {
|
||||||
|
version = version.substring(0, buildIndex)
|
||||||
|
}
|
||||||
let uptime: React.ReactNode
|
let uptime: React.ReactNode
|
||||||
if (system.info.u < 172800) {
|
if (system.info.u < 172800) {
|
||||||
const hours = Math.trunc(system.info.u / 3600)
|
const hours = Math.trunc(system.info.u / 3600)
|
||||||
@@ -268,7 +274,11 @@ export default function SystemDetail({ name }: { name: 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 },
|
||||||
{ value: system.info.k, Icon: TuxIcon, label: t({ comment: "Linux kernel", message: "Kernel" }) },
|
{
|
||||||
|
value: version,
|
||||||
|
Icon: isWindows ? WindowsIcon : TuxIcon,
|
||||||
|
label: isWindows ? t`Windows build` : t({ comment: "Linux kernel", message: "Kernel" }),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
value: `${system.info.m} (${system.info.c}c${system.info.t ? `/${system.info.t}t` : ""})`,
|
value: `${system.info.m} (${system.info.c}c${system.info.t ? `/${system.info.t}t` : ""})`,
|
||||||
Icon: CpuIcon,
|
Icon: CpuIcon,
|
||||||
|
|||||||
@@ -12,6 +12,21 @@ export function TuxIcon(props: SVGProps<SVGSVGElement>) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// meteor icons (MIT) https://github.com/zkreations/icons/blob/main/LICENSE
|
||||||
|
export function WindowsIcon(props: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" {...props}>
|
||||||
|
<path
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
d="M2 12h20m-11.3 8.3V3.7M2 5l20-3v20L2 19Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// MingCute Apache License 2.0 https://github.com/Richard9394/MingCute
|
// MingCute Apache License 2.0 https://github.com/Richard9394/MingCute
|
||||||
export function Rows(props: SVGProps<SVGSVGElement>) {
|
export function Rows(props: SVGProps<SVGSVGElement>) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
83
supplemental/scripts/install-agent-brew.sh
Executable file
83
supplemental/scripts/install-agent-brew.sh
Executable file
@@ -0,0 +1,83 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
PORT=45876
|
||||||
|
KEY=""
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
printf "Beszel Agent homebrew installation script\n\n"
|
||||||
|
printf "Usage: ./install-agent-brew.sh [options]\n\n"
|
||||||
|
printf "Options: \n"
|
||||||
|
printf " -k SSH key (required, or interactive if not provided)\n"
|
||||||
|
printf " -p Port (default: $PORT)\n"
|
||||||
|
printf " -h, --help Display this help message\n"
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Handle --help explicitly since getopts doesn't handle long options
|
||||||
|
if [ "$1" = "--help" ]; then
|
||||||
|
usage
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Parse arguments with getopts
|
||||||
|
while getopts "k:p:h" opt; do
|
||||||
|
case ${opt} in
|
||||||
|
k)
|
||||||
|
KEY="$OPTARG"
|
||||||
|
;;
|
||||||
|
p)
|
||||||
|
PORT="$OPTARG"
|
||||||
|
;;
|
||||||
|
h)
|
||||||
|
usage
|
||||||
|
;;
|
||||||
|
\?)
|
||||||
|
echo "Invalid option: -$OPTARG" >&2
|
||||||
|
usage
|
||||||
|
;;
|
||||||
|
:)
|
||||||
|
echo "Option -$OPTARG requires an argument." >&2
|
||||||
|
usage
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# Check if brew is installed, prompt to install if not
|
||||||
|
if ! command -v brew &>/dev/null; then
|
||||||
|
read -p "Homebrew is not installed. Would you like to install it now? (y/n): " install_brew
|
||||||
|
if [[ $install_brew =~ ^[Yy]$ ]]; then
|
||||||
|
echo "Installing Homebrew..."
|
||||||
|
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
||||||
|
|
||||||
|
# Verify installation was successful
|
||||||
|
if ! command -v brew &>/dev/null; then
|
||||||
|
echo "Homebrew installation failed. Please install manually and try again."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Homebrew installed successfully."
|
||||||
|
else
|
||||||
|
echo "Homebrew is required. Please install Homebrew and try again."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$KEY" ]; then
|
||||||
|
read -p "Enter SSH key: " KEY
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p ~/.config/beszel ~/.cache/beszel
|
||||||
|
|
||||||
|
echo "KEY=\"$KEY\"" >~/.config/beszel/beszel-agent.env
|
||||||
|
echo "LISTEN=$PORT" >>~/.config/beszel/beszel-agent.env
|
||||||
|
|
||||||
|
brew tap henrygd/beszel
|
||||||
|
brew install beszel-agent
|
||||||
|
brew services start beszel-agent
|
||||||
|
|
||||||
|
printf "\nCheck status: brew services info beszel-agent\n"
|
||||||
|
echo "Stop: brew services stop beszel-agent"
|
||||||
|
echo "Start: brew services start beszel-agent"
|
||||||
|
echo "Restart: brew services restart beszel-agent"
|
||||||
|
echo "Upgrade: brew upgrade beszel-agent"
|
||||||
|
echo "Uninstall: brew uninstall beszel-agent"
|
||||||
|
echo "View logs in ~/.cache/beszel/beszel-agent.log"
|
||||||
|
printf "Change environment variables in ~/.config/beszel/beszel-agent.env\n"
|
||||||
184
supplemental/scripts/install-agent.ps1
Normal file
184
supplemental/scripts/install-agent.ps1
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
param (
|
||||||
|
[switch]$Elevated,
|
||||||
|
[Parameter(Mandatory=$true)]
|
||||||
|
[string]$Key,
|
||||||
|
[int]$Port = 45876
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if key is provided or empty
|
||||||
|
if ([string]::IsNullOrWhiteSpace($Key)) {
|
||||||
|
Write-Host "ERROR: SSH Key is required." -ForegroundColor Red
|
||||||
|
Write-Host "Usage: .\install-agent.ps1 -Key 'your-ssh-key-here' [-Port port-number]" -ForegroundColor Yellow
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Stop on first error
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
# Function to check if running as admin
|
||||||
|
function Test-Admin {
|
||||||
|
return ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Non-admin tasks - install Scoop and Scoop apps - Only run if we're not in elevated mode
|
||||||
|
if (-not $Elevated) {
|
||||||
|
try {
|
||||||
|
# Check if Scoop is already installed
|
||||||
|
if (Get-Command scoop -ErrorAction SilentlyContinue) {
|
||||||
|
Write-Host "Scoop is already installed."
|
||||||
|
} else {
|
||||||
|
Write-Host "Installing Scoop..."
|
||||||
|
Invoke-RestMethod -Uri https://get.scoop.sh | Invoke-Expression
|
||||||
|
|
||||||
|
if (-not (Get-Command scoop -ErrorAction SilentlyContinue)) {
|
||||||
|
throw "Failed to install Scoop"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if git is already installed
|
||||||
|
if (Get-Command git -ErrorAction SilentlyContinue) {
|
||||||
|
Write-Host "Git is already installed."
|
||||||
|
} else {
|
||||||
|
Write-Host "Installing Git..."
|
||||||
|
scoop install git
|
||||||
|
|
||||||
|
if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
|
||||||
|
throw "Failed to install Git"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if nssm is already installed
|
||||||
|
if (Get-Command nssm -ErrorAction SilentlyContinue) {
|
||||||
|
Write-Host "NSSM is already installed."
|
||||||
|
} else {
|
||||||
|
Write-Host "Installing NSSM..."
|
||||||
|
scoop install nssm
|
||||||
|
|
||||||
|
if (-not (Get-Command nssm -ErrorAction SilentlyContinue)) {
|
||||||
|
throw "Failed to install NSSM"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add bucket and install agent
|
||||||
|
Write-Host "Adding beszel bucket..."
|
||||||
|
scoop bucket add beszel https://github.com/henrygd/beszel-scoops
|
||||||
|
|
||||||
|
Write-Host "Installing beszel-agent..."
|
||||||
|
scoop install beszel-agent
|
||||||
|
|
||||||
|
if (-not (Get-Command beszel-agent -ErrorAction SilentlyContinue)) {
|
||||||
|
throw "Failed to install beszel-agent"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Host "ERROR: $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
Write-Host "Installation failed. Please check the error message above." -ForegroundColor Red
|
||||||
|
Write-Host "Press any key to exit..." -ForegroundColor Red
|
||||||
|
$null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if we need admin privileges for the NSSM part
|
||||||
|
if (-not (Test-Admin)) {
|
||||||
|
Write-Host "Admin privileges required for NSSM. Relaunching as admin..." -ForegroundColor Yellow
|
||||||
|
Write-Host "Check service status with 'nssm status beszel-agent'"
|
||||||
|
Write-Host "Edit service configuration with 'nssm edit beszel-agent'"
|
||||||
|
|
||||||
|
# Relaunch the script with the -Elevated switch and pass parameters
|
||||||
|
Start-Process powershell.exe -Verb RunAs -ArgumentList "-File `"$PSCommandPath`" -Elevated -Key `"$Key`" -Port $Port"
|
||||||
|
exit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Admin tasks - service installation and firewall rules
|
||||||
|
try {
|
||||||
|
$agentPath = Join-Path -Path $(scoop prefix beszel-agent) -ChildPath "beszel-agent.exe"
|
||||||
|
if (-not $agentPath) {
|
||||||
|
throw "Could not find beszel-agent executable. Make sure it was properly installed."
|
||||||
|
}
|
||||||
|
|
||||||
|
# Install and configure the service
|
||||||
|
Write-Host "Installing beszel-agent service..."
|
||||||
|
|
||||||
|
# Check if service already exists
|
||||||
|
$existingService = Get-Service -Name "beszel-agent" -ErrorAction SilentlyContinue
|
||||||
|
if ($existingService) {
|
||||||
|
Write-Host "Service already exists. Stopping and removing existing service..."
|
||||||
|
try {
|
||||||
|
nssm stop beszel-agent
|
||||||
|
nssm remove beszel-agent confirm
|
||||||
|
} catch {
|
||||||
|
Write-Host "Warning: Failed to remove existing service: $($_.Exception.Message)" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nssm install beszel-agent $agentPath
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "Failed to install beszel-agent service"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Configuring service environment variables..."
|
||||||
|
nssm set beszel-agent AppEnvironmentExtra "+KEY=$Key"
|
||||||
|
nssm set beszel-agent AppEnvironmentExtra "+PORT=$Port"
|
||||||
|
|
||||||
|
# Configure log files
|
||||||
|
$logDir = "$env:ProgramData\beszel-agent\logs"
|
||||||
|
if (-not (Test-Path $logDir)) {
|
||||||
|
New-Item -ItemType Directory -Path $logDir -Force | Out-Null
|
||||||
|
}
|
||||||
|
$logFile = "$logDir\beszel-agent.log"
|
||||||
|
nssm set beszel-agent AppStdout $logFile
|
||||||
|
nssm set beszel-agent AppStderr $logFile
|
||||||
|
|
||||||
|
# Create a firewall rule if it doesn't exist
|
||||||
|
$ruleName = "Allow beszel-agent"
|
||||||
|
$existingRule = Get-NetFirewallRule -DisplayName $ruleName -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
|
# Remove existing rule if found
|
||||||
|
if ($existingRule) {
|
||||||
|
Write-Host "Removing existing firewall rule..."
|
||||||
|
try {
|
||||||
|
Remove-NetFirewallRule -DisplayName $ruleName
|
||||||
|
Write-Host "Existing firewall rule removed successfully."
|
||||||
|
} catch {
|
||||||
|
Write-Host "Warning: Failed to remove existing firewall rule: $($_.Exception.Message)" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create new rule with current settings
|
||||||
|
Write-Host "Creating firewall rule for beszel-agent on port $Port..."
|
||||||
|
try {
|
||||||
|
New-NetFirewallRule -DisplayName $ruleName -Direction Inbound -Action Allow -Protocol TCP -LocalPort $Port
|
||||||
|
Write-Host "Firewall rule created successfully."
|
||||||
|
} catch {
|
||||||
|
Write-Host "Warning: Failed to create firewall rule: $($_.Exception.Message)" -ForegroundColor Yellow
|
||||||
|
Write-Host "You may need to manually create a firewall rule for port $Port." -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Starting beszel-agent service..."
|
||||||
|
nssm start beszel-agent
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "Failed to start beszel-agent service"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Checking beszel-agent service status..."
|
||||||
|
Start-Sleep -Seconds 5 # Allow time to start before checking status
|
||||||
|
$serviceStatus = nssm status beszel-agent
|
||||||
|
|
||||||
|
if ($serviceStatus -eq "SERVICE_RUNNING") {
|
||||||
|
Write-Host "Success! The beszel-agent service is running properly." -ForegroundColor Green
|
||||||
|
} else {
|
||||||
|
Write-Host "Warning: The service status is '$serviceStatus' instead of 'SERVICE_RUNNING'." -ForegroundColor Yellow
|
||||||
|
Write-Host "You may need to troubleshoot the service installation." -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Host "ERROR: $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
Write-Host "Installation failed. Please check the error message above." -ForegroundColor Red
|
||||||
|
}
|
||||||
|
|
||||||
|
# Pause to see results before exit if this is an elevated window
|
||||||
|
if ($Elevated) {
|
||||||
|
Write-Host "Press any key to exit..." -ForegroundColor Cyan
|
||||||
|
$null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ GITHUB_URL="https://github.com"
|
|||||||
GITHUB_API_URL="https://api.github.com" # not blocked in China currently
|
GITHUB_API_URL="https://api.github.com" # not blocked in China currently
|
||||||
GITHUB_PROXY_URL=""
|
GITHUB_PROXY_URL=""
|
||||||
KEY=""
|
KEY=""
|
||||||
|
AUTO_UPDATE_FLAG="" # empty string means prompt, "true" means auto-enable, "false" means skip
|
||||||
|
|
||||||
# Check for help flag
|
# Check for help flag
|
||||||
case "$1" in
|
case "$1" in
|
||||||
@@ -37,8 +38,10 @@ case "$1" in
|
|||||||
printf " -k : SSH key (required, or interactive if not provided)\n"
|
printf " -k : SSH key (required, or interactive if not provided)\n"
|
||||||
printf " -p : Port (default: $PORT)\n"
|
printf " -p : Port (default: $PORT)\n"
|
||||||
printf " -u : Uninstall Beszel Agent\n"
|
printf " -u : Uninstall Beszel Agent\n"
|
||||||
printf " --china-mirrors [URL] : Use GitHub proxy (gh.beszel.dev) to resolve network timeout issues in mainland China\n"
|
printf " --auto-update [VALUE] : Control automatic daily updates\n"
|
||||||
printf " optional: specify a custom proxy URL, e.g., \"https://ghfast.top\"\n"
|
printf " VALUE can be true (enable) or false (disable). If not specified, will prompt.\n"
|
||||||
|
printf " --china-mirrors [URL] : Use GitHub proxy to resolve network timeout issues in mainland China\n"
|
||||||
|
printf " URL: optional custom proxy URL (default: https://gh.beszel.dev)\n"
|
||||||
printf " -h, --help : Display this help message\n"
|
printf " -h, --help : Display this help message\n"
|
||||||
exit 0
|
exit 0
|
||||||
;;
|
;;
|
||||||
@@ -84,17 +87,50 @@ while [ $# -gt 0 ]; do
|
|||||||
-u)
|
-u)
|
||||||
UNINSTALL=true
|
UNINSTALL=true
|
||||||
;;
|
;;
|
||||||
--china-mirrors)
|
--china-mirrors*)
|
||||||
if [ "$2" != "" ] && ! echo "$2" | grep -q '^-'; then
|
# Check if there's a value after the = sign
|
||||||
# use cstom proxy URL if provided
|
if echo "$1" | grep -q "="; then
|
||||||
|
# Extract the value after =
|
||||||
|
CUSTOM_PROXY=$(echo "$1" | cut -d'=' -f2)
|
||||||
|
if [ -n "$CUSTOM_PROXY" ]; then
|
||||||
|
GITHUB_PROXY_URL="$CUSTOM_PROXY"
|
||||||
|
GITHUB_URL="$(ensure_trailing_slash "$CUSTOM_PROXY")https://github.com"
|
||||||
|
else
|
||||||
|
GITHUB_PROXY_URL="https://gh.beszel.dev"
|
||||||
|
GITHUB_URL="$GITHUB_PROXY_URL"
|
||||||
|
fi
|
||||||
|
elif [ "$2" != "" ] && ! echo "$2" | grep -q '^-'; then
|
||||||
|
# use custom proxy URL provided as next argument
|
||||||
GITHUB_PROXY_URL="$2"
|
GITHUB_PROXY_URL="$2"
|
||||||
GITHUB_URL="$(ensure_trailing_slash "$2")https://github.com"
|
GITHUB_URL="$(ensure_trailing_slash "$2")https://github.com"
|
||||||
shift
|
shift
|
||||||
else
|
else
|
||||||
|
# No value specified, use default
|
||||||
GITHUB_PROXY_URL="https://gh.beszel.dev"
|
GITHUB_PROXY_URL="https://gh.beszel.dev"
|
||||||
GITHUB_URL="$GITHUB_PROXY_URL"
|
GITHUB_URL="$GITHUB_PROXY_URL"
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
|
--auto-update*)
|
||||||
|
# Check if there's a value after the = sign
|
||||||
|
if echo "$1" | grep -q "="; then
|
||||||
|
# Extract the value after =
|
||||||
|
AUTO_UPDATE_VALUE=$(echo "$1" | cut -d'=' -f2)
|
||||||
|
if [ "$AUTO_UPDATE_VALUE" = "true" ]; then
|
||||||
|
AUTO_UPDATE_FLAG="true"
|
||||||
|
elif [ "$AUTO_UPDATE_VALUE" = "false" ]; then
|
||||||
|
AUTO_UPDATE_FLAG="false"
|
||||||
|
else
|
||||||
|
echo "Invalid value for --auto-update flag: $AUTO_UPDATE_VALUE. Using default (prompt)."
|
||||||
|
fi
|
||||||
|
elif [ "$2" = "true" ] || [ "$2" = "false" ]; then
|
||||||
|
# Value provided as next argument
|
||||||
|
AUTO_UPDATE_FLAG="$2"
|
||||||
|
shift
|
||||||
|
else
|
||||||
|
# No value specified, use true
|
||||||
|
AUTO_UPDATE_FLAG="true"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Invalid option: $1" >&2
|
echo "Invalid option: $1" >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -348,8 +384,14 @@ EOF
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Auto-update service for Alpine
|
# Auto-update service for Alpine
|
||||||
printf "\nWould you like to enable automatic daily updates for beszel-agent? (y/n): "
|
if [ "$AUTO_UPDATE_FLAG" = "true" ]; then
|
||||||
read AUTO_UPDATE
|
AUTO_UPDATE="y"
|
||||||
|
elif [ "$AUTO_UPDATE_FLAG" = "false" ]; then
|
||||||
|
AUTO_UPDATE="n"
|
||||||
|
else
|
||||||
|
printf "\nWould you like to enable automatic daily updates for beszel-agent? (y/n): "
|
||||||
|
read AUTO_UPDATE
|
||||||
|
fi
|
||||||
case "$AUTO_UPDATE" in
|
case "$AUTO_UPDATE" in
|
||||||
[Yy]*)
|
[Yy]*)
|
||||||
echo "Setting up daily automatic updates for beszel-agent..."
|
echo "Setting up daily automatic updates for beszel-agent..."
|
||||||
@@ -432,8 +474,16 @@ EOF
|
|||||||
service beszel-agent restart
|
service beszel-agent restart
|
||||||
|
|
||||||
# Auto-update service for OpenWRT using a crontab job
|
# Auto-update service for OpenWRT using a crontab job
|
||||||
printf "\nWould you like to enable automatic daily updates for beszel-agent? (y/n): "
|
if [ "$AUTO_UPDATE_FLAG" = "true" ]; then
|
||||||
read AUTO_UPDATE
|
AUTO_UPDATE="y"
|
||||||
|
sleep 1 # give time for the service to start
|
||||||
|
elif [ "$AUTO_UPDATE_FLAG" = "false" ]; then
|
||||||
|
AUTO_UPDATE="n"
|
||||||
|
sleep 1 # give time for the service to start
|
||||||
|
else
|
||||||
|
printf "\nWould you like to enable automatic daily updates for beszel-agent? (y/n): "
|
||||||
|
read AUTO_UPDATE
|
||||||
|
fi
|
||||||
case "$AUTO_UPDATE" in
|
case "$AUTO_UPDATE" in
|
||||||
[Yy]*)
|
[Yy]*)
|
||||||
echo "Setting up daily automatic updates for beszel-agent..."
|
echo "Setting up daily automatic updates for beszel-agent..."
|
||||||
@@ -499,8 +549,16 @@ EOF
|
|||||||
systemctl start beszel-agent.service
|
systemctl start beszel-agent.service
|
||||||
|
|
||||||
# Prompt for auto-update setup
|
# Prompt for auto-update setup
|
||||||
printf "\nWould you like to enable automatic daily updates for beszel-agent? (y/n): "
|
if [ "$AUTO_UPDATE_FLAG" = "true" ]; then
|
||||||
read AUTO_UPDATE
|
AUTO_UPDATE="y"
|
||||||
|
sleep 1 # give time for the service to start
|
||||||
|
elif [ "$AUTO_UPDATE_FLAG" = "false" ]; then
|
||||||
|
AUTO_UPDATE="n"
|
||||||
|
sleep 1 # give time for the service to start
|
||||||
|
else
|
||||||
|
printf "\nWould you like to enable automatic daily updates for beszel-agent? (y/n): "
|
||||||
|
read AUTO_UPDATE
|
||||||
|
fi
|
||||||
case "$AUTO_UPDATE" in
|
case "$AUTO_UPDATE" in
|
||||||
[Yy]*)
|
[Yy]*)
|
||||||
echo "Setting up daily automatic updates for beszel-agent..."
|
echo "Setting up daily automatic updates for beszel-agent..."
|
||||||
|
|||||||
Reference in New Issue
Block a user