Compare commits

..

37 Commits

Author SHA1 Message Date
henrygd
5eca353429 updates 2026-03-07 12:19:18 -05:00
henrygd
d9e3c4678a updates 2026-03-05 16:10:19 -05:00
henrygd
1243a7bd8d update 2026-03-05 14:29:02 -05:00
henrygd
bd74ab8d7b updates 2026-03-03 19:08:28 -05:00
henrygd
016d775675 more updates 2026-03-03 16:32:50 -05:00
henrygd
bdbd135fdd updates 2026-03-03 14:23:42 -05:00
henrygd
48503f9f99 updates 2026-03-03 12:42:30 -05:00
henrygd
d34ef1ebe9 updates 2026-03-02 14:10:26 -05:00
henrygd
8f23fff1c9 refactor: mdraid comments and organization
also hide serial / firmware in smart details if empty, remove a few
unnecessary ops, and add a few more passed state values
2026-02-27 14:23:10 -05:00
VACInc
02c1a0c13d Add Linux mdraid health monitoring (#1750) 2026-02-27 13:42:47 -05:00
henrygd
69fdcb36ab support ZFS ARC on freebsd 2026-02-26 18:38:54 -05:00
henrygd
b91eb6de40 improve root I/O device detection and fallback (#1772)
- Match FILESYSTEM directly against I/O devices if partition lookup
fails
- Fall back to the most active I/O device if no root device is detected
- Add WARN logs in final fallback case to most active device
2026-02-26 18:11:33 -05:00
henrygd
ec69f6c6e0 improve disk I/O device matching for partition-to-disk mismatches (#1772)
findIoDevice now normalizes device names and falls back to prefix-based
matching when partition names differ from IOCounter names (e.g. nda0p2 →
nda0 on FreeBSD). The most-active prefix-related device is selected,
avoiding the broad "most active of all" heuristic that caused Docker
misattribution in #1737.
2026-02-26 16:59:12 -05:00
henrygd
a86cb91e07 improve install scripts with retries, validation, and better error messages
Add curl retries/timeouts, archive integrity checks, binary existence
checks, and temp dir cleanup on all failure paths. Unify --mirror flag
handling in hub script to match agent. Use cat instead of tee for
systemd service file, quiet systemctl output.
2026-02-26 12:29:05 -05:00
henrygd
004841717a add checks for non-empty CPU times during initialization (#401) 2026-02-25 19:04:29 -05:00
henrygd
096296ba7b fix: ensure rc.d directory exists for minimal FreeBSD installs in install-agent.sh 2026-02-25 16:22:37 -05:00
ilya
b012df5669 Fix volume path in Docker run command (#1764) 2026-02-24 15:47:16 -05:00
henrygd
12545b4b6d fix: dedupe root-mirrored extra filesystems during disk discovery (#1428) 2026-02-24 15:41:29 -05:00
henrygd
9e2296452b fix: compute bandwidth alerts from byte-per-second source (#1770)
Use Info.BandwidthBytes converted to MB/s with float division so
bandwidth alert checks are based on current data without integer
truncation near thresholds.
2026-02-24 13:07:27 -05:00
henrygd
ac79860d4a dev: update biome schema and disable assist/source/organizeImports 2026-02-20 15:50:44 -05:00
henrygd
e13a99fdac ui: add fallback to display language code if no emoji / flag 2026-02-20 15:46:24 -05:00
henrygd
4cfb2a86ad 0.18.4 release 2026-02-20 15:00:15 -05:00
henrygd
191f25f6e0 ui: refactor heartbeat settings page 2026-02-20 14:48:59 -05:00
henrygd
aa8b3711d7 update translations 2026-02-19 19:22:54 -05:00
henrygd
1fb0b25988 testing: improve flaky hub cleanup in agent_connect_test.go 2026-02-19 18:35:31 -05:00
henrygd
04600d83cc refactor: small go 1.26 updates and go fix changes 2026-02-19 18:04:33 -05:00
henrygd
5d8906c9b2 amd gpu: small refactor + trim "series" from device name 2026-02-19 17:39:13 -05:00
henrygd
daac287b9d ui: fix race issue with meter threshold colors
also increase the default container width
2026-02-19 17:37:57 -05:00
henrygd
d526ea61a9 ui: freeze header of smart device details table 2026-02-19 17:35:12 -05:00
henrygd
79616e1662 update translations 2026-02-19 16:21:59 -05:00
Sven van Ginkel
01e8bdf040 feat: allow precise value entry for alerts via text input (#1718) 2026-02-19 13:15:12 -05:00
henrygd
1e3a44e05d agent: improve multiplexed logs detection for podman (#1755) 2026-02-18 17:45:37 -05:00
henrygd
311095cfdd harden against docker api path traversal
Validate container IDs (12-64 hex) in hub container endpoints and agent
Docker requests, and build Docker URLs with escaped path segments. Add
regression tests for traversal/malformed container inputs and safe
endpoint construction.
2026-02-18 17:33:00 -05:00
henrygd
4869c834bb fix(ui): update bandwidth fallback to 0 when data is empty (avoid NaN) 2026-02-18 16:28:18 -05:00
henrygd
e1c1e97f0a chore: update go version / go deps / changelog 2026-02-18 16:17:05 -05:00
henrygd
f6b2824ccc rename gpu_apple_unsupported.go to gpu_darwin_unsupported.go 2026-02-18 15:15:58 -05:00
henrygd
f17ffc21b8 gate apple gpu collectors + revert readme change 2026-02-18 14:57:41 -05:00
125 changed files with 5959 additions and 698 deletions

View File

@@ -51,7 +51,6 @@ clean:
lint:
golangci-lint run
test: export GOEXPERIMENT=synctest
test:
go test -tags=testing ./...

View File

@@ -33,6 +33,7 @@ type Agent struct {
netIoStats map[uint16]system.NetIoStats // Keeps track of bandwidth usage per cache interval
netInterfaceDeltaTrackers map[uint16]*deltatracker.DeltaTracker[string, uint64] // Per-cache-time NIC delta trackers
dockerManager *dockerManager // Manages Docker API requests
pveManager *pveManager // Manages Proxmox VE API requests
sensorConfig *SensorConfig // Sensors config
systemInfo system.Info // Host system info (dynamic)
systemDetails system.Details // Host system details (static, once-per-connection)
@@ -99,6 +100,9 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
// initialize docker manager
agent.dockerManager = newDockerManager()
// initialize pve manager
agent.pveManager = newPVEManager()
// initialize system info
agent.refreshSystemDetails()
@@ -189,6 +193,15 @@ func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedD
}
}
if a.pveManager != nil {
if pveStats, err := a.pveManager.getPVEStats(); err == nil {
data.PVEStats = pveStats
slog.Debug("PVE", "data", data.PVEStats)
} else {
slog.Debug("PVE", "err", err)
}
}
// skip updating systemd services if cache time is not the default 60sec interval
if a.systemdManager != nil && cacheTimeMs == 60_000 {
totalCount := uint16(a.systemdManager.getServiceStatsCount())

View File

@@ -1,5 +1,4 @@
//go:build testing
// +build testing
package agent
@@ -15,6 +14,10 @@ import (
)
func createTestCacheData() *system.CombinedData {
var stats = container.Stats{}
stats.Name = "test-container"
stats.Cpu = 10.5
stats.Mem = 1073741824 // 1GB
return &system.CombinedData{
Stats: system.Stats{
Cpu: 50.5,
@@ -25,10 +28,7 @@ func createTestCacheData() *system.CombinedData {
AgentVersion: "0.12.0",
},
Containers: []*container.Stats{
{
Name: "test-container",
Cpu: 25.0,
},
&stats,
},
}
}

View File

@@ -1,5 +1,4 @@
//go:build testing
// +build testing
package agent

View File

@@ -1,5 +1,4 @@
//go:build testing
// +build testing
package agent

View File

@@ -1,9 +1,9 @@
package agent
import (
"context"
"errors"
"log/slog"
"os"
"os/signal"
"syscall"
"time"
@@ -91,8 +91,8 @@ func (c *ConnectionManager) Start(serverOptions ServerOptions) error {
c.eventChan = make(chan ConnectionEvent, 1)
// signal handling for shutdown
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
sigCtx, stopSignals := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stopSignals()
c.startWsTicker()
c.connect()
@@ -109,8 +109,8 @@ func (c *ConnectionManager) Start(serverOptions ServerOptions) error {
_ = c.startWebSocketConnection()
case <-healthTicker:
_ = health.Update()
case <-sigChan:
slog.Info("Shutting down")
case <-sigCtx.Done():
slog.Info("Shutting down", "cause", context.Cause(sigCtx))
_ = c.agent.StopServer()
c.closeWebSocket()
return health.CleanUp()

View File

@@ -1,5 +1,4 @@
//go:build testing
// +build testing
package agent

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 {
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
}
}
@@ -89,10 +89,7 @@ func getPerCoreCpuUsage(cacheTimeMs uint16) (system.Uint8Slice, error) {
lastTimes := lastPerCoreCpuTimes[cacheTimeMs]
// Limit to the number of cores available in both samples
length := len(perCoreTimes)
if len(lastTimes) < length {
length = len(lastTimes)
}
length := min(len(lastTimes), len(perCoreTimes))
usage := make([]uint8, length)
for i := 0; i < length; i++ {

View File

@@ -1,5 +1,4 @@
//go:build testing
// +build testing
package agent

View File

@@ -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,20 +121,28 @@ 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)
}
}
}
// Add EXTRA_FILESYSTEMS env var values to fsStats
if extraFilesystems, exists := GetEnv("EXTRA_FILESYSTEMS"); exists {
for _, fsEntry := range strings.Split(extraFilesystems, ",") {
for fsEntry := range strings.SplitSeq(extraFilesystems, ",") {
// Parse custom name from format: device__customname
fs, customName := parseFilesystemEntry(fsEntry)
@@ -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.

View File

@@ -1,5 +1,4 @@
//go:build testing
// +build testing
package agent
@@ -117,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"},
@@ -127,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) {
@@ -373,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}))
})
}

View File

@@ -1,6 +1,7 @@
package agent
import (
"bufio"
"bytes"
"context"
"encoding/binary"
@@ -27,7 +28,10 @@ import (
// ansiEscapePattern matches ANSI escape sequences (colors, cursor movement, etc.)
// This includes CSI sequences like \x1b[...m and simple escapes like \x1b[K
var ansiEscapePattern = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b[@-Z\\-_]`)
var (
ansiEscapePattern = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b[@-Z\\-_]`)
dockerContainerIDPattern = regexp.MustCompile(`^[a-fA-F0-9]{12,64}$`)
)
const (
// Docker API timeout in milliseconds
@@ -393,11 +397,12 @@ func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo, cacheTimeM
// add empty values if they doesn't exist in map
stats, initialized := dm.containerStatsMap[ctr.IdShort]
if !initialized {
stats = &container.Stats{Name: name, Id: ctr.IdShort, Image: ctr.Image}
stats = &container.Stats{Image: ctr.Image}
dm.containerStatsMap[ctr.IdShort] = stats
}
stats.Id = ctr.IdShort
stats.Name = name
statusText, health := parseDockerStatus(ctr.Status)
stats.Status = statusText
@@ -649,9 +654,34 @@ func getDockerHost() string {
return scheme + socks[0]
}
func validateContainerID(containerID string) error {
if !dockerContainerIDPattern.MatchString(containerID) {
return fmt.Errorf("invalid container id")
}
return nil
}
func buildDockerContainerEndpoint(containerID, action string, query url.Values) (string, error) {
if err := validateContainerID(containerID); err != nil {
return "", err
}
u := &url.URL{
Scheme: "http",
Host: "localhost",
Path: fmt.Sprintf("/containers/%s/%s", url.PathEscape(containerID), action),
}
if len(query) > 0 {
u.RawQuery = query.Encode()
}
return u.String(), nil
}
// getContainerInfo fetches the inspection data for a container
func (dm *dockerManager) getContainerInfo(ctx context.Context, containerID string) ([]byte, error) {
endpoint := fmt.Sprintf("http://localhost/containers/%s/json", containerID)
endpoint, err := buildDockerContainerEndpoint(containerID, "json", nil)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
@@ -682,7 +712,15 @@ func (dm *dockerManager) getContainerInfo(ctx context.Context, containerID strin
// getLogs fetches the logs for a container
func (dm *dockerManager) getLogs(ctx context.Context, containerID string) (string, error) {
endpoint := fmt.Sprintf("http://localhost/containers/%s/logs?stdout=1&stderr=1&tail=%d", containerID, dockerLogsTail)
query := url.Values{
"stdout": []string{"1"},
"stderr": []string{"1"},
"tail": []string{fmt.Sprintf("%d", dockerLogsTail)},
}
endpoint, err := buildDockerContainerEndpoint(containerID, "logs", query)
if err != nil {
return "", err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return "", err
@@ -700,8 +738,17 @@ func (dm *dockerManager) getLogs(ctx context.Context, containerID string) (strin
}
var builder strings.Builder
multiplexed := resp.Header.Get("Content-Type") == "application/vnd.docker.multiplexed-stream"
if err := decodeDockerLogStream(resp.Body, &builder, multiplexed); err != nil {
contentType := resp.Header.Get("Content-Type")
multiplexed := strings.HasSuffix(contentType, "multiplexed-stream")
logReader := io.Reader(resp.Body)
if !multiplexed {
// Podman may return multiplexed logs without Content-Type. Sniff the first frame header
// with a small buffered reader only when the header check fails.
bufferedReader := bufio.NewReaderSize(resp.Body, 8)
multiplexed = detectDockerMultiplexedStream(bufferedReader)
logReader = bufferedReader
}
if err := decodeDockerLogStream(logReader, &builder, multiplexed); err != nil {
return "", err
}
@@ -713,6 +760,23 @@ func (dm *dockerManager) getLogs(ctx context.Context, containerID string) (strin
return logs, nil
}
func detectDockerMultiplexedStream(reader *bufio.Reader) bool {
const headerSize = 8
header, err := reader.Peek(headerSize)
if err != nil {
return false
}
if header[0] != 0x01 && header[0] != 0x02 {
return false
}
// Docker's stream framing header reserves bytes 1-3 as zero.
if header[1] != 0 || header[2] != 0 || header[3] != 0 {
return false
}
frameLen := binary.BigEndian.Uint32(header[4:])
return frameLen <= maxLogFrameSize
}
func decodeDockerLogStream(reader io.Reader, builder *strings.Builder, multiplexed bool) error {
if !multiplexed {
_, err := io.Copy(builder, io.LimitReader(reader, maxTotalLogSize))

View File

@@ -1,5 +1,4 @@
//go:build testing
// +build testing
package agent
@@ -9,6 +8,7 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
@@ -25,6 +25,37 @@ import (
var defaultCacheTimeMs = uint16(60_000)
type recordingRoundTripper struct {
statusCode int
body string
contentType string
called bool
lastPath string
lastQuery map[string]string
}
func (rt *recordingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
rt.called = true
rt.lastPath = req.URL.EscapedPath()
rt.lastQuery = map[string]string{}
for key, values := range req.URL.Query() {
if len(values) > 0 {
rt.lastQuery[key] = values[0]
}
}
resp := &http.Response{
StatusCode: rt.statusCode,
Status: "200 OK",
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(rt.body)),
Request: req,
}
if rt.contentType != "" {
resp.Header.Set("Content-Type", rt.contentType)
}
return resp, nil
}
// cycleCpuDeltas cycles the CPU tracking data for a specific cache time interval
func (dm *dockerManager) cycleCpuDeltas(cacheTimeMs uint16) {
// Clear the CPU tracking maps for this cache time interval
@@ -116,6 +147,72 @@ func TestCalculateMemoryUsage(t *testing.T) {
}
}
func TestBuildDockerContainerEndpoint(t *testing.T) {
t.Run("valid container ID builds escaped endpoint", func(t *testing.T) {
endpoint, err := buildDockerContainerEndpoint("0123456789ab", "json", nil)
require.NoError(t, err)
assert.Equal(t, "http://localhost/containers/0123456789ab/json", endpoint)
})
t.Run("invalid container ID is rejected", func(t *testing.T) {
_, err := buildDockerContainerEndpoint("../../version", "json", nil)
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid container id")
})
}
func TestContainerDetailsRequestsValidateContainerID(t *testing.T) {
rt := &recordingRoundTripper{
statusCode: 200,
body: `{"Config":{"Env":["SECRET=1"]}}`,
}
dm := &dockerManager{
client: &http.Client{Transport: rt},
}
_, err := dm.getContainerInfo(context.Background(), "../version")
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid container id")
assert.False(t, rt.called, "request should be rejected before dispatching to Docker API")
}
func TestContainerDetailsRequestsUseExpectedDockerPaths(t *testing.T) {
t.Run("container info uses container json endpoint", func(t *testing.T) {
rt := &recordingRoundTripper{
statusCode: 200,
body: `{"Config":{"Env":["SECRET=1"]},"Name":"demo"}`,
}
dm := &dockerManager{
client: &http.Client{Transport: rt},
}
body, err := dm.getContainerInfo(context.Background(), "0123456789ab")
require.NoError(t, err)
assert.True(t, rt.called)
assert.Equal(t, "/containers/0123456789ab/json", rt.lastPath)
assert.NotContains(t, string(body), "SECRET=1", "sensitive env vars should be removed")
})
t.Run("container logs uses expected endpoint and query params", func(t *testing.T) {
rt := &recordingRoundTripper{
statusCode: 200,
body: "line1\nline2\n",
}
dm := &dockerManager{
client: &http.Client{Transport: rt},
}
logs, err := dm.getLogs(context.Background(), "abcdef123456")
require.NoError(t, err)
assert.True(t, rt.called)
assert.Equal(t, "/containers/abcdef123456/logs", rt.lastPath)
assert.Equal(t, "1", rt.lastQuery["stdout"])
assert.Equal(t, "1", rt.lastQuery["stderr"])
assert.Equal(t, "200", rt.lastQuery["tail"])
assert.Equal(t, "line1\nline2\n", logs)
})
}
func TestValidateCpuPercentage(t *testing.T) {
tests := []struct {
name string
@@ -172,17 +269,16 @@ func TestValidateCpuPercentage(t *testing.T) {
}
func TestUpdateContainerStatsValues(t *testing.T) {
stats := &container.Stats{
Name: "test-container",
Cpu: 0.0,
Mem: 0.0,
NetworkSent: 0.0,
NetworkRecv: 0.0,
PrevReadTime: time.Time{},
}
var stats = container.Stats{}
stats.Name = "test-container"
stats.Cpu = 0.0
stats.Mem = 0.0
stats.NetworkSent = 0.0
stats.NetworkRecv = 0.0
stats.PrevReadTime = time.Time{}
testTime := time.Now()
updateContainerStatsValues(stats, 75.5, 1048576, 524288, 262144, testTime)
updateContainerStatsValues(&stats, 75.5, 1048576, 524288, 262144, testTime)
// Check CPU percentage (should be rounded to 2 decimals)
assert.Equal(t, 75.5, stats.Cpu)
@@ -349,12 +445,11 @@ func TestCalculateNetworkStats(t *testing.T) {
},
}
stats := &container.Stats{
PrevReadTime: time.Now().Add(-time.Second), // 1 second ago
}
var stats = container.Stats{}
stats.PrevReadTime = time.Now().Add(-time.Second) // 1 second ago
// Test with initialized container
sent, recv := dm.calculateNetworkStats(ctr, apiStats, stats, true, "test-container", cacheTimeMs)
sent, recv := dm.calculateNetworkStats(ctr, apiStats, &stats, true, "test-container", cacheTimeMs)
// Should return calculated byte rates per second
assert.GreaterOrEqual(t, sent, uint64(0))
@@ -363,7 +458,7 @@ func TestCalculateNetworkStats(t *testing.T) {
// Cycle and test one-direction change (Tx only) is reflected independently
dm.cycleNetworkDeltasForCacheTime(cacheTimeMs)
apiStats.Networks["eth0"] = container.NetworkStats{TxBytes: 2500, RxBytes: 1800} // +500 Tx only
sent, recv = dm.calculateNetworkStats(ctr, apiStats, stats, true, "test-container", cacheTimeMs)
sent, recv = dm.calculateNetworkStats(ctr, apiStats, &stats, true, "test-container", cacheTimeMs)
assert.Greater(t, sent, uint64(0))
assert.Equal(t, uint64(0), recv)
}
@@ -629,7 +724,8 @@ func TestMemoryStatsEdgeCases(t *testing.T) {
}
func TestContainerStatsInitialization(t *testing.T) {
stats := &container.Stats{Name: "test-container"}
var stats = container.Stats{}
stats.Name = "test-container"
// Verify initial values
assert.Equal(t, "test-container", stats.Name)
@@ -641,7 +737,7 @@ func TestContainerStatsInitialization(t *testing.T) {
// Test updating values
testTime := time.Now()
updateContainerStatsValues(stats, 45.67, 2097152, 1048576, 524288, testTime)
updateContainerStatsValues(&stats, 45.67, 2097152, 1048576, 524288, testTime)
assert.Equal(t, 45.67, stats.Cpu)
assert.Equal(t, 2.0, stats.Mem)
@@ -719,12 +815,11 @@ func TestNetworkStatsCalculationWithRealData(t *testing.T) {
// Use exact timing for deterministic results
exactly1000msAgo := time.Now().Add(-1000 * time.Millisecond)
stats := &container.Stats{
PrevReadTime: exactly1000msAgo,
}
var stats = container.Stats{}
stats.PrevReadTime = exactly1000msAgo
// First call sets baseline
sent1, recv1 := dm.calculateNetworkStats(ctr, apiStats1, stats, true, "test", cacheTimeMs)
sent1, recv1 := dm.calculateNetworkStats(ctr, apiStats1, &stats, true, "test", cacheTimeMs)
assert.Equal(t, uint64(0), sent1)
assert.Equal(t, uint64(0), recv1)
@@ -739,7 +834,7 @@ func TestNetworkStatsCalculationWithRealData(t *testing.T) {
expectedRecvRate := deltaRecv * 1000 / expectedElapsedMs // Should be exactly 1000000
// Second call with changed data
sent2, recv2 := dm.calculateNetworkStats(ctr, apiStats2, stats, true, "test", cacheTimeMs)
sent2, recv2 := dm.calculateNetworkStats(ctr, apiStats2, &stats, true, "test", cacheTimeMs)
// Should be exactly the expected rates (no tolerance needed)
assert.Equal(t, expectedSentRate, sent2)
@@ -750,9 +845,9 @@ func TestNetworkStatsCalculationWithRealData(t *testing.T) {
stats.PrevReadTime = time.Now().Add(-1 * time.Millisecond)
apiStats1.Networks["eth0"] = container.NetworkStats{TxBytes: 0, RxBytes: 0}
apiStats2.Networks["eth0"] = container.NetworkStats{TxBytes: 10 * 1024 * 1024 * 1024, RxBytes: 0} // 10GB delta
_, _ = dm.calculateNetworkStats(ctr, apiStats1, stats, true, "test", cacheTimeMs) // baseline
_, _ = dm.calculateNetworkStats(ctr, apiStats1, &stats, true, "test", cacheTimeMs) // baseline
dm.cycleNetworkDeltasForCacheTime(cacheTimeMs)
sent3, recv3 := dm.calculateNetworkStats(ctr, apiStats2, stats, true, "test", cacheTimeMs)
sent3, recv3 := dm.calculateNetworkStats(ctr, apiStats2, &stats, true, "test", cacheTimeMs)
assert.Equal(t, uint64(0), sent3)
assert.Equal(t, uint64(0), recv3)
}
@@ -786,8 +881,9 @@ func TestContainerStatsEndToEndWithRealData(t *testing.T) {
}
// Initialize container stats
stats := &container.Stats{Name: "jellyfin"}
dm.containerStatsMap[ctr.IdShort] = stats
var stats = container.Stats{}
stats.Name = "jellyfin"
dm.containerStatsMap[ctr.IdShort] = &stats
// Test individual components that we can verify
usedMemory, memErr := calculateMemoryUsage(&apiStats, false)
@@ -816,6 +912,42 @@ func TestContainerStatsEndToEndWithRealData(t *testing.T) {
assert.Equal(t, testTime, testStats.PrevReadTime)
}
func TestGetLogsDetectsMultiplexedWithoutContentType(t *testing.T) {
// Docker multiplexed frame: [stream][0,0,0][len(4 bytes BE)][payload]
frame := []byte{
0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x05,
'H', 'e', 'l', 'l', 'o',
}
rt := &recordingRoundTripper{
statusCode: 200,
body: string(frame),
// Intentionally omit content type to simulate Podman behavior.
}
dm := &dockerManager{
client: &http.Client{Transport: rt},
}
logs, err := dm.getLogs(context.Background(), "abcdef123456")
require.NoError(t, err)
assert.Equal(t, "Hello", logs)
}
func TestGetLogsDoesNotMisclassifyRawStreamAsMultiplexed(t *testing.T) {
// Starts with 0x01, but doesn't match Docker frame signature (reserved bytes aren't all zero).
raw := []byte{0x01, 0x02, 0x03, 0x04, 'r', 'a', 'w'}
rt := &recordingRoundTripper{
statusCode: 200,
body: string(raw),
}
dm := &dockerManager{
client: &http.Client{Transport: rt},
}
logs, err := dm.getLogs(context.Background(), "abcdef123456")
require.NoError(t, err)
assert.Equal(t, raw, []byte(logs))
}
func TestEdgeCasesWithRealData(t *testing.T) {
// Test with minimal container stats
minimalStats := &container.ApiStats{

View File

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

View File

@@ -1,5 +1,4 @@
//go:build testing
// +build testing
package agent

41
agent/fs_utils.go Normal file
View 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
}

View File

@@ -708,8 +708,16 @@ func (gm *GPUManager) resolveLegacyCollectorPriority(caps gpuCapabilities) []col
priorities = append(priorities, collectorSourceIntelGpuTop)
}
// Apple collectors are currently opt-in only.
// Apple collectors are currently opt-in only for testing.
// Enable them with GPU_COLLECTOR=macmon or GPU_COLLECTOR=powermetrics.
// TODO: uncomment below when Apple collectors are confirmed to be working.
//
// Prefer macmon on macOS (no sudo). Fall back to powermetrics if present.
// if caps.hasMacmon {
// priorities = append(priorities, collectorSourceMacmon)
// } else if caps.hasPowermetrics {
// priorities = append(priorities, collectorSourcePowermetrics)
// }
// Keep nvtop as a last resort only when no vendor collector exists.
if len(priorities) == 0 && caps.hasNvtop {

View File

@@ -103,10 +103,8 @@ func (gm *GPUManager) updateAmdGpuData(cardPath string) bool {
// Read all sysfs values first (no lock needed - these can be slow)
usage, usageErr := readSysfsFloat(filepath.Join(devicePath, "gpu_busy_percent"))
vramUsed, memUsedErr := readSysfsFloat(filepath.Join(devicePath, "mem_info_vram_used"))
vramTotal, _ := readSysfsFloat(filepath.Join(devicePath, "mem_info_vram_total"))
memUsed := vramUsed
memTotal := vramTotal
memUsed, memUsedErr := readSysfsFloat(filepath.Join(devicePath, "mem_info_vram_used"))
memTotal, _ := readSysfsFloat(filepath.Join(devicePath, "mem_info_vram_total"))
// if gtt is present, add it to the memory used and total (https://github.com/henrygd/beszel/issues/1569#issuecomment-3837640484)
if gttUsed, err := readSysfsFloat(filepath.Join(devicePath, "mem_info_gtt_used")); err == nil && gttUsed > 0 {
if gttTotal, err := readSysfsFloat(filepath.Join(devicePath, "mem_info_gtt_total")); err == nil {
@@ -243,7 +241,10 @@ func getCachedAmdgpuName(deviceID, revisionID string) (name string, found bool,
// normalizeAmdgpuName trims standard suffixes from AMDGPU product names.
func normalizeAmdgpuName(name string) string {
return strings.TrimSuffix(strings.TrimSpace(name), " Graphics")
for _, suffix := range []string{" Graphics", " Series"} {
name = strings.TrimSuffix(name, suffix)
}
return name
}
// cacheAmdgpuName stores a resolved AMDGPU name in the lookup cache.

View File

@@ -1,5 +1,4 @@
//go:build testing
// +build testing
package agent

View File

@@ -1,5 +1,4 @@
//go:build testing
// +build testing
package agent

View File

@@ -1,5 +1,4 @@
//go:build testing
// +build testing
package health
@@ -37,7 +36,6 @@ func TestHealth(t *testing.T) {
})
// This test uses synctest to simulate time passing.
// NOTE: This test requires GOEXPERIMENT=synctest to run.
t.Run("check with simulated time", func(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
// Update the file to set the initial timestamp.

225
agent/mdraid_linux.go Normal file
View 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
View 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
View 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
}

177
agent/pve.go Normal file
View File

@@ -0,0 +1,177 @@
package agent
import (
"context"
"crypto/tls"
"errors"
"log/slog"
"net/http"
"time"
"github.com/henrygd/beszel/internal/entities/container"
"github.com/luthermonson/go-proxmox"
)
type pveManager struct {
client *proxmox.Client // Client to query PVE API
nodeName string // Cluster node name
cpuCount int // CPU count on node
nodeStatsMap map[string]*container.PveNodeStats // Keeps track of pve node stats
lastInitTry time.Time // Last time node initialization was attempted
}
// newPVEManager creates a new PVE manager - may return nil if required environment variables
// are not set or if there is an error connecting to the API
func newPVEManager() *pveManager {
url, exists := GetEnv("PROXMOX_URL")
if !exists {
url = "https://localhost:8006/api2/json"
}
const nodeEnvVar = "PROXMOX_NODE"
const tokenIDEnvVar = "PROXMOX_TOKENID"
const secretEnvVar = "PROXMOX_SECRET"
nodeName, nodeNameExists := GetEnv(nodeEnvVar)
tokenID, tokenIDExists := GetEnv(tokenIDEnvVar)
secret, secretExists := GetEnv(secretEnvVar)
if !nodeNameExists || !tokenIDExists || !secretExists {
slog.Debug("Proxmox env vars unset", nodeEnvVar, nodeNameExists, tokenIDEnvVar, tokenIDExists, secretEnvVar, secretExists)
return nil
}
// PROXMOX_INSECURE_TLS defaults to true; set to "false" to enable TLS verification
insecureTLS := true
if val, exists := GetEnv("PROXMOX_INSECURE_TLS"); exists {
insecureTLS = val != "false"
}
httpClient := http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: insecureTLS,
},
},
}
client := proxmox.NewClient(url,
proxmox.WithHTTPClient(&httpClient),
proxmox.WithAPIToken(tokenID, secret),
)
pveManager := pveManager{
client: client,
nodeName: nodeName,
nodeStatsMap: make(map[string]*container.PveNodeStats),
}
return &pveManager
}
// ensureInitialized checks if the PVE manager is initialized and attempts to initialize it if not.
// It returns an error if initialization fails or if a retry is pending.
func (pm *pveManager) ensureInitialized(ctx context.Context) error {
if pm.client == nil {
return errors.New("PVE client not configured")
}
if pm.cpuCount > 0 {
return nil
}
if time.Since(pm.lastInitTry) < 30*time.Second {
return errors.New("PVE initialization retry pending")
}
pm.lastInitTry = time.Now()
node, err := pm.client.Node(ctx, pm.nodeName)
if err != nil {
return err
}
if node.CPUInfo.CPUs <= 0 {
return errors.New("node returned zero CPUs")
}
pm.cpuCount = node.CPUInfo.CPUs
return nil
}
// getPVEStats returns stats for all running VMs/LXCs
func (pm *pveManager) getPVEStats() ([]*container.PveNodeStats, error) {
if err := pm.ensureInitialized(context.Background()); err != nil {
slog.Warn("Proxmox API unavailable", "err", err)
return nil, err
}
cluster, err := pm.client.Cluster(context.Background())
if err != nil {
slog.Error("Error getting cluster", "err", err)
return nil, err
}
resources, err := cluster.Resources(context.Background(), "vm")
if err != nil {
slog.Error("Error getting resources", "err", err, "resources", resources)
return nil, err
}
containersLength := len(resources)
resourceIds := make(map[string]struct{}, containersLength)
// only include running vms and lxcs on selected node
for _, resource := range resources {
if resource.Node == pm.nodeName && resource.Status == "running" {
resourceIds[resource.ID] = struct{}{}
}
}
// remove invalid container stats
for id := range pm.nodeStatsMap {
if _, exists := resourceIds[id]; !exists {
delete(pm.nodeStatsMap, id)
}
}
// populate stats
stats := make([]*container.PveNodeStats, 0, len(resourceIds))
for _, resource := range resources {
if _, exists := resourceIds[resource.ID]; !exists {
continue
}
resourceStats, initialized := pm.nodeStatsMap[resource.ID]
if !initialized {
resourceStats = &container.PveNodeStats{}
pm.nodeStatsMap[resource.ID] = resourceStats
}
resourceStats.Name = resource.Name
resourceStats.Id = resource.ID
resourceStats.Type = resource.Type
resourceStats.MaxCPU = resource.MaxCPU
resourceStats.MaxMem = resource.MaxMem
resourceStats.Uptime = resource.Uptime
resourceStats.DiskRead = resource.DiskRead
resourceStats.DiskWrite = resource.DiskWrite
resourceStats.Disk = resource.MaxDisk
// prevent first run from sending all prev sent/recv bytes
total_sent := resource.NetOut
total_recv := resource.NetIn
var sent_delta, recv_delta float64
if initialized {
secondsElapsed := time.Since(resourceStats.PrevReadTime).Seconds()
if secondsElapsed > 0 {
sent_delta = float64(total_sent-resourceStats.PrevNet.Sent) / secondsElapsed
recv_delta = float64(total_recv-resourceStats.PrevNet.Recv) / secondsElapsed
}
}
resourceStats.PrevNet.Sent = total_sent
resourceStats.PrevNet.Recv = total_recv
resourceStats.PrevReadTime = time.Now()
// Update final stats values
resourceStats.Cpu = twoDecimals(100.0 * resource.CPU * float64(resource.MaxCPU) / float64(pm.cpuCount))
resourceStats.Mem = bytesToMegabytes(float64(resource.Mem))
resourceStats.Bandwidth = [2]uint64{uint64(sent_delta), uint64(recv_delta)}
resourceStats.NetOut = total_sent
resourceStats.NetIn = total_recv
stats = append(stats, resourceStats)
}
return stats, nil
}

92
agent/pve_test.go Normal file
View File

@@ -0,0 +1,92 @@
package agent
import (
"errors"
"fmt"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
"github.com/henrygd/beszel/internal/entities/container"
"github.com/luthermonson/go-proxmox"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewPVEManagerDoesNotConnectAtStartup(t *testing.T) {
t.Setenv("BESZEL_AGENT_PROXMOX_URL", "https://127.0.0.1:1/api2/json")
t.Setenv("BESZEL_AGENT_PROXMOX_NODE", "pve")
t.Setenv("BESZEL_AGENT_PROXMOX_TOKENID", "root@pam!test")
t.Setenv("BESZEL_AGENT_PROXMOX_SECRET", "secret")
pm := newPVEManager()
require.NotNil(t, pm)
assert.Zero(t, pm.cpuCount)
}
func TestPVEManagerRetriesInitialization(t *testing.T) {
var nodeRequests atomic.Int32
var clusterRequests atomic.Int32
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api2/json/nodes/pve/status":
nodeRequests.Add(1)
fmt.Fprint(w, `{"data":{"cpuinfo":{"cpus":8}}}`)
case "/api2/json/cluster/status":
fmt.Fprint(w, `{"data":[{"type":"cluster","name":"test-cluster","id":"test-cluster","version":1,"quorate":1}]}`)
case "/api2/json/cluster/resources":
clusterRequests.Add(1)
fmt.Fprint(w, `{"data":[{"id":"qemu/101","type":"qemu","node":"pve","status":"running","name":"vm-101","cpu":0.5,"maxcpu":4,"maxmem":4096,"mem":2048,"netin":1024,"netout":2048,"diskread":10,"diskwrite":20,"maxdisk":8192,"uptime":60}]}`)
default:
t.Fatalf("unexpected path: %s", r.URL.Path)
}
}))
defer server.Close()
pm := &pveManager{
client: proxmox.NewClient(server.URL+"/api2/json",
proxmox.WithHTTPClient(&http.Client{
Transport: &failOnceRoundTripper{
base: server.Client().Transport,
},
}),
proxmox.WithAPIToken("root@pam!test", "secret"),
),
nodeName: "pve",
nodeStatsMap: make(map[string]*container.PveNodeStats),
}
stats, err := pm.getPVEStats()
require.Error(t, err)
assert.Nil(t, stats)
assert.Zero(t, pm.cpuCount)
pm.lastInitTry = time.Now().Add(-31 * time.Second)
stats, err = pm.getPVEStats()
require.NoError(t, err)
require.Len(t, stats, 1)
assert.Equal(t, int32(1), nodeRequests.Load())
assert.Equal(t, int32(1), clusterRequests.Load())
assert.Equal(t, 8, pm.cpuCount)
assert.Equal(t, "qemu/101", stats[0].Id)
assert.Equal(t, 25.0, stats[0].Cpu)
assert.Equal(t, uint64(1024), stats[0].NetIn)
assert.Equal(t, uint64(2048), stats[0].NetOut)
}
type failOnceRoundTripper struct {
base http.RoundTripper
failed atomic.Bool
}
func (rt *failOnceRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
if req.URL.Path == "/api2/json/nodes/pve/status" && !rt.failed.Swap(true) {
return nil, errors.New("dial tcp 127.0.0.1:8006: connect: connection refused")
}
return rt.base.RoundTrip(req)
}
var _ http.RoundTripper = (*failOnceRoundTripper)(nil)

View File

@@ -1,5 +1,4 @@
//go:build testing
// +build testing
package agent

View File

@@ -1,5 +1,4 @@
//go:build testing
// +build testing
package agent
@@ -560,6 +559,10 @@ func TestWriteToSessionEncoding(t *testing.T) {
// Helper function to create test data for encoding tests
func createTestCombinedData() *system.CombinedData {
var stats = container.Stats{}
stats.Name = "test-container"
stats.Cpu = 10.5
stats.Mem = 1073741824 // 1GB
return &system.CombinedData{
Stats: system.Stats{
Cpu: 25.5,
@@ -578,11 +581,7 @@ func createTestCombinedData() *system.CombinedData {
AgentVersion: "0.12.0",
},
Containers: []*container.Stats{
{
Name: "test-container",
Cpu: 10.5,
Mem: 1073741824, // 1GB
},
&stats,
},
}
}

View File

@@ -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 {
@@ -476,7 +489,7 @@ func (sm *SmartManager) CollectSmart(deviceInfo *DeviceInfo) error {
output, err := cmd.CombinedOutput()
// Check if device is in standby (exit status 2)
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 2 {
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok && exitErr.ExitCode() == 2 {
if hasExistingData {
// Device is in standby and we have cached data, keep using cache
return 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
}

View File

@@ -1,5 +1,4 @@
//go:build testing
// +build testing
package agent

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 := 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
View 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
View 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")
}

View File

@@ -0,0 +1,9 @@
//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.3"
Version = "0.18.4"
// AppName is the name of the application.
AppName = "beszel"
)

37
go.mod
View File

@@ -1,6 +1,6 @@
module github.com/henrygd/beszel
go 1.25.7
go 1.26.0
require (
github.com/blang/semver v3.5.1+incompatible
@@ -10,26 +10,30 @@ require (
github.com/fxamacker/cbor/v2 v2.9.0
github.com/gliderlabs/ssh v0.3.8
github.com/google/uuid v1.6.0
github.com/luthermonson/go-proxmox v0.4.0
github.com/lxzan/gws v1.8.9
github.com/nicholas-fedor/shoutrrr v0.13.1
github.com/pocketbase/dbx v1.11.0
github.com/pocketbase/pocketbase v0.36.2
github.com/nicholas-fedor/shoutrrr v0.13.2
github.com/pocketbase/dbx v1.12.0
github.com/pocketbase/pocketbase v0.36.4
github.com/shirou/gopsutil/v4 v4.26.1
github.com/spf13/cast v1.10.0
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.10
github.com/stretchr/testify v1.11.1
golang.org/x/crypto v0.47.0
golang.org/x/exp v0.0.0-20260112195511-716be5621a96
golang.org/x/sys v0.40.0
golang.org/x/crypto v0.48.0
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa
golang.org/x/sys v0.41.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/buger/goterm v1.0.4 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/disintegration/imaging v1.6.2 // indirect
github.com/diskfs/go-diskfs v1.7.0 // indirect
github.com/djherbis/times v1.6.0 // indirect
github.com/dolthub/maphash v0.1.0 // indirect
github.com/domodwyer/mailyak/v3 v3.6.2 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
@@ -41,9 +45,12 @@ require (
github.com/go-sql-driver/mysql v1.9.1 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/gorilla/websocket v1.4.2 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/klauspost/compress v1.18.3 // indirect
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect
github.com/jinzhu/copier v0.3.4 // indirect
github.com/klauspost/compress v1.18.4 // indirect
github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 // indirect
github.com/magefile/mage v1.14.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
@@ -54,15 +61,15 @@ require (
github.com/tklauser/numcpus v0.11.0 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
golang.org/x/image v0.35.0 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/oauth2 v0.34.0 // indirect
golang.org/x/image v0.36.0 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/oauth2 v0.35.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/term v0.39.0 // indirect
golang.org/x/text v0.33.0 // indirect
golang.org/x/term v0.40.0 // indirect
golang.org/x/text v0.34.0 // indirect
howett.net/plist v1.0.1 // indirect
modernc.org/libc v1.67.6 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.44.3 // indirect
modernc.org/sqlite v1.45.0 // indirect
)

110
go.sum
View File

@@ -2,6 +2,8 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/anchore/go-lzo v0.1.0 h1:NgAacnzqPeGH49Ky19QKLBZEuFRqtTG9cdaucc3Vncs=
github.com/anchore/go-lzo v0.1.0/go.mod h1:3kLx0bve2oN1iDwgM1U5zGku1Tfbdb0No5qp1eL1fIk=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=
@@ -9,6 +11,8 @@ github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3d
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ=
github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
github.com/buger/goterm v1.0.4 h1:Z9YvGmOih81P0FbVtEYTFF6YsSgxSUKEhf/f9bTMXbY=
github.com/buger/goterm v1.0.4/go.mod h1:HiFWV3xnkolgrBV3mY8m0X0Pumt4zg4QhbdOzQtB8tE=
github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA=
github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
@@ -17,8 +21,12 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/diskfs/go-diskfs v1.7.0 h1:vonWmt5CMowXwUc79jWyGrf2DIMeoOjkLlMnQYGVOs8=
github.com/diskfs/go-diskfs v1.7.0/go.mod h1:LhQyXqOugWFRahYUSw47NyZJPezFzB9UELwhpszLP/k=
github.com/distatus/battery v0.11.0 h1:KJk89gz90Iq/wJtbjjM9yUzBXV+ASV/EG2WOOL7N8lc=
github.com/distatus/battery v0.11.0/go.mod h1:KmVkE8A8hpIX4T78QRdMktYpEp35QfOL8A8dwZBxq2k=
github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=
github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0=
github.com/dolthub/maphash v0.1.0 h1:bsQ7JsF4FkkWyrP3oCnFJgrCUAFbFf3kOl4L/QxPDyQ=
github.com/dolthub/maphash v0.1.0/go.mod h1:gkg4Ch4CdCDu5h6PMriVLawB7koZ+5ijb9puGMV50a4=
github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8=
@@ -27,6 +35,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/elliotwutingfeng/asciiset v0.0.0-20230602022725-51bbb787efab h1:h1UgjJdAAhj+uPL68n7XASS6bU+07ZX1WJvVS2eyoeY=
github.com/elliotwutingfeng/asciiset v0.0.0-20230602022725-51bbb787efab/go.mod h1:GLo/8fDswSAniFG+BFIaiSPcK610jyzgEhWYPQwuQdw=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
@@ -51,6 +61,8 @@ github.com/go-sql-driver/mysql v1.9.1 h1:FrjNGn/BsJQjVRuSa8CBrM5BWA9BWoXXat3KrtS
github.com/go-sql-driver/mysql v1.9.1/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
@@ -62,6 +74,12 @@ github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/v
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/h2non/gock v1.2.0 h1:K6ol8rfrRkUOefooBC8elXoaNGYkpp7y2qcxGG6BzUE=
github.com/h2non/gock v1.2.0/go.mod h1:tNhoxHYW2W42cYkYb1WqzdbYIieALC99kpYr7rH/BQk=
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw=
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
@@ -69,35 +87,45 @@ github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLf
github.com/jarcoal/httpmock v1.4.1 h1:0Ju+VCFuARfFlhVXFc2HxlcQkfB+Xq12/EotHko+x2A=
github.com/jarcoal/httpmock v1.4.1/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/jinzhu/copier v0.3.4 h1:mfU6jI9PtCeUjkjQ322dlff9ELjGDu975C2p/nrubVI=
github.com/jinzhu/copier v0.3.4/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg=
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 h1:PwQumkgq4/acIiZhtifTV5OUqqiP82UAl0h87xj/l9k=
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 h1:PTw+yKnXcOFCR6+8hHTyWBeQ/P4Nb7dd4/0ohEcWQuM=
github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/luthermonson/go-proxmox v0.4.0 h1:LKXpG9d64zTaQF79wV0kfOnnSwIcdG39m7sc4ga+XZs=
github.com/luthermonson/go-proxmox v0.4.0/go.mod h1:U6dAkJ+iiwaeb1g/LMWpWuWN4nmvWeXhmoMuYJMumS4=
github.com/lxzan/gws v1.8.9 h1:VU3SGUeWlQrEwfUSfokcZep8mdg/BrUF+y73YYshdBM=
github.com/lxzan/gws v1.8.9/go.mod h1:d9yHaR1eDTBHagQC6KY7ycUOaz5KWeqQtP3xu7aMK8Y=
github.com/magefile/mage v1.14.0 h1:6QDX3g6z1YvJ4olPhT1wksUcSa/V0a1B+pJb73fBjyo=
github.com/magefile/mage v1.14.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nicholas-fedor/shoutrrr v0.13.1 h1:llEoHNbnMM4GfQ9+2Ns3n6ssvNfi3NPWluM0AQiicoY=
github.com/nicholas-fedor/shoutrrr v0.13.1/go.mod h1:kU4cFJpEAtTzl3iV0l+XUXmM90OlC5T01b7roM4/pYM=
github.com/onsi/ginkgo/v2 v2.27.3 h1:ICsZJ8JoYafeXFFlFAG75a7CxMsJHwgKwtO+82SE9L8=
github.com/onsi/ginkgo/v2 v2.27.3/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
github.com/onsi/gomega v1.38.3 h1:eTX+W6dobAYfFeGC2PV6RwXRu/MyT+cQguijutvkpSM=
github.com/onsi/gomega v1.38.3/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4=
github.com/nicholas-fedor/shoutrrr v0.13.2 h1:hfsYBIqSFYGg92pZP5CXk/g7/OJIkLYmiUnRl+AD1IA=
github.com/nicholas-fedor/shoutrrr v0.13.2/go.mod h1:ZqzV3gY/Wj6AvWs1etlO7+yKbh4iptSbeL8avBpMQbA=
github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI=
github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE=
github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28=
github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg=
github.com/pierrec/lz4/v4 v4.1.17 h1:kV4Ip+/hUBC+8T6+2EgburRtkE9ef4nbY3f4dFhGjMc=
github.com/pierrec/lz4/v4 v4.1.17/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pkg/xattr v0.4.9 h1:5883YPCtkSd8LFbs13nXplj9g9tlrwoJRjgpgMu1/fE=
github.com/pkg/xattr v0.4.9/go.mod h1:di8WF84zAKk8jzR1UBTEWh9AUlIZZ7M/JNt8e9B6ktU=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pocketbase/dbx v1.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU=
github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
github.com/pocketbase/pocketbase v0.36.2 h1:mzrxnvXKc3yxKlvZdbwoYXkH8kfIETteD0hWdgj0VI4=
github.com/pocketbase/pocketbase v0.36.2/go.mod h1:71vSF8whUDzC8mcLFE10+Qatf9JQdeOGIRWawOuLLKM=
github.com/pocketbase/dbx v1.12.0 h1:/oLErM+A0b4xI0PWTGPqSDVjzix48PqI/bng2l0PzoA=
github.com/pocketbase/dbx v1.12.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
github.com/pocketbase/pocketbase v0.36.4 h1:zTjRZbp2WfTOJJfb+pFRWa200UaQwxZYt8RzkFMlAZ4=
github.com/pocketbase/pocketbase v0.36.4/go.mod h1:9CiezhRudd9FZGa5xZa53QZBTNxc5vvw/FGG+diAECI=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
@@ -107,6 +135,8 @@ github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/f
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/shirou/gopsutil/v4 v4.26.1 h1:TOkEyriIXk2HX9d4isZJtbjXbEjf5qyKPAzbzY0JWSo=
github.com/shirou/gopsutil/v4 v4.26.1/go.mod h1:medLI9/UNAb0dOI9Q3/7yWSqKkj00u+1tgY8nvv41pc=
github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af h1:Sp5TG9f7K39yfB+If0vjp97vuT74F72r8hfRpP8jLU0=
github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
@@ -122,6 +152,8 @@ github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYI
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
github.com/ulikunitz/xz v0.5.11 h1:kpFauv27b6ynzBNT/Xy+1k+fK4WswhN/6PN5WhFAGw8=
github.com/ulikunitz/xz v0.5.11/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
@@ -129,41 +161,43 @@ github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQ
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.35.0 h1:LKjiHdgMtO8z7Fh18nGY6KDcoEtVfsgLDPeLyguqb7I=
golang.org/x/image v0.35.0/go.mod h1:MwPLTVgvxSASsxdLzKrl8BRFuyqMyGhLwmC+TO1Sybk=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210331175145-43e1dd70ce54/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
@@ -195,8 +229,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.44.3 h1:+39JvV/HWMcYslAwRxHb8067w+2zowvFOUrOWIy9PjY=
modernc.org/sqlite v1.44.3/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
modernc.org/sqlite v1.45.0 h1:r51cSGzKpbptxnby+EIIz5fop4VuE4qFoVEjNvWoObs=
modernc.org/sqlite v1.45.0/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

View File

@@ -1,5 +1,4 @@
//go:build testing
// +build testing
package alerts_test

View File

@@ -1,5 +1,4 @@
//go:build testing
// +build testing
package alerts_test

View File

@@ -1,5 +1,4 @@
//go:build testing
// +build testing
package alerts_test

View File

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

View File

@@ -1,5 +1,4 @@
//go:build testing
// +build testing
package alerts_test

View File

@@ -1,5 +1,4 @@
//go:build testing
// +build testing
package alerts

View File

@@ -127,22 +127,43 @@ var DockerHealthStrings = map[string]DockerHealth{
"unhealthy": DockerHealthUnhealthy,
}
// Docker container stats
type Stats struct {
Name string `json:"n" cbor:"0,keyasint"`
Cpu float64 `json:"c" cbor:"1,keyasint"`
Mem float64 `json:"m" cbor:"2,keyasint"`
NetworkSent float64 `json:"ns,omitzero" cbor:"3,keyasint,omitzero"` // deprecated 0.18.3 (MB) - keep field for old agents/records
NetworkRecv float64 `json:"nr,omitzero" cbor:"4,keyasint,omitzero"` // deprecated 0.18.3 (MB) - keep field for old agents/records
Bandwidth [2]uint64 `json:"b,omitzero" cbor:"9,keyasint,omitzero"` // [sent bytes, recv bytes]
Health DockerHealth `json:"-" cbor:"5,keyasint"`
Status string `json:"-" cbor:"6,keyasint"`
Id string `json:"-" cbor:"7,keyasint"`
Image string `json:"-" cbor:"8,keyasint"`
// PrevCpu [2]uint64 `json:"-"`
CpuSystem uint64 `json:"-"`
CpuContainer uint64 `json:"-"`
// SharedCoreMetrics contains fields that are common to both container Stats and PveNodeStats
type SharedCoreMetrics struct {
Name string `json:"n" cbor:"0,keyasint"`
Cpu float64 `json:"c" cbor:"1,keyasint"`
Mem float64 `json:"m" cbor:"2,keyasint"`
NetworkSent float64 `json:"ns,omitzero" cbor:"3,keyasint,omitzero"` // deprecated 0.18.3 (MB) - keep field for old agents/records
NetworkRecv float64 `json:"nr,omitzero" cbor:"4,keyasint,omitzero"` // deprecated 0.18.3 (MB) - keep field for old agents/records
Id string `json:"-" cbor:"7,keyasint"`
Bandwidth [2]uint64 `json:"b,omitzero" cbor:"9,keyasint,omitzero"` // [sent bytes, recv bytes]
PrevNet prevNetStats `json:"-"`
PrevReadTime time.Time `json:"-"`
}
// Stats holds data specific to docker containers for the containers table
type Stats struct {
SharedCoreMetrics // used to populate stats field in container_stats
// fields used for containers table
Health DockerHealth `json:"-" cbor:"5,keyasint"`
Status string `json:"-" cbor:"6,keyasint"`
Image string `json:"-" cbor:"8,keyasint"`
}
// PveNodeStats holds data specific to PVE nodes for the pve_vms table
type PveNodeStats struct {
SharedCoreMetrics // used to populate stats field in pve_stats
// fields used for pve_vms table
MaxCPU uint64 `json:"-" cbor:"10,keyasint,omitzero"` // PVE: max vCPU count
MaxMem uint64 `json:"-" cbor:"11,keyasint,omitzero"` // PVE: max memory bytes
Uptime uint64 `json:"-" cbor:"12,keyasint,omitzero"` // PVE: uptime in seconds
Type string `json:"-" cbor:"13,keyasint,omitzero"` // PVE: resource type (e.g. "qemu" or "lxc")
DiskRead uint64 `json:"-" cbor:"14,keyasint,omitzero"` // PVE: cumulative disk read bytes
DiskWrite uint64 `json:"-" cbor:"15,keyasint,omitzero"` // PVE: cumulative disk write bytes
Disk uint64 `json:"-" cbor:"16,keyasint,omitzero"` // PVE: allocated disk size in bytes
NetOut uint64 `json:"-" cbor:"17,keyasint,omitzero"` // PVE: cumulative bytes sent by VM
NetIn uint64 `json:"-" cbor:"18,keyasint,omitzero"` // PVE: cumulative bytes received by VM
}

View File

@@ -170,9 +170,10 @@ type Details struct {
// Final data structure to return to the hub
type CombinedData struct {
Stats Stats `json:"stats" cbor:"0,keyasint"`
Info Info `json:"info" cbor:"1,keyasint"`
Containers []*container.Stats `json:"container" cbor:"2,keyasint"`
SystemdServices []*systemd.Service `json:"systemd,omitempty" cbor:"3,keyasint,omitempty"`
Details *Details `cbor:"4,keyasint,omitempty"`
Stats Stats `json:"stats" cbor:"0,keyasint"`
Info Info `json:"info" cbor:"1,keyasint"`
Containers []*container.Stats `json:"container" cbor:"2,keyasint"`
SystemdServices []*systemd.Service `json:"systemd,omitempty" cbor:"3,keyasint,omitempty"`
Details *Details `cbor:"4,keyasint,omitempty"`
PVEStats []*container.PveNodeStats `json:"pve,omitempty" cbor:"5,keyasint,omitempty"`
}

View File

@@ -34,7 +34,7 @@ func ColorPrint(color, text string) {
fmt.Println(color + text + colorReset)
}
func ColorPrintf(color, format string, args ...interface{}) {
func ColorPrintf(color, format string, args ...any) {
fmt.Printf(color+format+colorReset+"\n", args...)
}

View File

@@ -1,5 +1,4 @@
//go:build testing
// +build testing
package hub
@@ -10,6 +9,7 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
@@ -35,6 +35,26 @@ func createTestHub(t testing.TB) (*Hub, *pbtests.TestApp, error) {
return NewHub(testApp), testApp, nil
}
// cleanupTestHub stops background system goroutines before tearing down the app.
func cleanupTestHub(hub *Hub, testApp *pbtests.TestApp) {
if hub != nil {
sm := hub.GetSystemManager()
sm.RemoveAllSystems()
// Give updater goroutines a brief window to observe cancellation before DB teardown.
for range 20 {
if sm.GetSystemCount() == 0 {
break
}
runtime.Gosched()
time.Sleep(5 * time.Millisecond)
}
time.Sleep(20 * time.Millisecond)
}
if testApp != nil {
testApp.Cleanup()
}
}
// Helper function to create a test record
func createTestRecord(app core.App, collection string, data map[string]any) (*core.Record, error) {
col, err := app.FindCachedCollectionByNameOrId(collection)
@@ -64,7 +84,7 @@ func TestValidateAgentHeaders(t *testing.T) {
if err != nil {
t.Fatal(err)
}
defer testApp.Cleanup()
defer cleanupTestHub(hub, testApp)
testCases := []struct {
name string
@@ -145,7 +165,7 @@ func TestGetAllFingerprintRecordsByToken(t *testing.T) {
if err != nil {
t.Fatal(err)
}
defer testApp.Cleanup()
defer cleanupTestHub(hub, testApp)
// create test user
userRecord, err := createTestUser(testApp)
@@ -235,7 +255,7 @@ func TestSetFingerprint(t *testing.T) {
if err != nil {
t.Fatal(err)
}
defer testApp.Cleanup()
defer cleanupTestHub(hub, testApp)
// Create test user
userRecord, err := createTestUser(testApp)
@@ -315,7 +335,7 @@ func TestCreateSystemFromAgentData(t *testing.T) {
if err != nil {
t.Fatal(err)
}
defer testApp.Cleanup()
defer cleanupTestHub(hub, testApp)
// Create test user
userRecord, err := createTestUser(testApp)
@@ -425,7 +445,7 @@ func TestUniversalTokenFlow(t *testing.T) {
if err != nil {
t.Fatal(err)
}
defer testApp.Cleanup()
defer cleanupTestHub(nil, testApp)
// Create test user
userRecord, err := createTestUser(testApp)
@@ -493,7 +513,7 @@ func TestAgentConnect(t *testing.T) {
if err != nil {
t.Fatal(err)
}
defer testApp.Cleanup()
defer cleanupTestHub(hub, testApp)
// Create test user
userRecord, err := createTestUser(testApp)
@@ -652,7 +672,7 @@ func TestHandleAgentConnect(t *testing.T) {
if err != nil {
t.Fatal(err)
}
defer testApp.Cleanup()
defer cleanupTestHub(hub, testApp)
// Create test user
userRecord, err := createTestUser(testApp)
@@ -737,7 +757,7 @@ func TestAgentWebSocketIntegration(t *testing.T) {
// Create hub and test app
hub, testApp, err := createTestHub(t)
require.NoError(t, err)
defer testApp.Cleanup()
defer cleanupTestHub(hub, testApp)
// Get the hub's SSH key
hubSigner, err := hub.GetSSHKey("")
@@ -942,6 +962,8 @@ func TestAgentWebSocketIntegration(t *testing.T) {
}
}
time.Sleep(20 * time.Millisecond)
// Verify fingerprint state by re-reading the specific record
updatedFingerprintRecord, err := testApp.FindRecordById("fingerprints", fingerprintRecord.Id)
require.NoError(t, err)
@@ -976,7 +998,7 @@ func TestMultipleSystemsWithSameUniversalToken(t *testing.T) {
// Create hub and test app
hub, testApp, err := createTestHub(t)
require.NoError(t, err)
defer testApp.Cleanup()
defer cleanupTestHub(hub, testApp)
// Get the hub's SSH key
hubSigner, err := hub.GetSSHKey("")
@@ -1144,6 +1166,8 @@ func TestMultipleSystemsWithSameUniversalToken(t *testing.T) {
assert.Equal(t, systemCount, systemsAfterCount, "Total system count should remain the same")
}
time.Sleep(20 * time.Millisecond)
// Verify that a fingerprint record exists for this fingerprint
fingerprints, err := testApp.FindRecordsByFilter("fingerprints", "token = {:token} && fingerprint = {:fingerprint}", "", -1, 0, map[string]any{
"token": universalToken,
@@ -1176,7 +1200,7 @@ func TestPermanentUniversalTokenFromDB(t *testing.T) {
// Create hub and test app
hub, testApp, err := createTestHub(t)
require.NoError(t, err)
defer testApp.Cleanup()
defer cleanupTestHub(hub, testApp)
// Get the hub's SSH key
hubSigner, err := hub.GetSSHKey("")
@@ -1273,7 +1297,7 @@ verify:
func TestFindOrCreateSystemForToken(t *testing.T) {
hub, testApp, err := createTestHub(t)
require.NoError(t, err)
defer testApp.Cleanup()
defer cleanupTestHub(hub, testApp)
// Create test user
userRecord, err := createTestUser(testApp)

View File

@@ -1,5 +1,4 @@
//go:build testing
// +build testing
package config_test

View File

@@ -1,5 +1,4 @@
//go:build testing
// +build testing
package expirymap

View File

@@ -1,5 +1,4 @@
//go:build testing
// +build testing
package heartbeat_test

View File

@@ -9,6 +9,7 @@ import (
"net/url"
"os"
"path"
"regexp"
"strings"
"time"
@@ -41,6 +42,8 @@ type Hub struct {
appURL string
}
var containerIDPattern = regexp.MustCompile(`^[a-fA-F0-9]{12,64}$`)
// NewHub creates a new Hub instance with default configuration
func NewHub(app core.App) *Hub {
hub := &Hub{}
@@ -461,6 +464,9 @@ func (h *Hub) containerRequestHandler(e *core.RequestEvent, fetchFunc func(*syst
if systemID == "" || containerID == "" {
return e.JSON(http.StatusBadRequest, map[string]string{"error": "system and container parameters are required"})
}
if !containerIDPattern.MatchString(containerID) {
return e.JSON(http.StatusBadRequest, map[string]string{"error": "invalid container parameter"})
}
system, err := h.sm.GetSystem(systemID)
if err != nil {

View File

@@ -1,5 +1,4 @@
//go:build testing
// +build testing
package hub_test
@@ -545,7 +544,7 @@ func TestApiRoutesAuthentication(t *testing.T) {
{
Name: "GET /containers/logs - with auth but invalid system should fail",
Method: http.MethodGet,
URL: "/api/beszel/containers/logs?system=invalid-system&container=test-container",
URL: "/api/beszel/containers/logs?system=invalid-system&container=0123456789ab",
Headers: map[string]string{
"Authorization": userToken,
},
@@ -553,6 +552,39 @@ func TestApiRoutesAuthentication(t *testing.T) {
ExpectedContent: []string{"system not found"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /containers/logs - traversal container should fail validation",
Method: http.MethodGet,
URL: "/api/beszel/containers/logs?system=" + system.Id + "&container=..%2F..%2Fversion",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 400,
ExpectedContent: []string{"invalid container parameter"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /containers/info - traversal container should fail validation",
Method: http.MethodGet,
URL: "/api/beszel/containers/info?system=" + system.Id + "&container=../../version?x=",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 400,
ExpectedContent: []string{"invalid container parameter"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /containers/info - non-hex container should fail validation",
Method: http.MethodGet,
URL: "/api/beszel/containers/info?system=" + system.Id + "&container=container_name",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 400,
ExpectedContent: []string{"invalid container parameter"},
TestAppFactory: testAppFactory,
},
// Auth Optional Routes - Should work without authentication
{

View File

@@ -1,5 +1,4 @@
//go:build testing
// +build testing
package hub

View File

@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"hash/fnv"
"log/slog"
"math/rand"
"net"
"strings"
@@ -209,6 +210,28 @@ func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error
}
}
// add pve_vms and pve_stats records
if len(data.PVEStats) > 0 {
if data.PVEStats[0].Id != "" {
if err := createPVEVMRecords(txApp, data.PVEStats, sys.Id); err != nil {
slog.Error("Error creating PVE VM records", "err", err)
return err
}
}
pveStatsCollection, err := txApp.FindCachedCollectionByNameOrId("pve_stats")
if err != nil {
return err
}
pveStatsRecord := core.NewRecord(pveStatsCollection)
pveStatsRecord.Set("system", systemRecord.Id)
pveStatsRecord.Set("stats", data.PVEStats)
pveStatsRecord.Set("type", "1m")
if err := txApp.SaveNoValidate(pveStatsRecord); err != nil {
slog.Error("Error creating PVE stats records", "err", err)
return err
}
}
// add new systemd_stats record
if len(data.SystemdServices) > 0 {
if err := createSystemdStatsRecords(txApp, data.SystemdServices, sys.Id); err != nil {
@@ -331,8 +354,43 @@ func createContainerRecords(app core.App, data []*container.Stats, systemId stri
return err
}
// createPVEVMRecords creates or updates pve_vms records
func createPVEVMRecords(app core.App, data []*container.PveNodeStats, systemId string) error {
if len(data) == 0 {
return nil
}
// shared params for all records
params := dbx.Params{
"system": systemId,
"updated": time.Now().UTC().UnixMilli(),
}
valueStrings := make([]string, 0, len(data))
for i, vm := range data {
suffix := fmt.Sprintf("%d", i)
valueStrings = append(valueStrings, fmt.Sprintf("({:id%[1]s}, {:system}, {:name%[1]s}, {:type%[1]s}, {:cpu%[1]s}, {:mem%[1]s}, {:netout%[1]s}, {:netin%[1]s}, {:maxcpu%[1]s}, {:maxmem%[1]s}, {:uptime%[1]s}, {:diskread%[1]s}, {:diskwrite%[1]s}, {:disk%[1]s}, {:updated})", suffix))
params["id"+suffix] = makeStableHashId(systemId, vm.Id)
params["name"+suffix] = vm.Name
params["type"+suffix] = vm.Type // "qemu" or "lxc"
params["cpu"+suffix] = vm.Cpu
params["mem"+suffix] = vm.Mem
params["maxcpu"+suffix] = vm.MaxCPU
params["maxmem"+suffix] = vm.MaxMem
params["uptime"+suffix] = vm.Uptime
params["diskread"+suffix] = vm.DiskRead
params["diskwrite"+suffix] = vm.DiskWrite
params["disk"+suffix] = vm.Disk
params["netout"+suffix] = vm.NetOut // cumulative bytes sent by VM
params["netin"+suffix] = vm.NetIn // cumulative bytes received by VM
}
queryString := fmt.Sprintf(
"INSERT INTO pve_vms (id, system, name, type, cpu, mem, netout, netin, maxcpu, maxmem, uptime, diskread, diskwrite, disk, updated) VALUES %s ON CONFLICT(id) DO UPDATE SET system=excluded.system, name=excluded.name, type=excluded.type, cpu=excluded.cpu, mem=excluded.mem, netout=excluded.netout, netin=excluded.netin, maxcpu=excluded.maxcpu, maxmem=excluded.maxmem, uptime=excluded.uptime, diskread=excluded.diskread, diskwrite=excluded.diskwrite, disk=excluded.disk, updated=excluded.updated",
strings.Join(valueStrings, ","),
)
_, err := app.DB().NewQuery(queryString).Bind(params).Execute()
return err
}
// getRecord retrieves the system record from the database.
// If the record is not found, it removes the system from the manager.
func (sys *System) getRecord() (*core.Record, error) {
record, err := sys.manager.hub.FindRecordById("systems", sys.Id)
if err != nil || record == nil {

View File

@@ -1,5 +1,4 @@
//go:build !testing
// +build !testing
package systems

View File

@@ -1,5 +1,4 @@
//go:build testing
// +build testing
package systems_test

View File

@@ -1,5 +1,4 @@
//go:build testing
// +build testing
package systems

View File

@@ -1,5 +1,4 @@
//go:build testing
// +build testing
package ws

View File

@@ -1,5 +1,4 @@
//go:build testing
// +build testing
package ws

View File

@@ -1,5 +1,4 @@
//go:build testing
// +build testing
package ws

View File

@@ -1685,6 +1685,300 @@ func init() {
"type": "base",
"updateRule": null,
"viewRule": null
},
{
"createRule": null,
"deleteRule": null,
"fields": [
{
"autogeneratePattern": "[a-z0-9]{10}",
"hidden": false,
"id": "text3208210256",
"max": 10,
"min": 10,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"cascadeDelete": true,
"collectionId": "2hz5ncl8tizk5nx",
"hidden": false,
"id": "pve_stats_sys01",
"maxSelect": 1,
"minSelect": 0,
"name": "system",
"presentable": false,
"required": true,
"system": false,
"type": "relation"
},
{
"hidden": false,
"id": "pve_stats_json1",
"maxSize": 2000000,
"name": "stats",
"presentable": false,
"required": true,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "pve_stats_type1",
"maxSelect": 1,
"name": "type",
"presentable": false,
"required": true,
"system": false,
"type": "select",
"values": [
"1m",
"10m",
"20m",
"120m",
"480m"
]
},
{
"hidden": false,
"id": "pve_stats_crt1",
"name": "created",
"onCreate": true,
"onUpdate": false,
"presentable": false,
"system": false,
"type": "autodate"
},
{
"hidden": false,
"id": "pve_stats_upd1",
"name": "updated",
"onCreate": true,
"onUpdate": true,
"presentable": false,
"system": false,
"type": "autodate"
}
],
"id": "pvestats",
"indexes": [
"CREATE INDEX ` + "`" + `idx_pve_stats_sys_type_created` + "`" + ` ON ` + "`" + `pve_stats` + "`" + ` (\n ` + "`" + `system` + "`" + `,\n ` + "`" + `type` + "`" + `,\n ` + "`" + `created` + "`" + `\n)"
],
"listRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id",
"name": "pve_stats",
"system": false,
"type": "base",
"updateRule": null,
"viewRule": null
},
{
"createRule": null,
"deleteRule": null,
"fields": [
{
"autogeneratePattern": "[a-f0-9]{8}",
"hidden": false,
"id": "text3208210256",
"max": 8,
"min": 8,
"name": "id",
"pattern": "^[a-f0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"cascadeDelete": false,
"collectionId": "2hz5ncl8tizk5nx",
"hidden": false,
"id": "pve_vms_sys001",
"maxSelect": 1,
"minSelect": 0,
"name": "system",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "pve_vms_name01",
"max": 0,
"min": 0,
"name": "name",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "pve_vms_type01",
"max": 0,
"min": 0,
"name": "type",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"hidden": false,
"id": "pve_vms_cpu001",
"max": 100,
"min": 0,
"name": "cpu",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "number1253106325",
"max": null,
"min": null,
"name": "maxcpu",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "pve_vms_mem001",
"max": null,
"min": 0,
"name": "mem",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "number1693954525",
"max": null,
"min": null,
"name": "maxmem",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "number208985346",
"max": null,
"min": null,
"name": "disk",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "number4125810518",
"max": null,
"min": null,
"name": "diskread",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "number752404475",
"max": null,
"min": null,
"name": "diskwrite",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "number1880667380",
"max": null,
"min": null,
"name": "netout",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "number2702533949",
"max": null,
"min": null,
"name": "netin",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "number1563400775",
"max": null,
"min": null,
"name": "uptime",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "pve_vms_upd001",
"max": null,
"min": null,
"name": "updated",
"onlyInt": true,
"presentable": false,
"required": true,
"system": false,
"type": "number"
}
],
"id": "pvevms",
"indexes": [
"CREATE INDEX ` + "`" + `idx_pve_vms_updated` + "`" + ` ON ` + "`" + `pve_vms` + "`" + ` (` + "`" + `updated` + "`" + `)",
"CREATE INDEX ` + "`" + `idx_pve_vms_system` + "`" + ` ON ` + "`" + `pve_vms` + "`" + ` (` + "`" + `system` + "`" + `)"
],
"listRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id",
"name": "pve_vms",
"system": false,
"type": "base",
"updateRule": null,
"viewRule": null
}
]`

View File

@@ -42,11 +42,11 @@ type StatsRecord struct {
// global variables for reusing allocations
var (
statsRecord StatsRecord
containerStats []container.Stats
containerStats []container.SharedCoreMetrics
sumStats system.Stats
tempStats system.Stats
queryParams = make(dbx.Params, 1)
containerSums = make(map[string]*container.Stats)
containerSums = make(map[string]*container.SharedCoreMetrics)
)
// Create longer records by averaging shorter records
@@ -82,7 +82,7 @@ func (rm *RecordManager) CreateLongerRecords() {
// wrap the operations in a transaction
rm.app.RunInTransaction(func(txApp core.App) error {
var err error
collections := [2]*core.Collection{}
collections := [3]*core.Collection{}
collections[0], err = txApp.FindCachedCollectionByNameOrId("system_stats")
if err != nil {
return err
@@ -91,6 +91,10 @@ func (rm *RecordManager) CreateLongerRecords() {
if err != nil {
return err
}
collections[2], err = txApp.FindCachedCollectionByNameOrId("pve_stats")
if err != nil {
return err
}
var systems RecordIds
db := txApp.DB()
@@ -150,8 +154,9 @@ func (rm *RecordManager) CreateLongerRecords() {
case "system_stats":
longerRecord.Set("stats", rm.AverageSystemStats(db, recordIds))
case "container_stats":
longerRecord.Set("stats", rm.AverageContainerStats(db, recordIds))
longerRecord.Set("stats", rm.AverageContainerStats(db, recordIds, "container_stats"))
case "pve_stats":
longerRecord.Set("stats", rm.AverageContainerStats(db, recordIds, "pve_stats"))
}
if err := txApp.SaveNoValidate(longerRecord); err != nil {
log.Println("failed to save longer record", "err", err)
@@ -435,8 +440,8 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
return sum
}
// Calculate the average stats of a list of container_stats records
func (rm *RecordManager) AverageContainerStats(db dbx.Builder, records RecordIds) []container.Stats {
// Calculate the average stats of a list of container_stats or pve_stats records
func (rm *RecordManager) AverageContainerStats(db dbx.Builder, records RecordIds, collectionName string) []container.SharedCoreMetrics {
// Clear global map for reuse
for k := range containerSums {
delete(containerSums, k)
@@ -453,15 +458,15 @@ func (rm *RecordManager) AverageContainerStats(db dbx.Builder, records RecordIds
containerStats = nil
queryParams["id"] = id
db.NewQuery("SELECT stats FROM container_stats WHERE id = {:id}").Bind(queryParams).One(&statsRecord)
db.NewQuery(fmt.Sprintf("SELECT stats FROM %s WHERE id = {:id}", collectionName)).Bind(queryParams).One(&statsRecord)
if err := json.Unmarshal(statsRecord.Stats, &containerStats); err != nil {
return []container.Stats{}
return []container.SharedCoreMetrics{}
}
for i := range containerStats {
stat := containerStats[i]
if _, ok := sums[stat.Name]; !ok {
sums[stat.Name] = &container.Stats{Name: stat.Name}
sums[stat.Name] = &container.SharedCoreMetrics{Name: stat.Name}
}
sums[stat.Name].Cpu += stat.Cpu
sums[stat.Name].Mem += stat.Mem
@@ -476,9 +481,9 @@ func (rm *RecordManager) AverageContainerStats(db dbx.Builder, records RecordIds
}
}
result := make([]container.Stats, 0, len(sums))
result := make([]container.SharedCoreMetrics, 0, len(sums))
for _, value := range sums {
result = append(result, container.Stats{
result = append(result, container.SharedCoreMetrics{
Name: value.Name,
Cpu: twoDecimals(value.Cpu / count),
Mem: twoDecimals(value.Mem / count),
@@ -499,6 +504,10 @@ func (rm *RecordManager) DeleteOldRecords() {
if err != nil {
return err
}
err = deleteOldPVEVMRecords(txApp)
if err != nil {
return err
}
err = deleteOldSystemdServiceRecords(txApp)
if err != nil {
return err
@@ -537,7 +546,7 @@ func deleteOldAlertsHistory(app core.App, countToKeep, countBeforeDeletion int)
// Deletes system_stats records older than what is displayed in the UI
func deleteOldSystemStats(app core.App) error {
// Collections to process
collections := [2]string{"system_stats", "container_stats"}
collections := [3]string{"system_stats", "container_stats", "pve_stats"}
// Record types and their retention periods
type RecordDeletionData struct {
@@ -590,6 +599,19 @@ func deleteOldSystemdServiceRecords(app core.App) error {
return nil
}
// Deletes pve_vms records that haven't been updated in the last 10 minutes
func deleteOldPVEVMRecords(app core.App) error {
now := time.Now().UTC()
tenMinutesAgo := now.Add(-10 * time.Minute)
_, err := app.DB().NewQuery("DELETE FROM pve_vms WHERE updated < {:updated}").Bind(dbx.Params{"updated": tenMinutesAgo.UnixMilli()}).Execute()
if err != nil {
return fmt.Errorf("failed to delete old pve_vms records: %v", err)
}
return nil
}
// Deletes container records that haven't been updated in the last 10 minutes
func deleteOldContainerRecords(app core.App) error {
now := time.Now().UTC()

View File

@@ -1,5 +1,4 @@
//go:build testing
// +build testing
package records_test

View File

@@ -1,5 +1,4 @@
//go:build testing
// +build testing
package records

View File

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

View File

@@ -1,13 +1,14 @@
{
"name": "beszel",
"private": true,
"version": "0.18.3",
"version": "0.18.4",
"type": "module",
"scripts": {
"dev": "vite --host",
"build": "lingui extract --overwrite && lingui compile && vite build",
"preview": "vite preview",
"sync": "lingui extract --overwrite && lingui compile",
"sync_no_compile": "lingui extract --overwrite --clean",
"sync_and_purge": "lingui extract --overwrite --clean && lingui compile",
"format": "biome format --write .",
"lint": "biome lint .",

View File

@@ -26,7 +26,7 @@ export default memo(function AlertsButton({ system }: { system: SystemRecord })
/>
</Button>
</SheetTrigger>
<SheetContent className="max-h-full overflow-auto w-150 !max-w-full p-4 sm:p-6">
<SheetContent className="max-h-full overflow-auto w-160 !max-w-full p-4 sm:p-6">
{opened && <AlertDialogContent system={system} />}
</SheetContent>
</Sheet>

View File

@@ -7,6 +7,7 @@ import { lazy, memo, Suspense, useMemo, useState } from "react"
import { $router, Link } from "@/components/router"
import { Checkbox } from "@/components/ui/checkbox"
import { DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Switch } from "@/components/ui/switch"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { toast } from "@/components/ui/use-toast"
@@ -20,7 +21,7 @@ const Slider = lazy(() => import("@/components/ui/slider"))
const endpoint = "/api/beszel/user-alerts"
const alertDebounce = 100
const alertDebounce = 400
const alertKeys = Object.keys(alertInfo) as (keyof typeof alertInfo)[]
@@ -244,7 +245,7 @@ export function AlertContent({
<Suspense fallback={<div className="h-10" />}>
{!singleDescription && (
<div>
<p id={`v${name}`} className="text-sm block h-8">
<p id={`v${name}`} className="text-sm block h-6">
{alertData.invert ? (
<Trans>
Average drops below{" "}
@@ -263,21 +264,38 @@ export function AlertContent({
</Trans>
)}
</p>
<div className="flex gap-3">
<div className="flex gap-3 items-center">
<Slider
aria-labelledby={`v${name}`}
defaultValue={[value]}
value={[value]}
onValueCommit={(val) => sendUpsert(min, val[0])}
onValueChange={(val) => setValue(val[0])}
step={alertData.step ?? 1}
min={alertData.min ?? 1}
max={alertData.max ?? 99}
/>
<Input
type="number"
value={value}
onChange={(e) => {
let val = parseFloat(e.target.value)
if (!Number.isNaN(val)) {
if (alertData.max != null) val = Math.min(val, alertData.max)
if (alertData.min != null) val = Math.max(val, alertData.min)
setValue(val)
sendUpsert(min, val)
}
}}
step={alertData.step ?? 1}
min={alertData.min ?? 1}
max={alertData.max ?? 99}
className="w-16 h-8 text-center px-1"
/>
</div>
</div>
)}
<div className={cn(singleDescription && "col-span-full lowercase")}>
<p id={`t${name}`} className="text-sm block h-8 first-letter:uppercase">
<p id={`t${name}`} className="text-sm block h-6 first-letter:uppercase">
{singleDescription && (
<>
{singleDescription}
@@ -289,15 +307,30 @@ export function AlertContent({
<Plural value={min} one="minute" other="minutes" />
</Trans>
</p>
<div className="flex gap-3">
<div className="flex gap-3 items-center">
<Slider
aria-labelledby={`v${name}`}
defaultValue={[min]}
onValueCommit={(minVal) => sendUpsert(minVal[0], value)}
aria-labelledby={`t${name}`}
value={[min]}
onValueCommit={(val) => sendUpsert(val[0], value)}
onValueChange={(val) => setMin(val[0])}
min={1}
max={60}
/>
<Input
type="number"
value={min}
onChange={(e) => {
let val = parseInt(e.target.value, 10)
if (!Number.isNaN(val)) {
val = Math.max(1, Math.min(val, 60))
setMin(val)
sendUpsert(val, value)
}
}}
min={1}
max={60}
className="w-16 h-8 text-center px-1"
/>
</div>
</div>
</Suspense>

View File

@@ -23,14 +23,16 @@ export default memo(function ContainerChart({
chartType,
chartConfig,
unit = "%",
filterStore = $containerFilter,
}: {
dataKey: string
chartData: ChartData
chartType: ChartType
chartConfig: ChartConfig
unit?: string
filterStore?: typeof $containerFilter
}) {
const filter = useStore($containerFilter)
const filter = useStore(filterStore)
const userSettings = useStore($userSettings)
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()

View File

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

View File

@@ -32,7 +32,10 @@ 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}</span> {label}
<span>
{e || <code className="font-mono bg-muted text-[.65em] w-5 h-4 grid place-items-center">{lang}</code>}
</span>{" "}
{label}
</DropdownMenuItem>
))}
</DropdownMenuContent>

View File

@@ -1,4 +1,5 @@
import { Trans } from "@lingui/react/macro"
import { t } from "@lingui/core/macro"
import { getPagePath } from "@nanostores/router"
import {
ContainerIcon,
@@ -31,6 +32,7 @@ import { Logo } from "./logo"
import { ModeToggle } from "./mode-toggle"
import { $router, basePath, Link, prependBasePath } from "./router"
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"
import { ProxmoxIcon } from "./ui/icons"
const CommandPalette = lazy(() => import("./command-palette"))
@@ -77,6 +79,20 @@ export default function Navbar() {
</TooltipTrigger>
<TooltipContent>S.M.A.R.T.</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Link
href={getPagePath($router, "proxmox")}
className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}
aria-label={t`Proxmox`}
>
<ProxmoxIcon className="h-[1.2rem] w-[1.2rem] opacity-90" />
</Link>
</TooltipTrigger>
<TooltipContent>
<Trans>Proxmox</Trans>
</TooltipContent>
</Tooltip>
<LangToggle />
<ModeToggle />
<Tooltip>

View File

@@ -0,0 +1,215 @@
import type { Column, ColumnDef } from "@tanstack/react-table"
import { Button } from "@/components/ui/button"
import { cn, decimalString, formatBytes, hourWithSeconds, toFixedFloat } from "@/lib/utils"
import type { PveVmRecord } from "@/types"
import {
ClockIcon,
CpuIcon,
HardDriveIcon,
MemoryStickIcon,
MonitorIcon,
ServerIcon,
TagIcon,
TimerIcon,
} from "lucide-react"
import { EthernetIcon } from "../ui/icons"
import { Badge } from "../ui/badge"
import { t } from "@lingui/core/macro"
import { $allSystemsById } from "@/lib/stores"
import { useStore } from "@nanostores/react"
/** Format uptime in seconds to a human-readable string */
export function formatUptime(seconds: number): string {
if (seconds < 60) return `${seconds}s`
if (seconds < 3600) {
const m = Math.floor(seconds / 60)
return `${m}m`
}
if (seconds < 86400) {
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
return m > 0 ? `${h}h ${m}m` : `${h}h`
}
const d = Math.floor(seconds / 86400)
const h = Math.floor((seconds % 86400) / 3600)
return h > 0 ? `${d}d ${h}h` : `${d}d`
}
export const pveVmCols: ColumnDef<PveVmRecord>[] = [
{
id: "name",
sortingFn: (a, b) => a.original.name.localeCompare(b.original.name),
accessorFn: (record) => record.name,
header: ({ column }) => <HeaderButton column={column} name={t`Name`} Icon={MonitorIcon} />,
cell: ({ getValue }) => {
return <span className="ms-1 max-w-48 block truncate">{getValue() as string}</span>
},
},
{
id: "system",
accessorFn: (record) => record.system,
sortingFn: (a, b) => {
const allSystems = $allSystemsById.get()
const systemNameA = allSystems[a.original.system]?.name ?? ""
const systemNameB = allSystems[b.original.system]?.name ?? ""
return systemNameA.localeCompare(systemNameB)
},
header: ({ column }) => <HeaderButton column={column} name={t`System`} Icon={ServerIcon} />,
cell: ({ getValue }) => {
const allSystems = useStore($allSystemsById)
return <span className="ms-1 max-w-34 block truncate">{allSystems[getValue() as string]?.name ?? ""}</span>
},
},
{
id: "type",
accessorFn: (record) => record.type,
sortingFn: (a, b) => a.original.type.localeCompare(b.original.type),
header: ({ column }) => <HeaderButton column={column} name={t`Type`} Icon={TagIcon} />,
cell: ({ getValue }) => {
const type = getValue() as string
return (
<Badge variant="outline" className="dark:border-white/12 ms-1">
{type}
</Badge>
)
},
},
{
id: "cpu",
accessorFn: (record) => record.cpu,
invertSorting: true,
header: ({ column }) => <HeaderButton column={column} name={t`CPU`} Icon={CpuIcon} />,
cell: ({ getValue }) => {
const val = getValue() as number
return <span className="ms-1 tabular-nums">{`${decimalString(val, val >= 10 ? 1 : 2)}%`}</span>
},
},
{
id: "mem",
accessorFn: (record) => record.mem,
invertSorting: true,
header: ({ column }) => <HeaderButton column={column} name={t`Memory`} Icon={MemoryStickIcon} />,
cell: ({ getValue }) => {
const val = getValue() as number
const formatted = formatBytes(val, false, undefined, true)
return (
<span className="ms-1 tabular-nums">{`${decimalString(formatted.value, formatted.value >= 10 ? 1 : 2)} ${formatted.unit}`}</span>
)
},
},
{
id: "maxmem",
accessorFn: (record) => record.maxmem,
header: ({ column }) => <HeaderButton column={column} name={t`Max`} Icon={MemoryStickIcon} />,
invertSorting: true,
cell: ({ getValue }) => {
// maxmem is stored in bytes; convert to MB for formatBytes
const formatted = formatBytes(getValue() as number, false, undefined, false)
return <span className="ms-1 tabular-nums">{`${toFixedFloat(formatted.value, 2)} ${formatted.unit}`}</span>
},
},
{
id: "disk",
accessorFn: (record) => record.disk,
invertSorting: true,
header: ({ column }) => <HeaderButton column={column} name={t`Disk`} Icon={HardDriveIcon} />,
cell: ({ getValue }) => {
const formatted = formatBytes(getValue() as number, false, undefined, false)
return <span className="ms-1 tabular-nums">{`${toFixedFloat(formatted.value, 2)} ${formatted.unit}`}</span>
},
},
{
id: "diskread",
accessorFn: (record) => record.diskread,
invertSorting: true,
header: ({ column }) => <HeaderButton column={column} name={t`Read`} Icon={HardDriveIcon} />,
cell: ({ getValue }) => {
const val = getValue() as number
const formatted = formatBytes(val, false, undefined, false)
return <span className="ms-1 tabular-nums">{`${toFixedFloat(formatted.value, 2)} ${formatted.unit}`}</span>
},
},
{
id: "diskwrite",
accessorFn: (record) => record.diskwrite,
invertSorting: true,
header: ({ column }) => <HeaderButton column={column} name={t`Write`} Icon={HardDriveIcon} />,
cell: ({ getValue }) => {
const val = getValue() as number
const formatted = formatBytes(val, false, undefined, false)
return <span className="ms-1 tabular-nums">{`${toFixedFloat(formatted.value, 2)} ${formatted.unit}`}</span>
},
},
{
id: "netin",
accessorFn: (record) => record.netin,
invertSorting: true,
header: ({ column }) => <HeaderButton column={column} name={t`Download`} Icon={EthernetIcon} />,
cell: ({ getValue }) => {
const val = getValue() as number
const formatted = formatBytes(val, false, undefined, false)
return (
<span className="ms-1 tabular-nums">{`${decimalString(formatted.value, formatted.value >= 10 ? 1 : 2)} ${formatted.unit}`}</span>
)
},
},
{
id: "netout",
accessorFn: (record) => record.netout,
invertSorting: true,
header: ({ column }) => <HeaderButton column={column} name={t`Upload`} Icon={EthernetIcon} />,
cell: ({ getValue }) => {
const val = getValue() as number
const formatted = formatBytes(val, false, undefined, false)
return (
<span className="ms-1 tabular-nums">{`${decimalString(formatted.value, formatted.value >= 10 ? 1 : 2)} ${formatted.unit}`}</span>
)
},
},
{
id: "maxcpu",
accessorFn: (record) => record.maxcpu,
header: ({ column }) => <HeaderButton column={column} name="vCPUs" Icon={CpuIcon} />,
invertSorting: true,
cell: ({ getValue }) => {
return <span className="ms-1 tabular-nums">{getValue() as number}</span>
},
},
{
id: "uptime",
accessorFn: (record) => record.uptime,
invertSorting: true,
header: ({ column }) => <HeaderButton column={column} name={t`Uptime`} Icon={TimerIcon} />,
cell: ({ getValue }) => {
return <span className="ms-1">{formatUptime(getValue() as number)}</span>
},
},
{
id: "updated",
invertSorting: true,
accessorFn: (record) => record.updated,
header: ({ column }) => <HeaderButton column={column} name={t`Updated`} Icon={ClockIcon} />,
cell: ({ getValue }) => {
const timestamp = getValue() as number
return <span className="ms-1 tabular-nums">{hourWithSeconds(new Date(timestamp).toISOString())}</span>
},
},
]
function HeaderButton({ column, name, Icon }: { column: Column<PveVmRecord>; name: string; Icon: React.ElementType }) {
const isSorted = column.getIsSorted()
return (
<Button
className={cn(
"h-9 px-3 flex items-center gap-2 duration-50",
isSorted && "bg-accent/70 light:bg-accent text-accent-foreground/90"
)}
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
{Icon && <Icon className="size-4" />}
{name}
{/* <ArrowUpDownIcon className="size-4" /> */}
</Button>
)
}

View File

@@ -0,0 +1,391 @@
import { t } from "@lingui/core/macro"
import { Trans } from "@lingui/react/macro"
import {
type ColumnFiltersState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getSortedRowModel,
type Row,
type SortingState,
type Table as TableType,
useReactTable,
type VisibilityState,
} from "@tanstack/react-table"
import { useVirtualizer, type VirtualItem } from "@tanstack/react-virtual"
import { memo, RefObject, useEffect, useRef, useState } from "react"
import { Input } from "@/components/ui/input"
import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { pb } from "@/lib/api"
import type { PveVmRecord } from "@/types"
import { pveVmCols, formatUptime } from "@/components/pve-table/pve-table-columns"
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { cn, decimalString, formatBytes, useBrowserStorage } from "@/lib/utils"
import { Sheet, SheetTitle, SheetHeader, SheetContent, SheetDescription } from "../ui/sheet"
import { $allSystemsById } from "@/lib/stores"
import { LoaderCircleIcon, XIcon } from "lucide-react"
import { Separator } from "../ui/separator"
import { $router, Link } from "../router"
import { listenKeys } from "nanostores"
import { getPagePath } from "@nanostores/router"
export default function PveTable({ systemId }: { systemId?: string }) {
const loadTime = Date.now()
const [data, setData] = useState<PveVmRecord[] | undefined>(undefined)
const [sorting, setSorting] = useBrowserStorage<SortingState>(
`sort-pve-${systemId ? 1 : 0}`,
[{ id: systemId ? "name" : "system", desc: false }],
sessionStorage
)
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
const [rowSelection, setRowSelection] = useState({})
const [globalFilter, setGlobalFilter] = useState("")
useEffect(() => {
function fetchData(systemId?: string) {
pb.collection<PveVmRecord>("pve_vms")
.getList(0, 2000, {
filter: systemId ? pb.filter("system={:system}", { system: systemId }) : undefined,
})
.then(({ items }) => {
if (items.length === 0) {
setData((curItems) => {
if (systemId) {
return curItems?.filter((item) => item.system !== systemId) ?? []
}
return []
})
return
}
setData((curItems) => {
const lastUpdated = Math.max(items[0].updated, items.at(-1)?.updated ?? 0)
const vmIds = new Set<string>()
const newItems: PveVmRecord[] = []
for (const item of items) {
if (Math.abs(lastUpdated - item.updated) < 70_000) {
vmIds.add(item.id)
newItems.push(item)
}
}
for (const item of curItems ?? []) {
if (!vmIds.has(item.id) && lastUpdated - item.updated < 70_000) {
newItems.push(item)
}
}
return newItems
})
})
}
// initial load
fetchData(systemId)
// if no systemId, pull pve vms after every system update
if (!systemId) {
return $allSystemsById.listen((_value, _oldValue, systemId) => {
// exclude initial load of systems
if (Date.now() - loadTime > 500) {
fetchData(systemId)
}
})
}
// if systemId, fetch pve vms after the system is updated
return listenKeys($allSystemsById, [systemId], (_newSystems) => {
fetchData(systemId)
})
}, [])
const table = useReactTable({
data: data ?? [],
columns: pveVmCols.filter((col) => (systemId ? col.id !== "system" : true)),
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
defaultColumn: {
sortUndefined: "last",
size: 100,
minSize: 0,
},
state: {
sorting,
columnFilters,
columnVisibility,
rowSelection,
globalFilter,
},
onGlobalFilterChange: setGlobalFilter,
globalFilterFn: (row, _columnId, filterValue) => {
const vm = row.original
const systemName = $allSystemsById.get()[vm.system]?.name ?? ""
const id = vm.id ?? ""
const name = vm.name ?? ""
const type = vm.type ?? ""
const searchString = `${systemName} ${id} ${name} ${type}`.toLowerCase()
return (filterValue as string)
.toLowerCase()
.split(" ")
.every((term) => searchString.includes(term))
},
})
const rows = table.getRowModel().rows
const visibleColumns = table.getVisibleLeafColumns()
return (
<Card className="p-6 @container w-full">
<CardHeader className="p-0 mb-4">
<div className="grid md:flex gap-5 w-full items-end">
<div className="px-2 sm:px-1">
<CardTitle className="mb-2">
<Trans>Proxmox Resources</Trans>
</CardTitle>
<CardDescription className="flex">
<Trans>CPU is percent of overall host CPU usage.</Trans>
</CardDescription>
</div>
<div className="relative ms-auto w-full max-w-full md:w-64">
<Input
placeholder={t`Filter...`}
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
className="ps-4 pe-10 w-full"
/>
{globalFilter && (
<button
type="button"
aria-label={t`Clear`}
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 flex items-center justify-center text-muted-foreground hover:text-foreground"
onClick={() => setGlobalFilter("")}
>
<XIcon className="h-4 w-4" />
</button>
)}
</div>
</div>
</CardHeader>
<div className="rounded-md">
<AllPveTable table={table} rows={rows} colLength={visibleColumns.length} data={data} />
</div>
</Card>
)
}
const AllPveTable = memo(function AllPveTable({
table,
rows,
colLength,
data,
}: {
table: TableType<PveVmRecord>
rows: Row<PveVmRecord>[]
colLength: number
data: PveVmRecord[] | undefined
}) {
const scrollRef = useRef<HTMLDivElement>(null)
const activeVm = useRef<PveVmRecord | null>(null)
const [sheetOpen, setSheetOpen] = useState(false)
const openSheet = (vm: PveVmRecord) => {
activeVm.current = vm
setSheetOpen(true)
}
const virtualizer = useVirtualizer<HTMLDivElement, HTMLTableRowElement>({
count: rows.length,
estimateSize: () => 54,
getScrollElement: () => scrollRef.current,
overscan: 5,
})
const virtualRows = virtualizer.getVirtualItems()
const paddingTop = Math.max(0, virtualRows[0]?.start ?? 0 - virtualizer.options.scrollMargin)
const paddingBottom = Math.max(0, virtualizer.getTotalSize() - (virtualRows[virtualRows.length - 1]?.end ?? 0))
return (
<div
className={cn(
"h-min max-h-[calc(100dvh-17rem)] max-w-full relative overflow-auto border rounded-md",
(!rows.length || rows.length > 2) && "min-h-50"
)}
ref={scrollRef}
>
{/* add header height to table size */}
<div style={{ height: `${virtualizer.getTotalSize() + 48}px`, paddingTop, paddingBottom }}>
<table className="text-sm w-full h-full text-nowrap">
<PveTableHead table={table} />
<TableBody>
{rows.length ? (
virtualRows.map((virtualRow) => {
const row = rows[virtualRow.index]
return <PveTableRow key={row.id} row={row} virtualRow={virtualRow} openSheet={openSheet} />
})
) : (
<TableRow>
<TableCell colSpan={colLength} className="h-37 text-center pointer-events-none">
{data ? (
<Trans>No results.</Trans>
) : (
<LoaderCircleIcon className="animate-spin size-10 opacity-60 mx-auto" />
)}
</TableCell>
</TableRow>
)}
</TableBody>
</table>
</div>
<PveVmSheet sheetOpen={sheetOpen} setSheetOpen={setSheetOpen} activeVm={activeVm} />
</div>
)
})
function PveVmSheet({
sheetOpen,
setSheetOpen,
activeVm,
}: {
sheetOpen: boolean
setSheetOpen: (open: boolean) => void
activeVm: RefObject<PveVmRecord | null>
}) {
const vm = activeVm.current
if (!vm) return null
const memFormatted = formatBytes(vm.mem, false, undefined, true)
const maxMemFormatted = formatBytes(vm.maxmem, false, undefined, false)
const netoutFormatted = formatBytes(vm.netout, false, undefined, false)
const netinFormatted = formatBytes(vm.netin, false, undefined, false)
const diskReadFormatted = formatBytes(vm.diskread, false, undefined, false)
const diskWriteFormatted = formatBytes(vm.diskwrite, false, undefined, false)
const diskFormatted = formatBytes(vm.disk, false, undefined, false)
return (
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
<SheetContent className="w-full sm:max-w-120 p-2">
<SheetHeader>
<SheetTitle>{vm.name}</SheetTitle>
<SheetDescription className="flex flex-wrap items-center gap-x-2 gap-y-1">
<Link className="hover:underline" href={getPagePath($router, "system", { id: vm.system })}>
{$allSystemsById.get()[vm.system]?.name ?? ""}
</Link>
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
{vm.type}
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
<Trans>Up {formatUptime(vm.uptime)}</Trans>
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
{vm.id}
</SheetDescription>
</SheetHeader>
<div className="px-3 pb-3 -mt-2 flex flex-col gap-3">
<h3 className="text-sm font-medium">
<Trans>Details</Trans>
</h3>
<dl className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
<dt className="text-muted-foreground">
<Trans>CPU Usage</Trans>
</dt>
<dd className="tabular-nums">{`${decimalString(vm.cpu, vm.cpu >= 10 ? 1 : 2)}%`}</dd>
<dt className="text-muted-foreground">
<Trans>Memory Used</Trans>
</dt>
<dd className="tabular-nums">{`${decimalString(memFormatted.value, memFormatted.value >= 10 ? 1 : 2)} ${memFormatted.unit}`}</dd>
<dt className="text-muted-foreground">
<Trans>Upload</Trans>
</dt>
<dd className="tabular-nums">{`${decimalString(netoutFormatted.value, netoutFormatted.value >= 10 ? 1 : 2)} ${netoutFormatted.unit}`}</dd>
<dt className="text-muted-foreground">
<Trans>Download</Trans>
</dt>
<dd className="tabular-nums">{`${decimalString(netinFormatted.value, netinFormatted.value >= 10 ? 1 : 2)} ${netinFormatted.unit}`}</dd>
<dt className="text-muted-foreground">
<Trans>vCPUs</Trans>
</dt>
<dd className="tabular-nums">{vm.maxcpu}</dd>
<dt className="text-muted-foreground">
<Trans>Max Memory</Trans>
</dt>
<dd className="tabular-nums">{`${decimalString(maxMemFormatted.value, maxMemFormatted.value >= 10 ? 1 : 2)} ${maxMemFormatted.unit}`}</dd>
<dt className="text-muted-foreground">
<Trans>Disk Read</Trans>
</dt>
<dd className="tabular-nums">{`${decimalString(diskReadFormatted.value, diskReadFormatted.value >= 10 ? 1 : 2)} ${diskReadFormatted.unit}`}</dd>
<dt className="text-muted-foreground">
<Trans>Disk Write</Trans>
</dt>
<dd className="tabular-nums">{`${decimalString(diskWriteFormatted.value, diskWriteFormatted.value >= 10 ? 1 : 2)} ${diskWriteFormatted.unit}`}</dd>
<dt className="text-muted-foreground">
<Trans>Disk Size</Trans>
</dt>
<dd className="tabular-nums">{`${decimalString(diskFormatted.value, diskFormatted.value >= 10 ? 1 : 2)} ${diskFormatted.unit}`}</dd>
<dt className="text-muted-foreground">
<Trans>Uptime</Trans>
</dt>
<dd className="tabular-nums">{formatUptime(vm.uptime)}</dd>
</dl>
</div>
</SheetContent>
</Sheet>
)
}
function PveTableHead({ table }: { table: TableType<PveVmRecord> }) {
return (
<TableHeader className="sticky top-0 z-50 w-full border-b-2">
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead className="px-2" key={header.id}>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
)
})}
</tr>
))}
</TableHeader>
)
}
const PveTableRow = memo(function PveTableRow({
row,
virtualRow,
openSheet,
}: {
row: Row<PveVmRecord>
virtualRow: VirtualItem
openSheet: (vm: PveVmRecord) => void
}) {
return (
<TableRow
data-state={row.getIsSelected() && "selected"}
className="cursor-pointer transition-opacity"
onClick={() => openSheet(row.original)}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className="py-0 ps-4.5"
style={{
height: virtualRow.size,
}}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
)
})

View File

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

View File

@@ -0,0 +1,26 @@
import { useLingui } from "@lingui/react/macro"
import { memo, useEffect, useMemo } from "react"
import PveTable from "@/components/pve-table/pve-table"
import { ActiveAlerts } from "@/components/active-alerts"
import { FooterRepoLink } from "@/components/footer-repo-link"
export default memo(() => {
const { t } = useLingui()
useEffect(() => {
document.title = `${t`All Proxmox VMs`} / Beszel`
}, [t])
return useMemo(
() => (
<>
<div className="grid gap-4">
<ActiveAlerts />
<PveTable />
</div>
<FooterRepoLink />
</>
),
[]
)
})

View File

@@ -70,7 +70,16 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
<SelectContent>
{languages.map(([lang, label, e]) => (
<SelectItem key={lang} value={lang}>
<span className="me-2.5">{e}</span>
<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>
{label}
</SelectItem>
))}

View File

@@ -1,7 +1,6 @@
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"
@@ -10,6 +9,7 @@ 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: any) {
} catch (error: unknown) {
toast({
title: t`Error`,
description: error.message,
description: (error as Error).message,
variant: "destructive",
})
} finally {
@@ -66,10 +66,10 @@ export default function HeartbeatSettings() {
variant: "destructive",
})
}
} catch (error: any) {
} catch (error: unknown) {
toast({
title: t`Error`,
description: error.message,
description: (error as Error).message,
variant: "destructive",
})
} finally {
@@ -77,8 +77,6 @@ export default function HeartbeatSettings() {
}
}
const TestIcon = isTesting ? LoaderCircleIcon : SendIcon
return (
<div>
<div>
@@ -94,107 +92,123 @@ export default function HeartbeatSettings() {
</div>
<Separator className="my-4" />
{isLoading ? (
<div className="flex items-center gap-2 text-muted-foreground py-4">
<LoaderCircleIcon className="h-4 w-4 animate-spin" />
<Trans>Loading heartbeat status...</Trans>
</div>
) : 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>
{status?.enabled ? (
<EnabledState status={status} isTesting={isTesting} sendTestHeartbeat={sendTestHeartbeat} />
) : (
<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>
<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"
/>
</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>
)
}
function ConfigItem({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div>
<p className="text-sm font-medium mb-0.5">{label}</p>
<p className={clsx("text-sm text-muted-foreground break-all", mono && "font-mono")}>{value}</p>
<p className={cn("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 grid gap-1.5">
<div className="bg-muted/50 rounded-md px-3 py-2.5 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

@@ -90,13 +90,6 @@ export default function SettingsLayout() {
icon: AlertOctagonIcon,
preload: alertsHistoryDataTableSettingsImport,
},
{
title: t`YAML Config`,
href: getPagePath($router, "settings", { name: "config" }),
icon: FileSlidersIcon,
admin: true,
preload: configYamlSettingsImport,
},
{
title: t`Heartbeat`,
href: getPagePath($router, "settings", { name: "heartbeat" }),
@@ -104,6 +97,13 @@ export default function SettingsLayout() {
admin: true,
preload: heartbeatSettingsImport,
},
{
title: t`YAML Config`,
href: getPagePath($router, "settings", { name: "config" }),
icon: FileSlidersIcon,
admin: true,
preload: configYamlSettingsImport,
},
]
const page = useStore($router)

View File

@@ -25,6 +25,7 @@ import {
$containerFilter,
$direction,
$maxValues,
$pveFilter,
$systems,
$temperatureFilter,
$userSettings,
@@ -160,6 +161,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
const [system, setSystem] = useState({} as SystemRecord)
const [systemStats, setSystemStats] = useState([] as SystemStatsRecord[])
const [containerData, setContainerData] = useState([] as ChartData["containerData"])
const [pveData, setPveData] = useState([] as ChartData["containerData"])
const temperatureChartRef = useRef<HTMLDivElement>(null)
const persistChartTime = useRef(false)
const [bottomSpacing, setBottomSpacing] = useState(0)
@@ -177,8 +179,10 @@ export default memo(function SystemDetail({ id }: { id: string }) {
persistChartTime.current = false
setSystemStats([])
setContainerData([])
setPveData([])
setDetails({} as SystemDetailsRecord)
$containerFilter.set("")
$pveFilter.set("")
}
}, [id])
@@ -277,6 +281,10 @@ export default memo(function SystemDetail({ id }: { id: string }) {
// Share chart config computation for all container charts
const containerChartConfigs = useContainerChartConfigs(containerData)
// PVE chart data and configs
const pveSyntheticChartData = useMemo(() => ({ ...chartData, containerData: pveData }), [chartData, pveData])
const pveChartConfigs = useContainerChartConfigs(pveData)
// make container stats for charts
const makeContainerData = useCallback((containers: ContainerStatsRecord[]) => {
const containerData = [] as ChartData["containerData"]
@@ -307,7 +315,8 @@ export default memo(function SystemDetail({ id }: { id: string }) {
Promise.allSettled([
getStats<SystemStatsRecord>("system_stats", system, chartTime),
getStats<ContainerStatsRecord>("container_stats", system, chartTime),
]).then(([systemStats, containerStats]) => {
getStats<ContainerStatsRecord>("pve_stats", system, chartTime),
]).then(([systemStats, containerStats, pveStats]) => {
// loading: false
setChartLoading(false)
@@ -334,6 +343,17 @@ export default memo(function SystemDetail({ id }: { id: string }) {
cache.set(cs_cache_key, containerData)
}
setContainerData(makeContainerData(containerData))
// make new pve stats
const ps_cache_key = `${system.id}_${chartTime}_pve_stats`
let pveRecords = (cache.get(ps_cache_key) || []) as ContainerStatsRecord[]
if (pveStats.status === "fulfilled" && pveStats.value.length) {
pveRecords = pveRecords.concat(addEmptyValues(pveRecords, pveStats.value, expectedInterval))
if (pveRecords.length > 120) {
pveRecords = pveRecords.slice(-100)
}
cache.set(ps_cache_key, pveRecords)
}
setPveData(makeContainerData(pveRecords))
})
}, [system, chartTime])
@@ -399,6 +419,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
const showMax = maxValues && isLongerChart
const containerFilterBar = containerData.length ? <FilterBar /> : null
const pveFilterBar = pveData.length ? <FilterBar store={$pveFilter} /> : null
const dataEmpty = !chartLoading && chartData.systemStats.length === 0
const lastGpus = systemStats.at(-1)?.stats?.g
@@ -493,6 +514,24 @@ export default memo(function SystemDetail({ id }: { id: string }) {
</ChartCard>
)}
{pveFilterBar && (
<ChartCard
empty={dataEmpty}
grid={grid}
title={t`Proxmox CPU Usage`}
description={t`Average CPU utilization of VMs and containers`}
cornerEl={pveFilterBar}
>
<ContainerChart
chartData={pveSyntheticChartData}
dataKey="c"
chartType={ChartType.CPU}
chartConfig={pveChartConfigs.cpu}
filterStore={$pveFilter}
/>
</ChartCard>
)}
<ChartCard
empty={dataEmpty}
grid={grid}
@@ -520,6 +559,24 @@ export default memo(function SystemDetail({ id }: { id: string }) {
</ChartCard>
)}
{pveFilterBar && (
<ChartCard
empty={dataEmpty}
grid={grid}
title={t`Proxmox Memory Usage`}
description={t`Memory usage of Proxmox VMs and containers`}
cornerEl={pveFilterBar}
>
<ContainerChart
chartData={pveSyntheticChartData}
dataKey="m"
chartType={ChartType.Memory}
chartConfig={pveChartConfigs.memory}
filterStore={$pveFilter}
/>
</ChartCard>
)}
<ChartCard empty={dataEmpty} grid={grid} title={t`Disk Usage`} description={t`Usage of root partition`}>
<DiskChart chartData={chartData} dataKey="stats.du" diskSize={systemStats.at(-1)?.stats.d ?? NaN} />
</ChartCard>
@@ -593,7 +650,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
if (showMax) {
return data?.stats?.bm?.[0] ?? (data?.stats?.nsm ?? 0) * 1024 * 1024
}
return data?.stats?.b?.[0] ?? data?.stats?.ns * 1024 * 1024
return data?.stats?.b?.[0] ?? (data?.stats?.ns ?? 0) * 1024 * 1024
},
color: 5,
opacity: 0.2,
@@ -604,7 +661,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
if (showMax) {
return data?.stats?.bm?.[1] ?? (data?.stats?.nrm ?? 0) * 1024 * 1024
}
return data?.stats?.b?.[1] ?? data?.stats?.nr * 1024 * 1024
return data?.stats?.b?.[1] ?? (data?.stats?.nr ?? 0) * 1024 * 1024
},
color: 2,
opacity: 0.2,
@@ -641,6 +698,24 @@ export default memo(function SystemDetail({ id }: { id: string }) {
</ChartCard>
)}
{pveFilterBar && pveData.length > 0 && (
<ChartCard
empty={dataEmpty}
grid={grid}
title={t`Proxmox Network I/O`}
description={t`Network traffic of Proxmox VMs and containers`}
cornerEl={pveFilterBar}
>
<ContainerChart
chartData={pveSyntheticChartData}
chartType={ChartType.Network}
dataKey="n"
chartConfig={pveChartConfigs.network}
filterStore={$pveFilter}
/>
</ChartCard>
)}
{/* Swap chart */}
{(systemStats.at(-1)?.stats.su ?? 0) > 0 && (
<ChartCard
@@ -877,6 +952,8 @@ export default memo(function SystemDetail({ id }: { id: string }) {
<LazyContainersTable systemId={system.id} />
)}
{pveData.length > 0 && <LazyPveTable systemId={system.id} />}
{isLinux && compareSemVer(chartData.agentVersion, parseSemVer("0.16.0")) >= 0 && (
<LazySystemdTable systemId={system.id} />
)}
@@ -1051,6 +1128,17 @@ function LazyContainersTable({ systemId }: { systemId: string }) {
)
}
const PveTable = lazy(() => import("../pve-table/pve-table"))
function LazyPveTable({ systemId }: { systemId: string }) {
const { isIntersecting, ref } = useIntersectionObserver({ rootMargin: "90px" })
return (
<div ref={ref} className={cn(isIntersecting && "contents")}>
{isIntersecting && <PveTable systemId={systemId} />}
</div>
)
}
const SmartTable = lazy(() => import("./system/smart-table"))
function LazySmartTable({ systemId }: { systemId: string }) {

View File

@@ -621,8 +621,8 @@ function DiskSheet({
const deviceName = disk?.name || unknown
const model = disk?.model || unknown
const capacity = disk?.capacity ? formatCapacity(disk.capacity) : unknown
const serialNumber = disk?.serial || unknown
const firmwareVersion = disk?.firmware || unknown
const serialNumber = disk?.serial
const firmwareVersion = disk?.firmware
const status = disk?.state || unknown
return (
@@ -636,34 +636,42 @@ 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-auto p-4 flex flex-col gap-4">
<div className="flex-1 overflow-hidden p-4 flex flex-col gap-4">
{isLoading ? (
<div className="flex justify-center py-8">
<LoaderCircleIcon className="animate-spin size-10 opacity-60" />
</div>
) : (
<>
<Alert className="pb-3">
<Alert className="pb-3 shrink-0">
{status === "PASSED" ? <CheckCircle2Icon className="size-4" /> : <XCircleIcon className="size-4" />}
<AlertTitle>
<Trans>S.M.A.R.T. Self-Test</Trans>: {status}
@@ -675,9 +683,9 @@ function DiskSheet({
)}
</Alert>
{smartAttributes.length > 0 ? (
<div className="rounded-md border overflow-auto">
<div className="rounded-md border min-h-0 flex flex-col">
<Table>
<TableHeader>
<TableHeader className="sticky top-0 z-10">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (

View File

@@ -33,7 +33,6 @@ import {
decimalString,
formatBytes,
formatTemperature,
getMeterState,
parseSemVer,
secondsToUptimeString,
} from "@/lib/utils"
@@ -81,6 +80,10 @@ const STATUS_COLORS = {
[SystemStatus.Pending]: "bg-yellow-500",
} as const
function getMeterStateByThresholds(value: number, warn = 65, crit = 90): MeterState {
return value >= crit ? MeterState.Crit : value >= warn ? MeterState.Warn : MeterState.Good
}
/**
* @param viewMode - "table" or "grid"
* @returns - Column definitions for the systems table
@@ -209,6 +212,7 @@ export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<Syste
header: sortableHeader,
cell(info: CellContext<SystemRecord, unknown>) {
const { info: sysInfo, status } = info.row.original
const { colorWarn = 65, colorCrit = 90 } = useStore($userSettings, { keys: ["colorWarn", "colorCrit"] })
// agent version
const { minor, patch } = parseSemVer(sysInfo.v)
let loadAverages = sysInfo.la
@@ -224,7 +228,7 @@ export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<Syste
}
const normalizedLoad = max / (sysInfo.t ?? 1)
const threshold = getMeterState(normalizedLoad * 100)
const threshold = getMeterStateByThresholds(normalizedLoad * 100, colorWarn, colorCrit)
return (
<div className="flex items-center gap-[.35em] w-full tabular-nums tracking-tight">
@@ -463,8 +467,9 @@ function sortableHeader(context: HeaderContext<SystemRecord, unknown>) {
}
function TableCellWithMeter(info: CellContext<SystemRecord, unknown>) {
const { colorWarn = 65, colorCrit = 90 } = useStore($userSettings, { keys: ["colorWarn", "colorCrit"] })
const val = Number(info.getValue()) || 0
const threshold = getMeterState(val)
const threshold = getMeterStateByThresholds(val, colorWarn, colorCrit)
const meterClass = cn(
"h-full",
(info.row.original.status !== SystemStatus.Up && STATUS_COLORS.paused) ||
@@ -483,6 +488,7 @@ function TableCellWithMeter(info: CellContext<SystemRecord, unknown>) {
}
function DiskCellWithMultiple(info: CellContext<SystemRecord, unknown>) {
const { colorWarn = 65, colorCrit = 90 } = useStore($userSettings, { keys: ["colorWarn", "colorCrit"] })
const { info: sysInfo, status, id } = info.row.original
const extraFs = Object.entries(sysInfo.efs ?? {})
@@ -496,7 +502,7 @@ function DiskCellWithMultiple(info: CellContext<SystemRecord, unknown>) {
extraFs.sort((a, b) => b[1] - a[1])
function getIndicatorColor(pct: number) {
const threshold = getMeterState(pct)
const threshold = getMeterStateByThresholds(pct, colorWarn, colorCrit)
return (
(status !== SystemStatus.Up && STATUS_COLORS.paused) ||
(threshold === MeterState.Good && STATUS_COLORS.up) ||
@@ -514,7 +520,9 @@ function DiskCellWithMultiple(info: CellContext<SystemRecord, unknown>) {
const extraDiskIndicators =
status !== SystemStatus.Up
? []
: [...new Set(extraFs.map(([, pct]) => getMeterState(pct)))].sort().map((state) => stateColors[state])
: [...new Set(extraFs.map(([, pct]) => getMeterStateByThresholds(pct, colorWarn, colorCrit)))]
.sort()
.map((state) => stateColors[state])
return (
<Tooltip>

View File

@@ -185,3 +185,12 @@ export function PlugChargingIcon(props: SVGProps<SVGSVGElement>) {
</svg>
)
}
// simple-icons (CC0) https://github.com/simple-icons/simple-icons/blob/develop/LICENSE.md
export function ProxmoxIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" {...props} stroke="currentColor" strokeWidth="1.5" fill="none">
<path d="M5 1.8c-1.2.6-1.2.7-.1 1.8l7 7.8c.2 0 8-8.6 8.1-8.8l-.5-.5q-.5-.5-1.7-.5c-1.6-.1-2.2.2-4.1 2.4L12 6 10.4 4 8 1.9c-.8-.4-2.4-.5-3.2 0M1.2 4.4q-1.2.5-1.3.8l3 3.5L5.8 12l-3 3.3L0 18.8c.1.5 1.5 1 2.6 1 1.7 0 2-.2 5.6-4.1l3.2-3.7a74 74 0 0 0-7.1-7.5c-.9-.4-2.2-.5-3-.1m18.5 0q-.7.4-4 4L12.6 12l3.3 3.7c3.5 3.9 3.9 4.2 5.6 4.2 1 0 2.4-.6 2.5-1 0-.2-1.3-1.8-2.9-3.6L18 12l3-3.3c1.6-1.8 3-3.3 2.9-3.5 0-.4-1.4-1-2.5-1q-1 0-1.7.3M8 17l-4 4.4.5.6q.6.4 1.7.4c1.6.1 2.2-.2 4.2-2.5l1.6-1.8 1.7 1.8c2 2.3 2.5 2.6 4 2.5q1.3 0 1.8-.4t.5-.6c0-.2-7.9-8.8-8-8.8z" />
</svg>
)
}

View File

@@ -40,7 +40,7 @@ export const alertInfo: Record<string, AlertInfo> = {
unit: " MB/s",
icon: EthernetIcon,
desc: () => t`Triggers when combined up/down exceeds a threshold`,
max: 125,
max: 250,
},
GPU: {
name: () => t`GPU Usage`,

View File

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

View File

@@ -55,6 +55,9 @@ listenKeys($userSettings, ["chartTime"], ({ chartTime }) => $chartTime.set(chart
/** Container chart filter */
export const $containerFilter = atom("")
/** PVE chart filter */
export const $pveFilter = atom("")
/** Temperature chart filter */
export const $temperatureFilter = atom("")

View File

@@ -6,7 +6,7 @@ import { useEffect, useState } from "react"
import { twMerge } from "tailwind-merge"
import { toast } from "@/components/ui/use-toast"
import type { ChartTimeData, FingerprintRecord, SemVer, SystemRecord } from "@/types"
import { HourFormat, MeterState, Unit } from "./enums"
import { HourFormat, Unit } from "./enums"
import { $copyContent, $userSettings } from "./stores"
export function cn(...inputs: ClassValue[]) {
@@ -210,7 +210,6 @@ export function useBrowserStorage<T>(key: string, defaultValue: T, storageInterf
const [value, setValue] = useState(() => {
return getStorageValue(key, defaultValue, storageInterface)
})
// biome-ignore lint/correctness/useExhaustiveDependencies: storageInterface won't change
useEffect(() => {
storageInterface?.setItem(key, JSON.stringify(value))
}, [key, value])
@@ -394,12 +393,6 @@ export function compareSemVer(a: SemVer, b: SemVer) {
return a.patch - b.patch
}
/** Get meter state from 0-100 value. Used for color coding meters. */
export function getMeterState(value: number): MeterState {
const { colorWarn = 65, colorCrit = 90 } = $userSettings.get()
return value >= colorCrit ? MeterState.Crit : value >= colorWarn ? MeterState.Warn : MeterState.Good
}
// biome-ignore lint/suspicious/noExplicitAny: any is used to allow any function to be passed in
export function debounce<T extends (...args: any[]) => any>(func: T, wait: number): (...args: Parameters<T>) => void {
let timeout: ReturnType<typeof setTimeout>

View File

@@ -93,6 +93,7 @@ msgstr "إجراءات"
#: src/components/alerts-history-columns.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/heartbeat.tsx
#: src/components/routes/settings/quiet-hours.tsx
msgid "Active"
msgstr "نشط"
@@ -140,6 +141,10 @@ msgstr "مسؤول"
msgid "After"
msgstr "بعد"
#: src/components/routes/settings/heartbeat.tsx
msgid "After setting the environment variables, restart your Beszel hub for changes to take effect."
msgstr "بعد تعيين متغيرات البيئة، أعد تشغيل مركز Beszel الخاص بك لتصبح التغييرات سارية المفعول."
#: src/components/systems-table/systems-table-columns.tsx
msgid "Agent"
msgstr "وكيل"
@@ -350,6 +355,10 @@ msgstr "تحقق من {email} للحصول على رابط إعادة التعي
msgid "Check logs for more details."
msgstr "تحقق من السجلات لمزيد من التفاصيل."
#: src/components/routes/settings/heartbeat.tsx
msgid "Check your monitoring service"
msgstr "تحقق من خدمة المراقبة الخاصة بك"
#: src/components/routes/settings/notifications.tsx
msgid "Check your notification service"
msgstr "تحقق من خدمة الإشعارات الخاصة بك"
@@ -643,6 +652,14 @@ msgstr "فارغة"
msgid "End Time"
msgstr "وقت النهاية"
#: src/components/routes/settings/heartbeat.tsx
msgid "Endpoint URL"
msgstr "عنوان URL للنقطة النهائية"
#: src/components/routes/settings/heartbeat.tsx
msgid "Endpoint URL to ping (required)"
msgstr "عنوان URL للنقطة النهائية لل ping (مطلوب)"
#: src/components/login/login.tsx
msgid "Enter email address to reset password"
msgstr "أدخل عنوان البريد الإشباكي لإعادة تعيين كلمة المرور"
@@ -662,6 +679,9 @@ msgstr "مؤقت"
#: src/components/login/auth-form.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/config-yaml.tsx
#: src/components/routes/settings/heartbeat.tsx
#: src/components/routes/settings/heartbeat.tsx
#: src/components/routes/settings/heartbeat.tsx
#: src/components/routes/settings/notifications.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
@@ -670,6 +690,10 @@ msgstr "مؤقت"
msgid "Error"
msgstr "خطأ"
#: src/components/routes/settings/heartbeat.tsx
msgid "Example:"
msgstr "مثال:"
#. placeholder {0}: alert.value
#. placeholder {1}: info.unit
#. placeholder {2}: alert.min
@@ -727,6 +751,10 @@ msgstr "فشل في المصادقة"
msgid "Failed to save settings"
msgstr "فشل في حفظ الإعدادات"
#: src/components/routes/settings/heartbeat.tsx
msgid "Failed to send heartbeat"
msgstr "فشل في إرسال نبضة القلب"
#: src/components/routes/settings/notifications.tsx
msgid "Failed to send test notification"
msgstr "فشل في إرسال إشعار الاختبار"
@@ -806,6 +834,18 @@ msgstr "شبكة"
msgid "Health"
msgstr "الصحة"
#: src/components/routes/settings/layout.tsx
msgid "Heartbeat"
msgstr "نبضة القلب"
#: src/components/routes/settings/heartbeat.tsx
msgid "Heartbeat Monitoring"
msgstr "مراقبة نبضة القلب"
#: src/components/routes/settings/heartbeat.tsx
msgid "Heartbeat sent successfully"
msgstr "تم إرسال نبضة القلب بنجاح"
#: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Button to copy install command"
@@ -816,6 +856,14 @@ msgstr "أمر Homebrew"
msgid "Host / IP"
msgstr "مضيف / IP"
#: src/components/routes/settings/heartbeat.tsx
msgid "HTTP Method"
msgstr "طريقة HTTP"
#: src/components/routes/settings/heartbeat.tsx
msgid "HTTP method: POST, GET, or HEAD (default: POST)"
msgstr "طريقة HTTP: POST، GET، أو HEAD (الافتراضي: POST)"
#. Context: Battery state
#: src/lib/i18n.ts
msgid "Idle"
@@ -834,6 +882,10 @@ msgstr "صورة"
msgid "Inactive"
msgstr "غير نشط"
#: src/components/routes/settings/heartbeat.tsx
msgid "Interval"
msgstr "الفاصل الزمني"
#: src/components/login/auth-form.tsx
msgid "Invalid email address."
msgstr "عنوان البريد الإشباكي غير صالح."
@@ -1110,6 +1162,10 @@ msgstr "متوقف مؤقتا"
msgid "Paused ({pausedSystemsLength})"
msgstr "متوقف مؤقتا ({pausedSystemsLength})"
#: src/components/routes/settings/heartbeat.tsx
msgid "Payload format"
msgstr "تنسيق الحمولة"
#: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/cpu-sheet.tsx
msgid "Per-core average utilization"
@@ -1307,6 +1363,10 @@ msgstr "بحث"
msgid "Search for systems or settings..."
msgstr "البحث عن الأنظمة أو الإعدادات..."
#: src/components/routes/settings/heartbeat.tsx
msgid "Seconds between pings (default: 60)"
msgstr "ثواني بين ال pings (الافتراضي: 60)"
#: src/components/alerts/alerts-sheet.tsx
msgid "See <0>notification settings</0> to configure how you receive alerts."
msgstr "راجع <0>إعدادات الإشعارات</0> لتكوين كيفية تلقي التنبيهات."
@@ -1315,6 +1375,18 @@ msgstr "راجع <0>إعدادات الإشعارات</0> لتكوين كيفي
msgid "Select {foo}"
msgstr "تحديد {foo}"
#: src/components/routes/settings/heartbeat.tsx
msgid "Send a single heartbeat ping to verify your endpoint is working."
msgstr "أرسل ping نبضة قلب واحدة للتحقق من أن نقطة النهاية الخاصة بك تعمل."
#: 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."
msgstr "أرسل pings صادرة دورية إلى خدمة مراقبة خارجية حتى تتمكن من مراقبة Beszel دون تعريضه للإنترنت."
#: src/components/routes/settings/heartbeat.tsx
msgid "Send test heartbeat"
msgstr "إرسال نبضة قلب اختبارية"
#: src/components/routes/system.tsx
msgid "Sent"
msgstr "تم الإرسال"
@@ -1335,6 +1407,10 @@ msgstr "الخدمات"
msgid "Set percentage thresholds for meter colors."
msgstr "تعيين عتبات النسبة المئوية لألوان العداد."
#: src/components/routes/settings/heartbeat.tsx
msgid "Set the following environment variables on your Beszel hub to enable heartbeat monitoring:"
msgstr "قم بتعيين متغيرات البيئة التالية على مركز Beszel الخاص بك لتمكين مراقبة نبضة القلب:"
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/navbar.tsx
@@ -1452,10 +1528,18 @@ msgstr "درجات حرارة مستشعرات النظام"
msgid "Test <0>URL</0>"
msgstr "اختبار <0>URL</0>"
#: src/components/routes/settings/heartbeat.tsx
msgid "Test heartbeat"
msgstr "اختبار نبضة القلب"
#: src/components/routes/settings/notifications.tsx
msgid "Test notification sent"
msgstr "تم إرسال إشعار الاختبار"
#: src/components/routes/settings/heartbeat.tsx
msgid "The overall status is <0>ok</0> when all systems are up, <1>warn</1> when alerts are triggered, and <2>error</2> when any system is down."
msgstr "الحالة العامة هي <0>موافق</0> عندما تكون جميع الأنظمة تعمل، و<1>تحذير</1> عند تشغيل التنبيهات، و<2>خطأ</2> عندما يكون أي نظام معطلاً."
#: src/components/login/forgot-pass-form.tsx
msgid "Then log into the backend and reset your user account password in the users table."
msgstr "ثم قم بتسجيل الدخول إلى الواجهة الخلفية وأعد تعيين كلمة مرور حساب المستخدم الخاص بك في جدول المستخدمين."
@@ -1642,6 +1726,7 @@ msgid "Upload"
msgstr "رفع"
#: src/components/routes/system/info-bar.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Uptime"
msgstr "مدة التشغيل"
@@ -1716,6 +1801,10 @@ msgstr "إشعارات Webhook / Push"
msgid "When enabled, this token allows agents to self-register without prior system creation."
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."
msgstr "عند استخدام POST، تتضمن كل نبضة قلب حمولة JSON مع ملخص حالة النظام وقائمة الأنظمة المعطلة والتنبيهات التي تم تشغيلها."
#: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Button to copy install command"

View File

@@ -93,6 +93,7 @@ msgstr "Действия"
#: src/components/alerts-history-columns.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/heartbeat.tsx
#: src/components/routes/settings/quiet-hours.tsx
msgid "Active"
msgstr "Активен"
@@ -140,6 +141,10 @@ msgstr "Администратор"
msgid "After"
msgstr "След"
#: src/components/routes/settings/heartbeat.tsx
msgid "After setting the environment variables, restart your Beszel hub for changes to take effect."
msgstr "След като настроите променливите на средата, рестартирайте вашия Beszel hub, за да влязат промените в сила."
#: src/components/systems-table/systems-table-columns.tsx
msgid "Agent"
msgstr "Агент"
@@ -231,7 +236,7 @@ msgstr "Bandwidth на мрежата"
#. Battery label in systems table header
#: src/components/systems-table/systems-table-columns.tsx
msgid "Bat"
msgstr ""
msgstr "Бат"
#: src/components/routes/system.tsx
#: src/lib/alerts.ts
@@ -350,6 +355,10 @@ msgstr "Провери {email} за линк за нулиране."
msgid "Check logs for more details."
msgstr "Провери log-овете за повече информация."
#: src/components/routes/settings/heartbeat.tsx
msgid "Check your monitoring service"
msgstr "Проверете вашата услуга за мониторинг"
#: src/components/routes/settings/notifications.tsx
msgid "Check your notification service"
msgstr "Провери услугата си за удостоверяване"
@@ -621,7 +630,7 @@ msgstr "Редактирай"
#: src/components/add-system.tsx
#: src/components/routes/settings/quiet-hours.tsx
msgid "Edit {foo}"
msgstr ""
msgstr "Редактиране на {foo}"
#: src/components/login/auth-form.tsx
#: src/components/login/forgot-pass-form.tsx
@@ -643,6 +652,14 @@ msgstr "Празна"
msgid "End Time"
msgstr "Крайно време"
#: src/components/routes/settings/heartbeat.tsx
msgid "Endpoint URL"
msgstr "URL адрес на крайната точка"
#: src/components/routes/settings/heartbeat.tsx
msgid "Endpoint URL to ping (required)"
msgstr "URL адрес на крайната точка за пинг (задължително)"
#: src/components/login/login.tsx
msgid "Enter email address to reset password"
msgstr "Въведи имейл адрес за да нулираш паролата"
@@ -662,6 +679,9 @@ msgstr "Ефимерен"
#: src/components/login/auth-form.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/config-yaml.tsx
#: src/components/routes/settings/heartbeat.tsx
#: src/components/routes/settings/heartbeat.tsx
#: src/components/routes/settings/heartbeat.tsx
#: src/components/routes/settings/notifications.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
@@ -670,6 +690,10 @@ msgstr "Ефимерен"
msgid "Error"
msgstr "Грешка"
#: src/components/routes/settings/heartbeat.tsx
msgid "Example:"
msgstr "Пример:"
#. placeholder {0}: alert.value
#. placeholder {1}: info.unit
#. placeholder {2}: alert.min
@@ -679,7 +703,7 @@ msgstr "Надвишава {0}{1} в последните {2, plural, one {# м
#: src/components/systemd-table/systemd-table.tsx
msgid "Exec main PID"
msgstr ""
msgstr "PID на главния изпълнителен процес"
#: src/components/routes/settings/config-yaml.tsx
msgid "Existing systems not defined in <0>config.yml</0> will be deleted. Please make regular backups."
@@ -727,6 +751,10 @@ msgstr "Неуспешно удостоверяване"
msgid "Failed to save settings"
msgstr "Неуспешно запазване на настройки"
#: src/components/routes/settings/heartbeat.tsx
msgid "Failed to send heartbeat"
msgstr "Неуспешно изпращане на heartbeat"
#: src/components/routes/settings/notifications.tsx
msgid "Failed to send test notification"
msgstr "Неуспешно изпрати тестова нотификация"
@@ -806,6 +834,18 @@ msgstr "Мрежово"
msgid "Health"
msgstr "Здраве"
#: src/components/routes/settings/layout.tsx
msgid "Heartbeat"
msgstr "Heartbeat"
#: src/components/routes/settings/heartbeat.tsx
msgid "Heartbeat Monitoring"
msgstr "Мониторинг на heartbeat"
#: src/components/routes/settings/heartbeat.tsx
msgid "Heartbeat sent successfully"
msgstr "Heartbeat е изпратен успешно"
#: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Button to copy install command"
@@ -816,6 +856,14 @@ msgstr "Команда Homebrew"
msgid "Host / IP"
msgstr "Хост / IP"
#: src/components/routes/settings/heartbeat.tsx
msgid "HTTP Method"
msgstr "HTTP метод"
#: src/components/routes/settings/heartbeat.tsx
msgid "HTTP method: POST, GET, or HEAD (default: POST)"
msgstr "HTTP метод: POST, GET или HEAD (по подразбиране: POST)"
#. Context: Battery state
#: src/lib/i18n.ts
msgid "Idle"
@@ -834,6 +882,10 @@ msgstr "Образ"
msgid "Inactive"
msgstr "Неактивен"
#: src/components/routes/settings/heartbeat.tsx
msgid "Interval"
msgstr "Интервал"
#: src/components/login/auth-form.tsx
msgid "Invalid email address."
msgstr "Невалиден имейл адрес."
@@ -914,7 +966,7 @@ msgstr "Търсиш къде да създадеш тревоги? Натисн
#: src/components/systemd-table/systemd-table.tsx
msgid "Main PID"
msgstr ""
msgstr "Главен PID"
#: src/components/routes/settings/layout.tsx
msgid "Manage display and notification preferences."
@@ -1110,6 +1162,10 @@ msgstr "На пауза"
msgid "Paused ({pausedSystemsLength})"
msgstr "На пауза ({pausedSystemsLength})"
#: src/components/routes/settings/heartbeat.tsx
msgid "Payload format"
msgstr "Формат на полезния товар"
#: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/cpu-sheet.tsx
msgid "Per-core average utilization"
@@ -1307,6 +1363,10 @@ msgstr "Търси"
msgid "Search for systems or settings..."
msgstr "Търси за системи или настройки..."
#: src/components/routes/settings/heartbeat.tsx
msgid "Seconds between pings (default: 60)"
msgstr "Секунди между пинговете (по подразбиране: 60)"
#: src/components/alerts/alerts-sheet.tsx
msgid "See <0>notification settings</0> to configure how you receive alerts."
msgstr "Виж <0>настройките за нотификациите</0> за да конфигурираш как получаваш тревоги."
@@ -1315,6 +1375,18 @@ msgstr "Виж <0>настройките за нотификациите</0> з
msgid "Select {foo}"
msgstr "Избери {foo}"
#: src/components/routes/settings/heartbeat.tsx
msgid "Send a single heartbeat ping to verify your endpoint is working."
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."
msgstr "Изпращайте периодични изходящи пингове към външна услуга за мониторинг, за да можете да наблюдавате Beszel, без да го излагате на интернет."
#: src/components/routes/settings/heartbeat.tsx
msgid "Send test heartbeat"
msgstr "Изпращане на тестов heartbeat"
#: src/components/routes/system.tsx
msgid "Sent"
msgstr "Изпратени"
@@ -1335,6 +1407,10 @@ msgstr "Услуги"
msgid "Set percentage thresholds for meter colors."
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:"
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/navbar.tsx
@@ -1452,10 +1528,18 @@ msgstr "Температири на системни сензори"
msgid "Test <0>URL</0>"
msgstr "Тествай <0>URL</0>"
#: src/components/routes/settings/heartbeat.tsx
msgid "Test heartbeat"
msgstr "Тестов heartbeat"
#: src/components/routes/settings/notifications.tsx
msgid "Test notification sent"
msgstr "Тестова нотификация изпратена"
#: src/components/routes/settings/heartbeat.tsx
msgid "The overall status is <0>ok</0> when all systems are up, <1>warn</1> when alerts are triggered, and <2>error</2> when any system is down."
msgstr "Общият статус е <0>ok</0>, когато всички системи работят, <1>warn</1>, когато са задействани предупреждения, и <2>error</2>, когато някоя система е спряла."
#: src/components/login/forgot-pass-form.tsx
msgid "Then log into the backend and reset your user account password in the users table."
msgstr "След това влез в backend-а и нулирай паролата за потребителския акаунт в таблицата за потребители."
@@ -1642,8 +1726,9 @@ msgid "Upload"
msgstr "Качване"
#: src/components/routes/system/info-bar.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Uptime"
msgstr "Време на работа"
msgstr "Uptime"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
@@ -1716,6 +1801,10 @@ msgstr "Webhook / Пуш нотификации"
msgid "When enabled, this token allows agents to self-register without prior system creation."
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."
msgstr "При използване на POST всеки heartbeat включва JSON полезен товар с резюме на състоянието на системата, списък на спрените системи и задействаните предупреждения."
#: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Button to copy install command"

View File

@@ -93,6 +93,7 @@ msgstr "Akce"
#: src/components/alerts-history-columns.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/heartbeat.tsx
#: src/components/routes/settings/quiet-hours.tsx
msgid "Active"
msgstr "Aktivní"
@@ -140,6 +141,10 @@ msgstr "Administrátor"
msgid "After"
msgstr "Po"
#: src/components/routes/settings/heartbeat.tsx
msgid "After setting the environment variables, restart your Beszel hub for changes to take effect."
msgstr "Po nastavení proměnných prostředí restartujte hub Beszel, aby se změny projevily."
#: src/components/systems-table/systems-table-columns.tsx
msgid "Agent"
msgstr "Agent"
@@ -231,7 +236,7 @@ msgstr "Přenos"
#. Battery label in systems table header
#: src/components/systems-table/systems-table-columns.tsx
msgid "Bat"
msgstr ""
msgstr "Bat"
#: src/components/routes/system.tsx
#: src/lib/alerts.ts
@@ -350,6 +355,10 @@ msgstr "Zkontrolujte {email} pro odkaz na obnovení."
msgid "Check logs for more details."
msgstr "Pro více informací zkontrolujte logy."
#: src/components/routes/settings/heartbeat.tsx
msgid "Check your monitoring service"
msgstr "Zkontrolujte svou monitorovací službu"
#: src/components/routes/settings/notifications.tsx
msgid "Check your notification service"
msgstr "Zkontrolujte službu upozornění"
@@ -556,11 +565,11 @@ msgstr "Vybíjení"
#: src/components/systems-table/systems-table-columns.tsx
msgid "Disk"
msgstr ""
msgstr "Disk"
#: src/components/routes/system.tsx
msgid "Disk I/O"
msgstr ""
msgstr "Disk I/O"
#: src/components/routes/settings/general.tsx
msgid "Disk unit"
@@ -643,6 +652,14 @@ msgstr "Prázdná"
msgid "End Time"
msgstr "Čas ukončení"
#: src/components/routes/settings/heartbeat.tsx
msgid "Endpoint URL"
msgstr "URL koncového bodu"
#: src/components/routes/settings/heartbeat.tsx
msgid "Endpoint URL to ping (required)"
msgstr "URL koncového bodu pro ping (vyžadováno)"
#: src/components/login/login.tsx
msgid "Enter email address to reset password"
msgstr "Zadejte e-mailovou adresu pro obnovu hesla"
@@ -662,6 +679,9 @@ msgstr "Efemérní"
#: src/components/login/auth-form.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/config-yaml.tsx
#: src/components/routes/settings/heartbeat.tsx
#: src/components/routes/settings/heartbeat.tsx
#: src/components/routes/settings/heartbeat.tsx
#: src/components/routes/settings/notifications.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
@@ -670,6 +690,10 @@ msgstr "Efemérní"
msgid "Error"
msgstr "Chyba"
#: src/components/routes/settings/heartbeat.tsx
msgid "Example:"
msgstr "Příklad:"
#. placeholder {0}: alert.value
#. placeholder {1}: info.unit
#. placeholder {2}: alert.min
@@ -727,6 +751,10 @@ msgstr "Ověření se nezdařilo"
msgid "Failed to save settings"
msgstr "Nepodařilo se uložit nastavení"
#: src/components/routes/settings/heartbeat.tsx
msgid "Failed to send heartbeat"
msgstr "Nepodařilo se odeslat heartbeat"
#: src/components/routes/settings/notifications.tsx
msgid "Failed to send test notification"
msgstr "Nepodařilo se odeslat testovací oznámení"
@@ -755,7 +783,7 @@ msgstr "Otisk"
#: src/components/routes/system/smart-table.tsx
msgid "Firmware"
msgstr ""
msgstr "Firmware"
#: src/components/alerts/alerts-sheet.tsx
msgid "For <0>{min}</0> {min, plural, one {minute} other {minutes}}"
@@ -806,6 +834,18 @@ msgstr "Mřížka"
msgid "Health"
msgstr "Zdraví"
#: src/components/routes/settings/layout.tsx
msgid "Heartbeat"
msgstr "Heartbeat"
#: src/components/routes/settings/heartbeat.tsx
msgid "Heartbeat Monitoring"
msgstr "Monitorování heartbeat"
#: src/components/routes/settings/heartbeat.tsx
msgid "Heartbeat sent successfully"
msgstr "Heartbeat úspěšně odeslán"
#: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Button to copy install command"
@@ -816,6 +856,14 @@ msgstr "Homebrew příkaz"
msgid "Host / IP"
msgstr "Hostitel / IP"
#: src/components/routes/settings/heartbeat.tsx
msgid "HTTP Method"
msgstr "HTTP metoda"
#: src/components/routes/settings/heartbeat.tsx
msgid "HTTP method: POST, GET, or HEAD (default: POST)"
msgstr "HTTP metoda: POST, GET nebo HEAD (výchozí: POST)"
#. Context: Battery state
#: src/lib/i18n.ts
msgid "Idle"
@@ -834,6 +882,10 @@ msgstr "Obraz"
msgid "Inactive"
msgstr "Neaktivní"
#: src/components/routes/settings/heartbeat.tsx
msgid "Interval"
msgstr "Interval"
#: src/components/login/auth-form.tsx
msgid "Invalid email address."
msgstr "Neplatná e-mailová adresa."
@@ -858,7 +910,7 @@ msgstr "Životní cyklus"
#: src/components/systemd-table/systemd-table.tsx
#: src/components/systemd-table/systemd-table.tsx
msgid "limit"
msgstr ""
msgstr "limit"
#: src/components/routes/system.tsx
msgid "Load Average"
@@ -958,7 +1010,7 @@ msgstr "Využití paměti docker kontejnerů"
#: src/components/routes/system/smart-table.tsx
msgid "Model"
msgstr ""
msgstr "Model"
#: src/components/add-system.tsx
#: src/components/alerts-history-columns.tsx
@@ -1110,6 +1162,10 @@ msgstr "Pozastaveno"
msgid "Paused ({pausedSystemsLength})"
msgstr "Pozastaveno ({pausedSystemsLength})"
#: src/components/routes/settings/heartbeat.tsx
msgid "Payload format"
msgstr "Formát payloadu"
#: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/cpu-sheet.tsx
msgid "Per-core average utilization"
@@ -1162,7 +1218,7 @@ msgstr "Přihlaste se prosím k vašemu účtu"
#: src/components/add-system.tsx
msgid "Port"
msgstr ""
msgstr "Port"
#. Power On Time
#: src/components/routes/system/smart-table.tsx
@@ -1307,6 +1363,10 @@ msgstr "Hledat"
msgid "Search for systems or settings..."
msgstr "Hledat systémy nebo nastavení..."
#: src/components/routes/settings/heartbeat.tsx
msgid "Seconds between pings (default: 60)"
msgstr "Sekundy mezi pingy (výchozí: 60)"
#: src/components/alerts/alerts-sheet.tsx
msgid "See <0>notification settings</0> to configure how you receive alerts."
msgstr "Podívejte se na <0>nastavení upozornění</0> pro nastavení toho, jak přijímáte upozornění."
@@ -1315,6 +1375,18 @@ msgstr "Podívejte se na <0>nastavení upozornění</0> pro nastavení toho, jak
msgid "Select {foo}"
msgstr "Vybrat {foo}"
#: src/components/routes/settings/heartbeat.tsx
msgid "Send a single heartbeat ping to verify your endpoint is working."
msgstr "Odešlete jeden heartbeat ping pro ověření funkčnosti vašeho koncového bodu."
#: 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."
msgstr "Odesílejte periodické odchozí pingy na externí monitorovací službu, abyste mohli monitorovat Beszel bez jeho vystavení internetu."
#: src/components/routes/settings/heartbeat.tsx
msgid "Send test heartbeat"
msgstr "Odeslat testovací heartbeat"
#: src/components/routes/system.tsx
msgid "Sent"
msgstr "Odeslat"
@@ -1335,6 +1407,10 @@ msgstr "Služby"
msgid "Set percentage thresholds for meter colors."
msgstr "Nastavte procentuální prahové hodnoty pro barvy měřičů."
#: src/components/routes/settings/heartbeat.tsx
msgid "Set the following environment variables on your Beszel hub to enable heartbeat monitoring:"
msgstr "Pro povolení monitorování heartbeat nastavte na hubu Beszel následující proměnné prostředí:"
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/navbar.tsx
@@ -1452,10 +1528,18 @@ msgstr "Teploty systémových senzorů"
msgid "Test <0>URL</0>"
msgstr "Testovat <0>URL</0>"
#: src/components/routes/settings/heartbeat.tsx
msgid "Test heartbeat"
msgstr "Testovat heartbeat"
#: src/components/routes/settings/notifications.tsx
msgid "Test notification sent"
msgstr "Testovací oznámení odesláno"
#: src/components/routes/settings/heartbeat.tsx
msgid "The overall status is <0>ok</0> when all systems are up, <1>warn</1> when alerts are triggered, and <2>error</2> when any system is down."
msgstr "Celkový stav je <0>ok</0>, když jsou všechny systémy v provozu, <1>warn</1>, když jsou spuštěny výstrahy, a <2>error</2>, když je některý systém mimo provoz."
#: src/components/login/forgot-pass-form.tsx
msgid "Then log into the backend and reset your user account password in the users table."
msgstr "Poté se přihlaste do backendu a obnovte heslo k uživatelskému účtu v tabulce uživatelů."
@@ -1497,7 +1581,7 @@ msgstr "Přepnout motiv"
#: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Token"
msgstr ""
msgstr "Token"
#: src/components/command-palette.tsx
#: src/components/routes/settings/layout.tsx
@@ -1642,8 +1726,9 @@ msgid "Upload"
msgstr "Odeslání"
#: src/components/routes/system/info-bar.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Uptime"
msgstr "Doba provozu"
msgstr "Uptime"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
@@ -1716,6 +1801,10 @@ msgstr "Webhook / Push oznámení"
msgid "When enabled, this token allows agents to self-register without prior system creation."
msgstr "Pokud je povoleno, umožňuje tento token agentům samo-registraci bez předchozího vytvoření systému."
#: 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."
msgstr "Při použití metody POST obsahuje každý heartbeat JSON payload se souhrnem stavu systému, seznamem nefunkčních systémů a spuštěnými výstrahami."
#: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Button to copy install command"

View File

@@ -93,6 +93,7 @@ msgstr "Handlinger"
#: src/components/alerts-history-columns.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/heartbeat.tsx
#: src/components/routes/settings/quiet-hours.tsx
msgid "Active"
msgstr "Aktiv"
@@ -140,6 +141,10 @@ msgstr "Administrator"
msgid "After"
msgstr "Efter"
#: src/components/routes/settings/heartbeat.tsx
msgid "After setting the environment variables, restart your Beszel hub for changes to take effect."
msgstr "Efter indstilling af miljøvariablerne skal du genstarte din Beszel-hub for at ændringerne kan træde i kraft."
#: src/components/systems-table/systems-table-columns.tsx
msgid "Agent"
msgstr "Agent"
@@ -231,7 +236,7 @@ msgstr "Båndbredde"
#. Battery label in systems table header
#: src/components/systems-table/systems-table-columns.tsx
msgid "Bat"
msgstr ""
msgstr "Bat"
#: src/components/routes/system.tsx
#: src/lib/alerts.ts
@@ -350,6 +355,10 @@ msgstr "Tjek {email} for et nulstillingslink."
msgid "Check logs for more details."
msgstr "Tjek logfiler for flere detaljer."
#: src/components/routes/settings/heartbeat.tsx
msgid "Check your monitoring service"
msgstr "Tjek din overvågningstjeneste"
#: src/components/routes/settings/notifications.tsx
msgid "Check your notification service"
msgstr "Tjek din notifikationstjeneste"
@@ -643,6 +652,14 @@ msgstr "Tom"
msgid "End Time"
msgstr "Sluttid"
#: src/components/routes/settings/heartbeat.tsx
msgid "Endpoint URL"
msgstr "Endpoint-URL"
#: src/components/routes/settings/heartbeat.tsx
msgid "Endpoint URL to ping (required)"
msgstr "Endpoint-URL til ping (påkrævet)"
#: src/components/login/login.tsx
msgid "Enter email address to reset password"
msgstr "Indtast emailadresse for at nulstille adgangskoden"
@@ -662,6 +679,9 @@ msgstr "Efemer"
#: src/components/login/auth-form.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/config-yaml.tsx
#: src/components/routes/settings/heartbeat.tsx
#: src/components/routes/settings/heartbeat.tsx
#: src/components/routes/settings/heartbeat.tsx
#: src/components/routes/settings/notifications.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
@@ -670,6 +690,10 @@ msgstr "Efemer"
msgid "Error"
msgstr "Fejl"
#: src/components/routes/settings/heartbeat.tsx
msgid "Example:"
msgstr "Eksempel:"
#. placeholder {0}: alert.value
#. placeholder {1}: info.unit
#. placeholder {2}: alert.min
@@ -727,6 +751,10 @@ msgstr "Kunne ikke godkende"
msgid "Failed to save settings"
msgstr "Kunne ikke gemme indstillinger"
#: src/components/routes/settings/heartbeat.tsx
msgid "Failed to send heartbeat"
msgstr "Kunne ikke sende heartbeat"
#: src/components/routes/settings/notifications.tsx
msgid "Failed to send test notification"
msgstr "Afsendelse af testnotifikation mislykkedes"
@@ -806,6 +834,18 @@ msgstr "Gitter"
msgid "Health"
msgstr "Sundhed"
#: src/components/routes/settings/layout.tsx
msgid "Heartbeat"
msgstr "Heartbeat"
#: src/components/routes/settings/heartbeat.tsx
msgid "Heartbeat Monitoring"
msgstr "Heartbeat-overvågning"
#: src/components/routes/settings/heartbeat.tsx
msgid "Heartbeat sent successfully"
msgstr "Heartbeat sendt succesfuldt"
#: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Button to copy install command"
@@ -816,6 +856,14 @@ msgstr "Homebrew-kommando"
msgid "Host / IP"
msgstr "Vært / IP"
#: src/components/routes/settings/heartbeat.tsx
msgid "HTTP Method"
msgstr "HTTP-metode"
#: src/components/routes/settings/heartbeat.tsx
msgid "HTTP method: POST, GET, or HEAD (default: POST)"
msgstr "HTTP-metode: POST, GET eller HEAD (standard: POST)"
#. Context: Battery state
#: src/lib/i18n.ts
msgid "Idle"
@@ -834,6 +882,10 @@ msgstr "Billede"
msgid "Inactive"
msgstr "Inaktiv"
#: src/components/routes/settings/heartbeat.tsx
msgid "Interval"
msgstr "Interval"
#: src/components/login/auth-form.tsx
msgid "Invalid email address."
msgstr "Ugyldig email adresse."
@@ -1110,6 +1162,10 @@ msgstr "Sat på pause"
msgid "Paused ({pausedSystemsLength})"
msgstr "Sat på pause ({pausedSystemsLength})"
#: src/components/routes/settings/heartbeat.tsx
msgid "Payload format"
msgstr "Payload-format"
#: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/cpu-sheet.tsx
msgid "Per-core average utilization"
@@ -1121,7 +1177,7 @@ msgstr "Procentdel af tid brugt i hver tilstand"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Permanent"
msgstr ""
msgstr "Permanent"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Persistence"
@@ -1307,6 +1363,10 @@ msgstr "Søg"
msgid "Search for systems or settings..."
msgstr "Søg efter systemer eller indstillinger..."
#: src/components/routes/settings/heartbeat.tsx
msgid "Seconds between pings (default: 60)"
msgstr "Sekunder mellem pings (standard: 60)"
#: src/components/alerts/alerts-sheet.tsx
msgid "See <0>notification settings</0> to configure how you receive alerts."
msgstr "Se <0>meddelelsesindstillinger</0> for at konfigurere, hvordan du modtager alarmer."
@@ -1315,6 +1375,18 @@ msgstr "Se <0>meddelelsesindstillinger</0> for at konfigurere, hvordan du modtag
msgid "Select {foo}"
msgstr "Vælg {foo}"
#: src/components/routes/settings/heartbeat.tsx
msgid "Send a single heartbeat ping to verify your endpoint is working."
msgstr "Send et enkelt heartbeat-ping for at bekræfte, at dit endpoint fungerer."
#: 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."
msgstr "Send periodiske udgående pings til en ekstern overvågningstjeneste, så du kan overvåge Beszel uden at eksponere det for internettet."
#: src/components/routes/settings/heartbeat.tsx
msgid "Send test heartbeat"
msgstr "Send test-heartbeat"
#: src/components/routes/system.tsx
msgid "Sent"
msgstr "Sendt"
@@ -1335,6 +1407,10 @@ msgstr "Tjenester"
msgid "Set percentage thresholds for meter colors."
msgstr "Indstil procentvise tærskler for målerfarver."
#: src/components/routes/settings/heartbeat.tsx
msgid "Set the following environment variables on your Beszel hub to enable heartbeat monitoring:"
msgstr "Indstil følgende miljøvariabler på din Beszel-hub for at aktivere heartbeat-overvågning:"
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/navbar.tsx
@@ -1452,10 +1528,18 @@ msgstr "Temperaturer i systemsensorer"
msgid "Test <0>URL</0>"
msgstr "Test <0>URL</0>"
#: src/components/routes/settings/heartbeat.tsx
msgid "Test heartbeat"
msgstr "Test-heartbeat"
#: src/components/routes/settings/notifications.tsx
msgid "Test notification sent"
msgstr "Test notifikation sendt"
#: src/components/routes/settings/heartbeat.tsx
msgid "The overall status is <0>ok</0> when all systems are up, <1>warn</1> when alerts are triggered, and <2>error</2> when any system is down."
msgstr "Den overordnede status er <0>ok</0>, når alle systemer kører, <1>warn</1>, når alarmer udløses, og <2>error</2>, når et system er nede."
#: src/components/login/forgot-pass-form.tsx
msgid "Then log into the backend and reset your user account password in the users table."
msgstr "Log derefter ind på backend og nulstil adgangskoden til din brugerkonto i tabellen brugere."
@@ -1642,6 +1726,7 @@ msgid "Upload"
msgstr "Overfør"
#: src/components/routes/system/info-bar.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Uptime"
msgstr "Oppetid"
@@ -1716,6 +1801,10 @@ msgstr "Webhook / Push notifikationer"
msgid "When enabled, this token allows agents to self-register without prior system creation."
msgstr "Når aktiveret, tillader denne token agenter at registrere sig selv uden forudgående systemoprettelse."
#: 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."
msgstr "Når du bruger POST, inkluderer hvert heartbeat en JSON-payload med resumé af systemstatus, liste over systemer, der er nede, og udløste alarmer."
#: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Button to copy install command"

View File

@@ -93,6 +93,7 @@ msgstr "Aktionen"
#: src/components/alerts-history-columns.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/heartbeat.tsx
#: src/components/routes/settings/quiet-hours.tsx
msgid "Active"
msgstr "Aktiv"
@@ -140,6 +141,10 @@ msgstr "Admin"
msgid "After"
msgstr "Nach"
#: src/components/routes/settings/heartbeat.tsx
msgid "After setting the environment variables, restart your Beszel hub for changes to take effect."
msgstr "Starten Sie nach dem Festlegen der Umgebungsvariablen Ihren Beszel-Hub neu, damit die Änderungen wirksam werden."
#: src/components/systems-table/systems-table-columns.tsx
msgid "Agent"
msgstr "Agent"
@@ -231,7 +236,7 @@ msgstr "Bandbreite"
#. Battery label in systems table header
#: src/components/systems-table/systems-table-columns.tsx
msgid "Bat"
msgstr ""
msgstr "Bat"
#: src/components/routes/system.tsx
#: src/lib/alerts.ts
@@ -350,6 +355,10 @@ msgstr "Überprüfe {email} auf einen Link zum Zurücksetzen."
msgid "Check logs for more details."
msgstr "Überprüfe die Protokolle für weitere Details."
#: src/components/routes/settings/heartbeat.tsx
msgid "Check your monitoring service"
msgstr "Überprüfen Sie Ihren Überwachungsdienst"
#: src/components/routes/settings/notifications.tsx
msgid "Check your notification service"
msgstr "Überprüfe deinen Benachrichtigungsdienst"
@@ -643,6 +652,14 @@ msgstr "Leer"
msgid "End Time"
msgstr "Endzeit"
#: src/components/routes/settings/heartbeat.tsx
msgid "Endpoint URL"
msgstr "Endpunkt-URL"
#: src/components/routes/settings/heartbeat.tsx
msgid "Endpoint URL to ping (required)"
msgstr "Endpunkt-URL zum Pingen (erforderlich)"
#: src/components/login/login.tsx
msgid "Enter email address to reset password"
msgstr "E-Mail-Adresse eingeben, um das Passwort zurückzusetzen"
@@ -662,6 +679,9 @@ msgstr "Flüchtig"
#: src/components/login/auth-form.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/config-yaml.tsx
#: src/components/routes/settings/heartbeat.tsx
#: src/components/routes/settings/heartbeat.tsx
#: src/components/routes/settings/heartbeat.tsx
#: src/components/routes/settings/notifications.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
@@ -670,6 +690,10 @@ msgstr "Flüchtig"
msgid "Error"
msgstr "Fehler"
#: src/components/routes/settings/heartbeat.tsx
msgid "Example:"
msgstr "Beispiel:"
#. placeholder {0}: alert.value
#. placeholder {1}: info.unit
#. placeholder {2}: alert.min
@@ -727,6 +751,10 @@ msgstr "Authentifizierung fehlgeschlagen"
msgid "Failed to save settings"
msgstr "Einstellungen konnten nicht gespeichert werden"
#: src/components/routes/settings/heartbeat.tsx
msgid "Failed to send heartbeat"
msgstr "Heartbeat konnte nicht gesendet werden"
#: src/components/routes/settings/notifications.tsx
msgid "Failed to send test notification"
msgstr "Testbenachrichtigung konnte nicht gesendet werden"
@@ -806,6 +834,18 @@ msgstr "Raster"
msgid "Health"
msgstr "Gesundheit"
#: src/components/routes/settings/layout.tsx
msgid "Heartbeat"
msgstr "Heartbeat"
#: src/components/routes/settings/heartbeat.tsx
msgid "Heartbeat Monitoring"
msgstr "Heartbeat-Überwachung"
#: src/components/routes/settings/heartbeat.tsx
msgid "Heartbeat sent successfully"
msgstr "Heartbeat erfolgreich gesendet"
#: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Button to copy install command"
@@ -816,6 +856,14 @@ msgstr "Homebrew-Befehl"
msgid "Host / IP"
msgstr "Host / IP"
#: src/components/routes/settings/heartbeat.tsx
msgid "HTTP Method"
msgstr "HTTP-Methode"
#: src/components/routes/settings/heartbeat.tsx
msgid "HTTP method: POST, GET, or HEAD (default: POST)"
msgstr "HTTP-Methode: POST, GET oder HEAD (Standard: POST)"
#. Context: Battery state
#: src/lib/i18n.ts
msgid "Idle"
@@ -834,6 +882,10 @@ msgstr "Image"
msgid "Inactive"
msgstr "Inaktiv"
#: src/components/routes/settings/heartbeat.tsx
msgid "Interval"
msgstr "Intervall"
#: src/components/login/auth-form.tsx
msgid "Invalid email address."
msgstr "Ungültige E-Mail-Adresse."
@@ -1096,7 +1148,7 @@ msgstr "Anfrage zum Zurücksetzen des Passworts erhalten"
#: src/components/routes/settings/quiet-hours.tsx
msgid "Past"
msgstr ""
msgstr "Vergangen"
#: src/components/systems-table/systems-table-columns.tsx
msgid "Pause"
@@ -1110,6 +1162,10 @@ msgstr "Pausiert"
msgid "Paused ({pausedSystemsLength})"
msgstr "Pausiert ({pausedSystemsLength})"
#: src/components/routes/settings/heartbeat.tsx
msgid "Payload format"
msgstr "Payload-Format"
#: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/cpu-sheet.tsx
msgid "Per-core average utilization"
@@ -1121,7 +1177,7 @@ msgstr "Prozentsatz der Zeit in jedem Zustand"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Permanent"
msgstr ""
msgstr "Permanent"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Persistence"
@@ -1248,7 +1304,7 @@ msgstr "Fortsetzen"
#: src/components/systems-table/systems-table-columns.tsx
msgctxt "Root disk label"
msgid "Root"
msgstr ""
msgstr "Root"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Rotate token"
@@ -1307,6 +1363,10 @@ msgstr "Suche"
msgid "Search for systems or settings..."
msgstr "Nach Systemen oder Einstellungen suchen..."
#: src/components/routes/settings/heartbeat.tsx
msgid "Seconds between pings (default: 60)"
msgstr "Sekunden zwischen Pings (Standard: 60)"
#: src/components/alerts/alerts-sheet.tsx
msgid "See <0>notification settings</0> to configure how you receive alerts."
msgstr "Siehe <0>Benachrichtigungseinstellungen</0>, um zu konfigurieren, wie du Warnungen erhältst."
@@ -1315,6 +1375,18 @@ msgstr "Siehe <0>Benachrichtigungseinstellungen</0>, um zu konfigurieren, wie du
msgid "Select {foo}"
msgstr "Auswählen {foo}"
#: src/components/routes/settings/heartbeat.tsx
msgid "Send a single heartbeat ping to verify your endpoint is working."
msgstr "Senden Sie einen einzelnen Heartbeat-Ping, um zu überprüfen, ob Ihr Endpunkt funktioniert."
#: 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."
msgstr "Senden Sie regelmäßige ausgehende Pings an einen externen Überwachungsdienst, damit Sie Beszel überwachen können, ohne es dem Internet auszusetzen."
#: src/components/routes/settings/heartbeat.tsx
msgid "Send test heartbeat"
msgstr "Test-Heartbeat senden"
#: src/components/routes/system.tsx
msgid "Sent"
msgstr "Gesendet"
@@ -1335,6 +1407,10 @@ msgstr "Dienste"
msgid "Set percentage thresholds for meter colors."
msgstr "Prozentuale Schwellenwerte für Zählerfarben festlegen."
#: src/components/routes/settings/heartbeat.tsx
msgid "Set the following environment variables on your Beszel hub to enable heartbeat monitoring:"
msgstr "Legen Sie die folgenden Umgebungsvariablen auf Ihrem Beszel-Hub fest, um die Heartbeat-Überwachung zu aktivieren:"
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/navbar.tsx
@@ -1452,10 +1528,18 @@ msgstr "Temperaturen der Systemsensoren"
msgid "Test <0>URL</0>"
msgstr "Test <0>URL</0>"
#: src/components/routes/settings/heartbeat.tsx
msgid "Test heartbeat"
msgstr "Test-Heartbeat"
#: src/components/routes/settings/notifications.tsx
msgid "Test notification sent"
msgstr "Testbenachrichtigung gesendet"
#: src/components/routes/settings/heartbeat.tsx
msgid "The overall status is <0>ok</0> when all systems are up, <1>warn</1> when alerts are triggered, and <2>error</2> when any system is down."
msgstr "Der Gesamtstatus ist <0>ok</0>, wenn alle Systeme in Betrieb sind, <1>warn</1>, wenn Warnungen ausgelöst werden, und <2>error</2>, wenn ein System ausgefallen ist."
#: src/components/login/forgot-pass-form.tsx
msgid "Then log into the backend and reset your user account password in the users table."
msgstr "Melde dich dann im Backend an und setze dein Benutzerkontopasswort in der Benutzertabelle zurück."
@@ -1642,6 +1726,7 @@ msgid "Upload"
msgstr "Hochladen"
#: src/components/routes/system/info-bar.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Uptime"
msgstr "Betriebszeit"
@@ -1716,6 +1801,10 @@ msgstr "Webhook / Push-Benachrichtigungen"
msgid "When enabled, this token allows agents to self-register without prior system creation."
msgstr "Wenn aktiviert, ermöglicht dieser Token Agenten die Selbstregistrierung ohne vorherige Systemerstellung."
#: 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."
msgstr "Bei Verwendung von POST enthält jeder Heartbeat eine JSON-Payload mit einer Zusammenfassung des Systemstatus, einer Liste der ausgefallenen Systeme und ausgelösten Warnungen."
#: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Button to copy install command"

View File

@@ -88,6 +88,7 @@ msgstr "Actions"
#: src/components/alerts-history-columns.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/heartbeat.tsx
#: src/components/routes/settings/quiet-hours.tsx
msgid "Active"
msgstr "Active"
@@ -135,6 +136,10 @@ msgstr "Admin"
msgid "After"
msgstr "After"
#: src/components/routes/settings/heartbeat.tsx
msgid "After setting the environment variables, restart your Beszel hub for changes to take effect."
msgstr "After setting the environment variables, restart your Beszel hub for changes to take effect."
#: src/components/systems-table/systems-table-columns.tsx
msgid "Agent"
msgstr "Agent"
@@ -345,6 +350,10 @@ msgstr "Check {email} for a reset link."
msgid "Check logs for more details."
msgstr "Check logs for more details."
#: src/components/routes/settings/heartbeat.tsx
msgid "Check your monitoring service"
msgstr "Check your monitoring service"
#: src/components/routes/settings/notifications.tsx
msgid "Check your notification service"
msgstr "Check your notification service"
@@ -638,6 +647,14 @@ msgstr "Empty"
msgid "End Time"
msgstr "End Time"
#: src/components/routes/settings/heartbeat.tsx
msgid "Endpoint URL"
msgstr "Endpoint URL"
#: src/components/routes/settings/heartbeat.tsx
msgid "Endpoint URL to ping (required)"
msgstr "Endpoint URL to ping (required)"
#: src/components/login/login.tsx
msgid "Enter email address to reset password"
msgstr "Enter email address to reset password"
@@ -657,6 +674,9 @@ msgstr "Ephemeral"
#: src/components/login/auth-form.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/config-yaml.tsx
#: src/components/routes/settings/heartbeat.tsx
#: src/components/routes/settings/heartbeat.tsx
#: src/components/routes/settings/heartbeat.tsx
#: src/components/routes/settings/notifications.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
@@ -665,6 +685,10 @@ msgstr "Ephemeral"
msgid "Error"
msgstr "Error"
#: src/components/routes/settings/heartbeat.tsx
msgid "Example:"
msgstr "Example:"
#. placeholder {0}: alert.value
#. placeholder {1}: info.unit
#. placeholder {2}: alert.min
@@ -722,6 +746,10 @@ msgstr "Failed to authenticate"
msgid "Failed to save settings"
msgstr "Failed to save settings"
#: src/components/routes/settings/heartbeat.tsx
msgid "Failed to send heartbeat"
msgstr "Failed to send heartbeat"
#: src/components/routes/settings/notifications.tsx
msgid "Failed to send test notification"
msgstr "Failed to send test notification"
@@ -801,6 +829,18 @@ msgstr "Grid"
msgid "Health"
msgstr "Health"
#: src/components/routes/settings/layout.tsx
msgid "Heartbeat"
msgstr "Heartbeat"
#: src/components/routes/settings/heartbeat.tsx
msgid "Heartbeat Monitoring"
msgstr "Heartbeat Monitoring"
#: src/components/routes/settings/heartbeat.tsx
msgid "Heartbeat sent successfully"
msgstr "Heartbeat sent successfully"
#: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Button to copy install command"
@@ -811,6 +851,14 @@ msgstr "Homebrew command"
msgid "Host / IP"
msgstr "Host / IP"
#: src/components/routes/settings/heartbeat.tsx
msgid "HTTP Method"
msgstr "HTTP Method"
#: src/components/routes/settings/heartbeat.tsx
msgid "HTTP method: POST, GET, or HEAD (default: POST)"
msgstr "HTTP method: POST, GET, or HEAD (default: POST)"
#. Context: Battery state
#: src/lib/i18n.ts
msgid "Idle"
@@ -829,6 +877,10 @@ msgstr "Image"
msgid "Inactive"
msgstr "Inactive"
#: src/components/routes/settings/heartbeat.tsx
msgid "Interval"
msgstr "Interval"
#: src/components/login/auth-form.tsx
msgid "Invalid email address."
msgstr "Invalid email address."
@@ -1105,6 +1157,10 @@ msgstr "Paused"
msgid "Paused ({pausedSystemsLength})"
msgstr "Paused ({pausedSystemsLength})"
#: src/components/routes/settings/heartbeat.tsx
msgid "Payload format"
msgstr "Payload format"
#: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/cpu-sheet.tsx
msgid "Per-core average utilization"
@@ -1302,6 +1358,10 @@ msgstr "Search"
msgid "Search for systems or settings..."
msgstr "Search for systems or settings..."
#: src/components/routes/settings/heartbeat.tsx
msgid "Seconds between pings (default: 60)"
msgstr "Seconds between pings (default: 60)"
#: src/components/alerts/alerts-sheet.tsx
msgid "See <0>notification settings</0> to configure how you receive alerts."
msgstr "See <0>notification settings</0> to configure how you receive alerts."
@@ -1310,6 +1370,18 @@ msgstr "See <0>notification settings</0> to configure how you receive alerts."
msgid "Select {foo}"
msgstr "Select {foo}"
#: src/components/routes/settings/heartbeat.tsx
msgid "Send a single heartbeat ping to verify your endpoint is working."
msgstr "Send a single heartbeat ping to verify your endpoint is working."
#: 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."
msgstr "Send periodic outbound pings to an external monitoring service so you can monitor Beszel without exposing it to the internet."
#: src/components/routes/settings/heartbeat.tsx
msgid "Send test heartbeat"
msgstr "Send test heartbeat"
#: src/components/routes/system.tsx
msgid "Sent"
msgstr "Sent"
@@ -1330,6 +1402,10 @@ msgstr "Services"
msgid "Set percentage thresholds for meter colors."
msgstr "Set percentage thresholds for meter colors."
#: src/components/routes/settings/heartbeat.tsx
msgid "Set the following environment variables on your Beszel hub to enable heartbeat monitoring:"
msgstr "Set the following environment variables on your Beszel hub to enable heartbeat monitoring:"
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/navbar.tsx
@@ -1447,10 +1523,18 @@ msgstr "Temperatures of system sensors"
msgid "Test <0>URL</0>"
msgstr "Test <0>URL</0>"
#: src/components/routes/settings/heartbeat.tsx
msgid "Test heartbeat"
msgstr "Test heartbeat"
#: src/components/routes/settings/notifications.tsx
msgid "Test notification sent"
msgstr "Test notification sent"
#: src/components/routes/settings/heartbeat.tsx
msgid "The overall status is <0>ok</0> when all systems are up, <1>warn</1> when alerts are triggered, and <2>error</2> when any system is down."
msgstr "The overall status is <0>ok</0> when all systems are up, <1>warn</1> when alerts are triggered, and <2>error</2> when any system is down."
#: src/components/login/forgot-pass-form.tsx
msgid "Then log into the backend and reset your user account password in the users table."
msgstr "Then log into the backend and reset your user account password in the users table."
@@ -1637,6 +1721,7 @@ msgid "Upload"
msgstr "Upload"
#: src/components/routes/system/info-bar.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Uptime"
msgstr "Uptime"
@@ -1711,6 +1796,10 @@ msgstr "Webhook / Push notifications"
msgid "When enabled, this token allows agents to self-register without prior system creation."
msgstr "When enabled, this token allows agents to self-register without prior system creation."
#: 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."
msgstr "When using POST, each heartbeat includes a JSON payload with system status summary, list of down systems, and triggered alerts."
#: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Button to copy install command"

View File

@@ -93,6 +93,7 @@ msgstr "Acciones"
#: src/components/alerts-history-columns.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/heartbeat.tsx
#: src/components/routes/settings/quiet-hours.tsx
msgid "Active"
msgstr "Activo"
@@ -140,6 +141,10 @@ msgstr "Administrador"
msgid "After"
msgstr "Después"
#: src/components/routes/settings/heartbeat.tsx
msgid "After setting the environment variables, restart your Beszel hub for changes to take effect."
msgstr "Después de configurar las variables de entorno, reinicie su hub Beszel para que los cambios surtan efecto."
#: src/components/systems-table/systems-table-columns.tsx
msgid "Agent"
msgstr "Agente"
@@ -350,6 +355,10 @@ msgstr "Revisa {email} para un enlace de restablecimiento."
msgid "Check logs for more details."
msgstr "Revisa los registros para más detalles."
#: src/components/routes/settings/heartbeat.tsx
msgid "Check your monitoring service"
msgstr "Compruebe su servicio de monitorización"
#: src/components/routes/settings/notifications.tsx
msgid "Check your notification service"
msgstr "Verifica tu servicio de notificaciones"
@@ -643,6 +652,14 @@ msgstr "Vacía"
msgid "End Time"
msgstr "Hora de finalización"
#: src/components/routes/settings/heartbeat.tsx
msgid "Endpoint URL"
msgstr "URL del punto de conexión"
#: src/components/routes/settings/heartbeat.tsx
msgid "Endpoint URL to ping (required)"
msgstr "URL del punto de conexión para ping (obligatorio)"
#: src/components/login/login.tsx
msgid "Enter email address to reset password"
msgstr "Ingresa la dirección de correo electrónico para restablecer la contraseña"
@@ -662,6 +679,9 @@ msgstr "Efímero"
#: src/components/login/auth-form.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/config-yaml.tsx
#: src/components/routes/settings/heartbeat.tsx
#: src/components/routes/settings/heartbeat.tsx
#: src/components/routes/settings/heartbeat.tsx
#: src/components/routes/settings/notifications.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
@@ -670,6 +690,10 @@ msgstr "Efímero"
msgid "Error"
msgstr "Error"
#: src/components/routes/settings/heartbeat.tsx
msgid "Example:"
msgstr "Ejemplo:"
#. placeholder {0}: alert.value
#. placeholder {1}: info.unit
#. placeholder {2}: alert.min
@@ -727,6 +751,10 @@ msgstr "Error al autenticar"
msgid "Failed to save settings"
msgstr "Error al guardar la configuración"
#: src/components/routes/settings/heartbeat.tsx
msgid "Failed to send heartbeat"
msgstr "Error al enviar el latido (heartbeat)"
#: src/components/routes/settings/notifications.tsx
msgid "Failed to send test notification"
msgstr "Error al enviar la notificación de prueba"
@@ -806,6 +834,18 @@ msgstr "Cuadrícula"
msgid "Health"
msgstr "Estado"
#: src/components/routes/settings/layout.tsx
msgid "Heartbeat"
msgstr "Heartbeat"
#: src/components/routes/settings/heartbeat.tsx
msgid "Heartbeat Monitoring"
msgstr "Monitorización de Heartbeat"
#: src/components/routes/settings/heartbeat.tsx
msgid "Heartbeat sent successfully"
msgstr "Latido enviado con éxito"
#: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Button to copy install command"
@@ -816,6 +856,14 @@ msgstr "Comando Homebrew"
msgid "Host / IP"
msgstr "Servidor / IP"
#: src/components/routes/settings/heartbeat.tsx
msgid "HTTP Method"
msgstr "Método HTTP"
#: src/components/routes/settings/heartbeat.tsx
msgid "HTTP method: POST, GET, or HEAD (default: POST)"
msgstr "Método HTTP: POST, GET o HEAD (predeterminado: POST)"
#. Context: Battery state
#: src/lib/i18n.ts
msgid "Idle"
@@ -834,6 +882,10 @@ msgstr "Imagen"
msgid "Inactive"
msgstr "Inactivo"
#: src/components/routes/settings/heartbeat.tsx
msgid "Interval"
msgstr "Intervalo"
#: src/components/login/auth-form.tsx
msgid "Invalid email address."
msgstr "Dirección de correo electrónico no válida."
@@ -1110,6 +1162,10 @@ msgstr "Pausado"
msgid "Paused ({pausedSystemsLength})"
msgstr "Pausado ({pausedSystemsLength})"
#: src/components/routes/settings/heartbeat.tsx
msgid "Payload format"
msgstr "Formato de carga útil (payload)"
#: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/cpu-sheet.tsx
msgid "Per-core average utilization"
@@ -1307,6 +1363,10 @@ msgstr "Buscar"
msgid "Search for systems or settings..."
msgstr "Buscar sistemas o configuraciones..."
#: src/components/routes/settings/heartbeat.tsx
msgid "Seconds between pings (default: 60)"
msgstr "Segundos entre pings (predeterminado: 60)"
#: src/components/alerts/alerts-sheet.tsx
msgid "See <0>notification settings</0> to configure how you receive alerts."
msgstr "Consulta la <0>configuración de notificaciones</0> para configurar cómo recibes alertas."
@@ -1315,6 +1375,18 @@ msgstr "Consulta la <0>configuración de notificaciones</0> para configurar cóm
msgid "Select {foo}"
msgstr "Seleccionar {foo}"
#: src/components/routes/settings/heartbeat.tsx
msgid "Send a single heartbeat ping to verify your endpoint is working."
msgstr "Envíe un único ping de latido para verificar que su punto de conexión funciona."
#: 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."
msgstr "Envíe pings salientes periódicos a un servicio de monitorización externo para que pueda supervisar Beszel sin exponerlo a internet."
#: src/components/routes/settings/heartbeat.tsx
msgid "Send test heartbeat"
msgstr "Enviar latido de prueba"
#: src/components/routes/system.tsx
msgid "Sent"
msgstr "Enviado"
@@ -1335,6 +1407,10 @@ msgstr "Servicios"
msgid "Set percentage thresholds for meter colors."
msgstr "Establecer umbrales de porcentaje para los colores de los medidores."
#: src/components/routes/settings/heartbeat.tsx
msgid "Set the following environment variables on your Beszel hub to enable heartbeat monitoring:"
msgstr "Configure las siguientes variables de entorno en su hub Beszel para habilitar la monitorización de latidos:"
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/navbar.tsx
@@ -1452,10 +1528,18 @@ msgstr "Temperaturas de los sensores del sistema"
msgid "Test <0>URL</0>"
msgstr "Probar <0>URL</0>"
#: src/components/routes/settings/heartbeat.tsx
msgid "Test heartbeat"
msgstr "Probar latido"
#: src/components/routes/settings/notifications.tsx
msgid "Test notification sent"
msgstr "Notificación de prueba enviada"
#: src/components/routes/settings/heartbeat.tsx
msgid "The overall status is <0>ok</0> when all systems are up, <1>warn</1> when alerts are triggered, and <2>error</2> when any system is down."
msgstr "El estado general es <0>ok</0> cuando todos los sistemas están activos, <1>warn</1> cuando se activan alertas y <2>error</2> cuando algún sistema está caído."
#: src/components/login/forgot-pass-form.tsx
msgid "Then log into the backend and reset your user account password in the users table."
msgstr "Luego inicia sesión en el backend y restablece la contraseña de tu cuenta de usuario en la tabla de usuarios."
@@ -1642,8 +1726,9 @@ msgid "Upload"
msgstr "Cargar"
#: src/components/routes/system/info-bar.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Uptime"
msgstr "Tiempo de actividad"
msgstr "Uptime"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
@@ -1716,6 +1801,10 @@ msgstr "Notificaciones Webhook / Push"
msgid "When enabled, this token allows agents to self-register without prior system creation."
msgstr "Cuando está habilitado, este token permite a los agentes registrarse automáticamente sin creación previa del sistema."
#: 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."
msgstr "Al usar POST, cada latido incluye una carga útil JSON con un resumen del estado del sistema, una lista de sistemas caídos y alertas activadas."
#: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Button to copy install command"

View File

@@ -93,6 +93,7 @@ msgstr "عملیات"
#: src/components/alerts-history-columns.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/heartbeat.tsx
#: src/components/routes/settings/quiet-hours.tsx
msgid "Active"
msgstr "فعال"
@@ -140,6 +141,10 @@ msgstr "مدیر"
msgid "After"
msgstr "بعد از"
#: src/components/routes/settings/heartbeat.tsx
msgid "After setting the environment variables, restart your Beszel hub for changes to take effect."
msgstr "پس از تنظیم متغیرهای محیطی، هاب Beszel خود را مجدداً راه اندازی کنید تا تغییرات اعمال شوند."
#: src/components/systems-table/systems-table-columns.tsx
msgid "Agent"
msgstr "عامل"
@@ -350,6 +355,10 @@ msgstr "ایمیل {email} خود را برای لینک بازنشانی برر
msgid "Check logs for more details."
msgstr "برای جزئیات بیشتر، لاگ‌ها را بررسی کنید."
#: src/components/routes/settings/heartbeat.tsx
msgid "Check your monitoring service"
msgstr "سرویس نظارتی خود را بررسی کنید"
#: src/components/routes/settings/notifications.tsx
msgid "Check your notification service"
msgstr "سرویس اطلاع‌رسانی خود را بررسی کنید"
@@ -643,6 +652,14 @@ msgstr "خالی"
msgid "End Time"
msgstr "زمان پایان"
#: src/components/routes/settings/heartbeat.tsx
msgid "Endpoint URL"
msgstr "URL نقطه پایانی"
#: src/components/routes/settings/heartbeat.tsx
msgid "Endpoint URL to ping (required)"
msgstr "URL نقطه پایانی برای پینگ (الزامی)"
#: src/components/login/login.tsx
msgid "Enter email address to reset password"
msgstr "آدرس ایمیل را برای بازنشانی رمز عبور وارد کنید"
@@ -662,6 +679,9 @@ msgstr "گذرا"
#: src/components/login/auth-form.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/config-yaml.tsx
#: src/components/routes/settings/heartbeat.tsx
#: src/components/routes/settings/heartbeat.tsx
#: src/components/routes/settings/heartbeat.tsx
#: src/components/routes/settings/notifications.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
@@ -670,6 +690,10 @@ msgstr "گذرا"
msgid "Error"
msgstr "خطا"
#: src/components/routes/settings/heartbeat.tsx
msgid "Example:"
msgstr "مثال:"
#. placeholder {0}: alert.value
#. placeholder {1}: info.unit
#. placeholder {2}: alert.min
@@ -727,6 +751,10 @@ msgstr "احراز هویت ناموفق بود"
msgid "Failed to save settings"
msgstr "ذخیره تنظیمات ناموفق بود"
#: src/components/routes/settings/heartbeat.tsx
msgid "Failed to send heartbeat"
msgstr "ارسال ضربان قلب ناموفق بود"
#: src/components/routes/settings/notifications.tsx
msgid "Failed to send test notification"
msgstr "ارسال اعلان آزمایشی ناموفق بود"
@@ -806,6 +834,18 @@ msgstr "جدول"
msgid "Health"
msgstr "سلامتی"
#: src/components/routes/settings/layout.tsx
msgid "Heartbeat"
msgstr "ضربان قلب"
#: src/components/routes/settings/heartbeat.tsx
msgid "Heartbeat Monitoring"
msgstr "نظارت بر ضربان قلب"
#: src/components/routes/settings/heartbeat.tsx
msgid "Heartbeat sent successfully"
msgstr "ضربان قلب با موفقیت ارسال شد"
#: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Button to copy install command"
@@ -816,6 +856,14 @@ msgstr "دستور Homebrew"
msgid "Host / IP"
msgstr "میزبان / IP"
#: src/components/routes/settings/heartbeat.tsx
msgid "HTTP Method"
msgstr "متد HTTP"
#: src/components/routes/settings/heartbeat.tsx
msgid "HTTP method: POST, GET, or HEAD (default: POST)"
msgstr "متد HTTP: POST، GET، یا HEAD (پیش‌فرض: POST)"
#. Context: Battery state
#: src/lib/i18n.ts
msgid "Idle"
@@ -834,6 +882,10 @@ msgstr "تصویر"
msgid "Inactive"
msgstr "غیرفعال"
#: src/components/routes/settings/heartbeat.tsx
msgid "Interval"
msgstr "بازه زمانی"
#: src/components/login/auth-form.tsx
msgid "Invalid email address."
msgstr "آدرس ایمیل نامعتبر است."
@@ -1110,6 +1162,10 @@ msgstr "مکث شده"
msgid "Paused ({pausedSystemsLength})"
msgstr "مکث شده ({pausedSystemsLength})"
#: src/components/routes/settings/heartbeat.tsx
msgid "Payload format"
msgstr "فرمت پی‌لود"
#: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/cpu-sheet.tsx
msgid "Per-core average utilization"
@@ -1307,6 +1363,10 @@ msgstr "جستجو"
msgid "Search for systems or settings..."
msgstr "جستجو برای سیستم‌ها یا تنظیمات..."
#: src/components/routes/settings/heartbeat.tsx
msgid "Seconds between pings (default: 60)"
msgstr "ثانیه بین پینگ‌ها (پیش‌فرض: ۶۰)"
#: src/components/alerts/alerts-sheet.tsx
msgid "See <0>notification settings</0> to configure how you receive alerts."
msgstr "برای پیکربندی نحوه دریافت هشدارها، به <0>تنظیمات اعلان</0> مراجعه کنید."
@@ -1315,6 +1375,18 @@ msgstr "برای پیکربندی نحوه دریافت هشدارها، به <0
msgid "Select {foo}"
msgstr "انتخاب {foo}"
#: src/components/routes/settings/heartbeat.tsx
msgid "Send a single heartbeat ping to verify your endpoint is working."
msgstr "یک پینگ ضربان قلب تکی ارسال کنید تا از کارکرد نقطه پایانی خود اطمینان حاصل کنید."
#: 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."
msgstr "پینگ‌های خروجی دوره‌ای را به یک سرویس نظارتی خارجی ارسال کنید تا بتوانید Beszel را بدون قرار دادن آن در معرض اینترنت نظارت کنید."
#: src/components/routes/settings/heartbeat.tsx
msgid "Send test heartbeat"
msgstr "ارسال ضربان قلب آزمایشی"
#: src/components/routes/system.tsx
msgid "Sent"
msgstr "ارسال شد"
@@ -1335,6 +1407,10 @@ msgstr "سرویس‌ها"
msgid "Set percentage thresholds for meter colors."
msgstr "آستانه های درصدی را برای رنگ های متر تنظیم کنید."
#: src/components/routes/settings/heartbeat.tsx
msgid "Set the following environment variables on your Beszel hub to enable heartbeat monitoring:"
msgstr "متغیرهای محیطی زیر را در هاب Beszel خود تنظیم کنید تا نظارت بر ضربان قلب فعال شود:"
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/navbar.tsx
@@ -1452,10 +1528,18 @@ msgstr "دمای حسگرهای سیستم"
msgid "Test <0>URL</0>"
msgstr "تست <0>آدرس اینترنتی</0>"
#: src/components/routes/settings/heartbeat.tsx
msgid "Test heartbeat"
msgstr "تست ضربان قلب"
#: src/components/routes/settings/notifications.tsx
msgid "Test notification sent"
msgstr "اعلان آزمایشی ارسال شد"
#: src/components/routes/settings/heartbeat.tsx
msgid "The overall status is <0>ok</0> when all systems are up, <1>warn</1> when alerts are triggered, and <2>error</2> when any system is down."
msgstr "وضعیت کلی زمانی <0>ok</0> است که همه سیستم‌ها بالا باشند، <1>warn</1> زمانی که هشدارها فعال شوند، و <2>error</2> زمانی که هر سیستمی پایین باشد."
#: src/components/login/forgot-pass-form.tsx
msgid "Then log into the backend and reset your user account password in the users table."
msgstr "سپس وارد بخش پشتیبان شوید و رمز عبور حساب کاربری خود را در جدول کاربران بازنشانی کنید."
@@ -1642,6 +1726,7 @@ msgid "Upload"
msgstr "آپلود"
#: src/components/routes/system/info-bar.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Uptime"
msgstr "آپتایم"
@@ -1716,6 +1801,10 @@ msgstr "اعلان‌های Webhook / Push"
msgid "When enabled, this token allows agents to self-register without prior system creation."
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."
msgstr "هنگام استفاده از POST، هر ضربان قلب شامل یک پی‌لود JSON با خلاصه وضعیت سیستم، لیست سیستم‌های پایین و هشدارهای فعال شده است."
#: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Button to copy install command"

View File

@@ -93,6 +93,7 @@ msgstr "Actions"
#: src/components/alerts-history-columns.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/heartbeat.tsx
#: src/components/routes/settings/quiet-hours.tsx
msgid "Active"
msgstr "Active"
@@ -140,6 +141,10 @@ msgstr "Admin"
msgid "After"
msgstr "Après"
#: src/components/routes/settings/heartbeat.tsx
msgid "After setting the environment variables, restart your Beszel hub for changes to take effect."
msgstr "Après avoir défini les variables d'environnement, redémarrez votre hub Beszel pour que les changements prennent effet."
#: src/components/systems-table/systems-table-columns.tsx
msgid "Agent"
msgstr "Agent"
@@ -231,7 +236,7 @@ msgstr "Bande passante"
#. Battery label in systems table header
#: src/components/systems-table/systems-table-columns.tsx
msgid "Bat"
msgstr ""
msgstr "Bat"
#: src/components/routes/system.tsx
#: src/lib/alerts.ts
@@ -350,6 +355,10 @@ msgstr "Vérifiez {email} pour un lien de réinitialisation."
msgid "Check logs for more details."
msgstr "Vérifiez les journaux pour plus de détails."
#: src/components/routes/settings/heartbeat.tsx
msgid "Check your monitoring service"
msgstr "Vérifiez votre service de surveillance"
#: src/components/routes/settings/notifications.tsx
msgid "Check your notification service"
msgstr "Vérifiez votre service de notification"
@@ -643,6 +652,14 @@ msgstr "Vide"
msgid "End Time"
msgstr "Heure de fin"
#: src/components/routes/settings/heartbeat.tsx
msgid "Endpoint URL"
msgstr "URL du point de terminaison"
#: src/components/routes/settings/heartbeat.tsx
msgid "Endpoint URL to ping (required)"
msgstr "URL du point de terminaison à pinguer (requis)"
#: src/components/login/login.tsx
msgid "Enter email address to reset password"
msgstr "Entrez l'adresse email pour réinitialiser le mot de passe"
@@ -662,6 +679,9 @@ msgstr "Éphémère"
#: src/components/login/auth-form.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/config-yaml.tsx
#: src/components/routes/settings/heartbeat.tsx
#: src/components/routes/settings/heartbeat.tsx
#: src/components/routes/settings/heartbeat.tsx
#: src/components/routes/settings/notifications.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
@@ -670,6 +690,10 @@ msgstr "Éphémère"
msgid "Error"
msgstr "Erreur"
#: src/components/routes/settings/heartbeat.tsx
msgid "Example:"
msgstr "Exemple :"
#. placeholder {0}: alert.value
#. placeholder {1}: info.unit
#. placeholder {2}: alert.min
@@ -727,6 +751,10 @@ msgstr "Échec de l'authentification"
msgid "Failed to save settings"
msgstr "Échec de l'enregistrement des paramètres"
#: src/components/routes/settings/heartbeat.tsx
msgid "Failed to send heartbeat"
msgstr "Échec de l'envoi du battement de cœur (heartbeat)"
#: src/components/routes/settings/notifications.tsx
msgid "Failed to send test notification"
msgstr "Échec de l'envoi de la notification de test"
@@ -806,6 +834,18 @@ msgstr "Grille"
msgid "Health"
msgstr "Santé"
#: src/components/routes/settings/layout.tsx
msgid "Heartbeat"
msgstr "Heartbeat"
#: src/components/routes/settings/heartbeat.tsx
msgid "Heartbeat Monitoring"
msgstr "Surveillance Heartbeat"
#: src/components/routes/settings/heartbeat.tsx
msgid "Heartbeat sent successfully"
msgstr "Battement de cœur envoyé avec succès"
#: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Button to copy install command"
@@ -816,6 +856,14 @@ msgstr "Commande Homebrew"
msgid "Host / IP"
msgstr "Hôte / IP"
#: src/components/routes/settings/heartbeat.tsx
msgid "HTTP Method"
msgstr "Méthode HTTP"
#: src/components/routes/settings/heartbeat.tsx
msgid "HTTP method: POST, GET, or HEAD (default: POST)"
msgstr "Méthode HTTP : POST, GET ou HEAD (par défaut : POST)"
#. Context: Battery state
#: src/lib/i18n.ts
msgid "Idle"
@@ -834,6 +882,10 @@ msgstr "Image"
msgid "Inactive"
msgstr "Inactif"
#: src/components/routes/settings/heartbeat.tsx
msgid "Interval"
msgstr "Intervalle"
#: src/components/login/auth-form.tsx
msgid "Invalid email address."
msgstr "Adresse email invalide."
@@ -1110,6 +1162,10 @@ msgstr "En pause"
msgid "Paused ({pausedSystemsLength})"
msgstr "Mis en pause ({pausedSystemsLength})"
#: src/components/routes/settings/heartbeat.tsx
msgid "Payload format"
msgstr "Format de la charge utile"
#: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/cpu-sheet.tsx
msgid "Per-core average utilization"
@@ -1307,6 +1363,10 @@ msgstr "Recherche"
msgid "Search for systems or settings..."
msgstr "Rechercher des systèmes ou des paramètres..."
#: src/components/routes/settings/heartbeat.tsx
msgid "Seconds between pings (default: 60)"
msgstr "Secondes entre les pings (par défaut : 60)"
#: src/components/alerts/alerts-sheet.tsx
msgid "See <0>notification settings</0> to configure how you receive alerts."
msgstr "Voir les <0>paramètres de notification</0> pour configurer comment vous recevez les alertes."
@@ -1315,6 +1375,18 @@ msgstr "Voir les <0>paramètres de notification</0> pour configurer comment vous
msgid "Select {foo}"
msgstr "Sélectionner {foo}"
#: src/components/routes/settings/heartbeat.tsx
msgid "Send a single heartbeat ping to verify your endpoint is working."
msgstr "Envoyez un seul ping heartbeat pour vérifier que votre point de terminaison fonctionne."
#: 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."
msgstr "Envoyez des pings sortants périodiques vers un service de surveillance externe afin de pouvoir surveiller Beszel sans l'exposer à Internet."
#: src/components/routes/settings/heartbeat.tsx
msgid "Send test heartbeat"
msgstr "Envoyer un heartbeat de test"
#: src/components/routes/system.tsx
msgid "Sent"
msgstr "Envoyé"
@@ -1335,6 +1407,10 @@ msgstr "Services"
msgid "Set percentage thresholds for meter colors."
msgstr "Définir des seuils de pourcentage pour les couleurs des compteurs."
#: src/components/routes/settings/heartbeat.tsx
msgid "Set the following environment variables on your Beszel hub to enable heartbeat monitoring:"
msgstr "Définissez les variables d'environnement suivantes sur votre hub Beszel pour activer la surveillance du heartbeat :"
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/navbar.tsx
@@ -1452,10 +1528,18 @@ msgstr "Températures des capteurs du système"
msgid "Test <0>URL</0>"
msgstr "Tester <0>URL</0>"
#: src/components/routes/settings/heartbeat.tsx
msgid "Test heartbeat"
msgstr "Tester le heartbeat"
#: src/components/routes/settings/notifications.tsx
msgid "Test notification sent"
msgstr "Notification de test envoyée"
#: src/components/routes/settings/heartbeat.tsx
msgid "The overall status is <0>ok</0> when all systems are up, <1>warn</1> when alerts are triggered, and <2>error</2> when any system is down."
msgstr "L'état général est <0>ok</0> quand tous les systèmes sont opérationnels, <1>warn</1> quand des alertes sont déclenchées, et <2>error</2> quand un système est en panne."
#: src/components/login/forgot-pass-form.tsx
msgid "Then log into the backend and reset your user account password in the users table."
msgstr "Ensuite, connectez-vous au backend et réinitialisez le mot de passe de votre compte utilisateur dans la table des utilisateurs."
@@ -1642,8 +1726,9 @@ msgid "Upload"
msgstr "Téléverser"
#: src/components/routes/system/info-bar.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Uptime"
msgstr "Temps de fonctionnement"
msgstr "Uptime"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
@@ -1716,6 +1801,10 @@ msgstr "Notifications Webhook / Push"
msgid "When enabled, this token allows agents to self-register without prior system creation."
msgstr "Lorsqu'il est activé, ce jeton permet aux agents de s'enregistrer automatiquement sans création préalable du système."
#: 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."
msgstr "En utilisant POST, chaque heartbeat inclut une charge utile JSON avec un résumé de l'état du sistema, la liste des systèmes en panne et les alertes déclenchées."
#: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Button to copy install command"

View File

@@ -93,6 +93,7 @@ msgstr "פעולות"
#: src/components/alerts-history-columns.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/heartbeat.tsx
#: src/components/routes/settings/quiet-hours.tsx
msgid "Active"
msgstr "פעיל"
@@ -140,6 +141,10 @@ msgstr "מנהל"
msgid "After"
msgstr "אחרי"
#: src/components/routes/settings/heartbeat.tsx
msgid "After setting the environment variables, restart your Beszel hub for changes to take effect."
msgstr "לאחר הגדרת משתני הסביבה, הפעל מחדש את ה-Beszel hub שלך כדי שהשינויים ייכנסו לתוקף."
#: src/components/systems-table/systems-table-columns.tsx
msgid "Agent"
msgstr "סוכן"
@@ -350,6 +355,10 @@ msgstr "בדוק את {email} לקישור איפוס."
msgid "Check logs for more details."
msgstr "בדוק לוגים לפרטים נוספים"
#: src/components/routes/settings/heartbeat.tsx
msgid "Check your monitoring service"
msgstr "בדוק את שירות הניטור שלך"
#: src/components/routes/settings/notifications.tsx
msgid "Check your notification service"
msgstr "בדוק את שירות ההתראות שלך"
@@ -643,6 +652,14 @@ msgstr "ריק"
msgid "End Time"
msgstr "זמן סיום"
#: src/components/routes/settings/heartbeat.tsx
msgid "Endpoint URL"
msgstr "URL של נקודת קצה"
#: src/components/routes/settings/heartbeat.tsx
msgid "Endpoint URL to ping (required)"
msgstr "URL של נקודת קצה לפינג (חובה)"
#: src/components/login/login.tsx
msgid "Enter email address to reset password"
msgstr "הכנס כתובת אימייל לאיפוס סיסמה"
@@ -662,6 +679,9 @@ msgstr "זמני"
#: src/components/login/auth-form.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/config-yaml.tsx
#: src/components/routes/settings/heartbeat.tsx
#: src/components/routes/settings/heartbeat.tsx
#: src/components/routes/settings/heartbeat.tsx
#: src/components/routes/settings/notifications.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
@@ -670,6 +690,10 @@ msgstr "זמני"
msgid "Error"
msgstr "שגיאה"
#: src/components/routes/settings/heartbeat.tsx
msgid "Example:"
msgstr "דוגמה:"
#. placeholder {0}: alert.value
#. placeholder {1}: info.unit
#. placeholder {2}: alert.min
@@ -727,6 +751,10 @@ msgstr "אימות נכשל"
msgid "Failed to save settings"
msgstr "שמירת הגדרות נכשלה"
#: src/components/routes/settings/heartbeat.tsx
msgid "Failed to send heartbeat"
msgstr "שליחת פעימת הלב נכשלה"
#: src/components/routes/settings/notifications.tsx
msgid "Failed to send test notification"
msgstr "שליחת התראת בדיקה נכשלה"
@@ -806,6 +834,18 @@ msgstr "רשת"
msgid "Health"
msgstr "בריאות"
#: src/components/routes/settings/layout.tsx
msgid "Heartbeat"
msgstr "פעימת לב"
#: src/components/routes/settings/heartbeat.tsx
msgid "Heartbeat Monitoring"
msgstr "ניטור פעימות לב"
#: src/components/routes/settings/heartbeat.tsx
msgid "Heartbeat sent successfully"
msgstr "פעימת הלב נשלחה בהצלחה"
#: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Button to copy install command"
@@ -816,6 +856,14 @@ msgstr "פקודת Homebrew"
msgid "Host / IP"
msgstr "מארח / IP"
#: src/components/routes/settings/heartbeat.tsx
msgid "HTTP Method"
msgstr "שיטת HTTP"
#: src/components/routes/settings/heartbeat.tsx
msgid "HTTP method: POST, GET, or HEAD (default: POST)"
msgstr "שיטת HTTP: POST, GET, או HEAD (ברירת מחדל: POST)"
#. Context: Battery state
#: src/lib/i18n.ts
msgid "Idle"
@@ -834,6 +882,10 @@ msgstr "תמונה"
msgid "Inactive"
msgstr "לא פעיל"
#: src/components/routes/settings/heartbeat.tsx
msgid "Interval"
msgstr "מרווח"
#: src/components/login/auth-form.tsx
msgid "Invalid email address."
msgstr "כתובת אימייל לא תקינה."
@@ -1110,6 +1162,10 @@ msgstr "מושהה"
msgid "Paused ({pausedSystemsLength})"
msgstr "מושהה ({pausedSystemsLength})"
#: src/components/routes/settings/heartbeat.tsx
msgid "Payload format"
msgstr "פורמט מטען (Payload)"
#: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/cpu-sheet.tsx
msgid "Per-core average utilization"
@@ -1307,6 +1363,10 @@ msgstr "חיפוש"
msgid "Search for systems or settings..."
msgstr "חפש מערכות או הגדרות..."
#: src/components/routes/settings/heartbeat.tsx
msgid "Seconds between pings (default: 60)"
msgstr "שניות בין פינגים (ברירת מחדל: 60)"
#: src/components/alerts/alerts-sheet.tsx
msgid "See <0>notification settings</0> to configure how you receive alerts."
msgstr "ראה <0>הגדרות התראות</0> כדי להגדיר כיצד אתה מקבל התראות."
@@ -1315,6 +1375,18 @@ msgstr "ראה <0>הגדרות התראות</0> כדי להגדיר כיצד א
msgid "Select {foo}"
msgstr "בחר {foo}"
#: src/components/routes/settings/heartbeat.tsx
msgid "Send a single heartbeat ping to verify your endpoint is working."
msgstr "שלח פינג פעימת לב בודד כדי לוודא שנקודת הקצה שלך עובדת."
#: 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."
msgstr "שלח פינגים יוצאים תקופתיים לשירות ניטור חיצוני כדי שתוכל לנטר את Beszel מבלי לחשוף אותו לאינטרנט."
#: src/components/routes/settings/heartbeat.tsx
msgid "Send test heartbeat"
msgstr "שלח פעימת לב לבדיקה"
#: src/components/routes/system.tsx
msgid "Sent"
msgstr "נשלח"
@@ -1335,6 +1407,10 @@ msgstr "שירותים"
msgid "Set percentage thresholds for meter colors."
msgstr "הגדר סף אחוזים עבור צבעי מד."
#: src/components/routes/settings/heartbeat.tsx
msgid "Set the following environment variables on your Beszel hub to enable heartbeat monitoring:"
msgstr "הגדר את משתני הסביבה הבאים ב-Beszel hub שלך כדי לאפשר ניטור פעימות לב:"
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/navbar.tsx
@@ -1452,10 +1528,18 @@ msgstr "טמפרטורות של חיישני המערכת"
msgid "Test <0>URL</0>"
msgstr "בדוק <0>URL</0>"
#: src/components/routes/settings/heartbeat.tsx
msgid "Test heartbeat"
msgstr "בדוק פעימת לב"
#: src/components/routes/settings/notifications.tsx
msgid "Test notification sent"
msgstr "התראת בדיקה נשלחה"
#: src/components/routes/settings/heartbeat.tsx
msgid "The overall status is <0>ok</0> when all systems are up, <1>warn</1> when alerts are triggered, and <2>error</2> when any system is down."
msgstr "הסטטוס הכללי הוא <0>ok</0> כשכל המערכות פועלות, <1>warn</1> כשמופעלות התראות, ו-<2>error</2> כשמערכת כלשהי מושבתת."
#: src/components/login/forgot-pass-form.tsx
msgid "Then log into the backend and reset your user account password in the users table."
msgstr "לאחר מכן התחבר ל-backend ואפס את סיסמת חשבון המשתמש שלך בטבלת המשתמשים."
@@ -1642,6 +1726,7 @@ msgid "Upload"
msgstr "העלאה"
#: src/components/routes/system/info-bar.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Uptime"
msgstr "זמן פעילות"
@@ -1716,6 +1801,10 @@ msgstr "Webhook / התראות דחיפה"
msgid "When enabled, this token allows agents to self-register without prior system creation."
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."
msgstr "בשימוש ב-POST, כל פעימת לב כוללת מטען JSON עם סיכום סטטוס המערכת, רשימת מערכות מושבתות והתראות שהופעלו."
#: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Button to copy install command"

View File

@@ -93,6 +93,7 @@ msgstr "Akcije"
#: src/components/alerts-history-columns.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/heartbeat.tsx
#: src/components/routes/settings/quiet-hours.tsx
msgid "Active"
msgstr "Aktivan"
@@ -140,6 +141,10 @@ msgstr "Admin"
msgid "After"
msgstr "Nakon"
#: src/components/routes/settings/heartbeat.tsx
msgid "After setting the environment variables, restart your Beszel hub for changes to take effect."
msgstr "Nakon postavljanja varijabli okruženja, ponovno pokrenite svoj Beszel hub kako bi promjene stupile na snagu."
#: src/components/systems-table/systems-table-columns.tsx
msgid "Agent"
msgstr "Agent"
@@ -231,7 +236,7 @@ msgstr "Mrežna Propusnost"
#. Battery label in systems table header
#: src/components/systems-table/systems-table-columns.tsx
msgid "Bat"
msgstr ""
msgstr "Bat"
#: src/components/routes/system.tsx
#: src/lib/alerts.ts
@@ -350,6 +355,10 @@ msgstr "Provjerite {email} za pristup poveznici za resetiranje."
msgid "Check logs for more details."
msgstr "Provjerite zapise (logove) za više detalja."
#: src/components/routes/settings/heartbeat.tsx
msgid "Check your monitoring service"
msgstr "Provjerite svoju uslugu nadzora"
#: src/components/routes/settings/notifications.tsx
msgid "Check your notification service"
msgstr "Provjerite svoju obavještajnu uslugu"
@@ -643,6 +652,14 @@ msgstr "Prazno"
msgid "End Time"
msgstr "Vrijeme završetka"
#: src/components/routes/settings/heartbeat.tsx
msgid "Endpoint URL"
msgstr "URL krajnje točke"
#: src/components/routes/settings/heartbeat.tsx
msgid "Endpoint URL to ping (required)"
msgstr "URL krajnje točke za pinganje (obavezno)"
#: src/components/login/login.tsx
msgid "Enter email address to reset password"
msgstr "Unesite email adresu kako biste resetirali lozinku"
@@ -662,6 +679,9 @@ msgstr "Efemeran"
#: src/components/login/auth-form.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/config-yaml.tsx
#: src/components/routes/settings/heartbeat.tsx
#: src/components/routes/settings/heartbeat.tsx
#: src/components/routes/settings/heartbeat.tsx
#: src/components/routes/settings/notifications.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
@@ -670,6 +690,10 @@ msgstr "Efemeran"
msgid "Error"
msgstr "Greška"
#: src/components/routes/settings/heartbeat.tsx
msgid "Example:"
msgstr "Primjer:"
#. placeholder {0}: alert.value
#. placeholder {1}: info.unit
#. placeholder {2}: alert.min
@@ -727,6 +751,10 @@ msgstr "Neuspješna provjera autentičnosti"
msgid "Failed to save settings"
msgstr "Neuspješno spremanje postavki"
#: src/components/routes/settings/heartbeat.tsx
msgid "Failed to send heartbeat"
msgstr "Slanje heartbeata nije uspjelo"
#: src/components/routes/settings/notifications.tsx
msgid "Failed to send test notification"
msgstr "Neuspješno slanje probne obavijesti"
@@ -806,6 +834,18 @@ msgstr "Rešetka"
msgid "Health"
msgstr "Zdravlje"
#: src/components/routes/settings/layout.tsx
msgid "Heartbeat"
msgstr "Heartbeat"
#: src/components/routes/settings/heartbeat.tsx
msgid "Heartbeat Monitoring"
msgstr "Nadzor heartbeata"
#: src/components/routes/settings/heartbeat.tsx
msgid "Heartbeat sent successfully"
msgstr "Heartbeat uspješno poslan"
#: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Button to copy install command"
@@ -816,6 +856,14 @@ msgstr "Homebrew naredba"
msgid "Host / IP"
msgstr "Host / IP"
#: src/components/routes/settings/heartbeat.tsx
msgid "HTTP Method"
msgstr "HTTP metoda"
#: src/components/routes/settings/heartbeat.tsx
msgid "HTTP method: POST, GET, or HEAD (default: POST)"
msgstr "HTTP metoda: POST, GET ili HEAD (zadano: POST)"
#. Context: Battery state
#: src/lib/i18n.ts
msgid "Idle"
@@ -834,6 +882,10 @@ msgstr "Slika"
msgid "Inactive"
msgstr "Neaktivno"
#: src/components/routes/settings/heartbeat.tsx
msgid "Interval"
msgstr "Interval"
#: src/components/login/auth-form.tsx
msgid "Invalid email address."
msgstr "Nevažeća email adresa."
@@ -958,7 +1010,7 @@ msgstr "Iskorištenost memorije Docker spremnika"
#: src/components/routes/system/smart-table.tsx
msgid "Model"
msgstr ""
msgstr "Model"
#: src/components/add-system.tsx
#: src/components/alerts-history-columns.tsx
@@ -1110,6 +1162,10 @@ msgstr "Pauzirano"
msgid "Paused ({pausedSystemsLength})"
msgstr "Pauzirano ({pausedSystemsLength})"
#: src/components/routes/settings/heartbeat.tsx
msgid "Payload format"
msgstr "Format korisnog tereta (Payload)"
#: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/cpu-sheet.tsx
msgid "Per-core average utilization"
@@ -1307,6 +1363,10 @@ msgstr "Pretraži"
msgid "Search for systems or settings..."
msgstr "Pretraži za sisteme ili postavke..."
#: src/components/routes/settings/heartbeat.tsx
msgid "Seconds between pings (default: 60)"
msgstr "Sekunde između pingova (zadano: 60)"
#: src/components/alerts/alerts-sheet.tsx
msgid "See <0>notification settings</0> to configure how you receive alerts."
msgstr "Pogledajte <0>postavke obavijesti</0> da biste konfigurirali način primanja upozorenja."
@@ -1315,6 +1375,18 @@ msgstr "Pogledajte <0>postavke obavijesti</0> da biste konfigurirali način prim
msgid "Select {foo}"
msgstr "Odaberi {foo}"
#: src/components/routes/settings/heartbeat.tsx
msgid "Send a single heartbeat ping to verify your endpoint is working."
msgstr "Pošaljite jedan heartbeat ping kako biste provjerili radi li vaša krajnja točka."
#: 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."
msgstr "Šaljite povremene odlazne pingove vanjskoj usluzi nadzora kako biste mogli nadzirati Beszel bez izlaganja internetu."
#: src/components/routes/settings/heartbeat.tsx
msgid "Send test heartbeat"
msgstr "Pošalji testni heartbeat"
#: src/components/routes/system.tsx
msgid "Sent"
msgstr "Poslano"
@@ -1335,6 +1407,10 @@ msgstr "Usluge"
msgid "Set percentage thresholds for meter colors."
msgstr "Postavite pragove postotka za boje mjerača."
#: src/components/routes/settings/heartbeat.tsx
msgid "Set the following environment variables on your Beszel hub to enable heartbeat monitoring:"
msgstr "Postavite sljedeće varijable okruženja na svom Beszel hubu kako biste omogućili nadzor heartbeata:"
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/navbar.tsx
@@ -1452,10 +1528,18 @@ msgstr "Temperature sistemskih senzora"
msgid "Test <0>URL</0>"
msgstr "Testni <0>URL</0>"
#: src/components/routes/settings/heartbeat.tsx
msgid "Test heartbeat"
msgstr "Testiraj heartbeat"
#: src/components/routes/settings/notifications.tsx
msgid "Test notification sent"
msgstr "Testna obavijest poslana"
#: src/components/routes/settings/heartbeat.tsx
msgid "The overall status is <0>ok</0> when all systems are up, <1>warn</1> when alerts are triggered, and <2>error</2> when any system is down."
msgstr "Ukupni status je <0>ok</0> kada su svi sustavi u radu, <1>warn</1> kada su aktivirana upozorenja i <2>error</2> kada je bilo koji sustav isključen."
#: src/components/login/forgot-pass-form.tsx
msgid "Then log into the backend and reset your user account password in the users table."
msgstr "Zatim se prijavite u backend i resetirajte lozinku korisničkog računa u tablici korisnika."
@@ -1642,8 +1726,9 @@ msgid "Upload"
msgstr "Otpremi"
#: src/components/routes/system/info-bar.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Uptime"
msgstr "Vrijeme rada"
msgstr "Uptime"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
@@ -1716,6 +1801,10 @@ msgstr "Webhook / Push obavijest"
msgid "When enabled, this token allows agents to self-register without prior system creation."
msgstr "Kada je omogućen, ovaj token omogućuje agentima da se sami registriraju bez prethodnog stvaranja sustava."
#: 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."
msgstr "Kada koristite POST, svaki heartbeat uključuje JSON payload sa sažetkom statusa sustava, popisom isključenih sustava i aktiviranim upozorenjima."
#: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Button to copy install command"

View File

@@ -93,6 +93,7 @@ msgstr "Műveletek"
#: src/components/alerts-history-columns.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/heartbeat.tsx
#: src/components/routes/settings/quiet-hours.tsx
msgid "Active"
msgstr "Aktív"
@@ -140,6 +141,10 @@ msgstr "Adminisztráció"
msgid "After"
msgstr "Utána"
#: src/components/routes/settings/heartbeat.tsx
msgid "After setting the environment variables, restart your Beszel hub for changes to take effect."
msgstr "A környezeti változók beállítása után indítsa újra a Beszel hubot a módosítások érvénybe léptetéséhez."
#: src/components/systems-table/systems-table-columns.tsx
msgid "Agent"
msgstr "Ügynök"
@@ -350,6 +355,10 @@ msgstr "Ellenőrizd a {email} címet a visszaállító linkért."
msgid "Check logs for more details."
msgstr "Ellenőrizd a naplót a további részletekért."
#: src/components/routes/settings/heartbeat.tsx
msgid "Check your monitoring service"
msgstr "Ellenőrizze a megfigyelő szolgáltatást"
#: src/components/routes/settings/notifications.tsx
msgid "Check your notification service"
msgstr "Ellenőrizd az értesítési szolgáltatásodat"
@@ -643,6 +652,14 @@ msgstr "Üres"
msgid "End Time"
msgstr "Befejezés ideje"
#: src/components/routes/settings/heartbeat.tsx
msgid "Endpoint URL"
msgstr "Végpont URL"
#: src/components/routes/settings/heartbeat.tsx
msgid "Endpoint URL to ping (required)"
msgstr "Pingelendő végpont URL (kötelező)"
#: src/components/login/login.tsx
msgid "Enter email address to reset password"
msgstr "E-mail cím megadása a jelszó visszaállításához"
@@ -662,6 +679,9 @@ msgstr "Ideiglenes"
#: src/components/login/auth-form.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/config-yaml.tsx
#: src/components/routes/settings/heartbeat.tsx
#: src/components/routes/settings/heartbeat.tsx
#: src/components/routes/settings/heartbeat.tsx
#: src/components/routes/settings/notifications.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
@@ -670,6 +690,10 @@ msgstr "Ideiglenes"
msgid "Error"
msgstr "Hiba"
#: src/components/routes/settings/heartbeat.tsx
msgid "Example:"
msgstr "Példa:"
#. placeholder {0}: alert.value
#. placeholder {1}: info.unit
#. placeholder {2}: alert.min
@@ -727,6 +751,10 @@ msgstr "Hitelesítés sikertelen"
msgid "Failed to save settings"
msgstr "Nem sikerült menteni a beállításokat"
#: src/components/routes/settings/heartbeat.tsx
msgid "Failed to send heartbeat"
msgstr "Nem sikerült elküldeni a szívverést (heartbeat)"
#: src/components/routes/settings/notifications.tsx
msgid "Failed to send test notification"
msgstr "Teszt értesítés elküldése sikertelen"
@@ -806,6 +834,18 @@ msgstr "Rács"
msgid "Health"
msgstr "Egészség"
#: src/components/routes/settings/layout.tsx
msgid "Heartbeat"
msgstr "Heartbeat"
#: src/components/routes/settings/heartbeat.tsx
msgid "Heartbeat Monitoring"
msgstr "Heartbeat figyelés"
#: src/components/routes/settings/heartbeat.tsx
msgid "Heartbeat sent successfully"
msgstr "Heartbeat sikeresen elküldve"
#: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Button to copy install command"
@@ -816,6 +856,14 @@ msgstr "Homebrew parancs"
msgid "Host / IP"
msgstr "Állomás / IP"
#: src/components/routes/settings/heartbeat.tsx
msgid "HTTP Method"
msgstr "HTTP metódus"
#: src/components/routes/settings/heartbeat.tsx
msgid "HTTP method: POST, GET, or HEAD (default: POST)"
msgstr "HTTP metódus: POST, GET vagy HEAD (alapértelmezett: POST)"
#. Context: Battery state
#: src/lib/i18n.ts
msgid "Idle"
@@ -834,6 +882,10 @@ msgstr "Kép"
msgid "Inactive"
msgstr "Inaktív"
#: src/components/routes/settings/heartbeat.tsx
msgid "Interval"
msgstr "Intervallum"
#: src/components/login/auth-form.tsx
msgid "Invalid email address."
msgstr "Érvénytelen e-mail cím."
@@ -1110,6 +1162,10 @@ msgstr "Szüneteltetve"
msgid "Paused ({pausedSystemsLength})"
msgstr "Szüneteltetve ({pausedSystemsLength})"
#: src/components/routes/settings/heartbeat.tsx
msgid "Payload format"
msgstr "Payload formátum"
#: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/cpu-sheet.tsx
msgid "Per-core average utilization"
@@ -1307,6 +1363,10 @@ msgstr "Keresés"
msgid "Search for systems or settings..."
msgstr "Keresés rendszerek vagy beállítások után..."
#: src/components/routes/settings/heartbeat.tsx
msgid "Seconds between pings (default: 60)"
msgstr "Pingek közötti másodpercek (alapértelmezett: 60)"
#: src/components/alerts/alerts-sheet.tsx
msgid "See <0>notification settings</0> to configure how you receive alerts."
msgstr "Lásd <0>az értesítési beállításokat</0>, hogy konfigurálja, hogyan kap értesítéseket."
@@ -1315,6 +1375,18 @@ msgstr "Lásd <0>az értesítési beállításokat</0>, hogy konfigurálja, hogy
msgid "Select {foo}"
msgstr "{foo} kiválasztása"
#: src/components/routes/settings/heartbeat.tsx
msgid "Send a single heartbeat ping to verify your endpoint is working."
msgstr "Küldjön egyetlen heartbeat pinget a végpont működésének ellenőrzéséhez."
#: 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."
msgstr "Küldjön időszakos kimenő pingeket egy külső megfigyelő szolgáltatásnak, így a Beszel-t az internetnek való kitettség nélkül is megfigyelheti."
#: src/components/routes/settings/heartbeat.tsx
msgid "Send test heartbeat"
msgstr "Teszt heartbeat küldése"
#: src/components/routes/system.tsx
msgid "Sent"
msgstr "Elküldve"
@@ -1335,6 +1407,10 @@ msgstr "Szolgáltatások"
msgid "Set percentage thresholds for meter colors."
msgstr "Százalékos küszöbértékek beállítása a mérőszínekhez."
#: src/components/routes/settings/heartbeat.tsx
msgid "Set the following environment variables on your Beszel hub to enable heartbeat monitoring:"
msgstr "Állítsa be a következő környezeti változókat a Beszel hubon a heartbeat figyelés engedélyezéséhez:"
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/navbar.tsx
@@ -1452,10 +1528,18 @@ msgstr "A rendszer érzékelőinek hőmérséklete"
msgid "Test <0>URL</0>"
msgstr "Teszt <0>URL</0>"
#: src/components/routes/settings/heartbeat.tsx
msgid "Test heartbeat"
msgstr "Teszt heartbeat"
#: src/components/routes/settings/notifications.tsx
msgid "Test notification sent"
msgstr "Teszt értesítés elküldve"
#: src/components/routes/settings/heartbeat.tsx
msgid "The overall status is <0>ok</0> when all systems are up, <1>warn</1> when alerts are triggered, and <2>error</2> when any system is down."
msgstr "Az összesített állapot <0>ok</0>, ha minden rendszer fut, <1>warn</1>, ha riasztások léptek fel, és <2>error</2>, ha bármelyik rendszer leállt."
#: src/components/login/forgot-pass-form.tsx
msgid "Then log into the backend and reset your user account password in the users table."
msgstr "Ezután jelentkezzen be a backendbe, és állítsa vissza a felhasználói fiók jelszavát a felhasználók táblázatban."
@@ -1642,6 +1726,7 @@ msgid "Upload"
msgstr "Feltöltés"
#: src/components/routes/system/info-bar.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Uptime"
msgstr "Üzemidő"
@@ -1716,6 +1801,10 @@ msgstr "Webhook / Push értesítések"
msgid "When enabled, this token allows agents to self-register without prior system creation."
msgstr "Ha engedélyezve van, ez a token lehetővé teszi az ügynökök számára a regisztrációt a rendszer előzetes létrehozása nélkül."
#: 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."
msgstr "POST használata esetén minden heartbeat tartalmaz egy JSON payload-ot a rendszerállapot összefoglalójával, a leállt rendszerek listájával és a kiváltott riasztásokkal."
#: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgctxt "Button to copy install command"

Some files were not shown because too many files have changed in this diff Show More