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
57 changed files with 287 additions and 1057 deletions

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

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

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

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

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

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

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

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

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

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

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