mirror of
https://github.com/henrygd/beszel.git
synced 2026-08-18 00:07:47 +02:00
Compare commits
64 Commits
d3a1d61955
...
release-pl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
065d7f7f01 | ||
|
|
418e3f0892 | ||
|
|
7556a63378 | ||
|
|
260b082c5e | ||
|
|
55054a75ab | ||
|
|
ccd1735a8e | ||
|
|
c67b69d17e | ||
|
|
da3ab62d4e | ||
|
|
ec4ec01a39 | ||
|
|
ca5497324c | ||
|
|
adaf6f338d | ||
|
|
1ab1229a61 | ||
|
|
9a54d844ba | ||
|
|
87405c5f10 | ||
|
|
bfa6a1e361 | ||
|
|
3688b2d033 | ||
|
|
b4e1f3fafe | ||
|
|
eebcd56462 | ||
|
|
eb5dd230cf | ||
|
|
3337dff64b | ||
|
|
b68acea5a8 | ||
|
|
66ac62a125 | ||
|
|
e2a18ec636 | ||
|
|
ffd4fc2c45 | ||
|
|
052489cada | ||
|
|
d50c09176f | ||
|
|
fe84cfa16d | ||
|
|
6d0b83f6de | ||
|
|
cf90249519 | ||
|
|
6607d4c0d6 | ||
|
|
bc55e249c4 | ||
|
|
977826e8f3 | ||
|
|
8450b40e0c | ||
|
|
ac4436bea3 | ||
|
|
fa5cda83c2 | ||
|
|
35af36fbd2 | ||
|
|
e380ab6917 | ||
|
|
c3a432101b | ||
|
|
98e86b4c9c | ||
|
|
d40372842b | ||
|
|
c9b6279e61 | ||
|
|
9887b662ad | ||
|
|
71bc6f9b9f | ||
|
|
87468965dc | ||
|
|
db0da58ac8 | ||
|
|
b9b3a23063 | ||
|
|
ac34e58113 | ||
|
|
3c8703d1c0 | ||
|
|
01efba50a5 | ||
|
|
d0453f1ca6 | ||
|
|
7ffc6e81ce | ||
|
|
1aa9fcd31d | ||
|
|
bd52134558 | ||
|
|
3fac02f0c0 | ||
|
|
e4c0522cae | ||
|
|
f2ccaacecb | ||
|
|
a81ce14046 | ||
|
|
6e4a2c8a3d | ||
|
|
8cf39be6ef | ||
|
|
146ca4284c | ||
|
|
6eae195d9c | ||
|
|
30bf65991b | ||
|
|
f57f5883ce | ||
|
|
0472343730 |
12
.github/dependabot.yml
vendored
Normal file
12
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: gomod
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
|
||||
44
.github/workflows/docker-images.yml
vendored
44
.github/workflows/docker-images.yml
vendored
@@ -41,7 +41,7 @@ jobs:
|
||||
# henrygd/beszel-agent-nvidia
|
||||
- image: henrygd/beszel-agent-nvidia
|
||||
dockerfile: ./internal/dockerfile_agent_nvidia
|
||||
platforms: linux/amd64
|
||||
platforms: linux/amd64,linux/arm64
|
||||
registry: docker.io
|
||||
username_secret: DOCKERHUB_USERNAME
|
||||
password_secret: DOCKERHUB_TOKEN
|
||||
@@ -52,6 +52,19 @@ jobs:
|
||||
type=semver,pattern={{major}}
|
||||
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
|
||||
- image: henrygd/beszel-agent-intel
|
||||
dockerfile: ./internal/dockerfile_agent_intel
|
||||
@@ -96,7 +109,7 @@ jobs:
|
||||
# ghcr.io/henrygd/beszel-agent-nvidia
|
||||
- image: ghcr.io/${{ github.repository }}/beszel-agent-nvidia
|
||||
dockerfile: ./internal/dockerfile_agent_nvidia
|
||||
platforms: linux/amd64
|
||||
platforms: linux/amd64,linux/arm64
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password_secret: GITHUB_TOKEN
|
||||
@@ -107,6 +120,19 @@ jobs:
|
||||
type=semver,pattern={{major}}
|
||||
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
|
||||
|
||||
# ghcr.io/henrygd/beszel-agent-nvidia:slim
|
||||
- image: ghcr.io/${{ github.repository }}/beszel-agent-nvidia
|
||||
dockerfile: ./internal/dockerfile_agent_nvidia_slim
|
||||
platforms: linux/amd64,linux/arm64
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password_secret: GITHUB_TOKEN
|
||||
tags: |
|
||||
type=raw,value=slim
|
||||
type=semver,pattern={{version}}-slim
|
||||
type=semver,pattern={{major}}.{{minor}}-slim
|
||||
type=semver,pattern={{major}}-slim
|
||||
|
||||
# ghcr.io/henrygd/beszel-agent-intel
|
||||
- image: ghcr.io/${{ github.repository }}/beszel-agent-intel
|
||||
dockerfile: ./internal/dockerfile_agent_intel
|
||||
@@ -152,7 +178,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
@@ -164,14 +190,14 @@ jobs:
|
||||
run: bun run --cwd ./internal/site build
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Docker metadata
|
||||
id: metadata
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ${{ matrix.image }}
|
||||
tags: ${{ matrix.tags }}
|
||||
@@ -181,7 +207,7 @@ jobs:
|
||||
env:
|
||||
password_secret_exists: ${{ secrets[matrix.password_secret] != '' && 'true' || 'false' }}
|
||||
if: github.event_name != 'pull_request' && env.password_secret_exists == 'true'
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ matrix.username || secrets[matrix.username_secret] }}
|
||||
password: ${{ secrets[matrix.password_secret] }}
|
||||
@@ -190,11 +216,13 @@ jobs:
|
||||
# Build and push Docker image with Buildx (don't push on PR)
|
||||
# https://github.com/docker/build-push-action
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: ./
|
||||
file: ${{ matrix.dockerfile }}
|
||||
platforms: ${{ matrix.platforms || 'linux/amd64,linux/arm64,linux/arm/v7' }}
|
||||
platforms: ${{ matrix.platforms || 'linux/amd64,linux/arm64,linux/arm/v6,linux/arm/v7' }}
|
||||
push: ${{ github.ref_type == 'tag' && secrets[matrix.password_secret] != '' }}
|
||||
provenance: mode=max
|
||||
sbom: true
|
||||
tags: ${{ steps.metadata.outputs.tags }}
|
||||
labels: ${{ steps.metadata.outputs.labels }}
|
||||
|
||||
4
.github/workflows/inactivity-actions.yml
vendored
4
.github/workflows/inactivity-actions.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
name: Lock Inactive Issues
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: klaasnicolaas/action-inactivity-lock@v1.1.3
|
||||
- uses: klaasnicolaas/action-inactivity-lock@v2.0.1
|
||||
id: lock
|
||||
with:
|
||||
days-inactive-issues: 14
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Close Stale Issues
|
||||
uses: actions/stale@v10
|
||||
uses: actions/stale@v11
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
|
||||
152
.github/workflows/release-please.yml
vendored
Normal file
152
.github/workflows/release-please.yml
vendored
Normal file
@@ -0,0 +1,152 @@
|
||||
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
|
||||
56
.github/workflows/release.yml
vendored
56
.github/workflows/release.yml
vendored
@@ -13,7 +13,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -27,12 +27,12 @@ jobs:
|
||||
run: bun run --cwd ./internal/site build
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version: "^1.22.1"
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
uses: actions/setup-dotnet@v6
|
||||
with:
|
||||
dotnet-version: "9.0.x"
|
||||
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
- name: GoReleaser beszel
|
||||
uses: goreleaser/goreleaser-action@v6
|
||||
uses: goreleaser/goreleaser-action@v7
|
||||
with:
|
||||
workdir: ./
|
||||
distribution: goreleaser
|
||||
@@ -52,3 +52,51 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ secrets.TOKEN || secrets.GITHUB_TOKEN }}
|
||||
WINGET_TOKEN: ${{ secrets.WINGET_TOKEN }}
|
||||
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
|
||||
|
||||
6
.github/workflows/vulncheck.yml
vendored
6
.github/workflows/vulncheck.yml
vendored
@@ -19,11 +19,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version: 1.26.x
|
||||
go-version: stable
|
||||
# cached: false
|
||||
- name: Get official govulncheck
|
||||
run: go install golang.org/x/vuln/cmd/govulncheck@latest
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -3,7 +3,6 @@ pb_data
|
||||
data
|
||||
temp
|
||||
.vscode
|
||||
beszel-agent
|
||||
beszel_data
|
||||
beszel_data*
|
||||
dist
|
||||
@@ -21,3 +20,5 @@ __debug_*
|
||||
agent/lhm/obj
|
||||
agent/lhm/bin
|
||||
dockerfile_agent_dev
|
||||
.cr-release-packages
|
||||
.tmp
|
||||
|
||||
@@ -22,6 +22,9 @@ builds:
|
||||
- amd64
|
||||
- arm64
|
||||
- arm
|
||||
goarm:
|
||||
- "6"
|
||||
- "7"
|
||||
ignore:
|
||||
- goos: windows
|
||||
goarch: arm64
|
||||
@@ -31,6 +34,8 @@ builds:
|
||||
goarch: arm64
|
||||
- goos: freebsd
|
||||
goarch: arm
|
||||
- goos: darwin
|
||||
goarch: arm
|
||||
|
||||
- id: beszel-agent
|
||||
binary: beszel-agent
|
||||
@@ -52,6 +57,10 @@ builds:
|
||||
- mipsle
|
||||
- mips
|
||||
- ppc64le
|
||||
goarm:
|
||||
- "5"
|
||||
- "6"
|
||||
- "7"
|
||||
gomips:
|
||||
- hardfloat
|
||||
- softfloat
|
||||
@@ -71,6 +80,8 @@ builds:
|
||||
gomips: hardfloat
|
||||
- goos: windows
|
||||
goarch: arm
|
||||
- goos: darwin
|
||||
goarch: arm
|
||||
- goos: darwin
|
||||
goarch: riscv64
|
||||
- goos: windows
|
||||
|
||||
4
.release-please-manifest.json
Normal file
4
.release-please-manifest.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"supplemental/helm/beszel-agent": "0.1.4",
|
||||
"supplemental/helm/beszel-hub": "0.1.4"
|
||||
}
|
||||
2
Makefile
2
Makefile
@@ -52,7 +52,7 @@ lint:
|
||||
golangci-lint run
|
||||
|
||||
test:
|
||||
go test -tags=testing ./...
|
||||
go test -tags='testing no_ui' ./...
|
||||
|
||||
tidy:
|
||||
go mod tidy
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
// Package battery provides functions to check if the system has a battery and return the charge state and percentage.
|
||||
// Package battery provides battery information for the host and connected devices.
|
||||
package battery
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
stateUnknown uint8 = iota
|
||||
stateEmpty
|
||||
@@ -9,3 +16,55 @@ const (
|
||||
stateDischarging
|
||||
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
|
||||
}
|
||||
|
||||
@@ -3,11 +3,7 @@
|
||||
package battery
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"math"
|
||||
"os/exec"
|
||||
"sync"
|
||||
|
||||
"howett.net/plist"
|
||||
)
|
||||
@@ -35,62 +31,46 @@ func readMacBatteries() ([]macBattery, error) {
|
||||
return batteries, nil
|
||||
}
|
||||
|
||||
// HasReadableBattery checks if the system has a battery and returns true if it does.
|
||||
var HasReadableBattery = sync.OnceValue(func() bool {
|
||||
systemHasBattery := false
|
||||
batteries, err := readMacBatteries()
|
||||
slog.Debug("Batteries", "batteries", batteries, "err", err)
|
||||
for _, bat := range batteries {
|
||||
if bat.MaxCapacity > 0 {
|
||||
systemHasBattery = true
|
||||
break
|
||||
}
|
||||
}
|
||||
return systemHasBattery
|
||||
})
|
||||
func HasReadableBattery() bool {
|
||||
batteries, _ := GetBatteryStats()
|
||||
return len(batteries) > 0
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
// GetBatteryStats returns every readable battery reported by macOS.
|
||||
func GetBatteryStats() ([]Battery, error) {
|
||||
batteries, err := readMacBatteries()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(batteries) == 0 {
|
||||
return batteryPercent, batteryState, errors.New("no batteries")
|
||||
return nil, errNoBatteries
|
||||
}
|
||||
|
||||
totalCapacity := 0
|
||||
totalCharge := 0
|
||||
batteryState = math.MaxUint8
|
||||
|
||||
result := make([]Battery, 0, len(batteries))
|
||||
for _, bat := range batteries {
|
||||
if bat.MaxCapacity == 0 {
|
||||
if bat.MaxCapacity <= 0 {
|
||||
// skip ghost batteries with 0 capacity
|
||||
// https://github.com/distatus/battery/issues/34
|
||||
continue
|
||||
}
|
||||
totalCapacity += bat.MaxCapacity
|
||||
totalCharge += min(bat.CurrentCapacity, bat.MaxCapacity)
|
||||
|
||||
percent := min(max(float64(bat.CurrentCapacity)/float64(bat.MaxCapacity)*100, 0), 100)
|
||||
state := stateUnknown
|
||||
switch {
|
||||
case !bat.ExternalConnected:
|
||||
batteryState = stateDischarging
|
||||
state = stateDischarging
|
||||
case bat.IsCharging:
|
||||
batteryState = stateCharging
|
||||
state = stateCharging
|
||||
case bat.CurrentCapacity == 0:
|
||||
batteryState = stateEmpty
|
||||
state = stateEmpty
|
||||
case !bat.FullyCharged:
|
||||
batteryState = stateIdle
|
||||
state = stateIdle
|
||||
default:
|
||||
batteryState = stateFull
|
||||
state = stateFull
|
||||
}
|
||||
result = append(result, Battery{Name: "Primary", Percent: uint8(percent), State: state,
|
||||
FullChargeCapacity: uint64(bat.MaxCapacity), HasFullChargeCapacity: true, System: true})
|
||||
}
|
||||
|
||||
if totalCapacity == 0 || batteryState == math.MaxUint8 {
|
||||
return batteryPercent, batteryState, errors.New("no battery capacity")
|
||||
if len(result) == 0 {
|
||||
return nil, errNoBatteries
|
||||
}
|
||||
|
||||
batteryPercent = uint8(float64(totalCharge) / float64(totalCapacity) * 100)
|
||||
return batteryPercent, batteryState, nil
|
||||
return normalizeBatteries(result), nil
|
||||
}
|
||||
|
||||
@@ -3,58 +3,19 @@
|
||||
package battery
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"github.com/henrygd/beszel/agent/utils"
|
||||
)
|
||||
|
||||
// getBatteryPaths returns the paths of all batteries in /sys/class/power_supply
|
||||
var getBatteryPaths func() ([]string, error)
|
||||
var batteryRoot = "/sys/class/power_supply"
|
||||
|
||||
// HasReadableBattery checks if the system has a battery and returns true if it does.
|
||||
var HasReadableBattery func() bool
|
||||
|
||||
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
|
||||
})
|
||||
// HasReadableBattery reports whether collection currently finds a readable battery.
|
||||
func HasReadableBattery() bool {
|
||||
batteries, _ := GetBatteryStats()
|
||||
return len(batteries) > 0
|
||||
}
|
||||
|
||||
func parseSysfsState(status string) uint8 {
|
||||
@@ -74,26 +35,18 @@ func parseSysfsState(status string) uint8 {
|
||||
}
|
||||
}
|
||||
|
||||
// GetBatteryStats returns the current battery percent and charge state.
|
||||
// Reads /sys/class/power_supply/*/capacity directly so the kernel-reported
|
||||
// value is used, which is always 0-100 and matches what the OS displays.
|
||||
func GetBatteryStats() (batteryPercent uint8, batteryState uint8, err error) {
|
||||
if !HasReadableBattery() {
|
||||
return batteryPercent, batteryState, errors.ErrUnsupported
|
||||
}
|
||||
paths, err := getBatteryPaths()
|
||||
// GetBatteryStats re-enumerates power supplies and returns every readable battery.
|
||||
func GetBatteryStats() ([]Battery, error) {
|
||||
entries, err := os.ReadDir(batteryRoot)
|
||||
if err != nil {
|
||||
return batteryPercent, batteryState, err
|
||||
return nil, err
|
||||
}
|
||||
if len(paths) == 0 {
|
||||
return batteryPercent, batteryState, errors.New("no batteries")
|
||||
}
|
||||
|
||||
batteryState = math.MaxUint8
|
||||
totalPercent := 0
|
||||
count := 0
|
||||
|
||||
for _, path := range paths {
|
||||
batteries := make([]Battery, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
path := filepath.Join(batteryRoot, entry.Name())
|
||||
if utils.ReadStringFile(filepath.Join(path, "type")) != "Battery" {
|
||||
continue
|
||||
}
|
||||
capStr, ok := utils.ReadStringFileOK(filepath.Join(path, "capacity"))
|
||||
if !ok {
|
||||
continue
|
||||
@@ -102,19 +55,31 @@ func GetBatteryStats() (batteryPercent uint8, batteryState uint8, err error) {
|
||||
if parseErr != nil {
|
||||
continue
|
||||
}
|
||||
totalPercent += cap
|
||||
count++
|
||||
|
||||
state := parseSysfsState(utils.ReadStringFile(filepath.Join(path, "status")))
|
||||
if state != stateUnknown {
|
||||
batteryState = state
|
||||
cap = min(max(cap, 0), 100)
|
||||
name := utils.ReadStringFile(filepath.Join(path, "model_name"))
|
||||
if name == "" {
|
||||
name = utils.ReadStringFile(filepath.Join(path, "model"))
|
||||
}
|
||||
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 count == 0 || batteryState == math.MaxUint8 {
|
||||
return batteryPercent, batteryState, errors.New("no battery capacity")
|
||||
if len(batteries) == 0 {
|
||||
return nil, errNoBatteries
|
||||
}
|
||||
|
||||
batteryPercent = uint8(totalPercent / count)
|
||||
return batteryPercent, batteryState, nil
|
||||
return normalizeBatteries(batteries), nil
|
||||
}
|
||||
|
||||
@@ -8,194 +8,102 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// setupFakeSysfs creates a temporary sysfs-like tree under t.TempDir(),
|
||||
// swaps sysfsPowerSupply, resets the sync.Once caches, and restores
|
||||
// everything on cleanup. Returns a helper to create battery directories.
|
||||
func setupFakeSysfs(t *testing.T) (tmpDir string, addBattery func(name, capacity, status string)) {
|
||||
type fakeBattery struct{ id, name, capacity, status, full, scope string }
|
||||
|
||||
func setupFakeSysfs(t *testing.T) (string, func(fakeBattery)) {
|
||||
t.Helper()
|
||||
|
||||
tmp := t.TempDir()
|
||||
resetBatteryState(tmp)
|
||||
|
||||
write := func(path, content string) {
|
||||
root := t.TempDir()
|
||||
previousRoot := batteryRoot
|
||||
batteryRoot = root
|
||||
t.Cleanup(func() { batteryRoot = previousRoot })
|
||||
write := func(path, value string) {
|
||||
t.Helper()
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
|
||||
require.NoError(t, os.WriteFile(path, []byte(value), 0o644))
|
||||
}
|
||||
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)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
write(filepath.Join(dir, "status"), b.status)
|
||||
if b.name != "" {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
return root, add
|
||||
}
|
||||
|
||||
func TestParseSysfsState(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want uint8
|
||||
}{
|
||||
{"Empty", stateEmpty},
|
||||
{"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)
|
||||
}
|
||||
assert.Equal(t, stateEmpty, parseSysfsState("Empty"))
|
||||
assert.Equal(t, stateFull, parseSysfsState("Full"))
|
||||
assert.Equal(t, stateCharging, parseSysfsState("Charging"))
|
||||
assert.Equal(t, stateDischarging, parseSysfsState("Discharging"))
|
||||
assert.Equal(t, stateIdle, parseSysfsState("Not charging"))
|
||||
assert.Equal(t, stateUnknown, parseSysfsState("SomethingElse"))
|
||||
}
|
||||
|
||||
func TestGetBatteryStats_SingleBattery(t *testing.T) {
|
||||
_, addBattery := setupFakeSysfs(t)
|
||||
addBattery("BAT0", "72", "Discharging")
|
||||
|
||||
pct, state, err := GetBatteryStats()
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, uint8(72), pct)
|
||||
assert.Equal(t, stateDischarging, state)
|
||||
func TestGetBatteryStatsMultipleNamedAndPrimary(t *testing.T) {
|
||||
_, add := setupFakeSysfs(t)
|
||||
add(fakeBattery{id: "BAT0", name: "Primary", capacity: "105", status: "Charging", full: "5000", scope: "System"})
|
||||
add(fakeBattery{id: "hidpp_battery_0", name: "MX Keys S", capacity: "55", status: "Unknown", full: "900", scope: "Device"})
|
||||
batteries, err := GetBatteryStats()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, batteries, 2)
|
||||
assert.Equal(t, "Primary", batteries[0].Name)
|
||||
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 TestGetBatteryStats_MultipleBatteries(t *testing.T) {
|
||||
_, addBattery := setupFakeSysfs(t)
|
||||
addBattery("BAT0", "80", "Charging")
|
||||
addBattery("BAT1", "40", "Charging")
|
||||
|
||||
pct, state, err := GetBatteryStats()
|
||||
assert.NoError(t, err)
|
||||
// average of 80 and 40 = 60
|
||||
assert.EqualValues(t, 60, pct)
|
||||
assert.Equal(t, stateCharging, state)
|
||||
func TestGetBatteryStatsFallbackDuplicatesAndUnreadable(t *testing.T) {
|
||||
root, add := setupFakeSysfs(t)
|
||||
add(fakeBattery{id: "BAT0", name: "Keyboard", capacity: "80", status: "Discharging"})
|
||||
add(fakeBattery{id: "BAT1", name: "Keyboard", capacity: "-4", status: "SomethingWeird"})
|
||||
add(fakeBattery{id: "BAT2", capacity: "not-a-number", status: "Charging"})
|
||||
add(fakeBattery{id: "BAT3", capacity: "42", status: "Full"})
|
||||
ac := filepath.Join(root, "AC0")
|
||||
require.NoError(t, os.MkdirAll(ac, 0o755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(ac, "type"), []byte("Mains"), 0o644))
|
||||
batteries, err := GetBatteryStats()
|
||||
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 TestGetBatteryStats_FullBattery(t *testing.T) {
|
||||
_, addBattery := setupFakeSysfs(t)
|
||||
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()
|
||||
func TestGetBatteryStatsHotPlugReenumerates(t *testing.T) {
|
||||
_, add := setupFakeSysfs(t)
|
||||
_, err := GetBatteryStats()
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestGetBatteryStats_NonBatterySupplyIgnored(t *testing.T) {
|
||||
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.False(t, HasReadableBattery())
|
||||
add(fakeBattery{id: "BAT0", capacity: "64", status: "Discharging"})
|
||||
batteries, err := GetBatteryStats()
|
||||
require.NoError(t, err)
|
||||
assert.True(t, HasReadableBattery())
|
||||
require.Len(t, batteries, 1)
|
||||
assert.Equal(t, uint8(64), batteries[0].Percent)
|
||||
}
|
||||
|
||||
func TestHasReadableBattery_False(t *testing.T) {
|
||||
setupFakeSysfs(t) // no batteries
|
||||
|
||||
assert.False(t, HasReadableBattery())
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
func TestGetBatteryStatsNoReadableCapacity(t *testing.T) {
|
||||
_, add := setupFakeSysfs(t)
|
||||
add(fakeBattery{id: "BAT0", status: "Charging"})
|
||||
_, err := GetBatteryStats()
|
||||
assert.Error(t, err)
|
||||
assert.False(t, HasReadableBattery())
|
||||
}
|
||||
|
||||
@@ -8,6 +8,6 @@ func HasReadableBattery() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func GetBatteryStats() (uint8, uint8, error) {
|
||||
return 0, 0, errors.ErrUnsupported
|
||||
func GetBatteryStats() ([]Battery, error) {
|
||||
return nil, errors.ErrUnsupported
|
||||
}
|
||||
|
||||
35
agent/battery/battery_test.go
Normal file
35
agent/battery/battery_test.go
Normal file
@@ -0,0 +1,35 @@
|
||||
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})
|
||||
}
|
||||
@@ -7,9 +7,6 @@ package battery
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"math"
|
||||
"sync"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
|
||||
@@ -79,7 +76,7 @@ var (
|
||||
setupDiDestroyDeviceInfoList = setupapi.NewProc("SetupDiDestroyDeviceInfoList")
|
||||
)
|
||||
|
||||
// winBatteryGet reads one battery by index. Returns (fullCapacity, currentCapacity, state, error).
|
||||
// winBatteryGet reads one battery by index.
|
||||
// Returns error == errNotFound when there are no more batteries.
|
||||
var errNotFound = errors.New("no more batteries")
|
||||
|
||||
@@ -122,7 +119,7 @@ func readWinBatteryState(powerState uint32) uint8 {
|
||||
}
|
||||
}
|
||||
|
||||
func winBatteryGet(idx int) (full, current uint32, state uint8, err error) {
|
||||
func winBatteryGet(idx int) (Battery, error) {
|
||||
hdev, err := setupDiSetup(
|
||||
setupDiGetClassDevsW,
|
||||
4,
|
||||
@@ -132,7 +129,7 @@ func winBatteryGet(idx int) (full, current uint32, state uint8, err error) {
|
||||
0, 0,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, 0, stateUnknown, err
|
||||
return Battery{}, err
|
||||
}
|
||||
defer syscall.SyscallN(setupDiDestroyDeviceInfoList.Addr(), hdev)
|
||||
|
||||
@@ -148,10 +145,10 @@ func winBatteryGet(idx int) (full, current uint32, state uint8, err error) {
|
||||
0,
|
||||
)
|
||||
if errno == 259 { // ERROR_NO_MORE_ITEMS
|
||||
return 0, 0, stateUnknown, errNotFound
|
||||
return Battery{}, errNotFound
|
||||
}
|
||||
if errno != 0 {
|
||||
return 0, 0, stateUnknown, errno
|
||||
return Battery{}, errno
|
||||
}
|
||||
|
||||
var cbRequired uint32
|
||||
@@ -165,7 +162,7 @@ func winBatteryGet(idx int) (full, current uint32, state uint8, err error) {
|
||||
0,
|
||||
)
|
||||
if errno != 0 && errno != 122 { // ERROR_INSUFFICIENT_BUFFER
|
||||
return 0, 0, stateUnknown, errno
|
||||
return Battery{}, errno
|
||||
}
|
||||
didd := make([]uint16, cbRequired/2)
|
||||
cbSize := (*uint32)(unsafe.Pointer(&didd[0]))
|
||||
@@ -185,7 +182,7 @@ func winBatteryGet(idx int) (full, current uint32, state uint8, err error) {
|
||||
0,
|
||||
)
|
||||
if errno != 0 {
|
||||
return 0, 0, stateUnknown, errno
|
||||
return Battery{}, errno
|
||||
}
|
||||
devicePath := &didd[2:][0]
|
||||
|
||||
@@ -199,7 +196,7 @@ func winBatteryGet(idx int) (full, current uint32, state uint8, err error) {
|
||||
0,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, 0, stateUnknown, err
|
||||
return Battery{}, err
|
||||
}
|
||||
defer windows.CloseHandle(handle)
|
||||
|
||||
@@ -216,7 +213,7 @@ func winBatteryGet(idx int) (full, current uint32, state uint8, err error) {
|
||||
&dwOut, nil,
|
||||
)
|
||||
if err != nil || bqi.BatteryTag == 0 {
|
||||
return 0, 0, stateUnknown, errors.New("battery tag not returned")
|
||||
return Battery{}, errors.New("battery tag not returned")
|
||||
}
|
||||
|
||||
var bi batteryInformation
|
||||
@@ -229,7 +226,21 @@ func winBatteryGet(idx int) (full, current uint32, state uint8, err error) {
|
||||
uint32(unsafe.Sizeof(bi)),
|
||||
&dwOut, nil,
|
||||
); err != nil {
|
||||
return 0, 0, stateUnknown, err
|
||||
return Battery{}, 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}
|
||||
@@ -243,56 +254,38 @@ func winBatteryGet(idx int) (full, current uint32, state uint8, err error) {
|
||||
uint32(unsafe.Sizeof(bs)),
|
||||
&dwOut, nil,
|
||||
); err != nil {
|
||||
return 0, 0, stateUnknown, err
|
||||
return Battery{}, err
|
||||
}
|
||||
|
||||
if bs.Capacity == 0xffffffff { // BATTERY_UNKNOWN_CAPACITY
|
||||
return 0, 0, stateUnknown, errors.New("battery capacity unknown")
|
||||
if bs.Capacity == 0xffffffff || bi.FullChargedCapacity == 0 || bi.FullChargedCapacity == 0xffffffff {
|
||||
return Battery{}, errors.New("battery capacity unknown")
|
||||
}
|
||||
|
||||
return bi.FullChargedCapacity, bs.Capacity, readWinBatteryState(bs.PowerState), nil
|
||||
percent := min(float64(bs.Capacity)/float64(bi.FullChargedCapacity)*100, 100)
|
||||
return Battery{Name: name, Percent: uint8(percent), State: readWinBatteryState(bs.PowerState),
|
||||
FullChargeCapacity: uint64(bi.FullChargedCapacity), HasFullChargeCapacity: true, System: true}, nil
|
||||
}
|
||||
|
||||
// HasReadableBattery checks if the system has a battery and returns true if it does.
|
||||
var HasReadableBattery = sync.OnceValue(func() bool {
|
||||
systemHasBattery := false
|
||||
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
|
||||
func HasReadableBattery() bool {
|
||||
batteries, _ := GetBatteryStats()
|
||||
return len(batteries) > 0
|
||||
}
|
||||
|
||||
// GetBatteryStats returns every readable battery reported by Windows.
|
||||
func GetBatteryStats() ([]Battery, error) {
|
||||
batteries := make([]Battery, 0, 2)
|
||||
for i := 0; ; i++ {
|
||||
full, current, state, bErr := winBatteryGet(i)
|
||||
battery, bErr := winBatteryGet(i)
|
||||
if errors.Is(bErr, errNotFound) {
|
||||
break
|
||||
}
|
||||
if bErr != nil || full == 0 {
|
||||
if bErr != nil {
|
||||
continue
|
||||
}
|
||||
totalFull += full
|
||||
totalCurrent += min(current, full)
|
||||
batteryState = state
|
||||
batteries = append(batteries, battery)
|
||||
}
|
||||
|
||||
if totalFull == 0 || batteryState == math.MaxUint8 {
|
||||
return batteryPercent, batteryState, errors.New("no battery capacity")
|
||||
if len(batteries) == 0 {
|
||||
return nil, errNoBatteries
|
||||
}
|
||||
|
||||
batteryPercent = uint8(float64(totalCurrent) / float64(totalFull) * 100)
|
||||
return batteryPercent, batteryState, nil
|
||||
return normalizeBatteries(batteries), nil
|
||||
}
|
||||
|
||||
@@ -53,8 +53,8 @@ func newWebSocketClient(agent *Agent) (client *WebSocketClient, err error) {
|
||||
client = &WebSocketClient{}
|
||||
|
||||
client.hubURL, err = url.Parse(hubURLStr)
|
||||
if err != nil {
|
||||
return nil, errors.New("invalid hub URL")
|
||||
if err != nil || client.hubURL.Host == "" {
|
||||
return nil, fmt.Errorf("invalid HUB_URL %q: must include scheme and host (e.g. http://hub.example.com:8090)", hubURLStr)
|
||||
}
|
||||
// get registration token
|
||||
client.token, err = getToken()
|
||||
|
||||
@@ -51,11 +51,18 @@ func TestNewWebSocketClient(t *testing.T) {
|
||||
errorMsg: "HUB_URL environment variable not set",
|
||||
},
|
||||
{
|
||||
name: "invalid URL",
|
||||
name: "malformed URL",
|
||||
hubURL: "ht\ttp://invalid",
|
||||
token: "test-token",
|
||||
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",
|
||||
|
||||
@@ -65,7 +65,6 @@ type dockerManager struct {
|
||||
dockerVersionChecked bool // Whether a version probe has completed successfully
|
||||
isWindows bool // Whether the Docker Engine API is running on Windows
|
||||
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
|
||||
excludeContainers []string // Patterns to exclude containers by name
|
||||
usingPodman bool // Whether the Docker Engine API is running on Podman
|
||||
@@ -374,16 +373,26 @@ func convertContainerPortsToString(ctr *container.ApiInfo) string {
|
||||
return ""
|
||||
}
|
||||
sort.Slice(ctr.Ports, func(i, j int) bool {
|
||||
return ctr.Ports[i].PublicPort < ctr.Ports[j].PublicPort
|
||||
if 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
|
||||
seenPorts := make(map[uint16]struct{})
|
||||
seen := make(map[string]struct{})
|
||||
for _, p := range ctr.Ports {
|
||||
_, ok := seenPorts[p.PublicPort]
|
||||
if p.PublicPort == 0 || ok {
|
||||
if p.PublicPort == 0 {
|
||||
continue
|
||||
}
|
||||
seenPorts[p.PublicPort] = struct{}{}
|
||||
keyIP := p.IP
|
||||
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 {
|
||||
builder.WriteString(", ")
|
||||
}
|
||||
@@ -747,20 +756,18 @@ func (dm *dockerManager) applyDockerVersionInfo(serverHeader string, versionInfo
|
||||
}
|
||||
}
|
||||
|
||||
// Decodes Docker API JSON response using a reusable buffer and decoder. Not thread safe.
|
||||
// Decodes a Docker API JSON response using a reusable buffer. Not thread safe.
|
||||
func (dm *dockerManager) decode(resp *http.Response, d any) error {
|
||||
if dm.buf == nil {
|
||||
// initialize buffer with 256kb starting size
|
||||
dm.buf = bytes.NewBuffer(make([]byte, 0, 1024*256))
|
||||
dm.decoder = json.NewDecoder(dm.buf)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer dm.buf.Reset()
|
||||
_, err := dm.buf.ReadFrom(resp.Body)
|
||||
if err != nil {
|
||||
if _, err := dm.buf.ReadFrom(resp.Body); err != nil {
|
||||
return err
|
||||
}
|
||||
return dm.decoder.Decode(d)
|
||||
return json.Unmarshal(dm.buf.Bytes(), d)
|
||||
}
|
||||
|
||||
// Test docker / podman sockets and return if one exists
|
||||
|
||||
@@ -804,6 +804,24 @@ func TestGetDockerStatsRetriesVersionCheckUntilSuccess(t *testing.T) {
|
||||
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) {
|
||||
dm := &dockerManager{
|
||||
lastCpuContainer: map[uint16]map[string]uint64{
|
||||
@@ -1003,6 +1021,44 @@ func TestCpuPercentageCalculationWithRealData(t *testing.T) {
|
||||
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) {
|
||||
// Create synthetic test data to avoid timing issues
|
||||
apiStats1 := &container.ApiStats{
|
||||
@@ -1864,6 +1920,14 @@ func TestConvertContainerPortsToString(t *testing.T) {
|
||||
},
|
||||
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",
|
||||
ports: []port{
|
||||
@@ -1872,6 +1936,22 @@ func TestConvertContainerPortsToString(t *testing.T) {
|
||||
},
|
||||
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",
|
||||
ports: []port{
|
||||
|
||||
101
agent/fans.go
Normal file
101
agent/fans.go
Normal file
@@ -0,0 +1,101 @@
|
||||
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
agent/fans_linux.go
Normal file
8
agent/fans_linux.go
Normal file
@@ -0,0 +1,8 @@
|
||||
//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
agent/fans_other.go
Normal file
7
agent/fans_other.go
Normal file
@@ -0,0 +1,7 @@
|
||||
//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
agent/fans_test.go
Normal file
87
agent/fans_test.go
Normal file
@@ -0,0 +1,87 @@
|
||||
//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)
|
||||
}
|
||||
25
agent/gpu.go
25
agent/gpu.go
@@ -48,6 +48,8 @@ type GPUManager struct {
|
||||
// Per-cache-key tracking for delta calculations
|
||||
// cacheKey -> gpuId -> snapshot of last count/usage/power values
|
||||
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
|
||||
@@ -90,6 +92,7 @@ const (
|
||||
collectorSourceNVML collectorSource = "nvml"
|
||||
collectorSourceNvidiaSMI collectorSource = collectorSource(nvidiaSmiCmd)
|
||||
collectorSourceIntelGpuTop collectorSource = collectorSource(intelGpuStatsCmd)
|
||||
collectorSourceIntelSysfs collectorSource = "intel_sysfs"
|
||||
collectorSourceAmdSysfs collectorSource = "amd_sysfs"
|
||||
collectorSourceRocmSMI collectorSource = collectorSource(rocmSmiCmd)
|
||||
collectorSourceMacmon collectorSource = collectorSource(macmonCmd)
|
||||
@@ -106,6 +109,7 @@ func isValidCollectorSource(source collectorSource) bool {
|
||||
collectorSourceNVML,
|
||||
collectorSourceNvidiaSMI,
|
||||
collectorSourceIntelGpuTop,
|
||||
collectorSourceIntelSysfs,
|
||||
collectorSourceAmdSysfs,
|
||||
collectorSourceRocmSMI,
|
||||
collectorSourceMacmon,
|
||||
@@ -122,6 +126,8 @@ type gpuCapabilities struct {
|
||||
hasAmdSysfs bool
|
||||
hasTegrastats bool
|
||||
hasIntelGpuTop bool
|
||||
hasXe bool
|
||||
hasIntelSysfs bool
|
||||
hasNvtop bool
|
||||
hasMacmon bool
|
||||
hasPowermetrics bool
|
||||
@@ -369,12 +375,13 @@ func (gm *GPUManager) calculateGPUAverage(id string, gpu *system.GPUData, cacheK
|
||||
|
||||
gpuAvg.Power = utils.TwoDecimals(deltaPower / float64(deltaCount))
|
||||
|
||||
gpuAvg.PowerPkg = utils.TwoDecimals(deltaPowerPkg / float64(deltaCount))
|
||||
|
||||
if gpu.Engines != nil {
|
||||
// make fresh map for averaged engine metrics to avoid mutating
|
||||
// the accumulator map stored in gm.GpuDataMap
|
||||
gpuAvg.Engines = make(map[string]float64, len(gpu.Engines))
|
||||
gpuAvg.Usage = gm.calculateIntelGPUUsage(&gpuAvg, gpu, lastSnapshot, deltaCount)
|
||||
gpuAvg.PowerPkg = utils.TwoDecimals(deltaPowerPkg / float64(deltaCount))
|
||||
} else {
|
||||
gpuAvg.Usage = utils.TwoDecimals(deltaUsage / float64(deltaCount))
|
||||
}
|
||||
@@ -444,6 +451,8 @@ func (gm *GPUManager) storeSnapshot(id string, gpu *system.GPUData, cacheKey uin
|
||||
func (gm *GPUManager) discoverGpuCapabilities() gpuCapabilities {
|
||||
caps := gpuCapabilities{
|
||||
hasAmdSysfs: gm.hasAmdSysfs(),
|
||||
hasXe: gm.hasXe(),
|
||||
hasIntelSysfs: gm.hasIntelSysfs(),
|
||||
}
|
||||
if _, err := exec.LookPath(nvidiaSmiCmd); err == nil {
|
||||
caps.hasNvidiaSmi = true
|
||||
@@ -472,7 +481,7 @@ func (gm *GPUManager) discoverGpuCapabilities() gpuCapabilities {
|
||||
}
|
||||
|
||||
func hasAnyGpuCollector(caps gpuCapabilities) bool {
|
||||
return caps.hasNvidiaSmi || caps.hasRocmSmi || caps.hasAmdSysfs || caps.hasTegrastats || caps.hasIntelGpuTop || caps.hasNvtop || caps.hasMacmon || caps.hasPowermetrics
|
||||
return caps.hasNvidiaSmi || caps.hasRocmSmi || caps.hasAmdSysfs || caps.hasTegrastats || caps.hasIntelGpuTop || caps.hasIntelSysfs || caps.hasNvtop || caps.hasMacmon || caps.hasPowermetrics
|
||||
}
|
||||
|
||||
func (gm *GPUManager) startIntelCollector() {
|
||||
@@ -563,6 +572,13 @@ func (gm *GPUManager) collectorDefinitions(caps gpuCapabilities) map[collectorSo
|
||||
return true
|
||||
},
|
||||
},
|
||||
collectorSourceIntelSysfs: {
|
||||
group: collectorGroupIntel,
|
||||
available: caps.hasIntelSysfs,
|
||||
start: func(_ func()) bool {
|
||||
return gm.startIntelSysfsCollector()
|
||||
},
|
||||
},
|
||||
collectorSourceAmdSysfs: {
|
||||
group: collectorGroupAmd,
|
||||
available: caps.hasAmdSysfs,
|
||||
@@ -705,9 +721,12 @@ func (gm *GPUManager) resolveLegacyCollectorPriority(caps gpuCapabilities) []col
|
||||
priorities = append(priorities, collectorSourceAmdSysfs)
|
||||
}
|
||||
|
||||
if caps.hasIntelGpuTop {
|
||||
if caps.hasIntelGpuTop && !caps.hasXe {
|
||||
priorities = append(priorities, collectorSourceIntelGpuTop)
|
||||
}
|
||||
if caps.hasIntelSysfs {
|
||||
priorities = append(priorities, collectorSourceIntelSysfs)
|
||||
}
|
||||
|
||||
// Apple collectors are currently opt-in only for testing.
|
||||
// Enable them with GPU_COLLECTOR=macmon or GPU_COLLECTOR=powermetrics.
|
||||
|
||||
280
agent/gpu_intel_sysfs_linux.go
Normal file
280
agent/gpu_intel_sysfs_linux.go
Normal file
@@ -0,0 +1,280 @@
|
||||
//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
agent/gpu_intel_sysfs_linux_test.go
Normal file
217
agent/gpu_intel_sysfs_linux_test.go
Normal file
@@ -0,0 +1,217 @@
|
||||
//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
agent/gpu_intel_sysfs_unsupported.go
Normal file
13
agent/gpu_intel_sysfs_unsupported.go
Normal file
@@ -0,0 +1,13 @@
|
||||
//go:build !linux
|
||||
|
||||
package agent
|
||||
|
||||
type intelSysfsEnergySnapshot struct{}
|
||||
|
||||
func (gm *GPUManager) hasIntelSysfs() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (gm *GPUManager) startIntelSysfsCollector() bool {
|
||||
return false
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -48,9 +49,14 @@ func (gm *GPUManager) updateNvtopSnapshots(snapshots []nvtopSnapshot) bool {
|
||||
|
||||
valid := false
|
||||
usedIDs := make(map[string]struct{}, len(snapshots))
|
||||
var xeName string
|
||||
for i, sample := range snapshots {
|
||||
// nvtop leaves device_name unset on xe devices.
|
||||
if sample.DeviceName == "" {
|
||||
continue
|
||||
if xeName == "" {
|
||||
xeName = xeGpuName()
|
||||
}
|
||||
sample.DeviceName = xeName
|
||||
}
|
||||
indexID := "n" + strconv.Itoa(i)
|
||||
id := indexID
|
||||
@@ -158,3 +164,38 @@ 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 + ")"
|
||||
}
|
||||
|
||||
@@ -332,11 +332,12 @@ func TestUpdateNvtopSnapshotsKeepsDeviceAssociationWhenOrderChanges(t *testing.T
|
||||
}
|
||||
|
||||
func TestParseCollectorPriority(t *testing.T) {
|
||||
got := parseCollectorPriority(" nvml, nvidia-smi, intel_gpu_top, amd_sysfs, nvtop, rocm-smi, bad ")
|
||||
got := parseCollectorPriority(" nvml, nvidia-smi, intel_gpu_top, intel_sysfs, amd_sysfs, nvtop, rocm-smi, bad ")
|
||||
want := []collectorSource{
|
||||
collectorSourceNVML,
|
||||
collectorSourceNvidiaSMI,
|
||||
collectorSourceIntelGpuTop,
|
||||
collectorSourceIntelSysfs,
|
||||
collectorSourceAmdSysfs,
|
||||
collectorSourceNVTop,
|
||||
collectorSourceRocmSMI,
|
||||
|
||||
@@ -166,14 +166,16 @@ type GetSmartDataHandler struct{}
|
||||
|
||||
func (h *GetSmartDataHandler) Handle(hctx *HandlerContext) error {
|
||||
if hctx.Agent.smartManager == nil {
|
||||
// return empty map to indicate no data
|
||||
return hctx.SendResponse(map[string]smart.SmartData{}, hctx.RequestID)
|
||||
return hctx.SendResponse(smart.SmartDataResponse{Data: map[string]smart.SmartData{}}, hctx.RequestID)
|
||||
}
|
||||
if err := hctx.Agent.smartManager.Refresh(false); err != nil {
|
||||
complete, err := hctx.Agent.smartManager.Refresh(false)
|
||||
if err != nil {
|
||||
slog.Debug("smart refresh failed", "err", err)
|
||||
}
|
||||
data := hctx.Agent.smartManager.GetCurrentData()
|
||||
return hctx.SendResponse(data, hctx.RequestID)
|
||||
return hctx.SendResponse(smart.SmartDataResponse{
|
||||
Data: hctx.Agent.smartManager.GetCurrentData(),
|
||||
Complete: complete,
|
||||
}, hctx.RequestID)
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/fxamacker/cbor/v2"
|
||||
"github.com/henrygd/beszel/internal/common"
|
||||
"github.com/henrygd/beszel/internal/entities/smart"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -17,6 +18,18 @@ type MockHandler struct {
|
||||
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 {
|
||||
if m.handleFunc != nil {
|
||||
return m.handleFunc(ctx)
|
||||
|
||||
@@ -180,8 +180,13 @@ func mdraidSmartStatus(health mdraidHealth) string {
|
||||
if health.degraded > 0 {
|
||||
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 {
|
||||
case "check", "repair":
|
||||
case "repair":
|
||||
return "WARNING"
|
||||
}
|
||||
switch state {
|
||||
|
||||
@@ -94,6 +94,18 @@ func TestMdraidSmartStatus(t *testing.T) {
|
||||
if got := mdraidSmartStatus(mdraidHealth{arrayState: "active", syncAction: "recover"}); got != "WARNING" {
|
||||
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" {
|
||||
t.Fatalf("mdraidSmartStatus(clean) = %q, want PASSED", got)
|
||||
}
|
||||
|
||||
@@ -21,6 +21,9 @@ func newAgentResponse(data any, requestID *uint32) common.AgentResponse {
|
||||
response.String = &v
|
||||
case map[string]smart.SmartData:
|
||||
response.SmartData = v
|
||||
case smart.SmartDataResponse:
|
||||
response.SmartData = v.Data
|
||||
response.SmartComplete = v.Complete
|
||||
case systemd.ServiceDetails:
|
||||
response.ServiceInfo = v
|
||||
default:
|
||||
|
||||
@@ -29,9 +29,6 @@ type ServerOptions struct {
|
||||
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.
|
||||
// It configures the server with secure defaults, sets up authentication,
|
||||
// and begins listening for connections. Returns an error if the server
|
||||
@@ -99,24 +96,15 @@ func (a *Agent) StartServer(opts ServerOptions) error {
|
||||
return a.server.Serve(ln)
|
||||
}
|
||||
|
||||
// getHubVersion retrieves and caches the hub version for a given session.
|
||||
// It extracts the version from the SSH client version string and caches
|
||||
// it to avoid repeated parsing. Returns a zero version if parsing fails.
|
||||
func (a *Agent) getHubVersion(sessionId string, sessionCtx ssh.Context) semver.Version {
|
||||
if hubVersions == nil {
|
||||
hubVersions = make(map[string]semver.Version, 1)
|
||||
}
|
||||
hubVersion, ok := hubVersions[sessionId]
|
||||
if ok {
|
||||
return hubVersion
|
||||
}
|
||||
// Extract hub version from SSH client version
|
||||
// getHubVersion extracts the hub version from the SSH client version string
|
||||
// for a given session. Returns a zero version if parsing fails.
|
||||
func (a *Agent) getHubVersion(sessionCtx ssh.Context) semver.Version {
|
||||
clientVersion := sessionCtx.Value(ssh.ContextKeyClientVersion)
|
||||
if versionStr, ok := clientVersion.(string); ok {
|
||||
hubVersion, _ = extractHubVersion(versionStr)
|
||||
hubVersion, _ := extractHubVersion(versionStr)
|
||||
return hubVersion
|
||||
}
|
||||
hubVersions[sessionId] = hubVersion
|
||||
return hubVersion
|
||||
return semver.Version{}
|
||||
}
|
||||
|
||||
// handleSession handles an incoming SSH session by gathering system statistics
|
||||
@@ -127,9 +115,8 @@ func (a *Agent) handleSession(s ssh.Session) {
|
||||
a.connectionManager.eventChan <- SSHConnect
|
||||
|
||||
sessionCtx := s.Context()
|
||||
sessionID := sessionCtx.SessionID()
|
||||
|
||||
hubVersion := a.getHubVersion(sessionID, sessionCtx)
|
||||
hubVersion := a.getHubVersion(sessionCtx)
|
||||
|
||||
// Legacy one-shot behavior for older hubs
|
||||
if hubVersion.LT(beszel.MinVersionAgentResponse) {
|
||||
|
||||
@@ -404,27 +404,23 @@ func TestGetHubVersion(t *testing.T) {
|
||||
clientVersion: "SSH-2.0-beszel_0.12.0",
|
||||
}
|
||||
|
||||
// Test first call - should extract and cache version
|
||||
version := agent.getHubVersion("test-session-123", mockCtx)
|
||||
// Test first call - should extract version
|
||||
version := agent.getHubVersion(mockCtx)
|
||||
assert.Equal(t, "0.12.0", version.String())
|
||||
|
||||
// Test second call - should return cached version
|
||||
mockCtx.clientVersion = "SSH-2.0-beszel_0.11.0" // Change version but should still return cached
|
||||
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)
|
||||
// Test that version reflects the current client version (no stale caching)
|
||||
mockCtx.clientVersion = "SSH-2.0-beszel_0.11.0"
|
||||
version = agent.getHubVersion(mockCtx)
|
||||
assert.Equal(t, "0.11.0", version.String())
|
||||
|
||||
// Test with invalid version string (non-beszel client)
|
||||
mockCtx.clientVersion = "SSH-2.0-OpenSSH_8.0"
|
||||
version = agent.getHubVersion("invalid-session", mockCtx)
|
||||
version = agent.getHubVersion(mockCtx)
|
||||
assert.Equal(t, "0.0.0", version.String()) // Should be empty version for non-beszel clients
|
||||
|
||||
// Test with no client version
|
||||
mockCtx.clientVersion = ""
|
||||
version = agent.getHubVersion("no-version-session", mockCtx)
|
||||
version = agent.getHubVersion(mockCtx)
|
||||
assert.True(t, version.EQ(semver.Version{})) // Should be empty version
|
||||
}
|
||||
|
||||
@@ -501,9 +497,6 @@ func TestWriteToSessionEncoding(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
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("")
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -585,39 +578,28 @@ func createTestCombinedData() *system.CombinedData {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHubVersionCaching(t *testing.T) {
|
||||
// Reset the global hubVersions map to ensure clean state
|
||||
hubVersions = nil
|
||||
|
||||
// TestGetHubVersionConcurrent guards against a regression of the
|
||||
// "concurrent map writes" panic previously caused by a shared, unsynchronized
|
||||
// hubVersions cache (see https://github.com/henrygd/beszel/issues/2128).
|
||||
// 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("")
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx1 := &mockSSHContext{
|
||||
sessionID: "session1",
|
||||
clientVersion: "SSH-2.0-beszel_0.12.0",
|
||||
const goroutines = 50
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(goroutines)
|
||||
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)
|
||||
}
|
||||
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())
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
@@ -55,6 +55,11 @@ type DeviceInfo struct {
|
||||
typeVerified bool
|
||||
// parserType holds the parser type (nvme, sat, scsi) that last succeeded.
|
||||
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.
|
||||
@@ -65,8 +70,9 @@ type deviceKey struct {
|
||||
|
||||
var errNoValidSmartData = fmt.Errorf("no valid SMART data found") // Error for missing data
|
||||
|
||||
// Refresh updates SMART data for all known devices
|
||||
func (sm *SmartManager) Refresh(forceScan bool) error {
|
||||
// Refresh updates SMART data for all known devices and reports whether every
|
||||
// discovered device was collected successfully.
|
||||
func (sm *SmartManager) Refresh(forceScan bool) (bool, error) {
|
||||
sm.refreshMutex.Lock()
|
||||
defer sm.refreshMutex.Unlock()
|
||||
|
||||
@@ -87,7 +93,7 @@ func (sm *SmartManager) Refresh(forceScan bool) error {
|
||||
}
|
||||
}
|
||||
|
||||
return sm.resolveRefreshError(scanErr, collectErr)
|
||||
return scanErr == nil && collectErr == nil, sm.resolveRefreshError(scanErr, collectErr)
|
||||
}
|
||||
|
||||
// devicesSnapshot returns a copy of the current device slice to avoid iterating
|
||||
@@ -251,8 +257,9 @@ func (sm *SmartManager) parseConfiguredDevices(config string) ([]*DeviceInfo, er
|
||||
}
|
||||
|
||||
devices = append(devices, &DeviceInfo{
|
||||
Name: name,
|
||||
Type: devType,
|
||||
Name: name,
|
||||
Type: devType,
|
||||
explicitType: devType != "",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -558,7 +565,9 @@ func (sm *SmartManager) smartctlArgs(deviceInfo *DeviceInfo, includeStandby bool
|
||||
deviceType = strings.ToLower(deviceInfo.Type)
|
||||
parserType = strings.ToLower(deviceInfo.parserType)
|
||||
// types sometimes misidentified in scan; see github.com/henrygd/beszel/issues/1345
|
||||
if deviceType != "" && deviceType != "scsi" && deviceType != "ata" {
|
||||
// An explicit SMART_DEVICES ":type" hint is a deliberate override, so always
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -663,6 +672,9 @@ func mergeDeviceLists(existing, scanned, configured []*DeviceInfo) []*DeviceInfo
|
||||
target.Type = prev.Type
|
||||
target.typeVerified = true
|
||||
target.parserType = prev.parserType
|
||||
if prev.explicitType {
|
||||
target.explicitType = true
|
||||
}
|
||||
}
|
||||
|
||||
// applyConfiguredMetadata updates a matched device with any configured
|
||||
@@ -676,6 +688,9 @@ func mergeDeviceLists(existing, scanned, configured []*DeviceInfo) []*DeviceInfo
|
||||
existingDev.typeVerified = false
|
||||
existingDev.parserType = normalizeParserType(newType)
|
||||
}
|
||||
if configuredDev.explicitType {
|
||||
existingDev.explicitType = true
|
||||
}
|
||||
if configuredDev.InfoName != "" {
|
||||
existingDev.InfoName = configuredDev.InfoName
|
||||
}
|
||||
|
||||
@@ -392,6 +392,81 @@ 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) {
|
||||
scanErr := errors.New("scan failed")
|
||||
collectErr := errors.New("collect failed")
|
||||
|
||||
@@ -132,9 +132,14 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
|
||||
var systemStats system.Stats
|
||||
|
||||
// battery
|
||||
if batteryPercent, batteryState, err := battery.GetBatteryStats(); err == nil {
|
||||
systemStats.Battery[0] = batteryPercent
|
||||
systemStats.Battery[1] = batteryState
|
||||
if batteries, err := battery.GetBatteryStats(); err == nil {
|
||||
systemStats.Batteries = make(map[string]uint8, len(batteries))
|
||||
for _, device := range batteries {
|
||||
systemStats.Batteries[device.Name] = device.Percent
|
||||
}
|
||||
if primary, ok := battery.Primary(batteries); ok {
|
||||
systemStats.Battery = [2]uint8{primary.Percent, primary.State}
|
||||
}
|
||||
}
|
||||
|
||||
// cpu metrics
|
||||
@@ -169,21 +174,11 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
|
||||
|
||||
// memory
|
||||
if v, err := mem.VirtualMemory(); err == nil {
|
||||
used, cacheBuff, swapUsed := calculateHostMemoryUsage(v, a.memCalc == "htop")
|
||||
// swap
|
||||
systemStats.Swap = utils.BytesToGigabytes(v.SwapTotal)
|
||||
systemStats.SwapUsed = utils.BytesToGigabytes(v.SwapTotal - v.SwapFree - v.SwapCached)
|
||||
// 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
|
||||
}
|
||||
systemStats.SwapUsed = utils.BytesToGigabytes(swapUsed)
|
||||
v.Used = used
|
||||
// if a.memCalc == "legacy" {
|
||||
// v.Used = v.Total - v.Free - v.Buffers - v.Cached
|
||||
// cacheBuff = v.Total - v.Free - v.Used
|
||||
@@ -193,10 +188,14 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
|
||||
if a.zfs {
|
||||
if arcSize, _ := zfs.ARCSize(); arcSize > 0 && arcSize < v.Used {
|
||||
v.Used = v.Used - arcSize
|
||||
v.UsedPercent = float64(v.Used) / float64(v.Total) * 100.0
|
||||
systemStats.MemZfsArc = utils.BytesToGigabytes(arcSize)
|
||||
}
|
||||
}
|
||||
if v.Total > 0 {
|
||||
v.UsedPercent = float64(v.Used) / float64(v.Total) * 100.0
|
||||
} else {
|
||||
v.UsedPercent = 0
|
||||
}
|
||||
systemStats.Mem = utils.BytesToGigabytes(v.Total)
|
||||
systemStats.MemBuffCache = utils.BytesToGigabytes(cacheBuff)
|
||||
systemStats.MemUsed = utils.BytesToGigabytes(v.Used)
|
||||
@@ -216,6 +215,9 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
|
||||
// TODO: maybe refactor to methods on systemStats
|
||||
a.updateTemperatures(&systemStats)
|
||||
|
||||
// fan speeds (Linux-only; sysfs hwmon)
|
||||
a.updateFans(&systemStats)
|
||||
|
||||
// GPU data
|
||||
if a.gpuManager != nil {
|
||||
// reset high gpu percent
|
||||
@@ -263,6 +265,38 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
|
||||
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
|
||||
func getOsPrettyName() (string, error) {
|
||||
file, err := os.Open("/etc/os-release")
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
"github.com/henrygd/beszel/internal/common"
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
"github.com/shirou/gopsutil/v4/mem"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -33,6 +34,59 @@ func TestGatherStatsDoesNotAttachDetailsToCachedRequests(t *testing.T) {
|
||||
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) {
|
||||
agent := &Agent{}
|
||||
|
||||
|
||||
3
docs/index.yaml
Normal file
3
docs/index.yaml
Normal file
@@ -0,0 +1,3 @@
|
||||
apiVersion: v1
|
||||
entries: {}
|
||||
generated: "2026-03-05T00:00:00Z"
|
||||
57
go.mod
57
go.mod
@@ -1,27 +1,27 @@
|
||||
module github.com/henrygd/beszel
|
||||
|
||||
go 1.26.3
|
||||
go 1.26.6
|
||||
|
||||
require (
|
||||
github.com/blang/semver v3.5.1+incompatible
|
||||
github.com/coreos/go-systemd/v22 v22.7.0
|
||||
github.com/ebitengine/purego v0.10.0
|
||||
github.com/fxamacker/cbor/v2 v2.9.0
|
||||
github.com/ebitengine/purego v0.10.2
|
||||
github.com/fxamacker/cbor/v2 v2.9.2
|
||||
github.com/gliderlabs/ssh v0.3.8
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/lxzan/gws v1.9.1
|
||||
github.com/nicholas-fedor/shoutrrr v0.15.1
|
||||
github.com/lxzan/gws v1.10.1
|
||||
github.com/nicholas-fedor/shoutrrr v0.17.0
|
||||
github.com/pocketbase/dbx v1.12.0
|
||||
github.com/pocketbase/pocketbase v0.36.8
|
||||
github.com/shirou/gopsutil/v4 v4.26.3
|
||||
github.com/pocketbase/pocketbase v0.39.10
|
||||
github.com/shirou/gopsutil/v4 v4.26.7
|
||||
github.com/spf13/cast v1.10.0
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/spf13/pflag v1.0.10
|
||||
github.com/stretchr/testify v1.11.1
|
||||
golang.org/x/crypto v0.52.0
|
||||
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90
|
||||
golang.org/x/net v0.55.0
|
||||
golang.org/x/sys v0.45.0
|
||||
golang.org/x/crypto v0.55.0
|
||||
golang.org/x/exp v0.0.0-20260811152304-ee035b5b010f
|
||||
golang.org/x/net v0.57.0
|
||||
golang.org/x/sys v0.47.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
howett.net/plist v1.0.1
|
||||
)
|
||||
@@ -35,34 +35,35 @@ require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/eclipse/paho.golang v0.23.0 // indirect
|
||||
github.com/fatih/color v1.19.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||
github.com/ganigeorgiev/fexpr v0.5.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.10.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.15 // indirect
|
||||
github.com/ganigeorgiev/fexpr v0.6.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/godbus/dbus/v5 v5.2.2 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/klauspost/compress v1.18.5 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20260324052639-156f7da3f749 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.22 // indirect
|
||||
github.com/klauspost/compress v1.19.2 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 // indirect
|
||||
github.com/mattn/go-colorable v0.1.15 // indirect
|
||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||
github.com/pocketbase/ozzo-validation/v4 v4.3.0 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.16 // indirect
|
||||
github.com/tklauser/numcpus v0.11.0 // indirect
|
||||
github.com/tklauser/go-sysconf v0.4.0 // indirect
|
||||
github.com/tklauser/numcpus v0.12.0 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
golang.org/x/image v0.41.0 // indirect
|
||||
golang.org/x/image v0.45.0 // indirect
|
||||
golang.org/x/oauth2 v0.36.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/term v0.43.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
modernc.org/libc v1.70.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/term v0.45.0 // indirect
|
||||
golang.org/x/text v0.41.0 // indirect
|
||||
modernc.org/libc v1.74.1 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/sqlite v1.48.0 // indirect
|
||||
modernc.org/memory v1.12.0 // indirect
|
||||
modernc.org/sqlite v1.55.0 // indirect
|
||||
)
|
||||
|
||||
157
go.sum
157
go.sum
@@ -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/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/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
|
||||
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/ebitengine/purego v0.10.2 h1:W809HbnvzAxgdm+aOvlSekrM16wGCdT/e76+9tS7gzE=
|
||||
github.com/ebitengine/purego v0.10.2/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/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/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
|
||||
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/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/ganigeorgiev/fexpr v0.5.0 h1:XA9JxtTE/Xm+g/JFI6RfZEHSiQlk+1glLvRK1Lpv/Tk=
|
||||
github.com/ganigeorgiev/fexpr v0.5.0/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
|
||||
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
||||
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
||||
github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
|
||||
github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI=
|
||||
github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ=
|
||||
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/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
|
||||
github.com/go-logr/logr v1.4.4/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.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
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.9.1 h1:FrjNGn/BsJQjVRuSa8CBrM5BWA9BWoXXat3KrtSb/iI=
|
||||
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/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/pprof v0.0.0-20260507013755-92041b743c96 h1:YDDnaZ9afWajDboPMt9Vikqca/yWAX7KAxVzb4lJU1M=
|
||||
github.com/google/pprof v0.0.0-20260507013755-92041b743c96/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
@@ -66,47 +66,49 @@ 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/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jarcoal/httpmock v1.4.1 h1:0Ju+VCFuARfFlhVXFc2HxlcQkfB+Xq12/EotHko+x2A=
|
||||
github.com/jarcoal/httpmock v1.4.1/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0=
|
||||
github.com/jarcoal/httpmock v1.4.2 h1:dKwiP/9zITCPfBLsDn3kchbSOu16JrnxtVEmL0fPRcI=
|
||||
github.com/jarcoal/httpmock v1.4.2/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0=
|
||||
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8=
|
||||
github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/lufia/plan9stats v0.0.0-20260324052639-156f7da3f749 h1:Qj3hTcdWH8uMZDI41HNuTuJN525C7NBrbtH5kSO6fPk=
|
||||
github.com/lufia/plan9stats v0.0.0-20260324052639-156f7da3f749/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||
github.com/lxzan/gws v1.9.1 h1:4lbIp4cW0hOLP3ejFHR/uWRy741AURx7oKkNNi2OT9o=
|
||||
github.com/lxzan/gws v1.9.1/go.mod h1:gXHSCPmTGryWJ4icuqy8Yho32E4YIMHH0fkDRYJRbdc=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 h1:eveIIGn4BGM3qknO74omf6HYr30/exH+eVUTuAgwjZ0=
|
||||
github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||
github.com/lxzan/gws v1.10.1 h1:1xG+tDOV0lgDeVPf0wNT74u3cn0K3LpcavRrTPTrMwQ=
|
||||
github.com/lxzan/gws v1.10.1/go.mod h1:gXHSCPmTGryWJ4icuqy8Yho32E4YIMHH0fkDRYJRbdc=
|
||||
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
|
||||
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/nicholas-fedor/shoutrrr v0.15.1 h1:dfgqpaeyr0CwUhqtwWBHS4girmAvFPOoxroHaVH1q1Y=
|
||||
github.com/nicholas-fedor/shoutrrr v0.15.1/go.mod h1:xrdV1ab2W0/xa5kM6WP9mBuqVuJaDWqUwYvYvVuPTjk=
|
||||
github.com/onsi/ginkgo/v2 v2.29.0 h1:rfh+ZFjgJhYWRoIqVf3Uwx/W20yLrcrE2h2GmYVRaag=
|
||||
github.com/onsi/ginkgo/v2 v2.29.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44=
|
||||
github.com/onsi/gomega v1.41.0 h1:OwKp4pXNgVxf6sCplzYo794OFNuoL2q2SBMU5NSWOjA=
|
||||
github.com/onsi/gomega v1.41.0/go.mod h1:M/Uqpu/8qTjtzCLUA2zJHX9Iilrau25x1PdoSRbWh5A=
|
||||
github.com/nicholas-fedor/shoutrrr v0.17.0 h1:xfp3z5QbE8jXvUhUEwWDk47SJ/b912VoB8MJJDU+q4E=
|
||||
github.com/nicholas-fedor/shoutrrr v0.17.0/go.mod h1:s4ldyLs6uwBy9lIjYrY+8lyTqJtPvZSrILw0CyMLock=
|
||||
github.com/onsi/ginkgo/v2 v2.32.0 h1:Hw7s2pVrQo/8Yz5N77qdnpHaoc+c6cC9WIV1Jce+J6E=
|
||||
github.com/onsi/ginkgo/v2 v2.32.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44=
|
||||
github.com/onsi/gomega v1.42.1 h1:iN1rCUX+44NZ1Dc97MPoeFYbFR0vh8zxoxMFwKdyZ6I=
|
||||
github.com/onsi/gomega v1.42.1/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pocketbase/dbx v1.12.0 h1:/oLErM+A0b4xI0PWTGPqSDVjzix48PqI/bng2l0PzoA=
|
||||
github.com/pocketbase/dbx v1.12.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
||||
github.com/pocketbase/pocketbase v0.36.8 h1:gCNqoesZ44saYOD3J7edhi5nDwUWKyQG7boM/kVwz2c=
|
||||
github.com/pocketbase/pocketbase v0.36.8/go.mod h1:OY4WaXbP0WnF/EXoBbboWJK+ZSZ1A85tiA0sjrTKxTA=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/pocketbase/ozzo-validation/v4 v4.3.0 h1:uKBDVma7bZqgR2a6AwE+k9hkuDFfiZMpBHQdZ1z3iQs=
|
||||
github.com/pocketbase/ozzo-validation/v4 v4.3.0/go.mod h1:6XNjSTw/Jb2F8LOkKO3oyzIWExbrGiYoS4uVxVwz90g=
|
||||
github.com/pocketbase/pocketbase v0.39.10 h1:2j8TDJRuo3aAC8Y8F9WFux0SwYcxeDCgEYQxxdWkwGE=
|
||||
github.com/pocketbase/pocketbase v0.39.10/go.mod h1:tSX3anHQ7Ul6dPV9WhlEc6No1DtklGF69iwnVNW3BEE=
|
||||
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/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
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/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc=
|
||||
github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
|
||||
github.com/shirou/gopsutil/v4 v4.26.7 h1:IXzpHz/dkMRYAhKkOXr1HB6SuzWU3eoyyeWe7g3bNZc=
|
||||
github.com/shirou/gopsutil/v4 v4.26.7/go.mod h1:5O9FjBiXoTDFatIWjZZosqj4pV0DRtLx598xGbBehzM=
|
||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
@@ -120,50 +122,51 @@ 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.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
|
||||
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
|
||||
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
|
||||
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
|
||||
github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU=
|
||||
github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI=
|
||||
github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4=
|
||||
github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
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/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.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.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA=
|
||||
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ=
|
||||
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||
golang.org/x/exp v0.0.0-20260811152304-ee035b5b010f h1:iXpLj9sdDH/RLYsnOMpbETK6KWtrHwvegcc4psWJHV8=
|
||||
golang.org/x/exp v0.0.0-20260811152304-ee035b5b010f/go.mod h1:EdfpwwqSu+0Li0mzskwHU6FWDV3t9Q+RZDo3QMUtL3Q=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
|
||||
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
|
||||
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||
golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
|
||||
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
|
||||
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
||||
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||
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/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.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-20190916202348-b4ddaad3f8a3/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.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||
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.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
||||
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 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
@@ -174,30 +177,30 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
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/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
|
||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
|
||||
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
|
||||
modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc=
|
||||
modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
|
||||
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
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/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
|
||||
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
|
||||
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
|
||||
modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ=
|
||||
modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/memory v1.12.0 h1:twkmYNkGXCvtYWzoux02jtK6eovjZbdI0uHFUYp6kuU=
|
||||
modernc.org/memory v1.12.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.48.0 h1:ElZyLop3Q2mHYk5IFPPXADejZrlHu7APbpB0sF78bq4=
|
||||
modernc.org/sqlite v1.48.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
|
||||
modernc.org/sqlite v1.55.0 h1:hIFh0MCH0rGinQ/4KYb5/UbCkRkb+UP+OkLCVWa5MTM=
|
||||
modernc.org/sqlite v1.55.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
|
||||
@@ -55,6 +55,7 @@ type SystemAlertStats struct {
|
||||
Temperatures map[string]float32 `json:"t"`
|
||||
LoadAvg [3]float64 `json:"la"`
|
||||
Battery [2]uint8 `json:"bat"`
|
||||
Batteries map[string]uint8 `json:"bats"`
|
||||
ExtraFs map[string]SystemAlertFsStats `json:"efs"`
|
||||
}
|
||||
|
||||
|
||||
@@ -322,8 +322,9 @@ func TestAlertSilencedMultiUser(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAlertSilencedWithActualAlert(t *testing.T) {
|
||||
hub, user := beszelTests.GetHubWithUser(t)
|
||||
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
hub, user := beszelTests.GetHubWithUser(t)
|
||||
defer hub.Cleanup()
|
||||
|
||||
// Create a system
|
||||
|
||||
@@ -29,8 +29,9 @@ func setStatusAlertEmail(t *testing.T, hub core.App, userID, email string) {
|
||||
}
|
||||
|
||||
func TestStatusAlerts(t *testing.T) {
|
||||
hub, user := beszelTests.GetHubWithUser(t)
|
||||
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
hub, user := beszelTests.GetHubWithUser(t)
|
||||
defer hub.Cleanup()
|
||||
|
||||
systems, err := beszelTests.CreateSystems(hub, 4, user.Id, "paused")
|
||||
@@ -234,8 +235,9 @@ func TestHandleStatusAlertsDoesNotSendRecoveryWhileDownIsOnlyPending(t *testing.
|
||||
}
|
||||
|
||||
func TestStatusAlertTimerCancellationPreventsBoundaryDelivery(t *testing.T) {
|
||||
hub, user := beszelTests.GetHubWithUser(t)
|
||||
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
hub, user := beszelTests.GetHubWithUser(t)
|
||||
defer hub.Cleanup()
|
||||
|
||||
userSettings, err := hub.FindFirstRecordByFilter("user_settings", "user={:user}", map[string]any{"user": user.Id})
|
||||
@@ -336,8 +338,9 @@ func TestStatusAlertDownFiresAfterDelayExpires(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestStatusAlertMultipleUsersRespectDifferentMinutes(t *testing.T) {
|
||||
hub, user1 := beszelTests.GetHubWithUser(t)
|
||||
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
hub, user1 := beszelTests.GetHubWithUser(t)
|
||||
defer hub.Cleanup()
|
||||
|
||||
setStatusAlertEmail(t, hub, user1.Id, "user1@example.com")
|
||||
@@ -423,8 +426,9 @@ func TestStatusAlertMultipleUsersRespectDifferentMinutes(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestStatusAlertMultipleUsersRecoveryBetweenMinutesOnlyAlertsEarlierUser(t *testing.T) {
|
||||
hub, user1 := beszelTests.GetHubWithUser(t)
|
||||
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
hub, user1 := beszelTests.GetHubWithUser(t)
|
||||
defer hub.Cleanup()
|
||||
|
||||
setStatusAlertEmail(t, hub, user1.Id, "user1@example.com")
|
||||
@@ -816,8 +820,9 @@ func TestResolveStatusAlerts(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAlertsHistoryStatus(t *testing.T) {
|
||||
hub, user := beszelTests.GetHubWithUser(t)
|
||||
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
hub, user := beszelTests.GetHubWithUser(t)
|
||||
defer hub.Cleanup()
|
||||
|
||||
// Create a system
|
||||
@@ -882,8 +887,9 @@ func TestAlertsHistoryStatus(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestStatusAlertClearedBeforeSend(t *testing.T) {
|
||||
hub, user := beszelTests.GetHubWithUser(t)
|
||||
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
hub, user := beszelTests.GetHubWithUser(t)
|
||||
defer hub.Cleanup()
|
||||
|
||||
// Create a system
|
||||
|
||||
@@ -63,7 +63,7 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
|
||||
case "GPU":
|
||||
val = data.Info.GpuPct
|
||||
case "Battery":
|
||||
if data.Stats.Battery[0] == 0 {
|
||||
if !hasRepresentativeBattery(data.Stats.Battery, data.Stats.Batteries) {
|
||||
continue
|
||||
}
|
||||
val = float64(data.Stats.Battery[0])
|
||||
@@ -167,6 +167,7 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
|
||||
stat := systemStats[i]
|
||||
// subtract 10 seconds to give a small time buffer
|
||||
systemStatsCreation := stat.Created.Time().Add(-time.Second * 10)
|
||||
stats = SystemAlertStats{}
|
||||
if err := json.Unmarshal(stat.Stats, &stats); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -235,6 +236,9 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
|
||||
}
|
||||
alert.val += maxUsage
|
||||
case "Battery":
|
||||
if !hasRepresentativeBattery(stats.Battery, stats.Batteries) {
|
||||
continue
|
||||
}
|
||||
alert.val += float64(stats.Battery[0])
|
||||
default:
|
||||
continue
|
||||
@@ -297,6 +301,10 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
|
||||
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) {
|
||||
// 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")
|
||||
|
||||
@@ -95,11 +95,10 @@ func waitForSystemAlert(d time.Duration) {
|
||||
|
||||
func testOneMinuteSystemAlert[T any](t *testing.T, alertName string, threshold float64, setValue systemAlertValueSetter[T], triggerValue, resolveValue T) {
|
||||
t.Helper()
|
||||
fixture := newSystemAlertTestFixture(t, alertName, 1, threshold)
|
||||
defer fixture.cleanup()
|
||||
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
fixture := newSystemAlertTestFixture(t, alertName, 1, threshold)
|
||||
defer fixture.cleanup()
|
||||
|
||||
submitValue(fixture, t, triggerValue, setValue)
|
||||
waitForSystemAlert(time.Second)
|
||||
|
||||
@@ -118,11 +117,10 @@ 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) {
|
||||
t.Helper()
|
||||
fixture := newSystemAlertTestFixture(t, alertName, min, threshold)
|
||||
defer fixture.cleanup()
|
||||
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
fixture := newSystemAlertTestFixture(t, alertName, min, threshold)
|
||||
defer fixture.cleanup()
|
||||
|
||||
submitValue(fixture, t, baselineValue, setValue)
|
||||
waitForSystemAlert(time.Minute + time.Second)
|
||||
fixture.assertTriggered(t, false, "Alert should not be triggered yet")
|
||||
@@ -201,7 +199,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, "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, "Battery", 20, setBatteryAlertValue, [2]uint8{19, 0}, [2]uint8{21, 0})
|
||||
testOneMinuteSystemAlert(t, "Battery", 20, setBatteryAlertValue, [2]uint8{0, 1}, [2]uint8{21, 0})
|
||||
}
|
||||
|
||||
func TestSystemAlertsTwoMin(t *testing.T) {
|
||||
|
||||
@@ -15,8 +15,9 @@ import (
|
||||
)
|
||||
|
||||
func TestAlertsHistory(t *testing.T) {
|
||||
hub, user := beszelTests.GetHubWithUser(t)
|
||||
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
hub, user := beszelTests.GetHubWithUser(t)
|
||||
defer hub.Cleanup()
|
||||
|
||||
// Create systems and alerts
|
||||
|
||||
@@ -42,7 +42,8 @@ type AgentResponse struct {
|
||||
SmartData map[string]smart.SmartData `cbor:"5,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 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 {
|
||||
|
||||
@@ -20,7 +20,7 @@ FROM alpine:3.23
|
||||
|
||||
COPY --from=builder /agent /agent
|
||||
|
||||
RUN apk add --no-cache -X https://dl-cdn.alpinelinux.org/alpine/edge/testing igt-gpu-tools smartmontools
|
||||
RUN apk add --no-cache -X https://dl-cdn.alpinelinux.org/alpine/edge/testing igt-gpu-tools nvtop smartmontools
|
||||
|
||||
# Ensure data persistence across container recreations
|
||||
VOLUME ["/var/lib/beszel-agent"]
|
||||
|
||||
90
internal/dockerfile_agent_nvidia_slim
Normal file
90
internal/dockerfile_agent_nvidia_slim
Normal file
@@ -0,0 +1,90 @@
|
||||
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"]
|
||||
@@ -52,6 +52,17 @@ type HostInfo struct {
|
||||
}
|
||||
|
||||
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
|
||||
systemDelta := s.CPUStats.SystemUsage - prevCpuSystem
|
||||
|
||||
@@ -70,7 +81,11 @@ func (s *ApiStats) CalculateCpuPercentWindows(prevCpuUsage uint64, prevRead time
|
||||
possIntervals /= 100 // Convert to number of 100ns intervals
|
||||
possIntervals *= uint64(s.NumProcs) // Multiple by the number of processors
|
||||
|
||||
// Intervals used
|
||||
// Intervals used. Same rollback guard as the Linux path: an out-of-order or
|
||||
// reset counter would wrap the subtraction to ~2^64.
|
||||
if s.CPUStats.CPUUsage.TotalUsage < prevCpuUsage {
|
||||
return 0.0
|
||||
}
|
||||
intervalsUsed := s.CPUStats.CPUUsage.TotalUsage - prevCpuUsage
|
||||
|
||||
// Percentage avoiding divide-by-zero
|
||||
|
||||
@@ -494,7 +494,7 @@ type SmartInfoForNvme struct {
|
||||
FirmwareVersion string `json:"firmware_version"`
|
||||
// NVMePCIVendor NVMePCIVendor `json:"nvme_pci_vendor"`
|
||||
// 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"`
|
||||
// NVMeControllerID uint16 `json:"nvme_controller_id"`
|
||||
// NVMeVersion VersionStringInfo `json:"nvme_version"`
|
||||
@@ -531,6 +531,13 @@ type SmartData struct {
|
||||
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 {
|
||||
ID uint16 `json:"id,omitempty" cbor:"0,keyasint,omitempty"`
|
||||
Name string `json:"n" cbor:"1,keyasint"`
|
||||
|
||||
@@ -33,6 +33,8 @@ type Stats struct {
|
||||
MaxNetworkSent float64 `json:"nsm,omitempty" cbor:"-"`
|
||||
MaxNetworkRecv float64 `json:"nrm,omitempty" cbor:"-"`
|
||||
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"`
|
||||
GPUData map[string]GPUData `json:"g,omitempty" cbor:"22,keyasint,omitempty"`
|
||||
// LoadAvg1 float64 `json:"l1,omitempty" cbor:"23,keyasint,omitempty"`
|
||||
@@ -42,7 +44,7 @@ type Stats struct {
|
||||
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
|
||||
LoadAvg [3]float64 `json:"la,omitempty" cbor:"28,keyasint"`
|
||||
Battery [2]uint8 `json:"bat,omitzero" cbor:"29,keyasint,omitzero"` // [percent, charge state, current]
|
||||
Battery [2]uint8 `json:"bat,omitzero" cbor:"29,keyasint,omitzero"` // [percent, charge state]
|
||||
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]
|
||||
MaxDiskIO [2]uint64 `json:"diom,omitzero" cbor:"-"` // [max read bytes, max write bytes]
|
||||
|
||||
37
internal/entities/system/system_test.go
Normal file
37
internal/entities/system/system_test.go
Normal file
@@ -0,0 +1,37 @@
|
||||
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
internal/ghupdate/checksum.go
Normal file
43
internal/ghupdate/checksum.go
Normal file
@@ -0,0 +1,43 @@
|
||||
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
internal/ghupdate/checksum_test.go
Normal file
57
internal/ghupdate/checksum_test.go
Normal file
@@ -0,0 +1,57 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -46,18 +46,23 @@ func extractTarGz(srcPath, destDir string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
path, err := archivePath(destDir, header.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if header.Typeflag == tar.TypeDir {
|
||||
if err := os.MkdirAll(filepath.Join(destDir, header.Name), 0755); err != nil {
|
||||
if err := os.MkdirAll(path, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(filepath.Join(destDir, header.Name)), 0755); err != nil {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
outFile, err := os.Create(filepath.Join(destDir, header.Name))
|
||||
outFile, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -72,6 +77,14 @@ func extractTarGz(srcPath, destDir string) error {
|
||||
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".
|
||||
//
|
||||
// Note that only dirs and regular files will be extracted.
|
||||
@@ -84,9 +97,6 @@ func extractZip(src, dest string) error {
|
||||
}
|
||||
defer zr.Close()
|
||||
|
||||
// normalize dest path to check later for Zip Slip
|
||||
dest = filepath.Clean(dest) + string(os.PathSeparator)
|
||||
|
||||
for _, f := range zr.File {
|
||||
err := extractFile(f, dest)
|
||||
if err != nil {
|
||||
@@ -100,11 +110,9 @@ func extractZip(src, dest string) error {
|
||||
// extractFile extracts the provided zipFile into "basePath/zipFileName" path,
|
||||
// creating all the necessary path directories.
|
||||
func extractFile(zipFile *zip.File, basePath string) error {
|
||||
path := filepath.Join(basePath, zipFile.Name)
|
||||
|
||||
// check for Zip Slip
|
||||
if !strings.HasPrefix(path, basePath) {
|
||||
return fmt.Errorf("invalid file path: %s", path)
|
||||
path, err := archivePath(basePath, zipFile.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
r, err := zipFile.Open()
|
||||
|
||||
@@ -135,21 +135,33 @@ func (p *updater) update() (updated bool, err error) {
|
||||
return false, err
|
||||
}
|
||||
|
||||
releaseDir := filepath.Join(p.config.DataDir, ".beszel_update")
|
||||
if err := os.MkdirAll(p.config.DataDir, 0755); err != nil {
|
||||
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)
|
||||
|
||||
ColorPrintf(ColorYellow, "Downloading %s...", asset.Name)
|
||||
|
||||
// download the release asset
|
||||
assetPath := filepath.Join(releaseDir, asset.Name)
|
||||
assetPath, err := archivePath(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 {
|
||||
return false, err
|
||||
}
|
||||
ColorPrint(ColorYellow, "Verifying checksum...")
|
||||
if err := verifyAssetChecksum(assetPath, asset.Digest); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
ColorPrintf(ColorYellow, "Extracting %s...", asset.Name)
|
||||
|
||||
extractDir := filepath.Join(releaseDir, "extracted_"+asset.Name)
|
||||
defer os.RemoveAll(extractDir)
|
||||
extractDir := filepath.Join(releaseDir, "extracted")
|
||||
|
||||
// Extract the archive (automatically detects format)
|
||||
if err := extract(assetPath, extractDir); err != nil {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package ghupdate
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
@@ -43,3 +46,59 @@ func TestExtractFailure(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
type releaseAsset struct {
|
||||
Name string `json:"name"`
|
||||
DownloadUrl string `json:"browser_download_url"`
|
||||
Digest string `json:"digest"`
|
||||
Id int `json:"id"`
|
||||
Size int `json:"size"`
|
||||
}
|
||||
|
||||
@@ -317,6 +317,9 @@ func getRealIP(r *http.Request) string {
|
||||
if ip := r.Header.Get("CF-Connecting-IP"); ip != "" {
|
||||
return ip
|
||||
}
|
||||
if ip := r.Header.Get("X-Real-IP"); ip != "" {
|
||||
return ip
|
||||
}
|
||||
if ip := r.Header.Get("X-Forwarded-For"); ip != "" {
|
||||
// X-Forwarded-For can contain a comma-separated list: "client_ip, proxy1, proxy2"
|
||||
// Take the first one
|
||||
|
||||
@@ -1796,6 +1796,24 @@ func TestGetRealIP(t *testing.T) {
|
||||
remoteAddr: "127.0.0.1:12345",
|
||||
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 {
|
||||
|
||||
@@ -64,7 +64,7 @@ func createConfigTestFingerprint(app core.App, systemID, token, fingerprint stri
|
||||
|
||||
// TestConfigSyncWithTokens tests the config.SyncSystems function with various token scenarios
|
||||
func TestConfigSyncWithTokens(t *testing.T) {
|
||||
testHub, err := tests.NewTestHub()
|
||||
testHub, err := tests.NewTestHub(t.TempDir())
|
||||
require.NoError(t, err)
|
||||
defer testHub.Cleanup()
|
||||
|
||||
|
||||
56
internal/hub/systems/ssh_timeout_test.go
Normal file
56
internal/hub/systems/ssh_timeout_test.go
Normal file
@@ -0,0 +1,56 @@
|
||||
//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
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -348,6 +348,9 @@ func (sys *System) getRecord(app core.App) (*core.Record, error) {
|
||||
record, err := app.FindRecordById("systems", sys.Id)
|
||||
if err != nil || record == nil {
|
||||
_ = sys.manager.RemoveSystem(sys.Id)
|
||||
if err == nil {
|
||||
err = fmt.Errorf("system record %s not found", sys.Id)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return record, nil
|
||||
@@ -377,10 +380,16 @@ func (sys *System) HasUser(app core.App, user *core.Record) bool {
|
||||
// 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
|
||||
// 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 {
|
||||
if sys.Status == down || sys.Status == paused {
|
||||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -388,6 +397,7 @@ func (sys *System) setDown(originalError error) error {
|
||||
if originalError != nil {
|
||||
sys.manager.hub.Logger().Error("System down", "system", record.GetString("name"), "err", originalError)
|
||||
}
|
||||
sys.detailsFetched.Store(false)
|
||||
record.Set("status", down)
|
||||
return sys.manager.hub.SaveNoValidate(record)
|
||||
}
|
||||
@@ -531,11 +541,16 @@ func (sys *System) FetchSystemdInfoFromAgent(serviceName string) (systemd.Servic
|
||||
return result, err
|
||||
}
|
||||
|
||||
// FetchSmartDataFromAgent fetches SMART data from the agent
|
||||
func (sys *System) FetchSmartDataFromAgent() (map[string]smart.SmartData, error) {
|
||||
// FetchSmartDataFromAgent fetches SMART data from the agent.
|
||||
func (sys *System) FetchSmartDataFromAgent() (smart.SmartDataResponse, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
var result map[string]smart.SmartData
|
||||
if sys.agentVersion.LT(beszel.MinVersionAgentResponse) {
|
||||
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)
|
||||
return result, err
|
||||
}
|
||||
@@ -623,10 +638,17 @@ func (sys *System) runSSHOperation(timeout time.Duration, retries int, operation
|
||||
continue
|
||||
}
|
||||
|
||||
retry, opErr := func() (bool, error) {
|
||||
// Bound the whole operation. A half-open TCP connection (a dead peer that
|
||||
// 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()
|
||||
return operation(session)
|
||||
}()
|
||||
}, sys.closeSSHConnection)
|
||||
|
||||
if opErr == nil {
|
||||
return nil
|
||||
@@ -645,6 +667,43 @@ func (sys *System) runSSHOperation(timeout time.Duration, retries int, operation
|
||||
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
|
||||
func (s *System) createSSHClient() error {
|
||||
if s.manager.sshConfig == nil {
|
||||
@@ -660,7 +719,7 @@ func (s *System) createSSHClient() error {
|
||||
host = net.JoinHostPort(host, s.Port)
|
||||
}
|
||||
var err error
|
||||
s.client, err = ssh.Dial(network, host, s.manager.sshConfig)
|
||||
s.client, err = dialSSHWithKeepAlive(network, host, s.manager.sshConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -669,6 +728,34 @@ func (s *System) createSSHClient() error {
|
||||
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
|
||||
// in case of network issues
|
||||
func (sys *System) createSessionWithTimeout(timeout time.Duration) (*ssh.Session, error) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/internal/entities/smart"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
@@ -17,13 +18,13 @@ type smartFetchState struct {
|
||||
|
||||
// FetchAndSaveSmartDevices fetches SMART data from the agent and saves it to the database
|
||||
func (sys *System) FetchAndSaveSmartDevices() error {
|
||||
smartData, err := sys.FetchSmartDataFromAgent()
|
||||
response, err := sys.FetchSmartDataFromAgent()
|
||||
if err != nil {
|
||||
sys.recordSmartFetchResult(err, 0)
|
||||
return err
|
||||
}
|
||||
err = sys.saveSmartDevices(smartData)
|
||||
sys.recordSmartFetchResult(err, len(smartData))
|
||||
err = sys.saveSmartDevices(response.Data, response.Complete)
|
||||
sys.recordSmartFetchResult(err, len(response.Data))
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -61,8 +62,9 @@ func (sys *System) smartFetchInterval() time.Duration {
|
||||
return time.Hour
|
||||
}
|
||||
|
||||
// saveSmartDevices saves SMART device data to the smart_devices collection
|
||||
func (sys *System) saveSmartDevices(smartData map[string]smart.SmartData) error {
|
||||
// saveSmartDevices saves SMART device data and, after a complete refresh,
|
||||
// removes rows for devices that are no longer reported.
|
||||
func (sys *System) saveSmartDevices(smartData map[string]smart.SmartData, complete bool) error {
|
||||
if len(smartData) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -73,20 +75,49 @@ func (sys *System) saveSmartDevices(smartData map[string]smart.SmartData) error
|
||||
return err
|
||||
}
|
||||
|
||||
for deviceKey, device := range smartData {
|
||||
if err := sys.upsertSmartDeviceRecord(collection, deviceKey, device); err != nil {
|
||||
return err
|
||||
}
|
||||
currentIDs := make(map[string]struct{}, len(smartData))
|
||||
for deviceKey := range smartData {
|
||||
currentIDs[makeStableHashId(sys.Id, deviceKey)] = struct{}{}
|
||||
}
|
||||
|
||||
return nil
|
||||
err = hub.RunInTransaction(func(txApp core.App) error {
|
||||
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(collection *core.Collection, deviceKey string, device smart.SmartData) error {
|
||||
hub := sys.manager.hub
|
||||
func (sys *System) upsertSmartDeviceRecord(app core.App, collection *core.Collection, deviceKey string, device smart.SmartData) error {
|
||||
recordID := makeStableHashId(sys.Id, deviceKey)
|
||||
|
||||
record, err := hub.FindRecordById(collection, recordID)
|
||||
record, err := app.FindRecordById(collection, recordID)
|
||||
if err != nil {
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return err
|
||||
@@ -114,7 +145,7 @@ func (sys *System) upsertSmartDeviceRecord(collection *core.Collection, deviceKe
|
||||
record.Set("cycles", powerCycles)
|
||||
record.Set("attributes", device.Attributes)
|
||||
|
||||
return hub.SaveNoValidate(record)
|
||||
return app.SaveNoValidate(record)
|
||||
}
|
||||
|
||||
// extractPowerMetrics extracts power on hours and power cycles from SMART attributes
|
||||
|
||||
@@ -7,10 +7,53 @@ import (
|
||||
"testing"
|
||||
"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/migrations"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
pbtests "github.com/pocketbase/pocketbase/tests"
|
||||
"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) {
|
||||
sm := &SystemManager{smartFetchMap: expirymap.New[smartFetchState](time.Hour)}
|
||||
t.Cleanup(sm.smartFetchMap.StopCleaner)
|
||||
@@ -92,3 +135,95 @@ func TestResetFailedSmartFetchState(t *testing.T) {
|
||||
_, ok = sm.smartFetchMap.GetOk("system-1")
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
package systems
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
@@ -157,3 +158,17 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,15 +88,23 @@ func unmarshalLegacyResponse(resp common.AgentResponse, action common.WebSocketA
|
||||
*d = *resp.String
|
||||
return nil
|
||||
case common.GetSmartData:
|
||||
d, ok := dest.(*map[string]smart.SmartData)
|
||||
if !ok {
|
||||
switch d := dest.(type) {
|
||||
case *map[string]smart.SmartData:
|
||||
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)
|
||||
}
|
||||
if resp.SmartData == nil {
|
||||
return errors.New("no SMART data in response")
|
||||
}
|
||||
*d = resp.SmartData
|
||||
return nil
|
||||
case common.GetSystemdInfo:
|
||||
d, ok := dest.(*systemd.ServiceDetails)
|
||||
if !ok {
|
||||
|
||||
33
internal/hub/transport/transport_test.go
Normal file
33
internal/hub/transport/transport_test.go
Normal file
@@ -0,0 +1,33 @@
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ package records
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"log/slog"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
@@ -68,15 +68,18 @@ func (rm *RecordManager) CreateLongerRecords() {
|
||||
},
|
||||
}
|
||||
// wrap the operations in a transaction
|
||||
// Pocketbase cron does not handle errors, log them here.
|
||||
rm.app.RunInTransaction(func(txApp core.App) error {
|
||||
var err error
|
||||
collections := [2]*core.Collection{}
|
||||
collections[0], err = txApp.FindCachedCollectionByNameOrId("system_stats")
|
||||
if err != nil {
|
||||
slog.Error("Error finding cached collection using system stats:", "err", err)
|
||||
return err
|
||||
}
|
||||
collections[1], err = txApp.FindCachedCollectionByNameOrId("container_stats")
|
||||
if err != nil {
|
||||
slog.Error("Error finding cached collection using container stats:", "err", err)
|
||||
return err
|
||||
}
|
||||
var systems RecordIds
|
||||
@@ -142,7 +145,7 @@ func (rm *RecordManager) CreateLongerRecords() {
|
||||
longerRecord.Set("stats", rm.AverageContainerStats(db, recordIds))
|
||||
}
|
||||
if err := txApp.SaveNoValidate(longerRecord); err != nil {
|
||||
log.Println("failed to save longer record", "err", err)
|
||||
slog.Error("failed to save longer record", "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -183,11 +186,16 @@ func AverageSystemStatsSlice(records []system.Stats) system.Stats {
|
||||
|
||||
// necessary because uint8 is not big enough for the sum
|
||||
batterySum := 0
|
||||
batteryCount := 0
|
||||
batterySums := make(map[string]uint64)
|
||||
batteryCounts := make(map[string]uint64)
|
||||
// accumulate per-core usage across records
|
||||
var cpuCoresSums []uint64
|
||||
// accumulate cpu breakdown [user, system, iowait, steal, idle]
|
||||
var cpuBreakdownSums []float64
|
||||
tempCount := float64(0)
|
||||
var fanSums map[string]uint64
|
||||
fanCount := uint64(0)
|
||||
|
||||
// Accumulate totals
|
||||
for i := range records {
|
||||
@@ -227,8 +235,15 @@ func AverageSystemStatsSlice(records []system.Stats) system.Stats {
|
||||
for i := range stats.DiskIoStats {
|
||||
sum.DiskIoStats[i] += stats.DiskIoStats[i]
|
||||
}
|
||||
batterySum += int(stats.Battery[0])
|
||||
sum.Battery[1] = stats.Battery[1]
|
||||
if hasBattery(stats.Battery, stats.Batteries) {
|
||||
batterySum += int(stats.Battery[0])
|
||||
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
|
||||
if stats.CpuCoresUsage != nil {
|
||||
@@ -279,6 +294,17 @@ 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
|
||||
if stats.ExtraFs != nil {
|
||||
if sum.ExtraFs == nil {
|
||||
@@ -363,7 +389,15 @@ func AverageSystemStatsSlice(records []system.Stats) system.Stats {
|
||||
sum.LoadAvg[2] = twoDecimals(sum.LoadAvg[2] / count)
|
||||
sum.Bandwidth[0] = sum.Bandwidth[0] / uint64(count)
|
||||
sum.Bandwidth[1] = sum.Bandwidth[1] / uint64(count)
|
||||
sum.Battery[0] = uint8(batterySum / int(count))
|
||||
if batteryCount > 0 {
|
||||
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
|
||||
if sum.NetworkInterfaces != nil {
|
||||
@@ -384,6 +418,14 @@ 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
|
||||
if sum.ExtraFs != nil {
|
||||
for key := range sum.ExtraFs {
|
||||
@@ -443,6 +485,10 @@ func AverageSystemStatsSlice(records []system.Stats) system.Stats {
|
||||
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
|
||||
func (rm *RecordManager) AverageContainerStats(db dbx.Builder, records RecordIds) []container.Stats {
|
||||
allStats := make([][]container.Stats, 0, len(records))
|
||||
|
||||
@@ -291,6 +291,28 @@ func TestAverageSystemStatsSlice_Temperatures(t *testing.T) {
|
||||
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) {
|
||||
input := []system.Stats{
|
||||
{
|
||||
@@ -580,6 +602,28 @@ func TestAverageSystemStatsSlice_BatteryLastChargeState(t *testing.T) {
|
||||
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) {
|
||||
input := []system.Stats{
|
||||
{Cpu: 10.0, Mem: 8.0},
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
// Delete old records
|
||||
func (rm *RecordManager) DeleteOldRecords() {
|
||||
// Pocketbase cron does not handle errors, log them here.
|
||||
rm.app.RunInTransaction(func(txApp core.App) error {
|
||||
err := deleteOldSystemStats(txApp)
|
||||
if err != nil {
|
||||
|
||||
@@ -9,11 +9,7 @@
|
||||
"preview": "vite preview",
|
||||
"sync": "lingui extract --overwrite && lingui compile",
|
||||
"sync_no_compile": "lingui extract --overwrite --clean",
|
||||
"sync_and_purge": "lingui extract --overwrite --clean && lingui compile",
|
||||
"format": "biome format --write .",
|
||||
"lint": "biome lint .",
|
||||
"check": "biome check .",
|
||||
"check:fix": "biome check --fix ."
|
||||
"sync_and_purge": "lingui extract --overwrite --clean && lingui compile"
|
||||
},
|
||||
"dependencies": {
|
||||
"@henrygd/queue": "^1.0.7",
|
||||
|
||||
@@ -175,8 +175,8 @@ export function UserAuthForm({
|
||||
*/
|
||||
function redirectToOauthProvider(provider: AuthProviderInfo) {
|
||||
const url = new URL(provider.authURL)
|
||||
// url.searchParams.set("redirect_uri", `${window.location.origin}${basePath}`)
|
||||
sessionStorage.setItem("provider", JSON.stringify(provider))
|
||||
url.searchParams.set("redirect_uri", `${window.location.origin}${basePath}`)
|
||||
localStorage.setItem("provider", JSON.stringify(provider))
|
||||
window.location.href = url.toString()
|
||||
}
|
||||
|
||||
@@ -186,12 +186,13 @@ export function UserAuthForm({
|
||||
const code = params.get("code")
|
||||
if (code) {
|
||||
const state = params.get("state")
|
||||
const provider: AuthProviderInfo = JSON.parse(sessionStorage.getItem("provider") ?? "{}")
|
||||
const provider: AuthProviderInfo = JSON.parse(localStorage.getItem("provider") ?? "{}")
|
||||
localStorage.removeItem("provider")
|
||||
window.history.replaceState({}, "", window.location.pathname)
|
||||
if (!state || provider.state !== state) {
|
||||
showLoginFaliedToast()
|
||||
} else {
|
||||
setIsOauthLoading(true)
|
||||
window.history.replaceState({}, "", window.location.pathname)
|
||||
pb.collection("users")
|
||||
.authWithOAuth2Code(provider.name, code, provider.codeVerifier, `${window.location.origin}${basePath}`)
|
||||
.then(() => $authenticated.set(pb.authStore.isValid))
|
||||
@@ -285,7 +286,7 @@ export function UserAuthForm({
|
||||
{errors?.passwordConfirm && <p className="px-1 text-xs text-red-600">{errors.passwordConfirm}</p>}
|
||||
</div>
|
||||
)}
|
||||
<div className="sr-only">
|
||||
<div className="sr-only" aria-hidden="true">
|
||||
{/* honeypot */}
|
||||
<label htmlFor="website">Website</label>
|
||||
<input
|
||||
@@ -293,6 +294,7 @@ export function UserAuthForm({
|
||||
type="text"
|
||||
name="website"
|
||||
tabIndex={-1}
|
||||
readOnly
|
||||
autoComplete="off"
|
||||
data-1p-ignore
|
||||
data-lpignore="true"
|
||||
|
||||
@@ -139,6 +139,7 @@ const SectionUniversalToken = memo(() => {
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [checked, setChecked] = useState(false)
|
||||
const [isPermanent, setIsPermanent] = useState(false)
|
||||
const publicKey = $publicKey.get()
|
||||
|
||||
async function updateToken(enable: number = -1, permanent: number = -1) {
|
||||
// enable: 0 for disable, 1 for enable, -1 (unset) for get current state
|
||||
@@ -191,6 +192,25 @@ const SectionUniversalToken = memo(() => {
|
||||
<ActionsButtonUniversalToken token={token} checked={checked} />
|
||||
</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 && (
|
||||
<div className="border-t pt-3">
|
||||
<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 { RootDiskCharts, ExtraFsCharts } from "./system/charts/disk-charts"
|
||||
import { BandwidthChart, ContainerNetworkChart } from "./system/charts/network-charts"
|
||||
import { TemperatureChart, BatteryChart } from "./system/charts/sensor-charts"
|
||||
import { TemperatureChart, FanChart, BatteryChart } from "./system/charts/sensor-charts"
|
||||
import { GpuPowerChart, GpuDetailCharts } from "./system/charts/gpu-charts"
|
||||
import { LazyContainersTable, LazySmartTable, LazySystemdTable } from "./system/lazy-tables"
|
||||
import { LoadAverageChart } from "./system/charts/load-average-chart"
|
||||
@@ -123,7 +123,9 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
||||
|
||||
<TemperatureChart {...coreProps} />
|
||||
|
||||
<BatteryChart {...coreProps} />
|
||||
<FanChart {...coreProps} />
|
||||
|
||||
<BatteryChart system={system} {...coreProps} />
|
||||
|
||||
{hasGpuPowerData && <GpuPowerChart chartData={chartData} grid={grid} dataEmpty={dataEmpty} />}
|
||||
</div>
|
||||
@@ -188,7 +190,8 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
||||
<LoadAverageChart chartData={chartData} grid={grid} dataEmpty={dataEmpty} />
|
||||
<BandwidthChart {...coreProps} systemStats={systemStats} />
|
||||
<TemperatureChart {...coreProps} setPageBottomExtraMargin={setPageBottomExtraMargin} />
|
||||
<BatteryChart {...coreProps} />
|
||||
<FanChart {...coreProps} />
|
||||
<BatteryChart system={system} {...coreProps} />
|
||||
<SwapChart chartData={chartData} grid={grid} dataEmpty={dataEmpty} systemStats={systemStats} />
|
||||
{pageBottomExtraMargin > 0 && <div style={{ marginBottom: pageBottomExtraMargin }}></div>}
|
||||
</div>
|
||||
|
||||
@@ -108,7 +108,7 @@ export function ContainerMemoryChart({
|
||||
dataPoints={dataPoints}
|
||||
tickFormatter={(val) => {
|
||||
const { value, unit } = formatBytes(val, false, Unit.Bytes, true)
|
||||
return `${toFixedFloat(value, val >= 10 ? 0 : 1)} ${unit}`
|
||||
return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}`
|
||||
}}
|
||||
contentFormatter={(item) => {
|
||||
const { value, unit } = formatBytes(item.value, false, Unit.Bytes, true)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { t } from "@lingui/core/macro"
|
||||
import AreaChartDefault from "@/components/charts/area-chart"
|
||||
import { batteryStateTranslations } from "@/lib/i18n"
|
||||
import { $temperatureFilter, $userSettings } from "@/lib/stores"
|
||||
import { $fanFilter, $temperatureFilter, $userSettings } from "@/lib/stores"
|
||||
import { cn, decimalString, formatTemperature, toFixedFloat } from "@/lib/utils"
|
||||
import type { ChartData, SystemStatsRecord } from "@/types"
|
||||
import type { ChartData, SystemRecord, SystemStatsRecord } from "@/types"
|
||||
import { ChartCard, FilterBar } from "../chart-card"
|
||||
import LineChartDefault from "@/components/charts/line-chart"
|
||||
import { useStore } from "@nanostores/react"
|
||||
@@ -14,18 +14,60 @@ export function BatteryChart({
|
||||
grid,
|
||||
dataEmpty,
|
||||
maxValues,
|
||||
system,
|
||||
}: {
|
||||
chartData: ChartData
|
||||
grid: boolean
|
||||
dataEmpty: boolean
|
||||
maxValues: boolean
|
||||
system: SystemRecord
|
||||
}) {
|
||||
const showBatteryChart = chartData.systemStats.at(-1)?.stats.bat
|
||||
const batteryNames = useMemo(() => {
|
||||
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) {
|
||||
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 (
|
||||
<ChartCard
|
||||
empty={dataEmpty}
|
||||
@@ -34,7 +76,7 @@ export function BatteryChart({
|
||||
description={`${t({
|
||||
message: "Current state",
|
||||
comment: "Context: Battery state",
|
||||
})}: ${batteryStateTranslations[chartData.systemStats.at(-1)?.stats.bat?.[1] ?? 0]()}`}
|
||||
})}: ${batteryStateTranslations[system.info.bat?.[1] ?? 0]()}`}
|
||||
>
|
||||
<AreaChartDefault
|
||||
chartData={chartData}
|
||||
@@ -209,3 +251,94 @@ export function TemperatureChart({
|
||||
</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
|
||||
)
|
||||
return (
|
||||
<div className="flex gap-2 items-center tabular-nums tracking-tight w-full">
|
||||
<div className="flex gap-2 items-center tabular-nums tracking-tight w-full min-w-0">
|
||||
<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={meterClass} style={{ width: `${val}%` }}></span>
|
||||
|
||||
@@ -477,7 +477,7 @@ const SystemCard = memo(
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm px-5 pt-3.5 pb-4">
|
||||
<div className="grid gap-2.5" style={{ gridTemplateColumns: "24px minmax(80px, max-content) 1fr" }}>
|
||||
<div className="grid gap-2.5" style={{ gridTemplateColumns: "24px minmax(80px, max-content) minmax(0, 1fr)" }}>
|
||||
{table.getAllColumns().map((column) => {
|
||||
if (!column.getIsVisible() || column.id === "system" || column.id === "actions") return null
|
||||
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">
|
||||
{name()}:
|
||||
</div>
|
||||
<div key={`${column.id}-value`} className="flex items-center">
|
||||
<div key={`${column.id}-value`} className="flex items-center min-w-0">
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -64,6 +64,9 @@ export const $containerFilter = atom("")
|
||||
/** Temperature chart filter */
|
||||
export const $temperatureFilter = atom("")
|
||||
|
||||
/** Fan-speed chart filter */
|
||||
export const $fanFilter = atom("")
|
||||
|
||||
/** Fallback copy to clipboard dialog content */
|
||||
export const $copyContent = atom("")
|
||||
|
||||
|
||||
6
internal/site/src/types.d.ts
vendored
6
internal/site/src/types.d.ts
vendored
@@ -143,12 +143,16 @@ export interface SystemStats {
|
||||
bm?: [number, number]
|
||||
/** temperatures */
|
||||
t?: Record<string, number>
|
||||
/** fan speeds (RPM) — keyed by `<chip>_<label-or-fan-idx>` */
|
||||
f?: Record<string, number>
|
||||
/** extra filesystems */
|
||||
efs?: Record<string, ExtraFsStats>
|
||||
/** GPU data */
|
||||
g?: Record<string, GPUData>
|
||||
/** battery percent and state */
|
||||
bat?: [number, BatteryState]
|
||||
/** battery percentages by device name */
|
||||
bats?: Record<string, number>
|
||||
/** network interfaces [upload bytes, download bytes, total upload bytes, total download bytes] */
|
||||
ni?: Record<string, [number, number, number, number]>
|
||||
}
|
||||
@@ -208,7 +212,7 @@ interface ContainerStats {
|
||||
n: string
|
||||
/** cpu percent */
|
||||
c: number
|
||||
/** memory used (gb) */
|
||||
/** memory used (mb) */
|
||||
m: number
|
||||
// network sent (mb)
|
||||
ns?: number
|
||||
|
||||
@@ -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.
|
||||
- **Simple**: Easy setup with little manual configuration required.
|
||||
- **Docker stats**: Tracks CPU, memory, and network usage history for each container.
|
||||
- **Alerts**: Configurable alerts for CPU, memory, disk, bandwidth, temperature, load average, and status.
|
||||
- **Alerts**: Configurable alerts for CPU, memory, disk, bandwidth, temperature, fan speed, load average, and status.
|
||||
- **Multi-user**: Users manage their own systems. Admins can share systems across users.
|
||||
- **OAuth / OIDC**: Supports many OAuth2 providers. Password auth can be disabled.
|
||||
- **Automatic backups**: Save to and restore from disk or S3-compatible storage.
|
||||
@@ -48,6 +48,7 @@ The [quick start guide](https://beszel.dev/guide/getting-started) and other docu
|
||||
- **Network usage** - Host system and containers.
|
||||
- **Load average** - Host system.
|
||||
- **Temperature** - Host system sensors.
|
||||
- **Fan speed** - Host system sensors (Linux, via `/sys/class/hwmon`).
|
||||
- **GPU usage / power draw** - Nvidia, AMD, and Intel.
|
||||
- **Battery** - Host system battery charge.
|
||||
- **Containers** - Status and metrics of all running Docker / Podman containers.
|
||||
|
||||
26
release-please-config.json
Normal file
26
release-please-config.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"$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
|
||||
}
|
||||
}
|
||||
}
|
||||
29
supplemental/helm/beszel-agent/CHANGELOG.md
Normal file
29
supplemental/helm/beszel-agent/CHANGELOG.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# 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
supplemental/helm/beszel-agent/Chart.yaml
Normal file
15
supplemental/helm/beszel-agent/Chart.yaml
Normal file
@@ -0,0 +1,15 @@
|
||||
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
supplemental/helm/beszel-agent/README.md
Normal file
538
supplemental/helm/beszel-agent/README.md
Normal file
@@ -0,0 +1,538 @@
|
||||
# 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.
|
||||
21
supplemental/helm/beszel-agent/templates/NOTES.txt
Normal file
21
supplemental/helm/beszel-agent/templates/NOTES.txt
Normal file
@@ -0,0 +1,21 @@
|
||||
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/
|
||||
73
supplemental/helm/beszel-agent/templates/_helpers.tpl
Normal file
73
supplemental/helm/beszel-agent/templates/_helpers.tpl
Normal file
@@ -0,0 +1,73 @@
|
||||
{{/*
|
||||
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 }}
|
||||
109
supplemental/helm/beszel-agent/templates/daemonset.yaml
Normal file
109
supplemental/helm/beszel-agent/templates/daemonset.yaml
Normal file
@@ -0,0 +1,109 @@
|
||||
{{- 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 }}
|
||||
112
supplemental/helm/beszel-agent/templates/deployment.yaml
Normal file
112
supplemental/helm/beszel-agent/templates/deployment.yaml
Normal file
@@ -0,0 +1,112 @@
|
||||
{{- 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 }}
|
||||
32
supplemental/helm/beszel-agent/templates/hpa.yaml
Normal file
32
supplemental/helm/beszel-agent/templates/hpa.yaml
Normal file
@@ -0,0 +1,32 @@
|
||||
{{- 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 }}
|
||||
38
supplemental/helm/beszel-agent/templates/httproute.yaml
Normal file
38
supplemental/helm/beszel-agent/templates/httproute.yaml
Normal file
@@ -0,0 +1,38 @@
|
||||
{{- 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 }}
|
||||
43
supplemental/helm/beszel-agent/templates/ingress.yaml
Normal file
43
supplemental/helm/beszel-agent/templates/ingress.yaml
Normal file
@@ -0,0 +1,43 @@
|
||||
{{- 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 }}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user