mirror of
https://github.com/henrygd/beszel.git
synced 2026-04-06 13:01:49 +02:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5db4eb4346 | ||
|
|
f6c5e2928a | ||
|
|
6a207c33fa | ||
|
|
9f19afccde | ||
|
|
f25f2469e3 | ||
|
|
5bd43ed461 | ||
|
|
afdc3f7779 | ||
|
|
a227c77526 | ||
|
|
8202d746af |
@@ -19,6 +19,8 @@ import (
|
|||||||
gossh "golang.org/x/crypto/ssh"
|
gossh "golang.org/x/crypto/ssh"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const defaultDataCacheTimeMs uint16 = 60_000
|
||||||
|
|
||||||
type Agent struct {
|
type Agent struct {
|
||||||
sync.Mutex // Used to lock agent while collecting data
|
sync.Mutex // Used to lock agent while collecting data
|
||||||
debug bool // true if LOG_LEVEL is set to debug
|
debug bool // true if LOG_LEVEL is set to debug
|
||||||
@@ -36,6 +38,7 @@ type Agent struct {
|
|||||||
sensorConfig *SensorConfig // Sensors config
|
sensorConfig *SensorConfig // Sensors config
|
||||||
systemInfo system.Info // Host system info (dynamic)
|
systemInfo system.Info // Host system info (dynamic)
|
||||||
systemDetails system.Details // Host system details (static, once-per-connection)
|
systemDetails system.Details // Host system details (static, once-per-connection)
|
||||||
|
detailsDirty bool // Whether system details have changed and need to be resent
|
||||||
gpuManager *GPUManager // Manages GPU data
|
gpuManager *GPUManager // Manages GPU data
|
||||||
cache *systemDataCache // Cache for system stats based on cache time
|
cache *systemDataCache // Cache for system stats based on cache time
|
||||||
connectionManager *ConnectionManager // Channel to signal connection events
|
connectionManager *ConnectionManager // Channel to signal connection events
|
||||||
@@ -97,7 +100,7 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
|
|||||||
slog.Debug(beszel.Version)
|
slog.Debug(beszel.Version)
|
||||||
|
|
||||||
// initialize docker manager
|
// initialize docker manager
|
||||||
agent.dockerManager = newDockerManager()
|
agent.dockerManager = newDockerManager(agent)
|
||||||
|
|
||||||
// initialize system info
|
// initialize system info
|
||||||
agent.refreshSystemDetails()
|
agent.refreshSystemDetails()
|
||||||
@@ -142,7 +145,7 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
|
|||||||
|
|
||||||
// if debugging, print stats
|
// if debugging, print stats
|
||||||
if agent.debug {
|
if agent.debug {
|
||||||
slog.Debug("Stats", "data", agent.gatherStats(common.DataRequestOptions{CacheTimeMs: 60_000, IncludeDetails: true}))
|
slog.Debug("Stats", "data", agent.gatherStats(common.DataRequestOptions{CacheTimeMs: defaultDataCacheTimeMs, IncludeDetails: true}))
|
||||||
}
|
}
|
||||||
|
|
||||||
return agent, nil
|
return agent, nil
|
||||||
@@ -164,11 +167,6 @@ func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedD
|
|||||||
Info: a.systemInfo,
|
Info: a.systemInfo,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Include static system details only when requested
|
|
||||||
if options.IncludeDetails {
|
|
||||||
data.Details = &a.systemDetails
|
|
||||||
}
|
|
||||||
|
|
||||||
// slog.Info("System data", "data", data, "cacheTimeMs", cacheTimeMs)
|
// slog.Info("System data", "data", data, "cacheTimeMs", cacheTimeMs)
|
||||||
|
|
||||||
if a.dockerManager != nil {
|
if a.dockerManager != nil {
|
||||||
@@ -181,7 +179,7 @@ func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedD
|
|||||||
}
|
}
|
||||||
|
|
||||||
// skip updating systemd services if cache time is not the default 60sec interval
|
// skip updating systemd services if cache time is not the default 60sec interval
|
||||||
if a.systemdManager != nil && cacheTimeMs == 60_000 {
|
if a.systemdManager != nil && cacheTimeMs == defaultDataCacheTimeMs {
|
||||||
totalCount := uint16(a.systemdManager.getServiceStatsCount())
|
totalCount := uint16(a.systemdManager.getServiceStatsCount())
|
||||||
if totalCount > 0 {
|
if totalCount > 0 {
|
||||||
numFailed := a.systemdManager.getFailedServiceCount()
|
numFailed := a.systemdManager.getFailedServiceCount()
|
||||||
@@ -212,7 +210,8 @@ func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedD
|
|||||||
slog.Debug("Extra FS", "data", data.Stats.ExtraFs)
|
slog.Debug("Extra FS", "data", data.Stats.ExtraFs)
|
||||||
|
|
||||||
a.cache.Set(data, cacheTimeMs)
|
a.cache.Set(data, cacheTimeMs)
|
||||||
return data
|
|
||||||
|
return a.attachSystemDetails(data, cacheTimeMs, options.IncludeDetails)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start initializes and starts the agent with optional WebSocket connection
|
// Start initializes and starts the agent with optional WebSocket connection
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -273,7 +274,7 @@ func (a *Agent) initializeDiskInfo() {
|
|||||||
hasRoot := false
|
hasRoot := false
|
||||||
isWindows := runtime.GOOS == "windows"
|
isWindows := runtime.GOOS == "windows"
|
||||||
|
|
||||||
partitions, err := disk.Partitions(false)
|
partitions, err := disk.PartitionsWithContext(context.Background(), true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Error getting disk partitions", "err", err)
|
slog.Error("Error getting disk partitions", "err", err)
|
||||||
}
|
}
|
||||||
|
|||||||
164
agent/docker.go
164
agent/docker.go
@@ -25,6 +25,7 @@ import (
|
|||||||
"github.com/henrygd/beszel/agent/deltatracker"
|
"github.com/henrygd/beszel/agent/deltatracker"
|
||||||
"github.com/henrygd/beszel/agent/utils"
|
"github.com/henrygd/beszel/agent/utils"
|
||||||
"github.com/henrygd/beszel/internal/entities/container"
|
"github.com/henrygd/beszel/internal/entities/container"
|
||||||
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
|
||||||
"github.com/blang/semver"
|
"github.com/blang/semver"
|
||||||
)
|
)
|
||||||
@@ -52,20 +53,22 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type dockerManager struct {
|
type dockerManager struct {
|
||||||
client *http.Client // Client to query Docker API
|
agent *Agent // Used to propagate system detail changes back to the agent
|
||||||
wg sync.WaitGroup // WaitGroup to wait for all goroutines to finish
|
client *http.Client // Client to query Docker API
|
||||||
sem chan struct{} // Semaphore to limit concurrent container requests
|
wg sync.WaitGroup // WaitGroup to wait for all goroutines to finish
|
||||||
containerStatsMutex sync.RWMutex // Mutex to prevent concurrent access to containerStatsMap
|
sem chan struct{} // Semaphore to limit concurrent container requests
|
||||||
apiContainerList []*container.ApiInfo // List of containers from Docker API
|
containerStatsMutex sync.RWMutex // Mutex to prevent concurrent access to containerStatsMap
|
||||||
containerStatsMap map[string]*container.Stats // Keeps track of container stats
|
apiContainerList []*container.ApiInfo // List of containers from Docker API
|
||||||
validIds map[string]struct{} // Map of valid container ids, used to prune invalid containers from containerStatsMap
|
containerStatsMap map[string]*container.Stats // Keeps track of container stats
|
||||||
goodDockerVersion bool // Whether docker version is at least 25.0.0 (one-shot works correctly)
|
validIds map[string]struct{} // Map of valid container ids, used to prune invalid containers from containerStatsMap
|
||||||
isWindows bool // Whether the Docker Engine API is running on Windows
|
goodDockerVersion bool // Whether docker version is at least 25.0.0 (one-shot works correctly)
|
||||||
buf *bytes.Buffer // Buffer to store and read response bodies
|
dockerVersionChecked bool // Whether a version probe has completed successfully
|
||||||
decoder *json.Decoder // Reusable JSON decoder that reads from buf
|
isWindows bool // Whether the Docker Engine API is running on Windows
|
||||||
apiStats *container.ApiStats // Reusable API stats object
|
buf *bytes.Buffer // Buffer to store and read response bodies
|
||||||
excludeContainers []string // Patterns to exclude containers by name
|
decoder *json.Decoder // Reusable JSON decoder that reads from buf
|
||||||
usingPodman bool // Whether the Docker Engine API is running on Podman
|
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)
|
// Cache-time-aware tracking for CPU stats (similar to cpu.go)
|
||||||
// Maps cache time intervals to container-specific CPU usage tracking
|
// Maps cache time intervals to container-specific CPU usage tracking
|
||||||
@@ -78,7 +81,6 @@ type dockerManager struct {
|
|||||||
networkSentTrackers map[uint16]*deltatracker.DeltaTracker[string, uint64]
|
networkSentTrackers map[uint16]*deltatracker.DeltaTracker[string, uint64]
|
||||||
networkRecvTrackers map[uint16]*deltatracker.DeltaTracker[string, uint64]
|
networkRecvTrackers map[uint16]*deltatracker.DeltaTracker[string, uint64]
|
||||||
lastNetworkReadTime map[uint16]map[string]time.Time // cacheTimeMs -> containerId -> last network read time
|
lastNetworkReadTime map[uint16]map[string]time.Time // cacheTimeMs -> containerId -> last network read time
|
||||||
retrySleep func(time.Duration)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// userAgentRoundTripper is a custom http.RoundTripper that adds a User-Agent header to all requests
|
// userAgentRoundTripper is a custom http.RoundTripper that adds a User-Agent header to all requests
|
||||||
@@ -87,6 +89,14 @@ type userAgentRoundTripper struct {
|
|||||||
userAgent string
|
userAgent string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dockerVersionResponse contains the /version fields used for engine checks.
|
||||||
|
type dockerVersionResponse struct {
|
||||||
|
Version string `json:"Version"`
|
||||||
|
Components []struct {
|
||||||
|
Name string `json:"Name"`
|
||||||
|
} `json:"Components"`
|
||||||
|
}
|
||||||
|
|
||||||
// RoundTrip implements the http.RoundTripper interface
|
// RoundTrip implements the http.RoundTripper interface
|
||||||
func (u *userAgentRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
func (u *userAgentRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
req.Header.Set("User-Agent", u.userAgent)
|
req.Header.Set("User-Agent", u.userAgent)
|
||||||
@@ -134,7 +144,14 @@ func (dm *dockerManager) getDockerStats(cacheTimeMs uint16) ([]*container.Stats,
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
dm.isWindows = strings.Contains(resp.Header.Get("Server"), "windows")
|
// Detect Podman and Windows from Server header
|
||||||
|
serverHeader := resp.Header.Get("Server")
|
||||||
|
if !dm.usingPodman && detectPodmanFromHeader(serverHeader) {
|
||||||
|
dm.setIsPodman()
|
||||||
|
}
|
||||||
|
dm.isWindows = strings.Contains(serverHeader, "windows")
|
||||||
|
|
||||||
|
dm.ensureDockerVersionChecked()
|
||||||
|
|
||||||
containersLength := len(dm.apiContainerList)
|
containersLength := len(dm.apiContainerList)
|
||||||
|
|
||||||
@@ -588,7 +605,7 @@ func (dm *dockerManager) deleteContainerStatsSync(id string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Creates a new http client for Docker or Podman API
|
// Creates a new http client for Docker or Podman API
|
||||||
func newDockerManager() *dockerManager {
|
func newDockerManager(agent *Agent) *dockerManager {
|
||||||
dockerHost, exists := utils.GetEnv("DOCKER_HOST")
|
dockerHost, exists := utils.GetEnv("DOCKER_HOST")
|
||||||
if exists {
|
if exists {
|
||||||
// return nil if set to empty string
|
// return nil if set to empty string
|
||||||
@@ -654,6 +671,7 @@ func newDockerManager() *dockerManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
manager := &dockerManager{
|
manager := &dockerManager{
|
||||||
|
agent: agent,
|
||||||
client: &http.Client{
|
client: &http.Client{
|
||||||
Timeout: timeout,
|
Timeout: timeout,
|
||||||
Transport: userAgentTransport,
|
Transport: userAgentTransport,
|
||||||
@@ -671,51 +689,54 @@ func newDockerManager() *dockerManager {
|
|||||||
networkSentTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
|
networkSentTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
|
||||||
networkRecvTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
|
networkRecvTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
|
||||||
lastNetworkReadTime: make(map[uint16]map[string]time.Time),
|
lastNetworkReadTime: make(map[uint16]map[string]time.Time),
|
||||||
retrySleep: time.Sleep,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If using podman, return client
|
// Best-effort startup probe. If the engine is not ready yet, getDockerStats will
|
||||||
if strings.Contains(dockerHost, "podman") {
|
// retry after the first successful /containers/json request.
|
||||||
manager.usingPodman = true
|
_, _ = manager.checkDockerVersion()
|
||||||
manager.goodDockerVersion = true
|
|
||||||
return manager
|
|
||||||
}
|
|
||||||
|
|
||||||
// run version check in goroutine to avoid blocking (server may not be ready and requires retries)
|
|
||||||
go manager.checkDockerVersion()
|
|
||||||
|
|
||||||
// give version check a chance to complete before returning
|
|
||||||
time.Sleep(50 * time.Millisecond)
|
|
||||||
|
|
||||||
return manager
|
return manager
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkDockerVersion checks Docker version and sets goodDockerVersion if at least 25.0.0.
|
// checkDockerVersion checks Docker version and sets goodDockerVersion if at least 25.0.0.
|
||||||
// Versions before 25.0.0 have a bug with one-shot which requires all requests to be made in one batch.
|
// Versions before 25.0.0 have a bug with one-shot which requires all requests to be made in one batch.
|
||||||
func (dm *dockerManager) checkDockerVersion() {
|
func (dm *dockerManager) checkDockerVersion() (bool, error) {
|
||||||
var err error
|
resp, err := dm.client.Get("http://localhost/version")
|
||||||
var resp *http.Response
|
if err != nil {
|
||||||
var versionInfo struct {
|
return false, err
|
||||||
Version string `json:"Version"`
|
|
||||||
}
|
}
|
||||||
const versionMaxTries = 2
|
if resp.StatusCode != http.StatusOK {
|
||||||
for i := 1; i <= versionMaxTries; i++ {
|
status := resp.Status
|
||||||
resp, err = dm.client.Get("http://localhost/version")
|
resp.Body.Close()
|
||||||
if err == nil && resp.StatusCode == http.StatusOK {
|
return false, fmt.Errorf("docker version request failed: %s", status)
|
||||||
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 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
|
return
|
||||||
}
|
}
|
||||||
if err := dm.decode(resp, &versionInfo); err != nil {
|
if _, err := dm.checkDockerVersion(); err != nil {
|
||||||
|
slog.Debug("Failed to get Docker version", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyDockerVersionInfo updates version-dependent behavior from engine metadata.
|
||||||
|
func (dm *dockerManager) applyDockerVersionInfo(serverHeader string, versionInfo *dockerVersionResponse) {
|
||||||
|
if detectPodmanEngine(serverHeader, versionInfo) {
|
||||||
|
dm.setIsPodman()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// if version > 24, one-shot works correctly and we can limit concurrent operations
|
// if version > 24, one-shot works correctly and we can limit concurrent operations
|
||||||
@@ -941,3 +962,46 @@ func (dm *dockerManager) GetHostInfo() (info container.HostInfo, err error) {
|
|||||||
func (dm *dockerManager) IsPodman() bool {
|
func (dm *dockerManager) IsPodman() bool {
|
||||||
return dm.usingPodman
|
return dm.usingPodman
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setIsPodman sets the manager to Podman mode and updates system details accordingly.
|
||||||
|
func (dm *dockerManager) setIsPodman() {
|
||||||
|
if dm.usingPodman {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dm.usingPodman = true
|
||||||
|
dm.goodDockerVersion = true
|
||||||
|
dm.dockerVersionChecked = true
|
||||||
|
// keep system details updated - this may be detected late if server isn't ready when
|
||||||
|
// agent starts, so make sure we notify the hub if this happens later.
|
||||||
|
if dm.agent != nil {
|
||||||
|
dm.agent.updateSystemDetails(func(details *system.Details) {
|
||||||
|
details.Podman = true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectPodmanFromHeader identifies Podman from the Docker API server header.
|
||||||
|
func detectPodmanFromHeader(server string) bool {
|
||||||
|
return strings.HasPrefix(server, "Libpod")
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectPodmanFromVersion identifies Podman from the version payload.
|
||||||
|
func detectPodmanFromVersion(versionInfo *dockerVersionResponse) bool {
|
||||||
|
if versionInfo == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, component := range versionInfo.Components {
|
||||||
|
if strings.HasPrefix(component.Name, "Podman") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectPodmanEngine checks both header and version metadata for Podman.
|
||||||
|
func detectPodmanEngine(serverHeader string, versionInfo *dockerVersionResponse) bool {
|
||||||
|
if detectPodmanFromHeader(serverHeader) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return detectPodmanFromVersion(versionInfo)
|
||||||
|
}
|
||||||
|
|||||||
@@ -539,59 +539,53 @@ func TestDockerManagerCreation(t *testing.T) {
|
|||||||
|
|
||||||
func TestCheckDockerVersion(t *testing.T) {
|
func TestCheckDockerVersion(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
responses []struct {
|
statusCode int
|
||||||
statusCode int
|
body string
|
||||||
body string
|
server string
|
||||||
}
|
expectSuccess bool
|
||||||
expectedGood bool
|
expectedGood bool
|
||||||
expectedRequests int
|
expectedPodman bool
|
||||||
|
expectError bool
|
||||||
|
expectedRequest string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "200 with good version on first try",
|
name: "good docker version",
|
||||||
responses: []struct {
|
statusCode: http.StatusOK,
|
||||||
statusCode int
|
body: `{"Version":"25.0.1"}`,
|
||||||
body string
|
expectSuccess: true,
|
||||||
}{
|
expectedGood: true,
|
||||||
{http.StatusOK, `{"Version":"25.0.1"}`},
|
expectedPodman: false,
|
||||||
},
|
expectedRequest: "/version",
|
||||||
expectedGood: true,
|
|
||||||
expectedRequests: 1,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "200 with old version on first try",
|
name: "old docker version",
|
||||||
responses: []struct {
|
statusCode: http.StatusOK,
|
||||||
statusCode int
|
body: `{"Version":"24.0.7"}`,
|
||||||
body string
|
expectSuccess: true,
|
||||||
}{
|
expectedGood: false,
|
||||||
{http.StatusOK, `{"Version":"24.0.7"}`},
|
expectedPodman: false,
|
||||||
},
|
expectedRequest: "/version",
|
||||||
expectedGood: false,
|
|
||||||
expectedRequests: 1,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "non-200 then 200 with good version",
|
name: "podman from server header",
|
||||||
responses: []struct {
|
statusCode: http.StatusOK,
|
||||||
statusCode int
|
body: `{"Version":"5.5.0"}`,
|
||||||
body string
|
server: "Libpod/5.5.0",
|
||||||
}{
|
expectSuccess: true,
|
||||||
{http.StatusServiceUnavailable, `"not ready"`},
|
expectedGood: true,
|
||||||
{http.StatusOK, `{"Version":"25.1.0"}`},
|
expectedPodman: true,
|
||||||
},
|
expectedRequest: "/version",
|
||||||
expectedGood: true,
|
|
||||||
expectedRequests: 2,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "non-200 on all retries",
|
name: "non-200 response",
|
||||||
responses: []struct {
|
statusCode: http.StatusServiceUnavailable,
|
||||||
statusCode int
|
body: `"not ready"`,
|
||||||
body string
|
expectSuccess: false,
|
||||||
}{
|
expectedGood: false,
|
||||||
{http.StatusInternalServerError, `"error"`},
|
expectedPodman: false,
|
||||||
{http.StatusUnauthorized, `"error"`},
|
expectError: true,
|
||||||
},
|
expectedRequest: "/version",
|
||||||
expectedGood: false,
|
|
||||||
expectedRequests: 2,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -599,13 +593,13 @@ func TestCheckDockerVersion(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
requestCount := 0
|
requestCount := 0
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
idx := requestCount
|
|
||||||
requestCount++
|
requestCount++
|
||||||
if idx >= len(tt.responses) {
|
assert.Equal(t, tt.expectedRequest, r.URL.EscapedPath())
|
||||||
idx = len(tt.responses) - 1
|
if tt.server != "" {
|
||||||
|
w.Header().Set("Server", tt.server)
|
||||||
}
|
}
|
||||||
w.WriteHeader(tt.responses[idx].statusCode)
|
w.WriteHeader(tt.statusCode)
|
||||||
fmt.Fprint(w, tt.responses[idx].body)
|
fmt.Fprint(w, tt.body)
|
||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
@@ -617,17 +611,24 @@ func TestCheckDockerVersion(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
retrySleep: func(time.Duration) {},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dm.checkDockerVersion()
|
success, err := dm.checkDockerVersion()
|
||||||
|
|
||||||
|
assert.Equal(t, tt.expectSuccess, success)
|
||||||
|
assert.Equal(t, tt.expectSuccess, dm.dockerVersionChecked)
|
||||||
assert.Equal(t, tt.expectedGood, dm.goodDockerVersion)
|
assert.Equal(t, tt.expectedGood, dm.goodDockerVersion)
|
||||||
assert.Equal(t, tt.expectedRequests, requestCount)
|
assert.Equal(t, tt.expectedPodman, dm.usingPodman)
|
||||||
|
assert.Equal(t, 1, requestCount)
|
||||||
|
if tt.expectError {
|
||||||
|
require.Error(t, err)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Run("request error on all retries", func(t *testing.T) {
|
t.Run("request error", func(t *testing.T) {
|
||||||
requestCount := 0
|
requestCount := 0
|
||||||
dm := &dockerManager{
|
dm := &dockerManager{
|
||||||
client: &http.Client{
|
client: &http.Client{
|
||||||
@@ -638,16 +639,171 @@ func TestCheckDockerVersion(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
retrySleep: func(time.Duration) {},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dm.checkDockerVersion()
|
success, err := dm.checkDockerVersion()
|
||||||
|
|
||||||
|
assert.False(t, success)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.False(t, dm.dockerVersionChecked)
|
||||||
assert.False(t, dm.goodDockerVersion)
|
assert.False(t, dm.goodDockerVersion)
|
||||||
assert.Equal(t, 2, requestCount)
|
assert.False(t, dm.usingPodman)
|
||||||
|
assert.Equal(t, 1, requestCount)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// newDockerManagerForVersionTest creates a dockerManager wired to a test server.
|
||||||
|
func newDockerManagerForVersionTest(server *httptest.Server) *dockerManager {
|
||||||
|
return &dockerManager{
|
||||||
|
client: &http.Client{
|
||||||
|
Transport: &http.Transport{
|
||||||
|
DialContext: func(_ context.Context, network, _ string) (net.Conn, error) {
|
||||||
|
return net.Dial(network, server.Listener.Addr().String())
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
containerStatsMap: make(map[string]*container.Stats),
|
||||||
|
lastCpuContainer: make(map[uint16]map[string]uint64),
|
||||||
|
lastCpuSystem: make(map[uint16]map[string]uint64),
|
||||||
|
lastCpuReadTime: make(map[uint16]map[string]time.Time),
|
||||||
|
networkSentTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
|
||||||
|
networkRecvTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
|
||||||
|
lastNetworkReadTime: make(map[uint16]map[string]time.Time),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetDockerStatsChecksDockerVersionAfterContainerList(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
containerServer string
|
||||||
|
versionServer string
|
||||||
|
versionBody string
|
||||||
|
expectedGood bool
|
||||||
|
expectedPodman bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "200 with good version on first try",
|
||||||
|
versionBody: `{"Version":"25.0.1"}`,
|
||||||
|
expectedGood: true,
|
||||||
|
expectedPodman: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "200 with old version on first try",
|
||||||
|
versionBody: `{"Version":"24.0.7"}`,
|
||||||
|
expectedGood: false,
|
||||||
|
expectedPodman: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "podman detected from server header",
|
||||||
|
containerServer: "Libpod/5.5.0",
|
||||||
|
expectedGood: true,
|
||||||
|
expectedPodman: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
requestCounts := map[string]int{}
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
requestCounts[r.URL.EscapedPath()]++
|
||||||
|
switch r.URL.EscapedPath() {
|
||||||
|
case "/containers/json":
|
||||||
|
if tt.containerServer != "" {
|
||||||
|
w.Header().Set("Server", tt.containerServer)
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
fmt.Fprint(w, `[]`)
|
||||||
|
case "/version":
|
||||||
|
if tt.versionServer != "" {
|
||||||
|
w.Header().Set("Server", tt.versionServer)
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
fmt.Fprint(w, tt.versionBody)
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected path: %s", r.URL.EscapedPath())
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
dm := newDockerManagerForVersionTest(server)
|
||||||
|
|
||||||
|
stats, err := dm.getDockerStats(defaultCacheTimeMs)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, stats)
|
||||||
|
assert.True(t, dm.dockerVersionChecked)
|
||||||
|
assert.Equal(t, tt.expectedGood, dm.goodDockerVersion)
|
||||||
|
assert.Equal(t, tt.expectedPodman, dm.usingPodman)
|
||||||
|
assert.Equal(t, 1, requestCounts["/containers/json"])
|
||||||
|
if tt.expectedPodman {
|
||||||
|
assert.Equal(t, 0, requestCounts["/version"])
|
||||||
|
} else {
|
||||||
|
assert.Equal(t, 1, requestCounts["/version"])
|
||||||
|
}
|
||||||
|
|
||||||
|
stats, err = dm.getDockerStats(defaultCacheTimeMs)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, stats)
|
||||||
|
assert.Equal(t, tt.expectedGood, dm.goodDockerVersion)
|
||||||
|
assert.Equal(t, tt.expectedPodman, dm.usingPodman)
|
||||||
|
assert.Equal(t, 2, requestCounts["/containers/json"])
|
||||||
|
if tt.expectedPodman {
|
||||||
|
assert.Equal(t, 0, requestCounts["/version"])
|
||||||
|
} else {
|
||||||
|
assert.Equal(t, 1, requestCounts["/version"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetDockerStatsRetriesVersionCheckUntilSuccess(t *testing.T) {
|
||||||
|
requestCounts := map[string]int{}
|
||||||
|
versionStatuses := []int{http.StatusServiceUnavailable, http.StatusOK}
|
||||||
|
versionBodies := []string{`"not ready"`, `{"Version":"25.1.0"}`}
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
requestCounts[r.URL.EscapedPath()]++
|
||||||
|
switch r.URL.EscapedPath() {
|
||||||
|
case "/containers/json":
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
fmt.Fprint(w, `[]`)
|
||||||
|
case "/version":
|
||||||
|
idx := requestCounts["/version"] - 1
|
||||||
|
if idx >= len(versionStatuses) {
|
||||||
|
idx = len(versionStatuses) - 1
|
||||||
|
}
|
||||||
|
w.WriteHeader(versionStatuses[idx])
|
||||||
|
fmt.Fprint(w, versionBodies[idx])
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected path: %s", r.URL.EscapedPath())
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
dm := newDockerManagerForVersionTest(server)
|
||||||
|
|
||||||
|
stats, err := dm.getDockerStats(defaultCacheTimeMs)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, stats)
|
||||||
|
assert.False(t, dm.dockerVersionChecked)
|
||||||
|
assert.False(t, dm.goodDockerVersion)
|
||||||
|
assert.Equal(t, 1, requestCounts["/version"])
|
||||||
|
|
||||||
|
stats, err = dm.getDockerStats(defaultCacheTimeMs)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, stats)
|
||||||
|
assert.True(t, dm.dockerVersionChecked)
|
||||||
|
assert.True(t, dm.goodDockerVersion)
|
||||||
|
assert.Equal(t, 2, requestCounts["/containers/json"])
|
||||||
|
assert.Equal(t, 2, requestCounts["/version"])
|
||||||
|
|
||||||
|
stats, err = dm.getDockerStats(defaultCacheTimeMs)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, stats)
|
||||||
|
assert.Equal(t, 3, requestCounts["/containers/json"])
|
||||||
|
assert.Equal(t, 2, requestCounts["/version"])
|
||||||
|
}
|
||||||
|
|
||||||
func TestCycleCpuDeltas(t *testing.T) {
|
func TestCycleCpuDeltas(t *testing.T) {
|
||||||
dm := &dockerManager{
|
dm := &dockerManager{
|
||||||
lastCpuContainer: map[uint16]map[string]uint64{
|
lastCpuContainer: map[uint16]map[string]uint64{
|
||||||
|
|||||||
11
agent/gpu.go
11
agent/gpu.go
@@ -542,7 +542,7 @@ func (gm *GPUManager) collectorDefinitions(caps gpuCapabilities) map[collectorSo
|
|||||||
return map[collectorSource]collectorDefinition{
|
return map[collectorSource]collectorDefinition{
|
||||||
collectorSourceNVML: {
|
collectorSourceNVML: {
|
||||||
group: collectorGroupNvidia,
|
group: collectorGroupNvidia,
|
||||||
available: caps.hasNvidiaSmi,
|
available: true,
|
||||||
start: func(_ func()) bool {
|
start: func(_ func()) bool {
|
||||||
return gm.startNvmlCollector()
|
return gm.startNvmlCollector()
|
||||||
},
|
},
|
||||||
@@ -734,9 +734,6 @@ func NewGPUManager() (*GPUManager, error) {
|
|||||||
}
|
}
|
||||||
var gm GPUManager
|
var gm GPUManager
|
||||||
caps := gm.discoverGpuCapabilities()
|
caps := gm.discoverGpuCapabilities()
|
||||||
if !hasAnyGpuCollector(caps) {
|
|
||||||
return nil, fmt.Errorf(noGPUFoundMsg)
|
|
||||||
}
|
|
||||||
gm.GpuDataMap = make(map[string]*system.GPUData)
|
gm.GpuDataMap = make(map[string]*system.GPUData)
|
||||||
|
|
||||||
// Jetson devices should always use tegrastats (ignore GPU_COLLECTOR).
|
// Jetson devices should always use tegrastats (ignore GPU_COLLECTOR).
|
||||||
@@ -745,7 +742,7 @@ func NewGPUManager() (*GPUManager, error) {
|
|||||||
return &gm, nil
|
return &gm, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// if GPU_COLLECTOR is set, start user-defined collectors.
|
// Respect explicit collector selection before capability auto-detection.
|
||||||
if collectorConfig, ok := utils.GetEnv("GPU_COLLECTOR"); ok && strings.TrimSpace(collectorConfig) != "" {
|
if collectorConfig, ok := utils.GetEnv("GPU_COLLECTOR"); ok && strings.TrimSpace(collectorConfig) != "" {
|
||||||
priorities := parseCollectorPriority(collectorConfig)
|
priorities := parseCollectorPriority(collectorConfig)
|
||||||
if gm.startCollectorsByPriority(priorities, caps) == 0 {
|
if gm.startCollectorsByPriority(priorities, caps) == 0 {
|
||||||
@@ -754,6 +751,10 @@ func NewGPUManager() (*GPUManager, error) {
|
|||||||
return &gm, nil
|
return &gm, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !hasAnyGpuCollector(caps) {
|
||||||
|
return nil, fmt.Errorf(noGPUFoundMsg)
|
||||||
|
}
|
||||||
|
|
||||||
// auto-detect and start collectors when GPU_COLLECTOR is unset.
|
// auto-detect and start collectors when GPU_COLLECTOR is unset.
|
||||||
if gm.startCollectorsByPriority(gm.resolveLegacyCollectorPriority(caps), caps) == 0 {
|
if gm.startCollectorsByPriority(gm.resolveLegacyCollectorPriority(caps), caps) == 0 {
|
||||||
return nil, fmt.Errorf(noGPUFoundMsg)
|
return nil, fmt.Errorf(noGPUFoundMsg)
|
||||||
|
|||||||
@@ -1461,6 +1461,25 @@ func TestNewGPUManagerConfiguredCollectorsMustStart(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCollectorDefinitionsNvmlDoesNotRequireNvidiaSmi(t *testing.T) {
|
||||||
|
gm := &GPUManager{}
|
||||||
|
definitions := gm.collectorDefinitions(gpuCapabilities{})
|
||||||
|
require.Contains(t, definitions, collectorSourceNVML)
|
||||||
|
assert.True(t, definitions[collectorSourceNVML].available)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewGPUManagerConfiguredNvmlBypassesCapabilityGate(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Setenv("PATH", dir)
|
||||||
|
t.Setenv("BESZEL_AGENT_GPU_COLLECTOR", "nvml")
|
||||||
|
|
||||||
|
gm, err := NewGPUManager()
|
||||||
|
require.Nil(t, gm)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "no configured GPU collectors are available")
|
||||||
|
assert.NotContains(t, err.Error(), noGPUFoundMsg)
|
||||||
|
}
|
||||||
|
|
||||||
func TestNewGPUManagerJetsonIgnoresCollectorConfig(t *testing.T) {
|
func TestNewGPUManagerJetsonIgnoresCollectorConfig(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
t.Setenv("PATH", dir)
|
t.Setenv("PATH", dir)
|
||||||
|
|||||||
@@ -193,7 +193,7 @@ func (a *Agent) handleSSHRequest(w io.Writer, req *common.HubRequest[cbor.RawMes
|
|||||||
|
|
||||||
// handleLegacyStats serves the legacy one-shot stats payload for older hubs
|
// handleLegacyStats serves the legacy one-shot stats payload for older hubs
|
||||||
func (a *Agent) handleLegacyStats(w io.Writer, hubVersion semver.Version) error {
|
func (a *Agent) handleLegacyStats(w io.Writer, hubVersion semver.Version) error {
|
||||||
stats := a.gatherStats(common.DataRequestOptions{CacheTimeMs: 60_000})
|
stats := a.gatherStats(common.DataRequestOptions{CacheTimeMs: defaultDataCacheTimeMs})
|
||||||
return a.writeToSession(w, stats, hubVersion)
|
return a.writeToSession(w, stats, hubVersion)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -115,6 +115,26 @@ func (a *Agent) refreshSystemDetails() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// attachSystemDetails returns details only for fresh default-interval responses.
|
||||||
|
func (a *Agent) attachSystemDetails(data *system.CombinedData, cacheTimeMs uint16, includeRequested bool) *system.CombinedData {
|
||||||
|
if cacheTimeMs != defaultDataCacheTimeMs || (!includeRequested && !a.detailsDirty) {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// copy data to avoid adding details to the original cached struct
|
||||||
|
response := *data
|
||||||
|
response.Details = &a.systemDetails
|
||||||
|
a.detailsDirty = false
|
||||||
|
return &response
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateSystemDetails applies a mutation to the static details payload and marks
|
||||||
|
// it for inclusion on the next fresh default-interval response.
|
||||||
|
func (a *Agent) updateSystemDetails(updateFunc func(details *system.Details)) {
|
||||||
|
updateFunc(&a.systemDetails)
|
||||||
|
a.detailsDirty = true
|
||||||
|
}
|
||||||
|
|
||||||
// Returns current info, stats about the host system
|
// Returns current info, stats about the host system
|
||||||
func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
|
func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
|
||||||
var systemStats system.Stats
|
var systemStats system.Stats
|
||||||
|
|||||||
61
agent/system_test.go
Normal file
61
agent/system_test.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/common"
|
||||||
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGatherStatsDoesNotAttachDetailsToCachedRequests(t *testing.T) {
|
||||||
|
agent := &Agent{
|
||||||
|
cache: NewSystemDataCache(),
|
||||||
|
systemDetails: system.Details{Hostname: "updated-host", Podman: true},
|
||||||
|
detailsDirty: true,
|
||||||
|
}
|
||||||
|
cached := &system.CombinedData{
|
||||||
|
Info: system.Info{Hostname: "cached-host"},
|
||||||
|
}
|
||||||
|
agent.cache.Set(cached, defaultDataCacheTimeMs)
|
||||||
|
|
||||||
|
response := agent.gatherStats(common.DataRequestOptions{CacheTimeMs: defaultDataCacheTimeMs})
|
||||||
|
|
||||||
|
assert.Same(t, cached, response)
|
||||||
|
assert.Nil(t, response.Details)
|
||||||
|
assert.True(t, agent.detailsDirty)
|
||||||
|
assert.Equal(t, "cached-host", response.Info.Hostname)
|
||||||
|
assert.Nil(t, cached.Details)
|
||||||
|
|
||||||
|
secondResponse := agent.gatherStats(common.DataRequestOptions{CacheTimeMs: defaultDataCacheTimeMs})
|
||||||
|
assert.Same(t, cached, secondResponse)
|
||||||
|
assert.Nil(t, secondResponse.Details)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateSystemDetailsMarksDetailsDirty(t *testing.T) {
|
||||||
|
agent := &Agent{}
|
||||||
|
|
||||||
|
agent.updateSystemDetails(func(details *system.Details) {
|
||||||
|
details.Hostname = "updated-host"
|
||||||
|
details.Podman = true
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.True(t, agent.detailsDirty)
|
||||||
|
assert.Equal(t, "updated-host", agent.systemDetails.Hostname)
|
||||||
|
assert.True(t, agent.systemDetails.Podman)
|
||||||
|
|
||||||
|
original := &system.CombinedData{}
|
||||||
|
realTimeResponse := agent.attachSystemDetails(original, 1000, true)
|
||||||
|
assert.Same(t, original, realTimeResponse)
|
||||||
|
assert.Nil(t, realTimeResponse.Details)
|
||||||
|
assert.True(t, agent.detailsDirty)
|
||||||
|
|
||||||
|
response := agent.attachSystemDetails(original, defaultDataCacheTimeMs, false)
|
||||||
|
require.NotNil(t, response.Details)
|
||||||
|
assert.NotSame(t, original, response)
|
||||||
|
assert.Equal(t, "updated-host", response.Details.Hostname)
|
||||||
|
assert.True(t, response.Details.Podman)
|
||||||
|
assert.False(t, agent.detailsDirty)
|
||||||
|
assert.Nil(t, original.Details)
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import "github.com/blang/semver"
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// Version is the current version of the application.
|
// Version is the current version of the application.
|
||||||
Version = "0.18.5"
|
Version = "0.18.6"
|
||||||
// AppName is the name of the application.
|
// AppName is the name of the application.
|
||||||
AppName = "beszel"
|
AppName = "beszel"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ func (sys *System) update() error {
|
|||||||
// update smart interval if it's set on the agent side
|
// update smart interval if it's set on the agent side
|
||||||
if data.Details.SmartInterval > 0 {
|
if data.Details.SmartInterval > 0 {
|
||||||
sys.smartInterval = data.Details.SmartInterval
|
sys.smartInterval = data.Details.SmartInterval
|
||||||
|
sys.manager.hub.Logger().Info("SMART interval updated from agent details", "system", sys.Id, "interval", sys.smartInterval.String())
|
||||||
// make sure we reset expiration of lastFetch to remain as long as the new smart interval
|
// make sure we reset expiration of lastFetch to remain as long as the new smart interval
|
||||||
// to prevent premature expiration leading to new fetch if interval is different.
|
// to prevent premature expiration leading to new fetch if interval is different.
|
||||||
sys.manager.smartFetchMap.UpdateExpiration(sys.Id, sys.smartInterval+time.Minute)
|
sys.manager.smartFetchMap.UpdateExpiration(sys.Id, sys.smartInterval+time.Minute)
|
||||||
@@ -156,11 +157,10 @@ func (sys *System) update() error {
|
|||||||
if sys.smartInterval <= 0 {
|
if sys.smartInterval <= 0 {
|
||||||
sys.smartInterval = time.Hour
|
sys.smartInterval = time.Hour
|
||||||
}
|
}
|
||||||
lastFetch, _ := sys.manager.smartFetchMap.GetOk(sys.Id)
|
if sys.shouldFetchSmart() && sys.smartFetching.CompareAndSwap(false, true) {
|
||||||
if time.Since(time.UnixMilli(lastFetch-1e4)) >= sys.smartInterval && sys.smartFetching.CompareAndSwap(false, true) {
|
sys.manager.hub.Logger().Info("SMART fetch", "system", sys.Id, "interval", sys.smartInterval.String())
|
||||||
go func() {
|
go func() {
|
||||||
defer sys.smartFetching.Store(false)
|
defer sys.smartFetching.Store(false)
|
||||||
sys.manager.smartFetchMap.Set(sys.Id, time.Now().UnixMilli(), sys.smartInterval+time.Minute)
|
|
||||||
_ = sys.FetchAndSaveSmartDevices()
|
_ = sys.FetchAndSaveSmartDevices()
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
@@ -643,6 +643,7 @@ func (s *System) createSSHClient() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
s.agentVersion, _ = extractAgentVersion(string(s.client.Conn.ServerVersion()))
|
s.agentVersion, _ = extractAgentVersion(string(s.client.Conn.ServerVersion()))
|
||||||
|
s.manager.resetFailedSmartFetchState(s.Id)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,10 +41,10 @@ var errSystemExists = errors.New("system exists")
|
|||||||
// SystemManager manages a collection of monitored systems and their connections.
|
// SystemManager manages a collection of monitored systems and their connections.
|
||||||
// It handles system lifecycle, status updates, and maintains both SSH and WebSocket connections.
|
// It handles system lifecycle, status updates, and maintains both SSH and WebSocket connections.
|
||||||
type SystemManager struct {
|
type SystemManager struct {
|
||||||
hub hubLike // Hub interface for database and alert operations
|
hub hubLike // Hub interface for database and alert operations
|
||||||
systems *store.Store[string, *System] // Thread-safe store of active systems
|
systems *store.Store[string, *System] // Thread-safe store of active systems
|
||||||
sshConfig *ssh.ClientConfig // SSH client configuration for system connections
|
sshConfig *ssh.ClientConfig // SSH client configuration for system connections
|
||||||
smartFetchMap *expirymap.ExpiryMap[int64] // Stores last SMART fetch time per system ID
|
smartFetchMap *expirymap.ExpiryMap[smartFetchState] // Stores last SMART fetch time/result; TTL is only for cleanup
|
||||||
}
|
}
|
||||||
|
|
||||||
// hubLike defines the interface requirements for the hub dependency.
|
// hubLike defines the interface requirements for the hub dependency.
|
||||||
@@ -62,7 +62,7 @@ func NewSystemManager(hub hubLike) *SystemManager {
|
|||||||
return &SystemManager{
|
return &SystemManager{
|
||||||
systems: store.New(map[string]*System{}),
|
systems: store.New(map[string]*System{}),
|
||||||
hub: hub,
|
hub: hub,
|
||||||
smartFetchMap: expirymap.New[int64](time.Hour),
|
smartFetchMap: expirymap.New[smartFetchState](time.Hour),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,6 +306,7 @@ func (sm *SystemManager) AddWebSocketSystem(systemId string, agentVersion semver
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
sm.resetFailedSmartFetchState(systemId)
|
||||||
|
|
||||||
system := sm.NewSystem(systemId)
|
system := sm.NewSystem(systemId)
|
||||||
system.WsConn = wsConn
|
system.WsConn = wsConn
|
||||||
@@ -317,6 +318,15 @@ func (sm *SystemManager) AddWebSocketSystem(systemId string, agentVersion semver
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resetFailedSmartFetchState clears only failed SMART cooldown entries so a fresh
|
||||||
|
// agent reconnect retries SMART discovery immediately after configuration changes.
|
||||||
|
func (sm *SystemManager) resetFailedSmartFetchState(systemID string) {
|
||||||
|
state, ok := sm.smartFetchMap.GetOk(systemID)
|
||||||
|
if ok && !state.Successful {
|
||||||
|
sm.smartFetchMap.Remove(systemID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// createSSHClientConfig initializes the SSH client configuration for connecting to an agent's server
|
// createSSHClientConfig initializes the SSH client configuration for connecting to an agent's server
|
||||||
func (sm *SystemManager) createSSHClientConfig() error {
|
func (sm *SystemManager) createSSHClientConfig() error {
|
||||||
privateKey, err := sm.hub.GetSSHKey("")
|
privateKey, err := sm.hub.GetSSHKey("")
|
||||||
|
|||||||
@@ -4,18 +4,61 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/smart"
|
"github.com/henrygd/beszel/internal/entities/smart"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type smartFetchState struct {
|
||||||
|
LastAttempt int64
|
||||||
|
Successful bool
|
||||||
|
}
|
||||||
|
|
||||||
// FetchAndSaveSmartDevices fetches SMART data from the agent and saves it to the database
|
// FetchAndSaveSmartDevices fetches SMART data from the agent and saves it to the database
|
||||||
func (sys *System) FetchAndSaveSmartDevices() error {
|
func (sys *System) FetchAndSaveSmartDevices() error {
|
||||||
smartData, err := sys.FetchSmartDataFromAgent()
|
smartData, err := sys.FetchSmartDataFromAgent()
|
||||||
if err != nil || len(smartData) == 0 {
|
if err != nil {
|
||||||
|
sys.recordSmartFetchResult(err, 0)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return sys.saveSmartDevices(smartData)
|
err = sys.saveSmartDevices(smartData)
|
||||||
|
sys.recordSmartFetchResult(err, len(smartData))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordSmartFetchResult stores a cooldown entry for the SMART interval and marks
|
||||||
|
// whether the last fetch produced any devices, so failed setup can retry on reconnect.
|
||||||
|
func (sys *System) recordSmartFetchResult(err error, deviceCount int) {
|
||||||
|
if sys.manager == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
interval := sys.smartFetchInterval()
|
||||||
|
success := err == nil && deviceCount > 0
|
||||||
|
if sys.manager.hub != nil {
|
||||||
|
sys.manager.hub.Logger().Info("SMART fetch result", "system", sys.Id, "success", success, "devices", deviceCount, "interval", interval.String(), "err", err)
|
||||||
|
}
|
||||||
|
sys.manager.smartFetchMap.Set(sys.Id, smartFetchState{LastAttempt: time.Now().UnixMilli(), Successful: success}, interval+time.Minute)
|
||||||
|
}
|
||||||
|
|
||||||
|
// shouldFetchSmart returns true when there is no active SMART cooldown entry for this system.
|
||||||
|
func (sys *System) shouldFetchSmart() bool {
|
||||||
|
if sys.manager == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
state, ok := sys.manager.smartFetchMap.GetOk(sys.Id)
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return !time.UnixMilli(state.LastAttempt).Add(sys.smartFetchInterval()).After(time.Now())
|
||||||
|
}
|
||||||
|
|
||||||
|
// smartFetchInterval returns the agent-provided SMART interval or the default when unset.
|
||||||
|
func (sys *System) smartFetchInterval() time.Duration {
|
||||||
|
if sys.smartInterval > 0 {
|
||||||
|
return sys.smartInterval
|
||||||
|
}
|
||||||
|
return time.Hour
|
||||||
}
|
}
|
||||||
|
|
||||||
// saveSmartDevices saves SMART device data to the smart_devices collection
|
// saveSmartDevices saves SMART device data to the smart_devices collection
|
||||||
|
|||||||
94
internal/hub/systems/system_smart_test.go
Normal file
94
internal/hub/systems/system_smart_test.go
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
//go:build testing
|
||||||
|
|
||||||
|
package systems
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/hub/expirymap"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRecordSmartFetchResult(t *testing.T) {
|
||||||
|
sm := &SystemManager{smartFetchMap: expirymap.New[smartFetchState](time.Hour)}
|
||||||
|
t.Cleanup(sm.smartFetchMap.StopCleaner)
|
||||||
|
|
||||||
|
sys := &System{
|
||||||
|
Id: "system-1",
|
||||||
|
manager: sm,
|
||||||
|
smartInterval: time.Hour,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Successful fetch with devices
|
||||||
|
sys.recordSmartFetchResult(nil, 5)
|
||||||
|
state, ok := sm.smartFetchMap.GetOk(sys.Id)
|
||||||
|
assert.True(t, ok, "expected smart fetch result to be stored")
|
||||||
|
assert.True(t, state.Successful, "expected successful fetch state to be recorded")
|
||||||
|
|
||||||
|
// Failed fetch
|
||||||
|
sys.recordSmartFetchResult(errors.New("failed"), 0)
|
||||||
|
state, ok = sm.smartFetchMap.GetOk(sys.Id)
|
||||||
|
assert.True(t, ok, "expected failed smart fetch state to be stored")
|
||||||
|
assert.False(t, state.Successful, "expected failed smart fetch state to be marked unsuccessful")
|
||||||
|
|
||||||
|
// Successful fetch but no devices
|
||||||
|
sys.recordSmartFetchResult(nil, 0)
|
||||||
|
state, ok = sm.smartFetchMap.GetOk(sys.Id)
|
||||||
|
assert.True(t, ok, "expected fetch with zero devices to be stored")
|
||||||
|
assert.False(t, state.Successful, "expected fetch with zero devices to be marked unsuccessful")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShouldFetchSmart(t *testing.T) {
|
||||||
|
sm := &SystemManager{smartFetchMap: expirymap.New[smartFetchState](time.Hour)}
|
||||||
|
t.Cleanup(sm.smartFetchMap.StopCleaner)
|
||||||
|
|
||||||
|
sys := &System{
|
||||||
|
Id: "system-1",
|
||||||
|
manager: sm,
|
||||||
|
smartInterval: time.Hour,
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.True(t, sys.shouldFetchSmart(), "expected initial smart fetch to be allowed")
|
||||||
|
|
||||||
|
sys.recordSmartFetchResult(errors.New("failed"), 0)
|
||||||
|
assert.False(t, sys.shouldFetchSmart(), "expected smart fetch to be blocked while interval entry exists")
|
||||||
|
|
||||||
|
sm.smartFetchMap.Remove(sys.Id)
|
||||||
|
assert.True(t, sys.shouldFetchSmart(), "expected smart fetch to be allowed after interval entry is cleared")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShouldFetchSmart_IgnoresExtendedTTLWhenFetchIsDue(t *testing.T) {
|
||||||
|
sm := &SystemManager{smartFetchMap: expirymap.New[smartFetchState](time.Hour)}
|
||||||
|
t.Cleanup(sm.smartFetchMap.StopCleaner)
|
||||||
|
|
||||||
|
sys := &System{
|
||||||
|
Id: "system-1",
|
||||||
|
manager: sm,
|
||||||
|
smartInterval: time.Hour,
|
||||||
|
}
|
||||||
|
|
||||||
|
sm.smartFetchMap.Set(sys.Id, smartFetchState{
|
||||||
|
LastAttempt: time.Now().Add(-2 * time.Hour).UnixMilli(),
|
||||||
|
Successful: true,
|
||||||
|
}, 10*time.Minute)
|
||||||
|
sm.smartFetchMap.UpdateExpiration(sys.Id, 3*time.Hour)
|
||||||
|
|
||||||
|
assert.True(t, sys.shouldFetchSmart(), "expected fetch time to take precedence over updated TTL")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResetFailedSmartFetchState(t *testing.T) {
|
||||||
|
sm := &SystemManager{smartFetchMap: expirymap.New[smartFetchState](time.Hour)}
|
||||||
|
t.Cleanup(sm.smartFetchMap.StopCleaner)
|
||||||
|
|
||||||
|
sm.smartFetchMap.Set("system-1", smartFetchState{LastAttempt: time.Now().UnixMilli(), Successful: false}, time.Hour)
|
||||||
|
sm.resetFailedSmartFetchState("system-1")
|
||||||
|
_, ok := sm.smartFetchMap.GetOk("system-1")
|
||||||
|
assert.False(t, ok, "expected failed smart fetch state to be cleared on reconnect")
|
||||||
|
|
||||||
|
sm.smartFetchMap.Set("system-1", smartFetchState{LastAttempt: time.Now().UnixMilli(), Successful: true}, time.Hour)
|
||||||
|
sm.resetFailedSmartFetchState("system-1")
|
||||||
|
_, ok = sm.smartFetchMap.GetOk("system-1")
|
||||||
|
assert.True(t, ok, "expected successful smart fetch state to be preserved")
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="manifest" href="./static/manifest.json" crossorigin="use-credentials" />
|
<link rel="manifest" href="./static/manifest.json" crossorigin="use-credentials" />
|
||||||
<link rel="icon" type="image/svg+xml" href="./static/icon.svg" />
|
<link rel="icon" type="image/svg+xml" href="./static/icon.svg" />
|
||||||
|
<link rel="apple-touch-icon" href="./static/icon.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0,maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0,maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||||
<meta name="robots" content="noindex, nofollow" />
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
<title>Beszel</title>
|
<title>Beszel</title>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "beszel",
|
"name": "beszel",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.18.5",
|
"version": "0.18.6",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host",
|
"dev": "vite --host",
|
||||||
|
|||||||
@@ -67,8 +67,8 @@ export default function AreaChartDefault({
|
|||||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||||
const { isIntersecting, ref } = useIntersectionObserver({ freeze: false })
|
const { isIntersecting, ref } = useIntersectionObserver({ freeze: false })
|
||||||
const sourceData = customData ?? chartData.systemStats
|
const sourceData = customData ?? chartData.systemStats
|
||||||
// Only update the rendered data while the chart is visible
|
|
||||||
const [displayData, setDisplayData] = useState(sourceData)
|
const [displayData, setDisplayData] = useState(sourceData)
|
||||||
|
const [displayMaxToggled, setDisplayMaxToggled] = useState(maxToggled)
|
||||||
|
|
||||||
// Reduce chart redraws by only updating while visible or when chart time changes
|
// Reduce chart redraws by only updating while visible or when chart time changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -78,7 +78,10 @@ export default function AreaChartDefault({
|
|||||||
if (shouldUpdate) {
|
if (shouldUpdate) {
|
||||||
setDisplayData(sourceData)
|
setDisplayData(sourceData)
|
||||||
}
|
}
|
||||||
}, [displayData, isIntersecting, sourceData])
|
if (isIntersecting && maxToggled !== displayMaxToggled) {
|
||||||
|
setDisplayMaxToggled(maxToggled)
|
||||||
|
}
|
||||||
|
}, [displayData, displayMaxToggled, isIntersecting, maxToggled, sourceData])
|
||||||
|
|
||||||
// Use a stable key derived from data point identities and visual properties
|
// Use a stable key derived from data point identities and visual properties
|
||||||
const areasKey = dataPoints?.map((d) => `${d.label}:${d.opacity}`).join("\0")
|
const areasKey = dataPoints?.map((d) => `${d.label}:${d.opacity}`).join("\0")
|
||||||
@@ -106,14 +109,14 @@ export default function AreaChartDefault({
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}, [areasKey, maxToggled])
|
}, [areasKey, displayMaxToggled])
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
if (displayData.length === 0) {
|
if (displayData.length === 0) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
// if (logRender) {
|
// if (logRender) {
|
||||||
// console.log("Rendered at", new Date(), "for", dataPoints?.at(0)?.label)
|
// console.log("Rendered", dataPoints?.map((d) => d.label).join(", "), new Date())
|
||||||
// }
|
// }
|
||||||
return (
|
return (
|
||||||
<ChartContainer
|
<ChartContainer
|
||||||
@@ -163,5 +166,5 @@ export default function AreaChartDefault({
|
|||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
)
|
)
|
||||||
}, [displayData, yAxisWidth, showTotal, filter])
|
}, [displayData, yAxisWidth, filter, Areas])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,8 +66,8 @@ export default function LineChartDefault({
|
|||||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||||
const { isIntersecting, ref } = useIntersectionObserver({ freeze: false })
|
const { isIntersecting, ref } = useIntersectionObserver({ freeze: false })
|
||||||
const sourceData = customData ?? chartData.systemStats
|
const sourceData = customData ?? chartData.systemStats
|
||||||
// Only update the rendered data while the chart is visible
|
|
||||||
const [displayData, setDisplayData] = useState(sourceData)
|
const [displayData, setDisplayData] = useState(sourceData)
|
||||||
|
const [displayMaxToggled, setDisplayMaxToggled] = useState(maxToggled)
|
||||||
|
|
||||||
// Reduce chart redraws by only updating while visible or when chart time changes
|
// Reduce chart redraws by only updating while visible or when chart time changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -77,7 +77,10 @@ export default function LineChartDefault({
|
|||||||
if (shouldUpdate) {
|
if (shouldUpdate) {
|
||||||
setDisplayData(sourceData)
|
setDisplayData(sourceData)
|
||||||
}
|
}
|
||||||
}, [displayData, isIntersecting, sourceData])
|
if (isIntersecting && maxToggled !== displayMaxToggled) {
|
||||||
|
setDisplayMaxToggled(maxToggled)
|
||||||
|
}
|
||||||
|
}, [displayData, displayMaxToggled, isIntersecting, maxToggled, sourceData])
|
||||||
|
|
||||||
// Use a stable key derived from data point identities and visual properties
|
// Use a stable key derived from data point identities and visual properties
|
||||||
const linesKey = dataPoints?.map((d) => `${d.label}:${d.strokeOpacity ?? ""}`).join("\0")
|
const linesKey = dataPoints?.map((d) => `${d.label}:${d.strokeOpacity ?? ""}`).join("\0")
|
||||||
@@ -105,14 +108,14 @@ export default function LineChartDefault({
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}, [linesKey, maxToggled])
|
}, [linesKey, displayMaxToggled])
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
if (displayData.length === 0) {
|
if (displayData.length === 0) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
// if (logRender) {
|
// if (logRender) {
|
||||||
// console.log("Rendered at", new Date(), "for", dataPoints?.at(0)?.label)
|
// console.log("Rendered", dataPoints?.map((d) => d.label).join(", "), new Date())
|
||||||
// }
|
// }
|
||||||
return (
|
return (
|
||||||
<ChartContainer
|
<ChartContainer
|
||||||
@@ -162,5 +165,5 @@ export default function LineChartDefault({
|
|||||||
</LineChart>
|
</LineChart>
|
||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
)
|
)
|
||||||
}, [displayData, yAxisWidth, showTotal, filter, chartData.chartTime])
|
}, [displayData, yAxisWidth, filter, Lines])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { listenKeys } from "nanostores"
|
|||||||
import { memo, type ReactNode, useEffect, useMemo, useRef, useState } from "react"
|
import { memo, type ReactNode, useEffect, useMemo, useRef, useState } from "react"
|
||||||
import { getStatusColor, systemdTableCols } from "@/components/systemd-table/systemd-table-columns"
|
import { getStatusColor, systemdTableCols } from "@/components/systemd-table/systemd-table-columns"
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||||
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet"
|
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet"
|
||||||
import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
@@ -161,13 +161,13 @@ export default function SystemdTable({ systemId }: { systemId?: string }) {
|
|||||||
<CardTitle className="mb-2">
|
<CardTitle className="mb-2">
|
||||||
<Trans>Systemd Services</Trans>
|
<Trans>Systemd Services</Trans>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="flex items-center">
|
<div className="text-sm text-muted-foreground flex items-center flex-wrap">
|
||||||
<Trans>Total: {data.length}</Trans>
|
<Trans>Total: {data.length}</Trans>
|
||||||
<Separator orientation="vertical" className="h-4 mx-2 bg-primary/40" />
|
<Separator orientation="vertical" className="h-4 mx-2 bg-primary/40" />
|
||||||
<Trans>Failed: {statusTotals[ServiceStatus.Failed]}</Trans>
|
<Trans>Failed: {statusTotals[ServiceStatus.Failed]}</Trans>
|
||||||
<Separator orientation="vertical" className="h-4 mx-2 bg-primary/40" />
|
<Separator orientation="vertical" className="h-4 mx-2 bg-primary/40" />
|
||||||
<Trans>Updated every 10 minutes.</Trans>
|
<Trans>Updated every 10 minutes.</Trans>
|
||||||
</CardDescription>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Input
|
<Input
|
||||||
placeholder={t`Filter...`}
|
placeholder={t`Filter...`}
|
||||||
|
|||||||
@@ -1,3 +1,17 @@
|
|||||||
|
## 0.18.6
|
||||||
|
|
||||||
|
- Add apple-touch-icon link to index.html (#1850)
|
||||||
|
|
||||||
|
- Fix UI bug where charts did not display 1m max until next update
|
||||||
|
|
||||||
|
- Fix regression in partition discovery on Docker (#1847)
|
||||||
|
|
||||||
|
- Fix agent detection of Podman when using socket proxy (#1846)
|
||||||
|
|
||||||
|
- Fix NVML GPU collection being disabled when `nvidia-smi` is not in PATH (#1849)
|
||||||
|
|
||||||
|
- Reset SMART interval on agent reconnect if the agent hasn't collected SMART data, allowing config changes to take effect immediately
|
||||||
|
|
||||||
## 0.18.5
|
## 0.18.5
|
||||||
|
|
||||||
- Add "update available" notification in hub web UI with `CHECK_UPDATES=true` (#1830)
|
- Add "update available" notification in hub web UI with `CHECK_UPDATES=true` (#1830)
|
||||||
|
|||||||
Reference in New Issue
Block a user