mirror of
https://github.com/henrygd/beszel.git
synced 2026-03-23 05:56:17 +01:00
Compare commits
25 Commits
v0.10.2
...
bda06f30b3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bda06f30b3 | ||
|
|
38f2ba3984 | ||
|
|
1a7d897bdc | ||
|
|
c74e7430ef | ||
|
|
2467bbc0f0 | ||
|
|
ea665e02da | ||
|
|
358e05d544 | ||
|
|
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:
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
- 'v*'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
@@ -29,6 +29,7 @@ builds:
|
||||
- linux
|
||||
- darwin
|
||||
- freebsd
|
||||
- openbsd
|
||||
- windows
|
||||
goarch:
|
||||
- amd64
|
||||
@@ -39,6 +40,8 @@ builds:
|
||||
ignore:
|
||||
- goos: freebsd
|
||||
goarch: arm
|
||||
- goos: openbsd
|
||||
goarch: arm
|
||||
- goos: windows
|
||||
goarch: arm
|
||||
- goos: darwin
|
||||
@@ -47,7 +50,7 @@ builds:
|
||||
goarch: riscv64
|
||||
|
||||
archives:
|
||||
- id: beszel
|
||||
- id: beszel-agent
|
||||
format: tar.gz
|
||||
builds:
|
||||
- beszel-agent
|
||||
@@ -59,7 +62,7 @@ archives:
|
||||
- goos: windows
|
||||
format: zip
|
||||
|
||||
- id: beszel-agent
|
||||
- id: beszel
|
||||
format: tar.gz
|
||||
builds:
|
||||
- beszel
|
||||
@@ -111,6 +114,65 @@ nfpms:
|
||||
# https://github.com/goreleaser/goreleaser/issues/5487
|
||||
#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:
|
||||
draft: true
|
||||
|
||||
|
||||
@@ -4,42 +4,36 @@ package agent
|
||||
import (
|
||||
"beszel"
|
||||
"beszel/internal/entities/system"
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/shirou/gopsutil/v4/common"
|
||||
)
|
||||
|
||||
type Agent struct {
|
||||
sync.Mutex // Used to lock agent while collecting data
|
||||
debug bool // true if LOG_LEVEL is set to debug
|
||||
zfs bool // true if system has arcstats
|
||||
memCalc string // Memory calculation formula
|
||||
fsNames []string // List of filesystem device names being monitored
|
||||
fsStats map[string]*system.FsStats // Keeps track of disk stats for each filesystem
|
||||
netInterfaces map[string]struct{} // Stores all valid network interfaces
|
||||
netIoStats system.NetIoStats // Keeps track of bandwidth usage
|
||||
dockerManager *dockerManager // Manages Docker API requests
|
||||
sensorsContext context.Context // Sensors context to override sys location
|
||||
sensorsWhitelist map[string]struct{} // List of sensors to monitor
|
||||
primarySensor string // Value of PRIMARY_SENSOR env var
|
||||
systemInfo system.Info // Host system info
|
||||
gpuManager *GPUManager // Manages GPU data
|
||||
cache *SessionCache // Cache for system stats based on primary session ID
|
||||
sync.Mutex // Used to lock agent while collecting data
|
||||
debug bool // true if LOG_LEVEL is set to debug
|
||||
zfs bool // true if system has arcstats
|
||||
memCalc string // Memory calculation formula
|
||||
fsNames []string // List of filesystem device names being monitored
|
||||
fsStats map[string]*system.FsStats // Keeps track of disk stats for each filesystem
|
||||
netInterfaces map[string]struct{} // Stores all valid network interfaces
|
||||
netIoStats system.NetIoStats // Keeps track of bandwidth usage
|
||||
dockerManager *dockerManager // Manages Docker API requests
|
||||
sensorConfig *SensorConfig // Sensors config
|
||||
systemInfo system.Info // Host system info
|
||||
gpuManager *GPUManager // Manages GPU data
|
||||
cache *SessionCache // Cache for system stats based on primary session ID
|
||||
}
|
||||
|
||||
func NewAgent() *Agent {
|
||||
agent := &Agent{
|
||||
sensorsContext: context.Background(),
|
||||
fsStats: make(map[string]*system.FsStats),
|
||||
cache: NewSessionCache(69 * time.Second),
|
||||
fsStats: make(map[string]*system.FsStats),
|
||||
cache: NewSessionCache(69 * time.Second),
|
||||
}
|
||||
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
|
||||
if logLevelStr, exists := GetEnv("LOG_LEVEL"); exists {
|
||||
switch strings.ToLower(logLevelStr) {
|
||||
@@ -55,24 +49,6 @@ func NewAgent() *Agent {
|
||||
|
||||
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
|
||||
agent.initializeSystemInfo()
|
||||
agent.initializeDiskInfo()
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -36,7 +37,12 @@ func (a *Agent) initializeDiskInfo() {
|
||||
|
||||
// Helper function to add a filesystem to fsStats if it doesn't exist
|
||||
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
|
||||
if _, exists := a.fsStats[key]; !exists {
|
||||
if root {
|
||||
|
||||
@@ -26,6 +26,7 @@ type dockerManager struct {
|
||||
containerStatsMap map[string]*container.Stats // Keeps track of container stats
|
||||
validIds map[string]struct{} // Map of valid container ids, used to prune invalid containers from containerStatsMap
|
||||
goodDockerVersion bool // Whether docker version is at least 25.0.0 (one-shot works correctly)
|
||||
isWindows bool // Whether the Docker Engine API is running on Windows
|
||||
}
|
||||
|
||||
// userAgentRoundTripper is a custom http.RoundTripper that adds a User-Agent header to all requests
|
||||
@@ -69,6 +70,8 @@ func (dm *dockerManager) getDockerStats() ([]*container.Stats, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dm.isWindows = strings.Contains(resp.Header.Get("Server"), "windows")
|
||||
|
||||
containersLength := len(dm.apiContainerList)
|
||||
|
||||
// store valid ids to clean up old container ids from map
|
||||
@@ -80,8 +83,7 @@ func (dm *dockerManager) getDockerStats() ([]*container.Stats, error) {
|
||||
|
||||
var failedContainers []*container.ApiInfo
|
||||
|
||||
for i := range dm.apiContainerList {
|
||||
ctr := dm.apiContainerList[i]
|
||||
for _, ctr := range dm.apiContainerList {
|
||||
ctr.IdShort = ctr.Id[:12]
|
||||
dm.validIds[ctr.IdShort] = struct{}{}
|
||||
// check if container is less than 1 minute old (possible restart)
|
||||
@@ -167,22 +169,27 @@ func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// check if container has valid data, otherwise may be in restart loop (#103)
|
||||
if res.MemoryStats.Usage == 0 {
|
||||
return fmt.Errorf("%s - no memory stats - see https://github.com/henrygd/beszel/issues/144", name)
|
||||
// calculate cpu and memory stats
|
||||
var usedMemory uint64
|
||||
var cpuPct float64
|
||||
|
||||
if dm.isWindows {
|
||||
usedMemory = res.MemoryStats.PrivateWorkingSet
|
||||
cpuPct = res.CalculateCpuPercentWindows(stats.PrevCpu[0], stats.PrevRead)
|
||||
} else {
|
||||
// check if container has valid data, otherwise may be in restart loop (#103)
|
||||
if res.MemoryStats.Usage == 0 {
|
||||
return fmt.Errorf("%s - no memory stats - see https://github.com/henrygd/beszel/issues/144", name)
|
||||
}
|
||||
memCache := res.MemoryStats.Stats.InactiveFile
|
||||
if memCache == 0 {
|
||||
memCache = res.MemoryStats.Stats.Cache
|
||||
}
|
||||
usedMemory = res.MemoryStats.Usage - memCache
|
||||
|
||||
cpuPct = res.CalculateCpuPercentLinux(stats.PrevCpu)
|
||||
}
|
||||
|
||||
// memory (https://docs.docker.com/reference/cli/docker/container/stats/)
|
||||
memCache := res.MemoryStats.Stats.InactiveFile
|
||||
if memCache == 0 {
|
||||
memCache = res.MemoryStats.Stats.Cache
|
||||
}
|
||||
usedMemory := res.MemoryStats.Usage - memCache
|
||||
|
||||
// cpu
|
||||
cpuDelta := res.CPUStats.CPUUsage.TotalUsage - stats.PrevCpu[0]
|
||||
systemDelta := res.CPUStats.SystemUsage - stats.PrevCpu[1]
|
||||
cpuPct := float64(cpuDelta) / float64(systemDelta) * 100
|
||||
if cpuPct > 100 {
|
||||
return fmt.Errorf("%s cpu pct greater than 100: %+v", name, cpuPct)
|
||||
}
|
||||
@@ -197,18 +204,18 @@ func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo) error {
|
||||
var sent_delta, recv_delta float64
|
||||
// prevent first run from sending all prev sent/recv bytes
|
||||
if initialized {
|
||||
secondsElapsed := time.Since(stats.PrevNet.Time).Seconds()
|
||||
secondsElapsed := time.Since(stats.PrevRead).Seconds()
|
||||
sent_delta = float64(total_sent-stats.PrevNet.Sent) / secondsElapsed
|
||||
recv_delta = float64(total_recv-stats.PrevNet.Recv) / secondsElapsed
|
||||
}
|
||||
stats.PrevNet.Sent = total_sent
|
||||
stats.PrevNet.Recv = total_recv
|
||||
stats.PrevNet.Time = time.Now()
|
||||
|
||||
stats.Cpu = twoDecimals(cpuPct)
|
||||
stats.Mem = bytesToMegabytes(float64(usedMemory))
|
||||
stats.NetworkSent = bytesToMegabytes(sent_delta)
|
||||
stats.NetworkRecv = bytesToMegabytes(recv_delta)
|
||||
stats.PrevRead = res.Read
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -125,14 +125,13 @@ func (gm *GPUManager) getJetsonParser() func(output []byte) bool {
|
||||
// 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`)
|
||||
|
||||
// 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 {
|
||||
gm.Lock()
|
||||
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
|
||||
ramMatches := ramPattern.FindSubmatch(output)
|
||||
if ramMatches != nil {
|
||||
@@ -184,12 +183,6 @@ func (gm *GPUManager) parseNvidiaData(output []byte) bool {
|
||||
if _, ok := gm.GpuDataMap[id]; !ok {
|
||||
name := strings.TrimPrefix(fields[1], "NVIDIA ")
|
||||
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
|
||||
gpu := gm.GpuDataMap[id]
|
||||
@@ -283,6 +276,7 @@ func (gm *GPUManager) detectGPUs() error {
|
||||
}
|
||||
if _, err := exec.LookPath(tegraStatsCmd); err == nil {
|
||||
gm.tegrastats = true
|
||||
gm.nvidiaSmi = false
|
||||
}
|
||||
if gm.nvidiaSmi || gm.rocmSmi || gm.tegrastats {
|
||||
return nil
|
||||
@@ -297,9 +291,11 @@ func (gm *GPUManager) startCollector(command string) {
|
||||
}
|
||||
switch command {
|
||||
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",
|
||||
"--format=csv,noheader,nounits"}
|
||||
"--format=csv,noheader,nounits",
|
||||
}
|
||||
collector.parse = gm.parseNvidiaData
|
||||
go collector.start()
|
||||
case tegraStatsCmd:
|
||||
|
||||
@@ -251,14 +251,13 @@ func TestParseJetsonData(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
gm *GPUManager
|
||||
wantMetrics *system.GPUData
|
||||
}{
|
||||
{
|
||||
name: "valid data",
|
||||
input: "11-14-2024 22:54:33 RAM 4300/30698MB GR3D_FREQ 45% tj@52.468C VDD_GPU_SOC 2171mW",
|
||||
wantMetrics: &system.GPUData{
|
||||
Name: "Jetson",
|
||||
Name: "GPU",
|
||||
MemoryUsed: 4300.0,
|
||||
MemoryTotal: 30698.0,
|
||||
Usage: 45.0,
|
||||
@@ -271,7 +270,7 @@ func TestParseJetsonData(t *testing.T) {
|
||||
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",
|
||||
wantMetrics: &system.GPUData{
|
||||
Name: "Jetson",
|
||||
Name: "GPU",
|
||||
MemoryUsed: 6185.0,
|
||||
MemoryTotal: 7620.0,
|
||||
Usage: 63.0,
|
||||
@@ -284,7 +283,7 @@ func TestParseJetsonData(t *testing.T) {
|
||||
name: "missing temperature",
|
||||
input: "11-14-2024 22:54:33 RAM 4300/30698MB GR3D_FREQ 45% VDD_GPU_SOC 2171mW",
|
||||
wantMetrics: &system.GPUData{
|
||||
Name: "Jetson",
|
||||
Name: "GPU",
|
||||
MemoryUsed: 4300.0,
|
||||
MemoryTotal: 30698.0,
|
||||
Usage: 45.0,
|
||||
@@ -292,32 +291,18 @@ func TestParseJetsonData(t *testing.T) {
|
||||
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 {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.gm != nil {
|
||||
// should return if no gpu set by nvidia-smi
|
||||
assert.Empty(t, tt.gm.GpuDataMap)
|
||||
return
|
||||
gm := &GPUManager{
|
||||
GpuDataMap: make(map[string]*system.GPUData),
|
||||
}
|
||||
tt.gm = &GPUManager{
|
||||
GpuDataMap: map[string]*system.GPUData{
|
||||
"0": {Name: "Jetson"},
|
||||
},
|
||||
}
|
||||
parser := tt.gm.getJetsonParser()
|
||||
parser := gm.getJetsonParser()
|
||||
valid := parser([]byte(tt.input))
|
||||
assert.Equal(t, true, valid)
|
||||
|
||||
got := tt.gm.GpuDataMap["0"]
|
||||
got := gm.GpuDataMap["0"]
|
||||
require.NotNil(t, got)
|
||||
assert.Equal(t, tt.wantMetrics.Name, got.Name)
|
||||
assert.InDelta(t, tt.wantMetrics.MemoryUsed, got.MemoryUsed, 0.01)
|
||||
@@ -443,7 +428,7 @@ echo "test"`
|
||||
}
|
||||
return nil
|
||||
},
|
||||
wantNvidiaSmi: true,
|
||||
wantNvidiaSmi: false,
|
||||
wantRocmSmi: true,
|
||||
wantTegrastats: true,
|
||||
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,14 +16,28 @@ import (
|
||||
"github.com/shirou/gopsutil/v4/host"
|
||||
"github.com/shirou/gopsutil/v4/mem"
|
||||
psutilNet "github.com/shirou/gopsutil/v4/net"
|
||||
"github.com/shirou/gopsutil/v4/sensors"
|
||||
)
|
||||
|
||||
// Sets initial / non-changing values about the host system
|
||||
func (a *Agent) initializeSystemInfo() {
|
||||
a.systemInfo.AgentVersion = beszel.Version
|
||||
a.systemInfo.Hostname, _ = os.Hostname()
|
||||
a.systemInfo.KernelVersion, _ = host.KernelVersion()
|
||||
|
||||
platform, _, version, _ := host.PlatformInformation()
|
||||
|
||||
if platform == "darwin" {
|
||||
a.systemInfo.KernelVersion = version
|
||||
a.systemInfo.Os = system.Darwin
|
||||
} else if strings.Contains(platform, "indows") {
|
||||
a.systemInfo.KernelVersion = strings.Replace(platform, "Microsoft ", "", 1) + " " + version
|
||||
a.systemInfo.Os = system.Windows
|
||||
} else {
|
||||
a.systemInfo.Os = system.Linux
|
||||
}
|
||||
|
||||
if a.systemInfo.KernelVersion == "" {
|
||||
a.systemInfo.KernelVersion, _ = host.KernelVersion()
|
||||
}
|
||||
|
||||
// cpu model
|
||||
if info, err := cpu.Info(); err == nil && len(info) > 0 {
|
||||
@@ -200,16 +214,24 @@ func (a *Agent) getSystemStats() system.Stats {
|
||||
if systemStats.Temperatures == nil {
|
||||
systemStats.Temperatures = make(map[string]float64, len(gpuData))
|
||||
}
|
||||
highestTemp := 0.0
|
||||
for _, gpu := range gpuData {
|
||||
if gpu.Temperature > 0 {
|
||||
systemStats.Temperatures[gpu.Name] = gpu.Temperature
|
||||
if a.primarySensor == gpu.Name {
|
||||
if a.sensorConfig.primarySensor == gpu.Name {
|
||||
a.systemInfo.DashboardTemp = gpu.Temperature
|
||||
}
|
||||
if gpu.Temperature > highestTemp {
|
||||
highestTemp = gpu.Temperature
|
||||
}
|
||||
}
|
||||
// update high gpu percent for dashboard
|
||||
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 +246,6 @@ func (a *Agent) getSystemStats() system.Stats {
|
||||
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
|
||||
func getARCSize() (uint64, error) {
|
||||
file, err := os.Open("/proc/spl/kstat/zfs/arcstats")
|
||||
|
||||
@@ -27,38 +27,41 @@ type ApiInfo struct {
|
||||
|
||||
// Docker container resources from /containers/{id}/stats
|
||||
type ApiStats struct {
|
||||
// Common stats
|
||||
// Read time.Time `json:"read"`
|
||||
// PreRead time.Time `json:"preread"`
|
||||
Read time.Time `json:"read"` // Time of stats generation
|
||||
NumProcs uint32 `json:"num_procs,omitzero"` // Windows specific, not populated on Linux.
|
||||
Networks map[string]NetworkStats
|
||||
CPUStats CPUStats `json:"cpu_stats"`
|
||||
MemoryStats MemoryStats `json:"memory_stats"`
|
||||
}
|
||||
|
||||
// Linux specific stats, not populated on Windows.
|
||||
// PidsStats PidsStats `json:"pids_stats,omitempty"`
|
||||
// BlkioStats BlkioStats `json:"blkio_stats,omitempty"`
|
||||
func (s *ApiStats) CalculateCpuPercentLinux(prevCpuUsage [2]uint64) float64 {
|
||||
cpuDelta := s.CPUStats.CPUUsage.TotalUsage - prevCpuUsage[0]
|
||||
systemDelta := s.CPUStats.SystemUsage - prevCpuUsage[1]
|
||||
return float64(cpuDelta) / float64(systemDelta) * 100
|
||||
}
|
||||
|
||||
// Windows specific stats, not populated on Linux.
|
||||
// NumProcs uint32 `json:"num_procs"`
|
||||
// StorageStats StorageStats `json:"storage_stats,omitempty"`
|
||||
// Networks request version >=1.21
|
||||
Networks map[string]NetworkStats
|
||||
// from: https://github.com/docker/cli/blob/master/cli/command/container/stats_helpers.go#L185
|
||||
func (s *ApiStats) CalculateCpuPercentWindows(prevCpuUsage uint64, prevRead time.Time) float64 {
|
||||
// Max number of 100ns intervals between the previous time read and now
|
||||
possIntervals := uint64(s.Read.Sub(prevRead).Nanoseconds())
|
||||
possIntervals /= 100 // Convert to number of 100ns intervals
|
||||
possIntervals *= uint64(s.NumProcs) // Multiple by the number of processors
|
||||
|
||||
// Shared stats
|
||||
CPUStats CPUStats `json:"cpu_stats,omitempty"`
|
||||
// PreCPUStats CPUStats `json:"precpu_stats,omitempty"` // "Pre"="Previous"
|
||||
MemoryStats MemoryStats `json:"memory_stats,omitempty"`
|
||||
// Intervals used
|
||||
intervalsUsed := s.CPUStats.CPUUsage.TotalUsage - prevCpuUsage
|
||||
|
||||
// Percentage avoiding divide-by-zero
|
||||
if possIntervals > 0 {
|
||||
return float64(intervalsUsed) / float64(possIntervals) * 100.0
|
||||
}
|
||||
return 0.00
|
||||
}
|
||||
|
||||
type CPUStats struct {
|
||||
// CPU Usage. Linux and Windows.
|
||||
CPUUsage CPUUsage `json:"cpu_usage"`
|
||||
|
||||
// System Usage. Linux only.
|
||||
SystemUsage uint64 `json:"system_cpu_usage,omitempty"`
|
||||
|
||||
// Online CPUs. Linux only.
|
||||
// OnlineCPUs uint32 `json:"online_cpus,omitempty"`
|
||||
|
||||
// Throttling Data. Linux only.
|
||||
// ThrottlingData ThrottlingData `json:"throttling_data,omitempty"`
|
||||
}
|
||||
|
||||
type CPUUsage struct {
|
||||
@@ -66,42 +69,15 @@ type CPUUsage struct {
|
||||
// Units: nanoseconds (Linux)
|
||||
// Units: 100's of nanoseconds (Windows)
|
||||
TotalUsage uint64 `json:"total_usage"`
|
||||
|
||||
// Total CPU time consumed per core (Linux). Not used on Windows.
|
||||
// Units: nanoseconds.
|
||||
// PercpuUsage []uint64 `json:"percpu_usage,omitempty"`
|
||||
|
||||
// Time spent by tasks of the cgroup in kernel mode (Linux).
|
||||
// Time spent by all container processes in kernel mode (Windows).
|
||||
// Units: nanoseconds (Linux).
|
||||
// Units: 100's of nanoseconds (Windows). Not populated for Hyper-V Containers.
|
||||
// UsageInKernelmode uint64 `json:"usage_in_kernelmode"`
|
||||
|
||||
// Time spent by tasks of the cgroup in user mode (Linux).
|
||||
// Time spent by all container processes in user mode (Windows).
|
||||
// Units: nanoseconds (Linux).
|
||||
// Units: 100's of nanoseconds (Windows). Not populated for Hyper-V Containers
|
||||
// UsageInUsermode uint64 `json:"usage_in_usermode"`
|
||||
}
|
||||
|
||||
type MemoryStats struct {
|
||||
// current res_counter usage for memory
|
||||
Usage uint64 `json:"usage,omitempty"`
|
||||
// all the stats exported via memory.stat.
|
||||
Stats MemoryStatsStats `json:"stats,omitempty"`
|
||||
// maximum usage ever recorded.
|
||||
// MaxUsage uint64 `json:"max_usage,omitempty"`
|
||||
// TODO(vishh): Export these as stronger types.
|
||||
// number of times memory usage hits limits.
|
||||
// Failcnt uint64 `json:"failcnt,omitempty"`
|
||||
// Limit uint64 `json:"limit,omitempty"`
|
||||
|
||||
// // committed bytes
|
||||
// Commit uint64 `json:"commitbytes,omitempty"`
|
||||
// // peak committed bytes
|
||||
// CommitPeak uint64 `json:"commitpeakbytes,omitempty"`
|
||||
// // private working set
|
||||
// PrivateWorkingSet uint64 `json:"privateworkingset,omitempty"`
|
||||
Stats MemoryStatsStats `json:"stats"`
|
||||
// private working set (Windows only)
|
||||
PrivateWorkingSet uint64 `json:"privateworkingset,omitempty"`
|
||||
}
|
||||
|
||||
type MemoryStatsStats struct {
|
||||
@@ -119,7 +95,6 @@ type NetworkStats struct {
|
||||
type prevNetStats struct {
|
||||
Sent uint64
|
||||
Recv uint64
|
||||
Time time.Time
|
||||
}
|
||||
|
||||
// Docker container stats
|
||||
@@ -131,4 +106,5 @@ type Stats struct {
|
||||
NetworkRecv float64 `json:"nr"`
|
||||
PrevCpu [2]uint64 `json:"-"`
|
||||
PrevNet prevNetStats `json:"-"`
|
||||
PrevRead time.Time `json:"-"`
|
||||
}
|
||||
|
||||
@@ -64,6 +64,14 @@ type NetIoStats struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
type Os uint8
|
||||
|
||||
const (
|
||||
Linux Os = iota
|
||||
Darwin
|
||||
Windows
|
||||
)
|
||||
|
||||
type Info struct {
|
||||
Hostname string `json:"h"`
|
||||
KernelVersion string `json:"k,omitempty"`
|
||||
@@ -79,6 +87,7 @@ type Info struct {
|
||||
Podman bool `json:"p,omitempty"`
|
||||
GpuPct float64 `json:"g,omitempty"`
|
||||
DashboardTemp float64 `json:"dt,omitempty"`
|
||||
Os Os `json:"os"`
|
||||
}
|
||||
|
||||
// Final data structure to return to the hub
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro"
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
@@ -19,11 +19,12 @@ import { $publicKey, pb } from "@/lib/stores"
|
||||
import { cn, copyToClipboard, isReadOnlyUser, useLocalStorage } from "@/lib/utils"
|
||||
import { i18n } from "@lingui/core"
|
||||
import { useStore } from "@nanostores/react"
|
||||
import { ChevronDownIcon, Copy, PlusIcon } from "lucide-react"
|
||||
import { ChevronDownIcon, Copy, ExternalLinkIcon, PlusIcon } from "lucide-react"
|
||||
import { memo, useRef, useState } from "react"
|
||||
import { basePath, navigate } from "./router"
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "./ui/dropdown-menu"
|
||||
import { SystemRecord } from "@/types"
|
||||
import { AppleIcon, DockerIcon, TuxIcon, WindowsIcon } from "./ui/icons"
|
||||
|
||||
export function AddSystemButton({ className }: { className?: string }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
@@ -72,15 +73,22 @@ function copyDockerRun(port = "45876", publicKey: string) {
|
||||
)
|
||||
}
|
||||
|
||||
function copyInstallCommand(port = "45876", publicKey: string) {
|
||||
let cmd = `curl -sL https://raw.githubusercontent.com/henrygd/beszel/main/supplemental/scripts/install-agent.sh -o install-agent.sh && chmod +x install-agent.sh && ./install-agent.sh -p ${port} -k "${publicKey}"`
|
||||
// add china mirrors flag if zh-CN
|
||||
function copyLinuxCommand(port = "45876", publicKey: string, brew = false) {
|
||||
let cmd = `curl -sL https://get.beszel.dev${
|
||||
brew ? "/brew" : ""
|
||||
} -o /tmp/install-agent.sh && chmod +x /tmp/install-agent.sh && /tmp/install-agent.sh -p ${port} -k "${publicKey}"`
|
||||
if ((i18n.locale + navigator.language).includes("zh-CN")) {
|
||||
cmd += ` --china-mirrors`
|
||||
}
|
||||
copyToClipboard(cmd)
|
||||
}
|
||||
|
||||
function copyWindowsCommand(port = "45876", publicKey: string) {
|
||||
copyToClipboard(
|
||||
`Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser; & iwr -useb https://get.beszel.dev -OutFile "$env:TEMP\install-agent.ps1"; & "$env:TEMP\install-agent.ps1" -Key "${publicKey}" -Port ${port}`
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* SystemDialog component for adding or editing a system.
|
||||
* @param {Object} props - The component props.
|
||||
@@ -197,7 +205,7 @@ export const SystemDialog = memo(({ setOpen, system }: { setOpen: (open: boolean
|
||||
className="absolute end-0 top-0"
|
||||
onClick={() => copyToClipboard(publicKey)}
|
||||
>
|
||||
<Copy className="h-4 w-4 " />
|
||||
<Copy className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
@@ -215,17 +223,39 @@ export const SystemDialog = memo(({ setOpen, system }: { setOpen: (open: boolean
|
||||
<CopyButton
|
||||
text={t`Copy` + " docker compose"}
|
||||
onClick={() => copyDockerCompose(isUnixSocket ? hostValue : port.current?.value, publicKey)}
|
||||
dropdownText={t`Copy` + " docker run"}
|
||||
dropdownOnClick={() => copyDockerRun(isUnixSocket ? hostValue : port.current?.value, publicKey)}
|
||||
icon={<DockerIcon className="size-4 -me-0.5" />}
|
||||
dropdownItems={[
|
||||
{
|
||||
text: t`Copy` + " docker run",
|
||||
onClick: () => copyDockerRun(isUnixSocket ? hostValue : port.current?.value, publicKey),
|
||||
icons: [<DockerIcon className="size-4" />],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</TabsContent>
|
||||
{/* Binary */}
|
||||
<TabsContent value="binary" className="contents">
|
||||
<CopyButton
|
||||
text={t`Copy Linux command`}
|
||||
onClick={() => copyInstallCommand(isUnixSocket ? hostValue : port.current?.value, publicKey)}
|
||||
dropdownText={t`Manual setup instructions`}
|
||||
dropdownUrl="https://beszel.dev/guide/agent-installation#binary"
|
||||
icon={<TuxIcon className="size-4" />}
|
||||
onClick={() => copyLinuxCommand(isUnixSocket ? hostValue : port.current?.value, publicKey)}
|
||||
dropdownItems={[
|
||||
{
|
||||
text: t`Copy Homebrew command`,
|
||||
onClick: () => copyLinuxCommand(isUnixSocket ? hostValue : port.current?.value, publicKey, true),
|
||||
icons: [<AppleIcon className="size-4" />, <TuxIcon className="w-4 h-4" />],
|
||||
},
|
||||
{
|
||||
text: t`Copy Windows command`,
|
||||
onClick: () => copyWindowsCommand(isUnixSocket ? hostValue : port.current?.value, publicKey),
|
||||
icons: [<WindowsIcon className="size-4" />],
|
||||
},
|
||||
{
|
||||
text: t`Manual setup instructions`,
|
||||
url: "https://beszel.dev/guide/agent-installation#binary",
|
||||
icons: [<ExternalLinkIcon className="size-4" />],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</TabsContent>
|
||||
{/* Save */}
|
||||
@@ -237,19 +267,30 @@ export const SystemDialog = memo(({ setOpen, system }: { setOpen: (open: boolean
|
||||
)
|
||||
})
|
||||
|
||||
interface DropdownItem {
|
||||
text: string
|
||||
onClick?: () => void
|
||||
url?: string
|
||||
icons?: React.ReactNode[]
|
||||
}
|
||||
|
||||
interface CopyButtonProps {
|
||||
text: string
|
||||
onClick: () => void
|
||||
dropdownText: string
|
||||
dropdownOnClick?: () => void
|
||||
dropdownUrl?: string
|
||||
dropdownItems: DropdownItem[]
|
||||
icon?: React.ReactNode
|
||||
}
|
||||
|
||||
const CopyButton = memo((props: CopyButtonProps) => {
|
||||
return (
|
||||
<div className="flex gap-0 rounded-lg">
|
||||
<Button type="button" variant="outline" onClick={props.onClick} className="rounded-e-none dark:border-e-0 grow">
|
||||
{props.text}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={props.onClick}
|
||||
className="rounded-e-none dark:border-e-0 grow flex items-center gap-2"
|
||||
>
|
||||
{props.text} {props.icon}
|
||||
</Button>
|
||||
<div className="w-px h-full bg-muted"></div>
|
||||
<DropdownMenu>
|
||||
@@ -259,15 +300,24 @@ const CopyButton = memo((props: CopyButtonProps) => {
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{props.dropdownUrl ? (
|
||||
<DropdownMenuItem asChild>
|
||||
<a href={props.dropdownUrl} className="cursor-pointer" target="_blank" rel="noopener noreferrer">
|
||||
{props.dropdownText}
|
||||
</a>
|
||||
{props.dropdownItems.map((item, index) => (
|
||||
<DropdownMenuItem key={index} asChild={!!item.url}>
|
||||
{item.url ? (
|
||||
<a
|
||||
href={item.url}
|
||||
className="cursor-pointer flex items-center gap-1.5"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{item.text} {item.icons?.map((icon) => icon)}
|
||||
</a>
|
||||
) : (
|
||||
<div onClick={item.onClick} className="cursor-pointer flex items-center gap-1.5">
|
||||
{item.text} {item.icons?.map((icon) => icon)}
|
||||
</div>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem onClick={props.dropdownOnClick} className="cursor-pointer">{props.dropdownText}</DropdownMenuItem>
|
||||
)}
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
@@ -16,16 +16,17 @@ import { useStore } from "@nanostores/react"
|
||||
import { $containerFilter } from "@/lib/stores"
|
||||
import { ChartData } from "@/types"
|
||||
import { Separator } from "../ui/separator"
|
||||
import { ChartType } from "@/lib/enums"
|
||||
|
||||
export default memo(function ContainerChart({
|
||||
dataKey,
|
||||
chartData,
|
||||
chartName,
|
||||
chartType,
|
||||
unit = "%",
|
||||
}: {
|
||||
dataKey: string
|
||||
chartData: ChartData
|
||||
chartName: string
|
||||
chartType: ChartType
|
||||
unit?: string
|
||||
}) {
|
||||
const filter = useStore($containerFilter)
|
||||
@@ -33,7 +34,7 @@ export default memo(function ContainerChart({
|
||||
|
||||
const { containerData } = chartData
|
||||
|
||||
const isNetChart = chartName === "net"
|
||||
const isNetChart = chartType === ChartType.Network
|
||||
|
||||
const chartConfig = useMemo(() => {
|
||||
let config = {} as Record<
|
||||
@@ -81,7 +82,7 @@ export default memo(function ContainerChart({
|
||||
tickFormatter: (value: any) => string
|
||||
}
|
||||
// tick formatter
|
||||
if (chartName === "cpu") {
|
||||
if (chartType === ChartType.CPU) {
|
||||
obj.tickFormatter = (value) => {
|
||||
const val = toFixedWithoutTrailingZeros(value, 2) + unit
|
||||
return updateYAxisWidth(val)
|
||||
@@ -111,6 +112,11 @@ export default memo(function ContainerChart({
|
||||
return null
|
||||
}
|
||||
}
|
||||
} else if (chartType === ChartType.Memory) {
|
||||
obj.toolTipFormatter = (item: any) => {
|
||||
const { v, u } = getSizeAndUnit(item.value, false)
|
||||
return updateYAxisWidth(toFixedFloat(v, 2) + u)
|
||||
}
|
||||
} else {
|
||||
obj.toolTipFormatter = (item: any) => decimalString(item.value) + unit
|
||||
}
|
||||
@@ -157,13 +163,14 @@ export default memo(function ContainerChart({
|
||||
<ChartTooltip
|
||||
animationEasing="ease-out"
|
||||
animationDuration={150}
|
||||
truncate={true}
|
||||
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
||||
// @ts-ignore
|
||||
itemSorter={(a, b) => b.value - a.value}
|
||||
content={<ChartTooltipContent filter={filter} contentFormatter={toolTipFormatter} />}
|
||||
/>
|
||||
{Object.keys(chartConfig).map((key) => {
|
||||
const filtered = filter && !key.includes(filter)
|
||||
const filtered = filter && !key.toLowerCase().includes(filter.toLowerCase())
|
||||
let fillOpacity = filtered ? 0.05 : 0.4
|
||||
let strokeOpacity = filtered ? 0.1 : 1
|
||||
return (
|
||||
|
||||
@@ -18,8 +18,11 @@ import {
|
||||
} from "@/lib/utils"
|
||||
import { ChartData } from "@/types"
|
||||
import { memo, useMemo } from "react"
|
||||
import { $temperatureFilter } from "@/lib/stores"
|
||||
import { useStore } from "@nanostores/react"
|
||||
|
||||
export default memo(function TemperatureChart({ chartData }: { chartData: ChartData }) {
|
||||
const filter = useStore($temperatureFilter)
|
||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||
|
||||
if (chartData.systemStats.length === 0) {
|
||||
@@ -86,22 +89,28 @@ export default memo(function TemperatureChart({ chartData }: { chartData: ChartD
|
||||
<ChartTooltipContent
|
||||
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
||||
contentFormatter={(item) => decimalString(item.value) + " °C"}
|
||||
// indicator="line"
|
||||
filter={filter}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{colors.map((key) => (
|
||||
<Line
|
||||
key={key}
|
||||
dataKey={key}
|
||||
name={key}
|
||||
type="monotoneX"
|
||||
dot={false}
|
||||
strokeWidth={1.5}
|
||||
stroke={newChartData.colors[key]}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
))}
|
||||
{colors.map((key) => {
|
||||
const filtered = filter && !key.toLowerCase().includes(filter.toLowerCase())
|
||||
let strokeOpacity = filtered ? 0.1 : 1
|
||||
return (
|
||||
<Line
|
||||
key={key}
|
||||
dataKey={key}
|
||||
name={key}
|
||||
type="monotoneX"
|
||||
dot={false}
|
||||
strokeWidth={1.5}
|
||||
stroke={newChartData.colors[key]}
|
||||
strokeOpacity={strokeOpacity}
|
||||
activeDot={{ opacity: filtered ? 0 : 1 }}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{colors.length < 12 && <ChartLegend content={<ChartLegendContent />} />}
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
import { memo, useEffect, useMemo } from "react"
|
||||
import { $systems } from "@/lib/stores"
|
||||
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 { t } from "@lingui/core/macro"
|
||||
import { getPagePath } from "@nanostores/router"
|
||||
@@ -133,7 +133,7 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
|
||||
keywords={["pocketbase"]}
|
||||
onSelect={() => {
|
||||
setOpen(false)
|
||||
window.open("/_/", "_blank")
|
||||
window.open(prependBasePath("/_/"), "_blank")
|
||||
}}
|
||||
>
|
||||
<UsersIcon className="me-2 h-4 w-4" />
|
||||
@@ -147,7 +147,7 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
setOpen(false)
|
||||
window.open("/_/#/logs", "_blank")
|
||||
window.open(prependBasePath("/_/#/logs"), "_blank")
|
||||
}}
|
||||
>
|
||||
<LogsIcon className="me-2 h-4 w-4" />
|
||||
@@ -161,7 +161,7 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
setOpen(false)
|
||||
window.open("/_/#/settings/backups", "_blank")
|
||||
window.open(prependBasePath("/_/#/settings/backups"), "_blank")
|
||||
}}
|
||||
>
|
||||
<DatabaseBackupIcon className="me-2 h-4 w-4" />
|
||||
@@ -176,7 +176,7 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
|
||||
keywords={["email"]}
|
||||
onSelect={() => {
|
||||
setOpen(false)
|
||||
window.open("/_/#/settings/mail", "_blank")
|
||||
window.open(prependBasePath("/_/#/settings/mail"), "_blank")
|
||||
}}
|
||||
>
|
||||
<MailIcon className="me-2 h-4 w-4" />
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { Plural, Trans } from "@lingui/react/macro"
|
||||
import { $systems, pb, $chartTime, $containerFilter, $userSettings, $direction, $maxValues } from "@/lib/stores"
|
||||
import {
|
||||
$systems,
|
||||
pb,
|
||||
$chartTime,
|
||||
$containerFilter,
|
||||
$userSettings,
|
||||
$direction,
|
||||
$maxValues,
|
||||
$temperatureFilter,
|
||||
} from "@/lib/stores"
|
||||
import { ChartData, ChartTimes, ContainerStatsRecord, GPUData, SystemRecord, SystemStatsRecord } from "@/types"
|
||||
import { ChartType, Os } from "@/lib/enums"
|
||||
import React, { lazy, memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { Card, CardHeader, CardTitle, CardDescription } from "../ui/card"
|
||||
import { useStore } from "@nanostores/react"
|
||||
@@ -22,7 +32,7 @@ import { Separator } from "../ui/separator"
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip"
|
||||
import { Button } from "../ui/button"
|
||||
import { Input } from "../ui/input"
|
||||
import { ChartAverage, ChartMax, Rows, TuxIcon } from "../ui/icons"
|
||||
import { ChartAverage, ChartMax, Rows, TuxIcon, WindowsIcon, AppleIcon } from "../ui/icons"
|
||||
import { useIntersectionObserver } from "@/lib/use-intersection-observer"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select"
|
||||
import { timeTicks } from "d3-time"
|
||||
@@ -218,7 +228,7 @@ export default function SystemDetail({ name }: { name: string }) {
|
||||
cache.set(cs_cache_key, containerData)
|
||||
}
|
||||
if (containerData.length) {
|
||||
!containerFilterBar && setContainerFilterBar(<ContainerFilterBar />)
|
||||
!containerFilterBar && setContainerFilterBar(<FilterBar />)
|
||||
} else if (containerFilterBar) {
|
||||
setContainerFilterBar(null)
|
||||
}
|
||||
@@ -251,6 +261,23 @@ export default function SystemDetail({ name }: { name: string }) {
|
||||
if (!system.info) {
|
||||
return []
|
||||
}
|
||||
|
||||
const osInfo = {
|
||||
[Os.Linux]: {
|
||||
Icon: TuxIcon,
|
||||
value: system.info.k,
|
||||
label: t({ comment: "Linux kernel", message: "Kernel" }),
|
||||
},
|
||||
[Os.Darwin]: {
|
||||
Icon: AppleIcon,
|
||||
value: `macOS ${system.info.k}`,
|
||||
},
|
||||
[Os.Windows]: {
|
||||
Icon: WindowsIcon,
|
||||
value: system.info.k,
|
||||
},
|
||||
}
|
||||
|
||||
let uptime: React.ReactNode
|
||||
if (system.info.u < 172800) {
|
||||
const hours = Math.trunc(system.info.u / 3600)
|
||||
@@ -268,7 +295,7 @@ export default function SystemDetail({ name }: { name: string }) {
|
||||
hide: system.info.h === system.host || system.info.h === system.name,
|
||||
},
|
||||
{ value: uptime, Icon: ClockArrowUp, label: t`Uptime`, hide: !system.info.u },
|
||||
{ value: system.info.k, Icon: TuxIcon, label: t({ comment: "Linux kernel", message: "Kernel" }) },
|
||||
osInfo[system.info.os ?? Os.Linux],
|
||||
{
|
||||
value: `${system.info.m} (${system.info.c}c${system.info.t ? `/${system.info.t}t` : ""})`,
|
||||
Icon: CpuIcon,
|
||||
@@ -302,7 +329,13 @@ export default function SystemDetail({ name }: { name: string }) {
|
||||
return
|
||||
}
|
||||
const handleKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
|
||||
if (
|
||||
e.target instanceof HTMLInputElement ||
|
||||
e.target instanceof HTMLTextAreaElement ||
|
||||
e.shiftKey ||
|
||||
e.ctrlKey ||
|
||||
e.metaKey
|
||||
) {
|
||||
return
|
||||
}
|
||||
const currentIndex = systems.findIndex((s) => s.name === name)
|
||||
@@ -446,7 +479,7 @@ export default function SystemDetail({ name }: { name: string }) {
|
||||
description={t`Average CPU utilization of containers`}
|
||||
cornerEl={containerFilterBar}
|
||||
>
|
||||
<ContainerChart chartData={chartData} dataKey="c" chartName="cpu" />
|
||||
<ContainerChart chartData={chartData} dataKey="c" chartType={ChartType.CPU} />
|
||||
</ChartCard>
|
||||
)}
|
||||
|
||||
@@ -467,7 +500,7 @@ export default function SystemDetail({ name }: { name: string }) {
|
||||
description={dockerOrPodman(t`Memory usage of docker containers`, system)}
|
||||
cornerEl={containerFilterBar}
|
||||
>
|
||||
<ContainerChart chartData={chartData} chartName="mem" dataKey="m" unit=" MB" />
|
||||
<ContainerChart chartData={chartData} dataKey="m" chartType={ChartType.Memory} />
|
||||
</ChartCard>
|
||||
)}
|
||||
|
||||
@@ -509,7 +542,7 @@ export default function SystemDetail({ name }: { name: string }) {
|
||||
cornerEl={containerFilterBar}
|
||||
>
|
||||
{/* @ts-ignore */}
|
||||
<ContainerChart chartData={chartData} chartName="net" dataKey="n" />
|
||||
<ContainerChart chartData={chartData} chartType={ChartType.Network} dataKey="n" />
|
||||
</ChartCard>
|
||||
</div>
|
||||
)}
|
||||
@@ -533,6 +566,7 @@ export default function SystemDetail({ name }: { name: string }) {
|
||||
grid={grid}
|
||||
title={t`Temperature`}
|
||||
description={t`Temperatures of system sensors`}
|
||||
cornerEl={<FilterBar store={$temperatureFilter} />}
|
||||
>
|
||||
<TemperatureChart chartData={chartData} />
|
||||
</ChartCard>
|
||||
@@ -630,12 +664,12 @@ export default function SystemDetail({ name }: { name: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
function ContainerFilterBar() {
|
||||
const containerFilter = useStore($containerFilter)
|
||||
function FilterBar({ store = $containerFilter }: { store?: typeof $containerFilter }) {
|
||||
const containerFilter = useStore(store)
|
||||
const { t } = useLingui()
|
||||
|
||||
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
$containerFilter.set(e.target.value)
|
||||
store.set(e.target.value)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
@@ -648,7 +682,7 @@ function ContainerFilterBar() {
|
||||
size="icon"
|
||||
aria-label="Clear"
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100"
|
||||
onClick={() => $containerFilter.set("")}
|
||||
onClick={() => store.set("")}
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
@@ -99,6 +99,7 @@ const ChartTooltipContent = React.forwardRef<
|
||||
unit?: string
|
||||
filter?: string
|
||||
contentFormatter?: (item: any, key: string) => React.ReactNode | string
|
||||
truncate?: boolean
|
||||
}
|
||||
>(
|
||||
(
|
||||
@@ -119,6 +120,7 @@ const ChartTooltipContent = React.forwardRef<
|
||||
filter,
|
||||
itemSorter,
|
||||
contentFormatter: content = undefined,
|
||||
truncate = false,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
@@ -127,7 +129,7 @@ const ChartTooltipContent = React.forwardRef<
|
||||
|
||||
React.useMemo(() => {
|
||||
if (filter) {
|
||||
payload = payload?.filter((item) => (item.name as string)?.includes(filter))
|
||||
payload = payload?.filter((item) => (item.name as string)?.toLowerCase().includes(filter.toLowerCase()))
|
||||
}
|
||||
if (itemSorter) {
|
||||
// @ts-ignore
|
||||
@@ -214,10 +216,15 @@ const ChartTooltipContent = React.forwardRef<
|
||||
nestLabel ? "items-end" : "items-center"
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-muted-foreground">{itemConfig?.label || item.name}</span>
|
||||
</div>
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span
|
||||
className={cn(
|
||||
"text-muted-foreground",
|
||||
truncate ? "max-w-40 truncate leading-normal -my-1" : ""
|
||||
)}
|
||||
>
|
||||
{itemConfig?.label || item.name}
|
||||
</span>
|
||||
{item.value !== undefined && (
|
||||
<span className="font-medium tabular-nums text-foreground">
|
||||
{content && typeof content === "function"
|
||||
|
||||
@@ -12,6 +12,42 @@ export function TuxIcon(props: SVGProps<SVGSVGElement>) {
|
||||
)
|
||||
}
|
||||
|
||||
// icon park (Apache 2.0) https://github.com/bytedance/IconPark/blob/master/LICENSE
|
||||
export function WindowsIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} viewBox="0 0 48 48">
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3.8"
|
||||
d="m6.8 11 12.9-1.7v12.1h-13zm18-2.2 16.4-2v14.6H25zm0 18.6 16.4.4v13.4L25 38.6zm-18-.8 12.9.3v10.9l-13-2.2z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// teenyicons (MIT) https://github.com/teenyicons/teenyicons/blob/master/LICENSE
|
||||
export function AppleIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" {...props}>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M14.1 4.7a5 5 0 0 1 3.8 2c-3.3 1.9-2.8 6.7.6 8L17.2 17c-.8 1.3-2 2.9-3.5 2.9-1.2 0-1.6-.9-3.3-.8s-2.2.8-3.5.8c-1.4 0-2.5-1.5-3.4-2.7-2.3-3.6-2.5-7.9-1.1-10 1-1.7 2.6-2.6 4.1-2.6 1.6 0 2.6.8 3.8.8 1.3 0 2-.8 3.8-.8M13.7 0c.2 1.2-.3 2.4-1 3.2a4 4 0 0 1-3 1.6c-.2-1.2.3-2.3 1-3.2.7-.8 2-1.5 3-1.6"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// ion icons (MIT) https://github.com/ionic-team/ionicons/blob/main/LICENSE
|
||||
export function DockerIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} viewBox="0 0 512 512" fill="currentColor">
|
||||
<path d="M507 211c-1-1-14-11-42-11a133 133 0 0 0-21 2c-6-36-36-54-37-55l-7-4-5 7a102 102 0 0 0-13 30c-5 21-2 40 8 57-12 7-33 9-37 9H16a16 16 0 0 0-16 16 241 241 0 0 0 15 87c11 30 29 53 51 67 25 15 66 24 113 24a344 344 0 0 0 62-6 257 257 0 0 0 82-29 224 224 0 0 0 55-46c27-30 43-64 55-94h4c30 0 48-12 58-22a63 63 0 0 0 15-22l2-6Z" />
|
||||
<path d="M47 236h45a4 4 0 0 0 4-4v-40a4 4 0 0 0-4-4H47a4 4 0 0 0-4 4v40a4 4 0 0 0 4 4m63 0h45a4 4 0 0 0 4-4v-40a4 4 0 0 0-4-4h-45a4 4 0 0 0-4 4v40a4 4 0 0 0 4 4m63 0h45a4 4 0 0 0 4-4v-40a4 4 0 0 0-4-4h-45a4 4 0 0 0-4 4v40a4 4 0 0 0 4 4m62 0h45a4 4 0 0 0 4-4v-40a4 4 0 0 0-4-4h-45a4 4 0 0 0-4 4v40a4 4 0 0 0 4 4m-125-57h45a4 4 0 0 0 4-4v-41a4 4 0 0 0-4-4h-45a4 4 0 0 0-4 4v41a4 4 0 0 0 4 4m63 0h45a4 4 0 0 0 4-4v-41a4 4 0 0 0-4-4h-45a4 4 0 0 0-4 4v41a4 4 0 0 0 4 4m62 0h45a4 4 0 0 0 4-4v-41a4 4 0 0 0-4-4h-45a4 4 0 0 0-4 4v41a4 4 0 0 0 4 4m0-58h45a4 4 0 0 0 4-4V76a4 4 0 0 0-4-4h-45a4 4 0 0 0-4 4v40a4 4 0 0 0 4 4m63 116h45a4 4 0 0 0 4-4v-40a4 4 0 0 0-4-4h-45a4 4 0 0 0-4 4v40a4 4 0 0 0 4 4" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// MingCute Apache License 2.0 https://github.com/Richard9394/MingCute
|
||||
export function Rows(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
|
||||
13
beszel/site/src/lib/enums.ts
Normal file
13
beszel/site/src/lib/enums.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export enum Os {
|
||||
Linux = 0,
|
||||
Darwin,
|
||||
Windows,
|
||||
// FreeBSD,
|
||||
}
|
||||
|
||||
export enum ChartType {
|
||||
Memory,
|
||||
Disk,
|
||||
Network,
|
||||
CPU,
|
||||
}
|
||||
@@ -38,6 +38,9 @@ $userSettings.subscribe((value) => {
|
||||
/** Container chart filter */
|
||||
export const $containerFilter = atom("")
|
||||
|
||||
/** Temperature chart filter */
|
||||
export const $temperatureFilter = atom("")
|
||||
|
||||
/** Fallback copy to clipboard dialog content */
|
||||
export const $copyContent = atom("")
|
||||
|
||||
|
||||
3
beszel/site/src/types.d.ts
vendored
3
beszel/site/src/types.d.ts
vendored
@@ -1,4 +1,5 @@
|
||||
import { RecordModel } from "pocketbase"
|
||||
import { Os } from "./lib/enums"
|
||||
|
||||
// global window properties
|
||||
declare global {
|
||||
@@ -48,6 +49,8 @@ export interface SystemInfo {
|
||||
g?: number
|
||||
/** dashboard display temperature */
|
||||
dt?: number
|
||||
/** operating system */
|
||||
os?: Os
|
||||
}
|
||||
|
||||
export interface SystemStats {
|
||||
|
||||
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_PROXY_URL=""
|
||||
KEY=""
|
||||
AUTO_UPDATE_FLAG="" # empty string means prompt, "true" means auto-enable, "false" means skip
|
||||
|
||||
# Check for help flag
|
||||
case "$1" in
|
||||
@@ -37,8 +38,10 @@ case "$1" in
|
||||
printf " -k : SSH key (required, or interactive if not provided)\n"
|
||||
printf " -p : Port (default: $PORT)\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 " optional: specify a custom proxy URL, e.g., \"https://ghfast.top\"\n"
|
||||
printf " --auto-update [VALUE] : Control automatic daily updates\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"
|
||||
exit 0
|
||||
;;
|
||||
@@ -84,17 +87,50 @@ while [ $# -gt 0 ]; do
|
||||
-u)
|
||||
UNINSTALL=true
|
||||
;;
|
||||
--china-mirrors)
|
||||
if [ "$2" != "" ] && ! echo "$2" | grep -q '^-'; then
|
||||
# use cstom proxy URL if provided
|
||||
--china-mirrors*)
|
||||
# Check if there's a value after the = sign
|
||||
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_URL="$(ensure_trailing_slash "$2")https://github.com"
|
||||
shift
|
||||
else
|
||||
# No value specified, use default
|
||||
GITHUB_PROXY_URL="https://gh.beszel.dev"
|
||||
GITHUB_URL="$GITHUB_PROXY_URL"
|
||||
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
|
||||
exit 1
|
||||
@@ -348,8 +384,14 @@ EOF
|
||||
fi
|
||||
|
||||
# Auto-update service for Alpine
|
||||
printf "\nWould you like to enable automatic daily updates for beszel-agent? (y/n): "
|
||||
read AUTO_UPDATE
|
||||
if [ "$AUTO_UPDATE_FLAG" = "true" ]; then
|
||||
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
|
||||
[Yy]*)
|
||||
echo "Setting up daily automatic updates for beszel-agent..."
|
||||
@@ -432,8 +474,16 @@ EOF
|
||||
service beszel-agent restart
|
||||
|
||||
# Auto-update service for OpenWRT using a crontab job
|
||||
printf "\nWould you like to enable automatic daily updates for beszel-agent? (y/n): "
|
||||
read AUTO_UPDATE
|
||||
if [ "$AUTO_UPDATE_FLAG" = "true" ]; then
|
||||
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
|
||||
[Yy]*)
|
||||
echo "Setting up daily automatic updates for beszel-agent..."
|
||||
@@ -499,8 +549,16 @@ EOF
|
||||
systemctl start beszel-agent.service
|
||||
|
||||
# Prompt for auto-update setup
|
||||
printf "\nWould you like to enable automatic daily updates for beszel-agent? (y/n): "
|
||||
read AUTO_UPDATE
|
||||
if [ "$AUTO_UPDATE_FLAG" = "true" ]; then
|
||||
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
|
||||
[Yy]*)
|
||||
echo "Setting up daily automatic updates for beszel-agent..."
|
||||
|
||||
Reference in New Issue
Block a user