Merge branch 'main' into fix/package-updates-sandbox

This commit is contained in:
Sven van Ginkel authored and GitHub committed 2026-10-05 22:19:36 +02:00
commit deec1d0b51
42 files changed
+1261 -397

No files matched your search

+39 -128
View File
@@ -10,42 +10,43 @@ jobs:
runs-on: ubuntu-latest
strategy:
fail-fast: false
max-parallel: 5
max-parallel: 3
matrix:
include:
# henrygd/beszel
- image: henrygd/beszel
# beszel
- image: beszel
dockerfile: ./internal/dockerfile_hub
registry: docker.io
username_secret: DOCKERHUB_USERNAME
password_secret: DOCKERHUB_TOKEN
tags: |
type=raw,value=edge
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
# henrygd/beszel-agent:alpine
- image: henrygd/beszel-agent
# beszel-agent
- image: beszel-agent
dockerfile: ./internal/dockerfile_agent
tags: |
type=raw,value=edge
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
# beszel-agent:alpine
- image: beszel-agent
dockerfile: ./internal/dockerfile_agent_alpine
flavor: latest=false
registry: docker.io
username_secret: DOCKERHUB_USERNAME
password_secret: DOCKERHUB_TOKEN
tags: |
type=raw,value=alpine
type=semver,pattern={{version}}-alpine
type=semver,pattern={{major}}.{{minor}}-alpine
type=semver,pattern={{major}}-alpine
# henrygd/beszel-agent-nvidia
- image: henrygd/beszel-agent-nvidia
# beszel-agent-nvidia
- image: beszel-agent-nvidia
dockerfile: ./internal/dockerfile_agent_nvidia
platforms: linux/amd64,linux/arm64
registry: docker.io
username_secret: DOCKERHUB_USERNAME
password_secret: DOCKERHUB_TOKEN
tags: |
type=raw,value=edge
type=semver,pattern={{version}}
@@ -53,122 +54,21 @@ jobs:
type=semver,pattern={{major}}
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
# henrygd/beszel-agent-nvidia:slim
- image: henrygd/beszel-agent-nvidia
# beszel-agent-nvidia:slim
- image: beszel-agent-nvidia
dockerfile: ./internal/dockerfile_agent_nvidia_slim
flavor: latest=false
platforms: linux/amd64,linux/arm64
registry: docker.io
username_secret: DOCKERHUB_USERNAME
password_secret: DOCKERHUB_TOKEN
tags: |
type=raw,value=slim
type=semver,pattern={{version}}-slim
type=semver,pattern={{major}}.{{minor}}-slim
type=semver,pattern={{major}}-slim
# henrygd/beszel-agent-intel
- image: henrygd/beszel-agent-intel
# beszel-agent-intel
- image: beszel-agent-intel
dockerfile: ./internal/dockerfile_agent_intel
platforms: linux/amd64
registry: docker.io
username_secret: DOCKERHUB_USERNAME
password_secret: DOCKERHUB_TOKEN
tags: |
type=raw,value=edge
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
# ghcr.io/henrygd/beszel
- image: ghcr.io/${{ github.repository }}/beszel
dockerfile: ./internal/dockerfile_hub
registry: ghcr.io
username: ${{ github.actor }}
password_secret: GITHUB_TOKEN
tags: |
type=raw,value=edge
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
# ghcr.io/henrygd/beszel-agent
- image: ghcr.io/${{ github.repository }}/beszel-agent
dockerfile: ./internal/dockerfile_agent
registry: ghcr.io
username: ${{ github.actor }}
password_secret: GITHUB_TOKEN
tags: |
type=raw,value=edge
type=raw,value=latest
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
# ghcr.io/henrygd/beszel-agent-nvidia
- image: ghcr.io/${{ github.repository }}/beszel-agent-nvidia
dockerfile: ./internal/dockerfile_agent_nvidia
platforms: linux/amd64,linux/arm64
registry: ghcr.io
username: ${{ github.actor }}
password_secret: GITHUB_TOKEN
tags: |
type=raw,value=edge
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
# ghcr.io/henrygd/beszel-agent-nvidia:slim
- image: ghcr.io/${{ github.repository }}/beszel-agent-nvidia
dockerfile: ./internal/dockerfile_agent_nvidia_slim
flavor: latest=false
platforms: linux/amd64,linux/arm64
registry: ghcr.io
username: ${{ github.actor }}
password_secret: GITHUB_TOKEN
tags: |
type=raw,value=slim
type=semver,pattern={{version}}-slim
type=semver,pattern={{major}}.{{minor}}-slim
type=semver,pattern={{major}}-slim
# ghcr.io/henrygd/beszel-agent-intel
- image: ghcr.io/${{ github.repository }}/beszel-agent-intel
dockerfile: ./internal/dockerfile_agent_intel
platforms: linux/amd64
registry: ghcr.io
username: ${{ github.actor }}
password_secret: GITHUB_TOKEN
tags: |
type=raw,value=edge
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
# ghcr.io/henrygd/beszel-agent:alpine
- image: ghcr.io/${{ github.repository }}/beszel-agent
dockerfile: ./internal/dockerfile_agent_alpine
flavor: latest=false
registry: ghcr.io
username: ${{ github.actor }}
password_secret: GITHUB_TOKEN
tags: |
type=raw,value=alpine
type=semver,pattern={{version}}-alpine
type=semver,pattern={{major}}.{{minor}}-alpine
type=semver,pattern={{major}}-alpine
# henrygd/beszel-agent
- image: henrygd/beszel-agent
dockerfile: ./internal/dockerfile_agent
registry: docker.io
username_secret: DOCKERHUB_USERNAME
password_secret: DOCKERHUB_TOKEN
tags: |
type=raw,value=edge
type=semver,pattern={{version}}
@@ -203,7 +103,11 @@ jobs:
id: metadata
uses: docker/metadata-action@v6
with:
images: ${{ matrix.image }}
# Each image is built once and pushed to both registries.
# Docker Hub is skipped when its token is missing (e.g. forks).
images: |
name=henrygd/${{ matrix.image }},enable=${{ secrets.DOCKERHUB_TOKEN != '' }}
name=ghcr.io/${{ github.repository }}/${{ matrix.image }}
# Variant images must not overwrite the standard image's latest tag.
flavor: ${{ matrix.flavor || 'latest=auto' }}
tags: ${{ matrix.tags }}
@@ -211,13 +115,20 @@ jobs:
# https://github.com/docker/login-action
- name: Login to Docker Hub
env:
password_secret_exists: ${{ secrets[matrix.password_secret] != '' && 'true' || 'false' }}
password_secret_exists: ${{ secrets.DOCKERHUB_TOKEN != '' && 'true' || 'false' }}
if: github.event_name != 'pull_request' && env.password_secret_exists == 'true'
uses: docker/login-action@v4
with:
username: ${{ matrix.username || secrets[matrix.username_secret] }}
password: ${{ secrets[matrix.password_secret] }}
registry: ${{ matrix.registry }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Build and push Docker image with Buildx (don't push on PR)
# https://github.com/docker/build-push-action
@@ -227,7 +138,7 @@ jobs:
context: ./
file: ${{ matrix.dockerfile }}
platforms: ${{ matrix.platforms || 'linux/amd64,linux/arm64,linux/arm/v6,linux/arm/v7' }}
push: ${{ github.ref_type == 'tag' && secrets[matrix.password_secret] != '' }}
push: ${{ github.ref_type == 'tag' }}
provenance: mode=max
sbom: true
tags: ${{ steps.metadata.outputs.tags }}
+8
View File
@@ -27,6 +27,7 @@ type Agent struct {
debug bool // true if LOG_LEVEL is set to debug
zfs bool // true if system has arcstats
memCalc string // Memory calculation formula
forceUseCgroup bool // Explicitly use the cgroup mount root for CPU accounting
fsNames []string // List of filesystem device names being monitored
fsStats map[string]*system.FsStats // Keeps track of disk stats for each filesystem
diskPrev map[uint16]map[string]prevDisk // Previous disk I/O counters per cache interval
@@ -79,6 +80,11 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
}
agent.memCalc, _ = utils.GetEnv("MEM_CALC")
if useCgroup, exists := utils.GetEnv("USE_CGROUP"); exists {
agent.forceUseCgroup = useCgroup == "true"
}
agent.initializeCpu()
agent.sensorConfig = agent.newSensorConfig()
// Parse disk usage cache duration (e.g., "15m", "1h") to avoid waking sleeping disks
@@ -106,6 +112,8 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
slog.Debug(beszel.Version)
agent.warnIfRootCgroup()
// initialize docker manager
agent.dockerManager = newDockerManager(agent)
+5 -5
View File
@@ -30,17 +30,17 @@ type CpuMetrics struct {
Iowait float64
Steal float64
Idle float64
// fromCgroup is set when Total comes from cgroup accounting (LXC) rather
// than /proc/stat, so per-core /proc/stat usage would not match it.
// fromCgroup is set when Total comes from cgroup accounting rather than
// /proc/stat, so per-core /proc/stat usage would not match it.
fromCgroup bool
}
// getCpuMetrics calculates detailed CPU usage metrics using cached previous measurements.
// It returns percentages for total, user, system, iowait, and steal time.
func getCpuMetrics(cacheTimeMs uint16) (CpuMetrics, error) {
func getCpuMetrics(cacheTimeMs uint16, forceUseCgroup bool) (CpuMetrics, error) {
// Inside LXC, lxcfs serves /proc/stat with the host cores' counters, not
// the guest's own usage. Prefer the cgroup's CPU accounting there. (#2332)
if metrics, ok := containerCpuMetrics(cacheTimeMs); ok {
// the guest's own usage. Prefer cgroup accounting there or when requested. (#2332)
if metrics, ok := containerCpuMetrics(cacheTimeMs, forceUseCgroup); ok {
metrics.fromCgroup = true
return metrics, nil
}
+73 -124
View File
@@ -3,6 +3,7 @@
package agent
import (
"log/slog"
"os"
"path/filepath"
"runtime"
@@ -23,15 +24,15 @@ import (
// reflects only the guest's processes, so inside LXC we derive CPU% from that
// instead.
//
// Other runtimes (Docker, Podman, k8s) are deliberately left alone: the agent
// is normally deployed there to monitor the host, and the host's /proc/stat is
// exactly what it should report.
// Other runtimes (Docker, Podman, k8s) use /proc/stat by default: the agent
// is normally deployed there to monitor the host. BESZEL_AGENT_USE_CGROUP=true
// explicitly opts into cgroup-root CPU accounting outside LXC.
// File paths and hooks are variables so tests can point them at fixtures.
var (
cpuCgroupRoot = "/sys/fs/cgroup" // default cgroup v2 mount point
cpuCgroupMountinfo = "/proc/self/mountinfo"
cpuProcSelfCgroup = "/proc/self/cgroup"
cgroupRoot = "/sys/fs/cgroup" // default cgroup v2 mount point
cgroupMountinfo = "/proc/self/mountinfo"
procSelfCgroup = "/proc/self/cgroup"
cpuSystemdContPath = "/run/systemd/container"
cpuNumCPU = runtime.NumCPU
cpuNow = time.Now
@@ -50,7 +51,7 @@ func detectLxc() bool {
// lxcfs mounted over /proc/stat is the direct cause of the host-core
// counters. Only match that mount point: an LXC host also has lxcfs
// mounted, but at /var/lib/lxcfs.
if data, err := os.ReadFile(cpuCgroupMountinfo); err == nil && procStatFromLxcfs(data) {
if data, err := os.ReadFile(cgroupMountinfo); err == nil && procStatFromLxcfs(data) {
return true
}
// set by liblxc for the container init and inherited on non-systemd guests
@@ -65,6 +66,12 @@ func detectLxc() bool {
return false
}
// useCgroup enables cgroup-root accounting automatically in LXC or
// explicitly when requested by the agent configuration.
func useCgroup(forceUse bool) bool {
return forceUse || inLxc()
}
// procStatFromLxcfs reports whether mountinfo shows lxcfs mounted on /proc/stat.
func procStatFromLxcfs(mountinfo []byte) bool {
for line := range strings.SplitSeq(string(mountinfo), "\n") {
@@ -91,10 +98,10 @@ type cgroupCpuSample struct {
var lastCgroupCpuSamples = make(map[uint16]cgroupCpuSample)
// init seeds the LXC CPU baseline so the first reported value is a real
// delta since startup rather than zero.
func init() {
if !inLxc() {
// initializeCpu seeds the cgroup baseline after Agent configuration is read,
// so the first reported CPU value is a delta instead of zero.
func (a *Agent) initializeCpu() {
if !useCgroup(a.forceUseCgroup) {
return
}
if s, ok := readContainerCpuSample(); ok {
@@ -103,12 +110,47 @@ func init() {
}
}
// containerCpuMetrics derives CPU metrics from the guest's own cgroup
// accounting when running inside LXC. It returns ok=false everywhere else and
// whenever cgroup accounting is unreadable, so callers keep the /proc/stat
// path.
func containerCpuMetrics(cacheTimeMs uint16) (CpuMetrics, bool) {
if !inLxc() {
// warnIfRootCgroup logs at startup when USE_CGROUP would read the host's root
// cgroup (e.g. a container run with --cgroupns=host), whose accounting covers
// the whole machine rather than the container.
func (a *Agent) warnIfRootCgroup() {
if a.forceUseCgroup && inRootCgroupV2() {
slog.Warn("USE_CGROUP is reading the host's root cgroup; use --cgroupns=private to monitor the container")
}
}
// cgroupV2Dir returns the cgroup v2 mount point (the mount root, not the
// agent's own leaf), or false if the process is not in the v2 hierarchy.
func cgroupV2Dir() (string, bool) {
if !utils.InCgroupV2(procSelfCgroup) {
return "", false
}
if mount := utils.CgroupMountPoint(cgroupMountinfo, "cgroup2", ""); mount != "" {
return mount, true
}
return cgroupRoot, true
}
// inRootCgroupV2 reports whether the visible cgroup v2 mount is the root
// cgroup. Every cgroup except the root has a cgroup.type file.
func inRootCgroupV2() bool {
dir, ok := cgroupV2Dir()
if !ok {
return false
}
if _, err := os.Stat(filepath.Join(dir, "cgroup.controllers")); err != nil {
return false // no cgroup v2 mount here
}
_, err := os.Stat(filepath.Join(dir, "cgroup.type"))
return os.IsNotExist(err)
}
// containerCpuMetrics derives CPU metrics from the cgroup mount root's
// accounting when running inside LXC or explicitly enabled. It returns
// ok=false when disabled or whenever cgroup accounting is unreadable, so
// callers keep the /proc/stat path.
func containerCpuMetrics(cacheTimeMs uint16, forceUseCgroup bool) (CpuMetrics, bool) {
if !useCgroup(forceUseCgroup) {
return CpuMetrics{}, false
}
cur, ok := readContainerCpuSample()
@@ -164,21 +206,21 @@ func readContainerCpuSample() (cgroupCpuSample, bool) {
// in /proc/self/cgroup (its service cgroup, or the ".lxc" leaf when started
// from an attached shell) only covers a subset and must not be descended into.
func readCgroupV2CpuSample() (cgroupCpuSample, bool) {
if !inCgroupV2() {
dir, ok := cgroupV2Dir()
if !ok {
return cgroupCpuSample{}, false // no v2 membership; try v1
}
dir := cpuCgroupRoot
if mount := cgroupMountPoint("cgroup2", ""); mount != "" {
dir = mount
stat, err := utils.ReadCgroupStat(filepath.Join(dir, "cpu.stat"))
if err != nil {
return cgroupCpuSample{}, false
}
stat := filepath.Join(dir, "cpu.stat")
usage, ok := cgroupStatValue(stat, "usage_usec")
usage, ok := stat["usage_usec"]
if !ok {
return cgroupCpuSample{}, false
}
s := cgroupCpuSample{usageUsec: usage, cores: cpuCgroupCores(dir)}
s.userUsec, _ = cgroupStatValue(stat, "user_usec")
s.systemUsec, _ = cgroupStatValue(stat, "system_usec")
s.userUsec = stat["user_usec"]
s.systemUsec = stat["system_usec"]
return s, true
}
@@ -187,7 +229,7 @@ func readCgroupV2CpuSample() (cgroupCpuSample, bool) {
// accounting includes every child cgroup, so it is read directly rather than
// the agent's own sub-cgroup.
func readCgroupV1CpuSample() (cgroupCpuSample, bool) {
dir := cgroupMountPoint("cgroup", "cpuacct")
dir := utils.CgroupMountPoint(cgroupMountinfo, "cgroup", "cpuacct")
if dir == "" {
return cgroupCpuSample{}, false
}
@@ -197,78 +239,13 @@ func readCgroupV1CpuSample() (cgroupCpuSample, bool) {
}
s := cgroupCpuSample{usageUsec: usageNs / 1000, cores: cpuCgroupCores(dir)}
// cpuacct.stat reports user/system in USER_HZ jiffies.
if v, ok := cgroupStatValue(filepath.Join(dir, "cpuacct.stat"), "user"); ok {
s.userUsec = v * 1e6 / cpuUserHZ
}
if v, ok := cgroupStatValue(filepath.Join(dir, "cpuacct.stat"), "system"); ok {
s.systemUsec = v * 1e6 / cpuUserHZ
if stat, err := utils.ReadCgroupStat(filepath.Join(dir, "cpuacct.stat")); err == nil {
s.userUsec = stat["user"] * 1e6 / cpuUserHZ
s.systemUsec = stat["system"] * 1e6 / cpuUserHZ
}
return s, true
}
// inCgroupV2 reports whether /proc/self/cgroup lists the v2 unified hierarchy
// (a "0::<path>" entry).
func inCgroupV2() bool {
data, err := os.ReadFile(cpuProcSelfCgroup)
if err != nil {
return false
}
for line := range strings.SplitSeq(string(data), "\n") {
if strings.HasPrefix(line, "0::") {
return true
}
}
return false
}
// cgroupMountPoint returns the mount point of a cgroup hierarchy from
// /proc/self/mountinfo: the cgroup2 mount for v2, or the cgroup mount whose
// super options list the wanted v1 controller.
func cgroupMountPoint(fstype, v1ctrl string) string {
data, err := os.ReadFile(cpuCgroupMountinfo)
if err != nil {
return ""
}
for _, line := range strings.Split(string(data), "\n") {
left, right, found := strings.Cut(line, " - ")
if !found {
continue
}
post := strings.Fields(right)
if len(post) == 0 || post[0] != fstype {
continue
}
if v1ctrl != "" && !mountOptHas(post, v1ctrl) {
continue
}
fields := strings.Fields(left)
if len(fields) >= 5 {
return unescapeMountPoint(fields[4])
}
}
return ""
}
// mountOptHas reports whether the comma-separated super options (field 3 after
// the " - " separator) contain opt.
func mountOptHas(post []string, opt string) bool {
if len(post) < 3 {
return false
}
for _, o := range strings.Split(post[2], ",") {
if o == opt {
return true
}
}
return false
}
// unescapeMountPoint decodes octal escapes (e.g. \040 for space) used in
// mountinfo paths.
func unescapeMountPoint(s string) string {
return strings.NewReplacer(`\040`, " ", `\011`, "\t", `\012`, "\n", `\134`, `\`).Replace(s)
}
// cpuCgroupCores returns how many CPU cores the cgroup at dir may use: the
// smallest of the process affinity mask, the cgroup cpuset, and the CPU quota.
func cpuCgroupCores(dir string) float64 {
@@ -335,38 +312,10 @@ func cpuQuotaCores(dir string) (float64, bool) {
}
}
}
if quota, ok := readCgroupInt(filepath.Join(dir, "cpu.cfs_quota_us")); ok && quota > 0 {
if period, ok := readCgroupInt(filepath.Join(dir, "cpu.cfs_period_us")); ok && period > 0 {
if quota, ok := utils.ReadIntFile(filepath.Join(dir, "cpu.cfs_quota_us")); ok && quota > 0 {
if period, ok := utils.ReadIntFile(filepath.Join(dir, "cpu.cfs_period_us")); ok && period > 0 {
return float64(quota) / float64(period), true
}
}
return 0, false
}
// cgroupStatValue returns the value of key in a cgroup "key value" stat file.
func cgroupStatValue(path, key string) (uint64, bool) {
data, err := os.ReadFile(path)
if err != nil {
return 0, false
}
for line := range strings.SplitSeq(string(data), "\n") {
name, value, found := strings.Cut(line, " ")
if !found || name != key {
continue
}
v, err := strconv.ParseUint(strings.TrimSpace(value), 10, 64)
return v, err == nil
}
return 0, false
}
// readCgroupInt reads a file containing a single signed integer
// (cpu.cfs_quota_us is -1 when no quota is set).
func readCgroupInt(path string) (int64, bool) {
data, err := os.ReadFile(path)
if err != nil {
return 0, false
}
v, err := strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64)
return v, err == nil
}
+150 -64
View File
@@ -22,13 +22,13 @@ func swapCpuContainerSeams(t *testing.T) {
numCPU func() int
now func() time.Time
}{
cpuCgroupRoot, cpuCgroupMountinfo, cpuProcSelfCgroup, cpuSystemdContPath, cpuNumCPU, cpuNow,
cgroupRoot, cgroupMountinfo, procSelfCgroup, cpuSystemdContPath, cpuNumCPU, cpuNow,
}
origInLxc := inLxc
samples := lastCgroupCpuSamples
env, hadEnv := os.LookupEnv("container")
t.Cleanup(func() {
cpuCgroupRoot, cpuCgroupMountinfo, cpuProcSelfCgroup, cpuSystemdContPath = backup.root, backup.mountinfo, backup.selfCgroup, backup.systemdCont
cgroupRoot, cgroupMountinfo, procSelfCgroup, cpuSystemdContPath = backup.root, backup.mountinfo, backup.selfCgroup, backup.systemdCont
cpuNumCPU, cpuNow = backup.numCPU, backup.now
inLxc = origInLxc
lastCgroupCpuSamples = samples
@@ -40,11 +40,13 @@ func swapCpuContainerSeams(t *testing.T) {
inLxc = sync.OnceValue(detectLxc)
lastCgroupCpuSamples = make(map[uint16]cgroupCpuSample)
os.Unsetenv("container")
t.Setenv("BESZEL_AGENT_USE_CGROUP", "")
t.Setenv("USE_CGROUP", "")
tmp := t.TempDir()
cpuCgroupRoot = filepath.Join(tmp, "cgroup")
cpuCgroupMountinfo = filepath.Join(tmp, "mountinfo")
cpuProcSelfCgroup = filepath.Join(tmp, "self-cgroup")
cgroupRoot = filepath.Join(tmp, "cgroup")
cgroupMountinfo = filepath.Join(tmp, "mountinfo")
procSelfCgroup = filepath.Join(tmp, "self-cgroup")
cpuSystemdContPath = filepath.Join(tmp, "systemd-container")
}
@@ -80,16 +82,16 @@ func TestDetectLxc(t *testing.T) {
{"systemd container lxc", func(t *testing.T) { writeCpuFixture(t, cpuSystemdContPath, "lxc\n") }, true},
{"systemd container nspawn", func(t *testing.T) { writeCpuFixture(t, cpuSystemdContPath, "systemd-nspawn\n") }, false},
{"lxcfs serving /proc/stat", func(t *testing.T) {
writeCpuFixture(t, cpuCgroupMountinfo,
writeCpuFixture(t, cgroupMountinfo,
"31 25 0:28 / /proc/stat rw,nosuid,nodev,relatime - fuse.lxcfs lxcfs rw,user_id=0,group_id=0\n")
}, true},
// an LXC host (e.g. Proxmox) mounts lxcfs too, but not over its own /proc
{"lxcfs mounted on host", func(t *testing.T) {
writeCpuFixture(t, cpuCgroupMountinfo,
writeCpuFixture(t, cgroupMountinfo,
"45 25 0:40 / /var/lib/lxcfs rw,nosuid,nodev,relatime - fuse.lxcfs lxcfs rw,user_id=0,group_id=0\n")
}, false},
{"cgroup-only mountinfo", func(t *testing.T) {
writeCpuFixture(t, cpuCgroupMountinfo,
writeCpuFixture(t, cgroupMountinfo,
"36 25 0:32 / /sys/fs/cgroup rw - cgroup2 cgroup2 rw,nsdelegate\n")
}, false},
}
@@ -104,12 +106,12 @@ func TestDetectLxc(t *testing.T) {
func TestReadCgroupV2CpuSample(t *testing.T) {
swapCpuContainerSeams(t)
writeCpuFixture(t, cpuProcSelfCgroup, "0::/\n")
writeCpuFixture(t, cpuCgroupMountinfo, "")
require.NoError(t, os.MkdirAll(cpuCgroupRoot, 0o755))
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"),
writeCpuFixture(t, procSelfCgroup, "0::/\n")
writeCpuFixture(t, cgroupMountinfo, "")
require.NoError(t, os.MkdirAll(cgroupRoot, 0o755))
writeCpuFixture(t, filepath.Join(cgroupRoot, "cpu.stat"),
"usage_usec 3000000\nuser_usec 2000000\nsystem_usec 1000000\nnr_throttled 7\n")
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpuset.cpus.effective"), "2,5-7\n")
writeCpuFixture(t, filepath.Join(cgroupRoot, "cpuset.cpus.effective"), "2,5-7\n")
cpuNumCPU = func() int { return 8 }
s, ok := readCgroupV2CpuSample()
@@ -127,10 +129,10 @@ func TestReadCgroupV2PrefersContainerRoot(t *testing.T) {
for _, rel := range []string{"system.slice/beszel-agent.service", ".lxc"} {
t.Run(rel, func(t *testing.T) {
swapCpuContainerSeams(t)
writeCpuFixture(t, cpuProcSelfCgroup, "0::/"+rel+"\n")
writeCpuFixture(t, cpuCgroupMountinfo, "")
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"), "usage_usec 9000\n")
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, rel, "cpu.stat"), "usage_usec 5\n")
writeCpuFixture(t, procSelfCgroup, "0::/"+rel+"\n")
writeCpuFixture(t, cgroupMountinfo, "")
writeCpuFixture(t, filepath.Join(cgroupRoot, "cpu.stat"), "usage_usec 9000\n")
writeCpuFixture(t, filepath.Join(cgroupRoot, rel, "cpu.stat"), "usage_usec 5\n")
s, ok := readCgroupV2CpuSample()
require.True(t, ok)
@@ -142,9 +144,9 @@ func TestReadCgroupV2PrefersContainerRoot(t *testing.T) {
// Same for v1: the cpuacct mount root covers the agent's sibling services.
func TestReadCgroupV1PrefersContainerRoot(t *testing.T) {
swapCpuContainerSeams(t)
writeCpuFixture(t, cpuProcSelfCgroup, "3:cpu,cpuacct:/system.slice/beszel-agent.service\n")
writeCpuFixture(t, procSelfCgroup, "3:cpu,cpuacct:/system.slice/beszel-agent.service\n")
v1 := filepath.Join(t.TempDir(), "cpu,cpuacct")
writeCpuFixture(t, cpuCgroupMountinfo,
writeCpuFixture(t, cgroupMountinfo,
"30 25 0:26 / "+v1+" rw,nosuid,nodev,noexec,relatime - cgroup cgroup rw,cpu,cpuacct\n")
writeCpuFixture(t, filepath.Join(v1, "cpuacct.usage"), "9000000\n")
writeCpuFixture(t, filepath.Join(v1, "system.slice/beszel-agent.service/cpuacct.usage"), "5000\n")
@@ -156,9 +158,9 @@ func TestReadCgroupV1PrefersContainerRoot(t *testing.T) {
func TestReadCgroupV1CpuSample(t *testing.T) {
swapCpuContainerSeams(t)
writeCpuFixture(t, cpuProcSelfCgroup, "3:cpuacct:/\n2:memory:/\n")
writeCpuFixture(t, procSelfCgroup, "3:cpuacct:/\n2:memory:/\n")
v1 := filepath.Join(t.TempDir(), "cpuacct")
writeCpuFixture(t, cpuCgroupMountinfo,
writeCpuFixture(t, cgroupMountinfo,
"30 25 0:26 / "+v1+" rw,nosuid,nodev,noexec,relatime - cgroup cgroup rw,cpuacct\n")
writeCpuFixture(t, filepath.Join(v1, "cpuacct.usage"), "2000000000\n")
writeCpuFixture(t, filepath.Join(v1, "cpuacct.stat"), "user 100\nsystem 50\n")
@@ -175,24 +177,24 @@ func TestReadCgroupV1CpuSample(t *testing.T) {
func TestContainerCpuMetricsMath(t *testing.T) {
swapCpuContainerSeams(t)
markLxc(t)
writeCpuFixture(t, cpuProcSelfCgroup, "0::/\n")
writeCpuFixture(t, cpuCgroupMountinfo, "")
require.NoError(t, os.MkdirAll(cpuCgroupRoot, 0o755))
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpuset.cpus.effective"), "0-3\n")
writeCpuFixture(t, procSelfCgroup, "0::/\n")
writeCpuFixture(t, cgroupMountinfo, "")
require.NoError(t, os.MkdirAll(cgroupRoot, 0o755))
writeCpuFixture(t, filepath.Join(cgroupRoot, "cpuset.cpus.effective"), "0-3\n")
cpuNumCPU = func() int { return 8 }
advance := fakeNow(t)
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"),
writeCpuFixture(t, filepath.Join(cgroupRoot, "cpu.stat"),
"usage_usec 1000000\nuser_usec 600000\nsystem_usec 400000\n")
m, ok := containerCpuMetrics(60000)
m, ok := containerCpuMetrics(60000, false)
require.True(t, ok)
assert.Zero(t, m.Total) // first call only seeds the baseline
// 1s elapsed, container burned 2 core-seconds on 4 usable cores
advance(time.Second)
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"),
writeCpuFixture(t, filepath.Join(cgroupRoot, "cpu.stat"),
"usage_usec 3000000\nuser_usec 1600000\nsystem_usec 900000\n")
m, ok = containerCpuMetrics(60000)
m, ok = containerCpuMetrics(60000, false)
require.True(t, ok)
assert.InDelta(t, 50, m.Total, 0.01)
assert.InDelta(t, 25, m.User, 0.01)
@@ -205,18 +207,18 @@ func TestContainerCpuMetricsMath(t *testing.T) {
func TestContainerCpuMetricsHonorsQuota(t *testing.T) {
swapCpuContainerSeams(t)
markLxc(t)
writeCpuFixture(t, cpuProcSelfCgroup, "0::/\n")
writeCpuFixture(t, cpuCgroupMountinfo, "")
require.NoError(t, os.MkdirAll(cpuCgroupRoot, 0o755))
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.max"), "200000 100000\n") // 2 cores
writeCpuFixture(t, procSelfCgroup, "0::/\n")
writeCpuFixture(t, cgroupMountinfo, "")
require.NoError(t, os.MkdirAll(cgroupRoot, 0o755))
writeCpuFixture(t, filepath.Join(cgroupRoot, "cpu.max"), "200000 100000\n") // 2 cores
cpuNumCPU = func() int { return 8 }
advance := fakeNow(t)
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"), "usage_usec 1000000\n")
containerCpuMetrics(60000)
writeCpuFixture(t, filepath.Join(cgroupRoot, "cpu.stat"), "usage_usec 1000000\n")
containerCpuMetrics(60000, false)
advance(time.Second)
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"), "usage_usec 2000000\n")
m, ok := containerCpuMetrics(60000)
writeCpuFixture(t, filepath.Join(cgroupRoot, "cpu.stat"), "usage_usec 2000000\n")
m, ok := containerCpuMetrics(60000, false)
require.True(t, ok)
assert.InDelta(t, 50, m.Total, 0.01) // 1 core-second against a 2-core quota
}
@@ -224,30 +226,30 @@ func TestContainerCpuMetricsHonorsQuota(t *testing.T) {
func TestContainerCpuMetricsZeroAndBackwardDelta(t *testing.T) {
swapCpuContainerSeams(t)
markLxc(t)
writeCpuFixture(t, cpuProcSelfCgroup, "0::/\n")
writeCpuFixture(t, cpuCgroupMountinfo, "")
require.NoError(t, os.MkdirAll(cpuCgroupRoot, 0o755))
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"), "usage_usec 5000000\n")
writeCpuFixture(t, procSelfCgroup, "0::/\n")
writeCpuFixture(t, cgroupMountinfo, "")
require.NoError(t, os.MkdirAll(cgroupRoot, 0o755))
writeCpuFixture(t, filepath.Join(cgroupRoot, "cpu.stat"), "usage_usec 5000000\n")
cpuNumCPU = func() int { return 4 }
advance := fakeNow(t)
// seed the baseline, then do not advance the clock: elapsed <= 0
containerCpuMetrics(60000)
m, ok := containerCpuMetrics(60000)
containerCpuMetrics(60000, false)
m, ok := containerCpuMetrics(60000, false)
require.True(t, ok)
assert.Zero(t, m.Total)
// counter goes backwards (cgroup recreated): report zero and re-baseline
advance(time.Second)
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"), "usage_usec 100000\n")
m, ok = containerCpuMetrics(60000)
writeCpuFixture(t, filepath.Join(cgroupRoot, "cpu.stat"), "usage_usec 100000\n")
m, ok = containerCpuMetrics(60000, false)
require.True(t, ok)
assert.Zero(t, m.Total)
// next tick measures from the new baseline, not the stale one
advance(time.Second)
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"), "usage_usec 1100000\n")
m, ok = containerCpuMetrics(60000)
writeCpuFixture(t, filepath.Join(cgroupRoot, "cpu.stat"), "usage_usec 1100000\n")
m, ok = containerCpuMetrics(60000, false)
require.True(t, ok)
assert.InDelta(t, 25, m.Total, 0.01) // 1e6 usec / (1s * 4 cores)
}
@@ -255,34 +257,118 @@ func TestContainerCpuMetricsZeroAndBackwardDelta(t *testing.T) {
func TestContainerCpuMetricsFallbacks(t *testing.T) {
t.Run("not in lxc", func(t *testing.T) {
swapCpuContainerSeams(t)
_, ok := containerCpuMetrics(60000)
_, ok := containerCpuMetrics(60000, false)
assert.False(t, ok)
})
// Docker agents monitor the host, so cgroup accounting must not kick in
// even when it is readable and no LXC marker is present.
t.Run("docker container", func(t *testing.T) {
swapCpuContainerSeams(t)
writeCpuFixture(t, cpuProcSelfCgroup, "0::/\n")
writeCpuFixture(t, cpuCgroupMountinfo, "")
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"), "usage_usec 5000000\n")
_, ok := containerCpuMetrics(60000)
writeCpuFixture(t, procSelfCgroup, "0::/\n")
writeCpuFixture(t, cgroupMountinfo, "")
writeCpuFixture(t, filepath.Join(cgroupRoot, "cpu.stat"), "usage_usec 5000000\n")
_, ok := containerCpuMetrics(60000, false)
assert.False(t, ok)
})
t.Run("in lxc without cgroup accounting", func(t *testing.T) {
swapCpuContainerSeams(t)
markLxc(t)
writeCpuFixture(t, cpuProcSelfCgroup, "0::/\n")
writeCpuFixture(t, cpuCgroupMountinfo, "")
// cpuCgroupRoot has no cpu.stat
_, ok := containerCpuMetrics(60000)
writeCpuFixture(t, procSelfCgroup, "0::/\n")
writeCpuFixture(t, cgroupMountinfo, "")
// cgroupRoot has no cpu.stat
_, ok := containerCpuMetrics(60000, false)
assert.False(t, ok)
})
}
func TestContainerCpuMetricsExplicitOptIn(t *testing.T) {
swapCpuContainerSeams(t)
writeCpuFixture(t, procSelfCgroup, "0::/system.slice/beszel-agent.service\n")
writeCpuFixture(t, cgroupMountinfo, "")
writeCpuFixture(t, filepath.Join(cgroupRoot, "cpu.stat"), "usage_usec 1000000\n")
cpuNumCPU = func() int { return 4 }
advance := fakeNow(t)
a := &Agent{forceUseCgroup: true}
a.initializeCpu()
require.False(t, lastCgroupCpuSamples[60000].at.IsZero())
advance(time.Second)
writeCpuFixture(t, filepath.Join(cgroupRoot, "cpu.stat"), "usage_usec 3000000\n")
m, err := getCpuMetrics(60000, a.forceUseCgroup)
require.NoError(t, err)
assert.InDelta(t, 50, m.Total, 0.01)
assert.True(t, m.fromCgroup)
}
func TestContainerCpuMetricsExplicitOptInUnreadable(t *testing.T) {
swapCpuContainerSeams(t)
writeCpuFixture(t, procSelfCgroup, "0::/\n")
_, ok := containerCpuMetrics(60000, true)
assert.False(t, ok)
}
func TestInRootCgroupV2(t *testing.T) {
swapCpuContainerSeams(t)
writeCpuFixture(t, cgroupMountinfo, "")
// no cgroup v2 membership
writeCpuFixture(t, procSelfCgroup, "4:cpu,cpuacct:/docker/abc\n")
writeCpuFixture(t, filepath.Join(cgroupRoot, "cgroup.controllers"), "cpu memory\n")
assert.False(t, inRootCgroupV2())
// --cgroupns=host: the mount is the host root, which has no cgroup.type
writeCpuFixture(t, procSelfCgroup, "0::/system.slice/docker-abc.scope\n")
assert.True(t, inRootCgroupV2())
// --cgroupns=private: the mount is the container's own (non-root) cgroup
writeCpuFixture(t, filepath.Join(cgroupRoot, "cgroup.type"), "domain\n")
assert.False(t, inRootCgroupV2())
// no cgroup v2 mount at all
require.NoError(t, os.RemoveAll(cgroupRoot))
assert.False(t, inRootCgroupV2())
}
func TestInitializeCpuDetectsLxc(t *testing.T) {
swapCpuContainerSeams(t)
markLxc(t)
writeCpuFixture(t, procSelfCgroup, "0::/\n")
writeCpuFixture(t, cgroupMountinfo, "")
writeCpuFixture(t, filepath.Join(cgroupRoot, "cpu.stat"), "usage_usec 1000000\n")
a := &Agent{}
a.initializeCpu()
assert.EqualValues(t, 1000000, lastCgroupCpuSamples[60000].usageUsec)
}
func TestNewAgentInitializesCgroupCpuFromEnv(t *testing.T) {
swapCpuContainerSeams(t)
t.Setenv("BESZEL_AGENT_USE_CGROUP", "true")
writeCpuFixture(t, procSelfCgroup, "0::/system.slice/beszel-agent.service\n")
writeCpuFixture(t, cgroupMountinfo, "")
writeCpuFixture(t, filepath.Join(cgroupRoot, "cpu.stat"), "usage_usec 1000000\n")
cpuNumCPU = func() int { return 4 }
advance := fakeNow(t)
a, err := NewAgent(t.TempDir())
require.NoError(t, err)
require.True(t, a.forceUseCgroup)
assert.EqualValues(t, 1000000, lastCgroupCpuSamples[60000].usageUsec)
// Changing the environment later must not change this agent's CPU source.
t.Setenv("BESZEL_AGENT_USE_CGROUP", "false")
advance(time.Second)
writeCpuFixture(t, filepath.Join(cgroupRoot, "cpu.stat"), "usage_usec 3000000\n")
m, err := getCpuMetrics(60000, a.forceUseCgroup)
require.NoError(t, err)
assert.InDelta(t, 50, m.Total, 0.01)
assert.True(t, m.fromCgroup)
}
// The host path must keep reporting through gopsutil untouched.
func TestGetCpuMetricsHostFallback(t *testing.T) {
swapCpuContainerSeams(t)
m, err := getCpuMetrics(60000)
m, err := getCpuMetrics(60000, false)
require.NoError(t, err)
assert.False(t, m.fromCgroup)
assert.GreaterOrEqual(t, m.Total, 0.0)
@@ -294,20 +380,20 @@ func TestGetCpuMetricsHostFallback(t *testing.T) {
func TestGetCpuMetricsPrefersCgroup(t *testing.T) {
swapCpuContainerSeams(t)
markLxc(t)
writeCpuFixture(t, cpuProcSelfCgroup, "0::/\n")
writeCpuFixture(t, cpuCgroupMountinfo, "")
require.NoError(t, os.MkdirAll(cpuCgroupRoot, 0o755))
writeCpuFixture(t, procSelfCgroup, "0::/\n")
writeCpuFixture(t, cgroupMountinfo, "")
require.NoError(t, os.MkdirAll(cgroupRoot, 0o755))
cpuNumCPU = func() int { return 4 }
advance := fakeNow(t)
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"), "usage_usec 0\n")
m, err := getCpuMetrics(60000)
writeCpuFixture(t, filepath.Join(cgroupRoot, "cpu.stat"), "usage_usec 0\n")
m, err := getCpuMetrics(60000, false)
require.NoError(t, err)
assert.Zero(t, m.Total)
advance(time.Second)
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"), "usage_usec 2000000\n")
m, err = getCpuMetrics(60000)
writeCpuFixture(t, filepath.Join(cgroupRoot, "cpu.stat"), "usage_usec 2000000\n")
m, err = getCpuMetrics(60000, false)
require.NoError(t, err)
assert.InDelta(t, 50, m.Total, 0.01)
assert.True(t, m.fromCgroup) // per-core usage is skipped for this source
+5 -1
View File
@@ -4,6 +4,10 @@ package agent
// containerCpuMetrics is Linux-only (cgroup accounting). Other platforms keep
// the gopsutil /proc path.
func containerCpuMetrics(uint16) (CpuMetrics, bool) {
func containerCpuMetrics(uint16, bool) (CpuMetrics, bool) {
return CpuMetrics{}, false
}
func (a *Agent) initializeCpu() {}
func (a *Agent) warnIfRootCgroup() {}
+55
View File
@@ -0,0 +1,55 @@
package agent
import (
"github.com/henrygd/beszel/agent/utils"
"github.com/henrygd/beszel/agent/zfs"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/shirou/gopsutil/v4/mem"
)
// memoryMetrics holds byte counters. For cgroups, Used excludes file cache,
// while retaining shared memory/tmpfs. BuffCache is the subtracted file cache.
type memoryMetrics struct {
Total, Used, BuffCache uint64
}
var memoryVirtualMemory = mem.VirtualMemory
func (a *Agent) updateMemoryStats(stats *system.Stats) {
v, err := memoryVirtualMemory()
var hostTotal uint64
if err == nil {
hostTotal = v.Total
// Swap retains its existing /proc/meminfo source in both memory paths.
stats.Swap = utils.BytesToGigabytes(v.SwapTotal)
stats.SwapUsed = utils.BytesToGigabytes(saturatingSub(v.SwapTotal, v.SwapFree))
}
metrics, fromCgroup := containerMemoryMetrics(hostTotal, a.forceUseCgroup)
if !fromCgroup {
if err != nil {
return
}
used, cacheBuff, _ := calculateHostMemoryUsage(v, a.memCalc == "htop")
metrics = memoryMetrics{Total: v.Total, Used: used, BuffCache: cacheBuff}
// Host ARC must not be subtracted from container-scoped accounting.
if a.zfs {
if arcSize, _ := zfs.ARCSize(); arcSize > 0 && arcSize < metrics.Used {
metrics.Used -= arcSize
stats.MemZfsArc = utils.BytesToGigabytes(arcSize)
}
}
}
stats.Mem = utils.BytesToGigabytes(metrics.Total)
stats.MemUsed = utils.BytesToGigabytes(metrics.Used)
stats.MemBuffCache = utils.BytesToGigabytes(metrics.BuffCache)
if metrics.Total > 0 {
stats.MemPct = utils.TwoDecimals(float64(metrics.Used) / float64(metrics.Total) * 100)
}
if a.systemDetails.MemoryTotal != metrics.Total {
a.updateSystemDetails(func(details *system.Details) {
details.MemoryTotal = metrics.Total
})
}
}
+82
View File
@@ -0,0 +1,82 @@
//go:build linux
package agent
import (
"path/filepath"
"strconv"
"github.com/henrygd/beszel/agent/utils"
)
// containerMemoryMetrics is only used when USE_CGROUP is explicitly enabled.
// Unlike /proc/stat, lxcfs already serves a container-scoped /proc/meminfo,
// so LXC keeps the existing memory path by default.
func containerMemoryMetrics(hostTotal uint64, forceUseCgroup bool) (memoryMetrics, bool) {
if !forceUseCgroup {
return memoryMetrics{}, false
}
if dir, ok := cgroupV2Dir(); ok {
if metrics, ok := readCgroupMemoryMetrics(dir, hostTotal, true); ok {
return metrics, true
}
}
if dir := utils.CgroupMountPoint(cgroupMountinfo, "cgroup", "memory"); dir != "" {
return readCgroupMemoryMetrics(dir, hostTotal, false)
}
return memoryMetrics{}, false
}
// Read the hierarchy mount root to include sibling services and descendants,
// rather than the agent's own service cgroup. A v1 unlimited limit is a
// page-aligned LONG_MAX sentinel; the threshold also covers 32-bit kernels.
func readCgroupMemoryMetrics(dir string, hostTotal uint64, v2 bool) (memoryMetrics, bool) {
usageFile, limitFile, cacheKey, shmemKey := "memory.usage_in_bytes", "memory.limit_in_bytes", "total_cache", "total_shmem"
if v2 {
usageFile, limitFile, cacheKey, shmemKey = "memory.current", "memory.max", "file", "shmem"
}
usage, ok := utils.ReadUintFile(filepath.Join(dir, usageFile))
if !ok {
return memoryMetrics{}, false
}
rawLimit, ok := utils.ReadStringFileOK(filepath.Join(dir, limitFile))
if !ok {
return memoryMetrics{}, false
}
var limit uint64
if !(v2 && rawLimit == "max") {
var err error
limit, err = strconv.ParseUint(rawLimit, 10, 64)
if err != nil {
return memoryMetrics{}, false
}
if !v2 && (limit >= (uint64(1)<<63)-(1<<20) || limit == 2147479552) {
limit = 0 // v1 unlimited sentinel
} else if limit == 0 {
return memoryMetrics{}, false
}
}
total := limit
if hostTotal > 0 && (total == 0 || hostTotal < total) {
total = hostTotal
}
if total == 0 {
return memoryMetrics{}, false // unlimited and no usable host total
}
stat, err := utils.ReadCgroupStat(filepath.Join(dir, "memory.stat"))
if err != nil {
return memoryMetrics{}, false
}
cache, ok := stat[cacheKey]
if !ok {
return memoryMetrics{}, false
}
shmem, ok := stat[shmemKey]
if !ok {
return memoryMetrics{}, false
}
// Both cache (v1) and file (v2) include shmem/tmpfs, which must remain
// charged as used memory rather than being classified as file cache.
cache = min(saturatingSub(cache, shmem), usage)
return memoryMetrics{Total: total, Used: usage - cache, BuffCache: cache}, true
}
+221
View File
@@ -0,0 +1,221 @@
//go:build testing && linux
package agent
import (
"errors"
"os"
"path/filepath"
"strconv"
"sync"
"testing"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/shirou/gopsutil/v4/mem"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func setupMemoryCgroup(t *testing.T, v2 bool) string {
t.Helper()
swapCpuContainerSeams(t)
if v2 {
writeCpuFixture(t, procSelfCgroup, "0::/system.slice/agent.service\n")
writeCpuFixture(t, cgroupMountinfo, "")
return cgroupRoot
}
dir := filepath.Join(filepath.Dir(cgroupRoot), "memory")
writeCpuFixture(t, procSelfCgroup, "2:memory:/system.slice/agent.service\n")
writeCpuFixture(t, cgroupMountinfo,
"30 25 0:26 / "+dir+" rw - cgroup cgroup rw,memory\n")
return dir
}
func writeMemoryFixture(t *testing.T, dir string, v2 bool, usage, limit, stat string) {
t.Helper()
usageName, limitName := "memory.usage_in_bytes", "memory.limit_in_bytes"
if v2 {
usageName, limitName = "memory.current", "memory.max"
}
writeCpuFixture(t, filepath.Join(dir, usageName), usage)
writeCpuFixture(t, filepath.Join(dir, limitName), limit)
writeCpuFixture(t, filepath.Join(dir, "memory.stat"), stat)
}
func TestContainerMemoryMetrics(t *testing.T) {
for _, v2 := range []bool{true, false} {
t.Run(strconv.FormatBool(v2), func(t *testing.T) {
dir := setupMemoryCgroup(t, v2)
key, shmemKey := "total_cache", "total_shmem"
if v2 {
key, shmemKey = "file", "shmem"
}
stat := func(cache, shmem string) string {
return key + " " + cache + "\n" + shmemKey + " " + shmem + "\n"
}
for _, tt := range []struct {
name, usage, limit, stat string
host uint64
want memoryMetrics
ok bool
}{
{"file cache excluded", "600", "1000", stat("200", "0"), 2000, memoryMetrics{1000, 400, 200}, true},
{"shared memory retained", "600", "1000", stat("200", "50"), 2000, memoryMetrics{1000, 450, 150}, true},
{"shared memory larger than cache", "600", "1000", stat("200", "300"), 2000, memoryMetrics{1000, 600, 0}, true},
{"zero usage", "0", "1000", stat("0", "0"), 2000, memoryMetrics{1000, 0, 0}, true},
{"cache larger than charge", "100", "1000", stat("200", "0"), 2000, memoryMetrics{1000, 0, 100}, true},
{"host caps limit", "600", "3000", stat("200", "0"), 2000, memoryMetrics{2000, 400, 200}, true},
{"finite without host", "600", "1000", stat("200", "0"), 0, memoryMetrics{1000, 400, 200}, true},
{"missing cache key", "600", "1000", shmemKey + " 0\n", 2000, memoryMetrics{}, false},
{"missing shared memory key", "600", "1000", key + " 200\n", 2000, memoryMetrics{}, false},
{"invalid cache", "600", "1000", stat("-1", "0"), 2000, memoryMetrics{}, false},
{"invalid shared memory", "600", "1000", stat("200", "-1"), 2000, memoryMetrics{}, false},
{"invalid usage", "bad", "1000", stat("200", "0"), 2000, memoryMetrics{}, false},
{"invalid limit", "600", "bad", stat("200", "0"), 2000, memoryMetrics{}, false},
{"zero limit", "600", "0", stat("200", "0"), 2000, memoryMetrics{}, false},
} {
t.Run(tt.name, func(t *testing.T) {
writeMemoryFixture(t, dir, v2, tt.usage, tt.limit, tt.stat)
metrics, ok := containerMemoryMetrics(tt.host, true)
assert.Equal(t, tt.ok, ok)
assert.Equal(t, tt.want, metrics)
})
}
limit := "9223372036854771712"
if v2 {
limit = "max"
}
writeMemoryFixture(t, dir, v2, "600", limit, stat("200", "0"))
writeMemoryFixture(t, filepath.Join(dir, "system.slice/agent.service"), v2, "1", "100", stat("0", "0"))
metrics, ok := containerMemoryMetrics(2000, true)
require.True(t, ok)
assert.Equal(t, memoryMetrics{2000, 400, 200}, metrics)
_, ok = containerMemoryMetrics(0, true)
assert.False(t, ok)
if !v2 {
writeMemoryFixture(t, dir, false, "600", "2147479552", stat("200", "0"))
metrics, ok = containerMemoryMetrics(2000, true)
require.True(t, ok)
assert.EqualValues(t, 2000, metrics.Total)
}
})
}
}
func TestContainerMemoryScopeAndEnablement(t *testing.T) {
dir := setupMemoryCgroup(t, true)
// A discovered non-default mount must win over the default root.
mount := filepath.Join(t.TempDir(), "unified")
writeCpuFixture(t, cgroupMountinfo,
"30 25 0:26 /guest "+mount+" rw - cgroup2 cgroup2 rw\n")
writeMemoryFixture(t, dir, true, "1", "1000", "file 0\nshmem 0\n")
writeMemoryFixture(t, mount, true, "600", "1000", "file 200\nshmem 0\n")
writeMemoryFixture(t, filepath.Join(mount, "system.slice/agent.service"), true, "10", "100", "file 0\nshmem 0\n")
_, ok := containerMemoryMetrics(2000, false)
assert.False(t, ok) // Docker/host agents keep the existing path by default.
markLxc(t)
inLxc = sync.OnceValue(detectLxc) // Simulate a fresh process in LXC.
_, ok = containerMemoryMetrics(2000, false)
assert.False(t, ok) // lxcfs already serves container-scoped /proc/meminfo.
metrics, ok := containerMemoryMetrics(2000, true)
require.True(t, ok)
assert.Equal(t, memoryMetrics{1000, 400, 200}, metrics)
require.NoError(t, os.Remove(filepath.Join(mount, "memory.stat")))
_, ok = containerMemoryMetrics(2000, true)
assert.False(t, ok)
}
func TestUpdateMemoryStatsCgroupAndFallback(t *testing.T) {
dir := setupMemoryCgroup(t, true)
const gib = uint64(1 << 30)
writeMemoryFixture(t, dir, true, strconv.FormatUint(6*gib, 10), strconv.FormatUint(10*gib, 10),
"file "+strconv.FormatUint(2*gib, 10)+"\nshmem 0\n")
original := memoryVirtualMemory
t.Cleanup(func() { memoryVirtualMemory = original })
memoryVirtualMemory = func() (*mem.VirtualMemoryStat, error) {
return &mem.VirtualMemoryStat{Total: 20 * gib, Used: 12 * gib, Free: 4 * gib,
Cached: 4 * gib, SwapTotal: 2 * gib, SwapFree: gib}, nil
}
a := &Agent{forceUseCgroup: true, memCalc: "htop", zfs: true}
var stats system.Stats
a.updateMemoryStats(&stats)
assert.Equal(t, float64(10), stats.Mem)
assert.Equal(t, float64(4), stats.MemUsed)
assert.Equal(t, float64(2), stats.MemBuffCache)
assert.Equal(t, float64(40), stats.MemPct)
assert.Zero(t, stats.MemZfsArc)
assert.Equal(t, float64(2), stats.Swap)
assert.Equal(t, float64(1), stats.SwapUsed)
assert.Equal(t, 10*gib, a.systemDetails.MemoryTotal)
assert.True(t, a.detailsDirty)
a.detailsDirty = false
a.updateMemoryStats(&system.Stats{})
assert.False(t, a.detailsDirty)
writeCpuFixture(t, filepath.Join(dir, "memory.max"), strconv.FormatUint(8*gib, 10))
a.updateMemoryStats(&system.Stats{})
assert.Equal(t, 8*gib, a.systemDetails.MemoryTotal)
assert.True(t, a.detailsDirty)
// A missing required field falls back to the original htop calculation.
writeCpuFixture(t, filepath.Join(dir, "memory.stat"), "file 123\n")
a.zfs = false
stats = system.Stats{}
a.updateMemoryStats(&stats)
assert.Equal(t, float64(20), stats.Mem)
assert.Equal(t, float64(12), stats.MemUsed)
assert.Equal(t, float64(4), stats.MemBuffCache)
assert.Equal(t, float64(60), stats.MemPct)
assert.Equal(t, 20*gib, a.systemDetails.MemoryTotal)
// Finite cgroup limits still work if /proc/meminfo cannot be read.
memoryVirtualMemory = func() (*mem.VirtualMemoryStat, error) { return nil, errors.New("unreadable") }
writeCpuFixture(t, filepath.Join(dir, "memory.stat"), "file 0\nshmem 0\n")
stats = system.Stats{}
a.updateMemoryStats(&stats)
assert.Equal(t, float64(8), stats.Mem)
assert.Equal(t, float64(6), stats.MemUsed)
assert.Equal(t, float64(75), stats.MemPct)
assert.Zero(t, stats.Swap)
}
func TestMemoryDetailsInitiallyUseCgroupLimit(t *testing.T) {
dir := setupMemoryCgroup(t, true)
writeMemoryFixture(t, dir, true, "600", "1000", "file 200\nshmem 0\n")
original := memoryVirtualMemory
t.Cleanup(func() { memoryVirtualMemory = original })
memoryVirtualMemory = func() (*mem.VirtualMemoryStat, error) {
return &mem.VirtualMemoryStat{Total: 2000}, nil
}
a := &Agent{forceUseCgroup: true}
a.refreshSystemDetails()
assert.EqualValues(t, 1000, a.systemDetails.MemoryTotal)
}
// Captured from this container: active file cache made the working-set
// calculation report ~80 GiB despite only ~17 GiB of anonymous RSS.
func TestMemoryStatsExcludeActiveFileCache(t *testing.T) {
dir := setupMemoryCgroup(t, false)
const usage = uint64(135881601024)
const cache = uint64(116823166976)
const shmem = uint64(77180928)
writeMemoryFixture(t, dir, false, strconv.FormatUint(usage, 10), "137438953472",
"total_cache 116823166976\ntotal_shmem 77180928\n"+
"total_inactive_file 49902608384\ntotal_active_file 67684671488\n")
original := memoryVirtualMemory
t.Cleanup(func() { memoryVirtualMemory = original })
memoryVirtualMemory = func() (*mem.VirtualMemoryStat, error) {
return &mem.VirtualMemoryStat{Total: 1580621720 * 1024}, nil
}
metrics, ok := containerMemoryMetrics(1580621720*1024, true)
require.True(t, ok)
assert.Equal(t, usage-(cache-shmem), metrics.Used)
assert.Equal(t, cache-shmem, metrics.BuffCache)
assert.Equal(t, usage, metrics.Used+metrics.BuffCache)
var stats system.Stats
a := &Agent{forceUseCgroup: true}
a.updateMemoryStats(&stats)
assert.Equal(t, float64(17.82), stats.MemUsed)
assert.Equal(t, float64(108.73), stats.MemBuffCache)
assert.Equal(t, float64(13.92), stats.MemPct)
}
+7
View File
@@ -0,0 +1,7 @@
//go:build !linux
package agent
func containerMemoryMetrics(uint64, bool) (memoryMetrics, bool) {
return memoryMetrics{}, false
}
+6 -1
View File
@@ -1,6 +1,7 @@
package agent
import (
"crypto/tls"
"errors"
"fmt"
"net/http"
@@ -20,7 +21,11 @@ type MonitorManager struct {
}
func newMonitorManager() *MonitorManager {
return newMonitorManagerWithProbe(networkMonitorProbe(&http.Client{Timeout: monitor.MaxProbeTimeout}))
client := &http.Client{Timeout: monitor.MaxProbeTimeout}
insecureTransport := http.DefaultTransport.(*http.Transport).Clone()
insecureTransport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
insecureClient := &http.Client{Timeout: monitor.MaxProbeTimeout, Transport: insecureTransport}
return newMonitorManagerWithProbe(networkMonitorProbe(client, insecureClient))
}
func newMonitorManagerWithProbe(probe monitorProbe) *MonitorManager {
+6 -1
View File
@@ -18,7 +18,9 @@ const networkMonitorUserAgent = "Beszel-Agent/" + beszel.Version + " (+https://b
// Implementations must honor cancellation and bound their execution time.
type monitorProbe func(context.Context, monitor.Config) (int64, error)
func networkMonitorProbe(client *http.Client) monitorProbe {
// networkMonitorProbe returns a probe that uses client for HTTP monitors, or
// insecureClient for HTTP monitors that skip TLS certificate verification.
func networkMonitorProbe(client, insecureClient *http.Client) monitorProbe {
return func(ctx context.Context, config monitor.Config) (int64, error) {
switch config.Protocol {
case "icmp":
@@ -26,6 +28,9 @@ func networkMonitorProbe(client *http.Client) monitorProbe {
case "tcp":
return monitorTCP(ctx, config.Target, config.Port)
case "http":
if config.SkipTLSVerify {
return monitorHTTP(ctx, insecureClient, config.Target)
}
return monitorHTTP(ctx, client, config.Target)
case "dns":
return monitorDNS(ctx, config.Target, config.Server)
+20 -2
View File
@@ -145,7 +145,7 @@ func TestMonitorManagerApplySyncUpsertRunsImmediatelyAndReturnsResult(t *testing
pm := &MonitorManager{
monitors: make(map[string]*monitorTask),
probe: networkMonitorProbe(server.Client()),
probe: networkMonitorProbe(server.Client(), nil),
}
resp, err := pm.HandleSyncRequest(monitor.SyncRequest{
@@ -261,6 +261,24 @@ func TestMonitorHTTP(t *testing.T) {
assert.Equal(t, int64(-1), responseUs)
require.Error(t, err)
})
t.Run("self-signed certificate", func(t *testing.T) {
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
probe := newMonitorManager().probe
config := monitor.Config{Protocol: "http", Target: server.URL}
responseUs, err := probe(context.Background(), config)
assert.Equal(t, int64(-1), responseUs)
require.Error(t, err)
config.SkipTLSVerify = true
responseUs, err = probe(context.Background(), config)
require.NoError(t, err)
assert.GreaterOrEqual(t, responseUs, int64(0))
})
}
func TestMonitorTCP(t *testing.T) {
@@ -576,7 +594,7 @@ func TestMonitorProbeTimeoutRecordsLoss(t *testing.T) {
defer server.Close()
defer close(release)
pm := newMonitorManager()
pm.probe = networkMonitorProbe(&http.Client{Timeout: 20 * time.Millisecond})
pm.probe = networkMonitorProbe(&http.Client{Timeout: 20 * time.Millisecond}, nil)
task := newMonitorTask(monitor.Config{ID: "timeout", Protocol: "http", Target: server.URL})
defer task.cancel()
+4 -1
View File
@@ -141,7 +141,10 @@ func runningInContainer() bool {
func detectPackageManager(dataDir string) (string, packageUpdatesCheck) {
switch {
case commandExists("apt-get"):
// openSUSE's zypper-aptitude provides an apt-get wrapper around zypper, so
// apt also requires the apt package in dpkg's database. dpkg alone is not
// enough because openSUSE packages it too.
case commandExists("apt-get") && exec.Command("dpkg-query", "-W", "apt").Run() == nil:
return "apt", checkApt
case commandExists("dnf"):
return "dnf", newDnfCheck(dataDir)
+23
View File
@@ -268,6 +268,29 @@ exit 1`,
})
}
func TestDetectPackageManager(t *testing.T) {
tests := []struct {
name string
commands map[string]string
manager string
}{
{"apt", map[string]string{"apt-get": "", "dpkg-query": "exit 0"}, "apt"},
{"zypper-aptitude", map[string]string{"apt-get": "", "zypper": ""}, "zypper"},
{"zypper-aptitude with dpkg", map[string]string{"apt-get": "", "dpkg-query": "exit 1", "zypper": ""}, "zypper"},
{"dnf", map[string]string{"dnf": "", "rpm": ""}, "dnf"},
{"none", nil, ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fakeCommands(t, tt.commands)
// only the fake commands, so the host's package managers are not found
t.Setenv("PATH", strings.SplitN(os.Getenv("PATH"), string(os.PathListSeparator), 2)[0])
manager, _ := detectPackageManager(t.TempDir())
assert.Equal(t, tt.manager, manager)
})
}
}
func TestParseZypperTable(t *testing.T) {
tests := []struct {
file string
+6 -1
View File
@@ -151,11 +151,16 @@ func (a *Agent) handleSession(s ssh.Session) {
s.Exit(1)
return
}
// A legacy hub never sends a request, so the session is done once the payload is out.
_ = s.Exit(0)
return
}
var req common.HubRequest[cbor.RawMessage]
if err := cbor.NewDecoder(s).Decode(&req); err != nil {
// Fallback to legacy one-shot if the first decode fails
// Only reachable for hubs that report >= MinVersionAgentResponse: a
// malformed or truncated request falls back to the one-shot payload
// so the peer still receives something parseable.
if err2 := a.handleLegacyStats(s, hubVersion); err2 != nil {
slog.Error("Error encoding stats (fallback)", "err", err2)
s.Exit(1)
+183
View File
@@ -3,6 +3,7 @@
package agent
import (
"bytes"
"context"
"crypto/ed25519"
"encoding/json"
@@ -19,6 +20,7 @@ import (
"testing"
"time"
"github.com/henrygd/beszel/internal/common"
"github.com/henrygd/beszel/internal/entities/container"
"github.com/henrygd/beszel/internal/entities/system"
@@ -972,3 +974,184 @@ func TestGetHubVersionConcurrent(t *testing.T) {
}
wg.Wait()
}
/////////////////////////////////////////////////////////////////
/////////////// Session Protocol Tests //////////////////////////
/////////////////////////////////////////////////////////////////
// dialTestAgentClient starts an agent SSH server on a private unix socket and
// connects a real SSH client whose ClientVersion mimics a hub of the given
// version, so session-protocol behavior can be exercised end to end.
func dialTestAgentClient(t *testing.T, clientVersion string) *gossh.Client {
t.Helper()
pubKey, privKey, err := ed25519.GenerateKey(nil)
require.NoError(t, err)
signer, err := gossh.NewSignerFromKey(privKey)
require.NoError(t, err)
sshPubKey, err := gossh.NewPublicKey(pubKey)
require.NoError(t, err)
socketFile := filepath.Join(t.TempDir(), "beszel-test.sock")
agent, err := NewAgent("")
require.NoError(t, err)
errChan := make(chan error, 1)
go func() {
errChan <- agent.StartServer(ServerOptions{
Network: "unix",
Addr: socketFile,
Keys: []gossh.PublicKey{sshPubKey},
})
}()
t.Cleanup(func() {
_ = agent.StopServer()
select {
case <-errChan:
default:
}
})
// Wait for the listener instead of a fixed sleep.
require.Eventually(t, func() bool {
conn, err := net.Dial("unix", socketFile)
if err != nil {
return false
}
_ = conn.Close()
return true
}, 5*time.Second, 25*time.Millisecond)
client, err := gossh.Dial("unix", socketFile, &gossh.ClientConfig{
User: "u",
Auth: []gossh.AuthMethod{gossh.PublicKeys(signer)},
HostKeyCallback: gossh.InsecureIgnoreHostKey(),
Timeout: 4 * time.Second,
ClientVersion: clientVersion,
})
require.NoError(t, err)
t.Cleanup(func() { _ = client.Close() })
return client
}
// waitSessionOutput runs readFn on a session stream and fails the test if it
// does not finish quickly. A healthy session ends in milliseconds; the guard
// exists so a handler that blocks (the bug this suite guards against) fails
// the test instead of hanging it until the 70s idle timeout.
func waitSessionOutput(t *testing.T, readFn func() ([]byte, error)) []byte {
t.Helper()
type result struct {
output []byte
err error
}
resCh := make(chan result, 1)
go func() {
output, err := readFn()
resCh <- result{output, err}
}()
select {
case res := <-resCh:
require.NoError(t, res.err)
return res.output
case <-time.After(10 * time.Second):
t.Fatal("session did not complete within 10s; handler appears to be blocked")
return nil
}
}
// TestLegacyHubGetsSinglePayload pins the legacy one-shot contract: a hub
// older than MinVersionAgentResponse never sends a request, so the agent must
// write exactly one stats payload, exit with status 0, and end the session
// promptly. Regression target: the handler used to fall through into the
// request decode after the legacy write, read an immediate EOF from the hub's
// empty stdin, and send a second payload via the decode-failure fallback.
// Covers all three routes into the legacy path: a pre-0.13 CBOR hub, a
// pre-0.12 JSON hub, and a client whose version string cannot be parsed.
func TestLegacyHubGetsSinglePayload(t *testing.T) {
tests := []struct {
name string
clientVersion string
cborFormat bool
}{
{name: "legacy cbor hub 0.12.0", clientVersion: "SSH-2.0-beszel_0.12.0", cborFormat: true},
{name: "legacy json hub 0.11.1", clientVersion: "SSH-2.0-beszel_0.11.1", cborFormat: false},
{name: "unparsable client version", clientVersion: "SSH-2.0-OpenSSH_8.0", cborFormat: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client := dialTestAgentClient(t, tt.clientVersion)
session, err := client.NewSession()
require.NoError(t, err)
defer func() { _ = session.Close() }()
output := waitSessionOutput(t, func() ([]byte, error) {
return session.Output("")
})
require.NotEmpty(t, output, "legacy hub must receive a stats payload")
// Exactly one payload: decode the first object, then require the
// stream to be exhausted. A second write would leave bytes here.
var data system.CombinedData
if tt.cborFormat {
dec := cbor.NewDecoder(bytes.NewReader(output))
require.NoError(t, dec.Decode(&data), "payload must be valid CBOR")
var extra any
require.ErrorIs(t, dec.Decode(&extra), io.EOF,
"legacy hub must receive exactly one payload, got trailing bytes")
} else {
dec := json.NewDecoder(bytes.NewReader(output))
require.NoError(t, dec.Decode(&data), "payload must be valid JSON")
var extra map[string]any
require.ErrorIs(t, dec.Decode(&extra), io.EOF,
"legacy hub must receive exactly one payload, got trailing bytes")
}
// ConnectionType is stamped by the SSH path itself, so it is a
// deterministic (environment-independent) proof that the payload
// came from the SSH handler.
assert.Equal(t, system.ConnectionTypeSSH, data.Info.ConnectionType,
"payload must be stamped as SSH-sourced")
// Output() returns an *ssh.ExitError when the handler exits
// non-zero; reaching this point with no error means exit status 0.
// (An s.Exit(1) from a failed legacy write would surface here.)
})
}
}
// TestModernHubRequestResponse guards the new-protocol path against
// regressions from the legacy-path changes: a hub at or above
// MinVersionAgentResponse sends a CBOR request and must get exactly one
// AgentResponse with populated system data and a clean exit.
func TestModernHubRequestResponse(t *testing.T) {
client := dialTestAgentClient(t, "SSH-2.0-beszel_0.21.0")
session, err := client.NewSession()
require.NoError(t, err)
defer func() { _ = session.Close() }()
stdout, err := session.StdoutPipe()
require.NoError(t, err)
stdin, err := session.StdinPipe()
require.NoError(t, err)
require.NoError(t, session.Shell())
// Same request shape the hub's SSH transport sends.
require.NoError(t, cbor.NewEncoder(stdin).Encode(common.HubRequest[any]{Action: common.GetData}))
require.NoError(t, stdin.Close())
output := waitSessionOutput(t, func() ([]byte, error) {
return io.ReadAll(stdout)
})
var resp common.AgentResponse
require.NoError(t, cbor.Unmarshal(output, &resp), "response must be a single valid CBOR AgentResponse")
assert.Empty(t, resp.Error)
require.NotNil(t, resp.SystemData, "GetData response must carry system data")
assert.Equal(t, system.ConnectionTypeSSH, resp.SystemData.Info.ConnectionType,
"GetData response must be stamped as SSH-sourced")
// Wait() returns an *ssh.ExitError on non-zero exit; nil means status 0.
require.NoError(t, session.Wait())
}
+9 -31
View File
@@ -106,11 +106,16 @@ func (a *Agent) refreshSystemDetails() {
// total memory
a.systemDetails.MemoryTotal = hostInfo.MemTotal
if a.systemDetails.MemoryTotal == 0 {
if v, err := mem.VirtualMemory(); err == nil {
hostMemoryTotal := hostInfo.MemTotal
if v, err := memoryVirtualMemory(); err == nil {
hostMemoryTotal = v.Total
if a.systemDetails.MemoryTotal == 0 {
a.systemDetails.MemoryTotal = v.Total
}
}
if metrics, ok := containerMemoryMetrics(hostMemoryTotal, a.forceUseCgroup); ok {
a.systemDetails.MemoryTotal = metrics.Total
}
// zfs
if _, err := zfs.ARCSize(); err != nil {
@@ -156,7 +161,7 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
}
// cpu metrics
cpuMetrics, err := getCpuMetrics(cacheTimeMs)
cpuMetrics, err := getCpuMetrics(cacheTimeMs, a.forceUseCgroup)
if err == nil {
systemStats.Cpu = utils.TwoDecimals(cpuMetrics.Total)
systemStats.CpuBreakdown = []float64{
@@ -190,34 +195,7 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
}
// memory
if v, err := mem.VirtualMemory(); err == nil {
used, cacheBuff, swapUsed := calculateHostMemoryUsage(v, a.memCalc == "htop")
// swap
systemStats.Swap = utils.BytesToGigabytes(v.SwapTotal)
systemStats.SwapUsed = utils.BytesToGigabytes(swapUsed)
v.Used = used
// if a.memCalc == "legacy" {
// v.Used = v.Total - v.Free - v.Buffers - v.Cached
// cacheBuff = v.Total - v.Free - v.Used
// v.UsedPercent = float64(v.Used) / float64(v.Total) * 100.0
// }
// subtract ZFS ARC size from used memory and add as its own category
if a.zfs {
if arcSize, _ := zfs.ARCSize(); arcSize > 0 && arcSize < v.Used {
v.Used = v.Used - arcSize
systemStats.MemZfsArc = utils.BytesToGigabytes(arcSize)
}
}
if v.Total > 0 {
v.UsedPercent = float64(v.Used) / float64(v.Total) * 100.0
} else {
v.UsedPercent = 0
}
systemStats.Mem = utils.BytesToGigabytes(v.Total)
systemStats.MemBuffCache = utils.BytesToGigabytes(cacheBuff)
systemStats.MemUsed = utils.BytesToGigabytes(v.Used)
systemStats.MemPct = utils.TwoDecimals(v.UsedPercent)
}
a.updateMemoryStats(&systemStats)
// disk usage
a.updateDiskUsage(&systemStats)
+100
View File
@@ -0,0 +1,100 @@
//go:build linux
package utils
import (
"os"
"strconv"
"strings"
)
// InCgroupV2 reports whether the given /proc/self/cgroup file lists the
// unified hierarchy (a "0::<path>" entry).
func InCgroupV2(path string) bool {
data, err := os.ReadFile(path)
if err != nil {
return false
}
for line := range strings.SplitSeq(string(data), "\n") {
if strings.HasPrefix(line, "0::") {
return true
}
}
return false
}
// CgroupMountPoint finds a hierarchy's mount point in the given mountinfo file.
// Use fstype "cgroup2" and an empty controller for v2, or "cgroup" and the
// desired controller for v1. It returns the mount root, not a process's leaf.
func CgroupMountPoint(path, fstype, controller string) string {
data, err := os.ReadFile(path)
if err != nil {
return ""
}
for line := range strings.SplitSeq(string(data), "\n") {
left, right, found := strings.Cut(line, " - ")
if !found {
continue
}
post := strings.Fields(right)
if len(post) == 0 || post[0] != fstype {
continue
}
if controller != "" && !mountOptHas(post, controller) {
continue
}
fields := strings.Fields(left)
if len(fields) >= 5 {
return unescapeMountPoint(fields[4])
}
}
return ""
}
// mountOptHas checks the comma-separated super options after the separator.
func mountOptHas(post []string, opt string) bool {
if len(post) < 3 {
return false
}
for o := range strings.SplitSeq(post[2], ",") {
if o == opt {
return true
}
}
return false
}
func unescapeMountPoint(s string) string {
return strings.NewReplacer(`\040`, " ", `\011`, "\t", `\012`, "\n", `\134`, `\`).Replace(s)
}
// ReadCgroupStat reads all unsigned counters from a cgroup key/value stat file
// in one read. Malformed entries are omitted; callers must check required keys.
func ReadCgroupStat(path string) (map[string]uint64, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
values := make(map[string]uint64)
for line := range strings.SplitSeq(string(data), "\n") {
fields := strings.Fields(line)
if len(fields) != 2 {
continue
}
value, err := strconv.ParseUint(fields[1], 10, 64)
if err == nil {
values[fields[0]] = value
}
}
return values, nil
}
// ReadIntFile parses a signed decimal integer (e.g. a v1 CPU quota of -1).
func ReadIntFile(path string) (int64, bool) {
raw, ok := ReadStringFileOK(path)
if !ok {
return 0, false
}
value, err := strconv.ParseInt(raw, 10, 64)
return value, err == nil
}
+88
View File
@@ -0,0 +1,88 @@
//go:build linux
package utils
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestInCgroupV2(t *testing.T) {
path := filepath.Join(t.TempDir(), "cgroup")
assert.False(t, InCgroupV2(path))
for _, tt := range []struct {
name, contents string
want bool
}{
{"v1", "3:cpu,cpuacct:/system.slice/agent.service\n2:memory:/\n", false},
{"v2 service", "0::/system.slice/agent.service\n", true},
{"hybrid", "2:memory:/\n0::/\n", true},
{"empty", "", false},
} {
t.Run(tt.name, func(t *testing.T) {
require.NoError(t, os.WriteFile(path, []byte(tt.contents), 0o644))
assert.Equal(t, tt.want, InCgroupV2(path))
})
}
}
func TestCgroupMountPoint(t *testing.T) {
path := filepath.Join(t.TempDir(), "mountinfo")
assert.Empty(t, CgroupMountPoint(path, "cgroup2", ""))
contents := "malformed\n" +
"30 25 0:26 / /sys/fs/cgroup/cpu rw - cgroup cgroup rw,cpu,cpuacct\n" +
"31 25 0:27 / /sys/fs/cgroup/memory rw - cgroup cgroup rw,memory\n" +
`32 25 0:28 /guest /sys/fs/cgroup/unified\040root\134name rw - cgroup2 cgroup2 rw` + "\n"
require.NoError(t, os.WriteFile(path, []byte(contents), 0o644))
assert.Equal(t, "/sys/fs/cgroup/cpu", CgroupMountPoint(path, "cgroup", "cpuacct"))
assert.Equal(t, "/sys/fs/cgroup/memory", CgroupMountPoint(path, "cgroup", "memory"))
assert.Equal(t, `/sys/fs/cgroup/unified root\name`, CgroupMountPoint(path, "cgroup2", ""))
assert.Empty(t, CgroupMountPoint(path, "cgroup", "mem"))
assert.Empty(t, CgroupMountPoint(path, "cgroup", "cpuset"))
}
func TestReadCgroupStat(t *testing.T) {
path := filepath.Join(t.TempDir(), "memory.stat")
_, err := ReadCgroupStat(path)
require.Error(t, err)
contents := "anon 1024\nfile\t2048\ninactive_file 0\n" +
"negative -1\noverflow 18446744073709551616\ninvalid abc\nmissing\nextra 1 2\n"
require.NoError(t, os.WriteFile(path, []byte(contents), 0o644))
stat, err := ReadCgroupStat(path)
require.NoError(t, err)
assert.Equal(t, map[string]uint64{"anon": 1024, "file": 2048, "inactive_file": 0}, stat)
_, ok := stat["inactive_file"]
assert.True(t, ok)
_, ok = stat["usage_usec"]
assert.False(t, ok)
}
func TestReadIntFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "quota")
_, ok := ReadIntFile(path)
assert.False(t, ok)
for _, tt := range []struct {
contents string
want int64
ok bool
}{
{" -1\n", -1, true},
{"100000\n", 100000, true},
{"0", 0, true},
{"max", 0, false},
{"9223372036854775808", 9223372036854775807, false},
} {
t.Run(tt.contents, func(t *testing.T) {
require.NoError(t, os.WriteFile(path, []byte(tt.contents), 0o644))
value, ok := ReadIntFile(path)
assert.Equal(t, tt.ok, ok)
if ok {
assert.Equal(t, tt.want, value)
}
})
}
}
+3
View File
@@ -28,6 +28,9 @@ type Config struct {
// Server is the DNS server to query (host or host:port, default port 53).
// Only used when Protocol is "dns"; empty means use the system resolver.
Server string `cbor:"5,keyasint,omitempty"`
// SkipTLSVerify disables certificate verification for HTTPS targets, such as
// those with self-signed certificates. Only used when Protocol is "http".
SkipTLSVerify bool `cbor:"6,keyasint,omitempty" db:"skipTlsVerify"`
}
// CertInfo holds details of the leaf TLS certificate presented by a target.
+2
View File
@@ -1,3 +1,5 @@
//go:build testing
package hub_test
import (
+2
View File
@@ -1,3 +1,5 @@
//go:build testing
package hub_test
import (
+7 -1
View File
@@ -68,6 +68,10 @@ func bindNetworkMonitorsEvents(hub *Hub) {
if protocol != "dns" {
e.Record.Set("server", "")
}
// only http uses TLS verification - clear it for other protocols
if protocol != "http" {
e.Record.Set("skipTlsVerify", false)
}
ID := generateMonitorID(systemID, *monitorConfigFromRecord(e.Record))
if ID != e.Record.Id {
newRecord := copyMonitorToNewRecord(e.Record, ID)
@@ -115,6 +119,8 @@ func monitorConfigFromRecord(record *core.Record) *monitor.Config {
Port: uint16(record.GetInt("port")),
Interval: uint16(record.GetInt("interval")),
Server: record.GetString("server"),
// SkipTLSVerify is a probe option, not part of the monitor's identity, so it does not affect the ID.
SkipTLSVerify: record.GetBool("skipTlsVerify"),
}
}
@@ -139,7 +145,7 @@ func copyMonitorToNewRecord(oldRecord *core.Record, newID string) *core.Record {
collection := oldRecord.Collection()
newRecord := core.NewRecord(collection)
newRecord.Id = newID
fields := []string{"system", "target", "protocol", "port", "server", "interval", "enabled"}
fields := []string{"system", "target", "protocol", "port", "server", "interval", "enabled", "skipTlsVerify"}
for _, field := range fields {
newRecord.Set(field, oldRecord.Get(field))
}
+18 -14
View File
@@ -1,3 +1,5 @@
//go:build testing
package hub
import (
@@ -293,20 +295,21 @@ func TestCopyMonitorToNewRecordDropsResultFields(t *testing.T) {
oldRecord := core.NewRecord(collection)
oldRecord.Load(map[string]any{
"system": "sys123",
"target": "https://example.com",
"protocol": "http",
"port": 443,
"server": "1.1.1.1",
"interval": 60,
"enabled": true,
"res": 1200,
"resAvg1h": 1300,
"resMin1h": 900,
"resMax1h": 1600,
"loss1h": 5,
"certInfo": map[string]any{"expires": 1800000000000},
"updated": "2026-04-29 12:00:00.000Z",
"system": "sys123",
"target": "https://example.com",
"protocol": "http",
"port": 443,
"server": "1.1.1.1",
"interval": 60,
"enabled": true,
"skipTlsVerify": true,
"res": 1200,
"resAvg1h": 1300,
"resMin1h": 900,
"resMax1h": 1600,
"loss1h": 5,
"certInfo": map[string]any{"expires": 1800000000000},
"updated": "2026-04-29 12:00:00.000Z",
})
newRecord := copyMonitorToNewRecord(oldRecord, "next12345")
@@ -317,6 +320,7 @@ func TestCopyMonitorToNewRecordDropsResultFields(t *testing.T) {
assert.Equal(t, 443, newRecord.GetInt("port"))
assert.Equal(t, "1.1.1.1", newRecord.GetString("server"))
assert.True(t, newRecord.GetBool("enabled"))
assert.True(t, newRecord.GetBool("skipTlsVerify"))
assert.Contains(t, []string{"", "null"}, newRecord.GetString("certInfo"))
assert.Zero(t, newRecord.GetFloat("res"))
assert.Zero(t, newRecord.GetFloat("resAvg1h"))
@@ -177,6 +177,23 @@ func TestNetworkMonitorReconnectSync(t *testing.T) {
}
}
func TestGetMonitorConfigsForSystemIncludesSkipTLSVerify(t *testing.T) {
sys, app := newTestSystemWithHub(t)
collection, err := app.FindCachedCollectionByNameOrId("network_monitors")
require.NoError(t, err)
record := core.NewRecord(collection)
record.Load(map[string]any{
"system": sys.Id, "target": "https://self-signed.example", "protocol": "http",
"interval": 60, "enabled": true, "skipTlsVerify": true,
})
require.NoError(t, app.SaveNoValidate(record))
configs, err := sys.manager.GetMonitorConfigsForSystem(sys.Id)
require.NoError(t, err)
require.Len(t, configs, 1)
require.True(t, configs[0].SkipTLSVerify, "full sync must keep the skip TLS verify option")
}
func TestGetMonitorConfigsForSystemQueryError(t *testing.T) {
sys, app := newTestSystemWithHub(t)
_, err := app.DB().NewQuery("DROP TABLE network_monitors").Execute()
+1 -1
View File
@@ -380,7 +380,7 @@ func (sm *SystemManager) resetFailedSmartFetchState(systemID string) {
func (sm *SystemManager) GetMonitorConfigsForSystem(systemID string) ([]monitor.Config, error) {
var configs []monitor.Config
err := sm.hub.DB().
NewQuery("SELECT id, target, protocol, port, interval, server FROM network_monitors WHERE system = {:system} AND enabled = true").
NewQuery("SELECT id, target, protocol, port, interval, server, skipTlsVerify FROM network_monitors WHERE system = {:system} AND enabled = true").
Bind(dbx.Params{"system": systemID}).
All(&configs)
return configs, err
@@ -0,0 +1,24 @@
package migrations
import (
"github.com/pocketbase/pocketbase/core"
m "github.com/pocketbase/pocketbase/migrations"
)
func init() {
m.Register(func(app core.App) error {
collection, err := app.FindCollectionByNameOrId("network_monitors")
if err != nil {
return err
}
collection.Fields.Add(&core.BoolField{Id: "nm_skip_tls_verify", Name: "skipTlsVerify"})
return app.Save(collection)
}, func(app core.App) error {
collection, err := app.FindCollectionByNameOrId("network_monitors")
if err != nil {
return err
}
collection.Fields.RemoveByName("skipTlsVerify")
return app.Save(collection)
})
}
@@ -11,7 +11,8 @@ import {
} from "@/components/ui/chart"
import { chartMargin, cn, formatShortDate } from "@/lib/utils"
import type { ChartData, SystemStatsRecord } from "@/types"
import { useYAxisWidth } from "./hooks"
import { hasChartValues, useYAxisWidth } from "./hooks"
import { ChartNoValues } from "./chart-no-values"
import type { AxisDomain } from "recharts/types/util/types"
import { useIntersectionObserver } from "@/lib/use-intersection-observer"
@@ -118,6 +119,9 @@ export default function AreaChartDefault({
if (displayData.length === 0) {
return null
}
if (!hasChartValues(displayData, dataPoints)) {
return <ChartNoValues ref={ref} />
}
// if (logRender) {
// console.log("Rendered", dataPoints?.map((d) => d.label).join(", "), new Date())
// }
@@ -0,0 +1,16 @@
import { t } from "@lingui/core/macro"
import type { Ref } from "react"
import Spinner from "@/components/spinner"
/**
* Shown in place of a chart whose records contain no values (e.g. every probe failed).
* The opacity-100 class hides ChartCard's loading spinner, same as a rendered chart.
* Takes the chart's intersection observer ref so the chart keeps updating once values arrive.
*/
export function ChartNoValues({ ref }: { ref?: Ref<HTMLDivElement> }) {
return (
<div ref={ref} className="absolute inset-0">
<Spinner msg={t`Waiting for enough records to display`} className="bg-card opacity-100" />
</div>
)
}
@@ -85,6 +85,14 @@ export function useContainerChartConfigs(containerData: ChartData["containerData
}, [containerData])
}
/**
* Whether any series has a numeric value in the data. Without one, recharts renders no y-axis
* ticks, so the y-axis width is never measured and the chart would stay hidden.
*/
export function hasChartValues<T>(data: T[], dataPoints?: { dataKey: (data: T) => unknown }[]) {
return !!dataPoints?.some(({ dataKey }) => data.some((record) => typeof dataKey(record) === "number"))
}
/** Sets the correct width of the y axis in recharts based on the longest label */
export function useYAxisWidth() {
const [yAxisWidth, setYAxisWidth] = useState(0)
@@ -11,7 +11,8 @@ import {
} from "@/components/ui/chart"
import { chartMargin, cn, formatShortDate } from "@/lib/utils"
import type { ChartOptions, SystemStatsRecord } from "@/types"
import { useYAxisWidth } from "./hooks"
import { hasChartValues, useYAxisWidth } from "./hooks"
import { ChartNoValues } from "./chart-no-values"
import type { AxisDomain } from "recharts/types/util/types"
import { useIntersectionObserver } from "@/lib/use-intersection-observer"
@@ -141,7 +142,7 @@ export default function LineChartDefault({
dataKey={dataPoint.dataKey}
name={dataPoint.label}
type="monotoneX"
dot={dataPoint.dot || false}
dot={dataPoint.dot ?? (connectNulls ? false : isolatedDot)}
strokeWidth={1.5}
stroke={color}
strokeOpacity={dataPoint.strokeOpacity}
@@ -160,6 +161,9 @@ export default function LineChartDefault({
if (displayData.length === 0) {
return null
}
if (!hasChartValues(displayData, dataPoints)) {
return <ChartNoValues ref={ref} />
}
// if (logRender) {
// console.log("Rendered", dataPoints?.map((d) => d.label).join(", "), new Date())
// }
@@ -19,6 +19,7 @@ import {
} from "@/components/ui/dropdown-menu"
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet"
import { Button } from "@/components/ui/button"
import { Switch } from "@/components/ui/switch"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
@@ -40,6 +41,7 @@ type MonitorValues = {
port: number
server: string
interval: string
skipTlsVerify: boolean
}
type NormalizedMonitorValues = Omit<MonitorValues, "system" | "interval"> & {
@@ -63,6 +65,7 @@ const NormalizedMonitorValuesSchema = v.pipe(
port: v.number(),
server: v.pipe(v.string(), v.trim()),
interval: MonitorIntervalSchema,
skipTlsVerify: v.boolean(),
}),
v.transform((input): NormalizedMonitorValues => {
let { protocol, port } = input
@@ -84,6 +87,8 @@ const NormalizedMonitorValuesSchema = v.pipe(
// Only DNS monitors use a custom server; clear it for other protocols.
server: protocol === "dns" ? input.server : "",
interval: input.interval,
// Only HTTPS targets use TLS; clear it for everything else.
skipTlsVerify: protocol === "http" && isHttpsTarget(httpTarget) && input.skipTlsVerify,
}
}),
v.forward(
@@ -121,6 +126,10 @@ const BulkMonitorSchema = v.object({
server: v.optional(v.pipe(v.string(), v.trim())),
})
function isHttpsTarget(target: string) {
return /^https:\/\//i.test(target)
}
function normalizeHttpTarget(target: string, port = 0) {
const useExplicitPort = port > 0 && port !== 80 && port !== 443
const hasOriginOnlyTarget = /^https?:\/\/[^/?#]+$/i.test(target)
@@ -198,6 +207,7 @@ function parseBulkMonitorLine(line: string, lineNumber: number, system: string)
port: parsed.output.port ? Number(parsed.output.port) : 0,
server: parsed.output.server || "",
interval: parsed.output.interval || `${defaultInterval}`,
skipTlsVerify: false,
})
}
@@ -720,6 +730,7 @@ function MonitorDialogContent({
const [port, setPort] = useState(monitor?.protocol === "tcp" && monitor.port ? String(monitor.port) : "")
const [server, setServer] = useState(monitor?.protocol === "dns" ? (monitor.server ?? "") : "")
const [monitorInterval, setMonitorInterval] = useState(String(monitor?.interval ?? defaultInterval))
const [skipTlsVerify, setSkipTlsVerify] = useState(monitor?.skipTlsVerify ?? false)
const [loading, setLoading] = useState(false)
const [selectedSystemId, setSelectedSystemId] = useState(monitor?.system ?? "")
const [selectedSystemIds, setSelectedSystemIds] = useState<Set<string>>(new Set())
@@ -728,6 +739,8 @@ function MonitorDialogContent({
const { t } = useLingui()
const isEditing = !!monitor
const dnsTargetIsIp = protocol === "dns" && isIpAddress(target.trim())
// Bare hostnames are normalized to https, so they can also skip verification.
const usesTls = protocol === "http" && !/^http:\/\//i.test(target.trim())
// When the dialog is opened, initialize form fields with monitor values (if editing) or defaults (if adding).
useEffect(() => {
@@ -740,6 +753,7 @@ function MonitorDialogContent({
setPort(monitor?.protocol === "tcp" && monitor.port ? String(monitor.port) : "")
setServer(monitor?.protocol === "dns" ? (monitor.server ?? "") : "")
setMonitorInterval(String(monitor?.interval ?? defaultInterval))
setSkipTlsVerify(monitor?.skipTlsVerify ?? false)
setSelectedSystemId(monitor?.system ?? "")
setSelectedSystemIds(new Set())
setLoading(false)
@@ -761,6 +775,7 @@ function MonitorDialogContent({
port: protocol === "tcp" ? Number(port) : 0,
server: protocol === "dns" ? server.trim() : "",
interval: monitorInterval,
skipTlsVerify,
},
monitor ? monitor.enabled : true
)
@@ -910,6 +925,22 @@ function MonitorDialogContent({
required
/>
</div>
{usesTls && (
<label
htmlFor="monitor-skip-tls-verify"
className="flex items-center justify-between gap-4 cursor-pointer rounded-lg border border-muted-foreground/15 hover:border-muted-foreground/20 transition-colors duration-100 px-3.5 py-3"
>
<div className="grid gap-1 select-none">
<span className="text-sm font-medium">
<Trans>Ignore TLS certificate errors</Trans>
</span>
<span className="text-xs text-muted-foreground">
<Trans>Use for self-signed certificates.</Trans>
</span>
</div>
<Switch id="monitor-skip-tls-verify" checked={skipTlsVerify} onCheckedChange={setSkipTlsVerify} />
</label>
)}
<DialogFooter>
{!isEditing && onOpenBulkAdd && (
<Button
@@ -16,6 +16,7 @@ import {
PlayCircleIcon,
CopyIcon,
ShieldCheckIcon,
ShieldOffIcon,
} from "lucide-react"
import { t } from "@lingui/core/macro"
import type { NetworkMonitorRecord, SystemRecord } from "@/types"
@@ -50,6 +51,8 @@ const protocolColors: Record<string, string> = {
tcp: "bg-purple-500/15! text-purple-600 dark:text-purple-400",
http: "bg-green-500/15! text-green-700 dark:text-green-400",
dns: "bg-amber-500/15! text-amber-600 dark:text-amber-400",
// HTTP monitors that skip TLS certificate verification
insecure: "bg-orange-500/15! text-orange-600 dark:text-orange-400",
}
const SYSTEM_STATUS_COLORS = {
@@ -175,8 +178,16 @@ export function getMonitorColumns(
meta: { label: t`Protocol` },
accessorFn: (record) => record.protocol,
header: ({ column }) => <HeaderButton column={column} name={t`Protocol`} Icon={ArrowLeftRightIcon} />,
cell: ({ getValue }) => {
cell: ({ row, getValue }) => {
const protocol = getValue() as string
if (row.original.skipTlsVerify) {
return (
<Badge className={cn("uppercase gap-1", protocolColors.insecure)}>
{protocol}
<ShieldOffIcon className="size-3" />
</Badge>
)
}
return <Badge className={cn("uppercase", protocolColors[protocol])}>{protocol}</Badge>
},
},
@@ -1,5 +1,5 @@
import { getMonitorTarget, monitorGapRecord } from "@/lib/network-monitor-utils"
import LineChartDefault, { isolatedDot } from "@/components/charts/line-chart"
import LineChartDefault from "@/components/charts/line-chart"
import type { DataPoint } from "@/components/charts/line-chart"
import { decimalString, formatMicroseconds, matchesFilterGroups, parseFilterGroups, toFixedFloat } from "@/lib/utils"
import { $monitorFilter } from "@/lib/stores"
@@ -61,7 +61,8 @@ function MonitorChart({
const points: DataPoint<NetworkMonitorStatsRecord>[] = []
const visibleIDs: string[] = []
const filterGroups = parseFilterGroups(filter)
const dot = chartData.chartTime === "1m"
// show every point at 1m; otherwise the chart default draws only isolated points
const dot = chartData.chartTime === "1m" || undefined
for (let i = 0; i < count; i++) {
const p = sortedMonitors[i]
const label = getLabel(p)
@@ -138,7 +139,6 @@ export function AvgMinMaxResponseChart({ monitorStats, monitor, chartData, empty
record.stats?.[monitor?.id ?? ""]?.[metric] ?? null
const avgPoint = {
label: "Avg",
dot: isolatedDot,
dataKey: dataFn("res_avg"),
color: 1,
order: 0,
@@ -150,7 +150,6 @@ export function AvgMinMaxResponseChart({ monitorStats, monitor, chartData, empty
return [
{
label: "Max",
dot: isolatedDot,
dataKey: dataFn("res_max"),
color: 3,
order: 0,
@@ -158,7 +157,6 @@ export function AvgMinMaxResponseChart({ monitorStats, monitor, chartData, empty
avgPoint,
{
label: "Min",
dot: isolatedDot,
dataKey: dataFn("res_min"),
color: 2,
order: 2,
@@ -151,6 +151,7 @@ export function useSystemData(id: string) {
return
}
let isFirst = true
const { expectedInterval } = chartTimeData["1m"]
pb.realtime
.subscribe(
`rt_metrics`,
@@ -168,9 +169,9 @@ export function useSystemData(id: string) {
setContainerData(containerPoint ? [containerPoint] : [])
return
}
setSystemStats((prev) => appendData(prev, [statsPoint], 1000, 60))
setSystemStats((prev) => appendData(prev, [statsPoint], expectedInterval, 60))
if (containerPoint) {
setContainerData((prev) => appendData(prev, [containerPoint], 1000, 60))
setContainerData((prev) => appendData(prev, [containerPoint], expectedInterval, 60))
}
},
{ query: { system: system.id } }
@@ -82,7 +82,7 @@ async function fetchMonitorStats(
}
const NETWORK_MONITOR_FIELDS =
"id,system,target,protocol,port,server,interval,res,resMin1h,resMax1h,resAvg1h,loss1h,enabled,certInfo,updated"
"id,system,target,protocol,port,server,interval,skipTlsVerify,res,resMin1h,resMax1h,resAvg1h,loss1h,enabled,certInfo,updated"
interface UseNetworkMonitorsProps {
systemId?: string
+2
View File
@@ -694,6 +694,8 @@ export interface NetworkMonitorRecord {
protocol: "icmp" | "tcp" | "http" | "dns"
port: number
server: string
/** Skip TLS certificate verification for HTTPS targets. */
skipTlsVerify: boolean
res: number
resMin1h: number
resMax1h: number
+2 -2
View File
@@ -2,9 +2,9 @@ apiVersion: v1
description: Installs beszel-agent in kubernetes
home: https://github.com/henrygd/beszel/tree/main/supplemental/helm/beszel-agent
name: beszel-agent
appVersion: "0.20.0"
appVersion: "0.21.0"
# Bump this version when publishing chart changes.
version: 0.1.7
version: 0.1.8
sources:
- https://github.com/henrygd/beszel/tree/main/supplemental/helm/beszel-agent
- https://www.beszel.dev/
+3 -3
View File
@@ -80,7 +80,7 @@ Essential parameters to configure:
| `secret.sshKey` | `ssh-key` | Key name in the secret for the SSH public key |
| `secret.tokenKey` | `token` | Key name in the secret for the authentication token |
| `image.repository` | `henrygd/beszel-agent` | Container image |
| `image.tag` | Chart AppVersion (0.20.0) | Image version |
| `image.tag` | Chart AppVersion (0.21.0) | Image version |
| `hostNetwork` | `false` | Use host network for network monitoring |
| `tolerations` | Allows all taints | Tolerations for running on tainted nodes |
@@ -385,7 +385,7 @@ helm upgrade beszel-agent ./beszel-agent \
# Change image version
helm upgrade beszel-agent ./beszel-agent \
--set image.tag="0.20.0"
--set image.tag="0.21.0"
```
### Restart All Agents
@@ -522,7 +522,7 @@ kubectl get secret beszel-agent -o jsonpath='{.data.ssh-key}' | base64 -d
## Chart Information
- **Chart Version**: 0.1.0
- **App Version**: 0.20.0
- **App Version**: 0.21.0
- **Kubernetes Version**: 1.19+
- **Maintainer**: cloudwithdan (nikoloskid@pm.me)
+2 -2
View File
@@ -2,9 +2,9 @@ apiVersion: v1
description: Installs beszel-hub in kubernetes
home: https://github.com/henrygd/beszel/tree/main/supplemental/helm/beszel-hub
name: beszel-hub
appVersion: "0.20.0"
appVersion: "0.21.0"
# Bump this version when publishing chart changes.
version: 0.1.7
version: 0.1.8
sources:
- https://github.com/henrygd/beszel/tree/main/supplemental/helm/beszel-hub
- https://www.beszel.dev/
+3 -3
View File
@@ -47,7 +47,7 @@ Key configuration options in `values.yaml`:
|-----------|---------|-------------|
| `replicaCount` | `1` | Number of Beszel Hub replicas |
| `image.repository` | `henrygd/beszel` | Container image repository |
| `image.tag` | Chart AppVersion (0.20.0) | Container image tag |
| `image.tag` | Chart AppVersion (0.21.0) | Container image tag |
| `image.pullPolicy` | `IfNotPresent` | Image pull policy |
| `service.port` | `8090` | Service port |
| `persistentVolumeClaim.enabled` | `true` | Enable persistent volume |
@@ -169,7 +169,7 @@ tolerations:
```yaml
replicaCount: 3
image:
tag: "0.20.0"
tag: "0.21.0"
service:
type: LoadBalancer
ingress:
@@ -330,7 +330,7 @@ By default, Beszel Hub uses a PersistentVolumeClaim for data storage. Ensure you
## Chart Information
- **Chart Version**: 0.1.0
- **App Version**: 0.20.0
- **App Version**: 0.21.0
- **Kubernetes Version**: 1.19+
- **Maintainer**: cloudwithdan (nikoloskid@pm.me)