mirror of
https://github.com/henrygd/beszel.git
synced 2026-03-22 13:36:16 +01:00
Compare commits
13 Commits
temp-pve
...
temp-down-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b386ce5190 | ||
|
|
e527534016 | ||
|
|
ec7ad632a9 | ||
|
|
963fce5a33 | ||
|
|
d38c0da06d | ||
|
|
cae6ac4626 | ||
|
|
6b1ff264f2 | ||
|
|
35d0e792ad | ||
|
|
654cd06b19 | ||
|
|
5e1b028130 | ||
|
|
638e7dc12a | ||
|
|
73c262455d | ||
|
|
0c4d2edd45 |
@@ -6,7 +6,6 @@ package agent
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -14,6 +13,7 @@ import (
|
||||
"github.com/gliderlabs/ssh"
|
||||
"github.com/henrygd/beszel"
|
||||
"github.com/henrygd/beszel/agent/deltatracker"
|
||||
"github.com/henrygd/beszel/agent/utils"
|
||||
"github.com/henrygd/beszel/internal/common"
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
gossh "golang.org/x/crypto/ssh"
|
||||
@@ -33,7 +33,6 @@ type Agent struct {
|
||||
netIoStats map[uint16]system.NetIoStats // Keeps track of bandwidth usage per cache interval
|
||||
netInterfaceDeltaTrackers map[uint16]*deltatracker.DeltaTracker[string, uint64] // Per-cache-time NIC delta trackers
|
||||
dockerManager *dockerManager // Manages Docker API requests
|
||||
pveManager *pveManager // Manages Proxmox VE API requests
|
||||
sensorConfig *SensorConfig // Sensors config
|
||||
systemInfo system.Info // Host system info (dynamic)
|
||||
systemDetails system.Details // Host system details (static, once-per-connection)
|
||||
@@ -69,11 +68,11 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
|
||||
slog.Info("Data directory", "path", agent.dataDir)
|
||||
}
|
||||
|
||||
agent.memCalc, _ = GetEnv("MEM_CALC")
|
||||
agent.memCalc, _ = utils.GetEnv("MEM_CALC")
|
||||
agent.sensorConfig = agent.newSensorConfig()
|
||||
|
||||
// Parse disk usage cache duration (e.g., "15m", "1h") to avoid waking sleeping disks
|
||||
if diskUsageCache, exists := GetEnv("DISK_USAGE_CACHE"); exists {
|
||||
if diskUsageCache, exists := utils.GetEnv("DISK_USAGE_CACHE"); exists {
|
||||
if duration, err := time.ParseDuration(diskUsageCache); err == nil {
|
||||
agent.diskUsageCacheDuration = duration
|
||||
slog.Info("DISK_USAGE_CACHE", "duration", duration)
|
||||
@@ -83,7 +82,7 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
|
||||
}
|
||||
|
||||
// Set up slog with a log level determined by the LOG_LEVEL env var
|
||||
if logLevelStr, exists := GetEnv("LOG_LEVEL"); exists {
|
||||
if logLevelStr, exists := utils.GetEnv("LOG_LEVEL"); exists {
|
||||
switch strings.ToLower(logLevelStr) {
|
||||
case "debug":
|
||||
agent.debug = true
|
||||
@@ -100,14 +99,11 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
|
||||
// initialize docker manager
|
||||
agent.dockerManager = newDockerManager()
|
||||
|
||||
// initialize pve manager
|
||||
agent.pveManager = newPVEManager()
|
||||
|
||||
// initialize system info
|
||||
agent.refreshSystemDetails()
|
||||
|
||||
// SMART_INTERVAL env var to update smart data at this interval
|
||||
if smartIntervalEnv, exists := GetEnv("SMART_INTERVAL"); exists {
|
||||
if smartIntervalEnv, exists := utils.GetEnv("SMART_INTERVAL"); exists {
|
||||
if duration, err := time.ParseDuration(smartIntervalEnv); err == nil && duration > 0 {
|
||||
agent.systemDetails.SmartInterval = duration
|
||||
slog.Info("SMART_INTERVAL", "duration", duration)
|
||||
@@ -152,15 +148,6 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
|
||||
return agent, nil
|
||||
}
|
||||
|
||||
// GetEnv retrieves an environment variable with a "BESZEL_AGENT_" prefix, or falls back to the unprefixed key.
|
||||
func GetEnv(key string) (value string, exists bool) {
|
||||
if value, exists = os.LookupEnv("BESZEL_AGENT_" + key); exists {
|
||||
return value, exists
|
||||
}
|
||||
// Fallback to the old unprefixed key
|
||||
return os.LookupEnv(key)
|
||||
}
|
||||
|
||||
func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedData {
|
||||
a.Lock()
|
||||
defer a.Unlock()
|
||||
@@ -193,15 +180,6 @@ func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedD
|
||||
}
|
||||
}
|
||||
|
||||
if a.pveManager != nil {
|
||||
if pveStats, err := a.pveManager.getPVEStats(); err == nil {
|
||||
data.PVEStats = pveStats
|
||||
slog.Debug("PVE", "data", data.PVEStats)
|
||||
} else {
|
||||
slog.Debug("PVE", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// skip updating systemd services if cache time is not the default 60sec interval
|
||||
if a.systemdManager != nil && cacheTimeMs == 60_000 {
|
||||
totalCount := uint16(a.systemdManager.getServiceStatsCount())
|
||||
@@ -226,7 +204,7 @@ func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedD
|
||||
data.Stats.ExtraFs[key] = stats
|
||||
// Add percentages to Info struct for dashboard
|
||||
if stats.DiskTotal > 0 {
|
||||
pct := twoDecimals((stats.DiskUsed / stats.DiskTotal) * 100)
|
||||
pct := utils.TwoDecimals((stats.DiskUsed / stats.DiskTotal) * 100)
|
||||
data.Info.ExtraFsPct[key] = pct
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,10 +14,6 @@ import (
|
||||
)
|
||||
|
||||
func createTestCacheData() *system.CombinedData {
|
||||
var stats = container.Stats{}
|
||||
stats.Name = "test-container"
|
||||
stats.Cpu = 10.5
|
||||
stats.Mem = 1073741824 // 1GB
|
||||
return &system.CombinedData{
|
||||
Stats: system.Stats{
|
||||
Cpu: 50.5,
|
||||
@@ -28,7 +24,10 @@ func createTestCacheData() *system.CombinedData {
|
||||
AgentVersion: "0.12.0",
|
||||
},
|
||||
Containers: []*container.Stats{
|
||||
&stats,
|
||||
{
|
||||
Name: "test-container",
|
||||
Cpu: 25.0,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel"
|
||||
"github.com/henrygd/beszel/agent/utils"
|
||||
"github.com/henrygd/beszel/internal/common"
|
||||
|
||||
"github.com/fxamacker/cbor/v2"
|
||||
@@ -43,7 +44,7 @@ type WebSocketClient struct {
|
||||
// newWebSocketClient creates a new WebSocket client for the given agent.
|
||||
// It reads configuration from environment variables and validates the hub URL.
|
||||
func newWebSocketClient(agent *Agent) (client *WebSocketClient, err error) {
|
||||
hubURLStr, exists := GetEnv("HUB_URL")
|
||||
hubURLStr, exists := utils.GetEnv("HUB_URL")
|
||||
if !exists {
|
||||
return nil, errors.New("HUB_URL environment variable not set")
|
||||
}
|
||||
@@ -72,12 +73,12 @@ func newWebSocketClient(agent *Agent) (client *WebSocketClient, err error) {
|
||||
// If neither is set, it returns an error.
|
||||
func getToken() (string, error) {
|
||||
// get token from env var
|
||||
token, _ := GetEnv("TOKEN")
|
||||
token, _ := utils.GetEnv("TOKEN")
|
||||
if token != "" {
|
||||
return token, nil
|
||||
}
|
||||
// get token from file
|
||||
tokenFile, _ := GetEnv("TOKEN_FILE")
|
||||
tokenFile, _ := utils.GetEnv("TOKEN_FILE")
|
||||
if tokenFile == "" {
|
||||
return "", errors.New("must set TOKEN or TOKEN_FILE")
|
||||
}
|
||||
@@ -197,7 +198,7 @@ func (client *WebSocketClient) handleAuthChallenge(msg *common.HubRequest[cbor.R
|
||||
}
|
||||
|
||||
if authRequest.NeedSysInfo {
|
||||
response.Name, _ = GetEnv("SYSTEM_NAME")
|
||||
response.Name, _ = utils.GetEnv("SYSTEM_NAME")
|
||||
response.Hostname = client.agent.systemDetails.Hostname
|
||||
serverAddr := client.agent.connectionManager.serverOptions.Addr
|
||||
_, response.Port, _ = net.SplitHostPort(serverAddr)
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
|
||||
"github.com/henrygd/beszel/agent/utils"
|
||||
)
|
||||
|
||||
// GetDataDir returns the path to the data directory for the agent and an error
|
||||
@@ -16,7 +18,7 @@ func GetDataDir(dataDirs ...string) (string, error) {
|
||||
return testDataDirs(dataDirs)
|
||||
}
|
||||
|
||||
dataDir, _ := GetEnv("DATA_DIR")
|
||||
dataDir, _ := utils.GetEnv("DATA_DIR")
|
||||
if dataDir != "" {
|
||||
dataDirs = append(dataDirs, dataDir)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/agent/utils"
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
|
||||
"github.com/shirou/gopsutil/v4/disk"
|
||||
@@ -37,7 +38,7 @@ func isDockerSpecialMountpoint(mountpoint string) bool {
|
||||
|
||||
// Sets up the filesystems to monitor for disk usage and I/O.
|
||||
func (a *Agent) initializeDiskInfo() {
|
||||
filesystem, _ := GetEnv("FILESYSTEM")
|
||||
filesystem, _ := utils.GetEnv("FILESYSTEM")
|
||||
efPath := "/extra-filesystems"
|
||||
hasRoot := false
|
||||
isWindows := runtime.GOOS == "windows"
|
||||
@@ -141,7 +142,7 @@ func (a *Agent) initializeDiskInfo() {
|
||||
}
|
||||
|
||||
// Add EXTRA_FILESYSTEMS env var values to fsStats
|
||||
if extraFilesystems, exists := GetEnv("EXTRA_FILESYSTEMS"); exists {
|
||||
if extraFilesystems, exists := utils.GetEnv("EXTRA_FILESYSTEMS"); exists {
|
||||
for fsEntry := range strings.SplitSeq(extraFilesystems, ",") {
|
||||
// Parse custom name from format: device__customname
|
||||
fs, customName := parseFilesystemEntry(fsEntry)
|
||||
@@ -412,12 +413,12 @@ func (a *Agent) updateDiskUsage(systemStats *system.Stats) {
|
||||
continue
|
||||
}
|
||||
if d, err := disk.Usage(stats.Mountpoint); err == nil {
|
||||
stats.DiskTotal = bytesToGigabytes(d.Total)
|
||||
stats.DiskUsed = bytesToGigabytes(d.Used)
|
||||
stats.DiskTotal = utils.BytesToGigabytes(d.Total)
|
||||
stats.DiskUsed = utils.BytesToGigabytes(d.Used)
|
||||
if stats.Root {
|
||||
systemStats.DiskTotal = bytesToGigabytes(d.Total)
|
||||
systemStats.DiskUsed = bytesToGigabytes(d.Used)
|
||||
systemStats.DiskPct = twoDecimals(d.UsedPercent)
|
||||
systemStats.DiskTotal = utils.BytesToGigabytes(d.Total)
|
||||
systemStats.DiskUsed = utils.BytesToGigabytes(d.Used)
|
||||
systemStats.DiskPct = utils.TwoDecimals(d.UsedPercent)
|
||||
}
|
||||
} else {
|
||||
// reset stats if error (likely unmounted)
|
||||
@@ -470,8 +471,8 @@ func (a *Agent) updateDiskIo(cacheTimeMs uint16, systemStats *system.Stats) {
|
||||
|
||||
diskIORead := (d.ReadBytes - prev.readBytes) * 1000 / msElapsed
|
||||
diskIOWrite := (d.WriteBytes - prev.writeBytes) * 1000 / msElapsed
|
||||
readMbPerSecond := bytesToMegabytes(float64(diskIORead))
|
||||
writeMbPerSecond := bytesToMegabytes(float64(diskIOWrite))
|
||||
readMbPerSecond := utils.BytesToMegabytes(float64(diskIORead))
|
||||
writeMbPerSecond := utils.BytesToMegabytes(float64(diskIOWrite))
|
||||
|
||||
// validate values
|
||||
if readMbPerSecond > 50_000 || writeMbPerSecond > 50_000 {
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/agent/deltatracker"
|
||||
"github.com/henrygd/beszel/agent/utils"
|
||||
"github.com/henrygd/beszel/internal/entities/container"
|
||||
|
||||
"github.com/blang/semver"
|
||||
@@ -28,10 +29,8 @@ import (
|
||||
|
||||
// ansiEscapePattern matches ANSI escape sequences (colors, cursor movement, etc.)
|
||||
// This includes CSI sequences like \x1b[...m and simple escapes like \x1b[K
|
||||
var (
|
||||
ansiEscapePattern = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b[@-Z\\-_]`)
|
||||
dockerContainerIDPattern = regexp.MustCompile(`^[a-fA-F0-9]{12,64}$`)
|
||||
)
|
||||
var ansiEscapePattern = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b[@-Z\\-_]`)
|
||||
var dockerContainerIDPattern = regexp.MustCompile(`^[a-fA-F0-9]{12,64}$`)
|
||||
|
||||
const (
|
||||
// Docker API timeout in milliseconds
|
||||
@@ -338,12 +337,12 @@ func validateCpuPercentage(cpuPct float64, containerName string) error {
|
||||
|
||||
// updateContainerStatsValues updates the final stats values
|
||||
func updateContainerStatsValues(stats *container.Stats, cpuPct float64, usedMemory uint64, sent_delta, recv_delta uint64, readTime time.Time) {
|
||||
stats.Cpu = twoDecimals(cpuPct)
|
||||
stats.Mem = bytesToMegabytes(float64(usedMemory))
|
||||
stats.Cpu = utils.TwoDecimals(cpuPct)
|
||||
stats.Mem = utils.BytesToMegabytes(float64(usedMemory))
|
||||
stats.Bandwidth = [2]uint64{sent_delta, recv_delta}
|
||||
// TODO(0.19+): stop populating NetworkSent/NetworkRecv (deprecated in 0.18.3)
|
||||
stats.NetworkSent = bytesToMegabytes(float64(sent_delta))
|
||||
stats.NetworkRecv = bytesToMegabytes(float64(recv_delta))
|
||||
stats.NetworkSent = utils.BytesToMegabytes(float64(sent_delta))
|
||||
stats.NetworkRecv = utils.BytesToMegabytes(float64(recv_delta))
|
||||
stats.PrevReadTime = readTime
|
||||
}
|
||||
|
||||
@@ -397,12 +396,11 @@ func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo, cacheTimeM
|
||||
// add empty values if they doesn't exist in map
|
||||
stats, initialized := dm.containerStatsMap[ctr.IdShort]
|
||||
if !initialized {
|
||||
stats = &container.Stats{Image: ctr.Image}
|
||||
stats = &container.Stats{Name: name, Id: ctr.IdShort, Image: ctr.Image}
|
||||
dm.containerStatsMap[ctr.IdShort] = stats
|
||||
}
|
||||
|
||||
stats.Id = ctr.IdShort
|
||||
stats.Name = name
|
||||
|
||||
statusText, health := parseDockerStatus(ctr.Status)
|
||||
stats.Status = statusText
|
||||
@@ -490,7 +488,7 @@ func (dm *dockerManager) deleteContainerStatsSync(id string) {
|
||||
|
||||
// Creates a new http client for Docker or Podman API
|
||||
func newDockerManager() *dockerManager {
|
||||
dockerHost, exists := GetEnv("DOCKER_HOST")
|
||||
dockerHost, exists := utils.GetEnv("DOCKER_HOST")
|
||||
if exists {
|
||||
// return nil if set to empty string
|
||||
if dockerHost == "" {
|
||||
@@ -526,7 +524,7 @@ func newDockerManager() *dockerManager {
|
||||
|
||||
// configurable timeout
|
||||
timeout := time.Millisecond * time.Duration(dockerTimeoutMs)
|
||||
if t, set := GetEnv("DOCKER_TIMEOUT"); set {
|
||||
if t, set := utils.GetEnv("DOCKER_TIMEOUT"); set {
|
||||
timeout, err = time.ParseDuration(t)
|
||||
if err != nil {
|
||||
slog.Error(err.Error())
|
||||
@@ -543,7 +541,7 @@ func newDockerManager() *dockerManager {
|
||||
|
||||
// Read container exclusion patterns from environment variable
|
||||
var excludeContainers []string
|
||||
if excludeStr, set := GetEnv("EXCLUDE_CONTAINERS"); set && excludeStr != "" {
|
||||
if excludeStr, set := utils.GetEnv("EXCLUDE_CONTAINERS"); set && excludeStr != "" {
|
||||
parts := strings.SplitSeq(excludeStr, ",")
|
||||
for part := range parts {
|
||||
trimmed := strings.TrimSpace(part)
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/agent/deltatracker"
|
||||
"github.com/henrygd/beszel/agent/utils"
|
||||
"github.com/henrygd/beszel/internal/entities/container"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -269,16 +270,17 @@ func TestValidateCpuPercentage(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUpdateContainerStatsValues(t *testing.T) {
|
||||
var stats = container.Stats{}
|
||||
stats.Name = "test-container"
|
||||
stats.Cpu = 0.0
|
||||
stats.Mem = 0.0
|
||||
stats.NetworkSent = 0.0
|
||||
stats.NetworkRecv = 0.0
|
||||
stats.PrevReadTime = time.Time{}
|
||||
stats := &container.Stats{
|
||||
Name: "test-container",
|
||||
Cpu: 0.0,
|
||||
Mem: 0.0,
|
||||
NetworkSent: 0.0,
|
||||
NetworkRecv: 0.0,
|
||||
PrevReadTime: time.Time{},
|
||||
}
|
||||
|
||||
testTime := time.Now()
|
||||
updateContainerStatsValues(&stats, 75.5, 1048576, 524288, 262144, testTime)
|
||||
updateContainerStatsValues(stats, 75.5, 1048576, 524288, 262144, testTime)
|
||||
|
||||
// Check CPU percentage (should be rounded to 2 decimals)
|
||||
assert.Equal(t, 75.5, stats.Cpu)
|
||||
@@ -297,48 +299,6 @@ func TestUpdateContainerStatsValues(t *testing.T) {
|
||||
assert.Equal(t, testTime, stats.PrevReadTime)
|
||||
}
|
||||
|
||||
func TestTwoDecimals(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input float64
|
||||
expected float64
|
||||
}{
|
||||
{"round down", 1.234, 1.23},
|
||||
{"round half up", 1.235, 1.24}, // math.Round rounds half up
|
||||
{"no rounding needed", 1.23, 1.23},
|
||||
{"negative number", -1.235, -1.24}, // math.Round rounds half up (more negative)
|
||||
{"zero", 0.0, 0.0},
|
||||
{"large number", 123.456, 123.46}, // rounds 5 up
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := twoDecimals(tt.input)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBytesToMegabytes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input float64
|
||||
expected float64
|
||||
}{
|
||||
{"1 MB", 1048576, 1.0},
|
||||
{"512 KB", 524288, 0.5},
|
||||
{"zero", 0, 0},
|
||||
{"large value", 1073741824, 1024}, // 1 GB = 1024 MB
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := bytesToMegabytes(tt.input)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitializeCpuTracking(t *testing.T) {
|
||||
dm := &dockerManager{
|
||||
lastCpuContainer: make(map[uint16]map[string]uint64),
|
||||
@@ -445,11 +405,12 @@ func TestCalculateNetworkStats(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
var stats = container.Stats{}
|
||||
stats.PrevReadTime = time.Now().Add(-time.Second) // 1 second ago
|
||||
stats := &container.Stats{
|
||||
PrevReadTime: time.Now().Add(-time.Second), // 1 second ago
|
||||
}
|
||||
|
||||
// Test with initialized container
|
||||
sent, recv := dm.calculateNetworkStats(ctr, apiStats, &stats, true, "test-container", cacheTimeMs)
|
||||
sent, recv := dm.calculateNetworkStats(ctr, apiStats, stats, true, "test-container", cacheTimeMs)
|
||||
|
||||
// Should return calculated byte rates per second
|
||||
assert.GreaterOrEqual(t, sent, uint64(0))
|
||||
@@ -458,7 +419,7 @@ func TestCalculateNetworkStats(t *testing.T) {
|
||||
// Cycle and test one-direction change (Tx only) is reflected independently
|
||||
dm.cycleNetworkDeltasForCacheTime(cacheTimeMs)
|
||||
apiStats.Networks["eth0"] = container.NetworkStats{TxBytes: 2500, RxBytes: 1800} // +500 Tx only
|
||||
sent, recv = dm.calculateNetworkStats(ctr, apiStats, &stats, true, "test-container", cacheTimeMs)
|
||||
sent, recv = dm.calculateNetworkStats(ctr, apiStats, stats, true, "test-container", cacheTimeMs)
|
||||
assert.Greater(t, sent, uint64(0))
|
||||
assert.Equal(t, uint64(0), recv)
|
||||
}
|
||||
@@ -724,8 +685,7 @@ func TestMemoryStatsEdgeCases(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestContainerStatsInitialization(t *testing.T) {
|
||||
var stats = container.Stats{}
|
||||
stats.Name = "test-container"
|
||||
stats := &container.Stats{Name: "test-container"}
|
||||
|
||||
// Verify initial values
|
||||
assert.Equal(t, "test-container", stats.Name)
|
||||
@@ -737,7 +697,7 @@ func TestContainerStatsInitialization(t *testing.T) {
|
||||
|
||||
// Test updating values
|
||||
testTime := time.Now()
|
||||
updateContainerStatsValues(&stats, 45.67, 2097152, 1048576, 524288, testTime)
|
||||
updateContainerStatsValues(stats, 45.67, 2097152, 1048576, 524288, testTime)
|
||||
|
||||
assert.Equal(t, 45.67, stats.Cpu)
|
||||
assert.Equal(t, 2.0, stats.Mem)
|
||||
@@ -815,11 +775,12 @@ func TestNetworkStatsCalculationWithRealData(t *testing.T) {
|
||||
|
||||
// Use exact timing for deterministic results
|
||||
exactly1000msAgo := time.Now().Add(-1000 * time.Millisecond)
|
||||
var stats = container.Stats{}
|
||||
stats.PrevReadTime = exactly1000msAgo
|
||||
stats := &container.Stats{
|
||||
PrevReadTime: exactly1000msAgo,
|
||||
}
|
||||
|
||||
// First call sets baseline
|
||||
sent1, recv1 := dm.calculateNetworkStats(ctr, apiStats1, &stats, true, "test", cacheTimeMs)
|
||||
sent1, recv1 := dm.calculateNetworkStats(ctr, apiStats1, stats, true, "test", cacheTimeMs)
|
||||
assert.Equal(t, uint64(0), sent1)
|
||||
assert.Equal(t, uint64(0), recv1)
|
||||
|
||||
@@ -834,7 +795,7 @@ func TestNetworkStatsCalculationWithRealData(t *testing.T) {
|
||||
expectedRecvRate := deltaRecv * 1000 / expectedElapsedMs // Should be exactly 1000000
|
||||
|
||||
// Second call with changed data
|
||||
sent2, recv2 := dm.calculateNetworkStats(ctr, apiStats2, &stats, true, "test", cacheTimeMs)
|
||||
sent2, recv2 := dm.calculateNetworkStats(ctr, apiStats2, stats, true, "test", cacheTimeMs)
|
||||
|
||||
// Should be exactly the expected rates (no tolerance needed)
|
||||
assert.Equal(t, expectedSentRate, sent2)
|
||||
@@ -845,9 +806,9 @@ func TestNetworkStatsCalculationWithRealData(t *testing.T) {
|
||||
stats.PrevReadTime = time.Now().Add(-1 * time.Millisecond)
|
||||
apiStats1.Networks["eth0"] = container.NetworkStats{TxBytes: 0, RxBytes: 0}
|
||||
apiStats2.Networks["eth0"] = container.NetworkStats{TxBytes: 10 * 1024 * 1024 * 1024, RxBytes: 0} // 10GB delta
|
||||
_, _ = dm.calculateNetworkStats(ctr, apiStats1, &stats, true, "test", cacheTimeMs) // baseline
|
||||
_, _ = dm.calculateNetworkStats(ctr, apiStats1, stats, true, "test", cacheTimeMs) // baseline
|
||||
dm.cycleNetworkDeltasForCacheTime(cacheTimeMs)
|
||||
sent3, recv3 := dm.calculateNetworkStats(ctr, apiStats2, &stats, true, "test", cacheTimeMs)
|
||||
sent3, recv3 := dm.calculateNetworkStats(ctr, apiStats2, stats, true, "test", cacheTimeMs)
|
||||
assert.Equal(t, uint64(0), sent3)
|
||||
assert.Equal(t, uint64(0), recv3)
|
||||
}
|
||||
@@ -881,9 +842,8 @@ func TestContainerStatsEndToEndWithRealData(t *testing.T) {
|
||||
}
|
||||
|
||||
// Initialize container stats
|
||||
var stats = container.Stats{}
|
||||
stats.Name = "jellyfin"
|
||||
dm.containerStatsMap[ctr.IdShort] = &stats
|
||||
stats := &container.Stats{Name: "jellyfin"}
|
||||
dm.containerStatsMap[ctr.IdShort] = stats
|
||||
|
||||
// Test individual components that we can verify
|
||||
usedMemory, memErr := calculateMemoryUsage(&apiStats, false)
|
||||
@@ -904,11 +864,11 @@ func TestContainerStatsEndToEndWithRealData(t *testing.T) {
|
||||
updateContainerStatsValues(testStats, cpuPct, usedMemory, 1000000, 500000, testTime)
|
||||
|
||||
assert.Equal(t, cpuPct, testStats.Cpu)
|
||||
assert.Equal(t, bytesToMegabytes(float64(usedMemory)), testStats.Mem)
|
||||
assert.Equal(t, utils.BytesToMegabytes(float64(usedMemory)), testStats.Mem)
|
||||
assert.Equal(t, [2]uint64{1000000, 500000}, testStats.Bandwidth)
|
||||
// Deprecated fields still populated for backward compatibility with older hubs
|
||||
assert.Equal(t, bytesToMegabytes(1000000), testStats.NetworkSent)
|
||||
assert.Equal(t, bytesToMegabytes(500000), testStats.NetworkRecv)
|
||||
assert.Equal(t, utils.BytesToMegabytes(1000000), testStats.NetworkSent)
|
||||
assert.Equal(t, utils.BytesToMegabytes(500000), testStats.NetworkRecv)
|
||||
assert.Equal(t, testTime, testStats.PrevReadTime)
|
||||
}
|
||||
|
||||
@@ -1189,13 +1149,13 @@ func TestConstantsAndUtilityFunctions(t *testing.T) {
|
||||
assert.Equal(t, 5*1024*1024, maxTotalLogSize) // 5MB
|
||||
|
||||
// Test utility functions
|
||||
assert.Equal(t, 1.5, twoDecimals(1.499))
|
||||
assert.Equal(t, 1.5, twoDecimals(1.5))
|
||||
assert.Equal(t, 1.5, twoDecimals(1.501))
|
||||
assert.Equal(t, 1.5, utils.TwoDecimals(1.499))
|
||||
assert.Equal(t, 1.5, utils.TwoDecimals(1.5))
|
||||
assert.Equal(t, 1.5, utils.TwoDecimals(1.501))
|
||||
|
||||
assert.Equal(t, 1.0, bytesToMegabytes(1048576)) // 1 MB
|
||||
assert.Equal(t, 0.5, bytesToMegabytes(524288)) // 512 KB
|
||||
assert.Equal(t, 0.0, bytesToMegabytes(0))
|
||||
assert.Equal(t, 1.0, utils.BytesToMegabytes(1048576)) // 1 MB
|
||||
assert.Equal(t, 0.5, utils.BytesToMegabytes(524288)) // 512 KB
|
||||
assert.Equal(t, 0.0, utils.BytesToMegabytes(0))
|
||||
}
|
||||
|
||||
func TestDecodeDockerLogStream(t *testing.T) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/henrygd/beszel/agent/utils"
|
||||
"github.com/henrygd/beszel/internal/entities/smart"
|
||||
)
|
||||
|
||||
@@ -141,9 +142,9 @@ func readEmmcHealth(blockName string) (emmcHealth, bool) {
|
||||
out.lifeA = lifeA
|
||||
out.lifeB = lifeB
|
||||
|
||||
out.model = readStringFile(filepath.Join(deviceDir, "name"))
|
||||
out.serial = readStringFile(filepath.Join(deviceDir, "serial"))
|
||||
out.revision = readStringFile(filepath.Join(deviceDir, "prv"))
|
||||
out.model = utils.ReadStringFile(filepath.Join(deviceDir, "name"))
|
||||
out.serial = utils.ReadStringFile(filepath.Join(deviceDir, "serial"))
|
||||
out.revision = utils.ReadStringFile(filepath.Join(deviceDir, "prv"))
|
||||
|
||||
if capBytes, ok := readBlockCapacityBytes(blockName); ok {
|
||||
out.capacity = capBytes
|
||||
@@ -153,7 +154,7 @@ func readEmmcHealth(blockName string) (emmcHealth, bool) {
|
||||
}
|
||||
|
||||
func readLifeTime(deviceDir string) (uint8, uint8, bool) {
|
||||
if content, ok := readStringFileOK(filepath.Join(deviceDir, "life_time")); ok {
|
||||
if content, ok := utils.ReadStringFileOK(filepath.Join(deviceDir, "life_time")); ok {
|
||||
a, b, ok := parseHexBytePair(content)
|
||||
return a, b, ok
|
||||
}
|
||||
@@ -170,7 +171,7 @@ func readBlockCapacityBytes(blockName string) (uint64, bool) {
|
||||
sizePath := filepath.Join(emmcSysfsRoot, "class", "block", blockName, "size")
|
||||
lbsPath := filepath.Join(emmcSysfsRoot, "class", "block", blockName, "queue", "logical_block_size")
|
||||
|
||||
sizeStr, ok := readStringFileOK(sizePath)
|
||||
sizeStr, ok := utils.ReadStringFileOK(sizePath)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
@@ -179,7 +180,7 @@ func readBlockCapacityBytes(blockName string) (uint64, bool) {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
lbsStr, ok := readStringFileOK(lbsPath)
|
||||
lbsStr, ok := utils.ReadStringFileOK(lbsPath)
|
||||
logicalBlockSize := uint64(512)
|
||||
if ok {
|
||||
if parsed, err := strconv.ParseUint(lbsStr, 10, 64); err == nil && parsed > 0 {
|
||||
@@ -191,7 +192,7 @@ func readBlockCapacityBytes(blockName string) (uint64, bool) {
|
||||
}
|
||||
|
||||
func readHexByteFile(path string) (uint8, bool) {
|
||||
content, ok := readStringFileOK(path)
|
||||
content, ok := utils.ReadStringFileOK(path)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// readStringFile returns trimmed file contents or empty string on error.
|
||||
func readStringFile(path string) string {
|
||||
content, _ := readStringFileOK(path)
|
||||
return content
|
||||
}
|
||||
|
||||
// readStringFileOK returns trimmed file contents and read success.
|
||||
func readStringFileOK(path string) (string, bool) {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return strings.TrimSpace(string(b)), true
|
||||
}
|
||||
|
||||
// fileExists reports whether the given path exists.
|
||||
func fileExists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// readUintFile parses a decimal uint64 value from a file.
|
||||
func readUintFile(path string) (uint64, bool) {
|
||||
raw, ok := readStringFileOK(path)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
parsed, err := strconv.ParseUint(raw, 10, 64)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return parsed, true
|
||||
}
|
||||
29
agent/gpu.go
29
agent/gpu.go
@@ -15,6 +15,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/agent/utils"
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
)
|
||||
|
||||
@@ -291,8 +292,8 @@ func (gm *GPUManager) parseAmdData(output []byte) bool {
|
||||
}
|
||||
gpu := gm.GpuDataMap[id]
|
||||
gpu.Temperature, _ = strconv.ParseFloat(v.Temperature, 64)
|
||||
gpu.MemoryUsed = bytesToMegabytes(memoryUsage)
|
||||
gpu.MemoryTotal = bytesToMegabytes(totalMemory)
|
||||
gpu.MemoryUsed = utils.BytesToMegabytes(memoryUsage)
|
||||
gpu.MemoryTotal = utils.BytesToMegabytes(totalMemory)
|
||||
gpu.Usage += usage
|
||||
gpu.Power += power
|
||||
gpu.Count++
|
||||
@@ -366,16 +367,16 @@ func (gm *GPUManager) calculateGPUAverage(id string, gpu *system.GPUData, cacheK
|
||||
gpuAvg := *gpu
|
||||
deltaUsage, deltaPower, deltaPowerPkg := gm.calculateDeltas(gpu, lastSnapshot)
|
||||
|
||||
gpuAvg.Power = twoDecimals(deltaPower / float64(deltaCount))
|
||||
gpuAvg.Power = utils.TwoDecimals(deltaPower / float64(deltaCount))
|
||||
|
||||
if gpu.Engines != nil {
|
||||
// make fresh map for averaged engine metrics to avoid mutating
|
||||
// the accumulator map stored in gm.GpuDataMap
|
||||
gpuAvg.Engines = make(map[string]float64, len(gpu.Engines))
|
||||
gpuAvg.Usage = gm.calculateIntelGPUUsage(&gpuAvg, gpu, lastSnapshot, deltaCount)
|
||||
gpuAvg.PowerPkg = twoDecimals(deltaPowerPkg / float64(deltaCount))
|
||||
gpuAvg.PowerPkg = utils.TwoDecimals(deltaPowerPkg / float64(deltaCount))
|
||||
} else {
|
||||
gpuAvg.Usage = twoDecimals(deltaUsage / float64(deltaCount))
|
||||
gpuAvg.Usage = utils.TwoDecimals(deltaUsage / float64(deltaCount))
|
||||
}
|
||||
|
||||
gm.lastAvgData[id] = gpuAvg
|
||||
@@ -410,17 +411,17 @@ func (gm *GPUManager) calculateIntelGPUUsage(gpuAvg, gpu *system.GPUData, lastSn
|
||||
} else {
|
||||
deltaEngine = engine
|
||||
}
|
||||
gpuAvg.Engines[name] = twoDecimals(deltaEngine / float64(deltaCount))
|
||||
gpuAvg.Engines[name] = utils.TwoDecimals(deltaEngine / float64(deltaCount))
|
||||
maxEngineUsage = max(maxEngineUsage, deltaEngine/float64(deltaCount))
|
||||
}
|
||||
return twoDecimals(maxEngineUsage)
|
||||
return utils.TwoDecimals(maxEngineUsage)
|
||||
}
|
||||
|
||||
// updateInstantaneousValues updates values that should reflect current state, not averages
|
||||
func (gm *GPUManager) updateInstantaneousValues(gpuAvg *system.GPUData, gpu *system.GPUData) {
|
||||
gpuAvg.Temperature = twoDecimals(gpu.Temperature)
|
||||
gpuAvg.MemoryUsed = twoDecimals(gpu.MemoryUsed)
|
||||
gpuAvg.MemoryTotal = twoDecimals(gpu.MemoryTotal)
|
||||
gpuAvg.Temperature = utils.TwoDecimals(gpu.Temperature)
|
||||
gpuAvg.MemoryUsed = utils.TwoDecimals(gpu.MemoryUsed)
|
||||
gpuAvg.MemoryTotal = utils.TwoDecimals(gpu.MemoryTotal)
|
||||
}
|
||||
|
||||
// storeSnapshot saves the current GPU state for this cache key
|
||||
@@ -687,7 +688,7 @@ func (gm *GPUManager) resolveLegacyCollectorPriority(caps gpuCapabilities) []col
|
||||
priorities := make([]collectorSource, 0, 4)
|
||||
|
||||
if caps.hasNvidiaSmi && !caps.hasTegrastats {
|
||||
if nvml, _ := GetEnv("NVML"); nvml == "true" {
|
||||
if nvml, _ := utils.GetEnv("NVML"); nvml == "true" {
|
||||
priorities = append(priorities, collectorSourceNVML, collectorSourceNvidiaSMI)
|
||||
} else {
|
||||
priorities = append(priorities, collectorSourceNvidiaSMI)
|
||||
@@ -695,7 +696,7 @@ func (gm *GPUManager) resolveLegacyCollectorPriority(caps gpuCapabilities) []col
|
||||
}
|
||||
|
||||
if caps.hasRocmSmi {
|
||||
if val, _ := GetEnv("AMD_SYSFS"); val == "true" {
|
||||
if val, _ := utils.GetEnv("AMD_SYSFS"); val == "true" {
|
||||
priorities = append(priorities, collectorSourceAmdSysfs)
|
||||
} else {
|
||||
priorities = append(priorities, collectorSourceRocmSMI)
|
||||
@@ -728,7 +729,7 @@ func (gm *GPUManager) resolveLegacyCollectorPriority(caps gpuCapabilities) []col
|
||||
|
||||
// NewGPUManager creates and initializes a new GPUManager
|
||||
func NewGPUManager() (*GPUManager, error) {
|
||||
if skipGPU, _ := GetEnv("SKIP_GPU"); skipGPU == "true" {
|
||||
if skipGPU, _ := utils.GetEnv("SKIP_GPU"); skipGPU == "true" {
|
||||
return nil, nil
|
||||
}
|
||||
var gm GPUManager
|
||||
@@ -745,7 +746,7 @@ func NewGPUManager() (*GPUManager, error) {
|
||||
}
|
||||
|
||||
// if GPU_COLLECTOR is set, start user-defined collectors.
|
||||
if collectorConfig, ok := GetEnv("GPU_COLLECTOR"); ok && strings.TrimSpace(collectorConfig) != "" {
|
||||
if collectorConfig, ok := utils.GetEnv("GPU_COLLECTOR"); ok && strings.TrimSpace(collectorConfig) != "" {
|
||||
priorities := parseCollectorPriority(collectorConfig)
|
||||
if gm.startCollectorsByPriority(priorities, caps) == 0 {
|
||||
return nil, fmt.Errorf("no configured GPU collectors are available")
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/agent/utils"
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
)
|
||||
|
||||
@@ -32,8 +33,8 @@ func (gm *GPUManager) hasAmdSysfs() bool {
|
||||
return false
|
||||
}
|
||||
for _, vendorPath := range cards {
|
||||
vendor, err := os.ReadFile(vendorPath)
|
||||
if err == nil && strings.TrimSpace(string(vendor)) == "0x1002" {
|
||||
vendor, err := utils.ReadStringFileLimited(vendorPath, 64)
|
||||
if err == nil && vendor == "0x1002" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -87,12 +88,11 @@ func (gm *GPUManager) collectAmdStats() error {
|
||||
|
||||
// isAmdGpu checks whether a DRM card path belongs to AMD vendor ID 0x1002.
|
||||
func isAmdGpu(cardPath string) bool {
|
||||
vendorPath := filepath.Join(cardPath, "device/vendor")
|
||||
vendor, err := os.ReadFile(vendorPath)
|
||||
vendor, err := utils.ReadStringFileLimited(filepath.Join(cardPath, "device/vendor"), 64)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(string(vendor)) == "0x1002"
|
||||
return vendor == "0x1002"
|
||||
}
|
||||
|
||||
// updateAmdGpuData reads GPU metrics from sysfs and updates the GPU data map.
|
||||
@@ -144,8 +144,8 @@ func (gm *GPUManager) updateAmdGpuData(cardPath string) bool {
|
||||
if usageErr == nil {
|
||||
gpu.Usage += usage
|
||||
}
|
||||
gpu.MemoryUsed = bytesToMegabytes(memUsed)
|
||||
gpu.MemoryTotal = bytesToMegabytes(memTotal)
|
||||
gpu.MemoryUsed = utils.BytesToMegabytes(memUsed)
|
||||
gpu.MemoryTotal = utils.BytesToMegabytes(memTotal)
|
||||
gpu.Temperature = temp
|
||||
gpu.Power += power
|
||||
gpu.Count++
|
||||
@@ -154,11 +154,11 @@ func (gm *GPUManager) updateAmdGpuData(cardPath string) bool {
|
||||
|
||||
// readSysfsFloat reads and parses a numeric value from a sysfs file.
|
||||
func readSysfsFloat(path string) (float64, error) {
|
||||
val, err := os.ReadFile(path)
|
||||
val, err := utils.ReadStringFileLimited(path, 64)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return strconv.ParseFloat(strings.TrimSpace(string(val)), 64)
|
||||
return strconv.ParseFloat(val, 64)
|
||||
}
|
||||
|
||||
// normalizeHexID normalizes hex IDs by trimming spaces, lowercasing, and dropping 0x.
|
||||
@@ -273,16 +273,16 @@ func cacheMissingAmdgpuName(deviceID, revisionID string) {
|
||||
// Falls back to showing the raw device ID if not found in the lookup table.
|
||||
func getAmdGpuName(devicePath string) string {
|
||||
// Try product_name first (works for some enterprise GPUs)
|
||||
if prod, err := os.ReadFile(filepath.Join(devicePath, "product_name")); err == nil {
|
||||
return strings.TrimSpace(string(prod))
|
||||
if prod, err := utils.ReadStringFileLimited(filepath.Join(devicePath, "product_name"), 128); err == nil {
|
||||
return prod
|
||||
}
|
||||
|
||||
// Read PCI device ID and look it up
|
||||
if deviceID, err := os.ReadFile(filepath.Join(devicePath, "device")); err == nil {
|
||||
id := normalizeHexID(string(deviceID))
|
||||
if deviceID, err := utils.ReadStringFileLimited(filepath.Join(devicePath, "device"), 64); err == nil {
|
||||
id := normalizeHexID(deviceID)
|
||||
revision := ""
|
||||
if revBytes, revErr := os.ReadFile(filepath.Join(devicePath, "revision")); revErr == nil {
|
||||
revision = normalizeHexID(string(revBytes))
|
||||
if rev, revErr := utils.ReadStringFileLimited(filepath.Join(devicePath, "revision"), 64); revErr == nil {
|
||||
revision = normalizeHexID(rev)
|
||||
}
|
||||
|
||||
if name, found, done := getCachedAmdgpuName(id, revision); found {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/henrygd/beszel/agent/utils"
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -128,14 +129,14 @@ func TestUpdateAmdGpuDataWithFakeSysfs(t *testing.T) {
|
||||
{
|
||||
name: "sums vram and gtt when gtt is present",
|
||||
writeGTT: true,
|
||||
wantMemoryUsed: bytesToMegabytes(1073741824 + 536870912),
|
||||
wantMemoryTotal: bytesToMegabytes(2147483648 + 4294967296),
|
||||
wantMemoryUsed: utils.BytesToMegabytes(1073741824 + 536870912),
|
||||
wantMemoryTotal: utils.BytesToMegabytes(2147483648 + 4294967296),
|
||||
},
|
||||
{
|
||||
name: "falls back to vram when gtt is missing",
|
||||
writeGTT: false,
|
||||
wantMemoryUsed: bytesToMegabytes(1073741824),
|
||||
wantMemoryTotal: bytesToMegabytes(2147483648),
|
||||
wantMemoryUsed: utils.BytesToMegabytes(1073741824),
|
||||
wantMemoryTotal: utils.BytesToMegabytes(2147483648),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/henrygd/beszel/agent/utils"
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
)
|
||||
|
||||
@@ -52,7 +53,7 @@ func (gm *GPUManager) updateIntelFromStats(sample *intelGpuStats) bool {
|
||||
func (gm *GPUManager) collectIntelStats() (err error) {
|
||||
// Build command arguments, optionally selecting a device via -d
|
||||
args := []string{"-s", intelGpuStatsInterval, "-l"}
|
||||
if dev, ok := GetEnv("INTEL_GPU_DEVICE"); ok && dev != "" {
|
||||
if dev, ok := utils.GetEnv("INTEL_GPU_DEVICE"); ok && dev != "" {
|
||||
args = append(args, "-d", dev)
|
||||
}
|
||||
cmd := exec.Command(intelGpuStatsCmd, args...)
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/agent/utils"
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
)
|
||||
|
||||
@@ -80,10 +81,10 @@ func (gm *GPUManager) updateNvtopSnapshots(snapshots []nvtopSnapshot) bool {
|
||||
gpu.Temperature = parseNvtopNumber(*sample.Temp)
|
||||
}
|
||||
if sample.MemUsed != nil {
|
||||
gpu.MemoryUsed = bytesToMegabytes(parseNvtopNumber(*sample.MemUsed))
|
||||
gpu.MemoryUsed = utils.BytesToMegabytes(parseNvtopNumber(*sample.MemUsed))
|
||||
}
|
||||
if sample.MemTotal != nil {
|
||||
gpu.MemoryTotal = bytesToMegabytes(parseNvtopNumber(*sample.MemTotal))
|
||||
gpu.MemoryTotal = utils.BytesToMegabytes(parseNvtopNumber(*sample.MemTotal))
|
||||
}
|
||||
if sample.GpuUtil != nil {
|
||||
gpu.Usage += parseNvtopNumber(*sample.GpuUtil)
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/agent/utils"
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -265,8 +266,8 @@ func TestParseNvtopData(t *testing.T) {
|
||||
assert.Equal(t, 48.0, g0.Temperature)
|
||||
assert.Equal(t, 5.0, g0.Usage)
|
||||
assert.Equal(t, 13.0, g0.Power)
|
||||
assert.Equal(t, bytesToMegabytes(349372416), g0.MemoryUsed)
|
||||
assert.Equal(t, bytesToMegabytes(4294967296), g0.MemoryTotal)
|
||||
assert.Equal(t, utils.BytesToMegabytes(349372416), g0.MemoryUsed)
|
||||
assert.Equal(t, utils.BytesToMegabytes(4294967296), g0.MemoryTotal)
|
||||
assert.Equal(t, 1.0, g0.Count)
|
||||
|
||||
g1, ok := gm.GpuDataMap["n1"]
|
||||
@@ -275,8 +276,8 @@ func TestParseNvtopData(t *testing.T) {
|
||||
assert.Equal(t, 48.0, g1.Temperature)
|
||||
assert.Equal(t, 12.0, g1.Usage)
|
||||
assert.Equal(t, 9.0, g1.Power)
|
||||
assert.Equal(t, bytesToMegabytes(1213784064), g1.MemoryUsed)
|
||||
assert.Equal(t, bytesToMegabytes(16929173504), g1.MemoryTotal)
|
||||
assert.Equal(t, utils.BytesToMegabytes(1213784064), g1.MemoryUsed)
|
||||
assert.Equal(t, utils.BytesToMegabytes(16929173504), g1.MemoryTotal)
|
||||
assert.Equal(t, 1.0, g1.Count)
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/henrygd/beszel/agent/utils"
|
||||
"github.com/henrygd/beszel/internal/entities/smart"
|
||||
)
|
||||
|
||||
@@ -42,7 +43,7 @@ func scanMdraidDevices() []*DeviceInfo {
|
||||
continue
|
||||
}
|
||||
mdDir := filepath.Join(blockDir, name, "md")
|
||||
if !fileExists(filepath.Join(mdDir, "array_state")) {
|
||||
if !utils.FileExists(filepath.Join(mdDir, "array_state")) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -134,24 +135,24 @@ func readMdraidHealth(blockName string) (mdraidHealth, bool) {
|
||||
}
|
||||
|
||||
mdDir := filepath.Join(mdraidSysfsRoot, "block", blockName, "md")
|
||||
arrayState, okState := readStringFileOK(filepath.Join(mdDir, "array_state"))
|
||||
arrayState, okState := utils.ReadStringFileOK(filepath.Join(mdDir, "array_state"))
|
||||
if !okState {
|
||||
return out, false
|
||||
}
|
||||
|
||||
out.arrayState = arrayState
|
||||
out.level = readStringFile(filepath.Join(mdDir, "level"))
|
||||
out.syncAction = readStringFile(filepath.Join(mdDir, "sync_action"))
|
||||
out.syncCompleted = readStringFile(filepath.Join(mdDir, "sync_completed"))
|
||||
out.syncSpeed = readStringFile(filepath.Join(mdDir, "sync_speed"))
|
||||
out.level = utils.ReadStringFile(filepath.Join(mdDir, "level"))
|
||||
out.syncAction = utils.ReadStringFile(filepath.Join(mdDir, "sync_action"))
|
||||
out.syncCompleted = utils.ReadStringFile(filepath.Join(mdDir, "sync_completed"))
|
||||
out.syncSpeed = utils.ReadStringFile(filepath.Join(mdDir, "sync_speed"))
|
||||
|
||||
if val, ok := readUintFile(filepath.Join(mdDir, "raid_disks")); ok {
|
||||
if val, ok := utils.ReadUintFile(filepath.Join(mdDir, "raid_disks")); ok {
|
||||
out.raidDisks = val
|
||||
}
|
||||
if val, ok := readUintFile(filepath.Join(mdDir, "degraded")); ok {
|
||||
if val, ok := utils.ReadUintFile(filepath.Join(mdDir, "degraded")); ok {
|
||||
out.degraded = val
|
||||
}
|
||||
if val, ok := readUintFile(filepath.Join(mdDir, "mismatch_cnt")); ok {
|
||||
if val, ok := utils.ReadUintFile(filepath.Join(mdDir, "mismatch_cnt")); ok {
|
||||
out.mismatchCnt = val
|
||||
}
|
||||
|
||||
@@ -169,11 +170,18 @@ func mdraidSmartStatus(health mdraidHealth) string {
|
||||
case "inactive", "faulty", "broken", "stopped":
|
||||
return "FAILED"
|
||||
}
|
||||
// During rebuild/recovery, arrays are often temporarily degraded; report as
|
||||
// warning instead of hard failure while synchronization is in progress.
|
||||
syncAction := strings.ToLower(strings.TrimSpace(health.syncAction))
|
||||
switch syncAction {
|
||||
case "resync", "recover", "reshape":
|
||||
return "WARNING"
|
||||
}
|
||||
if health.degraded > 0 {
|
||||
return "FAILED"
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(health.syncAction)) {
|
||||
case "resync", "recover", "reshape", "check", "repair":
|
||||
switch syncAction {
|
||||
case "check", "repair":
|
||||
return "WARNING"
|
||||
}
|
||||
switch state {
|
||||
@@ -205,7 +213,7 @@ func readMdraidBlockCapacityBytes(blockName, root string) (uint64, bool) {
|
||||
sizePath := filepath.Join(root, "block", blockName, "size")
|
||||
lbsPath := filepath.Join(root, "block", blockName, "queue", "logical_block_size")
|
||||
|
||||
sizeStr, ok := readStringFileOK(sizePath)
|
||||
sizeStr, ok := utils.ReadStringFileOK(sizePath)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
@@ -215,7 +223,7 @@ func readMdraidBlockCapacityBytes(blockName, root string) (uint64, bool) {
|
||||
}
|
||||
|
||||
logicalBlockSize := uint64(512)
|
||||
if lbsStr, ok := readStringFileOK(lbsPath); ok {
|
||||
if lbsStr, ok := utils.ReadStringFileOK(lbsPath); ok {
|
||||
if parsed, err := strconv.ParseUint(lbsStr, 10, 64); err == nil && parsed > 0 {
|
||||
logicalBlockSize = parsed
|
||||
}
|
||||
|
||||
@@ -85,6 +85,9 @@ func TestMdraidSmartStatus(t *testing.T) {
|
||||
if got := mdraidSmartStatus(mdraidHealth{arrayState: "inactive"}); got != "FAILED" {
|
||||
t.Fatalf("mdraidSmartStatus(inactive) = %q, want FAILED", got)
|
||||
}
|
||||
if got := mdraidSmartStatus(mdraidHealth{arrayState: "active", degraded: 1, syncAction: "recover"}); got != "WARNING" {
|
||||
t.Fatalf("mdraidSmartStatus(degraded+recover) = %q, want WARNING", got)
|
||||
}
|
||||
if got := mdraidSmartStatus(mdraidHealth{arrayState: "active", degraded: 1}); got != "FAILED" {
|
||||
t.Fatalf("mdraidSmartStatus(degraded) = %q, want FAILED", got)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/agent/deltatracker"
|
||||
"github.com/henrygd/beszel/agent/utils"
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
psutilNet "github.com/shirou/gopsutil/v4/net"
|
||||
)
|
||||
@@ -94,7 +95,7 @@ func (a *Agent) initializeNetIoStats() {
|
||||
a.netInterfaces = make(map[string]struct{}, 0)
|
||||
|
||||
// parse NICS env var for whitelist / blacklist
|
||||
nicsEnvVal, nicsEnvExists := GetEnv("NICS")
|
||||
nicsEnvVal, nicsEnvExists := utils.GetEnv("NICS")
|
||||
var nicCfg *NicConfig
|
||||
if nicsEnvExists {
|
||||
nicCfg = newNicConfig(nicsEnvVal)
|
||||
@@ -103,10 +104,7 @@ func (a *Agent) initializeNetIoStats() {
|
||||
// get current network I/O stats and record valid interfaces
|
||||
if netIO, err := psutilNet.IOCounters(true); err == nil {
|
||||
for _, v := range netIO {
|
||||
if nicsEnvExists && !isValidNic(v.Name, nicCfg) {
|
||||
continue
|
||||
}
|
||||
if a.skipNetworkInterface(v) {
|
||||
if skipNetworkInterface(v, nicCfg) {
|
||||
continue
|
||||
}
|
||||
slog.Info("Detected network interface", "name", v.Name, "sent", v.BytesSent, "recv", v.BytesRecv)
|
||||
@@ -215,10 +213,8 @@ func (a *Agent) applyNetworkTotals(
|
||||
totalBytesSent, totalBytesRecv uint64,
|
||||
bytesSentPerSecond, bytesRecvPerSecond uint64,
|
||||
) {
|
||||
networkSentPs := bytesToMegabytes(float64(bytesSentPerSecond))
|
||||
networkRecvPs := bytesToMegabytes(float64(bytesRecvPerSecond))
|
||||
if networkSentPs > 10_000 || networkRecvPs > 10_000 {
|
||||
slog.Warn("Invalid net stats. Resetting.", "sent", networkSentPs, "recv", networkRecvPs)
|
||||
if bytesSentPerSecond > 10_000_000_000 || bytesRecvPerSecond > 10_000_000_000 {
|
||||
slog.Warn("Invalid net stats. Resetting.", "sent", bytesSentPerSecond, "recv", bytesRecvPerSecond)
|
||||
for _, v := range netIO {
|
||||
if _, exists := a.netInterfaces[v.Name]; !exists {
|
||||
continue
|
||||
@@ -228,21 +224,29 @@ func (a *Agent) applyNetworkTotals(
|
||||
a.initializeNetIoStats()
|
||||
delete(a.netIoStats, cacheTimeMs)
|
||||
delete(a.netInterfaceDeltaTrackers, cacheTimeMs)
|
||||
systemStats.NetworkSent = 0
|
||||
systemStats.NetworkRecv = 0
|
||||
systemStats.Bandwidth[0], systemStats.Bandwidth[1] = 0, 0
|
||||
return
|
||||
}
|
||||
|
||||
systemStats.NetworkSent = networkSentPs
|
||||
systemStats.NetworkRecv = networkRecvPs
|
||||
systemStats.Bandwidth[0], systemStats.Bandwidth[1] = bytesSentPerSecond, bytesRecvPerSecond
|
||||
nis.BytesSent = totalBytesSent
|
||||
nis.BytesRecv = totalBytesRecv
|
||||
a.netIoStats[cacheTimeMs] = nis
|
||||
}
|
||||
|
||||
func (a *Agent) skipNetworkInterface(v psutilNet.IOCountersStat) bool {
|
||||
// skipNetworkInterface returns true if the network interface should be ignored.
|
||||
func skipNetworkInterface(v psutilNet.IOCountersStat, nicCfg *NicConfig) bool {
|
||||
if nicCfg != nil {
|
||||
if !isValidNic(v.Name, nicCfg) {
|
||||
return true
|
||||
}
|
||||
// In whitelist mode, we honor explicit inclusion without auto-filtering.
|
||||
if !nicCfg.isBlacklist {
|
||||
return false
|
||||
}
|
||||
// In blacklist mode, still apply the auto-filter below.
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(v.Name, "lo"),
|
||||
strings.HasPrefix(v.Name, "docker"),
|
||||
|
||||
@@ -261,6 +261,39 @@ func TestNewNicConfig(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestSkipNetworkInterface(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
nic psutilNet.IOCountersStat
|
||||
nicCfg *NicConfig
|
||||
expectSkip bool
|
||||
}{
|
||||
{"loopback lo", psutilNet.IOCountersStat{Name: "lo", BytesSent: 100, BytesRecv: 100}, nil, true},
|
||||
{"loopback lo0", psutilNet.IOCountersStat{Name: "lo0", BytesSent: 100, BytesRecv: 100}, nil, true},
|
||||
{"docker prefix", psutilNet.IOCountersStat{Name: "docker0", BytesSent: 100, BytesRecv: 100}, nil, true},
|
||||
{"br- prefix", psutilNet.IOCountersStat{Name: "br-lan", BytesSent: 100, BytesRecv: 100}, nil, true},
|
||||
{"veth prefix", psutilNet.IOCountersStat{Name: "veth0abc", BytesSent: 100, BytesRecv: 100}, nil, true},
|
||||
{"bond prefix", psutilNet.IOCountersStat{Name: "bond0", BytesSent: 100, BytesRecv: 100}, nil, true},
|
||||
{"cali prefix", psutilNet.IOCountersStat{Name: "cali1234", BytesSent: 100, BytesRecv: 100}, nil, true},
|
||||
{"zero BytesRecv", psutilNet.IOCountersStat{Name: "eth0", BytesSent: 100, BytesRecv: 0}, nil, true},
|
||||
{"zero BytesSent", psutilNet.IOCountersStat{Name: "eth0", BytesSent: 0, BytesRecv: 100}, nil, true},
|
||||
{"both zero", psutilNet.IOCountersStat{Name: "eth0", BytesSent: 0, BytesRecv: 0}, nil, true},
|
||||
{"normal eth0", psutilNet.IOCountersStat{Name: "eth0", BytesSent: 100, BytesRecv: 200}, nil, false},
|
||||
{"normal wlan0", psutilNet.IOCountersStat{Name: "wlan0", BytesSent: 1, BytesRecv: 1}, nil, false},
|
||||
{"whitelist overrides skip (docker)", psutilNet.IOCountersStat{Name: "docker0", BytesSent: 100, BytesRecv: 100}, newNicConfig("docker0"), false},
|
||||
{"whitelist overrides skip (lo)", psutilNet.IOCountersStat{Name: "lo", BytesSent: 100, BytesRecv: 100}, newNicConfig("lo"), false},
|
||||
{"whitelist exclusion", psutilNet.IOCountersStat{Name: "eth1", BytesSent: 100, BytesRecv: 100}, newNicConfig("eth0"), true},
|
||||
{"blacklist skip lo", psutilNet.IOCountersStat{Name: "lo", BytesSent: 100, BytesRecv: 100}, newNicConfig("-eth0"), true},
|
||||
{"blacklist explicit eth0", psutilNet.IOCountersStat{Name: "eth0", BytesSent: 100, BytesRecv: 100}, newNicConfig("-eth0"), true},
|
||||
{"blacklist allow eth1", psutilNet.IOCountersStat{Name: "eth1", BytesSent: 100, BytesRecv: 100}, newNicConfig("-eth0"), false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.expectSkip, skipNetworkInterface(tt.nic, tt.nicCfg))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureNetworkInterfacesMap(t *testing.T) {
|
||||
var a Agent
|
||||
var stats system.Stats
|
||||
@@ -383,8 +416,6 @@ func TestApplyNetworkTotals(t *testing.T) {
|
||||
totalBytesSent uint64
|
||||
totalBytesRecv uint64
|
||||
expectReset bool
|
||||
expectedNetworkSent float64
|
||||
expectedNetworkRecv float64
|
||||
expectedBandwidthSent uint64
|
||||
expectedBandwidthRecv uint64
|
||||
}{
|
||||
@@ -395,8 +426,6 @@ func TestApplyNetworkTotals(t *testing.T) {
|
||||
totalBytesSent: 10000000,
|
||||
totalBytesRecv: 20000000,
|
||||
expectReset: false,
|
||||
expectedNetworkSent: 0.95, // ~1 MB/s rounded to 2 decimals
|
||||
expectedNetworkRecv: 1.91, // ~2 MB/s rounded to 2 decimals
|
||||
expectedBandwidthSent: 1000000,
|
||||
expectedBandwidthRecv: 2000000,
|
||||
},
|
||||
@@ -424,18 +453,6 @@ func TestApplyNetworkTotals(t *testing.T) {
|
||||
totalBytesRecv: 20000000,
|
||||
expectReset: true,
|
||||
},
|
||||
{
|
||||
name: "Valid network stats - at threshold boundary",
|
||||
bytesSentPerSecond: 10485750000, // ~9999.99 MB/s (rounds to 9999.99)
|
||||
bytesRecvPerSecond: 10485750000, // ~9999.99 MB/s (rounds to 9999.99)
|
||||
totalBytesSent: 10000000,
|
||||
totalBytesRecv: 20000000,
|
||||
expectReset: false,
|
||||
expectedNetworkSent: 9999.99,
|
||||
expectedNetworkRecv: 9999.99,
|
||||
expectedBandwidthSent: 10485750000,
|
||||
expectedBandwidthRecv: 10485750000,
|
||||
},
|
||||
{
|
||||
name: "Zero values",
|
||||
bytesSentPerSecond: 0,
|
||||
@@ -443,8 +460,6 @@ func TestApplyNetworkTotals(t *testing.T) {
|
||||
totalBytesSent: 0,
|
||||
totalBytesRecv: 0,
|
||||
expectReset: false,
|
||||
expectedNetworkSent: 0.0,
|
||||
expectedNetworkRecv: 0.0,
|
||||
expectedBandwidthSent: 0,
|
||||
expectedBandwidthRecv: 0,
|
||||
},
|
||||
@@ -481,14 +496,10 @@ func TestApplyNetworkTotals(t *testing.T) {
|
||||
// Should have reset network tracking state - maps cleared and stats zeroed
|
||||
assert.NotContains(t, a.netIoStats, cacheTimeMs, "cache entry should be cleared after reset")
|
||||
assert.NotContains(t, a.netInterfaceDeltaTrackers, cacheTimeMs, "tracker should be cleared on reset")
|
||||
assert.Zero(t, systemStats.NetworkSent)
|
||||
assert.Zero(t, systemStats.NetworkRecv)
|
||||
assert.Zero(t, systemStats.Bandwidth[0])
|
||||
assert.Zero(t, systemStats.Bandwidth[1])
|
||||
} else {
|
||||
// Should have applied stats
|
||||
assert.Equal(t, tt.expectedNetworkSent, systemStats.NetworkSent)
|
||||
assert.Equal(t, tt.expectedNetworkRecv, systemStats.NetworkRecv)
|
||||
assert.Equal(t, tt.expectedBandwidthSent, systemStats.Bandwidth[0])
|
||||
assert.Equal(t, tt.expectedBandwidthRecv, systemStats.Bandwidth[1])
|
||||
|
||||
|
||||
177
agent/pve.go
177
agent/pve.go
@@ -1,177 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/internal/entities/container"
|
||||
|
||||
"github.com/luthermonson/go-proxmox"
|
||||
)
|
||||
|
||||
type pveManager struct {
|
||||
client *proxmox.Client // Client to query PVE API
|
||||
nodeName string // Cluster node name
|
||||
cpuCount int // CPU count on node
|
||||
nodeStatsMap map[string]*container.PveNodeStats // Keeps track of pve node stats
|
||||
lastInitTry time.Time // Last time node initialization was attempted
|
||||
}
|
||||
|
||||
// newPVEManager creates a new PVE manager - may return nil if required environment variables
|
||||
// are not set or if there is an error connecting to the API
|
||||
func newPVEManager() *pveManager {
|
||||
url, exists := GetEnv("PROXMOX_URL")
|
||||
if !exists {
|
||||
url = "https://localhost:8006/api2/json"
|
||||
}
|
||||
const nodeEnvVar = "PROXMOX_NODE"
|
||||
const tokenIDEnvVar = "PROXMOX_TOKENID"
|
||||
const secretEnvVar = "PROXMOX_SECRET"
|
||||
|
||||
nodeName, nodeNameExists := GetEnv(nodeEnvVar)
|
||||
tokenID, tokenIDExists := GetEnv(tokenIDEnvVar)
|
||||
secret, secretExists := GetEnv(secretEnvVar)
|
||||
|
||||
if !nodeNameExists || !tokenIDExists || !secretExists {
|
||||
slog.Debug("Proxmox env vars unset", nodeEnvVar, nodeNameExists, tokenIDEnvVar, tokenIDExists, secretEnvVar, secretExists)
|
||||
return nil
|
||||
}
|
||||
|
||||
// PROXMOX_INSECURE_TLS defaults to true; set to "false" to enable TLS verification
|
||||
insecureTLS := true
|
||||
if val, exists := GetEnv("PROXMOX_INSECURE_TLS"); exists {
|
||||
insecureTLS = val != "false"
|
||||
}
|
||||
|
||||
httpClient := http.Client{
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
InsecureSkipVerify: insecureTLS,
|
||||
},
|
||||
},
|
||||
}
|
||||
client := proxmox.NewClient(url,
|
||||
proxmox.WithHTTPClient(&httpClient),
|
||||
proxmox.WithAPIToken(tokenID, secret),
|
||||
)
|
||||
|
||||
pveManager := pveManager{
|
||||
client: client,
|
||||
nodeName: nodeName,
|
||||
nodeStatsMap: make(map[string]*container.PveNodeStats),
|
||||
}
|
||||
|
||||
return &pveManager
|
||||
}
|
||||
|
||||
// ensureInitialized checks if the PVE manager is initialized and attempts to initialize it if not.
|
||||
// It returns an error if initialization fails or if a retry is pending.
|
||||
func (pm *pveManager) ensureInitialized(ctx context.Context) error {
|
||||
if pm.client == nil {
|
||||
return errors.New("PVE client not configured")
|
||||
}
|
||||
if pm.cpuCount > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if time.Since(pm.lastInitTry) < 30*time.Second {
|
||||
return errors.New("PVE initialization retry pending")
|
||||
}
|
||||
pm.lastInitTry = time.Now()
|
||||
|
||||
node, err := pm.client.Node(ctx, pm.nodeName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if node.CPUInfo.CPUs <= 0 {
|
||||
return errors.New("node returned zero CPUs")
|
||||
}
|
||||
|
||||
pm.cpuCount = node.CPUInfo.CPUs
|
||||
return nil
|
||||
}
|
||||
|
||||
// getPVEStats returns stats for all running VMs/LXCs
|
||||
func (pm *pveManager) getPVEStats() ([]*container.PveNodeStats, error) {
|
||||
if err := pm.ensureInitialized(context.Background()); err != nil {
|
||||
slog.Warn("Proxmox API unavailable", "err", err)
|
||||
return nil, err
|
||||
}
|
||||
cluster, err := pm.client.Cluster(context.Background())
|
||||
if err != nil {
|
||||
slog.Error("Error getting cluster", "err", err)
|
||||
return nil, err
|
||||
}
|
||||
resources, err := cluster.Resources(context.Background(), "vm")
|
||||
if err != nil {
|
||||
slog.Error("Error getting resources", "err", err, "resources", resources)
|
||||
return nil, err
|
||||
}
|
||||
containersLength := len(resources)
|
||||
resourceIds := make(map[string]struct{}, containersLength)
|
||||
|
||||
// only include running vms and lxcs on selected node
|
||||
for _, resource := range resources {
|
||||
if resource.Node == pm.nodeName && resource.Status == "running" {
|
||||
resourceIds[resource.ID] = struct{}{}
|
||||
}
|
||||
}
|
||||
// remove invalid container stats
|
||||
for id := range pm.nodeStatsMap {
|
||||
if _, exists := resourceIds[id]; !exists {
|
||||
delete(pm.nodeStatsMap, id)
|
||||
}
|
||||
}
|
||||
|
||||
// populate stats
|
||||
stats := make([]*container.PveNodeStats, 0, len(resourceIds))
|
||||
for _, resource := range resources {
|
||||
if _, exists := resourceIds[resource.ID]; !exists {
|
||||
continue
|
||||
}
|
||||
resourceStats, initialized := pm.nodeStatsMap[resource.ID]
|
||||
if !initialized {
|
||||
resourceStats = &container.PveNodeStats{}
|
||||
pm.nodeStatsMap[resource.ID] = resourceStats
|
||||
}
|
||||
resourceStats.Name = resource.Name
|
||||
resourceStats.Id = resource.ID
|
||||
resourceStats.Type = resource.Type
|
||||
resourceStats.MaxCPU = resource.MaxCPU
|
||||
resourceStats.MaxMem = resource.MaxMem
|
||||
resourceStats.Uptime = resource.Uptime
|
||||
resourceStats.DiskRead = resource.DiskRead
|
||||
resourceStats.DiskWrite = resource.DiskWrite
|
||||
resourceStats.Disk = resource.MaxDisk
|
||||
|
||||
// prevent first run from sending all prev sent/recv bytes
|
||||
total_sent := resource.NetOut
|
||||
total_recv := resource.NetIn
|
||||
var sent_delta, recv_delta float64
|
||||
if initialized {
|
||||
secondsElapsed := time.Since(resourceStats.PrevReadTime).Seconds()
|
||||
if secondsElapsed > 0 {
|
||||
sent_delta = float64(total_sent-resourceStats.PrevNet.Sent) / secondsElapsed
|
||||
recv_delta = float64(total_recv-resourceStats.PrevNet.Recv) / secondsElapsed
|
||||
}
|
||||
}
|
||||
resourceStats.PrevNet.Sent = total_sent
|
||||
resourceStats.PrevNet.Recv = total_recv
|
||||
resourceStats.PrevReadTime = time.Now()
|
||||
|
||||
// Update final stats values
|
||||
resourceStats.Cpu = twoDecimals(100.0 * resource.CPU * float64(resource.MaxCPU) / float64(pm.cpuCount))
|
||||
resourceStats.Mem = bytesToMegabytes(float64(resource.Mem))
|
||||
resourceStats.Bandwidth = [2]uint64{uint64(sent_delta), uint64(recv_delta)}
|
||||
resourceStats.NetOut = total_sent
|
||||
resourceStats.NetIn = total_recv
|
||||
|
||||
stats = append(stats, resourceStats)
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/internal/entities/container"
|
||||
"github.com/luthermonson/go-proxmox"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewPVEManagerDoesNotConnectAtStartup(t *testing.T) {
|
||||
t.Setenv("BESZEL_AGENT_PROXMOX_URL", "https://127.0.0.1:1/api2/json")
|
||||
t.Setenv("BESZEL_AGENT_PROXMOX_NODE", "pve")
|
||||
t.Setenv("BESZEL_AGENT_PROXMOX_TOKENID", "root@pam!test")
|
||||
t.Setenv("BESZEL_AGENT_PROXMOX_SECRET", "secret")
|
||||
|
||||
pm := newPVEManager()
|
||||
require.NotNil(t, pm)
|
||||
assert.Zero(t, pm.cpuCount)
|
||||
}
|
||||
|
||||
func TestPVEManagerRetriesInitialization(t *testing.T) {
|
||||
var nodeRequests atomic.Int32
|
||||
var clusterRequests atomic.Int32
|
||||
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api2/json/nodes/pve/status":
|
||||
nodeRequests.Add(1)
|
||||
fmt.Fprint(w, `{"data":{"cpuinfo":{"cpus":8}}}`)
|
||||
case "/api2/json/cluster/status":
|
||||
fmt.Fprint(w, `{"data":[{"type":"cluster","name":"test-cluster","id":"test-cluster","version":1,"quorate":1}]}`)
|
||||
case "/api2/json/cluster/resources":
|
||||
clusterRequests.Add(1)
|
||||
fmt.Fprint(w, `{"data":[{"id":"qemu/101","type":"qemu","node":"pve","status":"running","name":"vm-101","cpu":0.5,"maxcpu":4,"maxmem":4096,"mem":2048,"netin":1024,"netout":2048,"diskread":10,"diskwrite":20,"maxdisk":8192,"uptime":60}]}`)
|
||||
default:
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
pm := &pveManager{
|
||||
client: proxmox.NewClient(server.URL+"/api2/json",
|
||||
proxmox.WithHTTPClient(&http.Client{
|
||||
Transport: &failOnceRoundTripper{
|
||||
base: server.Client().Transport,
|
||||
},
|
||||
}),
|
||||
proxmox.WithAPIToken("root@pam!test", "secret"),
|
||||
),
|
||||
nodeName: "pve",
|
||||
nodeStatsMap: make(map[string]*container.PveNodeStats),
|
||||
}
|
||||
|
||||
stats, err := pm.getPVEStats()
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, stats)
|
||||
assert.Zero(t, pm.cpuCount)
|
||||
|
||||
pm.lastInitTry = time.Now().Add(-31 * time.Second)
|
||||
stats, err = pm.getPVEStats()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, stats, 1)
|
||||
assert.Equal(t, int32(1), nodeRequests.Load())
|
||||
assert.Equal(t, int32(1), clusterRequests.Load())
|
||||
assert.Equal(t, 8, pm.cpuCount)
|
||||
assert.Equal(t, "qemu/101", stats[0].Id)
|
||||
assert.Equal(t, 25.0, stats[0].Cpu)
|
||||
assert.Equal(t, uint64(1024), stats[0].NetIn)
|
||||
assert.Equal(t, uint64(2048), stats[0].NetOut)
|
||||
}
|
||||
|
||||
type failOnceRoundTripper struct {
|
||||
base http.RoundTripper
|
||||
failed atomic.Bool
|
||||
}
|
||||
|
||||
func (rt *failOnceRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
if req.URL.Path == "/api2/json/nodes/pve/status" && !rt.failed.Swap(true) {
|
||||
return nil, errors.New("dial tcp 127.0.0.1:8006: connect: connection refused")
|
||||
}
|
||||
return rt.base.RoundTrip(req)
|
||||
}
|
||||
|
||||
var _ http.RoundTripper = (*failOnceRoundTripper)(nil)
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/henrygd/beszel/agent/utils"
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
|
||||
"github.com/shirou/gopsutil/v4/common"
|
||||
@@ -26,9 +27,9 @@ type SensorConfig struct {
|
||||
}
|
||||
|
||||
func (a *Agent) newSensorConfig() *SensorConfig {
|
||||
primarySensor, _ := GetEnv("PRIMARY_SENSOR")
|
||||
sysSensors, _ := GetEnv("SYS_SENSORS")
|
||||
sensorsEnvVal, sensorsSet := GetEnv("SENSORS")
|
||||
primarySensor, _ := utils.GetEnv("PRIMARY_SENSOR")
|
||||
sysSensors, _ := utils.GetEnv("SYS_SENSORS")
|
||||
sensorsEnvVal, sensorsSet := utils.GetEnv("SENSORS")
|
||||
skipCollection := sensorsSet && sensorsEnvVal == ""
|
||||
|
||||
return a.newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal, skipCollection)
|
||||
@@ -135,7 +136,7 @@ func (a *Agent) updateTemperatures(systemStats *system.Stats) {
|
||||
case sensorName:
|
||||
a.systemInfo.DashboardTemp = sensor.Temperature
|
||||
}
|
||||
systemStats.Temperatures[sensorName] = twoDecimals(sensor.Temperature)
|
||||
systemStats.Temperatures[sensorName] = utils.TwoDecimals(sensor.Temperature)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel"
|
||||
"github.com/henrygd/beszel/agent/utils"
|
||||
"github.com/henrygd/beszel/internal/common"
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
|
||||
@@ -36,7 +37,7 @@ var hubVersions map[string]semver.Version
|
||||
// and begins listening for connections. Returns an error if the server
|
||||
// is already running or if there's an issue starting the server.
|
||||
func (a *Agent) StartServer(opts ServerOptions) error {
|
||||
if disableSSH, _ := GetEnv("DISABLE_SSH"); disableSSH == "true" {
|
||||
if disableSSH, _ := utils.GetEnv("DISABLE_SSH"); disableSSH == "true" {
|
||||
return errors.New("SSH disabled")
|
||||
}
|
||||
if a.server != nil {
|
||||
@@ -238,11 +239,11 @@ func ParseKeys(input string) ([]gossh.PublicKey, error) {
|
||||
// and finally defaults to ":45876".
|
||||
func GetAddress(addr string) string {
|
||||
if addr == "" {
|
||||
addr, _ = GetEnv("LISTEN")
|
||||
addr, _ = utils.GetEnv("LISTEN")
|
||||
}
|
||||
if addr == "" {
|
||||
// Legacy PORT environment variable support
|
||||
addr, _ = GetEnv("PORT")
|
||||
addr, _ = utils.GetEnv("PORT")
|
||||
}
|
||||
if addr == "" {
|
||||
return ":45876"
|
||||
@@ -258,7 +259,7 @@ func GetAddress(addr string) string {
|
||||
// It checks the NETWORK environment variable first, then infers from
|
||||
// the address format: addresses starting with "/" are "unix", others are "tcp".
|
||||
func GetNetwork(addr string) string {
|
||||
if network, ok := GetEnv("NETWORK"); ok && network != "" {
|
||||
if network, ok := utils.GetEnv("NETWORK"); ok && network != "" {
|
||||
return network
|
||||
}
|
||||
if strings.HasPrefix(addr, "/") {
|
||||
|
||||
@@ -559,10 +559,6 @@ func TestWriteToSessionEncoding(t *testing.T) {
|
||||
|
||||
// Helper function to create test data for encoding tests
|
||||
func createTestCombinedData() *system.CombinedData {
|
||||
var stats = container.Stats{}
|
||||
stats.Name = "test-container"
|
||||
stats.Cpu = 10.5
|
||||
stats.Mem = 1073741824 // 1GB
|
||||
return &system.CombinedData{
|
||||
Stats: system.Stats{
|
||||
Cpu: 25.5,
|
||||
@@ -581,7 +577,11 @@ func createTestCombinedData() *system.CombinedData {
|
||||
AgentVersion: "0.12.0",
|
||||
},
|
||||
Containers: []*container.Stats{
|
||||
&stats,
|
||||
{
|
||||
Name: "test-container",
|
||||
Cpu: 10.5,
|
||||
Mem: 1073741824, // 1GB
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/agent/utils"
|
||||
"github.com/henrygd/beszel/internal/entities/smart"
|
||||
)
|
||||
|
||||
@@ -156,7 +157,7 @@ func (sm *SmartManager) ScanDevices(force bool) error {
|
||||
currentDevices := sm.devicesSnapshot()
|
||||
|
||||
var configuredDevices []*DeviceInfo
|
||||
if configuredRaw, ok := GetEnv("SMART_DEVICES"); ok {
|
||||
if configuredRaw, ok := utils.GetEnv("SMART_DEVICES"); ok {
|
||||
slog.Info("SMART_DEVICES", "value", configuredRaw)
|
||||
config := strings.TrimSpace(configuredRaw)
|
||||
if config == "" {
|
||||
@@ -222,7 +223,7 @@ func (sm *SmartManager) ScanDevices(force bool) error {
|
||||
}
|
||||
|
||||
func (sm *SmartManager) parseConfiguredDevices(config string) ([]*DeviceInfo, error) {
|
||||
splitChar := os.Getenv("SMART_DEVICES_SEPARATOR")
|
||||
splitChar, _ := utils.GetEnv("SMART_DEVICES_SEPARATOR")
|
||||
if splitChar == "" {
|
||||
splitChar = ","
|
||||
}
|
||||
@@ -260,7 +261,7 @@ func (sm *SmartManager) parseConfiguredDevices(config string) ([]*DeviceInfo, er
|
||||
}
|
||||
|
||||
func (sm *SmartManager) refreshExcludedDevices() {
|
||||
rawValue, _ := GetEnv("EXCLUDE_SMART")
|
||||
rawValue, _ := utils.GetEnv("EXCLUDE_SMART")
|
||||
sm.excludedDevices = make(map[string]struct{})
|
||||
|
||||
for entry := range strings.SplitSeq(rawValue, ",") {
|
||||
@@ -870,15 +871,18 @@ func (sm *SmartManager) parseSmartForSata(output []byte) (bool, int) {
|
||||
smartData.FirmwareVersion = data.FirmwareVersion
|
||||
smartData.Capacity = data.UserCapacity.Bytes
|
||||
smartData.Temperature = data.Temperature.Current
|
||||
if smartData.Temperature == 0 {
|
||||
if temp, ok := temperatureFromAtaDeviceStatistics(data.AtaDeviceStatistics); ok {
|
||||
smartData.Temperature = temp
|
||||
}
|
||||
}
|
||||
smartData.SmartStatus = getSmartStatus(smartData.Temperature, data.SmartStatus.Passed)
|
||||
smartData.DiskName = data.Device.Name
|
||||
smartData.DiskType = data.Device.Type
|
||||
|
||||
// get values from ata_device_statistics if necessary
|
||||
var ataDeviceStats smart.AtaDeviceStatistics
|
||||
if smartData.Temperature == 0 {
|
||||
if temp := findAtaDeviceStatisticsValue(&data, &ataDeviceStats, 5, "Current Temperature", 0, 255); temp != nil {
|
||||
smartData.Temperature = uint8(*temp)
|
||||
}
|
||||
}
|
||||
|
||||
// update SmartAttributes
|
||||
smartData.Attributes = make([]*smart.SmartAttribute, 0, len(data.AtaSmartAttributes.Table))
|
||||
for _, attr := range data.AtaSmartAttributes.Table {
|
||||
@@ -913,23 +917,20 @@ func getSmartStatus(temperature uint8, passed bool) string {
|
||||
}
|
||||
}
|
||||
|
||||
func temperatureFromAtaDeviceStatistics(stats smart.AtaDeviceStatistics) (uint8, bool) {
|
||||
entry := findAtaDeviceStatisticsEntry(stats, 5, "Current Temperature")
|
||||
if entry == nil || entry.Value == nil {
|
||||
return 0, false
|
||||
}
|
||||
if *entry.Value > 255 {
|
||||
return 0, false
|
||||
}
|
||||
return uint8(*entry.Value), true
|
||||
}
|
||||
|
||||
// findAtaDeviceStatisticsEntry centralizes ATA devstat lookups so additional
|
||||
// metrics can be pulled from the same structure in the future.
|
||||
func findAtaDeviceStatisticsEntry(stats smart.AtaDeviceStatistics, pageNumber uint8, entryName string) *smart.AtaDeviceStatisticsEntry {
|
||||
for pageIdx := range stats.Pages {
|
||||
page := &stats.Pages[pageIdx]
|
||||
if page.Number != pageNumber {
|
||||
func findAtaDeviceStatisticsValue(data *smart.SmartInfoForSata, ataDeviceStats *smart.AtaDeviceStatistics, entryNumber uint8, entryName string, minValue, maxValue int64) *int64 {
|
||||
if len(ataDeviceStats.Pages) == 0 {
|
||||
if len(data.AtaDeviceStatistics) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := json.Unmarshal(data.AtaDeviceStatistics, ataDeviceStats); err != nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
for pageIdx := range ataDeviceStats.Pages {
|
||||
page := &ataDeviceStats.Pages[pageIdx]
|
||||
if page.Number != entryNumber {
|
||||
continue
|
||||
}
|
||||
for entryIdx := range page.Table {
|
||||
@@ -937,7 +938,10 @@ func findAtaDeviceStatisticsEntry(stats smart.AtaDeviceStatistics, pageNumber ui
|
||||
if !strings.EqualFold(entry.Name, entryName) {
|
||||
continue
|
||||
}
|
||||
return entry
|
||||
if entry.Value == nil || *entry.Value < minValue || *entry.Value > maxValue {
|
||||
return nil
|
||||
}
|
||||
return entry.Value
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -121,6 +121,78 @@ func TestParseSmartForSataDeviceStatisticsTemperature(t *testing.T) {
|
||||
assert.Equal(t, uint8(22), deviceData.Temperature)
|
||||
}
|
||||
|
||||
func TestParseSmartForSataAtaDeviceStatistics(t *testing.T) {
|
||||
// tests that ata_device_statistics values are parsed correctly
|
||||
jsonPayload := []byte(`{
|
||||
"smartctl": {"exit_status": 0},
|
||||
"device": {"name": "/dev/sdb", "type": "sat"},
|
||||
"model_name": "SanDisk SSD U110 16GB",
|
||||
"serial_number": "lksjfh23lhj",
|
||||
"firmware_version": "U21B001",
|
||||
"user_capacity": {"bytes": 16013942784},
|
||||
"smart_status": {"passed": true},
|
||||
"ata_smart_attributes": {"table": []},
|
||||
"ata_device_statistics": {
|
||||
"pages": [
|
||||
{
|
||||
"number": 5,
|
||||
"name": "Temperature Statistics",
|
||||
"table": [
|
||||
{"name": "Current Temperature", "value": 43, "flags": {"valid": true}},
|
||||
{"name": "Specified Minimum Operating Temperature", "value": -20, "flags": {"valid": true}}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}`)
|
||||
|
||||
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
|
||||
hasData, exitStatus := sm.parseSmartForSata(jsonPayload)
|
||||
require.True(t, hasData)
|
||||
assert.Equal(t, 0, exitStatus)
|
||||
|
||||
deviceData, ok := sm.SmartDataMap["lksjfh23lhj"]
|
||||
require.True(t, ok, "expected smart data entry for serial lksjfh23lhj")
|
||||
assert.Equal(t, uint8(43), deviceData.Temperature)
|
||||
}
|
||||
|
||||
func TestParseSmartForSataNegativeDeviceStatistics(t *testing.T) {
|
||||
// Tests that negative values in ata_device_statistics (e.g. min operating temp)
|
||||
// do not cause the entire SAT parser to fail.
|
||||
jsonPayload := []byte(`{
|
||||
"smartctl": {"exit_status": 0},
|
||||
"device": {"name": "/dev/sdb", "type": "sat"},
|
||||
"model_name": "SanDisk SSD U110 16GB",
|
||||
"serial_number": "NEGATIVE123",
|
||||
"firmware_version": "U21B001",
|
||||
"user_capacity": {"bytes": 16013942784},
|
||||
"smart_status": {"passed": true},
|
||||
"temperature": {"current": 38},
|
||||
"ata_smart_attributes": {"table": []},
|
||||
"ata_device_statistics": {
|
||||
"pages": [
|
||||
{
|
||||
"number": 5,
|
||||
"name": "Temperature Statistics",
|
||||
"table": [
|
||||
{"name": "Current Temperature", "value": 38, "flags": {"valid": true}},
|
||||
{"name": "Specified Minimum Operating Temperature", "value": -20, "flags": {"valid": true}}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}`)
|
||||
|
||||
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
|
||||
hasData, exitStatus := sm.parseSmartForSata(jsonPayload)
|
||||
require.True(t, hasData)
|
||||
assert.Equal(t, 0, exitStatus)
|
||||
|
||||
deviceData, ok := sm.SmartDataMap["NEGATIVE123"]
|
||||
require.True(t, ok, "expected smart data entry for serial NEGATIVE123")
|
||||
assert.Equal(t, uint8(38), deviceData.Temperature)
|
||||
}
|
||||
|
||||
func TestParseSmartForSataParentheticalRawValue(t *testing.T) {
|
||||
jsonPayload := []byte(`{
|
||||
"smartctl": {"exit_status": 0},
|
||||
@@ -727,6 +799,182 @@ func TestIsVirtualDeviceScsi(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindAtaDeviceStatisticsValue(t *testing.T) {
|
||||
val42 := int64(42)
|
||||
val100 := int64(100)
|
||||
valMinus20 := int64(-20)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
data smart.SmartInfoForSata
|
||||
ataDeviceStats smart.AtaDeviceStatistics
|
||||
entryNumber uint8
|
||||
entryName string
|
||||
minValue int64
|
||||
maxValue int64
|
||||
expectedValue *int64
|
||||
}{
|
||||
{
|
||||
name: "value in ataDeviceStats",
|
||||
ataDeviceStats: smart.AtaDeviceStatistics{
|
||||
Pages: []smart.AtaDeviceStatisticsPage{
|
||||
{
|
||||
Number: 5,
|
||||
Table: []smart.AtaDeviceStatisticsEntry{
|
||||
{Name: "Current Temperature", Value: &val42},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
entryNumber: 5,
|
||||
entryName: "Current Temperature",
|
||||
minValue: 0,
|
||||
maxValue: 100,
|
||||
expectedValue: &val42,
|
||||
},
|
||||
{
|
||||
name: "value unmarshaled from data",
|
||||
data: smart.SmartInfoForSata{
|
||||
AtaDeviceStatistics: []byte(`{"pages":[{"number":5,"table":[{"name":"Current Temperature","value":100}]}]}`),
|
||||
},
|
||||
entryNumber: 5,
|
||||
entryName: "Current Temperature",
|
||||
minValue: 0,
|
||||
maxValue: 255,
|
||||
expectedValue: &val100,
|
||||
},
|
||||
{
|
||||
name: "value out of range (too high)",
|
||||
ataDeviceStats: smart.AtaDeviceStatistics{
|
||||
Pages: []smart.AtaDeviceStatisticsPage{
|
||||
{
|
||||
Number: 5,
|
||||
Table: []smart.AtaDeviceStatisticsEntry{
|
||||
{Name: "Current Temperature", Value: &val100},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
entryNumber: 5,
|
||||
entryName: "Current Temperature",
|
||||
minValue: 0,
|
||||
maxValue: 50,
|
||||
expectedValue: nil,
|
||||
},
|
||||
{
|
||||
name: "value out of range (too low)",
|
||||
ataDeviceStats: smart.AtaDeviceStatistics{
|
||||
Pages: []smart.AtaDeviceStatisticsPage{
|
||||
{
|
||||
Number: 5,
|
||||
Table: []smart.AtaDeviceStatisticsEntry{
|
||||
{Name: "Min Temp", Value: &valMinus20},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
entryNumber: 5,
|
||||
entryName: "Min Temp",
|
||||
minValue: 0,
|
||||
maxValue: 100,
|
||||
expectedValue: nil,
|
||||
},
|
||||
{
|
||||
name: "no statistics available",
|
||||
data: smart.SmartInfoForSata{},
|
||||
entryNumber: 5,
|
||||
entryName: "Current Temperature",
|
||||
minValue: 0,
|
||||
maxValue: 255,
|
||||
expectedValue: nil,
|
||||
},
|
||||
{
|
||||
name: "wrong page number",
|
||||
ataDeviceStats: smart.AtaDeviceStatistics{
|
||||
Pages: []smart.AtaDeviceStatisticsPage{
|
||||
{
|
||||
Number: 1,
|
||||
Table: []smart.AtaDeviceStatisticsEntry{
|
||||
{Name: "Current Temperature", Value: &val42},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
entryNumber: 5,
|
||||
entryName: "Current Temperature",
|
||||
minValue: 0,
|
||||
maxValue: 100,
|
||||
expectedValue: nil,
|
||||
},
|
||||
{
|
||||
name: "wrong entry name",
|
||||
ataDeviceStats: smart.AtaDeviceStatistics{
|
||||
Pages: []smart.AtaDeviceStatisticsPage{
|
||||
{
|
||||
Number: 5,
|
||||
Table: []smart.AtaDeviceStatisticsEntry{
|
||||
{Name: "Other Stat", Value: &val42},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
entryNumber: 5,
|
||||
entryName: "Current Temperature",
|
||||
minValue: 0,
|
||||
maxValue: 100,
|
||||
expectedValue: nil,
|
||||
},
|
||||
{
|
||||
name: "case insensitive name match",
|
||||
ataDeviceStats: smart.AtaDeviceStatistics{
|
||||
Pages: []smart.AtaDeviceStatisticsPage{
|
||||
{
|
||||
Number: 5,
|
||||
Table: []smart.AtaDeviceStatisticsEntry{
|
||||
{Name: "CURRENT TEMPERATURE", Value: &val42},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
entryNumber: 5,
|
||||
entryName: "Current Temperature",
|
||||
minValue: 0,
|
||||
maxValue: 100,
|
||||
expectedValue: &val42,
|
||||
},
|
||||
{
|
||||
name: "entry value is nil",
|
||||
ataDeviceStats: smart.AtaDeviceStatistics{
|
||||
Pages: []smart.AtaDeviceStatisticsPage{
|
||||
{
|
||||
Number: 5,
|
||||
Table: []smart.AtaDeviceStatisticsEntry{
|
||||
{Name: "Current Temperature", Value: nil},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
entryNumber: 5,
|
||||
entryName: "Current Temperature",
|
||||
minValue: 0,
|
||||
maxValue: 100,
|
||||
expectedValue: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := findAtaDeviceStatisticsValue(&tt.data, &tt.ataDeviceStats, tt.entryNumber, tt.entryName, tt.minValue, tt.maxValue)
|
||||
if tt.expectedValue == nil {
|
||||
assert.Nil(t, result)
|
||||
} else {
|
||||
require.NotNil(t, result)
|
||||
assert.Equal(t, *tt.expectedValue, *result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshExcludedDevices(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"github.com/henrygd/beszel"
|
||||
"github.com/henrygd/beszel/agent/battery"
|
||||
"github.com/henrygd/beszel/agent/utils"
|
||||
"github.com/henrygd/beszel/agent/zfs"
|
||||
"github.com/henrygd/beszel/internal/entities/container"
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
@@ -127,13 +128,13 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
|
||||
// cpu metrics
|
||||
cpuMetrics, err := getCpuMetrics(cacheTimeMs)
|
||||
if err == nil {
|
||||
systemStats.Cpu = twoDecimals(cpuMetrics.Total)
|
||||
systemStats.Cpu = utils.TwoDecimals(cpuMetrics.Total)
|
||||
systemStats.CpuBreakdown = []float64{
|
||||
twoDecimals(cpuMetrics.User),
|
||||
twoDecimals(cpuMetrics.System),
|
||||
twoDecimals(cpuMetrics.Iowait),
|
||||
twoDecimals(cpuMetrics.Steal),
|
||||
twoDecimals(cpuMetrics.Idle),
|
||||
utils.TwoDecimals(cpuMetrics.User),
|
||||
utils.TwoDecimals(cpuMetrics.System),
|
||||
utils.TwoDecimals(cpuMetrics.Iowait),
|
||||
utils.TwoDecimals(cpuMetrics.Steal),
|
||||
utils.TwoDecimals(cpuMetrics.Idle),
|
||||
}
|
||||
} else {
|
||||
slog.Error("Error getting cpu metrics", "err", err)
|
||||
@@ -157,8 +158,8 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
|
||||
// memory
|
||||
if v, err := mem.VirtualMemory(); err == nil {
|
||||
// swap
|
||||
systemStats.Swap = bytesToGigabytes(v.SwapTotal)
|
||||
systemStats.SwapUsed = bytesToGigabytes(v.SwapTotal - v.SwapFree - v.SwapCached)
|
||||
systemStats.Swap = utils.BytesToGigabytes(v.SwapTotal)
|
||||
systemStats.SwapUsed = utils.BytesToGigabytes(v.SwapTotal - v.SwapFree - v.SwapCached)
|
||||
// cache + buffers value for default mem calculation
|
||||
// note: gopsutil automatically adds SReclaimable to v.Cached
|
||||
cacheBuff := v.Cached + v.Buffers - v.Shared
|
||||
@@ -181,13 +182,13 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
|
||||
if arcSize, _ := zfs.ARCSize(); arcSize > 0 && arcSize < v.Used {
|
||||
v.Used = v.Used - arcSize
|
||||
v.UsedPercent = float64(v.Used) / float64(v.Total) * 100.0
|
||||
systemStats.MemZfsArc = bytesToGigabytes(arcSize)
|
||||
systemStats.MemZfsArc = utils.BytesToGigabytes(arcSize)
|
||||
}
|
||||
}
|
||||
systemStats.Mem = bytesToGigabytes(v.Total)
|
||||
systemStats.MemBuffCache = bytesToGigabytes(cacheBuff)
|
||||
systemStats.MemUsed = bytesToGigabytes(v.Used)
|
||||
systemStats.MemPct = twoDecimals(v.UsedPercent)
|
||||
systemStats.Mem = utils.BytesToGigabytes(v.Total)
|
||||
systemStats.MemBuffCache = utils.BytesToGigabytes(cacheBuff)
|
||||
systemStats.MemUsed = utils.BytesToGigabytes(v.Used)
|
||||
systemStats.MemPct = utils.TwoDecimals(v.UsedPercent)
|
||||
}
|
||||
|
||||
// disk usage
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/coreos/go-systemd/v22/dbus"
|
||||
"github.com/henrygd/beszel/agent/utils"
|
||||
"github.com/henrygd/beszel/internal/entities/systemd"
|
||||
)
|
||||
|
||||
@@ -49,7 +50,7 @@ func isSystemdAvailable() bool {
|
||||
|
||||
// newSystemdManager creates a new systemdManager.
|
||||
func newSystemdManager() (*systemdManager, error) {
|
||||
if skipSystemd, _ := GetEnv("SKIP_SYSTEMD"); skipSystemd == "true" {
|
||||
if skipSystemd, _ := utils.GetEnv("SKIP_SYSTEMD"); skipSystemd == "true" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -294,7 +295,7 @@ func unescapeServiceName(name string) string {
|
||||
// otherwise defaults to "*service".
|
||||
func getServicePatterns() []string {
|
||||
patterns := []string{}
|
||||
if envPatterns, _ := GetEnv("SERVICE_PATTERNS"); envPatterns != "" {
|
||||
if envPatterns, _ := utils.GetEnv("SERVICE_PATTERNS"); envPatterns != "" {
|
||||
for pattern := range strings.SplitSeq(envPatterns, ",") {
|
||||
pattern = strings.TrimSpace(pattern)
|
||||
if pattern == "" {
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
package agent
|
||||
|
||||
import "math"
|
||||
|
||||
func bytesToMegabytes(b float64) float64 {
|
||||
return twoDecimals(b / 1048576)
|
||||
}
|
||||
|
||||
func bytesToGigabytes(b uint64) float64 {
|
||||
return twoDecimals(float64(b) / 1073741824)
|
||||
}
|
||||
|
||||
func twoDecimals(value float64) float64 {
|
||||
return math.Round(value*100) / 100
|
||||
}
|
||||
88
agent/utils/utils.go
Normal file
88
agent/utils/utils.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"io"
|
||||
"math"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// GetEnv retrieves an environment variable with a "BESZEL_AGENT_" prefix, or falls back to the unprefixed key.
|
||||
func GetEnv(key string) (value string, exists bool) {
|
||||
if value, exists = os.LookupEnv("BESZEL_AGENT_" + key); exists {
|
||||
return value, exists
|
||||
}
|
||||
return os.LookupEnv(key)
|
||||
}
|
||||
|
||||
// BytesToMegabytes converts bytes to megabytes and rounds to two decimal places.
|
||||
func BytesToMegabytes(b float64) float64 {
|
||||
return TwoDecimals(b / 1048576)
|
||||
}
|
||||
|
||||
// BytesToGigabytes converts bytes to gigabytes and rounds to two decimal places.
|
||||
func BytesToGigabytes(b uint64) float64 {
|
||||
return TwoDecimals(float64(b) / 1073741824)
|
||||
}
|
||||
|
||||
// TwoDecimals rounds a float64 value to two decimal places.
|
||||
func TwoDecimals(value float64) float64 {
|
||||
return math.Round(value*100) / 100
|
||||
}
|
||||
|
||||
// func RoundFloat(val float64, precision uint) float64 {
|
||||
// ratio := math.Pow(10, float64(precision))
|
||||
// return math.Round(val*ratio) / ratio
|
||||
// }
|
||||
|
||||
// ReadStringFile returns trimmed file contents or empty string on error.
|
||||
func ReadStringFile(path string) string {
|
||||
content, _ := ReadStringFileOK(path)
|
||||
return content
|
||||
}
|
||||
|
||||
// ReadStringFileOK returns trimmed file contents and read success.
|
||||
func ReadStringFileOK(path string) (string, bool) {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return strings.TrimSpace(string(b)), true
|
||||
}
|
||||
|
||||
// ReadStringFileLimited reads a file into a string with a maximum size (in bytes) to avoid
|
||||
// allocating large buffers and potential panics with pseudo-files when the size is misreported.
|
||||
func ReadStringFileLimited(path string, maxSize int) (string, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
buf := make([]byte, maxSize)
|
||||
n, err := f.Read(buf)
|
||||
if err != nil && err != io.EOF {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(string(buf[:n])), nil
|
||||
}
|
||||
|
||||
// FileExists reports whether the given path exists.
|
||||
func FileExists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// ReadUintFile parses a decimal uint64 value from a file.
|
||||
func ReadUintFile(path string) (uint64, bool) {
|
||||
raw, ok := ReadStringFileOK(path)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
parsed, err := strconv.ParseUint(raw, 10, 64)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return parsed, true
|
||||
}
|
||||
165
agent/utils/utils_test.go
Normal file
165
agent/utils/utils_test.go
Normal file
@@ -0,0 +1,165 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestTwoDecimals(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input float64
|
||||
expected float64
|
||||
}{
|
||||
{"round down", 1.234, 1.23},
|
||||
{"round half up", 1.235, 1.24}, // math.Round rounds half up
|
||||
{"no rounding needed", 1.23, 1.23},
|
||||
{"negative number", -1.235, -1.24}, // math.Round rounds half up (more negative)
|
||||
{"zero", 0.0, 0.0},
|
||||
{"large number", 123.456, 123.46}, // rounds 5 up
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := TwoDecimals(tt.input)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBytesToMegabytes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input float64
|
||||
expected float64
|
||||
}{
|
||||
{"1 MB", 1048576, 1.0},
|
||||
{"512 KB", 524288, 0.5},
|
||||
{"zero", 0, 0},
|
||||
{"large value", 1073741824, 1024}, // 1 GB = 1024 MB
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := BytesToMegabytes(tt.input)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBytesToGigabytes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input uint64
|
||||
expected float64
|
||||
}{
|
||||
{"1 GB", 1073741824, 1.0},
|
||||
{"512 MB", 536870912, 0.5},
|
||||
{"0 GB", 0, 0},
|
||||
{"2 GB", 2147483648, 2.0},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := BytesToGigabytes(tt.input)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileFunctions(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
testFilePath := filepath.Join(tmpDir, "test.txt")
|
||||
testContent := "hello world"
|
||||
|
||||
// Test FileExists (false)
|
||||
assert.False(t, FileExists(testFilePath))
|
||||
|
||||
// Test ReadStringFileOK (false)
|
||||
content, ok := ReadStringFileOK(testFilePath)
|
||||
assert.False(t, ok)
|
||||
assert.Empty(t, content)
|
||||
|
||||
// Test ReadStringFile (empty)
|
||||
assert.Empty(t, ReadStringFile(testFilePath))
|
||||
|
||||
// Write file
|
||||
err := os.WriteFile(testFilePath, []byte(testContent+"\n "), 0644)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Test FileExists (true)
|
||||
assert.True(t, FileExists(testFilePath))
|
||||
|
||||
// Test ReadStringFileOK (true)
|
||||
content, ok = ReadStringFileOK(testFilePath)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, testContent, content)
|
||||
|
||||
// Test ReadStringFile (content)
|
||||
assert.Equal(t, testContent, ReadStringFile(testFilePath))
|
||||
}
|
||||
|
||||
func TestReadUintFile(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
t.Run("valid uint", func(t *testing.T) {
|
||||
path := filepath.Join(tmpDir, "uint.txt")
|
||||
os.WriteFile(path, []byte(" 12345\n"), 0644)
|
||||
val, ok := ReadUintFile(path)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, uint64(12345), val)
|
||||
})
|
||||
|
||||
t.Run("invalid uint", func(t *testing.T) {
|
||||
path := filepath.Join(tmpDir, "invalid.txt")
|
||||
os.WriteFile(path, []byte("abc"), 0644)
|
||||
val, ok := ReadUintFile(path)
|
||||
assert.False(t, ok)
|
||||
assert.Equal(t, uint64(0), val)
|
||||
})
|
||||
|
||||
t.Run("missing file", func(t *testing.T) {
|
||||
path := filepath.Join(tmpDir, "missing.txt")
|
||||
val, ok := ReadUintFile(path)
|
||||
assert.False(t, ok)
|
||||
assert.Equal(t, uint64(0), val)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetEnv(t *testing.T) {
|
||||
key := "TEST_VAR"
|
||||
prefixedKey := "BESZEL_AGENT_" + key
|
||||
|
||||
t.Run("prefixed variable exists", func(t *testing.T) {
|
||||
os.Setenv(prefixedKey, "prefixed_val")
|
||||
os.Setenv(key, "unprefixed_val")
|
||||
defer os.Unsetenv(prefixedKey)
|
||||
defer os.Unsetenv(key)
|
||||
|
||||
val, exists := GetEnv(key)
|
||||
assert.True(t, exists)
|
||||
assert.Equal(t, "prefixed_val", val)
|
||||
})
|
||||
|
||||
t.Run("only unprefixed variable exists", func(t *testing.T) {
|
||||
os.Unsetenv(prefixedKey)
|
||||
os.Setenv(key, "unprefixed_val")
|
||||
defer os.Unsetenv(key)
|
||||
|
||||
val, exists := GetEnv(key)
|
||||
assert.True(t, exists)
|
||||
assert.Equal(t, "unprefixed_val", val)
|
||||
})
|
||||
|
||||
t.Run("neither variable exists", func(t *testing.T) {
|
||||
os.Unsetenv(prefixedKey)
|
||||
os.Unsetenv(key)
|
||||
|
||||
val, exists := GetEnv(key)
|
||||
assert.False(t, exists)
|
||||
assert.Empty(t, val)
|
||||
})
|
||||
}
|
||||
9
go.mod
9
go.mod
@@ -1,6 +1,6 @@
|
||||
module github.com/henrygd/beszel
|
||||
|
||||
go 1.26.0
|
||||
go 1.26.1
|
||||
|
||||
require (
|
||||
github.com/blang/semver v3.5.1+incompatible
|
||||
@@ -10,7 +10,6 @@ require (
|
||||
github.com/fxamacker/cbor/v2 v2.9.0
|
||||
github.com/gliderlabs/ssh v0.3.8
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/luthermonson/go-proxmox v0.4.0
|
||||
github.com/lxzan/gws v1.8.9
|
||||
github.com/nicholas-fedor/shoutrrr v0.13.2
|
||||
github.com/pocketbase/dbx v1.12.0
|
||||
@@ -29,11 +28,8 @@ require (
|
||||
require (
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
|
||||
github.com/buger/goterm v1.0.4 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/disintegration/imaging v1.6.2 // indirect
|
||||
github.com/diskfs/go-diskfs v1.7.0 // indirect
|
||||
github.com/djherbis/times v1.6.0 // indirect
|
||||
github.com/dolthub/maphash v0.1.0 // indirect
|
||||
github.com/domodwyer/mailyak/v3 v3.6.2 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
@@ -45,12 +41,9 @@ require (
|
||||
github.com/go-sql-driver/mysql v1.9.1 // indirect
|
||||
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/gorilla/websocket v1.4.2 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jinzhu/copier v0.3.4 // indirect
|
||||
github.com/klauspost/compress v1.18.4 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 // indirect
|
||||
github.com/magefile/mage v1.14.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
|
||||
34
go.sum
34
go.sum
@@ -2,8 +2,6 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
|
||||
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||
github.com/anchore/go-lzo v0.1.0 h1:NgAacnzqPeGH49Ky19QKLBZEuFRqtTG9cdaucc3Vncs=
|
||||
github.com/anchore/go-lzo v0.1.0/go.mod h1:3kLx0bve2oN1iDwgM1U5zGku1Tfbdb0No5qp1eL1fIk=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
|
||||
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=
|
||||
@@ -11,8 +9,6 @@ github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3d
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
|
||||
github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ=
|
||||
github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
|
||||
github.com/buger/goterm v1.0.4 h1:Z9YvGmOih81P0FbVtEYTFF6YsSgxSUKEhf/f9bTMXbY=
|
||||
github.com/buger/goterm v1.0.4/go.mod h1:HiFWV3xnkolgrBV3mY8m0X0Pumt4zg4QhbdOzQtB8tE=
|
||||
github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA=
|
||||
github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
@@ -21,12 +17,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||
github.com/diskfs/go-diskfs v1.7.0 h1:vonWmt5CMowXwUc79jWyGrf2DIMeoOjkLlMnQYGVOs8=
|
||||
github.com/diskfs/go-diskfs v1.7.0/go.mod h1:LhQyXqOugWFRahYUSw47NyZJPezFzB9UELwhpszLP/k=
|
||||
github.com/distatus/battery v0.11.0 h1:KJk89gz90Iq/wJtbjjM9yUzBXV+ASV/EG2WOOL7N8lc=
|
||||
github.com/distatus/battery v0.11.0/go.mod h1:KmVkE8A8hpIX4T78QRdMktYpEp35QfOL8A8dwZBxq2k=
|
||||
github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=
|
||||
github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0=
|
||||
github.com/dolthub/maphash v0.1.0 h1:bsQ7JsF4FkkWyrP3oCnFJgrCUAFbFf3kOl4L/QxPDyQ=
|
||||
github.com/dolthub/maphash v0.1.0/go.mod h1:gkg4Ch4CdCDu5h6PMriVLawB7koZ+5ijb9puGMV50a4=
|
||||
github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8=
|
||||
@@ -35,8 +27,6 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
|
||||
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/elliotwutingfeng/asciiset v0.0.0-20230602022725-51bbb787efab h1:h1UgjJdAAhj+uPL68n7XASS6bU+07ZX1WJvVS2eyoeY=
|
||||
github.com/elliotwutingfeng/asciiset v0.0.0-20230602022725-51bbb787efab/go.mod h1:GLo/8fDswSAniFG+BFIaiSPcK610jyzgEhWYPQwuQdw=
|
||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
@@ -61,8 +51,6 @@ github.com/go-sql-driver/mysql v1.9.1 h1:FrjNGn/BsJQjVRuSa8CBrM5BWA9BWoXXat3KrtS
|
||||
github.com/go-sql-driver/mysql v1.9.1/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
||||
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
|
||||
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
||||
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
@@ -74,12 +62,6 @@ github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/v
|
||||
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
|
||||
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/h2non/gock v1.2.0 h1:K6ol8rfrRkUOefooBC8elXoaNGYkpp7y2qcxGG6BzUE=
|
||||
github.com/h2non/gock v1.2.0/go.mod h1:tNhoxHYW2W42cYkYb1WqzdbYIieALC99kpYr7rH/BQk=
|
||||
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw=
|
||||
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
@@ -87,8 +69,6 @@ github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLf
|
||||
github.com/jarcoal/httpmock v1.4.1 h1:0Ju+VCFuARfFlhVXFc2HxlcQkfB+Xq12/EotHko+x2A=
|
||||
github.com/jarcoal/httpmock v1.4.1/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0=
|
||||
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||
github.com/jinzhu/copier v0.3.4 h1:mfU6jI9PtCeUjkjQ322dlff9ELjGDu975C2p/nrubVI=
|
||||
github.com/jinzhu/copier v0.3.4/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg=
|
||||
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
|
||||
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
@@ -97,12 +77,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 h1:PTw+yKnXcOFCR6+8hHTyWBeQ/P4Nb7dd4/0ohEcWQuM=
|
||||
github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||
github.com/luthermonson/go-proxmox v0.4.0 h1:LKXpG9d64zTaQF79wV0kfOnnSwIcdG39m7sc4ga+XZs=
|
||||
github.com/luthermonson/go-proxmox v0.4.0/go.mod h1:U6dAkJ+iiwaeb1g/LMWpWuWN4nmvWeXhmoMuYJMumS4=
|
||||
github.com/lxzan/gws v1.8.9 h1:VU3SGUeWlQrEwfUSfokcZep8mdg/BrUF+y73YYshdBM=
|
||||
github.com/lxzan/gws v1.8.9/go.mod h1:d9yHaR1eDTBHagQC6KY7ycUOaz5KWeqQtP3xu7aMK8Y=
|
||||
github.com/magefile/mage v1.14.0 h1:6QDX3g6z1YvJ4olPhT1wksUcSa/V0a1B+pJb73fBjyo=
|
||||
github.com/magefile/mage v1.14.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
@@ -115,10 +91,6 @@ github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI
|
||||
github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE=
|
||||
github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28=
|
||||
github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg=
|
||||
github.com/pierrec/lz4/v4 v4.1.17 h1:kV4Ip+/hUBC+8T6+2EgburRtkE9ef4nbY3f4dFhGjMc=
|
||||
github.com/pierrec/lz4/v4 v4.1.17/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
github.com/pkg/xattr v0.4.9 h1:5883YPCtkSd8LFbs13nXplj9g9tlrwoJRjgpgMu1/fE=
|
||||
github.com/pkg/xattr v0.4.9/go.mod h1:di8WF84zAKk8jzR1UBTEWh9AUlIZZ7M/JNt8e9B6ktU=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
@@ -135,8 +107,6 @@ github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/f
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/shirou/gopsutil/v4 v4.26.1 h1:TOkEyriIXk2HX9d4isZJtbjXbEjf5qyKPAzbzY0JWSo=
|
||||
github.com/shirou/gopsutil/v4 v4.26.1/go.mod h1:medLI9/UNAb0dOI9Q3/7yWSqKkj00u+1tgY8nvv41pc=
|
||||
github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af h1:Sp5TG9f7K39yfB+If0vjp97vuT74F72r8hfRpP8jLU0=
|
||||
github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
@@ -152,8 +122,6 @@ github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYI
|
||||
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
|
||||
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
|
||||
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
|
||||
github.com/ulikunitz/xz v0.5.11 h1:kpFauv27b6ynzBNT/Xy+1k+fK4WswhN/6PN5WhFAGw8=
|
||||
github.com/ulikunitz/xz v0.5.11/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
@@ -180,8 +148,6 @@ golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210331175145-43e1dd70ce54/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
|
||||
@@ -40,16 +40,22 @@ type UserNotificationSettings struct {
|
||||
Webhooks []string `json:"webhooks"`
|
||||
}
|
||||
|
||||
type SystemAlertFsStats struct {
|
||||
DiskTotal float64 `json:"d"`
|
||||
DiskUsed float64 `json:"du"`
|
||||
}
|
||||
|
||||
// Values pulled from system_stats.stats that are relevant to alerts.
|
||||
type SystemAlertStats struct {
|
||||
Cpu float64 `json:"cpu"`
|
||||
Mem float64 `json:"mp"`
|
||||
Disk float64 `json:"dp"`
|
||||
NetSent float64 `json:"ns"`
|
||||
NetRecv float64 `json:"nr"`
|
||||
Bandwidth [2]uint64 `json:"b"`
|
||||
GPU map[string]SystemAlertGPUData `json:"g"`
|
||||
Temperatures map[string]float32 `json:"t"`
|
||||
LoadAvg [3]float64 `json:"la"`
|
||||
Battery [2]uint8 `json:"bat"`
|
||||
ExtraFs map[string]SystemAlertFsStats `json:"efs"`
|
||||
}
|
||||
|
||||
type SystemAlertGPUData struct {
|
||||
@@ -259,13 +265,14 @@ func (am *AlertManager) SendShoutrrrAlert(notificationUrl, title, message, link,
|
||||
}
|
||||
|
||||
// Add link
|
||||
if scheme == "ntfy" {
|
||||
switch scheme {
|
||||
case "ntfy":
|
||||
queryParams.Add("Actions", fmt.Sprintf("view, %s, %s", linkText, link))
|
||||
} else if scheme == "lark" {
|
||||
case "lark":
|
||||
queryParams.Add("link", link)
|
||||
} else if scheme == "bark" {
|
||||
case "bark":
|
||||
queryParams.Add("url", link)
|
||||
} else {
|
||||
default:
|
||||
message += "\n\n" + link
|
||||
}
|
||||
|
||||
|
||||
155
internal/alerts/alerts_disk_test.go
Normal file
155
internal/alerts/alerts_disk_test.go
Normal file
@@ -0,0 +1,155 @@
|
||||
//go:build testing
|
||||
|
||||
package alerts_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
beszelTests "github.com/henrygd/beszel/internal/tests"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestDiskAlertExtraFsMultiMinute tests that multi-minute disk alerts correctly use
|
||||
// historical per-minute values for extra (non-root) filesystems, not the current live snapshot.
|
||||
func TestDiskAlertExtraFsMultiMinute(t *testing.T) {
|
||||
hub, user := beszelTests.GetHubWithUser(t)
|
||||
defer hub.Cleanup()
|
||||
|
||||
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
|
||||
require.NoError(t, err)
|
||||
systemRecord := systems[0]
|
||||
|
||||
// Disk alert: threshold 80%, min=2 (requires historical averaging)
|
||||
diskAlert, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{
|
||||
"name": "Disk",
|
||||
"system": systemRecord.Id,
|
||||
"user": user.Id,
|
||||
"value": 80, // threshold: 80%
|
||||
"min": 2, // 2 minutes - requires historical averaging
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, diskAlert.GetBool("triggered"), "Alert should not be triggered initially")
|
||||
|
||||
am := hub.GetAlertManager()
|
||||
now := time.Now().UTC()
|
||||
|
||||
extraFsHigh := map[string]*system.FsStats{
|
||||
"/mnt/data": {DiskTotal: 1000, DiskUsed: 920}, // 92% - above threshold
|
||||
}
|
||||
|
||||
// Insert 4 historical records spread over 3 minutes (same pattern as battery tests).
|
||||
// The oldest record must predate (now - 2min) so the alert time window is valid.
|
||||
recordTimes := []time.Duration{
|
||||
-180 * time.Second, // 3 min ago - anchors oldest record before alert.time
|
||||
-90 * time.Second,
|
||||
-60 * time.Second,
|
||||
-30 * time.Second,
|
||||
}
|
||||
|
||||
for _, offset := range recordTimes {
|
||||
stats := system.Stats{
|
||||
DiskPct: 30, // root disk at 30% - below threshold
|
||||
ExtraFs: extraFsHigh,
|
||||
}
|
||||
statsJSON, _ := json.Marshal(stats)
|
||||
|
||||
recordTime := now.Add(offset)
|
||||
record, err := beszelTests.CreateRecord(hub, "system_stats", map[string]any{
|
||||
"system": systemRecord.Id,
|
||||
"type": "1m",
|
||||
"stats": string(statsJSON),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
record.SetRaw("created", recordTime.Format(types.DefaultDateLayout))
|
||||
err = hub.SaveNoValidate(record)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
combinedDataHigh := &system.CombinedData{
|
||||
Stats: system.Stats{
|
||||
DiskPct: 30,
|
||||
ExtraFs: extraFsHigh,
|
||||
},
|
||||
Info: system.Info{
|
||||
DiskPct: 30,
|
||||
},
|
||||
}
|
||||
|
||||
systemRecord.Set("updated", now)
|
||||
err = hub.SaveNoValidate(systemRecord)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = am.HandleSystemAlerts(systemRecord, combinedDataHigh)
|
||||
require.NoError(t, err)
|
||||
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
|
||||
diskAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": diskAlert.Id})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, diskAlert.GetBool("triggered"),
|
||||
"Alert SHOULD be triggered when extra disk average (92%%) exceeds threshold (80%%)")
|
||||
|
||||
// --- Resolution: extra disk drops to 50%, alert should resolve ---
|
||||
|
||||
extraFsLow := map[string]*system.FsStats{
|
||||
"/mnt/data": {DiskTotal: 1000, DiskUsed: 500}, // 50% - below threshold
|
||||
}
|
||||
|
||||
newNow := now.Add(2 * time.Minute)
|
||||
recordTimesLow := []time.Duration{
|
||||
-180 * time.Second,
|
||||
-90 * time.Second,
|
||||
-60 * time.Second,
|
||||
-30 * time.Second,
|
||||
}
|
||||
|
||||
for _, offset := range recordTimesLow {
|
||||
stats := system.Stats{
|
||||
DiskPct: 30,
|
||||
ExtraFs: extraFsLow,
|
||||
}
|
||||
statsJSON, _ := json.Marshal(stats)
|
||||
|
||||
recordTime := newNow.Add(offset)
|
||||
record, err := beszelTests.CreateRecord(hub, "system_stats", map[string]any{
|
||||
"system": systemRecord.Id,
|
||||
"type": "1m",
|
||||
"stats": string(statsJSON),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
record.SetRaw("created", recordTime.Format(types.DefaultDateLayout))
|
||||
err = hub.SaveNoValidate(record)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
combinedDataLow := &system.CombinedData{
|
||||
Stats: system.Stats{
|
||||
DiskPct: 30,
|
||||
ExtraFs: extraFsLow,
|
||||
},
|
||||
Info: system.Info{
|
||||
DiskPct: 30,
|
||||
},
|
||||
}
|
||||
|
||||
systemRecord.Set("updated", newNow)
|
||||
err = hub.SaveNoValidate(systemRecord)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = am.HandleSystemAlerts(systemRecord, combinedDataLow)
|
||||
require.NoError(t, err)
|
||||
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
|
||||
diskAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": diskAlert.Id})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, diskAlert.GetBool("triggered"),
|
||||
"Alert should be resolved when extra disk average (50%%) drops below threshold (80%%)")
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
|
||||
func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *system.CombinedData) error {
|
||||
@@ -92,7 +91,7 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
|
||||
}
|
||||
}
|
||||
|
||||
min := max(1, cast.ToUint8(alertRecord.Get("min")))
|
||||
min := max(1, uint8(alertRecord.GetInt("min")))
|
||||
|
||||
alert := SystemAlertData{
|
||||
systemRecord: systemRecord,
|
||||
@@ -192,22 +191,24 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
|
||||
case "Memory":
|
||||
alert.val += stats.Mem
|
||||
case "Bandwidth":
|
||||
alert.val += stats.NetSent + stats.NetRecv
|
||||
alert.val += float64(stats.Bandwidth[0]+stats.Bandwidth[1]) / (1024 * 1024)
|
||||
case "Disk":
|
||||
if alert.mapSums == nil {
|
||||
alert.mapSums = make(map[string]float32, len(data.Stats.ExtraFs)+1)
|
||||
alert.mapSums = make(map[string]float32, len(stats.ExtraFs)+1)
|
||||
}
|
||||
// add root disk
|
||||
if _, ok := alert.mapSums["root"]; !ok {
|
||||
alert.mapSums["root"] = 0.0
|
||||
}
|
||||
alert.mapSums["root"] += float32(stats.Disk)
|
||||
// add extra disks
|
||||
for key, fs := range data.Stats.ExtraFs {
|
||||
if _, ok := alert.mapSums[key]; !ok {
|
||||
alert.mapSums[key] = 0.0
|
||||
// add extra disks from historical record
|
||||
for key, fs := range stats.ExtraFs {
|
||||
if fs.DiskTotal > 0 {
|
||||
if _, ok := alert.mapSums[key]; !ok {
|
||||
alert.mapSums[key] = 0.0
|
||||
}
|
||||
alert.mapSums[key] += float32(fs.DiskUsed / fs.DiskTotal * 100)
|
||||
}
|
||||
alert.mapSums[key] += float32(fs.DiskUsed / fs.DiskTotal * 100)
|
||||
}
|
||||
case "Temperature":
|
||||
if alert.mapSums == nil {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/henrygd/beszel"
|
||||
"github.com/henrygd/beszel/agent"
|
||||
"github.com/henrygd/beszel/agent/health"
|
||||
"github.com/henrygd/beszel/agent/utils"
|
||||
"github.com/spf13/pflag"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
@@ -116,12 +117,12 @@ func (opts *cmdOptions) loadPublicKeys() ([]ssh.PublicKey, error) {
|
||||
}
|
||||
|
||||
// Try environment variable
|
||||
if key, ok := agent.GetEnv("KEY"); ok && key != "" {
|
||||
if key, ok := utils.GetEnv("KEY"); ok && key != "" {
|
||||
return agent.ParseKeys(key)
|
||||
}
|
||||
|
||||
// Try key file
|
||||
keyFile, ok := agent.GetEnv("KEY_FILE")
|
||||
keyFile, ok := utils.GetEnv("KEY_FILE")
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("no key provided: must set -key flag, KEY env var, or KEY_FILE env var. Use 'beszel-agent help' for usage")
|
||||
}
|
||||
|
||||
@@ -127,43 +127,22 @@ var DockerHealthStrings = map[string]DockerHealth{
|
||||
"unhealthy": DockerHealthUnhealthy,
|
||||
}
|
||||
|
||||
// SharedCoreMetrics contains fields that are common to both container Stats and PveNodeStats
|
||||
type SharedCoreMetrics struct {
|
||||
Name string `json:"n" cbor:"0,keyasint"`
|
||||
Cpu float64 `json:"c" cbor:"1,keyasint"`
|
||||
Mem float64 `json:"m" cbor:"2,keyasint"`
|
||||
NetworkSent float64 `json:"ns,omitzero" cbor:"3,keyasint,omitzero"` // deprecated 0.18.3 (MB) - keep field for old agents/records
|
||||
NetworkRecv float64 `json:"nr,omitzero" cbor:"4,keyasint,omitzero"` // deprecated 0.18.3 (MB) - keep field for old agents/records
|
||||
Id string `json:"-" cbor:"7,keyasint"`
|
||||
Bandwidth [2]uint64 `json:"b,omitzero" cbor:"9,keyasint,omitzero"` // [sent bytes, recv bytes]
|
||||
PrevNet prevNetStats `json:"-"`
|
||||
PrevReadTime time.Time `json:"-"`
|
||||
}
|
||||
|
||||
// Stats holds data specific to docker containers for the containers table
|
||||
// Docker container stats
|
||||
type Stats struct {
|
||||
SharedCoreMetrics // used to populate stats field in container_stats
|
||||
|
||||
// fields used for containers table
|
||||
Name string `json:"n" cbor:"0,keyasint"`
|
||||
Cpu float64 `json:"c" cbor:"1,keyasint"`
|
||||
Mem float64 `json:"m" cbor:"2,keyasint"`
|
||||
NetworkSent float64 `json:"ns,omitzero" cbor:"3,keyasint,omitzero"` // deprecated 0.18.3 (MB) - keep field for old agents/records
|
||||
NetworkRecv float64 `json:"nr,omitzero" cbor:"4,keyasint,omitzero"` // deprecated 0.18.3 (MB) - keep field for old agents/records
|
||||
Bandwidth [2]uint64 `json:"b,omitzero" cbor:"9,keyasint,omitzero"` // [sent bytes, recv bytes]
|
||||
|
||||
Health DockerHealth `json:"-" cbor:"5,keyasint"`
|
||||
Status string `json:"-" cbor:"6,keyasint"`
|
||||
Id string `json:"-" cbor:"7,keyasint"`
|
||||
Image string `json:"-" cbor:"8,keyasint"`
|
||||
}
|
||||
|
||||
// PveNodeStats holds data specific to PVE nodes for the pve_vms table
|
||||
type PveNodeStats struct {
|
||||
SharedCoreMetrics // used to populate stats field in pve_stats
|
||||
|
||||
// fields used for pve_vms table
|
||||
|
||||
MaxCPU uint64 `json:"-" cbor:"10,keyasint,omitzero"` // PVE: max vCPU count
|
||||
MaxMem uint64 `json:"-" cbor:"11,keyasint,omitzero"` // PVE: max memory bytes
|
||||
Uptime uint64 `json:"-" cbor:"12,keyasint,omitzero"` // PVE: uptime in seconds
|
||||
Type string `json:"-" cbor:"13,keyasint,omitzero"` // PVE: resource type (e.g. "qemu" or "lxc")
|
||||
DiskRead uint64 `json:"-" cbor:"14,keyasint,omitzero"` // PVE: cumulative disk read bytes
|
||||
DiskWrite uint64 `json:"-" cbor:"15,keyasint,omitzero"` // PVE: cumulative disk write bytes
|
||||
Disk uint64 `json:"-" cbor:"16,keyasint,omitzero"` // PVE: allocated disk size in bytes
|
||||
NetOut uint64 `json:"-" cbor:"17,keyasint,omitzero"` // PVE: cumulative bytes sent by VM
|
||||
NetIn uint64 `json:"-" cbor:"18,keyasint,omitzero"` // PVE: cumulative bytes received by VM
|
||||
// PrevCpu [2]uint64 `json:"-"`
|
||||
CpuSystem uint64 `json:"-"`
|
||||
CpuContainer uint64 `json:"-"`
|
||||
PrevNet prevNetStats `json:"-"`
|
||||
PrevReadTime time.Time `json:"-"`
|
||||
}
|
||||
|
||||
@@ -143,8 +143,8 @@ type AtaDeviceStatisticsPage struct {
|
||||
}
|
||||
|
||||
type AtaDeviceStatisticsEntry struct {
|
||||
Name string `json:"name"`
|
||||
Value *uint64 `json:"value,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Value *int64 `json:"value,omitempty"`
|
||||
}
|
||||
|
||||
type AtaSmartAttribute struct {
|
||||
@@ -356,8 +356,8 @@ type SmartInfoForSata struct {
|
||||
SmartStatus SmartStatusInfo `json:"smart_status"`
|
||||
// AtaSmartData AtaSmartData `json:"ata_smart_data"`
|
||||
// AtaSctCapabilities AtaSctCapabilities `json:"ata_sct_capabilities"`
|
||||
AtaSmartAttributes AtaSmartAttributes `json:"ata_smart_attributes"`
|
||||
AtaDeviceStatistics AtaDeviceStatistics `json:"ata_device_statistics"`
|
||||
AtaSmartAttributes AtaSmartAttributes `json:"ata_smart_attributes"`
|
||||
AtaDeviceStatistics json.RawMessage `json:"ata_device_statistics"`
|
||||
// PowerOnTime PowerOnTimeInfo `json:"power_on_time"`
|
||||
// PowerCycleCount uint16 `json:"power_cycle_count"`
|
||||
Temperature TemperatureInfo `json:"temperature"`
|
||||
|
||||
@@ -12,8 +12,9 @@ import (
|
||||
|
||||
type Stats struct {
|
||||
Cpu float64 `json:"cpu" cbor:"0,keyasint"`
|
||||
MaxCpu float64 `json:"cpum,omitempty" cbor:"1,keyasint,omitempty"`
|
||||
MaxCpu float64 `json:"cpum,omitempty" cbor:"-"`
|
||||
Mem float64 `json:"m" cbor:"2,keyasint"`
|
||||
MaxMem float64 `json:"mm,omitempty" cbor:"-"`
|
||||
MemUsed float64 `json:"mu" cbor:"3,keyasint"`
|
||||
MemPct float64 `json:"mp" cbor:"4,keyasint"`
|
||||
MemBuffCache float64 `json:"mb" cbor:"5,keyasint"`
|
||||
@@ -23,26 +24,25 @@ type Stats struct {
|
||||
DiskTotal float64 `json:"d" cbor:"9,keyasint"`
|
||||
DiskUsed float64 `json:"du" cbor:"10,keyasint"`
|
||||
DiskPct float64 `json:"dp" cbor:"11,keyasint"`
|
||||
DiskReadPs float64 `json:"dr" cbor:"12,keyasint"`
|
||||
DiskWritePs float64 `json:"dw" cbor:"13,keyasint"`
|
||||
MaxDiskReadPs float64 `json:"drm,omitempty" cbor:"14,keyasint,omitempty"`
|
||||
MaxDiskWritePs float64 `json:"dwm,omitempty" cbor:"15,keyasint,omitempty"`
|
||||
DiskReadPs float64 `json:"dr,omitzero" cbor:"12,keyasint,omitzero"`
|
||||
DiskWritePs float64 `json:"dw,omitzero" cbor:"13,keyasint,omitzero"`
|
||||
MaxDiskReadPs float64 `json:"drm,omitempty" cbor:"-"`
|
||||
MaxDiskWritePs float64 `json:"dwm,omitempty" cbor:"-"`
|
||||
NetworkSent float64 `json:"ns,omitzero" cbor:"16,keyasint,omitzero"`
|
||||
NetworkRecv float64 `json:"nr,omitzero" cbor:"17,keyasint,omitzero"`
|
||||
MaxNetworkSent float64 `json:"nsm,omitempty" cbor:"18,keyasint,omitempty"`
|
||||
MaxNetworkRecv float64 `json:"nrm,omitempty" cbor:"19,keyasint,omitempty"`
|
||||
MaxNetworkSent float64 `json:"nsm,omitempty" cbor:"-"`
|
||||
MaxNetworkRecv float64 `json:"nrm,omitempty" cbor:"-"`
|
||||
Temperatures map[string]float64 `json:"t,omitempty" cbor:"20,keyasint,omitempty"`
|
||||
ExtraFs map[string]*FsStats `json:"efs,omitempty" cbor:"21,keyasint,omitempty"`
|
||||
GPUData map[string]GPUData `json:"g,omitempty" cbor:"22,keyasint,omitempty"`
|
||||
LoadAvg1 float64 `json:"l1,omitempty" cbor:"23,keyasint,omitempty"`
|
||||
LoadAvg5 float64 `json:"l5,omitempty" cbor:"24,keyasint,omitempty"`
|
||||
LoadAvg15 float64 `json:"l15,omitempty" cbor:"25,keyasint,omitempty"`
|
||||
Bandwidth [2]uint64 `json:"b,omitzero" cbor:"26,keyasint,omitzero"` // [sent bytes, recv bytes]
|
||||
MaxBandwidth [2]uint64 `json:"bm,omitzero" cbor:"27,keyasint,omitzero"` // [sent bytes, recv bytes]
|
||||
// LoadAvg1 float64 `json:"l1,omitempty" cbor:"23,keyasint,omitempty"`
|
||||
// LoadAvg5 float64 `json:"l5,omitempty" cbor:"24,keyasint,omitempty"`
|
||||
// LoadAvg15 float64 `json:"l15,omitempty" cbor:"25,keyasint,omitempty"`
|
||||
Bandwidth [2]uint64 `json:"b,omitzero" cbor:"26,keyasint,omitzero"` // [sent bytes, recv bytes]
|
||||
MaxBandwidth [2]uint64 `json:"bm,omitzero" cbor:"-"` // [sent bytes, recv bytes]
|
||||
// TODO: remove other load fields in future release in favor of load avg array
|
||||
LoadAvg [3]float64 `json:"la,omitempty" cbor:"28,keyasint"`
|
||||
Battery [2]uint8 `json:"bat,omitzero" cbor:"29,keyasint,omitzero"` // [percent, charge state, current]
|
||||
MaxMem float64 `json:"mm,omitempty" cbor:"30,keyasint,omitempty"`
|
||||
Battery [2]uint8 `json:"bat,omitzero" cbor:"29,keyasint,omitzero"` // [percent, charge state, current]
|
||||
NetworkInterfaces map[string][4]uint64 `json:"ni,omitempty" cbor:"31,keyasint,omitempty"` // [upload bytes, download bytes, total upload, total download]
|
||||
DiskIO [2]uint64 `json:"dio,omitzero" cbor:"32,keyasint,omitzero"` // [read bytes, write bytes]
|
||||
MaxDiskIO [2]uint64 `json:"diom,omitzero" cbor:"-"` // [max read bytes, max write bytes]
|
||||
@@ -90,8 +90,8 @@ type FsStats struct {
|
||||
TotalWrite uint64 `json:"-"`
|
||||
DiskReadPs float64 `json:"r" cbor:"2,keyasint"`
|
||||
DiskWritePs float64 `json:"w" cbor:"3,keyasint"`
|
||||
MaxDiskReadPS float64 `json:"rm,omitempty" cbor:"4,keyasint,omitempty"`
|
||||
MaxDiskWritePS float64 `json:"wm,omitempty" cbor:"5,keyasint,omitempty"`
|
||||
MaxDiskReadPS float64 `json:"rm,omitempty" cbor:"-"`
|
||||
MaxDiskWritePS float64 `json:"wm,omitempty" cbor:"-"`
|
||||
// TODO: remove DiskReadPs and DiskWritePs in future release in favor of DiskReadBytes and DiskWriteBytes
|
||||
DiskReadBytes uint64 `json:"rb" cbor:"6,keyasint,omitempty"`
|
||||
DiskWriteBytes uint64 `json:"wb" cbor:"7,keyasint,omitempty"`
|
||||
@@ -129,23 +129,23 @@ type Info struct {
|
||||
KernelVersion string `json:"k,omitempty" cbor:"1,keyasint,omitempty"` // deprecated - moved to Details struct
|
||||
Cores int `json:"c,omitzero" cbor:"2,keyasint,omitzero"` // deprecated - moved to Details struct
|
||||
// Threads is needed in Info struct to calculate load average thresholds
|
||||
Threads int `json:"t,omitempty" cbor:"3,keyasint,omitempty"`
|
||||
CpuModel string `json:"m,omitempty" cbor:"4,keyasint,omitempty"` // deprecated - moved to Details struct
|
||||
Uptime uint64 `json:"u" cbor:"5,keyasint"`
|
||||
Cpu float64 `json:"cpu" cbor:"6,keyasint"`
|
||||
MemPct float64 `json:"mp" cbor:"7,keyasint"`
|
||||
DiskPct float64 `json:"dp" cbor:"8,keyasint"`
|
||||
Bandwidth float64 `json:"b" cbor:"9,keyasint"`
|
||||
AgentVersion string `json:"v" cbor:"10,keyasint"`
|
||||
Podman bool `json:"p,omitempty" cbor:"11,keyasint,omitempty"` // deprecated - moved to Details struct
|
||||
GpuPct float64 `json:"g,omitempty" cbor:"12,keyasint,omitempty"`
|
||||
DashboardTemp float64 `json:"dt,omitempty" cbor:"13,keyasint,omitempty"`
|
||||
Os Os `json:"os,omitempty" cbor:"14,keyasint,omitempty"` // deprecated - moved to Details struct
|
||||
LoadAvg1 float64 `json:"l1,omitempty" cbor:"15,keyasint,omitempty"` // deprecated - use `la` array instead
|
||||
LoadAvg5 float64 `json:"l5,omitempty" cbor:"16,keyasint,omitempty"` // deprecated - use `la` array instead
|
||||
LoadAvg15 float64 `json:"l15,omitempty" cbor:"17,keyasint,omitempty"` // deprecated - use `la` array instead
|
||||
BandwidthBytes uint64 `json:"bb" cbor:"18,keyasint"`
|
||||
Threads int `json:"t,omitempty" cbor:"3,keyasint,omitempty"`
|
||||
CpuModel string `json:"m,omitempty" cbor:"4,keyasint,omitempty"` // deprecated - moved to Details struct
|
||||
Uptime uint64 `json:"u" cbor:"5,keyasint"`
|
||||
Cpu float64 `json:"cpu" cbor:"6,keyasint"`
|
||||
MemPct float64 `json:"mp" cbor:"7,keyasint"`
|
||||
DiskPct float64 `json:"dp" cbor:"8,keyasint"`
|
||||
Bandwidth float64 `json:"b,omitzero" cbor:"9,keyasint"` // deprecated in favor of BandwidthBytes
|
||||
AgentVersion string `json:"v" cbor:"10,keyasint"`
|
||||
Podman bool `json:"p,omitempty" cbor:"11,keyasint,omitempty"` // deprecated - moved to Details struct
|
||||
GpuPct float64 `json:"g,omitempty" cbor:"12,keyasint,omitempty"`
|
||||
DashboardTemp float64 `json:"dt,omitempty" cbor:"13,keyasint,omitempty"`
|
||||
Os Os `json:"os,omitempty" cbor:"14,keyasint,omitempty"` // deprecated - moved to Details struct
|
||||
// LoadAvg1 float64 `json:"l1,omitempty" cbor:"15,keyasint,omitempty"` // deprecated - use `la` array instead
|
||||
// LoadAvg5 float64 `json:"l5,omitempty" cbor:"16,keyasint,omitempty"` // deprecated - use `la` array instead
|
||||
// LoadAvg15 float64 `json:"l15,omitempty" cbor:"17,keyasint,omitempty"` // deprecated - use `la` array instead
|
||||
|
||||
BandwidthBytes uint64 `json:"bb" cbor:"18,keyasint"`
|
||||
LoadAvg [3]float64 `json:"la,omitempty" cbor:"19,keyasint"`
|
||||
ConnectionType ConnectionType `json:"ct,omitempty" cbor:"20,keyasint,omitempty,omitzero"`
|
||||
ExtraFsPct map[string]float64 `json:"efs,omitempty" cbor:"21,keyasint,omitempty"`
|
||||
@@ -170,10 +170,9 @@ type Details struct {
|
||||
|
||||
// Final data structure to return to the hub
|
||||
type CombinedData struct {
|
||||
Stats Stats `json:"stats" cbor:"0,keyasint"`
|
||||
Info Info `json:"info" cbor:"1,keyasint"`
|
||||
Containers []*container.Stats `json:"container" cbor:"2,keyasint"`
|
||||
SystemdServices []*systemd.Service `json:"systemd,omitempty" cbor:"3,keyasint,omitempty"`
|
||||
Details *Details `cbor:"4,keyasint,omitempty"`
|
||||
PVEStats []*container.PveNodeStats `json:"pve,omitempty" cbor:"5,keyasint,omitempty"`
|
||||
Stats Stats `json:"stats" cbor:"0,keyasint"`
|
||||
Info Info `json:"info" cbor:"1,keyasint"`
|
||||
Containers []*container.Stats `json:"container" cbor:"2,keyasint"`
|
||||
SystemdServices []*systemd.Service `json:"systemd,omitempty" cbor:"3,keyasint,omitempty"`
|
||||
Details *Details `cbor:"4,keyasint,omitempty"`
|
||||
}
|
||||
|
||||
@@ -917,7 +917,7 @@ func TestAgentWebSocketIntegration(t *testing.T) {
|
||||
|
||||
// Wait for connection result
|
||||
maxWait := 2 * time.Second
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
time.Sleep(40 * time.Millisecond)
|
||||
checkInterval := 20 * time.Millisecond
|
||||
timeout := time.After(maxWait)
|
||||
ticker := time.Tick(checkInterval)
|
||||
|
||||
@@ -1,35 +1,39 @@
|
||||
// Package expirymap provides a thread-safe map with expiring entries.
|
||||
// It supports TTL-based expiration with both lazy cleanup on access
|
||||
// and periodic background cleanup.
|
||||
package expirymap
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/pocketbase/tools/store"
|
||||
)
|
||||
|
||||
type val[T any] struct {
|
||||
type val[T comparable] struct {
|
||||
value T
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
type ExpiryMap[T any] struct {
|
||||
store *store.Store[string, *val[T]]
|
||||
cleanupInterval time.Duration
|
||||
type ExpiryMap[T comparable] struct {
|
||||
store store.Store[string, val[T]]
|
||||
stopChan chan struct{}
|
||||
stopOnce sync.Once
|
||||
}
|
||||
|
||||
// New creates a new expiry map with custom cleanup interval
|
||||
func New[T any](cleanupInterval time.Duration) *ExpiryMap[T] {
|
||||
func New[T comparable](cleanupInterval time.Duration) *ExpiryMap[T] {
|
||||
m := &ExpiryMap[T]{
|
||||
store: store.New(map[string]*val[T]{}),
|
||||
cleanupInterval: cleanupInterval,
|
||||
store: *store.New(map[string]val[T]{}),
|
||||
stopChan: make(chan struct{}),
|
||||
}
|
||||
m.startCleaner()
|
||||
go m.startCleaner(cleanupInterval)
|
||||
return m
|
||||
}
|
||||
|
||||
// Set stores a value with the given TTL
|
||||
func (m *ExpiryMap[T]) Set(key string, value T, ttl time.Duration) {
|
||||
m.store.Set(key, &val[T]{
|
||||
m.store.Set(key, val[T]{
|
||||
value: value,
|
||||
expires: time.Now().Add(ttl),
|
||||
})
|
||||
@@ -55,7 +59,7 @@ func (m *ExpiryMap[T]) GetOk(key string) (T, bool) {
|
||||
// GetByValue retrieves a value by value
|
||||
func (m *ExpiryMap[T]) GetByValue(val T) (key string, value T, ok bool) {
|
||||
for key, v := range m.store.GetAll() {
|
||||
if reflect.DeepEqual(v.value, val) {
|
||||
if v.value == val {
|
||||
// check if expired
|
||||
if v.expires.Before(time.Now()) {
|
||||
m.store.Remove(key)
|
||||
@@ -75,7 +79,7 @@ func (m *ExpiryMap[T]) Remove(key string) {
|
||||
// RemovebyValue removes a value by value
|
||||
func (m *ExpiryMap[T]) RemovebyValue(value T) (T, bool) {
|
||||
for key, val := range m.store.GetAll() {
|
||||
if reflect.DeepEqual(val.value, value) {
|
||||
if val.value == value {
|
||||
m.store.Remove(key)
|
||||
return val.value, true
|
||||
}
|
||||
@@ -84,13 +88,23 @@ func (m *ExpiryMap[T]) RemovebyValue(value T) (T, bool) {
|
||||
}
|
||||
|
||||
// startCleaner runs the background cleanup process
|
||||
func (m *ExpiryMap[T]) startCleaner() {
|
||||
go func() {
|
||||
tick := time.Tick(m.cleanupInterval)
|
||||
for range tick {
|
||||
func (m *ExpiryMap[T]) startCleaner(interval time.Duration) {
|
||||
tick := time.Tick(interval)
|
||||
for {
|
||||
select {
|
||||
case <-tick:
|
||||
m.cleanup()
|
||||
case <-m.stopChan:
|
||||
return
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
// StopCleaner stops the background cleanup process
|
||||
func (m *ExpiryMap[T]) StopCleaner() {
|
||||
m.stopOnce.Do(func() {
|
||||
close(m.stopChan)
|
||||
})
|
||||
}
|
||||
|
||||
// cleanup removes all expired entries
|
||||
@@ -102,3 +116,12 @@ func (m *ExpiryMap[T]) cleanup() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateExpiration updates the expiration time of a key
|
||||
func (m *ExpiryMap[T]) UpdateExpiration(key string, ttl time.Duration) {
|
||||
value, ok := m.store.GetOk(key)
|
||||
if ok {
|
||||
value.expires = time.Now().Add(ttl)
|
||||
m.store.Set(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ package expirymap
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"testing/synctest"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -177,6 +178,33 @@ func TestExpiryMap_GenericTypes(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestExpiryMap_UpdateExpiration(t *testing.T) {
|
||||
em := New[string](time.Hour)
|
||||
|
||||
// Set a value with short TTL
|
||||
em.Set("key1", "value1", time.Millisecond*50)
|
||||
|
||||
// Verify it exists
|
||||
assert.True(t, em.Has("key1"))
|
||||
|
||||
// Update expiration to a longer TTL
|
||||
em.UpdateExpiration("key1", time.Hour)
|
||||
|
||||
// Wait for the original TTL to pass
|
||||
time.Sleep(time.Millisecond * 100)
|
||||
|
||||
// Should still exist because expiration was updated
|
||||
assert.True(t, em.Has("key1"))
|
||||
value, ok := em.GetOk("key1")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "value1", value)
|
||||
|
||||
// Try updating non-existent key (should not panic)
|
||||
assert.NotPanics(t, func() {
|
||||
em.UpdateExpiration("nonexistent", time.Hour)
|
||||
})
|
||||
}
|
||||
|
||||
func TestExpiryMap_ZeroValues(t *testing.T) {
|
||||
em := New[string](time.Hour)
|
||||
|
||||
@@ -473,3 +501,52 @@ func TestExpiryMap_ValueOperations_Integration(t *testing.T) {
|
||||
assert.Equal(t, "unique", value)
|
||||
assert.Equal(t, "key2", key)
|
||||
}
|
||||
|
||||
func TestExpiryMap_Cleaner(t *testing.T) {
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
em := New[string](time.Second)
|
||||
defer em.StopCleaner()
|
||||
|
||||
em.Set("test", "value", 500*time.Millisecond)
|
||||
|
||||
// Wait 600ms, value is expired but cleaner hasn't run yet (interval is 1s)
|
||||
time.Sleep(600 * time.Millisecond)
|
||||
synctest.Wait()
|
||||
|
||||
// Map should still hold the value in its internal store before lazy access or cleaner
|
||||
assert.Equal(t, 1, len(em.store.GetAll()), "store should still have 1 item before cleaner runs")
|
||||
|
||||
// Wait another 500ms so cleaner (1s interval) runs
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
synctest.Wait() // Wait for background goroutine to process the tick
|
||||
|
||||
assert.Equal(t, 0, len(em.store.GetAll()), "store should be empty after cleaner runs")
|
||||
})
|
||||
}
|
||||
|
||||
func TestExpiryMap_StopCleaner(t *testing.T) {
|
||||
em := New[string](time.Hour)
|
||||
|
||||
// Initially, stopChan is open, reading would block
|
||||
select {
|
||||
case <-em.stopChan:
|
||||
t.Fatal("stopChan should be open initially")
|
||||
default:
|
||||
// success
|
||||
}
|
||||
|
||||
em.StopCleaner()
|
||||
|
||||
// After StopCleaner, stopChan is closed, reading returns immediately
|
||||
select {
|
||||
case <-em.stopChan:
|
||||
// success
|
||||
default:
|
||||
t.Fatal("stopChan was not closed by StopCleaner")
|
||||
}
|
||||
|
||||
// Calling StopCleaner again should NOT panic thanks to sync.Once
|
||||
assert.NotPanics(t, func() {
|
||||
em.StopCleaner()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"log/slog"
|
||||
"math/rand"
|
||||
"net"
|
||||
"strings"
|
||||
@@ -49,7 +48,6 @@ type System struct {
|
||||
detailsFetched atomic.Bool // True if static system details have been fetched and saved
|
||||
smartFetching atomic.Bool // True if SMART devices are currently being fetched
|
||||
smartInterval time.Duration // Interval for periodic SMART data updates
|
||||
lastSmartFetch atomic.Int64 // Unix milliseconds of last SMART data fetch
|
||||
}
|
||||
|
||||
func (sm *SystemManager) NewSystem(systemId string) *System {
|
||||
@@ -135,19 +133,34 @@ func (sys *System) update() error {
|
||||
return err
|
||||
}
|
||||
|
||||
// ensure deprecated fields from older agents are migrated to current fields
|
||||
migrateDeprecatedFields(data, !sys.detailsFetched.Load())
|
||||
|
||||
// create system records
|
||||
_, err = sys.createRecords(data)
|
||||
|
||||
// if details were included and fetched successfully, mark details as fetched and update smart interval if set by agent
|
||||
if err == nil && data.Details != nil {
|
||||
sys.detailsFetched.Store(true)
|
||||
// update smart interval if it's set on the agent side
|
||||
if data.Details.SmartInterval > 0 {
|
||||
sys.smartInterval = data.Details.SmartInterval
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch and save SMART devices when system first comes online or at intervals
|
||||
if backgroundSmartFetchEnabled() {
|
||||
if backgroundSmartFetchEnabled() && sys.detailsFetched.Load() {
|
||||
if sys.smartInterval <= 0 {
|
||||
sys.smartInterval = time.Hour
|
||||
}
|
||||
lastFetch := sys.lastSmartFetch.Load()
|
||||
if time.Since(time.UnixMilli(lastFetch)) >= sys.smartInterval && sys.smartFetching.CompareAndSwap(false, true) {
|
||||
lastFetch, _ := sys.manager.smartFetchMap.GetOk(sys.Id)
|
||||
if time.Since(time.UnixMilli(lastFetch-1e4)) >= sys.smartInterval && sys.smartFetching.CompareAndSwap(false, true) {
|
||||
go func() {
|
||||
defer sys.smartFetching.Store(false)
|
||||
sys.lastSmartFetch.Store(time.Now().UnixMilli())
|
||||
sys.manager.smartFetchMap.Set(sys.Id, time.Now().UnixMilli(), sys.smartInterval+time.Minute)
|
||||
_ = sys.FetchAndSaveSmartDevices()
|
||||
}()
|
||||
}
|
||||
@@ -210,28 +223,6 @@ func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error
|
||||
}
|
||||
}
|
||||
|
||||
// add pve_vms and pve_stats records
|
||||
if len(data.PVEStats) > 0 {
|
||||
if data.PVEStats[0].Id != "" {
|
||||
if err := createPVEVMRecords(txApp, data.PVEStats, sys.Id); err != nil {
|
||||
slog.Error("Error creating PVE VM records", "err", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
pveStatsCollection, err := txApp.FindCachedCollectionByNameOrId("pve_stats")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pveStatsRecord := core.NewRecord(pveStatsCollection)
|
||||
pveStatsRecord.Set("system", systemRecord.Id)
|
||||
pveStatsRecord.Set("stats", data.PVEStats)
|
||||
pveStatsRecord.Set("type", "1m")
|
||||
if err := txApp.SaveNoValidate(pveStatsRecord); err != nil {
|
||||
slog.Error("Error creating PVE stats records", "err", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// add new systemd_stats record
|
||||
if len(data.SystemdServices) > 0 {
|
||||
if err := createSystemdStatsRecords(txApp, data.SystemdServices, sys.Id); err != nil {
|
||||
@@ -244,11 +235,6 @@ func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error
|
||||
if err := createSystemDetailsRecord(txApp, data.Details, sys.Id); err != nil {
|
||||
return err
|
||||
}
|
||||
sys.detailsFetched.Store(true)
|
||||
// update smart interval if it's set on the agent side
|
||||
if data.Details.SmartInterval > 0 {
|
||||
sys.smartInterval = data.Details.SmartInterval
|
||||
}
|
||||
}
|
||||
|
||||
// update system record (do this last because it triggers alerts and we need above records to be inserted first)
|
||||
@@ -354,43 +340,8 @@ func createContainerRecords(app core.App, data []*container.Stats, systemId stri
|
||||
return err
|
||||
}
|
||||
|
||||
// createPVEVMRecords creates or updates pve_vms records
|
||||
func createPVEVMRecords(app core.App, data []*container.PveNodeStats, systemId string) error {
|
||||
if len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
// shared params for all records
|
||||
params := dbx.Params{
|
||||
"system": systemId,
|
||||
"updated": time.Now().UTC().UnixMilli(),
|
||||
}
|
||||
valueStrings := make([]string, 0, len(data))
|
||||
for i, vm := range data {
|
||||
suffix := fmt.Sprintf("%d", i)
|
||||
valueStrings = append(valueStrings, fmt.Sprintf("({:id%[1]s}, {:system}, {:name%[1]s}, {:type%[1]s}, {:cpu%[1]s}, {:mem%[1]s}, {:netout%[1]s}, {:netin%[1]s}, {:maxcpu%[1]s}, {:maxmem%[1]s}, {:uptime%[1]s}, {:diskread%[1]s}, {:diskwrite%[1]s}, {:disk%[1]s}, {:updated})", suffix))
|
||||
params["id"+suffix] = makeStableHashId(systemId, vm.Id)
|
||||
params["name"+suffix] = vm.Name
|
||||
params["type"+suffix] = vm.Type // "qemu" or "lxc"
|
||||
params["cpu"+suffix] = vm.Cpu
|
||||
params["mem"+suffix] = vm.Mem
|
||||
params["maxcpu"+suffix] = vm.MaxCPU
|
||||
params["maxmem"+suffix] = vm.MaxMem
|
||||
params["uptime"+suffix] = vm.Uptime
|
||||
params["diskread"+suffix] = vm.DiskRead
|
||||
params["diskwrite"+suffix] = vm.DiskWrite
|
||||
params["disk"+suffix] = vm.Disk
|
||||
params["netout"+suffix] = vm.NetOut // cumulative bytes sent by VM
|
||||
params["netin"+suffix] = vm.NetIn // cumulative bytes received by VM
|
||||
}
|
||||
queryString := fmt.Sprintf(
|
||||
"INSERT INTO pve_vms (id, system, name, type, cpu, mem, netout, netin, maxcpu, maxmem, uptime, diskread, diskwrite, disk, updated) VALUES %s ON CONFLICT(id) DO UPDATE SET system=excluded.system, name=excluded.name, type=excluded.type, cpu=excluded.cpu, mem=excluded.mem, netout=excluded.netout, netin=excluded.netin, maxcpu=excluded.maxcpu, maxmem=excluded.maxmem, uptime=excluded.uptime, diskread=excluded.diskread, diskwrite=excluded.diskwrite, disk=excluded.disk, updated=excluded.updated",
|
||||
strings.Join(valueStrings, ","),
|
||||
)
|
||||
_, err := app.DB().NewQuery(queryString).Bind(params).Execute()
|
||||
return err
|
||||
}
|
||||
|
||||
// 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)
|
||||
if err != nil || record == nil {
|
||||
@@ -761,3 +712,50 @@ func getJitter() <-chan time.Time {
|
||||
msDelay := (interval * minPercent / 100) + rand.Intn(interval*jitterRange/100)
|
||||
return time.After(time.Duration(msDelay) * time.Millisecond)
|
||||
}
|
||||
|
||||
// migrateDeprecatedFields moves values from deprecated fields to their new locations if the new
|
||||
// fields are not already populated. Deprecated fields and refs may be removed at least 30 days
|
||||
// and one minor version release after the release that includes the migration.
|
||||
//
|
||||
// This is run when processing incoming system data from agents, which may be on older versions.
|
||||
func migrateDeprecatedFields(cd *system.CombinedData, createDetails bool) {
|
||||
// migration added 0.19.0
|
||||
if cd.Stats.Bandwidth[0] == 0 && cd.Stats.Bandwidth[1] == 0 {
|
||||
cd.Stats.Bandwidth[0] = uint64(cd.Stats.NetworkSent * 1024 * 1024)
|
||||
cd.Stats.Bandwidth[1] = uint64(cd.Stats.NetworkRecv * 1024 * 1024)
|
||||
cd.Stats.NetworkSent, cd.Stats.NetworkRecv = 0, 0
|
||||
}
|
||||
// migration added 0.19.0
|
||||
if cd.Info.BandwidthBytes == 0 {
|
||||
cd.Info.BandwidthBytes = uint64(cd.Info.Bandwidth * 1024 * 1024)
|
||||
cd.Info.Bandwidth = 0
|
||||
}
|
||||
// migration added 0.19.0
|
||||
if cd.Stats.DiskIO[0] == 0 && cd.Stats.DiskIO[1] == 0 {
|
||||
cd.Stats.DiskIO[0] = uint64(cd.Stats.DiskReadPs * 1024 * 1024)
|
||||
cd.Stats.DiskIO[1] = uint64(cd.Stats.DiskWritePs * 1024 * 1024)
|
||||
cd.Stats.DiskReadPs, cd.Stats.DiskWritePs = 0, 0
|
||||
}
|
||||
// migration added 0.19.0 - Move deprecated Info fields to Details struct
|
||||
if cd.Details == nil && cd.Info.Hostname != "" {
|
||||
if createDetails {
|
||||
cd.Details = &system.Details{
|
||||
Hostname: cd.Info.Hostname,
|
||||
Kernel: cd.Info.KernelVersion,
|
||||
Cores: cd.Info.Cores,
|
||||
Threads: cd.Info.Threads,
|
||||
CpuModel: cd.Info.CpuModel,
|
||||
Podman: cd.Info.Podman,
|
||||
Os: cd.Info.Os,
|
||||
MemoryTotal: uint64(cd.Stats.Mem * 1024 * 1024 * 1024),
|
||||
}
|
||||
}
|
||||
// zero the deprecated fields to prevent saving them in systems.info DB json payload
|
||||
cd.Info.Hostname = ""
|
||||
cd.Info.KernelVersion = ""
|
||||
cd.Info.Cores = 0
|
||||
cd.Info.CpuModel = ""
|
||||
cd.Info.Podman = false
|
||||
cd.Info.Os = 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/henrygd/beszel/internal/hub/ws"
|
||||
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
"github.com/henrygd/beszel/internal/hub/expirymap"
|
||||
|
||||
"github.com/henrygd/beszel/internal/common"
|
||||
|
||||
@@ -40,9 +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
|
||||
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
|
||||
}
|
||||
|
||||
// hubLike defines the interface requirements for the hub dependency.
|
||||
@@ -58,8 +60,9 @@ type hubLike interface {
|
||||
// The hub must implement the hubLike interface to provide database and alert functionality.
|
||||
func NewSystemManager(hub hubLike) *SystemManager {
|
||||
return &SystemManager{
|
||||
systems: store.New(map[string]*System{}),
|
||||
hub: hub,
|
||||
systems: store.New(map[string]*System{}),
|
||||
hub: hub,
|
||||
smartFetchMap: expirymap.New[int64](time.Hour),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
159
internal/hub/systems/system_test.go
Normal file
159
internal/hub/systems/system_test.go
Normal file
@@ -0,0 +1,159 @@
|
||||
//go:build testing
|
||||
|
||||
package systems
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
)
|
||||
|
||||
func TestCombinedData_MigrateDeprecatedFields(t *testing.T) {
|
||||
t.Run("Migrate NetworkSent and NetworkRecv to Bandwidth", func(t *testing.T) {
|
||||
cd := &system.CombinedData{
|
||||
Stats: system.Stats{
|
||||
NetworkSent: 1.5, // 1.5 MB
|
||||
NetworkRecv: 2.5, // 2.5 MB
|
||||
},
|
||||
}
|
||||
migrateDeprecatedFields(cd, true)
|
||||
|
||||
expectedSent := uint64(1.5 * 1024 * 1024)
|
||||
expectedRecv := uint64(2.5 * 1024 * 1024)
|
||||
|
||||
if cd.Stats.Bandwidth[0] != expectedSent {
|
||||
t.Errorf("expected Bandwidth[0] %d, got %d", expectedSent, cd.Stats.Bandwidth[0])
|
||||
}
|
||||
if cd.Stats.Bandwidth[1] != expectedRecv {
|
||||
t.Errorf("expected Bandwidth[1] %d, got %d", expectedRecv, cd.Stats.Bandwidth[1])
|
||||
}
|
||||
if cd.Stats.NetworkSent != 0 || cd.Stats.NetworkRecv != 0 {
|
||||
t.Errorf("expected NetworkSent and NetworkRecv to be reset, got %f, %f", cd.Stats.NetworkSent, cd.Stats.NetworkRecv)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Migrate Info.Bandwidth to Info.BandwidthBytes", func(t *testing.T) {
|
||||
cd := &system.CombinedData{
|
||||
Info: system.Info{
|
||||
Bandwidth: 10.0, // 10 MB
|
||||
},
|
||||
}
|
||||
migrateDeprecatedFields(cd, true)
|
||||
|
||||
expected := uint64(10 * 1024 * 1024)
|
||||
if cd.Info.BandwidthBytes != expected {
|
||||
t.Errorf("expected BandwidthBytes %d, got %d", expected, cd.Info.BandwidthBytes)
|
||||
}
|
||||
if cd.Info.Bandwidth != 0 {
|
||||
t.Errorf("expected Info.Bandwidth to be reset, got %f", cd.Info.Bandwidth)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Migrate DiskReadPs and DiskWritePs to DiskIO", func(t *testing.T) {
|
||||
cd := &system.CombinedData{
|
||||
Stats: system.Stats{
|
||||
DiskReadPs: 3.0, // 3 MB
|
||||
DiskWritePs: 4.0, // 4 MB
|
||||
},
|
||||
}
|
||||
migrateDeprecatedFields(cd, true)
|
||||
|
||||
expectedRead := uint64(3 * 1024 * 1024)
|
||||
expectedWrite := uint64(4 * 1024 * 1024)
|
||||
|
||||
if cd.Stats.DiskIO[0] != expectedRead {
|
||||
t.Errorf("expected DiskIO[0] %d, got %d", expectedRead, cd.Stats.DiskIO[0])
|
||||
}
|
||||
if cd.Stats.DiskIO[1] != expectedWrite {
|
||||
t.Errorf("expected DiskIO[1] %d, got %d", expectedWrite, cd.Stats.DiskIO[1])
|
||||
}
|
||||
if cd.Stats.DiskReadPs != 0 || cd.Stats.DiskWritePs != 0 {
|
||||
t.Errorf("expected DiskReadPs and DiskWritePs to be reset, got %f, %f", cd.Stats.DiskReadPs, cd.Stats.DiskWritePs)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Migrate Info fields to Details struct", func(t *testing.T) {
|
||||
cd := &system.CombinedData{
|
||||
Stats: system.Stats{
|
||||
Mem: 16.0, // 16 GB
|
||||
},
|
||||
Info: system.Info{
|
||||
Hostname: "test-host",
|
||||
KernelVersion: "6.8.0",
|
||||
Cores: 8,
|
||||
Threads: 16,
|
||||
CpuModel: "Intel i7",
|
||||
Podman: true,
|
||||
Os: system.Linux,
|
||||
},
|
||||
}
|
||||
migrateDeprecatedFields(cd, true)
|
||||
|
||||
if cd.Details == nil {
|
||||
t.Fatal("expected Details struct to be created")
|
||||
}
|
||||
if cd.Details.Hostname != "test-host" {
|
||||
t.Errorf("expected Hostname 'test-host', got '%s'", cd.Details.Hostname)
|
||||
}
|
||||
if cd.Details.Kernel != "6.8.0" {
|
||||
t.Errorf("expected Kernel '6.8.0', got '%s'", cd.Details.Kernel)
|
||||
}
|
||||
if cd.Details.Cores != 8 {
|
||||
t.Errorf("expected Cores 8, got %d", cd.Details.Cores)
|
||||
}
|
||||
if cd.Details.Threads != 16 {
|
||||
t.Errorf("expected Threads 16, got %d", cd.Details.Threads)
|
||||
}
|
||||
if cd.Details.CpuModel != "Intel i7" {
|
||||
t.Errorf("expected CpuModel 'Intel i7', got '%s'", cd.Details.CpuModel)
|
||||
}
|
||||
if cd.Details.Podman != true {
|
||||
t.Errorf("expected Podman true, got %v", cd.Details.Podman)
|
||||
}
|
||||
if cd.Details.Os != system.Linux {
|
||||
t.Errorf("expected Os Linux, got %d", cd.Details.Os)
|
||||
}
|
||||
expectedMem := uint64(16 * 1024 * 1024 * 1024)
|
||||
if cd.Details.MemoryTotal != expectedMem {
|
||||
t.Errorf("expected MemoryTotal %d, got %d", expectedMem, cd.Details.MemoryTotal)
|
||||
}
|
||||
|
||||
if cd.Info.Hostname != "" || cd.Info.KernelVersion != "" || cd.Info.Cores != 0 || cd.Info.CpuModel != "" || cd.Info.Podman != false || cd.Info.Os != 0 {
|
||||
t.Errorf("expected Info fields to be reset, got %+v", cd.Info)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Do not migrate if Details already exists", func(t *testing.T) {
|
||||
cd := &system.CombinedData{
|
||||
Details: &system.Details{Hostname: "existing-host"},
|
||||
Info: system.Info{
|
||||
Hostname: "deprecated-host",
|
||||
},
|
||||
}
|
||||
migrateDeprecatedFields(cd, true)
|
||||
|
||||
if cd.Details.Hostname != "existing-host" {
|
||||
t.Errorf("expected Hostname 'existing-host', got '%s'", cd.Details.Hostname)
|
||||
}
|
||||
if cd.Info.Hostname != "deprecated-host" {
|
||||
t.Errorf("expected Info.Hostname to remain 'deprecated-host', got '%s'", cd.Info.Hostname)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Do not create details if migrateDetails is false", func(t *testing.T) {
|
||||
cd := &system.CombinedData{
|
||||
Info: system.Info{
|
||||
Hostname: "deprecated-host",
|
||||
},
|
||||
}
|
||||
migrateDeprecatedFields(cd, false)
|
||||
|
||||
if cd.Details != nil {
|
||||
t.Fatal("expected Details struct to not be created")
|
||||
}
|
||||
|
||||
if cd.Info.Hostname != "" {
|
||||
t.Errorf("expected Info.Hostname to be reset, got '%s'", cd.Info.Hostname)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -113,4 +113,5 @@ func (sm *SystemManager) RemoveAllSystems() {
|
||||
for _, system := range sm.systems.GetAll() {
|
||||
sm.RemoveSystem(system.Id)
|
||||
}
|
||||
sm.smartFetchMap.StopCleaner()
|
||||
}
|
||||
|
||||
@@ -1685,300 +1685,6 @@ func init() {
|
||||
"type": "base",
|
||||
"updateRule": null,
|
||||
"viewRule": null
|
||||
},
|
||||
{
|
||||
"createRule": null,
|
||||
"deleteRule": null,
|
||||
"fields": [
|
||||
{
|
||||
"autogeneratePattern": "[a-z0-9]{10}",
|
||||
"hidden": false,
|
||||
"id": "text3208210256",
|
||||
"max": 10,
|
||||
"min": 10,
|
||||
"name": "id",
|
||||
"pattern": "^[a-z0-9]+$",
|
||||
"presentable": false,
|
||||
"primaryKey": true,
|
||||
"required": true,
|
||||
"system": true,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"cascadeDelete": true,
|
||||
"collectionId": "2hz5ncl8tizk5nx",
|
||||
"hidden": false,
|
||||
"id": "pve_stats_sys01",
|
||||
"maxSelect": 1,
|
||||
"minSelect": 0,
|
||||
"name": "system",
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "pve_stats_json1",
|
||||
"maxSize": 2000000,
|
||||
"name": "stats",
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "json"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "pve_stats_type1",
|
||||
"maxSelect": 1,
|
||||
"name": "type",
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "select",
|
||||
"values": [
|
||||
"1m",
|
||||
"10m",
|
||||
"20m",
|
||||
"120m",
|
||||
"480m"
|
||||
]
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "pve_stats_crt1",
|
||||
"name": "created",
|
||||
"onCreate": true,
|
||||
"onUpdate": false,
|
||||
"presentable": false,
|
||||
"system": false,
|
||||
"type": "autodate"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "pve_stats_upd1",
|
||||
"name": "updated",
|
||||
"onCreate": true,
|
||||
"onUpdate": true,
|
||||
"presentable": false,
|
||||
"system": false,
|
||||
"type": "autodate"
|
||||
}
|
||||
],
|
||||
"id": "pvestats",
|
||||
"indexes": [
|
||||
"CREATE INDEX ` + "`" + `idx_pve_stats_sys_type_created` + "`" + ` ON ` + "`" + `pve_stats` + "`" + ` (\n ` + "`" + `system` + "`" + `,\n ` + "`" + `type` + "`" + `,\n ` + "`" + `created` + "`" + `\n)"
|
||||
],
|
||||
"listRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id",
|
||||
"name": "pve_stats",
|
||||
"system": false,
|
||||
"type": "base",
|
||||
"updateRule": null,
|
||||
"viewRule": null
|
||||
},
|
||||
{
|
||||
"createRule": null,
|
||||
"deleteRule": null,
|
||||
"fields": [
|
||||
{
|
||||
"autogeneratePattern": "[a-f0-9]{8}",
|
||||
"hidden": false,
|
||||
"id": "text3208210256",
|
||||
"max": 8,
|
||||
"min": 8,
|
||||
"name": "id",
|
||||
"pattern": "^[a-f0-9]+$",
|
||||
"presentable": false,
|
||||
"primaryKey": true,
|
||||
"required": true,
|
||||
"system": true,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"cascadeDelete": false,
|
||||
"collectionId": "2hz5ncl8tizk5nx",
|
||||
"hidden": false,
|
||||
"id": "pve_vms_sys001",
|
||||
"maxSelect": 1,
|
||||
"minSelect": 0,
|
||||
"name": "system",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"hidden": false,
|
||||
"id": "pve_vms_name01",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "name",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"hidden": false,
|
||||
"id": "pve_vms_type01",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "type",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "pve_vms_cpu001",
|
||||
"max": 100,
|
||||
"min": 0,
|
||||
"name": "cpu",
|
||||
"onlyInt": false,
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "number1253106325",
|
||||
"max": null,
|
||||
"min": null,
|
||||
"name": "maxcpu",
|
||||
"onlyInt": false,
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "pve_vms_mem001",
|
||||
"max": null,
|
||||
"min": 0,
|
||||
"name": "mem",
|
||||
"onlyInt": false,
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "number1693954525",
|
||||
"max": null,
|
||||
"min": null,
|
||||
"name": "maxmem",
|
||||
"onlyInt": false,
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "number208985346",
|
||||
"max": null,
|
||||
"min": null,
|
||||
"name": "disk",
|
||||
"onlyInt": false,
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "number4125810518",
|
||||
"max": null,
|
||||
"min": null,
|
||||
"name": "diskread",
|
||||
"onlyInt": false,
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "number752404475",
|
||||
"max": null,
|
||||
"min": null,
|
||||
"name": "diskwrite",
|
||||
"onlyInt": false,
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "number1880667380",
|
||||
"max": null,
|
||||
"min": null,
|
||||
"name": "netout",
|
||||
"onlyInt": false,
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "number2702533949",
|
||||
"max": null,
|
||||
"min": null,
|
||||
"name": "netin",
|
||||
"onlyInt": false,
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "number1563400775",
|
||||
"max": null,
|
||||
"min": null,
|
||||
"name": "uptime",
|
||||
"onlyInt": false,
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "pve_vms_upd001",
|
||||
"max": null,
|
||||
"min": null,
|
||||
"name": "updated",
|
||||
"onlyInt": true,
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "number"
|
||||
}
|
||||
],
|
||||
"id": "pvevms",
|
||||
"indexes": [
|
||||
"CREATE INDEX ` + "`" + `idx_pve_vms_updated` + "`" + ` ON ` + "`" + `pve_vms` + "`" + ` (` + "`" + `updated` + "`" + `)",
|
||||
"CREATE INDEX ` + "`" + `idx_pve_vms_system` + "`" + ` ON ` + "`" + `pve_vms` + "`" + ` (` + "`" + `system` + "`" + `)"
|
||||
],
|
||||
"listRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id",
|
||||
"name": "pve_vms",
|
||||
"system": false,
|
||||
"type": "base",
|
||||
"updateRule": null,
|
||||
"viewRule": null
|
||||
}
|
||||
]`
|
||||
|
||||
@@ -42,11 +42,11 @@ type StatsRecord struct {
|
||||
// global variables for reusing allocations
|
||||
var (
|
||||
statsRecord StatsRecord
|
||||
containerStats []container.SharedCoreMetrics
|
||||
containerStats []container.Stats
|
||||
sumStats system.Stats
|
||||
tempStats system.Stats
|
||||
queryParams = make(dbx.Params, 1)
|
||||
containerSums = make(map[string]*container.SharedCoreMetrics)
|
||||
containerSums = make(map[string]*container.Stats)
|
||||
)
|
||||
|
||||
// Create longer records by averaging shorter records
|
||||
@@ -82,7 +82,7 @@ func (rm *RecordManager) CreateLongerRecords() {
|
||||
// wrap the operations in a transaction
|
||||
rm.app.RunInTransaction(func(txApp core.App) error {
|
||||
var err error
|
||||
collections := [3]*core.Collection{}
|
||||
collections := [2]*core.Collection{}
|
||||
collections[0], err = txApp.FindCachedCollectionByNameOrId("system_stats")
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -91,10 +91,6 @@ func (rm *RecordManager) CreateLongerRecords() {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
collections[2], err = txApp.FindCachedCollectionByNameOrId("pve_stats")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var systems RecordIds
|
||||
db := txApp.DB()
|
||||
|
||||
@@ -154,9 +150,8 @@ func (rm *RecordManager) CreateLongerRecords() {
|
||||
case "system_stats":
|
||||
longerRecord.Set("stats", rm.AverageSystemStats(db, recordIds))
|
||||
case "container_stats":
|
||||
longerRecord.Set("stats", rm.AverageContainerStats(db, recordIds, "container_stats"))
|
||||
case "pve_stats":
|
||||
longerRecord.Set("stats", rm.AverageContainerStats(db, recordIds, "pve_stats"))
|
||||
|
||||
longerRecord.Set("stats", rm.AverageContainerStats(db, recordIds))
|
||||
}
|
||||
if err := txApp.SaveNoValidate(longerRecord); err != nil {
|
||||
log.Println("failed to save longer record", "err", err)
|
||||
@@ -440,8 +435,8 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
|
||||
return sum
|
||||
}
|
||||
|
||||
// Calculate the average stats of a list of container_stats or pve_stats records
|
||||
func (rm *RecordManager) AverageContainerStats(db dbx.Builder, records RecordIds, collectionName string) []container.SharedCoreMetrics {
|
||||
// Calculate the average stats of a list of container_stats records
|
||||
func (rm *RecordManager) AverageContainerStats(db dbx.Builder, records RecordIds) []container.Stats {
|
||||
// Clear global map for reuse
|
||||
for k := range containerSums {
|
||||
delete(containerSums, k)
|
||||
@@ -458,15 +453,15 @@ func (rm *RecordManager) AverageContainerStats(db dbx.Builder, records RecordIds
|
||||
containerStats = nil
|
||||
|
||||
queryParams["id"] = id
|
||||
db.NewQuery(fmt.Sprintf("SELECT stats FROM %s WHERE id = {:id}", collectionName)).Bind(queryParams).One(&statsRecord)
|
||||
db.NewQuery("SELECT stats FROM container_stats WHERE id = {:id}").Bind(queryParams).One(&statsRecord)
|
||||
|
||||
if err := json.Unmarshal(statsRecord.Stats, &containerStats); err != nil {
|
||||
return []container.SharedCoreMetrics{}
|
||||
return []container.Stats{}
|
||||
}
|
||||
for i := range containerStats {
|
||||
stat := containerStats[i]
|
||||
if _, ok := sums[stat.Name]; !ok {
|
||||
sums[stat.Name] = &container.SharedCoreMetrics{Name: stat.Name}
|
||||
sums[stat.Name] = &container.Stats{Name: stat.Name}
|
||||
}
|
||||
sums[stat.Name].Cpu += stat.Cpu
|
||||
sums[stat.Name].Mem += stat.Mem
|
||||
@@ -481,9 +476,9 @@ func (rm *RecordManager) AverageContainerStats(db dbx.Builder, records RecordIds
|
||||
}
|
||||
}
|
||||
|
||||
result := make([]container.SharedCoreMetrics, 0, len(sums))
|
||||
result := make([]container.Stats, 0, len(sums))
|
||||
for _, value := range sums {
|
||||
result = append(result, container.SharedCoreMetrics{
|
||||
result = append(result, container.Stats{
|
||||
Name: value.Name,
|
||||
Cpu: twoDecimals(value.Cpu / count),
|
||||
Mem: twoDecimals(value.Mem / count),
|
||||
@@ -504,10 +499,6 @@ func (rm *RecordManager) DeleteOldRecords() {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = deleteOldPVEVMRecords(txApp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = deleteOldSystemdServiceRecords(txApp)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -546,7 +537,7 @@ func deleteOldAlertsHistory(app core.App, countToKeep, countBeforeDeletion int)
|
||||
// Deletes system_stats records older than what is displayed in the UI
|
||||
func deleteOldSystemStats(app core.App) error {
|
||||
// Collections to process
|
||||
collections := [3]string{"system_stats", "container_stats", "pve_stats"}
|
||||
collections := [2]string{"system_stats", "container_stats"}
|
||||
|
||||
// Record types and their retention periods
|
||||
type RecordDeletionData struct {
|
||||
@@ -599,19 +590,6 @@ func deleteOldSystemdServiceRecords(app core.App) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Deletes pve_vms records that haven't been updated in the last 10 minutes
|
||||
func deleteOldPVEVMRecords(app core.App) error {
|
||||
now := time.Now().UTC()
|
||||
tenMinutesAgo := now.Add(-10 * time.Minute)
|
||||
|
||||
_, err := app.DB().NewQuery("DELETE FROM pve_vms WHERE updated < {:updated}").Bind(dbx.Params{"updated": tenMinutesAgo.UnixMilli()}).Execute()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete old pve_vms records: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Deletes container records that haven't been updated in the last 10 minutes
|
||||
func deleteOldContainerRecords(app core.App) error {
|
||||
now := time.Now().UTC()
|
||||
|
||||
@@ -23,16 +23,14 @@ export default memo(function ContainerChart({
|
||||
chartType,
|
||||
chartConfig,
|
||||
unit = "%",
|
||||
filterStore = $containerFilter,
|
||||
}: {
|
||||
dataKey: string
|
||||
chartData: ChartData
|
||||
chartType: ChartType
|
||||
chartConfig: ChartConfig
|
||||
unit?: string
|
||||
filterStore?: typeof $containerFilter
|
||||
}) {
|
||||
const filter = useStore(filterStore)
|
||||
const filter = useStore($containerFilter)
|
||||
const userSettings = useStore($userSettings)
|
||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||
|
||||
|
||||
@@ -16,19 +16,16 @@ import { useYAxisWidth } from "./hooks"
|
||||
export default memo(function LoadAverageChart({ chartData }: { chartData: ChartData }) {
|
||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||
|
||||
const keys: { legacy: keyof SystemStats; color: string; label: string }[] = [
|
||||
const keys: { color: string; label: string }[] = [
|
||||
{
|
||||
legacy: "l1",
|
||||
color: "hsl(271, 81%, 60%)", // Purple
|
||||
label: t({ message: `1 min`, comment: "Load average" }),
|
||||
},
|
||||
{
|
||||
legacy: "l5",
|
||||
color: "hsl(217, 91%, 60%)", // Blue
|
||||
label: t({ message: `5 min`, comment: "Load average" }),
|
||||
},
|
||||
{
|
||||
legacy: "l15",
|
||||
color: "hsl(25, 95%, 53%)", // Orange
|
||||
label: t({ message: `15 min`, comment: "Load average" }),
|
||||
},
|
||||
@@ -66,27 +63,18 @@ export default memo(function LoadAverageChart({ chartData }: { chartData: ChartD
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{keys.map(({ legacy, color, label }, i) => {
|
||||
const dataKey = (value: { stats: SystemStats }) => {
|
||||
const { minor, patch } = chartData.agentVersion
|
||||
if (minor <= 12 && patch < 1) {
|
||||
return value.stats?.[legacy]
|
||||
}
|
||||
return value.stats?.la?.[i] ?? value.stats?.[legacy]
|
||||
}
|
||||
return (
|
||||
<Line
|
||||
key={label}
|
||||
dataKey={dataKey}
|
||||
name={label}
|
||||
type="monotoneX"
|
||||
dot={false}
|
||||
strokeWidth={1.5}
|
||||
stroke={color}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{keys.map(({ color, label }, i) => (
|
||||
<Line
|
||||
key={label}
|
||||
dataKey={(value: { stats: SystemStats }) => value.stats?.la?.[i]}
|
||||
name={label}
|
||||
type="monotoneX"
|
||||
dot={false}
|
||||
strokeWidth={1.5}
|
||||
stroke={color}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
))}
|
||||
<ChartLegend content={<ChartLegendContent />} />
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Trans } from "@lingui/react/macro"
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { getPagePath } from "@nanostores/router"
|
||||
import {
|
||||
ContainerIcon,
|
||||
@@ -32,7 +31,6 @@ import { Logo } from "./logo"
|
||||
import { ModeToggle } from "./mode-toggle"
|
||||
import { $router, basePath, Link, prependBasePath } from "./router"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"
|
||||
import { ProxmoxIcon } from "./ui/icons"
|
||||
|
||||
const CommandPalette = lazy(() => import("./command-palette"))
|
||||
|
||||
@@ -79,20 +77,6 @@ export default function Navbar() {
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>S.M.A.R.T.</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href={getPagePath($router, "proxmox")}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}
|
||||
aria-label={t`Proxmox`}
|
||||
>
|
||||
<ProxmoxIcon className="h-[1.2rem] w-[1.2rem] opacity-90" />
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<Trans>Proxmox</Trans>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<LangToggle />
|
||||
<ModeToggle />
|
||||
<Tooltip>
|
||||
|
||||
@@ -1,215 +0,0 @@
|
||||
import type { Column, ColumnDef } from "@tanstack/react-table"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn, decimalString, formatBytes, hourWithSeconds, toFixedFloat } from "@/lib/utils"
|
||||
import type { PveVmRecord } from "@/types"
|
||||
import {
|
||||
ClockIcon,
|
||||
CpuIcon,
|
||||
HardDriveIcon,
|
||||
MemoryStickIcon,
|
||||
MonitorIcon,
|
||||
ServerIcon,
|
||||
TagIcon,
|
||||
TimerIcon,
|
||||
} from "lucide-react"
|
||||
import { EthernetIcon } from "../ui/icons"
|
||||
import { Badge } from "../ui/badge"
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { $allSystemsById } from "@/lib/stores"
|
||||
import { useStore } from "@nanostores/react"
|
||||
|
||||
/** Format uptime in seconds to a human-readable string */
|
||||
export function formatUptime(seconds: number): string {
|
||||
if (seconds < 60) return `${seconds}s`
|
||||
if (seconds < 3600) {
|
||||
const m = Math.floor(seconds / 60)
|
||||
return `${m}m`
|
||||
}
|
||||
if (seconds < 86400) {
|
||||
const h = Math.floor(seconds / 3600)
|
||||
const m = Math.floor((seconds % 3600) / 60)
|
||||
return m > 0 ? `${h}h ${m}m` : `${h}h`
|
||||
}
|
||||
const d = Math.floor(seconds / 86400)
|
||||
const h = Math.floor((seconds % 86400) / 3600)
|
||||
return h > 0 ? `${d}d ${h}h` : `${d}d`
|
||||
}
|
||||
|
||||
export const pveVmCols: ColumnDef<PveVmRecord>[] = [
|
||||
{
|
||||
id: "name",
|
||||
sortingFn: (a, b) => a.original.name.localeCompare(b.original.name),
|
||||
accessorFn: (record) => record.name,
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Name`} Icon={MonitorIcon} />,
|
||||
cell: ({ getValue }) => {
|
||||
return <span className="ms-1 max-w-48 block truncate">{getValue() as string}</span>
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "system",
|
||||
accessorFn: (record) => record.system,
|
||||
sortingFn: (a, b) => {
|
||||
const allSystems = $allSystemsById.get()
|
||||
const systemNameA = allSystems[a.original.system]?.name ?? ""
|
||||
const systemNameB = allSystems[b.original.system]?.name ?? ""
|
||||
return systemNameA.localeCompare(systemNameB)
|
||||
},
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`System`} Icon={ServerIcon} />,
|
||||
cell: ({ getValue }) => {
|
||||
const allSystems = useStore($allSystemsById)
|
||||
return <span className="ms-1 max-w-34 block truncate">{allSystems[getValue() as string]?.name ?? ""}</span>
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "type",
|
||||
accessorFn: (record) => record.type,
|
||||
sortingFn: (a, b) => a.original.type.localeCompare(b.original.type),
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Type`} Icon={TagIcon} />,
|
||||
cell: ({ getValue }) => {
|
||||
const type = getValue() as string
|
||||
return (
|
||||
<Badge variant="outline" className="dark:border-white/12 ms-1">
|
||||
{type}
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "cpu",
|
||||
accessorFn: (record) => record.cpu,
|
||||
invertSorting: true,
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`CPU`} Icon={CpuIcon} />,
|
||||
cell: ({ getValue }) => {
|
||||
const val = getValue() as number
|
||||
return <span className="ms-1 tabular-nums">{`${decimalString(val, val >= 10 ? 1 : 2)}%`}</span>
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "mem",
|
||||
accessorFn: (record) => record.mem,
|
||||
invertSorting: true,
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Memory`} Icon={MemoryStickIcon} />,
|
||||
cell: ({ getValue }) => {
|
||||
const val = getValue() as number
|
||||
const formatted = formatBytes(val, false, undefined, true)
|
||||
return (
|
||||
<span className="ms-1 tabular-nums">{`${decimalString(formatted.value, formatted.value >= 10 ? 1 : 2)} ${formatted.unit}`}</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "maxmem",
|
||||
accessorFn: (record) => record.maxmem,
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Max`} Icon={MemoryStickIcon} />,
|
||||
invertSorting: true,
|
||||
cell: ({ getValue }) => {
|
||||
// maxmem is stored in bytes; convert to MB for formatBytes
|
||||
const formatted = formatBytes(getValue() as number, false, undefined, false)
|
||||
return <span className="ms-1 tabular-nums">{`${toFixedFloat(formatted.value, 2)} ${formatted.unit}`}</span>
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "disk",
|
||||
accessorFn: (record) => record.disk,
|
||||
invertSorting: true,
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Disk`} Icon={HardDriveIcon} />,
|
||||
cell: ({ getValue }) => {
|
||||
const formatted = formatBytes(getValue() as number, false, undefined, false)
|
||||
return <span className="ms-1 tabular-nums">{`${toFixedFloat(formatted.value, 2)} ${formatted.unit}`}</span>
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "diskread",
|
||||
accessorFn: (record) => record.diskread,
|
||||
invertSorting: true,
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Read`} Icon={HardDriveIcon} />,
|
||||
cell: ({ getValue }) => {
|
||||
const val = getValue() as number
|
||||
const formatted = formatBytes(val, false, undefined, false)
|
||||
return <span className="ms-1 tabular-nums">{`${toFixedFloat(formatted.value, 2)} ${formatted.unit}`}</span>
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "diskwrite",
|
||||
accessorFn: (record) => record.diskwrite,
|
||||
invertSorting: true,
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Write`} Icon={HardDriveIcon} />,
|
||||
cell: ({ getValue }) => {
|
||||
const val = getValue() as number
|
||||
const formatted = formatBytes(val, false, undefined, false)
|
||||
return <span className="ms-1 tabular-nums">{`${toFixedFloat(formatted.value, 2)} ${formatted.unit}`}</span>
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "netin",
|
||||
accessorFn: (record) => record.netin,
|
||||
invertSorting: true,
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Download`} Icon={EthernetIcon} />,
|
||||
cell: ({ getValue }) => {
|
||||
const val = getValue() as number
|
||||
const formatted = formatBytes(val, false, undefined, false)
|
||||
return (
|
||||
<span className="ms-1 tabular-nums">{`${decimalString(formatted.value, formatted.value >= 10 ? 1 : 2)} ${formatted.unit}`}</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "netout",
|
||||
accessorFn: (record) => record.netout,
|
||||
invertSorting: true,
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Upload`} Icon={EthernetIcon} />,
|
||||
cell: ({ getValue }) => {
|
||||
const val = getValue() as number
|
||||
const formatted = formatBytes(val, false, undefined, false)
|
||||
return (
|
||||
<span className="ms-1 tabular-nums">{`${decimalString(formatted.value, formatted.value >= 10 ? 1 : 2)} ${formatted.unit}`}</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "maxcpu",
|
||||
accessorFn: (record) => record.maxcpu,
|
||||
header: ({ column }) => <HeaderButton column={column} name="vCPUs" Icon={CpuIcon} />,
|
||||
invertSorting: true,
|
||||
cell: ({ getValue }) => {
|
||||
return <span className="ms-1 tabular-nums">{getValue() as number}</span>
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "uptime",
|
||||
accessorFn: (record) => record.uptime,
|
||||
invertSorting: true,
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Uptime`} Icon={TimerIcon} />,
|
||||
cell: ({ getValue }) => {
|
||||
return <span className="ms-1">{formatUptime(getValue() as number)}</span>
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "updated",
|
||||
invertSorting: true,
|
||||
accessorFn: (record) => record.updated,
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Updated`} Icon={ClockIcon} />,
|
||||
cell: ({ getValue }) => {
|
||||
const timestamp = getValue() as number
|
||||
return <span className="ms-1 tabular-nums">{hourWithSeconds(new Date(timestamp).toISOString())}</span>
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
function HeaderButton({ column, name, Icon }: { column: Column<PveVmRecord>; name: string; Icon: React.ElementType }) {
|
||||
const isSorted = column.getIsSorted()
|
||||
return (
|
||||
<Button
|
||||
className={cn(
|
||||
"h-9 px-3 flex items-center gap-2 duration-50",
|
||||
isSorted && "bg-accent/70 light:bg-accent text-accent-foreground/90"
|
||||
)}
|
||||
variant="ghost"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
{Icon && <Icon className="size-4" />}
|
||||
{name}
|
||||
{/* <ArrowUpDownIcon className="size-4" /> */}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -1,391 +0,0 @@
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { Trans } from "@lingui/react/macro"
|
||||
import {
|
||||
type ColumnFiltersState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getSortedRowModel,
|
||||
type Row,
|
||||
type SortingState,
|
||||
type Table as TableType,
|
||||
useReactTable,
|
||||
type VisibilityState,
|
||||
} from "@tanstack/react-table"
|
||||
import { useVirtualizer, type VirtualItem } from "@tanstack/react-virtual"
|
||||
import { memo, RefObject, useEffect, useRef, useState } from "react"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { pb } from "@/lib/api"
|
||||
import type { PveVmRecord } from "@/types"
|
||||
import { pveVmCols, formatUptime } from "@/components/pve-table/pve-table-columns"
|
||||
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { cn, decimalString, formatBytes, useBrowserStorage } from "@/lib/utils"
|
||||
import { Sheet, SheetTitle, SheetHeader, SheetContent, SheetDescription } from "../ui/sheet"
|
||||
import { $allSystemsById } from "@/lib/stores"
|
||||
import { LoaderCircleIcon, XIcon } from "lucide-react"
|
||||
import { Separator } from "../ui/separator"
|
||||
import { $router, Link } from "../router"
|
||||
import { listenKeys } from "nanostores"
|
||||
import { getPagePath } from "@nanostores/router"
|
||||
|
||||
export default function PveTable({ systemId }: { systemId?: string }) {
|
||||
const loadTime = Date.now()
|
||||
const [data, setData] = useState<PveVmRecord[] | undefined>(undefined)
|
||||
const [sorting, setSorting] = useBrowserStorage<SortingState>(
|
||||
`sort-pve-${systemId ? 1 : 0}`,
|
||||
[{ id: systemId ? "name" : "system", desc: false }],
|
||||
sessionStorage
|
||||
)
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
|
||||
const [rowSelection, setRowSelection] = useState({})
|
||||
const [globalFilter, setGlobalFilter] = useState("")
|
||||
|
||||
useEffect(() => {
|
||||
function fetchData(systemId?: string) {
|
||||
pb.collection<PveVmRecord>("pve_vms")
|
||||
.getList(0, 2000, {
|
||||
filter: systemId ? pb.filter("system={:system}", { system: systemId }) : undefined,
|
||||
})
|
||||
.then(({ items }) => {
|
||||
if (items.length === 0) {
|
||||
setData((curItems) => {
|
||||
if (systemId) {
|
||||
return curItems?.filter((item) => item.system !== systemId) ?? []
|
||||
}
|
||||
return []
|
||||
})
|
||||
return
|
||||
}
|
||||
setData((curItems) => {
|
||||
const lastUpdated = Math.max(items[0].updated, items.at(-1)?.updated ?? 0)
|
||||
const vmIds = new Set<string>()
|
||||
const newItems: PveVmRecord[] = []
|
||||
for (const item of items) {
|
||||
if (Math.abs(lastUpdated - item.updated) < 70_000) {
|
||||
vmIds.add(item.id)
|
||||
newItems.push(item)
|
||||
}
|
||||
}
|
||||
for (const item of curItems ?? []) {
|
||||
if (!vmIds.has(item.id) && lastUpdated - item.updated < 70_000) {
|
||||
newItems.push(item)
|
||||
}
|
||||
}
|
||||
return newItems
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// initial load
|
||||
fetchData(systemId)
|
||||
|
||||
// if no systemId, pull pve vms after every system update
|
||||
if (!systemId) {
|
||||
return $allSystemsById.listen((_value, _oldValue, systemId) => {
|
||||
// exclude initial load of systems
|
||||
if (Date.now() - loadTime > 500) {
|
||||
fetchData(systemId)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// if systemId, fetch pve vms after the system is updated
|
||||
return listenKeys($allSystemsById, [systemId], (_newSystems) => {
|
||||
fetchData(systemId)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const table = useReactTable({
|
||||
data: data ?? [],
|
||||
columns: pveVmCols.filter((col) => (systemId ? col.id !== "system" : true)),
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
defaultColumn: {
|
||||
sortUndefined: "last",
|
||||
size: 100,
|
||||
minSize: 0,
|
||||
},
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters,
|
||||
columnVisibility,
|
||||
rowSelection,
|
||||
globalFilter,
|
||||
},
|
||||
onGlobalFilterChange: setGlobalFilter,
|
||||
globalFilterFn: (row, _columnId, filterValue) => {
|
||||
const vm = row.original
|
||||
const systemName = $allSystemsById.get()[vm.system]?.name ?? ""
|
||||
const id = vm.id ?? ""
|
||||
const name = vm.name ?? ""
|
||||
const type = vm.type ?? ""
|
||||
const searchString = `${systemName} ${id} ${name} ${type}`.toLowerCase()
|
||||
|
||||
return (filterValue as string)
|
||||
.toLowerCase()
|
||||
.split(" ")
|
||||
.every((term) => searchString.includes(term))
|
||||
},
|
||||
})
|
||||
|
||||
const rows = table.getRowModel().rows
|
||||
const visibleColumns = table.getVisibleLeafColumns()
|
||||
|
||||
return (
|
||||
<Card className="p-6 @container w-full">
|
||||
<CardHeader className="p-0 mb-4">
|
||||
<div className="grid md:flex gap-5 w-full items-end">
|
||||
<div className="px-2 sm:px-1">
|
||||
<CardTitle className="mb-2">
|
||||
<Trans>Proxmox Resources</Trans>
|
||||
</CardTitle>
|
||||
<CardDescription className="flex">
|
||||
<Trans>CPU is percent of overall host CPU usage.</Trans>
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="relative ms-auto w-full max-w-full md:w-64">
|
||||
<Input
|
||||
placeholder={t`Filter...`}
|
||||
value={globalFilter}
|
||||
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||
className="ps-4 pe-10 w-full"
|
||||
/>
|
||||
{globalFilter && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t`Clear`}
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 flex items-center justify-center text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setGlobalFilter("")}
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<div className="rounded-md">
|
||||
<AllPveTable table={table} rows={rows} colLength={visibleColumns.length} data={data} />
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const AllPveTable = memo(function AllPveTable({
|
||||
table,
|
||||
rows,
|
||||
colLength,
|
||||
data,
|
||||
}: {
|
||||
table: TableType<PveVmRecord>
|
||||
rows: Row<PveVmRecord>[]
|
||||
colLength: number
|
||||
data: PveVmRecord[] | undefined
|
||||
}) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const activeVm = useRef<PveVmRecord | null>(null)
|
||||
const [sheetOpen, setSheetOpen] = useState(false)
|
||||
const openSheet = (vm: PveVmRecord) => {
|
||||
activeVm.current = vm
|
||||
setSheetOpen(true)
|
||||
}
|
||||
|
||||
const virtualizer = useVirtualizer<HTMLDivElement, HTMLTableRowElement>({
|
||||
count: rows.length,
|
||||
estimateSize: () => 54,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
overscan: 5,
|
||||
})
|
||||
const virtualRows = virtualizer.getVirtualItems()
|
||||
|
||||
const paddingTop = Math.max(0, virtualRows[0]?.start ?? 0 - virtualizer.options.scrollMargin)
|
||||
const paddingBottom = Math.max(0, virtualizer.getTotalSize() - (virtualRows[virtualRows.length - 1]?.end ?? 0))
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"h-min max-h-[calc(100dvh-17rem)] max-w-full relative overflow-auto border rounded-md",
|
||||
(!rows.length || rows.length > 2) && "min-h-50"
|
||||
)}
|
||||
ref={scrollRef}
|
||||
>
|
||||
{/* add header height to table size */}
|
||||
<div style={{ height: `${virtualizer.getTotalSize() + 48}px`, paddingTop, paddingBottom }}>
|
||||
<table className="text-sm w-full h-full text-nowrap">
|
||||
<PveTableHead table={table} />
|
||||
<TableBody>
|
||||
{rows.length ? (
|
||||
virtualRows.map((virtualRow) => {
|
||||
const row = rows[virtualRow.index]
|
||||
return <PveTableRow key={row.id} row={row} virtualRow={virtualRow} openSheet={openSheet} />
|
||||
})
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={colLength} className="h-37 text-center pointer-events-none">
|
||||
{data ? (
|
||||
<Trans>No results.</Trans>
|
||||
) : (
|
||||
<LoaderCircleIcon className="animate-spin size-10 opacity-60 mx-auto" />
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</table>
|
||||
</div>
|
||||
<PveVmSheet sheetOpen={sheetOpen} setSheetOpen={setSheetOpen} activeVm={activeVm} />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
function PveVmSheet({
|
||||
sheetOpen,
|
||||
setSheetOpen,
|
||||
activeVm,
|
||||
}: {
|
||||
sheetOpen: boolean
|
||||
setSheetOpen: (open: boolean) => void
|
||||
activeVm: RefObject<PveVmRecord | null>
|
||||
}) {
|
||||
const vm = activeVm.current
|
||||
if (!vm) return null
|
||||
|
||||
const memFormatted = formatBytes(vm.mem, false, undefined, true)
|
||||
const maxMemFormatted = formatBytes(vm.maxmem, false, undefined, false)
|
||||
const netoutFormatted = formatBytes(vm.netout, false, undefined, false)
|
||||
const netinFormatted = formatBytes(vm.netin, false, undefined, false)
|
||||
const diskReadFormatted = formatBytes(vm.diskread, false, undefined, false)
|
||||
const diskWriteFormatted = formatBytes(vm.diskwrite, false, undefined, false)
|
||||
const diskFormatted = formatBytes(vm.disk, false, undefined, false)
|
||||
|
||||
return (
|
||||
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
|
||||
<SheetContent className="w-full sm:max-w-120 p-2">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{vm.name}</SheetTitle>
|
||||
<SheetDescription className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<Link className="hover:underline" href={getPagePath($router, "system", { id: vm.system })}>
|
||||
{$allSystemsById.get()[vm.system]?.name ?? ""}
|
||||
</Link>
|
||||
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
|
||||
{vm.type}
|
||||
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
|
||||
<Trans>Up {formatUptime(vm.uptime)}</Trans>
|
||||
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
|
||||
{vm.id}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="px-3 pb-3 -mt-2 flex flex-col gap-3">
|
||||
<h3 className="text-sm font-medium">
|
||||
<Trans>Details</Trans>
|
||||
</h3>
|
||||
<dl className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
|
||||
<dt className="text-muted-foreground">
|
||||
<Trans>CPU Usage</Trans>
|
||||
</dt>
|
||||
<dd className="tabular-nums">{`${decimalString(vm.cpu, vm.cpu >= 10 ? 1 : 2)}%`}</dd>
|
||||
|
||||
<dt className="text-muted-foreground">
|
||||
<Trans>Memory Used</Trans>
|
||||
</dt>
|
||||
<dd className="tabular-nums">{`${decimalString(memFormatted.value, memFormatted.value >= 10 ? 1 : 2)} ${memFormatted.unit}`}</dd>
|
||||
|
||||
<dt className="text-muted-foreground">
|
||||
<Trans>Upload</Trans>
|
||||
</dt>
|
||||
<dd className="tabular-nums">{`${decimalString(netoutFormatted.value, netoutFormatted.value >= 10 ? 1 : 2)} ${netoutFormatted.unit}`}</dd>
|
||||
|
||||
<dt className="text-muted-foreground">
|
||||
<Trans>Download</Trans>
|
||||
</dt>
|
||||
<dd className="tabular-nums">{`${decimalString(netinFormatted.value, netinFormatted.value >= 10 ? 1 : 2)} ${netinFormatted.unit}`}</dd>
|
||||
|
||||
<dt className="text-muted-foreground">
|
||||
<Trans>vCPUs</Trans>
|
||||
</dt>
|
||||
<dd className="tabular-nums">{vm.maxcpu}</dd>
|
||||
|
||||
<dt className="text-muted-foreground">
|
||||
<Trans>Max Memory</Trans>
|
||||
</dt>
|
||||
<dd className="tabular-nums">{`${decimalString(maxMemFormatted.value, maxMemFormatted.value >= 10 ? 1 : 2)} ${maxMemFormatted.unit}`}</dd>
|
||||
|
||||
<dt className="text-muted-foreground">
|
||||
<Trans>Disk Read</Trans>
|
||||
</dt>
|
||||
<dd className="tabular-nums">{`${decimalString(diskReadFormatted.value, diskReadFormatted.value >= 10 ? 1 : 2)} ${diskReadFormatted.unit}`}</dd>
|
||||
|
||||
<dt className="text-muted-foreground">
|
||||
<Trans>Disk Write</Trans>
|
||||
</dt>
|
||||
<dd className="tabular-nums">{`${decimalString(diskWriteFormatted.value, diskWriteFormatted.value >= 10 ? 1 : 2)} ${diskWriteFormatted.unit}`}</dd>
|
||||
|
||||
<dt className="text-muted-foreground">
|
||||
<Trans>Disk Size</Trans>
|
||||
</dt>
|
||||
<dd className="tabular-nums">{`${decimalString(diskFormatted.value, diskFormatted.value >= 10 ? 1 : 2)} ${diskFormatted.unit}`}</dd>
|
||||
|
||||
<dt className="text-muted-foreground">
|
||||
<Trans>Uptime</Trans>
|
||||
</dt>
|
||||
<dd className="tabular-nums">{formatUptime(vm.uptime)}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
function PveTableHead({ table }: { table: TableType<PveVmRecord> }) {
|
||||
return (
|
||||
<TableHeader className="sticky top-0 z-50 w-full border-b-2">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead className="px-2" key={header.id}>
|
||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</TableHead>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</TableHeader>
|
||||
)
|
||||
}
|
||||
|
||||
const PveTableRow = memo(function PveTableRow({
|
||||
row,
|
||||
virtualRow,
|
||||
openSheet,
|
||||
}: {
|
||||
row: Row<PveVmRecord>
|
||||
virtualRow: VirtualItem
|
||||
openSheet: (vm: PveVmRecord) => void
|
||||
}) {
|
||||
return (
|
||||
<TableRow
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
className="cursor-pointer transition-opacity"
|
||||
onClick={() => openSheet(row.original)}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className="py-0 ps-4.5"
|
||||
style={{
|
||||
height: virtualRow.size,
|
||||
}}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
)
|
||||
})
|
||||
@@ -3,7 +3,6 @@ import { createRouter } from "@nanostores/router"
|
||||
const routes = {
|
||||
home: "/",
|
||||
containers: "/containers",
|
||||
proxmox: "/proxmox",
|
||||
smart: "/smart",
|
||||
system: `/system/:id`,
|
||||
settings: `/settings/:name?`,
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { useLingui } from "@lingui/react/macro"
|
||||
import { memo, useEffect, useMemo } from "react"
|
||||
import PveTable from "@/components/pve-table/pve-table"
|
||||
import { ActiveAlerts } from "@/components/active-alerts"
|
||||
import { FooterRepoLink } from "@/components/footer-repo-link"
|
||||
|
||||
export default memo(() => {
|
||||
const { t } = useLingui()
|
||||
|
||||
useEffect(() => {
|
||||
document.title = `${t`All Proxmox VMs`} / Beszel`
|
||||
}, [t])
|
||||
|
||||
return useMemo(
|
||||
() => (
|
||||
<>
|
||||
<div className="grid gap-4">
|
||||
<ActiveAlerts />
|
||||
<PveTable />
|
||||
</div>
|
||||
<FooterRepoLink />
|
||||
</>
|
||||
),
|
||||
[]
|
||||
)
|
||||
})
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
$containerFilter,
|
||||
$direction,
|
||||
$maxValues,
|
||||
$pveFilter,
|
||||
$systems,
|
||||
$temperatureFilter,
|
||||
$userSettings,
|
||||
@@ -161,7 +160,6 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
||||
const [system, setSystem] = useState({} as SystemRecord)
|
||||
const [systemStats, setSystemStats] = useState([] as SystemStatsRecord[])
|
||||
const [containerData, setContainerData] = useState([] as ChartData["containerData"])
|
||||
const [pveData, setPveData] = useState([] as ChartData["containerData"])
|
||||
const temperatureChartRef = useRef<HTMLDivElement>(null)
|
||||
const persistChartTime = useRef(false)
|
||||
const [bottomSpacing, setBottomSpacing] = useState(0)
|
||||
@@ -179,10 +177,8 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
||||
persistChartTime.current = false
|
||||
setSystemStats([])
|
||||
setContainerData([])
|
||||
setPveData([])
|
||||
setDetails({} as SystemDetailsRecord)
|
||||
$containerFilter.set("")
|
||||
$pveFilter.set("")
|
||||
}
|
||||
}, [id])
|
||||
|
||||
@@ -281,10 +277,6 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
||||
// Share chart config computation for all container charts
|
||||
const containerChartConfigs = useContainerChartConfigs(containerData)
|
||||
|
||||
// PVE chart data and configs
|
||||
const pveSyntheticChartData = useMemo(() => ({ ...chartData, containerData: pveData }), [chartData, pveData])
|
||||
const pveChartConfigs = useContainerChartConfigs(pveData)
|
||||
|
||||
// make container stats for charts
|
||||
const makeContainerData = useCallback((containers: ContainerStatsRecord[]) => {
|
||||
const containerData = [] as ChartData["containerData"]
|
||||
@@ -315,8 +307,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
||||
Promise.allSettled([
|
||||
getStats<SystemStatsRecord>("system_stats", system, chartTime),
|
||||
getStats<ContainerStatsRecord>("container_stats", system, chartTime),
|
||||
getStats<ContainerStatsRecord>("pve_stats", system, chartTime),
|
||||
]).then(([systemStats, containerStats, pveStats]) => {
|
||||
]).then(([systemStats, containerStats]) => {
|
||||
// loading: false
|
||||
setChartLoading(false)
|
||||
|
||||
@@ -343,17 +334,6 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
||||
cache.set(cs_cache_key, containerData)
|
||||
}
|
||||
setContainerData(makeContainerData(containerData))
|
||||
// make new pve stats
|
||||
const ps_cache_key = `${system.id}_${chartTime}_pve_stats`
|
||||
let pveRecords = (cache.get(ps_cache_key) || []) as ContainerStatsRecord[]
|
||||
if (pveStats.status === "fulfilled" && pveStats.value.length) {
|
||||
pveRecords = pveRecords.concat(addEmptyValues(pveRecords, pveStats.value, expectedInterval))
|
||||
if (pveRecords.length > 120) {
|
||||
pveRecords = pveRecords.slice(-100)
|
||||
}
|
||||
cache.set(ps_cache_key, pveRecords)
|
||||
}
|
||||
setPveData(makeContainerData(pveRecords))
|
||||
})
|
||||
}, [system, chartTime])
|
||||
|
||||
@@ -419,7 +399,6 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
||||
const showMax = maxValues && isLongerChart
|
||||
|
||||
const containerFilterBar = containerData.length ? <FilterBar /> : null
|
||||
const pveFilterBar = pveData.length ? <FilterBar store={$pveFilter} /> : null
|
||||
|
||||
const dataEmpty = !chartLoading && chartData.systemStats.length === 0
|
||||
const lastGpus = systemStats.at(-1)?.stats?.g
|
||||
@@ -514,24 +493,6 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
||||
</ChartCard>
|
||||
)}
|
||||
|
||||
{pveFilterBar && (
|
||||
<ChartCard
|
||||
empty={dataEmpty}
|
||||
grid={grid}
|
||||
title={t`Proxmox CPU Usage`}
|
||||
description={t`Average CPU utilization of VMs and containers`}
|
||||
cornerEl={pveFilterBar}
|
||||
>
|
||||
<ContainerChart
|
||||
chartData={pveSyntheticChartData}
|
||||
dataKey="c"
|
||||
chartType={ChartType.CPU}
|
||||
chartConfig={pveChartConfigs.cpu}
|
||||
filterStore={$pveFilter}
|
||||
/>
|
||||
</ChartCard>
|
||||
)}
|
||||
|
||||
<ChartCard
|
||||
empty={dataEmpty}
|
||||
grid={grid}
|
||||
@@ -559,24 +520,6 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
||||
</ChartCard>
|
||||
)}
|
||||
|
||||
{pveFilterBar && (
|
||||
<ChartCard
|
||||
empty={dataEmpty}
|
||||
grid={grid}
|
||||
title={t`Proxmox Memory Usage`}
|
||||
description={t`Memory usage of Proxmox VMs and containers`}
|
||||
cornerEl={pveFilterBar}
|
||||
>
|
||||
<ContainerChart
|
||||
chartData={pveSyntheticChartData}
|
||||
dataKey="m"
|
||||
chartType={ChartType.Memory}
|
||||
chartConfig={pveChartConfigs.memory}
|
||||
filterStore={$pveFilter}
|
||||
/>
|
||||
</ChartCard>
|
||||
)}
|
||||
|
||||
<ChartCard empty={dataEmpty} grid={grid} title={t`Disk Usage`} description={t`Usage of root partition`}>
|
||||
<DiskChart chartData={chartData} dataKey="stats.du" diskSize={systemStats.at(-1)?.stats.d ?? NaN} />
|
||||
</ChartCard>
|
||||
@@ -698,24 +641,6 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
||||
</ChartCard>
|
||||
)}
|
||||
|
||||
{pveFilterBar && pveData.length > 0 && (
|
||||
<ChartCard
|
||||
empty={dataEmpty}
|
||||
grid={grid}
|
||||
title={t`Proxmox Network I/O`}
|
||||
description={t`Network traffic of Proxmox VMs and containers`}
|
||||
cornerEl={pveFilterBar}
|
||||
>
|
||||
<ContainerChart
|
||||
chartData={pveSyntheticChartData}
|
||||
chartType={ChartType.Network}
|
||||
dataKey="n"
|
||||
chartConfig={pveChartConfigs.network}
|
||||
filterStore={$pveFilter}
|
||||
/>
|
||||
</ChartCard>
|
||||
)}
|
||||
|
||||
{/* Swap chart */}
|
||||
{(systemStats.at(-1)?.stats.su ?? 0) > 0 && (
|
||||
<ChartCard
|
||||
@@ -729,7 +654,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
||||
)}
|
||||
|
||||
{/* Load Average chart */}
|
||||
{chartData.agentVersion?.minor >= 12 && (
|
||||
{chartData.agentVersion?.minor > 12 && (
|
||||
<ChartCard
|
||||
empty={dataEmpty}
|
||||
grid={grid}
|
||||
@@ -952,8 +877,6 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
||||
<LazyContainersTable systemId={system.id} />
|
||||
)}
|
||||
|
||||
{pveData.length > 0 && <LazyPveTable systemId={system.id} />}
|
||||
|
||||
{isLinux && compareSemVer(chartData.agentVersion, parseSemVer("0.16.0")) >= 0 && (
|
||||
<LazySystemdTable systemId={system.id} />
|
||||
)}
|
||||
@@ -1128,17 +1051,6 @@ function LazyContainersTable({ systemId }: { systemId: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
const PveTable = lazy(() => import("../pve-table/pve-table"))
|
||||
|
||||
function LazyPveTable({ systemId }: { systemId: string }) {
|
||||
const { isIntersecting, ref } = useIntersectionObserver({ rootMargin: "90px" })
|
||||
return (
|
||||
<div ref={ref} className={cn(isIntersecting && "contents")}>
|
||||
{isIntersecting && <PveTable systemId={systemId} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const SmartTable = lazy(() => import("./system/smart-table"))
|
||||
|
||||
function LazySmartTable({ systemId }: { systemId: string }) {
|
||||
|
||||
@@ -198,32 +198,19 @@ export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<Syste
|
||||
},
|
||||
{
|
||||
id: "loadAverage",
|
||||
accessorFn: ({ info }) => {
|
||||
const sum = info.la?.reduce((acc, curr) => acc + curr, 0)
|
||||
// TODO: remove this in future release in favor of la array
|
||||
if (!sum) {
|
||||
return (info.l1 ?? 0) + (info.l5 ?? 0) + (info.l15 ?? 0) || undefined
|
||||
}
|
||||
return sum || undefined
|
||||
},
|
||||
accessorFn: ({ info }) => info.la?.reduce((acc, curr) => acc + curr, 0),
|
||||
name: () => t({ message: "Load Avg", comment: "Short label for load average" }),
|
||||
size: 0,
|
||||
Icon: HourglassIcon,
|
||||
header: sortableHeader,
|
||||
cell(info: CellContext<SystemRecord, unknown>) {
|
||||
const { info: sysInfo, status } = info.row.original
|
||||
const { major, minor } = parseSemVer(sysInfo.v)
|
||||
const { colorWarn = 65, colorCrit = 90 } = useStore($userSettings, { keys: ["colorWarn", "colorCrit"] })
|
||||
// agent version
|
||||
const { minor, patch } = parseSemVer(sysInfo.v)
|
||||
let loadAverages = sysInfo.la
|
||||
|
||||
// use legacy load averages if agent version is less than 12.1.0
|
||||
if (!loadAverages || (minor === 12 && patch < 1)) {
|
||||
loadAverages = [sysInfo.l1 ?? 0, sysInfo.l5 ?? 0, sysInfo.l15 ?? 0]
|
||||
}
|
||||
const loadAverages = sysInfo.la || []
|
||||
|
||||
const max = Math.max(...loadAverages)
|
||||
if (max === 0 && (status === SystemStatus.Paused || minor < 12)) {
|
||||
if (max === 0 && (status === SystemStatus.Paused || (major < 1 && minor < 13))) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -248,19 +235,20 @@ export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<Syste
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorFn: ({ info }) => info.bb || (info.b || 0) * 1024 * 1024 || undefined,
|
||||
accessorFn: ({ info, status }) => (status !== SystemStatus.Up ? undefined : info.bb),
|
||||
id: "net",
|
||||
name: () => t`Net`,
|
||||
size: 0,
|
||||
Icon: EthernetIcon,
|
||||
header: sortableHeader,
|
||||
sortUndefined: "last",
|
||||
cell(info) {
|
||||
const sys = info.row.original
|
||||
const userSettings = useStore($userSettings, { keys: ["unitNet"] })
|
||||
if (sys.status === SystemStatus.Paused) {
|
||||
const val = info.getValue() as number | undefined
|
||||
if (val === undefined) {
|
||||
return null
|
||||
}
|
||||
const { value, unit } = formatBytes((info.getValue() || 0) as number, true, userSettings.unitNet, false)
|
||||
const userSettings = useStore($userSettings, { keys: ["unitNet"] })
|
||||
const { value, unit } = formatBytes(val, true, userSettings.unitNet, false)
|
||||
return (
|
||||
<span className="tabular-nums whitespace-nowrap">
|
||||
{decimalString(value, value >= 100 ? 1 : 2)} {unit}
|
||||
|
||||
@@ -185,12 +185,3 @@ export function PlugChargingIcon(props: SVGProps<SVGSVGElement>) {
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// simple-icons (CC0) https://github.com/simple-icons/simple-icons/blob/develop/LICENSE.md
|
||||
export function ProxmoxIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" {...props} stroke="currentColor" strokeWidth="1.5" fill="none">
|
||||
<path d="M5 1.8c-1.2.6-1.2.7-.1 1.8l7 7.8c.2 0 8-8.6 8.1-8.8l-.5-.5q-.5-.5-1.7-.5c-1.6-.1-2.2.2-4.1 2.4L12 6 10.4 4 8 1.9c-.8-.4-2.4-.5-3.2 0M1.2 4.4q-1.2.5-1.3.8l3 3.5L5.8 12l-3 3.3L0 18.8c.1.5 1.5 1 2.6 1 1.7 0 2-.2 5.6-4.1l3.2-3.7a74 74 0 0 0-7.1-7.5c-.9-.4-2.2-.5-3-.1m18.5 0q-.7.4-4 4L12.6 12l3.3 3.7c3.5 3.9 3.9 4.2 5.6 4.2 1 0 2.4-.6 2.5-1 0-.2-1.3-1.8-2.9-3.6L18 12l3-3.3c1.6-1.8 3-3.3 2.9-3.5 0-.4-1.4-1-2.5-1q-1 0-1.7.3M8 17l-4 4.4.5.6q.6.4 1.7.4c1.6.1 2.2-.2 4.2-2.5l1.6-1.8 1.7 1.8c2 2.3 2.5 2.6 4 2.5q1.3 0 1.8-.4t.5-.6c0-.2-7.9-8.8-8-8.8z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -55,9 +55,6 @@ listenKeys($userSettings, ["chartTime"], ({ chartTime }) => $chartTime.set(chart
|
||||
/** Container chart filter */
|
||||
export const $containerFilter = atom("")
|
||||
|
||||
/** PVE chart filter */
|
||||
export const $pveFilter = atom("")
|
||||
|
||||
/** Temperature chart filter */
|
||||
export const $temperatureFilter = atom("")
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ import * as systemsManager from "@/lib/systemsManager.ts"
|
||||
const LoginPage = lazy(() => import("@/components/login/login.tsx"))
|
||||
const Home = lazy(() => import("@/components/routes/home.tsx"))
|
||||
const Containers = lazy(() => import("@/components/routes/containers.tsx"))
|
||||
const Proxmox = lazy(() => import("@/components/routes/proxmox.tsx"))
|
||||
const Smart = lazy(() => import("@/components/routes/smart.tsx"))
|
||||
const SystemDetail = lazy(() => import("@/components/routes/system.tsx"))
|
||||
const CopyToClipboardDialog = lazy(() => import("@/components/copy-to-clipboard.tsx"))
|
||||
@@ -64,8 +63,6 @@ const App = memo(() => {
|
||||
return <SystemDetail id={page.params.id} />
|
||||
} else if (page.route === "containers") {
|
||||
return <Containers />
|
||||
} else if (page.route === "proxmox") {
|
||||
return <Proxmox />
|
||||
} else if (page.route === "smart") {
|
||||
return <Smart />
|
||||
} else if (page.route === "settings") {
|
||||
|
||||
269
internal/site/src/types.d.ts
vendored
269
internal/site/src/types.d.ts
vendored
@@ -45,12 +45,6 @@ export interface SystemInfo {
|
||||
c: number
|
||||
/** cpu model */
|
||||
m: string
|
||||
/** load average 1 minute */
|
||||
l1?: number
|
||||
/** load average 5 minutes */
|
||||
l5?: number
|
||||
/** load average 15 minutes */
|
||||
l15?: number
|
||||
/** load average */
|
||||
la?: [number, number, number]
|
||||
/** operating system */
|
||||
@@ -94,13 +88,6 @@ export interface SystemStats {
|
||||
cpub?: number[]
|
||||
/** per-core cpu usage [CPU0..] (0-100 integers) */
|
||||
cpus?: number[]
|
||||
// TODO: remove these in future release in favor of la
|
||||
/** load average 1 minute */
|
||||
l1?: number
|
||||
/** load average 5 minutes */
|
||||
l5?: number
|
||||
/** load average 15 minutes */
|
||||
l15?: number
|
||||
/** load average */
|
||||
la?: [number, number, number]
|
||||
/** total memory (gb) */
|
||||
@@ -275,36 +262,6 @@ export interface ContainerRecord extends RecordModel {
|
||||
updated: number
|
||||
}
|
||||
|
||||
export interface PveVmRecord extends RecordModel {
|
||||
id: string
|
||||
system: string
|
||||
name: string
|
||||
/** "qemu" or "lxc" */
|
||||
type: string
|
||||
/** CPU usage percent (0–100, relative to host) */
|
||||
cpu: number
|
||||
/** Memory used (MB) */
|
||||
mem: number
|
||||
/** Total upload (bytes, sent by VM) */
|
||||
netout: number
|
||||
/** Total download (bytes, received by VM) */
|
||||
netin: number
|
||||
/** Max vCPU count */
|
||||
maxcpu: number
|
||||
/** Max memory (bytes) */
|
||||
maxmem: number
|
||||
/** Uptime (seconds) */
|
||||
uptime: number
|
||||
/** Cumulative disk read (bytes) */
|
||||
diskread: number
|
||||
/** Cumulative disk write (bytes) */
|
||||
diskwrite: number
|
||||
/** Allocated disk size (bytes) */
|
||||
disk: number
|
||||
/** Unix timestamp (ms) */
|
||||
updated: number
|
||||
}
|
||||
|
||||
export type ChartTimes = "1m" | "1h" | "12h" | "24h" | "1w" | "30d"
|
||||
|
||||
export interface ChartTimeData {
|
||||
@@ -455,116 +412,116 @@ export interface SystemdRecord extends RecordModel {
|
||||
}
|
||||
|
||||
export interface SystemdServiceDetails {
|
||||
AccessSELinuxContext: string
|
||||
ActivationDetails: any[]
|
||||
ActiveEnterTimestamp: number
|
||||
ActiveEnterTimestampMonotonic: number
|
||||
ActiveExitTimestamp: number
|
||||
ActiveExitTimestampMonotonic: number
|
||||
ActiveState: string
|
||||
After: string[]
|
||||
AllowIsolate: boolean
|
||||
AssertResult: boolean
|
||||
AssertTimestamp: number
|
||||
AssertTimestampMonotonic: number
|
||||
Asserts: any[]
|
||||
Before: string[]
|
||||
BindsTo: any[]
|
||||
BoundBy: any[]
|
||||
CPUUsageNSec: number
|
||||
CanClean: any[]
|
||||
CanFreeze: boolean
|
||||
CanIsolate: boolean
|
||||
CanLiveMount: boolean
|
||||
CanReload: boolean
|
||||
CanStart: boolean
|
||||
CanStop: boolean
|
||||
CollectMode: string
|
||||
ConditionResult: boolean
|
||||
ConditionTimestamp: number
|
||||
ConditionTimestampMonotonic: number
|
||||
Conditions: any[]
|
||||
ConflictedBy: any[]
|
||||
Conflicts: string[]
|
||||
ConsistsOf: any[]
|
||||
DebugInvocation: boolean
|
||||
DefaultDependencies: boolean
|
||||
Description: string
|
||||
Documentation: string[]
|
||||
DropInPaths: any[]
|
||||
ExecMainPID: number
|
||||
FailureAction: string
|
||||
FailureActionExitStatus: number
|
||||
Following: string
|
||||
FragmentPath: string
|
||||
FreezerState: string
|
||||
Id: string
|
||||
IgnoreOnIsolate: boolean
|
||||
InactiveEnterTimestamp: number
|
||||
InactiveEnterTimestampMonotonic: number
|
||||
InactiveExitTimestamp: number
|
||||
InactiveExitTimestampMonotonic: number
|
||||
InvocationID: string
|
||||
Job: Array<number | string>
|
||||
JobRunningTimeoutUSec: number
|
||||
JobTimeoutAction: string
|
||||
JobTimeoutRebootArgument: string
|
||||
JobTimeoutUSec: number
|
||||
JoinsNamespaceOf: any[]
|
||||
LoadError: string[]
|
||||
LoadState: string
|
||||
MainPID: number
|
||||
Markers: any[]
|
||||
MemoryCurrent: number
|
||||
MemoryLimit: number
|
||||
MemoryPeak: number
|
||||
NRestarts: number
|
||||
Names: string[]
|
||||
NeedDaemonReload: boolean
|
||||
OnFailure: any[]
|
||||
OnFailureJobMode: string
|
||||
OnFailureOf: any[]
|
||||
OnSuccess: any[]
|
||||
OnSuccessJobMode: string
|
||||
OnSuccessOf: any[]
|
||||
PartOf: any[]
|
||||
Perpetual: boolean
|
||||
PropagatesReloadTo: any[]
|
||||
PropagatesStopTo: any[]
|
||||
RebootArgument: string
|
||||
Refs: any[]
|
||||
RefuseManualStart: boolean
|
||||
RefuseManualStop: boolean
|
||||
ReloadPropagatedFrom: any[]
|
||||
RequiredBy: any[]
|
||||
Requires: string[]
|
||||
RequiresMountsFor: any[]
|
||||
Requisite: any[]
|
||||
RequisiteOf: any[]
|
||||
Result: string
|
||||
SliceOf: any[]
|
||||
SourcePath: string
|
||||
StartLimitAction: string
|
||||
StartLimitBurst: number
|
||||
StartLimitIntervalUSec: number
|
||||
StateChangeTimestamp: number
|
||||
StateChangeTimestampMonotonic: number
|
||||
StopPropagatedFrom: any[]
|
||||
StopWhenUnneeded: boolean
|
||||
SubState: string
|
||||
SuccessAction: string
|
||||
SuccessActionExitStatus: number
|
||||
SurviveFinalKillSignal: boolean
|
||||
TasksCurrent: number
|
||||
TasksMax: number
|
||||
Transient: boolean
|
||||
TriggeredBy: string[]
|
||||
Triggers: any[]
|
||||
UnitFilePreset: string
|
||||
UnitFileState: string
|
||||
UpheldBy: any[]
|
||||
Upholds: any[]
|
||||
WantedBy: any[]
|
||||
Wants: string[]
|
||||
WantsMountsFor: any[]
|
||||
}
|
||||
AccessSELinuxContext: string;
|
||||
ActivationDetails: any[];
|
||||
ActiveEnterTimestamp: number;
|
||||
ActiveEnterTimestampMonotonic: number;
|
||||
ActiveExitTimestamp: number;
|
||||
ActiveExitTimestampMonotonic: number;
|
||||
ActiveState: string;
|
||||
After: string[];
|
||||
AllowIsolate: boolean;
|
||||
AssertResult: boolean;
|
||||
AssertTimestamp: number;
|
||||
AssertTimestampMonotonic: number;
|
||||
Asserts: any[];
|
||||
Before: string[];
|
||||
BindsTo: any[];
|
||||
BoundBy: any[];
|
||||
CPUUsageNSec: number;
|
||||
CanClean: any[];
|
||||
CanFreeze: boolean;
|
||||
CanIsolate: boolean;
|
||||
CanLiveMount: boolean;
|
||||
CanReload: boolean;
|
||||
CanStart: boolean;
|
||||
CanStop: boolean;
|
||||
CollectMode: string;
|
||||
ConditionResult: boolean;
|
||||
ConditionTimestamp: number;
|
||||
ConditionTimestampMonotonic: number;
|
||||
Conditions: any[];
|
||||
ConflictedBy: any[];
|
||||
Conflicts: string[];
|
||||
ConsistsOf: any[];
|
||||
DebugInvocation: boolean;
|
||||
DefaultDependencies: boolean;
|
||||
Description: string;
|
||||
Documentation: string[];
|
||||
DropInPaths: any[];
|
||||
ExecMainPID: number;
|
||||
FailureAction: string;
|
||||
FailureActionExitStatus: number;
|
||||
Following: string;
|
||||
FragmentPath: string;
|
||||
FreezerState: string;
|
||||
Id: string;
|
||||
IgnoreOnIsolate: boolean;
|
||||
InactiveEnterTimestamp: number;
|
||||
InactiveEnterTimestampMonotonic: number;
|
||||
InactiveExitTimestamp: number;
|
||||
InactiveExitTimestampMonotonic: number;
|
||||
InvocationID: string;
|
||||
Job: Array<number | string>;
|
||||
JobRunningTimeoutUSec: number;
|
||||
JobTimeoutAction: string;
|
||||
JobTimeoutRebootArgument: string;
|
||||
JobTimeoutUSec: number;
|
||||
JoinsNamespaceOf: any[];
|
||||
LoadError: string[];
|
||||
LoadState: string;
|
||||
MainPID: number;
|
||||
Markers: any[];
|
||||
MemoryCurrent: number;
|
||||
MemoryLimit: number;
|
||||
MemoryPeak: number;
|
||||
NRestarts: number;
|
||||
Names: string[];
|
||||
NeedDaemonReload: boolean;
|
||||
OnFailure: any[];
|
||||
OnFailureJobMode: string;
|
||||
OnFailureOf: any[];
|
||||
OnSuccess: any[];
|
||||
OnSuccessJobMode: string;
|
||||
OnSuccessOf: any[];
|
||||
PartOf: any[];
|
||||
Perpetual: boolean;
|
||||
PropagatesReloadTo: any[];
|
||||
PropagatesStopTo: any[];
|
||||
RebootArgument: string;
|
||||
Refs: any[];
|
||||
RefuseManualStart: boolean;
|
||||
RefuseManualStop: boolean;
|
||||
ReloadPropagatedFrom: any[];
|
||||
RequiredBy: any[];
|
||||
Requires: string[];
|
||||
RequiresMountsFor: any[];
|
||||
Requisite: any[];
|
||||
RequisiteOf: any[];
|
||||
Result: string;
|
||||
SliceOf: any[];
|
||||
SourcePath: string;
|
||||
StartLimitAction: string;
|
||||
StartLimitBurst: number;
|
||||
StartLimitIntervalUSec: number;
|
||||
StateChangeTimestamp: number;
|
||||
StateChangeTimestampMonotonic: number;
|
||||
StopPropagatedFrom: any[];
|
||||
StopWhenUnneeded: boolean;
|
||||
SubState: string;
|
||||
SuccessAction: string;
|
||||
SuccessActionExitStatus: number;
|
||||
SurviveFinalKillSignal: boolean;
|
||||
TasksCurrent: number;
|
||||
TasksMax: number;
|
||||
Transient: boolean;
|
||||
TriggeredBy: string[];
|
||||
Triggers: any[];
|
||||
UnitFilePreset: string;
|
||||
UnitFileState: string;
|
||||
UpheldBy: any[];
|
||||
Upholds: any[];
|
||||
WantedBy: any[];
|
||||
Wants: string[];
|
||||
WantsMountsFor: any[];
|
||||
}
|
||||
Reference in New Issue
Block a user