Compare commits

..
Author SHA1 Message Date
DigitalandGitHub 214180a431 fix: SMART_DEVICES with same path and different -d types only shows one device (#2053)
* fix: SMART_DEVICES with same path and different -d types only shows one device

* test: add unit test for parseSmartForSata with configuredType

* refactor: replace string manipulation with normalizeParserType for disk type handling

* fix: handle transient failures for USB bridge passthrough device types in CollectSmart
2026-08-17 13:57:09 -04:00
119 changed files with 771 additions and 4792 deletions
-12
View File
@@ -1,12 +0,0 @@
version: 2
updates:
- package-ecosystem: gomod
directory: /
schedule:
interval: weekly
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
+8 -36
View File
@@ -41,7 +41,7 @@ jobs:
# henrygd/beszel-agent-nvidia # henrygd/beszel-agent-nvidia
- image: henrygd/beszel-agent-nvidia - image: henrygd/beszel-agent-nvidia
dockerfile: ./internal/dockerfile_agent_nvidia dockerfile: ./internal/dockerfile_agent_nvidia
platforms: linux/amd64,linux/arm64 platforms: linux/amd64
registry: docker.io registry: docker.io
username_secret: DOCKERHUB_USERNAME username_secret: DOCKERHUB_USERNAME
password_secret: DOCKERHUB_TOKEN password_secret: DOCKERHUB_TOKEN
@@ -52,19 +52,6 @@ jobs:
type=semver,pattern={{major}} type=semver,pattern={{major}}
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }} type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
# henrygd/beszel-agent-nvidia:slim
- image: henrygd/beszel-agent-nvidia
dockerfile: ./internal/dockerfile_agent_nvidia_slim
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 # henrygd/beszel-agent-intel
- image: henrygd/beszel-agent-intel - image: henrygd/beszel-agent-intel
dockerfile: ./internal/dockerfile_agent_intel dockerfile: ./internal/dockerfile_agent_intel
@@ -109,7 +96,7 @@ jobs:
# ghcr.io/henrygd/beszel-agent-nvidia # ghcr.io/henrygd/beszel-agent-nvidia
- image: ghcr.io/${{ github.repository }}/beszel-agent-nvidia - image: ghcr.io/${{ github.repository }}/beszel-agent-nvidia
dockerfile: ./internal/dockerfile_agent_nvidia dockerfile: ./internal/dockerfile_agent_nvidia
platforms: linux/amd64,linux/arm64 platforms: linux/amd64
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password_secret: GITHUB_TOKEN password_secret: GITHUB_TOKEN
@@ -120,19 +107,6 @@ jobs:
type=semver,pattern={{major}} type=semver,pattern={{major}}
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }} 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
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 # ghcr.io/henrygd/beszel-agent-intel
- image: ghcr.io/${{ github.repository }}/beszel-agent-intel - image: ghcr.io/${{ github.repository }}/beszel-agent-intel
dockerfile: ./internal/dockerfile_agent_intel dockerfile: ./internal/dockerfile_agent_intel
@@ -178,7 +152,7 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v7 uses: actions/checkout@v4
- name: Set up bun - name: Set up bun
uses: oven-sh/setup-bun@v2 uses: oven-sh/setup-bun@v2
@@ -190,14 +164,14 @@ jobs:
run: bun run --cwd ./internal/site build run: bun run --cwd ./internal/site build
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v4 uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Docker metadata - name: Docker metadata
id: metadata id: metadata
uses: docker/metadata-action@v6 uses: docker/metadata-action@v5
with: with:
images: ${{ matrix.image }} images: ${{ matrix.image }}
tags: ${{ matrix.tags }} tags: ${{ matrix.tags }}
@@ -207,7 +181,7 @@ jobs:
env: env:
password_secret_exists: ${{ secrets[matrix.password_secret] != '' && 'true' || 'false' }} password_secret_exists: ${{ secrets[matrix.password_secret] != '' && 'true' || 'false' }}
if: github.event_name != 'pull_request' && env.password_secret_exists == 'true' if: github.event_name != 'pull_request' && env.password_secret_exists == 'true'
uses: docker/login-action@v4 uses: docker/login-action@v3
with: with:
username: ${{ matrix.username || secrets[matrix.username_secret] }} username: ${{ matrix.username || secrets[matrix.username_secret] }}
password: ${{ secrets[matrix.password_secret] }} password: ${{ secrets[matrix.password_secret] }}
@@ -216,13 +190,11 @@ jobs:
# Build and push Docker image with Buildx (don't push on PR) # Build and push Docker image with Buildx (don't push on PR)
# https://github.com/docker/build-push-action # https://github.com/docker/build-push-action
- name: Build and push Docker image - name: Build and push Docker image
uses: docker/build-push-action@v7 uses: docker/build-push-action@v5
with: with:
context: ./ context: ./
file: ${{ matrix.dockerfile }} file: ${{ matrix.dockerfile }}
platforms: ${{ matrix.platforms || 'linux/amd64,linux/arm64,linux/arm/v6,linux/arm/v7' }} platforms: ${{ matrix.platforms || 'linux/amd64,linux/arm64,linux/arm/v7' }}
push: ${{ github.ref_type == 'tag' && secrets[matrix.password_secret] != '' }} push: ${{ github.ref_type == 'tag' && secrets[matrix.password_secret] != '' }}
provenance: mode=max
sbom: true
tags: ${{ steps.metadata.outputs.tags }} tags: ${{ steps.metadata.outputs.tags }}
labels: ${{ steps.metadata.outputs.labels }} labels: ${{ steps.metadata.outputs.labels }}
+2 -2
View File
@@ -15,7 +15,7 @@ jobs:
name: Lock Inactive Issues name: Lock Inactive Issues
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- uses: klaasnicolaas/action-inactivity-lock@v2.0.1 - uses: klaasnicolaas/action-inactivity-lock@v1.1.3
id: lock id: lock
with: with:
days-inactive-issues: 14 days-inactive-issues: 14
@@ -29,7 +29,7 @@ jobs:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- name: Close Stale Issues - name: Close Stale Issues
uses: actions/stale@v11 uses: actions/stale@v10
with: with:
repo-token: ${{ secrets.GITHUB_TOKEN }} repo-token: ${{ secrets.GITHUB_TOKEN }}
-152
View File
@@ -1,152 +0,0 @@
on:
push:
branches:
- main
paths:
- ".github/workflows/release-please.yml"
- "release-please-config.json"
- ".release-please-manifest.json"
- "supplemental/helm/**"
permissions:
contents: write
issues: write
pull-requests: write
env:
PUBLISHABLE_ITEMS: '["supplemental/helm/beszel-agent", "supplemental/helm/beszel-hub"]'
name: helm-release
concurrency:
group: helm-release
cancel-in-progress: false
jobs:
release:
name: Release
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v5
with:
fetch-depth: 0
- uses: googleapis/release-please-action@v4
name: Prepare release
id: release-please
with:
token: ${{ secrets.CR_TOKEN }}
- name: Dump Release Please Output
env:
RELEASE_PLEASE_OUTPUT: ${{ toJson(steps.release-please.outputs) }}
run: |
echo "$RELEASE_PLEASE_OUTPUT"
- name: Determine what should be published
uses: actions/github-script@v8
id: items-to-publish
env:
CHANGED_ITEMS: "${{ steps.release-please.outputs.paths_released }}"
with:
script: |
const changedItems = JSON.parse(process.env.CHANGED_ITEMS || '[]');
console.log("changed items", changedItems);
const eligibleItems = JSON.parse(process.env.PUBLISHABLE_ITEMS || '[]');
console.log("eligible items", eligibleItems);
const itemsToPublish = changedItems.filter(i => eligibleItems.includes(i));
console.log("items to publish", itemsToPublish);
return itemsToPublish;
outputs:
items_to_publish: ${{ steps.items-to-publish.outputs.result }}
releases: ${{ toJson(steps.release-please.outputs) }}
release-charts:
name: Release Charts
needs: release
runs-on: ubuntu-latest
if: ${{ needs.release.outputs.items_to_publish != '' && toJson(fromJson(needs.release.outputs.items_to_publish)) != '[]' }}
strategy:
fail-fast: false
max-parallel: 1
matrix:
path: ${{ fromJSON(needs.release.outputs.items_to_publish) }}
env:
TAG: ${{ fromJson(needs.release.outputs.releases)[format('{0}--tag_name', matrix.path)] }}
VERSION: ${{ fromJson(needs.release.outputs.releases)[format('{0}--version', matrix.path)] }}
RELEASE_ID: ${{ fromJson(needs.release.outputs.releases)[format('{0}--id', matrix.path)] }}
steps:
- name: Debug
run: |
echo ${{ env.TAG }}
echo ${{ env.VERSION }}
echo ${{ matrix.path }}
- name: ✨ Checkout repository
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Configure Git
run: |
echo ${{ needs.release.outputs.items_to_publish }}
git config user.name "$GITHUB_ACTOR"
git config user.email "$GITHUB_ACTOR@users.noreply.github.com"
- name: Install Chart Releaser
run: |
version=v1.6.0
mkdir .tmp
install_dir=.tmp
echo "Installing chart-releaser on $install_dir..."
curl -sSLo cr.tar.gz "https://github.com/helm/chart-releaser/releases/download/$version/chart-releaser_${version#v}_linux_amd64.tar.gz"
tar -xzf cr.tar.gz -C "$install_dir"
rm -f cr.tar.gz
- name: Package chart
run: |
.tmp/cr package ${{ matrix.path }}
ls -la .cr-release-packages/
- name: Upload chart package to draft release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release upload "$TAG" .cr-release-packages/*.tgz --clobber
- name: Publish chart release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CHART_PATH: ${{ matrix.path }}
run: |
chart_name="${CHART_PATH##*/}"
gh api --method PATCH "repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}" \
-f "name=Helm chart: ${chart_name} v${VERSION}" \
-F draft=false \
-f make_latest=false
- name: Update index
run: |
owner=$(cut -d '/' -f 1 <<< "$GITHUB_REPOSITORY")
repo=$(cut -d '/' -f 2 <<< "$GITHUB_REPOSITORY")
# Create the local output directory for the generated index
mkdir -p docs
# Generate index.yaml for GitHub Pages
args=(-o "$owner" -r "$repo" --push -t "${{ secrets.CR_TOKEN }}" --index-path docs/index.yaml --release-name-template '{{ .Name }}-v{{ .Version }}')
.tmp/cr index "${args[@]}"
- name: Restore application release as latest
if: ${{ always() }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
release_id=$(gh api "repos/${GITHUB_REPOSITORY}/releases?per_page=100" \
--jq 'map(select(.draft == false and .prerelease == false and (.tag_name | test("^v[0-9]"))))[0].id')
test -n "$release_id"
gh api --method PATCH "repos/${GITHUB_REPOSITORY}/releases/${release_id}" \
-f make_latest=true
+4 -52
View File
@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v7 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
@@ -27,12 +27,12 @@ jobs:
run: bun run --cwd ./internal/site build run: bun run --cwd ./internal/site build
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v7 uses: actions/setup-go@v5
with: with:
go-version: "^1.22.1" go-version: "^1.22.1"
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@v6 uses: actions/setup-dotnet@v4
with: with:
dotnet-version: "9.0.x" dotnet-version: "9.0.x"
@@ -42,7 +42,7 @@ jobs:
shell: bash shell: bash
- name: GoReleaser beszel - name: GoReleaser beszel
uses: goreleaser/goreleaser-action@v7 uses: goreleaser/goreleaser-action@v6
with: with:
workdir: ./ workdir: ./
distribution: goreleaser distribution: goreleaser
@@ -52,51 +52,3 @@ jobs:
GITHUB_TOKEN: ${{ secrets.TOKEN || secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.TOKEN || secrets.GITHUB_TOKEN }}
WINGET_TOKEN: ${{ secrets.WINGET_TOKEN }} WINGET_TOKEN: ${{ secrets.WINGET_TOKEN }}
IS_FORK: ${{ github.repository_owner != 'henrygd' }} IS_FORK: ${{ github.repository_owner != 'henrygd' }}
update-helm-app-version:
name: Update Helm app version
needs: goreleaser
if: ${{ github.repository_owner == 'henrygd' }}
runs-on: ubuntu-latest
steps:
- name: Checkout main
uses: actions/checkout@v7
with:
ref: main
token: ${{ secrets.CR_TOKEN }}
- name: Update chart app versions
env:
RELEASE_TAG: ${{ github.ref_name }}
run: |
version="${RELEASE_TAG#v}"
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.-]+)?$ ]]; then
echo "Invalid release version: $version" >&2
exit 1
fi
for chart in supplemental/helm/beszel-agent supplemental/helm/beszel-hub; do
current_version=$(awk -F '"' '/^appVersion:/ { print $2 }' "$chart/Chart.yaml")
NEW_VERSION="$version" perl -pi -e 's/^appVersion:.*$/appVersion: "$ENV{NEW_VERSION}"/' "$chart/Chart.yaml"
OLD_VERSION="$current_version" NEW_VERSION="$version" \
perl -pi -e 's/\Q$ENV{OLD_VERSION}\E/$ENV{NEW_VERSION}/g' "$chart/README.md"
done
- name: Commit app version update
env:
RELEASE_TAG: ${{ github.ref_name }}
run: |
if git diff --quiet -- supplemental/helm; then
echo "Helm charts already use ${RELEASE_TAG#v}"
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add supplemental/helm/beszel-agent/Chart.yaml \
supplemental/helm/beszel-agent/README.md \
supplemental/helm/beszel-hub/Chart.yaml \
supplemental/helm/beszel-hub/README.md
git commit -m "fix(helm): update app version to ${RELEASE_TAG#v}"
git push origin HEAD:main
+3 -3
View File
@@ -19,11 +19,11 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Check out code into the Go module directory - name: Check out code into the Go module directory
uses: actions/checkout@v7 uses: actions/checkout@v6
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v7 uses: actions/setup-go@v6
with: with:
go-version: stable go-version: 1.26.x
# cached: false # cached: false
- name: Get official govulncheck - name: Get official govulncheck
run: go install golang.org/x/vuln/cmd/govulncheck@latest run: go install golang.org/x/vuln/cmd/govulncheck@latest
+1 -2
View File
@@ -3,6 +3,7 @@ pb_data
data data
temp temp
.vscode .vscode
beszel-agent
beszel_data beszel_data
beszel_data* beszel_data*
dist dist
@@ -20,5 +21,3 @@ __debug_*
agent/lhm/obj agent/lhm/obj
agent/lhm/bin agent/lhm/bin
dockerfile_agent_dev dockerfile_agent_dev
.cr-release-packages
.tmp
-11
View File
@@ -22,9 +22,6 @@ builds:
- amd64 - amd64
- arm64 - arm64
- arm - arm
goarm:
- "6"
- "7"
ignore: ignore:
- goos: windows - goos: windows
goarch: arm64 goarch: arm64
@@ -34,8 +31,6 @@ builds:
goarch: arm64 goarch: arm64
- goos: freebsd - goos: freebsd
goarch: arm goarch: arm
- goos: darwin
goarch: arm
- id: beszel-agent - id: beszel-agent
binary: beszel-agent binary: beszel-agent
@@ -57,10 +52,6 @@ builds:
- mipsle - mipsle
- mips - mips
- ppc64le - ppc64le
goarm:
- "5"
- "6"
- "7"
gomips: gomips:
- hardfloat - hardfloat
- softfloat - softfloat
@@ -80,8 +71,6 @@ builds:
gomips: hardfloat gomips: hardfloat
- goos: windows - goos: windows
goarch: arm goarch: arm
- goos: darwin
goarch: arm
- goos: darwin - goos: darwin
goarch: riscv64 goarch: riscv64
- goos: windows - goos: windows
-4
View File
@@ -1,4 +0,0 @@
{
"supplemental/helm/beszel-agent": "0.1.4",
"supplemental/helm/beszel-hub": "0.1.4"
}
+1 -1
View File
@@ -52,7 +52,7 @@ lint:
golangci-lint run golangci-lint run
test: test:
go test -tags='testing no_ui' ./... go test -tags=testing ./...
tidy: tidy:
go mod tidy go mod tidy
+1 -60
View File
@@ -1,13 +1,6 @@
// Package battery provides battery information for the host and connected devices. // Package battery provides functions to check if the system has a battery and return the charge state and percentage.
package battery package battery
import (
"errors"
"sort"
"strconv"
"strings"
)
const ( const (
stateUnknown uint8 = iota stateUnknown uint8 = iota
stateEmpty stateEmpty
@@ -16,55 +9,3 @@ const (
stateDischarging stateDischarging
stateIdle stateIdle
) )
// Battery is a readable battery reported by the operating system.
type Battery struct {
Name string
Percent uint8
State uint8
FullChargeCapacity uint64
HasFullChargeCapacity bool
System bool
}
var errNoBatteries = errors.New("no readable batteries")
// normalizeBatteries supplies stable fallback names and disambiguates duplicates.
func normalizeBatteries(batteries []Battery) []Battery {
nameCounts := make(map[string]int, len(batteries))
for i := range batteries {
name := strings.TrimSpace(batteries[i].Name)
if name == "" {
name = "Battery " + strconv.Itoa(i+1)
}
nameCounts[name]++
if nameCounts[name] > 1 {
name += " (" + strconv.Itoa(nameCounts[name]) + ")"
}
batteries[i].Name = name
}
return batteries
}
// Primary returns the representative battery. Reported full-charge capacity wins,
// then system-scoped devices, then name for deterministic ties.
func Primary(batteries []Battery) (Battery, bool) {
if len(batteries) == 0 {
return Battery{}, false
}
ordered := append([]Battery(nil), batteries...)
sort.SliceStable(ordered, func(i, j int) bool {
a, b := ordered[i], ordered[j]
if a.HasFullChargeCapacity != b.HasFullChargeCapacity {
return a.HasFullChargeCapacity
}
if a.HasFullChargeCapacity && a.FullChargeCapacity != b.FullChargeCapacity {
return a.FullChargeCapacity > b.FullChargeCapacity
}
if a.System != b.System {
return a.System
}
return a.Name < b.Name
})
return ordered[0], true
}
+47 -27
View File
@@ -3,7 +3,11 @@
package battery package battery
import ( import (
"errors"
"log/slog"
"math"
"os/exec" "os/exec"
"sync"
"howett.net/plist" "howett.net/plist"
) )
@@ -31,46 +35,62 @@ func readMacBatteries() ([]macBattery, error) {
return batteries, nil return batteries, nil
} }
func HasReadableBattery() bool { // HasReadableBattery checks if the system has a battery and returns true if it does.
batteries, _ := GetBatteryStats() var HasReadableBattery = sync.OnceValue(func() bool {
return len(batteries) > 0 systemHasBattery := false
}
// GetBatteryStats returns every readable battery reported by macOS.
func GetBatteryStats() ([]Battery, error) {
batteries, err := readMacBatteries() batteries, err := readMacBatteries()
if err != nil { slog.Debug("Batteries", "batteries", batteries, "err", err)
return nil, err
}
if len(batteries) == 0 {
return nil, errNoBatteries
}
result := make([]Battery, 0, len(batteries))
for _, bat := range batteries { for _, bat := range batteries {
if bat.MaxCapacity <= 0 { if bat.MaxCapacity > 0 {
systemHasBattery = true
break
}
}
return systemHasBattery
})
// GetBatteryStats returns the current battery percent and charge state.
// Uses CurrentCapacity/MaxCapacity to match the value macOS displays.
func GetBatteryStats() (batteryPercent uint8, batteryState uint8, err error) {
if !HasReadableBattery() {
return batteryPercent, batteryState, errors.ErrUnsupported
}
batteries, err := readMacBatteries()
if len(batteries) == 0 {
return batteryPercent, batteryState, errors.New("no batteries")
}
totalCapacity := 0
totalCharge := 0
batteryState = math.MaxUint8
for _, bat := range batteries {
if bat.MaxCapacity == 0 {
// skip ghost batteries with 0 capacity // skip ghost batteries with 0 capacity
// https://github.com/distatus/battery/issues/34 // https://github.com/distatus/battery/issues/34
continue continue
} }
percent := min(max(float64(bat.CurrentCapacity)/float64(bat.MaxCapacity)*100, 0), 100) totalCapacity += bat.MaxCapacity
state := stateUnknown totalCharge += min(bat.CurrentCapacity, bat.MaxCapacity)
switch { switch {
case !bat.ExternalConnected: case !bat.ExternalConnected:
state = stateDischarging batteryState = stateDischarging
case bat.IsCharging: case bat.IsCharging:
state = stateCharging batteryState = stateCharging
case bat.CurrentCapacity == 0: case bat.CurrentCapacity == 0:
state = stateEmpty batteryState = stateEmpty
case !bat.FullyCharged: case !bat.FullyCharged:
state = stateIdle batteryState = stateIdle
default: default:
state = stateFull batteryState = stateFull
} }
result = append(result, Battery{Name: "Primary", Percent: uint8(percent), State: state,
FullChargeCapacity: uint64(bat.MaxCapacity), HasFullChargeCapacity: true, System: true})
} }
if len(result) == 0 {
return nil, errNoBatteries if totalCapacity == 0 || batteryState == math.MaxUint8 {
return batteryPercent, batteryState, errors.New("no battery capacity")
} }
return normalizeBatteries(result), nil
batteryPercent = uint8(float64(totalCharge) / float64(totalCapacity) * 100)
return batteryPercent, batteryState, nil
} }
+75 -40
View File
@@ -3,19 +3,58 @@
package battery package battery
import ( import (
"errors"
"log/slog"
"math"
"os" "os"
"path/filepath" "path/filepath"
"strconv" "strconv"
"sync"
"github.com/henrygd/beszel/agent/utils" "github.com/henrygd/beszel/agent/utils"
) )
var batteryRoot = "/sys/class/power_supply" // getBatteryPaths returns the paths of all batteries in /sys/class/power_supply
var getBatteryPaths func() ([]string, error)
// HasReadableBattery reports whether collection currently finds a readable battery. // HasReadableBattery checks if the system has a battery and returns true if it does.
func HasReadableBattery() bool { var HasReadableBattery func() bool
batteries, _ := GetBatteryStats()
return len(batteries) > 0 func init() {
resetBatteryState("/sys/class/power_supply")
}
// resetBatteryState resets the sync.Once functions to a fresh state.
// Tests call this after swapping sysfsPowerSupply so the new path is picked up.
func resetBatteryState(sysfsPowerSupplyPath string) {
getBatteryPaths = sync.OnceValues(func() ([]string, error) {
entries, err := os.ReadDir(sysfsPowerSupplyPath)
if err != nil {
return nil, err
}
var paths []string
for _, e := range entries {
path := filepath.Join(sysfsPowerSupplyPath, e.Name())
if utils.ReadStringFile(filepath.Join(path, "type")) == "Battery" {
paths = append(paths, path)
}
}
return paths, nil
})
HasReadableBattery = sync.OnceValue(func() bool {
systemHasBattery := false
paths, err := getBatteryPaths()
for _, path := range paths {
if _, ok := utils.ReadStringFileOK(filepath.Join(path, "capacity")); ok {
systemHasBattery = true
break
}
}
if !systemHasBattery {
slog.Debug("No battery found", "err", err)
}
return systemHasBattery
})
} }
func parseSysfsState(status string) uint8 { func parseSysfsState(status string) uint8 {
@@ -35,18 +74,26 @@ func parseSysfsState(status string) uint8 {
} }
} }
// GetBatteryStats re-enumerates power supplies and returns every readable battery. // GetBatteryStats returns the current battery percent and charge state.
func GetBatteryStats() ([]Battery, error) { // Reads /sys/class/power_supply/*/capacity directly so the kernel-reported
entries, err := os.ReadDir(batteryRoot) // value is used, which is always 0-100 and matches what the OS displays.
if err != nil { func GetBatteryStats() (batteryPercent uint8, batteryState uint8, err error) {
return nil, err if !HasReadableBattery() {
return batteryPercent, batteryState, errors.ErrUnsupported
} }
batteries := make([]Battery, 0, len(entries)) paths, err := getBatteryPaths()
for _, entry := range entries { if err != nil {
path := filepath.Join(batteryRoot, entry.Name()) return batteryPercent, batteryState, err
if utils.ReadStringFile(filepath.Join(path, "type")) != "Battery" { }
continue if len(paths) == 0 {
} return batteryPercent, batteryState, errors.New("no batteries")
}
batteryState = math.MaxUint8
totalPercent := 0
count := 0
for _, path := range paths {
capStr, ok := utils.ReadStringFileOK(filepath.Join(path, "capacity")) capStr, ok := utils.ReadStringFileOK(filepath.Join(path, "capacity"))
if !ok { if !ok {
continue continue
@@ -55,31 +102,19 @@ func GetBatteryStats() ([]Battery, error) {
if parseErr != nil { if parseErr != nil {
continue continue
} }
cap = min(max(cap, 0), 100) totalPercent += cap
name := utils.ReadStringFile(filepath.Join(path, "model_name")) count++
if name == "" {
name = utils.ReadStringFile(filepath.Join(path, "model")) state := parseSysfsState(utils.ReadStringFile(filepath.Join(path, "status")))
if state != stateUnknown {
batteryState = state
} }
if name == "" {
name = entry.Name()
}
battery := Battery{
Name: name,
Percent: uint8(cap),
State: parseSysfsState(utils.ReadStringFile(filepath.Join(path, "status"))),
System: utils.ReadStringFile(filepath.Join(path, "scope")) != "Device",
}
for _, fullName := range []string{"charge_full", "energy_full"} {
if parsed, ok := utils.ReadUintFile(filepath.Join(path, fullName)); ok && parsed > 0 {
battery.FullChargeCapacity = parsed
battery.HasFullChargeCapacity = true
break
}
}
batteries = append(batteries, battery)
} }
if len(batteries) == 0 {
return nil, errNoBatteries if count == 0 || batteryState == math.MaxUint8 {
return batteryPercent, batteryState, errors.New("no battery capacity")
} }
return normalizeBatteries(batteries), nil
batteryPercent = uint8(totalPercent / count)
return batteryPercent, batteryState, nil
} }
+169 -77
View File
@@ -8,102 +8,194 @@ import (
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
type fakeBattery struct{ id, name, capacity, status, full, scope string } // setupFakeSysfs creates a temporary sysfs-like tree under t.TempDir(),
// swaps sysfsPowerSupply, resets the sync.Once caches, and restores
func setupFakeSysfs(t *testing.T) (string, func(fakeBattery)) { // everything on cleanup. Returns a helper to create battery directories.
func setupFakeSysfs(t *testing.T) (tmpDir string, addBattery func(name, capacity, status string)) {
t.Helper() t.Helper()
root := t.TempDir()
previousRoot := batteryRoot tmp := t.TempDir()
batteryRoot = root resetBatteryState(tmp)
t.Cleanup(func() { batteryRoot = previousRoot })
write := func(path, value string) { write := func(path, content string) {
t.Helper() t.Helper()
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) dir := filepath.Dir(path)
require.NoError(t, os.WriteFile(path, []byte(value), 0o644)) if err := os.MkdirAll(dir, 0o755); err != nil {
} t.Fatal(err)
add := func(b fakeBattery) {
t.Helper()
dir := filepath.Join(root, b.id)
write(filepath.Join(dir, "type"), "Battery")
if b.capacity != "" {
write(filepath.Join(dir, "capacity"), b.capacity)
} }
write(filepath.Join(dir, "status"), b.status) if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
if b.name != "" { t.Fatal(err)
write(filepath.Join(dir, "model_name"), b.name)
}
if b.full != "" {
write(filepath.Join(dir, "energy_full"), b.full)
}
if b.scope != "" {
write(filepath.Join(dir, "scope"), b.scope)
} }
} }
return root, add
addBattery = func(name, capacity, status string) {
t.Helper()
batDir := filepath.Join(tmp, name)
write(filepath.Join(batDir, "type"), "Battery")
write(filepath.Join(batDir, "capacity"), capacity)
write(filepath.Join(batDir, "status"), status)
}
return tmp, addBattery
} }
func TestParseSysfsState(t *testing.T) { func TestParseSysfsState(t *testing.T) {
assert.Equal(t, stateEmpty, parseSysfsState("Empty")) tests := []struct {
assert.Equal(t, stateFull, parseSysfsState("Full")) input string
assert.Equal(t, stateCharging, parseSysfsState("Charging")) want uint8
assert.Equal(t, stateDischarging, parseSysfsState("Discharging")) }{
assert.Equal(t, stateIdle, parseSysfsState("Not charging")) {"Empty", stateEmpty},
assert.Equal(t, stateUnknown, parseSysfsState("SomethingElse")) {"Full", stateFull},
{"Charging", stateCharging},
{"Discharging", stateDischarging},
{"Not charging", stateIdle},
{"", stateUnknown},
{"SomethingElse", stateUnknown},
}
for _, tt := range tests {
assert.Equal(t, tt.want, parseSysfsState(tt.input), "parseSysfsState(%q)", tt.input)
}
} }
func TestGetBatteryStatsMultipleNamedAndPrimary(t *testing.T) { func TestGetBatteryStats_SingleBattery(t *testing.T) {
_, add := setupFakeSysfs(t) _, addBattery := setupFakeSysfs(t)
add(fakeBattery{id: "BAT0", name: "Primary", capacity: "105", status: "Charging", full: "5000", scope: "System"}) addBattery("BAT0", "72", "Discharging")
add(fakeBattery{id: "hidpp_battery_0", name: "MX Keys S", capacity: "55", status: "Unknown", full: "900", scope: "Device"})
batteries, err := GetBatteryStats() pct, state, err := GetBatteryStats()
require.NoError(t, err) assert.NoError(t, err)
require.Len(t, batteries, 2) assert.Equal(t, uint8(72), pct)
assert.Equal(t, "Primary", batteries[0].Name) assert.Equal(t, stateDischarging, state)
assert.Equal(t, uint8(100), batteries[0].Percent)
assert.Equal(t, stateUnknown, batteries[1].State)
primary, ok := Primary(batteries)
require.True(t, ok)
assert.Equal(t, "Primary", primary.Name)
} }
func TestGetBatteryStatsFallbackDuplicatesAndUnreadable(t *testing.T) { func TestGetBatteryStats_MultipleBatteries(t *testing.T) {
root, add := setupFakeSysfs(t) _, addBattery := setupFakeSysfs(t)
add(fakeBattery{id: "BAT0", name: "Keyboard", capacity: "80", status: "Discharging"}) addBattery("BAT0", "80", "Charging")
add(fakeBattery{id: "BAT1", name: "Keyboard", capacity: "-4", status: "SomethingWeird"}) addBattery("BAT1", "40", "Charging")
add(fakeBattery{id: "BAT2", capacity: "not-a-number", status: "Charging"})
add(fakeBattery{id: "BAT3", capacity: "42", status: "Full"}) pct, state, err := GetBatteryStats()
ac := filepath.Join(root, "AC0") assert.NoError(t, err)
require.NoError(t, os.MkdirAll(ac, 0o755)) // average of 80 and 40 = 60
require.NoError(t, os.WriteFile(filepath.Join(ac, "type"), []byte("Mains"), 0o644)) assert.EqualValues(t, 60, pct)
batteries, err := GetBatteryStats() assert.Equal(t, stateCharging, state)
require.NoError(t, err)
require.Len(t, batteries, 3)
assert.Equal(t, "Keyboard", batteries[0].Name)
assert.Equal(t, "Keyboard (2)", batteries[1].Name)
assert.Equal(t, uint8(0), batteries[1].Percent)
assert.Equal(t, "BAT3", batteries[2].Name)
} }
func TestGetBatteryStatsHotPlugReenumerates(t *testing.T) { func TestGetBatteryStats_FullBattery(t *testing.T) {
_, add := setupFakeSysfs(t) _, addBattery := setupFakeSysfs(t)
_, err := GetBatteryStats() addBattery("BAT0", "100", "Full")
pct, state, err := GetBatteryStats()
assert.NoError(t, err)
assert.Equal(t, uint8(100), pct)
assert.Equal(t, stateFull, state)
}
func TestGetBatteryStats_EmptyBattery(t *testing.T) {
_, addBattery := setupFakeSysfs(t)
addBattery("BAT0", "0", "Empty")
pct, state, err := GetBatteryStats()
assert.NoError(t, err)
assert.Equal(t, uint8(0), pct)
assert.Equal(t, stateEmpty, state)
}
func TestGetBatteryStats_NotCharging(t *testing.T) {
_, addBattery := setupFakeSysfs(t)
addBattery("BAT0", "80", "Not charging")
pct, state, err := GetBatteryStats()
assert.NoError(t, err)
assert.Equal(t, uint8(80), pct)
assert.Equal(t, stateIdle, state)
}
func TestGetBatteryStats_NoBatteries(t *testing.T) {
setupFakeSysfs(t) // empty directory, no batteries
_, _, err := GetBatteryStats()
assert.Error(t, err) assert.Error(t, err)
assert.False(t, HasReadableBattery()) }
add(fakeBattery{id: "BAT0", capacity: "64", status: "Discharging"})
batteries, err := GetBatteryStats() func TestGetBatteryStats_NonBatterySupplyIgnored(t *testing.T) {
require.NoError(t, err) tmp, addBattery := setupFakeSysfs(t)
// Add a real battery
addBattery("BAT0", "55", "Charging")
// Add an AC adapter (type != Battery) - should be ignored
acDir := filepath.Join(tmp, "AC0")
if err := os.MkdirAll(acDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(acDir, "type"), []byte("Mains"), 0o644); err != nil {
t.Fatal(err)
}
pct, state, err := GetBatteryStats()
assert.NoError(t, err)
assert.Equal(t, uint8(55), pct)
assert.Equal(t, stateCharging, state)
}
func TestGetBatteryStats_InvalidCapacitySkipped(t *testing.T) {
tmp, addBattery := setupFakeSysfs(t)
// One battery with valid capacity
addBattery("BAT0", "90", "Discharging")
// Another with invalid capacity text
badDir := filepath.Join(tmp, "BAT1")
if err := os.MkdirAll(badDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(badDir, "type"), []byte("Battery"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(badDir, "capacity"), []byte("not-a-number"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(badDir, "status"), []byte("Discharging"), 0o644); err != nil {
t.Fatal(err)
}
pct, _, err := GetBatteryStats()
assert.NoError(t, err)
// Only BAT0 counted
assert.Equal(t, uint8(90), pct)
}
func TestGetBatteryStats_UnknownStatusOnly(t *testing.T) {
_, addBattery := setupFakeSysfs(t)
addBattery("BAT0", "50", "SomethingWeird")
_, _, err := GetBatteryStats()
assert.Error(t, err)
}
func TestHasReadableBattery_True(t *testing.T) {
_, addBattery := setupFakeSysfs(t)
addBattery("BAT0", "50", "Charging")
assert.True(t, HasReadableBattery()) assert.True(t, HasReadableBattery())
require.Len(t, batteries, 1)
assert.Equal(t, uint8(64), batteries[0].Percent)
} }
func TestGetBatteryStatsNoReadableCapacity(t *testing.T) { func TestHasReadableBattery_False(t *testing.T) {
_, add := setupFakeSysfs(t) setupFakeSysfs(t) // no batteries
add(fakeBattery{id: "BAT0", status: "Charging"})
_, err := GetBatteryStats() assert.False(t, HasReadableBattery())
assert.Error(t, err) }
func TestHasReadableBattery_NoCapacityFile(t *testing.T) {
tmp, _ := setupFakeSysfs(t)
// Battery dir with type file but no capacity file
batDir := filepath.Join(tmp, "BAT0")
err := os.MkdirAll(batDir, 0o755)
assert.NoError(t, err)
err = os.WriteFile(filepath.Join(batDir, "type"), []byte("Battery"), 0o644)
assert.NoError(t, err)
assert.False(t, HasReadableBattery()) assert.False(t, HasReadableBattery())
} }
+2 -2
View File
@@ -8,6 +8,6 @@ func HasReadableBattery() bool {
return false return false
} }
func GetBatteryStats() ([]Battery, error) { func GetBatteryStats() (uint8, uint8, error) {
return nil, errors.ErrUnsupported return 0, 0, errors.ErrUnsupported
} }
-35
View File
@@ -1,35 +0,0 @@
package battery
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPrimarySelection(t *testing.T) {
tests := []struct {
name string
bats []Battery
want string
}{
{"largest reported capacity", []Battery{{Name: "Small", FullChargeCapacity: 20, HasFullChargeCapacity: true, System: true}, {Name: "Large", FullChargeCapacity: 80, HasFullChargeCapacity: true}}, "Large"},
{"reported ranks over missing", []Battery{{Name: "Unknown", System: true}, {Name: "Known", FullChargeCapacity: 1, HasFullChargeCapacity: true}}, "Known"},
{"system wins capacity tie", []Battery{{Name: "Peripheral", FullChargeCapacity: 50, HasFullChargeCapacity: true}, {Name: "System", FullChargeCapacity: 50, HasFullChargeCapacity: true, System: true}}, "System"},
{"name resolves final tie", []Battery{{Name: "Zed"}, {Name: "Alpha"}}, "Alpha"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, ok := Primary(tt.bats)
require.True(t, ok)
assert.Equal(t, tt.want, got.Name)
})
}
_, ok := Primary(nil)
assert.False(t, ok)
}
func TestNormalizeBatteriesFallbackNames(t *testing.T) {
bats := normalizeBatteries([]Battery{{}, {}, {Name: "Mouse"}, {Name: "Mouse"}})
assert.Equal(t, []string{"Battery 1", "Battery 2", "Mouse", "Mouse (2)"}, []string{bats[0].Name, bats[1].Name, bats[2].Name, bats[3].Name})
}
+50 -43
View File
@@ -7,6 +7,9 @@ package battery
import ( import (
"errors" "errors"
"log/slog"
"math"
"sync"
"syscall" "syscall"
"unsafe" "unsafe"
@@ -76,7 +79,7 @@ var (
setupDiDestroyDeviceInfoList = setupapi.NewProc("SetupDiDestroyDeviceInfoList") setupDiDestroyDeviceInfoList = setupapi.NewProc("SetupDiDestroyDeviceInfoList")
) )
// winBatteryGet reads one battery by index. // winBatteryGet reads one battery by index. Returns (fullCapacity, currentCapacity, state, error).
// Returns error == errNotFound when there are no more batteries. // Returns error == errNotFound when there are no more batteries.
var errNotFound = errors.New("no more batteries") var errNotFound = errors.New("no more batteries")
@@ -119,7 +122,7 @@ func readWinBatteryState(powerState uint32) uint8 {
} }
} }
func winBatteryGet(idx int) (Battery, error) { func winBatteryGet(idx int) (full, current uint32, state uint8, err error) {
hdev, err := setupDiSetup( hdev, err := setupDiSetup(
setupDiGetClassDevsW, setupDiGetClassDevsW,
4, 4,
@@ -129,7 +132,7 @@ func winBatteryGet(idx int) (Battery, error) {
0, 0, 0, 0,
) )
if err != nil { if err != nil {
return Battery{}, err return 0, 0, stateUnknown, err
} }
defer syscall.SyscallN(setupDiDestroyDeviceInfoList.Addr(), hdev) defer syscall.SyscallN(setupDiDestroyDeviceInfoList.Addr(), hdev)
@@ -145,10 +148,10 @@ func winBatteryGet(idx int) (Battery, error) {
0, 0,
) )
if errno == 259 { // ERROR_NO_MORE_ITEMS if errno == 259 { // ERROR_NO_MORE_ITEMS
return Battery{}, errNotFound return 0, 0, stateUnknown, errNotFound
} }
if errno != 0 { if errno != 0 {
return Battery{}, errno return 0, 0, stateUnknown, errno
} }
var cbRequired uint32 var cbRequired uint32
@@ -162,7 +165,7 @@ func winBatteryGet(idx int) (Battery, error) {
0, 0,
) )
if errno != 0 && errno != 122 { // ERROR_INSUFFICIENT_BUFFER if errno != 0 && errno != 122 { // ERROR_INSUFFICIENT_BUFFER
return Battery{}, errno return 0, 0, stateUnknown, errno
} }
didd := make([]uint16, cbRequired/2) didd := make([]uint16, cbRequired/2)
cbSize := (*uint32)(unsafe.Pointer(&didd[0])) cbSize := (*uint32)(unsafe.Pointer(&didd[0]))
@@ -182,7 +185,7 @@ func winBatteryGet(idx int) (Battery, error) {
0, 0,
) )
if errno != 0 { if errno != 0 {
return Battery{}, errno return 0, 0, stateUnknown, errno
} }
devicePath := &didd[2:][0] devicePath := &didd[2:][0]
@@ -196,7 +199,7 @@ func winBatteryGet(idx int) (Battery, error) {
0, 0,
) )
if err != nil { if err != nil {
return Battery{}, err return 0, 0, stateUnknown, err
} }
defer windows.CloseHandle(handle) defer windows.CloseHandle(handle)
@@ -213,7 +216,7 @@ func winBatteryGet(idx int) (Battery, error) {
&dwOut, nil, &dwOut, nil,
) )
if err != nil || bqi.BatteryTag == 0 { if err != nil || bqi.BatteryTag == 0 {
return Battery{}, errors.New("battery tag not returned") return 0, 0, stateUnknown, errors.New("battery tag not returned")
} }
var bi batteryInformation var bi batteryInformation
@@ -226,21 +229,7 @@ func winBatteryGet(idx int) (Battery, error) {
uint32(unsafe.Sizeof(bi)), uint32(unsafe.Sizeof(bi)),
&dwOut, nil, &dwOut, nil,
); err != nil { ); err != nil {
return Battery{}, err return 0, 0, stateUnknown, err
}
// BatteryDeviceName is optional, so retain the deterministic fallback on error.
name := ""
nameQuery := bqi
nameQuery.InformationLevel = 4 // BatteryDeviceName
nameBuffer := make([]uint16, 128)
if err := windows.DeviceIoControl(
handle, 2703428,
(*byte)(unsafe.Pointer(&nameQuery)), uint32(unsafe.Sizeof(nameQuery)),
(*byte)(unsafe.Pointer(&nameBuffer[0])), uint32(len(nameBuffer)*2),
&dwOut, nil,
); err == nil {
name = windows.UTF16ToString(nameBuffer)
} }
bws := batteryWaitStatus{BatteryTag: bqi.BatteryTag} bws := batteryWaitStatus{BatteryTag: bqi.BatteryTag}
@@ -254,38 +243,56 @@ func winBatteryGet(idx int) (Battery, error) {
uint32(unsafe.Sizeof(bs)), uint32(unsafe.Sizeof(bs)),
&dwOut, nil, &dwOut, nil,
); err != nil { ); err != nil {
return Battery{}, err return 0, 0, stateUnknown, err
} }
if bs.Capacity == 0xffffffff || bi.FullChargedCapacity == 0 || bi.FullChargedCapacity == 0xffffffff { if bs.Capacity == 0xffffffff { // BATTERY_UNKNOWN_CAPACITY
return Battery{}, errors.New("battery capacity unknown") return 0, 0, stateUnknown, errors.New("battery capacity unknown")
} }
percent := min(float64(bs.Capacity)/float64(bi.FullChargedCapacity)*100, 100)
return Battery{Name: name, Percent: uint8(percent), State: readWinBatteryState(bs.PowerState), return bi.FullChargedCapacity, bs.Capacity, readWinBatteryState(bs.PowerState), nil
FullChargeCapacity: uint64(bi.FullChargedCapacity), HasFullChargeCapacity: true, System: true}, nil
} }
// HasReadableBattery checks if the system has a battery and returns true if it does. // HasReadableBattery checks if the system has a battery and returns true if it does.
func HasReadableBattery() bool { var HasReadableBattery = sync.OnceValue(func() bool {
batteries, _ := GetBatteryStats() systemHasBattery := false
return len(batteries) > 0 full, _, _, err := winBatteryGet(0)
} if err == nil && full > 0 {
systemHasBattery = true
}
if !systemHasBattery {
slog.Debug("No battery found", "err", err)
}
return systemHasBattery
})
// GetBatteryStats returns the current battery percent and charge state.
func GetBatteryStats() (batteryPercent uint8, batteryState uint8, err error) {
if !HasReadableBattery() {
return batteryPercent, batteryState, errors.ErrUnsupported
}
totalFull := uint32(0)
totalCurrent := uint32(0)
batteryState = math.MaxUint8
// GetBatteryStats returns every readable battery reported by Windows.
func GetBatteryStats() ([]Battery, error) {
batteries := make([]Battery, 0, 2)
for i := 0; ; i++ { for i := 0; ; i++ {
battery, bErr := winBatteryGet(i) full, current, state, bErr := winBatteryGet(i)
if errors.Is(bErr, errNotFound) { if errors.Is(bErr, errNotFound) {
break break
} }
if bErr != nil { if bErr != nil || full == 0 {
continue continue
} }
batteries = append(batteries, battery) totalFull += full
totalCurrent += min(current, full)
batteryState = state
} }
if len(batteries) == 0 {
return nil, errNoBatteries if totalFull == 0 || batteryState == math.MaxUint8 {
return batteryPercent, batteryState, errors.New("no battery capacity")
} }
return normalizeBatteries(batteries), nil
batteryPercent = uint8(float64(totalCurrent) / float64(totalFull) * 100)
return batteryPercent, batteryState, nil
} }
+2 -2
View File
@@ -53,8 +53,8 @@ func newWebSocketClient(agent *Agent) (client *WebSocketClient, err error) {
client = &WebSocketClient{} client = &WebSocketClient{}
client.hubURL, err = url.Parse(hubURLStr) client.hubURL, err = url.Parse(hubURLStr)
if err != nil || client.hubURL.Host == "" { if err != nil {
return nil, fmt.Errorf("invalid HUB_URL %q: must include scheme and host (e.g. http://hub.example.com:8090)", hubURLStr) return nil, errors.New("invalid hub URL")
} }
// get registration token // get registration token
client.token, err = getToken() client.token, err = getToken()
+2 -9
View File
@@ -51,18 +51,11 @@ func TestNewWebSocketClient(t *testing.T) {
errorMsg: "HUB_URL environment variable not set", errorMsg: "HUB_URL environment variable not set",
}, },
{ {
name: "malformed URL", name: "invalid URL",
hubURL: "ht\ttp://invalid", hubURL: "ht\ttp://invalid",
token: "test-token", token: "test-token",
expectError: true, expectError: true,
errorMsg: "invalid HUB_URL", errorMsg: "invalid hub URL",
},
{
name: "URL without host",
hubURL: "http:/api",
token: "test-token",
expectError: true,
errorMsg: "invalid HUB_URL",
}, },
{ {
name: "missing token", name: "missing token",
+11 -18
View File
@@ -65,6 +65,7 @@ type dockerManager struct {
dockerVersionChecked bool // Whether a version probe has completed successfully dockerVersionChecked bool // Whether a version probe has completed successfully
isWindows bool // Whether the Docker Engine API is running on Windows isWindows bool // Whether the Docker Engine API is running on Windows
buf *bytes.Buffer // Buffer to store and read response bodies buf *bytes.Buffer // Buffer to store and read response bodies
decoder *json.Decoder // Reusable JSON decoder that reads from buf
apiStats *container.ApiStats // Reusable API stats object apiStats *container.ApiStats // Reusable API stats object
excludeContainers []string // Patterns to exclude containers by name excludeContainers []string // Patterns to exclude containers by name
usingPodman bool // Whether the Docker Engine API is running on Podman usingPodman bool // Whether the Docker Engine API is running on Podman
@@ -373,26 +374,16 @@ func convertContainerPortsToString(ctr *container.ApiInfo) string {
return "" return ""
} }
sort.Slice(ctr.Ports, func(i, j int) bool { sort.Slice(ctr.Ports, func(i, j int) bool {
if ctr.Ports[i].PublicPort != ctr.Ports[j].PublicPort { return ctr.Ports[i].PublicPort < ctr.Ports[j].PublicPort
return ctr.Ports[i].PublicPort < ctr.Ports[j].PublicPort
}
return ctr.Ports[i].IP < ctr.Ports[j].IP
}) })
var builder strings.Builder var builder strings.Builder
seen := make(map[string]struct{}) seenPorts := make(map[uint16]struct{})
for _, p := range ctr.Ports { for _, p := range ctr.Ports {
if p.PublicPort == 0 { _, ok := seenPorts[p.PublicPort]
if p.PublicPort == 0 || ok {
continue continue
} }
keyIP := p.IP seenPorts[p.PublicPort] = struct{}{}
if keyIP == "0.0.0.0" || keyIP == "::" {
keyIP = ""
}
key := keyIP + ":" + strconv.Itoa(int(p.PublicPort))
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
if builder.Len() > 0 { if builder.Len() > 0 {
builder.WriteString(", ") builder.WriteString(", ")
} }
@@ -756,18 +747,20 @@ func (dm *dockerManager) applyDockerVersionInfo(serverHeader string, versionInfo
} }
} }
// Decodes a Docker API JSON response using a reusable buffer. Not thread safe. // Decodes Docker API JSON response using a reusable buffer and decoder. Not thread safe.
func (dm *dockerManager) decode(resp *http.Response, d any) error { func (dm *dockerManager) decode(resp *http.Response, d any) error {
if dm.buf == nil { if dm.buf == nil {
// initialize buffer with 256kb starting size // initialize buffer with 256kb starting size
dm.buf = bytes.NewBuffer(make([]byte, 0, 1024*256)) dm.buf = bytes.NewBuffer(make([]byte, 0, 1024*256))
dm.decoder = json.NewDecoder(dm.buf)
} }
defer resp.Body.Close() defer resp.Body.Close()
defer dm.buf.Reset() defer dm.buf.Reset()
if _, err := dm.buf.ReadFrom(resp.Body); err != nil { _, err := dm.buf.ReadFrom(resp.Body)
if err != nil {
return err return err
} }
return json.Unmarshal(dm.buf.Bytes(), d) return dm.decoder.Decode(d)
} }
// Test docker / podman sockets and return if one exists // Test docker / podman sockets and return if one exists
-80
View File
@@ -804,24 +804,6 @@ func TestGetDockerStatsRetriesVersionCheckUntilSuccess(t *testing.T) {
assert.Equal(t, 2, requestCounts["/version"]) assert.Equal(t, 2, requestCounts["/version"])
} }
// A failed decode must not break later decodes. Previously the reused json.Decoder
// stayed desynced after one truncated response, breaking decode until restart.
func TestDecodeRecoversFromError(t *testing.T) {
dm := &dockerManager{}
// truncated JSON: body reads fine, decode fails
var bad []container.ApiInfo
err := dm.decode(&http.Response{Body: io.NopCloser(strings.NewReader(`[{"Id":"abc`))}, &bad)
require.Error(t, err)
// the next decode must still succeed
var good []container.ApiInfo
err = dm.decode(&http.Response{Body: io.NopCloser(strings.NewReader(`[{"Id":"abcdef012345","Names":["/ok"]}]`))}, &good)
require.NoError(t, err)
require.Len(t, good, 1)
assert.Equal(t, "abcdef012345", good[0].Id)
}
func TestCycleCpuDeltas(t *testing.T) { func TestCycleCpuDeltas(t *testing.T) {
dm := &dockerManager{ dm := &dockerManager{
lastCpuContainer: map[uint16]map[string]uint64{ lastCpuContainer: map[uint16]map[string]uint64{
@@ -1021,44 +1003,6 @@ func TestCpuPercentageCalculationWithRealData(t *testing.T) {
assert.InDelta(t, expectedPct, actualPct, 0.01) assert.InDelta(t, expectedPct, actualPct, 0.01)
} }
func TestCpuPercentageHandlesCounterRollback(t *testing.T) {
// If a stats response is processed after a newer one for the same container,
// or an accounting counter resets, the current total can be lower than the
// stored previous value. Unsigned subtraction wraps to ~2^64 instead of
// going negative, so the percentage explodes, validateCpuPercentage rejects
// the sample, and the whole collection is discarded - network stats too.
stats := &container.ApiStats{
CPUStats: container.CPUStats{
CPUUsage: container.CPUUsage{TotalUsage: 1_000_000},
SystemUsage: 20_000_000,
},
}
// Container counter went backwards.
assert.Equal(t, 0.0, stats.CalculateCpuPercentLinux(2_000_000, 10_000_000))
// System counter went backwards.
assert.Equal(t, 0.0, stats.CalculateCpuPercentLinux(500_000, 30_000_000))
// A normal forward sample is unaffected: 500000 / 10000000 * 100 = 5%.
assert.InDelta(t, 5.0, stats.CalculateCpuPercentLinux(500_000, 10_000_000), 0.001)
}
func TestCpuPercentageWindowsHandlesCounterRollback(t *testing.T) {
now := time.Now()
stats := &container.ApiStats{
Read: now,
NumProcs: 4,
CPUStats: container.CPUStats{
CPUUsage: container.CPUUsage{TotalUsage: 1_000_000},
},
}
prevRead := now.Add(-time.Second)
// Container counter went backwards.
assert.Equal(t, 0.0, stats.CalculateCpuPercentWindows(2_000_000, prevRead))
// A normal forward sample is unaffected.
assert.Greater(t, stats.CalculateCpuPercentWindows(500_000, prevRead), 0.0)
}
func TestNetworkStatsCalculationWithRealData(t *testing.T) { func TestNetworkStatsCalculationWithRealData(t *testing.T) {
// Create synthetic test data to avoid timing issues // Create synthetic test data to avoid timing issues
apiStats1 := &container.ApiStats{ apiStats1 := &container.ApiStats{
@@ -1920,14 +1864,6 @@ func TestConvertContainerPortsToString(t *testing.T) {
}, },
expected: "80, 443", expected: "80, 443",
}, },
{
name: "ipv4 and ipv6 wildcard bindings are deduplicated",
ports: []port{
{PublicPort: 80, IP: "0.0.0.0"},
{PublicPort: 80, IP: "::"},
},
expected: "80",
},
{ {
name: "multiple ports with different IPs", name: "multiple ports with different IPs",
ports: []port{ ports: []port{
@@ -1936,22 +1872,6 @@ func TestConvertContainerPortsToString(t *testing.T) {
}, },
expected: "80, 1.2.3.4:443", expected: "80, 1.2.3.4:443",
}, },
{
name: "same port bound to multiple IPs shows all entries",
ports: []port{
{PublicPort: 65533, IP: "172.16.151.72"},
{PublicPort: 65533, IP: "172.16.156.25"},
},
expected: "172.16.151.72:65533, 172.16.156.25:65533",
},
{
name: "same port bound to IPv4 and IPv6",
ports: []port{
{PublicPort: 65534, IP: "172.16.151.72"},
{PublicPort: 65534, IP: "fd04:38e2:98c6:3fd::72"},
},
expected: "172.16.151.72:65534, fd04:38e2:98c6:3fd::72:65534",
},
{ {
name: "ports slice is nilled after call", name: "ports slice is nilled after call",
ports: []port{ ports: []port{
-101
View File
@@ -1,101 +0,0 @@
package agent
import (
"log/slog"
"os"
"path/filepath"
"strings"
"sync"
"github.com/henrygd/beszel/agent/utils"
"github.com/henrygd/beszel/internal/entities/system"
)
type fanSensor struct {
key, path string
}
var getFanSensors = newFanSensorCache(hwmonRoot)
func newFanSensorCache(root string) func() ([]fanSensor, error) {
return sync.OnceValues(func() ([]fanSensor, error) {
return discoverHwmonFans(root)
})
}
// updateFans populates systemStats.Fans from the host's hwmon sysfs tree.
// No-op on platforms where hwmon isn't available (see fans_other.go).
func (a *Agent) updateFans(systemStats *system.Stats) {
if hwmonRoot == "" {
return
}
sensors, err := getFanSensors()
if err != nil {
slog.Debug("Error reading fans", "err", err)
return
}
fans := readFanSensors(sensors)
if len(fans) == 0 {
return
}
systemStats.Fans = fans
// Note: Commented out because we don't currently use this value in the UI.
// Compute the single "dashboard" value used by the FanSpeed alert.
// Per-sensor RPMs live in Stats.Fans and drive the multi-line FanChart
// in the UI; the alert path only needs one number to compare against
// the user's threshold, so we use the highest RPM across all fans
// a.systemInfo.DashboardFan = 0
// for _, rpm := range fans {
// if rpm > a.systemInfo.DashboardFan {
// a.systemInfo.DashboardFan = rpm
// }
// }
}
// readHwmonFans walks the given hwmon root (typically /sys/class/hwmon) and
// returns a map of "<chip>_<label-or-fan-idx>" → RPM for every fan*_input
// file it finds. Zero RPM is retained because it can represent a real fan that
// has stopped; negative and malformed readings are ignored.
func readHwmonFans(root string) (map[string]uint16, error) {
sensors, err := discoverHwmonFans(root)
if err != nil {
return nil, err
}
return readFanSensors(sensors), nil
}
func discoverHwmonFans(root string) ([]fanSensor, error) {
entries, err := os.ReadDir(root)
if err != nil {
return nil, err
}
var sensors []fanSensor
for _, entry := range entries {
chipDir := filepath.Join(root, entry.Name())
chipName := utils.ReadStringFile(filepath.Join(chipDir, "name"))
if chipName == "" {
chipName = entry.Name()
}
inputs, _ := filepath.Glob(filepath.Join(chipDir, "fan*_input"))
for _, inputPath := range inputs {
base := strings.TrimSuffix(filepath.Base(inputPath), "_input")
label := utils.ReadStringFile(filepath.Join(chipDir, base+"_label"))
key := chipName + "_" + base
if label != "" {
key = chipName + "_" + label
}
sensors = append(sensors, fanSensor{key, inputPath})
}
}
return sensors, nil
}
func readFanSensors(sensors []fanSensor) map[string]uint16 {
fans := make(map[string]uint16, len(sensors))
for _, sensor := range sensors {
if rpm, ok := utils.ReadUintFile(sensor.path); ok {
fans[sensor.key] = uint16(rpm)
}
}
return fans
}
-8
View File
@@ -1,8 +0,0 @@
//go:build linux
package agent
// hwmonRoot is the sysfs entry point for hardware monitor chips. Each
// subdirectory (hwmon0, hwmon1, …) is one chip; fan*_input files inside it
// expose RPM readings.
const hwmonRoot = "/sys/class/hwmon"
-7
View File
@@ -1,7 +0,0 @@
//go:build !linux
package agent
// hwmonRoot is empty on non-Linux platforms — fan RPM reporting via sysfs
// hwmon is Linux-specific. updateFans() short-circuits when this is empty.
const hwmonRoot = ""
-87
View File
@@ -1,87 +0,0 @@
//go:build testing
package agent
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// writeFile creates path with parents and writes contents.
func writeFile(t *testing.T, path, contents string) {
t.Helper()
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
require.NoError(t, os.WriteFile(path, []byte(contents), 0o644))
}
// TestReadHwmonFans verifies the /sys/class/hwmon walker:
// - picks up fan*_input from every chip,
// - keys entries by chip name + sensor label (or fan idx if no label),
// - retains 0 RPM for stopped fans,
// - tolerates chips with no fan files at all.
func TestReadHwmonFans(t *testing.T) {
root := t.TempDir()
// hwmon0: Raspberry Pi 5 active cooler — one fan, no label.
writeFile(t, filepath.Join(root, "hwmon0", "name"), "pwmfan\n")
writeFile(t, filepath.Join(root, "hwmon0", "fan1_input"), "6500\n")
// hwmon1: a thermal-only chip, no fan files. Must not error.
writeFile(t, filepath.Join(root, "hwmon1", "name"), "cpu_thermal\n")
writeFile(t, filepath.Join(root, "hwmon1", "temp1_input"), "55000\n")
// hwmon2: two fans — one stopped (0 RPM) and one labeled "chassis".
writeFile(t, filepath.Join(root, "hwmon2", "name"), "nct6798\n")
writeFile(t, filepath.Join(root, "hwmon2", "fan1_input"), "0\n")
writeFile(t, filepath.Join(root, "hwmon2", "fan2_input"), "1200\n")
writeFile(t, filepath.Join(root, "hwmon2", "fan2_label"), "chassis\n")
fans, err := readHwmonFans(root)
require.NoError(t, err)
assert.Equal(t, map[string]uint16{
"pwmfan_fan1": 6500,
"nct6798_fan1": 0,
"nct6798_chassis": 1200,
}, fans)
}
// TestReadHwmonFansMissingRoot returns an error rather than panicking when the
// hwmon root doesn't exist (e.g. running on a kernel without hwmon support).
func TestReadHwmonFansMissingRoot(t *testing.T) {
_, err := readHwmonFans(filepath.Join(t.TempDir(), "does-not-exist"))
assert.Error(t, err)
}
// TestReadHwmonFansEmpty returns an empty map (not nil error) when the root
// exists but contains no chips at all.
func TestReadHwmonFansEmpty(t *testing.T) {
root := t.TempDir()
fans, err := readHwmonFans(root)
require.NoError(t, err)
assert.Empty(t, fans)
}
func TestFanDiscoveryCache(t *testing.T) {
root := t.TempDir()
input := filepath.Join(root, "hwmon0", "fan1_input")
writeFile(t, filepath.Join(root, "hwmon0", "name"), "chip\n")
writeFile(t, input, "1000\n")
getSensors := newFanSensorCache(root)
sensors, err := getSensors()
require.NoError(t, err)
fans := readFanSensors(sensors)
assert.Equal(t, uint16(1000), fans["chip_fan1"])
writeFile(t, input, "1200\n")
writeFile(t, filepath.Join(root, "hwmon0", "fan1_label"), "case\n")
sensors, err = getSensors()
require.NoError(t, err)
fans = readFanSensors(sensors)
assert.Equal(t, map[string]uint16{"chip_fan1": 1200}, fans)
}
+3 -22
View File
@@ -48,8 +48,6 @@ type GPUManager struct {
// Per-cache-key tracking for delta calculations // Per-cache-key tracking for delta calculations
// cacheKey -> gpuId -> snapshot of last count/usage/power values // cacheKey -> gpuId -> snapshot of last count/usage/power values
lastSnapshots map[uint16]map[string]*gpuSnapshot lastSnapshots map[uint16]map[string]*gpuSnapshot
// Per-card energy snapshots for Intel sysfs power calculation.
intelSysfsEnergySnapshots map[string]intelSysfsEnergySnapshot
} }
// gpuSnapshot stores the last observed incremental values for delta tracking // gpuSnapshot stores the last observed incremental values for delta tracking
@@ -92,7 +90,6 @@ const (
collectorSourceNVML collectorSource = "nvml" collectorSourceNVML collectorSource = "nvml"
collectorSourceNvidiaSMI collectorSource = collectorSource(nvidiaSmiCmd) collectorSourceNvidiaSMI collectorSource = collectorSource(nvidiaSmiCmd)
collectorSourceIntelGpuTop collectorSource = collectorSource(intelGpuStatsCmd) collectorSourceIntelGpuTop collectorSource = collectorSource(intelGpuStatsCmd)
collectorSourceIntelSysfs collectorSource = "intel_sysfs"
collectorSourceAmdSysfs collectorSource = "amd_sysfs" collectorSourceAmdSysfs collectorSource = "amd_sysfs"
collectorSourceRocmSMI collectorSource = collectorSource(rocmSmiCmd) collectorSourceRocmSMI collectorSource = collectorSource(rocmSmiCmd)
collectorSourceMacmon collectorSource = collectorSource(macmonCmd) collectorSourceMacmon collectorSource = collectorSource(macmonCmd)
@@ -109,7 +106,6 @@ func isValidCollectorSource(source collectorSource) bool {
collectorSourceNVML, collectorSourceNVML,
collectorSourceNvidiaSMI, collectorSourceNvidiaSMI,
collectorSourceIntelGpuTop, collectorSourceIntelGpuTop,
collectorSourceIntelSysfs,
collectorSourceAmdSysfs, collectorSourceAmdSysfs,
collectorSourceRocmSMI, collectorSourceRocmSMI,
collectorSourceMacmon, collectorSourceMacmon,
@@ -126,8 +122,6 @@ type gpuCapabilities struct {
hasAmdSysfs bool hasAmdSysfs bool
hasTegrastats bool hasTegrastats bool
hasIntelGpuTop bool hasIntelGpuTop bool
hasXe bool
hasIntelSysfs bool
hasNvtop bool hasNvtop bool
hasMacmon bool hasMacmon bool
hasPowermetrics bool hasPowermetrics bool
@@ -375,13 +369,12 @@ func (gm *GPUManager) calculateGPUAverage(id string, gpu *system.GPUData, cacheK
gpuAvg.Power = utils.TwoDecimals(deltaPower / float64(deltaCount)) gpuAvg.Power = utils.TwoDecimals(deltaPower / float64(deltaCount))
gpuAvg.PowerPkg = utils.TwoDecimals(deltaPowerPkg / float64(deltaCount))
if gpu.Engines != nil { if gpu.Engines != nil {
// make fresh map for averaged engine metrics to avoid mutating // make fresh map for averaged engine metrics to avoid mutating
// the accumulator map stored in gm.GpuDataMap // the accumulator map stored in gm.GpuDataMap
gpuAvg.Engines = make(map[string]float64, len(gpu.Engines)) gpuAvg.Engines = make(map[string]float64, len(gpu.Engines))
gpuAvg.Usage = gm.calculateIntelGPUUsage(&gpuAvg, gpu, lastSnapshot, deltaCount) gpuAvg.Usage = gm.calculateIntelGPUUsage(&gpuAvg, gpu, lastSnapshot, deltaCount)
gpuAvg.PowerPkg = utils.TwoDecimals(deltaPowerPkg / float64(deltaCount))
} else { } else {
gpuAvg.Usage = utils.TwoDecimals(deltaUsage / float64(deltaCount)) gpuAvg.Usage = utils.TwoDecimals(deltaUsage / float64(deltaCount))
} }
@@ -451,8 +444,6 @@ func (gm *GPUManager) storeSnapshot(id string, gpu *system.GPUData, cacheKey uin
func (gm *GPUManager) discoverGpuCapabilities() gpuCapabilities { func (gm *GPUManager) discoverGpuCapabilities() gpuCapabilities {
caps := gpuCapabilities{ caps := gpuCapabilities{
hasAmdSysfs: gm.hasAmdSysfs(), hasAmdSysfs: gm.hasAmdSysfs(),
hasXe: gm.hasXe(),
hasIntelSysfs: gm.hasIntelSysfs(),
} }
if _, err := exec.LookPath(nvidiaSmiCmd); err == nil { if _, err := exec.LookPath(nvidiaSmiCmd); err == nil {
caps.hasNvidiaSmi = true caps.hasNvidiaSmi = true
@@ -481,7 +472,7 @@ func (gm *GPUManager) discoverGpuCapabilities() gpuCapabilities {
} }
func hasAnyGpuCollector(caps gpuCapabilities) bool { func hasAnyGpuCollector(caps gpuCapabilities) bool {
return caps.hasNvidiaSmi || caps.hasRocmSmi || caps.hasAmdSysfs || caps.hasTegrastats || caps.hasIntelGpuTop || caps.hasIntelSysfs || caps.hasNvtop || caps.hasMacmon || caps.hasPowermetrics return caps.hasNvidiaSmi || caps.hasRocmSmi || caps.hasAmdSysfs || caps.hasTegrastats || caps.hasIntelGpuTop || caps.hasNvtop || caps.hasMacmon || caps.hasPowermetrics
} }
func (gm *GPUManager) startIntelCollector() { func (gm *GPUManager) startIntelCollector() {
@@ -572,13 +563,6 @@ func (gm *GPUManager) collectorDefinitions(caps gpuCapabilities) map[collectorSo
return true return true
}, },
}, },
collectorSourceIntelSysfs: {
group: collectorGroupIntel,
available: caps.hasIntelSysfs,
start: func(_ func()) bool {
return gm.startIntelSysfsCollector()
},
},
collectorSourceAmdSysfs: { collectorSourceAmdSysfs: {
group: collectorGroupAmd, group: collectorGroupAmd,
available: caps.hasAmdSysfs, available: caps.hasAmdSysfs,
@@ -721,12 +705,9 @@ func (gm *GPUManager) resolveLegacyCollectorPriority(caps gpuCapabilities) []col
priorities = append(priorities, collectorSourceAmdSysfs) priorities = append(priorities, collectorSourceAmdSysfs)
} }
if caps.hasIntelGpuTop && !caps.hasXe { if caps.hasIntelGpuTop {
priorities = append(priorities, collectorSourceIntelGpuTop) priorities = append(priorities, collectorSourceIntelGpuTop)
} }
if caps.hasIntelSysfs {
priorities = append(priorities, collectorSourceIntelSysfs)
}
// Apple collectors are currently opt-in only for testing. // Apple collectors are currently opt-in only for testing.
// Enable them with GPU_COLLECTOR=macmon or GPU_COLLECTOR=powermetrics. // Enable them with GPU_COLLECTOR=macmon or GPU_COLLECTOR=powermetrics.
-280
View File
@@ -1,280 +0,0 @@
//go:build linux
package agent
import (
"fmt"
"log/slog"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/henrygd/beszel/agent/utils"
"github.com/henrygd/beszel/internal/entities/system"
)
var (
drmSysfsRoot = "/sys/class/drm"
intelSysfsNow = time.Now
)
type intelSysfsEnergySnapshot struct {
microjoules uint64
timestamp time.Time
}
type intelSysfsCard struct {
cardPath string
hwmonDir string
}
// hasIntelSysfs returns true if any Intel DRM card exposes an hwmon energy counter.
func (gm *GPUManager) hasIntelSysfs() bool {
cards, err := discoverIntelSysfsCards()
return err == nil && len(cards) > 0
}
// startIntelSysfsCollector starts Intel GPU collection via sysfs.
func (gm *GPUManager) startIntelSysfsCollector() bool {
go func() {
if err := gm.collectIntelSysfsStats(); err != nil {
slog.Warn("Error collecting Intel GPU data via sysfs", "err", err)
}
}()
return true
}
// collectIntelSysfsStats collects Intel GPU metrics directly from DRM sysfs / hwmon.
func (gm *GPUManager) collectIntelSysfsStats() error {
sysfsPollInterval := 3000 * time.Millisecond
cards, err := discoverIntelSysfsCards()
if err != nil {
return err
}
if len(cards) == 0 {
return errNoValidData
}
slog.Debug("Using sysfs for Intel GPU data collection", "cards", len(cards))
for _, card := range cards {
slog.Debug("Intel sysfs card detected", "card", filepath.Base(card.cardPath), "hwmon", card.hwmonDir)
}
failures := 0
for {
hasData := false
for _, card := range cards {
if gm.updateIntelSysfsGpuData(card.cardPath, card.hwmonDir) {
hasData = true
}
}
if !hasData {
failures++
if failures > maxFailureRetries {
return errNoValidData
}
slog.Warn("No Intel GPU data from sysfs", "failures", failures)
time.Sleep(retryWaitTime)
continue
}
failures = 0
time.Sleep(sysfsPollInterval)
}
}
func discoverIntelSysfsCards() ([]intelSysfsCard, error) {
paths, err := filepath.Glob(filepath.Join(drmSysfsRoot, "card*"))
if err != nil {
return nil, err
}
var cards []intelSysfsCard
for _, cardPath := range paths {
if strings.Contains(filepath.Base(cardPath), "-") || !isIntelGpu(cardPath) {
continue
}
hwmonDir := findIntelEnergyHwmon(filepath.Join(cardPath, "device"))
if hwmonDir == "" {
continue
}
cards = append(cards, intelSysfsCard{cardPath: cardPath, hwmonDir: hwmonDir})
}
return cards, nil
}
func isIntelGpu(cardPath string) bool {
vendor, err := utils.ReadStringFileLimited(filepath.Join(cardPath, "device/vendor"), 64)
if err != nil {
return false
}
return strings.EqualFold(strings.TrimSpace(vendor), "0x8086")
}
func findIntelEnergyHwmon(devicePath string) string {
hwmons, _ := filepath.Glob(filepath.Join(devicePath, "hwmon/hwmon*"))
var fallback string
for _, hwmonDir := range hwmons {
if !sysfsFileExists(filepath.Join(hwmonDir, "energy1_input")) {
continue
}
if name, err := utils.ReadStringFileLimited(filepath.Join(hwmonDir, "name"), 64); err == nil && strings.EqualFold(strings.TrimSpace(name), "xe") {
return hwmonDir
}
if fallback == "" {
fallback = hwmonDir
}
}
return fallback
}
func sysfsFileExists(path string) bool {
_, err := utils.ReadStringFileLimited(path, 1)
return err == nil
}
// updateIntelSysfsGpuData reads GPU metrics from sysfs and updates the GPU data map.
// Returns true if the required energy counter was read successfully.
func (gm *GPUManager) updateIntelSysfsGpuData(cardPath, hwmonDir string) bool {
devicePath := filepath.Join(cardPath, "device")
id := filepath.Base(cardPath)
energy, err := readSysfsUint(filepath.Join(hwmonDir, "energy1_input"))
if err != nil {
return false
}
now := intelSysfsNow()
power, hasPower := gm.calculateIntelSysfsPower(id, energy, now)
powerPkg, hasPowerPkg := gm.readIntelSysfsPowerPkg(id, hwmonDir, now)
temp := readIntelSysfsTemperature(hwmonDir)
usage, usageErr := readOptionalSysfsFloat(filepath.Join(devicePath, "gpu_busy_percent"))
memUsed, memUsedErr := readFirstOptionalSysfsFloat(
filepath.Join(devicePath, "mem_info_vram_used"),
filepath.Join(devicePath, "mem_info_lmem_used"),
filepath.Join(devicePath, "mem_info_local_mem_used"),
)
memTotal, memTotalErr := readFirstOptionalSysfsFloat(
filepath.Join(devicePath, "mem_info_vram_total"),
filepath.Join(devicePath, "mem_info_lmem_total"),
filepath.Join(devicePath, "mem_info_local_mem_total"),
)
gm.Lock()
defer gm.Unlock()
gpu, ok := gm.GpuDataMap[id]
if !ok {
gpu = &system.GPUData{Name: getIntelSysfsGpuName(cardPath)}
gm.GpuDataMap[id] = gpu
}
if usageErr == nil {
gpu.Usage += usage
}
if memUsedErr == nil {
gpu.MemoryUsed = utils.BytesToMegabytes(memUsed)
}
if memTotalErr == nil {
gpu.MemoryTotal = utils.BytesToMegabytes(memTotal)
}
if temp > 0 {
gpu.Temperature = temp
}
if hasPower {
gpu.Power += power
slog.Debug("Computed Intel sysfs GPU power", "card", id, "watts", power)
}
if hasPowerPkg {
gpu.PowerPkg += powerPkg
}
gpu.Count++
return true
}
func (gm *GPUManager) calculateIntelSysfsPower(cardID string, microjoules uint64, timestamp time.Time) (float64, bool) {
if gm.intelSysfsEnergySnapshots == nil {
gm.intelSysfsEnergySnapshots = make(map[string]intelSysfsEnergySnapshot)
}
last, ok := gm.intelSysfsEnergySnapshots[cardID]
gm.intelSysfsEnergySnapshots[cardID] = intelSysfsEnergySnapshot{microjoules: microjoules, timestamp: timestamp}
if !ok {
return 0, false
}
if microjoules < last.microjoules {
slog.Debug("Intel sysfs energy counter reset", "card", cardID)
return 0, false
}
elapsed := timestamp.Sub(last.timestamp).Seconds()
if elapsed <= 0 {
return 0, false
}
delta := microjoules - last.microjoules
return float64(delta) / 1_000_000.0 / elapsed, true
}
func (gm *GPUManager) readIntelSysfsPowerPkg(cardID, hwmonDir string, timestamp time.Time) (float64, bool) {
energyPaths, _ := filepath.Glob(filepath.Join(hwmonDir, "energy*_input"))
for _, path := range energyPaths {
if filepath.Base(path) == "energy1_input" {
continue
}
energy, err := readSysfsUint(path)
if err != nil {
continue
}
return gm.calculateIntelSysfsPower(cardID+":"+filepath.Base(path), energy, timestamp)
}
return 0, false
}
func readIntelSysfsTemperature(hwmonDir string) float64 {
tempPaths, _ := filepath.Glob(filepath.Join(hwmonDir, "temp*_input"))
for _, path := range tempPaths {
temp, err := readSysfsFloat(path)
if err == nil && temp > 0 {
return temp / 1000.0
}
}
return 0
}
func readSysfsUint(path string) (uint64, error) {
val, err := utils.ReadStringFileLimited(path, 64)
if err != nil {
slog.Debug("Failed to read sysfs value", "path", path, "error", err)
return 0, err
}
return strconv.ParseUint(strings.TrimSpace(val), 10, 64)
}
func readOptionalSysfsFloat(path string) (float64, error) {
val, err := os.ReadFile(path)
if err != nil {
return 0, err
}
return strconv.ParseFloat(strings.TrimSpace(string(val)), 64)
}
func readFirstOptionalSysfsFloat(paths ...string) (float64, error) {
for _, path := range paths {
val, err := readOptionalSysfsFloat(path)
if err == nil {
return val, nil
}
}
return 0, fmt.Errorf("no sysfs values found")
}
func getIntelSysfsGpuName(cardPath string) string {
devicePath := filepath.Join(cardPath, "device")
if product, err := utils.ReadStringFileLimited(filepath.Join(devicePath, "product_name"), 128); err == nil && strings.TrimSpace(product) != "" {
return strings.TrimSpace(product)
}
if name, err := utils.ReadStringFileLimited(filepath.Join(devicePath, "name"), 128); err == nil && strings.TrimSpace(name) != "" {
return strings.TrimSpace(name)
}
return fmt.Sprintf("Intel GPU %s", filepath.Base(cardPath))
}
-217
View File
@@ -1,217 +0,0 @@
//go:build linux
package agent
import (
"os"
"path/filepath"
"testing"
"time"
"github.com/henrygd/beszel/agent/utils"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func setupIntelSysfsTest(t *testing.T) (root, cardPath, hwmonPath string) {
t.Helper()
root = t.TempDir()
oldRoot := drmSysfsRoot
drmSysfsRoot = root
t.Cleanup(func() {
drmSysfsRoot = oldRoot
})
cardPath = filepath.Join(root, "card0")
devicePath := filepath.Join(cardPath, "device")
hwmonPath = filepath.Join(devicePath, "hwmon", "hwmon0")
require.NoError(t, os.MkdirAll(hwmonPath, 0o755))
return root, cardPath, hwmonPath
}
func writeIntelSysfsFile(t *testing.T, basePath, name, content string) {
t.Helper()
require.NoError(t, os.WriteFile(filepath.Join(basePath, name), []byte(content), 0o644))
}
func setIntelSysfsTime(t *testing.T, now time.Time) {
t.Helper()
oldNow := intelSysfsNow
intelSysfsNow = func() time.Time { return now }
t.Cleanup(func() {
intelSysfsNow = oldNow
})
}
func TestIntelSysfsDetectsIntelCardWithEnergy(t *testing.T) {
_, cardPath, hwmonPath := setupIntelSysfsTest(t)
devicePath := filepath.Join(cardPath, "device")
writeIntelSysfsFile(t, devicePath, "vendor", "0x8086\n")
writeIntelSysfsFile(t, hwmonPath, "name", "xe\n")
writeIntelSysfsFile(t, hwmonPath, "energy1_input", "1000000\n")
gm := &GPUManager{}
assert.True(t, gm.hasIntelSysfs())
cards, err := discoverIntelSysfsCards()
require.NoError(t, err)
require.Len(t, cards, 1)
assert.Equal(t, cardPath, cards[0].cardPath)
assert.Equal(t, hwmonPath, cards[0].hwmonDir)
}
func TestIntelSysfsRejectsNonIntelCard(t *testing.T) {
_, cardPath, hwmonPath := setupIntelSysfsTest(t)
devicePath := filepath.Join(cardPath, "device")
writeIntelSysfsFile(t, devicePath, "vendor", "0x1002\n")
writeIntelSysfsFile(t, hwmonPath, "name", "xe\n")
writeIntelSysfsFile(t, hwmonPath, "energy1_input", "1000000\n")
gm := &GPUManager{}
assert.False(t, gm.hasIntelSysfs())
}
func TestIntelSysfsRequiresEnergyInput(t *testing.T) {
_, cardPath, hwmonPath := setupIntelSysfsTest(t)
devicePath := filepath.Join(cardPath, "device")
writeIntelSysfsFile(t, devicePath, "vendor", "0x8086\n")
writeIntelSysfsFile(t, hwmonPath, "name", "xe\n")
gm := &GPUManager{}
assert.False(t, gm.hasIntelSysfs())
}
func TestIntelSysfsFirstSampleInitializesWithoutBogusPower(t *testing.T) {
_, cardPath, hwmonPath := setupIntelSysfsTest(t)
devicePath := filepath.Join(cardPath, "device")
writeIntelSysfsFile(t, devicePath, "vendor", "0x8086\n")
writeIntelSysfsFile(t, hwmonPath, "name", "xe\n")
writeIntelSysfsFile(t, hwmonPath, "energy1_input", "1000000\n")
setIntelSysfsTime(t, time.Unix(100, 0))
gm := &GPUManager{GpuDataMap: make(map[string]*system.GPUData)}
ok := gm.updateIntelSysfsGpuData(cardPath, hwmonPath)
require.True(t, ok)
gpu := gm.GpuDataMap["card0"]
require.NotNil(t, gpu)
assert.Equal(t, "Intel GPU card0", gpu.Name)
assert.Equal(t, 0.0, gpu.Power)
assert.Equal(t, 1.0, gpu.Count)
}
func TestIntelSysfsSecondSampleComputesWatts(t *testing.T) {
_, cardPath, hwmonPath := setupIntelSysfsTest(t)
devicePath := filepath.Join(cardPath, "device")
writeIntelSysfsFile(t, devicePath, "vendor", "0x8086\n")
writeIntelSysfsFile(t, hwmonPath, "energy1_input", "1000000\n")
gm := &GPUManager{GpuDataMap: make(map[string]*system.GPUData)}
oldNow := intelSysfsNow
intelSysfsNow = func() time.Time { return time.Unix(100, 0) }
t.Cleanup(func() { intelSysfsNow = oldNow })
require.True(t, gm.updateIntelSysfsGpuData(cardPath, hwmonPath))
writeIntelSysfsFile(t, hwmonPath, "energy1_input", "6000000\n")
intelSysfsNow = func() time.Time { return time.Unix(102, 0) }
require.True(t, gm.updateIntelSysfsGpuData(cardPath, hwmonPath))
gpu := gm.GpuDataMap["card0"]
require.NotNil(t, gpu)
assert.Equal(t, 2.5, gpu.Power)
assert.Equal(t, 2.0, gpu.Count)
}
func TestIntelSysfsSecondEnergyCounterMapsToPowerPkg(t *testing.T) {
_, cardPath, hwmonPath := setupIntelSysfsTest(t)
devicePath := filepath.Join(cardPath, "device")
writeIntelSysfsFile(t, devicePath, "vendor", "0x8086\n")
writeIntelSysfsFile(t, hwmonPath, "energy1_input", "1000000\n")
writeIntelSysfsFile(t, hwmonPath, "energy2_input", "2000000\n")
oldNow := intelSysfsNow
t.Cleanup(func() { intelSysfsNow = oldNow })
gm := &GPUManager{GpuDataMap: make(map[string]*system.GPUData)}
intelSysfsNow = func() time.Time { return time.Unix(100, 0) }
require.True(t, gm.updateIntelSysfsGpuData(cardPath, hwmonPath))
writeIntelSysfsFile(t, hwmonPath, "energy1_input", "2000000\n")
writeIntelSysfsFile(t, hwmonPath, "energy2_input", "8000000\n")
intelSysfsNow = func() time.Time { return time.Unix(102, 0) }
require.True(t, gm.updateIntelSysfsGpuData(cardPath, hwmonPath))
gpu := gm.GpuDataMap["card0"]
require.NotNil(t, gpu)
assert.Equal(t, 0.5, gpu.Power)
assert.Equal(t, 3.0, gpu.PowerPkg)
}
func TestIntelSysfsCounterResetSkipsOneSample(t *testing.T) {
gm := &GPUManager{}
power, ok := gm.calculateIntelSysfsPower("card0", 5000000, time.Unix(100, 0))
assert.False(t, ok)
assert.Equal(t, 0.0, power)
power, ok = gm.calculateIntelSysfsPower("card0", 1000000, time.Unix(101, 0))
assert.False(t, ok)
assert.Equal(t, 0.0, power)
power, ok = gm.calculateIntelSysfsPower("card0", 3000000, time.Unix(103, 0))
assert.True(t, ok)
assert.Equal(t, 1.0, power)
}
func TestIntelSysfsTempInputMapsToCelsius(t *testing.T) {
_, cardPath, hwmonPath := setupIntelSysfsTest(t)
devicePath := filepath.Join(cardPath, "device")
writeIntelSysfsFile(t, devicePath, "vendor", "0x8086\n")
writeIntelSysfsFile(t, hwmonPath, "energy1_input", "1000000\n")
writeIntelSysfsFile(t, hwmonPath, "temp1_input", "43500\n")
setIntelSysfsTime(t, time.Unix(100, 0))
gm := &GPUManager{GpuDataMap: make(map[string]*system.GPUData)}
require.True(t, gm.updateIntelSysfsGpuData(cardPath, hwmonPath))
gpu := gm.GpuDataMap["card0"]
require.NotNil(t, gpu)
assert.Equal(t, 43.5, gpu.Temperature)
}
func TestIntelSysfsMissingOptionalFilesDoNotFail(t *testing.T) {
_, cardPath, hwmonPath := setupIntelSysfsTest(t)
devicePath := filepath.Join(cardPath, "device")
writeIntelSysfsFile(t, devicePath, "vendor", "0x8086\n")
writeIntelSysfsFile(t, hwmonPath, "energy1_input", "1000000\n")
setIntelSysfsTime(t, time.Unix(100, 0))
gm := &GPUManager{GpuDataMap: make(map[string]*system.GPUData)}
require.True(t, gm.updateIntelSysfsGpuData(cardPath, hwmonPath))
gpu := gm.GpuDataMap["card0"]
require.NotNil(t, gpu)
assert.Equal(t, 0.0, gpu.Usage)
assert.Equal(t, 0.0, gpu.MemoryUsed)
assert.Equal(t, 0.0, gpu.MemoryTotal)
assert.Equal(t, 0.0, gpu.Temperature)
}
func TestIntelSysfsMapsOpportunisticMemoryAndUsage(t *testing.T) {
_, cardPath, hwmonPath := setupIntelSysfsTest(t)
devicePath := filepath.Join(cardPath, "device")
writeIntelSysfsFile(t, devicePath, "vendor", "0x8086\n")
writeIntelSysfsFile(t, devicePath, "gpu_busy_percent", "37\n")
writeIntelSysfsFile(t, devicePath, "mem_info_lmem_used", "1073741824\n")
writeIntelSysfsFile(t, devicePath, "mem_info_lmem_total", "2147483648\n")
writeIntelSysfsFile(t, hwmonPath, "energy1_input", "1000000\n")
setIntelSysfsTime(t, time.Unix(100, 0))
gm := &GPUManager{GpuDataMap: make(map[string]*system.GPUData)}
require.True(t, gm.updateIntelSysfsGpuData(cardPath, hwmonPath))
gpu := gm.GpuDataMap["card0"]
require.NotNil(t, gpu)
assert.Equal(t, 37.0, gpu.Usage)
assert.Equal(t, utils.BytesToMegabytes(1073741824), gpu.MemoryUsed)
assert.Equal(t, utils.BytesToMegabytes(2147483648), gpu.MemoryTotal)
}
-13
View File
@@ -1,13 +0,0 @@
//go:build !linux
package agent
type intelSysfsEnergySnapshot struct{}
func (gm *GPUManager) hasIntelSysfs() bool {
return false
}
func (gm *GPUManager) startIntelSysfsCollector() bool {
return false
}
+1 -42
View File
@@ -5,7 +5,6 @@ import (
"io" "io"
"log/slog" "log/slog"
"os/exec" "os/exec"
"path/filepath"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -49,14 +48,9 @@ func (gm *GPUManager) updateNvtopSnapshots(snapshots []nvtopSnapshot) bool {
valid := false valid := false
usedIDs := make(map[string]struct{}, len(snapshots)) usedIDs := make(map[string]struct{}, len(snapshots))
var xeName string
for i, sample := range snapshots { for i, sample := range snapshots {
// nvtop leaves device_name unset on xe devices.
if sample.DeviceName == "" { if sample.DeviceName == "" {
if xeName == "" { continue
xeName = xeGpuName()
}
sample.DeviceName = xeName
} }
indexID := "n" + strconv.Itoa(i) indexID := "n" + strconv.Itoa(i)
id := indexID id := indexID
@@ -164,38 +158,3 @@ func (gm *GPUManager) startNvtopCollector(interval string, onFailure func()) {
} }
}() }()
} }
// xeDevicePath returns the sysfs device path of the first xe GPU, or "".
func xeDevicePath() string {
cards, err := filepath.Glob("/sys/class/drm/card*")
if err != nil {
return ""
}
for _, card := range cards {
if strings.Contains(filepath.Base(card), "-") {
continue
}
if uevent, err := utils.ReadStringFileLimited(filepath.Join(card, "device", "uevent"), 4096); err == nil && strings.Contains(uevent, "DRIVER=xe") {
return filepath.Join(card, "device")
}
}
return ""
}
func (gm *GPUManager) hasXe() bool {
return xeDevicePath() != ""
}
// xeGpuName names an xe GPU from its PCI device id; nvtop leaves device_name unset on xe.
func xeGpuName() string {
devicePath := xeDevicePath()
if devicePath == "" {
return "GPU"
}
id, err := utils.ReadStringFileLimited(filepath.Join(devicePath, "device"), 64)
if err != nil {
return "GPU"
}
id = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(id, "0x")))
return "Intel GPU (" + id + ")"
}
+1 -2
View File
@@ -332,12 +332,11 @@ func TestUpdateNvtopSnapshotsKeepsDeviceAssociationWhenOrderChanges(t *testing.T
} }
func TestParseCollectorPriority(t *testing.T) { func TestParseCollectorPriority(t *testing.T) {
got := parseCollectorPriority(" nvml, nvidia-smi, intel_gpu_top, intel_sysfs, amd_sysfs, nvtop, rocm-smi, bad ") got := parseCollectorPriority(" nvml, nvidia-smi, intel_gpu_top, amd_sysfs, nvtop, rocm-smi, bad ")
want := []collectorSource{ want := []collectorSource{
collectorSourceNVML, collectorSourceNVML,
collectorSourceNvidiaSMI, collectorSourceNvidiaSMI,
collectorSourceIntelGpuTop, collectorSourceIntelGpuTop,
collectorSourceIntelSysfs,
collectorSourceAmdSysfs, collectorSourceAmdSysfs,
collectorSourceNVTop, collectorSourceNVTop,
collectorSourceRocmSMI, collectorSourceRocmSMI,
+5 -7
View File
@@ -166,16 +166,14 @@ type GetSmartDataHandler struct{}
func (h *GetSmartDataHandler) Handle(hctx *HandlerContext) error { func (h *GetSmartDataHandler) Handle(hctx *HandlerContext) error {
if hctx.Agent.smartManager == nil { if hctx.Agent.smartManager == nil {
return hctx.SendResponse(smart.SmartDataResponse{Data: map[string]smart.SmartData{}}, hctx.RequestID) // return empty map to indicate no data
return hctx.SendResponse(map[string]smart.SmartData{}, hctx.RequestID)
} }
complete, err := hctx.Agent.smartManager.Refresh(false) if err := hctx.Agent.smartManager.Refresh(false); err != nil {
if err != nil {
slog.Debug("smart refresh failed", "err", err) slog.Debug("smart refresh failed", "err", err)
} }
return hctx.SendResponse(smart.SmartDataResponse{ data := hctx.Agent.smartManager.GetCurrentData()
Data: hctx.Agent.smartManager.GetCurrentData(), return hctx.SendResponse(data, hctx.RequestID)
Complete: complete,
}, hctx.RequestID)
} }
//////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////
-13
View File
@@ -7,7 +7,6 @@ import (
"github.com/fxamacker/cbor/v2" "github.com/fxamacker/cbor/v2"
"github.com/henrygd/beszel/internal/common" "github.com/henrygd/beszel/internal/common"
"github.com/henrygd/beszel/internal/entities/smart"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
@@ -18,18 +17,6 @@ type MockHandler struct {
handleFunc func(ctx *HandlerContext) error handleFunc func(ctx *HandlerContext) error
} }
func TestNewAgentResponseSmartData(t *testing.T) {
response := newAgentResponse(smart.SmartDataResponse{
Data: map[string]smart.SmartData{
"AAA": {SerialNumber: "AAA"},
},
Complete: true,
}, nil)
assert.Equal(t, "AAA", response.SmartData["AAA"].SerialNumber)
assert.True(t, response.SmartComplete)
}
func (m *MockHandler) Handle(ctx *HandlerContext) error { func (m *MockHandler) Handle(ctx *HandlerContext) error {
if m.handleFunc != nil { if m.handleFunc != nil {
return m.handleFunc(ctx) return m.handleFunc(ctx)
+1 -6
View File
@@ -180,13 +180,8 @@ func mdraidSmartStatus(health mdraidHealth) string {
if health.degraded > 0 { if health.degraded > 0 {
return "FAILED" return "FAILED"
} }
if health.mismatchCnt > 0 {
return "WARNING"
}
// "check" scans for consistency problems without repairing mismatches.
// With no mismatches, keep it green while reporting progress attributes.
switch syncAction { switch syncAction {
case "repair": case "check", "repair":
return "WARNING" return "WARNING"
} }
switch state { switch state {
-12
View File
@@ -94,18 +94,6 @@ func TestMdraidSmartStatus(t *testing.T) {
if got := mdraidSmartStatus(mdraidHealth{arrayState: "active", syncAction: "recover"}); got != "WARNING" { if got := mdraidSmartStatus(mdraidHealth{arrayState: "active", syncAction: "recover"}); got != "WARNING" {
t.Fatalf("mdraidSmartStatus(recover) = %q, want WARNING", got) t.Fatalf("mdraidSmartStatus(recover) = %q, want WARNING", got)
} }
if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean", syncAction: "check"}); got != "PASSED" {
t.Fatalf("mdraidSmartStatus(clean+check) = %q, want PASSED", got)
}
if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean", syncAction: "check", mismatchCnt: 1}); got != "WARNING" {
t.Fatalf("mdraidSmartStatus(clean+check+mismatch) = %q, want WARNING", got)
}
if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean", mismatchCnt: 1}); got != "WARNING" {
t.Fatalf("mdraidSmartStatus(clean+mismatch) = %q, want WARNING", got)
}
if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean", syncAction: "repair"}); got != "WARNING" {
t.Fatalf("mdraidSmartStatus(repair) = %q, want WARNING", got)
}
if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean"}); got != "PASSED" { if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean"}); got != "PASSED" {
t.Fatalf("mdraidSmartStatus(clean) = %q, want PASSED", got) t.Fatalf("mdraidSmartStatus(clean) = %q, want PASSED", got)
} }
-3
View File
@@ -21,9 +21,6 @@ func newAgentResponse(data any, requestID *uint32) common.AgentResponse {
response.String = &v response.String = &v
case map[string]smart.SmartData: case map[string]smart.SmartData:
response.SmartData = v response.SmartData = v
case smart.SmartDataResponse:
response.SmartData = v.Data
response.SmartComplete = v.Complete
case systemd.ServiceDetails: case systemd.ServiceDetails:
response.ServiceInfo = v response.ServiceInfo = v
default: default:
+21 -8
View File
@@ -29,6 +29,9 @@ type ServerOptions struct {
Keys []gossh.PublicKey // SSH public keys for authentication Keys []gossh.PublicKey // SSH public keys for authentication
} }
// hubVersions caches hub versions by session ID to avoid repeated parsing.
var hubVersions map[string]semver.Version
// StartServer starts the SSH server with the provided options. // StartServer starts the SSH server with the provided options.
// It configures the server with secure defaults, sets up authentication, // It configures the server with secure defaults, sets up authentication,
// and begins listening for connections. Returns an error if the server // and begins listening for connections. Returns an error if the server
@@ -96,15 +99,24 @@ func (a *Agent) StartServer(opts ServerOptions) error {
return a.server.Serve(ln) return a.server.Serve(ln)
} }
// getHubVersion extracts the hub version from the SSH client version string // getHubVersion retrieves and caches the hub version for a given session.
// for a given session. Returns a zero version if parsing fails. // It extracts the version from the SSH client version string and caches
func (a *Agent) getHubVersion(sessionCtx ssh.Context) semver.Version { // it to avoid repeated parsing. Returns a zero version if parsing fails.
clientVersion := sessionCtx.Value(ssh.ContextKeyClientVersion) func (a *Agent) getHubVersion(sessionId string, sessionCtx ssh.Context) semver.Version {
if versionStr, ok := clientVersion.(string); ok { if hubVersions == nil {
hubVersion, _ := extractHubVersion(versionStr) hubVersions = make(map[string]semver.Version, 1)
}
hubVersion, ok := hubVersions[sessionId]
if ok {
return hubVersion return hubVersion
} }
return semver.Version{} // Extract hub version from SSH client version
clientVersion := sessionCtx.Value(ssh.ContextKeyClientVersion)
if versionStr, ok := clientVersion.(string); ok {
hubVersion, _ = extractHubVersion(versionStr)
}
hubVersions[sessionId] = hubVersion
return hubVersion
} }
// handleSession handles an incoming SSH session by gathering system statistics // handleSession handles an incoming SSH session by gathering system statistics
@@ -115,8 +127,9 @@ func (a *Agent) handleSession(s ssh.Session) {
a.connectionManager.eventChan <- SSHConnect a.connectionManager.eventChan <- SSHConnect
sessionCtx := s.Context() sessionCtx := s.Context()
sessionID := sessionCtx.SessionID()
hubVersion := a.getHubVersion(sessionCtx) hubVersion := a.getHubVersion(sessionID, sessionCtx)
// Legacy one-shot behavior for older hubs // Legacy one-shot behavior for older hubs
if hubVersion.LT(beszel.MinVersionAgentResponse) { if hubVersion.LT(beszel.MinVersionAgentResponse) {
+45 -27
View File
@@ -404,23 +404,27 @@ func TestGetHubVersion(t *testing.T) {
clientVersion: "SSH-2.0-beszel_0.12.0", clientVersion: "SSH-2.0-beszel_0.12.0",
} }
// Test first call - should extract version // Test first call - should extract and cache version
version := agent.getHubVersion(mockCtx) version := agent.getHubVersion("test-session-123", mockCtx)
assert.Equal(t, "0.12.0", version.String()) assert.Equal(t, "0.12.0", version.String())
// Test that version reflects the current client version (no stale caching) // Test second call - should return cached version
mockCtx.clientVersion = "SSH-2.0-beszel_0.11.0" mockCtx.clientVersion = "SSH-2.0-beszel_0.11.0" // Change version but should still return cached
version = agent.getHubVersion(mockCtx) version = agent.getHubVersion("test-session-123", mockCtx)
assert.Equal(t, "0.12.0", version.String()) // Should still be cached version
// Test different session - should extract new version
version = agent.getHubVersion("different-session", mockCtx)
assert.Equal(t, "0.11.0", version.String()) assert.Equal(t, "0.11.0", version.String())
// Test with invalid version string (non-beszel client) // Test with invalid version string (non-beszel client)
mockCtx.clientVersion = "SSH-2.0-OpenSSH_8.0" mockCtx.clientVersion = "SSH-2.0-OpenSSH_8.0"
version = agent.getHubVersion(mockCtx) version = agent.getHubVersion("invalid-session", mockCtx)
assert.Equal(t, "0.0.0", version.String()) // Should be empty version for non-beszel clients assert.Equal(t, "0.0.0", version.String()) // Should be empty version for non-beszel clients
// Test with no client version // Test with no client version
mockCtx.clientVersion = "" mockCtx.clientVersion = ""
version = agent.getHubVersion(mockCtx) version = agent.getHubVersion("no-version-session", mockCtx)
assert.True(t, version.EQ(semver.Version{})) // Should be empty version assert.True(t, version.EQ(semver.Version{})) // Should be empty version
} }
@@ -497,6 +501,9 @@ func TestWriteToSessionEncoding(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
// Reset the global hubVersions map to ensure clean state for each test
hubVersions = nil
agent, err := NewAgent("") agent, err := NewAgent("")
require.NoError(t, err) require.NoError(t, err)
@@ -578,28 +585,39 @@ func createTestCombinedData() *system.CombinedData {
} }
} }
// TestGetHubVersionConcurrent guards against a regression of the func TestHubVersionCaching(t *testing.T) {
// "concurrent map writes" panic previously caused by a shared, unsynchronized // Reset the global hubVersions map to ensure clean state
// hubVersions cache (see https://github.com/henrygd/beszel/issues/2128). hubVersions = nil
// getHubVersion no longer shares mutable state between sessions, so calling
// it concurrently from many goroutines must be safe under `go test -race`.
func TestGetHubVersionConcurrent(t *testing.T) {
agent, err := NewAgent("") agent, err := NewAgent("")
require.NoError(t, err) require.NoError(t, err)
const goroutines = 50 ctx1 := &mockSSHContext{
var wg sync.WaitGroup sessionID: "session1",
wg.Add(goroutines) clientVersion: "SSH-2.0-beszel_0.12.0",
for i := 0; i < goroutines; i++ {
go func(i int) {
defer wg.Done()
ctx := &mockSSHContext{
sessionID: fmt.Sprintf("session-%d", i),
clientVersion: "SSH-2.0-beszel_0.12.0",
}
version := agent.getHubVersion(ctx)
assert.Equal(t, "0.12.0", version.String())
}(i)
} }
wg.Wait() ctx2 := &mockSSHContext{
sessionID: "session2",
clientVersion: "SSH-2.0-beszel_0.11.0",
}
// First calls should cache the versions
v1 := agent.getHubVersion("session1", ctx1)
v2 := agent.getHubVersion("session2", ctx2)
assert.Equal(t, "0.12.0", v1.String())
assert.Equal(t, "0.11.0", v2.String())
// Verify caching by changing context but keeping same session ID
ctx1.clientVersion = "SSH-2.0-beszel_0.10.0"
v1Cached := agent.getHubVersion("session1", ctx1)
assert.Equal(t, "0.12.0", v1Cached.String()) // Should still be cached version
// New session should get new version
ctx3 := &mockSSHContext{
sessionID: "session3",
clientVersion: "SSH-2.0-beszel_0.13.0",
}
v3 := agent.getHubVersion("session3", ctx3)
assert.Equal(t, "0.13.0", v3.String())
} }
+58 -36
View File
@@ -55,11 +55,6 @@ type DeviceInfo struct {
typeVerified bool typeVerified bool
// parserType holds the parser type (nvme, sat, scsi) that last succeeded. // parserType holds the parser type (nvme, sat, scsi) that last succeeded.
parserType string parserType string
// explicitType reports whether Type came from an explicit ":type" hint in
// SMART_DEVICES. Such a type is a deliberate user override and must always be
// passed to smartctl via -d, even for scsi/ata where a scan-detected type is
// otherwise left off (see smartctlArgs and issue #1345).
explicitType bool
} }
// deviceKey is a composite key for a device, used to identify a device uniquely. // deviceKey is a composite key for a device, used to identify a device uniquely.
@@ -70,9 +65,16 @@ type deviceKey struct {
var errNoValidSmartData = fmt.Errorf("no valid SMART data found") // Error for missing data var errNoValidSmartData = fmt.Errorf("no valid SMART data found") // Error for missing data
// Refresh updates SMART data for all known devices and reports whether every // isBridgePassthroughType reports whether the device type uses a USB bridge
// discovered device was collected successfully. // passthrough driver that may fail transiently (exit 2) due to wakeup data
func (sm *SmartManager) Refresh(forceScan bool) (bool, error) { // or other bridge-specific quirks. A single retry is attempted in CollectSmart.
func isBridgePassthroughType(deviceType string) bool {
dt := strings.ToLower(deviceType)
return strings.HasPrefix(dt, "jms56x") || strings.HasPrefix(dt, "jmb39x")
}
// Refresh updates SMART data for all known devices
func (sm *SmartManager) Refresh(forceScan bool) error {
sm.refreshMutex.Lock() sm.refreshMutex.Lock()
defer sm.refreshMutex.Unlock() defer sm.refreshMutex.Unlock()
@@ -93,7 +95,7 @@ func (sm *SmartManager) Refresh(forceScan bool) (bool, error) {
} }
} }
return scanErr == nil && collectErr == nil, sm.resolveRefreshError(scanErr, collectErr) return sm.resolveRefreshError(scanErr, collectErr)
} }
// devicesSnapshot returns a copy of the current device slice to avoid iterating // devicesSnapshot returns a copy of the current device slice to avoid iterating
@@ -257,9 +259,8 @@ func (sm *SmartManager) parseConfiguredDevices(config string) ([]*DeviceInfo, er
} }
devices = append(devices, &DeviceInfo{ devices = append(devices, &DeviceInfo{
Name: name, Name: name,
Type: devType, Type: devType,
explicitType: devType != "",
}) })
} }
@@ -375,9 +376,15 @@ func (sm *SmartManager) parseSmartOutput(deviceInfo *DeviceInfo, output []byte)
Type string Type string
Parse func([]byte) (bool, int) Parse func([]byte) (bool, int)
}{ }{
{Type: "nvme", Parse: sm.parseSmartForNvme}, {Type: "nvme", Parse: func(output []byte) (bool, int) {
{Type: "sat", Parse: sm.parseSmartForSata}, return sm.parseSmartForNvme(output, deviceInfo.Type)
{Type: "scsi", Parse: sm.parseSmartForScsi}, }},
{Type: "sat", Parse: func(output []byte) (bool, int) {
return sm.parseSmartForSata(output, deviceInfo.Type)
}},
{Type: "scsi", Parse: func(output []byte) (bool, int) {
return sm.parseSmartForScsi(output, deviceInfo.Type)
}},
} }
deviceType := normalizeParserType(deviceInfo.parserType) deviceType := normalizeParserType(deviceInfo.parserType)
@@ -502,15 +509,22 @@ func (sm *SmartManager) CollectSmart(deviceInfo *DeviceInfo) error {
// Check if device is in standby (exit status 2) // Check if device is in standby (exit status 2)
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok && exitErr.ExitCode() == 2 { if exitErr, ok := errors.AsType[*exec.ExitError](err); ok && exitErr.ExitCode() == 2 {
if hasExistingData { if hasExistingData {
// Device is in standby and we have cached data, keep using cache
return nil return nil
} }
// No cached data, need to collect initial data by bypassing standby // No cached data, retry without -n standby
ctx2, cancel2 := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel2()
args = sm.smartctlArgs(deviceInfo, false) args = sm.smartctlArgs(deviceInfo, false)
cmd = exec.CommandContext(ctx2, sm.smartctlPath, args...) cmd = exec.CommandContext(ctx, sm.smartctlPath, args...)
output, err = cmd.CombinedOutput() output, err = cmd.CombinedOutput()
// Bridge passthrough drivers (jms56x, jmb39x) can fail siently due
// to wakeup data left by the bridge firmware. A second retry succeeds
// after the first attempt has cleared the bridge state.
if deviceInfo != nil && isBridgePassthroughType(deviceInfo.Type) {
if exitErr2, ok2 := errors.AsType[*exec.ExitError](err); ok2 && exitErr2.ExitCode() == 2 {
cmd = exec.CommandContext(ctx, sm.smartctlPath, args...)
output, err = cmd.CombinedOutput()
}
}
} }
hasValidData := sm.parseSmartOutput(deviceInfo, output) hasValidData := sm.parseSmartOutput(deviceInfo, output)
@@ -565,9 +579,7 @@ func (sm *SmartManager) smartctlArgs(deviceInfo *DeviceInfo, includeStandby bool
deviceType = strings.ToLower(deviceInfo.Type) deviceType = strings.ToLower(deviceInfo.Type)
parserType = strings.ToLower(deviceInfo.parserType) parserType = strings.ToLower(deviceInfo.parserType)
// types sometimes misidentified in scan; see github.com/henrygd/beszel/issues/1345 // types sometimes misidentified in scan; see github.com/henrygd/beszel/issues/1345
// An explicit SMART_DEVICES ":type" hint is a deliberate override, so always if deviceType != "" && deviceType != "scsi" && deviceType != "ata" {
// pass it through; otherwise scsi/ata are left off so smartctl can auto-detect.
if deviceType != "" && (deviceInfo.explicitType || (deviceType != "scsi" && deviceType != "ata")) {
args = append(args, "-d", deviceInfo.Type) args = append(args, "-d", deviceInfo.Type)
} }
} }
@@ -672,9 +684,6 @@ func mergeDeviceLists(existing, scanned, configured []*DeviceInfo) []*DeviceInfo
target.Type = prev.Type target.Type = prev.Type
target.typeVerified = true target.typeVerified = true
target.parserType = prev.parserType target.parserType = prev.parserType
if prev.explicitType {
target.explicitType = true
}
} }
// applyConfiguredMetadata updates a matched device with any configured // applyConfiguredMetadata updates a matched device with any configured
@@ -688,9 +697,6 @@ func mergeDeviceLists(existing, scanned, configured []*DeviceInfo) []*DeviceInfo
existingDev.typeVerified = false existingDev.typeVerified = false
existingDev.parserType = normalizeParserType(newType) existingDev.parserType = normalizeParserType(newType)
} }
if configuredDev.explicitType {
existingDev.explicitType = true
}
if configuredDev.InfoName != "" { if configuredDev.InfoName != "" {
existingDev.InfoName = configuredDev.InfoName existingDev.InfoName = configuredDev.InfoName
} }
@@ -748,6 +754,7 @@ func mergeDeviceLists(existing, scanned, configured []*DeviceInfo) []*DeviceInfo
} }
if existingDev := deviceIndexByName[configuredDevice.Name]; existingDev != nil { if existingDev := deviceIndexByName[configuredDevice.Name]; existingDev != nil {
applyConfiguredMetadata(existingDev, configuredDevice) applyConfiguredMetadata(existingDev, configuredDevice)
delete(deviceIndexByName, configuredDevice.Name)
continue continue
} }
@@ -851,9 +858,12 @@ func (sm *SmartManager) isVirtualDeviceFromStrings(fields ...string) bool {
return false return false
} }
// parseSmartForSata parses the output of smartctl --all -j for SATA/ATA devices and updates the SmartDataMap // parseSmartForSata parses the output of smartctl --all -j for SATA/ATA devices and updates the SmartDataMap.
// Returns hasValidData and exitStatus // configuredType is the user-specified device type (e.g., "sat", "jms56x,0") from SMART_DEVICES.
func (sm *SmartManager) parseSmartForSata(output []byte) (bool, int) { // When non-empty, it overrides the disk type reported by smartctl in SmartData.DiskType so that
// updateSmartDevices can match entries by the configured composite (name, type) key.
// Returns hasValidData and exitStatus.
func (sm *SmartManager) parseSmartForSata(output []byte, configuredType string) (bool, int) {
var data smart.SmartInfoForSata var data smart.SmartInfoForSata
if err := json.Unmarshal(output, &data); err != nil { if err := json.Unmarshal(output, &data); err != nil {
@@ -892,6 +902,9 @@ func (sm *SmartManager) parseSmartForSata(output []byte) (bool, int) {
smartData.SmartStatus = getSmartStatus(smartData.Temperature, data.SmartStatus.Passed) smartData.SmartStatus = getSmartStatus(smartData.Temperature, data.SmartStatus.Passed)
smartData.DiskName = data.Device.Name smartData.DiskName = data.Device.Name
smartData.DiskType = data.Device.Type smartData.DiskType = data.Device.Type
if configuredType != "" {
smartData.DiskType = normalizeParserType(configuredType)
}
// get values from ata_device_statistics if necessary // get values from ata_device_statistics if necessary
var ataDeviceStats smart.AtaDeviceStatistics var ataDeviceStats smart.AtaDeviceStatistics
@@ -965,7 +978,7 @@ func findAtaDeviceStatisticsValue(data *smart.SmartInfoForSata, ataDeviceStats *
return nil return nil
} }
func (sm *SmartManager) parseSmartForScsi(output []byte) (bool, int) { func (sm *SmartManager) parseSmartForScsi(output []byte, configuredType string) (bool, int) {
var data smart.SmartInfoForScsi var data smart.SmartInfoForScsi
if err := json.Unmarshal(output, &data); err != nil { if err := json.Unmarshal(output, &data); err != nil {
@@ -1000,6 +1013,9 @@ func (sm *SmartManager) parseSmartForScsi(output []byte) (bool, int) {
smartData.SmartStatus = getSmartStatus(smartData.Temperature, data.SmartStatus.Passed) smartData.SmartStatus = getSmartStatus(smartData.Temperature, data.SmartStatus.Passed)
smartData.DiskName = data.Device.Name smartData.DiskName = data.Device.Name
smartData.DiskType = data.Device.Type smartData.DiskType = data.Device.Type
if configuredType != "" {
smartData.DiskType = normalizeParserType(configuredType)
}
attributes := make([]*smart.SmartAttribute, 0, 10) attributes := make([]*smart.SmartAttribute, 0, 10)
attributes = append(attributes, &smart.SmartAttribute{Name: "PowerOnHours", RawValue: data.PowerOnTime.Hours}) attributes = append(attributes, &smart.SmartAttribute{Name: "PowerOnHours", RawValue: data.PowerOnTime.Hours})
@@ -1097,9 +1113,12 @@ func (sm *SmartManager) lookupDarwinNvmeCapacity(serial string) uint64 {
return sm.darwinNvmeCapacity[serial] return sm.darwinNvmeCapacity[serial]
} }
// parseSmartForNvme parses the output of smartctl --all -j /dev/nvmeX and updates the SmartDataMap // parseSmartForNvme parses the output of smartctl --all -j /dev/nvmeX and updates the SmartDataMap.
// Returns hasValidData and exitStatus // configuredType is the user-specified device type (e.g., "nvme") from SMART_DEVICES.
func (sm *SmartManager) parseSmartForNvme(output []byte) (bool, int) { // When non-empty, it overrides the disk type reported by smartctl in SmartData.DiskType so that
// updateSmartDevices can match entries by the configured composite (name, type) key.
// Returns hasValidData and exitStatus.
func (sm *SmartManager) parseSmartForNvme(output []byte, configuredType string) (bool, int) {
data := &smart.SmartInfoForNvme{} data := &smart.SmartInfoForNvme{}
if err := json.Unmarshal(output, &data); err != nil { if err := json.Unmarshal(output, &data); err != nil {
@@ -1143,6 +1162,9 @@ func (sm *SmartManager) parseSmartForNvme(output []byte) (bool, int) {
smartData.SmartStatus = getSmartStatus(smartData.Temperature, data.SmartStatus.Passed) smartData.SmartStatus = getSmartStatus(smartData.Temperature, data.SmartStatus.Passed)
smartData.DiskName = data.Device.Name smartData.DiskName = data.Device.Name
smartData.DiskType = data.Device.Type smartData.DiskType = data.Device.Type
if configuredType != "" {
smartData.DiskType = normalizeParserType(configuredType)
}
// nvme attributes does not follow the same format as ata attributes, // nvme attributes does not follow the same format as ata attributes,
// so we manually map each field to SmartAttributes // so we manually map each field to SmartAttributes
+29 -84
View File
@@ -24,7 +24,7 @@ func TestParseSmartForScsi(t *testing.T) {
SmartDataMap: make(map[string]*smart.SmartData), SmartDataMap: make(map[string]*smart.SmartData),
} }
hasData, exitStatus := sm.parseSmartForScsi(data) hasData, exitStatus := sm.parseSmartForScsi(data, "")
if !hasData { if !hasData {
t.Fatalf("expected SCSI data to parse successfully") t.Fatalf("expected SCSI data to parse successfully")
} }
@@ -69,7 +69,7 @@ func TestParseSmartForSata(t *testing.T) {
SmartDataMap: make(map[string]*smart.SmartData), SmartDataMap: make(map[string]*smart.SmartData),
} }
hasData, exitStatus := sm.parseSmartForSata(data) hasData, exitStatus := sm.parseSmartForSata(data, "")
require.True(t, hasData) require.True(t, hasData)
assert.Equal(t, 64, exitStatus) assert.Equal(t, 64, exitStatus)
@@ -88,6 +88,26 @@ func TestParseSmartForSata(t *testing.T) {
} }
} }
func TestParseSmartForSataWithConfiguredType(t *testing.T) {
fixturePath := filepath.Join("test-data", "smart", "sda.json")
data, err := os.ReadFile(fixturePath)
require.NoError(t, err)
sm := &SmartManager{
SmartDataMap: make(map[string]*smart.SmartData),
}
hasData, exitStatus := sm.parseSmartForSata(data, "sat+megaraid")
require.True(t, hasData)
assert.Equal(t, 64, exitStatus)
deviceData, ok := sm.SmartDataMap["9C40918040082"]
require.True(t, ok, "expected smart data entry for serial 9C40918040082")
assert.Equal(t, "sat+megaraid", deviceData.DiskType,
"DiskType should be overridden by configuredType")
}
func TestParseSmartForSataDeviceStatisticsTemperature(t *testing.T) { func TestParseSmartForSataDeviceStatisticsTemperature(t *testing.T) {
jsonPayload := []byte(`{ jsonPayload := []byte(`{
"smartctl": {"exit_status": 0}, "smartctl": {"exit_status": 0},
@@ -112,7 +132,7 @@ func TestParseSmartForSataDeviceStatisticsTemperature(t *testing.T) {
}`) }`)
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)} sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
hasData, exitStatus := sm.parseSmartForSata(jsonPayload) hasData, exitStatus := sm.parseSmartForSata(jsonPayload, "")
require.True(t, hasData) require.True(t, hasData)
assert.Equal(t, 0, exitStatus) assert.Equal(t, 0, exitStatus)
@@ -147,7 +167,7 @@ func TestParseSmartForSataAtaDeviceStatistics(t *testing.T) {
}`) }`)
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)} sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
hasData, exitStatus := sm.parseSmartForSata(jsonPayload) hasData, exitStatus := sm.parseSmartForSata(jsonPayload, "")
require.True(t, hasData) require.True(t, hasData)
assert.Equal(t, 0, exitStatus) assert.Equal(t, 0, exitStatus)
@@ -184,7 +204,7 @@ func TestParseSmartForSataNegativeDeviceStatistics(t *testing.T) {
}`) }`)
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)} sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
hasData, exitStatus := sm.parseSmartForSata(jsonPayload) hasData, exitStatus := sm.parseSmartForSata(jsonPayload, "")
require.True(t, hasData) require.True(t, hasData)
assert.Equal(t, 0, exitStatus) assert.Equal(t, 0, exitStatus)
@@ -223,7 +243,7 @@ func TestParseSmartForSataParentheticalRawValue(t *testing.T) {
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)} sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
hasData, exitStatus := sm.parseSmartForSata(jsonPayload) hasData, exitStatus := sm.parseSmartForSata(jsonPayload, "")
require.True(t, hasData) require.True(t, hasData)
assert.Equal(t, 0, exitStatus) assert.Equal(t, 0, exitStatus)
@@ -245,7 +265,7 @@ func TestParseSmartForNvme(t *testing.T) {
SmartDataMap: make(map[string]*smart.SmartData), SmartDataMap: make(map[string]*smart.SmartData),
} }
hasData, exitStatus := sm.parseSmartForNvme(data) hasData, exitStatus := sm.parseSmartForNvme(data, "")
require.True(t, hasData) require.True(t, hasData)
assert.Equal(t, 0, exitStatus) assert.Equal(t, 0, exitStatus)
@@ -392,81 +412,6 @@ func TestSmartctlArgs(t *testing.T) {
) )
} }
// TestSmartctlArgsExplicitType verifies that an explicit SMART_DEVICES type hint
// is always passed to smartctl via -d, while a scan-detected scsi/ata type is
// still left off so smartctl can auto-detect it (see issue #1345).
func TestSmartctlArgsExplicitType(t *testing.T) {
sm := &SmartManager{}
// Scan-detected scsi: -d is intentionally omitted.
scanScsi := &DeviceInfo{Name: "/dev/sda", Type: "scsi"}
assert.Equal(t,
[]string{"-a", "--json=c", "/dev/sda"},
sm.smartctlArgs(scanScsi, false),
)
// Explicit scsi from SMART_DEVICES: -d scsi must be passed.
explicitScsi := &DeviceInfo{Name: "/dev/sda", Type: "scsi", explicitType: true}
assert.Equal(t,
[]string{"-d", "scsi", "-a", "--json=c", "/dev/sda"},
sm.smartctlArgs(explicitScsi, false),
)
// Explicit ata from SMART_DEVICES: -d ata must be passed (devstat still added).
explicitAta := &DeviceInfo{Name: "/dev/sdb", Type: "ata", explicitType: true}
assert.Equal(t,
[]string{"-d", "ata", "-a", "--json=c", "-l", "devstat", "/dev/sdb"},
sm.smartctlArgs(explicitAta, false),
)
}
// TestSmartDevicesExplicitTypeFlowsToSmartctlArgs is a regression test for
// issue #2072: an explicit SMART_DEVICES type (e.g. /dev/sda:scsi) must win over
// a wrong scan-detected type (sat) and be handed to smartctl as -d scsi.
func TestSmartDevicesExplicitTypeFlowsToSmartctlArgs(t *testing.T) {
sm := &SmartManager{}
configured, err := sm.parseConfiguredDevices("/dev/sda:scsi")
require.NoError(t, err)
require.Len(t, configured, 1)
assert.True(t, configured[0].explicitType)
// smartctl --scan misreports this USB drive as sat, which fails on it.
scanned := []*DeviceInfo{
{Name: "/dev/sda", Type: "sat", Protocol: "ATA"},
}
merged := mergeDeviceLists(nil, scanned, configured)
require.Len(t, merged, 1)
device := merged[0]
assert.Equal(t, "scsi", device.Type, "configured type should win over scan-detected sat")
assert.True(t, device.explicitType, "explicit hint must survive the merge")
assert.Equal(t,
[]string{"-d", "scsi", "-a", "--json=c", "/dev/sda"},
sm.smartctlArgs(device, false),
"explicit scsi type must be passed to smartctl, not dropped",
)
}
// TestMergeDeviceListsPreservesExplicitTypeAcrossRescan ensures a verified,
// explicitly-typed device keeps its explicit flag when a later scan re-reports
// it with a different auto-detected type.
func TestMergeDeviceListsPreservesExplicitTypeAcrossRescan(t *testing.T) {
existing := []*DeviceInfo{
{Name: "/dev/sda", Type: "scsi", parserType: "scsi", typeVerified: true, explicitType: true},
}
scanned := []*DeviceInfo{
{Name: "/dev/sda", Type: "sat"},
}
merged := mergeDeviceLists(existing, scanned, nil)
require.Len(t, merged, 1)
assert.Equal(t, "scsi", merged[0].Type)
assert.True(t, merged[0].explicitType, "explicit type flag should survive a rescan")
}
func TestResolveRefreshError(t *testing.T) { func TestResolveRefreshError(t *testing.T) {
scanErr := errors.New("scan failed") scanErr := errors.New("scan failed")
collectErr := errors.New("collect failed") collectErr := errors.New("collect failed")
@@ -1300,7 +1245,7 @@ func TestParseSmartForNvmeAppleSSD(t *testing.T) {
darwinNvmeProvider: fakeProvider, darwinNvmeProvider: fakeProvider,
} }
hasData, _ := sm.parseSmartForNvme(data) hasData, _ := sm.parseSmartForNvme(data, "")
require.True(t, hasData) require.True(t, hasData)
deviceData, ok := sm.SmartDataMap["0ba0147940253c15"] deviceData, ok := sm.SmartDataMap["0ba0147940253c15"]
@@ -1312,7 +1257,7 @@ func TestParseSmartForNvmeAppleSSD(t *testing.T) {
assert.Equal(t, 1, providerCalls, "system_profiler should be called once") assert.Equal(t, 1, providerCalls, "system_profiler should be called once")
// Second parse: provider should NOT be called again (cache hit) // Second parse: provider should NOT be called again (cache hit)
_, _ = sm.parseSmartForNvme(data) _, _ = sm.parseSmartForNvme(data, "")
assert.Equal(t, 1, providerCalls, "system_profiler should not be called again after caching") assert.Equal(t, 1, providerCalls, "system_profiler should not be called again after caching")
} }
+17 -51
View File
@@ -132,14 +132,9 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
var systemStats system.Stats var systemStats system.Stats
// battery // battery
if batteries, err := battery.GetBatteryStats(); err == nil { if batteryPercent, batteryState, err := battery.GetBatteryStats(); err == nil {
systemStats.Batteries = make(map[string]uint8, len(batteries)) systemStats.Battery[0] = batteryPercent
for _, device := range batteries { systemStats.Battery[1] = batteryState
systemStats.Batteries[device.Name] = device.Percent
}
if primary, ok := battery.Primary(batteries); ok {
systemStats.Battery = [2]uint8{primary.Percent, primary.State}
}
} }
// cpu metrics // cpu metrics
@@ -174,11 +169,21 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
// memory // memory
if v, err := mem.VirtualMemory(); err == nil { if v, err := mem.VirtualMemory(); err == nil {
used, cacheBuff, swapUsed := calculateHostMemoryUsage(v, a.memCalc == "htop")
// swap // swap
systemStats.Swap = utils.BytesToGigabytes(v.SwapTotal) systemStats.Swap = utils.BytesToGigabytes(v.SwapTotal)
systemStats.SwapUsed = utils.BytesToGigabytes(swapUsed) systemStats.SwapUsed = utils.BytesToGigabytes(v.SwapTotal - v.SwapFree - v.SwapCached)
v.Used = used // cache + buffers value for default mem calculation
// note: gopsutil automatically adds SReclaimable to v.Cached
cacheBuff := v.Cached + v.Buffers - v.Shared
if cacheBuff <= 0 {
cacheBuff = max(v.Total-v.Free-v.Used, 0)
}
// htop memory calculation overrides (likely outdated as of mid 2025)
if a.memCalc == "htop" {
// cacheBuff = v.Cached + v.Buffers - v.Shared
v.Used = v.Total - (v.Free + cacheBuff)
v.UsedPercent = float64(v.Used) / float64(v.Total) * 100.0
}
// if a.memCalc == "legacy" { // if a.memCalc == "legacy" {
// v.Used = v.Total - v.Free - v.Buffers - v.Cached // v.Used = v.Total - v.Free - v.Buffers - v.Cached
// cacheBuff = v.Total - v.Free - v.Used // cacheBuff = v.Total - v.Free - v.Used
@@ -188,14 +193,10 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
if a.zfs { if a.zfs {
if arcSize, _ := zfs.ARCSize(); arcSize > 0 && arcSize < v.Used { if arcSize, _ := zfs.ARCSize(); arcSize > 0 && arcSize < v.Used {
v.Used = v.Used - arcSize v.Used = v.Used - arcSize
v.UsedPercent = float64(v.Used) / float64(v.Total) * 100.0
systemStats.MemZfsArc = utils.BytesToGigabytes(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.Mem = utils.BytesToGigabytes(v.Total)
systemStats.MemBuffCache = utils.BytesToGigabytes(cacheBuff) systemStats.MemBuffCache = utils.BytesToGigabytes(cacheBuff)
systemStats.MemUsed = utils.BytesToGigabytes(v.Used) systemStats.MemUsed = utils.BytesToGigabytes(v.Used)
@@ -215,9 +216,6 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
// TODO: maybe refactor to methods on systemStats // TODO: maybe refactor to methods on systemStats
a.updateTemperatures(&systemStats) a.updateTemperatures(&systemStats)
// fan speeds (Linux-only; sysfs hwmon)
a.updateFans(&systemStats)
// GPU data // GPU data
if a.gpuManager != nil { if a.gpuManager != nil {
// reset high gpu percent // reset high gpu percent
@@ -265,38 +263,6 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
return systemStats return systemStats
} }
// calculateHostMemoryUsage derives counters defensively because /proc/meminfo may
// change while gopsutil reads it. Invalid unsigned subtractions saturate at zero.
func calculateHostMemoryUsage(v *mem.VirtualMemoryStat, htop bool) (used, cacheBuff, swapUsed uint64) {
used = v.Used
if used > v.Total {
used = saturatingSub(v.Total, v.Available)
}
// gopsutil automatically adds SReclaimable to Cached.
cacheBuff = min(v.Cached, v.Total)
cacheBuff += min(v.Buffers, v.Total-cacheBuff)
cacheBuff = saturatingSub(cacheBuff, min(v.Shared, v.Total))
if v.Cached == 0 && v.Buffers == 0 {
cacheBuff = saturatingSub(v.Total, v.Free, used)
}
if htop {
used = saturatingSub(v.Total, v.Free, cacheBuff)
}
return used, cacheBuff, saturatingSub(v.SwapTotal, v.SwapFree, v.SwapCached)
}
// saturatingSub subtracts each value, returning zero on underflow.
func saturatingSub(value uint64, subtrahends ...uint64) uint64 {
for _, subtrahend := range subtrahends {
if subtrahend > value {
return 0
}
value -= subtrahend
}
return value
}
// getOsPrettyName attempts to get the pretty OS name from /etc/os-release on Linux systems // getOsPrettyName attempts to get the pretty OS name from /etc/os-release on Linux systems
func getOsPrettyName() (string, error) { func getOsPrettyName() (string, error) {
file, err := os.Open("/etc/os-release") file, err := os.Open("/etc/os-release")
-54
View File
@@ -5,7 +5,6 @@ import (
"github.com/henrygd/beszel/internal/common" "github.com/henrygd/beszel/internal/common"
"github.com/henrygd/beszel/internal/entities/system" "github.com/henrygd/beszel/internal/entities/system"
"github.com/shirou/gopsutil/v4/mem"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@@ -34,59 +33,6 @@ func TestGatherStatsDoesNotAttachDetailsToCachedRequests(t *testing.T) {
assert.Nil(t, secondResponse.Details) assert.Nil(t, secondResponse.Details)
} }
func TestCalculateHostMemoryUsage(t *testing.T) {
tests := []struct {
name string
memory mem.VirtualMemoryStat
htop bool
used, cacheBuff, swapUsed uint64
}{
{
name: "normal",
memory: mem.VirtualMemoryStat{Total: 100, Available: 40, Used: 60, Free: 20, Cached: 25, Buffers: 10, Shared: 5, SwapTotal: 20, SwapFree: 8, SwapCached: 2},
used: 60,
cacheBuff: 30,
swapUsed: 10,
},
{
name: "inconsistent counters saturate",
memory: mem.VirtualMemoryStat{Total: 100, Available: 110, Used: ^uint64(0) - 9, Free: 90, Cached: 5, Buffers: 10, Shared: 20, SwapTotal: 10, SwapFree: 9, SwapCached: 2},
used: 0,
cacheBuff: 0,
swapUsed: 0,
},
{
name: "htop subtraction saturates",
memory: mem.VirtualMemoryStat{Total: 100, Available: 20, Used: 80, Free: 90, Cached: 20, Buffers: 5, SwapTotal: 30, SwapFree: 10, SwapCached: 5},
htop: true,
used: 0,
cacheBuff: 25,
swapUsed: 15,
},
{
name: "zero cache from shared cancellation does not fall back",
memory: mem.VirtualMemoryStat{Total: 100, Used: 60, Free: 10, Cached: 20, Buffers: 10, Shared: 30},
used: 60,
cacheBuff: 0,
},
{
name: "absent cache counters use fallback",
memory: mem.VirtualMemoryStat{Total: 100, Used: 60, Free: 10},
used: 60,
cacheBuff: 30,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
used, cacheBuff, swapUsed := calculateHostMemoryUsage(&tt.memory, tt.htop)
assert.Equal(t, tt.used, used)
assert.Equal(t, tt.cacheBuff, cacheBuff)
assert.Equal(t, tt.swapUsed, swapUsed)
})
}
}
func TestUpdateSystemDetailsMarksDetailsDirty(t *testing.T) { func TestUpdateSystemDetailsMarksDetailsDirty(t *testing.T) {
agent := &Agent{} agent := &Agent{}
-3
View File
@@ -1,3 +0,0 @@
apiVersion: v1
entries: {}
generated: "2026-03-05T00:00:00Z"
+28 -29
View File
@@ -1,27 +1,27 @@
module github.com/henrygd/beszel module github.com/henrygd/beszel
go 1.26.6 go 1.26.3
require ( require (
github.com/blang/semver v3.5.1+incompatible github.com/blang/semver v3.5.1+incompatible
github.com/coreos/go-systemd/v22 v22.7.0 github.com/coreos/go-systemd/v22 v22.7.0
github.com/ebitengine/purego v0.10.2 github.com/ebitengine/purego v0.10.0
github.com/fxamacker/cbor/v2 v2.9.2 github.com/fxamacker/cbor/v2 v2.9.0
github.com/gliderlabs/ssh v0.3.8 github.com/gliderlabs/ssh v0.3.8
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/lxzan/gws v1.10.1 github.com/lxzan/gws v1.9.1
github.com/nicholas-fedor/shoutrrr v0.17.0 github.com/nicholas-fedor/shoutrrr v0.15.1
github.com/pocketbase/dbx v1.12.0 github.com/pocketbase/dbx v1.12.0
github.com/pocketbase/pocketbase v0.39.10 github.com/pocketbase/pocketbase v0.36.8
github.com/shirou/gopsutil/v4 v4.26.7 github.com/shirou/gopsutil/v4 v4.26.3
github.com/spf13/cast v1.10.0 github.com/spf13/cast v1.10.0
github.com/spf13/cobra v1.10.2 github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.10 github.com/spf13/pflag v1.0.10
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
golang.org/x/crypto v0.55.0 golang.org/x/crypto v0.52.0
golang.org/x/exp v0.0.0-20260811152304-ee035b5b010f golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90
golang.org/x/net v0.57.0 golang.org/x/net v0.55.0
golang.org/x/sys v0.47.0 golang.org/x/sys v0.45.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
howett.net/plist v1.0.1 howett.net/plist v1.0.1
) )
@@ -35,35 +35,34 @@ require (
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/eclipse/paho.golang v0.23.0 // indirect github.com/eclipse/paho.golang v0.23.0 // indirect
github.com/fatih/color v1.19.0 // indirect github.com/fatih/color v1.19.0 // indirect
github.com/fsnotify/fsnotify v1.10.1 // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/gabriel-vasile/mimetype v1.4.15 // indirect github.com/ganigeorgiev/fexpr v0.5.0 // indirect
github.com/ganigeorgiev/fexpr v0.6.0 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect
github.com/go-sql-driver/mysql v1.9.1 // indirect github.com/go-sql-driver/mysql v1.9.1 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/gorilla/websocket v1.5.3 // indirect github.com/gorilla/websocket v1.5.3 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/klauspost/compress v1.19.2 // indirect github.com/klauspost/compress v1.18.5 // indirect
github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 // indirect github.com/lufia/plan9stats v0.0.0-20260324052639-156f7da3f749 // indirect
github.com/mattn/go-colorable v0.1.15 // indirect github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect github.com/mattn/go-isatty v0.0.22 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/pocketbase/ozzo-validation/v4 v4.3.0 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/tklauser/go-sysconf v0.4.0 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect
github.com/tklauser/numcpus v0.12.0 // indirect github.com/tklauser/numcpus v0.11.0 // indirect
github.com/x448/float16 v0.8.4 // indirect github.com/x448/float16 v0.8.4 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect
golang.org/x/image v0.45.0 // indirect golang.org/x/image v0.41.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.22.0 // indirect golang.org/x/sync v0.20.0 // indirect
golang.org/x/term v0.45.0 // indirect golang.org/x/term v0.43.0 // indirect
golang.org/x/text v0.41.0 // indirect golang.org/x/text v0.37.0 // indirect
modernc.org/libc v1.74.1 // indirect modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.12.0 // indirect modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.55.0 // indirect modernc.org/sqlite v1.48.0 // indirect
) )
+77 -80
View File
@@ -21,29 +21,29 @@ github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCO
github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c= github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/ebitengine/purego v0.10.2 h1:W809HbnvzAxgdm+aOvlSekrM16wGCdT/e76+9tS7gzE= github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
github.com/ebitengine/purego v0.10.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/eclipse/paho.golang v0.23.0 h1:KHgl2wz6EJo7cMBmkuhpt7C576vP+kpPv7jjvSyR6Mk= github.com/eclipse/paho.golang v0.23.0 h1:KHgl2wz6EJo7cMBmkuhpt7C576vP+kpPv7jjvSyR6Mk=
github.com/eclipse/paho.golang v0.23.0/go.mod h1:nQRhTkoZv8EAiNs5UU0/WdQIx2NrnWUpL9nsGJTQN04= github.com/eclipse/paho.golang v0.23.0/go.mod h1:nQRhTkoZv8EAiNs5UU0/WdQIx2NrnWUpL9nsGJTQN04=
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78= github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI= github.com/ganigeorgiev/fexpr v0.5.0 h1:XA9JxtTE/Xm+g/JFI6RfZEHSiQlk+1glLvRK1Lpv/Tk=
github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ= github.com/ganigeorgiev/fexpr v0.5.0/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
github.com/ganigeorgiev/fexpr v0.6.0 h1:Fza3O/QMBKEudUvxV862qe6GjxM60GJjjKytdp+VQus=
github.com/ganigeorgiev/fexpr v0.6.0/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 h1:byhDUpfEwjsVQb1vBunvIjh2BHQ9ead57VkAEY4V+Es=
github.com/go-ozzo/ozzo-validation/v4 v4.3.0/go.mod h1:2NKgrcHl3z6cJs+3Oo940FPRiTzuqKbvfrL2RxCj6Ew=
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/go-sql-driver/mysql v1.9.1 h1:FrjNGn/BsJQjVRuSa8CBrM5BWA9BWoXXat3KrtSb/iI= github.com/go-sql-driver/mysql v1.9.1 h1:FrjNGn/BsJQjVRuSa8CBrM5BWA9BWoXXat3KrtSb/iI=
github.com/go-sql-driver/mysql v1.9.1/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/go-sql-driver/mysql v1.9.1/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
@@ -56,8 +56,8 @@ github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArs
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= github.com/google/pprof v0.0.0-20260507013755-92041b743c96 h1:YDDnaZ9afWajDboPMt9Vikqca/yWAX7KAxVzb4lJU1M=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= github.com/google/pprof v0.0.0-20260507013755-92041b743c96/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 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/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
@@ -66,49 +66,47 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= 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= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jarcoal/httpmock v1.4.2 h1:dKwiP/9zITCPfBLsDn3kchbSOu16JrnxtVEmL0fPRcI= github.com/jarcoal/httpmock v1.4.1 h1:0Ju+VCFuARfFlhVXFc2HxlcQkfB+Xq12/EotHko+x2A=
github.com/jarcoal/httpmock v1.4.2/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0= 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/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 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 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 h1:eveIIGn4BGM3qknO74omf6HYr30/exH+eVUTuAgwjZ0= github.com/lufia/plan9stats v0.0.0-20260324052639-156f7da3f749 h1:Qj3hTcdWH8uMZDI41HNuTuJN525C7NBrbtH5kSO6fPk=
github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= github.com/lufia/plan9stats v0.0.0-20260324052639-156f7da3f749/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/lxzan/gws v1.10.1 h1:1xG+tDOV0lgDeVPf0wNT74u3cn0K3LpcavRrTPTrMwQ= github.com/lxzan/gws v1.9.1 h1:4lbIp4cW0hOLP3ejFHR/uWRy741AURx7oKkNNi2OT9o=
github.com/lxzan/gws v1.10.1/go.mod h1:gXHSCPmTGryWJ4icuqy8Yho32E4YIMHH0fkDRYJRbdc= github.com/lxzan/gws v1.9.1/go.mod h1:gXHSCPmTGryWJ4icuqy8Yho32E4YIMHH0fkDRYJRbdc=
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= 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/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nicholas-fedor/shoutrrr v0.17.0 h1:xfp3z5QbE8jXvUhUEwWDk47SJ/b912VoB8MJJDU+q4E= github.com/nicholas-fedor/shoutrrr v0.15.1 h1:dfgqpaeyr0CwUhqtwWBHS4girmAvFPOoxroHaVH1q1Y=
github.com/nicholas-fedor/shoutrrr v0.17.0/go.mod h1:s4ldyLs6uwBy9lIjYrY+8lyTqJtPvZSrILw0CyMLock= github.com/nicholas-fedor/shoutrrr v0.15.1/go.mod h1:xrdV1ab2W0/xa5kM6WP9mBuqVuJaDWqUwYvYvVuPTjk=
github.com/onsi/ginkgo/v2 v2.32.0 h1:Hw7s2pVrQo/8Yz5N77qdnpHaoc+c6cC9WIV1Jce+J6E= github.com/onsi/ginkgo/v2 v2.29.0 h1:rfh+ZFjgJhYWRoIqVf3Uwx/W20yLrcrE2h2GmYVRaag=
github.com/onsi/ginkgo/v2 v2.32.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= github.com/onsi/ginkgo/v2 v2.29.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44=
github.com/onsi/gomega v1.42.1 h1:iN1rCUX+44NZ1Dc97MPoeFYbFR0vh8zxoxMFwKdyZ6I= github.com/onsi/gomega v1.41.0 h1:OwKp4pXNgVxf6sCplzYo794OFNuoL2q2SBMU5NSWOjA=
github.com/onsi/gomega v1.42.1/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg= github.com/onsi/gomega v1.41.0/go.mod h1:M/Uqpu/8qTjtzCLUA2zJHX9Iilrau25x1PdoSRbWh5A=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pocketbase/dbx v1.12.0 h1:/oLErM+A0b4xI0PWTGPqSDVjzix48PqI/bng2l0PzoA= 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/dbx v1.12.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
github.com/pocketbase/ozzo-validation/v4 v4.3.0 h1:uKBDVma7bZqgR2a6AwE+k9hkuDFfiZMpBHQdZ1z3iQs= github.com/pocketbase/pocketbase v0.36.8 h1:gCNqoesZ44saYOD3J7edhi5nDwUWKyQG7boM/kVwz2c=
github.com/pocketbase/ozzo-validation/v4 v4.3.0/go.mod h1:6XNjSTw/Jb2F8LOkKO3oyzIWExbrGiYoS4uVxVwz90g= github.com/pocketbase/pocketbase v0.36.8/go.mod h1:OY4WaXbP0WnF/EXoBbboWJK+ZSZ1A85tiA0sjrTKxTA=
github.com/pocketbase/pocketbase v0.39.10 h1:2j8TDJRuo3aAC8Y8F9WFux0SwYcxeDCgEYQxxdWkwGE= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/pocketbase/pocketbase v0.39.10/go.mod h1:tSX3anHQ7Ul6dPV9WhlEc6No1DtklGF69iwnVNW3BEE= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6 h1:jL3a8soXdzuTCcRnKhOmtcsVOObdDTFf4O2B403HPRU=
github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/shirou/gopsutil/v4 v4.26.7 h1:IXzpHz/dkMRYAhKkOXr1HB6SuzWU3eoyyeWe7g3bNZc= github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc=
github.com/shirou/gopsutil/v4 v4.26.7/go.mod h1:5O9FjBiXoTDFatIWjZZosqj4pV0DRtLx598xGbBehzM= github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= 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/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
@@ -122,51 +120,50 @@ github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+Q
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU= github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI= github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4= github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg= github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= 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/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/exp v0.0.0-20260811152304-ee035b5b010f h1:iXpLj9sdDH/RLYsnOMpbETK6KWtrHwvegcc4psWJHV8= golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA=
golang.org/x/exp v0.0.0-20260811152304-ee035b5b010f/go.mod h1:EdfpwwqSu+0Li0mzskwHU6FWDV3t9Q+RZDo3QMUtL3Q= golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0= golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4= golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 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-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-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 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.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 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 h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
@@ -177,30 +174,30 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM= howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM=
howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc= modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk= modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI= modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ= modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os= modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.12.0 h1:twkmYNkGXCvtYWzoux02jtK6eovjZbdI0uHFUYp6kuU= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.12.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= 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 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.55.0 h1:hIFh0MCH0rGinQ/4KYb5/UbCkRkb+UP+OkLCVWa5MTM= modernc.org/sqlite v1.48.0 h1:ElZyLop3Q2mHYk5IFPPXADejZrlHu7APbpB0sF78bq4=
modernc.org/sqlite v1.55.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw= modernc.org/sqlite v1.48.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
-1
View File
@@ -55,7 +55,6 @@ type SystemAlertStats struct {
Temperatures map[string]float32 `json:"t"` Temperatures map[string]float32 `json:"t"`
LoadAvg [3]float64 `json:"la"` LoadAvg [3]float64 `json:"la"`
Battery [2]uint8 `json:"bat"` Battery [2]uint8 `json:"bat"`
Batteries map[string]uint8 `json:"bats"`
ExtraFs map[string]SystemAlertFsStats `json:"efs"` ExtraFs map[string]SystemAlertFsStats `json:"efs"`
} }
+1 -2
View File
@@ -322,9 +322,8 @@ func TestAlertSilencedMultiUser(t *testing.T) {
} }
func TestAlertSilencedWithActualAlert(t *testing.T) { func TestAlertSilencedWithActualAlert(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
synctest.Test(t, func(t *testing.T) { synctest.Test(t, func(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup() defer hub.Cleanup()
// Create a system // Create a system
+6 -12
View File
@@ -29,9 +29,8 @@ func setStatusAlertEmail(t *testing.T, hub core.App, userID, email string) {
} }
func TestStatusAlerts(t *testing.T) { func TestStatusAlerts(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
synctest.Test(t, func(t *testing.T) { synctest.Test(t, func(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup() defer hub.Cleanup()
systems, err := beszelTests.CreateSystems(hub, 4, user.Id, "paused") systems, err := beszelTests.CreateSystems(hub, 4, user.Id, "paused")
@@ -235,9 +234,8 @@ func TestHandleStatusAlertsDoesNotSendRecoveryWhileDownIsOnlyPending(t *testing.
} }
func TestStatusAlertTimerCancellationPreventsBoundaryDelivery(t *testing.T) { func TestStatusAlertTimerCancellationPreventsBoundaryDelivery(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
synctest.Test(t, func(t *testing.T) { synctest.Test(t, func(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup() defer hub.Cleanup()
userSettings, err := hub.FindFirstRecordByFilter("user_settings", "user={:user}", map[string]any{"user": user.Id}) userSettings, err := hub.FindFirstRecordByFilter("user_settings", "user={:user}", map[string]any{"user": user.Id})
@@ -338,9 +336,8 @@ func TestStatusAlertDownFiresAfterDelayExpires(t *testing.T) {
} }
func TestStatusAlertMultipleUsersRespectDifferentMinutes(t *testing.T) { func TestStatusAlertMultipleUsersRespectDifferentMinutes(t *testing.T) {
hub, user1 := beszelTests.GetHubWithUser(t)
synctest.Test(t, func(t *testing.T) { synctest.Test(t, func(t *testing.T) {
hub, user1 := beszelTests.GetHubWithUser(t)
defer hub.Cleanup() defer hub.Cleanup()
setStatusAlertEmail(t, hub, user1.Id, "user1@example.com") setStatusAlertEmail(t, hub, user1.Id, "user1@example.com")
@@ -426,9 +423,8 @@ func TestStatusAlertMultipleUsersRespectDifferentMinutes(t *testing.T) {
} }
func TestStatusAlertMultipleUsersRecoveryBetweenMinutesOnlyAlertsEarlierUser(t *testing.T) { func TestStatusAlertMultipleUsersRecoveryBetweenMinutesOnlyAlertsEarlierUser(t *testing.T) {
hub, user1 := beszelTests.GetHubWithUser(t)
synctest.Test(t, func(t *testing.T) { synctest.Test(t, func(t *testing.T) {
hub, user1 := beszelTests.GetHubWithUser(t)
defer hub.Cleanup() defer hub.Cleanup()
setStatusAlertEmail(t, hub, user1.Id, "user1@example.com") setStatusAlertEmail(t, hub, user1.Id, "user1@example.com")
@@ -820,9 +816,8 @@ func TestResolveStatusAlerts(t *testing.T) {
} }
func TestAlertsHistoryStatus(t *testing.T) { func TestAlertsHistoryStatus(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
synctest.Test(t, func(t *testing.T) { synctest.Test(t, func(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup() defer hub.Cleanup()
// Create a system // Create a system
@@ -887,9 +882,8 @@ func TestAlertsHistoryStatus(t *testing.T) {
} }
func TestStatusAlertClearedBeforeSend(t *testing.T) { func TestStatusAlertClearedBeforeSend(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
synctest.Test(t, func(t *testing.T) { synctest.Test(t, func(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup() defer hub.Cleanup()
// Create a system // Create a system
+1 -9
View File
@@ -63,7 +63,7 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
case "GPU": case "GPU":
val = data.Info.GpuPct val = data.Info.GpuPct
case "Battery": case "Battery":
if !hasRepresentativeBattery(data.Stats.Battery, data.Stats.Batteries) { if data.Stats.Battery[0] == 0 {
continue continue
} }
val = float64(data.Stats.Battery[0]) val = float64(data.Stats.Battery[0])
@@ -167,7 +167,6 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
stat := systemStats[i] stat := systemStats[i]
// subtract 10 seconds to give a small time buffer // subtract 10 seconds to give a small time buffer
systemStatsCreation := stat.Created.Time().Add(-time.Second * 10) systemStatsCreation := stat.Created.Time().Add(-time.Second * 10)
stats = SystemAlertStats{}
if err := json.Unmarshal(stat.Stats, &stats); err != nil { if err := json.Unmarshal(stat.Stats, &stats); err != nil {
return err return err
} }
@@ -236,9 +235,6 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
} }
alert.val += maxUsage alert.val += maxUsage
case "Battery": case "Battery":
if !hasRepresentativeBattery(stats.Battery, stats.Batteries) {
continue
}
alert.val += float64(stats.Battery[0]) alert.val += float64(stats.Battery[0])
default: default:
continue continue
@@ -301,10 +297,6 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
return nil return nil
} }
func hasRepresentativeBattery(legacy [2]uint8, batteries map[string]uint8) bool {
return legacy != [2]uint8{} || len(batteries) > 0
}
func (am *AlertManager) sendSystemAlert(alert SystemAlertData) { func (am *AlertManager) sendSystemAlert(alert SystemAlertData) {
// log.Printf("Sending alert %s: val %f | count %d | threshold %f\n", alert.name, alert.val, alert.count, alert.threshold) // log.Printf("Sending alert %s: val %f | count %d | threshold %f\n", alert.name, alert.val, alert.count, alert.threshold)
systemName := alert.systemRecord.GetString("name") systemName := alert.systemRecord.GetString("name")
+7 -5
View File
@@ -95,10 +95,11 @@ func waitForSystemAlert(d time.Duration) {
func testOneMinuteSystemAlert[T any](t *testing.T, alertName string, threshold float64, setValue systemAlertValueSetter[T], triggerValue, resolveValue T) { func testOneMinuteSystemAlert[T any](t *testing.T, alertName string, threshold float64, setValue systemAlertValueSetter[T], triggerValue, resolveValue T) {
t.Helper() t.Helper()
fixture := newSystemAlertTestFixture(t, alertName, 1, threshold)
defer fixture.cleanup()
synctest.Test(t, func(t *testing.T) { synctest.Test(t, func(t *testing.T) {
fixture := newSystemAlertTestFixture(t, alertName, 1, threshold)
defer fixture.cleanup()
submitValue(fixture, t, triggerValue, setValue) submitValue(fixture, t, triggerValue, setValue)
waitForSystemAlert(time.Second) waitForSystemAlert(time.Second)
@@ -117,10 +118,11 @@ func testOneMinuteSystemAlert[T any](t *testing.T, alertName string, threshold f
func testMultiMinuteSystemAlert[T any](t *testing.T, alertName string, threshold float64, min int, setValue systemAlertValueSetter[T], baselineValue, triggerValue, resolveValue T) { func testMultiMinuteSystemAlert[T any](t *testing.T, alertName string, threshold float64, min int, setValue systemAlertValueSetter[T], baselineValue, triggerValue, resolveValue T) {
t.Helper() t.Helper()
fixture := newSystemAlertTestFixture(t, alertName, min, threshold)
defer fixture.cleanup()
synctest.Test(t, func(t *testing.T) { synctest.Test(t, func(t *testing.T) {
fixture := newSystemAlertTestFixture(t, alertName, min, threshold)
defer fixture.cleanup()
submitValue(fixture, t, baselineValue, setValue) submitValue(fixture, t, baselineValue, setValue)
waitForSystemAlert(time.Minute + time.Second) waitForSystemAlert(time.Minute + time.Second)
fixture.assertTriggered(t, false, "Alert should not be triggered yet") fixture.assertTriggered(t, false, "Alert should not be triggered yet")
@@ -199,7 +201,7 @@ func TestSystemAlertsOneMin(t *testing.T) {
testOneMinuteSystemAlert(t, "LoadAvg1", 4, setLoadAvgAlertValue, [3]float64{4.1, 0, 0}, [3]float64{3.9, 0, 0}) testOneMinuteSystemAlert(t, "LoadAvg1", 4, setLoadAvgAlertValue, [3]float64{4.1, 0, 0}, [3]float64{3.9, 0, 0})
testOneMinuteSystemAlert(t, "LoadAvg5", 4, setLoadAvgAlertValue, [3]float64{0, 4.1, 0}, [3]float64{0, 3.9, 0}) testOneMinuteSystemAlert(t, "LoadAvg5", 4, setLoadAvgAlertValue, [3]float64{0, 4.1, 0}, [3]float64{0, 3.9, 0})
testOneMinuteSystemAlert(t, "LoadAvg15", 4, setLoadAvgAlertValue, [3]float64{0, 0, 4.1}, [3]float64{0, 0, 3.9}) testOneMinuteSystemAlert(t, "LoadAvg15", 4, setLoadAvgAlertValue, [3]float64{0, 0, 4.1}, [3]float64{0, 0, 3.9})
testOneMinuteSystemAlert(t, "Battery", 20, setBatteryAlertValue, [2]uint8{0, 1}, [2]uint8{21, 0}) testOneMinuteSystemAlert(t, "Battery", 20, setBatteryAlertValue, [2]uint8{19, 0}, [2]uint8{21, 0})
} }
func TestSystemAlertsTwoMin(t *testing.T) { func TestSystemAlertsTwoMin(t *testing.T) {
+1 -2
View File
@@ -15,9 +15,8 @@ import (
) )
func TestAlertsHistory(t *testing.T) { func TestAlertsHistory(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
synctest.Test(t, func(t *testing.T) { synctest.Test(t, func(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup() defer hub.Cleanup()
// Create systems and alerts // Create systems and alerts
+1 -2
View File
@@ -42,8 +42,7 @@ type AgentResponse struct {
SmartData map[string]smart.SmartData `cbor:"5,keyasint,omitempty,omitzero"` // Legacy (<= 0.17) SmartData map[string]smart.SmartData `cbor:"5,keyasint,omitempty,omitzero"` // Legacy (<= 0.17)
ServiceInfo systemd.ServiceDetails `cbor:"6,keyasint,omitempty,omitzero"` // Legacy (<= 0.17) ServiceInfo systemd.ServiceDetails `cbor:"6,keyasint,omitempty,omitzero"` // Legacy (<= 0.17)
// Data is the generic response payload for new endpoints (0.18+) // Data is the generic response payload for new endpoints (0.18+)
Data cbor.RawMessage `cbor:"7,keyasint,omitempty,omitzero"` Data cbor.RawMessage `cbor:"7,keyasint,omitempty,omitzero"`
SmartComplete bool `cbor:"8,keyasint,omitempty,omitzero"`
} }
type FingerprintRequest struct { type FingerprintRequest struct {
+1 -1
View File
@@ -20,7 +20,7 @@ FROM alpine:3.23
COPY --from=builder /agent /agent COPY --from=builder /agent /agent
RUN apk add --no-cache -X https://dl-cdn.alpinelinux.org/alpine/edge/testing igt-gpu-tools nvtop smartmontools RUN apk add --no-cache -X https://dl-cdn.alpinelinux.org/alpine/edge/testing igt-gpu-tools smartmontools
# Ensure data persistence across container recreations # Ensure data persistence across container recreations
VOLUME ["/var/lib/beszel-agent"] VOLUME ["/var/lib/beszel-agent"]
-90
View File
@@ -1,90 +0,0 @@
FROM --platform=$BUILDPLATFORM golang:bookworm AS builder
WORKDIR /app
COPY ../go.mod ../go.sum ./
RUN go mod download
# Copy source files
COPY . ./
# Build
ARG TARGETOS=linux
ARG TARGETARCH
ARG TARGETVARIANT
RUN set -eux; \
if [ "$TARGETARCH" = "arm" ] && [ -n "$TARGETVARIANT" ]; then \
export GOARM="${TARGETVARIANT#v}"; \
fi; \
CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH \
go build -tags glibc -ldflags "-w -s" -o /agent ./internal/cmd/agent
# --------------------------
# Smartmontools builder stage
# --------------------------
FROM --platform=$TARGETPLATFORM debian:bookworm-slim AS smartmontools-builder
# Keep smartmontools 7.5 built from source to match the current NVIDIA agent image behavior.
# A simpler Debian package based approach is also possible:
#
# RUN apt-get update && apt-get install -y --no-install-recommends \
# smartmontools \
# && rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y --no-install-recommends \
wget \
ca-certificates \
build-essential \
make \
g++ \
&& wget https://downloads.sourceforge.net/project/smartmontools/smartmontools/7.5/smartmontools-7.5.tar.gz \
&& tar zxvf smartmontools-7.5.tar.gz \
&& cd smartmontools-7.5 \
&& ./configure --prefix=/usr --sysconfdir=/etc \
&& make \
&& make install \
&& rm -rf /smartmontools-7.5* \
&& apt-get remove -y wget build-essential \
&& apt-get autoremove -y \
&& rm -rf /var/lib/apt/lists/*
# Copy smartmontools binary, data files, and required runtime libraries
RUN set -eux; \
mkdir -p /out/rootfs/usr/share /out/rootfs/lib /out/rootfs/lib64 /out/rootfs/usr/lib; \
if [ -d /usr/share/smartmontools ]; then \
cp -a /usr/share/smartmontools /out/rootfs/usr/share/; \
fi; \
ldd /usr/sbin/smartctl \
| awk '{print $3}' \
| grep '^/' \
| xargs -r -I '{}' sh -c 'mkdir -p "/out/rootfs$(dirname "{}")"; cp -v "{}" "/out/rootfs{}"'; \
interp="$(ldd /usr/sbin/smartctl | awk "/ld-linux/ {print \$1}")"; \
if [ -n "$interp" ] && [ -e "$interp" ]; then \
mkdir -p "/out/rootfs$(dirname "$interp")"; \
cp -v "$interp" "/out/rootfs$interp"; \
fi
# --------------------------
# Final image: lightweight multi-arch NVIDIA agent (slim)
# --------------------------
FROM --platform=$TARGETPLATFORM gcr.io/distroless/base-debian12
COPY --from=builder /agent /agent
# AMD GPU name lookup (used by agent on hybrid laptops when /usr/share/libdrm/amdgpu.ids is read)
COPY --from=builder /app/agent/test-data/amdgpu.ids /usr/share/libdrm/amdgpu.ids
# Copy smartmontools binaries and config files
COPY --from=smartmontools-builder /usr/sbin/smartctl /usr/sbin/smartctl
COPY --from=smartmontools-builder /out/rootfs/ /
# nvidia-smi is intentionally not bundled.
# Mount the host binary instead, for example:
# - /usr/bin/nvidia-smi:/usr/bin/nvidia-smi:ro
# Ensure data persistence across container recreations
VOLUME ["/var/lib/beszel-agent"]
WORKDIR /var/lib/beszel-agent
ENTRYPOINT ["/agent"]
+1 -16
View File
@@ -52,17 +52,6 @@ type HostInfo struct {
} }
func (s *ApiStats) CalculateCpuPercentLinux(prevCpuContainer uint64, prevCpuSystem uint64) float64 { func (s *ApiStats) CalculateCpuPercentLinux(prevCpuContainer uint64, prevCpuSystem uint64) float64 {
// A counter can read lower than the stored previous value when a stats
// response is processed after a newer one for the same container, or when an
// accounting counter resets. Unsigned subtraction wraps to ~2^64 instead of
// going negative: on the container counter that surfaces as an absurd
// percentage the caller rejects, discarding the whole sample; on the system
// counter it inflates the divisor and silently reports near-zero CPU.
// Treat either direction as a new baseline.
if s.CPUStats.CPUUsage.TotalUsage < prevCpuContainer || s.CPUStats.SystemUsage < prevCpuSystem {
return 0.0
}
cpuDelta := s.CPUStats.CPUUsage.TotalUsage - prevCpuContainer cpuDelta := s.CPUStats.CPUUsage.TotalUsage - prevCpuContainer
systemDelta := s.CPUStats.SystemUsage - prevCpuSystem systemDelta := s.CPUStats.SystemUsage - prevCpuSystem
@@ -81,11 +70,7 @@ func (s *ApiStats) CalculateCpuPercentWindows(prevCpuUsage uint64, prevRead time
possIntervals /= 100 // Convert to number of 100ns intervals possIntervals /= 100 // Convert to number of 100ns intervals
possIntervals *= uint64(s.NumProcs) // Multiple by the number of processors possIntervals *= uint64(s.NumProcs) // Multiple by the number of processors
// Intervals used. Same rollback guard as the Linux path: an out-of-order or // Intervals used
// reset counter would wrap the subtraction to ~2^64.
if s.CPUStats.CPUUsage.TotalUsage < prevCpuUsage {
return 0.0
}
intervalsUsed := s.CPUStats.CPUUsage.TotalUsage - prevCpuUsage intervalsUsed := s.CPUStats.CPUUsage.TotalUsage - prevCpuUsage
// Percentage avoiding divide-by-zero // Percentage avoiding divide-by-zero
+1 -8
View File
@@ -494,7 +494,7 @@ type SmartInfoForNvme struct {
FirmwareVersion string `json:"firmware_version"` FirmwareVersion string `json:"firmware_version"`
// NVMePCIVendor NVMePCIVendor `json:"nvme_pci_vendor"` // NVMePCIVendor NVMePCIVendor `json:"nvme_pci_vendor"`
// NVMeIEEEOUIIdentifier uint32 `json:"nvme_ieee_oui_identifier"` // NVMeIEEEOUIIdentifier uint32 `json:"nvme_ieee_oui_identifier"`
NVMeTotalCapacity uint64 `json:"nvme_total_capacity"` NVMeTotalCapacity uint64 `json:"nvme_total_capacity"`
// NVMeUnallocatedCapacity uint64 `json:"nvme_unallocated_capacity"` // NVMeUnallocatedCapacity uint64 `json:"nvme_unallocated_capacity"`
// NVMeControllerID uint16 `json:"nvme_controller_id"` // NVMeControllerID uint16 `json:"nvme_controller_id"`
// NVMeVersion VersionStringInfo `json:"nvme_version"` // NVMeVersion VersionStringInfo `json:"nvme_version"`
@@ -531,13 +531,6 @@ type SmartData struct {
Attributes []*SmartAttribute `json:"a,omitempty" cbor:"9,keyasint,omitempty"` Attributes []*SmartAttribute `json:"a,omitempty" cbor:"9,keyasint,omitempty"`
} }
// SmartDataResponse contains the collected data and whether every discovered
// device was collected. Older agents omit Complete, so hubs must not prune from it.
type SmartDataResponse struct {
Data map[string]SmartData `json:"data" cbor:"0,keyasint"`
Complete bool `json:"complete" cbor:"1,keyasint,omitempty"` // Whether every discovered device was collected
}
type SmartAttribute struct { type SmartAttribute struct {
ID uint16 `json:"id,omitempty" cbor:"0,keyasint,omitempty"` ID uint16 `json:"id,omitempty" cbor:"0,keyasint,omitempty"`
Name string `json:"n" cbor:"1,keyasint"` Name string `json:"n" cbor:"1,keyasint"`
+1 -3
View File
@@ -33,8 +33,6 @@ type Stats struct {
MaxNetworkSent float64 `json:"nsm,omitempty" cbor:"-"` MaxNetworkSent float64 `json:"nsm,omitempty" cbor:"-"`
MaxNetworkRecv float64 `json:"nrm,omitempty" cbor:"-"` MaxNetworkRecv float64 `json:"nrm,omitempty" cbor:"-"`
Temperatures map[string]float64 `json:"t,omitempty" cbor:"20,keyasint,omitempty"` Temperatures map[string]float64 `json:"t,omitempty" cbor:"20,keyasint,omitempty"`
Fans map[string]uint16 `json:"f,omitempty" cbor:"36,keyasint,omitempty"`
Batteries map[string]uint8 `json:"bats,omitempty" cbor:"37,keyasint,omitempty"`
ExtraFs map[string]*FsStats `json:"efs,omitempty" cbor:"21,keyasint,omitempty"` ExtraFs map[string]*FsStats `json:"efs,omitempty" cbor:"21,keyasint,omitempty"`
GPUData map[string]GPUData `json:"g,omitempty" cbor:"22,keyasint,omitempty"` GPUData map[string]GPUData `json:"g,omitempty" cbor:"22,keyasint,omitempty"`
// LoadAvg1 float64 `json:"l1,omitempty" cbor:"23,keyasint,omitempty"` // LoadAvg1 float64 `json:"l1,omitempty" cbor:"23,keyasint,omitempty"`
@@ -44,7 +42,7 @@ type Stats struct {
MaxBandwidth [2]uint64 `json:"bm,omitzero" cbor:"-"` // [sent bytes, recv bytes] MaxBandwidth [2]uint64 `json:"bm,omitzero" cbor:"-"` // [sent bytes, recv bytes]
// TODO: remove other load fields in future release in favor of load avg array // TODO: remove other load fields in future release in favor of load avg array
LoadAvg [3]float64 `json:"la,omitempty" cbor:"28,keyasint"` LoadAvg [3]float64 `json:"la,omitempty" cbor:"28,keyasint"`
Battery [2]uint8 `json:"bat,omitzero" cbor:"29,keyasint,omitzero"` // [percent, charge state] Battery [2]uint8 `json:"bat,omitzero" cbor:"29,keyasint,omitzero"` // [percent, charge state, current]
NetworkInterfaces map[string][4]uint64 `json:"ni,omitempty" cbor:"31,keyasint,omitempty"` // [upload bytes, download bytes, total upload, total download] NetworkInterfaces map[string][4]uint64 `json:"ni,omitempty" cbor:"31,keyasint,omitempty"` // [upload bytes, download bytes, total upload, total download]
DiskIO [2]uint64 `json:"dio,omitzero" cbor:"32,keyasint,omitzero"` // [read bytes, write bytes] DiskIO [2]uint64 `json:"dio,omitzero" cbor:"32,keyasint,omitzero"` // [read bytes, write bytes]
MaxDiskIO [2]uint64 `json:"diom,omitzero" cbor:"-"` // [max read bytes, max write bytes] MaxDiskIO [2]uint64 `json:"diom,omitzero" cbor:"-"` // [max read bytes, max write bytes]
-37
View File
@@ -1,37 +0,0 @@
package system
import (
"encoding/json"
"testing"
"github.com/fxamacker/cbor/v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestStatsBatteryTransport(t *testing.T) {
stats := Stats{Battery: [2]uint8{0, 1}, Batteries: map[string]uint8{"Primary": 0, "Mouse": 75}}
jsonData, err := json.Marshal(stats)
require.NoError(t, err)
var jsonPayload map[string]any
require.NoError(t, json.Unmarshal(jsonData, &jsonPayload))
assert.Equal(t, []any{float64(0), float64(1)}, jsonPayload["bat"])
assert.Equal(t, map[string]any{"Primary": float64(0), "Mouse": float64(75)}, jsonPayload["bats"])
cborData, err := cbor.Marshal(stats)
require.NoError(t, err)
var decoded Stats
require.NoError(t, cbor.Unmarshal(cborData, &decoded))
assert.Equal(t, stats.Battery, decoded.Battery)
assert.Equal(t, stats.Batteries, decoded.Batteries)
}
func TestStatsLegacyBatteryPayload(t *testing.T) {
data, err := json.Marshal(Stats{Battery: [2]uint8{50, 4}})
require.NoError(t, err)
var payload map[string]any
require.NoError(t, json.Unmarshal(data, &payload))
assert.Contains(t, payload, "bat")
assert.NotContains(t, payload, "bats")
}
-43
View File
@@ -1,43 +0,0 @@
package ghupdate
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
"strings"
)
func verifyAssetChecksum(path, digest string) error {
algorithm, expectedHex, ok := strings.Cut(digest, ":")
if !ok || algorithm == "" || expectedHex == "" {
return fmt.Errorf("invalid release digest %q", digest)
}
if !strings.EqualFold(algorithm, "sha256") {
return fmt.Errorf("unsupported release digest algorithm %q", algorithm)
}
expected, err := hex.DecodeString(expectedHex)
if err != nil || len(expected) != sha256.Size {
return fmt.Errorf("invalid SHA-256 release digest %q", digest)
}
file, err := os.Open(path)
if err != nil {
return fmt.Errorf("failed to open release for checksum verification: %w", err)
}
defer file.Close()
hash := sha256.New()
if _, err := io.Copy(hash, file); err != nil {
return fmt.Errorf("failed to calculate release checksum: %w", err)
}
actual := hash.Sum(nil)
if !bytes.Equal(actual, expected) {
return fmt.Errorf("release checksum mismatch: expected %s, got %s", expectedHex, hex.EncodeToString(actual))
}
return nil
}
-57
View File
@@ -1,57 +0,0 @@
package ghupdate
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestVerifyAssetChecksum(t *testing.T) {
path := filepath.Join(t.TempDir(), "asset")
if err := os.WriteFile(path, []byte("beszel release asset"), 0600); err != nil {
t.Fatal(err)
}
tests := []struct {
name string
digest string
wantErr string
}{
{
name: "valid",
digest: "sha256:2316f86af2c3af2f0ef595ad5359cdd19b329d4829a5425460ca9ffbf92671ab",
},
{
name: "mismatch",
digest: "sha256:0316f86af2c3af2f0ef595ad5359cdd19b329d4829a5425460ca9ffbf92671ab",
wantErr: "checksum mismatch",
},
{
name: "malformed",
digest: "sha256:not-a-checksum",
wantErr: "invalid SHA-256",
},
{
name: "missing",
wantErr: "invalid release digest",
},
{
name: "unsupported algorithm",
digest: "sha512:2316f86af2c3af2f0ef595ad5359cdd19b329d4829a5425460ca9ffbf92671ab",
wantErr: "unsupported",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := verifyAssetChecksum(path, tt.digest)
if tt.wantErr == "" && err != nil {
t.Fatalf("expected checksum to verify, got %v", err)
}
if tt.wantErr != "" && (err == nil || !strings.Contains(err.Error(), tt.wantErr)) {
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
}
})
}
}
+11 -19
View File
@@ -46,23 +46,18 @@ func extractTarGz(srcPath, destDir string) error {
return err return err
} }
path, err := archivePath(destDir, header.Name)
if err != nil {
return err
}
if header.Typeflag == tar.TypeDir { if header.Typeflag == tar.TypeDir {
if err := os.MkdirAll(path, 0755); err != nil { if err := os.MkdirAll(filepath.Join(destDir, header.Name), 0755); err != nil {
return err return err
} }
continue continue
} }
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { if err := os.MkdirAll(filepath.Dir(filepath.Join(destDir, header.Name)), 0755); err != nil {
return err return err
} }
outFile, err := os.Create(path) outFile, err := os.Create(filepath.Join(destDir, header.Name))
if err != nil { if err != nil {
return err return err
} }
@@ -77,14 +72,6 @@ func extractTarGz(srcPath, destDir string) error {
return nil return nil
} }
// archivePath returns a path within destDir, rejecting path traversal entries.
func archivePath(destDir, name string) (string, error) {
if !filepath.IsLocal(name) {
return "", fmt.Errorf("invalid file path: %q", name)
}
return filepath.Join(destDir, name), nil
}
// extractZip extracts the zip archive at "src" to "dest". // extractZip extracts the zip archive at "src" to "dest".
// //
// Note that only dirs and regular files will be extracted. // Note that only dirs and regular files will be extracted.
@@ -97,6 +84,9 @@ func extractZip(src, dest string) error {
} }
defer zr.Close() defer zr.Close()
// normalize dest path to check later for Zip Slip
dest = filepath.Clean(dest) + string(os.PathSeparator)
for _, f := range zr.File { for _, f := range zr.File {
err := extractFile(f, dest) err := extractFile(f, dest)
if err != nil { if err != nil {
@@ -110,9 +100,11 @@ func extractZip(src, dest string) error {
// extractFile extracts the provided zipFile into "basePath/zipFileName" path, // extractFile extracts the provided zipFile into "basePath/zipFileName" path,
// creating all the necessary path directories. // creating all the necessary path directories.
func extractFile(zipFile *zip.File, basePath string) error { func extractFile(zipFile *zip.File, basePath string) error {
path, err := archivePath(basePath, zipFile.Name) path := filepath.Join(basePath, zipFile.Name)
if err != nil {
return err // check for Zip Slip
if !strings.HasPrefix(path, basePath) {
return fmt.Errorf("invalid file path: %s", path)
} }
r, err := zipFile.Open() r, err := zipFile.Open()
+4 -16
View File
@@ -135,33 +135,21 @@ func (p *updater) update() (updated bool, err error) {
return false, err return false, err
} }
if err := os.MkdirAll(p.config.DataDir, 0755); err != nil { releaseDir := filepath.Join(p.config.DataDir, ".beszel_update")
return false, fmt.Errorf("failed to create update data directory: %w", err)
}
releaseDir, err := os.MkdirTemp(p.config.DataDir, ".beszel_update-")
if err != nil {
return false, fmt.Errorf("failed to create update directory: %w", err)
}
defer os.RemoveAll(releaseDir) defer os.RemoveAll(releaseDir)
ColorPrintf(ColorYellow, "Downloading %s...", asset.Name) ColorPrintf(ColorYellow, "Downloading %s...", asset.Name)
// download the release asset // download the release asset
assetPath, err := archivePath(releaseDir, asset.Name) assetPath := filepath.Join(releaseDir, asset.Name)
if err != nil {
return false, err
}
if err := downloadFile(p.config.Context, p.config.HttpClient, asset.DownloadUrl, assetPath, p.config.UseMirror); err != nil { if err := downloadFile(p.config.Context, p.config.HttpClient, asset.DownloadUrl, assetPath, p.config.UseMirror); err != nil {
return false, err return false, err
} }
ColorPrint(ColorYellow, "Verifying checksum...")
if err := verifyAssetChecksum(assetPath, asset.Digest); err != nil {
return false, err
}
ColorPrintf(ColorYellow, "Extracting %s...", asset.Name) ColorPrintf(ColorYellow, "Extracting %s...", asset.Name)
extractDir := filepath.Join(releaseDir, "extracted") extractDir := filepath.Join(releaseDir, "extracted_"+asset.Name)
defer os.RemoveAll(extractDir)
// Extract the archive (automatically detects format) // Extract the archive (automatically detects format)
if err := extract(assetPath, extractDir); err != nil { if err := extract(assetPath, extractDir); err != nil {
-59
View File
@@ -1,9 +1,6 @@
package ghupdate package ghupdate
import ( import (
"archive/tar"
"compress/gzip"
"os"
"path/filepath" "path/filepath"
"testing" "testing"
) )
@@ -46,59 +43,3 @@ func TestExtractFailure(t *testing.T) {
t.Fatal("Expected Extract to fail due to missing tar.gz file") t.Fatal("Expected Extract to fail due to missing tar.gz file")
} }
} }
func TestArchivePath(t *testing.T) {
destDir := t.TempDir()
for _, name := range []string{
"",
"..",
filepath.Join("..", "file"),
filepath.Join("dir", "..", "..", "file"),
string(os.PathSeparator) + filepath.Join("tmp", "file"),
} {
if _, err := archivePath(destDir, name); err == nil {
t.Errorf("expected %q to be rejected", name)
}
}
name := filepath.Join("dir", "file")
if path, err := archivePath(destDir, name); err != nil || path != filepath.Join(destDir, name) {
t.Errorf("archivePath(%q) = %q, %v", name, path, err)
}
}
func TestExtractTarGzRejectsPathTraversal(t *testing.T) {
testDir := t.TempDir()
archivePath := filepath.Join(testDir, "malicious.tar.gz")
destDir := filepath.Join(testDir, "extract")
escapedPath := filepath.Join(testDir, "escaped")
archive, err := os.Create(archivePath)
if err != nil {
t.Fatal(err)
}
gz := gzip.NewWriter(archive)
tw := tar.NewWriter(gz)
if err := tw.WriteHeader(&tar.Header{Name: "../escaped", Mode: 0600, Size: 1}); err != nil {
t.Fatal(err)
}
if _, err := tw.Write([]byte("x")); err != nil {
t.Fatal(err)
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
if err := gz.Close(); err != nil {
t.Fatal(err)
}
if err := archive.Close(); err != nil {
t.Fatal(err)
}
if err := extract(archivePath, destDir); err == nil {
t.Fatal("expected path traversal archive to be rejected")
}
if _, err := os.Stat(escapedPath); !os.IsNotExist(err) {
t.Fatalf("path traversal wrote %s", escapedPath)
}
}
-1
View File
@@ -8,7 +8,6 @@ import (
type releaseAsset struct { type releaseAsset struct {
Name string `json:"name"` Name string `json:"name"`
DownloadUrl string `json:"browser_download_url"` DownloadUrl string `json:"browser_download_url"`
Digest string `json:"digest"`
Id int `json:"id"` Id int `json:"id"`
Size int `json:"size"` Size int `json:"size"`
} }
-3
View File
@@ -317,9 +317,6 @@ func getRealIP(r *http.Request) string {
if ip := r.Header.Get("CF-Connecting-IP"); ip != "" { if ip := r.Header.Get("CF-Connecting-IP"); ip != "" {
return ip return ip
} }
if ip := r.Header.Get("X-Real-IP"); ip != "" {
return ip
}
if ip := r.Header.Get("X-Forwarded-For"); ip != "" { if ip := r.Header.Get("X-Forwarded-For"); ip != "" {
// X-Forwarded-For can contain a comma-separated list: "client_ip, proxy1, proxy2" // X-Forwarded-For can contain a comma-separated list: "client_ip, proxy1, proxy2"
// Take the first one // Take the first one
-18
View File
@@ -1796,24 +1796,6 @@ func TestGetRealIP(t *testing.T) {
remoteAddr: "127.0.0.1:12345", remoteAddr: "127.0.0.1:12345",
expectedIP: "192.168.1.8", expectedIP: "192.168.1.8",
}, },
{
name: "X-Real-IP header",
headers: map[string]string{"X-Real-IP": "10.8.0.4"},
remoteAddr: "172.21.0.1:12345",
expectedIP: "10.8.0.4",
},
{
name: "X-Real-IP takes precedence over X-Forwarded-For",
headers: map[string]string{"X-Real-IP": "10.8.0.4", "X-Forwarded-For": "10.8.0.5"},
remoteAddr: "172.21.0.1:12345",
expectedIP: "10.8.0.4",
},
{
name: "CF-Connecting-IP takes precedence over X-Real-IP",
headers: map[string]string{"CF-Connecting-IP": "1.2.3.4", "X-Real-IP": "10.8.0.4"},
remoteAddr: "172.21.0.1:12345",
expectedIP: "1.2.3.4",
},
} }
for _, tc := range testCases { for _, tc := range testCases {
+1 -1
View File
@@ -64,7 +64,7 @@ func createConfigTestFingerprint(app core.App, systemID, token, fingerprint stri
// TestConfigSyncWithTokens tests the config.SyncSystems function with various token scenarios // TestConfigSyncWithTokens tests the config.SyncSystems function with various token scenarios
func TestConfigSyncWithTokens(t *testing.T) { func TestConfigSyncWithTokens(t *testing.T) {
testHub, err := tests.NewTestHub(t.TempDir()) testHub, err := tests.NewTestHub()
require.NoError(t, err) require.NoError(t, err)
defer testHub.Cleanup() defer testHub.Cleanup()
-56
View File
@@ -1,56 +0,0 @@
//go:build testing
package systems
import (
"errors"
"testing"
"testing/synctest"
"time"
"github.com/stretchr/testify/assert"
)
// TestRunWithTimeout covers the guard added for issue #2041: the per-system SSH
// data exchange must never block the updater indefinitely on a dead connection.
func TestRunWithTimeout(t *testing.T) {
t.Run("returns the operation result when it completes before the timeout", func(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
wantErr := errors.New("boom")
onTimeoutCalled := false
retry, err := runWithTimeout(10*time.Second, func() (bool, error) {
return true, wantErr
}, func() { onTimeoutCalled = true })
assert.True(t, retry, "should return the operation's retry value")
assert.Equal(t, wantErr, err, "should return the operation's error")
assert.False(t, onTimeoutCalled, "onTimeout must not fire when the op completes")
})
})
t.Run("times out and tears down the connection when the op blocks", func(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
// unblock simulates a half-open connection: the op is stuck reading a
// response that never arrives until the connection is torn down.
unblock := make(chan struct{})
onTimeoutCalled := false
start := time.Now()
retry, err := runWithTimeout(5*time.Second, func() (bool, error) {
<-unblock
return false, nil
}, func() {
onTimeoutCalled = true
close(unblock) // tearing down the connection releases the blocked read
})
assert.Equal(t, 5*time.Second, time.Since(start), "should return exactly at the timeout")
assert.True(t, retry, "a timeout should be retryable so the next tick re-dials")
assert.Error(t, err, "a timeout must surface an error so the system is set down")
assert.True(t, onTimeoutCalled, "onTimeout must fire so the dead connection is closed")
synctest.Wait() // ensure the released op goroutine exits cleanly
})
})
}
+6 -93
View File
@@ -348,9 +348,6 @@ func (sys *System) getRecord(app core.App) (*core.Record, error) {
record, err := app.FindRecordById("systems", sys.Id) record, err := app.FindRecordById("systems", sys.Id)
if err != nil || record == nil { if err != nil || record == nil {
_ = sys.manager.RemoveSystem(sys.Id) _ = sys.manager.RemoveSystem(sys.Id)
if err == nil {
err = fmt.Errorf("system record %s not found", sys.Id)
}
return nil, err return nil, err
} }
return record, nil return record, nil
@@ -380,16 +377,10 @@ func (sys *System) HasUser(app core.App, user *core.Record) bool {
// setDown marks a system as down in the database. // setDown marks a system as down in the database.
// It takes the original error that caused the system to go down and returns any error // It takes the original error that caused the system to go down and returns any error
// encountered during the process of updating the system status. // encountered during the process of updating the system status.
// It is a no-op if the system's context has been cancelled.
func (sys *System) setDown(originalError error) error { func (sys *System) setDown(originalError error) error {
if sys.Status == down || sys.Status == paused { if sys.Status == down || sys.Status == paused {
return nil return nil
} }
// the updater can race shutdown, and the app may already be disposed by the
// time we get here, so don't touch the database once the context is cancelled
if sys.ctx != nil && sys.ctx.Err() != nil {
return sys.ctx.Err()
}
record, err := sys.getRecord(sys.manager.hub) record, err := sys.getRecord(sys.manager.hub)
if err != nil { if err != nil {
return err return err
@@ -397,7 +388,6 @@ func (sys *System) setDown(originalError error) error {
if originalError != nil { if originalError != nil {
sys.manager.hub.Logger().Error("System down", "system", record.GetString("name"), "err", originalError) sys.manager.hub.Logger().Error("System down", "system", record.GetString("name"), "err", originalError)
} }
sys.detailsFetched.Store(false)
record.Set("status", down) record.Set("status", down)
return sys.manager.hub.SaveNoValidate(record) return sys.manager.hub.SaveNoValidate(record)
} }
@@ -541,16 +531,11 @@ func (sys *System) FetchSystemdInfoFromAgent(serviceName string) (systemd.Servic
return result, err return result, err
} }
// FetchSmartDataFromAgent fetches SMART data from the agent. // FetchSmartDataFromAgent fetches SMART data from the agent
func (sys *System) FetchSmartDataFromAgent() (smart.SmartDataResponse, error) { func (sys *System) FetchSmartDataFromAgent() (map[string]smart.SmartData, error) {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel() defer cancel()
if sys.agentVersion.LT(beszel.MinVersionAgentResponse) { var result map[string]smart.SmartData
var data map[string]smart.SmartData
err := sys.request(ctx, common.GetSmartData, nil, &data)
return smart.SmartDataResponse{Data: data}, err
}
var result smart.SmartDataResponse
err := sys.request(ctx, common.GetSmartData, nil, &result) err := sys.request(ctx, common.GetSmartData, nil, &result)
return result, err return result, err
} }
@@ -638,17 +623,10 @@ func (sys *System) runSSHOperation(timeout time.Duration, retries int, operation
continue continue
} }
// Bound the whole operation. A half-open TCP connection (a dead peer that retry, opErr := func() (bool, error) {
// never sends RST/FIN) or a wedged agent that accepts the session but
// never writes a response would otherwise block the read forever. Because
// StartUpdater runs update() synchronously on its ticker, that stalls the
// per-system updater indefinitely with no error and no re-dial until the
// hub is restarted (issue #2041). On timeout we tear down the connection
// so the blocked read unwinds and the system is re-dialed on the next tick.
retry, opErr := runWithTimeout(sshOperationTimeout, func() (bool, error) {
defer session.Close() defer session.Close()
return operation(session) return operation(session)
}, sys.closeSSHConnection) }()
if opErr == nil { if opErr == nil {
return nil return nil
@@ -667,43 +645,6 @@ func (sys *System) runSSHOperation(timeout time.Duration, retries int, operation
return fmt.Errorf("ssh operation failed") return fmt.Errorf("ssh operation failed")
} }
// sshOperationTimeout bounds a single SSH data exchange (send request, read
// response, wait for the remote command to exit). It is more generous than the
// session-creation timeout to tolerate briefly slow agents, but is kept well
// under the collection interval so a stalled connection is detected and
// re-dialed within one cycle (see issue #2041).
const sshOperationTimeout = 20 * time.Second
// runWithTimeout runs op in a goroutine and returns its result, or, if op does
// not finish within timeout, calls onTimeout (used to tear down the connection
// so a blocked op can unwind) and returns a retryable timeout error. This
// guarantees the caller can never block indefinitely on a dead SSH connection.
func runWithTimeout(timeout time.Duration, op func() (bool, error), onTimeout func()) (retry bool, err error) {
type opResult struct {
retry bool
err error
}
// Buffered so the op goroutine never leaks even when we return on timeout.
done := make(chan opResult, 1)
go func() {
r, e := op()
done <- opResult{retry: r, err: e}
}()
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case res := <-done:
return res.retry, res.err
case <-timer.C:
if onTimeout != nil {
onTimeout()
}
return true, fmt.Errorf("ssh operation timed out after %s", timeout)
}
}
// createSSHClient creates a new SSH client for the system // createSSHClient creates a new SSH client for the system
func (s *System) createSSHClient() error { func (s *System) createSSHClient() error {
if s.manager.sshConfig == nil { if s.manager.sshConfig == nil {
@@ -719,7 +660,7 @@ func (s *System) createSSHClient() error {
host = net.JoinHostPort(host, s.Port) host = net.JoinHostPort(host, s.Port)
} }
var err error var err error
s.client, err = dialSSHWithKeepAlive(network, host, s.manager.sshConfig) s.client, err = ssh.Dial(network, host, s.manager.sshConfig)
if err != nil { if err != nil {
return err return err
} }
@@ -728,34 +669,6 @@ func (s *System) createSSHClient() error {
return nil return nil
} }
// sshKeepAliveInterval is the TCP keep-alive idle interval for SSH connections
// to agents. Enabling OS-level keep-alives lets the hub eventually detect a
// dead peer on an otherwise idle connection instead of trusting it forever.
// This is a backstop for genuine network death; an application-level wedge
// (agent process hung while its kernel keeps ACKing) is caught by the
// per-operation timeout in runSSHOperation instead (see issue #2041).
const sshKeepAliveInterval = 30 * time.Second
// dialSSHWithKeepAlive dials an SSH connection like ssh.Dial, but enables TCP
// keep-alive on the underlying connection so half-open connections are
// eventually detected by the operating system.
func dialSSHWithKeepAlive(network, addr string, config *ssh.ClientConfig) (*ssh.Client, error) {
dialer := net.Dialer{
Timeout: config.Timeout,
KeepAlive: sshKeepAliveInterval,
}
conn, err := dialer.Dial(network, addr)
if err != nil {
return nil, err
}
sshConn, chans, reqs, err := ssh.NewClientConn(conn, addr, config)
if err != nil {
_ = conn.Close()
return nil, err
}
return ssh.NewClient(sshConn, chans, reqs), nil
}
// createSessionWithTimeout creates a new SSH session with a timeout to avoid hanging // createSessionWithTimeout creates a new SSH session with a timeout to avoid hanging
// in case of network issues // in case of network issues
func (sys *System) createSessionWithTimeout(timeout time.Duration) (*ssh.Session, error) { func (sys *System) createSessionWithTimeout(timeout time.Duration) (*ssh.Session, error) {
+14 -45
View File
@@ -7,7 +7,6 @@ import (
"time" "time"
"github.com/henrygd/beszel/internal/entities/smart" "github.com/henrygd/beszel/internal/entities/smart"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
) )
@@ -18,13 +17,13 @@ type smartFetchState struct {
// FetchAndSaveSmartDevices fetches SMART data from the agent and saves it to the database // FetchAndSaveSmartDevices fetches SMART data from the agent and saves it to the database
func (sys *System) FetchAndSaveSmartDevices() error { func (sys *System) FetchAndSaveSmartDevices() error {
response, err := sys.FetchSmartDataFromAgent() smartData, err := sys.FetchSmartDataFromAgent()
if err != nil { if err != nil {
sys.recordSmartFetchResult(err, 0) sys.recordSmartFetchResult(err, 0)
return err return err
} }
err = sys.saveSmartDevices(response.Data, response.Complete) err = sys.saveSmartDevices(smartData)
sys.recordSmartFetchResult(err, len(response.Data)) sys.recordSmartFetchResult(err, len(smartData))
return err return err
} }
@@ -62,9 +61,8 @@ func (sys *System) smartFetchInterval() time.Duration {
return time.Hour return time.Hour
} }
// saveSmartDevices saves SMART device data and, after a complete refresh, // saveSmartDevices saves SMART device data to the smart_devices collection
// removes rows for devices that are no longer reported. func (sys *System) saveSmartDevices(smartData map[string]smart.SmartData) error {
func (sys *System) saveSmartDevices(smartData map[string]smart.SmartData, complete bool) error {
if len(smartData) == 0 { if len(smartData) == 0 {
return nil return nil
} }
@@ -75,49 +73,20 @@ func (sys *System) saveSmartDevices(smartData map[string]smart.SmartData, comple
return err return err
} }
currentIDs := make(map[string]struct{}, len(smartData)) for deviceKey, device := range smartData {
for deviceKey := range smartData { if err := sys.upsertSmartDeviceRecord(collection, deviceKey, device); err != nil {
currentIDs[makeStableHashId(sys.Id, deviceKey)] = struct{}{} return err
}
} }
err = hub.RunInTransaction(func(txApp core.App) error { return nil
if complete {
existing, err := txApp.FindRecordsByFilter(
collection,
"system = {:system}",
"",
0,
0,
dbx.Params{"system": sys.Id},
)
if err != nil {
return err
}
for _, record := range existing {
if _, ok := currentIDs[record.Id]; ok {
continue
}
if err := txApp.Delete(record); err != nil {
return err
}
}
}
for deviceKey, device := range smartData {
if err := sys.upsertSmartDeviceRecord(txApp, collection, deviceKey, device); err != nil {
return err
}
}
return nil
})
return err
} }
func (sys *System) upsertSmartDeviceRecord(app core.App, collection *core.Collection, deviceKey string, device smart.SmartData) error { func (sys *System) upsertSmartDeviceRecord(collection *core.Collection, deviceKey string, device smart.SmartData) error {
hub := sys.manager.hub
recordID := makeStableHashId(sys.Id, deviceKey) recordID := makeStableHashId(sys.Id, deviceKey)
record, err := app.FindRecordById(collection, recordID) record, err := hub.FindRecordById(collection, recordID)
if err != nil { if err != nil {
if !errors.Is(err, sql.ErrNoRows) { if !errors.Is(err, sql.ErrNoRows) {
return err return err
@@ -145,7 +114,7 @@ func (sys *System) upsertSmartDeviceRecord(app core.App, collection *core.Collec
record.Set("cycles", powerCycles) record.Set("cycles", powerCycles)
record.Set("attributes", device.Attributes) record.Set("attributes", device.Attributes)
return app.SaveNoValidate(record) return hub.SaveNoValidate(record)
} }
// extractPowerMetrics extracts power on hours and power cycles from SMART attributes // extractPowerMetrics extracts power on hours and power cycles from SMART attributes
-135
View File
@@ -7,53 +7,10 @@ import (
"testing" "testing"
"time" "time"
"github.com/henrygd/beszel/internal/entities/smart"
esystem "github.com/henrygd/beszel/internal/entities/system"
"github.com/henrygd/beszel/internal/hub/expirymap" "github.com/henrygd/beszel/internal/hub/expirymap"
_ "github.com/henrygd/beszel/internal/migrations"
"github.com/pocketbase/pocketbase/core"
pbtests "github.com/pocketbase/pocketbase/tests"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/ssh"
) )
// stubHub implements hubLike using a plain pocketbase test app, so
// smart-device DB tests can run in-package without an import cycle to
// internal/hub (which imports this package).
type stubHub struct{ core.App }
func (stubHub) GetSSHKey(dataDir string) (ssh.Signer, error) { return nil, nil }
func (stubHub) HandleSystemAlerts(systemRecord *core.Record, data *esystem.CombinedData) error {
return nil
}
func (stubHub) HandleStatusAlerts(status string, systemRecord *core.Record) error { return nil }
func (stubHub) CancelPendingStatusAlerts(systemID string) {}
// newTestSystemWithHub creates a System backed by a real (temp) database, along
// with a matching "systems" record, for tests that need to exercise DB reads/writes.
func newTestSystemWithHub(t *testing.T) (*System, *pbtests.TestApp) {
t.Helper()
testApp, err := pbtests.NewTestApp(t.TempDir())
require.NoError(t, err)
t.Cleanup(testApp.Cleanup)
sm := &SystemManager{hub: stubHub{testApp}, smartFetchMap: expirymap.New[smartFetchState](time.Hour)}
t.Cleanup(sm.smartFetchMap.StopCleaner)
col, err := testApp.FindCachedCollectionByNameOrId("systems")
require.NoError(t, err)
systemRecord := core.NewRecord(col)
systemRecord.Set("name", "test-system")
systemRecord.Set("host", "127.0.0.1")
systemRecord.Set("port", "45876")
require.NoError(t, testApp.SaveNoValidate(systemRecord))
sys := &System{Id: systemRecord.Id, manager: sm}
return sys, testApp
}
func TestRecordSmartFetchResult(t *testing.T) { func TestRecordSmartFetchResult(t *testing.T) {
sm := &SystemManager{smartFetchMap: expirymap.New[smartFetchState](time.Hour)} sm := &SystemManager{smartFetchMap: expirymap.New[smartFetchState](time.Hour)}
t.Cleanup(sm.smartFetchMap.StopCleaner) t.Cleanup(sm.smartFetchMap.StopCleaner)
@@ -135,95 +92,3 @@ func TestResetFailedSmartFetchState(t *testing.T) {
_, ok = sm.smartFetchMap.GetOk("system-1") _, ok = sm.smartFetchMap.GetOk("system-1")
assert.True(t, ok, "expected successful smart fetch state to be preserved") assert.True(t, ok, "expected successful smart fetch state to be preserved")
} }
// countSmartDeviceRecords returns the number of smart_devices rows for the given system.
func countSmartDeviceRecords(t *testing.T, app core.App, systemID string) []*core.Record {
t.Helper()
records, err := app.FindAllRecords("smart_devices", nil)
require.NoError(t, err)
var forSystem []*core.Record
for _, r := range records {
if r.GetString("system") == systemID {
forSystem = append(forSystem, r)
}
}
return forSystem
}
func TestSaveSmartDevices_RemovesStaleDevices(t *testing.T) {
sys, testApp := newTestSystemWithHub(t)
// first fetch reports two devices: sda (serial AAA) and sdb (serial BBB)
err := sys.saveSmartDevices(map[string]smart.SmartData{
"AAA": {SerialNumber: "AAA", DiskName: "sda", ModelName: "Disk A"},
"BBB": {SerialNumber: "BBB", DiskName: "sdb", ModelName: "Disk B"},
}, true)
require.NoError(t, err)
records := countSmartDeviceRecords(t, testApp, sys.Id)
require.Len(t, records, 2, "expected both devices to be saved")
var recordA *core.Record
for _, r := range records {
if r.GetString("serial") == "AAA" {
recordA = r
}
}
require.NotNil(t, recordA, "expected to find device AAA")
originalID := recordA.Id
deleteEvents := 0
testApp.OnRecordAfterDeleteSuccess("smart_devices").BindFunc(func(e *core.RecordEvent) error {
deleteEvents++
return e.Next()
})
// A complete refresh confirms that BBB is gone, so remove it through
// PocketBase and notify realtime subscribers.
err = sys.saveSmartDevices(map[string]smart.SmartData{
"AAA": {SerialNumber: "AAA", DiskName: "sda", ModelName: "Disk A", Temperature: 42},
}, true)
require.NoError(t, err)
records = countSmartDeviceRecords(t, testApp, sys.Id)
require.Len(t, records, 1, "expected stale device BBB to be removed")
assert.Equal(t, "AAA", records[0].GetString("serial"))
assert.Equal(t, originalID, records[0].Id, "expected existing device to be updated in place, not recreated")
assert.EqualValues(t, 42, records[0].GetInt("temp"))
assert.Equal(t, 1, deleteEvents, "expected PocketBase delete hooks to run")
}
func TestSaveSmartDevices_IncompleteDataDoesNotRemoveDevices(t *testing.T) {
sys, testApp := newTestSystemWithHub(t)
require.NoError(t, sys.saveSmartDevices(map[string]smart.SmartData{
"AAA": {SerialNumber: "AAA", DiskName: "sda"},
"BBB": {SerialNumber: "BBB", DiskName: "sdb"},
}, true))
// AAA was collected but BBB failed. The response is useful for updating AAA,
// but it is not authoritative enough to remove BBB.
require.NoError(t, sys.saveSmartDevices(map[string]smart.SmartData{
"AAA": {SerialNumber: "AAA", DiskName: "sda", Temperature: 42},
}, false))
assert.Len(t, countSmartDeviceRecords(t, testApp, sys.Id), 2)
recordA, err := testApp.FindRecordById("smart_devices", makeStableHashId(sys.Id, "AAA"))
require.NoError(t, err)
assert.EqualValues(t, 42, recordA.GetInt("temp"))
}
func TestSaveSmartDevices_EmptyDataIsNoop(t *testing.T) {
sys, testApp := newTestSystemWithHub(t)
err := sys.saveSmartDevices(map[string]smart.SmartData{
"AAA": {SerialNumber: "AAA", DiskName: "sda"},
}, true)
require.NoError(t, err)
err = sys.saveSmartDevices(map[string]smart.SmartData{}, true)
require.NoError(t, err)
records := countSmartDeviceRecords(t, testApp, sys.Id)
assert.Len(t, records, 1, "empty fetch result should not delete existing devices")
}
-15
View File
@@ -3,7 +3,6 @@
package systems package systems
import ( import (
"context"
"testing" "testing"
"github.com/henrygd/beszel/internal/entities/system" "github.com/henrygd/beszel/internal/entities/system"
@@ -158,17 +157,3 @@ func TestCombinedData_MigrateDeprecatedFields(t *testing.T) {
} }
}) })
} }
func TestSetDownAfterContextCancelled(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
// manager is nil on purpose: setDown must bail out before touching the app
sys := &System{Status: up, ctx: ctx}
if err := sys.setDown(nil); err != context.Canceled {
t.Fatalf("expected context.Canceled, got %v", err)
}
if sys.Status != up {
t.Fatalf("status should be untouched, got %q", sys.Status)
}
}
+7 -15
View File
@@ -88,23 +88,15 @@ func unmarshalLegacyResponse(resp common.AgentResponse, action common.WebSocketA
*d = *resp.String *d = *resp.String
return nil return nil
case common.GetSmartData: case common.GetSmartData:
switch d := dest.(type) { d, ok := dest.(*map[string]smart.SmartData)
case *map[string]smart.SmartData: if !ok {
if resp.SmartData == nil {
return errors.New("no SMART data in response")
}
*d = resp.SmartData
return nil
case *smart.SmartDataResponse:
if resp.SmartData == nil {
return errors.New("no SMART data in response")
}
d.Data = resp.SmartData
d.Complete = resp.SmartComplete
return nil
default:
return fmt.Errorf("unexpected dest type for GetSmartData: %T", dest) return fmt.Errorf("unexpected dest type for GetSmartData: %T", dest)
} }
if resp.SmartData == nil {
return errors.New("no SMART data in response")
}
*d = resp.SmartData
return nil
case common.GetSystemdInfo: case common.GetSystemdInfo:
d, ok := dest.(*systemd.ServiceDetails) d, ok := dest.(*systemd.ServiceDetails)
if !ok { if !ok {
-33
View File
@@ -1,33 +0,0 @@
package transport
import (
"testing"
"github.com/henrygd/beszel/internal/common"
"github.com/henrygd/beszel/internal/entities/smart"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestUnmarshalSmartDataResponse(t *testing.T) {
for _, test := range []struct {
name string
complete bool
}{
{name: "complete response", complete: true},
{name: "older agent defaults to incomplete", complete: false},
} {
t.Run(test.name, func(t *testing.T) {
response := common.AgentResponse{
SmartData: map[string]smart.SmartData{
"AAA": {SerialNumber: "AAA"},
},
SmartComplete: test.complete,
}
var result smart.SmartDataResponse
require.NoError(t, UnmarshalResponse(response, common.GetSmartData, &result))
assert.Equal(t, test.complete, result.Complete)
assert.Equal(t, "AAA", result.Data["AAA"].SerialNumber)
})
}
}
+5 -51
View File
@@ -3,7 +3,7 @@ package records
import ( import (
"encoding/json" "encoding/json"
"log/slog" "log"
"math" "math"
"time" "time"
@@ -68,18 +68,15 @@ func (rm *RecordManager) CreateLongerRecords() {
}, },
} }
// wrap the operations in a transaction // wrap the operations in a transaction
// Pocketbase cron does not handle errors, log them here.
rm.app.RunInTransaction(func(txApp core.App) error { rm.app.RunInTransaction(func(txApp core.App) error {
var err error var err error
collections := [2]*core.Collection{} collections := [2]*core.Collection{}
collections[0], err = txApp.FindCachedCollectionByNameOrId("system_stats") collections[0], err = txApp.FindCachedCollectionByNameOrId("system_stats")
if err != nil { if err != nil {
slog.Error("Error finding cached collection using system stats:", "err", err)
return err return err
} }
collections[1], err = txApp.FindCachedCollectionByNameOrId("container_stats") collections[1], err = txApp.FindCachedCollectionByNameOrId("container_stats")
if err != nil { if err != nil {
slog.Error("Error finding cached collection using container stats:", "err", err)
return err return err
} }
var systems RecordIds var systems RecordIds
@@ -145,7 +142,7 @@ func (rm *RecordManager) CreateLongerRecords() {
longerRecord.Set("stats", rm.AverageContainerStats(db, recordIds)) longerRecord.Set("stats", rm.AverageContainerStats(db, recordIds))
} }
if err := txApp.SaveNoValidate(longerRecord); err != nil { if err := txApp.SaveNoValidate(longerRecord); err != nil {
slog.Error("failed to save longer record", "err", err) log.Println("failed to save longer record", "err", err)
} }
} }
} }
@@ -186,16 +183,11 @@ func AverageSystemStatsSlice(records []system.Stats) system.Stats {
// necessary because uint8 is not big enough for the sum // necessary because uint8 is not big enough for the sum
batterySum := 0 batterySum := 0
batteryCount := 0
batterySums := make(map[string]uint64)
batteryCounts := make(map[string]uint64)
// accumulate per-core usage across records // accumulate per-core usage across records
var cpuCoresSums []uint64 var cpuCoresSums []uint64
// accumulate cpu breakdown [user, system, iowait, steal, idle] // accumulate cpu breakdown [user, system, iowait, steal, idle]
var cpuBreakdownSums []float64 var cpuBreakdownSums []float64
tempCount := float64(0) tempCount := float64(0)
var fanSums map[string]uint64
fanCount := uint64(0)
// Accumulate totals // Accumulate totals
for i := range records { for i := range records {
@@ -235,15 +227,8 @@ func AverageSystemStatsSlice(records []system.Stats) system.Stats {
for i := range stats.DiskIoStats { for i := range stats.DiskIoStats {
sum.DiskIoStats[i] += stats.DiskIoStats[i] sum.DiskIoStats[i] += stats.DiskIoStats[i]
} }
if hasBattery(stats.Battery, stats.Batteries) { batterySum += int(stats.Battery[0])
batterySum += int(stats.Battery[0]) sum.Battery[1] = stats.Battery[1]
batteryCount++
sum.Battery[1] = stats.Battery[1]
}
for name, percent := range stats.Batteries {
batterySums[name] += uint64(percent)
batteryCounts[name]++
}
// accumulate per-core usage if present // accumulate per-core usage if present
if stats.CpuCoresUsage != nil { if stats.CpuCoresUsage != nil {
@@ -294,17 +279,6 @@ func AverageSystemStatsSlice(records []system.Stats) system.Stats {
} }
} }
// Accumulate fan speeds
if stats.Fans != nil {
if fanSums == nil {
fanSums = make(map[string]uint64, len(stats.Fans))
}
fanCount++
for key, value := range stats.Fans {
fanSums[key] += uint64(value)
}
}
// Accumulate extra filesystem stats // Accumulate extra filesystem stats
if stats.ExtraFs != nil { if stats.ExtraFs != nil {
if sum.ExtraFs == nil { if sum.ExtraFs == nil {
@@ -389,15 +363,7 @@ func AverageSystemStatsSlice(records []system.Stats) system.Stats {
sum.LoadAvg[2] = twoDecimals(sum.LoadAvg[2] / count) sum.LoadAvg[2] = twoDecimals(sum.LoadAvg[2] / count)
sum.Bandwidth[0] = sum.Bandwidth[0] / uint64(count) sum.Bandwidth[0] = sum.Bandwidth[0] / uint64(count)
sum.Bandwidth[1] = sum.Bandwidth[1] / uint64(count) sum.Bandwidth[1] = sum.Bandwidth[1] / uint64(count)
if batteryCount > 0 { sum.Battery[0] = uint8(batterySum / int(count))
sum.Battery[0] = uint8(batterySum / batteryCount)
}
if len(batterySums) > 0 {
sum.Batteries = make(map[string]uint8, len(batterySums))
for name, total := range batterySums {
sum.Batteries[name] = uint8(total / batteryCounts[name])
}
}
// Average network interfaces // Average network interfaces
if sum.NetworkInterfaces != nil { if sum.NetworkInterfaces != nil {
@@ -418,14 +384,6 @@ func AverageSystemStatsSlice(records []system.Stats) system.Stats {
} }
} }
// Average fan speeds
if fanSums != nil && fanCount > 0 {
sum.Fans = make(map[string]uint16, len(fanSums))
for key, value := range fanSums {
sum.Fans[key] = uint16(value / fanCount)
}
}
// Average extra filesystem stats // Average extra filesystem stats
if sum.ExtraFs != nil { if sum.ExtraFs != nil {
for key := range sum.ExtraFs { for key := range sum.ExtraFs {
@@ -485,10 +443,6 @@ func AverageSystemStatsSlice(records []system.Stats) system.Stats {
return sum return sum
} }
func hasBattery(legacy [2]uint8, batteries map[string]uint8) bool {
return legacy != [2]uint8{} || len(batteries) > 0
}
// Calculate the average stats of a list of container_stats records // Calculate the average stats of a list of container_stats records
func (rm *RecordManager) AverageContainerStats(db dbx.Builder, records RecordIds) []container.Stats { func (rm *RecordManager) AverageContainerStats(db dbx.Builder, records RecordIds) []container.Stats {
allStats := make([][]container.Stats, 0, len(records)) allStats := make([][]container.Stats, 0, len(records))
@@ -291,28 +291,6 @@ func TestAverageSystemStatsSlice_Temperatures(t *testing.T) {
assert.Equal(t, 80.0, result.Temperatures["gpu"]) assert.Equal(t, 80.0, result.Temperatures["gpu"])
} }
// Tests that fan speeds are averaged and records without fan data are excluded.
func TestAverageSystemStatsSlice_Fans(t *testing.T) {
input := []system.Stats{
{
Fans: map[string]uint16{"cpu": 60_000, "case": 1_000},
},
{
Fans: map[string]uint16{"cpu": 50_000, "case": 2_000},
},
{
// No fan data - should not affect fan averaging
Cpu: 30.0,
},
}
result := records.AverageSystemStatsSlice(input)
require.NotNil(t, result.Fans)
assert.Equal(t, uint16(55_000), result.Fans["cpu"])
assert.Equal(t, uint16(1_500), result.Fans["case"])
}
func TestAverageSystemStatsSlice_NetworkInterfaces(t *testing.T) { func TestAverageSystemStatsSlice_NetworkInterfaces(t *testing.T) {
input := []system.Stats{ input := []system.Stats{
{ {
@@ -602,28 +580,6 @@ func TestAverageSystemStatsSlice_BatteryLastChargeState(t *testing.T) {
assert.Equal(t, uint8(0), result.Battery[1]) // last record's charge state assert.Equal(t, uint8(0), result.Battery[1]) // last record's charge state
} }
func TestAverageSystemStatsSlice_BatteriesIndependentSamples(t *testing.T) {
input := []system.Stats{
{Battery: [2]uint8{80, 4}, Batteries: map[string]uint8{"Primary": 80, "Mouse": 0}},
{Battery: [2]uint8{60, 3}, Batteries: map[string]uint8{"Primary": 60}},
{Battery: [2]uint8{30, 4}, Batteries: map[string]uint8{"Mouse": 40}},
{},
}
result := records.AverageSystemStatsSlice(input)
assert.Equal(t, map[string]uint8{"Primary": 70, "Mouse": 20}, result.Batteries)
assert.Equal(t, uint8(56), result.Battery[0], "representative battery excludes absent samples")
assert.Equal(t, uint8(4), result.Battery[1], "representative state comes from its latest sample")
}
func TestAverageSystemStatsSlice_ZeroRepresentativeBattery(t *testing.T) {
result := records.AverageSystemStatsSlice([]system.Stats{
{Battery: [2]uint8{0, 1}, Batteries: map[string]uint8{"Primary": 0}},
{},
})
assert.Equal(t, [2]uint8{0, 1}, result.Battery)
assert.Equal(t, map[string]uint8{"Primary": 0}, result.Batteries)
}
func TestAverageSystemStatsSlice_ThreeRecordsRounding(t *testing.T) { func TestAverageSystemStatsSlice_ThreeRecordsRounding(t *testing.T) {
input := []system.Stats{ input := []system.Stats{
{Cpu: 10.0, Mem: 8.0}, {Cpu: 10.0, Mem: 8.0},
-1
View File
@@ -12,7 +12,6 @@ import (
// Delete old records // Delete old records
func (rm *RecordManager) DeleteOldRecords() { func (rm *RecordManager) DeleteOldRecords() {
// Pocketbase cron does not handle errors, log them here.
rm.app.RunInTransaction(func(txApp core.App) error { rm.app.RunInTransaction(func(txApp core.App) error {
err := deleteOldSystemStats(txApp) err := deleteOldSystemStats(txApp)
if err != nil { if err != nil {
+5 -1
View File
@@ -9,7 +9,11 @@
"preview": "vite preview", "preview": "vite preview",
"sync": "lingui extract --overwrite && lingui compile", "sync": "lingui extract --overwrite && lingui compile",
"sync_no_compile": "lingui extract --overwrite --clean", "sync_no_compile": "lingui extract --overwrite --clean",
"sync_and_purge": "lingui extract --overwrite --clean && lingui compile" "sync_and_purge": "lingui extract --overwrite --clean && lingui compile",
"format": "biome format --write .",
"lint": "biome lint .",
"check": "biome check .",
"check:fix": "biome check --fix ."
}, },
"dependencies": { "dependencies": {
"@henrygd/queue": "^1.0.7", "@henrygd/queue": "^1.0.7",
@@ -175,8 +175,8 @@ export function UserAuthForm({
*/ */
function redirectToOauthProvider(provider: AuthProviderInfo) { function redirectToOauthProvider(provider: AuthProviderInfo) {
const url = new URL(provider.authURL) const url = new URL(provider.authURL)
url.searchParams.set("redirect_uri", `${window.location.origin}${basePath}`) // url.searchParams.set("redirect_uri", `${window.location.origin}${basePath}`)
localStorage.setItem("provider", JSON.stringify(provider)) sessionStorage.setItem("provider", JSON.stringify(provider))
window.location.href = url.toString() window.location.href = url.toString()
} }
@@ -186,13 +186,12 @@ export function UserAuthForm({
const code = params.get("code") const code = params.get("code")
if (code) { if (code) {
const state = params.get("state") const state = params.get("state")
const provider: AuthProviderInfo = JSON.parse(localStorage.getItem("provider") ?? "{}") const provider: AuthProviderInfo = JSON.parse(sessionStorage.getItem("provider") ?? "{}")
localStorage.removeItem("provider")
window.history.replaceState({}, "", window.location.pathname)
if (!state || provider.state !== state) { if (!state || provider.state !== state) {
showLoginFaliedToast() showLoginFaliedToast()
} else { } else {
setIsOauthLoading(true) setIsOauthLoading(true)
window.history.replaceState({}, "", window.location.pathname)
pb.collection("users") pb.collection("users")
.authWithOAuth2Code(provider.name, code, provider.codeVerifier, `${window.location.origin}${basePath}`) .authWithOAuth2Code(provider.name, code, provider.codeVerifier, `${window.location.origin}${basePath}`)
.then(() => $authenticated.set(pb.authStore.isValid)) .then(() => $authenticated.set(pb.authStore.isValid))
@@ -286,7 +285,7 @@ export function UserAuthForm({
{errors?.passwordConfirm && <p className="px-1 text-xs text-red-600">{errors.passwordConfirm}</p>} {errors?.passwordConfirm && <p className="px-1 text-xs text-red-600">{errors.passwordConfirm}</p>}
</div> </div>
)} )}
<div className="sr-only" aria-hidden="true"> <div className="sr-only">
{/* honeypot */} {/* honeypot */}
<label htmlFor="website">Website</label> <label htmlFor="website">Website</label>
<input <input
@@ -294,7 +293,6 @@ export function UserAuthForm({
type="text" type="text"
name="website" name="website"
tabIndex={-1} tabIndex={-1}
readOnly
autoComplete="off" autoComplete="off"
data-1p-ignore data-1p-ignore
data-lpignore="true" data-lpignore="true"
@@ -139,7 +139,6 @@ const SectionUniversalToken = memo(() => {
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [checked, setChecked] = useState(false) const [checked, setChecked] = useState(false)
const [isPermanent, setIsPermanent] = useState(false) const [isPermanent, setIsPermanent] = useState(false)
const publicKey = $publicKey.get()
async function updateToken(enable: number = -1, permanent: number = -1) { async function updateToken(enable: number = -1, permanent: number = -1) {
// enable: 0 for disable, 1 for enable, -1 (unset) for get current state // enable: 0 for disable, 1 for enable, -1 (unset) for get current state
@@ -192,25 +191,6 @@ const SectionUniversalToken = memo(() => {
<ActionsButtonUniversalToken token={token} checked={checked} /> <ActionsButtonUniversalToken token={token} checked={checked} />
</div> </div>
{checked && (
<div className="flex items-center gap-4 min-w-0 border-t pt-3">
<span className="text-sm text-muted-foreground whitespace-nowrap">
<Trans>Public key</Trans>
</span>
<div className="min-w-0 flex-1 truncate">
<span className="text-sm text-primary font-mono">{publicKey}</span>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => copyToClipboard(publicKey)}
title={t`Copy public key`}
>
<CopyIcon className="w-4" />
</Button>
</div>
)}
{checked && ( {checked && (
<div className="border-t pt-3"> <div className="border-t pt-3">
<div className="text-sm font-medium"> <div className="text-sm font-medium">
@@ -9,7 +9,7 @@ import { CpuChart, ContainerCpuChart } from "./system/charts/cpu-charts"
import { MemoryChart, ContainerMemoryChart, SwapChart } from "./system/charts/memory-charts" import { MemoryChart, ContainerMemoryChart, SwapChart } from "./system/charts/memory-charts"
import { RootDiskCharts, ExtraFsCharts } from "./system/charts/disk-charts" import { RootDiskCharts, ExtraFsCharts } from "./system/charts/disk-charts"
import { BandwidthChart, ContainerNetworkChart } from "./system/charts/network-charts" import { BandwidthChart, ContainerNetworkChart } from "./system/charts/network-charts"
import { TemperatureChart, FanChart, BatteryChart } from "./system/charts/sensor-charts" import { TemperatureChart, BatteryChart } from "./system/charts/sensor-charts"
import { GpuPowerChart, GpuDetailCharts } from "./system/charts/gpu-charts" import { GpuPowerChart, GpuDetailCharts } from "./system/charts/gpu-charts"
import { LazyContainersTable, LazySmartTable, LazySystemdTable } from "./system/lazy-tables" import { LazyContainersTable, LazySmartTable, LazySystemdTable } from "./system/lazy-tables"
import { LoadAverageChart } from "./system/charts/load-average-chart" import { LoadAverageChart } from "./system/charts/load-average-chart"
@@ -123,9 +123,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
<TemperatureChart {...coreProps} /> <TemperatureChart {...coreProps} />
<FanChart {...coreProps} /> <BatteryChart {...coreProps} />
<BatteryChart system={system} {...coreProps} />
{hasGpuPowerData && <GpuPowerChart chartData={chartData} grid={grid} dataEmpty={dataEmpty} />} {hasGpuPowerData && <GpuPowerChart chartData={chartData} grid={grid} dataEmpty={dataEmpty} />}
</div> </div>
@@ -190,8 +188,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
<LoadAverageChart chartData={chartData} grid={grid} dataEmpty={dataEmpty} /> <LoadAverageChart chartData={chartData} grid={grid} dataEmpty={dataEmpty} />
<BandwidthChart {...coreProps} systemStats={systemStats} /> <BandwidthChart {...coreProps} systemStats={systemStats} />
<TemperatureChart {...coreProps} setPageBottomExtraMargin={setPageBottomExtraMargin} /> <TemperatureChart {...coreProps} setPageBottomExtraMargin={setPageBottomExtraMargin} />
<FanChart {...coreProps} /> <BatteryChart {...coreProps} />
<BatteryChart system={system} {...coreProps} />
<SwapChart chartData={chartData} grid={grid} dataEmpty={dataEmpty} systemStats={systemStats} /> <SwapChart chartData={chartData} grid={grid} dataEmpty={dataEmpty} systemStats={systemStats} />
{pageBottomExtraMargin > 0 && <div style={{ marginBottom: pageBottomExtraMargin }}></div>} {pageBottomExtraMargin > 0 && <div style={{ marginBottom: pageBottomExtraMargin }}></div>}
</div> </div>
@@ -108,7 +108,7 @@ export function ContainerMemoryChart({
dataPoints={dataPoints} dataPoints={dataPoints}
tickFormatter={(val) => { tickFormatter={(val) => {
const { value, unit } = formatBytes(val, false, Unit.Bytes, true) const { value, unit } = formatBytes(val, false, Unit.Bytes, true)
return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}` return `${toFixedFloat(value, val >= 10 ? 0 : 1)} ${unit}`
}} }}
contentFormatter={(item) => { contentFormatter={(item) => {
const { value, unit } = formatBytes(item.value, false, Unit.Bytes, true) const { value, unit } = formatBytes(item.value, false, Unit.Bytes, true)
@@ -1,9 +1,9 @@
import { t } from "@lingui/core/macro" import { t } from "@lingui/core/macro"
import AreaChartDefault from "@/components/charts/area-chart" import AreaChartDefault from "@/components/charts/area-chart"
import { batteryStateTranslations } from "@/lib/i18n" import { batteryStateTranslations } from "@/lib/i18n"
import { $fanFilter, $temperatureFilter, $userSettings } from "@/lib/stores" import { $temperatureFilter, $userSettings } from "@/lib/stores"
import { cn, decimalString, formatTemperature, toFixedFloat } from "@/lib/utils" import { cn, decimalString, formatTemperature, toFixedFloat } from "@/lib/utils"
import type { ChartData, SystemRecord, SystemStatsRecord } from "@/types" import type { ChartData, SystemStatsRecord } from "@/types"
import { ChartCard, FilterBar } from "../chart-card" import { ChartCard, FilterBar } from "../chart-card"
import LineChartDefault from "@/components/charts/line-chart" import LineChartDefault from "@/components/charts/line-chart"
import { useStore } from "@nanostores/react" import { useStore } from "@nanostores/react"
@@ -14,60 +14,18 @@ export function BatteryChart({
grid, grid,
dataEmpty, dataEmpty,
maxValues, maxValues,
system,
}: { }: {
chartData: ChartData chartData: ChartData
grid: boolean grid: boolean
dataEmpty: boolean dataEmpty: boolean
maxValues: boolean maxValues: boolean
system: SystemRecord
}) { }) {
const batteryNames = useMemo(() => { const showBatteryChart = chartData.systemStats.at(-1)?.stats.bat
const names = new Set<string>()
for (const record of chartData.systemStats) {
for (const name in record.stats?.bats ?? {}) {
names.add(name)
}
}
return [...names].sort()
}, [chartData.systemStats])
const hasNamedBatteries = batteryNames.length > 0
const showBatteryChart = hasNamedBatteries || chartData.systemStats.some((record) => record.stats?.bat)
if (!showBatteryChart) { if (!showBatteryChart) {
return null return null
} }
if (hasNamedBatteries) {
const dataPoints = batteryNames.map((name, index) => ({
label: name,
dataKey: ({ stats }: SystemStatsRecord) => stats?.bats?.[name],
color: `hsl(${(index * 360 + 226) / batteryNames.length}, 65%, 52%)`,
}))
return (
<ChartCard
empty={dataEmpty}
grid={grid}
title={t`Battery`}
description={`${t({
message: "Current state",
comment: "Context: Battery state",
})}: ${batteryStateTranslations[system.info.bat?.[1] ?? 0]()}`}
>
<LineChartDefault
chartData={chartData}
maxToggled={maxValues}
dataPoints={dataPoints}
domain={[0, 100]}
legend={true}
tickFormatter={(val) => `${val}%`}
contentFormatter={({ value }) => `${value}%`}
itemSorter={(a, b) => b.value - a.value}
/>
</ChartCard>
)
}
return ( return (
<ChartCard <ChartCard
empty={dataEmpty} empty={dataEmpty}
@@ -76,7 +34,7 @@ export function BatteryChart({
description={`${t({ description={`${t({
message: "Current state", message: "Current state",
comment: "Context: Battery state", comment: "Context: Battery state",
})}: ${batteryStateTranslations[system.info.bat?.[1] ?? 0]()}`} })}: ${batteryStateTranslations[chartData.systemStats.at(-1)?.stats.bat?.[1] ?? 0]()}`}
> >
<AreaChartDefault <AreaChartDefault
chartData={chartData} chartData={chartData}
@@ -251,94 +209,3 @@ export function TemperatureChart({
</div> </div>
) )
} }
export function FanChart({ chartData, grid, dataEmpty }: { chartData: ChartData; grid: boolean; dataEmpty: boolean }) {
const showFanChart = chartData.systemStats.at(-1)?.stats.f
const filter = useStore($fanFilter)
const statsRef = useRef(chartData.systemStats)
statsRef.current = chartData.systemStats
// Stable key derived from current sensor names (sorted) so the memo only
// recomputes when the set of fans changes — not on every data point.
let sensorNamesKey = ""
for (let i = chartData.systemStats.length - 1; i >= 0; i--) {
const f = chartData.systemStats[i].stats?.f
if (f) {
sensorNamesKey = Object.keys(f).sort().join("\0")
break
}
}
const { colorMap, dataKeys, sortedKeys } = useMemo(() => {
const stats = statsRef.current
const sums = {} as Record<string, number>
for (const data of stats) {
const f = data.stats?.f
if (!f) continue
for (const key of Object.keys(f)) {
sums[key] = (sums[key] ?? 0) + f[key]
}
}
const sorted = Object.keys(sums).sort((a, b) => sums[b] - sums[a])
const colorMap = {} as Record<string, string>
const dataKeys = {} as Record<string, (d: SystemStatsRecord) => number | undefined>
for (let i = 0; i < sorted.length; i++) {
const key = sorted[i]
colorMap[key] = `hsl(${((i * 360) / sorted.length) % 360}, 60%, 55%)`
dataKeys[key] = (d: SystemStatsRecord) => d.stats?.f?.[key]
}
return { colorMap, dataKeys, sortedKeys: sorted }
}, [sensorNamesKey])
const dataPoints = useMemo(() => {
return sortedKeys.map((key) => {
const filterTerms = filter
? filter
.toLowerCase()
.split(" ")
.filter((term) => term.length > 0)
: []
const filtered = filterTerms.length > 0 && !filterTerms.some((term) => key.toLowerCase().includes(term))
const strokeOpacity = filtered ? 0.1 : 1
return {
label: key,
dataKey: dataKeys[key],
color: colorMap[key],
strokeOpacity,
activeDot: !filtered,
}
})
}, [sortedKeys, filter, dataKeys, colorMap])
if (!showFanChart) {
return null
}
const legend = dataPoints.length < 12
return (
<div className={cn("odd:last-of-type:col-span-full", { "col-span-full": !grid })}>
<ChartCard
empty={dataEmpty}
grid={grid}
title={t`Fans`}
description={t`System fan speeds (RPM)`}
cornerEl={<FilterBar store={$fanFilter} />}
legend={legend}
>
<LineChartDefault
chartData={chartData}
itemSorter={(a, b) => b.value - a.value}
domain={["auto", "auto"]}
legend={legend}
tickFormatter={(val) => `${toFixedFloat(val, 0)}`}
contentFormatter={(item) => `${decimalString(item.value, 0)} RPM`}
dataPoints={dataPoints}
filter={filter}
></LineChartDefault>
</ChartCard>
</div>
)
}
@@ -470,7 +470,7 @@ function TableCellWithMeter(info: CellContext<SystemRecord, unknown>) {
STATUS_COLORS.down STATUS_COLORS.down
) )
return ( return (
<div className="flex gap-2 items-center tabular-nums tracking-tight w-full min-w-0"> <div className="flex gap-2 items-center tabular-nums tracking-tight w-full">
<span className="min-w-8 shrink-0">{decimalString(val, val >= 10 ? 1 : 2)}%</span> <span className="min-w-8 shrink-0">{decimalString(val, val >= 10 ? 1 : 2)}%</span>
<span className="flex-1 min-w-8 grid bg-muted h-[1em] rounded-sm overflow-hidden"> <span className="flex-1 min-w-8 grid bg-muted h-[1em] rounded-sm overflow-hidden">
<span className={meterClass} style={{ width: `${val}%` }}></span> <span className={meterClass} style={{ width: `${val}%` }}></span>
@@ -477,7 +477,7 @@ const SystemCard = memo(
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="text-sm px-5 pt-3.5 pb-4"> <CardContent className="text-sm px-5 pt-3.5 pb-4">
<div className="grid gap-2.5" style={{ gridTemplateColumns: "24px minmax(80px, max-content) minmax(0, 1fr)" }}> <div className="grid gap-2.5" style={{ gridTemplateColumns: "24px minmax(80px, max-content) 1fr" }}>
{table.getAllColumns().map((column) => { {table.getAllColumns().map((column) => {
if (!column.getIsVisible() || column.id === "system" || column.id === "actions") return null if (!column.getIsVisible() || column.id === "system" || column.id === "actions") return null
const cell = row.getAllCells().find((cell) => cell.column.id === column.id) const cell = row.getAllCells().find((cell) => cell.column.id === column.id)
@@ -496,7 +496,7 @@ const SystemCard = memo(
<div key={`${column.id}-label`} className="flex items-center text-muted-foreground pr-3"> <div key={`${column.id}-label`} className="flex items-center text-muted-foreground pr-3">
{name()}: {name()}:
</div> </div>
<div key={`${column.id}-value`} className="flex items-center min-w-0"> <div key={`${column.id}-value`} className="flex items-center">
{flexRender(cell.column.columnDef.cell, cell.getContext())} {flexRender(cell.column.columnDef.cell, cell.getContext())}
</div> </div>
</> </>
-3
View File
@@ -64,9 +64,6 @@ export const $containerFilter = atom("")
/** Temperature chart filter */ /** Temperature chart filter */
export const $temperatureFilter = atom("") export const $temperatureFilter = atom("")
/** Fan-speed chart filter */
export const $fanFilter = atom("")
/** Fallback copy to clipboard dialog content */ /** Fallback copy to clipboard dialog content */
export const $copyContent = atom("") export const $copyContent = atom("")
+1 -5
View File
@@ -143,16 +143,12 @@ export interface SystemStats {
bm?: [number, number] bm?: [number, number]
/** temperatures */ /** temperatures */
t?: Record<string, number> t?: Record<string, number>
/** fan speeds (RPM) — keyed by `<chip>_<label-or-fan-idx>` */
f?: Record<string, number>
/** extra filesystems */ /** extra filesystems */
efs?: Record<string, ExtraFsStats> efs?: Record<string, ExtraFsStats>
/** GPU data */ /** GPU data */
g?: Record<string, GPUData> g?: Record<string, GPUData>
/** battery percent and state */ /** battery percent and state */
bat?: [number, BatteryState] bat?: [number, BatteryState]
/** battery percentages by device name */
bats?: Record<string, number>
/** network interfaces [upload bytes, download bytes, total upload bytes, total download bytes] */ /** network interfaces [upload bytes, download bytes, total upload bytes, total download bytes] */
ni?: Record<string, [number, number, number, number]> ni?: Record<string, [number, number, number, number]>
} }
@@ -212,7 +208,7 @@ interface ContainerStats {
n: string n: string
/** cpu percent */ /** cpu percent */
c: number c: number
/** memory used (mb) */ /** memory used (gb) */
m: number m: number
// network sent (mb) // network sent (mb)
ns?: number ns?: number
+1 -2
View File
@@ -16,7 +16,7 @@ It has a friendly web interface, simple configuration, and is ready to use out o
- **Lightweight**: Smaller and less resource-intensive than leading solutions. - **Lightweight**: Smaller and less resource-intensive than leading solutions.
- **Simple**: Easy setup with little manual configuration required. - **Simple**: Easy setup with little manual configuration required.
- **Docker stats**: Tracks CPU, memory, and network usage history for each container. - **Docker stats**: Tracks CPU, memory, and network usage history for each container.
- **Alerts**: Configurable alerts for CPU, memory, disk, bandwidth, temperature, fan speed, load average, and status. - **Alerts**: Configurable alerts for CPU, memory, disk, bandwidth, temperature, load average, and status.
- **Multi-user**: Users manage their own systems. Admins can share systems across users. - **Multi-user**: Users manage their own systems. Admins can share systems across users.
- **OAuth / OIDC**: Supports many OAuth2 providers. Password auth can be disabled. - **OAuth / OIDC**: Supports many OAuth2 providers. Password auth can be disabled.
- **Automatic backups**: Save to and restore from disk or S3-compatible storage. - **Automatic backups**: Save to and restore from disk or S3-compatible storage.
@@ -48,7 +48,6 @@ The [quick start guide](https://beszel.dev/guide/getting-started) and other docu
- **Network usage** - Host system and containers. - **Network usage** - Host system and containers.
- **Load average** - Host system. - **Load average** - Host system.
- **Temperature** - Host system sensors. - **Temperature** - Host system sensors.
- **Fan speed** - Host system sensors (Linux, via `/sys/class/hwmon`).
- **GPU usage / power draw** - Nvidia, AMD, and Intel. - **GPU usage / power draw** - Nvidia, AMD, and Intel.
- **Battery** - Host system battery charge. - **Battery** - Host system battery charge.
- **Containers** - Status and metrics of all running Docker / Podman containers. - **Containers** - Status and metrics of all running Docker / Podman containers.
-26
View File
@@ -1,26 +0,0 @@
{
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
"release-type": "simple",
"packages": {
"supplemental/helm/beszel-agent": {
"package-name": "beszel-agent",
"release-type": "helm",
"changelog-path": "CHANGELOG.md",
"bump-minor-pre-major": true,
"bump-patch-for-minor-pre-major": true,
"draft": true,
"force-tag-creation": true,
"prerelease": false
},
"supplemental/helm/beszel-hub": {
"package-name": "beszel-hub",
"release-type": "helm",
"changelog-path": "CHANGELOG.md",
"bump-minor-pre-major": true,
"bump-patch-for-minor-pre-major": true,
"draft": true,
"force-tag-creation": true,
"prerelease": false
}
}
}
@@ -1,29 +0,0 @@
# Changelog
## [0.1.4](https://github.com/henrygd/beszel/compare/beszel-agent-v0.1.3...beszel-agent-v0.1.4) (2026-08-17)
### Bug Fixes
* **helm:** update app version to 0.18.7 ([418e3f0](https://github.com/henrygd/beszel/commit/418e3f0892747a62a3b743b9693c24406439388c))
## [0.1.3](https://github.com/henrygd/beszel/compare/beszel-agent-v0.1.2...beszel-agent-v0.1.3) (2026-08-17)
### Bug Fixes
* **helm:** publish and index chart releases reliably, maybe ([55054a7](https://github.com/henrygd/beszel/commit/55054a75aba20bc2dd4ffd1c92daaa13749d7dd7))
## [0.1.2](https://github.com/henrygd/beszel/compare/beszel-agent-v0.1.1...beszel-agent-v0.1.2) (2026-08-16)
### Bug Fixes
* **helm:** publish immutable chart releases safely, maybe ([c67b69d](https://github.com/henrygd/beszel/commit/c67b69d17e93cf0b5a50ff23d4140d481e22134f))
## [0.1.1](https://github.com/henrygd/beszel/compare/beszel-agent-v0.1.0...beszel-agent-v0.1.1) (2026-08-16)
### Features
* huge beszel hub and agent helm chart update ([#1582](https://github.com/henrygd/beszel/issues/1582)) ([ec4ec01](https://github.com/henrygd/beszel/commit/ec4ec01a393796d2e282b22a499cdd98c3c958ba))
-15
View File
@@ -1,15 +0,0 @@
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.18.7"
# The release workflow manages this chart version; do not edit manually.
version: 0.1.4
sources:
- https://github.com/henrygd/beszel/tree/main/supplemental/helm/beszel-agent
- https://www.beszel.dev/
- https://github.com/henrygd/beszel
icon: https://repository-images.githubusercontent.com/825470378/2710c6db-f934-4a8b-a2c4-7a0abbcd2ad6
maintainers:
- name: cloudwithdan
email: nikoloskid@pm.me
-538
View File
@@ -1,538 +0,0 @@
# Beszel Agent Helm Chart
A Kubernetes Helm chart for deploying [Beszel Agent](https://www.beszel.dev/) - a lightweight monitoring agent that collects system metrics and sends them to a central Beszel Hub.
## Overview
This Helm chart simplifies the deployment of Beszel Agent in Kubernetes environments. By default, it deploys as a DaemonSet to run one agent on each node in the cluster. The agent monitors node-level system resources (CPU, memory, disk, network, temperature, GPU, etc.) and provides detailed metrics to the Beszel Hub for centralized monitoring and alerting.
## Features
- ✅ DaemonSet deployment by default (one agent per node)
- ✅ GPU support via NVIDIA runtime (optional)
- ✅ Additional filesystem mounting for multi-disk monitoring
- ✅ Flexible deployment as DaemonSet or single Deployment
- ✅ Environment variable configuration for agent authentication
- ✅ Host network support for detailed network monitoring
- ✅ Automatic handling of tainted nodes via tolerations
## Prerequisites
- Kubernetes 1.19+
- Helm 3.0+
- Beszel Hub instance running and accessible
- SSH public key for agent authentication
## What Gets Monitored
In Kubernetes environments, the Beszel agent monitors **node-level metrics**:
- **CPU usage** - Node CPU utilization and per-core stats
- **Memory usage** - Node memory, swap, and ZFS ARC
- **Disk usage** - Node filesystem usage and I/O statistics
- **Network usage** - Node network traffic (requires `hostNetwork: true`)
- **Load average** - System load averages
- **Temperature** - Node hardware sensors
- **GPU usage/power** - NVIDIA, AMD, and Intel GPUs (with appropriate image)
- **Battery** - Node battery status (if applicable)
- **S.M.A.T.** - Disk health monitoring
**Note**: The agent does **not** monitor individual Kubernetes pods or containers. For pod/container metrics, use Kubernetes metrics-server or monitoring tools like Prometheus.
## Quick Start
### 1. Add the Helm Repository
```bash
helm repo add beszel https://henrygd.github.io/beszel
helm repo update
```
### 2. Install the Chart
```bash
helm install beszel-agent ./beszel-agent \
--set env.KEY="ssh-ed25519 AAAA... your-public-key" \
--set env.TOKEN="your-token-value" \
--set env.HUB_URL="http://beszel-hub:8090"
```
Or with custom values:
```bash
helm install beszel-agent ./beszel-agent -f custom-values.yaml
```
### 3. Verify the Agent is Running
```bash
kubectl get pods -l app.kubernetes.io/name=beszel-agent
kubectl logs -l app.kubernetes.io/name=beszel-agent
```
## Configuration
### Basic Configuration
Essential parameters to configure:
| Parameter | Default | Description |
|-----------|---------|-------------|
| `daemonset.enabled` | `true` | Deploy as DaemonSet (one pod per node) |
| `env.KEY` | Required* | SSH public key for Hub authentication (*unless using existingSecret) |
| `env.TOKEN` | Empty | Authentication token (optional) |
| `env.HUB_URL` | Empty | Hub URL (e.g., http://beszel-hub:8090) |
| `env.PORT` | `45876` | Port the agent listens on |
| `secret.existingSecret` | Empty | Name of an existing Kubernetes Secret to use |
| `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.18.7) | Image version |
| `hostNetwork` | `false` | Use host network for network monitoring |
| `tolerations` | Allows all taints | Tolerations for running on tainted nodes |
### Minimal Configuration
```bash
helm install beszel-agent ./beszel-agent \
--set env.KEY="ssh-ed25519 AAAA... your-public-key" \
--set env.TOKEN="your-token-value" \
--set env.HUB_URL="http://beszel-hub:8090"
```
### Standard Configuration
```yaml
# values.yaml
image:
repository: henrygd/beszel-agent
tag: "" # Uses chart appVersion
env:
PORT: "45876"
KEY: "ssh-ed25519 AAAA... your-public-key"
TOKEN: "your-token-value"
HUB_URL: "http://beszel-hub:8090"
# Use host network for accurate network monitoring
hostNetwork: false
```
### GPU Support (NVIDIA)
For systems with NVIDIA GPUs, use the special GPU-enabled image:
```yaml
image:
repository: henrygd/beszel-agent-nvidia
# Enable NVIDIA runtime
gpuRuntime: nvidia
env:
PORT: "45876"
KEY: "ssh-ed25519 AAAA... your-public-key"
TOKEN: "your-token-value"
HUB_URL: "http://beszel-hub:8090"
NVIDIA_VISIBLE_DEVICES: "all"
NVIDIA_DRIVER_CAPABILITIES: "compute,video,utility"
```
**Note**: The GPU image (`henrygd/beszel-agent-nvidia`) is specifically for monitoring NVIDIA GPUs on the node. It does not provide container-level GPU metrics.
Or via CLI:
```bash
helm install beszel-agent ./beszel-agent \
--set image.repository=henrygd/beszel-agent-nvidia \
--set gpuRuntime=nvidia \
--set env.NVIDIA_VISIBLE_DEVICES=all \
--set env.NVIDIA_DRIVER_CAPABILITIES="compute,video,utility" \
--set env.KEY="ssh-ed25519 AAAA... your-public-key" \
--set env.TOKEN="your-token-value" \
--set env.HUB_URL="http://beszel-hub:8090"
```
### Monitor Additional Filesystems
To monitor additional disks or partitions:
```yaml
volumes:
- name: extra-filesystems
hostPath:
path: /mnt/disk/.beszel
type: DirectoryOrCreate
volumeMounts:
- name: extra-filesystems
mountPath: /extra-filesystems
readOnly: true
env:
PORT: "45876"
KEY: "ssh-ed25519 AAAA... your-public-key"
TOKEN: "your-token-value"
HUB_URL: "http://beszel-hub:8090"
```
### Advanced Configuration
#### Resource Limits
```yaml
resources:
limits:
cpu: 500m
memory: 256Mi
requests:
cpu: 100m
memory: 128Mi
```
#### Node Selection
Run agents on specific nodes:
```yaml
nodeSelector:
monitoring: "true"
tolerations:
- key: monitoring
operator: Equal
value: "true"
effect: NoSchedule
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
labelSelector:
matchExpressions:
- key: app.kubernetes.io/name
operator: In
values:
- beszel-agent
topologyKey: kubernetes.io/hostname
```
#### Host Network
For detailed network statistics, enable host network mode:
```yaml
hostNetwork: true
env:
PORT: "45876"
KEY: "ssh-ed25519 AAAA... your-public-key"
TOKEN: "your-token-value"
HUB_URL: "http://beszel-hub:8090"
```
**Note**: When `hostNetwork: true`, the agent can monitor the node's actual network interfaces. When `false`, it only sees the pod's network namespace.
#### DaemonSet Mode
By default, the agent is deployed as a DaemonSet, running one pod on each cluster node:
```yaml
daemonset:
enabled: true # Default - one agent per node
# Or disable for single Deployment deployment
daemonset:
enabled: false
replicaCount: 1
```
#### Tolerations
By default, tolerations are set to allow agents to run on all nodes, including tainted ones:
```yaml
tolerations:
- operator: Exists
effect: NoSchedule
- operator: Exists
effect: NoExecute
```
To restrict agents to specific nodes:
```yaml
tolerations: []
nodeSelector:
monitoring: "true"
```
### Using Existing Secrets
The chart supports referencing an existing Kubernetes Secret instead of having the chart create one. This is useful when:
- You want to manage secrets externally (e.g., with a secret operator, external secret manager, or GitOps)
- You want to share a single secret across multiple deployments
- You prefer not to store sensitive values in Helm values
```yaml
# Create the secret manually
apiVersion: v1
kind: Secret
metadata:
name: my-beszel-secret
type: Opaque
data:
ssh-key: c3NoLWVkMjU1IDEgQUFBQU... # base64 encoded SSH public key
token: dG9rZW4tdmFsdWU= # base64 encoded token (optional)
```
Then reference it in your values:
```yaml
secret:
existingSecret: my-beszel-secret
sshKey: ssh-key # key name in the secret (default: ssh-key)
tokenKey: token # key name in the secret (default: token)
env:
HUB_URL: "http://beszel-hub:8090"
```
**Note**: When using `existingSecret`, do not set `env.KEY` or `env.TOKEN` - the chart will use the values from the existing secret instead.
You can also use different key names if your secret uses non-standard keys:
```yaml
secret:
existingSecret: my-beszel-secret
sshKey: public-key # custom key name
tokenKey: auth-token # custom key name
```
## Deployment Examples
### Full Cluster Monitoring (DaemonSet - Default)
```bash
helm install beszel-agent ./beszel-agent \
--set env.KEY="ssh-ed25519 AAAA... your-public-key" \
--set env.TOKEN="your-token-value" \
--set env.HUB_URL="http://beszel-hub:8090"
```
This deploys one agent on every node in the cluster automatically.
### Single Agent Deployment (Non-DaemonSet)
```yaml
# values.yaml
daemonset:
enabled: false
replicaCount: 1
env:
PORT: "45876"
KEY: "ssh-ed25519 AAAA... your-public-key"
TOKEN: "your-token-value"
HUB_URL: "http://beszel-hub:8090"
```
Or via CLI:
```bash
helm install beszel-agent ./beszel-agent \
--set daemonset.enabled=false \
--set replicaCount=1 \
--set env.KEY="ssh-ed25519 AAAA... your-public-key" \
--set env.TOKEN="your-token-value" \
--set env.HUB_URL="http://beszel-hub:8090"
```
### Network Monitoring with Host Network
```yaml
hostNetwork: true
env:
PORT: "45876"
KEY: "ssh-ed25519 AAAA... your-public-key"
TOKEN: "your-token-value"
HUB_URL: "http://beszel-hub:8090"
podSecurityContext:
hostNetwork: true
```
## Managing the Agent
### Check Agent Status
```bash
# List agent pods
kubectl get pods -l app.kubernetes.io/name=beszel-agent
# View agent logs
kubectl logs -l app.kubernetes.io/name=beszel-agent -f
# Describe a specific pod
kubectl describe pod <pod-name>
```
### Update Configuration
```bash
# Update the SSH key
helm upgrade beszel-agent ./beszel-agent \
--set env.KEY="ssh-ed25519 AAAA... your-public-key" \
--set env.TOKEN="your-token-value" \
--set env.HUB_URL="http://beszel-hub:8090"
# Change image version
helm upgrade beszel-agent ./beszel-agent \
--set image.tag="0.18.7"
```
### Restart All Agents
```bash
# For DaemonSet (default)
kubectl rollout restart daemonset beszel-agent
# For Deployment (if daemonset.enabled=false)
kubectl rollout restart deployment beszel-agent
```
### Uninstall
```bash
helm uninstall beszel-agent
```
### View Helm Release History
```bash
helm history beszel-agent
helm rollback beszel-agent 1 # Rollback to previous version
```
## Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `PORT` | `45876` | Port the agent listens on |
| `KEY` | Required | SSH public key for Hub authentication |
| `TOKEN` | Empty | Authentication token (optional) |
| `HUB_URL` | Empty | Hub URL (e.g., http://beszel-hub:8090) |
| `NVIDIA_VISIBLE_DEVICES` | Not set | GPU visibility (GPU agents only) |
| `NVIDIA_DRIVER_CAPABILITIES` | Not set | GPU capabilities (GPU agents only) |
## Troubleshooting
### Agent Pod Won't Start
```bash
# Check pod events and logs
kubectl describe pod <pod-name>
kubectl logs <pod-name>
```
### Cannot Connect to Hub
- Verify Hub is accessible from the pod's network
- Check DNS resolution: `kubectl exec <pod-name> -- nslookup beszel-hub.default.svc.cluster.local`
- Verify SSH key is correctly configured
- Check firewall rules for port 8090 (Hub) and 45876 (Agent)
### GPU Not Detected
- Confirm image is `henrygd/beszel-agent-nvidia`
- Verify NVIDIA runtime is installed on nodes
- Check GPU visibility: `kubectl exec <pod-name> -- nvidia-smi`
- Verify runtimeClassName matches your GPU runtime
### SSH Key Authentication Failed
- Verify key format (should be valid SSH public key)
- Check key is correctly set in `env.KEY`
- Ensure Hub has the corresponding private key
- Verify Hub can authenticate agents with this key
### High Memory Usage
Adjust resource limits:
```yaml
resources:
limits:
memory: 512Mi
requests:
memory: 256Mi
```
## Security Considerations
- Store SSH keys securely (use Kubernetes Secrets)
- Restrict container to read-only root filesystem if possible
- Limit resource usage with resource limits
- Use network policies to restrict traffic
- Run with minimal privileges
- Regularly update agent image to latest version
- Use private container registries if applicable
### Using Kubernetes Secrets for Configuration
The chart automatically creates a Kubernetes Secret to store sensitive authentication data:
```bash
# Install with all configuration options
helm install beszel-agent ./beszel-agent \
--set env.KEY="ssh-ed25519 AAAA... your-public-key" \
--set env.TOKEN="your-optional-token" \
--set env.HUB_URL="http://beszel-hub:8090"
```
Or create the installation with a values file:
```yaml
# values.yaml
env:
KEY: "ssh-ed25519 AAAA... your-public-key"
TOKEN: "your-optional-token"
HUB_URL: "http://beszel-hub:8090"
```
Configuration stored in Kubernetes Secrets (encrypted at rest):
- `KEY` - SSH public key for authentication (required)
- `TOKEN` - Authentication token (optional)
Configuration as regular environment variables:
- `HUB_URL` - Hub address (e.g., http://beszel-hub:8090 or https://beszel.example.com)
To verify the secret was created:
```bash
kubectl get secret beszel-agent
kubectl get secret beszel-agent -o jsonpath='{.data.ssh-key}' | base64 -d
```
## Support and Documentation
- **Project Homepage**: https://www.beszel.dev/
- **GitHub Repository**: https://github.com/henrygd/beszel
- **Agent Documentation**: https://www.beszel.dev/
**Note**: The main Beszel documentation describes Docker/Podman container monitoring. In Kubernetes, the agent focuses on node-level metrics. For Kubernetes-specific container/pod monitoring, use tools like metrics-server, Prometheus, or the Kubernetes Metrics API.
## Chart Information
- **Chart Version**: 0.1.0
- **App Version**: 0.18.7
- **Kubernetes Version**: 1.19+
- **Maintainer**: cloudwithdan (nikoloskid@pm.me)
## License
Please refer to the main Beszel project repository for license information.
@@ -1,21 +0,0 @@
1. Get Beszel Agent Status
kubectl get daemonset -n {{ .Release.Namespace }} {{ include "beszel-agent.fullname" . }}
kubectl get pods -n {{ .Release.Namespace }} -l app.kubernetes.io/name={{ include "beszel-agent.name" . }}
2. View Agent Logs
kubectl logs -n {{ .Release.Namespace }} -l app.kubernetes.io/name={{ include "beszel-agent.name" . }} -f
3. Verify SSH Key Configuration
kubectl get secret -n {{ .Release.Namespace }} {{ include "beszel-agent.fullname" . }} -o jsonpath='{.data.ssh-key}' | base64 -d
4. Agent Configuration
- Port: {{ .Values.env.PORT }}
- Image: {{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}
{{- if .Values.hostNetwork }}
- Host Network: Enabled
{{- end }}
{{- if .Values.gpuRuntime }}
- GPU Runtime: {{ .Values.gpuRuntime }}
{{- end }}
5. Next Steps
- Ensure the Beszel Hub is accessible from the cluster
- Check that the SSH key is registered with the Hub
- Verify agent connectivity: kubectl logs -n {{ .Release.Namespace }} -l app.kubernetes.io/name={{ include "beszel-agent.name" . }}
For more information, visit: https://www.beszel.dev/
@@ -1,73 +0,0 @@
{{/*
Expand the name of the chart.
*/}}
{{- define "beszel-agent.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a default fully qualified app name.
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
If release name contains chart name it will be used as a full name.
*/}}
{{- define "beszel-agent.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}
{{/*
Create chart name and version as used by the chart label.
*/}}
{{- define "beszel-agent.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Common labels
*/}}
{{- define "beszel-agent.labels" -}}
helm.sh/chart: {{ include "beszel-agent.chart" . }}
{{ include "beszel-agent.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{/*
Selector labels
*/}}
{{- define "beszel-agent.selectorLabels" -}}
app.kubernetes.io/name: {{ include "beszel-agent.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{/*
Create the name of the service account to use
*/}}
{{- define "beszel-agent.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }}
{{- default (include "beszel-agent.fullname" .) .Values.serviceAccount.name }}
{{- else }}
{{- default "default" .Values.serviceAccount.name }}
{{- end }}
{{- end }}
{{/*
Create the name of the secret to use
*/}}
{{- define "beszel-agent.secretName" -}}
{{- if .Values.secret.existingSecret }}
{{- .Values.secret.existingSecret }}
{{- else }}
{{- include "beszel-agent.fullname" . }}
{{- end }}
{{- end }}
@@ -1,109 +0,0 @@
{{- if .Values.daemonset.enabled }}
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: {{ include "beszel-agent.fullname" . }}
labels:
{{- include "beszel-agent.labels" . | nindent 4 }}
spec:
selector:
matchLabels:
{{- include "beszel-agent.selectorLabels" . | nindent 6 }}
template:
metadata:
{{- with .Values.podAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "beszel-agent.labels" . | nindent 8 }}
{{- with .Values.podLabels }}
{{- toYaml . | nindent 8 }}
{{- end }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
serviceAccountName: {{ include "beszel-agent.serviceAccountName" . }}
{{- with .Values.podSecurityContext }}
securityContext:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- if .Values.hostNetwork }}
hostNetwork: {{ .Values.hostNetwork }}
{{- end }}
{{- if .Values.gpuRuntime }}
runtimeClassName: {{ .Values.gpuRuntime }}
{{- end }}
containers:
- name: {{ .Chart.Name }}
{{- with .Values.securityContext }}
securityContext:
{{- toYaml . | nindent 12 }}
{{- end }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: agent
containerPort: {{ .Values.service.port }}
protocol: TCP
{{- if .Values.service.hostPort }}
hostPort: {{ .Values.service.hostPort }}
{{- end }}
env:
- name: SYSTEM_NAME
valueFrom:
fieldRef:
fieldPath: spec.nodeName
- name: KEY
valueFrom:
secretKeyRef:
name: {{ include "beszel-agent.secretName" . }}
key: {{ .Values.secret.sshKey }}
{{- if or .Values.env.TOKEN .Values.secret.existingSecret }}
- name: TOKEN
valueFrom:
secretKeyRef:
name: {{ include "beszel-agent.secretName" . }}
key: {{ .Values.secret.tokenKey }}
{{- end }}
{{- range $key, $value := .Values.env }}
{{- if and (ne $key "KEY") (ne $key "TOKEN") (ne $key "SYSTEM_NAME") (ne $value "") }}
- name: {{ $key }}
value: {{ $value | quote }}
{{- end }}
{{- end }}
{{- with .Values.livenessProbe }}
livenessProbe:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.readinessProbe }}
readinessProbe:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.resources }}
resources:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.volumeMounts }}
volumeMounts:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.volumes }}
volumes:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- end }}
@@ -1,112 +0,0 @@
{{- if not .Values.daemonset.enabled }}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "beszel-agent.fullname" . }}
labels:
{{- include "beszel-agent.labels" . | nindent 4 }}
spec:
{{- if not .Values.autoscaling.enabled }}
replicas: {{ .Values.replicaCount }}
{{- end }}
selector:
matchLabels:
{{- include "beszel-agent.selectorLabels" . | nindent 6 }}
template:
metadata:
{{- with .Values.podAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "beszel-agent.labels" . | nindent 8 }}
{{- with .Values.podLabels }}
{{- toYaml . | nindent 8 }}
{{- end }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
serviceAccountName: {{ include "beszel-agent.serviceAccountName" . }}
{{- with .Values.podSecurityContext }}
securityContext:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- if .Values.hostNetwork }}
hostNetwork: {{ .Values.hostNetwork }}
{{- end }}
{{- if .Values.gpuRuntime }}
runtimeClassName: {{ .Values.gpuRuntime }}
{{- end }}
containers:
- name: {{ .Chart.Name }}
{{- with .Values.securityContext }}
securityContext:
{{- toYaml . | nindent 12 }}
{{- end }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: agent
containerPort: {{ .Values.service.port }}
protocol: TCP
{{- if .Values.service.hostPort }}
hostPort: {{ .Values.service.hostPort }}
{{- end }}
env:
- name: SYSTEM_NAME
valueFrom:
fieldRef:
fieldPath: spec.nodeName
- name: KEY
valueFrom:
secretKeyRef:
name: {{ include "beszel-agent.secretName" . }}
key: {{ .Values.secret.sshKey }}
{{- if or .Values.env.TOKEN .Values.secret.existingSecret }}
- name: TOKEN
valueFrom:
secretKeyRef:
name: {{ include "beszel-agent.secretName" . }}
key: {{ .Values.secret.tokenKey }}
{{- end }}
{{- range $key, $value := .Values.env }}
{{- if and (ne $key "KEY") (ne $key "TOKEN") (ne $key "SYSTEM_NAME") (ne $value "") }}
- name: {{ $key }}
value: {{ $value | quote }}
{{- end }}
{{- end }}
{{- with .Values.livenessProbe }}
livenessProbe:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.readinessProbe }}
readinessProbe:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.resources }}
resources:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.volumeMounts }}
volumeMounts:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.volumes }}
volumes:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- end }}
@@ -1,32 +0,0 @@
{{- if .Values.autoscaling.enabled }}
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: {{ include "beszel-agent.fullname" . }}
labels:
{{- include "beszel-agent.labels" . | nindent 4 }}
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: {{ include "beszel-agent.fullname" . }}
minReplicas: {{ .Values.autoscaling.minReplicas }}
maxReplicas: {{ .Values.autoscaling.maxReplicas }}
metrics:
{{- if .Values.autoscaling.targetCPUUtilizationPercentage }}
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
{{- end }}
{{- if .Values.autoscaling.targetMemoryUtilizationPercentage }}
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }}
{{- end }}
{{- end }}
@@ -1,38 +0,0 @@
{{- if .Values.httpRoute.enabled -}}
{{- $fullName := include "beszel-agent.fullname" . -}}
{{- $svcPort := .Values.service.port -}}
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: {{ $fullName }}
labels:
{{- include "beszel-agent.labels" . | nindent 4 }}
{{- with .Values.httpRoute.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
parentRefs:
{{- with .Values.httpRoute.parentRefs }}
{{- toYaml . | nindent 4 }}
{{- end }}
{{- with .Values.httpRoute.hostnames }}
hostnames:
{{- toYaml . | nindent 4 }}
{{- end }}
rules:
{{- range .Values.httpRoute.rules }}
{{- with .matches }}
- matches:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .filters }}
filters:
{{- toYaml . | nindent 8 }}
{{- end }}
backendRefs:
- name: {{ $fullName }}
port: {{ $svcPort }}
weight: 1
{{- end }}
{{- end }}
@@ -1,43 +0,0 @@
{{- if .Values.ingress.enabled -}}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "beszel-agent.fullname" . }}
labels:
{{- include "beszel-agent.labels" . | nindent 4 }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- with .Values.ingress.className }}
ingressClassName: {{ . }}
{{- end }}
{{- if .Values.ingress.tls }}
tls:
{{- range .Values.ingress.tls }}
- hosts:
{{- range .hosts }}
- {{ . | quote }}
{{- end }}
secretName: {{ .secretName }}
{{- end }}
{{- end }}
rules:
{{- range .Values.ingress.hosts }}
- host: {{ .host | quote }}
http:
paths:
{{- range .paths }}
- path: {{ .path }}
{{- with .pathType }}
pathType: {{ . }}
{{- end }}
backend:
service:
name: {{ include "beszel-agent.fullname" $ }}
port:
number: {{ $.Values.service.port }}
{{- end }}
{{- end }}
{{- end }}
@@ -1,20 +0,0 @@
{{- if and (not .Values.secret.existingSecret) (not .Values.env.KEY) }}
{{- fail "env.KEY is required when not using an existingSecret. Please provide an SSH public key for agent authentication." }}
{{- end }}
{{- if and .Values.secret.existingSecret .Values.env.KEY }}
{{- fail "Cannot use both existingSecret and env.KEY. Please choose one method for providing authentication credentials." }}
{{- end }}
{{- if not .Values.secret.existingSecret }}
apiVersion: v1
kind: Secret
metadata:
name: {{ include "beszel-agent.fullname" . }}
labels:
{{- include "beszel-agent.labels" . | nindent 4 }}
type: Opaque
data:
{{ .Values.secret.sshKey }}: {{ .Values.env.KEY | b64enc | quote }}
{{- if .Values.env.TOKEN }}
{{ .Values.secret.tokenKey }}: {{ .Values.env.TOKEN | b64enc | quote }}
{{- end }}
{{- end }}

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