mirror of
https://github.com/henrygd/beszel.git
synced 2026-03-23 14:06:18 +01:00
Compare commits
9 Commits
v0.18.3
...
docker-24-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
845369ab54 | ||
|
|
dba3519b2c | ||
|
|
48c35aa54d | ||
|
|
6b7845b03e | ||
|
|
221be1da58 | ||
|
|
8347afd68e | ||
|
|
2a3885a52e | ||
|
|
5452e50080 | ||
|
|
028f7bafb2 |
@@ -5,11 +5,8 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -19,7 +16,6 @@ import (
|
|||||||
"github.com/henrygd/beszel/agent/deltatracker"
|
"github.com/henrygd/beszel/agent/deltatracker"
|
||||||
"github.com/henrygd/beszel/internal/common"
|
"github.com/henrygd/beszel/internal/common"
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
"github.com/shirou/gopsutil/v4/host"
|
|
||||||
gossh "golang.org/x/crypto/ssh"
|
gossh "golang.org/x/crypto/ssh"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -65,7 +61,7 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
|
|||||||
agent.netIoStats = make(map[uint16]system.NetIoStats)
|
agent.netIoStats = make(map[uint16]system.NetIoStats)
|
||||||
agent.netInterfaceDeltaTrackers = make(map[uint16]*deltatracker.DeltaTracker[string, uint64])
|
agent.netInterfaceDeltaTrackers = make(map[uint16]*deltatracker.DeltaTracker[string, uint64])
|
||||||
|
|
||||||
agent.dataDir, err = getDataDir(dataDir...)
|
agent.dataDir, err = GetDataDir(dataDir...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("Data directory not found")
|
slog.Warn("Data directory not found")
|
||||||
} else {
|
} else {
|
||||||
@@ -228,38 +224,12 @@ func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedD
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartAgent initializes and starts the agent with optional WebSocket connection
|
// Start initializes and starts the agent with optional WebSocket connection
|
||||||
func (a *Agent) Start(serverOptions ServerOptions) error {
|
func (a *Agent) Start(serverOptions ServerOptions) error {
|
||||||
a.keys = serverOptions.Keys
|
a.keys = serverOptions.Keys
|
||||||
return a.connectionManager.Start(serverOptions)
|
return a.connectionManager.Start(serverOptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Agent) getFingerprint() string {
|
func (a *Agent) getFingerprint() string {
|
||||||
// first look for a fingerprint in the data directory
|
return GetFingerprint(a.dataDir, a.systemDetails.Hostname, a.systemDetails.CpuModel)
|
||||||
if a.dataDir != "" {
|
|
||||||
if fp, err := os.ReadFile(filepath.Join(a.dataDir, "fingerprint")); err == nil {
|
|
||||||
return string(fp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// if no fingerprint is found, generate one
|
|
||||||
fingerprint, err := host.HostID()
|
|
||||||
// we ignore a commonly known "product_uuid" known not to be unique
|
|
||||||
if err != nil || fingerprint == "" || fingerprint == "03000200-0400-0500-0006-000700080009" {
|
|
||||||
fingerprint = a.systemDetails.Hostname + a.systemDetails.CpuModel
|
|
||||||
}
|
|
||||||
|
|
||||||
// hash fingerprint
|
|
||||||
sum := sha256.Sum256([]byte(fingerprint))
|
|
||||||
fingerprint = hex.EncodeToString(sum[:24])
|
|
||||||
|
|
||||||
// save fingerprint to data directory
|
|
||||||
if a.dataDir != "" {
|
|
||||||
err = os.WriteFile(filepath.Join(a.dataDir, "fingerprint"), []byte(fingerprint), 0644)
|
|
||||||
if err != nil {
|
|
||||||
slog.Warn("Failed to save fingerprint", "err", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return fingerprint
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ import (
|
|||||||
"runtime"
|
"runtime"
|
||||||
)
|
)
|
||||||
|
|
||||||
// getDataDir returns the path to the data directory for the agent and an error
|
// GetDataDir returns the path to the data directory for the agent and an error
|
||||||
// if the directory is not valid. Attempts to find the optimal data directory if
|
// if the directory is not valid. Attempts to find the optimal data directory if
|
||||||
// no data directories are provided.
|
// no data directories are provided.
|
||||||
func getDataDir(dataDirs ...string) (string, error) {
|
func GetDataDir(dataDirs ...string) (string, error) {
|
||||||
if len(dataDirs) > 0 {
|
if len(dataDirs) > 0 {
|
||||||
return testDataDirs(dataDirs)
|
return testDataDirs(dataDirs)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ func TestGetDataDir(t *testing.T) {
|
|||||||
// Test with explicit dataDir parameter
|
// Test with explicit dataDir parameter
|
||||||
t.Run("explicit data dir", func(t *testing.T) {
|
t.Run("explicit data dir", func(t *testing.T) {
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
result, err := getDataDir(tempDir)
|
result, err := GetDataDir(tempDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, tempDir, result)
|
assert.Equal(t, tempDir, result)
|
||||||
})
|
})
|
||||||
@@ -26,7 +26,7 @@ func TestGetDataDir(t *testing.T) {
|
|||||||
t.Run("explicit data dir - create new", func(t *testing.T) {
|
t.Run("explicit data dir - create new", func(t *testing.T) {
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
newDir := filepath.Join(tempDir, "new-data-dir")
|
newDir := filepath.Join(tempDir, "new-data-dir")
|
||||||
result, err := getDataDir(newDir)
|
result, err := GetDataDir(newDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, newDir, result)
|
assert.Equal(t, newDir, result)
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ func TestGetDataDir(t *testing.T) {
|
|||||||
|
|
||||||
os.Setenv("BESZEL_AGENT_DATA_DIR", tempDir)
|
os.Setenv("BESZEL_AGENT_DATA_DIR", tempDir)
|
||||||
|
|
||||||
result, err := getDataDir()
|
result, err := GetDataDir()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, tempDir, result)
|
assert.Equal(t, tempDir, result)
|
||||||
})
|
})
|
||||||
@@ -60,7 +60,7 @@ func TestGetDataDir(t *testing.T) {
|
|||||||
// Test with invalid explicit dataDir
|
// Test with invalid explicit dataDir
|
||||||
t.Run("invalid explicit data dir", func(t *testing.T) {
|
t.Run("invalid explicit data dir", func(t *testing.T) {
|
||||||
invalidPath := "/invalid/path/that/cannot/be/created"
|
invalidPath := "/invalid/path/that/cannot/be/created"
|
||||||
_, err := getDataDir(invalidPath)
|
_, err := GetDataDir(invalidPath)
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -79,7 +79,7 @@ func TestGetDataDir(t *testing.T) {
|
|||||||
|
|
||||||
// This will try platform-specific defaults, which may or may not work
|
// This will try platform-specific defaults, which may or may not work
|
||||||
// We're mainly testing that it doesn't panic and returns some result
|
// We're mainly testing that it doesn't panic and returns some result
|
||||||
result, err := getDataDir()
|
result, err := GetDataDir()
|
||||||
// We don't assert success/failure here since it depends on system permissions
|
// We don't assert success/failure here since it depends on system permissions
|
||||||
// Just verify we get a string result if no error
|
// Just verify we get a string result if no error
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|||||||
@@ -26,6 +26,15 @@ func parseFilesystemEntry(entry string) (device, customName string) {
|
|||||||
return device, customName
|
return device, customName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isDockerSpecialMountpoint(mountpoint string) bool {
|
||||||
|
switch mountpoint {
|
||||||
|
case "/etc/hosts", "/etc/resolv.conf", "/etc/hostname":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Sets up the filesystems to monitor for disk usage and I/O.
|
// Sets up the filesystems to monitor for disk usage and I/O.
|
||||||
func (a *Agent) initializeDiskInfo() {
|
func (a *Agent) initializeDiskInfo() {
|
||||||
filesystem, _ := GetEnv("FILESYSTEM")
|
filesystem, _ := GetEnv("FILESYSTEM")
|
||||||
@@ -69,11 +78,15 @@ func (a *Agent) initializeDiskInfo() {
|
|||||||
if _, exists := a.fsStats[key]; !exists {
|
if _, exists := a.fsStats[key]; !exists {
|
||||||
if root {
|
if root {
|
||||||
slog.Info("Detected root device", "name", key)
|
slog.Info("Detected root device", "name", key)
|
||||||
// Check if root device is in /proc/diskstats, use fallback if not
|
// Check if root device is in /proc/diskstats. Do not guess a
|
||||||
|
// fallback device for root: that can misattribute root I/O to a
|
||||||
|
// different disk while usage remains tied to root mountpoint.
|
||||||
if _, ioMatch = diskIoCounters[key]; !ioMatch {
|
if _, ioMatch = diskIoCounters[key]; !ioMatch {
|
||||||
key, ioMatch = findIoDevice(filesystem, diskIoCounters, a.fsStats)
|
if matchedKey, match := findIoDevice(filesystem, diskIoCounters); match {
|
||||||
if !ioMatch {
|
key = matchedKey
|
||||||
slog.Info("Using I/O fallback", "device", device, "mountpoint", mountpoint, "fallback", key)
|
ioMatch = true
|
||||||
|
} else {
|
||||||
|
slog.Warn("Root I/O unmapped; set FILESYSTEM", "device", device, "mountpoint", mountpoint)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -141,8 +154,8 @@ func (a *Agent) initializeDiskInfo() {
|
|||||||
for _, p := range partitions {
|
for _, p := range partitions {
|
||||||
// fmt.Println(p.Device, p.Mountpoint)
|
// fmt.Println(p.Device, p.Mountpoint)
|
||||||
// Binary root fallback or docker root fallback
|
// Binary root fallback or docker root fallback
|
||||||
if !hasRoot && (p.Mountpoint == rootMountPoint || (p.Mountpoint == "/etc/hosts" && strings.HasPrefix(p.Device, "/dev"))) {
|
if !hasRoot && (p.Mountpoint == rootMountPoint || (isDockerSpecialMountpoint(p.Mountpoint) && strings.HasPrefix(p.Device, "/dev"))) {
|
||||||
fs, match := findIoDevice(filepath.Base(p.Device), diskIoCounters, a.fsStats)
|
fs, match := findIoDevice(filepath.Base(p.Device), diskIoCounters)
|
||||||
if match {
|
if match {
|
||||||
addFsStat(fs, p.Mountpoint, true)
|
addFsStat(fs, p.Mountpoint, true)
|
||||||
hasRoot = true
|
hasRoot = true
|
||||||
@@ -176,33 +189,26 @@ func (a *Agent) initializeDiskInfo() {
|
|||||||
|
|
||||||
// If no root filesystem set, use fallback
|
// If no root filesystem set, use fallback
|
||||||
if !hasRoot {
|
if !hasRoot {
|
||||||
rootDevice, _ := findIoDevice(filepath.Base(filesystem), diskIoCounters, a.fsStats)
|
rootKey := filepath.Base(rootMountPoint)
|
||||||
slog.Info("Root disk", "mountpoint", rootMountPoint, "io", rootDevice)
|
if _, exists := a.fsStats[rootKey]; exists {
|
||||||
a.fsStats[rootDevice] = &system.FsStats{Root: true, Mountpoint: rootMountPoint}
|
rootKey = "root"
|
||||||
|
}
|
||||||
|
slog.Warn("Root device not detected; root I/O disabled", "mountpoint", rootMountPoint)
|
||||||
|
a.fsStats[rootKey] = &system.FsStats{Root: true, Mountpoint: rootMountPoint}
|
||||||
}
|
}
|
||||||
|
|
||||||
a.initializeDiskIoStats(diskIoCounters)
|
a.initializeDiskIoStats(diskIoCounters)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns matching device from /proc/diskstats,
|
// Returns matching device from /proc/diskstats.
|
||||||
// or the device with the most reads if no match is found.
|
|
||||||
// bool is true if a match was found.
|
// bool is true if a match was found.
|
||||||
func findIoDevice(filesystem string, diskIoCounters map[string]disk.IOCountersStat, fsStats map[string]*system.FsStats) (string, bool) {
|
func findIoDevice(filesystem string, diskIoCounters map[string]disk.IOCountersStat) (string, bool) {
|
||||||
var maxReadBytes uint64
|
|
||||||
maxReadDevice := "/"
|
|
||||||
for _, d := range diskIoCounters {
|
for _, d := range diskIoCounters {
|
||||||
if d.Name == filesystem || (d.Label != "" && d.Label == filesystem) {
|
if d.Name == filesystem || (d.Label != "" && d.Label == filesystem) {
|
||||||
return d.Name, true
|
return d.Name, true
|
||||||
}
|
}
|
||||||
if d.ReadBytes > maxReadBytes {
|
|
||||||
// don't use if device already exists in fsStats
|
|
||||||
if _, exists := fsStats[d.Name]; !exists {
|
|
||||||
maxReadBytes = d.ReadBytes
|
|
||||||
maxReadDevice = d.Name
|
|
||||||
}
|
}
|
||||||
}
|
return "", false
|
||||||
}
|
|
||||||
return maxReadDevice, false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sets start values for disk I/O stats.
|
// Sets start values for disk I/O stats.
|
||||||
|
|||||||
@@ -94,6 +94,62 @@ func TestParseFilesystemEntry(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFindIoDevice(t *testing.T) {
|
||||||
|
t.Run("matches by device name", func(t *testing.T) {
|
||||||
|
ioCounters := map[string]disk.IOCountersStat{
|
||||||
|
"sda": {Name: "sda"},
|
||||||
|
"sdb": {Name: "sdb"},
|
||||||
|
}
|
||||||
|
|
||||||
|
device, ok := findIoDevice("sdb", ioCounters)
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Equal(t, "sdb", device)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("matches by device label", func(t *testing.T) {
|
||||||
|
ioCounters := map[string]disk.IOCountersStat{
|
||||||
|
"sda": {Name: "sda", Label: "rootfs"},
|
||||||
|
"sdb": {Name: "sdb"},
|
||||||
|
}
|
||||||
|
|
||||||
|
device, ok := findIoDevice("rootfs", ioCounters)
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Equal(t, "sda", device)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("returns no fallback when not found", func(t *testing.T) {
|
||||||
|
ioCounters := map[string]disk.IOCountersStat{
|
||||||
|
"sda": {Name: "sda"},
|
||||||
|
"sdb": {Name: "sdb"},
|
||||||
|
}
|
||||||
|
|
||||||
|
device, ok := findIoDevice("nvme0n1p1", ioCounters)
|
||||||
|
assert.False(t, ok)
|
||||||
|
assert.Equal(t, "", device)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsDockerSpecialMountpoint(t *testing.T) {
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
mountpoint string
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{name: "hosts", mountpoint: "/etc/hosts", expected: true},
|
||||||
|
{name: "resolv", mountpoint: "/etc/resolv.conf", expected: true},
|
||||||
|
{name: "hostname", mountpoint: "/etc/hostname", expected: true},
|
||||||
|
{name: "root", mountpoint: "/", expected: false},
|
||||||
|
{name: "passwd", mountpoint: "/etc/passwd", expected: false},
|
||||||
|
{name: "extra-filesystem", mountpoint: "/extra-filesystems/sda1", expected: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tc.expected, isDockerSpecialMountpoint(tc.mountpoint))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestInitializeDiskInfoWithCustomNames(t *testing.T) {
|
func TestInitializeDiskInfoWithCustomNames(t *testing.T) {
|
||||||
// Set up environment variables
|
// Set up environment variables
|
||||||
oldEnv := os.Getenv("EXTRA_FILESYSTEMS")
|
oldEnv := os.Getenv("EXTRA_FILESYSTEMS")
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ var ansiEscapePattern = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*
|
|||||||
const (
|
const (
|
||||||
// Docker API timeout in milliseconds
|
// Docker API timeout in milliseconds
|
||||||
dockerTimeoutMs = 2100
|
dockerTimeoutMs = 2100
|
||||||
|
// Number of consecutive /containers/json failures before forcing a client reset on old Docker versions
|
||||||
|
dockerClientResetFailureThreshold = 3
|
||||||
|
// Minimum time between Docker client resets to avoid reset flapping
|
||||||
|
dockerClientResetCooldown = 30 * time.Second
|
||||||
// Maximum realistic network speed (5 GB/s) to detect bad deltas
|
// Maximum realistic network speed (5 GB/s) to detect bad deltas
|
||||||
maxNetworkSpeedBps uint64 = 5e9
|
maxNetworkSpeedBps uint64 = 5e9
|
||||||
// Maximum conceivable memory usage of a container (100TB) to detect bad memory stats
|
// Maximum conceivable memory usage of a container (100TB) to detect bad memory stats
|
||||||
@@ -55,12 +59,16 @@ type dockerManager struct {
|
|||||||
containerStatsMap map[string]*container.Stats // Keeps track of container stats
|
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
|
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)
|
goodDockerVersion bool // Whether docker version is at least 25.0.0 (one-shot works correctly)
|
||||||
|
versionChecked bool // Whether docker version detection completed successfully
|
||||||
isWindows bool // Whether the Docker Engine API is running on Windows
|
isWindows bool // Whether the Docker Engine API is running on Windows
|
||||||
buf *bytes.Buffer // Buffer to store and read response bodies
|
buf *bytes.Buffer // Buffer to store and read response bodies
|
||||||
decoder *json.Decoder // Reusable JSON decoder that reads from buf
|
decoder *json.Decoder // Reusable JSON decoder that reads from buf
|
||||||
apiStats *container.ApiStats // Reusable API stats object
|
apiStats *container.ApiStats // Reusable API stats object
|
||||||
excludeContainers []string // Patterns to exclude containers by name
|
excludeContainers []string // Patterns to exclude containers by name
|
||||||
usingPodman bool // Whether the Docker Engine API is running on Podman
|
usingPodman bool // Whether the Docker Engine API is running on Podman
|
||||||
|
transport *http.Transport // Base transport used by client for connection resets
|
||||||
|
consecutiveListFailures int // Number of consecutive /containers/json request failures
|
||||||
|
lastClientReset time.Time // Last time the Docker client connections were reset
|
||||||
|
|
||||||
// Cache-time-aware tracking for CPU stats (similar to cpu.go)
|
// Cache-time-aware tracking for CPU stats (similar to cpu.go)
|
||||||
// Maps cache time intervals to container-specific CPU usage tracking
|
// Maps cache time intervals to container-specific CPU usage tracking
|
||||||
@@ -119,8 +127,10 @@ func (dm *dockerManager) shouldExcludeContainer(name string) bool {
|
|||||||
func (dm *dockerManager) getDockerStats(cacheTimeMs uint16) ([]*container.Stats, error) {
|
func (dm *dockerManager) getDockerStats(cacheTimeMs uint16) ([]*container.Stats, error) {
|
||||||
resp, err := dm.client.Get("http://localhost/containers/json")
|
resp, err := dm.client.Get("http://localhost/containers/json")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
dm.handleContainerListError(err)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
dm.consecutiveListFailures = 0
|
||||||
|
|
||||||
dm.apiContainerList = dm.apiContainerList[:0]
|
dm.apiContainerList = dm.apiContainerList[:0]
|
||||||
if err := dm.decode(resp, &dm.apiContainerList); err != nil {
|
if err := dm.decode(resp, &dm.apiContainerList); err != nil {
|
||||||
@@ -204,6 +214,50 @@ func (dm *dockerManager) getDockerStats(cacheTimeMs uint16) ([]*container.Stats,
|
|||||||
return stats, nil
|
return stats, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (dm *dockerManager) handleContainerListError(err error) {
|
||||||
|
dm.consecutiveListFailures++
|
||||||
|
if !dm.shouldResetDockerClient(err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dm.resetDockerClientConnections()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dm *dockerManager) shouldResetDockerClient(err error) bool {
|
||||||
|
if !dm.versionChecked || dm.goodDockerVersion {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if dm.consecutiveListFailures < dockerClientResetFailureThreshold {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !dm.lastClientReset.IsZero() && time.Since(dm.lastClientReset) < dockerClientResetCooldown {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return isDockerApiOverloadError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isDockerApiOverloadError(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
msg := err.Error()
|
||||||
|
return strings.Contains(msg, "Client.Timeout exceeded") ||
|
||||||
|
strings.Contains(msg, "request canceled") ||
|
||||||
|
strings.Contains(msg, "context deadline exceeded") ||
|
||||||
|
strings.Contains(msg, "EOF")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dm *dockerManager) resetDockerClientConnections() {
|
||||||
|
if dm.transport == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dm.transport.CloseIdleConnections()
|
||||||
|
dm.lastClientReset = time.Now()
|
||||||
|
slog.Warn("Reset Docker client connections after repeated /containers/json failures", "failures", dm.consecutiveListFailures)
|
||||||
|
}
|
||||||
|
|
||||||
// initializeCpuTracking initializes CPU tracking maps for a specific cache time interval
|
// initializeCpuTracking initializes CPU tracking maps for a specific cache time interval
|
||||||
func (dm *dockerManager) initializeCpuTracking(cacheTimeMs uint16) {
|
func (dm *dockerManager) initializeCpuTracking(cacheTimeMs uint16) {
|
||||||
// Initialize cache time maps if they don't exist
|
// Initialize cache time maps if they don't exist
|
||||||
@@ -553,6 +607,7 @@ func newDockerManager() *dockerManager {
|
|||||||
Timeout: timeout,
|
Timeout: timeout,
|
||||||
Transport: userAgentTransport,
|
Transport: userAgentTransport,
|
||||||
},
|
},
|
||||||
|
transport: transport,
|
||||||
containerStatsMap: make(map[string]*container.Stats),
|
containerStatsMap: make(map[string]*container.Stats),
|
||||||
sem: make(chan struct{}, 5),
|
sem: make(chan struct{}, 5),
|
||||||
apiContainerList: []*container.ApiInfo{},
|
apiContainerList: []*container.ApiInfo{},
|
||||||
@@ -611,6 +666,7 @@ func (dm *dockerManager) checkDockerVersion() {
|
|||||||
if err := dm.decode(resp, &versionInfo); err != nil {
|
if err := dm.decode(resp, &versionInfo); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
dm.versionChecked = true
|
||||||
// if version > 24, one-shot works correctly and we can limit concurrent operations
|
// if version > 24, one-shot works correctly and we can limit concurrent operations
|
||||||
if dockerVersion, err := semver.Parse(versionInfo.Version); err == nil && dockerVersion.Major > 24 {
|
if dockerVersion, err := semver.Parse(versionInfo.Version); err == nil && dockerVersion.Major > 24 {
|
||||||
dm.goodDockerVersion = true
|
dm.goodDockerVersion = true
|
||||||
|
|||||||
87
agent/fingerprint.go
Normal file
87
agent/fingerprint.go
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/shirou/gopsutil/v4/cpu"
|
||||||
|
"github.com/shirou/gopsutil/v4/host"
|
||||||
|
)
|
||||||
|
|
||||||
|
const fingerprintFileName = "fingerprint"
|
||||||
|
|
||||||
|
// knownBadUUID is a commonly known "product_uuid" that is not unique across systems.
|
||||||
|
const knownBadUUID = "03000200-0400-0500-0006-000700080009"
|
||||||
|
|
||||||
|
// GetFingerprint returns the agent fingerprint. It first tries to read a saved
|
||||||
|
// fingerprint from the data directory. If not found (or dataDir is empty), it
|
||||||
|
// generates one from system properties. The hostname and cpuModel parameters are
|
||||||
|
// used as fallback material if host.HostID() fails. If either is empty, they
|
||||||
|
// are fetched from the system automatically.
|
||||||
|
//
|
||||||
|
// If a new fingerprint is generated and a dataDir is provided, it is saved.
|
||||||
|
func GetFingerprint(dataDir, hostname, cpuModel string) string {
|
||||||
|
if dataDir != "" {
|
||||||
|
if fp, err := readFingerprint(dataDir); err == nil {
|
||||||
|
return fp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fp := generateFingerprint(hostname, cpuModel)
|
||||||
|
if dataDir != "" {
|
||||||
|
_ = SaveFingerprint(dataDir, fp)
|
||||||
|
}
|
||||||
|
return fp
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateFingerprint creates a fingerprint from system properties.
|
||||||
|
// It tries host.HostID() first, falling back to hostname + cpuModel.
|
||||||
|
// If hostname or cpuModel are empty, they are fetched from the system.
|
||||||
|
func generateFingerprint(hostname, cpuModel string) string {
|
||||||
|
fingerprint, err := host.HostID()
|
||||||
|
if err != nil || fingerprint == "" || fingerprint == knownBadUUID {
|
||||||
|
if hostname == "" {
|
||||||
|
hostname, _ = os.Hostname()
|
||||||
|
}
|
||||||
|
if cpuModel == "" {
|
||||||
|
if info, err := cpu.Info(); err == nil && len(info) > 0 {
|
||||||
|
cpuModel = info[0].ModelName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fingerprint = hostname + cpuModel
|
||||||
|
}
|
||||||
|
|
||||||
|
sum := sha256.Sum256([]byte(fingerprint))
|
||||||
|
return hex.EncodeToString(sum[:24])
|
||||||
|
}
|
||||||
|
|
||||||
|
// readFingerprint reads the saved fingerprint from the data directory.
|
||||||
|
func readFingerprint(dataDir string) (string, error) {
|
||||||
|
fp, err := os.ReadFile(filepath.Join(dataDir, fingerprintFileName))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
s := strings.TrimSpace(string(fp))
|
||||||
|
if s == "" {
|
||||||
|
return "", errors.New("fingerprint file is empty")
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveFingerprint writes the fingerprint to the data directory.
|
||||||
|
func SaveFingerprint(dataDir, fingerprint string) error {
|
||||||
|
return os.WriteFile(filepath.Join(dataDir, fingerprintFileName), []byte(fingerprint), 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteFingerprint removes the saved fingerprint file from the data directory.
|
||||||
|
// Returns nil if the file does not exist (idempotent).
|
||||||
|
func DeleteFingerprint(dataDir string) error {
|
||||||
|
err := os.Remove(filepath.Join(dataDir, fingerprintFileName))
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
103
agent/fingerprint_test.go
Normal file
103
agent/fingerprint_test.go
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
//go:build testing
|
||||||
|
// +build testing
|
||||||
|
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetFingerprint(t *testing.T) {
|
||||||
|
t.Run("reads existing fingerprint from file", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
expected := "abc123def456"
|
||||||
|
err := os.WriteFile(filepath.Join(dir, fingerprintFileName), []byte(expected), 0644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
fp := GetFingerprint(dir, "", "")
|
||||||
|
assert.Equal(t, expected, fp)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("trims whitespace from file", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
err := os.WriteFile(filepath.Join(dir, fingerprintFileName), []byte(" abc123 \n"), 0644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
fp := GetFingerprint(dir, "", "")
|
||||||
|
assert.Equal(t, "abc123", fp)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("generates fingerprint when file does not exist", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
fp := GetFingerprint(dir, "", "")
|
||||||
|
assert.NotEmpty(t, fp)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("generates fingerprint when dataDir is empty", func(t *testing.T) {
|
||||||
|
fp := GetFingerprint("", "", "")
|
||||||
|
assert.NotEmpty(t, fp)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("generates consistent fingerprint for same inputs", func(t *testing.T) {
|
||||||
|
fp1 := GetFingerprint("", "myhost", "mycpu")
|
||||||
|
fp2 := GetFingerprint("", "myhost", "mycpu")
|
||||||
|
assert.Equal(t, fp1, fp2)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("prefers saved fingerprint over generated", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, SaveFingerprint(dir, "saved-fp"))
|
||||||
|
|
||||||
|
fp := GetFingerprint(dir, "anyhost", "anycpu")
|
||||||
|
assert.Equal(t, "saved-fp", fp)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveFingerprint(t *testing.T) {
|
||||||
|
t.Run("saves fingerprint to file", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
err := SaveFingerprint(dir, "abc123")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
content, err := os.ReadFile(filepath.Join(dir, fingerprintFileName))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "abc123", string(content))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("overwrites existing fingerprint", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, SaveFingerprint(dir, "old"))
|
||||||
|
require.NoError(t, SaveFingerprint(dir, "new"))
|
||||||
|
|
||||||
|
content, err := os.ReadFile(filepath.Join(dir, fingerprintFileName))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "new", string(content))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteFingerprint(t *testing.T) {
|
||||||
|
t.Run("deletes existing fingerprint", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
fp := filepath.Join(dir, fingerprintFileName)
|
||||||
|
err := os.WriteFile(fp, []byte("abc123"), 0644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = DeleteFingerprint(dir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify file is gone
|
||||||
|
_, err = os.Stat(fp)
|
||||||
|
assert.True(t, os.IsNotExist(err))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no error when file does not exist", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
err := DeleteFingerprint(dir)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -36,6 +36,9 @@ var hubVersions map[string]semver.Version
|
|||||||
// and begins listening for connections. Returns an error if the server
|
// and begins listening for connections. Returns an error if the server
|
||||||
// is already running or if there's an issue starting the server.
|
// is already running or if there's an issue starting the server.
|
||||||
func (a *Agent) StartServer(opts ServerOptions) error {
|
func (a *Agent) StartServer(opts ServerOptions) error {
|
||||||
|
if disableSSH, _ := GetEnv("DISABLE_SSH"); disableSSH == "true" {
|
||||||
|
return errors.New("SSH disabled")
|
||||||
|
}
|
||||||
if a.server != nil {
|
if a.server != nil {
|
||||||
return errors.New("server already started")
|
return errors.New("server already started")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
//go:build testing
|
||||||
|
// +build testing
|
||||||
|
|
||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -180,6 +183,23 @@ func TestStartServer(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestStartServerDisableSSH(t *testing.T) {
|
||||||
|
os.Setenv("BESZEL_AGENT_DISABLE_SSH", "true")
|
||||||
|
defer os.Unsetenv("BESZEL_AGENT_DISABLE_SSH")
|
||||||
|
|
||||||
|
agent, err := NewAgent("")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
opts := ServerOptions{
|
||||||
|
Network: "tcp",
|
||||||
|
Addr: ":45990",
|
||||||
|
}
|
||||||
|
|
||||||
|
err = agent.StartServer(opts)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "SSH disabled")
|
||||||
|
}
|
||||||
|
|
||||||
/////////////////////////////////////////////////////////////////
|
/////////////////////////////////////////////////////////////////
|
||||||
//////////////////// ParseKeys Tests ////////////////////////////
|
//////////////////// ParseKeys Tests ////////////////////////////
|
||||||
/////////////////////////////////////////////////////////////////
|
/////////////////////////////////////////////////////////////////
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -18,8 +19,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/smart"
|
"github.com/henrygd/beszel/internal/entities/smart"
|
||||||
|
|
||||||
"log/slog"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// SmartManager manages data collection for SMART devices
|
// SmartManager manages data collection for SMART devices
|
||||||
@@ -1125,7 +1124,6 @@ func NewSmartManager() (*SmartManager, error) {
|
|||||||
sm.refreshExcludedDevices()
|
sm.refreshExcludedDevices()
|
||||||
path, err := sm.detectSmartctl()
|
path, err := sm.detectSmartctl()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Debug(err.Error())
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
slog.Debug("smartctl", "path", path)
|
slog.Debug("smartctl", "path", path)
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ func detectRestarter() restarter {
|
|||||||
func Update(useMirror bool) error {
|
func Update(useMirror bool) error {
|
||||||
exePath, _ := os.Executable()
|
exePath, _ := os.Executable()
|
||||||
|
|
||||||
dataDir, err := getDataDir()
|
dataDir, err := GetDataDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
dataDir = os.TempDir()
|
dataDir = os.TempDir()
|
||||||
}
|
}
|
||||||
@@ -125,4 +125,3 @@ func Update(useMirror bool) error {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
2
go.mod
2
go.mod
@@ -1,6 +1,6 @@
|
|||||||
module github.com/henrygd/beszel
|
module github.com/henrygd/beszel
|
||||||
|
|
||||||
go 1.25.5
|
go 1.25.7
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/blang/semver v3.5.1+incompatible
|
github.com/blang/semver v3.5.1+incompatible
|
||||||
|
|||||||
@@ -31,9 +31,6 @@ func (opts *cmdOptions) parse() bool {
|
|||||||
|
|
||||||
// Subcommands that don't require any pflag parsing
|
// Subcommands that don't require any pflag parsing
|
||||||
switch subcommand {
|
switch subcommand {
|
||||||
case "-v", "version":
|
|
||||||
fmt.Println(beszel.AppName+"-agent", beszel.Version)
|
|
||||||
return true
|
|
||||||
case "health":
|
case "health":
|
||||||
err := health.Check()
|
err := health.Check()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -41,6 +38,9 @@ func (opts *cmdOptions) parse() bool {
|
|||||||
}
|
}
|
||||||
fmt.Print("ok")
|
fmt.Print("ok")
|
||||||
return true
|
return true
|
||||||
|
case "fingerprint":
|
||||||
|
handleFingerprint()
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// pflag.CommandLine.ParseErrorsWhitelist.UnknownFlags = true
|
// pflag.CommandLine.ParseErrorsWhitelist.UnknownFlags = true
|
||||||
@@ -49,6 +49,7 @@ func (opts *cmdOptions) parse() bool {
|
|||||||
pflag.StringVarP(&opts.hubURL, "url", "u", "", "URL of the Beszel hub")
|
pflag.StringVarP(&opts.hubURL, "url", "u", "", "URL of the Beszel hub")
|
||||||
pflag.StringVarP(&opts.token, "token", "t", "", "Token to use for authentication")
|
pflag.StringVarP(&opts.token, "token", "t", "", "Token to use for authentication")
|
||||||
chinaMirrors := pflag.BoolP("china-mirrors", "c", false, "Use mirror for update (gh.beszel.dev) instead of GitHub")
|
chinaMirrors := pflag.BoolP("china-mirrors", "c", false, "Use mirror for update (gh.beszel.dev) instead of GitHub")
|
||||||
|
version := pflag.BoolP("version", "v", false, "Show version information")
|
||||||
help := pflag.BoolP("help", "h", false, "Show this help message")
|
help := pflag.BoolP("help", "h", false, "Show this help message")
|
||||||
|
|
||||||
// Convert old single-dash long flags to double-dash for backward compatibility
|
// Convert old single-dash long flags to double-dash for backward compatibility
|
||||||
@@ -73,8 +74,8 @@ func (opts *cmdOptions) parse() bool {
|
|||||||
builder.WriteString(os.Args[0])
|
builder.WriteString(os.Args[0])
|
||||||
builder.WriteString(" [command] [flags]\n")
|
builder.WriteString(" [command] [flags]\n")
|
||||||
builder.WriteString("\nCommands:\n")
|
builder.WriteString("\nCommands:\n")
|
||||||
|
builder.WriteString(" fingerprint View or reset the agent fingerprint\n")
|
||||||
builder.WriteString(" health Check if the agent is running\n")
|
builder.WriteString(" health Check if the agent is running\n")
|
||||||
// builder.WriteString(" help Display this help message\n")
|
|
||||||
builder.WriteString(" update Update to the latest version\n")
|
builder.WriteString(" update Update to the latest version\n")
|
||||||
builder.WriteString("\nFlags:\n")
|
builder.WriteString("\nFlags:\n")
|
||||||
fmt.Print(builder.String())
|
fmt.Print(builder.String())
|
||||||
@@ -86,6 +87,9 @@ func (opts *cmdOptions) parse() bool {
|
|||||||
|
|
||||||
// Must run after pflag.Parse()
|
// Must run after pflag.Parse()
|
||||||
switch {
|
switch {
|
||||||
|
case *version:
|
||||||
|
fmt.Println(beszel.AppName+"-agent", beszel.Version)
|
||||||
|
return true
|
||||||
case *help || subcommand == "help":
|
case *help || subcommand == "help":
|
||||||
pflag.Usage()
|
pflag.Usage()
|
||||||
return true
|
return true
|
||||||
@@ -133,6 +137,38 @@ func (opts *cmdOptions) getAddress() string {
|
|||||||
return agent.GetAddress(opts.listen)
|
return agent.GetAddress(opts.listen)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleFingerprint handles the "fingerprint" command with subcommands "view" and "reset".
|
||||||
|
func handleFingerprint() {
|
||||||
|
subCmd := ""
|
||||||
|
if len(os.Args) > 2 {
|
||||||
|
subCmd = os.Args[2]
|
||||||
|
}
|
||||||
|
|
||||||
|
switch subCmd {
|
||||||
|
case "", "view":
|
||||||
|
dataDir, _ := agent.GetDataDir()
|
||||||
|
fp := agent.GetFingerprint(dataDir, "", "")
|
||||||
|
fmt.Println(fp)
|
||||||
|
case "help", "-h", "--help":
|
||||||
|
fmt.Print(fingerprintUsage())
|
||||||
|
case "reset":
|
||||||
|
dataDir, err := agent.GetDataDir()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := agent.DeleteFingerprint(dataDir); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
fmt.Println("Fingerprint reset. A new one will be generated on next start.")
|
||||||
|
default:
|
||||||
|
log.Fatalf("Unknown command: %q\n\n%s", subCmd, fingerprintUsage())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fingerprintUsage() string {
|
||||||
|
return fmt.Sprintf("Usage: %s fingerprint [view|reset]\n\nCommands:\n view Print fingerprint (default)\n reset Reset saved fingerprint\n", os.Args[0])
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
var opts cmdOptions
|
var opts cmdOptions
|
||||||
subcommandHandled := opts.parse()
|
subcommandHandled := opts.parse()
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type { ClassValue } from "clsx"
|
|||||||
import {
|
import {
|
||||||
ArrowUpDownIcon,
|
ArrowUpDownIcon,
|
||||||
ChevronRightSquareIcon,
|
ChevronRightSquareIcon,
|
||||||
|
ClockArrowUp,
|
||||||
CopyIcon,
|
CopyIcon,
|
||||||
CpuIcon,
|
CpuIcon,
|
||||||
HardDriveIcon,
|
HardDriveIcon,
|
||||||
@@ -34,6 +35,7 @@ import {
|
|||||||
formatTemperature,
|
formatTemperature,
|
||||||
getMeterState,
|
getMeterState,
|
||||||
parseSemVer,
|
parseSemVer,
|
||||||
|
secondsToString,
|
||||||
} from "@/lib/utils"
|
} from "@/lib/utils"
|
||||||
import { batteryStateTranslations } from "@/lib/i18n"
|
import { batteryStateTranslations } from "@/lib/i18n"
|
||||||
import type { SystemRecord } from "@/types"
|
import type { SystemRecord } from "@/types"
|
||||||
@@ -373,6 +375,29 @@ export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<Syste
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
accessorFn: ({ info }) => info.u || undefined,
|
||||||
|
id: "uptime",
|
||||||
|
name: () => t`Uptime`,
|
||||||
|
size: 50,
|
||||||
|
Icon: ClockArrowUp,
|
||||||
|
header: sortableHeader,
|
||||||
|
cell(info) {
|
||||||
|
const uptime = info.getValue() as number
|
||||||
|
if (!uptime) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
let formatted: string
|
||||||
|
if (uptime < 3600) {
|
||||||
|
formatted = secondsToString(uptime, "minute")
|
||||||
|
} else if (uptime < 360000) {
|
||||||
|
formatted = secondsToString(uptime, "hour")
|
||||||
|
} else {
|
||||||
|
formatted = secondsToString(uptime, "day")
|
||||||
|
}
|
||||||
|
return <span className="tabular-nums whitespace-nowrap">{formatted}</span>
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
accessorFn: ({ info }) => info.v,
|
accessorFn: ({ info }) => info.v,
|
||||||
id: "agent",
|
id: "agent",
|
||||||
|
|||||||
@@ -7,13 +7,15 @@ param (
|
|||||||
[int]$Port = 45876,
|
[int]$Port = 45876,
|
||||||
[string]$AgentPath = "",
|
[string]$AgentPath = "",
|
||||||
[string]$NSSMPath = "",
|
[string]$NSSMPath = "",
|
||||||
[switch]$ConfigureFirewall
|
[switch]$ConfigureFirewall,
|
||||||
|
[ValidateSet("Auto", "Scoop", "WinGet")]
|
||||||
|
[string]$InstallMethod = "Auto"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check if required parameters are provided
|
# Check if required parameters are provided
|
||||||
if ([string]::IsNullOrWhiteSpace($Key)) {
|
if ([string]::IsNullOrWhiteSpace($Key)) {
|
||||||
Write-Host "ERROR: SSH Key is required." -ForegroundColor Red
|
Write-Host "ERROR: SSH Key is required." -ForegroundColor Red
|
||||||
Write-Host "Usage: .\install-agent.ps1 -Key 'your-ssh-key-here' [-Token 'your-token-here'] [-Url 'your-hub-url-here'] [-Port port-number] [-ConfigureFirewall]" -ForegroundColor Yellow
|
Write-Host "Usage: .\install-agent.ps1 -Key 'your-ssh-key-here' [-Token 'your-token-here'] [-Url 'your-hub-url-here'] [-Port port-number] [-InstallMethod Auto|Scoop|WinGet] [-ConfigureFirewall]" -ForegroundColor Yellow
|
||||||
Write-Host "Note: Token and Url are optional for backwards compatibility with older hub versions." -ForegroundColor Yellow
|
Write-Host "Note: Token and Url are optional for backwards compatibility with older hub versions." -ForegroundColor Yellow
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
@@ -487,6 +489,21 @@ try {
|
|||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($InstallMethod -eq "Scoop") {
|
||||||
|
if (-not (Test-CommandExists "scoop")) {
|
||||||
|
throw "InstallMethod is set to Scoop, but Scoop is not available in PATH."
|
||||||
|
}
|
||||||
|
Write-Host "Using Scoop for installation..."
|
||||||
|
$AgentPath = Install-WithScoop -Key $Key -Port $Port
|
||||||
|
}
|
||||||
|
elseif ($InstallMethod -eq "WinGet") {
|
||||||
|
if (-not (Test-CommandExists "winget")) {
|
||||||
|
throw "InstallMethod is set to WinGet, but WinGet is not available in PATH."
|
||||||
|
}
|
||||||
|
Write-Host "Using WinGet for installation..."
|
||||||
|
$AgentPath = Install-WithWinGet -Key $Key -Port $Port
|
||||||
|
}
|
||||||
|
else {
|
||||||
if (Test-CommandExists "scoop") {
|
if (Test-CommandExists "scoop") {
|
||||||
Write-Host "Using Scoop for installation..."
|
Write-Host "Using Scoop for installation..."
|
||||||
$AgentPath = Install-WithScoop -Key $Key -Port $Port
|
$AgentPath = Install-WithScoop -Key $Key -Port $Port
|
||||||
@@ -500,6 +517,7 @@ try {
|
|||||||
$AgentPath = Install-WithScoop -Key $Key -Port $Port
|
$AgentPath = Install-WithScoop -Key $Key -Port $Port
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (-not $AgentPath) {
|
if (-not $AgentPath) {
|
||||||
throw "Could not find beszel-agent executable. Make sure it was properly installed."
|
throw "Could not find beszel-agent executable. Make sure it was properly installed."
|
||||||
@@ -561,7 +579,8 @@ try {
|
|||||||
"-Token", "`"$Token`"",
|
"-Token", "`"$Token`"",
|
||||||
"-Url", "`"$Url`"",
|
"-Url", "`"$Url`"",
|
||||||
"-Port", $Port,
|
"-Port", $Port,
|
||||||
"-AgentPath", "`"$AgentPath`""
|
"-AgentPath", "`"$AgentPath`"",
|
||||||
|
"-InstallMethod", $InstallMethod
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add NSSMPath if we found it
|
# Add NSSMPath if we found it
|
||||||
|
|||||||
Reference in New Issue
Block a user