mirror of
https://github.com/henrygd/beszel.git
synced 2026-03-22 05:36:15 +01:00
Compare commits
11 Commits
v0.18.4
...
8f23fff1c9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f23fff1c9 | ||
|
|
02c1a0c13d | ||
|
|
69fdcb36ab | ||
|
|
b91eb6de40 | ||
|
|
ec69f6c6e0 | ||
|
|
a86cb91e07 | ||
|
|
004841717a | ||
|
|
096296ba7b | ||
|
|
b012df5669 | ||
|
|
12545b4b6d | ||
|
|
9e2296452b |
@@ -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 {
|
||||
if times, err := cpu.Times(false); err == nil && len(times) > 0 {
|
||||
lastCpuTimes[60000] = times[0]
|
||||
}
|
||||
if perCoreTimes, err := cpu.Times(true); err == nil {
|
||||
if perCoreTimes, err := cpu.Times(true); err == nil && len(perCoreTimes) > 0 {
|
||||
lastPerCoreCpuTimes[60000] = perCoreTimes
|
||||
}
|
||||
}
|
||||
|
||||
205
agent/disk.go
205
agent/disk.go
@@ -78,14 +78,21 @@ func (a *Agent) initializeDiskInfo() {
|
||||
if _, exists := a.fsStats[key]; !exists {
|
||||
if root {
|
||||
slog.Info("Detected root device", "name", key)
|
||||
// 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.
|
||||
// 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.
|
||||
if _, ioMatch = diskIoCounters[key]; !ioMatch {
|
||||
if matchedKey, match := findIoDevice(filesystem, diskIoCounters); match {
|
||||
if matchedKey, match := findIoDevice(key, diskIoCounters); match {
|
||||
key = matchedKey
|
||||
ioMatch = true
|
||||
} else {
|
||||
} else if filesystem != "" {
|
||||
if matchedKey, match := findIoDevice(filesystem, diskIoCounters); match {
|
||||
key = matchedKey
|
||||
ioMatch = true
|
||||
}
|
||||
}
|
||||
if !ioMatch {
|
||||
slog.Warn("Root I/O unmapped; set FILESYSTEM", "device", device, "mountpoint", mountpoint)
|
||||
}
|
||||
}
|
||||
@@ -114,14 +121,22 @@ func (a *Agent) initializeDiskInfo() {
|
||||
// Use FILESYSTEM env var to find root filesystem
|
||||
if filesystem != "" {
|
||||
for _, p := range partitions {
|
||||
if strings.HasSuffix(p.Device, filesystem) || p.Mountpoint == filesystem {
|
||||
if filesystemMatchesPartitionSetting(filesystem, p) {
|
||||
addFsStat(p.Device, p.Mountpoint, true)
|
||||
hasRoot = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasRoot {
|
||||
slog.Warn("Partition details not found", "filesystem", filesystem)
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,28 +202,180 @@ func (a *Agent) initializeDiskInfo() {
|
||||
}
|
||||
}
|
||||
|
||||
// If no root filesystem set, use fallback
|
||||
// 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 !hasRoot {
|
||||
rootKey := filepath.Base(rootMountPoint)
|
||||
if _, exists := a.fsStats[rootKey]; exists {
|
||||
rootKey = "root"
|
||||
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")
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
for _, d := range diskIoCounters {
|
||||
if d.Name == filesystem || (d.Label != "" && d.Label == filesystem) {
|
||||
return d.Name, true
|
||||
// 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
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
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.
|
||||
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) {
|
||||
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
|
||||
}
|
||||
|
||||
// Sets start values for disk I/O stats.
|
||||
|
||||
@@ -116,7 +116,7 @@ func TestFindIoDevice(t *testing.T) {
|
||||
assert.Equal(t, "sda", device)
|
||||
})
|
||||
|
||||
t.Run("returns no fallback when not found", func(t *testing.T) {
|
||||
t.Run("returns no match when not found", func(t *testing.T) {
|
||||
ioCounters := map[string]disk.IOCountersStat{
|
||||
"sda": {Name: "sda"},
|
||||
"sdb": {Name: "sdb"},
|
||||
@@ -126,6 +126,106 @@ 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) {
|
||||
@@ -372,3 +472,37 @@ 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}))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -199,19 +199,6 @@ 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 {
|
||||
|
||||
41
agent/fs_utils.go
Normal file
41
agent/fs_utils.go
Normal file
@@ -0,0 +1,41 @@
|
||||
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
|
||||
}
|
||||
225
agent/mdraid_linux.go
Normal file
225
agent/mdraid_linux.go
Normal file
@@ -0,0 +1,225 @@
|
||||
//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
|
||||
}
|
||||
100
agent/mdraid_linux_test.go
Normal file
100
agent/mdraid_linux_test.go
Normal file
@@ -0,0 +1,100 @@
|
||||
//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)
|
||||
}
|
||||
}
|
||||
11
agent/mdraid_stub.go
Normal file
11
agent/mdraid_stub.go
Normal file
@@ -0,0 +1,11 @@
|
||||
//go:build !linux
|
||||
|
||||
package agent
|
||||
|
||||
func scanMdraidDevices() []*DeviceInfo {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (sm *SmartManager) collectMdraidHealth(deviceInfo *DeviceInfo) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
@@ -199,6 +199,13 @@ 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)
|
||||
@@ -450,6 +457,12 @@ 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 {
|
||||
@@ -1146,9 +1159,11 @@ 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 health via sysfs, in which case smartctl is optional.
|
||||
if runtime.GOOS == "linux" && len(scanEmmcDevices()) > 0 {
|
||||
return sm, nil
|
||||
// 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
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -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 := getARCSize(); err != nil {
|
||||
if _, err := zfs.ARCSize(); 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, _ := getARCSize(); arcSize > 0 && arcSize < v.Used {
|
||||
if arcSize, _ := zfs.ARCSize(); arcSize > 0 && arcSize < v.Used {
|
||||
v.Used = v.Used - arcSize
|
||||
v.UsedPercent = float64(v.Used) / float64(v.Total) * 100.0
|
||||
systemStats.MemZfsArc = bytesToGigabytes(arcSize)
|
||||
@@ -250,32 +250,6 @@ 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")
|
||||
|
||||
11
agent/zfs/zfs_freebsd.go
Normal file
11
agent/zfs/zfs_freebsd.go
Normal file
@@ -0,0 +1,11 @@
|
||||
//go:build freebsd
|
||||
|
||||
package zfs
|
||||
|
||||
import (
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func ARCSize() (uint64, error) {
|
||||
return unix.SysctlUint64("kstat.zfs.misc.arcstats.size")
|
||||
}
|
||||
34
agent/zfs/zfs_linux.go
Normal file
34
agent/zfs/zfs_linux.go
Normal file
@@ -0,0 +1,34 @@
|
||||
//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")
|
||||
}
|
||||
9
agent/zfs/zfs_unsupported.go
Normal file
9
agent/zfs/zfs_unsupported.go
Normal file
@@ -0,0 +1,9 @@
|
||||
//go:build !linux && !freebsd
|
||||
|
||||
package zfs
|
||||
|
||||
import "errors"
|
||||
|
||||
func ARCSize() (uint64, error) {
|
||||
return 0, errors.ErrUnsupported
|
||||
}
|
||||
@@ -38,7 +38,7 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
|
||||
case "Memory":
|
||||
val = data.Info.MemPct
|
||||
case "Bandwidth":
|
||||
val = data.Info.Bandwidth
|
||||
val = float64(data.Info.BandwidthBytes) / (1024 * 1024)
|
||||
unit = " MB/s"
|
||||
case "Disk":
|
||||
maxUsedPct := data.Info.DiskPct
|
||||
|
||||
@@ -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`
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 || unknown
|
||||
const firmwareVersion = disk?.firmware || unknown
|
||||
const serialNumber = disk?.serial
|
||||
const firmwareVersion = disk?.firmware
|
||||
const status = disk?.state || unknown
|
||||
|
||||
return (
|
||||
@@ -636,24 +636,32 @@ function DiskSheet({
|
||||
{model}
|
||||
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
|
||||
{capacity}
|
||||
<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>
|
||||
{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>
|
||||
</>
|
||||
)}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex-1 overflow-hidden p-4 flex flex-col gap-4">
|
||||
|
||||
@@ -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 via Linux sysfs when available).
|
||||
- **S.M.A.R.T.** - Host system disk health (includes eMMC wear/EOL and Linux mdraid array health via sysfs when available).
|
||||
|
||||
## Help and discussion
|
||||
|
||||
|
||||
@@ -374,7 +374,7 @@ else
|
||||
fi
|
||||
|
||||
# Stop existing service if it exists (for upgrades)
|
||||
if [ -f "$BIN_PATH" ]; then
|
||||
if [ "$UNINSTALL" != true ] && [ -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
|
||||
systemctl disable beszel-agent.service >/dev/null 2>&1
|
||||
|
||||
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 2>/dev/null
|
||||
systemctl disable beszel-agent-update.timer >/dev/null 2>&1
|
||||
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 "Creating a dedicated user for the Beszel Agent service..."
|
||||
echo "Configuring the 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; then
|
||||
if getent group docker >/dev/null 2>&1; 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; then
|
||||
if getent group docker >/dev/null 2>&1; 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; then
|
||||
if getent group disk >/dev/null 2>&1; then
|
||||
echo "Adding beszel to disk group"
|
||||
usermod -aG disk beszel
|
||||
fi
|
||||
@@ -629,7 +629,6 @@ 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)
|
||||
@@ -656,19 +655,29 @@ else
|
||||
INSTALL_VERSION=$(echo "$INSTALL_VERSION" | sed 's/^v//')
|
||||
fi
|
||||
|
||||
echo "Downloading and installing agent version ${INSTALL_VERSION} from ${GITHUB_URL} ..."
|
||||
echo "Downloading beszel-agent v${INSTALL_VERSION}..."
|
||||
|
||||
# Download checksums file
|
||||
TEMP_DIR=$(mktemp -d)
|
||||
cd "$TEMP_DIR" || exit 1
|
||||
CHECKSUM=$(curl -sL "$GITHUB_URL/henrygd/beszel/releases/download/v${INSTALL_VERSION}/beszel_${INSTALL_VERSION}_checksums.txt" | grep "$FILE_NAME" | cut -d' ' -f1)
|
||||
CHECKSUM=$(curl -fsSL "$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 -#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"
|
||||
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."
|
||||
rm -rf "$TEMP_DIR"
|
||||
exit 1
|
||||
fi
|
||||
@@ -685,6 +694,12 @@ 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"
|
||||
@@ -871,6 +886,8 @@ 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
|
||||
@@ -989,7 +1006,7 @@ EOF
|
||||
# Load and start the service
|
||||
printf "\nLoading and starting the agent service...\n"
|
||||
systemctl daemon-reload
|
||||
systemctl enable beszel-agent.service
|
||||
systemctl enable beszel-agent.service >/dev/null 2>&1
|
||||
systemctl restart beszel-agent.service
|
||||
|
||||
|
||||
@@ -1035,7 +1052,7 @@ WantedBy=timers.target
|
||||
EOF
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now beszel-agent-update.timer
|
||||
systemctl enable --now beszel-agent-update.timer >/dev/null 2>&1
|
||||
|
||||
printf "\nDaily updates have been enabled.\n"
|
||||
;;
|
||||
|
||||
@@ -156,7 +156,7 @@ fi
|
||||
|
||||
# Define default values
|
||||
PORT=8090
|
||||
GITHUB_PROXY_URL="https://ghfast.top/"
|
||||
GITHUB_URL="https://github.com"
|
||||
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 <url> : Use a custom GitHub mirror URL (e.g., https://ghfast.top/)\n"
|
||||
printf " -c, --mirror [URL] : Use a GitHub mirror/proxy URL (default: https://gh.beszel.dev)\n"
|
||||
printf " --auto-update : Enable automatic daily updates (disabled by default)\n"
|
||||
printf " -h, --help : Display this help message\n"
|
||||
exit 0
|
||||
@@ -183,10 +183,14 @@ while [ $# -gt 0 ]; do
|
||||
PORT="$1"
|
||||
shift
|
||||
;;
|
||||
-c)
|
||||
shift
|
||||
GITHUB_PROXY_URL=$(ensure_trailing_slash "$1")
|
||||
-c | --mirror)
|
||||
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"
|
||||
@@ -199,9 +203,6 @@ 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"
|
||||
@@ -323,10 +324,41 @@ OS=$(uname -s | tr '[:upper:]' '[:lower:]')
|
||||
ARCH=$(detect_architecture)
|
||||
FILE_NAME="beszel_${OS}_${ARCH}.tar.gz"
|
||||
|
||||
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"
|
||||
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"
|
||||
chown beszel:beszel "$BIN_PATH"
|
||||
rm -rf "$TEMP_DIR"
|
||||
|
||||
if is_freebsd; then
|
||||
echo "Creating FreeBSD rc service..."
|
||||
@@ -375,8 +407,8 @@ EOF
|
||||
|
||||
else
|
||||
# Original systemd service installation code
|
||||
printf "Creating the systemd service for the Beszel Hub...\n\n"
|
||||
tee /etc/systemd/system/beszel-hub.service <<EOF
|
||||
printf "Creating the systemd service for the Beszel Hub...\n"
|
||||
cat >/etc/systemd/system/beszel-hub.service <<EOF
|
||||
[Unit]
|
||||
Description=Beszel Hub Service
|
||||
After=network.target
|
||||
@@ -393,10 +425,10 @@ WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
# Load and start the service
|
||||
printf "\nLoading and starting the Beszel Hub service...\n"
|
||||
printf "Loading and starting the Beszel Hub service...\n"
|
||||
systemctl daemon-reload
|
||||
systemctl enable beszel-hub.service
|
||||
systemctl start beszel-hub.service
|
||||
systemctl enable --quiet beszel-hub.service
|
||||
systemctl start --quiet beszel-hub.service
|
||||
|
||||
# Wait for the service to start or fail
|
||||
sleep 2
|
||||
@@ -444,4 +476,4 @@ EOF
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "The Beszel Hub has been installed and configured successfully! It is now accessible on port $PORT."
|
||||
printf "\n\033[32mBeszel Hub has been installed successfully! It is now accessible on port $PORT.\033[0m\n"
|
||||
|
||||
Reference in New Issue
Block a user