mirror of
https://github.com/henrygd/beszel.git
synced 2026-03-22 21:46:18 +01:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d87102e0c | ||
|
|
6a406e5206 | ||
|
|
240e75f025 | ||
|
|
ea984844ff | ||
|
|
0d157b5857 | ||
|
|
d0b6e725c8 | ||
|
|
ffe7f8547a | ||
|
|
37817b0f15 | ||
|
|
a66ac418ae | ||
|
|
2ee2f53267 | ||
|
|
e5c766c00b | ||
|
|
da43ba10e1 |
@@ -9,19 +9,21 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/agent/health"
|
"github.com/henrygd/beszel/agent/health"
|
||||||
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ConnectionManager manages the connection state and events for the agent.
|
// ConnectionManager manages the connection state and events for the agent.
|
||||||
// It handles both WebSocket and SSH connections, automatically switching between
|
// It handles both WebSocket and SSH connections, automatically switching between
|
||||||
// them based on availability and managing reconnection attempts.
|
// them based on availability and managing reconnection attempts.
|
||||||
type ConnectionManager struct {
|
type ConnectionManager struct {
|
||||||
agent *Agent // Reference to the parent agent
|
agent *Agent // Reference to the parent agent
|
||||||
State ConnectionState // Current connection state
|
State ConnectionState // Current connection state
|
||||||
eventChan chan ConnectionEvent // Channel for connection events
|
eventChan chan ConnectionEvent // Channel for connection events
|
||||||
wsClient *WebSocketClient // WebSocket client for hub communication
|
wsClient *WebSocketClient // WebSocket client for hub communication
|
||||||
serverOptions ServerOptions // Configuration for SSH server
|
serverOptions ServerOptions // Configuration for SSH server
|
||||||
wsTicker *time.Ticker // Ticker for WebSocket connection attempts
|
wsTicker *time.Ticker // Ticker for WebSocket connection attempts
|
||||||
isConnecting bool // Prevents multiple simultaneous reconnection attempts
|
isConnecting bool // Prevents multiple simultaneous reconnection attempts
|
||||||
|
ConnectionType system.ConnectionType
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConnectionState represents the current connection state of the agent.
|
// ConnectionState represents the current connection state of the agent.
|
||||||
@@ -144,15 +146,18 @@ func (c *ConnectionManager) handleStateChange(newState ConnectionState) {
|
|||||||
switch newState {
|
switch newState {
|
||||||
case WebSocketConnected:
|
case WebSocketConnected:
|
||||||
slog.Info("WebSocket connected", "host", c.wsClient.hubURL.Host)
|
slog.Info("WebSocket connected", "host", c.wsClient.hubURL.Host)
|
||||||
|
c.ConnectionType = system.ConnectionTypeWebSocket
|
||||||
c.stopWsTicker()
|
c.stopWsTicker()
|
||||||
_ = c.agent.StopServer()
|
_ = c.agent.StopServer()
|
||||||
c.isConnecting = false
|
c.isConnecting = false
|
||||||
case SSHConnected:
|
case SSHConnected:
|
||||||
// stop new ws connection attempts
|
// stop new ws connection attempts
|
||||||
slog.Info("SSH connection established")
|
slog.Info("SSH connection established")
|
||||||
|
c.ConnectionType = system.ConnectionTypeSSH
|
||||||
c.stopWsTicker()
|
c.stopWsTicker()
|
||||||
c.isConnecting = false
|
c.isConnecting = false
|
||||||
case Disconnected:
|
case Disconnected:
|
||||||
|
c.ConnectionType = system.ConnectionTypeNone
|
||||||
if c.isConnecting {
|
if c.isConnecting {
|
||||||
// Already handling reconnection, avoid duplicate attempts
|
// Already handling reconnection, avoid duplicate attempts
|
||||||
return
|
return
|
||||||
|
|||||||
76
agent/gpu.go
76
agent/gpu.go
@@ -27,13 +27,10 @@ const (
|
|||||||
nvidiaSmiInterval string = "4" // in seconds
|
nvidiaSmiInterval string = "4" // in seconds
|
||||||
tegraStatsInterval string = "3700" // in milliseconds
|
tegraStatsInterval string = "3700" // in milliseconds
|
||||||
rocmSmiInterval time.Duration = 4300 * time.Millisecond
|
rocmSmiInterval time.Duration = 4300 * time.Millisecond
|
||||||
|
|
||||||
// Command retry and timeout constants
|
// Command retry and timeout constants
|
||||||
retryWaitTime time.Duration = 5 * time.Second
|
retryWaitTime time.Duration = 5 * time.Second
|
||||||
maxFailureRetries int = 5
|
maxFailureRetries int = 5
|
||||||
|
|
||||||
cmdBufferSize uint16 = 10 * 1024
|
|
||||||
|
|
||||||
// Unit Conversions
|
// Unit Conversions
|
||||||
mebibytesInAMegabyte float64 = 1.024 // nvidia-smi reports memory in MiB
|
mebibytesInAMegabyte float64 = 1.024 // nvidia-smi reports memory in MiB
|
||||||
milliwattsInAWatt float64 = 1000.0 // tegrastats reports power in mW
|
milliwattsInAWatt float64 = 1000.0 // tegrastats reports power in mW
|
||||||
@@ -42,10 +39,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
|
intelGpuStats bool
|
||||||
|
GpuDataMap map[string]*system.GPUData
|
||||||
}
|
}
|
||||||
|
|
||||||
// RocmSmiJson represents the JSON structure of rocm-smi output
|
// RocmSmiJson represents the JSON structure of rocm-smi output
|
||||||
@@ -66,6 +64,7 @@ type gpuCollector struct {
|
|||||||
cmdArgs []string
|
cmdArgs []string
|
||||||
parse func([]byte) bool // returns true if valid data was found
|
parse func([]byte) bool // returns true if valid data was found
|
||||||
buf []byte
|
buf []byte
|
||||||
|
bufSize uint16
|
||||||
}
|
}
|
||||||
|
|
||||||
var errNoValidData = fmt.Errorf("no valid GPU data found") // Error for missing data
|
var errNoValidData = fmt.Errorf("no valid GPU data found") // Error for missing data
|
||||||
@@ -99,7 +98,7 @@ func (c *gpuCollector) collect() error {
|
|||||||
|
|
||||||
scanner := bufio.NewScanner(stdout)
|
scanner := bufio.NewScanner(stdout)
|
||||||
if c.buf == nil {
|
if c.buf == nil {
|
||||||
c.buf = make([]byte, 0, cmdBufferSize)
|
c.buf = make([]byte, 0, c.bufSize)
|
||||||
}
|
}
|
||||||
scanner.Buffer(c.buf, bufio.MaxScanTokenSize)
|
scanner.Buffer(c.buf, bufio.MaxScanTokenSize)
|
||||||
|
|
||||||
@@ -244,20 +243,31 @@ func (gm *GPUManager) GetCurrentData() map[string]system.GPUData {
|
|||||||
// copy / reset the data
|
// copy / reset the data
|
||||||
gpuData := make(map[string]system.GPUData, len(gm.GpuDataMap))
|
gpuData := make(map[string]system.GPUData, len(gm.GpuDataMap))
|
||||||
for id, gpu := range gm.GpuDataMap {
|
for id, gpu := range gm.GpuDataMap {
|
||||||
gpuAvg := *gpu
|
|
||||||
|
|
||||||
gpuAvg.Temperature = twoDecimals(gpu.Temperature)
|
|
||||||
gpuAvg.MemoryUsed = twoDecimals(gpu.MemoryUsed)
|
|
||||||
gpuAvg.MemoryTotal = twoDecimals(gpu.MemoryTotal)
|
|
||||||
|
|
||||||
// avoid division by zero
|
// avoid division by zero
|
||||||
if gpu.Count > 0 {
|
count := max(gpu.Count, 1)
|
||||||
gpuAvg.Usage = twoDecimals(gpu.Usage / gpu.Count)
|
|
||||||
gpuAvg.Power = twoDecimals(gpu.Power / gpu.Count)
|
// average the data
|
||||||
|
gpuAvg := *gpu
|
||||||
|
gpuAvg.Temperature = twoDecimals(gpu.Temperature)
|
||||||
|
gpuAvg.Power = twoDecimals(gpu.Power / count)
|
||||||
|
|
||||||
|
// intel gpu stats doesn't provide usage, memory used, or memory total
|
||||||
|
if gm.intelGpuStats {
|
||||||
|
maxEngineUsage := 0.0
|
||||||
|
for name, engine := range gpu.Engines {
|
||||||
|
gpuAvg.Engines[name] = twoDecimals(engine / count)
|
||||||
|
maxEngineUsage = max(maxEngineUsage, engine/count)
|
||||||
|
}
|
||||||
|
gpuAvg.Usage = twoDecimals(maxEngineUsage)
|
||||||
|
} else {
|
||||||
|
gpuAvg.Usage = twoDecimals(gpu.Usage / count)
|
||||||
|
gpuAvg.MemoryUsed = twoDecimals(gpu.MemoryUsed)
|
||||||
|
gpuAvg.MemoryTotal = twoDecimals(gpu.MemoryTotal)
|
||||||
}
|
}
|
||||||
|
|
||||||
// reset accumulators in the original
|
// reset accumulators in the original gpu data for next collection
|
||||||
gpu.Usage, gpu.Power, gpu.Count = 0, 0, 0
|
gpu.Usage, gpu.Power, gpu.Count = gpuAvg.Usage, gpuAvg.Power, 1
|
||||||
|
gpu.Engines = gpuAvg.Engines
|
||||||
|
|
||||||
// append id to the name if there are multiple GPUs with the same name
|
// append id to the name if there are multiple GPUs with the same name
|
||||||
if nameCounts[gpu.Name] > 1 {
|
if nameCounts[gpu.Name] > 1 {
|
||||||
@@ -284,18 +294,37 @@ func (gm *GPUManager) detectGPUs() error {
|
|||||||
gm.tegrastats = true
|
gm.tegrastats = true
|
||||||
gm.nvidiaSmi = false
|
gm.nvidiaSmi = false
|
||||||
}
|
}
|
||||||
if gm.nvidiaSmi || gm.rocmSmi || gm.tegrastats {
|
if _, err := exec.LookPath(intelGpuStatsCmd); err == nil {
|
||||||
|
gm.intelGpuStats = true
|
||||||
|
}
|
||||||
|
if gm.nvidiaSmi || gm.rocmSmi || gm.tegrastats || gm.intelGpuStats {
|
||||||
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, tegrastats, or intel_gpu_top")
|
||||||
}
|
}
|
||||||
|
|
||||||
// startCollector starts the appropriate GPU data collector based on the command
|
// startCollector starts the appropriate GPU data collector based on the command
|
||||||
func (gm *GPUManager) startCollector(command string) {
|
func (gm *GPUManager) startCollector(command string) {
|
||||||
collector := gpuCollector{
|
collector := gpuCollector{
|
||||||
name: command,
|
name: command,
|
||||||
|
bufSize: 10 * 1024,
|
||||||
}
|
}
|
||||||
switch command {
|
switch command {
|
||||||
|
case intelGpuStatsCmd:
|
||||||
|
go func() {
|
||||||
|
failures := 0
|
||||||
|
for {
|
||||||
|
if err := gm.collectIntelStats(); err != nil {
|
||||||
|
failures++
|
||||||
|
if failures > maxFailureRetries {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
slog.Warn("Error collecting Intel GPU data; see https://beszel.dev/guide/gpu", "err", err)
|
||||||
|
time.Sleep(retryWaitTime)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
case nvidiaSmiCmd:
|
case nvidiaSmiCmd:
|
||||||
collector.cmdArgs = []string{
|
collector.cmdArgs = []string{
|
||||||
"-l", nvidiaSmiInterval,
|
"-l", nvidiaSmiInterval,
|
||||||
@@ -344,6 +373,9 @@ func NewGPUManager() (*GPUManager, error) {
|
|||||||
if gm.tegrastats {
|
if gm.tegrastats {
|
||||||
gm.startCollector(tegraStatsCmd)
|
gm.startCollector(tegraStatsCmd)
|
||||||
}
|
}
|
||||||
|
if gm.intelGpuStats {
|
||||||
|
gm.startCollector(intelGpuStatsCmd)
|
||||||
|
}
|
||||||
|
|
||||||
return &gm, nil
|
return &gm, nil
|
||||||
}
|
}
|
||||||
|
|||||||
102
agent/gpu_intel.go
Normal file
102
agent/gpu_intel.go
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
intelGpuStatsCmd string = "intel_gpu_top"
|
||||||
|
intelGpuStatsInterval string = "3300" // in milliseconds
|
||||||
|
)
|
||||||
|
|
||||||
|
type intelGpuStats struct {
|
||||||
|
Power struct {
|
||||||
|
GPU float64 `json:"GPU"`
|
||||||
|
} `json:"power"`
|
||||||
|
Engines map[string]struct {
|
||||||
|
Busy float64 `json:"busy"`
|
||||||
|
} `json:"engines"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateIntelFromStats updates aggregated GPU data from a single intelGpuStats sample
|
||||||
|
func (gm *GPUManager) updateIntelFromStats(sample *intelGpuStats) bool {
|
||||||
|
gm.Lock()
|
||||||
|
defer gm.Unlock()
|
||||||
|
|
||||||
|
// only one gpu for now - cmd doesn't provide all by default
|
||||||
|
gpuData, ok := gm.GpuDataMap["0"]
|
||||||
|
if !ok {
|
||||||
|
gpuData = &system.GPUData{Name: "GPU", Engines: make(map[string]float64)}
|
||||||
|
gm.GpuDataMap["0"] = gpuData
|
||||||
|
}
|
||||||
|
|
||||||
|
if sample.Power.GPU > 0 {
|
||||||
|
gpuData.Power += sample.Power.GPU
|
||||||
|
}
|
||||||
|
|
||||||
|
if gpuData.Engines == nil {
|
||||||
|
gpuData.Engines = make(map[string]float64, len(sample.Engines))
|
||||||
|
}
|
||||||
|
for name, engine := range sample.Engines {
|
||||||
|
gpuData.Engines[name] += engine.Busy
|
||||||
|
}
|
||||||
|
|
||||||
|
gpuData.Count++
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// collectIntelStats executes intel_gpu_top in JSON mode and stream-decodes the array of samples
|
||||||
|
func (gm *GPUManager) collectIntelStats() error {
|
||||||
|
cmd := exec.Command(intelGpuStatsCmd, "-s", intelGpuStatsInterval, "-J")
|
||||||
|
stdout, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
dec := json.NewDecoder(stdout)
|
||||||
|
|
||||||
|
// Expect a JSON array stream: [ { ... }, { ... }, ... ]
|
||||||
|
tok, err := dec.Token()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if delim, ok := tok.(json.Delim); !ok || delim != '[' {
|
||||||
|
return fmt.Errorf("unexpected JSON start token: %v", tok)
|
||||||
|
}
|
||||||
|
|
||||||
|
var sample intelGpuStats
|
||||||
|
for {
|
||||||
|
if dec.More() {
|
||||||
|
// Clear the engines map before decoding
|
||||||
|
if sample.Engines != nil {
|
||||||
|
for k := range sample.Engines {
|
||||||
|
delete(sample.Engines, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := dec.Decode(&sample); err != nil {
|
||||||
|
return fmt.Errorf("decode intel gpu: %w", err)
|
||||||
|
}
|
||||||
|
gm.updateIntelFromStats(&sample)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Attempt to read closing bracket (will only be present when process exits)
|
||||||
|
tok, err = dec.Token()
|
||||||
|
if err != nil {
|
||||||
|
// When the process is still running, decoder will block in More/Decode; any error here is terminal
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if delim, ok := tok.(json.Delim); ok && delim == ']' {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cmd.Wait()
|
||||||
|
}
|
||||||
@@ -792,3 +792,96 @@ func TestAccumulation(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestIntelUpdateFromStats(t *testing.T) {
|
||||||
|
gm := &GPUManager{
|
||||||
|
GpuDataMap: make(map[string]*system.GPUData),
|
||||||
|
}
|
||||||
|
|
||||||
|
// First sample with power and two engines
|
||||||
|
sample1 := intelGpuStats{
|
||||||
|
Engines: map[string]struct {
|
||||||
|
Busy float64 `json:"busy"`
|
||||||
|
}{
|
||||||
|
"Render/3D": {Busy: 20.0},
|
||||||
|
"Video": {Busy: 5.0},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
sample1.Power.GPU = 10.5
|
||||||
|
|
||||||
|
ok := gm.updateIntelFromStats(&sample1)
|
||||||
|
assert.True(t, ok)
|
||||||
|
|
||||||
|
gpu := gm.GpuDataMap["0"]
|
||||||
|
require.NotNil(t, gpu)
|
||||||
|
assert.Equal(t, "GPU", gpu.Name)
|
||||||
|
assert.InDelta(t, 10.5, gpu.Power, 0.001)
|
||||||
|
assert.InDelta(t, 20.0, gpu.Engines["Render/3D"], 0.001)
|
||||||
|
assert.InDelta(t, 5.0, gpu.Engines["Video"], 0.001)
|
||||||
|
assert.Equal(t, float64(1), gpu.Count)
|
||||||
|
|
||||||
|
// Second sample with zero power (should not add) and additional engine busy
|
||||||
|
sample2 := intelGpuStats{
|
||||||
|
Engines: map[string]struct {
|
||||||
|
Busy float64 `json:"busy"`
|
||||||
|
}{
|
||||||
|
"Render/3D": {Busy: 10.0},
|
||||||
|
"Video": {Busy: 2.5},
|
||||||
|
"Blitter": {Busy: 1.0},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
// zero power should not increment power accumulator
|
||||||
|
sample2.Power.GPU = 0.0
|
||||||
|
|
||||||
|
ok = gm.updateIntelFromStats(&sample2)
|
||||||
|
assert.True(t, ok)
|
||||||
|
|
||||||
|
gpu = gm.GpuDataMap["0"]
|
||||||
|
require.NotNil(t, gpu)
|
||||||
|
assert.InDelta(t, 10.5, gpu.Power, 0.001)
|
||||||
|
assert.InDelta(t, 30.0, gpu.Engines["Render/3D"], 0.001) // 20 + 10
|
||||||
|
assert.InDelta(t, 7.5, gpu.Engines["Video"], 0.001) // 5 + 2.5
|
||||||
|
assert.InDelta(t, 1.0, gpu.Engines["Blitter"], 0.001)
|
||||||
|
assert.Equal(t, float64(2), gpu.Count)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntelCollectorStreaming(t *testing.T) {
|
||||||
|
// Save and override PATH
|
||||||
|
origPath := os.Getenv("PATH")
|
||||||
|
defer os.Setenv("PATH", origPath)
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
os.Setenv("PATH", dir)
|
||||||
|
|
||||||
|
// Create a fake intel_gpu_top that prints a JSON array with two samples and exits
|
||||||
|
scriptPath := filepath.Join(dir, "intel_gpu_top")
|
||||||
|
script := `#!/bin/sh
|
||||||
|
# Ignore args -s and -J
|
||||||
|
# Emit a JSON array with two objects, separated by a comma, then exit
|
||||||
|
(echo '['; \
|
||||||
|
echo '{"power":{"GPU":1.5},"engines":{"Render/3D":{"busy":12.34}}},'; \
|
||||||
|
echo '{"power":{"GPU":2.0},"engines":{"Video":{"busy":5}}}'; \
|
||||||
|
echo ']')`
|
||||||
|
if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gm := &GPUManager{
|
||||||
|
GpuDataMap: make(map[string]*system.GPUData),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run the collector once; it should read two samples and return
|
||||||
|
if err := gm.collectIntelStats(); err != nil {
|
||||||
|
t.Fatalf("collectIntelStats error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gpu := gm.GpuDataMap["0"]
|
||||||
|
require.NotNil(t, gpu)
|
||||||
|
// Power should be sum of non-zero samples: 1.5 + 2.0 = 3.5
|
||||||
|
assert.InDelta(t, 3.5, gpu.Power, 0.001)
|
||||||
|
// Engines aggregated
|
||||||
|
assert.InDelta(t, 12.34, gpu.Engines["Render/3D"], 0.001)
|
||||||
|
assert.InDelta(t, 5.0, gpu.Engines["Video"], 0.001)
|
||||||
|
// Count should be 2 samples
|
||||||
|
assert.Equal(t, float64(2), gpu.Count)
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,10 +6,13 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/agent/deltatracker"
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
psutilNet "github.com/shirou/gopsutil/v4/net"
|
psutilNet "github.com/shirou/gopsutil/v4/net"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var netInterfaceDeltaTracker = deltatracker.NewDeltaTracker[string, uint64]()
|
||||||
|
|
||||||
func (a *Agent) updateNetworkStats(systemStats *system.Stats) {
|
func (a *Agent) updateNetworkStats(systemStats *system.Stats) {
|
||||||
// network stats
|
// network stats
|
||||||
if len(a.netInterfaces) == 0 {
|
if len(a.netInterfaces) == 0 {
|
||||||
@@ -40,12 +43,13 @@ func (a *Agent) updateNetworkStats(systemStats *system.Stats) {
|
|||||||
totalBytesRecv += v.BytesRecv
|
totalBytesRecv += v.BytesRecv
|
||||||
|
|
||||||
// track deltas for each network interface
|
// track deltas for each network interface
|
||||||
netInterfaceDeltaTracker.Set(fmt.Sprintf("%sdown", v.Name), v.BytesRecv)
|
|
||||||
netInterfaceDeltaTracker.Set(fmt.Sprintf("%sup", v.Name), v.BytesSent)
|
|
||||||
var upDelta, downDelta uint64
|
var upDelta, downDelta uint64
|
||||||
|
upKey, downKey := fmt.Sprintf("%sup", v.Name), fmt.Sprintf("%sdown", v.Name)
|
||||||
|
netInterfaceDeltaTracker.Set(upKey, v.BytesSent)
|
||||||
|
netInterfaceDeltaTracker.Set(downKey, v.BytesRecv)
|
||||||
if msElapsed > 0 {
|
if msElapsed > 0 {
|
||||||
upDelta = netInterfaceDeltaTracker.Delta(fmt.Sprintf("%sup", v.Name)) * 1000 / msElapsed
|
upDelta = netInterfaceDeltaTracker.Delta(upKey) * 1000 / msElapsed
|
||||||
downDelta = netInterfaceDeltaTracker.Delta(fmt.Sprintf("%sdown", v.Name)) * 1000 / msElapsed
|
downDelta = netInterfaceDeltaTracker.Delta(downKey) * 1000 / msElapsed
|
||||||
}
|
}
|
||||||
// add interface to systemStats
|
// add interface to systemStats
|
||||||
systemStats.NetworkInterfaces[v.Name] = [4]uint64{upDelta, downDelta, v.BytesSent, v.BytesRecv}
|
systemStats.NetworkInterfaces[v.Name] = [4]uint64{upDelta, downDelta, v.BytesSent, v.BytesRecv}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import (
|
|||||||
|
|
||||||
"github.com/henrygd/beszel"
|
"github.com/henrygd/beszel"
|
||||||
"github.com/henrygd/beszel/agent/battery"
|
"github.com/henrygd/beszel/agent/battery"
|
||||||
"github.com/henrygd/beszel/agent/deltatracker"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
|
||||||
"github.com/shirou/gopsutil/v4/cpu"
|
"github.com/shirou/gopsutil/v4/cpu"
|
||||||
@@ -21,8 +20,6 @@ import (
|
|||||||
"github.com/shirou/gopsutil/v4/mem"
|
"github.com/shirou/gopsutil/v4/mem"
|
||||||
)
|
)
|
||||||
|
|
||||||
var netInterfaceDeltaTracker = deltatracker.NewDeltaTracker[string, uint64]()
|
|
||||||
|
|
||||||
// Sets initial / non-changing values about the host system
|
// Sets initial / non-changing values about the host system
|
||||||
func (a *Agent) initializeSystemInfo() {
|
func (a *Agent) initializeSystemInfo() {
|
||||||
a.systemInfo.AgentVersion = beszel.Version
|
a.systemInfo.AgentVersion = beszel.Version
|
||||||
@@ -34,7 +31,7 @@ func (a *Agent) initializeSystemInfo() {
|
|||||||
a.systemInfo.KernelVersion = version
|
a.systemInfo.KernelVersion = version
|
||||||
a.systemInfo.Os = system.Darwin
|
a.systemInfo.Os = system.Darwin
|
||||||
} else if strings.Contains(platform, "indows") {
|
} else if strings.Contains(platform, "indows") {
|
||||||
a.systemInfo.KernelVersion = strings.Replace(platform, "Microsoft ", "", 1) + " " + version
|
a.systemInfo.KernelVersion = fmt.Sprintf("%s %s", strings.Replace(platform, "Microsoft ", "", 1), version)
|
||||||
a.systemInfo.Os = system.Windows
|
a.systemInfo.Os = system.Windows
|
||||||
} else if platform == "freebsd" {
|
} else if platform == "freebsd" {
|
||||||
a.systemInfo.Os = system.Freebsd
|
a.systemInfo.Os = system.Freebsd
|
||||||
@@ -215,6 +212,7 @@ func (a *Agent) getSystemStats() system.Stats {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// update base system info
|
// update base system info
|
||||||
|
a.systemInfo.ConnectionType = a.connectionManager.ConnectionType
|
||||||
a.systemInfo.Cpu = systemStats.Cpu
|
a.systemInfo.Cpu = systemStats.Cpu
|
||||||
a.systemInfo.LoadAvg = systemStats.LoadAvg
|
a.systemInfo.LoadAvg = systemStats.LoadAvg
|
||||||
// TODO: remove these in future release in favor of load avg array
|
// TODO: remove these in future release in favor of load avg array
|
||||||
|
|||||||
@@ -45,13 +45,14 @@ type Stats struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type GPUData struct {
|
type GPUData struct {
|
||||||
Name string `json:"n" cbor:"0,keyasint"`
|
Name string `json:"n" cbor:"0,keyasint"`
|
||||||
Temperature float64 `json:"-"`
|
Temperature float64 `json:"-"`
|
||||||
MemoryUsed float64 `json:"mu,omitempty" cbor:"1,keyasint,omitempty"`
|
MemoryUsed float64 `json:"mu,omitempty,omitzero" cbor:"1,keyasint,omitempty,omitzero"`
|
||||||
MemoryTotal float64 `json:"mt,omitempty" cbor:"2,keyasint,omitempty"`
|
MemoryTotal float64 `json:"mt,omitempty,omitzero" cbor:"2,keyasint,omitempty,omitzero"`
|
||||||
Usage float64 `json:"u" cbor:"3,keyasint"`
|
Usage float64 `json:"u" cbor:"3,keyasint,omitempty"`
|
||||||
Power float64 `json:"p,omitempty" cbor:"4,keyasint,omitempty"`
|
Power float64 `json:"p,omitempty" cbor:"4,keyasint,omitempty"`
|
||||||
Count float64 `json:"-"`
|
Count float64 `json:"-"`
|
||||||
|
Engines map[string]float64 `json:"e,omitempty" cbor:"5,keyasint,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type FsStats struct {
|
type FsStats struct {
|
||||||
@@ -84,6 +85,14 @@ const (
|
|||||||
Freebsd
|
Freebsd
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type ConnectionType = uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
ConnectionTypeNone ConnectionType = iota
|
||||||
|
ConnectionTypeSSH
|
||||||
|
ConnectionTypeWebSocket
|
||||||
|
)
|
||||||
|
|
||||||
type Info struct {
|
type Info struct {
|
||||||
Hostname string `json:"h" cbor:"0,keyasint"`
|
Hostname string `json:"h" cbor:"0,keyasint"`
|
||||||
KernelVersion string `json:"k,omitempty" cbor:"1,keyasint,omitempty"`
|
KernelVersion string `json:"k,omitempty" cbor:"1,keyasint,omitempty"`
|
||||||
@@ -105,7 +114,8 @@ type Info struct {
|
|||||||
LoadAvg15 float64 `json:"l15,omitempty" cbor:"17,keyasint,omitempty"`
|
LoadAvg15 float64 `json:"l15,omitempty" cbor:"17,keyasint,omitempty"`
|
||||||
BandwidthBytes uint64 `json:"bb" cbor:"18,keyasint"`
|
BandwidthBytes uint64 `json:"bb" cbor:"18,keyasint"`
|
||||||
// TODO: remove load fields in future release in favor of load avg array
|
// TODO: remove load fields in future release in favor of load avg array
|
||||||
LoadAvg [3]float64 `json:"la,omitempty" cbor:"19,keyasint"`
|
LoadAvg [3]float64 `json:"la,omitempty" cbor:"19,keyasint"`
|
||||||
|
ConnectionType ConnectionType `json:"ct,omitempty" cbor:"20,keyasint,omitempty,omitzero"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Final data structure to return to the hub
|
// Final data structure to return to the hub
|
||||||
|
|||||||
@@ -284,6 +284,16 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
|
|||||||
gpu.Usage += value.Usage
|
gpu.Usage += value.Usage
|
||||||
gpu.Power += value.Power
|
gpu.Power += value.Power
|
||||||
gpu.Count += value.Count
|
gpu.Count += value.Count
|
||||||
|
|
||||||
|
if value.Engines != nil {
|
||||||
|
if gpu.Engines == nil {
|
||||||
|
gpu.Engines = make(map[string]float64, len(value.Engines))
|
||||||
|
}
|
||||||
|
for engineKey, engineValue := range value.Engines {
|
||||||
|
gpu.Engines[engineKey] += engineValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
sum.GPUData[id] = gpu
|
sum.GPUData[id] = gpu
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -353,6 +363,13 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
|
|||||||
gpu.Usage = twoDecimals(gpu.Usage / count)
|
gpu.Usage = twoDecimals(gpu.Usage / count)
|
||||||
gpu.Power = twoDecimals(gpu.Power / count)
|
gpu.Power = twoDecimals(gpu.Power / count)
|
||||||
gpu.Count = twoDecimals(gpu.Count / count)
|
gpu.Count = twoDecimals(gpu.Count / count)
|
||||||
|
|
||||||
|
if gpu.Engines != nil {
|
||||||
|
for engineKey := range gpu.Engines {
|
||||||
|
gpu.Engines[engineKey] = twoDecimals(gpu.Engines[engineKey] / count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
sum.GPUData[id] = gpu
|
sum.GPUData[id] = gpu
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ export function useNetworkInterfaces(interfaces: SystemStats["ni"]) {
|
|||||||
data: (index = 3) => {
|
data: (index = 3) => {
|
||||||
return sortedKeys.map((key) => ({
|
return sortedKeys.map((key) => ({
|
||||||
label: key,
|
label: key,
|
||||||
dataKey: (stats: SystemStatsRecord) => stats.stats?.ni?.[key]?.[index],
|
dataKey: ({ stats }: SystemStatsRecord) => stats?.ni?.[key]?.[index],
|
||||||
color: `hsl(${220 + (((sortedKeys.indexOf(key) * 360) / sortedKeys.length) % 360)}, 70%, 50%)`,
|
color: `hsl(${220 + (((sortedKeys.indexOf(key) * 360) / sortedKeys.length) % 360)}, 70%, 50%)`,
|
||||||
|
|
||||||
opacity: 0.3,
|
opacity: 0.3,
|
||||||
|
|||||||
@@ -3,7 +3,15 @@ import { Plural, Trans, useLingui } from "@lingui/react/macro"
|
|||||||
import { useStore } from "@nanostores/react"
|
import { useStore } from "@nanostores/react"
|
||||||
import { getPagePath } from "@nanostores/router"
|
import { getPagePath } from "@nanostores/router"
|
||||||
import { timeTicks } from "d3-time"
|
import { timeTicks } from "d3-time"
|
||||||
import { ClockArrowUp, CpuIcon, GlobeIcon, LayoutGridIcon, MonitorIcon, XIcon } from "lucide-react"
|
import {
|
||||||
|
ChevronRightSquareIcon,
|
||||||
|
ClockArrowUp,
|
||||||
|
CpuIcon,
|
||||||
|
GlobeIcon,
|
||||||
|
LayoutGridIcon,
|
||||||
|
MonitorIcon,
|
||||||
|
XIcon,
|
||||||
|
} from "lucide-react"
|
||||||
import { subscribeKeys } from "nanostores"
|
import { subscribeKeys } from "nanostores"
|
||||||
import React, { type JSX, memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
import React, { type JSX, memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||||
import AreaChartDefault from "@/components/charts/area-chart"
|
import AreaChartDefault from "@/components/charts/area-chart"
|
||||||
@@ -16,7 +24,7 @@ import MemChart from "@/components/charts/mem-chart"
|
|||||||
import SwapChart from "@/components/charts/swap-chart"
|
import SwapChart from "@/components/charts/swap-chart"
|
||||||
import TemperatureChart from "@/components/charts/temperature-chart"
|
import TemperatureChart from "@/components/charts/temperature-chart"
|
||||||
import { getPbTimestamp, pb } from "@/lib/api"
|
import { getPbTimestamp, pb } from "@/lib/api"
|
||||||
import { ChartType, Os, SystemStatus, Unit } from "@/lib/enums"
|
import { ChartType, ConnectionType, Os, SystemStatus, Unit } from "@/lib/enums"
|
||||||
import { batteryStateTranslations } from "@/lib/i18n"
|
import { batteryStateTranslations } from "@/lib/i18n"
|
||||||
import {
|
import {
|
||||||
$allSystemsByName,
|
$allSystemsByName,
|
||||||
@@ -47,12 +55,13 @@ import { $router, navigate } from "../router"
|
|||||||
import Spinner from "../spinner"
|
import Spinner from "../spinner"
|
||||||
import { Button } from "../ui/button"
|
import { Button } from "../ui/button"
|
||||||
import { Card, CardDescription, CardHeader, CardTitle } from "../ui/card"
|
import { Card, CardDescription, CardHeader, CardTitle } from "../ui/card"
|
||||||
import { AppleIcon, ChartAverage, ChartMax, FreeBsdIcon, Rows, TuxIcon, WindowsIcon } from "../ui/icons"
|
import { AppleIcon, ChartAverage, ChartMax, FreeBsdIcon, Rows, TuxIcon, WebSocketIcon, WindowsIcon } from "../ui/icons"
|
||||||
import { Input } from "../ui/input"
|
import { Input } from "../ui/input"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select"
|
||||||
import { Separator } from "../ui/separator"
|
import { Separator } from "../ui/separator"
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip"
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip"
|
||||||
import NetworkSheet from "./system/network-sheet"
|
import NetworkSheet from "./system/network-sheet"
|
||||||
|
import LineChartDefault from "../charts/line-chart"
|
||||||
|
|
||||||
type ChartTimeData = {
|
type ChartTimeData = {
|
||||||
time: number
|
time: number
|
||||||
@@ -130,7 +139,7 @@ async function getStats<T extends SystemStatsRecord | ContainerStatsRecord>(
|
|||||||
|
|
||||||
function dockerOrPodman(str: string, system: SystemRecord) {
|
function dockerOrPodman(str: string, system: SystemRecord) {
|
||||||
if (system.info.p) {
|
if (system.info.p) {
|
||||||
str = str.replace("docker", "podman").replace("Docker", "Podman")
|
return str.replace("docker", "podman").replace("Docker", "Podman")
|
||||||
}
|
}
|
||||||
return str
|
return str
|
||||||
}
|
}
|
||||||
@@ -390,6 +399,7 @@ export default memo(function SystemDetail({ name }: { name: string }) {
|
|||||||
const lastGpuVals = Object.values(systemStats.at(-1)?.stats.g ?? {})
|
const lastGpuVals = Object.values(systemStats.at(-1)?.stats.g ?? {})
|
||||||
const hasGpuData = lastGpuVals.length > 0
|
const hasGpuData = lastGpuVals.length > 0
|
||||||
const hasGpuPowerData = lastGpuVals.some((gpu) => gpu.p !== undefined)
|
const hasGpuPowerData = lastGpuVals.some((gpu) => gpu.p !== undefined)
|
||||||
|
const hasGpuEnginesData = lastGpuVals.some((gpu) => gpu.e !== undefined)
|
||||||
|
|
||||||
let translatedStatus: string = system.status
|
let translatedStatus: string = system.status
|
||||||
if (system.status === SystemStatus.Up) {
|
if (system.status === SystemStatus.Up) {
|
||||||
@@ -407,25 +417,45 @@ export default memo(function SystemDetail({ name }: { name: string }) {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-[1.6rem] font-semibold mb-1.5">{system.name}</h1>
|
<h1 className="text-[1.6rem] font-semibold mb-1.5">{system.name}</h1>
|
||||||
<div className="flex flex-wrap items-center gap-3 gap-y-2 text-sm opacity-90">
|
<div className="flex flex-wrap items-center gap-3 gap-y-2 text-sm opacity-90">
|
||||||
<div className="capitalize flex gap-2 items-center">
|
<TooltipProvider>
|
||||||
<span className={cn("relative flex h-3 w-3")}>
|
<Tooltip>
|
||||||
{system.status === SystemStatus.Up && (
|
<TooltipTrigger asChild>
|
||||||
<span
|
<div className="capitalize flex gap-2 items-center">
|
||||||
className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"
|
<span className={cn("relative flex h-3 w-3")}>
|
||||||
style={{ animationDuration: "1.5s" }}
|
{system.status === SystemStatus.Up && (
|
||||||
></span>
|
<span
|
||||||
|
className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"
|
||||||
|
style={{ animationDuration: "1.5s" }}
|
||||||
|
></span>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={cn("relative inline-flex rounded-full h-3 w-3", {
|
||||||
|
"bg-green-500": system.status === SystemStatus.Up,
|
||||||
|
"bg-red-500": system.status === SystemStatus.Down,
|
||||||
|
"bg-primary/40": system.status === SystemStatus.Paused,
|
||||||
|
"bg-yellow-500": system.status === SystemStatus.Pending,
|
||||||
|
})}
|
||||||
|
></span>
|
||||||
|
</span>
|
||||||
|
{translatedStatus}
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
{system.info.ct && (
|
||||||
|
<TooltipContent>
|
||||||
|
{system.info.ct === ConnectionType.WebSocket ? (
|
||||||
|
<div className="flex gap-1 items-center">
|
||||||
|
<WebSocketIcon className="size-4" /> WebSocket
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex gap-1 items-center">
|
||||||
|
<ChevronRightSquareIcon className="size-4" strokeWidth={2} /> SSH
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TooltipContent>
|
||||||
)}
|
)}
|
||||||
<span
|
</Tooltip>
|
||||||
className={cn("relative inline-flex rounded-full h-3 w-3", {
|
</TooltipProvider>
|
||||||
"bg-green-500": system.status === SystemStatus.Up,
|
|
||||||
"bg-red-500": system.status === SystemStatus.Down,
|
|
||||||
"bg-primary/40": system.status === SystemStatus.Paused,
|
|
||||||
"bg-yellow-500": system.status === SystemStatus.Pending,
|
|
||||||
})}
|
|
||||||
></span>
|
|
||||||
</span>
|
|
||||||
{translatedStatus}
|
|
||||||
</div>
|
|
||||||
{systemInfo.map(({ value, label, Icon, hide }) => {
|
{systemInfo.map(({ value, label, Icon, hide }) => {
|
||||||
if (hide || !value) {
|
if (hide || !value) {
|
||||||
return null
|
return null
|
||||||
@@ -731,6 +761,12 @@ export default memo(function SystemDetail({ name }: { name: string }) {
|
|||||||
</ChartCard>
|
</ChartCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Non-power GPU charts */}
|
||||||
|
{hasGpuData && (
|
||||||
|
<div className="grid xl:grid-cols-2 gap-4">
|
||||||
{/* GPU power draw chart */}
|
{/* GPU power draw chart */}
|
||||||
{hasGpuPowerData && (
|
{hasGpuPowerData && (
|
||||||
<ChartCard
|
<ChartCard
|
||||||
@@ -742,11 +778,16 @@ export default memo(function SystemDetail({ name }: { name: string }) {
|
|||||||
<GpuPowerChart chartData={chartData} />
|
<GpuPowerChart chartData={chartData} />
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
)}
|
)}
|
||||||
</div>
|
{hasGpuEnginesData && (
|
||||||
|
<ChartCard
|
||||||
{/* GPU charts */}
|
empty={dataEmpty}
|
||||||
{hasGpuData && (
|
grid={grid}
|
||||||
<div className="grid xl:grid-cols-2 gap-4">
|
title={t`GPU Engines`}
|
||||||
|
description={t`Average utilization of GPU engines`}
|
||||||
|
>
|
||||||
|
<GpuEnginesChart chartData={chartData} />
|
||||||
|
</ChartCard>
|
||||||
|
)}
|
||||||
{Object.keys(systemStats.at(-1)?.stats.g ?? {}).map((id) => {
|
{Object.keys(systemStats.at(-1)?.stats.g ?? {}).map((id) => {
|
||||||
const gpu = systemStats.at(-1)?.stats.g?.[id] as GPUData
|
const gpu = systemStats.at(-1)?.stats.g?.[id] as GPUData
|
||||||
return (
|
return (
|
||||||
@@ -771,6 +812,8 @@ export default memo(function SystemDetail({ name }: { name: string }) {
|
|||||||
contentFormatter={({ value }) => `${decimalString(value)}%`}
|
contentFormatter={({ value }) => `${decimalString(value)}%`}
|
||||||
/>
|
/>
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
|
|
||||||
|
{(gpu.mt ?? 0) > 0 && (
|
||||||
<ChartCard
|
<ChartCard
|
||||||
empty={dataEmpty}
|
empty={dataEmpty}
|
||||||
grid={grid}
|
grid={grid}
|
||||||
@@ -798,7 +841,9 @@ export default memo(function SystemDetail({ name }: { name: string }) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -869,6 +914,22 @@ export default memo(function SystemDetail({ name }: { name: string }) {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function GpuEnginesChart({ chartData }: { chartData: ChartData }) {
|
||||||
|
const dataPoints = []
|
||||||
|
const engines = Object.keys(chartData.systemStats?.at(-1)?.stats.g?.[0]?.e ?? {}).sort()
|
||||||
|
for (const engine of engines) {
|
||||||
|
dataPoints.push({
|
||||||
|
label: engine,
|
||||||
|
dataKey: ({ stats }: SystemStatsRecord) => stats?.g?.[0]?.e?.[engine] ?? 0,
|
||||||
|
color: `hsl(${140 + ((engines.indexOf(engine) * 360) / engines.length) % 360}, 65%, 52%)`,
|
||||||
|
opacity: 0.35,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<LineChartDefault legend={true} chartData={chartData} dataPoints={dataPoints} tickFormatter={(val) => `${toFixedFloat(val, 2)}%`} contentFormatter={({ value }) => `${decimalString(value)}%`} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function FilterBar({ store = $containerFilter }: { store?: typeof $containerFilter }) {
|
function FilterBar({ store = $containerFilter }: { store?: typeof $containerFilter }) {
|
||||||
const containerFilter = useStore(store)
|
const containerFilter = useStore(store)
|
||||||
const { t } = useLingui()
|
const { t } = useLingui()
|
||||||
|
|||||||
@@ -41,9 +41,10 @@ export default memo(function NetworkSheet({
|
|||||||
<Sheet open={netInterfacesOpen} onOpenChange={setNetInterfacesOpen}>
|
<Sheet open={netInterfacesOpen} onOpenChange={setNetInterfacesOpen}>
|
||||||
<SheetTrigger asChild>
|
<SheetTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
|
aria-label={t`View more`}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="shrink-0 absolute top-3 end-3 sm:inline-flex sm:top-0 sm:end-0"
|
className="shrink-0 max-sm:absolute max-sm:top-3 max-sm:end-3"
|
||||||
>
|
>
|
||||||
<MoreHorizontalIcon />
|
<MoreHorizontalIcon />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { CellContext, ColumnDef, HeaderContext } from "@tanstack/react-tabl
|
|||||||
import type { ClassValue } from "clsx"
|
import type { ClassValue } from "clsx"
|
||||||
import {
|
import {
|
||||||
ArrowUpDownIcon,
|
ArrowUpDownIcon,
|
||||||
|
ChevronRightSquareIcon,
|
||||||
CopyIcon,
|
CopyIcon,
|
||||||
CpuIcon,
|
CpuIcon,
|
||||||
HardDriveIcon,
|
HardDriveIcon,
|
||||||
@@ -20,7 +21,7 @@ import {
|
|||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { memo, useMemo, useRef, useState } from "react"
|
import { memo, useMemo, useRef, useState } from "react"
|
||||||
import { isReadOnlyUser, pb } from "@/lib/api"
|
import { isReadOnlyUser, pb } from "@/lib/api"
|
||||||
import { MeterState, SystemStatus } from "@/lib/enums"
|
import { ConnectionType, MeterState, SystemStatus } from "@/lib/enums"
|
||||||
import { $longestSystemNameLen, $userSettings } from "@/lib/stores"
|
import { $longestSystemNameLen, $userSettings } from "@/lib/stores"
|
||||||
import {
|
import {
|
||||||
cn,
|
cn,
|
||||||
@@ -54,7 +55,7 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "../ui/dropdown-menu"
|
} from "../ui/dropdown-menu"
|
||||||
import { EthernetIcon, GpuIcon, HourglassIcon, ThermometerIcon } from "../ui/icons"
|
import { EthernetIcon, GpuIcon, HourglassIcon, ThermometerIcon, WebSocketIcon } from "../ui/icons"
|
||||||
|
|
||||||
const STATUS_COLORS = {
|
const STATUS_COLORS = {
|
||||||
[SystemStatus.Up]: "bg-green-500",
|
[SystemStatus.Up]: "bg-green-500",
|
||||||
@@ -271,18 +272,18 @@ export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnD
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
const system = info.row.original
|
const system = info.row.original
|
||||||
|
const color = {
|
||||||
|
"text-green-500": version === globalThis.BESZEL.HUB_VERSION,
|
||||||
|
"text-yellow-500": version !== globalThis.BESZEL.HUB_VERSION,
|
||||||
|
"text-red-500": system.status !== SystemStatus.Up,
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<span className={cn("flex gap-1.5 items-center md:pe-5 tabular-nums", viewMode === "table" && "ps-0.5")}>
|
<div className={cn("flex gap-1.5 items-center md:pe-5 tabular-nums", viewMode === "table" && "ps-0.5")}>
|
||||||
<IndicatorDot
|
{system.info.ct === ConnectionType.WebSocket && <WebSocketIcon className={cn("size-3", color)} />}
|
||||||
system={system}
|
{system.info.ct === ConnectionType.SSH && <ChevronRightSquareIcon className={cn("size-3", color)} />}
|
||||||
className={
|
{!system.info.ct && <IndicatorDot system={system} className={cn(color, "bg-current mx-0.5")} />}
|
||||||
(system.status !== SystemStatus.Up && STATUS_COLORS[SystemStatus.Paused]) ||
|
|
||||||
(version === globalThis.BESZEL.HUB_VERSION && STATUS_COLORS[SystemStatus.Up]) ||
|
|
||||||
STATUS_COLORS[SystemStatus.Pending]
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<span className="truncate max-w-14">{info.getValue() as string}</span>
|
<span className="truncate max-w-14">{info.getValue() as string}</span>
|
||||||
</span>
|
</div>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -305,10 +306,11 @@ function sortableHeader(context: HeaderContext<SystemRecord, unknown>) {
|
|||||||
const { column } = context
|
const { column } = context
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
const { Icon, hideSort, name }: { Icon: React.ElementType; name: () => string; hideSort: boolean } = column.columnDef
|
const { Icon, hideSort, name }: { Icon: React.ElementType; name: () => string; hideSort: boolean } = column.columnDef
|
||||||
|
const isSorted = column.getIsSorted()
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="h-9 px-3 flex"
|
className={cn("h-9 px-3 flex duration-50", isSorted && "bg-accent/70 light:bg-accent text-accent-foreground/90")}
|
||||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||||
>
|
>
|
||||||
{Icon && <Icon className="me-2 size-4" />}
|
{Icon && <Icon className="me-2 size-4" />}
|
||||||
|
|||||||
@@ -337,7 +337,7 @@ const AllSystemsTable = memo(
|
|||||||
{/* add header height to table size */}
|
{/* add header height to table size */}
|
||||||
<div style={{ height: `${virtualizer.getTotalSize() + 50}px`, paddingTop, paddingBottom }}>
|
<div style={{ height: `${virtualizer.getTotalSize() + 50}px`, paddingTop, paddingBottom }}>
|
||||||
<table className="text-sm w-full h-full">
|
<table className="text-sm w-full h-full">
|
||||||
<SystemsTableHead table={table} colLength={colLength} />
|
<SystemsTableHead table={table} />
|
||||||
<TableBody onMouseEnter={preloadSystemDetail}>
|
<TableBody onMouseEnter={preloadSystemDetail}>
|
||||||
{rows.length ? (
|
{rows.length ? (
|
||||||
virtualRows.map((virtualRow) => {
|
virtualRows.map((virtualRow) => {
|
||||||
@@ -367,26 +367,23 @@ const AllSystemsTable = memo(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
function SystemsTableHead({ table, colLength }: { table: TableType<SystemRecord>; colLength: number }) {
|
function SystemsTableHead({ table }: { table: TableType<SystemRecord> }) {
|
||||||
const { i18n } = useLingui()
|
const { t } = useLingui()
|
||||||
|
return (
|
||||||
return useMemo(() => {
|
<TableHeader className="sticky top-0 z-20 w-full border-b-2">
|
||||||
return (
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
<TableHeader className="sticky top-0 z-20 w-full border-b-2">
|
<tr key={headerGroup.id}>
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
{headerGroup.headers.map((header) => {
|
||||||
<tr key={headerGroup.id}>
|
return (
|
||||||
{headerGroup.headers.map((header) => {
|
<TableHead className="px-1.5" key={header.id}>
|
||||||
return (
|
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
<TableHead className="px-1.5" key={header.id}>
|
</TableHead>
|
||||||
{flexRender(header.column.columnDef.header, header.getContext())}
|
)
|
||||||
</TableHead>
|
})}
|
||||||
)
|
</tr>
|
||||||
})}
|
))}
|
||||||
</tr>
|
</TableHeader>
|
||||||
))}
|
)
|
||||||
</TableHeader>
|
|
||||||
)
|
|
||||||
}, [i18n.locale, colLength])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const SystemTableRow = memo(
|
const SystemTableRow = memo(
|
||||||
|
|||||||
@@ -130,3 +130,12 @@ export function HourglassIcon(props: SVGProps<SVGSVGElement>) {
|
|||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function WebSocketIcon(props: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 256 193" {...props} fill="currentColor">
|
||||||
|
<title>WebSocket</title>
|
||||||
|
<path d="M192 145h32V68l-36-35-22 22 26 27zm32 16H113l-26-27 11-11 22 22h45l-44-45 11-11 44 44V88l-21-22 11-11-55-55H0l32 32h65l24 23-34 34-24-23V48H32v31l55 55-23 22 36 36h156z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -53,3 +53,9 @@ export enum HourFormat {
|
|||||||
"12h" = "12h",
|
"12h" = "12h",
|
||||||
"24h" = "24h",
|
"24h" = "24h",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Connection type */
|
||||||
|
export enum ConnectionType {
|
||||||
|
SSH = 1,
|
||||||
|
WebSocket,
|
||||||
|
}
|
||||||
|
|||||||
@@ -179,8 +179,8 @@ export function formatTemperature(celsius: number, unit?: Unit): { value: number
|
|||||||
if (!unit) {
|
if (!unit) {
|
||||||
unit = $userSettings.get().unitTemp || Unit.Celsius
|
unit = $userSettings.get().unitTemp || Unit.Celsius
|
||||||
}
|
}
|
||||||
// need loose equality check due to form data being strings
|
// biome-ignore lint/suspicious/noDoubleEquals: need loose equality check due to form data being strings
|
||||||
if (unit === Unit.Fahrenheit) {
|
if (unit == Unit.Fahrenheit) {
|
||||||
return {
|
return {
|
||||||
value: celsius * 1.8 + 32,
|
value: celsius * 1.8 + 32,
|
||||||
unit: "°F",
|
unit: "°F",
|
||||||
@@ -202,8 +202,8 @@ export function formatBytes(
|
|||||||
// Convert MB to bytes if isMegabytes is true
|
// Convert MB to bytes if isMegabytes is true
|
||||||
if (isMegabytes) size *= 1024 * 1024
|
if (isMegabytes) size *= 1024 * 1024
|
||||||
|
|
||||||
// need loose equality check due to form data being strings
|
// biome-ignore lint/suspicious/noDoubleEquals: need loose equality check due to form data being strings
|
||||||
if (unit === Unit.Bits) {
|
if (unit == Unit.Bits) {
|
||||||
const bits = size * 8
|
const bits = size * 8
|
||||||
const suffix = perSecond ? "ps" : ""
|
const suffix = perSecond ? "ps" : ""
|
||||||
if (bits < 1000) return { value: bits, unit: `b${suffix}` }
|
if (bits < 1000) return { value: bits, unit: `b${suffix}` }
|
||||||
|
|||||||
6
internal/site/src/types.d.ts
vendored
6
internal/site/src/types.d.ts
vendored
@@ -1,5 +1,5 @@
|
|||||||
import type { RecordModel } from "pocketbase"
|
import type { RecordModel } from "pocketbase"
|
||||||
import type { Unit, Os, BatteryState, HourFormat } from "./lib/enums"
|
import type { Unit, Os, BatteryState, HourFormat, ConnectionType } from "@/lib/enums"
|
||||||
|
|
||||||
// global window properties
|
// global window properties
|
||||||
declare global {
|
declare global {
|
||||||
@@ -75,6 +75,8 @@ export interface SystemInfo {
|
|||||||
dt?: number
|
dt?: number
|
||||||
/** operating system */
|
/** operating system */
|
||||||
os?: Os
|
os?: Os
|
||||||
|
/** connection type */
|
||||||
|
ct?: ConnectionType
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SystemStats {
|
export interface SystemStats {
|
||||||
@@ -156,6 +158,8 @@ export interface GPUData {
|
|||||||
u: number
|
u: number
|
||||||
/** power (w) */
|
/** power (w) */
|
||||||
p?: number
|
p?: number
|
||||||
|
/** engines */
|
||||||
|
e?: Record<string, number>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExtraFsStats {
|
export interface ExtraFsStats {
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
## 0.12.10
|
||||||
|
|
||||||
|
- Show connection type (WebSocket / SSH) in hub UI.
|
||||||
|
|
||||||
|
- Fix temperature unit and bytes / bits settings. (#1180)
|
||||||
|
|
||||||
## 0.12.9
|
## 0.12.9
|
||||||
|
|
||||||
- Fix divide by zero error introduced in 0.12.8 :) (#1175)
|
- Fix divide by zero error introduced in 0.12.8 :) (#1175)
|
||||||
|
|||||||
@@ -161,6 +161,53 @@ run_rc_command "$1"
|
|||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Detect system architecture
|
||||||
|
detect_architecture() {
|
||||||
|
local arch=$(uname -m)
|
||||||
|
|
||||||
|
if [ "$arch" = "mips" ]; then
|
||||||
|
detect_mips_endianness
|
||||||
|
return $?
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$arch" in
|
||||||
|
x86_64)
|
||||||
|
arch="amd64"
|
||||||
|
;;
|
||||||
|
armv6l|armv7l)
|
||||||
|
arch="arm"
|
||||||
|
;;
|
||||||
|
aarch64)
|
||||||
|
arch="arm64"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo "$arch"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Detect MIPS endianness using ELF header
|
||||||
|
detect_mips_endianness() {
|
||||||
|
local bins="/bin/sh /bin/ls /usr/bin/env"
|
||||||
|
local bin_to_check endian
|
||||||
|
|
||||||
|
for bin_to_check in $bins; do
|
||||||
|
if [ -f "$bin_to_check" ]; then
|
||||||
|
# The 6th byte in ELF header: 01 = little, 02 = big
|
||||||
|
endian=$(hexdump -n 1 -s 5 -e '1/1 "%02x"' "$bin_to_check" 2>/dev/null)
|
||||||
|
if [ "$endian" = "01" ]; then
|
||||||
|
echo "mipsle"
|
||||||
|
return
|
||||||
|
elif [ "$endian" = "02" ]; then
|
||||||
|
echo "mips"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Final fallback
|
||||||
|
echo "mips"
|
||||||
|
}
|
||||||
|
|
||||||
# Default values
|
# Default values
|
||||||
PORT=45876
|
PORT=45876
|
||||||
UNINSTALL=false
|
UNINSTALL=false
|
||||||
@@ -556,7 +603,7 @@ fi
|
|||||||
echo "Downloading and installing the agent..."
|
echo "Downloading and installing the agent..."
|
||||||
|
|
||||||
OS=$(uname -s | sed -e 'y/ABCDEFGHIJKLMNOPQRSTUVWXYZ/abcdefghijklmnopqrstuvwxyz/')
|
OS=$(uname -s | sed -e 'y/ABCDEFGHIJKLMNOPQRSTUVWXYZ/abcdefghijklmnopqrstuvwxyz/')
|
||||||
ARCH=$(uname -m | sed -e 's/x86_64/amd64/' -e 's/armv6l/arm/' -e 's/armv7l/arm/' -e 's/aarch64/arm64/')
|
ARCH=$(detect_architecture)
|
||||||
FILE_NAME="beszel-agent_${OS}_${ARCH}.tar.gz"
|
FILE_NAME="beszel-agent_${OS}_${ARCH}.tar.gz"
|
||||||
|
|
||||||
# Determine version to install
|
# Determine version to install
|
||||||
@@ -738,9 +785,7 @@ EXTRA_HELP=" update Update the Beszel agent
|
|||||||
restart Restart the Beszel agent"
|
restart Restart the Beszel agent"
|
||||||
|
|
||||||
update() {
|
update() {
|
||||||
if $BIN_PATH update | grep -q "Update completed successfully"; then
|
$BIN_PATH update
|
||||||
/etc/init.d/beszel-agent restart
|
|
||||||
fi
|
|
||||||
}
|
}
|
||||||
|
|
||||||
EOF
|
EOF
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ fi
|
|||||||
version=0.0.1
|
version=0.0.1
|
||||||
PORT=8090 # Default port
|
PORT=8090 # Default port
|
||||||
GITHUB_PROXY_URL="https://ghfast.top/" # Default proxy URL
|
GITHUB_PROXY_URL="https://ghfast.top/" # Default proxy URL
|
||||||
|
AUTO_UPDATE_FLAG="false" # default to no auto-updates, "true" means enable
|
||||||
|
|
||||||
# Function to ensure the proxy URL ends with a /
|
# Function to ensure the proxy URL ends with a /
|
||||||
ensure_trailing_slash() {
|
ensure_trailing_slash() {
|
||||||
@@ -32,26 +33,42 @@ ensure_trailing_slash() {
|
|||||||
# Ensure the proxy URL ends with a /
|
# Ensure the proxy URL ends with a /
|
||||||
GITHUB_PROXY_URL=$(ensure_trailing_slash "$GITHUB_PROXY_URL")
|
GITHUB_PROXY_URL=$(ensure_trailing_slash "$GITHUB_PROXY_URL")
|
||||||
|
|
||||||
# Read command line options
|
# Parse command line arguments
|
||||||
while getopts ":uhp:c:" opt; do
|
while [ $# -gt 0 ]; do
|
||||||
case $opt in
|
case "$1" in
|
||||||
u) UNINSTALL="true" ;;
|
-u)
|
||||||
h)
|
UNINSTALL="true"
|
||||||
printf "Beszel Hub installation script\n\n"
|
shift
|
||||||
printf "Usage: ./install-hub.sh [options]\n\n"
|
;;
|
||||||
printf "Options: \n"
|
-h|--help)
|
||||||
printf " -u : Uninstall the Beszel Hub\n"
|
printf "Beszel Hub installation script\n\n"
|
||||||
printf " -p <port> : Specify a port number (default: 8090)\n"
|
printf "Usage: ./install-hub.sh [options]\n\n"
|
||||||
printf " -c <url> : Use a custom GitHub mirror URL (e.g., https://ghfast.top/)\n"
|
printf "Options: \n"
|
||||||
echo " -h : Display this help message"
|
printf " -u : Uninstall the Beszel Hub\n"
|
||||||
exit 0
|
printf " -p <port> : Specify a port number (default: 8090)\n"
|
||||||
;;
|
printf " -c <url> : Use a custom GitHub mirror URL (e.g., https://ghfast.top/)\n"
|
||||||
p) PORT=$OPTARG ;;
|
printf " --auto-update : Enable automatic daily updates (disabled by default)\n"
|
||||||
c) GITHUB_PROXY_URL=$(ensure_trailing_slash "$OPTARG") ;;
|
printf " -h, --help : Display this help message\n"
|
||||||
\?)
|
exit 0
|
||||||
echo "Invalid option: -$OPTARG"
|
;;
|
||||||
exit 1
|
-p)
|
||||||
;;
|
shift
|
||||||
|
PORT="$1"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-c)
|
||||||
|
shift
|
||||||
|
GITHUB_PROXY_URL=$(ensure_trailing_slash "$1")
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--auto-update)
|
||||||
|
AUTO_UPDATE_FLAG="true"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Invalid option: $1" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
@@ -63,7 +80,14 @@ if [ "$UNINSTALL" = "true" ]; then
|
|||||||
|
|
||||||
# Remove the systemd service file
|
# Remove the systemd service file
|
||||||
echo "Removing the systemd service file..."
|
echo "Removing the systemd service file..."
|
||||||
rm /etc/systemd/system/beszel-hub.service
|
rm -f /etc/systemd/system/beszel-hub.service
|
||||||
|
|
||||||
|
# Remove the update timer and service if they exist
|
||||||
|
echo "Removing the daily update service and timer..."
|
||||||
|
systemctl stop beszel-hub-update.timer 2>/dev/null
|
||||||
|
systemctl disable beszel-hub-update.timer 2>/dev/null
|
||||||
|
rm -f /etc/systemd/system/beszel-hub-update.service
|
||||||
|
rm -f /etc/systemd/system/beszel-hub-update.timer
|
||||||
|
|
||||||
# Reload the systemd daemon
|
# Reload the systemd daemon
|
||||||
echo "Reloading the systemd daemon..."
|
echo "Reloading the systemd daemon..."
|
||||||
@@ -75,7 +99,7 @@ if [ "$UNINSTALL" = "true" ]; then
|
|||||||
|
|
||||||
# Remove the dedicated user
|
# Remove the dedicated user
|
||||||
echo "Removing the dedicated user..."
|
echo "Removing the dedicated user..."
|
||||||
userdel beszel
|
userdel beszel 2>/dev/null
|
||||||
|
|
||||||
echo "The Beszel Hub has been uninstalled successfully!"
|
echo "The Beszel Hub has been uninstalled successfully!"
|
||||||
exit 0
|
exit 0
|
||||||
@@ -151,4 +175,39 @@ if [ "$(systemctl is-active beszel-hub.service)" != "active" ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Enable auto-update if flag is set to true
|
||||||
|
if [ "$AUTO_UPDATE_FLAG" = "true" ]; then
|
||||||
|
echo "Setting up daily automatic updates for beszel-hub..."
|
||||||
|
|
||||||
|
# Create systemd service for the daily update
|
||||||
|
cat >/etc/systemd/system/beszel-hub-update.service <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=Update beszel-hub if needed
|
||||||
|
Wants=beszel-hub.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/opt/beszel/beszel update
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Create systemd timer for the daily update
|
||||||
|
cat >/etc/systemd/system/beszel-hub-update.timer <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=Run beszel-hub update daily
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=daily
|
||||||
|
Persistent=true
|
||||||
|
RandomizedDelaySec=4h
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable --now beszel-hub-update.timer
|
||||||
|
|
||||||
|
printf "\nDaily updates have been enabled.\n"
|
||||||
|
fi
|
||||||
|
|
||||||
echo "The Beszel Hub has been installed and configured successfully! It is now accessible on port $PORT."
|
echo "The Beszel Hub has been installed and configured successfully! It is now accessible on port $PORT."
|
||||||
|
|||||||
Reference in New Issue
Block a user