Compare commits

..

11 Commits

Author SHA1 Message Date
hank
24feffcc92 New translations en.po (German) 2026-03-14 16:37:21 -04:00
hank
9586b461d8 New translations en.po (Turkish) 2026-03-02 08:16:29 -05:00
hank
78936b1481 New translations en.po (Chinese Simplified) 2026-03-01 12:14:20 -05:00
hank
5b6af761f1 New translations en.po (German) 2026-02-27 10:20:45 -05:00
hank
29b914a43b New translations en.po (Bulgarian) 2026-02-25 06:49:42 -05:00
hank
4a86357d64 New translations en.po (Bulgarian) 2026-02-25 03:59:03 -05:00
hank
b65f011222 New translations en.po (Dutch) 2026-02-24 06:37:18 -05:00
hank
f836609552 New translations en.po (Serbian (Cyrillic)) 2026-02-23 10:11:15 -05:00
hank
b2a3c52005 New translations en.po (Russian) 2026-02-21 04:46:18 -05:00
hank
6e2277ead1 New translations en.po (Chinese Traditional, Hong Kong) 2026-02-19 21:25:17 -05:00
hank
cffc3d8569 New translations en.po (Chinese Simplified) 2026-02-19 21:25:16 -05:00
82 changed files with 471 additions and 2747 deletions

View File

@@ -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)
@@ -100,9 +99,6 @@ 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()
@@ -193,15 +189,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())

View File

@@ -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,
},
},
}
}

View File

