mirror of
https://github.com/henrygd/beszel.git
synced 2026-07-02 10:18:22 +02:00
Compare commits
18 Commits
v0.18.5
...
7f4f14b505
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f4f14b505 | ||
|
|
2fda4ff264 | ||
|
|
20b0b40ec8 | ||
|
|
d548a012b4 | ||
|
|
ce5d1217dd | ||
|
|
cef09d7cb1 | ||
|
|
f6440acb43 | ||
|
|
5463a38f0f | ||
|
|
80135fdad3 | ||
|
|
5db4eb4346 | ||
|
|
f6c5e2928a | ||
|
|
6a207c33fa | ||
|
|
9f19afccde | ||
|
|
f25f2469e3 | ||
|
|
5bd43ed461 | ||
|
|
afdc3f7779 | ||
|
|
a227c77526 | ||
|
|
8202d746af |
@@ -19,6 +19,8 @@ import (
|
|||||||
gossh "golang.org/x/crypto/ssh"
|
gossh "golang.org/x/crypto/ssh"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const defaultDataCacheTimeMs uint16 = 60_000
|
||||||
|
|
||||||
type Agent struct {
|
type Agent struct {
|
||||||
sync.Mutex // Used to lock agent while collecting data
|
sync.Mutex // Used to lock agent while collecting data
|
||||||
debug bool // true if LOG_LEVEL is set to debug
|
debug bool // true if LOG_LEVEL is set to debug
|
||||||
@@ -36,6 +38,7 @@ type Agent struct {
|
|||||||
sensorConfig *SensorConfig // Sensors config
|
sensorConfig *SensorConfig // Sensors config
|
||||||
systemInfo system.Info // Host system info (dynamic)
|
systemInfo system.Info // Host system info (dynamic)
|
||||||
systemDetails system.Details // Host system details (static, once-per-connection)
|
systemDetails system.Details // Host system details (static, once-per-connection)
|
||||||
|
detailsDirty bool // Whether system details have changed and need to be resent
|
||||||
gpuManager *GPUManager // Manages GPU data
|
gpuManager *GPUManager // Manages GPU data
|
||||||
cache *systemDataCache // Cache for system stats based on cache time
|
cache *systemDataCache // Cache for system stats based on cache time
|
||||||
connectionManager *ConnectionManager // Channel to signal connection events
|
connectionManager *ConnectionManager // Channel to signal connection events
|
||||||
@@ -97,7 +100,7 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
|
|||||||
slog.Debug(beszel.Version)
|
slog.Debug(beszel.Version)
|
||||||
|
|
||||||
// initialize docker manager
|
// initialize docker manager
|
||||||
agent.dockerManager = newDockerManager()
|
agent.dockerManager = newDockerManager(agent)
|
||||||
|
|
||||||
// initialize system info
|
// initialize system info
|
||||||
agent.refreshSystemDetails()
|
agent.refreshSystemDetails()
|
||||||
@@ -142,7 +145,7 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
|
|||||||
|
|
||||||
// if debugging, print stats
|
// if debugging, print stats
|
||||||
if agent.debug {
|
if agent.debug {
|
||||||
slog.Debug("Stats", "data", agent.gatherStats(common.DataRequestOptions{CacheTimeMs: 60_000, IncludeDetails: true}))
|
slog.Debug("Stats", "data", agent.gatherStats(common.DataRequestOptions{CacheTimeMs: defaultDataCacheTimeMs, IncludeDetails: true}))
|
||||||
}
|
}
|
||||||
|
|
||||||
return agent, nil
|
return agent, nil
|
||||||
@@ -164,11 +167,6 @@ func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedD
|
|||||||
Info: a.systemInfo,
|
Info: a.systemInfo,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Include static system details only when requested
|
|
||||||
if options.IncludeDetails {
|
|
||||||
data.Details = &a.systemDetails
|
|
||||||
}
|
|
||||||
|
|
||||||
// slog.Info("System data", "data", data, "cacheTimeMs", cacheTimeMs)
|
// slog.Info("System data", "data", data, "cacheTimeMs", cacheTimeMs)
|
||||||
|
|
||||||
if a.dockerManager != nil {
|
if a.dockerManager != nil {
|
||||||
@@ -181,7 +179,7 @@ func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedD
|
|||||||
}
|
}
|
||||||
|
|
||||||
// skip updating systemd services if cache time is not the default 60sec interval
|
// skip updating systemd services if cache time is not the default 60sec interval
|
||||||
if a.systemdManager != nil && cacheTimeMs == 60_000 {
|
if a.systemdManager != nil && cacheTimeMs == defaultDataCacheTimeMs {
|
||||||
totalCount := uint16(a.systemdManager.getServiceStatsCount())
|
totalCount := uint16(a.systemdManager.getServiceStatsCount())
|
||||||
if totalCount > 0 {
|
if totalCount > 0 {
|
||||||
numFailed := a.systemdManager.getFailedServiceCount()
|
numFailed := a.systemdManager.getFailedServiceCount()
|
||||||
@@ -212,7 +210,8 @@ func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedD
|
|||||||
slog.Debug("Extra FS", "data", data.Stats.ExtraFs)
|
slog.Debug("Extra FS", "data", data.Stats.ExtraFs)
|
||||||
|
|
||||||
a.cache.Set(data, cacheTimeMs)
|
a.cache.Set(data, cacheTimeMs)
|
||||||
return data
|
|
||||||
|
return a.attachSystemDetails(data, cacheTimeMs, options.IncludeDetails)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start initializes and starts the agent with optional WebSocket connection
|
// Start initializes and starts the agent with optional WebSocket connection
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -238,9 +239,11 @@ func (d *diskDiscovery) addConfiguredExtraFilesystems(extraFilesystems string) {
|
|||||||
|
|
||||||
// addPartitionExtraFs registers partitions mounted under /extra-filesystems so
|
// addPartitionExtraFs registers partitions mounted under /extra-filesystems so
|
||||||
// their display names can come from the folder name while their I/O keys still
|
// their display names can come from the folder name while their I/O keys still
|
||||||
// prefer the underlying partition device.
|
// prefer the underlying partition device. Only direct children are matched to
|
||||||
|
// avoid registering nested virtual mounts (e.g. /proc, /sys) that are returned by
|
||||||
|
// disk.Partitions(true) when the host root is bind-mounted in /extra-filesystems.
|
||||||
func (d *diskDiscovery) addPartitionExtraFs(p disk.PartitionStat) {
|
func (d *diskDiscovery) addPartitionExtraFs(p disk.PartitionStat) {
|
||||||
if !strings.HasPrefix(p.Mountpoint, d.ctx.efPath) {
|
if filepath.Dir(p.Mountpoint) != d.ctx.efPath {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
device, customName := extraFilesystemPartitionInfo(p)
|
device, customName := extraFilesystemPartitionInfo(p)
|
||||||
@@ -273,7 +276,7 @@ func (a *Agent) initializeDiskInfo() {
|
|||||||
hasRoot := false
|
hasRoot := false
|
||||||
isWindows := runtime.GOOS == "windows"
|
isWindows := runtime.GOOS == "windows"
|
||||||
|
|
||||||
partitions, err := disk.Partitions(false)
|
partitions, err := disk.PartitionsWithContext(context.Background(), true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Error getting disk partitions", "err", err)
|
slog.Error("Error getting disk partitions", "err", err)
|
||||||
}
|
}
|
||||||
@@ -628,9 +631,17 @@ func (a *Agent) updateDiskIo(cacheTimeMs uint16, systemStats *system.Stats) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// getRootMountPoint returns the appropriate root mount point for the system
|
// getRootMountPoint returns the appropriate root mount point for the system.
|
||||||
|
// On Windows it returns the system drive (e.g. "C:").
|
||||||
// For immutable systems like Fedora Silverblue, it returns /sysroot instead of /
|
// For immutable systems like Fedora Silverblue, it returns /sysroot instead of /
|
||||||
func (a *Agent) getRootMountPoint() string {
|
func (a *Agent) getRootMountPoint() string {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
if sd := os.Getenv("SystemDrive"); sd != "" {
|
||||||
|
return sd
|
||||||
|
}
|
||||||
|
return "C:"
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Check if /etc/os-release contains indicators of an immutable system
|
// 1. Check if /etc/os-release contains indicators of an immutable system
|
||||||
if osReleaseContent, err := os.ReadFile("/etc/os-release"); err == nil {
|
if osReleaseContent, err := os.ReadFile("/etc/os-release"); err == nil {
|
||||||
content := string(osReleaseContent)
|
content := string(osReleaseContent)
|
||||||
|
|||||||
@@ -530,6 +530,87 @@ func TestAddExtraFilesystemFolders(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAddPartitionExtraFs(t *testing.T) {
|
||||||
|
makeDiscovery := func(agent *Agent) diskDiscovery {
|
||||||
|
return diskDiscovery{
|
||||||
|
agent: agent,
|
||||||
|
ctx: fsRegistrationContext{
|
||||||
|
isWindows: false,
|
||||||
|
efPath: "/extra-filesystems",
|
||||||
|
diskIoCounters: map[string]disk.IOCountersStat{
|
||||||
|
"nvme0n1p1": {Name: "nvme0n1p1"},
|
||||||
|
"nvme1n1": {Name: "nvme1n1"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("registers direct child of extra-filesystems", func(t *testing.T) {
|
||||||
|
agent := &Agent{fsStats: make(map[string]*system.FsStats)}
|
||||||
|
d := makeDiscovery(agent)
|
||||||
|
|
||||||
|
d.addPartitionExtraFs(disk.PartitionStat{
|
||||||
|
Device: "/dev/nvme0n1p1",
|
||||||
|
Mountpoint: "/extra-filesystems/nvme0n1p1__caddy1-root",
|
||||||
|
})
|
||||||
|
|
||||||
|
stats, exists := agent.fsStats["nvme0n1p1"]
|
||||||
|
assert.True(t, exists)
|
||||||
|
assert.Equal(t, "/extra-filesystems/nvme0n1p1__caddy1-root", stats.Mountpoint)
|
||||||
|
assert.Equal(t, "caddy1-root", stats.Name)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("skips nested mount under extra-filesystem bind mount", func(t *testing.T) {
|
||||||
|
agent := &Agent{fsStats: make(map[string]*system.FsStats)}
|
||||||
|
d := makeDiscovery(agent)
|
||||||
|
|
||||||
|
// These simulate the virtual mounts that appear when host / is bind-mounted
|
||||||
|
// with disk.Partitions(all=true) — e.g. /proc, /sys, /dev visible under the mount.
|
||||||
|
for _, nested := range []string{
|
||||||
|
"/extra-filesystems/nvme0n1p1__caddy1-root/proc",
|
||||||
|
"/extra-filesystems/nvme0n1p1__caddy1-root/sys",
|
||||||
|
"/extra-filesystems/nvme0n1p1__caddy1-root/dev",
|
||||||
|
"/extra-filesystems/nvme0n1p1__caddy1-root/run",
|
||||||
|
} {
|
||||||
|
d.addPartitionExtraFs(disk.PartitionStat{Device: "tmpfs", Mountpoint: nested})
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Empty(t, agent.fsStats)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("registers both direct children, skips their nested mounts", func(t *testing.T) {
|
||||||
|
agent := &Agent{fsStats: make(map[string]*system.FsStats)}
|
||||||
|
d := makeDiscovery(agent)
|
||||||
|
|
||||||
|
partitions := []disk.PartitionStat{
|
||||||
|
{Device: "/dev/nvme0n1p1", Mountpoint: "/extra-filesystems/nvme0n1p1__caddy1-root"},
|
||||||
|
{Device: "/dev/nvme1n1", Mountpoint: "/extra-filesystems/nvme1n1__caddy1-docker"},
|
||||||
|
{Device: "proc", Mountpoint: "/extra-filesystems/nvme0n1p1__caddy1-root/proc"},
|
||||||
|
{Device: "sysfs", Mountpoint: "/extra-filesystems/nvme0n1p1__caddy1-root/sys"},
|
||||||
|
{Device: "overlay", Mountpoint: "/extra-filesystems/nvme0n1p1__caddy1-root/var/lib/docker"},
|
||||||
|
}
|
||||||
|
for _, p := range partitions {
|
||||||
|
d.addPartitionExtraFs(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Len(t, agent.fsStats, 2)
|
||||||
|
assert.Equal(t, "caddy1-root", agent.fsStats["nvme0n1p1"].Name)
|
||||||
|
assert.Equal(t, "caddy1-docker", agent.fsStats["nvme1n1"].Name)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("skips partition not under extra-filesystems", func(t *testing.T) {
|
||||||
|
agent := &Agent{fsStats: make(map[string]*system.FsStats)}
|
||||||
|
d := makeDiscovery(agent)
|
||||||
|
|
||||||
|
d.addPartitionExtraFs(disk.PartitionStat{
|
||||||
|
Device: "/dev/nvme0n1p1",
|
||||||
|
Mountpoint: "/",
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Empty(t, agent.fsStats)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestFindIoDevice(t *testing.T) {
|
func TestFindIoDevice(t *testing.T) {
|
||||||
t.Run("matches by device name", func(t *testing.T) {
|
t.Run("matches by device name", func(t *testing.T) {
|
||||||
ioCounters := map[string]disk.IOCountersStat{
|
ioCounters := map[string]disk.IOCountersStat{
|
||||||
|
|||||||
130
agent/docker.go
130
agent/docker.go
@@ -25,6 +25,7 @@ import (
|
|||||||
"github.com/henrygd/beszel/agent/deltatracker"
|
"github.com/henrygd/beszel/agent/deltatracker"
|
||||||
"github.com/henrygd/beszel/agent/utils"
|
"github.com/henrygd/beszel/agent/utils"
|
||||||
"github.com/henrygd/beszel/internal/entities/container"
|
"github.com/henrygd/beszel/internal/entities/container"
|
||||||
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
|
||||||
"github.com/blang/semver"
|
"github.com/blang/semver"
|
||||||
)
|
)
|
||||||
@@ -52,6 +53,7 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type dockerManager struct {
|
type dockerManager struct {
|
||||||
|
agent *Agent // Used to propagate system detail changes back to the agent
|
||||||
client *http.Client // Client to query Docker API
|
client *http.Client // Client to query Docker API
|
||||||
wg sync.WaitGroup // WaitGroup to wait for all goroutines to finish
|
wg sync.WaitGroup // WaitGroup to wait for all goroutines to finish
|
||||||
sem chan struct{} // Semaphore to limit concurrent container requests
|
sem chan struct{} // Semaphore to limit concurrent container requests
|
||||||
@@ -60,6 +62,7 @@ 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)
|
||||||
|
dockerVersionChecked bool // Whether a version probe has 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
|
||||||
@@ -78,7 +81,6 @@ type dockerManager struct {
|
|||||||
networkSentTrackers map[uint16]*deltatracker.DeltaTracker[string, uint64]
|
networkSentTrackers map[uint16]*deltatracker.DeltaTracker[string, uint64]
|
||||||
networkRecvTrackers map[uint16]*deltatracker.DeltaTracker[string, uint64]
|
networkRecvTrackers map[uint16]*deltatracker.DeltaTracker[string, uint64]
|
||||||
lastNetworkReadTime map[uint16]map[string]time.Time // cacheTimeMs -> containerId -> last network read time
|
lastNetworkReadTime map[uint16]map[string]time.Time // cacheTimeMs -> containerId -> last network read time
|
||||||
retrySleep func(time.Duration)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// userAgentRoundTripper is a custom http.RoundTripper that adds a User-Agent header to all requests
|
// userAgentRoundTripper is a custom http.RoundTripper that adds a User-Agent header to all requests
|
||||||
@@ -87,6 +89,14 @@ type userAgentRoundTripper struct {
|
|||||||
userAgent string
|
userAgent string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dockerVersionResponse contains the /version fields used for engine checks.
|
||||||
|
type dockerVersionResponse struct {
|
||||||
|
Version string `json:"Version"`
|
||||||
|
Components []struct {
|
||||||
|
Name string `json:"Name"`
|
||||||
|
} `json:"Components"`
|
||||||
|
}
|
||||||
|
|
||||||
// RoundTrip implements the http.RoundTripper interface
|
// RoundTrip implements the http.RoundTripper interface
|
||||||
func (u *userAgentRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
func (u *userAgentRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
req.Header.Set("User-Agent", u.userAgent)
|
req.Header.Set("User-Agent", u.userAgent)
|
||||||
@@ -134,7 +144,14 @@ func (dm *dockerManager) getDockerStats(cacheTimeMs uint16) ([]*container.Stats,
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
dm.isWindows = strings.Contains(resp.Header.Get("Server"), "windows")
|
// Detect Podman and Windows from Server header
|
||||||
|
serverHeader := resp.Header.Get("Server")
|
||||||
|
if !dm.usingPodman && detectPodmanFromHeader(serverHeader) {
|
||||||
|
dm.setIsPodman()
|
||||||
|
}
|
||||||
|
dm.isWindows = strings.Contains(serverHeader, "windows")
|
||||||
|
|
||||||
|
dm.ensureDockerVersionChecked()
|
||||||
|
|
||||||
containersLength := len(dm.apiContainerList)
|
containersLength := len(dm.apiContainerList)
|
||||||
|
|
||||||
@@ -588,7 +605,7 @@ func (dm *dockerManager) deleteContainerStatsSync(id string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Creates a new http client for Docker or Podman API
|
// Creates a new http client for Docker or Podman API
|
||||||
func newDockerManager() *dockerManager {
|
func newDockerManager(agent *Agent) *dockerManager {
|
||||||
dockerHost, exists := utils.GetEnv("DOCKER_HOST")
|
dockerHost, exists := utils.GetEnv("DOCKER_HOST")
|
||||||
if exists {
|
if exists {
|
||||||
// return nil if set to empty string
|
// return nil if set to empty string
|
||||||
@@ -654,6 +671,7 @@ func newDockerManager() *dockerManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
manager := &dockerManager{
|
manager := &dockerManager{
|
||||||
|
agent: agent,
|
||||||
client: &http.Client{
|
client: &http.Client{
|
||||||
Timeout: timeout,
|
Timeout: timeout,
|
||||||
Transport: userAgentTransport,
|
Transport: userAgentTransport,
|
||||||
@@ -671,51 +689,54 @@ func newDockerManager() *dockerManager {
|
|||||||
networkSentTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
|
networkSentTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
|
||||||
networkRecvTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
|
networkRecvTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
|
||||||
lastNetworkReadTime: make(map[uint16]map[string]time.Time),
|
lastNetworkReadTime: make(map[uint16]map[string]time.Time),
|
||||||
retrySleep: time.Sleep,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If using podman, return client
|
// Best-effort startup probe. If the engine is not ready yet, getDockerStats will
|
||||||
if strings.Contains(dockerHost, "podman") {
|
// retry after the first successful /containers/json request.
|
||||||
manager.usingPodman = true
|
_, _ = manager.checkDockerVersion()
|
||||||
manager.goodDockerVersion = true
|
|
||||||
return manager
|
|
||||||
}
|
|
||||||
|
|
||||||
// run version check in goroutine to avoid blocking (server may not be ready and requires retries)
|
|
||||||
go manager.checkDockerVersion()
|
|
||||||
|
|
||||||
// give version check a chance to complete before returning
|
|
||||||
time.Sleep(50 * time.Millisecond)
|
|
||||||
|
|
||||||
return manager
|
return manager
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkDockerVersion checks Docker version and sets goodDockerVersion if at least 25.0.0.
|
// checkDockerVersion checks Docker version and sets goodDockerVersion if at least 25.0.0.
|
||||||
// Versions before 25.0.0 have a bug with one-shot which requires all requests to be made in one batch.
|
// Versions before 25.0.0 have a bug with one-shot which requires all requests to be made in one batch.
|
||||||
func (dm *dockerManager) checkDockerVersion() {
|
func (dm *dockerManager) checkDockerVersion() (bool, error) {
|
||||||
var err error
|
resp, err := dm.client.Get("http://localhost/version")
|
||||||
var resp *http.Response
|
if err != nil {
|
||||||
var versionInfo struct {
|
return false, err
|
||||||
Version string `json:"Version"`
|
|
||||||
}
|
}
|
||||||
const versionMaxTries = 2
|
if resp.StatusCode != http.StatusOK {
|
||||||
for i := 1; i <= versionMaxTries; i++ {
|
status := resp.Status
|
||||||
resp, err = dm.client.Get("http://localhost/version")
|
|
||||||
if err == nil && resp.StatusCode == http.StatusOK {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if resp != nil {
|
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
|
return false, fmt.Errorf("docker version request failed: %s", status)
|
||||||
}
|
}
|
||||||
if i < versionMaxTries {
|
|
||||||
slog.Debug("Failed to get Docker version; retrying", "attempt", i, "err", err, "response", resp)
|
var versionInfo dockerVersionResponse
|
||||||
dm.retrySleep(5 * time.Second)
|
serverHeader := resp.Header.Get("Server")
|
||||||
|
if err := dm.decode(resp, &versionInfo); err != nil {
|
||||||
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dm.applyDockerVersionInfo(serverHeader, &versionInfo)
|
||||||
|
dm.dockerVersionChecked = true
|
||||||
|
return true, nil
|
||||||
}
|
}
|
||||||
if err != nil || resp.StatusCode != http.StatusOK {
|
|
||||||
|
// ensureDockerVersionChecked retries the version probe after a successful
|
||||||
|
// container list request.
|
||||||
|
func (dm *dockerManager) ensureDockerVersionChecked() {
|
||||||
|
if dm.dockerVersionChecked {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := dm.decode(resp, &versionInfo); err != nil {
|
if _, err := dm.checkDockerVersion(); err != nil {
|
||||||
|
slog.Debug("Failed to get Docker version", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyDockerVersionInfo updates version-dependent behavior from engine metadata.
|
||||||
|
func (dm *dockerManager) applyDockerVersionInfo(serverHeader string, versionInfo *dockerVersionResponse) {
|
||||||
|
if detectPodmanEngine(serverHeader, versionInfo) {
|
||||||
|
dm.setIsPodman()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 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
|
||||||
@@ -941,3 +962,46 @@ func (dm *dockerManager) GetHostInfo() (info container.HostInfo, err error) {
|
|||||||
func (dm *dockerManager) IsPodman() bool {
|
func (dm *dockerManager) IsPodman() bool {
|
||||||
return dm.usingPodman
|
return dm.usingPodman
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setIsPodman sets the manager to Podman mode and updates system details accordingly.
|
||||||
|
func (dm *dockerManager) setIsPodman() {
|
||||||
|
if dm.usingPodman {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dm.usingPodman = true
|
||||||
|
dm.goodDockerVersion = true
|
||||||
|
dm.dockerVersionChecked = true
|
||||||
|
// keep system details updated - this may be detected late if server isn't ready when
|
||||||
|
// agent starts, so make sure we notify the hub if this happens later.
|
||||||
|
if dm.agent != nil {
|
||||||
|
dm.agent.updateSystemDetails(func(details *system.Details) {
|
||||||
|
details.Podman = true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectPodmanFromHeader identifies Podman from the Docker API server header.
|
||||||
|
func detectPodmanFromHeader(server string) bool {
|
||||||
|
return strings.HasPrefix(server, "Libpod")
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectPodmanFromVersion identifies Podman from the version payload.
|
||||||
|
func detectPodmanFromVersion(versionInfo *dockerVersionResponse) bool {
|
||||||
|
if versionInfo == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, component := range versionInfo.Components {
|
||||||
|
if strings.HasPrefix(component.Name, "Podman") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectPodmanEngine checks both header and version metadata for Podman.
|
||||||
|
func detectPodmanEngine(serverHeader string, versionInfo *dockerVersionResponse) bool {
|
||||||
|
if detectPodmanFromHeader(serverHeader) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return detectPodmanFromVersion(versionInfo)
|
||||||
|
}
|
||||||
|
|||||||
@@ -540,58 +540,52 @@ func TestDockerManagerCreation(t *testing.T) {
|
|||||||
func TestCheckDockerVersion(t *testing.T) {
|
func TestCheckDockerVersion(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
responses []struct {
|
|
||||||
statusCode int
|
statusCode int
|
||||||
body string
|
body string
|
||||||
}
|
server string
|
||||||
|
expectSuccess bool
|
||||||
expectedGood bool
|
expectedGood bool
|
||||||
expectedRequests int
|
expectedPodman bool
|
||||||
|
expectError bool
|
||||||
|
expectedRequest string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "200 with good version on first try",
|
name: "good docker version",
|
||||||
responses: []struct {
|
statusCode: http.StatusOK,
|
||||||
statusCode int
|
body: `{"Version":"25.0.1"}`,
|
||||||
body string
|
expectSuccess: true,
|
||||||
}{
|
|
||||||
{http.StatusOK, `{"Version":"25.0.1"}`},
|
|
||||||
},
|
|
||||||
expectedGood: true,
|
expectedGood: true,
|
||||||
expectedRequests: 1,
|
expectedPodman: false,
|
||||||
|
expectedRequest: "/version",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "200 with old version on first try",
|
name: "old docker version",
|
||||||
responses: []struct {
|
statusCode: http.StatusOK,
|
||||||
statusCode int
|
body: `{"Version":"24.0.7"}`,
|
||||||
body string
|
expectSuccess: true,
|
||||||
}{
|
|
||||||
{http.StatusOK, `{"Version":"24.0.7"}`},
|
|
||||||
},
|
|
||||||
expectedGood: false,
|
expectedGood: false,
|
||||||
expectedRequests: 1,
|
expectedPodman: false,
|
||||||
|
expectedRequest: "/version",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "non-200 then 200 with good version",
|
name: "podman from server header",
|
||||||
responses: []struct {
|
statusCode: http.StatusOK,
|
||||||
statusCode int
|
body: `{"Version":"5.5.0"}`,
|
||||||
body string
|
server: "Libpod/5.5.0",
|
||||||
}{
|
expectSuccess: true,
|
||||||
{http.StatusServiceUnavailable, `"not ready"`},
|
|
||||||
{http.StatusOK, `{"Version":"25.1.0"}`},
|
|
||||||
},
|
|
||||||
expectedGood: true,
|
expectedGood: true,
|
||||||
expectedRequests: 2,
|
expectedPodman: true,
|
||||||
|
expectedRequest: "/version",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "non-200 on all retries",
|
name: "non-200 response",
|
||||||
responses: []struct {
|
statusCode: http.StatusServiceUnavailable,
|
||||||
statusCode int
|
body: `"not ready"`,
|
||||||
body string
|
expectSuccess: false,
|
||||||
}{
|
|
||||||
{http.StatusInternalServerError, `"error"`},
|
|
||||||
{http.StatusUnauthorized, `"error"`},
|
|
||||||
},
|
|
||||||
expectedGood: false,
|
expectedGood: false,
|
||||||
expectedRequests: 2,
|
expectedPodman: false,
|
||||||
|
expectError: true,
|
||||||
|
expectedRequest: "/version",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -599,13 +593,13 @@ func TestCheckDockerVersion(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
requestCount := 0
|
requestCount := 0
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
idx := requestCount
|
|
||||||
requestCount++
|
requestCount++
|
||||||
if idx >= len(tt.responses) {
|
assert.Equal(t, tt.expectedRequest, r.URL.EscapedPath())
|
||||||
idx = len(tt.responses) - 1
|
if tt.server != "" {
|
||||||
|
w.Header().Set("Server", tt.server)
|
||||||
}
|
}
|
||||||
w.WriteHeader(tt.responses[idx].statusCode)
|
w.WriteHeader(tt.statusCode)
|
||||||
fmt.Fprint(w, tt.responses[idx].body)
|
fmt.Fprint(w, tt.body)
|
||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
@@ -617,17 +611,24 @@ func TestCheckDockerVersion(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
retrySleep: func(time.Duration) {},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dm.checkDockerVersion()
|
success, err := dm.checkDockerVersion()
|
||||||
|
|
||||||
|
assert.Equal(t, tt.expectSuccess, success)
|
||||||
|
assert.Equal(t, tt.expectSuccess, dm.dockerVersionChecked)
|
||||||
assert.Equal(t, tt.expectedGood, dm.goodDockerVersion)
|
assert.Equal(t, tt.expectedGood, dm.goodDockerVersion)
|
||||||
assert.Equal(t, tt.expectedRequests, requestCount)
|
assert.Equal(t, tt.expectedPodman, dm.usingPodman)
|
||||||
|
assert.Equal(t, 1, requestCount)
|
||||||
|
if tt.expectError {
|
||||||
|
require.Error(t, err)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Run("request error on all retries", func(t *testing.T) {
|
t.Run("request error", func(t *testing.T) {
|
||||||
requestCount := 0
|
requestCount := 0
|
||||||
dm := &dockerManager{
|
dm := &dockerManager{
|
||||||
client: &http.Client{
|
client: &http.Client{
|
||||||
@@ -638,16 +639,171 @@ func TestCheckDockerVersion(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
retrySleep: func(time.Duration) {},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dm.checkDockerVersion()
|
success, err := dm.checkDockerVersion()
|
||||||
|
|
||||||
|
assert.False(t, success)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.False(t, dm.dockerVersionChecked)
|
||||||
assert.False(t, dm.goodDockerVersion)
|
assert.False(t, dm.goodDockerVersion)
|
||||||
assert.Equal(t, 2, requestCount)
|
assert.False(t, dm.usingPodman)
|
||||||
|
assert.Equal(t, 1, requestCount)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// newDockerManagerForVersionTest creates a dockerManager wired to a test server.
|
||||||
|
func newDockerManagerForVersionTest(server *httptest.Server) *dockerManager {
|
||||||
|
return &dockerManager{
|
||||||
|
client: &http.Client{
|
||||||
|
Transport: &http.Transport{
|
||||||
|
DialContext: func(_ context.Context, network, _ string) (net.Conn, error) {
|
||||||
|
return net.Dial(network, server.Listener.Addr().String())
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
containerStatsMap: make(map[string]*container.Stats),
|
||||||
|
lastCpuContainer: make(map[uint16]map[string]uint64),
|
||||||
|
lastCpuSystem: make(map[uint16]map[string]uint64),
|
||||||
|
lastCpuReadTime: make(map[uint16]map[string]time.Time),
|
||||||
|
networkSentTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
|
||||||
|
networkRecvTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
|
||||||
|
lastNetworkReadTime: make(map[uint16]map[string]time.Time),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetDockerStatsChecksDockerVersionAfterContainerList(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
containerServer string
|
||||||
|
versionServer string
|
||||||
|
versionBody string
|
||||||
|
expectedGood bool
|
||||||
|
expectedPodman bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "200 with good version on first try",
|
||||||
|
versionBody: `{"Version":"25.0.1"}`,
|
||||||
|
expectedGood: true,
|
||||||
|
expectedPodman: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "200 with old version on first try",
|
||||||
|
versionBody: `{"Version":"24.0.7"}`,
|
||||||
|
expectedGood: false,
|
||||||
|
expectedPodman: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "podman detected from server header",
|
||||||
|
containerServer: "Libpod/5.5.0",
|
||||||
|
expectedGood: true,
|
||||||
|
expectedPodman: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
requestCounts := map[string]int{}
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
requestCounts[r.URL.EscapedPath()]++
|
||||||
|
switch r.URL.EscapedPath() {
|
||||||
|
case "/containers/json":
|
||||||
|
if tt.containerServer != "" {
|
||||||
|
w.Header().Set("Server", tt.containerServer)
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
fmt.Fprint(w, `[]`)
|
||||||
|
case "/version":
|
||||||
|
if tt.versionServer != "" {
|
||||||
|
w.Header().Set("Server", tt.versionServer)
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
fmt.Fprint(w, tt.versionBody)
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected path: %s", r.URL.EscapedPath())
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
dm := newDockerManagerForVersionTest(server)
|
||||||
|
|
||||||
|
stats, err := dm.getDockerStats(defaultCacheTimeMs)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, stats)
|
||||||
|
assert.True(t, dm.dockerVersionChecked)
|
||||||
|
assert.Equal(t, tt.expectedGood, dm.goodDockerVersion)
|
||||||
|
assert.Equal(t, tt.expectedPodman, dm.usingPodman)
|
||||||
|
assert.Equal(t, 1, requestCounts["/containers/json"])
|
||||||
|
if tt.expectedPodman {
|
||||||
|
assert.Equal(t, 0, requestCounts["/version"])
|
||||||
|
} else {
|
||||||
|
assert.Equal(t, 1, requestCounts["/version"])
|
||||||
|
}
|
||||||
|
|
||||||
|
stats, err = dm.getDockerStats(defaultCacheTimeMs)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, stats)
|
||||||
|
assert.Equal(t, tt.expectedGood, dm.goodDockerVersion)
|
||||||
|
assert.Equal(t, tt.expectedPodman, dm.usingPodman)
|
||||||
|
assert.Equal(t, 2, requestCounts["/containers/json"])
|
||||||
|
if tt.expectedPodman {
|
||||||
|
assert.Equal(t, 0, requestCounts["/version"])
|
||||||
|
} else {
|
||||||
|
assert.Equal(t, 1, requestCounts["/version"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetDockerStatsRetriesVersionCheckUntilSuccess(t *testing.T) {
|
||||||
|
requestCounts := map[string]int{}
|
||||||
|
versionStatuses := []int{http.StatusServiceUnavailable, http.StatusOK}
|
||||||
|
versionBodies := []string{`"not ready"`, `{"Version":"25.1.0"}`}
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
requestCounts[r.URL.EscapedPath()]++
|
||||||
|
switch r.URL.EscapedPath() {
|
||||||
|
case "/containers/json":
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
fmt.Fprint(w, `[]`)
|
||||||
|
case "/version":
|
||||||
|
idx := requestCounts["/version"] - 1
|
||||||
|
if idx >= len(versionStatuses) {
|
||||||
|
idx = len(versionStatuses) - 1
|
||||||
|
}
|
||||||
|
w.WriteHeader(versionStatuses[idx])
|
||||||
|
fmt.Fprint(w, versionBodies[idx])
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected path: %s", r.URL.EscapedPath())
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
dm := newDockerManagerForVersionTest(server)
|
||||||
|
|
||||||
|
stats, err := dm.getDockerStats(defaultCacheTimeMs)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, stats)
|
||||||
|
assert.False(t, dm.dockerVersionChecked)
|
||||||
|
assert.False(t, dm.goodDockerVersion)
|
||||||
|
assert.Equal(t, 1, requestCounts["/version"])
|
||||||
|
|
||||||
|
stats, err = dm.getDockerStats(defaultCacheTimeMs)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, stats)
|
||||||
|
assert.True(t, dm.dockerVersionChecked)
|
||||||
|
assert.True(t, dm.goodDockerVersion)
|
||||||
|
assert.Equal(t, 2, requestCounts["/containers/json"])
|
||||||
|
assert.Equal(t, 2, requestCounts["/version"])
|
||||||
|
|
||||||
|
stats, err = dm.getDockerStats(defaultCacheTimeMs)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, stats)
|
||||||
|
assert.Equal(t, 3, requestCounts["/containers/json"])
|
||||||
|
assert.Equal(t, 2, requestCounts["/version"])
|
||||||
|
}
|
||||||
|
|
||||||
func TestCycleCpuDeltas(t *testing.T) {
|
func TestCycleCpuDeltas(t *testing.T) {
|
||||||
dm := &dockerManager{
|
dm := &dockerManager{
|
||||||
lastCpuContainer: map[uint16]map[string]uint64{
|
lastCpuContainer: map[uint16]map[string]uint64{
|
||||||
|
|||||||
11
agent/gpu.go
11
agent/gpu.go
@@ -542,7 +542,7 @@ func (gm *GPUManager) collectorDefinitions(caps gpuCapabilities) map[collectorSo
|
|||||||
return map[collectorSource]collectorDefinition{
|
return map[collectorSource]collectorDefinition{
|
||||||
collectorSourceNVML: {
|
collectorSourceNVML: {
|
||||||
group: collectorGroupNvidia,
|
group: collectorGroupNvidia,
|
||||||
available: caps.hasNvidiaSmi,
|
available: true,
|
||||||
start: func(_ func()) bool {
|
start: func(_ func()) bool {
|
||||||
return gm.startNvmlCollector()
|
return gm.startNvmlCollector()
|
||||||
},
|
},
|
||||||
@@ -734,9 +734,6 @@ func NewGPUManager() (*GPUManager, error) {
|
|||||||
}
|
}
|
||||||
var gm GPUManager
|
var gm GPUManager
|
||||||
caps := gm.discoverGpuCapabilities()
|
caps := gm.discoverGpuCapabilities()
|
||||||
if !hasAnyGpuCollector(caps) {
|
|
||||||
return nil, fmt.Errorf(noGPUFoundMsg)
|
|
||||||
}
|
|
||||||
gm.GpuDataMap = make(map[string]*system.GPUData)
|
gm.GpuDataMap = make(map[string]*system.GPUData)
|
||||||
|
|
||||||
// Jetson devices should always use tegrastats (ignore GPU_COLLECTOR).
|
// Jetson devices should always use tegrastats (ignore GPU_COLLECTOR).
|
||||||
@@ -745,7 +742,7 @@ func NewGPUManager() (*GPUManager, error) {
|
|||||||
return &gm, nil
|
return &gm, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// if GPU_COLLECTOR is set, start user-defined collectors.
|
// Respect explicit collector selection before capability auto-detection.
|
||||||
if collectorConfig, ok := utils.GetEnv("GPU_COLLECTOR"); ok && strings.TrimSpace(collectorConfig) != "" {
|
if collectorConfig, ok := utils.GetEnv("GPU_COLLECTOR"); ok && strings.TrimSpace(collectorConfig) != "" {
|
||||||
priorities := parseCollectorPriority(collectorConfig)
|
priorities := parseCollectorPriority(collectorConfig)
|
||||||
if gm.startCollectorsByPriority(priorities, caps) == 0 {
|
if gm.startCollectorsByPriority(priorities, caps) == 0 {
|
||||||
@@ -754,6 +751,10 @@ func NewGPUManager() (*GPUManager, error) {
|
|||||||
return &gm, nil
|
return &gm, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !hasAnyGpuCollector(caps) {
|
||||||
|
return nil, fmt.Errorf(noGPUFoundMsg)
|
||||||
|
}
|
||||||
|
|
||||||
// auto-detect and start collectors when GPU_COLLECTOR is unset.
|
// auto-detect and start collectors when GPU_COLLECTOR is unset.
|
||||||
if gm.startCollectorsByPriority(gm.resolveLegacyCollectorPriority(caps), caps) == 0 {
|
if gm.startCollectorsByPriority(gm.resolveLegacyCollectorPriority(caps), caps) == 0 {
|
||||||
return nil, fmt.Errorf(noGPUFoundMsg)
|
return nil, fmt.Errorf(noGPUFoundMsg)
|
||||||
|
|||||||
@@ -1461,6 +1461,25 @@ func TestNewGPUManagerConfiguredCollectorsMustStart(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCollectorDefinitionsNvmlDoesNotRequireNvidiaSmi(t *testing.T) {
|
||||||
|
gm := &GPUManager{}
|
||||||
|
definitions := gm.collectorDefinitions(gpuCapabilities{})
|
||||||
|
require.Contains(t, definitions, collectorSourceNVML)
|
||||||
|
assert.True(t, definitions[collectorSourceNVML].available)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewGPUManagerConfiguredNvmlBypassesCapabilityGate(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Setenv("PATH", dir)
|
||||||
|
t.Setenv("BESZEL_AGENT_GPU_COLLECTOR", "nvml")
|
||||||
|
|
||||||
|
gm, err := NewGPUManager()
|
||||||
|
require.Nil(t, gm)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "no configured GPU collectors are available")
|
||||||
|
assert.NotContains(t, err.Error(), noGPUFoundMsg)
|
||||||
|
}
|
||||||
|
|
||||||
func TestNewGPUManagerJetsonIgnoresCollectorConfig(t *testing.T) {
|
func TestNewGPUManagerJetsonIgnoresCollectorConfig(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
t.Setenv("PATH", dir)
|
t.Setenv("PATH", dir)
|
||||||
|
|||||||
@@ -8,6 +8,6 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="LibreHardwareMonitorLib" Version="0.9.5" />
|
<PackageReference Include="LibreHardwareMonitorLib" Version="0.9.6" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ type SensorConfig struct {
|
|||||||
isBlacklist bool
|
isBlacklist bool
|
||||||
hasWildcards bool
|
hasWildcards bool
|
||||||
skipCollection bool
|
skipCollection bool
|
||||||
|
firstRun bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Agent) newSensorConfig() *SensorConfig {
|
func (a *Agent) newSensorConfig() *SensorConfig {
|
||||||
@@ -52,6 +53,7 @@ func (a *Agent) newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal
|
|||||||
context: context.Background(),
|
context: context.Background(),
|
||||||
primarySensor: primarySensor,
|
primarySensor: primarySensor,
|
||||||
skipCollection: skipCollection,
|
skipCollection: skipCollection,
|
||||||
|
firstRun: true,
|
||||||
sensors: make(map[string]struct{}),
|
sensors: make(map[string]struct{}),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,6 +169,14 @@ func (a *Agent) getTempsWithTimeout(getTemps getTempsFn) ([]sensors.TemperatureS
|
|||||||
err error
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use a longer timeout on the first run to allow for initialization
|
||||||
|
// (e.g. Windows LHM subprocess startup)
|
||||||
|
timeout := temperatureFetchTimeout
|
||||||
|
if a.sensorConfig.firstRun {
|
||||||
|
a.sensorConfig.firstRun = false
|
||||||
|
timeout = 10 * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
resultCh := make(chan result, 1)
|
resultCh := make(chan result, 1)
|
||||||
go func() {
|
go func() {
|
||||||
temps, err := a.getTempsWithPanicRecovery(getTemps)
|
temps, err := a.getTempsWithPanicRecovery(getTemps)
|
||||||
@@ -176,7 +186,7 @@ func (a *Agent) getTempsWithTimeout(getTemps getTempsFn) ([]sensors.TemperatureS
|
|||||||
select {
|
select {
|
||||||
case res := <-resultCh:
|
case res := <-resultCh:
|
||||||
return res.temps, res.err
|
return res.temps, res.err
|
||||||
case <-time.After(temperatureFetchTimeout):
|
case <-time.After(timeout):
|
||||||
return nil, errTemperatureFetchTimeout
|
return nil, errTemperatureFetchTimeout
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -193,7 +193,7 @@ func (a *Agent) handleSSHRequest(w io.Writer, req *common.HubRequest[cbor.RawMes
|
|||||||
|
|
||||||
// handleLegacyStats serves the legacy one-shot stats payload for older hubs
|
// handleLegacyStats serves the legacy one-shot stats payload for older hubs
|
||||||
func (a *Agent) handleLegacyStats(w io.Writer, hubVersion semver.Version) error {
|
func (a *Agent) handleLegacyStats(w io.Writer, hubVersion semver.Version) error {
|
||||||
stats := a.gatherStats(common.DataRequestOptions{CacheTimeMs: 60_000})
|
stats := a.gatherStats(common.DataRequestOptions{CacheTimeMs: defaultDataCacheTimeMs})
|
||||||
return a.writeToSession(w, stats, hubVersion)
|
return a.writeToSession(w, stats, hubVersion)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -115,6 +115,26 @@ func (a *Agent) refreshSystemDetails() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// attachSystemDetails returns details only for fresh default-interval responses.
|
||||||
|
func (a *Agent) attachSystemDetails(data *system.CombinedData, cacheTimeMs uint16, includeRequested bool) *system.CombinedData {
|
||||||
|
if cacheTimeMs != defaultDataCacheTimeMs || (!includeRequested && !a.detailsDirty) {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// copy data to avoid adding details to the original cached struct
|
||||||
|
response := *data
|
||||||
|
response.Details = &a.systemDetails
|
||||||
|
a.detailsDirty = false
|
||||||
|
return &response
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateSystemDetails applies a mutation to the static details payload and marks
|
||||||
|
// it for inclusion on the next fresh default-interval response.
|
||||||
|
func (a *Agent) updateSystemDetails(updateFunc func(details *system.Details)) {
|
||||||
|
updateFunc(&a.systemDetails)
|
||||||
|
a.detailsDirty = true
|
||||||
|
}
|
||||||
|
|
||||||
// Returns current info, stats about the host system
|
// Returns current info, stats about the host system
|
||||||
func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
|
func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
|
||||||
var systemStats system.Stats
|
var systemStats system.Stats
|
||||||
|
|||||||
61
agent/system_test.go
Normal file
61
agent/system_test.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/common"
|
||||||
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGatherStatsDoesNotAttachDetailsToCachedRequests(t *testing.T) {
|
||||||
|
agent := &Agent{
|
||||||
|
cache: NewSystemDataCache(),
|
||||||
|
systemDetails: system.Details{Hostname: "updated-host", Podman: true},
|
||||||
|
detailsDirty: true,
|
||||||
|
}
|
||||||
|
cached := &system.CombinedData{
|
||||||
|
Info: system.Info{Hostname: "cached-host"},
|
||||||
|
}
|
||||||
|
agent.cache.Set(cached, defaultDataCacheTimeMs)
|
||||||
|
|
||||||
|
response := agent.gatherStats(common.DataRequestOptions{CacheTimeMs: defaultDataCacheTimeMs})
|
||||||
|
|
||||||
|
assert.Same(t, cached, response)
|
||||||
|
assert.Nil(t, response.Details)
|
||||||
|
assert.True(t, agent.detailsDirty)
|
||||||
|
assert.Equal(t, "cached-host", response.Info.Hostname)
|
||||||
|
assert.Nil(t, cached.Details)
|
||||||
|
|
||||||
|
secondResponse := agent.gatherStats(common.DataRequestOptions{CacheTimeMs: defaultDataCacheTimeMs})
|
||||||
|
assert.Same(t, cached, secondResponse)
|
||||||
|
assert.Nil(t, secondResponse.Details)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateSystemDetailsMarksDetailsDirty(t *testing.T) {
|
||||||
|
agent := &Agent{}
|
||||||
|
|
||||||
|
agent.updateSystemDetails(func(details *system.Details) {
|
||||||
|
details.Hostname = "updated-host"
|
||||||
|
details.Podman = true
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.True(t, agent.detailsDirty)
|
||||||
|
assert.Equal(t, "updated-host", agent.systemDetails.Hostname)
|
||||||
|
assert.True(t, agent.systemDetails.Podman)
|
||||||
|
|
||||||
|
original := &system.CombinedData{}
|
||||||
|
realTimeResponse := agent.attachSystemDetails(original, 1000, true)
|
||||||
|
assert.Same(t, original, realTimeResponse)
|
||||||
|
assert.Nil(t, realTimeResponse.Details)
|
||||||
|
assert.True(t, agent.detailsDirty)
|
||||||
|
|
||||||
|
response := agent.attachSystemDetails(original, defaultDataCacheTimeMs, false)
|
||||||
|
require.NotNil(t, response.Details)
|
||||||
|
assert.NotSame(t, original, response)
|
||||||
|
assert.Equal(t, "updated-host", response.Details.Hostname)
|
||||||
|
assert.True(t, response.Details.Podman)
|
||||||
|
assert.False(t, agent.detailsDirty)
|
||||||
|
assert.Nil(t, original.Details)
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import "github.com/blang/semver"
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// Version is the current version of the application.
|
// Version is the current version of the application.
|
||||||
Version = "0.18.5"
|
Version = "0.18.6"
|
||||||
// AppName is the name of the application.
|
// AppName is the name of the application.
|
||||||
AppName = "beszel"
|
AppName = "beszel"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -109,6 +109,18 @@ func (am *AlertManager) cancelPendingAlert(alertID string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CancelPendingStatusAlerts cancels all pending status alert timers for a given system.
|
||||||
|
// This is called when a system is paused to prevent delayed alerts from firing.
|
||||||
|
func (am *AlertManager) CancelPendingStatusAlerts(systemID string) {
|
||||||
|
am.pendingAlerts.Range(func(key, value any) bool {
|
||||||
|
info := value.(*alertInfo)
|
||||||
|
if info.alertData.SystemID == systemID {
|
||||||
|
am.cancelPendingAlert(key.(string))
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// processPendingAlert sends a "down" alert if the pending alert has expired and the system is still down.
|
// processPendingAlert sends a "down" alert if the pending alert has expired and the system is still down.
|
||||||
func (am *AlertManager) processPendingAlert(alertID string) {
|
func (am *AlertManager) processPendingAlert(alertID string) {
|
||||||
value, loaded := am.pendingAlerts.LoadAndDelete(alertID)
|
value, loaded := am.pendingAlerts.LoadAndDelete(alertID)
|
||||||
|
|||||||
@@ -941,3 +941,68 @@ func TestStatusAlertClearedBeforeSend(t *testing.T) {
|
|||||||
assert.EqualValues(t, 0, alertHistoryCount, "Should have no unresolved alert history records since alert never triggered")
|
assert.EqualValues(t, 0, alertHistoryCount, "Should have no unresolved alert history records since alert never triggered")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCancelPendingStatusAlertsClearsAllAlertsForSystem(t *testing.T) {
|
||||||
|
hub, user := beszelTests.GetHubWithUser(t)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
userSettings, err := hub.FindFirstRecordByFilter("user_settings", "user={:user}", map[string]any{"user": user.Id})
|
||||||
|
require.NoError(t, err)
|
||||||
|
userSettings.Set("settings", `{"emails":["test@example.com"],"webhooks":[]}`)
|
||||||
|
require.NoError(t, hub.Save(userSettings))
|
||||||
|
|
||||||
|
systemCollection, err := hub.FindCollectionByNameOrId("systems")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
system1 := core.NewRecord(systemCollection)
|
||||||
|
system1.Set("name", "system-1")
|
||||||
|
system1.Set("status", "up")
|
||||||
|
system1.Set("host", "127.0.0.1")
|
||||||
|
system1.Set("users", []string{user.Id})
|
||||||
|
require.NoError(t, hub.Save(system1))
|
||||||
|
|
||||||
|
system2 := core.NewRecord(systemCollection)
|
||||||
|
system2.Set("name", "system-2")
|
||||||
|
system2.Set("status", "up")
|
||||||
|
system2.Set("host", "127.0.0.2")
|
||||||
|
system2.Set("users", []string{user.Id})
|
||||||
|
require.NoError(t, hub.Save(system2))
|
||||||
|
|
||||||
|
alertCollection, err := hub.FindCollectionByNameOrId("alerts")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
alert1 := core.NewRecord(alertCollection)
|
||||||
|
alert1.Set("user", user.Id)
|
||||||
|
alert1.Set("system", system1.Id)
|
||||||
|
alert1.Set("name", "Status")
|
||||||
|
alert1.Set("triggered", false)
|
||||||
|
alert1.Set("min", 5)
|
||||||
|
require.NoError(t, hub.Save(alert1))
|
||||||
|
|
||||||
|
alert2 := core.NewRecord(alertCollection)
|
||||||
|
alert2.Set("user", user.Id)
|
||||||
|
alert2.Set("system", system2.Id)
|
||||||
|
alert2.Set("name", "Status")
|
||||||
|
alert2.Set("triggered", false)
|
||||||
|
alert2.Set("min", 5)
|
||||||
|
require.NoError(t, hub.Save(alert2))
|
||||||
|
|
||||||
|
am := alerts.NewTestAlertManagerWithoutWorker(hub)
|
||||||
|
initialEmailCount := hub.TestMailer.TotalSend()
|
||||||
|
|
||||||
|
// Both systems go down
|
||||||
|
require.NoError(t, am.HandleStatusAlerts("down", system1))
|
||||||
|
require.NoError(t, am.HandleStatusAlerts("down", system2))
|
||||||
|
assert.Equal(t, 2, am.GetPendingAlertsCount(), "both systems should have pending alerts")
|
||||||
|
|
||||||
|
// System 1 is paused — cancel its pending alerts
|
||||||
|
am.CancelPendingStatusAlerts(system1.Id)
|
||||||
|
assert.Equal(t, 1, am.GetPendingAlertsCount(), "only system2 alert should remain pending after pausing system1")
|
||||||
|
|
||||||
|
// Expire and process remaining alerts — only system2 should fire
|
||||||
|
am.ForceExpirePendingAlerts()
|
||||||
|
processed, err := am.ProcessPendingAlerts()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, processed, 1, "only the non-paused system's alert should be processed")
|
||||||
|
assert.Equal(t, initialEmailCount+1, hub.TestMailer.TotalSend(), "only system2 should send a down notification")
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,6 +25,30 @@ type UpdateInfo struct {
|
|||||||
Url string `json:"url"`
|
Url string `json:"url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Middleware to allow only admin role users
|
||||||
|
var requireAdminRole = customAuthMiddleware(func(e *core.RequestEvent) bool {
|
||||||
|
return e.Auth.GetString("role") == "admin"
|
||||||
|
})
|
||||||
|
|
||||||
|
// Middleware to exclude readonly users
|
||||||
|
var excludeReadOnlyRole = customAuthMiddleware(func(e *core.RequestEvent) bool {
|
||||||
|
return e.Auth.GetString("role") != "readonly"
|
||||||
|
})
|
||||||
|
|
||||||
|
// customAuthMiddleware handles boilerplate for custom authentication middlewares. fn should
|
||||||
|
// return true if the request is allowed, false otherwise. e.Auth is guaranteed to be non-nil.
|
||||||
|
func customAuthMiddleware(fn func(*core.RequestEvent) bool) func(*core.RequestEvent) error {
|
||||||
|
return func(e *core.RequestEvent) error {
|
||||||
|
if e.Auth == nil {
|
||||||
|
return e.UnauthorizedError("The request requires valid record authorization token.", nil)
|
||||||
|
}
|
||||||
|
if !fn(e) {
|
||||||
|
return e.ForbiddenError("The authorized record is not allowed to perform this action.", nil)
|
||||||
|
}
|
||||||
|
return e.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// registerMiddlewares registers custom middlewares
|
// registerMiddlewares registers custom middlewares
|
||||||
func (h *Hub) registerMiddlewares(se *core.ServeEvent) {
|
func (h *Hub) registerMiddlewares(se *core.ServeEvent) {
|
||||||
// authorizes request with user matching the provided email
|
// authorizes request with user matching the provided email
|
||||||
@@ -33,7 +57,7 @@ func (h *Hub) registerMiddlewares(se *core.ServeEvent) {
|
|||||||
return e.Next()
|
return e.Next()
|
||||||
}
|
}
|
||||||
isAuthRefresh := e.Request.URL.Path == "/api/collections/users/auth-refresh" && e.Request.Method == http.MethodPost
|
isAuthRefresh := e.Request.URL.Path == "/api/collections/users/auth-refresh" && e.Request.Method == http.MethodPost
|
||||||
e.Auth, err = e.App.FindFirstRecordByData("users", "email", email)
|
e.Auth, err = e.App.FindAuthRecordByEmail("users", email)
|
||||||
if err != nil || !isAuthRefresh {
|
if err != nil || !isAuthRefresh {
|
||||||
return e.Next()
|
return e.Next()
|
||||||
}
|
}
|
||||||
@@ -84,19 +108,19 @@ func (h *Hub) registerApiRoutes(se *core.ServeEvent) error {
|
|||||||
// send test notification
|
// send test notification
|
||||||
apiAuth.POST("/test-notification", h.SendTestNotification)
|
apiAuth.POST("/test-notification", h.SendTestNotification)
|
||||||
// heartbeat status and test
|
// heartbeat status and test
|
||||||
apiAuth.GET("/heartbeat-status", h.getHeartbeatStatus)
|
apiAuth.GET("/heartbeat-status", h.getHeartbeatStatus).BindFunc(requireAdminRole)
|
||||||
apiAuth.POST("/test-heartbeat", h.testHeartbeat)
|
apiAuth.POST("/test-heartbeat", h.testHeartbeat).BindFunc(requireAdminRole)
|
||||||
// get config.yml content
|
// get config.yml content
|
||||||
apiAuth.GET("/config-yaml", config.GetYamlConfig)
|
apiAuth.GET("/config-yaml", config.GetYamlConfig).BindFunc(requireAdminRole)
|
||||||
// handle agent websocket connection
|
// handle agent websocket connection
|
||||||
apiNoAuth.GET("/agent-connect", h.handleAgentConnect)
|
apiNoAuth.GET("/agent-connect", h.handleAgentConnect)
|
||||||
// get or create universal tokens
|
// get or create universal tokens
|
||||||
apiAuth.GET("/universal-token", h.getUniversalToken)
|
apiAuth.GET("/universal-token", h.getUniversalToken).BindFunc(excludeReadOnlyRole)
|
||||||
// update / delete user alerts
|
// update / delete user alerts
|
||||||
apiAuth.POST("/user-alerts", alerts.UpsertUserAlerts)
|
apiAuth.POST("/user-alerts", alerts.UpsertUserAlerts)
|
||||||
apiAuth.DELETE("/user-alerts", alerts.DeleteUserAlerts)
|
apiAuth.DELETE("/user-alerts", alerts.DeleteUserAlerts)
|
||||||
// refresh SMART devices for a system
|
// refresh SMART devices for a system
|
||||||
apiAuth.POST("/smart/refresh", h.refreshSmartData)
|
apiAuth.POST("/smart/refresh", h.refreshSmartData).BindFunc(excludeReadOnlyRole)
|
||||||
// get systemd service details
|
// get systemd service details
|
||||||
apiAuth.GET("/systemd/info", h.getSystemdInfo)
|
apiAuth.GET("/systemd/info", h.getSystemdInfo)
|
||||||
// /containers routes
|
// /containers routes
|
||||||
@@ -246,9 +270,6 @@ func (h *Hub) getUniversalToken(e *core.RequestEvent) error {
|
|||||||
|
|
||||||
// getHeartbeatStatus returns current heartbeat configuration and whether it's enabled
|
// getHeartbeatStatus returns current heartbeat configuration and whether it's enabled
|
||||||
func (h *Hub) getHeartbeatStatus(e *core.RequestEvent) error {
|
func (h *Hub) getHeartbeatStatus(e *core.RequestEvent) error {
|
||||||
if e.Auth.GetString("role") != "admin" {
|
|
||||||
return e.ForbiddenError("Requires admin role", nil)
|
|
||||||
}
|
|
||||||
if h.hb == nil {
|
if h.hb == nil {
|
||||||
return e.JSON(http.StatusOK, map[string]any{
|
return e.JSON(http.StatusOK, map[string]any{
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
@@ -266,9 +287,6 @@ func (h *Hub) getHeartbeatStatus(e *core.RequestEvent) error {
|
|||||||
|
|
||||||
// testHeartbeat triggers a single heartbeat ping and returns the result
|
// testHeartbeat triggers a single heartbeat ping and returns the result
|
||||||
func (h *Hub) testHeartbeat(e *core.RequestEvent) error {
|
func (h *Hub) testHeartbeat(e *core.RequestEvent) error {
|
||||||
if e.Auth.GetString("role") != "admin" {
|
|
||||||
return e.ForbiddenError("Requires admin role", nil)
|
|
||||||
}
|
|
||||||
if h.hb == nil {
|
if h.hb == nil {
|
||||||
return e.JSON(http.StatusOK, map[string]any{
|
return e.JSON(http.StatusOK, map[string]any{
|
||||||
"err": "Heartbeat not configured. Set HEARTBEAT_URL environment variable.",
|
"err": "Heartbeat not configured. Set HEARTBEAT_URL environment variable.",
|
||||||
|
|||||||
@@ -34,19 +34,13 @@ func TestApiRoutesAuthentication(t *testing.T) {
|
|||||||
user, err := beszelTests.CreateUser(hub, "testuser@example.com", "password123")
|
user, err := beszelTests.CreateUser(hub, "testuser@example.com", "password123")
|
||||||
require.NoError(t, err, "Failed to create test user")
|
require.NoError(t, err, "Failed to create test user")
|
||||||
|
|
||||||
adminUser, err := beszelTests.CreateRecord(hub, "users", map[string]any{
|
adminUser, err := beszelTests.CreateUserWithRole(hub, "admin@example.com", "password123", "admin")
|
||||||
"email": "admin@example.com",
|
|
||||||
"password": "password123",
|
|
||||||
"role": "admin",
|
|
||||||
})
|
|
||||||
require.NoError(t, err, "Failed to create admin user")
|
require.NoError(t, err, "Failed to create admin user")
|
||||||
adminUserToken, err := adminUser.NewAuthToken()
|
adminUserToken, err := adminUser.NewAuthToken()
|
||||||
|
|
||||||
// superUser, err := beszelTests.CreateRecord(hub, core.CollectionNameSuperusers, map[string]any{
|
readOnlyUser, err := beszelTests.CreateUserWithRole(hub, "readonly@example.com", "password123", "readonly")
|
||||||
// "email": "superuser@example.com",
|
require.NoError(t, err, "Failed to create readonly user")
|
||||||
// "password": "password123",
|
readOnlyUserToken, err := readOnlyUser.NewAuthToken()
|
||||||
// })
|
|
||||||
// require.NoError(t, err, "Failed to create superuser")
|
|
||||||
|
|
||||||
userToken, err := user.NewAuthToken()
|
userToken, err := user.NewAuthToken()
|
||||||
require.NoError(t, err, "Failed to create auth token")
|
require.NoError(t, err, "Failed to create auth token")
|
||||||
@@ -106,7 +100,7 @@ func TestApiRoutesAuthentication(t *testing.T) {
|
|||||||
"Authorization": userToken,
|
"Authorization": userToken,
|
||||||
},
|
},
|
||||||
ExpectedStatus: 403,
|
ExpectedStatus: 403,
|
||||||
ExpectedContent: []string{"Requires admin"},
|
ExpectedContent: []string{"The authorized record is not allowed to perform this action."},
|
||||||
TestAppFactory: testAppFactory,
|
TestAppFactory: testAppFactory,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -136,7 +130,7 @@ func TestApiRoutesAuthentication(t *testing.T) {
|
|||||||
"Authorization": userToken,
|
"Authorization": userToken,
|
||||||
},
|
},
|
||||||
ExpectedStatus: 403,
|
ExpectedStatus: 403,
|
||||||
ExpectedContent: []string{"Requires admin role"},
|
ExpectedContent: []string{"The authorized record is not allowed to perform this action."},
|
||||||
TestAppFactory: testAppFactory,
|
TestAppFactory: testAppFactory,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -158,7 +152,7 @@ func TestApiRoutesAuthentication(t *testing.T) {
|
|||||||
"Authorization": userToken,
|
"Authorization": userToken,
|
||||||
},
|
},
|
||||||
ExpectedStatus: 403,
|
ExpectedStatus: 403,
|
||||||
ExpectedContent: []string{"Requires admin role"},
|
ExpectedContent: []string{"The authorized record is not allowed to perform this action."},
|
||||||
TestAppFactory: testAppFactory,
|
TestAppFactory: testAppFactory,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -202,6 +196,39 @@ func TestApiRoutesAuthentication(t *testing.T) {
|
|||||||
ExpectedContent: []string{"\"permanent\":true", "permanent-token-123"},
|
ExpectedContent: []string{"\"permanent\":true", "permanent-token-123"},
|
||||||
TestAppFactory: testAppFactory,
|
TestAppFactory: testAppFactory,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "GET /universal-token - with readonly auth should fail",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
URL: "/api/beszel/universal-token",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": readOnlyUserToken,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 403,
|
||||||
|
ExpectedContent: []string{"The authorized record is not allowed to perform this action."},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "POST /smart/refresh - missing system should fail 400 with user auth",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/smart/refresh",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": userToken,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 400,
|
||||||
|
ExpectedContent: []string{"system parameter is required"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "POST /smart/refresh - with readonly auth should fail",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/smart/refresh",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": readOnlyUserToken,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 403,
|
||||||
|
ExpectedContent: []string{"The authorized record is not allowed to perform this action."},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Name: "POST /user-alerts - no auth should fail",
|
Name: "POST /user-alerts - no auth should fail",
|
||||||
Method: http.MethodPost,
|
Method: http.MethodPost,
|
||||||
|
|||||||
@@ -279,9 +279,6 @@ func createFingerprintRecord(app core.App, systemID, token string) error {
|
|||||||
|
|
||||||
// Returns the current config.yml file as a JSON object
|
// Returns the current config.yml file as a JSON object
|
||||||
func GetYamlConfig(e *core.RequestEvent) error {
|
func GetYamlConfig(e *core.RequestEvent) error {
|
||||||
if e.Auth.GetString("role") != "admin" {
|
|
||||||
return e.ForbiddenError("Requires admin role", nil)
|
|
||||||
}
|
|
||||||
configContent, err := generateYAML(e.App)
|
configContent, err := generateYAML(e.App)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ func (sys *System) update() error {
|
|||||||
// update smart interval if it's set on the agent side
|
// update smart interval if it's set on the agent side
|
||||||
if data.Details.SmartInterval > 0 {
|
if data.Details.SmartInterval > 0 {
|
||||||
sys.smartInterval = data.Details.SmartInterval
|
sys.smartInterval = data.Details.SmartInterval
|
||||||
|
sys.manager.hub.Logger().Info("SMART interval updated from agent details", "system", sys.Id, "interval", sys.smartInterval.String())
|
||||||
// make sure we reset expiration of lastFetch to remain as long as the new smart interval
|
// make sure we reset expiration of lastFetch to remain as long as the new smart interval
|
||||||
// to prevent premature expiration leading to new fetch if interval is different.
|
// to prevent premature expiration leading to new fetch if interval is different.
|
||||||
sys.manager.smartFetchMap.UpdateExpiration(sys.Id, sys.smartInterval+time.Minute)
|
sys.manager.smartFetchMap.UpdateExpiration(sys.Id, sys.smartInterval+time.Minute)
|
||||||
@@ -156,11 +157,10 @@ func (sys *System) update() error {
|
|||||||
if sys.smartInterval <= 0 {
|
if sys.smartInterval <= 0 {
|
||||||
sys.smartInterval = time.Hour
|
sys.smartInterval = time.Hour
|
||||||
}
|
}
|
||||||
lastFetch, _ := sys.manager.smartFetchMap.GetOk(sys.Id)
|
if sys.shouldFetchSmart() && sys.smartFetching.CompareAndSwap(false, true) {
|
||||||
if time.Since(time.UnixMilli(lastFetch-1e4)) >= sys.smartInterval && sys.smartFetching.CompareAndSwap(false, true) {
|
sys.manager.hub.Logger().Info("SMART fetch", "system", sys.Id, "interval", sys.smartInterval.String())
|
||||||
go func() {
|
go func() {
|
||||||
defer sys.smartFetching.Store(false)
|
defer sys.smartFetching.Store(false)
|
||||||
sys.manager.smartFetchMap.Set(sys.Id, time.Now().UnixMilli(), sys.smartInterval+time.Minute)
|
|
||||||
_ = sys.FetchAndSaveSmartDevices()
|
_ = sys.FetchAndSaveSmartDevices()
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
@@ -643,6 +643,7 @@ func (s *System) createSSHClient() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
s.agentVersion, _ = extractAgentVersion(string(s.client.Conn.ServerVersion()))
|
s.agentVersion, _ = extractAgentVersion(string(s.client.Conn.ServerVersion()))
|
||||||
|
s.manager.resetFailedSmartFetchState(s.Id)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ type SystemManager struct {
|
|||||||
hub hubLike // Hub interface for database and alert operations
|
hub hubLike // Hub interface for database and alert operations
|
||||||
systems *store.Store[string, *System] // Thread-safe store of active systems
|
systems *store.Store[string, *System] // Thread-safe store of active systems
|
||||||
sshConfig *ssh.ClientConfig // SSH client configuration for system connections
|
sshConfig *ssh.ClientConfig // SSH client configuration for system connections
|
||||||
smartFetchMap *expirymap.ExpiryMap[int64] // Stores last SMART fetch time per system ID
|
smartFetchMap *expirymap.ExpiryMap[smartFetchState] // Stores last SMART fetch time/result; TTL is only for cleanup
|
||||||
}
|
}
|
||||||
|
|
||||||
// hubLike defines the interface requirements for the hub dependency.
|
// hubLike defines the interface requirements for the hub dependency.
|
||||||
@@ -54,6 +54,7 @@ type hubLike interface {
|
|||||||
GetSSHKey(dataDir string) (ssh.Signer, error)
|
GetSSHKey(dataDir string) (ssh.Signer, error)
|
||||||
HandleSystemAlerts(systemRecord *core.Record, data *system.CombinedData) error
|
HandleSystemAlerts(systemRecord *core.Record, data *system.CombinedData) error
|
||||||
HandleStatusAlerts(status string, systemRecord *core.Record) error
|
HandleStatusAlerts(status string, systemRecord *core.Record) error
|
||||||
|
CancelPendingStatusAlerts(systemID string)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSystemManager creates a new SystemManager instance with the provided hub.
|
// NewSystemManager creates a new SystemManager instance with the provided hub.
|
||||||
@@ -62,7 +63,7 @@ func NewSystemManager(hub hubLike) *SystemManager {
|
|||||||
return &SystemManager{
|
return &SystemManager{
|
||||||
systems: store.New(map[string]*System{}),
|
systems: store.New(map[string]*System{}),
|
||||||
hub: hub,
|
hub: hub,
|
||||||
smartFetchMap: expirymap.New[int64](time.Hour),
|
smartFetchMap: expirymap.New[smartFetchState](time.Hour),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,6 +190,7 @@ func (sm *SystemManager) onRecordAfterUpdateSuccess(e *core.RecordEvent) error {
|
|||||||
system.closeSSHConnection()
|
system.closeSSHConnection()
|
||||||
}
|
}
|
||||||
_ = deactivateAlerts(e.App, e.Record.Id)
|
_ = deactivateAlerts(e.App, e.Record.Id)
|
||||||
|
sm.hub.CancelPendingStatusAlerts(e.Record.Id)
|
||||||
return e.Next()
|
return e.Next()
|
||||||
case pending:
|
case pending:
|
||||||
// Resume monitoring, preferring existing WebSocket connection
|
// Resume monitoring, preferring existing WebSocket connection
|
||||||
@@ -306,6 +308,7 @@ func (sm *SystemManager) AddWebSocketSystem(systemId string, agentVersion semver
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
sm.resetFailedSmartFetchState(systemId)
|
||||||
|
|
||||||
system := sm.NewSystem(systemId)
|
system := sm.NewSystem(systemId)
|
||||||
system.WsConn = wsConn
|
system.WsConn = wsConn
|
||||||
@@ -317,6 +320,15 @@ func (sm *SystemManager) AddWebSocketSystem(systemId string, agentVersion semver
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resetFailedSmartFetchState clears only failed SMART cooldown entries so a fresh
|
||||||
|
// agent reconnect retries SMART discovery immediately after configuration changes.
|
||||||
|
func (sm *SystemManager) resetFailedSmartFetchState(systemID string) {
|
||||||
|
state, ok := sm.smartFetchMap.GetOk(systemID)
|
||||||
|
if ok && !state.Successful {
|
||||||
|
sm.smartFetchMap.Remove(systemID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// createSSHClientConfig initializes the SSH client configuration for connecting to an agent's server
|
// createSSHClientConfig initializes the SSH client configuration for connecting to an agent's server
|
||||||
func (sm *SystemManager) createSSHClientConfig() error {
|
func (sm *SystemManager) createSSHClientConfig() error {
|
||||||
privateKey, err := sm.hub.GetSSHKey("")
|
privateKey, err := sm.hub.GetSSHKey("")
|
||||||
|
|||||||
@@ -4,18 +4,61 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/smart"
|
"github.com/henrygd/beszel/internal/entities/smart"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type smartFetchState struct {
|
||||||
|
LastAttempt int64
|
||||||
|
Successful bool
|
||||||
|
}
|
||||||
|
|
||||||
// FetchAndSaveSmartDevices fetches SMART data from the agent and saves it to the database
|
// FetchAndSaveSmartDevices fetches SMART data from the agent and saves it to the database
|
||||||
func (sys *System) FetchAndSaveSmartDevices() error {
|
func (sys *System) FetchAndSaveSmartDevices() error {
|
||||||
smartData, err := sys.FetchSmartDataFromAgent()
|
smartData, err := sys.FetchSmartDataFromAgent()
|
||||||
if err != nil || len(smartData) == 0 {
|
if err != nil {
|
||||||
|
sys.recordSmartFetchResult(err, 0)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return sys.saveSmartDevices(smartData)
|
err = sys.saveSmartDevices(smartData)
|
||||||
|
sys.recordSmartFetchResult(err, len(smartData))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordSmartFetchResult stores a cooldown entry for the SMART interval and marks
|
||||||
|
// whether the last fetch produced any devices, so failed setup can retry on reconnect.
|
||||||
|
func (sys *System) recordSmartFetchResult(err error, deviceCount int) {
|
||||||
|
if sys.manager == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
interval := sys.smartFetchInterval()
|
||||||
|
success := err == nil && deviceCount > 0
|
||||||
|
if sys.manager.hub != nil {
|
||||||
|
sys.manager.hub.Logger().Info("SMART fetch result", "system", sys.Id, "success", success, "devices", deviceCount, "interval", interval.String(), "err", err)
|
||||||
|
}
|
||||||
|
sys.manager.smartFetchMap.Set(sys.Id, smartFetchState{LastAttempt: time.Now().UnixMilli(), Successful: success}, interval+time.Minute)
|
||||||
|
}
|
||||||
|
|
||||||
|
// shouldFetchSmart returns true when there is no active SMART cooldown entry for this system.
|
||||||
|
func (sys *System) shouldFetchSmart() bool {
|
||||||
|
if sys.manager == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
state, ok := sys.manager.smartFetchMap.GetOk(sys.Id)
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return !time.UnixMilli(state.LastAttempt).Add(sys.smartFetchInterval()).After(time.Now())
|
||||||
|
}
|
||||||
|
|
||||||
|
// smartFetchInterval returns the agent-provided SMART interval or the default when unset.
|
||||||
|
func (sys *System) smartFetchInterval() time.Duration {
|
||||||
|
if sys.smartInterval > 0 {
|
||||||
|
return sys.smartInterval
|
||||||
|
}
|
||||||
|
return time.Hour
|
||||||
}
|
}
|
||||||
|
|
||||||
// saveSmartDevices saves SMART device data to the smart_devices collection
|
// saveSmartDevices saves SMART device data to the smart_devices collection
|
||||||
|
|||||||
94
internal/hub/systems/system_smart_test.go
Normal file
94
internal/hub/systems/system_smart_test.go
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
//go:build testing
|
||||||
|
|
||||||
|
package systems
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/hub/expirymap"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRecordSmartFetchResult(t *testing.T) {
|
||||||
|
sm := &SystemManager{smartFetchMap: expirymap.New[smartFetchState](time.Hour)}
|
||||||
|
t.Cleanup(sm.smartFetchMap.StopCleaner)
|
||||||
|
|
||||||
|
sys := &System{
|
||||||
|
Id: "system-1",
|
||||||
|
manager: sm,
|
||||||
|
smartInterval: time.Hour,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Successful fetch with devices
|
||||||
|
sys.recordSmartFetchResult(nil, 5)
|
||||||
|
state, ok := sm.smartFetchMap.GetOk(sys.Id)
|
||||||
|
assert.True(t, ok, "expected smart fetch result to be stored")
|
||||||
|
assert.True(t, state.Successful, "expected successful fetch state to be recorded")
|
||||||
|
|
||||||
|
// Failed fetch
|
||||||
|
sys.recordSmartFetchResult(errors.New("failed"), 0)
|
||||||
|
state, ok = sm.smartFetchMap.GetOk(sys.Id)
|
||||||
|
assert.True(t, ok, "expected failed smart fetch state to be stored")
|
||||||
|
assert.False(t, state.Successful, "expected failed smart fetch state to be marked unsuccessful")
|
||||||
|
|
||||||
|
// Successful fetch but no devices
|
||||||
|
sys.recordSmartFetchResult(nil, 0)
|
||||||
|
state, ok = sm.smartFetchMap.GetOk(sys.Id)
|
||||||
|
assert.True(t, ok, "expected fetch with zero devices to be stored")
|
||||||
|
assert.False(t, state.Successful, "expected fetch with zero devices to be marked unsuccessful")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShouldFetchSmart(t *testing.T) {
|
||||||
|
sm := &SystemManager{smartFetchMap: expirymap.New[smartFetchState](time.Hour)}
|
||||||
|
t.Cleanup(sm.smartFetchMap.StopCleaner)
|
||||||
|
|
||||||
|
sys := &System{
|
||||||
|
Id: "system-1",
|
||||||
|
manager: sm,
|
||||||
|
smartInterval: time.Hour,
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.True(t, sys.shouldFetchSmart(), "expected initial smart fetch to be allowed")
|
||||||
|
|
||||||
|
sys.recordSmartFetchResult(errors.New("failed"), 0)
|
||||||
|
assert.False(t, sys.shouldFetchSmart(), "expected smart fetch to be blocked while interval entry exists")
|
||||||
|
|
||||||
|
sm.smartFetchMap.Remove(sys.Id)
|
||||||
|
assert.True(t, sys.shouldFetchSmart(), "expected smart fetch to be allowed after interval entry is cleared")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShouldFetchSmart_IgnoresExtendedTTLWhenFetchIsDue(t *testing.T) {
|
||||||
|
sm := &SystemManager{smartFetchMap: expirymap.New[smartFetchState](time.Hour)}
|
||||||
|
t.Cleanup(sm.smartFetchMap.StopCleaner)
|
||||||
|
|
||||||
|
sys := &System{
|
||||||
|
Id: "system-1",
|
||||||
|
manager: sm,
|
||||||
|
smartInterval: time.Hour,
|
||||||
|
}
|
||||||
|
|
||||||
|
sm.smartFetchMap.Set(sys.Id, smartFetchState{
|
||||||
|
LastAttempt: time.Now().Add(-2 * time.Hour).UnixMilli(),
|
||||||
|
Successful: true,
|
||||||
|
}, 10*time.Minute)
|
||||||
|
sm.smartFetchMap.UpdateExpiration(sys.Id, 3*time.Hour)
|
||||||
|
|
||||||
|
assert.True(t, sys.shouldFetchSmart(), "expected fetch time to take precedence over updated TTL")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResetFailedSmartFetchState(t *testing.T) {
|
||||||
|
sm := &SystemManager{smartFetchMap: expirymap.New[smartFetchState](time.Hour)}
|
||||||
|
t.Cleanup(sm.smartFetchMap.StopCleaner)
|
||||||
|
|
||||||
|
sm.smartFetchMap.Set("system-1", smartFetchState{LastAttempt: time.Now().UnixMilli(), Successful: false}, time.Hour)
|
||||||
|
sm.resetFailedSmartFetchState("system-1")
|
||||||
|
_, ok := sm.smartFetchMap.GetOk("system-1")
|
||||||
|
assert.False(t, ok, "expected failed smart fetch state to be cleared on reconnect")
|
||||||
|
|
||||||
|
sm.smartFetchMap.Set("system-1", smartFetchState{LastAttempt: time.Now().UnixMilli(), Successful: true}, time.Hour)
|
||||||
|
sm.resetFailedSmartFetchState("system-1")
|
||||||
|
_, ok = sm.smartFetchMap.GetOk("system-1")
|
||||||
|
assert.True(t, ok, "expected successful smart fetch state to be preserved")
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="manifest" href="./static/manifest.json" crossorigin="use-credentials" />
|
<link rel="manifest" href="./static/manifest.json" crossorigin="use-credentials" />
|
||||||
<link rel="icon" type="image/svg+xml" href="./static/icon.svg" />
|
<link rel="icon" type="image/svg+xml" href="./static/icon.svg" />
|
||||||
|
<link rel="apple-touch-icon" href="./static/icon.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0,maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0,maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||||
<meta name="robots" content="noindex, nofollow" />
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
<title>Beszel</title>
|
<title>Beszel</title>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "beszel",
|
"name": "beszel",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.18.5",
|
"version": "0.18.6",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host",
|
"dev": "vite --host",
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export default memo(function AlertsButton({ system }: { system: SystemRecord })
|
|||||||
<SheetTrigger asChild>
|
<SheetTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" aria-label={t`Alerts`} data-nolink onClick={() => setOpened(true)}>
|
<Button variant="ghost" size="icon" aria-label={t`Alerts`} data-nolink onClick={() => setOpened(true)}>
|
||||||
<BellIcon
|
<BellIcon
|
||||||
className={cn("h-[1.2em] w-[1.2em] pointer-events-none", {
|
className={cn("size-[1.2em] pointer-events-none", {
|
||||||
"fill-primary": hasSystemAlert,
|
"fill-primary": hasSystemAlert,
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -67,8 +67,8 @@ export default function AreaChartDefault({
|
|||||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||||
const { isIntersecting, ref } = useIntersectionObserver({ freeze: false })
|
const { isIntersecting, ref } = useIntersectionObserver({ freeze: false })
|
||||||
const sourceData = customData ?? chartData.systemStats
|
const sourceData = customData ?? chartData.systemStats
|
||||||
// Only update the rendered data while the chart is visible
|
|
||||||
const [displayData, setDisplayData] = useState(sourceData)
|
const [displayData, setDisplayData] = useState(sourceData)
|
||||||
|
const [displayMaxToggled, setDisplayMaxToggled] = useState(maxToggled)
|
||||||
|
|
||||||
// Reduce chart redraws by only updating while visible or when chart time changes
|
// Reduce chart redraws by only updating while visible or when chart time changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -78,7 +78,10 @@ export default function AreaChartDefault({
|
|||||||
if (shouldUpdate) {
|
if (shouldUpdate) {
|
||||||
setDisplayData(sourceData)
|
setDisplayData(sourceData)
|
||||||
}
|
}
|
||||||
}, [displayData, isIntersecting, sourceData])
|
if (isIntersecting && maxToggled !== displayMaxToggled) {
|
||||||
|
setDisplayMaxToggled(maxToggled)
|
||||||
|
}
|
||||||
|
}, [displayData, displayMaxToggled, isIntersecting, maxToggled, sourceData])
|
||||||
|
|
||||||
// Use a stable key derived from data point identities and visual properties
|
// Use a stable key derived from data point identities and visual properties
|
||||||
const areasKey = dataPoints?.map((d) => `${d.label}:${d.opacity}`).join("\0")
|
const areasKey = dataPoints?.map((d) => `${d.label}:${d.opacity}`).join("\0")
|
||||||
@@ -106,14 +109,14 @@ export default function AreaChartDefault({
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}, [areasKey, maxToggled])
|
}, [areasKey, displayMaxToggled])
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
if (displayData.length === 0) {
|
if (displayData.length === 0) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
// if (logRender) {
|
// if (logRender) {
|
||||||
// console.log("Rendered at", new Date(), "for", dataPoints?.at(0)?.label)
|
// console.log("Rendered", dataPoints?.map((d) => d.label).join(", "), new Date())
|
||||||
// }
|
// }
|
||||||
return (
|
return (
|
||||||
<ChartContainer
|
<ChartContainer
|
||||||
@@ -163,5 +166,5 @@ export default function AreaChartDefault({
|
|||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
)
|
)
|
||||||
}, [displayData, yAxisWidth, showTotal, filter])
|
}, [displayData, yAxisWidth, filter, Areas])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,8 +66,8 @@ export default function LineChartDefault({
|
|||||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||||
const { isIntersecting, ref } = useIntersectionObserver({ freeze: false })
|
const { isIntersecting, ref } = useIntersectionObserver({ freeze: false })
|
||||||
const sourceData = customData ?? chartData.systemStats
|
const sourceData = customData ?? chartData.systemStats
|
||||||
// Only update the rendered data while the chart is visible
|
|
||||||
const [displayData, setDisplayData] = useState(sourceData)
|
const [displayData, setDisplayData] = useState(sourceData)
|
||||||
|
const [displayMaxToggled, setDisplayMaxToggled] = useState(maxToggled)
|
||||||
|
|
||||||
// Reduce chart redraws by only updating while visible or when chart time changes
|
// Reduce chart redraws by only updating while visible or when chart time changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -77,7 +77,10 @@ export default function LineChartDefault({
|
|||||||
if (shouldUpdate) {
|
if (shouldUpdate) {
|
||||||
setDisplayData(sourceData)
|
setDisplayData(sourceData)
|
||||||
}
|
}
|
||||||
}, [displayData, isIntersecting, sourceData])
|
if (isIntersecting && maxToggled !== displayMaxToggled) {
|
||||||
|
setDisplayMaxToggled(maxToggled)
|
||||||
|
}
|
||||||
|
}, [displayData, displayMaxToggled, isIntersecting, maxToggled, sourceData])
|
||||||
|
|
||||||
// Use a stable key derived from data point identities and visual properties
|
// Use a stable key derived from data point identities and visual properties
|
||||||
const linesKey = dataPoints?.map((d) => `${d.label}:${d.strokeOpacity ?? ""}`).join("\0")
|
const linesKey = dataPoints?.map((d) => `${d.label}:${d.strokeOpacity ?? ""}`).join("\0")
|
||||||
@@ -105,14 +108,14 @@ export default function LineChartDefault({
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}, [linesKey, maxToggled])
|
}, [linesKey, displayMaxToggled])
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
if (displayData.length === 0) {
|
if (displayData.length === 0) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
// if (logRender) {
|
// if (logRender) {
|
||||||
// console.log("Rendered at", new Date(), "for", dataPoints?.at(0)?.label)
|
// console.log("Rendered", dataPoints?.map((d) => d.label).join(", "), new Date())
|
||||||
// }
|
// }
|
||||||
return (
|
return (
|
||||||
<ChartContainer
|
<ChartContainer
|
||||||
@@ -162,5 +165,5 @@ export default function LineChartDefault({
|
|||||||
</LineChart>
|
</LineChart>
|
||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
)
|
)
|
||||||
}, [displayData, yAxisWidth, showTotal, filter, chartData.chartTime])
|
}, [displayData, yAxisWidth, filter, Lines])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export default function Navbar() {
|
|||||||
className="p-2 ps-0 me-3 group"
|
className="p-2 ps-0 me-3 group"
|
||||||
onMouseEnter={runOnce(() => import("@/components/routes/home"))}
|
onMouseEnter={runOnce(() => import("@/components/routes/home"))}
|
||||||
>
|
>
|
||||||
<Logo className="h-[1.1rem] md:h-5 fill-foreground" />
|
<Logo className="h-[1.2rem] md:h-5 fill-foreground" />
|
||||||
</Link>
|
</Link>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -125,6 +125,7 @@ export default function Navbar() {
|
|||||||
<DropdownMenuSubContent>{AdminLinks}</DropdownMenuSubContent>
|
<DropdownMenuSubContent>{AdminLinks}</DropdownMenuSubContent>
|
||||||
</DropdownMenuSub>
|
</DropdownMenuSub>
|
||||||
)}
|
)}
|
||||||
|
{!isReadOnlyUser() && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="flex items-center"
|
className="flex items-center"
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
@@ -134,6 +135,7 @@ export default function Navbar() {
|
|||||||
<PlusIcon className="h-4 w-4 me-2.5" />
|
<PlusIcon className="h-4 w-4 me-2.5" />
|
||||||
<Trans>Add {{ foo: systemTranslation }}</Trans>
|
<Trans>Add {{ foo: systemTranslation }}</Trans>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
@@ -217,10 +219,12 @@ export default function Navbar() {
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
{!isReadOnlyUser() && (
|
||||||
<Button variant="outline" className="flex gap-1 ms-2" onClick={() => setAddSystemDialogOpen(true)}>
|
<Button variant="outline" className="flex gap-1 ms-2" onClick={() => setAddSystemDialogOpen(true)}>
|
||||||
<PlusIcon className="h-4 w-4 -ms-1" />
|
<PlusIcon className="h-4 w-4 -ms-1" />
|
||||||
<Trans>Add {{ foo: systemTranslation }}</Trans>
|
<Trans>Add {{ foo: systemTranslation }}</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ import { Input } from "@/components/ui/input"
|
|||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { pb } from "@/lib/api"
|
import { isReadOnlyUser, pb } from "@/lib/api"
|
||||||
import type { SmartDeviceRecord, SmartAttribute } from "@/types"
|
import type { SmartDeviceRecord, SmartAttribute } from "@/types"
|
||||||
import {
|
import {
|
||||||
formatBytes,
|
formatBytes,
|
||||||
@@ -492,7 +492,7 @@ export default function DisksTable({ systemId }: { systemId?: string }) {
|
|||||||
const tableColumns = useMemo(() => {
|
const tableColumns = useMemo(() => {
|
||||||
const columns = createColumns(longestName, longestModel, longestDevice)
|
const columns = createColumns(longestName, longestModel, longestDevice)
|
||||||
const baseColumns = systemId ? columns.filter((col) => col.id !== "system") : columns
|
const baseColumns = systemId ? columns.filter((col) => col.id !== "system") : columns
|
||||||
return [...baseColumns, actionColumn]
|
return isReadOnlyUser() ? baseColumns : [...baseColumns, actionColumn]
|
||||||
}, [systemId, actionColumn, longestName, longestModel, longestDevice])
|
}, [systemId, actionColumn, longestName, longestModel, longestDevice])
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { listenKeys } from "nanostores"
|
|||||||
import { memo, type ReactNode, useEffect, useMemo, useRef, useState } from "react"
|
import { memo, type ReactNode, useEffect, useMemo, useRef, useState } from "react"
|
||||||
import { getStatusColor, systemdTableCols } from "@/components/systemd-table/systemd-table-columns"
|
import { getStatusColor, systemdTableCols } from "@/components/systemd-table/systemd-table-columns"
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||||
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet"
|
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet"
|
||||||
import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
@@ -161,13 +161,13 @@ export default function SystemdTable({ systemId }: { systemId?: string }) {
|
|||||||
<CardTitle className="mb-2">
|
<CardTitle className="mb-2">
|
||||||
<Trans>Systemd Services</Trans>
|
<Trans>Systemd Services</Trans>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="flex items-center">
|
<div className="text-sm text-muted-foreground flex items-center flex-wrap">
|
||||||
<Trans>Total: {data.length}</Trans>
|
<Trans>Total: {data.length}</Trans>
|
||||||
<Separator orientation="vertical" className="h-4 mx-2 bg-primary/40" />
|
<Separator orientation="vertical" className="h-4 mx-2 bg-primary/40" />
|
||||||
<Trans>Failed: {statusTotals[ServiceStatus.Failed]}</Trans>
|
<Trans>Failed: {statusTotals[ServiceStatus.Failed]}</Trans>
|
||||||
<Separator orientation="vertical" className="h-4 mx-2 bg-primary/40" />
|
<Separator orientation="vertical" className="h-4 mx-2 bg-primary/40" />
|
||||||
<Trans>Updated every 10 minutes.</Trans>
|
<Trans>Updated every 10 minutes.</Trans>
|
||||||
</CardDescription>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Input
|
<Input
|
||||||
placeholder={t`Filter...`}
|
placeholder={t`Filter...`}
|
||||||
|
|||||||
@@ -460,14 +460,14 @@ const SystemCard = memo(
|
|||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<CardHeader className="py-1 ps-5 pe-3 bg-muted/30 border-b border-border/60">
|
<CardHeader className="py-1 ps-4 pe-2 bg-muted/30 border-b border-border/60">
|
||||||
<div className="flex items-center gap-2 w-full overflow-hidden">
|
<div className="flex items-center gap-1 w-full overflow-hidden">
|
||||||
<CardTitle className="text-base tracking-normal text-primary/90 flex items-center min-w-0 flex-1 gap-2.5">
|
<h3 className="text-primary/90 min-w-0 flex-1 gap-2.5 font-semibold">
|
||||||
<div className="flex items-center gap-2.5 min-w-0 flex-1">
|
<div className="flex items-center gap-2.5 min-w-0 flex-1">
|
||||||
<IndicatorDot system={system} />
|
<IndicatorDot system={system} />
|
||||||
<span className="text-[.95em]/normal tracking-normal text-primary/90 truncate">{system.name}</span>
|
<span className="text-[.95em]/normal tracking-normal text-primary/90 truncate">{system.name}</span>
|
||||||
</div>
|
</div>
|
||||||
</CardTitle>
|
</h3>
|
||||||
{table.getColumn("actions")?.getIsVisible() && (
|
{table.getColumn("actions")?.getIsVisible() && (
|
||||||
<div className="flex gap-1 shrink-0 relative z-10">
|
<div className="flex gap-1 shrink-0 relative z-10">
|
||||||
<AlertButton system={system} />
|
<AlertButton system={system} />
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ const AlertDialogContent = React.forwardRef<
|
|||||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
||||||
|
|
||||||
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
<div className={cn("grid gap-2 text-center sm:text-start", className)} {...props} />
|
<div className={cn("grid gap-2 text-start", className)} {...props} />
|
||||||
)
|
)
|
||||||
AlertDialogHeader.displayName = "AlertDialogHeader"
|
AlertDialogHeader.displayName = "AlertDialogHeader"
|
||||||
|
|
||||||
|
|||||||
@@ -18,11 +18,7 @@ CardHeader.displayName = "CardHeader"
|
|||||||
|
|
||||||
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||||
({ className, ...props }, ref) => (
|
({ className, ...props }, ref) => (
|
||||||
<h3
|
<h3 ref={ref} className={cn("text-card-title font-semibold leading-none tracking-tight", className)} {...props} />
|
||||||
ref={ref}
|
|
||||||
className={cn("text-[1.4em] sm:text-2xl font-semibold leading-none tracking-tight", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
CardTitle.displayName = "CardTitle"
|
CardTitle.displayName = "CardTitle"
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ const DialogContent = React.forwardRef<
|
|||||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||||
|
|
||||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
<div className={cn("grid gap-1.5 text-center sm:text-start", className)} {...props} />
|
<div className={cn("grid gap-1.5 text-start", className)} {...props} />
|
||||||
)
|
)
|
||||||
DialogHeader.displayName = "DialogHeader"
|
DialogHeader.displayName = "DialogHeader"
|
||||||
|
|
||||||
|
|||||||
@@ -177,6 +177,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@utility text-card-title {
|
||||||
|
@apply text-[1.4rem] sm:text-2xl;
|
||||||
|
}
|
||||||
|
|
||||||
.recharts-tooltip-wrapper {
|
.recharts-tooltip-wrapper {
|
||||||
z-index: 51;
|
z-index: 51;
|
||||||
@apply tabular-nums;
|
@apply tabular-nums;
|
||||||
|
|||||||
@@ -1,3 +1,17 @@
|
|||||||
|
## 0.18.6
|
||||||
|
|
||||||
|
- Add apple-touch-icon link to index.html (#1850)
|
||||||
|
|
||||||
|
- Fix UI bug where charts did not display 1m max until next update
|
||||||
|
|
||||||
|
- Fix regression in partition discovery on Docker (#1847)
|
||||||
|
|
||||||
|
- Fix agent detection of Podman when using socket proxy (#1846)
|
||||||
|
|
||||||
|
- Fix NVML GPU collection being disabled when `nvidia-smi` is not in PATH (#1849)
|
||||||
|
|
||||||
|
- Reset SMART interval on agent reconnect if the agent hasn't collected SMART data, allowing config changes to take effect immediately
|
||||||
|
|
||||||
## 0.18.5
|
## 0.18.5
|
||||||
|
|
||||||
- Add "update available" notification in hub web UI with `CHECK_UPDATES=true` (#1830)
|
- Add "update available" notification in hub web UI with `CHECK_UPDATES=true` (#1830)
|
||||||
|
|||||||
Reference in New Issue
Block a user