mirror of
https://github.com/henrygd/beszel.git
synced 2026-04-02 19:11:50 +02:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0fff699bf6 | ||
|
|
ba10da1b9f | ||
|
|
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"
|
||||
)
|
||||
|
||||
const defaultDataCacheTimeMs uint16 = 60_000
|
||||
|
||||
type Agent struct {
|
||||
sync.Mutex // Used to lock agent while collecting data
|
||||
debug bool // true if LOG_LEVEL is set to debug
|
||||
@@ -36,6 +38,7 @@ type Agent struct {
|
||||
sensorConfig *SensorConfig // Sensors config
|
||||
systemInfo system.Info // Host system info (dynamic)
|
||||
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
|
||||
cache *systemDataCache // Cache for system stats based on cache time
|
||||
connectionManager *ConnectionManager // Channel to signal connection events
|
||||
@@ -97,7 +100,7 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
|
||||
slog.Debug(beszel.Version)
|
||||
|
||||
// initialize docker manager
|
||||
agent.dockerManager = newDockerManager()
|
||||
agent.dockerManager = newDockerManager(agent)
|
||||
|
||||
// initialize system info
|
||||
agent.refreshSystemDetails()
|
||||
@@ -142,7 +145,7 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
|
||||
|
||||
// if debugging, print stats
|
||||
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
|
||||
@@ -164,11 +167,6 @@ func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedD
|
||||
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)
|
||||
|
||||
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
|
||||
if a.systemdManager != nil && cacheTimeMs == 60_000 {
|
||||
if a.systemdManager != nil && cacheTimeMs == defaultDataCacheTimeMs {
|
||||
totalCount := uint16(a.systemdManager.getServiceStatsCount())
|
||||
if totalCount > 0 {
|
||||
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)
|
||||
|
||||
a.cache.Set(data, cacheTimeMs)
|
||||
return data
|
||||
|
||||
return a.attachSystemDetails(data, cacheTimeMs, options.IncludeDetails)
|
||||
}
|
||||
|
||||
// Start initializes and starts the agent with optional WebSocket connection
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -238,9 +239,11 @@ func (d *diskDiscovery) addConfiguredExtraFilesystems(extraFilesystems string) {
|
||||
|
||||
// addPartitionExtraFs registers partitions mounted under /extra-filesystems so
|
||||
// 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) {
|
||||
if !strings.HasPrefix(p.Mountpoint, d.ctx.efPath) {
|
||||
if filepath.Dir(p.Mountpoint) != d.ctx.efPath {
|
||||
return
|
||||
}
|
||||
device, customName := extraFilesystemPartitionInfo(p)
|
||||
@@ -273,7 +276,7 @@ func (a *Agent) initializeDiskInfo() {
|
||||
hasRoot := false
|
||||
isWindows := runtime.GOOS == "windows"
|
||||
|
||||
partitions, err := disk.Partitions(false)
|
||||
partitions, err := disk.PartitionsWithContext(context.Background(), true)
|
||||
if err != nil {
|
||||
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 /
|
||||
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
|
||||
if osReleaseContent, err := os.ReadFile("/etc/os-release"); err == nil {
|
||||
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) {
|
||||
t.Run("matches by device name", func(t *testing.T) {
|
||||
ioCounters := map[string]disk.IOCountersStat{
|
||||
|
||||
164
agent/docker.go
164
agent/docker.go
@@ -25,6 +25,7 @@ import (
|
||||
"github.com/henrygd/beszel/agent/deltatracker"
|
||||
"github.com/henrygd/beszel/agent/utils"
|
||||
"github.com/henrygd/beszel/internal/entities/container"
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
|
||||
"github.com/blang/semver"
|
||||
)
|
||||
@@ -52,20 +53,22 @@ const (
|
||||
)
|
||||
|
||||
type dockerManager struct {
|
||||
client *http.Client // Client to query Docker API
|
||||
wg sync.WaitGroup // WaitGroup to wait for all goroutines to finish
|
||||
sem chan struct{} // Semaphore to limit concurrent container requests
|
||||
containerStatsMutex sync.RWMutex // Mutex to prevent concurrent access to containerStatsMap
|
||||
apiContainerList []*container.ApiInfo // List of containers from Docker API
|
||||
containerStatsMap map[string]*container.Stats // Keeps track of container stats
|
||||
validIds map[string]struct{} // Map of valid container ids, used to prune invalid containers from containerStatsMap
|
||||
goodDockerVersion bool // Whether docker version is at least 25.0.0 (one-shot works correctly)
|
||||
isWindows bool // Whether the Docker Engine API is running on Windows
|
||||
buf *bytes.Buffer // Buffer to store and read response bodies
|
||||
decoder *json.Decoder // Reusable JSON decoder that reads from buf
|
||||
apiStats *container.ApiStats // Reusable API stats object
|
||||
excludeContainers []string // Patterns to exclude containers by name
|
||||
usingPodman bool // Whether the Docker Engine API is running on Podman
|
||||
agent *Agent // Used to propagate system detail changes back to the agent
|
||||
client *http.Client // Client to query Docker API
|
||||
wg sync.WaitGroup // WaitGroup to wait for all goroutines to finish
|
||||
sem chan struct{} // Semaphore to limit concurrent container requests
|
||||
containerStatsMutex sync.RWMutex // Mutex to prevent concurrent access to containerStatsMap
|
||||
apiContainerList []*container.ApiInfo // List of containers from Docker API
|
||||
containerStatsMap map[string]*container.Stats // Keeps track of container stats
|
||||
validIds map[string]struct{} // Map of valid container ids, used to prune invalid containers from containerStatsMap
|
||||
goodDockerVersion bool // Whether docker version is at least 25.0.0 (one-shot works correctly)
|
||||
dockerVersionChecked bool // Whether a version probe has completed successfully
|
||||
isWindows bool // Whether the Docker Engine API is running on Windows
|
||||
buf *bytes.Buffer // Buffer to store and read response bodies
|
||||
decoder *json.Decoder // Reusable JSON decoder that reads from buf
|
||||
apiStats *container.ApiStats // Reusable API stats object
|
||||
excludeContainers []string // Patterns to exclude containers by name
|
||||
usingPodman bool // Whether the Docker Engine API is running on Podman
|
||||
|
||||
// Cache-time-aware tracking for CPU stats (similar to cpu.go)
|
||||
// Maps cache time intervals to container-specific CPU usage tracking
|
||||
@@ -78,7 +81,6 @@ type dockerManager struct {
|
||||
networkSentTrackers 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
|
||||
retrySleep func(time.Duration)
|
||||
}
|
||||
|
||||
// userAgentRoundTripper is a custom http.RoundTripper that adds a User-Agent header to all requests
|
||||
@@ -87,6 +89,14 @@ type userAgentRoundTripper struct {
|
||||
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
|
||||
func (u *userAgentRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
req.Header.Set("User-Agent", u.userAgent)
|
||||
@@ -134,7 +144,14 @@ func (dm *dockerManager) getDockerStats(cacheTimeMs uint16) ([]*container.Stats,
|
||||
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)
|
||||
|
||||
@@ -588,7 +605,7 @@ func (dm *dockerManager) deleteContainerStatsSync(id string) {
|
||||
}
|
||||
|
||||
// Creates a new http client for Docker or Podman API
|
||||
func newDockerManager() *dockerManager {
|
||||
func newDockerManager(agent *Agent) *dockerManager {
|
||||
dockerHost, exists := utils.GetEnv("DOCKER_HOST")
|
||||
if exists {
|
||||
// return nil if set to empty string
|
||||
@@ -654,6 +671,7 @@ func newDockerManager() *dockerManager {
|
||||
}
|
||||
|
||||
manager := &dockerManager{
|
||||
agent: agent,
|
||||
client: &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: userAgentTransport,
|
||||
@@ -671,51 +689,54 @@ func newDockerManager() *dockerManager {
|
||||
networkSentTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
|
||||
networkRecvTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
|
||||
lastNetworkReadTime: make(map[uint16]map[string]time.Time),
|
||||
retrySleep: time.Sleep,
|
||||
}
|
||||
|
||||
// If using podman, return client
|
||||
if strings.Contains(dockerHost, "podman") {
|
||||
manager.usingPodman = true
|
||||
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)
|
||||
// Best-effort startup probe. If the engine is not ready yet, getDockerStats will
|
||||
// retry after the first successful /containers/json request.
|
||||
_, _ = manager.checkDockerVersion()
|
||||
|
||||
return manager
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (dm *dockerManager) checkDockerVersion() {
|
||||
var err error
|
||||
var resp *http.Response
|
||||
var versionInfo struct {
|
||||
Version string `json:"Version"`
|
||||
func (dm *dockerManager) checkDockerVersion() (bool, error) {
|
||||
resp, err := dm.client.Get("http://localhost/version")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
const versionMaxTries = 2
|
||||
for i := 1; i <= versionMaxTries; i++ {
|
||||
resp, err = dm.client.Get("http://localhost/version")
|
||||
if err == nil && resp.StatusCode == http.StatusOK {
|
||||
break
|
||||
}
|
||||
if resp != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
if i < versionMaxTries {
|
||||
slog.Debug("Failed to get Docker version; retrying", "attempt", i, "err", err, "response", resp)
|
||||
dm.retrySleep(5 * time.Second)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
status := resp.Status
|
||||
resp.Body.Close()
|
||||
return false, fmt.Errorf("docker version request failed: %s", status)
|
||||
}
|
||||
if err != nil || resp.StatusCode != http.StatusOK {
|
||||
|
||||
var versionInfo dockerVersionResponse
|
||||
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
|
||||
}
|
||||
|
||||
// ensureDockerVersionChecked retries the version probe after a successful
|
||||
// container list request.
|
||||
func (dm *dockerManager) ensureDockerVersionChecked() {
|
||||
if dm.dockerVersionChecked {
|
||||
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
|
||||
}
|
||||
// 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 {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -539,59 +539,53 @@ func TestDockerManagerCreation(t *testing.T) {
|
||||
|
||||
func TestCheckDockerVersion(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
responses []struct {
|
||||
statusCode int
|
||||
body string
|
||||
}
|
||||
expectedGood bool
|
||||
expectedRequests int
|
||||
name string
|
||||
statusCode int
|
||||
body string
|
||||
server string
|
||||
expectSuccess bool
|
||||
expectedGood bool
|
||||
expectedPodman bool
|
||||
expectError bool
|
||||
expectedRequest string
|
||||
}{
|
||||
{
|
||||
name: "200 with good version on first try",
|
||||
responses: []struct {
|
||||
statusCode int
|
||||
body string
|
||||
}{
|
||||
{http.StatusOK, `{"Version":"25.0.1"}`},
|
||||
},
|
||||
expectedGood: true,
|
||||
expectedRequests: 1,
|
||||
name: "good docker version",
|
||||
statusCode: http.StatusOK,
|
||||
body: `{"Version":"25.0.1"}`,
|
||||
expectSuccess: true,
|
||||
expectedGood: true,
|
||||
expectedPodman: false,
|
||||
expectedRequest: "/version",
|
||||
},
|
||||
{
|
||||
name: "200 with old version on first try",
|
||||
responses: []struct {
|
||||
statusCode int
|
||||
body string
|
||||
}{
|
||||
{http.StatusOK, `{"Version":"24.0.7"}`},
|
||||
},
|
||||
expectedGood: false,
|
||||
expectedRequests: 1,
|
||||
name: "old docker version",
|
||||
statusCode: http.StatusOK,
|
||||
body: `{"Version":"24.0.7"}`,
|
||||
expectSuccess: true,
|
||||
expectedGood: false,
|
||||
expectedPodman: false,
|
||||
expectedRequest: "/version",
|
||||
},
|
||||
{
|
||||
name: "non-200 then 200 with good version",
|
||||
responses: []struct {
|
||||
statusCode int
|
||||
body string
|
||||
}{
|
||||
{http.StatusServiceUnavailable, `"not ready"`},
|
||||
{http.StatusOK, `{"Version":"25.1.0"}`},
|
||||
},
|
||||
expectedGood: true,
|
||||
expectedRequests: 2,
|
||||
name: "podman from server header",
|
||||
statusCode: http.StatusOK,
|
||||
body: `{"Version":"5.5.0"}`,
|
||||
server: "Libpod/5.5.0",
|
||||
expectSuccess: true,
|
||||
expectedGood: true,
|
||||
expectedPodman: true,
|
||||
expectedRequest: "/version",
|
||||
},
|
||||
{
|
||||
name: "non-200 on all retries",
|
||||
responses: []struct {
|
||||
statusCode int
|
||||
body string
|
||||
}{
|
||||
{http.StatusInternalServerError, `"error"`},
|
||||
{http.StatusUnauthorized, `"error"`},
|
||||
},
|
||||
expectedGood: false,
|
||||
expectedRequests: 2,
|
||||
name: "non-200 response",
|
||||
statusCode: http.StatusServiceUnavailable,
|
||||
body: `"not ready"`,
|
||||
expectSuccess: false,
|
||||
expectedGood: false,
|
||||
expectedPodman: false,
|
||||
expectError: true,
|
||||
expectedRequest: "/version",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -599,13 +593,13 @@ func TestCheckDockerVersion(t *testing.T) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
requestCount := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
idx := requestCount
|
||||
requestCount++
|
||||
if idx >= len(tt.responses) {
|
||||
idx = len(tt.responses) - 1
|
||||
assert.Equal(t, tt.expectedRequest, r.URL.EscapedPath())
|
||||
if tt.server != "" {
|
||||
w.Header().Set("Server", tt.server)
|
||||
}
|
||||
w.WriteHeader(tt.responses[idx].statusCode)
|
||||
fmt.Fprint(w, tt.responses[idx].body)
|
||||
w.WriteHeader(tt.statusCode)
|
||||
fmt.Fprint(w, tt.body)
|
||||
}))
|
||||
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.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
|
||||
dm := &dockerManager{
|
||||
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.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) {
|
||||
dm := &dockerManager{
|
||||
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{
|
||||
collectorSourceNVML: {
|
||||
group: collectorGroupNvidia,
|
||||
available: caps.hasNvidiaSmi,
|
||||
available: true,
|
||||
start: func(_ func()) bool {
|
||||
return gm.startNvmlCollector()
|
||||
},
|
||||
@@ -734,9 +734,6 @@ func NewGPUManager() (*GPUManager, error) {
|
||||
}
|
||||
var gm GPUManager
|
||||
caps := gm.discoverGpuCapabilities()
|
||||
if !hasAnyGpuCollector(caps) {
|
||||
return nil, fmt.Errorf(noGPUFoundMsg)
|
||||
}
|
||||
gm.GpuDataMap = make(map[string]*system.GPUData)
|
||||
|
||||
// Jetson devices should always use tegrastats (ignore GPU_COLLECTOR).
|
||||
@@ -745,7 +742,7 @@ func NewGPUManager() (*GPUManager, error) {
|
||||
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) != "" {
|
||||
priorities := parseCollectorPriority(collectorConfig)
|
||||
if gm.startCollectorsByPriority(priorities, caps) == 0 {
|
||||
@@ -754,6 +751,10 @@ func NewGPUManager() (*GPUManager, error) {
|
||||
return &gm, nil
|
||||
}
|
||||
|
||||
if !hasAnyGpuCollector(caps) {
|
||||
return nil, fmt.Errorf(noGPUFoundMsg)
|
||||
}
|
||||
|
||||
// auto-detect and start collectors when GPU_COLLECTOR is unset.
|
||||
if gm.startCollectorsByPriority(gm.resolveLegacyCollectorPriority(caps), caps) == 0 {
|
||||
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) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("PATH", dir)
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="LibreHardwareMonitorLib" Version="0.9.5" />
|
||||
<PackageReference Include="LibreHardwareMonitorLib" Version="0.9.6" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -26,6 +26,7 @@ type SensorConfig struct {
|
||||
isBlacklist bool
|
||||
hasWildcards bool
|
||||
skipCollection bool
|
||||
firstRun bool
|
||||
}
|
||||
|
||||
func (a *Agent) newSensorConfig() *SensorConfig {
|
||||
@@ -52,6 +53,7 @@ func (a *Agent) newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal
|
||||
context: context.Background(),
|
||||
primarySensor: primarySensor,
|
||||
skipCollection: skipCollection,
|
||||
firstRun: true,
|
||||
sensors: make(map[string]struct{}),
|
||||
}
|
||||
|
||||
@@ -167,6 +169,14 @@ func (a *Agent) getTempsWithTimeout(getTemps getTempsFn) ([]sensors.TemperatureS
|
||||
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)
|
||||
go func() {
|
||||
temps, err := a.getTempsWithPanicRecovery(getTemps)
|
||||
@@ -176,7 +186,7 @@ func (a *Agent) getTempsWithTimeout(getTemps getTempsFn) ([]sensors.TemperatureS
|
||||
select {
|
||||
case res := <-resultCh:
|
||||
return res.temps, res.err
|
||||
case <-time.After(temperatureFetchTimeout):
|
||||
case <-time.After(timeout):
|
||||
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
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
func (a *Agent) getSystemStats(cacheTimeMs uint16) 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 (
|
||||
// Version is the current version of the application.
|
||||
Version = "0.18.5"
|
||||
Version = "0.18.6"
|
||||
// AppName is the name of the application.
|
||||
AppName = "beszel"
|
||||
)
|
||||
|
||||
@@ -109,6 +109,18 @@ func (am *AlertManager) cancelPendingAlert(alertID string) bool {
|
||||
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.
|
||||
func (am *AlertManager) processPendingAlert(alertID string) {
|
||||
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")
|
||||
})
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package hub
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -25,6 +26,32 @@ type UpdateInfo struct {
|
||||
Url string `json:"url"`
|
||||
}
|
||||
|
||||
var containerIDPattern = regexp.MustCompile(`^[a-fA-F0-9]{12,64}$`)
|
||||
|
||||
// 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
|
||||
func (h *Hub) registerMiddlewares(se *core.ServeEvent) {
|
||||
// authorizes request with user matching the provided email
|
||||
@@ -33,7 +60,7 @@ func (h *Hub) registerMiddlewares(se *core.ServeEvent) {
|
||||
return e.Next()
|
||||
}
|
||||
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 {
|
||||
return e.Next()
|
||||
}
|
||||
@@ -84,19 +111,19 @@ func (h *Hub) registerApiRoutes(se *core.ServeEvent) error {
|
||||
// send test notification
|
||||
apiAuth.POST("/test-notification", h.SendTestNotification)
|
||||
// heartbeat status and test
|
||||
apiAuth.GET("/heartbeat-status", h.getHeartbeatStatus)
|
||||
apiAuth.POST("/test-heartbeat", h.testHeartbeat)
|
||||
apiAuth.GET("/heartbeat-status", h.getHeartbeatStatus).BindFunc(requireAdminRole)
|
||||
apiAuth.POST("/test-heartbeat", h.testHeartbeat).BindFunc(requireAdminRole)
|
||||
// get config.yml content
|
||||
apiAuth.GET("/config-yaml", config.GetYamlConfig)
|
||||
apiAuth.GET("/config-yaml", config.GetYamlConfig).BindFunc(requireAdminRole)
|
||||
// handle agent websocket connection
|
||||
apiNoAuth.GET("/agent-connect", h.handleAgentConnect)
|
||||
// get or create universal tokens
|
||||
apiAuth.GET("/universal-token", h.getUniversalToken)
|
||||
apiAuth.GET("/universal-token", h.getUniversalToken).BindFunc(excludeReadOnlyRole)
|
||||
// update / delete user alerts
|
||||
apiAuth.POST("/user-alerts", alerts.UpsertUserAlerts)
|
||||
apiAuth.DELETE("/user-alerts", alerts.DeleteUserAlerts)
|
||||
// refresh SMART devices for a system
|
||||
apiAuth.POST("/smart/refresh", h.refreshSmartData)
|
||||
apiAuth.POST("/smart/refresh", h.refreshSmartData).BindFunc(excludeReadOnlyRole)
|
||||
// get systemd service details
|
||||
apiAuth.GET("/systemd/info", h.getSystemdInfo)
|
||||
// /containers routes
|
||||
@@ -153,6 +180,10 @@ func (info *UpdateInfo) getUpdate(e *core.RequestEvent) error {
|
||||
|
||||
// GetUniversalToken handles the universal token API endpoint (create, read, delete)
|
||||
func (h *Hub) getUniversalToken(e *core.RequestEvent) error {
|
||||
if e.Auth.IsSuperuser() {
|
||||
return e.ForbiddenError("Superusers cannot use universal tokens", nil)
|
||||
}
|
||||
|
||||
tokenMap := universalTokenMap.GetMap()
|
||||
userID := e.Auth.Id
|
||||
query := e.Request.URL.Query()
|
||||
@@ -246,9 +277,6 @@ func (h *Hub) getUniversalToken(e *core.RequestEvent) error {
|
||||
|
||||
// getHeartbeatStatus returns current heartbeat configuration and whether it's enabled
|
||||
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 {
|
||||
return e.JSON(http.StatusOK, map[string]any{
|
||||
"enabled": false,
|
||||
@@ -266,9 +294,6 @@ func (h *Hub) getHeartbeatStatus(e *core.RequestEvent) error {
|
||||
|
||||
// testHeartbeat triggers a single heartbeat ping and returns the result
|
||||
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 {
|
||||
return e.JSON(http.StatusOK, map[string]any{
|
||||
"err": "Heartbeat not configured. Set HEARTBEAT_URL environment variable.",
|
||||
@@ -285,21 +310,18 @@ func (h *Hub) containerRequestHandler(e *core.RequestEvent, fetchFunc func(*syst
|
||||
systemID := e.Request.URL.Query().Get("system")
|
||||
containerID := e.Request.URL.Query().Get("container")
|
||||
|
||||
if systemID == "" || containerID == "" {
|
||||
return e.JSON(http.StatusBadRequest, map[string]string{"error": "system and container parameters are required"})
|
||||
}
|
||||
if !containerIDPattern.MatchString(containerID) {
|
||||
return e.JSON(http.StatusBadRequest, map[string]string{"error": "invalid container parameter"})
|
||||
if systemID == "" || containerID == "" || !containerIDPattern.MatchString(containerID) {
|
||||
return e.BadRequestError("Invalid system or container parameter", nil)
|
||||
}
|
||||
|
||||
system, err := h.sm.GetSystem(systemID)
|
||||
if err != nil {
|
||||
return e.JSON(http.StatusNotFound, map[string]string{"error": "system not found"})
|
||||
if err != nil || !system.HasUser(e.App, e.Auth.Id) {
|
||||
return e.NotFoundError("", nil)
|
||||
}
|
||||
|
||||
data, err := fetchFunc(system, containerID)
|
||||
if err != nil {
|
||||
return e.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
||||
return e.InternalServerError("", err)
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, map[string]string{responseKey: data})
|
||||
@@ -325,15 +347,23 @@ func (h *Hub) getSystemdInfo(e *core.RequestEvent) error {
|
||||
serviceName := query.Get("service")
|
||||
|
||||
if systemID == "" || serviceName == "" {
|
||||
return e.JSON(http.StatusBadRequest, map[string]string{"error": "system and service parameters are required"})
|
||||
return e.BadRequestError("Invalid system or service parameter", nil)
|
||||
}
|
||||
system, err := h.sm.GetSystem(systemID)
|
||||
if err != nil || !system.HasUser(e.App, e.Auth.Id) {
|
||||
return e.NotFoundError("", nil)
|
||||
}
|
||||
// verify service exists before fetching details
|
||||
_, err = e.App.FindFirstRecordByFilter("systemd_services", "system = {:system} && name = {:name}", dbx.Params{
|
||||
"system": systemID,
|
||||
"name": serviceName,
|
||||
})
|
||||
if err != nil {
|
||||
return e.JSON(http.StatusNotFound, map[string]string{"error": "system not found"})
|
||||
return e.NotFoundError("", err)
|
||||
}
|
||||
details, err := system.FetchSystemdInfoFromAgent(serviceName)
|
||||
if err != nil {
|
||||
return e.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
||||
return e.InternalServerError("", err)
|
||||
}
|
||||
e.Response.Header().Set("Cache-Control", "public, max-age=60")
|
||||
return e.JSON(http.StatusOK, map[string]any{"details": details})
|
||||
@@ -344,17 +374,16 @@ func (h *Hub) getSystemdInfo(e *core.RequestEvent) error {
|
||||
func (h *Hub) refreshSmartData(e *core.RequestEvent) error {
|
||||
systemID := e.Request.URL.Query().Get("system")
|
||||
if systemID == "" {
|
||||
return e.JSON(http.StatusBadRequest, map[string]string{"error": "system parameter is required"})
|
||||
return e.BadRequestError("Invalid system parameter", nil)
|
||||
}
|
||||
|
||||
system, err := h.sm.GetSystem(systemID)
|
||||
if err != nil {
|
||||
return e.JSON(http.StatusNotFound, map[string]string{"error": "system not found"})
|
||||
if err != nil || !system.HasUser(e.App, e.Auth.Id) {
|
||||
return e.NotFoundError("", nil)
|
||||
}
|
||||
|
||||
// Fetch and save SMART devices
|
||||
if err := system.FetchAndSaveSmartDevices(); err != nil {
|
||||
return e.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return e.InternalServerError("", err)
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, map[string]string{"status": "ok"})
|
||||
|
||||
@@ -3,6 +3,7 @@ package hub_test
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
@@ -25,33 +26,33 @@ func jsonReader(v any) io.Reader {
|
||||
}
|
||||
|
||||
func TestApiRoutesAuthentication(t *testing.T) {
|
||||
hub, _ := beszelTests.NewTestHub(t.TempDir())
|
||||
hub, user := beszelTests.GetHubWithUser(t)
|
||||
defer hub.Cleanup()
|
||||
|
||||
hub.StartHub()
|
||||
|
||||
// Create test user and get auth token
|
||||
user, err := beszelTests.CreateUser(hub, "testuser@example.com", "password123")
|
||||
require.NoError(t, err, "Failed to create test user")
|
||||
|
||||
adminUser, err := beszelTests.CreateRecord(hub, "users", map[string]any{
|
||||
"email": "admin@example.com",
|
||||
"password": "password123",
|
||||
"role": "admin",
|
||||
})
|
||||
require.NoError(t, err, "Failed to create admin user")
|
||||
adminUserToken, err := adminUser.NewAuthToken()
|
||||
|
||||
// superUser, err := beszelTests.CreateRecord(hub, core.CollectionNameSuperusers, map[string]any{
|
||||
// "email": "superuser@example.com",
|
||||
// "password": "password123",
|
||||
// })
|
||||
// require.NoError(t, err, "Failed to create superuser")
|
||||
|
||||
userToken, err := user.NewAuthToken()
|
||||
require.NoError(t, err, "Failed to create auth token")
|
||||
|
||||
// Create test system for user-alerts endpoints
|
||||
// Create test user and get auth token
|
||||
user2, err := beszelTests.CreateUser(hub, "testuser@example.com", "password123")
|
||||
require.NoError(t, err, "Failed to create test user")
|
||||
user2Token, err := user2.NewAuthToken()
|
||||
require.NoError(t, err, "Failed to create user2 auth token")
|
||||
|
||||
adminUser, err := beszelTests.CreateUserWithRole(hub, "admin@example.com", "password123", "admin")
|
||||
require.NoError(t, err, "Failed to create admin user")
|
||||
adminUserToken, err := adminUser.NewAuthToken()
|
||||
|
||||
readOnlyUser, err := beszelTests.CreateUserWithRole(hub, "readonly@example.com", "password123", "readonly")
|
||||
require.NoError(t, err, "Failed to create readonly user")
|
||||
readOnlyUserToken, err := readOnlyUser.NewAuthToken()
|
||||
require.NoError(t, err, "Failed to create readonly user auth token")
|
||||
|
||||
superuser, err := beszelTests.CreateSuperuser(hub, "superuser@example.com", "password123")
|
||||
require.NoError(t, err, "Failed to create superuser")
|
||||
superuserToken, err := superuser.NewAuthToken()
|
||||
require.NoError(t, err, "Failed to create superuser auth token")
|
||||
|
||||
// Create test system
|
||||
system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{
|
||||
"name": "test-system",
|
||||
"users": []string{user.Id},
|
||||
@@ -106,7 +107,7 @@ func TestApiRoutesAuthentication(t *testing.T) {
|
||||
"Authorization": userToken,
|
||||
},
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{"Requires admin"},
|
||||
ExpectedContent: []string{"The authorized record is not allowed to perform this action."},
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
{
|
||||
@@ -136,7 +137,7 @@ func TestApiRoutesAuthentication(t *testing.T) {
|
||||
"Authorization": userToken,
|
||||
},
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{"Requires admin role"},
|
||||
ExpectedContent: []string{"The authorized record is not allowed to perform this action."},
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
{
|
||||
@@ -158,7 +159,7 @@ func TestApiRoutesAuthentication(t *testing.T) {
|
||||
"Authorization": userToken,
|
||||
},
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{"Requires admin role"},
|
||||
ExpectedContent: []string{"The authorized record is not allowed to perform this action."},
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
{
|
||||
@@ -202,6 +203,74 @@ func TestApiRoutesAuthentication(t *testing.T) {
|
||||
ExpectedContent: []string{"\"permanent\":true", "permanent-token-123"},
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
{
|
||||
Name: "GET /universal-token - superuser should fail",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/beszel/universal-token",
|
||||
Headers: map[string]string{
|
||||
"Authorization": superuserToken,
|
||||
},
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{"Superusers cannot use universal tokens"},
|
||||
TestAppFactory: func(t testing.TB) *pbTests.TestApp {
|
||||
return hub.TestApp
|
||||
},
|
||||
},
|
||||
{
|
||||
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{"Invalid", "system", "parameter"},
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
{
|
||||
Name: "POST /smart/refresh - with readonly auth should fail",
|
||||
Method: http.MethodPost,
|
||||
URL: fmt.Sprintf("/api/beszel/smart/refresh?system=%s", system.Id),
|
||||
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 - non-user system should fail",
|
||||
Method: http.MethodPost,
|
||||
URL: fmt.Sprintf("/api/beszel/smart/refresh?system=%s", system.Id),
|
||||
Headers: map[string]string{
|
||||
"Authorization": user2Token,
|
||||
},
|
||||
ExpectedStatus: 404,
|
||||
ExpectedContent: []string{"The requested resource wasn't found."},
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
{
|
||||
Name: "POST /smart/refresh - good user should pass validation",
|
||||
Method: http.MethodPost,
|
||||
URL: fmt.Sprintf("/api/beszel/smart/refresh?system=%s", system.Id),
|
||||
Headers: map[string]string{
|
||||
"Authorization": userToken,
|
||||
},
|
||||
ExpectedStatus: 500,
|
||||
ExpectedContent: []string{"Something went wrong while processing your request."},
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
{
|
||||
Name: "POST /user-alerts - no auth should fail",
|
||||
Method: http.MethodPost,
|
||||
@@ -273,20 +342,42 @@ func TestApiRoutesAuthentication(t *testing.T) {
|
||||
{
|
||||
Name: "GET /containers/logs - no auth should fail",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/beszel/containers/logs?system=test-system&container=test-container",
|
||||
URL: "/api/beszel/containers/logs?system=test-system&container=abababababab",
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{"requires valid"},
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
{
|
||||
Name: "GET /containers/logs - request for valid non-user system should fail",
|
||||
Method: http.MethodGet,
|
||||
URL: fmt.Sprintf("/api/beszel/containers/logs?system=%s&container=abababababab", system.Id),
|
||||
ExpectedStatus: 404,
|
||||
ExpectedContent: []string{"The requested resource wasn't found."},
|
||||
TestAppFactory: testAppFactory,
|
||||
Headers: map[string]string{
|
||||
"Authorization": user2Token,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "GET /containers/info - request for valid non-user system should fail",
|
||||
Method: http.MethodGet,
|
||||
URL: fmt.Sprintf("/api/beszel/containers/info?system=%s&container=abababababab", system.Id),
|
||||
ExpectedStatus: 404,
|
||||
ExpectedContent: []string{"The requested resource wasn't found."},
|
||||
TestAppFactory: testAppFactory,
|
||||
Headers: map[string]string{
|
||||
"Authorization": user2Token,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "GET /containers/logs - with auth but missing system param should fail",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/beszel/containers/logs?container=test-container",
|
||||
URL: "/api/beszel/containers/logs?container=abababababab",
|
||||
Headers: map[string]string{
|
||||
"Authorization": userToken,
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{"system and container parameters are required"},
|
||||
ExpectedContent: []string{"Invalid", "parameter"},
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
{
|
||||
@@ -297,7 +388,7 @@ func TestApiRoutesAuthentication(t *testing.T) {
|
||||
"Authorization": userToken,
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{"system and container parameters are required"},
|
||||
ExpectedContent: []string{"Invalid", "parameter"},
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
{
|
||||
@@ -308,7 +399,7 @@ func TestApiRoutesAuthentication(t *testing.T) {
|
||||
"Authorization": userToken,
|
||||
},
|
||||
ExpectedStatus: 404,
|
||||
ExpectedContent: []string{"system not found"},
|
||||
ExpectedContent: []string{"The requested resource wasn't found."},
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
{
|
||||
@@ -319,7 +410,7 @@ func TestApiRoutesAuthentication(t *testing.T) {
|
||||
"Authorization": userToken,
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{"invalid container parameter"},
|
||||
ExpectedContent: []string{"Invalid", "parameter"},
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
{
|
||||
@@ -330,7 +421,7 @@ func TestApiRoutesAuthentication(t *testing.T) {
|
||||
"Authorization": userToken,
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{"invalid container parameter"},
|
||||
ExpectedContent: []string{"Invalid", "parameter"},
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
{
|
||||
@@ -341,9 +432,114 @@ func TestApiRoutesAuthentication(t *testing.T) {
|
||||
"Authorization": userToken,
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{"invalid container parameter"},
|
||||
ExpectedContent: []string{"Invalid", "parameter"},
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
{
|
||||
Name: "GET /containers/logs - good user should pass validation",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/beszel/containers/logs?system=" + system.Id + "&container=0123456789ab",
|
||||
Headers: map[string]string{
|
||||
"Authorization": userToken,
|
||||
},
|
||||
ExpectedStatus: 500,
|
||||
ExpectedContent: []string{"Something went wrong while processing your request."},
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
{
|
||||
Name: "GET /containers/info - good user should pass validation",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/beszel/containers/info?system=" + system.Id + "&container=0123456789ab",
|
||||
Headers: map[string]string{
|
||||
"Authorization": userToken,
|
||||
},
|
||||
ExpectedStatus: 500,
|
||||
ExpectedContent: []string{"Something went wrong while processing your request."},
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
// /systemd routes
|
||||
{
|
||||
Name: "GET /systemd/info - no auth should fail",
|
||||
Method: http.MethodGet,
|
||||
URL: fmt.Sprintf("/api/beszel/systemd/info?system=%s&service=nginx.service", system.Id),
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{"requires valid"},
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
{
|
||||
Name: "GET /systemd/info - request for valid non-user system should fail",
|
||||
Method: http.MethodGet,
|
||||
URL: fmt.Sprintf("/api/beszel/systemd/info?system=%s&service=nginx.service", system.Id),
|
||||
ExpectedStatus: 404,
|
||||
ExpectedContent: []string{"The requested resource wasn't found."},
|
||||
TestAppFactory: testAppFactory,
|
||||
Headers: map[string]string{
|
||||
"Authorization": user2Token,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "GET /systemd/info - with auth but missing system param should fail",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/beszel/systemd/info?service=nginx.service",
|
||||
Headers: map[string]string{
|
||||
"Authorization": userToken,
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{"Invalid", "parameter"},
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
{
|
||||
Name: "GET /systemd/info - with auth but missing service param should fail",
|
||||
Method: http.MethodGet,
|
||||
URL: fmt.Sprintf("/api/beszel/systemd/info?system=%s", system.Id),
|
||||
Headers: map[string]string{
|
||||
"Authorization": userToken,
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{"Invalid", "parameter"},
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
{
|
||||
Name: "GET /systemd/info - with auth but invalid system should fail",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/beszel/systemd/info?system=invalid-system&service=nginx.service",
|
||||
Headers: map[string]string{
|
||||
"Authorization": userToken,
|
||||
},
|
||||
ExpectedStatus: 404,
|
||||
ExpectedContent: []string{"The requested resource wasn't found."},
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
{
|
||||
Name: "GET /systemd/info - service not in systemd_services collection should fail",
|
||||
Method: http.MethodGet,
|
||||
URL: fmt.Sprintf("/api/beszel/systemd/info?system=%s&service=notregistered.service", system.Id),
|
||||
Headers: map[string]string{
|
||||
"Authorization": userToken,
|
||||
},
|
||||
ExpectedStatus: 404,
|
||||
ExpectedContent: []string{"The requested resource wasn't found."},
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
{
|
||||
Name: "GET /systemd/info - with auth and existing service record should pass validation",
|
||||
Method: http.MethodGet,
|
||||
URL: fmt.Sprintf("/api/beszel/systemd/info?system=%s&service=nginx.service", system.Id),
|
||||
Headers: map[string]string{
|
||||
"Authorization": userToken,
|
||||
},
|
||||
ExpectedStatus: 500,
|
||||
ExpectedContent: []string{"Something went wrong while processing your request."},
|
||||
TestAppFactory: testAppFactory,
|
||||
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||
beszelTests.CreateRecord(app, "systemd_services", map[string]any{
|
||||
"system": system.Id,
|
||||
"name": "nginx.service",
|
||||
"state": 0,
|
||||
"sub": 1,
|
||||
})
|
||||
},
|
||||
},
|
||||
|
||||
// Auth Optional Routes - Should work without authentication
|
||||
{
|
||||
|
||||
@@ -279,9 +279,6 @@ func createFingerprintRecord(app core.App, systemID, token string) error {
|
||||
|
||||
// Returns the current config.yml file as a JSON object
|
||||
func GetYamlConfig(e *core.RequestEvent) error {
|
||||
if e.Auth.GetString("role") != "admin" {
|
||||
return e.ForbiddenError("Requires admin role", nil)
|
||||
}
|
||||
configContent, err := generateYAML(e.App)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/henrygd/beszel/internal/alerts"
|
||||
@@ -38,8 +37,6 @@ type Hub struct {
|
||||
appURL string
|
||||
}
|
||||
|
||||
var containerIDPattern = regexp.MustCompile(`^[a-fA-F0-9]{12,64}$`)
|
||||
|
||||
// NewHub creates a new Hub instance with default configuration
|
||||
func NewHub(app core.App) *Hub {
|
||||
hub := &Hub{App: app}
|
||||
|
||||
@@ -5,7 +5,6 @@ package hub
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
@@ -62,7 +61,6 @@ func (rm *responseModifier) modifyHTML(html string) string {
|
||||
|
||||
// startServer sets up the development server for Beszel
|
||||
func (h *Hub) startServer(se *core.ServeEvent) error {
|
||||
slog.Info("starting server", "appURL", h.appURL)
|
||||
proxy := httputil.NewSingleHostReverseProxy(&url.URL{
|
||||
Scheme: "http",
|
||||
Host: "localhost:5173",
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"hash/fnv"
|
||||
"math/rand"
|
||||
"net"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
@@ -145,6 +146,7 @@ func (sys *System) update() error {
|
||||
// update smart interval if it's set on the agent side
|
||||
if data.Details.SmartInterval > 0 {
|
||||
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
|
||||
// to prevent premature expiration leading to new fetch if interval is different.
|
||||
sys.manager.smartFetchMap.UpdateExpiration(sys.Id, sys.smartInterval+time.Minute)
|
||||
@@ -156,11 +158,10 @@ func (sys *System) update() error {
|
||||
if sys.smartInterval <= 0 {
|
||||
sys.smartInterval = time.Hour
|
||||
}
|
||||
lastFetch, _ := sys.manager.smartFetchMap.GetOk(sys.Id)
|
||||
if time.Since(time.UnixMilli(lastFetch-1e4)) >= sys.smartInterval && sys.smartFetching.CompareAndSwap(false, true) {
|
||||
if sys.shouldFetchSmart() && sys.smartFetching.CompareAndSwap(false, true) {
|
||||
sys.manager.hub.Logger().Info("SMART fetch", "system", sys.Id, "interval", sys.smartInterval.String())
|
||||
go func() {
|
||||
defer sys.smartFetching.Store(false)
|
||||
sys.manager.smartFetchMap.Set(sys.Id, time.Now().UnixMilli(), sys.smartInterval+time.Minute)
|
||||
_ = sys.FetchAndSaveSmartDevices()
|
||||
}()
|
||||
}
|
||||
@@ -184,7 +185,7 @@ func (sys *System) handlePaused() {
|
||||
|
||||
// createRecords updates the system record and adds system_stats and container_stats records
|
||||
func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error) {
|
||||
systemRecord, err := sys.getRecord()
|
||||
systemRecord, err := sys.getRecord(sys.manager.hub)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -343,8 +344,8 @@ func createContainerRecords(app core.App, data []*container.Stats, systemId stri
|
||||
|
||||
// getRecord retrieves the system record from the database.
|
||||
// If the record is not found, it removes the system from the manager.
|
||||
func (sys *System) getRecord() (*core.Record, error) {
|
||||
record, err := sys.manager.hub.FindRecordById("systems", sys.Id)
|
||||
func (sys *System) getRecord(app core.App) (*core.Record, error) {
|
||||
record, err := app.FindRecordById("systems", sys.Id)
|
||||
if err != nil || record == nil {
|
||||
_ = sys.manager.RemoveSystem(sys.Id)
|
||||
return nil, err
|
||||
@@ -352,6 +353,16 @@ func (sys *System) getRecord() (*core.Record, error) {
|
||||
return record, nil
|
||||
}
|
||||
|
||||
// HasUser checks if the given user ID is in the system's users list.
|
||||
func (sys *System) HasUser(app core.App, userID string) bool {
|
||||
record, err := sys.getRecord(app)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
users := record.GetStringSlice("users")
|
||||
return slices.Contains(users, userID)
|
||||
}
|
||||
|
||||
// setDown marks a system as down in the database.
|
||||
// It takes the original error that caused the system to go down and returns any error
|
||||
// encountered during the process of updating the system status.
|
||||
@@ -359,7 +370,7 @@ func (sys *System) setDown(originalError error) error {
|
||||
if sys.Status == down || sys.Status == paused {
|
||||
return nil
|
||||
}
|
||||
record, err := sys.getRecord()
|
||||
record, err := sys.getRecord(sys.manager.hub)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -643,6 +654,7 @@ func (s *System) createSSHClient() error {
|
||||
return err
|
||||
}
|
||||
s.agentVersion, _ = extractAgentVersion(string(s.client.Conn.ServerVersion()))
|
||||
s.manager.resetFailedSmartFetchState(s.Id)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -41,10 +41,10 @@ var errSystemExists = errors.New("system exists")
|
||||
// SystemManager manages a collection of monitored systems and their connections.
|
||||
// It handles system lifecycle, status updates, and maintains both SSH and WebSocket connections.
|
||||
type SystemManager struct {
|
||||
hub hubLike // Hub interface for database and alert operations
|
||||
systems *store.Store[string, *System] // Thread-safe store of active systems
|
||||
sshConfig *ssh.ClientConfig // SSH client configuration for system connections
|
||||
smartFetchMap *expirymap.ExpiryMap[int64] // Stores last SMART fetch time per system ID
|
||||
hub hubLike // Hub interface for database and alert operations
|
||||
systems *store.Store[string, *System] // Thread-safe store of active systems
|
||||
sshConfig *ssh.ClientConfig // SSH client configuration for system connections
|
||||
smartFetchMap *expirymap.ExpiryMap[smartFetchState] // Stores last SMART fetch time/result; TTL is only for cleanup
|
||||
}
|
||||
|
||||
// hubLike defines the interface requirements for the hub dependency.
|
||||
@@ -54,6 +54,7 @@ type hubLike interface {
|
||||
GetSSHKey(dataDir string) (ssh.Signer, error)
|
||||
HandleSystemAlerts(systemRecord *core.Record, data *system.CombinedData) error
|
||||
HandleStatusAlerts(status string, systemRecord *core.Record) error
|
||||
CancelPendingStatusAlerts(systemID string)
|
||||
}
|
||||
|
||||
// NewSystemManager creates a new SystemManager instance with the provided hub.
|
||||
@@ -62,7 +63,7 @@ func NewSystemManager(hub hubLike) *SystemManager {
|
||||
return &SystemManager{
|
||||
systems: store.New(map[string]*System{}),
|
||||
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()
|
||||
}
|
||||
_ = deactivateAlerts(e.App, e.Record.Id)
|
||||
sm.hub.CancelPendingStatusAlerts(e.Record.Id)
|
||||
return e.Next()
|
||||
case pending:
|
||||
// Resume monitoring, preferring existing WebSocket connection
|
||||
@@ -306,6 +308,7 @@ func (sm *SystemManager) AddWebSocketSystem(systemId string, agentVersion semver
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sm.resetFailedSmartFetchState(systemId)
|
||||
|
||||
system := sm.NewSystem(systemId)
|
||||
system.WsConn = wsConn
|
||||
@@ -317,6 +320,15 @@ func (sm *SystemManager) AddWebSocketSystem(systemId string, agentVersion semver
|
||||
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
|
||||
func (sm *SystemManager) createSSHClientConfig() error {
|
||||
privateKey, err := sm.hub.GetSSHKey("")
|
||||
|
||||
@@ -4,18 +4,61 @@ import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/internal/entities/smart"
|
||||
"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
|
||||
func (sys *System) FetchAndSaveSmartDevices() error {
|
||||
smartData, err := sys.FetchSmartDataFromAgent()
|
||||
if err != nil || len(smartData) == 0 {
|
||||
if err != nil {
|
||||
sys.recordSmartFetchResult(err, 0)
|
||||
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
|
||||
|
||||
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" />
|
||||
<link rel="manifest" href="./static/manifest.json" crossorigin="use-credentials" />
|
||||
<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="robots" content="noindex, nofollow" />
|
||||
<title>Beszel</title>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "beszel",
|
||||
"private": true,
|
||||
"version": "0.18.5",
|
||||
"version": "0.18.6",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host",
|
||||
|
||||
@@ -20,7 +20,7 @@ export default memo(function AlertsButton({ system }: { system: SystemRecord })
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="ghost" size="icon" aria-label={t`Alerts`} data-nolink onClick={() => setOpened(true)}>
|
||||
<BellIcon
|
||||
className={cn("h-[1.2em] w-[1.2em] pointer-events-none", {
|
||||
className={cn("size-[1.2em] pointer-events-none", {
|
||||
"fill-primary": hasSystemAlert,
|
||||
})}
|
||||
/>
|
||||
|
||||
@@ -67,8 +67,8 @@ export default function AreaChartDefault({
|
||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||
const { isIntersecting, ref } = useIntersectionObserver({ freeze: false })
|
||||
const sourceData = customData ?? chartData.systemStats
|
||||
// Only update the rendered data while the chart is visible
|
||||
const [displayData, setDisplayData] = useState(sourceData)
|
||||
const [displayMaxToggled, setDisplayMaxToggled] = useState(maxToggled)
|
||||
|
||||
// Reduce chart redraws by only updating while visible or when chart time changes
|
||||
useEffect(() => {
|
||||
@@ -78,7 +78,10 @@ export default function AreaChartDefault({
|
||||
if (shouldUpdate) {
|
||||
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
|
||||
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(() => {
|
||||
if (displayData.length === 0) {
|
||||
return null
|
||||
}
|
||||
// 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 (
|
||||
<ChartContainer
|
||||
@@ -163,5 +166,5 @@ export default function AreaChartDefault({
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
)
|
||||
}, [displayData, yAxisWidth, showTotal, filter])
|
||||
}, [displayData, yAxisWidth, filter, Areas])
|
||||
}
|
||||
|
||||
@@ -66,8 +66,8 @@ export default function LineChartDefault({
|
||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||
const { isIntersecting, ref } = useIntersectionObserver({ freeze: false })
|
||||
const sourceData = customData ?? chartData.systemStats
|
||||
// Only update the rendered data while the chart is visible
|
||||
const [displayData, setDisplayData] = useState(sourceData)
|
||||
const [displayMaxToggled, setDisplayMaxToggled] = useState(maxToggled)
|
||||
|
||||
// Reduce chart redraws by only updating while visible or when chart time changes
|
||||
useEffect(() => {
|
||||
@@ -77,7 +77,10 @@ export default function LineChartDefault({
|
||||
if (shouldUpdate) {
|
||||
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
|
||||
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(() => {
|
||||
if (displayData.length === 0) {
|
||||
return null
|
||||
}
|
||||
// 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 (
|
||||
<ChartContainer
|
||||
@@ -162,5 +165,5 @@ export default function LineChartDefault({
|
||||
</LineChart>
|
||||
</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"
|
||||
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>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -125,15 +125,17 @@ export default function Navbar() {
|
||||
<DropdownMenuSubContent>{AdminLinks}</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
className="flex items-center"
|
||||
onSelect={() => {
|
||||
setAddSystemDialogOpen(true)
|
||||
}}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4 me-2.5" />
|
||||
<Trans>Add {{ foo: systemTranslation }}</Trans>
|
||||
</DropdownMenuItem>
|
||||
{!isReadOnlyUser() && (
|
||||
<DropdownMenuItem
|
||||
className="flex items-center"
|
||||
onSelect={() => {
|
||||
setAddSystemDialogOpen(true)
|
||||
}}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4 me-2.5" />
|
||||
<Trans>Add {{ foo: systemTranslation }}</Trans>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
@@ -217,10 +219,12 @@ export default function Navbar() {
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button variant="outline" className="flex gap-1 ms-2" onClick={() => setAddSystemDialogOpen(true)}>
|
||||
<PlusIcon className="h-4 w-4 -ms-1" />
|
||||
<Trans>Add {{ foo: systemTranslation }}</Trans>
|
||||
</Button>
|
||||
{!isReadOnlyUser() && (
|
||||
<Button variant="outline" className="flex gap-1 ms-2" onClick={() => setAddSystemDialogOpen(true)}>
|
||||
<PlusIcon className="h-4 w-4 -ms-1" />
|
||||
<Trans>Add {{ foo: systemTranslation }}</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -36,7 +36,7 @@ import { Input } from "@/components/ui/input"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { pb } from "@/lib/api"
|
||||
import { isReadOnlyUser, pb } from "@/lib/api"
|
||||
import type { SmartDeviceRecord, SmartAttribute } from "@/types"
|
||||
import {
|
||||
formatBytes,
|
||||
@@ -492,7 +492,7 @@ export default function DisksTable({ systemId }: { systemId?: string }) {
|
||||
const tableColumns = useMemo(() => {
|
||||
const columns = createColumns(longestName, longestModel, longestDevice)
|
||||
const baseColumns = systemId ? columns.filter((col) => col.id !== "system") : columns
|
||||
return [...baseColumns, actionColumn]
|
||||
return isReadOnlyUser() ? baseColumns : [...baseColumns, actionColumn]
|
||||
}, [systemId, actionColumn, longestName, longestModel, longestDevice])
|
||||
|
||||
const table = useReactTable({
|
||||
|
||||
@@ -18,7 +18,7 @@ import { listenKeys } from "nanostores"
|
||||
import { memo, type ReactNode, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { getStatusColor, systemdTableCols } from "@/components/systemd-table/systemd-table-columns"
|
||||
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 { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet"
|
||||
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">
|
||||
<Trans>Systemd Services</Trans>
|
||||
</CardTitle>
|
||||
<CardDescription className="flex items-center">
|
||||
<div className="text-sm text-muted-foreground flex items-center flex-wrap">
|
||||
<Trans>Total: {data.length}</Trans>
|
||||
<Separator orientation="vertical" className="h-4 mx-2 bg-primary/40" />
|
||||
<Trans>Failed: {statusTotals[ServiceStatus.Failed]}</Trans>
|
||||
<Separator orientation="vertical" className="h-4 mx-2 bg-primary/40" />
|
||||
<Trans>Updated every 10 minutes.</Trans>
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
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">
|
||||
<div className="flex items-center gap-2 w-full overflow-hidden">
|
||||
<CardTitle className="text-base tracking-normal text-primary/90 flex items-center min-w-0 flex-1 gap-2.5">
|
||||
<CardHeader className="py-1 ps-4 pe-2 bg-muted/30 border-b border-border/60">
|
||||
<div className="flex items-center gap-1 w-full overflow-hidden">
|
||||
<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">
|
||||
<IndicatorDot system={system} />
|
||||
<span className="text-[.95em]/normal tracking-normal text-primary/90 truncate">{system.name}</span>
|
||||
</div>
|
||||
</CardTitle>
|
||||
</h3>
|
||||
{table.getColumn("actions")?.getIsVisible() && (
|
||||
<div className="flex gap-1 shrink-0 relative z-10">
|
||||
<AlertButton system={system} />
|
||||
|
||||
@@ -43,7 +43,7 @@ const AlertDialogContent = React.forwardRef<
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
||||
|
||||
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"
|
||||
|
||||
|
||||
@@ -18,11 +18,7 @@ CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn("text-[1.4em] sm:text-2xl font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
<h3 ref={ref} className={cn("text-card-title font-semibold leading-none tracking-tight", className)} {...props} />
|
||||
)
|
||||
)
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
@@ -52,7 +52,7 @@ const DialogContent = React.forwardRef<
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
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"
|
||||
|
||||
|
||||
@@ -177,6 +177,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
@utility text-card-title {
|
||||
@apply text-[1.4rem] sm:text-2xl;
|
||||
}
|
||||
|
||||
.recharts-tooltip-wrapper {
|
||||
z-index: 51;
|
||||
@apply tabular-nums;
|
||||
|
||||
@@ -77,6 +77,16 @@ func CreateUser(app core.App, email string, password string) (*core.Record, erro
|
||||
return user, app.Save(user)
|
||||
}
|
||||
|
||||
// Helper function to create a test superuser for config tests
|
||||
func CreateSuperuser(app core.App, email string, password string) (*core.Record, error) {
|
||||
superusersCollection, _ := app.FindCachedCollectionByNameOrId(core.CollectionNameSuperusers)
|
||||
superuser := core.NewRecord(superusersCollection)
|
||||
superuser.Set("email", email)
|
||||
superuser.Set("password", password)
|
||||
|
||||
return superuser, app.Save(superuser)
|
||||
}
|
||||
|
||||
func CreateUserWithRole(app core.App, email string, password string, roleName string) (*core.Record, error) {
|
||||
user, err := CreateUser(app, email, password)
|
||||
if err != nil {
|
||||
|
||||
@@ -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
|
||||
|
||||
- Add "update available" notification in hub web UI with `CHECK_UPDATES=true` (#1830)
|
||||
|
||||
Reference in New Issue
Block a user