@@ -14,10 +14,10 @@ var lastPerCoreCpuTimes = make(map[uint16][]cpu.TimesStat)
// init initializes the CPU monitoring by storing the initial CPU times
// for the default 60-second cache interval.
func init() {
if times, err := cpu.Times(false); err == nil && len(times) > 0 {
if times, err := cpu.Times(false); err == nil {
lastCpuTimes[60000] = times[0]
}
if perCoreTimes, err := cpu.Times(true); err == nil && len(perCoreTimes) > 0 {
if perCoreTimes, err := cpu.Times(true); err == nil {
lastPerCoreCpuTimes[60000] = perCoreTimes
}
}

View File

@@ -78,21 +78,14 @@ func (a *Agent) initializeDiskInfo() {
if _, exists := a.fsStats[key]; !exists {
if root {
slog.Info("Detected root device", "name", key)
// Try to map root device to a diskIoCounters entry. First
// checks for an exact key match, then uses findIoDevice for
// normalized / prefix-based matching (e.g. nda0p2 → nda0),
// and finally falls back to the FILESYSTEM env var.
// Check if root device is in /proc/diskstats. Do not guess a
// fallback device for root: that can misattribute root I/O to a
// different disk while usage remains tied to root mountpoint.
if _, ioMatch = diskIoCounters[key]; !ioMatch {
if matchedKey, match := findIoDevice(key, diskIoCounters); match {
if matchedKey, match := findIoDevice(filesystem, diskIoCounters); match {
key = matchedKey
ioMatch = true
} else if filesystem != "" {
if matchedKey, match := findIoDevice(filesystem, diskIoCounters); match {
key = matchedKey
ioMatch = true
}
}
if !ioMatch {
} else {
slog.Warn("Root I/O unmapped; set FILESYSTEM", "device", device, "mountpoint", mountpoint)
}
}
@@ -121,22 +114,14 @@ func (a *Agent) initializeDiskInfo() {
// Use FILESYSTEM env var to find root filesystem
if filesystem != "" {
for _, p := range partitions {
if filesystemMatchesPartitionSetting(filesystem, p) {
if strings.HasSuffix(p.Device, filesystem) || p.Mountpoint == filesystem {
addFsStat(p.Device, p.Mountpoint, true)
hasRoot = true
break
}
}
if !hasRoot {
// FILESYSTEM may name a physical disk absent from partitions (e.g.
// ZFS lists dataset paths like zroot/ROOT/default, not block devices).
// Try matching directly against diskIoCounters.
if ioKey, match := findIoDevice(filesystem, diskIoCounters); match {
a.fsStats[ioKey] = &system.FsStats{Root: true, Mountpoint: rootMountPoint}
hasRoot = true
} else {
slog.Warn("Partition details not found", "filesystem", filesystem)
}
slog.Warn("Partition details not found", "filesystem", filesystem)
}
}
@@ -202,180 +187,28 @@ func (a *Agent) initializeDiskInfo() {
}
}
// If no root filesystem set, try the most active I/O device as a last
// resort (e.g. ZFS where dataset names are unrelated to disk names).
// If no root filesystem set, use fallback
if !hasRoot {
rootKey := mostActiveIoDevice(diskIoCounters)
if rootKey != "" {
slog.Warn("Using most active device for root I/O; set FILESYSTEM to override", "device", rootKey)
} else {
rootKey = filepath.Base(rootMountPoint)
if _, exists := a.fsStats[rootKey]; exists {
rootKey = "root"
}
slog.Warn("Root I/O device not detected; set FILESYSTEM to override")
rootKey := filepath.Base(rootMountPoint)
if _, exists := a.fsStats[rootKey]; exists {
rootKey = "root"
}
slog.Warn("Root device not detected; root I/O disabled", "mountpoint", rootMountPoint)
a.fsStats[rootKey] = &system.FsStats{Root: true, Mountpoint: rootMountPoint}
}
a.pruneDuplicateRootExtraFilesystems()
a.initializeDiskIoStats(diskIoCounters)
}
// Removes extra filesystems that mirror root usage (https://github.com/henrygd/beszel/issues/1428).
func (a *Agent) pruneDuplicateRootExtraFilesystems() {
var rootMountpoint string
for _, stats := range a.fsStats {
if stats != nil && stats.Root {
rootMountpoint = stats.Mountpoint
break
}
}
if rootMountpoint == "" {
return
}
rootUsage, err := disk.Usage(rootMountpoint)
if err != nil {
return
}
for name, stats := range a.fsStats {
if stats == nil || stats.Root {
continue
}
extraUsage, err := disk.Usage(stats.Mountpoint)
if err != nil {
continue
}
if hasSameDiskUsage(rootUsage, extraUsage) {
slog.Info("Ignoring duplicate FS", "name", name, "mount", stats.Mountpoint)
delete(a.fsStats, name)
}
}
}
// hasSameDiskUsage compares root/extra usage with a small byte tolerance.
func hasSameDiskUsage(a, b *disk.UsageStat) bool {
if a == nil || b == nil || a.Total == 0 || b.Total == 0 {
return false
}
// Allow minor drift between sequential disk usage calls.
const toleranceBytes uint64 = 16 * 1024 * 1024
return withinUsageTolerance(a.Total, b.Total, toleranceBytes) &&
withinUsageTolerance(a.Used, b.Used, toleranceBytes)
}
// withinUsageTolerance reports whether two byte values differ by at most tolerance.
func withinUsageTolerance(a, b, tolerance uint64) bool {
if a >= b {
return a-b <= tolerance
}
return b-a <= tolerance
}
type ioMatchCandidate struct {
name string
bytes uint64
ops uint64
}
// findIoDevice prefers exact device/label matches, then falls back to a
// prefix-related candidate with the highest recent activity.
// Returns matching device from /proc/diskstats.
// bool is true if a match was found.
func findIoDevice(filesystem string, diskIoCounters map[string]disk.IOCountersStat) (string, bool) {
filesystem = normalizeDeviceName(filesystem)
if filesystem == "" {
return "", false
}
candidates := []ioMatchCandidate{}
for _, d := range diskIoCounters {
if normalizeDeviceName(d.Name) == filesystem || (d.Label != "" && normalizeDeviceName(d.Label) == filesystem) {
if d.Name == filesystem || (d.Label != "" && d.Label == filesystem) {
return d.Name, true
}
if prefixRelated(normalizeDeviceName(d.Name), filesystem) ||
(d.Label != "" && prefixRelated(normalizeDeviceName(d.Label), filesystem)) {
candidates = append(candidates, ioMatchCandidate{
name: d.Name,
bytes: d.ReadBytes + d.WriteBytes,
ops: d.ReadCount + d.WriteCount,
})
}
}
if len(candidates) == 0 {
return "", false
}
best := candidates[0]
for _, c := range candidates[1:] {
if c.bytes > best.bytes ||
(c.bytes == best.bytes && c.ops > best.ops) ||
(c.bytes == best.bytes && c.ops == best.ops && c.name < best.name) {
best = c
}
}
slog.Info("Using disk I/O fallback", "requested", filesystem, "selected", best.name)
return best.name, true
}
// mostActiveIoDevice returns the device with the highest I/O activity,
// or "" if diskIoCounters is empty.
func mostActiveIoDevice(diskIoCounters map[string]disk.IOCountersStat) string {
var best ioMatchCandidate
for _, d := range diskIoCounters {
c := ioMatchCandidate{
name: d.Name,
bytes: d.ReadBytes + d.WriteBytes,
ops: d.ReadCount + d.WriteCount,
}
if best.name == "" || c.bytes > best.bytes ||
(c.bytes == best.bytes && c.ops > best.ops) ||
(c.bytes == best.bytes && c.ops == best.ops && c.name < best.name) {
best = c
}
}
return best.name
}
// prefixRelated reports whether either identifier is a prefix of the other.
func prefixRelated(a, b string) bool {
if a == "" || b == "" || a == b {
return false
}
return strings.HasPrefix(a, b) || strings.HasPrefix(b, a)
}
// filesystemMatchesPartitionSetting checks whether a FILESYSTEM env var value
// matches a partition by mountpoint, exact device name, or prefix relationship
// (e.g. FILESYSTEM=ada0 matches partition /dev/ada0p2).
func filesystemMatchesPartitionSetting(filesystem string, p disk.PartitionStat) bool {
filesystem = strings.TrimSpace(filesystem)
if filesystem == "" {
return false
}
if p.Mountpoint == filesystem {
return true
}
fsName := normalizeDeviceName(filesystem)
partName := normalizeDeviceName(p.Device)
if fsName == "" || partName == "" {
return false
}
if fsName == partName {
return true
}
return prefixRelated(partName, fsName)
}
// normalizeDeviceName canonicalizes device strings for comparisons.
func normalizeDeviceName(value string) string {
name := filepath.Base(strings.TrimSpace(value))
if name == "." {
return ""
}
return name
return "", false
}
// Sets start values for disk I/O stats.

View File

@@ -116,7 +116,7 @@ func TestFindIoDevice(t *testing.T) {
assert.Equal(t, "sda", device)
})
t.Run("returns no match when not found", func(t *testing.T) {
t.Run("returns no fallback when not found", func(t *testing.T) {
ioCounters := map[string]disk.IOCountersStat{
"sda": {Name: "sda"},
"sdb": {Name: "sdb"},
@@ -126,106 +126,6 @@ func TestFindIoDevice(t *testing.T) {
assert.False(t, ok)
assert.Equal(t, "", device)
})
t.Run("uses uncertain unique prefix fallback", func(t *testing.T) {
ioCounters := map[string]disk.IOCountersStat{
"nvme0n1": {Name: "nvme0n1"},
"sda": {Name: "sda"},
}
device, ok := findIoDevice("nvme0n1p2", ioCounters)
assert.True(t, ok)
assert.Equal(t, "nvme0n1", device)
})
t.Run("uses dominant activity when prefix matches are ambiguous", func(t *testing.T) {
ioCounters := map[string]disk.IOCountersStat{
"sda": {Name: "sda", ReadBytes: 5000, WriteBytes: 5000, ReadCount: 100, WriteCount: 100},
"sdb": {Name: "sdb", ReadBytes: 1000, WriteBytes: 1000, ReadCount: 50, WriteCount: 50},
}
device, ok := findIoDevice("sd", ioCounters)
assert.True(t, ok)
assert.Equal(t, "sda", device)
})
t.Run("uses highest activity when ambiguous without dominance", func(t *testing.T) {
ioCounters := map[string]disk.IOCountersStat{
"sda": {Name: "sda", ReadBytes: 3000, WriteBytes: 3000, ReadCount: 50, WriteCount: 50},
"sdb": {Name: "sdb", ReadBytes: 2500, WriteBytes: 2500, ReadCount: 40, WriteCount: 40},
}
device, ok := findIoDevice("sd", ioCounters)
assert.True(t, ok)
assert.Equal(t, "sda", device)
})
t.Run("matches /dev/-prefixed partition to parent disk", func(t *testing.T) {
ioCounters := map[string]disk.IOCountersStat{
"nda0": {Name: "nda0", ReadBytes: 1000, WriteBytes: 1000},
}
device, ok := findIoDevice("/dev/nda0p2", ioCounters)
assert.True(t, ok)
assert.Equal(t, "nda0", device)
})
t.Run("uses deterministic name tie-breaker", func(t *testing.T) {
ioCounters := map[string]disk.IOCountersStat{
"sdb": {Name: "sdb", ReadBytes: 2000, WriteBytes: 2000, ReadCount: 10, WriteCount: 10},
"sda": {Name: "sda", ReadBytes: 2000, WriteBytes: 2000, ReadCount: 10, WriteCount: 10},
}
device, ok := findIoDevice("sd", ioCounters)
assert.True(t, ok)
assert.Equal(t, "sda", device)
})
}
func TestFilesystemMatchesPartitionSetting(t *testing.T) {
p := disk.PartitionStat{Device: "/dev/ada0p2", Mountpoint: "/"}
t.Run("matches mountpoint setting", func(t *testing.T) {
assert.True(t, filesystemMatchesPartitionSetting("/", p))
})
t.Run("matches exact partition setting", func(t *testing.T) {
assert.True(t, filesystemMatchesPartitionSetting("ada0p2", p))
assert.True(t, filesystemMatchesPartitionSetting("/dev/ada0p2", p))
})
t.Run("matches prefix-style parent setting", func(t *testing.T) {
assert.True(t, filesystemMatchesPartitionSetting("ada0", p))
assert.True(t, filesystemMatchesPartitionSetting("/dev/ada0", p))
})
t.Run("does not match unrelated device", func(t *testing.T) {
assert.False(t, filesystemMatchesPartitionSetting("sda", p))
assert.False(t, filesystemMatchesPartitionSetting("nvme0n1", p))
assert.False(t, filesystemMatchesPartitionSetting("", p))
})
}
func TestMostActiveIoDevice(t *testing.T) {
t.Run("returns most active device", func(t *testing.T) {
ioCounters := map[string]disk.IOCountersStat{
"nda0": {Name: "nda0", ReadBytes: 5000, WriteBytes: 5000, ReadCount: 100, WriteCount: 100},
"nda1": {Name: "nda1", ReadBytes: 1000, WriteBytes: 1000, ReadCount: 50, WriteCount: 50},
}
assert.Equal(t, "nda0", mostActiveIoDevice(ioCounters))
})
t.Run("uses deterministic tie-breaker", func(t *testing.T) {
ioCounters := map[string]disk.IOCountersStat{
"sdb": {Name: "sdb", ReadBytes: 1000, WriteBytes: 1000, ReadCount: 10, WriteCount: 10},
"sda": {Name: "sda", ReadBytes: 1000, WriteBytes: 1000, ReadCount: 10, WriteCount: 10},
}
assert.Equal(t, "sda", mostActiveIoDevice(ioCounters))
})
t.Run("returns empty for empty map", func(t *testing.T) {
assert.Equal(t, "", mostActiveIoDevice(map[string]disk.IOCountersStat{}))
})
}
func TestIsDockerSpecialMountpoint(t *testing.T) {
@@ -472,37 +372,3 @@ func TestDiskUsageCaching(t *testing.T) {
"lastDiskUsageUpdate should be refreshed when cache expires")
})
}
func TestHasSameDiskUsage(t *testing.T) {
const toleranceBytes uint64 = 16 * 1024 * 1024
t.Run("returns true when totals and usage are equal", func(t *testing.T) {
a := &disk.UsageStat{Total: 100 * 1024 * 1024 * 1024, Used: 42 * 1024 * 1024 * 1024}
b := &disk.UsageStat{Total: 100 * 1024 * 1024 * 1024, Used: 42 * 1024 * 1024 * 1024}
assert.True(t, hasSameDiskUsage(a, b))
})
t.Run("returns true within tolerance", func(t *testing.T) {
a := &disk.UsageStat{Total: 100 * 1024 * 1024 * 1024, Used: 42 * 1024 * 1024 * 1024}
b := &disk.UsageStat{
Total: a.Total + toleranceBytes - 1,
Used: a.Used - toleranceBytes + 1,
}
assert.True(t, hasSameDiskUsage(a, b))
})
t.Run("returns false when total exceeds tolerance", func(t *testing.T) {
a := &disk.UsageStat{Total: 100 * 1024 * 1024 * 1024, Used: 42 * 1024 * 1024 * 1024}
b := &disk.UsageStat{
Total: a.Total + toleranceBytes + 1,
Used: a.Used,
}
assert.False(t, hasSameDiskUsage(a, b))
})
t.Run("returns false for nil or zero total", func(t *testing.T) {
assert.False(t, hasSameDiskUsage(nil, &disk.UsageStat{Total: 1, Used: 1}))
assert.False(t, hasSameDiskUsage(&disk.UsageStat{Total: 1, Used: 1}, nil))
assert.False(t, hasSameDiskUsage(&disk.UsageStat{Total: 0, Used: 0}, &disk.UsageStat{Total: 1, Used: 1}))
})
}

View File

@@ -28,10 +28,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
@@ -397,12 +395,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

View File

@@ -269,16 +269,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)
@@ -445,11 +446,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 +460,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 +726,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 +738,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 +816,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 +836,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 +847,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 +883,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)

View File

@@ -199,6 +199,19 @@ func readHexByteFile(path string) (uint8, bool) {
return b, ok
}
func readStringFile(path string) string {
content, _ := readStringFileOK(path)
return content
}
func readStringFileOK(path string) (string, bool) {
b, err := os.ReadFile(path)
if err != nil {
return "", false
}
return strings.TrimSpace(string(b)), true
}
func hasEmmcHealthFiles(deviceDir string) bool {
entries, err := os.ReadDir(deviceDir)
if err != nil {

View File

@@ -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
}

View File

@@ -1,225 +0,0 @@
//go:build linux
package agent
import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/henrygd/beszel/internal/entities/smart"
)
// mdraidSysfsRoot is a test hook; production value is "/sys".
var mdraidSysfsRoot = "/sys"
type mdraidHealth struct {
level string
arrayState string
degraded uint64
raidDisks uint64
syncAction string
syncCompleted string
syncSpeed string
mismatchCnt uint64
capacity uint64
}
// scanMdraidDevices discovers Linux md arrays exposed in sysfs.
func scanMdraidDevices() []*DeviceInfo {
blockDir := filepath.Join(mdraidSysfsRoot, "block")
entries, err := os.ReadDir(blockDir)
if err != nil {
return nil
}
devices := make([]*DeviceInfo, 0, 2)
for _, ent := range entries {
name := ent.Name()
if !isMdraidBlockName(name) {
continue
}
mdDir := filepath.Join(blockDir, name, "md")
if !fileExists(filepath.Join(mdDir, "array_state")) {
continue
}
devPath := filepath.Join("/dev", name)
devices = append(devices, &DeviceInfo{
Name: devPath,
Type: "mdraid",
InfoName: devPath + " [mdraid]",
Protocol: "MD",
})
}
return devices
}
// collectMdraidHealth reads mdraid health and stores it in SmartDataMap.
func (sm *SmartManager) collectMdraidHealth(deviceInfo *DeviceInfo) (bool, error) {
if deviceInfo == nil || deviceInfo.Name == "" {
return false, nil
}
base := filepath.Base(deviceInfo.Name)
if !isMdraidBlockName(base) && !strings.EqualFold(deviceInfo.Type, "mdraid") {
return false, nil
}
health, ok := readMdraidHealth(base)
if !ok {
return false, nil
}
deviceInfo.Type = "mdraid"
key := fmt.Sprintf("mdraid:%s", base)
status := mdraidSmartStatus(health)
attrs := make([]*smart.SmartAttribute, 0, 10)
if health.arrayState != "" {
attrs = append(attrs, &smart.SmartAttribute{Name: "ArrayState", RawString: health.arrayState})
}
if health.level != "" {
attrs = append(attrs, &smart.SmartAttribute{Name: "RaidLevel", RawString: health.level})
}
if health.raidDisks > 0 {
attrs = append(attrs, &smart.SmartAttribute{Name: "RaidDisks", RawValue: health.raidDisks})
}
if health.degraded > 0 {
attrs = append(attrs, &smart.SmartAttribute{Name: "Degraded", RawValue: health.degraded})
}
if health.syncAction != "" {
attrs = append(attrs, &smart.SmartAttribute{Name: "SyncAction", RawString: health.syncAction})
}
if health.syncCompleted != "" {
attrs = append(attrs, &smart.SmartAttribute{Name: "SyncCompleted", RawString: health.syncCompleted})
}
if health.syncSpeed != "" {
attrs = append(attrs, &smart.SmartAttribute{Name: "SyncSpeed", RawString: health.syncSpeed})
}
if health.mismatchCnt > 0 {
attrs = append(attrs, &smart.SmartAttribute{Name: "MismatchCount", RawValue: health.mismatchCnt})
}
sm.Lock()
defer sm.Unlock()
if _, exists := sm.SmartDataMap[key]; !exists {
sm.SmartDataMap[key] = &smart.SmartData{}
}
data := sm.SmartDataMap[key]
data.ModelName = "Linux MD RAID"
if health.level != "" {
data.ModelName = "Linux MD RAID (" + health.level + ")"
}
data.Capacity = health.capacity
data.SmartStatus = status
data.DiskName = filepath.Join("/dev", base)
data.DiskType = "mdraid"
data.Attributes = attrs
return true, nil
}
// readMdraidHealth reads md array health fields from sysfs.
func readMdraidHealth(blockName string) (mdraidHealth, bool) {
var out mdraidHealth
if !isMdraidBlockName(blockName) {
return out, false
}
mdDir := filepath.Join(mdraidSysfsRoot, "block", blockName, "md")
arrayState, okState := 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"))
if val, ok := readUintFile(filepath.Join(mdDir, "raid_disks")); ok {
out.raidDisks = val
}
if val, ok := readUintFile(filepath.Join(mdDir, "degraded")); ok {
out.degraded = val
}
if val, ok := readUintFile(filepath.Join(mdDir, "mismatch_cnt")); ok {
out.mismatchCnt = val
}
if capBytes, ok := readMdraidBlockCapacityBytes(blockName, mdraidSysfsRoot); ok {
out.capacity = capBytes
}
return out, true
}
// mdraidSmartStatus maps md state/sync signals to a SMART-like status.
func mdraidSmartStatus(health mdraidHealth) string {
state := strings.ToLower(strings.TrimSpace(health.arrayState))
switch state {
case "inactive", "faulty", "broken", "stopped":
return "FAILED"
}
if health.degraded > 0 {
return "FAILED"
}
switch strings.ToLower(strings.TrimSpace(health.syncAction)) {
case "resync", "recover", "reshape", "check", "repair":
return "WARNING"
}
switch state {
case "clean", "active", "active-idle", "write-pending", "read-auto", "readonly":
return "PASSED"
}
return "UNKNOWN"
}
// isMdraidBlockName matches /dev/mdN-style block device names.
func isMdraidBlockName(name string) bool {
if !strings.HasPrefix(name, "md") {
return false
}
suffix := strings.TrimPrefix(name, "md")
if suffix == "" {
return false
}
for _, c := range suffix {
if c < '0' || c > '9' {
return false
}
}
return true
}
// readMdraidBlockCapacityBytes converts block size metadata into bytes.
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)
if !ok {
return 0, false
}
sectors, err := strconv.ParseUint(sizeStr, 10, 64)
if err != nil || sectors == 0 {
return 0, false
}
logicalBlockSize := uint64(512)
if lbsStr, ok := readStringFileOK(lbsPath); ok {
if parsed, err := strconv.ParseUint(lbsStr, 10, 64); err == nil && parsed > 0 {
logicalBlockSize = parsed
}
}
return sectors * logicalBlockSize, true
}

View File

@@ -1,100 +0,0 @@
//go:build linux
package agent
import (
"os"
"path/filepath"
"testing"
"github.com/henrygd/beszel/internal/entities/smart"
)
func TestMdraidMockSysfsScanAndCollect(t *testing.T) {
tmp := t.TempDir()
prev := mdraidSysfsRoot
mdraidSysfsRoot = tmp
t.Cleanup(func() { mdraidSysfsRoot = prev })
mdDir := filepath.Join(tmp, "block", "md0", "md")
queueDir := filepath.Join(tmp, "block", "md0", "queue")
if err := os.MkdirAll(mdDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(queueDir, 0o755); err != nil {
t.Fatal(err)
}
write := func(path, content string) {
t.Helper()
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
write(filepath.Join(mdDir, "array_state"), "active\n")
write(filepath.Join(mdDir, "level"), "raid1\n")
write(filepath.Join(mdDir, "raid_disks"), "2\n")
write(filepath.Join(mdDir, "degraded"), "0\n")
write(filepath.Join(mdDir, "sync_action"), "resync\n")
write(filepath.Join(mdDir, "sync_completed"), "10%\n")
write(filepath.Join(mdDir, "sync_speed"), "100M\n")
write(filepath.Join(mdDir, "mismatch_cnt"), "0\n")
write(filepath.Join(queueDir, "logical_block_size"), "512\n")
write(filepath.Join(tmp, "block", "md0", "size"), "2048\n")
devs := scanMdraidDevices()
if len(devs) != 1 {
t.Fatalf("scanMdraidDevices() = %d devices, want 1", len(devs))
}
if devs[0].Name != "/dev/md0" || devs[0].Type != "mdraid" {
t.Fatalf("scanMdraidDevices()[0] = %+v, want Name=/dev/md0 Type=mdraid", devs[0])
}
sm := &SmartManager{SmartDataMap: map[string]*smart.SmartData{}}
ok, err := sm.collectMdraidHealth(devs[0])
if err != nil || !ok {
t.Fatalf("collectMdraidHealth() = (ok=%v, err=%v), want (true,nil)", ok, err)
}
if len(sm.SmartDataMap) != 1 {
t.Fatalf("SmartDataMap len=%d, want 1", len(sm.SmartDataMap))
}
var got *smart.SmartData
for _, v := range sm.SmartDataMap {
got = v
break
}
if got == nil {
t.Fatalf("SmartDataMap value nil")
}
if got.DiskType != "mdraid" || got.DiskName != "/dev/md0" {
t.Fatalf("disk fields = (type=%q name=%q), want (mdraid,/dev/md0)", got.DiskType, got.DiskName)
}
if got.SmartStatus != "WARNING" {
t.Fatalf("SmartStatus=%q, want WARNING", got.SmartStatus)
}
if got.ModelName == "" || got.Capacity == 0 {
t.Fatalf("identity fields = (model=%q cap=%d), want non-empty model and cap>0", got.ModelName, got.Capacity)
}
if len(got.Attributes) < 5 {
t.Fatalf("attributes len=%d, want >= 5", len(got.Attributes))
}
}
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}); got != "FAILED" {
t.Fatalf("mdraidSmartStatus(degraded) = %q, want FAILED", got)
}
if got := mdraidSmartStatus(mdraidHealth{arrayState: "active", syncAction: "recover"}); got != "WARNING" {
t.Fatalf("mdraidSmartStatus(recover) = %q, want WARNING", got)
}
if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean"}); got != "PASSED" {
t.Fatalf("mdraidSmartStatus(clean) = %q, want PASSED", got)
}
if got := mdraidSmartStatus(mdraidHealth{arrayState: "unknown"}); got != "UNKNOWN" {
t.Fatalf("mdraidSmartStatus(unknown) = %q, want UNKNOWN", got)
}
}

View File

@@ -1,11 +0,0 @@
//go:build !linux
package agent
func scanMdraidDevices() []*DeviceInfo {
return nil
}
func (sm *SmartManager) collectMdraidHealth(deviceInfo *DeviceInfo) (bool, error) {
return false, nil
}

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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
},
},
}
}

View File

@@ -199,13 +199,6 @@ func (sm *SmartManager) ScanDevices(force bool) error {
hasValidScan = true
}
// Add Linux mdraid arrays by reading sysfs health fields. This does not
// require smartctl and does not scan the whole device.
if raidDevices := scanMdraidDevices(); len(raidDevices) > 0 {
scannedDevices = append(scannedDevices, raidDevices...)
hasValidScan = true
}
finalDevices := mergeDeviceLists(currentDevices, scannedDevices, configuredDevices)
finalDevices = sm.filterExcludedDevices(finalDevices)
sm.updateSmartDevices(finalDevices)
@@ -457,12 +450,6 @@ func (sm *SmartManager) CollectSmart(deviceInfo *DeviceInfo) error {
return errNoValidSmartData
}
// mdraid health is not exposed via SMART; Linux exposes array state in sysfs.
if deviceInfo != nil {
if ok, err := sm.collectMdraidHealth(deviceInfo); ok {
return err
}
}
// eMMC health is not exposed via SMART on Linux, but the kernel provides
// wear / EOL indicators via sysfs. Prefer that path when available.
if deviceInfo != nil {
@@ -1159,11 +1146,9 @@ func NewSmartManager() (*SmartManager, error) {
slog.Debug("smartctl", "path", path, "err", err)
if err != nil {
// Keep the previous fail-fast behavior unless this Linux host exposes
// eMMC or mdraid health via sysfs, in which case smartctl is optional.
if runtime.GOOS == "linux" {
if len(scanEmmcDevices()) > 0 || len(scanMdraidDevices()) > 0 {
return sm, nil
}
// eMMC health via sysfs, in which case smartctl is optional.
if runtime.GOOS == "linux" && len(scanEmmcDevices()) > 0 {
return sm, nil
}
return nil, err
}

View File

@@ -7,12 +7,12 @@ import (
"log/slog"
"os"
"runtime"
"strconv"
"strings"
"time"
"github.com/henrygd/beszel"
"github.com/henrygd/beszel/agent/battery"
"github.com/henrygd/beszel/agent/zfs"
"github.com/henrygd/beszel/internal/entities/container"
"github.com/henrygd/beszel/internal/entities/system"
@@ -107,7 +107,7 @@ func (a *Agent) refreshSystemDetails() {
}
// zfs
if _, err := zfs.ARCSize(); err != nil {
if _, err := getARCSize(); err != nil {
slog.Debug("Not monitoring ZFS ARC", "err", err)
} else {
a.zfs = true
@@ -178,7 +178,7 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
// }
// subtract ZFS ARC size from used memory and add as its own category
if a.zfs {
if arcSize, _ := zfs.ARCSize(); arcSize > 0 && arcSize < v.Used {
if arcSize, _ := getARCSize(); arcSize > 0 && arcSize < v.Used {
v.Used = v.Used - arcSize
v.UsedPercent = float64(v.Used) / float64(v.Total) * 100.0
systemStats.MemZfsArc = bytesToGigabytes(arcSize)
@@ -250,6 +250,32 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
return systemStats
}
// Returns the size of the ZFS ARC memory cache in bytes
func getARCSize() (uint64, error) {
file, err := os.Open("/proc/spl/kstat/zfs/arcstats")
if err != nil {
return 0, err
}
defer file.Close()
// Scan the lines
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "size") {
// Example line: size 4 15032385536
fields := strings.Fields(line)
if len(fields) < 3 {
return 0, err
}
// Return the size as uint64
return strconv.ParseUint(fields[2], 10, 64)
}
}
return 0, fmt.Errorf("failed to parse size field")
}
// getOsPrettyName attempts to get the pretty OS name from /etc/os-release on Linux systems
func getOsPrettyName() (string, error) {
file, err := os.Open("/etc/os-release")

View File

@@ -1,11 +0,0 @@
//go:build freebsd
package zfs
import (
"golang.org/x/sys/unix"
)
func ARCSize() (uint64, error) {
return unix.SysctlUint64("kstat.zfs.misc.arcstats.size")
}

View File

@@ -1,34 +0,0 @@
//go:build linux
// Package zfs provides functions to read ZFS statistics.
package zfs
import (
"bufio"
"fmt"
"os"
"strconv"
"strings"
)
func ARCSize() (uint64, error) {
file, err := os.Open("/proc/spl/kstat/zfs/arcstats")
if err != nil {
return 0, err
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "size") {
fields := strings.Fields(line)
if len(fields) < 3 {
return 0, fmt.Errorf("unexpected arcstats size format: %s", line)
}
return strconv.ParseUint(fields[2], 10, 64)
}
}
return 0, fmt.Errorf("size field not found in arcstats")
}

View File

@@ -1,9 +0,0 @@
//go:build !linux && !freebsd
package zfs
import "errors"
func ARCSize() (uint64, error) {
return 0, errors.ErrUnsupported
}

View File

@@ -6,7 +6,7 @@ import "github.com/blang/semver"
const (
// Version is the current version of the application.
Version = "0.18.4"
Version = "0.18.3"
// AppName is the name of the application.
AppName = "beszel"
)

7
go.mod
View File

@@ -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
View File

@@ -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=

View File

@@ -38,7 +38,7 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
case "Memory":
val = data.Info.MemPct
case "Bandwidth":
val = float64(data.Info.BandwidthBytes) / (1024 * 1024)
val = data.Info.Bandwidth
unit = " MB/s"
case "Disk":
maxUsedPct := data.Info.DiskPct

View File

@@ -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:"-"`
}

View File

@@ -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"`
}

View File

@@ -6,7 +6,6 @@ import (
"errors"
"fmt"
"hash/fnv"
"log/slog"
"math/rand"
"net"
"strings"
@@ -210,28 +209,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 {
@@ -354,43 +331,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 {

View File

@@ -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
}
]`

View File

@@ -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()

View File

@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.2.4/schema.json",
"$schema": "https://biomejs.dev/schemas/2.2.3/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
@@ -12,7 +12,7 @@
"lineWidth": 120,
"formatWithErrors": true
},
"assist": { "actions": { "source": { "organizeImports": "off" } } },
"assist": { "actions": { "source": { "organizeImports": "on" } } },
"linter": {
"enabled": true,
"rules": {

View File

@@ -1,7 +1,7 @@
{
"name": "beszel",
"private": true,
"version": "0.18.4",
"version": "0.18.3",
"type": "module",
"scripts": {
"dev": "vite --host",

View File

@@ -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()

View File

@@ -43,7 +43,7 @@ export function copyDockerCompose(port = "45876", publicKey: string, token: stri
export function copyDockerRun(port = "45876", publicKey: string, token: string) {
copyToClipboard(
`docker run -d --name beszel-agent --network host --restart unless-stopped -v /var/run/docker.sock:/var/run/docker.sock:ro -v beszel_agent_data:/var/lib/beszel-agent -e KEY="${publicKey}" -e LISTEN=${port} -e TOKEN="${token}" -e HUB_URL="${getHubURL()}" henrygd/beszel-agent`
`docker run -d --name beszel-agent --network host --restart unless-stopped -v /var/run/docker.sock:/var/run/docker.sock:ro -v ./beszel_agent_data:/var/lib/beszel-agent -e KEY="${publicKey}" -e LISTEN=${port} -e TOKEN="${token}" -e HUB_URL="${getHubURL()}" henrygd/beszel-agent`
)
}

View File

@@ -32,10 +32,7 @@ export function LangToggle() {
className={cn("px-2.5 flex gap-2.5 cursor-pointer", lang === i18n.locale && "bg-accent/70 font-medium")}
onClick={() => dynamicActivate(lang)}
>
<span>
{e || <code className="font-mono bg-muted text-[.65em] w-5 h-4 grid place-items-center">{lang}</code>}
</span>{" "}
{label}
<span>{e}</span> {label}
</DropdownMenuItem>
))}
</DropdownMenuContent>

View File

@@ -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>

View File

@@ -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>
)
}

View File

@@ -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>
)
})

View File

@@ -3,7 +3,6 @@ import { createRouter } from "@nanostores/router"
const routes = {
home: "/",
containers: "/containers",
proxmox: "/proxmox",
smart: "/smart",
system: `/system/:id`,
settings: `/settings/:name?`,

View File

@@ -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 />
</>
),
[]
)
})

View File

@@ -70,16 +70,7 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
<SelectContent>
{languages.map(([lang, label, e]) => (
<SelectItem key={lang} value={lang}>
<span className="me-2.5">
{e || (
<code
aria-hidden="true"
className="font-mono bg-muted text-[.65em] w-5 h-4 inline-grid place-items-center"
>
{lang}
</code>
)}
</span>
<span className="me-2.5">{e}</span>
{label}
</SelectItem>
))}

View File

@@ -1,6 +1,7 @@
import { t } from "@lingui/core/macro"
import { Trans } from "@lingui/react/macro"
import { redirectPage } from "@nanostores/router"
import clsx from "clsx"
import { LoaderCircleIcon, SendIcon } from "lucide-react"
import { useEffect, useState } from "react"
import { $router } from "@/components/router"
@@ -9,7 +10,6 @@ import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator"
import { toast } from "@/components/ui/use-toast"
import { isAdmin, pb } from "@/lib/api"
import { cn } from "@/lib/utils"
interface HeartbeatStatus {
enabled: boolean
@@ -37,10 +37,10 @@ export default function HeartbeatSettings() {
setIsLoading(true)
const res = await pb.send<HeartbeatStatus>("/api/beszel/heartbeat-status", {})
setStatus(res)
} catch (error: unknown) {
} catch (error: any) {
toast({
title: t`Error`,
description: (error as Error).message,
description: error.message,
variant: "destructive",
})
} finally {
@@ -66,10 +66,10 @@ export default function HeartbeatSettings() {
variant: "destructive",
})
}
} catch (error: unknown) {
} catch (error: any) {
toast({
title: t`Error`,
description: (error as Error).message,
description: error.message,
variant: "destructive",
})
} finally {
@@ -77,6 +77,8 @@ export default function HeartbeatSettings() {
}
}
const TestIcon = isTesting ? LoaderCircleIcon : SendIcon
return (
<div>
<div>
@@ -92,107 +94,91 @@ export default function HeartbeatSettings() {
</div>
<Separator className="my-4" />
{status?.enabled ? (
<EnabledState status={status} isTesting={isTesting} sendTestHeartbeat={sendTestHeartbeat} />
) : (
<NotEnabledState isLoading={isLoading} />
)}
</div>
)
}
function EnabledState({
status,
isTesting,
sendTestHeartbeat,
}: {
status: HeartbeatStatus
isTesting: boolean
sendTestHeartbeat: () => void
}) {
const TestIcon = isTesting ? LoaderCircleIcon : SendIcon
return (
<div className="space-y-5">
<div className="flex items-center gap-2">
<Badge variant="success">
<Trans>Active</Trans>
</Badge>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<ConfigItem label={t`Endpoint URL`} value={status.url ?? ""} mono />
<ConfigItem label={t`Interval`} value={`${status.interval}s`} />
<ConfigItem label={t`HTTP Method`} value={status.method ?? "POST"} />
</div>
<Separator />
<div>
<h4 className="text-base font-medium mb-1">
<Trans>Test heartbeat</Trans>
</h4>
<p className="text-sm text-muted-foreground leading-relaxed mb-3">
<Trans>Send a single heartbeat ping to verify your endpoint is working.</Trans>
</p>
<Button
type="button"
variant="outline"
className="flex items-center gap-1.5"
onClick={sendTestHeartbeat}
disabled={isTesting}
>
<TestIcon className={cn("size-4", isTesting && "animate-spin")} />
<Trans>Send test heartbeat</Trans>
</Button>
</div>
<Separator />
<div>
<h4 className="text-base font-medium mb-2">
<Trans>Payload format</Trans>
</h4>
<p className="text-sm text-muted-foreground leading-relaxed mb-2">
<Trans>
When using POST, each heartbeat includes a JSON payload with system status summary, list of down systems,
and triggered alerts.
</Trans>
</p>
<p className="text-sm text-muted-foreground leading-relaxed">
<Trans>
The overall status is <code className="bg-muted rounded-sm px-1 text-primary">ok</code> when all systems are
up, <code className="bg-muted rounded-sm px-1 text-primary">warn</code> when alerts are triggered, and{" "}
<code className="bg-muted rounded-sm px-1 text-primary">error</code> when any system is down.
</Trans>
</p>
</div>
</div>
)
}
function NotEnabledState({ isLoading }: { isLoading?: boolean }) {
return (
<div className={cn("grid gap-4", isLoading && "animate-pulse")}>
<div>
<p className="text-sm text-muted-foreground leading-relaxed mb-3">
<Trans>Set the following environment variables on your Beszel hub to enable heartbeat monitoring:</Trans>
</p>
<div className="grid gap-2.5">
<EnvVarItem
name="HEARTBEAT_URL"
description={t`Endpoint URL to ping (required)`}
example="https://uptime.betterstack.com/api/v1/heartbeat/xxxx"
/>
<EnvVarItem name="HEARTBEAT_INTERVAL" description={t`Seconds between pings (default: 60)`} example="60" />
<EnvVarItem
name="HEARTBEAT_METHOD"
description={t`HTTP method: POST, GET, or HEAD (default: POST)`}
example="POST"
/>
{isLoading ? (
<div className="flex items-center gap-2 text-muted-foreground py-4">
<LoaderCircleIcon className="h-4 w-4 animate-spin" />
<Trans>Loading...</Trans>
</div>
</div>
<p className="text-sm text-muted-foreground leading-relaxed">
<Trans>After setting the environment variables, restart your Beszel hub for changes to take effect.</Trans>
</p>
) : status?.enabled ? (
<div className="space-y-5">
<div className="flex items-center gap-2">
<Badge variant="success">
<Trans>Active</Trans>
</Badge>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<ConfigItem label={t`Endpoint URL`} value={status.url ?? ""} mono />
<ConfigItem label={t`Interval`} value={`${status.interval}s`} />
<ConfigItem label={t`HTTP Method`} value={status.method ?? "POST"} />
</div>
<Separator />
<div>
<h4 className="text-base font-medium mb-1">
<Trans>Test heartbeat</Trans>
</h4>
<p className="text-sm text-muted-foreground leading-relaxed mb-3">
<Trans>Send a single heartbeat ping to verify your endpoint is working.</Trans>
</p>
<Button
type="button"
variant="outline"
className="flex items-center gap-1.5"
onClick={sendTestHeartbeat}
disabled={isTesting}
>
<TestIcon className={clsx("h-4 w-4", isTesting && "animate-spin")} />
<Trans>Send test heartbeat</Trans>
</Button>
</div>
<Separator />
<div>
<h4 className="text-base font-medium mb-2">
<Trans>Payload format</Trans>
</h4>
<p className="text-sm text-muted-foreground leading-relaxed mb-2">
<Trans>
When using POST, each heartbeat includes a JSON payload with system status summary, list of down
systems, and triggered alerts.
</Trans>
</p>
<p className="text-sm text-muted-foreground leading-relaxed">
<Trans>
The overall status is <code className="bg-muted rounded-sm px-1 text-primary">ok</code> when all systems
are up, <code className="bg-muted rounded-sm px-1 text-primary">warn</code> when alerts are triggered,
and <code className="bg-muted rounded-sm px-1 text-primary">error</code> when any system is down.
</Trans>
</p>
</div>
</div>
) : (
<div className="grid gap-4">
<div>
<p className="text-sm text-muted-foreground leading-relaxed mb-3">
<Trans>Set the following environment variables on your Beszel hub to enable heartbeat monitoring:</Trans>
</p>
<div className="grid gap-2.5">
<EnvVarItem
name="HEARTBEAT_URL"
description={t`Endpoint URL to ping (required)`}
example="https://uptime.betterstack.com/api/v1/heartbeat/xxxx"
/>
<EnvVarItem name="HEARTBEAT_INTERVAL" description={t`Seconds between pings (default: 60)`} example="60" />
<EnvVarItem
name="HEARTBEAT_METHOD"
description={t`HTTP method: POST, GET, or HEAD (default: POST)`}
example="POST"
/>
</div>
</div>
<p className="text-sm text-muted-foreground leading-relaxed">
<Trans>After setting the environment variables, restart your Beszel hub for changes to take effect.</Trans>
</p>
</div>
)}
</div>
)
}
@@ -201,14 +187,14 @@ function ConfigItem({ label, value, mono }: { label: string; value: string; mono
return (
<div>
<p className="text-sm font-medium mb-0.5">{label}</p>
<p className={cn("text-sm text-muted-foreground break-all", mono && "font-mono")}>{value}</p>
<p className={clsx("text-sm text-muted-foreground break-all", mono && "font-mono")}>{value}</p>
</div>
)
}
function EnvVarItem({ name, description, example }: { name: string; description: string; example: string }) {
return (
<div className="bg-muted/50 rounded-md px-3 py-2.5 grid gap-1.5">
<div className="bg-muted/50 rounded-md px-3 py-2 grid gap-1.5">
<code className="text-sm font-mono text-primary font-medium leading-tight">{name}</code>
<p className="text-sm text-muted-foreground">{description}</p>
<p className="text-xs text-muted-foreground">

View File

@@ -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
@@ -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 }) {

View File

@@ -621,8 +621,8 @@ function DiskSheet({
const deviceName = disk?.name || unknown
const model = disk?.model || unknown
const capacity = disk?.capacity ? formatCapacity(disk.capacity) : unknown
const serialNumber = disk?.serial
const firmwareVersion = disk?.firmware
const serialNumber = disk?.serial || unknown
const firmwareVersion = disk?.firmware || unknown
const status = disk?.state || unknown
return (
@@ -636,32 +636,24 @@ function DiskSheet({
{model}
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
{capacity}
{serialNumber && (
<>
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
<Tooltip>
<TooltipTrigger asChild>
<span>{serialNumber}</span>
</TooltipTrigger>
<TooltipContent>
<Trans>Serial Number</Trans>
</TooltipContent>
</Tooltip>
</>
)}
{firmwareVersion && (
<>
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
<Tooltip>
<TooltipTrigger asChild>
<span>{firmwareVersion}</span>
</TooltipTrigger>
<TooltipContent>
<Trans>Firmware</Trans>
</TooltipContent>
</Tooltip>
</>
)}
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
<Tooltip>
<TooltipTrigger asChild>
<span>{serialNumber}</span>
</TooltipTrigger>
<TooltipContent>
<Trans>Serial Number</Trans>
</TooltipContent>
</Tooltip>
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
<Tooltip>
<TooltipTrigger asChild>
<span>{firmwareVersion}</span>
</TooltipTrigger>
<TooltipContent>
<Trans>Firmware</Trans>
</TooltipContent>
</Tooltip>
</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-hidden p-4 flex flex-col gap-4">

View File

@@ -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>
)
}

View File

@@ -8,7 +8,7 @@ export default [
["es", "Español", "🇪🇸"],
["fa", "فارسی", "🇮🇷"],
["fr", "Français", "🇫🇷"],
["he", "עברית", ""],
["he", "עברית", "🕎"],
["hr", "Hrvatski", "🇭🇷"],
["hu", "Magyar", "🇭🇺"],
["id", "Indonesia", "🇮🇩"],

View File

@@ -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("")

View File

@@ -937,6 +937,7 @@ msgstr "متوسط التحميل"
msgid "Load state"
msgstr "حالة التحميل"
#: src/components/routes/settings/heartbeat.tsx
#: src/components/systemd-table/systemd-table.tsx
msgid "Loading..."
msgstr "جاري التحميل..."

View File

@@ -8,7 +8,7 @@ msgstr ""
"Language: bg\n"
"Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-01-31 21:16\n"
"PO-Revision-Date: 2026-02-25 11:49\n"
"Last-Translator: \n"
"Language-Team: Bulgarian\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
@@ -357,7 +357,7 @@ msgstr "Провери log-овете за повече информация."
#: src/components/routes/settings/heartbeat.tsx
msgid "Check your monitoring service"
msgstr "Проверете вашата услуга за мониторинг"
msgstr "Проверете мониторинг услугата си"
#: src/components/routes/settings/notifications.tsx
msgid "Check your notification service"
@@ -1377,7 +1377,7 @@ msgstr "Избери {foo}"
#: src/components/routes/settings/heartbeat.tsx
msgid "Send a single heartbeat ping to verify your endpoint is working."
msgstr "Изпратете единичен heartbeat пинг, за да проверите дали вашата крайна точка работи."
msgstr "Изпратете единичен heartbeat пинг, за да се уверите, че крайната Ви точка работи."
#: src/components/routes/settings/heartbeat.tsx
msgid "Send periodic outbound pings to an external monitoring service so you can monitor Beszel without exposing it to the internet."
@@ -1409,7 +1409,7 @@ msgstr "Задайте процентни прагове за цветовете
#: src/components/routes/settings/heartbeat.tsx
msgid "Set the following environment variables on your Beszel hub to enable heartbeat monitoring:"
msgstr "Задайте следните променливи на средата на вашия Beszel hub, за да активирате мониторинга на heartbeat:"
msgstr "Задайте следните променливи на средата на вашия Beszel hub, за да активирате heartbeat мониторинг:"
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
@@ -1728,7 +1728,7 @@ msgstr "Качване"
#: src/components/routes/system/info-bar.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Uptime"
msgstr "Uptime"
msgstr "Време на работа"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
@@ -1799,7 +1799,7 @@ msgstr "Webhook / Пуш нотификации"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "When enabled, this token allows agents to self-register without prior system creation."
msgstr "Когато е активиран, този символ позволява на агентите да се регистрират сами без предварително създаване на система."
msgstr "Когато е активиран, този токен позволява на агентите да се регистрират сами без предварително създаване на система."
#: src/components/routes/settings/heartbeat.tsx
msgid "When using POST, each heartbeat includes a JSON payload with system status summary, list of down systems, and triggered alerts."
@@ -1834,3 +1834,4 @@ msgstr "Да"
#: src/components/routes/settings/layout.tsx
msgid "Your user settings have been updated."
msgstr "Настройките за потребителя ти са обновени."

View File

@@ -937,6 +937,7 @@ msgstr "Prům. zatížení"
msgid "Load state"
msgstr "Stav načtení"
#: src/components/routes/settings/heartbeat.tsx
#: src/components/systemd-table/systemd-table.tsx
msgid "Loading..."
msgstr "Načítání..."

View File

@@ -937,6 +937,7 @@ msgstr "Belastning gns."
msgid "Load state"
msgstr "Indlæsningstilstand"
#: src/components/routes/settings/heartbeat.tsx
#: src/components/systemd-table/systemd-table.tsx
msgid "Loading..."
msgstr "Indlæser..."

View File

@@ -8,7 +8,7 @@ msgstr ""
"Language: de\n"
"Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-01-31 21:16\n"
"PO-Revision-Date: 2026-03-14 20:37\n"
"Last-Translator: \n"
"Language-Team: German\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
@@ -236,7 +236,7 @@ msgstr "Bandbreite"
#. Battery label in systems table header
#: src/components/systems-table/systems-table-columns.tsx
msgid "Bat"
msgstr "Bat"
msgstr "Batt"
#: src/components/routes/system.tsx
#: src/lib/alerts.ts
@@ -1177,7 +1177,7 @@ msgstr "Prozentsatz der Zeit in jedem Zustand"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Permanent"
msgstr "Permanent"
msgstr "Dauerhaft"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Persistence"
@@ -1834,3 +1834,4 @@ msgstr "Ja"
#: src/components/routes/settings/layout.tsx
msgid "Your user settings have been updated."
msgstr "Deine Benutzereinstellungen wurden aktualisiert."

View File

@@ -932,6 +932,7 @@ msgstr "Load Avg"
msgid "Load state"
msgstr "Load state"
#: src/components/routes/settings/heartbeat.tsx
#: src/components/systemd-table/systemd-table.tsx
msgid "Loading..."
msgstr "Loading..."

View File

@@ -937,6 +937,7 @@ msgstr "Carga media"
msgid "Load state"
msgstr "Estado de carga"
#: src/components/routes/settings/heartbeat.tsx
#: src/components/systemd-table/systemd-table.tsx
msgid "Loading..."
msgstr "Cargando..."

View File

@@ -937,6 +937,7 @@ msgstr "میانگین بار"
msgid "Load state"
msgstr "وضعیت بارگذاری"
#: src/components/routes/settings/heartbeat.tsx
#: src/components/systemd-table/systemd-table.tsx
msgid "Loading..."
msgstr "در حال بارگذاری..."

View File

@@ -937,6 +937,7 @@ msgstr "Charge moy."
msgid "Load state"
msgstr "État de charge"
#: src/components/routes/settings/heartbeat.tsx
#: src/components/systemd-table/systemd-table.tsx
msgid "Loading..."
msgstr "Chargement..."

View File

@@ -937,6 +937,7 @@ msgstr "ממוצע עומס"
msgid "Load state"
msgstr "מצב עומס"
#: src/components/routes/settings/heartbeat.tsx
#: src/components/systemd-table/systemd-table.tsx
msgid "Loading..."
msgstr "טוען..."

View File

@@ -937,6 +937,7 @@ msgstr "Prosječno Opterećenje"
msgid "Load state"
msgstr "Stanje učitavanja"
#: src/components/routes/settings/heartbeat.tsx
#: src/components/systemd-table/systemd-table.tsx
msgid "Loading..."
msgstr "Učitavanje..."

View File

@@ -937,6 +937,7 @@ msgstr "Terhelési átlag"
msgid "Load state"
msgstr "Betöltési állapot"
#: src/components/routes/settings/heartbeat.tsx
#: src/components/systemd-table/systemd-table.tsx
msgid "Loading..."
msgstr "Betöltés..."

View File

@@ -937,6 +937,7 @@ msgstr "Rata-rata Beban"
msgid "Load state"
msgstr "Beban saat ini"
#: src/components/routes/settings/heartbeat.tsx
#: src/components/systemd-table/systemd-table.tsx
msgid "Loading..."
msgstr "Memuat..."

View File

@@ -937,6 +937,7 @@ msgstr "Carico Medio"
msgid "Load state"
msgstr "Stato di caricamento"
#: src/components/routes/settings/heartbeat.tsx
#: src/components/systemd-table/systemd-table.tsx
msgid "Loading..."
msgstr "Caricamento..."

View File

@@ -937,6 +937,7 @@ msgstr "負荷平均"
msgid "Load state"
msgstr "ロード状態"
#: src/components/routes/settings/heartbeat.tsx
#: src/components/systemd-table/systemd-table.tsx
msgid "Loading..."
msgstr "読み込み中..."

View File

@@ -937,6 +937,7 @@ msgstr "부하 평균"
msgid "Load state"
msgstr "로드 상태"
#: src/components/routes/settings/heartbeat.tsx
#: src/components/systemd-table/systemd-table.tsx
msgid "Loading..."
msgstr "로딩 중..."

View File

@@ -8,7 +8,7 @@ msgstr ""
"Language: nl\n"
"Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-02-19 19:40\n"
"PO-Revision-Date: 2026-02-24 11:37\n"
"Last-Translator: \n"
"Language-Team: Dutch\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
@@ -836,7 +836,7 @@ msgstr "Gezondheid"
#: src/components/routes/settings/layout.tsx
msgid "Heartbeat"
msgstr "Heartbeat"
msgstr ""
#: src/components/routes/settings/heartbeat.tsx
msgid "Heartbeat Monitoring"
@@ -884,7 +884,7 @@ msgstr "Inactief"
#: src/components/routes/settings/heartbeat.tsx
msgid "Interval"
msgstr "Interval"
msgstr ""
#: src/components/login/auth-form.tsx
msgid "Invalid email address."
@@ -1834,3 +1834,4 @@ msgstr "Ja"
#: src/components/routes/settings/layout.tsx
msgid "Your user settings have been updated."
msgstr "Je gebruikersinstellingen zijn bijgewerkt."

View File

@@ -937,6 +937,7 @@ msgstr "Snittbelastning"
msgid "Load state"
msgstr "Lastetilstand"
#: src/components/routes/settings/heartbeat.tsx
#: src/components/systemd-table/systemd-table.tsx
msgid "Loading..."
msgstr "Laster..."

View File

@@ -937,6 +937,7 @@ msgstr "Śr. obciążenie"
msgid "Load state"
msgstr "Stan obciążenia"
#: src/components/routes/settings/heartbeat.tsx
#: src/components/systemd-table/systemd-table.tsx
msgid "Loading..."
msgstr "Ładowanie..."

View File

@@ -937,6 +937,7 @@ msgstr "Carga Média"
msgid "Load state"
msgstr "Estado de carga"
#: src/components/routes/settings/heartbeat.tsx
#: src/components/systemd-table/systemd-table.tsx
msgid "Loading..."
msgstr "Carregando..."

View File

@@ -8,7 +8,7 @@ msgstr ""
"Language: ru\n"
"Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-01-31 21:16\n"
"PO-Revision-Date: 2026-02-21 09:46\n"
"Last-Translator: \n"
"Language-Team: Russian\n"
"Plural-Forms: nplurals=4; plural=((n%10==1 && n%100!=11) ? 0 : ((n%10 >= 2 && n%10 <=4 && (n%100 < 12 || n%100 > 14)) ? 1 : ((n%10 == 0 || (n%10 >= 5 && n%10 <=9)) || (n%100 >= 11 && n%100 <= 14)) ? 2 : 3));\n"
@@ -1728,7 +1728,7 @@ msgstr "Отдача"
#: src/components/routes/system/info-bar.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Uptime"
msgstr "Uptime"
msgstr "Время работы"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
@@ -1834,3 +1834,4 @@ msgstr "Да"
#: src/components/routes/settings/layout.tsx
msgid "Your user settings have been updated."
msgstr "Ваши настройки пользователя были обновлены."

View File

@@ -937,6 +937,7 @@ msgstr "Povpr. obrem."
msgid "Load state"
msgstr "Stanje nalaganja"
#: src/components/routes/settings/heartbeat.tsx
#: src/components/systemd-table/systemd-table.tsx
msgid "Loading..."
msgstr "Nalaganje..."

View File

@@ -8,7 +8,7 @@ msgstr ""
"Language: sr\n"
"Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-02-03 15:27\n"
"PO-Revision-Date: 2026-02-23 15:11\n"
"Last-Translator: \n"
"Language-Team: Serbian (Cyrillic)\n"
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
@@ -836,7 +836,7 @@ msgstr "Здравље"
#: src/components/routes/settings/layout.tsx
msgid "Heartbeat"
msgstr "Heartbeat"
msgstr "Оркуцај"
#: src/components/routes/settings/heartbeat.tsx
msgid "Heartbeat Monitoring"
@@ -884,7 +884,7 @@ msgstr "Неактивно"
#: src/components/routes/settings/heartbeat.tsx
msgid "Interval"
msgstr "Interval"
msgstr "Интервал"
#: src/components/login/auth-form.tsx
msgid "Invalid email address."
@@ -1728,7 +1728,7 @@ msgstr "Отпреми"
#: src/components/routes/system/info-bar.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Uptime"
msgstr "Uptime"
msgstr "Време рада"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
@@ -1834,3 +1834,4 @@ msgstr "Да"
#: src/components/routes/settings/layout.tsx
msgid "Your user settings have been updated."
msgstr "Ваша корисничка подешавања су ажурирана."

View File

@@ -937,6 +937,7 @@ msgstr "Belastning"
msgid "Load state"
msgstr "Laddningstillstånd"
#: src/components/routes/settings/heartbeat.tsx
#: src/components/systemd-table/systemd-table.tsx
msgid "Loading..."
msgstr "Laddar..."

View File

@@ -8,7 +8,7 @@ msgstr ""
"Language: tr\n"
"Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-01-31 21:16\n"
"PO-Revision-Date: 2026-03-02 13:16\n"
"Last-Translator: \n"
"Language-Team: Turkish\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
@@ -143,7 +143,7 @@ msgstr "Sonra"
#: src/components/routes/settings/heartbeat.tsx
msgid "After setting the environment variables, restart your Beszel hub for changes to take effect."
msgstr "Ortam değişkenlerini ayarladıktan sonra, değişikliklerin etkili olması için Beszel hub'ınızı yeniden başlatın."
msgstr "Ortam değişkenlerini ayarladıktan sonra, değişikliklerin etkili olması için Beszel Hub'ınızı yeniden başlatın."
#: src/components/systems-table/systems-table-columns.tsx
msgid "Agent"
@@ -466,7 +466,7 @@ msgstr "YAML'ı kopyala"
#: src/components/systemd-table/systemd-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "CPU"
msgstr "CPU"
msgstr "İşlemci"
#: src/components/routes/system/cpu-sheet.tsx
msgid "CPU Cores"
@@ -565,7 +565,7 @@ msgstr "Boşalıyor"
#: src/components/systems-table/systems-table-columns.tsx
msgid "Disk"
msgstr "Disk"
msgstr "Depolama alanı"
#: src/components/routes/system.tsx
msgid "Disk I/O"
@@ -836,7 +836,7 @@ msgstr "Sağlık"
#: src/components/routes/settings/layout.tsx
msgid "Heartbeat"
msgstr "Heartbeat"
msgstr ""
#: src/components/routes/settings/heartbeat.tsx
msgid "Heartbeat Monitoring"
@@ -1010,7 +1010,7 @@ msgstr "Docker konteynerlerinin bellek kullanımı"
#: src/components/routes/system/smart-table.tsx
msgid "Model"
msgstr "Model"
msgstr ""
#: src/components/add-system.tsx
#: src/components/alerts-history-columns.tsx
@@ -1530,7 +1530,7 @@ msgstr "<0>URL</0>'yi Test Et"
#: src/components/routes/settings/heartbeat.tsx
msgid "Test heartbeat"
msgstr "Test heartbeat"
msgstr ""
#: src/components/routes/settings/notifications.tsx
msgid "Test notification sent"
@@ -1581,7 +1581,7 @@ msgstr "Temayı değiştir"
#: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Token"
msgstr "Token"
msgstr "Erişim Anahtarı"
#: src/components/command-palette.tsx
#: src/components/routes/settings/layout.tsx
@@ -1728,7 +1728,7 @@ msgstr "Yükle"
#: src/components/routes/system/info-bar.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Uptime"
msgstr "Uptime"
msgstr "Çalışma Süresi"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
@@ -1834,3 +1834,4 @@ msgstr "Evet"
#: src/components/routes/settings/layout.tsx
msgid "Your user settings have been updated."
msgstr "Kullanıcı ayarlarınız güncellendi."

View File

@@ -937,6 +937,7 @@ msgstr "Сер. навантаження"
msgid "Load state"
msgstr "Стан завантаження"
#: src/components/routes/settings/heartbeat.tsx
#: src/components/systemd-table/systemd-table.tsx
msgid "Loading..."
msgstr "Завантаження..."

View File

@@ -937,6 +937,7 @@ msgstr "Tải TB"
msgid "Load state"
msgstr "Trạng thái tải"
#: src/components/routes/settings/heartbeat.tsx
#: src/components/systemd-table/systemd-table.tsx
msgid "Loading..."
msgstr "Đang tải..."

View File

@@ -8,7 +8,7 @@ msgstr ""
"Language: zh\n"
"Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-01-31 21:16\n"
"PO-Revision-Date: 2026-03-01 17:14\n"
"Last-Translator: \n"
"Language-Team: Chinese Simplified\n"
"Plural-Forms: nplurals=1; plural=0;\n"
@@ -836,7 +836,7 @@ msgstr "健康"
#: src/components/routes/settings/layout.tsx
msgid "Heartbeat"
msgstr "Heartbeat"
msgstr "心跳"
#: src/components/routes/settings/heartbeat.tsx
msgid "Heartbeat Monitoring"
@@ -1728,7 +1728,7 @@ msgstr "上传"
#: src/components/routes/system/info-bar.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Uptime"
msgstr "运行时间"
msgstr "正常运行时间"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
@@ -1834,3 +1834,4 @@ msgstr "是"
#: src/components/routes/settings/layout.tsx
msgid "Your user settings have been updated."
msgstr "您的用户设置已更新。"

View File

@@ -8,7 +8,7 @@ msgstr ""
"Language: zh\n"
"Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-01-31 21:16\n"
"PO-Revision-Date: 2026-02-20 02:25\n"
"Last-Translator: \n"
"Language-Team: Chinese Traditional, Hong Kong\n"
"Plural-Forms: nplurals=1; plural=0;\n"
@@ -836,7 +836,7 @@ msgstr "健康狀態"
#: src/components/routes/settings/layout.tsx
msgid "Heartbeat"
msgstr "Heartbeat"
msgstr ""
#: src/components/routes/settings/heartbeat.tsx
msgid "Heartbeat Monitoring"
@@ -937,6 +937,7 @@ msgstr "平均負載"
msgid "Load state"
msgstr "載入狀態"
#: src/components/routes/settings/heartbeat.tsx
#: src/components/systemd-table/systemd-table.tsx
msgid "Loading..."
msgstr "載入中..."
@@ -1728,7 +1729,7 @@ msgstr "上傳"
#: src/components/routes/system/info-bar.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Uptime"
msgstr "運行時間"
msgstr "正常運行時間"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
@@ -1834,3 +1835,4 @@ msgstr "是"
#: src/components/routes/settings/layout.tsx
msgid "Your user settings have been updated."
msgstr "您的用戶設置已更新。"

View File

@@ -937,6 +937,7 @@ msgstr "平均負載"
msgid "Load state"
msgstr "載入狀態"
#: src/components/routes/settings/heartbeat.tsx
#: src/components/systemd-table/systemd-table.tsx
msgid "Loading..."
msgstr "載入中..."

View File

@@ -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") {

View File

@@ -275,36 +275,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 (0100, 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 +425,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[];
}

View File

@@ -51,7 +51,7 @@ The [quick start guide](https://beszel.dev/guide/getting-started) and other docu
- **GPU usage / power draw** - Nvidia, AMD, and Intel.
- **Battery** - Host system battery charge.
- **Containers** - Status and metrics of all running Docker / Podman containers.
- **S.M.A.R.T.** - Host system disk health (includes eMMC wear/EOL and Linux mdraid array health via sysfs when available).
- **S.M.A.R.T.** - Host system disk health (includes eMMC wear/EOL via Linux sysfs when available).
## Help and discussion

View File

@@ -1,10 +1,10 @@
## 0.18.4
## Unreleased
- Add outbound heartbeat monitoring to external services (#1729)
- Add outbound heartbeat monitoring to external services (BetterStack, Uptime Kuma, Healthchecks.io, etc.) with system status summary payload. Configured via `BESZEL_HUB_HEARTBEAT_URL`, `BESZEL_HUB_HEARTBEAT_INTERVAL`, and `BESZEL_HUB_HEARTBEAT_METHOD` environment variables.
- Add experimental GPU monitoring for Apple Silicon. (#1747, #1746)
- Add GPU monitoring for Apple Silicon. (#1747, #1746)
- Add `nvtop` integration for GPU monitoring. (#1508)
- Add `nvtop` integration for expanded GPU compatibility.
- Add `GPU_COLLECTOR` environment variable to manually specify the GPU collector(s).
@@ -16,21 +16,11 @@
- Add `fingerprint` command to the agent. (#1726)
- Add precise value entry for alerts via text input. (#1718)
- Include GTT memory in AMD GPU metrics and improve device name lookup. (#1569)
- Improve multiplexed logs detection for Podman. (#1755)
- Harden against Docker API path traversal.
- Fix issue where the agent could report incorrect root disk I/O when running in Docker. (#1737)
- Retry Docker check on non-200 HTTP response. (#1754)
- Fix race issue with meter threshold colors.
- Update Go version and dependencies.
- Update Go to 1.26.
- Add `InstallMethod` parameter to Windows install script.

View File

@@ -374,7 +374,7 @@ else
fi
# Stop existing service if it exists (for upgrades)
if [ "$UNINSTALL" != true ] && [ -f "$BIN_PATH" ]; then
if [ -f "$BIN_PATH" ]; then
echo "Existing installation detected. Stopping service for upgrade..."
if is_alpine; then
rc-service beszel-agent stop 2>/dev/null || true
@@ -451,7 +451,7 @@ if [ "$UNINSTALL" = true ]; then
else
echo "Stopping and disabling the agent service..."
systemctl stop beszel-agent.service
systemctl disable beszel-agent.service >/dev/null 2>&1
systemctl disable beszel-agent.service
echo "Removing the systemd service file..."
rm /etc/systemd/system/beszel-agent.service
@@ -459,7 +459,7 @@ if [ "$UNINSTALL" = true ]; then
# Remove the update timer and service if they exist
echo "Removing the daily update service and timer..."
systemctl stop beszel-agent-update.timer 2>/dev/null
systemctl disable beszel-agent-update.timer >/dev/null 2>&1
systemctl disable beszel-agent-update.timer 2>/dev/null
rm -f /etc/systemd/system/beszel-agent-update.service
rm -f /etc/systemd/system/beszel-agent-update.timer
@@ -549,14 +549,14 @@ else
fi
# Create a dedicated user for the service if it doesn't exist
echo "Configuring the dedicated user for the Beszel Agent service..."
echo "Creating a dedicated user for the Beszel Agent service..."
if is_alpine; then
if ! id -u beszel >/dev/null 2>&1; then
addgroup beszel
adduser -S -D -H -s /sbin/nologin -G beszel beszel
fi
# Add the user to the docker group to allow access to the Docker socket if group docker exists
if getent group docker >/dev/null 2>&1; then
if getent group docker; then
echo "Adding beszel to docker group"
addgroup beszel docker
fi
@@ -604,12 +604,12 @@ else
useradd --system --home-dir /nonexistent --shell /bin/false beszel
fi
# Add the user to the docker group to allow access to the Docker socket if group docker exists
if getent group docker >/dev/null 2>&1; then
if getent group docker; then
echo "Adding beszel to docker group"
usermod -aG docker beszel
fi
# Add the user to the disk group to allow access to disk devices if group disk exists
if getent group disk >/dev/null 2>&1; then
if getent group disk; then
echo "Adding beszel to disk group"
usermod -aG disk beszel
fi
@@ -629,6 +629,7 @@ if [ ! -d "$BIN_DIR" ]; then
fi
# Download and install the Beszel Agent
echo "Downloading and installing the agent..."
OS=$(uname -s | sed -e 'y/ABCDEFGHIJKLMNOPQRSTUVWXYZ/abcdefghijklmnopqrstuvwxyz/')
ARCH=$(detect_architecture)
@@ -655,29 +656,19 @@ else
INSTALL_VERSION=$(echo "$INSTALL_VERSION" | sed 's/^v//')
fi
echo "Downloading beszel-agent v${INSTALL_VERSION}..."
echo "Downloading and installing agent version ${INSTALL_VERSION} from ${GITHUB_URL} ..."
# Download checksums file
TEMP_DIR=$(mktemp -d)
cd "$TEMP_DIR" || exit 1
CHECKSUM=$(curl -fsSL "$GITHUB_URL/henrygd/beszel/releases/download/v${INSTALL_VERSION}/beszel_${INSTALL_VERSION}_checksums.txt" | grep "$FILE_NAME" | cut -d' ' -f1)
CHECKSUM=$(curl -sL "$GITHUB_URL/henrygd/beszel/releases/download/v${INSTALL_VERSION}/beszel_${INSTALL_VERSION}_checksums.txt" | grep "$FILE_NAME" | cut -d' ' -f1)
if [ -z "$CHECKSUM" ] || ! echo "$CHECKSUM" | grep -qE "^[a-fA-F0-9]{64}$"; then
echo "Failed to get checksum or invalid checksum format"
echo "Try again with --mirror (or --mirror <url>) if GitHub is not reachable."
rm -rf "$TEMP_DIR"
exit 1
fi
if ! curl -fL# --retry 3 --retry-delay 2 --connect-timeout 10 "$GITHUB_URL/henrygd/beszel/releases/download/v${INSTALL_VERSION}/$FILE_NAME" -o "$FILE_NAME"; then
echo "Failed to download the agent from $GITHUB_URL/henrygd/beszel/releases/download/v${INSTALL_VERSION}/$FILE_NAME"
echo "Try again with --mirror (or --mirror <url>) if GitHub is not reachable."
rm -rf "$TEMP_DIR"
exit 1
fi
if ! tar -tzf "$FILE_NAME" >/dev/null 2>&1; then
echo "Downloaded archive is invalid or incomplete (possible network/proxy issue)."
echo "Try again with --mirror (or --mirror <url>) if the download path is unstable."
if ! curl -#L "$GITHUB_URL/henrygd/beszel/releases/download/v${INSTALL_VERSION}/$FILE_NAME" -o "$FILE_NAME"; then
echo "Failed to download the agent from ""$GITHUB_URL/henrygd/beszel/releases/download/v${INSTALL_VERSION}/$FILE_NAME"
rm -rf "$TEMP_DIR"
exit 1
fi
@@ -694,12 +685,6 @@ if ! tar -xzf "$FILE_NAME" beszel-agent; then
exit 1
fi
if [ ! -s "$TEMP_DIR/beszel-agent" ]; then
echo "Downloaded binary is missing or empty."
rm -rf "$TEMP_DIR"
exit 1
fi
if [ -f "$BIN_PATH" ]; then
echo "Backing up existing binary..."
cp "$BIN_PATH" "$BIN_PATH.bak"
@@ -886,8 +871,6 @@ EOF
elif is_freebsd; then
echo "Checking for existing FreeBSD service configuration..."
# Ensure rc.d directory exists on minimal FreeBSD installs
mkdir -p /usr/local/etc/rc.d
# Create environment configuration file with proper permissions if it doesn't exist
if [ ! -f "$AGENT_DIR/env" ]; then
@@ -1006,7 +989,7 @@ EOF
# Load and start the service
printf "\nLoading and starting the agent service...\n"
systemctl daemon-reload
systemctl enable beszel-agent.service >/dev/null 2>&1
systemctl enable beszel-agent.service
systemctl restart beszel-agent.service
@@ -1052,7 +1035,7 @@ WantedBy=timers.target
EOF
systemctl daemon-reload
systemctl enable --now beszel-agent-update.timer >/dev/null 2>&1
systemctl enable --now beszel-agent-update.timer
printf "\nDaily updates have been enabled.\n"
;;

View File

@@ -156,7 +156,7 @@ fi
# Define default values
PORT=8090
GITHUB_URL="https://github.com"
GITHUB_PROXY_URL="https://ghfast.top/"
AUTO_UPDATE_FLAG="false"
UNINSTALL=false
@@ -173,7 +173,7 @@ while [ $# -gt 0 ]; do
printf "Options: \n"
printf " -u : Uninstall the Beszel Hub\n"
printf " -p <port> : Specify a port number (default: 8090)\n"
printf " -c, --mirror [URL] : Use a GitHub mirror/proxy URL (default: https://gh.beszel.dev)\n"
printf " -c <url> : Use a custom GitHub mirror URL (e.g., https://ghfast.top/)\n"
printf " --auto-update : Enable automatic daily updates (disabled by default)\n"
printf " -h, --help : Display this help message\n"
exit 0
@@ -183,14 +183,10 @@ while [ $# -gt 0 ]; do
PORT="$1"
shift
;;
-c | --mirror)
-c)
shift
GITHUB_PROXY_URL=$(ensure_trailing_slash "$1")
shift
if [ -n "$1" ] && ! echo "$1" | grep -q '^-'; then
GITHUB_URL="$(ensure_trailing_slash "$1")https://github.com"
shift
else
GITHUB_URL="https://gh.beszel.dev"
fi
;;
--auto-update)
AUTO_UPDATE_FLAG="true"
@@ -203,6 +199,9 @@ while [ $# -gt 0 ]; do
esac
done
# Ensure the proxy URL ends with a /
GITHUB_PROXY_URL=$(ensure_trailing_slash "$GITHUB_PROXY_URL")
# Set paths based on operating system
if is_freebsd; then
HUB_DIR="/usr/local/etc/beszel"
@@ -324,41 +323,10 @@ OS=$(uname -s | tr '[:upper:]' '[:lower:]')
ARCH=$(detect_architecture)
FILE_NAME="beszel_${OS}_${ARCH}.tar.gz"
TEMP_DIR=$(mktemp -d)
ARCHIVE_PATH="$TEMP_DIR/$FILE_NAME"
DOWNLOAD_URL="$GITHUB_URL/henrygd/beszel/releases/latest/download/$FILE_NAME"
if ! curl -fL# --retry 3 --retry-delay 2 --connect-timeout 10 "$DOWNLOAD_URL" -o "$ARCHIVE_PATH"; then
echo "Failed to download the Beszel Hub from:"
echo "$DOWNLOAD_URL"
echo "Try again with --mirror (or --mirror <url>) if GitHub is not reachable."
rm -rf "$TEMP_DIR"
exit 1
fi
if ! tar -tzf "$ARCHIVE_PATH" >/dev/null 2>&1; then
echo "Downloaded archive is invalid or incomplete (possible network/proxy issue)."
echo "Try again with --mirror (or --mirror <url>) if the download path is unstable."
rm -rf "$TEMP_DIR"
exit 1
fi
if ! tar -xzf "$ARCHIVE_PATH" -C "$TEMP_DIR" beszel; then
echo "Failed to extract beszel from archive."
rm -rf "$TEMP_DIR"
exit 1
fi
if [ ! -s "$TEMP_DIR/beszel" ]; then
echo "Downloaded binary is missing or empty."
rm -rf "$TEMP_DIR"
exit 1
fi
chmod +x "$TEMP_DIR/beszel"
mv "$TEMP_DIR/beszel" "$BIN_PATH"
curl -sL "${GITHUB_PROXY_URL}https://github.com/henrygd/beszel/releases/latest/download/$FILE_NAME" | tar -xz -O beszel | tee ./beszel >/dev/null
chmod +x ./beszel
mv ./beszel "$BIN_PATH"
chown beszel:beszel "$BIN_PATH"
rm -rf "$TEMP_DIR"
if is_freebsd; then
echo "Creating FreeBSD rc service..."
@@ -407,8 +375,8 @@ EOF
else
# Original systemd service installation code
printf "Creating the systemd service for the Beszel Hub...\n"
cat >/etc/systemd/system/beszel-hub.service <<EOF
printf "Creating the systemd service for the Beszel Hub...\n\n"
tee /etc/systemd/system/beszel-hub.service <<EOF
[Unit]
Description=Beszel Hub Service
After=network.target
@@ -425,10 +393,10 @@ WantedBy=multi-user.target
EOF
# Load and start the service
printf "Loading and starting the Beszel Hub service...\n"
printf "\nLoading and starting the Beszel Hub service...\n"
systemctl daemon-reload
systemctl enable --quiet beszel-hub.service
systemctl start --quiet beszel-hub.service
systemctl enable beszel-hub.service
systemctl start beszel-hub.service
# Wait for the service to start or fail
sleep 2
@@ -476,4 +444,4 @@ EOF
fi
fi
printf "\n\033[32mBeszel Hub has been installed successfully! It is now accessible on port $PORT.\033[0m\n"
echo "The Beszel Hub has been installed and configured successfully! It is now accessible on port $PORT."