mirror of
https://github.com/henrygd/beszel.git
synced 2026-08-19 00:37:48 +02:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5eca353429 | ||
|
|
d9e3c4678a | ||
|
|
1243a7bd8d | ||
|
|
bd74ab8d7b | ||
|
|
016d775675 | ||
|
|
bdbd135fdd | ||
|
|
48503f9f99 | ||
|
|
d34ef1ebe9 |
12
.github/dependabot.yml
vendored
12
.github/dependabot.yml
vendored
@@ -1,12 +0,0 @@
|
|||||||
version: 2
|
|
||||||
updates:
|
|
||||||
- package-ecosystem: gomod
|
|
||||||
directory: /
|
|
||||||
schedule:
|
|
||||||
interval: weekly
|
|
||||||
|
|
||||||
- package-ecosystem: github-actions
|
|
||||||
directory: /
|
|
||||||
schedule:
|
|
||||||
interval: weekly
|
|
||||||
|
|
||||||
46
.github/workflows/docker-images.yml
vendored
46
.github/workflows/docker-images.yml
vendored
@@ -41,7 +41,7 @@ jobs:
|
|||||||
# henrygd/beszel-agent-nvidia
|
# henrygd/beszel-agent-nvidia
|
||||||
- image: henrygd/beszel-agent-nvidia
|
- image: henrygd/beszel-agent-nvidia
|
||||||
dockerfile: ./internal/dockerfile_agent_nvidia
|
dockerfile: ./internal/dockerfile_agent_nvidia
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64
|
||||||
registry: docker.io
|
registry: docker.io
|
||||||
username_secret: DOCKERHUB_USERNAME
|
username_secret: DOCKERHUB_USERNAME
|
||||||
password_secret: DOCKERHUB_TOKEN
|
password_secret: DOCKERHUB_TOKEN
|
||||||
@@ -52,19 +52,6 @@ jobs:
|
|||||||
type=semver,pattern={{major}}
|
type=semver,pattern={{major}}
|
||||||
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
|
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
|
||||||
|
|
||||||
# henrygd/beszel-agent-nvidia:slim
|
|
||||||
- image: henrygd/beszel-agent-nvidia
|
|
||||||
dockerfile: ./internal/dockerfile_agent_nvidia_slim
|
|
||||||
platforms: linux/amd64,linux/arm64
|
|
||||||
registry: docker.io
|
|
||||||
username_secret: DOCKERHUB_USERNAME
|
|
||||||
password_secret: DOCKERHUB_TOKEN
|
|
||||||
tags: |
|
|
||||||
type=raw,value=slim
|
|
||||||
type=semver,pattern={{version}}-slim
|
|
||||||
type=semver,pattern={{major}}.{{minor}}-slim
|
|
||||||
type=semver,pattern={{major}}-slim
|
|
||||||
|
|
||||||
# henrygd/beszel-agent-intel
|
# henrygd/beszel-agent-intel
|
||||||
- image: henrygd/beszel-agent-intel
|
- image: henrygd/beszel-agent-intel
|
||||||
dockerfile: ./internal/dockerfile_agent_intel
|
dockerfile: ./internal/dockerfile_agent_intel
|
||||||
@@ -109,7 +96,7 @@ jobs:
|
|||||||
# ghcr.io/henrygd/beszel-agent-nvidia
|
# ghcr.io/henrygd/beszel-agent-nvidia
|
||||||
- image: ghcr.io/${{ github.repository }}/beszel-agent-nvidia
|
- image: ghcr.io/${{ github.repository }}/beszel-agent-nvidia
|
||||||
dockerfile: ./internal/dockerfile_agent_nvidia
|
dockerfile: ./internal/dockerfile_agent_nvidia
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password_secret: GITHUB_TOKEN
|
password_secret: GITHUB_TOKEN
|
||||||
@@ -120,19 +107,6 @@ jobs:
|
|||||||
type=semver,pattern={{major}}
|
type=semver,pattern={{major}}
|
||||||
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
|
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
|
||||||
|
|
||||||
# ghcr.io/henrygd/beszel-agent-nvidia:slim
|
|
||||||
- image: ghcr.io/${{ github.repository }}/beszel-agent-nvidia
|
|
||||||
dockerfile: ./internal/dockerfile_agent_nvidia_slim
|
|
||||||
platforms: linux/amd64,linux/arm64
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password_secret: GITHUB_TOKEN
|
|
||||||
tags: |
|
|
||||||
type=raw,value=slim
|
|
||||||
type=semver,pattern={{version}}-slim
|
|
||||||
type=semver,pattern={{major}}.{{minor}}-slim
|
|
||||||
type=semver,pattern={{major}}-slim
|
|
||||||
|
|
||||||
# ghcr.io/henrygd/beszel-agent-intel
|
# ghcr.io/henrygd/beszel-agent-intel
|
||||||
- image: ghcr.io/${{ github.repository }}/beszel-agent-intel
|
- image: ghcr.io/${{ github.repository }}/beszel-agent-intel
|
||||||
dockerfile: ./internal/dockerfile_agent_intel
|
dockerfile: ./internal/dockerfile_agent_intel
|
||||||
@@ -178,7 +152,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up bun
|
- name: Set up bun
|
||||||
uses: oven-sh/setup-bun@v2
|
uses: oven-sh/setup-bun@v2
|
||||||
@@ -190,14 +164,14 @@ jobs:
|
|||||||
run: bun run --cwd ./internal/site build
|
run: bun run --cwd ./internal/site build
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v4
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v4
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Docker metadata
|
- name: Docker metadata
|
||||||
id: metadata
|
id: metadata
|
||||||
uses: docker/metadata-action@v6
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: ${{ matrix.image }}
|
images: ${{ matrix.image }}
|
||||||
tags: ${{ matrix.tags }}
|
tags: ${{ matrix.tags }}
|
||||||
@@ -207,7 +181,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
password_secret_exists: ${{ secrets[matrix.password_secret] != '' && 'true' || 'false' }}
|
password_secret_exists: ${{ secrets[matrix.password_secret] != '' && 'true' || 'false' }}
|
||||||
if: github.event_name != 'pull_request' && env.password_secret_exists == 'true'
|
if: github.event_name != 'pull_request' && env.password_secret_exists == 'true'
|
||||||
uses: docker/login-action@v4
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ matrix.username || secrets[matrix.username_secret] }}
|
username: ${{ matrix.username || secrets[matrix.username_secret] }}
|
||||||
password: ${{ secrets[matrix.password_secret] }}
|
password: ${{ secrets[matrix.password_secret] }}
|
||||||
@@ -216,13 +190,11 @@ jobs:
|
|||||||
# Build and push Docker image with Buildx (don't push on PR)
|
# Build and push Docker image with Buildx (don't push on PR)
|
||||||
# https://github.com/docker/build-push-action
|
# https://github.com/docker/build-push-action
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
uses: docker/build-push-action@v7
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: ./
|
context: ./
|
||||||
file: ${{ matrix.dockerfile }}
|
file: ${{ matrix.dockerfile }}
|
||||||
platforms: ${{ matrix.platforms || 'linux/amd64,linux/arm64,linux/arm/v6,linux/arm/v7' }}
|
platforms: ${{ matrix.platforms || 'linux/amd64,linux/arm64,linux/arm/v7' }}
|
||||||
push: ${{ github.ref_type == 'tag' && secrets[matrix.password_secret] != '' }}
|
push: ${{ github.ref_type == 'tag' && secrets[matrix.password_secret] != '' }}
|
||||||
provenance: mode=max
|
|
||||||
sbom: true
|
|
||||||
tags: ${{ steps.metadata.outputs.tags }}
|
tags: ${{ steps.metadata.outputs.tags }}
|
||||||
labels: ${{ steps.metadata.outputs.labels }}
|
labels: ${{ steps.metadata.outputs.labels }}
|
||||||
|
|||||||
109
.github/workflows/helm-charts.yml
vendored
109
.github/workflows/helm-charts.yml
vendored
@@ -1,109 +0,0 @@
|
|||||||
name: Helm charts
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
paths:
|
|
||||||
- "supplemental/helm/**"
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
paths:
|
|
||||||
- "supplemental/helm/**"
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
|
|
||||||
env:
|
|
||||||
OCI_REGISTRY: ghcr.io/henrygd/beszel-charts
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
changes:
|
|
||||||
name: Detect changed charts
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
outputs:
|
|
||||||
charts: ${{ steps.changes.outputs.charts }}
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v7
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Detect changed charts
|
|
||||||
id: changes
|
|
||||||
env:
|
|
||||||
BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }}
|
|
||||||
run: |
|
|
||||||
charts=()
|
|
||||||
|
|
||||||
for name in beszel-agent beszel-hub; do
|
|
||||||
path="supplemental/helm/$name"
|
|
||||||
if ! git diff --quiet "$BASE_SHA" "$GITHUB_SHA" -- "$path"; then
|
|
||||||
charts+=("$name|$path")
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
printf '%s\n' "${charts[@]}" \
|
|
||||||
| jq -Rsc 'split("\n") | map(select(length > 0) | split("|") | {name: .[0], path: .[1]})' \
|
|
||||||
| xargs -0 printf 'charts=%s\n' >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
validate-and-publish:
|
|
||||||
name: ${{ github.event_name == 'push' && 'Publish' || 'Validate' }} ${{ matrix.chart.name }}
|
|
||||||
needs: changes
|
|
||||||
if: needs.changes.outputs.charts != '[]'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
chart: ${{ fromJSON(needs.changes.outputs.charts) }}
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v7
|
|
||||||
|
|
||||||
- name: Set up Helm
|
|
||||||
uses: azure/setup-helm@v4
|
|
||||||
|
|
||||||
- name: Lint chart
|
|
||||||
run: helm lint "${{ matrix.chart.path }}" --set env.KEY=ci-placeholder
|
|
||||||
|
|
||||||
- name: Render chart
|
|
||||||
run: helm template "${{ matrix.chart.name }}" "${{ matrix.chart.path }}" --set env.KEY=ci-placeholder > /dev/null
|
|
||||||
|
|
||||||
- name: Package chart
|
|
||||||
id: package
|
|
||||||
env:
|
|
||||||
CHART_NAME: ${{ matrix.chart.name }}
|
|
||||||
CHART_PATH: ${{ matrix.chart.path }}
|
|
||||||
run: |
|
|
||||||
version=$(awk '/^version:/ { print $2 }' "$CHART_PATH/Chart.yaml")
|
|
||||||
test -n "$version"
|
|
||||||
|
|
||||||
mkdir -p .helm-packages
|
|
||||||
helm package "$CHART_PATH" --destination .helm-packages
|
|
||||||
|
|
||||||
package=".helm-packages/${CHART_NAME}-${version}.tgz"
|
|
||||||
test -f "$package"
|
|
||||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "package=$package" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Log in to GHCR
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
|
||||||
run: echo "$GITHUB_TOKEN" | helm registry login ghcr.io --username "$GITHUB_ACTOR" --password-stdin
|
|
||||||
|
|
||||||
- name: Check chart version is unpublished
|
|
||||||
env:
|
|
||||||
CHART_NAME: ${{ matrix.chart.name }}
|
|
||||||
CHART_VERSION: ${{ steps.package.outputs.version }}
|
|
||||||
run: |
|
|
||||||
chart="oci://${OCI_REGISTRY}/${CHART_NAME}"
|
|
||||||
if helm show chart "$chart" --version "$CHART_VERSION" > /dev/null 2>&1; then
|
|
||||||
echo "${CHART_NAME} ${CHART_VERSION} is already published. Bump version in Chart.yaml." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Publish chart
|
|
||||||
if: github.event_name == 'push'
|
|
||||||
run: helm push "${{ steps.package.outputs.package }}" "oci://${OCI_REGISTRY}"
|
|
||||||
4
.github/workflows/inactivity-actions.yml
vendored
4
.github/workflows/inactivity-actions.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
|||||||
name: Lock Inactive Issues
|
name: Lock Inactive Issues
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- uses: klaasnicolaas/action-inactivity-lock@v2.0.1
|
- uses: klaasnicolaas/action-inactivity-lock@v1.1.3
|
||||||
id: lock
|
id: lock
|
||||||
with:
|
with:
|
||||||
days-inactive-issues: 14
|
days-inactive-issues: 14
|
||||||
@@ -29,7 +29,7 @@ jobs:
|
|||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- name: Close Stale Issues
|
- name: Close Stale Issues
|
||||||
uses: actions/stale@v11
|
uses: actions/stale@v10
|
||||||
with:
|
with:
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
|||||||
10
.github/workflows/release.yml
vendored
10
.github/workflows/release.yml
vendored
@@ -13,7 +13,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
@@ -27,12 +27,12 @@ jobs:
|
|||||||
run: bun run --cwd ./internal/site build
|
run: bun run --cwd ./internal/site build
|
||||||
|
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v7
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: stable
|
go-version: "^1.22.1"
|
||||||
|
|
||||||
- name: Set up .NET
|
- name: Set up .NET
|
||||||
uses: actions/setup-dotnet@v6
|
uses: actions/setup-dotnet@v4
|
||||||
with:
|
with:
|
||||||
dotnet-version: "9.0.x"
|
dotnet-version: "9.0.x"
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
- name: GoReleaser beszel
|
- name: GoReleaser beszel
|
||||||
uses: goreleaser/goreleaser-action@v7
|
uses: goreleaser/goreleaser-action@v6
|
||||||
with:
|
with:
|
||||||
workdir: ./
|
workdir: ./
|
||||||
distribution: goreleaser
|
distribution: goreleaser
|
||||||
|
|||||||
101
.github/workflows/update-helm-charts.yml
vendored
101
.github/workflows/update-helm-charts.yml
vendored
@@ -1,101 +0,0 @@
|
|||||||
name: Update Helm charts
|
|
||||||
|
|
||||||
on:
|
|
||||||
release:
|
|
||||||
types:
|
|
||||||
- published
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: update-helm-charts
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
update:
|
|
||||||
name: Propose chart update
|
|
||||||
if: ${{ github.repository_owner == 'henrygd' && startsWith(github.event.release.tag_name, 'v') && !github.event.release.prerelease }}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
env:
|
|
||||||
BRANCH: automation/update-helm-app-version
|
|
||||||
RELEASE_TAG: ${{ github.event.release.tag_name }}
|
|
||||||
AUTOMATION_TOKEN: ${{ secrets.CR_TOKEN || github.token }}
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout main
|
|
||||||
uses: actions/checkout@v7
|
|
||||||
with:
|
|
||||||
ref: main
|
|
||||||
token: ${{ env.AUTOMATION_TOKEN }}
|
|
||||||
|
|
||||||
- name: Update chart versions
|
|
||||||
id: update
|
|
||||||
run: |
|
|
||||||
version="${RELEASE_TAG#v}"
|
|
||||||
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
|
||||||
echo "Unsupported software release version: $version" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
changed=false
|
|
||||||
for chart in supplemental/helm/beszel-agent supplemental/helm/beszel-hub; do
|
|
||||||
current_app_version=$(awk -F '"' '/^appVersion:/ { print $2 }' "$chart/Chart.yaml")
|
|
||||||
if [[ "$current_app_version" == "$version" ]]; then
|
|
||||||
echo "$chart already uses appVersion $version"
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
newest_version=$(printf '%s\n' "$current_app_version" "$version" | sort -V | tail -n 1)
|
|
||||||
if [[ "$newest_version" != "$version" ]]; then
|
|
||||||
echo "Skipping stale update of $chart from $current_app_version to $version"
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
chart_version=$(awk '/^version:/ { print $2 }' "$chart/Chart.yaml")
|
|
||||||
if [[ ! "$chart_version" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
|
|
||||||
echo "Unsupported chart version in $chart/Chart.yaml: $chart_version" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
next_chart_version="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.$((BASH_REMATCH[3] + 1))"
|
|
||||||
|
|
||||||
NEW_APP_VERSION="$version" NEW_CHART_VERSION="$next_chart_version" \
|
|
||||||
perl -pi -e 's/^appVersion:.*$/appVersion: "$ENV{NEW_APP_VERSION}"/; s/^version:.*$/version: $ENV{NEW_CHART_VERSION}/' \
|
|
||||||
"$chart/Chart.yaml"
|
|
||||||
OLD_APP_VERSION="$current_app_version" NEW_APP_VERSION="$version" \
|
|
||||||
perl -pi -e 's/\Q$ENV{OLD_APP_VERSION}\E/$ENV{NEW_APP_VERSION}/g' "$chart/README.md"
|
|
||||||
|
|
||||||
echo "$chart: appVersion $current_app_version -> $version, chart $chart_version -> $next_chart_version"
|
|
||||||
changed=true
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "changed=$changed" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Open or update pull request
|
|
||||||
if: steps.update.outputs.changed == 'true'
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ env.AUTOMATION_TOKEN }}
|
|
||||||
run: |
|
|
||||||
version="${RELEASE_TAG#v}"
|
|
||||||
title="chore(helm): update app version to ${version}"
|
|
||||||
body="Updates the Helm charts for [Beszel ${version}](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/tag/${RELEASE_TAG}) and bumps their chart patch versions. Merging this pull request publishes the updated charts to GHCR."
|
|
||||||
|
|
||||||
git config user.name "github-actions[bot]"
|
|
||||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
|
||||||
git checkout -B "$BRANCH"
|
|
||||||
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 "$title"
|
|
||||||
|
|
||||||
git fetch origin "$BRANCH" || true
|
|
||||||
git push --force-with-lease origin "HEAD:refs/heads/${BRANCH}"
|
|
||||||
|
|
||||||
pr_number=$(gh pr list --head "$BRANCH" --base main --state open --json number --jq '.[0].number')
|
|
||||||
if [[ -n "$pr_number" ]]; then
|
|
||||||
gh pr edit "$pr_number" --title "$title" --body "$body"
|
|
||||||
else
|
|
||||||
gh pr create --base main --head "$BRANCH" --title "$title" --body "$body"
|
|
||||||
fi
|
|
||||||
10
.github/workflows/vulncheck.yml
vendored
10
.github/workflows/vulncheck.yml
vendored
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
name: VulnCheck
|
name: VulnCheck
|
||||||
on:
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
@@ -15,11 +19,11 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Check out code into the Go module directory
|
- name: Check out code into the Go module directory
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v4
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v7
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: stable
|
go-version: 1.25.x
|
||||||
# cached: false
|
# cached: false
|
||||||
- name: Get official govulncheck
|
- name: Get official govulncheck
|
||||||
run: go install golang.org/x/vuln/cmd/govulncheck@latest
|
run: go install golang.org/x/vuln/cmd/govulncheck@latest
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -3,6 +3,7 @@ pb_data
|
|||||||
data
|
data
|
||||||
temp
|
temp
|
||||||
.vscode
|
.vscode
|
||||||
|
beszel-agent
|
||||||
beszel_data
|
beszel_data
|
||||||
beszel_data*
|
beszel_data*
|
||||||
dist
|
dist
|
||||||
@@ -20,5 +21,3 @@ __debug_*
|
|||||||
agent/lhm/obj
|
agent/lhm/obj
|
||||||
agent/lhm/bin
|
agent/lhm/bin
|
||||||
dockerfile_agent_dev
|
dockerfile_agent_dev
|
||||||
.cr-release-packages
|
|
||||||
.tmp
|
|
||||||
|
|||||||
@@ -31,16 +31,12 @@ builds:
|
|||||||
goarch: arm64
|
goarch: arm64
|
||||||
- goos: freebsd
|
- goos: freebsd
|
||||||
goarch: arm
|
goarch: arm
|
||||||
- goos: darwin
|
|
||||||
goarch: arm
|
|
||||||
|
|
||||||
- id: beszel-agent
|
- id: beszel-agent
|
||||||
binary: beszel-agent
|
binary: beszel-agent
|
||||||
main: internal/cmd/agent/agent.go
|
main: internal/cmd/agent/agent.go
|
||||||
env:
|
env:
|
||||||
- CGO_ENABLED=0
|
- CGO_ENABLED=0
|
||||||
ldflags:
|
|
||||||
- -s -w -X github.com/henrygd/beszel/internal/ghupdate.buildGOARM={{ .Arm }}
|
|
||||||
goos:
|
goos:
|
||||||
- linux
|
- linux
|
||||||
- darwin
|
- darwin
|
||||||
@@ -56,10 +52,6 @@ builds:
|
|||||||
- mipsle
|
- mipsle
|
||||||
- mips
|
- mips
|
||||||
- ppc64le
|
- ppc64le
|
||||||
goarm:
|
|
||||||
- "5"
|
|
||||||
- "6"
|
|
||||||
- "7"
|
|
||||||
gomips:
|
gomips:
|
||||||
- hardfloat
|
- hardfloat
|
||||||
- softfloat
|
- softfloat
|
||||||
@@ -79,8 +71,6 @@ builds:
|
|||||||
gomips: hardfloat
|
gomips: hardfloat
|
||||||
- goos: windows
|
- goos: windows
|
||||||
goarch: arm
|
goarch: arm
|
||||||
- goos: darwin
|
|
||||||
goarch: arm
|
|
||||||
- goos: darwin
|
- goos: darwin
|
||||||
goarch: riscv64
|
goarch: riscv64
|
||||||
- goos: windows
|
- goos: windows
|
||||||
@@ -107,7 +97,6 @@ archives:
|
|||||||
{{ .Binary }}_
|
{{ .Binary }}_
|
||||||
{{- .Os }}_
|
{{- .Os }}_
|
||||||
{{- .Arch }}
|
{{- .Arch }}
|
||||||
{{- if ne .Arm "6" }}{{ with .Arm }}v{{ . }}{{ end }}{{ end }}
|
|
||||||
format_overrides:
|
format_overrides:
|
||||||
- goos: windows
|
- goos: windows
|
||||||
formats: [zip]
|
formats: [zip]
|
||||||
|
|||||||
2
Makefile
2
Makefile
@@ -52,7 +52,7 @@ lint:
|
|||||||
golangci-lint run
|
golangci-lint run
|
||||||
|
|
||||||
test:
|
test:
|
||||||
go test -tags='testing no_ui' ./...
|
go test -tags=testing ./...
|
||||||
|
|
||||||
tidy:
|
tidy:
|
||||||
go mod tidy
|
go mod tidy
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ package agent
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -13,14 +14,11 @@ import (
|
|||||||
"github.com/gliderlabs/ssh"
|
"github.com/gliderlabs/ssh"
|
||||||
"github.com/henrygd/beszel"
|
"github.com/henrygd/beszel"
|
||||||
"github.com/henrygd/beszel/agent/deltatracker"
|
"github.com/henrygd/beszel/agent/deltatracker"
|
||||||
"github.com/henrygd/beszel/agent/utils"
|
|
||||||
"github.com/henrygd/beszel/internal/common"
|
"github.com/henrygd/beszel/internal/common"
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
gossh "golang.org/x/crypto/ssh"
|
gossh "golang.org/x/crypto/ssh"
|
||||||
)
|
)
|
||||||
|
|
||||||
const defaultDataCacheTimeMs uint16 = 60_000
|
|
||||||
|
|
||||||
type Agent struct {
|
type Agent struct {
|
||||||
sync.Mutex // Used to lock agent while collecting data
|
sync.Mutex // Used to lock agent while collecting data
|
||||||
debug bool // true if LOG_LEVEL is set to debug
|
debug bool // true if LOG_LEVEL is set to debug
|
||||||
@@ -35,10 +33,10 @@ type Agent struct {
|
|||||||
netIoStats map[uint16]system.NetIoStats // Keeps track of bandwidth usage per cache interval
|
netIoStats map[uint16]system.NetIoStats // Keeps track of bandwidth usage per cache interval
|
||||||
netInterfaceDeltaTrackers map[uint16]*deltatracker.DeltaTracker[string, uint64] // Per-cache-time NIC delta trackers
|
netInterfaceDeltaTrackers map[uint16]*deltatracker.DeltaTracker[string, uint64] // Per-cache-time NIC delta trackers
|
||||||
dockerManager *dockerManager // Manages Docker API requests
|
dockerManager *dockerManager // Manages Docker API requests
|
||||||
|
pveManager *pveManager // Manages Proxmox VE API requests
|
||||||
sensorConfig *SensorConfig // Sensors config
|
sensorConfig *SensorConfig // Sensors config
|
||||||
systemInfo system.Info // Host system info (dynamic)
|
systemInfo system.Info // Host system info (dynamic)
|
||||||
systemDetails system.Details // Host system details (static, once-per-connection)
|
systemDetails system.Details // Host system details (static, once-per-connection)
|
||||||
detailsDirty bool // Whether system details have changed and need to be resent
|
|
||||||
gpuManager *GPUManager // Manages GPU data
|
gpuManager *GPUManager // Manages GPU data
|
||||||
cache *systemDataCache // Cache for system stats based on cache time
|
cache *systemDataCache // Cache for system stats based on cache time
|
||||||
connectionManager *ConnectionManager // Channel to signal connection events
|
connectionManager *ConnectionManager // Channel to signal connection events
|
||||||
@@ -71,11 +69,11 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
|
|||||||
slog.Info("Data directory", "path", agent.dataDir)
|
slog.Info("Data directory", "path", agent.dataDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
agent.memCalc, _ = utils.GetEnv("MEM_CALC")
|
agent.memCalc, _ = GetEnv("MEM_CALC")
|
||||||
agent.sensorConfig = agent.newSensorConfig()
|
agent.sensorConfig = agent.newSensorConfig()
|
||||||
|
|
||||||
// Parse disk usage cache duration (e.g., "15m", "1h") to avoid waking sleeping disks
|
// Parse disk usage cache duration (e.g., "15m", "1h") to avoid waking sleeping disks
|
||||||
if diskUsageCache, exists := utils.GetEnv("DISK_USAGE_CACHE"); exists {
|
if diskUsageCache, exists := GetEnv("DISK_USAGE_CACHE"); exists {
|
||||||
if duration, err := time.ParseDuration(diskUsageCache); err == nil {
|
if duration, err := time.ParseDuration(diskUsageCache); err == nil {
|
||||||
agent.diskUsageCacheDuration = duration
|
agent.diskUsageCacheDuration = duration
|
||||||
slog.Info("DISK_USAGE_CACHE", "duration", duration)
|
slog.Info("DISK_USAGE_CACHE", "duration", duration)
|
||||||
@@ -85,7 +83,7 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Set up slog with a log level determined by the LOG_LEVEL env var
|
// Set up slog with a log level determined by the LOG_LEVEL env var
|
||||||
if logLevelStr, exists := utils.GetEnv("LOG_LEVEL"); exists {
|
if logLevelStr, exists := GetEnv("LOG_LEVEL"); exists {
|
||||||
switch strings.ToLower(logLevelStr) {
|
switch strings.ToLower(logLevelStr) {
|
||||||
case "debug":
|
case "debug":
|
||||||
agent.debug = true
|
agent.debug = true
|
||||||
@@ -100,13 +98,16 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
|
|||||||
slog.Debug(beszel.Version)
|
slog.Debug(beszel.Version)
|
||||||
|
|
||||||
// initialize docker manager
|
// initialize docker manager
|
||||||
agent.dockerManager = newDockerManager(agent)
|
agent.dockerManager = newDockerManager()
|
||||||
|
|
||||||
|
// initialize pve manager
|
||||||
|
agent.pveManager = newPVEManager()
|
||||||
|
|
||||||
// initialize system info
|
// initialize system info
|
||||||
agent.refreshSystemDetails()
|
agent.refreshSystemDetails()
|
||||||
|
|
||||||
// SMART_INTERVAL env var to update smart data at this interval
|
// SMART_INTERVAL env var to update smart data at this interval
|
||||||
if smartIntervalEnv, exists := utils.GetEnv("SMART_INTERVAL"); exists {
|
if smartIntervalEnv, exists := GetEnv("SMART_INTERVAL"); exists {
|
||||||
if duration, err := time.ParseDuration(smartIntervalEnv); err == nil && duration > 0 {
|
if duration, err := time.ParseDuration(smartIntervalEnv); err == nil && duration > 0 {
|
||||||
agent.systemDetails.SmartInterval = duration
|
agent.systemDetails.SmartInterval = duration
|
||||||
slog.Info("SMART_INTERVAL", "duration", duration)
|
slog.Info("SMART_INTERVAL", "duration", duration)
|
||||||
@@ -145,12 +146,21 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
|
|||||||
|
|
||||||
// if debugging, print stats
|
// if debugging, print stats
|
||||||
if agent.debug {
|
if agent.debug {
|
||||||
slog.Debug("Stats", "data", agent.gatherStats(common.DataRequestOptions{CacheTimeMs: defaultDataCacheTimeMs, IncludeDetails: true}))
|
slog.Debug("Stats", "data", agent.gatherStats(common.DataRequestOptions{CacheTimeMs: 60_000, IncludeDetails: true}))
|
||||||
}
|
}
|
||||||
|
|
||||||
return agent, nil
|
return agent, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetEnv retrieves an environment variable with a "BESZEL_AGENT_" prefix, or falls back to the unprefixed key.
|
||||||
|
func GetEnv(key string) (value string, exists bool) {
|
||||||
|
if value, exists = os.LookupEnv("BESZEL_AGENT_" + key); exists {
|
||||||
|
return value, exists
|
||||||
|
}
|
||||||
|
// Fallback to the old unprefixed key
|
||||||
|
return os.LookupEnv(key)
|
||||||
|
}
|
||||||
|
|
||||||
func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedData {
|
func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedData {
|
||||||
a.Lock()
|
a.Lock()
|
||||||
defer a.Unlock()
|
defer a.Unlock()
|
||||||
@@ -167,6 +177,11 @@ func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedD
|
|||||||
Info: a.systemInfo,
|
Info: a.systemInfo,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Include static system details only when requested
|
||||||
|
if options.IncludeDetails {
|
||||||
|
data.Details = &a.systemDetails
|
||||||
|
}
|
||||||
|
|
||||||
// slog.Info("System data", "data", data, "cacheTimeMs", cacheTimeMs)
|
// slog.Info("System data", "data", data, "cacheTimeMs", cacheTimeMs)
|
||||||
|
|
||||||
if a.dockerManager != nil {
|
if a.dockerManager != nil {
|
||||||
@@ -178,8 +193,17 @@ func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedD
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if a.pveManager != nil {
|
||||||
|
if pveStats, err := a.pveManager.getPVEStats(); err == nil {
|
||||||
|
data.PVEStats = pveStats
|
||||||
|
slog.Debug("PVE", "data", data.PVEStats)
|
||||||
|
} else {
|
||||||
|
slog.Debug("PVE", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// skip updating systemd services if cache time is not the default 60sec interval
|
// skip updating systemd services if cache time is not the default 60sec interval
|
||||||
if a.systemdManager != nil && cacheTimeMs == defaultDataCacheTimeMs {
|
if a.systemdManager != nil && cacheTimeMs == 60_000 {
|
||||||
totalCount := uint16(a.systemdManager.getServiceStatsCount())
|
totalCount := uint16(a.systemdManager.getServiceStatsCount())
|
||||||
if totalCount > 0 {
|
if totalCount > 0 {
|
||||||
numFailed := a.systemdManager.getFailedServiceCount()
|
numFailed := a.systemdManager.getFailedServiceCount()
|
||||||
@@ -202,7 +226,7 @@ func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedD
|
|||||||
data.Stats.ExtraFs[key] = stats
|
data.Stats.ExtraFs[key] = stats
|
||||||
// Add percentages to Info struct for dashboard
|
// Add percentages to Info struct for dashboard
|
||||||
if stats.DiskTotal > 0 {
|
if stats.DiskTotal > 0 {
|
||||||
pct := utils.TwoDecimals((stats.DiskUsed / stats.DiskTotal) * 100)
|
pct := twoDecimals((stats.DiskUsed / stats.DiskTotal) * 100)
|
||||||
data.Info.ExtraFsPct[key] = pct
|
data.Info.ExtraFsPct[key] = pct
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -210,8 +234,7 @@ func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedD
|
|||||||
slog.Debug("Extra FS", "data", data.Stats.ExtraFs)
|
slog.Debug("Extra FS", "data", data.Stats.ExtraFs)
|
||||||
|
|
||||||
a.cache.Set(data, cacheTimeMs)
|
a.cache.Set(data, cacheTimeMs)
|
||||||
|
return data
|
||||||
return a.attachSystemDetails(data, cacheTimeMs, options.IncludeDetails)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start initializes and starts the agent with optional WebSocket connection
|
// Start initializes and starts the agent with optional WebSocket connection
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func createTestCacheData() *system.CombinedData {
|
func createTestCacheData() *system.CombinedData {
|
||||||
|
var stats = container.Stats{}
|
||||||
|
stats.Name = "test-container"
|
||||||
|
stats.Cpu = 10.5
|
||||||
|
stats.Mem = 1073741824 // 1GB
|
||||||
return &system.CombinedData{
|
return &system.CombinedData{
|
||||||
Stats: system.Stats{
|
Stats: system.Stats{
|
||||||
Cpu: 50.5,
|
Cpu: 50.5,
|
||||||
@@ -24,10 +28,7 @@ func createTestCacheData() *system.CombinedData {
|
|||||||
AgentVersion: "0.12.0",
|
AgentVersion: "0.12.0",
|
||||||
},
|
},
|
||||||
Containers: []*container.Stats{
|
Containers: []*container.Stats{
|
||||||
{
|
&stats,
|
||||||
Name: "test-container",
|
|
||||||
Cpu: 25.0,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,70 +1,84 @@
|
|||||||
// Package battery provides battery information for the host and connected devices.
|
//go:build !freebsd
|
||||||
|
|
||||||
|
// Package battery provides functions to check if the system has a battery and to get the battery stats.
|
||||||
package battery
|
package battery
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"sort"
|
"log/slog"
|
||||||
"strconv"
|
"math"
|
||||||
"strings"
|
|
||||||
|
"github.com/distatus/battery"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
var (
|
||||||
stateUnknown uint8 = iota
|
systemHasBattery = false
|
||||||
stateEmpty
|
haveCheckedBattery = false
|
||||||
stateFull
|
|
||||||
stateCharging
|
|
||||||
stateDischarging
|
|
||||||
stateIdle
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Battery is a readable battery reported by the operating system.
|
// HasReadableBattery checks if the system has a battery and returns true if it does.
|
||||||
type Battery struct {
|
func HasReadableBattery() bool {
|
||||||
Name string
|
if haveCheckedBattery {
|
||||||
Percent uint8
|
return systemHasBattery
|
||||||
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
|
haveCheckedBattery = true
|
||||||
|
batteries, err := battery.GetAll()
|
||||||
|
for _, bat := range batteries {
|
||||||
|
if bat != nil && (bat.Full > 0 || bat.Design > 0) {
|
||||||
|
systemHasBattery = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !systemHasBattery {
|
||||||
|
slog.Debug("No battery found", "err", err)
|
||||||
|
}
|
||||||
|
return systemHasBattery
|
||||||
}
|
}
|
||||||
|
|
||||||
// Primary returns the representative battery. Reported full-charge capacity wins,
|
// GetBatteryStats returns the current battery percent and charge state
|
||||||
// then system-scoped devices, then name for deterministic ties.
|
// percent = (current charge of all batteries) / (sum of designed/full capacity of all batteries)
|
||||||
func Primary(batteries []Battery) (Battery, bool) {
|
func GetBatteryStats() (batteryPercent uint8, batteryState uint8, err error) {
|
||||||
|
if !HasReadableBattery() {
|
||||||
|
return batteryPercent, batteryState, errors.ErrUnsupported
|
||||||
|
}
|
||||||
|
batteries, err := battery.GetAll()
|
||||||
|
// we'll handle errors later by skipping batteries with errors, rather
|
||||||
|
// than skipping everything because of the presence of some errors.
|
||||||
if len(batteries) == 0 {
|
if len(batteries) == 0 {
|
||||||
return Battery{}, false
|
return batteryPercent, batteryState, errors.New("no batteries")
|
||||||
}
|
}
|
||||||
ordered := append([]Battery(nil), batteries...)
|
|
||||||
sort.SliceStable(ordered, func(i, j int) bool {
|
totalCapacity := float64(0)
|
||||||
a, b := ordered[i], ordered[j]
|
totalCharge := float64(0)
|
||||||
if a.HasFullChargeCapacity != b.HasFullChargeCapacity {
|
errs, partialErrs := err.(battery.Errors)
|
||||||
return a.HasFullChargeCapacity
|
|
||||||
|
batteryState = math.MaxUint8
|
||||||
|
|
||||||
|
for i, bat := range batteries {
|
||||||
|
if partialErrs && errs[i] != nil {
|
||||||
|
// if there were some errors, like missing data, skip it
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
if a.HasFullChargeCapacity && a.FullChargeCapacity != b.FullChargeCapacity {
|
if bat == nil || bat.Full == 0 {
|
||||||
return a.FullChargeCapacity > b.FullChargeCapacity
|
// skip batteries with no capacity. Charge is unlikely to ever be zero, but
|
||||||
|
// we can't guarantee that, so don't skip based on charge.
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
if a.System != b.System {
|
totalCapacity += bat.Full
|
||||||
return a.System
|
totalCharge += min(bat.Current, bat.Full)
|
||||||
|
if bat.State.Raw >= 0 {
|
||||||
|
batteryState = uint8(bat.State.Raw)
|
||||||
}
|
}
|
||||||
return a.Name < b.Name
|
}
|
||||||
})
|
|
||||||
return ordered[0], true
|
if totalCapacity == 0 || batteryState == math.MaxUint8 {
|
||||||
|
// for macs there's sometimes a ghost battery with 0 capacity
|
||||||
|
// https://github.com/distatus/battery/issues/34
|
||||||
|
// Instead of skipping over those batteries, we'll check for total 0 capacity
|
||||||
|
// and return an error. This also prevents a divide by zero.
|
||||||
|
return batteryPercent, batteryState, errors.New("no battery capacity")
|
||||||
|
}
|
||||||
|
|
||||||
|
batteryPercent = uint8(totalCharge / totalCapacity * 100)
|
||||||
|
return batteryPercent, batteryState, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
//go:build darwin
|
|
||||||
|
|
||||||
package battery
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os/exec"
|
|
||||||
|
|
||||||
"howett.net/plist"
|
|
||||||
)
|
|
||||||
|
|
||||||
type macBattery struct {
|
|
||||||
CurrentCapacity int `plist:"CurrentCapacity"`
|
|
||||||
MaxCapacity int `plist:"MaxCapacity"`
|
|
||||||
FullyCharged bool `plist:"FullyCharged"`
|
|
||||||
IsCharging bool `plist:"IsCharging"`
|
|
||||||
ExternalConnected bool `plist:"ExternalConnected"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func readMacBatteries() ([]macBattery, error) {
|
|
||||||
out, err := exec.Command("ioreg", "-n", "AppleSmartBattery", "-r", "-a").Output()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if len(out) == 0 {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
var batteries []macBattery
|
|
||||||
if _, err := plist.Unmarshal(out, &batteries); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return batteries, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func HasReadableBattery() bool {
|
|
||||||
batteries, _ := GetBatteryStats()
|
|
||||||
return len(batteries) > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 nil, errNoBatteries
|
|
||||||
}
|
|
||||||
result := make([]Battery, 0, len(batteries))
|
|
||||||
for _, bat := range batteries {
|
|
||||||
if bat.MaxCapacity <= 0 {
|
|
||||||
// skip ghost batteries with 0 capacity
|
|
||||||
// https://github.com/distatus/battery/issues/34
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
percent := min(max(float64(bat.CurrentCapacity)/float64(bat.MaxCapacity)*100, 0), 100)
|
|
||||||
state := stateUnknown
|
|
||||||
switch {
|
|
||||||
case !bat.ExternalConnected:
|
|
||||||
state = stateDischarging
|
|
||||||
case bat.IsCharging:
|
|
||||||
state = stateCharging
|
|
||||||
case bat.CurrentCapacity == 0:
|
|
||||||
state = stateEmpty
|
|
||||||
case !bat.FullyCharged:
|
|
||||||
state = stateIdle
|
|
||||||
default:
|
|
||||||
state = stateFull
|
|
||||||
}
|
|
||||||
result = append(result, Battery{Name: "Primary", Percent: uint8(percent), State: state,
|
|
||||||
FullChargeCapacity: uint64(bat.MaxCapacity), HasFullChargeCapacity: true, System: true})
|
|
||||||
}
|
|
||||||
if len(result) == 0 {
|
|
||||||
return nil, errNoBatteries
|
|
||||||
}
|
|
||||||
return normalizeBatteries(result), nil
|
|
||||||
}
|
|
||||||
13
agent/battery/battery_freebsd.go
Normal file
13
agent/battery/battery_freebsd.go
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
//go:build freebsd
|
||||||
|
|
||||||
|
package battery
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
func HasReadableBattery() bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetBatteryStats() (uint8, uint8, error) {
|
||||||
|
return 0, 0, errors.ErrUnsupported
|
||||||
|
}
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
//go:build linux
|
|
||||||
|
|
||||||
package battery
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/agent/utils"
|
|
||||||
)
|
|
||||||
|
|
||||||
var batteryRoot = "/sys/class/power_supply"
|
|
||||||
|
|
||||||
// HasReadableBattery reports whether collection currently finds a readable battery.
|
|
||||||
func HasReadableBattery() bool {
|
|
||||||
batteries, _ := GetBatteryStats()
|
|
||||||
return len(batteries) > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseSysfsState(status string) uint8 {
|
|
||||||
switch status {
|
|
||||||
case "Empty":
|
|
||||||
return stateEmpty
|
|
||||||
case "Full":
|
|
||||||
return stateFull
|
|
||||||
case "Charging":
|
|
||||||
return stateCharging
|
|
||||||
case "Discharging":
|
|
||||||
return stateDischarging
|
|
||||||
case "Not charging":
|
|
||||||
return stateIdle
|
|
||||||
default:
|
|
||||||
return stateUnknown
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetBatteryStats re-enumerates power supplies and returns every readable battery.
|
|
||||||
func GetBatteryStats() ([]Battery, error) {
|
|
||||||
entries, err := os.ReadDir(batteryRoot)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
|
||||||
cap, parseErr := strconv.Atoi(capStr)
|
|
||||||
if parseErr != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
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 len(batteries) == 0 {
|
|
||||||
return nil, errNoBatteries
|
|
||||||
}
|
|
||||||
return normalizeBatteries(batteries), nil
|
|
||||||
}
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
//go:build testing && linux
|
|
||||||
|
|
||||||
package battery
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
type fakeBattery struct{ id, name, capacity, status, full, scope string }
|
|
||||||
|
|
||||||
func setupFakeSysfs(t *testing.T) (string, func(fakeBattery)) {
|
|
||||||
t.Helper()
|
|
||||||
root := t.TempDir()
|
|
||||||
previousRoot := batteryRoot
|
|
||||||
batteryRoot = root
|
|
||||||
t.Cleanup(func() { batteryRoot = previousRoot })
|
|
||||||
write := func(path, value string) {
|
|
||||||
t.Helper()
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return root, add
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseSysfsState(t *testing.T) {
|
|
||||||
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 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 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 TestGetBatteryStatsHotPlugReenumerates(t *testing.T) {
|
|
||||||
_, add := setupFakeSysfs(t)
|
|
||||||
_, err := GetBatteryStats()
|
|
||||||
assert.Error(t, err)
|
|
||||||
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 TestGetBatteryStatsNoReadableCapacity(t *testing.T) {
|
|
||||||
_, add := setupFakeSysfs(t)
|
|
||||||
add(fakeBattery{id: "BAT0", status: "Charging"})
|
|
||||||
_, err := GetBatteryStats()
|
|
||||||
assert.Error(t, err)
|
|
||||||
assert.False(t, HasReadableBattery())
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
//go:build !darwin && !linux && !windows
|
|
||||||
|
|
||||||
package battery
|
|
||||||
|
|
||||||
import "errors"
|
|
||||||
|
|
||||||
func HasReadableBattery() bool {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetBatteryStats() ([]Battery, error) {
|
|
||||||
return nil, errors.ErrUnsupported
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
package battery
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestPrimarySelection(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
bats []Battery
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{"largest reported capacity", []Battery{{Name: "Small", FullChargeCapacity: 20, HasFullChargeCapacity: true, System: true}, {Name: "Large", FullChargeCapacity: 80, HasFullChargeCapacity: true}}, "Large"},
|
|
||||||
{"reported ranks over missing", []Battery{{Name: "Unknown", System: true}, {Name: "Known", FullChargeCapacity: 1, HasFullChargeCapacity: true}}, "Known"},
|
|
||||||
{"system wins capacity tie", []Battery{{Name: "Peripheral", FullChargeCapacity: 50, HasFullChargeCapacity: true}, {Name: "System", FullChargeCapacity: 50, HasFullChargeCapacity: true, System: true}}, "System"},
|
|
||||||
{"name resolves final tie", []Battery{{Name: "Zed"}, {Name: "Alpha"}}, "Alpha"},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
got, ok := Primary(tt.bats)
|
|
||||||
require.True(t, ok)
|
|
||||||
assert.Equal(t, tt.want, got.Name)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
_, ok := Primary(nil)
|
|
||||||
assert.False(t, ok)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNormalizeBatteriesFallbackNames(t *testing.T) {
|
|
||||||
bats := normalizeBatteries([]Battery{{}, {}, {Name: "Mouse"}, {Name: "Mouse"}})
|
|
||||||
assert.Equal(t, []string{"Battery 1", "Battery 2", "Mouse", "Mouse (2)"}, []string{bats[0].Name, bats[1].Name, bats[2].Name, bats[3].Name})
|
|
||||||
}
|
|
||||||
@@ -1,291 +0,0 @@
|
|||||||
//go:build windows
|
|
||||||
|
|
||||||
// Most of the Windows battery code is based on
|
|
||||||
// distatus/battery by Karol 'Kenji Takahashi' Woźniak
|
|
||||||
|
|
||||||
package battery
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"syscall"
|
|
||||||
"unsafe"
|
|
||||||
|
|
||||||
"golang.org/x/sys/windows"
|
|
||||||
)
|
|
||||||
|
|
||||||
type batteryQueryInformation struct {
|
|
||||||
BatteryTag uint32
|
|
||||||
InformationLevel int32
|
|
||||||
AtRate int32
|
|
||||||
}
|
|
||||||
|
|
||||||
type batteryInformation struct {
|
|
||||||
Capabilities uint32
|
|
||||||
Technology uint8
|
|
||||||
Reserved [3]uint8
|
|
||||||
Chemistry [4]uint8
|
|
||||||
DesignedCapacity uint32
|
|
||||||
FullChargedCapacity uint32
|
|
||||||
DefaultAlert1 uint32
|
|
||||||
DefaultAlert2 uint32
|
|
||||||
CriticalBias uint32
|
|
||||||
CycleCount uint32
|
|
||||||
}
|
|
||||||
|
|
||||||
type batteryWaitStatus struct {
|
|
||||||
BatteryTag uint32
|
|
||||||
Timeout uint32
|
|
||||||
PowerState uint32
|
|
||||||
LowCapacity uint32
|
|
||||||
HighCapacity uint32
|
|
||||||
}
|
|
||||||
|
|
||||||
type batteryStatus struct {
|
|
||||||
PowerState uint32
|
|
||||||
Capacity uint32
|
|
||||||
Voltage uint32
|
|
||||||
Rate int32
|
|
||||||
}
|
|
||||||
|
|
||||||
type winGUID struct {
|
|
||||||
Data1 uint32
|
|
||||||
Data2 uint16
|
|
||||||
Data3 uint16
|
|
||||||
Data4 [8]byte
|
|
||||||
}
|
|
||||||
|
|
||||||
type spDeviceInterfaceData struct {
|
|
||||||
cbSize uint32
|
|
||||||
InterfaceClassGuid winGUID
|
|
||||||
Flags uint32
|
|
||||||
Reserved uint
|
|
||||||
}
|
|
||||||
|
|
||||||
var guidDeviceBattery = winGUID{
|
|
||||||
0x72631e54,
|
|
||||||
0x78A4,
|
|
||||||
0x11d0,
|
|
||||||
[8]byte{0xbc, 0xf7, 0x00, 0xaa, 0x00, 0xb7, 0xb3, 0x2a},
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
setupapi = &windows.LazyDLL{Name: "setupapi.dll", System: true}
|
|
||||||
setupDiGetClassDevsW = setupapi.NewProc("SetupDiGetClassDevsW")
|
|
||||||
setupDiEnumDeviceInterfaces = setupapi.NewProc("SetupDiEnumDeviceInterfaces")
|
|
||||||
setupDiGetDeviceInterfaceDetailW = setupapi.NewProc("SetupDiGetDeviceInterfaceDetailW")
|
|
||||||
setupDiDestroyDeviceInfoList = setupapi.NewProc("SetupDiDestroyDeviceInfoList")
|
|
||||||
)
|
|
||||||
|
|
||||||
// winBatteryGet reads one battery by index.
|
|
||||||
// Returns error == errNotFound when there are no more batteries.
|
|
||||||
var errNotFound = errors.New("no more batteries")
|
|
||||||
|
|
||||||
func setupDiSetup(proc *windows.LazyProc, nargs, a1, a2, a3, a4, a5, a6 uintptr) (uintptr, error) {
|
|
||||||
_ = nargs
|
|
||||||
r1, _, errno := syscall.SyscallN(proc.Addr(), a1, a2, a3, a4, a5, a6)
|
|
||||||
if windows.Handle(r1) == windows.InvalidHandle {
|
|
||||||
if errno != 0 {
|
|
||||||
return 0, error(errno)
|
|
||||||
}
|
|
||||||
return 0, syscall.EINVAL
|
|
||||||
}
|
|
||||||
return r1, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func setupDiCall(proc *windows.LazyProc, nargs, a1, a2, a3, a4, a5, a6 uintptr) syscall.Errno {
|
|
||||||
_ = nargs
|
|
||||||
r1, _, errno := syscall.SyscallN(proc.Addr(), a1, a2, a3, a4, a5, a6)
|
|
||||||
if r1 == 0 {
|
|
||||||
if errno != 0 {
|
|
||||||
return errno
|
|
||||||
}
|
|
||||||
return syscall.EINVAL
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func readWinBatteryState(powerState uint32) uint8 {
|
|
||||||
switch {
|
|
||||||
case powerState&0x00000004 != 0:
|
|
||||||
return stateCharging
|
|
||||||
case powerState&0x00000008 != 0:
|
|
||||||
return stateEmpty
|
|
||||||
case powerState&0x00000002 != 0:
|
|
||||||
return stateDischarging
|
|
||||||
case powerState&0x00000001 != 0:
|
|
||||||
return stateFull
|
|
||||||
default:
|
|
||||||
return stateUnknown
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func winBatteryGet(idx int) (Battery, error) {
|
|
||||||
hdev, err := setupDiSetup(
|
|
||||||
setupDiGetClassDevsW,
|
|
||||||
4,
|
|
||||||
uintptr(unsafe.Pointer(&guidDeviceBattery)),
|
|
||||||
0, 0,
|
|
||||||
2|16, // DIGCF_PRESENT|DIGCF_DEVICEINTERFACE
|
|
||||||
0, 0,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return Battery{}, err
|
|
||||||
}
|
|
||||||
defer syscall.SyscallN(setupDiDestroyDeviceInfoList.Addr(), hdev)
|
|
||||||
|
|
||||||
var did spDeviceInterfaceData
|
|
||||||
did.cbSize = uint32(unsafe.Sizeof(did))
|
|
||||||
errno := setupDiCall(
|
|
||||||
setupDiEnumDeviceInterfaces,
|
|
||||||
5,
|
|
||||||
hdev, 0,
|
|
||||||
uintptr(unsafe.Pointer(&guidDeviceBattery)),
|
|
||||||
uintptr(idx),
|
|
||||||
uintptr(unsafe.Pointer(&did)),
|
|
||||||
0,
|
|
||||||
)
|
|
||||||
if errno == 259 { // ERROR_NO_MORE_ITEMS
|
|
||||||
return Battery{}, errNotFound
|
|
||||||
}
|
|
||||||
if errno != 0 {
|
|
||||||
return Battery{}, errno
|
|
||||||
}
|
|
||||||
|
|
||||||
var cbRequired uint32
|
|
||||||
errno = setupDiCall(
|
|
||||||
setupDiGetDeviceInterfaceDetailW,
|
|
||||||
6,
|
|
||||||
hdev,
|
|
||||||
uintptr(unsafe.Pointer(&did)),
|
|
||||||
0, 0,
|
|
||||||
uintptr(unsafe.Pointer(&cbRequired)),
|
|
||||||
0,
|
|
||||||
)
|
|
||||||
if errno != 0 && errno != 122 { // ERROR_INSUFFICIENT_BUFFER
|
|
||||||
return Battery{}, errno
|
|
||||||
}
|
|
||||||
didd := make([]uint16, cbRequired/2)
|
|
||||||
cbSize := (*uint32)(unsafe.Pointer(&didd[0]))
|
|
||||||
if unsafe.Sizeof(uint(0)) == 8 {
|
|
||||||
*cbSize = 8
|
|
||||||
} else {
|
|
||||||
*cbSize = 6
|
|
||||||
}
|
|
||||||
errno = setupDiCall(
|
|
||||||
setupDiGetDeviceInterfaceDetailW,
|
|
||||||
6,
|
|
||||||
hdev,
|
|
||||||
uintptr(unsafe.Pointer(&did)),
|
|
||||||
uintptr(unsafe.Pointer(&didd[0])),
|
|
||||||
uintptr(cbRequired),
|
|
||||||
uintptr(unsafe.Pointer(&cbRequired)),
|
|
||||||
0,
|
|
||||||
)
|
|
||||||
if errno != 0 {
|
|
||||||
return Battery{}, errno
|
|
||||||
}
|
|
||||||
devicePath := &didd[2:][0]
|
|
||||||
|
|
||||||
handle, err := windows.CreateFile(
|
|
||||||
devicePath,
|
|
||||||
windows.GENERIC_READ|windows.GENERIC_WRITE,
|
|
||||||
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE,
|
|
||||||
nil,
|
|
||||||
windows.OPEN_EXISTING,
|
|
||||||
windows.FILE_ATTRIBUTE_NORMAL,
|
|
||||||
0,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return Battery{}, err
|
|
||||||
}
|
|
||||||
defer windows.CloseHandle(handle)
|
|
||||||
|
|
||||||
var dwOut uint32
|
|
||||||
var dwWait uint32
|
|
||||||
var bqi batteryQueryInformation
|
|
||||||
err = windows.DeviceIoControl(
|
|
||||||
handle,
|
|
||||||
2703424, // IOCTL_BATTERY_QUERY_TAG
|
|
||||||
(*byte)(unsafe.Pointer(&dwWait)),
|
|
||||||
uint32(unsafe.Sizeof(dwWait)),
|
|
||||||
(*byte)(unsafe.Pointer(&bqi.BatteryTag)),
|
|
||||||
uint32(unsafe.Sizeof(bqi.BatteryTag)),
|
|
||||||
&dwOut, nil,
|
|
||||||
)
|
|
||||||
if err != nil || bqi.BatteryTag == 0 {
|
|
||||||
return Battery{}, errors.New("battery tag not returned")
|
|
||||||
}
|
|
||||||
|
|
||||||
var bi batteryInformation
|
|
||||||
if err = windows.DeviceIoControl(
|
|
||||||
handle,
|
|
||||||
2703428, // IOCTL_BATTERY_QUERY_INFORMATION
|
|
||||||
(*byte)(unsafe.Pointer(&bqi)),
|
|
||||||
uint32(unsafe.Sizeof(bqi)),
|
|
||||||
(*byte)(unsafe.Pointer(&bi)),
|
|
||||||
uint32(unsafe.Sizeof(bi)),
|
|
||||||
&dwOut, nil,
|
|
||||||
); err != nil {
|
|
||||||
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}
|
|
||||||
var bs batteryStatus
|
|
||||||
if err = windows.DeviceIoControl(
|
|
||||||
handle,
|
|
||||||
2703436, // IOCTL_BATTERY_QUERY_STATUS
|
|
||||||
(*byte)(unsafe.Pointer(&bws)),
|
|
||||||
uint32(unsafe.Sizeof(bws)),
|
|
||||||
(*byte)(unsafe.Pointer(&bs)),
|
|
||||||
uint32(unsafe.Sizeof(bs)),
|
|
||||||
&dwOut, nil,
|
|
||||||
); err != nil {
|
|
||||||
return Battery{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if bs.Capacity == 0xffffffff || bi.FullChargedCapacity == 0 || bi.FullChargedCapacity == 0xffffffff {
|
|
||||||
return Battery{}, errors.New("battery capacity unknown")
|
|
||||||
}
|
|
||||||
percent := min(float64(bs.Capacity)/float64(bi.FullChargedCapacity)*100, 100)
|
|
||||||
return Battery{Name: name, Percent: uint8(percent), State: readWinBatteryState(bs.PowerState),
|
|
||||||
FullChargeCapacity: uint64(bi.FullChargedCapacity), HasFullChargeCapacity: true, System: true}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// HasReadableBattery checks if the system has a battery and returns true if it does.
|
|
||||||
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++ {
|
|
||||||
battery, bErr := winBatteryGet(i)
|
|
||||||
if errors.Is(bErr, errNotFound) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if bErr != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
batteries = append(batteries, battery)
|
|
||||||
}
|
|
||||||
if len(batteries) == 0 {
|
|
||||||
return nil, errNoBatteries
|
|
||||||
}
|
|
||||||
return normalizeBatteries(batteries), nil
|
|
||||||
}
|
|
||||||
@@ -14,13 +14,11 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel"
|
"github.com/henrygd/beszel"
|
||||||
"github.com/henrygd/beszel/agent/utils"
|
|
||||||
"github.com/henrygd/beszel/internal/common"
|
"github.com/henrygd/beszel/internal/common"
|
||||||
|
|
||||||
"github.com/fxamacker/cbor/v2"
|
"github.com/fxamacker/cbor/v2"
|
||||||
"github.com/lxzan/gws"
|
"github.com/lxzan/gws"
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
"golang.org/x/net/proxy"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -45,7 +43,7 @@ type WebSocketClient struct {
|
|||||||
// newWebSocketClient creates a new WebSocket client for the given agent.
|
// newWebSocketClient creates a new WebSocket client for the given agent.
|
||||||
// It reads configuration from environment variables and validates the hub URL.
|
// It reads configuration from environment variables and validates the hub URL.
|
||||||
func newWebSocketClient(agent *Agent) (client *WebSocketClient, err error) {
|
func newWebSocketClient(agent *Agent) (client *WebSocketClient, err error) {
|
||||||
hubURLStr, exists := utils.GetEnv("HUB_URL")
|
hubURLStr, exists := GetEnv("HUB_URL")
|
||||||
if !exists {
|
if !exists {
|
||||||
return nil, errors.New("HUB_URL environment variable not set")
|
return nil, errors.New("HUB_URL environment variable not set")
|
||||||
}
|
}
|
||||||
@@ -53,8 +51,8 @@ func newWebSocketClient(agent *Agent) (client *WebSocketClient, err error) {
|
|||||||
client = &WebSocketClient{}
|
client = &WebSocketClient{}
|
||||||
|
|
||||||
client.hubURL, err = url.Parse(hubURLStr)
|
client.hubURL, err = url.Parse(hubURLStr)
|
||||||
if err != nil || client.hubURL.Host == "" {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("invalid HUB_URL %q: must include scheme and host (e.g. http://hub.example.com:8090)", hubURLStr)
|
return nil, errors.New("invalid hub URL")
|
||||||
}
|
}
|
||||||
// get registration token
|
// get registration token
|
||||||
client.token, err = getToken()
|
client.token, err = getToken()
|
||||||
@@ -74,12 +72,12 @@ func newWebSocketClient(agent *Agent) (client *WebSocketClient, err error) {
|
|||||||
// If neither is set, it returns an error.
|
// If neither is set, it returns an error.
|
||||||
func getToken() (string, error) {
|
func getToken() (string, error) {
|
||||||
// get token from env var
|
// get token from env var
|
||||||
token, _ := utils.GetEnv("TOKEN")
|
token, _ := GetEnv("TOKEN")
|
||||||
if token != "" {
|
if token != "" {
|
||||||
return token, nil
|
return token, nil
|
||||||
}
|
}
|
||||||
// get token from file
|
// get token from file
|
||||||
tokenFile, _ := utils.GetEnv("TOKEN_FILE")
|
tokenFile, _ := GetEnv("TOKEN_FILE")
|
||||||
if tokenFile == "" {
|
if tokenFile == "" {
|
||||||
return "", errors.New("must set TOKEN or TOKEN_FILE")
|
return "", errors.New("must set TOKEN or TOKEN_FILE")
|
||||||
}
|
}
|
||||||
@@ -105,11 +103,6 @@ func (client *WebSocketClient) getOptions() *gws.ClientOption {
|
|||||||
}
|
}
|
||||||
client.hubURL.Path = path.Join(client.hubURL.Path, "api/beszel/agent-connect")
|
client.hubURL.Path = path.Join(client.hubURL.Path, "api/beszel/agent-connect")
|
||||||
|
|
||||||
// make sure BESZEL_AGENT_ALL_PROXY works (GWS only checks ALL_PROXY)
|
|
||||||
if val := os.Getenv("BESZEL_AGENT_ALL_PROXY"); val != "" {
|
|
||||||
os.Setenv("ALL_PROXY", val)
|
|
||||||
}
|
|
||||||
|
|
||||||
client.options = &gws.ClientOption{
|
client.options = &gws.ClientOption{
|
||||||
Addr: client.hubURL.String(),
|
Addr: client.hubURL.String(),
|
||||||
TlsConfig: &tls.Config{InsecureSkipVerify: true},
|
TlsConfig: &tls.Config{InsecureSkipVerify: true},
|
||||||
@@ -118,9 +111,6 @@ func (client *WebSocketClient) getOptions() *gws.ClientOption {
|
|||||||
"X-Token": []string{client.token},
|
"X-Token": []string{client.token},
|
||||||
"X-Beszel": []string{beszel.Version},
|
"X-Beszel": []string{beszel.Version},
|
||||||
},
|
},
|
||||||
NewDialer: func() (gws.Dialer, error) {
|
|
||||||
return proxy.FromEnvironment(), nil
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
return client.options
|
return client.options
|
||||||
}
|
}
|
||||||
@@ -207,7 +197,7 @@ func (client *WebSocketClient) handleAuthChallenge(msg *common.HubRequest[cbor.R
|
|||||||
}
|
}
|
||||||
|
|
||||||
if authRequest.NeedSysInfo {
|
if authRequest.NeedSysInfo {
|
||||||
response.Name, _ = utils.GetEnv("SYSTEM_NAME")
|
response.Name, _ = GetEnv("SYSTEM_NAME")
|
||||||
response.Hostname = client.agent.systemDetails.Hostname
|
response.Hostname = client.agent.systemDetails.Hostname
|
||||||
serverAddr := client.agent.connectionManager.serverOptions.Addr
|
serverAddr := client.agent.connectionManager.serverOptions.Addr
|
||||||
_, response.Port, _ = net.SplitHostPort(serverAddr)
|
_, response.Port, _ = net.SplitHostPort(serverAddr)
|
||||||
|
|||||||
@@ -51,18 +51,11 @@ func TestNewWebSocketClient(t *testing.T) {
|
|||||||
errorMsg: "HUB_URL environment variable not set",
|
errorMsg: "HUB_URL environment variable not set",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "malformed URL",
|
name: "invalid URL",
|
||||||
hubURL: "ht\ttp://invalid",
|
hubURL: "ht\ttp://invalid",
|
||||||
token: "test-token",
|
token: "test-token",
|
||||||
expectError: true,
|
expectError: true,
|
||||||
errorMsg: "invalid HUB_URL",
|
errorMsg: "invalid hub URL",
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "URL without host",
|
|
||||||
hubURL: "http:/api",
|
|
||||||
token: "test-token",
|
|
||||||
expectError: true,
|
|
||||||
errorMsg: "invalid HUB_URL",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "missing token",
|
name: "missing token",
|
||||||
@@ -77,11 +70,19 @@ func TestNewWebSocketClient(t *testing.T) {
|
|||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
// Set up environment
|
// Set up environment
|
||||||
if tc.hubURL != "" {
|
if tc.hubURL != "" {
|
||||||
t.Setenv("BESZEL_AGENT_HUB_URL", tc.hubURL)
|
os.Setenv("BESZEL_AGENT_HUB_URL", tc.hubURL)
|
||||||
|
} else {
|
||||||
|
os.Unsetenv("BESZEL_AGENT_HUB_URL")
|
||||||
}
|
}
|
||||||
if tc.token != "" {
|
if tc.token != "" {
|
||||||
t.Setenv("BESZEL_AGENT_TOKEN", tc.token)
|
os.Setenv("BESZEL_AGENT_TOKEN", tc.token)
|
||||||
|
} else {
|
||||||
|
os.Unsetenv("BESZEL_AGENT_TOKEN")
|
||||||
}
|
}
|
||||||
|
defer func() {
|
||||||
|
os.Unsetenv("BESZEL_AGENT_HUB_URL")
|
||||||
|
os.Unsetenv("BESZEL_AGENT_TOKEN")
|
||||||
|
}()
|
||||||
|
|
||||||
client, err := newWebSocketClient(agent)
|
client, err := newWebSocketClient(agent)
|
||||||
|
|
||||||
@@ -137,8 +138,12 @@ func TestWebSocketClient_GetOptions(t *testing.T) {
|
|||||||
for _, tc := range testCases {
|
for _, tc := range testCases {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
// Set up environment
|
// Set up environment
|
||||||
t.Setenv("BESZEL_AGENT_HUB_URL", tc.inputURL)
|
os.Setenv("BESZEL_AGENT_HUB_URL", tc.inputURL)
|
||||||
t.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
os.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
||||||
|
defer func() {
|
||||||
|
os.Unsetenv("BESZEL_AGENT_HUB_URL")
|
||||||
|
os.Unsetenv("BESZEL_AGENT_TOKEN")
|
||||||
|
}()
|
||||||
|
|
||||||
client, err := newWebSocketClient(agent)
|
client, err := newWebSocketClient(agent)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -180,8 +185,12 @@ func TestWebSocketClient_VerifySignature(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Set up environment
|
// Set up environment
|
||||||
t.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080")
|
os.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080")
|
||||||
t.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
os.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
||||||
|
defer func() {
|
||||||
|
os.Unsetenv("BESZEL_AGENT_HUB_URL")
|
||||||
|
os.Unsetenv("BESZEL_AGENT_TOKEN")
|
||||||
|
}()
|
||||||
|
|
||||||
client, err := newWebSocketClient(agent)
|
client, err := newWebSocketClient(agent)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -249,8 +258,12 @@ func TestWebSocketClient_HandleHubRequest(t *testing.T) {
|
|||||||
agent := createTestAgent(t)
|
agent := createTestAgent(t)
|
||||||
|
|
||||||
// Set up environment
|
// Set up environment
|
||||||
t.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080")
|
os.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080")
|
||||||
t.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
os.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
||||||
|
defer func() {
|
||||||
|
os.Unsetenv("BESZEL_AGENT_HUB_URL")
|
||||||
|
os.Unsetenv("BESZEL_AGENT_TOKEN")
|
||||||
|
}()
|
||||||
|
|
||||||
client, err := newWebSocketClient(agent)
|
client, err := newWebSocketClient(agent)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -337,8 +350,13 @@ func TestGetUserAgent(t *testing.T) {
|
|||||||
func TestWebSocketClient_Close(t *testing.T) {
|
func TestWebSocketClient_Close(t *testing.T) {
|
||||||
agent := createTestAgent(t)
|
agent := createTestAgent(t)
|
||||||
|
|
||||||
t.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080")
|
// Set up environment
|
||||||
t.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
os.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080")
|
||||||
|
os.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
||||||
|
defer func() {
|
||||||
|
os.Unsetenv("BESZEL_AGENT_HUB_URL")
|
||||||
|
os.Unsetenv("BESZEL_AGENT_TOKEN")
|
||||||
|
}()
|
||||||
|
|
||||||
client, err := newWebSocketClient(agent)
|
client, err := newWebSocketClient(agent)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -353,8 +371,13 @@ func TestWebSocketClient_Close(t *testing.T) {
|
|||||||
func TestWebSocketClient_ConnectRateLimit(t *testing.T) {
|
func TestWebSocketClient_ConnectRateLimit(t *testing.T) {
|
||||||
agent := createTestAgent(t)
|
agent := createTestAgent(t)
|
||||||
|
|
||||||
t.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080")
|
// Set up environment
|
||||||
t.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
os.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080")
|
||||||
|
os.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
||||||
|
defer func() {
|
||||||
|
os.Unsetenv("BESZEL_AGENT_HUB_URL")
|
||||||
|
os.Unsetenv("BESZEL_AGENT_TOKEN")
|
||||||
|
}()
|
||||||
|
|
||||||
client, err := newWebSocketClient(agent)
|
client, err := newWebSocketClient(agent)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -370,10 +393,20 @@ func TestWebSocketClient_ConnectRateLimit(t *testing.T) {
|
|||||||
|
|
||||||
// TestGetToken tests the getToken function with various scenarios
|
// TestGetToken tests the getToken function with various scenarios
|
||||||
func TestGetToken(t *testing.T) {
|
func TestGetToken(t *testing.T) {
|
||||||
|
unsetEnvVars := func() {
|
||||||
|
os.Unsetenv("BESZEL_AGENT_TOKEN")
|
||||||
|
os.Unsetenv("TOKEN")
|
||||||
|
os.Unsetenv("BESZEL_AGENT_TOKEN_FILE")
|
||||||
|
os.Unsetenv("TOKEN_FILE")
|
||||||
|
}
|
||||||
|
|
||||||
t.Run("token from TOKEN environment variable", func(t *testing.T) {
|
t.Run("token from TOKEN environment variable", func(t *testing.T) {
|
||||||
|
unsetEnvVars()
|
||||||
|
|
||||||
// Set TOKEN env var
|
// Set TOKEN env var
|
||||||
expectedToken := "test-token-from-env"
|
expectedToken := "test-token-from-env"
|
||||||
t.Setenv("TOKEN", expectedToken)
|
os.Setenv("TOKEN", expectedToken)
|
||||||
|
defer os.Unsetenv("TOKEN")
|
||||||
|
|
||||||
token, err := getToken()
|
token, err := getToken()
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
@@ -381,9 +414,12 @@ func TestGetToken(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("token from BESZEL_AGENT_TOKEN environment variable", func(t *testing.T) {
|
t.Run("token from BESZEL_AGENT_TOKEN environment variable", func(t *testing.T) {
|
||||||
|
unsetEnvVars()
|
||||||
|
|
||||||
// Set BESZEL_AGENT_TOKEN env var (should take precedence)
|
// Set BESZEL_AGENT_TOKEN env var (should take precedence)
|
||||||
expectedToken := "test-token-from-beszel-env"
|
expectedToken := "test-token-from-beszel-env"
|
||||||
t.Setenv("BESZEL_AGENT_TOKEN", expectedToken)
|
os.Setenv("BESZEL_AGENT_TOKEN", expectedToken)
|
||||||
|
defer os.Unsetenv("BESZEL_AGENT_TOKEN")
|
||||||
|
|
||||||
token, err := getToken()
|
token, err := getToken()
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
@@ -391,6 +427,8 @@ func TestGetToken(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("token from TOKEN_FILE", func(t *testing.T) {
|
t.Run("token from TOKEN_FILE", func(t *testing.T) {
|
||||||
|
unsetEnvVars()
|
||||||
|
|
||||||
// Create a temporary token file
|
// Create a temporary token file
|
||||||
expectedToken := "test-token-from-file"
|
expectedToken := "test-token-from-file"
|
||||||
tokenFile, err := os.CreateTemp("", "token-test-*.txt")
|
tokenFile, err := os.CreateTemp("", "token-test-*.txt")
|
||||||
@@ -402,7 +440,8 @@ func TestGetToken(t *testing.T) {
|
|||||||
tokenFile.Close()
|
tokenFile.Close()
|
||||||
|
|
||||||
// Set TOKEN_FILE env var
|
// Set TOKEN_FILE env var
|
||||||
t.Setenv("TOKEN_FILE", tokenFile.Name())
|
os.Setenv("TOKEN_FILE", tokenFile.Name())
|
||||||
|
defer os.Unsetenv("TOKEN_FILE")
|
||||||
|
|
||||||
token, err := getToken()
|
token, err := getToken()
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
@@ -410,6 +449,8 @@ func TestGetToken(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("token from BESZEL_AGENT_TOKEN_FILE", func(t *testing.T) {
|
t.Run("token from BESZEL_AGENT_TOKEN_FILE", func(t *testing.T) {
|
||||||
|
unsetEnvVars()
|
||||||
|
|
||||||
// Create a temporary token file
|
// Create a temporary token file
|
||||||
expectedToken := "test-token-from-beszel-file"
|
expectedToken := "test-token-from-beszel-file"
|
||||||
tokenFile, err := os.CreateTemp("", "token-test-*.txt")
|
tokenFile, err := os.CreateTemp("", "token-test-*.txt")
|
||||||
@@ -421,7 +462,8 @@ func TestGetToken(t *testing.T) {
|
|||||||
tokenFile.Close()
|
tokenFile.Close()
|
||||||
|
|
||||||
// Set BESZEL_AGENT_TOKEN_FILE env var (should take precedence)
|
// Set BESZEL_AGENT_TOKEN_FILE env var (should take precedence)
|
||||||
t.Setenv("BESZEL_AGENT_TOKEN_FILE", tokenFile.Name())
|
os.Setenv("BESZEL_AGENT_TOKEN_FILE", tokenFile.Name())
|
||||||
|
defer os.Unsetenv("BESZEL_AGENT_TOKEN_FILE")
|
||||||
|
|
||||||
token, err := getToken()
|
token, err := getToken()
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
@@ -429,6 +471,8 @@ func TestGetToken(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("TOKEN takes precedence over TOKEN_FILE", func(t *testing.T) {
|
t.Run("TOKEN takes precedence over TOKEN_FILE", func(t *testing.T) {
|
||||||
|
unsetEnvVars()
|
||||||
|
|
||||||
// Create a temporary token file
|
// Create a temporary token file
|
||||||
fileToken := "token-from-file"
|
fileToken := "token-from-file"
|
||||||
tokenFile, err := os.CreateTemp("", "token-test-*.txt")
|
tokenFile, err := os.CreateTemp("", "token-test-*.txt")
|
||||||
@@ -441,8 +485,12 @@ func TestGetToken(t *testing.T) {
|
|||||||
|
|
||||||
// Set both TOKEN and TOKEN_FILE
|
// Set both TOKEN and TOKEN_FILE
|
||||||
envToken := "token-from-env"
|
envToken := "token-from-env"
|
||||||
t.Setenv("TOKEN", envToken)
|
os.Setenv("TOKEN", envToken)
|
||||||
t.Setenv("TOKEN_FILE", tokenFile.Name())
|
os.Setenv("TOKEN_FILE", tokenFile.Name())
|
||||||
|
defer func() {
|
||||||
|
os.Unsetenv("TOKEN")
|
||||||
|
os.Unsetenv("TOKEN_FILE")
|
||||||
|
}()
|
||||||
|
|
||||||
token, err := getToken()
|
token, err := getToken()
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
@@ -450,10 +498,7 @@ func TestGetToken(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("error when neither TOKEN nor TOKEN_FILE is set", func(t *testing.T) {
|
t.Run("error when neither TOKEN nor TOKEN_FILE is set", func(t *testing.T) {
|
||||||
t.Setenv("BESZEL_AGENT_TOKEN", "")
|
unsetEnvVars()
|
||||||
t.Setenv("TOKEN", "")
|
|
||||||
t.Setenv("BESZEL_AGENT_TOKEN_FILE", "")
|
|
||||||
t.Setenv("TOKEN_FILE", "")
|
|
||||||
|
|
||||||
token, err := getToken()
|
token, err := getToken()
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
@@ -462,8 +507,11 @@ func TestGetToken(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("error when TOKEN_FILE points to non-existent file", func(t *testing.T) {
|
t.Run("error when TOKEN_FILE points to non-existent file", func(t *testing.T) {
|
||||||
|
unsetEnvVars()
|
||||||
|
|
||||||
// Set TOKEN_FILE to a non-existent file
|
// Set TOKEN_FILE to a non-existent file
|
||||||
t.Setenv("TOKEN_FILE", "/non/existent/file.txt")
|
os.Setenv("TOKEN_FILE", "/non/existent/file.txt")
|
||||||
|
defer os.Unsetenv("TOKEN_FILE")
|
||||||
|
|
||||||
token, err := getToken()
|
token, err := getToken()
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
@@ -472,6 +520,8 @@ func TestGetToken(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("handles empty token file", func(t *testing.T) {
|
t.Run("handles empty token file", func(t *testing.T) {
|
||||||
|
unsetEnvVars()
|
||||||
|
|
||||||
// Create an empty token file
|
// Create an empty token file
|
||||||
tokenFile, err := os.CreateTemp("", "token-test-*.txt")
|
tokenFile, err := os.CreateTemp("", "token-test-*.txt")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -479,7 +529,8 @@ func TestGetToken(t *testing.T) {
|
|||||||
tokenFile.Close()
|
tokenFile.Close()
|
||||||
|
|
||||||
// Set TOKEN_FILE env var
|
// Set TOKEN_FILE env var
|
||||||
t.Setenv("TOKEN_FILE", tokenFile.Name())
|
os.Setenv("TOKEN_FILE", tokenFile.Name())
|
||||||
|
defer os.Unsetenv("TOKEN_FILE")
|
||||||
|
|
||||||
token, err := getToken()
|
token, err := getToken()
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
@@ -487,6 +538,8 @@ func TestGetToken(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("strips whitespace from TOKEN_FILE", func(t *testing.T) {
|
t.Run("strips whitespace from TOKEN_FILE", func(t *testing.T) {
|
||||||
|
unsetEnvVars()
|
||||||
|
|
||||||
tokenWithWhitespace := " test-token-with-whitespace \n\t"
|
tokenWithWhitespace := " test-token-with-whitespace \n\t"
|
||||||
expectedToken := "test-token-with-whitespace"
|
expectedToken := "test-token-with-whitespace"
|
||||||
tokenFile, err := os.CreateTemp("", "token-test-*.txt")
|
tokenFile, err := os.CreateTemp("", "token-test-*.txt")
|
||||||
@@ -497,7 +550,8 @@ func TestGetToken(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
tokenFile.Close()
|
tokenFile.Close()
|
||||||
|
|
||||||
t.Setenv("TOKEN_FILE", tokenFile.Name())
|
os.Setenv("TOKEN_FILE", tokenFile.Name())
|
||||||
|
defer os.Unsetenv("TOKEN_FILE")
|
||||||
|
|
||||||
token, err := getToken()
|
token, err := getToken()
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|||||||
@@ -4,15 +4,11 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
|
||||||
"os"
|
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"strings"
|
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/agent/health"
|
"github.com/henrygd/beszel/agent/health"
|
||||||
"github.com/henrygd/beszel/agent/utils"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -115,36 +111,13 @@ func (c *ConnectionManager) Start(serverOptions ServerOptions) error {
|
|||||||
_ = health.Update()
|
_ = health.Update()
|
||||||
case <-sigCtx.Done():
|
case <-sigCtx.Done():
|
||||||
slog.Info("Shutting down", "cause", context.Cause(sigCtx))
|
slog.Info("Shutting down", "cause", context.Cause(sigCtx))
|
||||||
return c.stop()
|
_ = c.agent.StopServer()
|
||||||
|
c.closeWebSocket()
|
||||||
|
return health.CleanUp()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// stop does not stop the connection manager itself, just any active connections. The manager will attempt to reconnect after stopping, so this should only be called immediately before shutting down the entire agent.
|
|
||||||
//
|
|
||||||
// If we need or want to expose a graceful Stop method in the future, do something like this to actually stop the manager:
|
|
||||||
//
|
|
||||||
// func (c *ConnectionManager) Start(serverOptions ServerOptions) error {
|
|
||||||
// ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
// c.cancel = cancel
|
|
||||||
//
|
|
||||||
// for {
|
|
||||||
// select {
|
|
||||||
// case <-ctx.Done():
|
|
||||||
// return c.stop()
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// func (c *ConnectionManager) Stop() {
|
|
||||||
// c.cancel()
|
|
||||||
// }
|
|
||||||
func (c *ConnectionManager) stop() error {
|
|
||||||
_ = c.agent.StopServer()
|
|
||||||
c.closeWebSocket()
|
|
||||||
return health.CleanUp()
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleEvent processes connection events and updates the connection state accordingly.
|
// handleEvent processes connection events and updates the connection state accordingly.
|
||||||
func (c *ConnectionManager) handleEvent(event ConnectionEvent) {
|
func (c *ConnectionManager) handleEvent(event ConnectionEvent) {
|
||||||
switch event {
|
switch event {
|
||||||
@@ -212,16 +185,9 @@ func (c *ConnectionManager) connect() {
|
|||||||
|
|
||||||
// Try WebSocket first, if it fails, start SSH server
|
// Try WebSocket first, if it fails, start SSH server
|
||||||
err := c.startWebSocketConnection()
|
err := c.startWebSocketConnection()
|
||||||
if err != nil {
|
if err != nil && c.State == Disconnected {
|
||||||
if shouldExitOnErr(err) {
|
c.startSSHServer()
|
||||||
time.Sleep(2 * time.Second) // prevent tight restart loop
|
c.startWsTicker()
|
||||||
_ = c.stop()
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
if c.State == Disconnected {
|
|
||||||
c.startSSHServer()
|
|
||||||
c.startWsTicker()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,14 +224,3 @@ func (c *ConnectionManager) closeWebSocket() {
|
|||||||
c.wsClient.Close()
|
c.wsClient.Close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// shouldExitOnErr checks if the error is a DNS resolution failure and if the
|
|
||||||
// EXIT_ON_DNS_ERROR env var is set. https://github.com/henrygd/beszel/issues/1924.
|
|
||||||
func shouldExitOnErr(err error) bool {
|
|
||||||
if val, _ := utils.GetEnv("EXIT_ON_DNS_ERROR"); val == "true" {
|
|
||||||
if opErr, ok := errors.AsType[*net.OpError](err); ok {
|
|
||||||
return strings.Contains(opErr.Err.Error(), "lookup")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ package agent
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/ed25519"
|
"crypto/ed25519"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -183,6 +183,10 @@ func TestConnectionManager_TickerManagement(t *testing.T) {
|
|||||||
|
|
||||||
// TestConnectionManager_WebSocketConnectionFlow tests WebSocket connection logic
|
// TestConnectionManager_WebSocketConnectionFlow tests WebSocket connection logic
|
||||||
func TestConnectionManager_WebSocketConnectionFlow(t *testing.T) {
|
func TestConnectionManager_WebSocketConnectionFlow(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Skipping WebSocket connection test in short mode")
|
||||||
|
}
|
||||||
|
|
||||||
agent := createTestAgent(t)
|
agent := createTestAgent(t)
|
||||||
cm := agent.connectionManager
|
cm := agent.connectionManager
|
||||||
|
|
||||||
@@ -192,18 +196,19 @@ func TestConnectionManager_WebSocketConnectionFlow(t *testing.T) {
|
|||||||
assert.Equal(t, Disconnected, cm.State, "State should remain Disconnected after failed connection")
|
assert.Equal(t, Disconnected, cm.State, "State should remain Disconnected after failed connection")
|
||||||
|
|
||||||
// Test with invalid URL
|
// Test with invalid URL
|
||||||
t.Setenv("BESZEL_AGENT_HUB_URL", "1,33%")
|
os.Setenv("BESZEL_AGENT_HUB_URL", "invalid-url")
|
||||||
t.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
os.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
||||||
|
defer func() {
|
||||||
_, err2 := newWebSocketClient(agent)
|
os.Unsetenv("BESZEL_AGENT_HUB_URL")
|
||||||
assert.Error(t, err2, "WebSocket client creation should fail with invalid URL")
|
os.Unsetenv("BESZEL_AGENT_TOKEN")
|
||||||
|
}()
|
||||||
|
|
||||||
// Test with missing token
|
// Test with missing token
|
||||||
t.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080")
|
os.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080")
|
||||||
t.Setenv("BESZEL_AGENT_TOKEN", "")
|
os.Unsetenv("BESZEL_AGENT_TOKEN")
|
||||||
|
|
||||||
_, err3 := newWebSocketClient(agent)
|
_, err2 := newWebSocketClient(agent)
|
||||||
assert.Error(t, err3, "WebSocket client creation should fail without token")
|
assert.Error(t, err2, "WebSocket client creation should fail without token")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestConnectionManager_ReconnectionLogic tests reconnection prevention logic
|
// TestConnectionManager_ReconnectionLogic tests reconnection prevention logic
|
||||||
@@ -229,8 +234,12 @@ func TestConnectionManager_ConnectWithRateLimit(t *testing.T) {
|
|||||||
cm := agent.connectionManager
|
cm := agent.connectionManager
|
||||||
|
|
||||||
// Set up environment for WebSocket client creation
|
// Set up environment for WebSocket client creation
|
||||||
t.Setenv("BESZEL_AGENT_HUB_URL", "ws://localhost:8080")
|
os.Setenv("BESZEL_AGENT_HUB_URL", "ws://localhost:8080")
|
||||||
t.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
os.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
||||||
|
defer func() {
|
||||||
|
os.Unsetenv("BESZEL_AGENT_HUB_URL")
|
||||||
|
os.Unsetenv("BESZEL_AGENT_TOKEN")
|
||||||
|
}()
|
||||||
|
|
||||||
// Create WebSocket client
|
// Create WebSocket client
|
||||||
wsClient, err := newWebSocketClient(agent)
|
wsClient, err := newWebSocketClient(agent)
|
||||||
@@ -276,8 +285,12 @@ func TestConnectionManager_CloseWebSocket(t *testing.T) {
|
|||||||
}, "Should not panic when closing nil WebSocket client")
|
}, "Should not panic when closing nil WebSocket client")
|
||||||
|
|
||||||
// Set up environment and create WebSocket client
|
// Set up environment and create WebSocket client
|
||||||
t.Setenv("BESZEL_AGENT_HUB_URL", "ws://localhost:8080")
|
os.Setenv("BESZEL_AGENT_HUB_URL", "ws://localhost:8080")
|
||||||
t.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
os.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
||||||
|
defer func() {
|
||||||
|
os.Unsetenv("BESZEL_AGENT_HUB_URL")
|
||||||
|
os.Unsetenv("BESZEL_AGENT_TOKEN")
|
||||||
|
}()
|
||||||
|
|
||||||
wsClient, err := newWebSocketClient(agent)
|
wsClient, err := newWebSocketClient(agent)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -299,65 +312,3 @@ func TestConnectionManager_ConnectFlow(t *testing.T) {
|
|||||||
cm.connect()
|
cm.connect()
|
||||||
}, "Connect should not panic without WebSocket client")
|
}, "Connect should not panic without WebSocket client")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestShouldExitOnErr(t *testing.T) {
|
|
||||||
createDialErr := func(msg string) error {
|
|
||||||
return &net.OpError{
|
|
||||||
Op: "dial",
|
|
||||||
Net: "tcp",
|
|
||||||
Err: errors.New(msg),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
err error
|
|
||||||
envValue string
|
|
||||||
expected bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "no env var",
|
|
||||||
err: createDialErr("lookup lkahsdfasdf: no such host"),
|
|
||||||
envValue: "",
|
|
||||||
expected: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "env var false",
|
|
||||||
err: createDialErr("lookup lkahsdfasdf: no such host"),
|
|
||||||
envValue: "false",
|
|
||||||
expected: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "env var true, matching error",
|
|
||||||
err: createDialErr("lookup lkahsdfasdf: no such host"),
|
|
||||||
envValue: "true",
|
|
||||||
expected: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "env var true, matching error with extra context",
|
|
||||||
err: createDialErr("lookup beszel.server.lan on [::1]:53: read udp [::1]:44557->[::1]:53: read: connection refused"),
|
|
||||||
envValue: "true",
|
|
||||||
expected: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "env var true, non-matching error",
|
|
||||||
err: errors.New("connection refused"),
|
|
||||||
envValue: "true",
|
|
||||||
expected: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "env var true, dial but not lookup",
|
|
||||||
err: createDialErr("connection timeout"),
|
|
||||||
envValue: "true",
|
|
||||||
expected: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
t.Setenv("EXIT_ON_DNS_ERROR", tt.envValue)
|
|
||||||
result := shouldExitOnErr(tt.err)
|
|
||||||
assert.Equal(t, tt.expected, result)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/agent/utils"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetDataDir returns the path to the data directory for the agent and an error
|
// GetDataDir returns the path to the data directory for the agent and an error
|
||||||
@@ -18,7 +16,7 @@ func GetDataDir(dataDirs ...string) (string, error) {
|
|||||||
return testDataDirs(dataDirs)
|
return testDataDirs(dataDirs)
|
||||||
}
|
}
|
||||||
|
|
||||||
dataDir, _ := utils.GetEnv("DATA_DIR")
|
dataDir, _ := GetEnv("DATA_DIR")
|
||||||
if dataDir != "" {
|
if dataDir != "" {
|
||||||
dataDirs = append(dataDirs, dataDir)
|
dataDirs = append(dataDirs, dataDir)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,17 @@ func TestGetDataDir(t *testing.T) {
|
|||||||
t.Run("DATA_DIR environment variable", func(t *testing.T) {
|
t.Run("DATA_DIR environment variable", func(t *testing.T) {
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
t.Setenv("BESZEL_AGENT_DATA_DIR", tempDir)
|
// Set environment variable
|
||||||
|
oldValue := os.Getenv("DATA_DIR")
|
||||||
|
defer func() {
|
||||||
|
if oldValue == "" {
|
||||||
|
os.Unsetenv("BESZEL_AGENT_DATA_DIR")
|
||||||
|
} else {
|
||||||
|
os.Setenv("BESZEL_AGENT_DATA_DIR", oldValue)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
os.Setenv("BESZEL_AGENT_DATA_DIR", tempDir)
|
||||||
|
|
||||||
result, err := GetDataDir()
|
result, err := GetDataDir()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -55,6 +65,17 @@ func TestGetDataDir(t *testing.T) {
|
|||||||
|
|
||||||
// Test fallback behavior (empty dataDir, no env var)
|
// Test fallback behavior (empty dataDir, no env var)
|
||||||
t.Run("fallback to default directories", func(t *testing.T) {
|
t.Run("fallback to default directories", func(t *testing.T) {
|
||||||
|
// Clear DATA_DIR environment variable
|
||||||
|
oldValue := os.Getenv("DATA_DIR")
|
||||||
|
defer func() {
|
||||||
|
if oldValue == "" {
|
||||||
|
os.Unsetenv("DATA_DIR")
|
||||||
|
} else {
|
||||||
|
os.Setenv("DATA_DIR", oldValue)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
os.Unsetenv("DATA_DIR")
|
||||||
|
|
||||||
// This will try platform-specific defaults, which may or may not work
|
// This will try platform-specific defaults, which may or may not work
|
||||||
// We're mainly testing that it doesn't panic and returns some result
|
// We're mainly testing that it doesn't panic and returns some result
|
||||||
result, err := GetDataDir()
|
result, err := GetDataDir()
|
||||||
|
|||||||
518
agent/disk.go
518
agent/disk.go
@@ -1,7 +1,6 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -9,59 +8,11 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/agent/utils"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
|
||||||
"github.com/shirou/gopsutil/v4/disk"
|
"github.com/shirou/gopsutil/v4/disk"
|
||||||
)
|
)
|
||||||
|
|
||||||
// fsRegistrationContext holds the shared lookup state needed to resolve a
|
|
||||||
// filesystem into the tracked fsStats key and metadata.
|
|
||||||
type fsRegistrationContext struct {
|
|
||||||
filesystem string // value of optional FILESYSTEM env var
|
|
||||||
isWindows bool
|
|
||||||
efPath string // path to extra filesystems (default "/extra-filesystems")
|
|
||||||
diskIoCounters map[string]disk.IOCountersStat
|
|
||||||
}
|
|
||||||
|
|
||||||
// diskDiscovery groups the transient state for a single initializeDiskInfo run so
|
|
||||||
// helper methods can share the same partitions, mount paths, and lookup functions
|
|
||||||
type diskDiscovery struct {
|
|
||||||
agent *Agent
|
|
||||||
rootMountPoint string
|
|
||||||
partitions []disk.PartitionStat
|
|
||||||
usageFn func(string) (*disk.UsageStat, error)
|
|
||||||
ctx fsRegistrationContext
|
|
||||||
}
|
|
||||||
|
|
||||||
// prevDisk stores previous per-device disk counters for a given cache interval
|
|
||||||
type prevDisk struct {
|
|
||||||
readBytes uint64
|
|
||||||
writeBytes uint64
|
|
||||||
readTime uint64 // cumulative ms spent on reads (from ReadTime)
|
|
||||||
writeTime uint64 // cumulative ms spent on writes (from WriteTime)
|
|
||||||
ioTime uint64 // cumulative ms spent doing I/O (from IoTime)
|
|
||||||
weightedIO uint64 // cumulative weighted ms (queue-depth × ms, from WeightedIO)
|
|
||||||
readCount uint64 // cumulative read operation count
|
|
||||||
writeCount uint64 // cumulative write operation count
|
|
||||||
at time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// prevDiskFromCounter creates a prevDisk snapshot from a disk.IOCountersStat at time t.
|
|
||||||
func prevDiskFromCounter(d disk.IOCountersStat, t time.Time) prevDisk {
|
|
||||||
return prevDisk{
|
|
||||||
readBytes: d.ReadBytes,
|
|
||||||
writeBytes: d.WriteBytes,
|
|
||||||
readTime: d.ReadTime,
|
|
||||||
writeTime: d.WriteTime,
|
|
||||||
ioTime: d.IoTime,
|
|
||||||
weightedIO: d.WeightedIO,
|
|
||||||
readCount: d.ReadCount,
|
|
||||||
writeCount: d.WriteCount,
|
|
||||||
at: t,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseFilesystemEntry parses a filesystem entry in the format "device__customname"
|
// parseFilesystemEntry parses a filesystem entry in the format "device__customname"
|
||||||
// Returns the device/filesystem part and the custom name part
|
// Returns the device/filesystem part and the custom name part
|
||||||
func parseFilesystemEntry(entry string) (device, customName string) {
|
func parseFilesystemEntry(entry string) (device, customName string) {
|
||||||
@@ -75,236 +26,23 @@ func parseFilesystemEntry(entry string) (device, customName string) {
|
|||||||
return device, customName
|
return device, customName
|
||||||
}
|
}
|
||||||
|
|
||||||
// extraFilesystemPartitionInfo derives the I/O device and optional display name
|
|
||||||
// for a mounted /extra-filesystems partition. Prefer the partition device reported
|
|
||||||
// by the system and only use the folder name for custom naming metadata.
|
|
||||||
func extraFilesystemPartitionInfo(p disk.PartitionStat) (device, customName string) {
|
|
||||||
device = strings.TrimSpace(p.Device)
|
|
||||||
folderDevice, customName := parseFilesystemEntry(filepath.Base(p.Mountpoint))
|
|
||||||
if device == "" {
|
|
||||||
device = folderDevice
|
|
||||||
}
|
|
||||||
return device, customName
|
|
||||||
}
|
|
||||||
|
|
||||||
func isDockerSpecialMountpoint(mountpoint string) bool {
|
func isDockerSpecialMountpoint(mountpoint string) bool {
|
||||||
switch mountpoint {
|
switch mountpoint {
|
||||||
case "/etc/hosts", "/etc/resolv.conf", "/etc/hostname":
|
case "/etc/hosts", "/etc/resolv.conf", "/etc/hostname":
|
||||||
return true
|
return true
|
||||||
}
|
default:
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// registerFilesystemStats resolves the tracked key and stats payload for a
|
|
||||||
// filesystem before it is inserted into fsStats.
|
|
||||||
func registerFilesystemStats(existing map[string]*system.FsStats, device, mountpoint string, root bool, customName string, ctx fsRegistrationContext) (string, *system.FsStats, bool) {
|
|
||||||
key := device
|
|
||||||
if !ctx.isWindows {
|
|
||||||
key = filepath.Base(device)
|
|
||||||
}
|
|
||||||
|
|
||||||
if root {
|
|
||||||
// Try to map root device to a diskIoCounters entry. First checks for an
|
|
||||||
// exact key match, then uses findIoDevice for normalized / prefix-based
|
|
||||||
// matching (e.g. nda0p2 -> nda0), and finally falls back to FILESYSTEM.
|
|
||||||
if _, ioMatch := ctx.diskIoCounters[key]; !ioMatch {
|
|
||||||
if matchedKey, match := findIoDevice(key, ctx.diskIoCounters); match {
|
|
||||||
key = matchedKey
|
|
||||||
} else if ctx.filesystem != "" {
|
|
||||||
if matchedKey, match := findIoDevice(ctx.filesystem, ctx.diskIoCounters); match {
|
|
||||||
key = matchedKey
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if _, ioMatch = ctx.diskIoCounters[key]; !ioMatch {
|
|
||||||
slog.Warn("Root I/O unmapped; set FILESYSTEM", "device", device, "mountpoint", mountpoint)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Check if non-root has diskstats and prefer the folder device for
|
|
||||||
// /extra-filesystems mounts when the discovered partition device is a
|
|
||||||
// mapper path (e.g. luks UUID) that obscures the underlying block device.
|
|
||||||
if _, ioMatch := ctx.diskIoCounters[key]; !ioMatch {
|
|
||||||
if strings.HasPrefix(mountpoint, ctx.efPath) {
|
|
||||||
folderDevice, _ := parseFilesystemEntry(filepath.Base(mountpoint))
|
|
||||||
if folderDevice != "" {
|
|
||||||
if matchedKey, match := findIoDevice(folderDevice, ctx.diskIoCounters); match {
|
|
||||||
key = matchedKey
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if _, ioMatch = ctx.diskIoCounters[key]; !ioMatch {
|
|
||||||
if matchedKey, match := findIoDevice(key, ctx.diskIoCounters); match {
|
|
||||||
key = matchedKey
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, exists := existing[key]; exists {
|
|
||||||
return "", nil, false
|
|
||||||
}
|
|
||||||
|
|
||||||
fsStats := &system.FsStats{Root: root, Mountpoint: mountpoint}
|
|
||||||
if customName != "" {
|
|
||||||
fsStats.Name = customName
|
|
||||||
}
|
|
||||||
return key, fsStats, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// addFsStat inserts a discovered filesystem if it resolves to a new tracking
|
|
||||||
// key. The key selection itself lives in buildFsStatRegistration so that logic
|
|
||||||
// can stay directly unit-tested.
|
|
||||||
func (d *diskDiscovery) addFsStat(device, mountpoint string, root bool, customName string) {
|
|
||||||
key, fsStats, ok := registerFilesystemStats(d.agent.fsStats, device, mountpoint, root, customName, d.ctx)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
d.agent.fsStats[key] = fsStats
|
|
||||||
name := key
|
|
||||||
if customName != "" {
|
|
||||||
name = customName
|
|
||||||
}
|
|
||||||
slog.Info("Detected disk", "name", name, "device", device, "mount", mountpoint, "io", key, "root", root)
|
|
||||||
}
|
|
||||||
|
|
||||||
// addConfiguredRootFs resolves FILESYSTEM against partitions first, then falls
|
|
||||||
// back to direct diskstats matching for setups like ZFS where partitions do not
|
|
||||||
// expose the physical device name.
|
|
||||||
func (d *diskDiscovery) addConfiguredRootFs() bool {
|
|
||||||
if d.ctx.filesystem == "" {
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, p := range d.partitions {
|
|
||||||
if filesystemMatchesPartitionSetting(d.ctx.filesystem, p) {
|
|
||||||
d.addFsStat(p.Device, p.Mountpoint, true, "")
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// FILESYSTEM may name a physical disk absent from partitions (e.g. ZFS lists
|
|
||||||
// dataset paths like zroot/ROOT/default, not block devices).
|
|
||||||
if ioKey, match := findIoDevice(d.ctx.filesystem, d.ctx.diskIoCounters); match {
|
|
||||||
d.agent.fsStats[ioKey] = &system.FsStats{Root: true, Mountpoint: d.rootMountPoint}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
slog.Warn("Partition details not found", "filesystem", d.ctx.filesystem)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func isRootFallbackPartition(p disk.PartitionStat, rootMountPoint string) bool {
|
|
||||||
return p.Mountpoint == rootMountPoint ||
|
|
||||||
(isDockerSpecialMountpoint(p.Mountpoint) && strings.HasPrefix(p.Device, "/dev"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// addPartitionRootFs handles the non-configured root fallback path when a
|
|
||||||
// partition looks like the active root mount but still needs translating to an
|
|
||||||
// I/O device key.
|
|
||||||
func (d *diskDiscovery) addPartitionRootFs(device, mountpoint string) bool {
|
|
||||||
fs, match := findIoDevice(filepath.Base(device), d.ctx.diskIoCounters)
|
|
||||||
if !match {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
// The resolved I/O device is already known here, so use it directly to avoid
|
|
||||||
// a second fallback search inside buildFsStatRegistration.
|
|
||||||
d.addFsStat(fs, mountpoint, true, "")
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// addLastResortRootFs is only used when neither FILESYSTEM nor partition-based
|
|
||||||
// heuristics can identify root, so it picks the busiest I/O device as a final
|
|
||||||
// fallback and preserves the root mountpoint for usage collection.
|
|
||||||
func (d *diskDiscovery) addLastResortRootFs() {
|
|
||||||
rootKey := mostActiveIoDevice(d.ctx.diskIoCounters)
|
|
||||||
if rootKey != "" {
|
|
||||||
slog.Warn("Using most active device for root I/O; set FILESYSTEM to override", "device", rootKey)
|
|
||||||
} else {
|
|
||||||
rootKey = filepath.Base(d.rootMountPoint)
|
|
||||||
if _, exists := d.agent.fsStats[rootKey]; exists {
|
|
||||||
rootKey = "root"
|
|
||||||
}
|
|
||||||
slog.Warn("Root I/O device not detected; set FILESYSTEM to override")
|
|
||||||
}
|
|
||||||
d.agent.fsStats[rootKey] = &system.FsStats{Root: true, Mountpoint: d.rootMountPoint}
|
|
||||||
}
|
|
||||||
|
|
||||||
// findPartitionByFilesystemSetting matches an EXTRA_FILESYSTEMS entry against a
|
|
||||||
// discovered partition either by mountpoint or by device suffix.
|
|
||||||
func findPartitionByFilesystemSetting(filesystem string, partitions []disk.PartitionStat) (disk.PartitionStat, bool) {
|
|
||||||
for _, p := range partitions {
|
|
||||||
if strings.HasSuffix(p.Device, filesystem) || p.Mountpoint == filesystem {
|
|
||||||
return p, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return disk.PartitionStat{}, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// addConfiguredExtraFsEntry resolves one EXTRA_FILESYSTEMS entry, preferring a
|
|
||||||
// discovered partition and falling back to any path that disk.Usage accepts.
|
|
||||||
func (d *diskDiscovery) addConfiguredExtraFsEntry(filesystem, customName string) {
|
|
||||||
if p, found := findPartitionByFilesystemSetting(filesystem, d.partitions); found {
|
|
||||||
d.addFsStat(p.Device, p.Mountpoint, false, customName)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := d.usageFn(filesystem); err == nil {
|
|
||||||
d.addFsStat(filepath.Base(filesystem), filesystem, false, customName)
|
|
||||||
return
|
|
||||||
} else {
|
|
||||||
slog.Error("Invalid filesystem", "name", filesystem, "err", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// addConfiguredExtraFilesystems parses and registers the comma-separated
|
|
||||||
// EXTRA_FILESYSTEMS env var entries.
|
|
||||||
func (d *diskDiscovery) addConfiguredExtraFilesystems(extraFilesystems string) {
|
|
||||||
for fsEntry := range strings.SplitSeq(extraFilesystems, ",") {
|
|
||||||
filesystem, customName := parseFilesystemEntry(fsEntry)
|
|
||||||
d.addConfiguredExtraFsEntry(filesystem, customName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// addPartitionExtraFs registers partitions mounted under /extra-filesystems so
|
|
||||||
// their display names can come from the folder name while their I/O keys still
|
|
||||||
// prefer the underlying partition device. Only direct children are matched to
|
|
||||||
// avoid registering nested virtual mounts (e.g. /proc, /sys) that are returned by
|
|
||||||
// disk.Partitions(true) when the host root is bind-mounted in /extra-filesystems.
|
|
||||||
func (d *diskDiscovery) addPartitionExtraFs(p disk.PartitionStat) {
|
|
||||||
if filepath.Dir(p.Mountpoint) != d.ctx.efPath {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
device, customName := extraFilesystemPartitionInfo(p)
|
|
||||||
d.addFsStat(device, p.Mountpoint, false, customName)
|
|
||||||
}
|
|
||||||
|
|
||||||
// addExtraFilesystemFolders handles bare directories under /extra-filesystems
|
|
||||||
// that may not appear in partition discovery, while skipping mountpoints that
|
|
||||||
// were already registered from higher-fidelity sources.
|
|
||||||
func (d *diskDiscovery) addExtraFilesystemFolders(folderNames []string) {
|
|
||||||
existingMountpoints := make(map[string]bool, len(d.agent.fsStats))
|
|
||||||
for _, stats := range d.agent.fsStats {
|
|
||||||
existingMountpoints[stats.Mountpoint] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, folderName := range folderNames {
|
|
||||||
mountpoint := filepath.Join(d.ctx.efPath, folderName)
|
|
||||||
slog.Debug("/extra-filesystems", "mountpoint", mountpoint)
|
|
||||||
if existingMountpoints[mountpoint] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
device, customName := parseFilesystemEntry(folderName)
|
|
||||||
d.addFsStat(device, mountpoint, false, customName)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sets up the filesystems to monitor for disk usage and I/O.
|
// Sets up the filesystems to monitor for disk usage and I/O.
|
||||||
func (a *Agent) initializeDiskInfo() {
|
func (a *Agent) initializeDiskInfo() {
|
||||||
filesystem, _ := utils.GetEnv("FILESYSTEM")
|
filesystem, _ := GetEnv("FILESYSTEM")
|
||||||
|
efPath := "/extra-filesystems"
|
||||||
hasRoot := false
|
hasRoot := false
|
||||||
isWindows := runtime.GOOS == "windows"
|
isWindows := runtime.GOOS == "windows"
|
||||||
|
|
||||||
partitions, err := disk.PartitionsWithContext(context.Background(), true)
|
partitions, err := disk.Partitions(false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Error getting disk partitions", "err", err)
|
slog.Error("Error getting disk partitions", "err", err)
|
||||||
}
|
}
|
||||||
@@ -317,57 +55,167 @@ func (a *Agent) initializeDiskInfo() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ioContext := context.WithValue(a.sensorsContext,
|
||||||
|
// common.EnvKey, common.EnvMap{common.HostProcEnvKey: "/tmp/testproc"},
|
||||||
|
// )
|
||||||
|
// diskIoCounters, err := disk.IOCountersWithContext(ioContext)
|
||||||
|
|
||||||
diskIoCounters, err := disk.IOCounters()
|
diskIoCounters, err := disk.IOCounters()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Error getting diskstats", "err", err)
|
slog.Error("Error getting diskstats", "err", err)
|
||||||
}
|
}
|
||||||
slog.Debug("Disk I/O", "diskstats", diskIoCounters)
|
slog.Debug("Disk I/O", "diskstats", diskIoCounters)
|
||||||
ctx := fsRegistrationContext{
|
|
||||||
filesystem: filesystem,
|
// Helper function to add a filesystem to fsStats if it doesn't exist
|
||||||
isWindows: isWindows,
|
addFsStat := func(device, mountpoint string, root bool, customName ...string) {
|
||||||
diskIoCounters: diskIoCounters,
|
var key string
|
||||||
efPath: "/extra-filesystems",
|
if isWindows {
|
||||||
|
key = device
|
||||||
|
} else {
|
||||||
|
key = filepath.Base(device)
|
||||||
|
}
|
||||||
|
var ioMatch bool
|
||||||
|
if _, exists := a.fsStats[key]; !exists {
|
||||||
|
if root {
|
||||||
|
slog.Info("Detected root device", "name", key)
|
||||||
|
// Try to map root device to a diskIoCounters entry. First
|
||||||
|
// checks for an exact key match, then uses findIoDevice for
|
||||||
|
// normalized / prefix-based matching (e.g. nda0p2 → nda0),
|
||||||
|
// and finally falls back to the FILESYSTEM env var.
|
||||||
|
if _, ioMatch = diskIoCounters[key]; !ioMatch {
|
||||||
|
if matchedKey, match := findIoDevice(key, diskIoCounters); match {
|
||||||
|
key = matchedKey
|
||||||
|
ioMatch = true
|
||||||
|
} else if filesystem != "" {
|
||||||
|
if matchedKey, match := findIoDevice(filesystem, diskIoCounters); match {
|
||||||
|
key = matchedKey
|
||||||
|
ioMatch = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !ioMatch {
|
||||||
|
slog.Warn("Root I/O unmapped; set FILESYSTEM", "device", device, "mountpoint", mountpoint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Check if non-root has diskstats and fall back to folder name if not
|
||||||
|
// Scenario: device is encrypted and named luks-2bcb02be-999d-4417-8d18-5c61e660fb6e - not in /proc/diskstats.
|
||||||
|
// However, the device can be specified by mounting folder from luks device at /extra-filesystems/sda1
|
||||||
|
if _, ioMatch = diskIoCounters[key]; !ioMatch {
|
||||||
|
efBase := filepath.Base(mountpoint)
|
||||||
|
if _, ioMatch = diskIoCounters[efBase]; ioMatch {
|
||||||
|
key = efBase
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fsStats := &system.FsStats{Root: root, Mountpoint: mountpoint}
|
||||||
|
if len(customName) > 0 && customName[0] != "" {
|
||||||
|
fsStats.Name = customName[0]
|
||||||
|
}
|
||||||
|
a.fsStats[key] = fsStats
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the appropriate root mount point for this system
|
// Get the appropriate root mount point for this system
|
||||||
discovery := diskDiscovery{
|
rootMountPoint := a.getRootMountPoint()
|
||||||
agent: a,
|
|
||||||
rootMountPoint: a.getRootMountPoint(),
|
// Use FILESYSTEM env var to find root filesystem
|
||||||
partitions: partitions,
|
if filesystem != "" {
|
||||||
usageFn: disk.Usage,
|
for _, p := range partitions {
|
||||||
ctx: ctx,
|
if filesystemMatchesPartitionSetting(filesystem, p) {
|
||||||
|
addFsStat(p.Device, p.Mountpoint, true)
|
||||||
|
hasRoot = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasRoot {
|
||||||
|
// FILESYSTEM may name a physical disk absent from partitions (e.g.
|
||||||
|
// ZFS lists dataset paths like zroot/ROOT/default, not block devices).
|
||||||
|
// Try matching directly against diskIoCounters.
|
||||||
|
if ioKey, match := findIoDevice(filesystem, diskIoCounters); match {
|
||||||
|
a.fsStats[ioKey] = &system.FsStats{Root: true, Mountpoint: rootMountPoint}
|
||||||
|
hasRoot = true
|
||||||
|
} else {
|
||||||
|
slog.Warn("Partition details not found", "filesystem", filesystem)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
hasRoot = discovery.addConfiguredRootFs()
|
|
||||||
|
|
||||||
// Add EXTRA_FILESYSTEMS env var values to fsStats
|
// Add EXTRA_FILESYSTEMS env var values to fsStats
|
||||||
if extraFilesystems, exists := utils.GetEnv("EXTRA_FILESYSTEMS"); exists {
|
if extraFilesystems, exists := GetEnv("EXTRA_FILESYSTEMS"); exists {
|
||||||
discovery.addConfiguredExtraFilesystems(extraFilesystems)
|
for fsEntry := range strings.SplitSeq(extraFilesystems, ",") {
|
||||||
|
// Parse custom name from format: device__customname
|
||||||
|
fs, customName := parseFilesystemEntry(fsEntry)
|
||||||
|
|
||||||
|
found := false
|
||||||
|
for _, p := range partitions {
|
||||||
|
if strings.HasSuffix(p.Device, fs) || p.Mountpoint == fs {
|
||||||
|
addFsStat(p.Device, p.Mountpoint, false, customName)
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// if not in partitions, test if we can get disk usage
|
||||||
|
if !found {
|
||||||
|
if _, err := disk.Usage(fs); err == nil {
|
||||||
|
addFsStat(filepath.Base(fs), fs, false, customName)
|
||||||
|
} else {
|
||||||
|
slog.Error("Invalid filesystem", "name", fs, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process partitions for various mount points
|
// Process partitions for various mount points
|
||||||
for _, p := range partitions {
|
for _, p := range partitions {
|
||||||
if !hasRoot && isRootFallbackPartition(p, discovery.rootMountPoint) {
|
// fmt.Println(p.Device, p.Mountpoint)
|
||||||
hasRoot = discovery.addPartitionRootFs(p.Device, p.Mountpoint)
|
// Binary root fallback or docker root fallback
|
||||||
|
if !hasRoot && (p.Mountpoint == rootMountPoint || (isDockerSpecialMountpoint(p.Mountpoint) && strings.HasPrefix(p.Device, "/dev"))) {
|
||||||
|
fs, match := findIoDevice(filepath.Base(p.Device), diskIoCounters)
|
||||||
|
if match {
|
||||||
|
addFsStat(fs, p.Mountpoint, true)
|
||||||
|
hasRoot = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if device is in /extra-filesystems
|
||||||
|
if strings.HasPrefix(p.Mountpoint, efPath) {
|
||||||
|
device, customName := parseFilesystemEntry(p.Mountpoint)
|
||||||
|
addFsStat(device, p.Mountpoint, false, customName)
|
||||||
}
|
}
|
||||||
discovery.addPartitionExtraFs(p)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check all folders in /extra-filesystems and add them if not already present
|
// Check all folders in /extra-filesystems and add them if not already present
|
||||||
if folders, err := os.ReadDir(discovery.ctx.efPath); err == nil {
|
if folders, err := os.ReadDir(efPath); err == nil {
|
||||||
folderNames := make([]string, 0, len(folders))
|
existingMountpoints := make(map[string]bool)
|
||||||
|
for _, stats := range a.fsStats {
|
||||||
|
existingMountpoints[stats.Mountpoint] = true
|
||||||
|
}
|
||||||
for _, folder := range folders {
|
for _, folder := range folders {
|
||||||
if folder.IsDir() {
|
if folder.IsDir() {
|
||||||
folderNames = append(folderNames, folder.Name())
|
mountpoint := filepath.Join(efPath, folder.Name())
|
||||||
|
slog.Debug("/extra-filesystems", "mountpoint", mountpoint)
|
||||||
|
if !existingMountpoints[mountpoint] {
|
||||||
|
device, customName := parseFilesystemEntry(folder.Name())
|
||||||
|
addFsStat(device, mountpoint, false, customName)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
discovery.addExtraFilesystemFolders(folderNames)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no root filesystem set, try the most active I/O device as a last
|
// If no root filesystem set, try the most active I/O device as a last
|
||||||
// resort (e.g. ZFS where dataset names are unrelated to disk names).
|
// resort (e.g. ZFS where dataset names are unrelated to disk names).
|
||||||
if !hasRoot {
|
if !hasRoot {
|
||||||
discovery.addLastResortRootFs()
|
rootKey := mostActiveIoDevice(diskIoCounters)
|
||||||
|
if rootKey != "" {
|
||||||
|
slog.Warn("Using most active device for root I/O; set FILESYSTEM to override", "device", rootKey)
|
||||||
|
} else {
|
||||||
|
rootKey = filepath.Base(rootMountPoint)
|
||||||
|
if _, exists := a.fsStats[rootKey]; exists {
|
||||||
|
rootKey = "root"
|
||||||
|
}
|
||||||
|
slog.Warn("Root I/O device not detected; set FILESYSTEM to override")
|
||||||
|
}
|
||||||
|
a.fsStats[rootKey] = &system.FsStats{Root: true, Mountpoint: rootMountPoint}
|
||||||
}
|
}
|
||||||
|
|
||||||
a.pruneDuplicateRootExtraFilesystems()
|
a.pruneDuplicateRootExtraFilesystems()
|
||||||
@@ -532,8 +380,6 @@ func normalizeDeviceName(value string) string {
|
|||||||
|
|
||||||
// Sets start values for disk I/O stats.
|
// Sets start values for disk I/O stats.
|
||||||
func (a *Agent) initializeDiskIoStats(diskIoCounters map[string]disk.IOCountersStat) {
|
func (a *Agent) initializeDiskIoStats(diskIoCounters map[string]disk.IOCountersStat) {
|
||||||
a.fsNames = a.fsNames[:0]
|
|
||||||
now := time.Now()
|
|
||||||
for device, stats := range a.fsStats {
|
for device, stats := range a.fsStats {
|
||||||
// skip if not in diskIoCounters
|
// skip if not in diskIoCounters
|
||||||
d, exists := diskIoCounters[device]
|
d, exists := diskIoCounters[device]
|
||||||
@@ -542,7 +388,7 @@ func (a *Agent) initializeDiskIoStats(diskIoCounters map[string]disk.IOCountersS
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// populate initial values
|
// populate initial values
|
||||||
stats.Time = now
|
stats.Time = time.Now()
|
||||||
stats.TotalRead = d.ReadBytes
|
stats.TotalRead = d.ReadBytes
|
||||||
stats.TotalWrite = d.WriteBytes
|
stats.TotalWrite = d.WriteBytes
|
||||||
// add to list of valid io device names
|
// add to list of valid io device names
|
||||||
@@ -566,12 +412,12 @@ func (a *Agent) updateDiskUsage(systemStats *system.Stats) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if d, err := disk.Usage(stats.Mountpoint); err == nil {
|
if d, err := disk.Usage(stats.Mountpoint); err == nil {
|
||||||
stats.DiskTotal = utils.BytesToGigabytes(d.Total)
|
stats.DiskTotal = bytesToGigabytes(d.Total)
|
||||||
stats.DiskUsed = utils.BytesToGigabytes(d.Used)
|
stats.DiskUsed = bytesToGigabytes(d.Used)
|
||||||
if stats.Root {
|
if stats.Root {
|
||||||
systemStats.DiskTotal = utils.BytesToGigabytes(d.Total)
|
systemStats.DiskTotal = bytesToGigabytes(d.Total)
|
||||||
systemStats.DiskUsed = utils.BytesToGigabytes(d.Used)
|
systemStats.DiskUsed = bytesToGigabytes(d.Used)
|
||||||
systemStats.DiskPct = utils.TwoDecimals(d.UsedPercent)
|
systemStats.DiskPct = twoDecimals(d.UsedPercent)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// reset stats if error (likely unmounted)
|
// reset stats if error (likely unmounted)
|
||||||
@@ -609,72 +455,36 @@ func (a *Agent) updateDiskIo(cacheTimeMs uint16, systemStats *system.Stats) {
|
|||||||
prev, hasPrev := a.diskPrev[cacheTimeMs][name]
|
prev, hasPrev := a.diskPrev[cacheTimeMs][name]
|
||||||
if !hasPrev {
|
if !hasPrev {
|
||||||
// Seed from agent-level fsStats if present, else seed from current
|
// Seed from agent-level fsStats if present, else seed from current
|
||||||
prev = prevDisk{
|
prev = prevDisk{readBytes: stats.TotalRead, writeBytes: stats.TotalWrite, at: stats.Time}
|
||||||
readBytes: stats.TotalRead,
|
|
||||||
writeBytes: stats.TotalWrite,
|
|
||||||
readTime: d.ReadTime,
|
|
||||||
writeTime: d.WriteTime,
|
|
||||||
ioTime: d.IoTime,
|
|
||||||
weightedIO: d.WeightedIO,
|
|
||||||
readCount: d.ReadCount,
|
|
||||||
writeCount: d.WriteCount,
|
|
||||||
at: stats.Time,
|
|
||||||
}
|
|
||||||
if prev.at.IsZero() {
|
if prev.at.IsZero() {
|
||||||
prev = prevDiskFromCounter(d, now)
|
prev = prevDisk{readBytes: d.ReadBytes, writeBytes: d.WriteBytes, at: now}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
msElapsed := uint64(now.Sub(prev.at).Milliseconds())
|
msElapsed := uint64(now.Sub(prev.at).Milliseconds())
|
||||||
|
|
||||||
// Update per-interval snapshot
|
|
||||||
a.diskPrev[cacheTimeMs][name] = prevDiskFromCounter(d, now)
|
|
||||||
|
|
||||||
// Avoid division by zero or clock issues
|
|
||||||
if msElapsed < 100 {
|
if msElapsed < 100 {
|
||||||
|
// Avoid division by zero or clock issues; update snapshot and continue
|
||||||
|
a.diskPrev[cacheTimeMs][name] = prevDisk{readBytes: d.ReadBytes, writeBytes: d.WriteBytes, at: now}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
diskIORead := (d.ReadBytes - prev.readBytes) * 1000 / msElapsed
|
diskIORead := (d.ReadBytes - prev.readBytes) * 1000 / msElapsed
|
||||||
diskIOWrite := (d.WriteBytes - prev.writeBytes) * 1000 / msElapsed
|
diskIOWrite := (d.WriteBytes - prev.writeBytes) * 1000 / msElapsed
|
||||||
readMbPerSecond := utils.BytesToMegabytes(float64(diskIORead))
|
readMbPerSecond := bytesToMegabytes(float64(diskIORead))
|
||||||
writeMbPerSecond := utils.BytesToMegabytes(float64(diskIOWrite))
|
writeMbPerSecond := bytesToMegabytes(float64(diskIOWrite))
|
||||||
|
|
||||||
// validate values
|
// validate values
|
||||||
if readMbPerSecond > 50_000 || writeMbPerSecond > 50_000 {
|
if readMbPerSecond > 50_000 || writeMbPerSecond > 50_000 {
|
||||||
slog.Warn("Invalid disk I/O. Resetting.", "name", d.Name, "read", readMbPerSecond, "write", writeMbPerSecond)
|
slog.Warn("Invalid disk I/O. Resetting.", "name", d.Name, "read", readMbPerSecond, "write", writeMbPerSecond)
|
||||||
|
// Reset interval snapshot and seed from current
|
||||||
|
a.diskPrev[cacheTimeMs][name] = prevDisk{readBytes: d.ReadBytes, writeBytes: d.WriteBytes, at: now}
|
||||||
// also refresh agent baseline to avoid future negatives
|
// also refresh agent baseline to avoid future negatives
|
||||||
a.initializeDiskIoStats(ioCounters)
|
a.initializeDiskIoStats(ioCounters)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// These properties are calculated differently on different platforms,
|
// Update per-interval snapshot
|
||||||
// but generally represent cumulative time spent doing reads/writes on the device.
|
a.diskPrev[cacheTimeMs][name] = prevDisk{readBytes: d.ReadBytes, writeBytes: d.WriteBytes, at: now}
|
||||||
// This can surpass 100% if there are multiple concurrent I/O operations.
|
|
||||||
// Linux kernel docs:
|
|
||||||
// This is the total number of milliseconds spent by all reads (as
|
|
||||||
// measured from __make_request() to end_that_request_last()).
|
|
||||||
// https://www.kernel.org/doc/Documentation/iostats.txt (fields 4, 8)
|
|
||||||
diskReadTime := utils.TwoDecimals(float64(d.ReadTime-prev.readTime) / float64(msElapsed) * 100)
|
|
||||||
diskWriteTime := utils.TwoDecimals(float64(d.WriteTime-prev.writeTime) / float64(msElapsed) * 100)
|
|
||||||
|
|
||||||
// I/O utilization %: fraction of wall time the device had any I/O in progress (0-100).
|
|
||||||
diskIoUtilPct := utils.TwoDecimals(float64(d.IoTime-prev.ioTime) / float64(msElapsed) * 100)
|
|
||||||
|
|
||||||
// Weighted I/O: queue-depth weighted I/O time, normalized to interval (can exceed 100%).
|
|
||||||
// Linux kernel field 11: incremented by iops_in_progress × ms_since_last_update.
|
|
||||||
// Used to display queue depth. Multipled by 100 to increase accuracy of digit truncation (divided by 100 in UI).
|
|
||||||
diskWeightedIO := utils.TwoDecimals(float64(d.WeightedIO-prev.weightedIO) / float64(msElapsed) * 100)
|
|
||||||
|
|
||||||
// r_await / w_await: average time per read/write operation in milliseconds.
|
|
||||||
// Equivalent to r_await and w_await in iostat.
|
|
||||||
var rAwait, wAwait float64
|
|
||||||
if deltaReadCount := d.ReadCount - prev.readCount; deltaReadCount > 0 {
|
|
||||||
rAwait = utils.TwoDecimals(float64(d.ReadTime-prev.readTime) / float64(deltaReadCount))
|
|
||||||
}
|
|
||||||
if deltaWriteCount := d.WriteCount - prev.writeCount; deltaWriteCount > 0 {
|
|
||||||
wAwait = utils.TwoDecimals(float64(d.WriteTime-prev.writeTime) / float64(deltaWriteCount))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update global fsStats baseline for cross-interval correctness
|
// Update global fsStats baseline for cross-interval correctness
|
||||||
stats.Time = now
|
stats.Time = now
|
||||||
@@ -684,40 +494,20 @@ func (a *Agent) updateDiskIo(cacheTimeMs uint16, systemStats *system.Stats) {
|
|||||||
stats.DiskWritePs = writeMbPerSecond
|
stats.DiskWritePs = writeMbPerSecond
|
||||||
stats.DiskReadBytes = diskIORead
|
stats.DiskReadBytes = diskIORead
|
||||||
stats.DiskWriteBytes = diskIOWrite
|
stats.DiskWriteBytes = diskIOWrite
|
||||||
stats.DiskIoStats[0] = diskReadTime
|
|
||||||
stats.DiskIoStats[1] = diskWriteTime
|
|
||||||
stats.DiskIoStats[2] = diskIoUtilPct
|
|
||||||
stats.DiskIoStats[3] = rAwait
|
|
||||||
stats.DiskIoStats[4] = wAwait
|
|
||||||
stats.DiskIoStats[5] = diskWeightedIO
|
|
||||||
|
|
||||||
if stats.Root {
|
if stats.Root {
|
||||||
systemStats.DiskReadPs = stats.DiskReadPs
|
systemStats.DiskReadPs = stats.DiskReadPs
|
||||||
systemStats.DiskWritePs = stats.DiskWritePs
|
systemStats.DiskWritePs = stats.DiskWritePs
|
||||||
systemStats.DiskIO[0] = diskIORead
|
systemStats.DiskIO[0] = diskIORead
|
||||||
systemStats.DiskIO[1] = diskIOWrite
|
systemStats.DiskIO[1] = diskIOWrite
|
||||||
systemStats.DiskIoStats[0] = diskReadTime
|
|
||||||
systemStats.DiskIoStats[1] = diskWriteTime
|
|
||||||
systemStats.DiskIoStats[2] = diskIoUtilPct
|
|
||||||
systemStats.DiskIoStats[3] = rAwait
|
|
||||||
systemStats.DiskIoStats[4] = wAwait
|
|
||||||
systemStats.DiskIoStats[5] = diskWeightedIO
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// getRootMountPoint returns the appropriate root mount point for the system.
|
// getRootMountPoint returns the appropriate root mount point for the system
|
||||||
// On Windows it returns the system drive (e.g. "C:").
|
|
||||||
// For immutable systems like Fedora Silverblue, it returns /sysroot instead of /
|
// For immutable systems like Fedora Silverblue, it returns /sysroot instead of /
|
||||||
func (a *Agent) getRootMountPoint() string {
|
func (a *Agent) getRootMountPoint() string {
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
if sd := os.Getenv("SystemDrive"); sd != "" {
|
|
||||||
return sd
|
|
||||||
}
|
|
||||||
return "C:"
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. Check if /etc/os-release contains indicators of an immutable system
|
// 1. Check if /etc/os-release contains indicators of an immutable system
|
||||||
if osReleaseContent, err := os.ReadFile("/etc/os-release"); err == nil {
|
if osReleaseContent, err := os.ReadFile("/etc/os-release"); err == nil {
|
||||||
content := string(osReleaseContent)
|
content := string(osReleaseContent)
|
||||||
|
|||||||
@@ -93,524 +93,6 @@ func TestParseFilesystemEntry(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExtraFilesystemPartitionInfo(t *testing.T) {
|
|
||||||
t.Run("uses partition device for label-only mountpoint", func(t *testing.T) {
|
|
||||||
device, customName := extraFilesystemPartitionInfo(disk.PartitionStat{
|
|
||||||
Device: "/dev/sdc",
|
|
||||||
Mountpoint: "/extra-filesystems/Share",
|
|
||||||
})
|
|
||||||
|
|
||||||
assert.Equal(t, "/dev/sdc", device)
|
|
||||||
assert.Equal(t, "", customName)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("uses custom name from mountpoint suffix", func(t *testing.T) {
|
|
||||||
device, customName := extraFilesystemPartitionInfo(disk.PartitionStat{
|
|
||||||
Device: "/dev/sdc",
|
|
||||||
Mountpoint: "/extra-filesystems/sdc__Share",
|
|
||||||
})
|
|
||||||
|
|
||||||
assert.Equal(t, "/dev/sdc", device)
|
|
||||||
assert.Equal(t, "Share", customName)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("falls back to folder device when partition device is unavailable", func(t *testing.T) {
|
|
||||||
device, customName := extraFilesystemPartitionInfo(disk.PartitionStat{
|
|
||||||
Mountpoint: "/extra-filesystems/sdc__Share",
|
|
||||||
})
|
|
||||||
|
|
||||||
assert.Equal(t, "sdc", device)
|
|
||||||
assert.Equal(t, "Share", customName)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("supports custom name without folder device prefix", func(t *testing.T) {
|
|
||||||
device, customName := extraFilesystemPartitionInfo(disk.PartitionStat{
|
|
||||||
Device: "/dev/sdc",
|
|
||||||
Mountpoint: "/extra-filesystems/__Share",
|
|
||||||
})
|
|
||||||
|
|
||||||
assert.Equal(t, "/dev/sdc", device)
|
|
||||||
assert.Equal(t, "Share", customName)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildFsStatRegistration(t *testing.T) {
|
|
||||||
t.Run("uses basename for non-windows exact io match", func(t *testing.T) {
|
|
||||||
key, stats, ok := registerFilesystemStats(
|
|
||||||
map[string]*system.FsStats{},
|
|
||||||
"/dev/sda1",
|
|
||||||
"/mnt/data",
|
|
||||||
false,
|
|
||||||
"archive",
|
|
||||||
fsRegistrationContext{
|
|
||||||
isWindows: false,
|
|
||||||
diskIoCounters: map[string]disk.IOCountersStat{
|
|
||||||
"sda1": {Name: "sda1"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert.True(t, ok)
|
|
||||||
assert.Equal(t, "sda1", key)
|
|
||||||
assert.Equal(t, "/mnt/data", stats.Mountpoint)
|
|
||||||
assert.Equal(t, "archive", stats.Name)
|
|
||||||
assert.False(t, stats.Root)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("maps root partition to io device by prefix", func(t *testing.T) {
|
|
||||||
key, stats, ok := registerFilesystemStats(
|
|
||||||
map[string]*system.FsStats{},
|
|
||||||
"/dev/ada0p2",
|
|
||||||
"/",
|
|
||||||
true,
|
|
||||||
"",
|
|
||||||
fsRegistrationContext{
|
|
||||||
isWindows: false,
|
|
||||||
diskIoCounters: map[string]disk.IOCountersStat{
|
|
||||||
"ada0": {Name: "ada0", ReadBytes: 1000, WriteBytes: 1000},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert.True(t, ok)
|
|
||||||
assert.Equal(t, "ada0", key)
|
|
||||||
assert.True(t, stats.Root)
|
|
||||||
assert.Equal(t, "/", stats.Mountpoint)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("uses filesystem setting as root fallback", func(t *testing.T) {
|
|
||||||
key, _, ok := registerFilesystemStats(
|
|
||||||
map[string]*system.FsStats{},
|
|
||||||
"overlay",
|
|
||||||
"/",
|
|
||||||
true,
|
|
||||||
"",
|
|
||||||
fsRegistrationContext{
|
|
||||||
filesystem: "nvme0n1p2",
|
|
||||||
isWindows: false,
|
|
||||||
diskIoCounters: map[string]disk.IOCountersStat{
|
|
||||||
"nvme0n1": {Name: "nvme0n1", ReadBytes: 1000, WriteBytes: 1000},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert.True(t, ok)
|
|
||||||
assert.Equal(t, "nvme0n1", key)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("prefers parsed extra-filesystems device over mapper device", func(t *testing.T) {
|
|
||||||
key, stats, ok := registerFilesystemStats(
|
|
||||||
map[string]*system.FsStats{},
|
|
||||||
"/dev/mapper/luks-2bcb02be-999d-4417-8d18-5c61e660fb6e",
|
|
||||||
"/extra-filesystems/nvme0n1p2__Archive",
|
|
||||||
false,
|
|
||||||
"Archive",
|
|
||||||
fsRegistrationContext{
|
|
||||||
isWindows: false,
|
|
||||||
diskIoCounters: map[string]disk.IOCountersStat{
|
|
||||||
"dm-1": {Name: "dm-1", Label: "luks-2bcb02be-999d-4417-8d18-5c61e660fb6e"},
|
|
||||||
"nvme0n1p2": {Name: "nvme0n1p2"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert.True(t, ok)
|
|
||||||
assert.Equal(t, "nvme0n1p2", key)
|
|
||||||
assert.Equal(t, "Archive", stats.Name)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("falls back to mapper io device when folder device cannot be resolved", func(t *testing.T) {
|
|
||||||
key, stats, ok := registerFilesystemStats(
|
|
||||||
map[string]*system.FsStats{},
|
|
||||||
"/dev/mapper/luks-2bcb02be-999d-4417-8d18-5c61e660fb6e",
|
|
||||||
"/extra-filesystems/Archive",
|
|
||||||
false,
|
|
||||||
"Archive",
|
|
||||||
fsRegistrationContext{
|
|
||||||
isWindows: false,
|
|
||||||
diskIoCounters: map[string]disk.IOCountersStat{
|
|
||||||
"dm-1": {Name: "dm-1", Label: "luks-2bcb02be-999d-4417-8d18-5c61e660fb6e"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert.True(t, ok)
|
|
||||||
assert.Equal(t, "dm-1", key)
|
|
||||||
assert.Equal(t, "Archive", stats.Name)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("uses full device name on windows", func(t *testing.T) {
|
|
||||||
key, _, ok := registerFilesystemStats(
|
|
||||||
map[string]*system.FsStats{},
|
|
||||||
`C:`,
|
|
||||||
`C:\\`,
|
|
||||||
false,
|
|
||||||
"",
|
|
||||||
fsRegistrationContext{
|
|
||||||
isWindows: true,
|
|
||||||
diskIoCounters: map[string]disk.IOCountersStat{
|
|
||||||
`C:`: {Name: `C:`},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert.True(t, ok)
|
|
||||||
assert.Equal(t, `C:`, key)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("skips existing key", func(t *testing.T) {
|
|
||||||
key, stats, ok := registerFilesystemStats(
|
|
||||||
map[string]*system.FsStats{"sda1": {Mountpoint: "/existing"}},
|
|
||||||
"/dev/sda1",
|
|
||||||
"/mnt/data",
|
|
||||||
false,
|
|
||||||
"",
|
|
||||||
fsRegistrationContext{
|
|
||||||
isWindows: false,
|
|
||||||
diskIoCounters: map[string]disk.IOCountersStat{
|
|
||||||
"sda1": {Name: "sda1"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert.False(t, ok)
|
|
||||||
assert.Empty(t, key)
|
|
||||||
assert.Nil(t, stats)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAddConfiguredRootFs(t *testing.T) {
|
|
||||||
t.Run("adds root from matching partition", func(t *testing.T) {
|
|
||||||
agent := &Agent{fsStats: make(map[string]*system.FsStats)}
|
|
||||||
discovery := diskDiscovery{
|
|
||||||
agent: agent,
|
|
||||||
rootMountPoint: "/",
|
|
||||||
partitions: []disk.PartitionStat{{Device: "/dev/ada0p2", Mountpoint: "/"}},
|
|
||||||
ctx: fsRegistrationContext{
|
|
||||||
filesystem: "/dev/ada0p2",
|
|
||||||
isWindows: false,
|
|
||||||
diskIoCounters: map[string]disk.IOCountersStat{
|
|
||||||
"ada0": {Name: "ada0", ReadBytes: 1000, WriteBytes: 1000},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
ok := discovery.addConfiguredRootFs()
|
|
||||||
|
|
||||||
assert.True(t, ok)
|
|
||||||
stats, exists := agent.fsStats["ada0"]
|
|
||||||
assert.True(t, exists)
|
|
||||||
assert.True(t, stats.Root)
|
|
||||||
assert.Equal(t, "/", stats.Mountpoint)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("adds root from io device when partition is missing", func(t *testing.T) {
|
|
||||||
agent := &Agent{fsStats: make(map[string]*system.FsStats)}
|
|
||||||
discovery := diskDiscovery{
|
|
||||||
agent: agent,
|
|
||||||
rootMountPoint: "/sysroot",
|
|
||||||
ctx: fsRegistrationContext{
|
|
||||||
filesystem: "zroot",
|
|
||||||
isWindows: false,
|
|
||||||
diskIoCounters: map[string]disk.IOCountersStat{
|
|
||||||
"nda0": {Name: "nda0", Label: "zroot", ReadBytes: 1000, WriteBytes: 1000},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
ok := discovery.addConfiguredRootFs()
|
|
||||||
|
|
||||||
assert.True(t, ok)
|
|
||||||
stats, exists := agent.fsStats["nda0"]
|
|
||||||
assert.True(t, exists)
|
|
||||||
assert.True(t, stats.Root)
|
|
||||||
assert.Equal(t, "/sysroot", stats.Mountpoint)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("returns false when filesystem cannot be resolved", func(t *testing.T) {
|
|
||||||
agent := &Agent{fsStats: make(map[string]*system.FsStats)}
|
|
||||||
discovery := diskDiscovery{
|
|
||||||
agent: agent,
|
|
||||||
rootMountPoint: "/",
|
|
||||||
ctx: fsRegistrationContext{
|
|
||||||
filesystem: "missing-disk",
|
|
||||||
isWindows: false,
|
|
||||||
diskIoCounters: map[string]disk.IOCountersStat{},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
ok := discovery.addConfiguredRootFs()
|
|
||||||
|
|
||||||
assert.False(t, ok)
|
|
||||||
assert.Empty(t, agent.fsStats)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAddPartitionRootFs(t *testing.T) {
|
|
||||||
t.Run("adds root from fallback partition candidate", func(t *testing.T) {
|
|
||||||
agent := &Agent{fsStats: make(map[string]*system.FsStats)}
|
|
||||||
discovery := diskDiscovery{
|
|
||||||
agent: agent,
|
|
||||||
ctx: fsRegistrationContext{
|
|
||||||
isWindows: false,
|
|
||||||
diskIoCounters: map[string]disk.IOCountersStat{
|
|
||||||
"nvme0n1": {Name: "nvme0n1", ReadBytes: 1000, WriteBytes: 1000},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
ok := discovery.addPartitionRootFs("/dev/nvme0n1p2", "/")
|
|
||||||
|
|
||||||
assert.True(t, ok)
|
|
||||||
stats, exists := agent.fsStats["nvme0n1"]
|
|
||||||
assert.True(t, exists)
|
|
||||||
assert.True(t, stats.Root)
|
|
||||||
assert.Equal(t, "/", stats.Mountpoint)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("returns false when no io device matches", func(t *testing.T) {
|
|
||||||
agent := &Agent{fsStats: make(map[string]*system.FsStats)}
|
|
||||||
discovery := diskDiscovery{agent: agent, ctx: fsRegistrationContext{diskIoCounters: map[string]disk.IOCountersStat{}}}
|
|
||||||
|
|
||||||
ok := discovery.addPartitionRootFs("/dev/mapper/root", "/")
|
|
||||||
|
|
||||||
assert.False(t, ok)
|
|
||||||
assert.Empty(t, agent.fsStats)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAddLastResortRootFs(t *testing.T) {
|
|
||||||
t.Run("uses most active io device when available", func(t *testing.T) {
|
|
||||||
agent := &Agent{fsStats: make(map[string]*system.FsStats)}
|
|
||||||
discovery := diskDiscovery{agent: agent, rootMountPoint: "/", ctx: fsRegistrationContext{diskIoCounters: map[string]disk.IOCountersStat{
|
|
||||||
"sda": {Name: "sda", ReadBytes: 5000, WriteBytes: 5000},
|
|
||||||
"sdb": {Name: "sdb", ReadBytes: 1000, WriteBytes: 1000},
|
|
||||||
}}}
|
|
||||||
|
|
||||||
discovery.addLastResortRootFs()
|
|
||||||
|
|
||||||
stats, exists := agent.fsStats["sda"]
|
|
||||||
assert.True(t, exists)
|
|
||||||
assert.True(t, stats.Root)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("falls back to root key when mountpoint basename collides", func(t *testing.T) {
|
|
||||||
agent := &Agent{fsStats: map[string]*system.FsStats{
|
|
||||||
"sysroot": {Mountpoint: "/extra-filesystems/sysroot"},
|
|
||||||
}}
|
|
||||||
discovery := diskDiscovery{agent: agent, rootMountPoint: "/sysroot", ctx: fsRegistrationContext{diskIoCounters: map[string]disk.IOCountersStat{}}}
|
|
||||||
|
|
||||||
discovery.addLastResortRootFs()
|
|
||||||
|
|
||||||
stats, exists := agent.fsStats["root"]
|
|
||||||
assert.True(t, exists)
|
|
||||||
assert.True(t, stats.Root)
|
|
||||||
assert.Equal(t, "/sysroot", stats.Mountpoint)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAddConfiguredExtraFsEntry(t *testing.T) {
|
|
||||||
t.Run("uses matching partition when present", func(t *testing.T) {
|
|
||||||
agent := &Agent{fsStats: make(map[string]*system.FsStats)}
|
|
||||||
discovery := diskDiscovery{
|
|
||||||
agent: agent,
|
|
||||||
partitions: []disk.PartitionStat{{Device: "/dev/sdb1", Mountpoint: "/mnt/backup"}},
|
|
||||||
usageFn: func(string) (*disk.UsageStat, error) {
|
|
||||||
t.Fatal("usage fallback should not be called when partition matches")
|
|
||||||
return nil, nil
|
|
||||||
},
|
|
||||||
ctx: fsRegistrationContext{
|
|
||||||
isWindows: false,
|
|
||||||
diskIoCounters: map[string]disk.IOCountersStat{
|
|
||||||
"sdb1": {Name: "sdb1"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
discovery.addConfiguredExtraFsEntry("sdb1", "backup")
|
|
||||||
|
|
||||||
stats, exists := agent.fsStats["sdb1"]
|
|
||||||
assert.True(t, exists)
|
|
||||||
assert.Equal(t, "/mnt/backup", stats.Mountpoint)
|
|
||||||
assert.Equal(t, "backup", stats.Name)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("falls back to usage-validated path", func(t *testing.T) {
|
|
||||||
agent := &Agent{fsStats: make(map[string]*system.FsStats)}
|
|
||||||
discovery := diskDiscovery{
|
|
||||||
agent: agent,
|
|
||||||
usageFn: func(path string) (*disk.UsageStat, error) {
|
|
||||||
assert.Equal(t, "/srv/archive", path)
|
|
||||||
return &disk.UsageStat{}, nil
|
|
||||||
},
|
|
||||||
ctx: fsRegistrationContext{
|
|
||||||
isWindows: false,
|
|
||||||
diskIoCounters: map[string]disk.IOCountersStat{
|
|
||||||
"archive": {Name: "archive"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
discovery.addConfiguredExtraFsEntry("/srv/archive", "archive")
|
|
||||||
|
|
||||||
stats, exists := agent.fsStats["archive"]
|
|
||||||
assert.True(t, exists)
|
|
||||||
assert.Equal(t, "/srv/archive", stats.Mountpoint)
|
|
||||||
assert.Equal(t, "archive", stats.Name)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("ignores invalid filesystem entry", func(t *testing.T) {
|
|
||||||
agent := &Agent{fsStats: make(map[string]*system.FsStats)}
|
|
||||||
discovery := diskDiscovery{
|
|
||||||
agent: agent,
|
|
||||||
usageFn: func(string) (*disk.UsageStat, error) {
|
|
||||||
return nil, os.ErrNotExist
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
discovery.addConfiguredExtraFsEntry("/missing/archive", "")
|
|
||||||
|
|
||||||
assert.Empty(t, agent.fsStats)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAddConfiguredExtraFilesystems(t *testing.T) {
|
|
||||||
t.Run("parses and registers multiple configured filesystems", func(t *testing.T) {
|
|
||||||
agent := &Agent{fsStats: make(map[string]*system.FsStats)}
|
|
||||||
discovery := diskDiscovery{
|
|
||||||
agent: agent,
|
|
||||||
partitions: []disk.PartitionStat{{Device: "/dev/sda1", Mountpoint: "/mnt/fast"}},
|
|
||||||
usageFn: func(path string) (*disk.UsageStat, error) {
|
|
||||||
if path == "/srv/archive" {
|
|
||||||
return &disk.UsageStat{}, nil
|
|
||||||
}
|
|
||||||
return nil, os.ErrNotExist
|
|
||||||
},
|
|
||||||
ctx: fsRegistrationContext{
|
|
||||||
isWindows: false,
|
|
||||||
diskIoCounters: map[string]disk.IOCountersStat{
|
|
||||||
"sda1": {Name: "sda1"},
|
|
||||||
"archive": {Name: "archive"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
discovery.addConfiguredExtraFilesystems("sda1__fast,/srv/archive__cold")
|
|
||||||
|
|
||||||
assert.Contains(t, agent.fsStats, "sda1")
|
|
||||||
assert.Equal(t, "fast", agent.fsStats["sda1"].Name)
|
|
||||||
assert.Contains(t, agent.fsStats, "archive")
|
|
||||||
assert.Equal(t, "cold", agent.fsStats["archive"].Name)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAddExtraFilesystemFolders(t *testing.T) {
|
|
||||||
t.Run("adds missing folders and skips existing mountpoints", func(t *testing.T) {
|
|
||||||
agent := &Agent{fsStats: map[string]*system.FsStats{
|
|
||||||
"existing": {Mountpoint: "/extra-filesystems/existing"},
|
|
||||||
}}
|
|
||||||
discovery := diskDiscovery{
|
|
||||||
agent: agent,
|
|
||||||
ctx: fsRegistrationContext{
|
|
||||||
isWindows: false,
|
|
||||||
efPath: "/extra-filesystems",
|
|
||||||
diskIoCounters: map[string]disk.IOCountersStat{
|
|
||||||
"newdisk": {Name: "newdisk"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
discovery.addExtraFilesystemFolders([]string{"existing", "newdisk__Archive"})
|
|
||||||
|
|
||||||
assert.Len(t, agent.fsStats, 2)
|
|
||||||
stats, exists := agent.fsStats["newdisk"]
|
|
||||||
assert.True(t, exists)
|
|
||||||
assert.Equal(t, "/extra-filesystems/newdisk__Archive", stats.Mountpoint)
|
|
||||||
assert.Equal(t, "Archive", stats.Name)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAddPartitionExtraFs(t *testing.T) {
|
|
||||||
makeDiscovery := func(agent *Agent) diskDiscovery {
|
|
||||||
return diskDiscovery{
|
|
||||||
agent: agent,
|
|
||||||
ctx: fsRegistrationContext{
|
|
||||||
isWindows: false,
|
|
||||||
efPath: "/extra-filesystems",
|
|
||||||
diskIoCounters: map[string]disk.IOCountersStat{
|
|
||||||
"nvme0n1p1": {Name: "nvme0n1p1"},
|
|
||||||
"nvme1n1": {Name: "nvme1n1"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Run("registers direct child of extra-filesystems", func(t *testing.T) {
|
|
||||||
agent := &Agent{fsStats: make(map[string]*system.FsStats)}
|
|
||||||
d := makeDiscovery(agent)
|
|
||||||
|
|
||||||
d.addPartitionExtraFs(disk.PartitionStat{
|
|
||||||
Device: "/dev/nvme0n1p1",
|
|
||||||
Mountpoint: "/extra-filesystems/nvme0n1p1__caddy1-root",
|
|
||||||
})
|
|
||||||
|
|
||||||
stats, exists := agent.fsStats["nvme0n1p1"]
|
|
||||||
assert.True(t, exists)
|
|
||||||
assert.Equal(t, "/extra-filesystems/nvme0n1p1__caddy1-root", stats.Mountpoint)
|
|
||||||
assert.Equal(t, "caddy1-root", stats.Name)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("skips nested mount under extra-filesystem bind mount", func(t *testing.T) {
|
|
||||||
agent := &Agent{fsStats: make(map[string]*system.FsStats)}
|
|
||||||
d := makeDiscovery(agent)
|
|
||||||
|
|
||||||
// These simulate the virtual mounts that appear when host / is bind-mounted
|
|
||||||
// with disk.Partitions(all=true) — e.g. /proc, /sys, /dev visible under the mount.
|
|
||||||
for _, nested := range []string{
|
|
||||||
"/extra-filesystems/nvme0n1p1__caddy1-root/proc",
|
|
||||||
"/extra-filesystems/nvme0n1p1__caddy1-root/sys",
|
|
||||||
"/extra-filesystems/nvme0n1p1__caddy1-root/dev",
|
|
||||||
"/extra-filesystems/nvme0n1p1__caddy1-root/run",
|
|
||||||
} {
|
|
||||||
d.addPartitionExtraFs(disk.PartitionStat{Device: "tmpfs", Mountpoint: nested})
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Empty(t, agent.fsStats)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("registers both direct children, skips their nested mounts", func(t *testing.T) {
|
|
||||||
agent := &Agent{fsStats: make(map[string]*system.FsStats)}
|
|
||||||
d := makeDiscovery(agent)
|
|
||||||
|
|
||||||
partitions := []disk.PartitionStat{
|
|
||||||
{Device: "/dev/nvme0n1p1", Mountpoint: "/extra-filesystems/nvme0n1p1__caddy1-root"},
|
|
||||||
{Device: "/dev/nvme1n1", Mountpoint: "/extra-filesystems/nvme1n1__caddy1-docker"},
|
|
||||||
{Device: "proc", Mountpoint: "/extra-filesystems/nvme0n1p1__caddy1-root/proc"},
|
|
||||||
{Device: "sysfs", Mountpoint: "/extra-filesystems/nvme0n1p1__caddy1-root/sys"},
|
|
||||||
{Device: "overlay", Mountpoint: "/extra-filesystems/nvme0n1p1__caddy1-root/var/lib/docker"},
|
|
||||||
}
|
|
||||||
for _, p := range partitions {
|
|
||||||
d.addPartitionExtraFs(p)
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Len(t, agent.fsStats, 2)
|
|
||||||
assert.Equal(t, "caddy1-root", agent.fsStats["nvme0n1p1"].Name)
|
|
||||||
assert.Equal(t, "caddy1-docker", agent.fsStats["nvme1n1"].Name)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("skips partition not under extra-filesystems", func(t *testing.T) {
|
|
||||||
agent := &Agent{fsStats: make(map[string]*system.FsStats)}
|
|
||||||
d := makeDiscovery(agent)
|
|
||||||
|
|
||||||
d.addPartitionExtraFs(disk.PartitionStat{
|
|
||||||
Device: "/dev/nvme0n1p1",
|
|
||||||
Mountpoint: "/",
|
|
||||||
})
|
|
||||||
|
|
||||||
assert.Empty(t, agent.fsStats)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFindIoDevice(t *testing.T) {
|
func TestFindIoDevice(t *testing.T) {
|
||||||
t.Run("matches by device name", func(t *testing.T) {
|
t.Run("matches by device name", func(t *testing.T) {
|
||||||
ioCounters := map[string]disk.IOCountersStat{
|
ioCounters := map[string]disk.IOCountersStat{
|
||||||
@@ -768,8 +250,18 @@ func TestIsDockerSpecialMountpoint(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestInitializeDiskInfoWithCustomNames(t *testing.T) {
|
func TestInitializeDiskInfoWithCustomNames(t *testing.T) {
|
||||||
|
// Set up environment variables
|
||||||
|
oldEnv := os.Getenv("EXTRA_FILESYSTEMS")
|
||||||
|
defer func() {
|
||||||
|
if oldEnv != "" {
|
||||||
|
os.Setenv("EXTRA_FILESYSTEMS", oldEnv)
|
||||||
|
} else {
|
||||||
|
os.Unsetenv("EXTRA_FILESYSTEMS")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
// Test with custom names
|
// Test with custom names
|
||||||
t.Setenv("EXTRA_FILESYSTEMS", "sda1__my-storage,/dev/sdb1__backup-drive,nvme0n1p2")
|
os.Setenv("EXTRA_FILESYSTEMS", "sda1__my-storage,/dev/sdb1__backup-drive,nvme0n1p2")
|
||||||
|
|
||||||
// Mock disk partitions (we'll just test the parsing logic)
|
// Mock disk partitions (we'll just test the parsing logic)
|
||||||
// Since the actual disk operations are system-dependent, we'll focus on the parsing
|
// Since the actual disk operations are system-dependent, we'll focus on the parsing
|
||||||
@@ -797,7 +289,7 @@ func TestInitializeDiskInfoWithCustomNames(t *testing.T) {
|
|||||||
|
|
||||||
for _, tc := range testCases {
|
for _, tc := range testCases {
|
||||||
t.Run("env_"+tc.envValue, func(t *testing.T) {
|
t.Run("env_"+tc.envValue, func(t *testing.T) {
|
||||||
t.Setenv("EXTRA_FILESYSTEMS", tc.envValue)
|
os.Setenv("EXTRA_FILESYSTEMS", tc.envValue)
|
||||||
|
|
||||||
// Create mock partitions that would match our test cases
|
// Create mock partitions that would match our test cases
|
||||||
partitions := []disk.PartitionStat{}
|
partitions := []disk.PartitionStat{}
|
||||||
@@ -818,7 +310,7 @@ func TestInitializeDiskInfoWithCustomNames(t *testing.T) {
|
|||||||
// Test the parsing logic by calling the relevant part
|
// Test the parsing logic by calling the relevant part
|
||||||
// We'll create a simplified version to test just the parsing
|
// We'll create a simplified version to test just the parsing
|
||||||
extraFilesystems := tc.envValue
|
extraFilesystems := tc.envValue
|
||||||
for fsEntry := range strings.SplitSeq(extraFilesystems, ",") {
|
for _, fsEntry := range strings.Split(extraFilesystems, ",") {
|
||||||
// Parse the entry
|
// Parse the entry
|
||||||
fsEntry = strings.TrimSpace(fsEntry)
|
fsEntry = strings.TrimSpace(fsEntry)
|
||||||
var fs, customName string
|
var fs, customName string
|
||||||
@@ -1014,33 +506,3 @@ func TestHasSameDiskUsage(t *testing.T) {
|
|||||||
assert.False(t, hasSameDiskUsage(&disk.UsageStat{Total: 0, Used: 0}, &disk.UsageStat{Total: 1, Used: 1}))
|
assert.False(t, hasSameDiskUsage(&disk.UsageStat{Total: 0, Used: 0}, &disk.UsageStat{Total: 1, Used: 1}))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestInitializeDiskIoStatsResetsTrackedDevices(t *testing.T) {
|
|
||||||
agent := &Agent{
|
|
||||||
fsStats: map[string]*system.FsStats{
|
|
||||||
"sda": {},
|
|
||||||
"sdb": {},
|
|
||||||
},
|
|
||||||
fsNames: []string{"stale", "sda"},
|
|
||||||
}
|
|
||||||
|
|
||||||
agent.initializeDiskIoStats(map[string]disk.IOCountersStat{
|
|
||||||
"sda": {Name: "sda", ReadBytes: 10, WriteBytes: 20},
|
|
||||||
"sdb": {Name: "sdb", ReadBytes: 30, WriteBytes: 40},
|
|
||||||
})
|
|
||||||
|
|
||||||
assert.ElementsMatch(t, []string{"sda", "sdb"}, agent.fsNames)
|
|
||||||
assert.Len(t, agent.fsNames, 2)
|
|
||||||
assert.Equal(t, uint64(10), agent.fsStats["sda"].TotalRead)
|
|
||||||
assert.Equal(t, uint64(20), agent.fsStats["sda"].TotalWrite)
|
|
||||||
assert.False(t, agent.fsStats["sda"].Time.IsZero())
|
|
||||||
assert.False(t, agent.fsStats["sdb"].Time.IsZero())
|
|
||||||
|
|
||||||
agent.initializeDiskIoStats(map[string]disk.IOCountersStat{
|
|
||||||
"sdb": {Name: "sdb", ReadBytes: 50, WriteBytes: 60},
|
|
||||||
})
|
|
||||||
|
|
||||||
assert.Equal(t, []string{"sdb"}, agent.fsNames)
|
|
||||||
assert.Equal(t, uint64(50), agent.fsStats["sdb"].TotalRead)
|
|
||||||
assert.Equal(t, uint64(60), agent.fsStats["sdb"].TotalWrite)
|
|
||||||
}
|
|
||||||
|
|||||||
336
agent/docker.go
336
agent/docker.go
@@ -16,24 +16,22 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"regexp"
|
"regexp"
|
||||||
"sort"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/agent/deltatracker"
|
"github.com/henrygd/beszel/agent/deltatracker"
|
||||||
"github.com/henrygd/beszel/agent/utils"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/container"
|
"github.com/henrygd/beszel/internal/entities/container"
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
|
||||||
|
|
||||||
"github.com/blang/semver"
|
"github.com/blang/semver"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ansiEscapePattern matches ANSI escape sequences (colors, cursor movement, etc.)
|
// ansiEscapePattern matches ANSI escape sequences (colors, cursor movement, etc.)
|
||||||
// This includes CSI sequences like \x1b[...m and simple escapes like \x1b[K
|
// This includes CSI sequences like \x1b[...m and simple escapes like \x1b[K
|
||||||
var ansiEscapePattern = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b[@-Z\\-_]`)
|
var (
|
||||||
var dockerContainerIDPattern = regexp.MustCompile(`^[a-fA-F0-9]{12,64}$`)
|
ansiEscapePattern = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b[@-Z\\-_]`)
|
||||||
|
dockerContainerIDPattern = regexp.MustCompile(`^[a-fA-F0-9]{12,64}$`)
|
||||||
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// Docker API timeout in milliseconds
|
// Docker API timeout in milliseconds
|
||||||
@@ -53,21 +51,20 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type dockerManager struct {
|
type dockerManager struct {
|
||||||
agent *Agent // Used to propagate system detail changes back to the agent
|
client *http.Client // Client to query Docker API
|
||||||
client *http.Client // Client to query Docker API
|
wg sync.WaitGroup // WaitGroup to wait for all goroutines to finish
|
||||||
wg sync.WaitGroup // WaitGroup to wait for all goroutines to finish
|
sem chan struct{} // Semaphore to limit concurrent container requests
|
||||||
sem chan struct{} // Semaphore to limit concurrent container requests
|
containerStatsMutex sync.RWMutex // Mutex to prevent concurrent access to containerStatsMap
|
||||||
containerStatsMutex sync.RWMutex // Mutex to prevent concurrent access to containerStatsMap
|
apiContainerList []*container.ApiInfo // List of containers from Docker API
|
||||||
apiContainerList []*container.ApiInfo // List of containers from Docker API
|
containerStatsMap map[string]*container.Stats // Keeps track of container stats
|
||||||
containerStatsMap map[string]*container.Stats // Keeps track of container stats
|
validIds map[string]struct{} // Map of valid container ids, used to prune invalid containers from containerStatsMap
|
||||||
validIds map[string]struct{} // Map of valid container ids, used to prune invalid containers from containerStatsMap
|
goodDockerVersion bool // Whether docker version is at least 25.0.0 (one-shot works correctly)
|
||||||
goodDockerVersion bool // Whether docker version is at least 25.0.0 (one-shot works correctly)
|
isWindows bool // Whether the Docker Engine API is running on Windows
|
||||||
dockerVersionChecked bool // Whether a version probe has completed successfully
|
buf *bytes.Buffer // Buffer to store and read response bodies
|
||||||
isWindows bool // Whether the Docker Engine API is running on Windows
|
decoder *json.Decoder // Reusable JSON decoder that reads from buf
|
||||||
buf *bytes.Buffer // Buffer to store and read response bodies
|
apiStats *container.ApiStats // Reusable API stats object
|
||||||
apiStats *container.ApiStats // Reusable API stats object
|
excludeContainers []string // Patterns to exclude containers by name
|
||||||
excludeContainers []string // Patterns to exclude containers by name
|
usingPodman bool // Whether the Docker Engine API is running on Podman
|
||||||
usingPodman bool // Whether the Docker Engine API is running on Podman
|
|
||||||
|
|
||||||
// Cache-time-aware tracking for CPU stats (similar to cpu.go)
|
// Cache-time-aware tracking for CPU stats (similar to cpu.go)
|
||||||
// Maps cache time intervals to container-specific CPU usage tracking
|
// Maps cache time intervals to container-specific CPU usage tracking
|
||||||
@@ -79,7 +76,7 @@ type dockerManager struct {
|
|||||||
// cacheTimeMs -> DeltaTracker for network bytes sent/received
|
// cacheTimeMs -> DeltaTracker for network bytes sent/received
|
||||||
networkSentTrackers map[uint16]*deltatracker.DeltaTracker[string, uint64]
|
networkSentTrackers map[uint16]*deltatracker.DeltaTracker[string, uint64]
|
||||||
networkRecvTrackers map[uint16]*deltatracker.DeltaTracker[string, uint64]
|
networkRecvTrackers map[uint16]*deltatracker.DeltaTracker[string, uint64]
|
||||||
lastNetworkReadTime map[uint16]map[string]time.Time // cacheTimeMs -> containerId -> last network read time
|
retrySleep func(time.Duration)
|
||||||
}
|
}
|
||||||
|
|
||||||
// userAgentRoundTripper is a custom http.RoundTripper that adds a User-Agent header to all requests
|
// userAgentRoundTripper is a custom http.RoundTripper that adds a User-Agent header to all requests
|
||||||
@@ -88,14 +85,6 @@ type userAgentRoundTripper struct {
|
|||||||
userAgent string
|
userAgent string
|
||||||
}
|
}
|
||||||
|
|
||||||
// dockerVersionResponse contains the /version fields used for engine checks.
|
|
||||||
type dockerVersionResponse struct {
|
|
||||||
Version string `json:"Version"`
|
|
||||||
Components []struct {
|
|
||||||
Name string `json:"Name"`
|
|
||||||
} `json:"Components"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// RoundTrip implements the http.RoundTripper interface
|
// RoundTrip implements the http.RoundTripper interface
|
||||||
func (u *userAgentRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
func (u *userAgentRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
req.Header.Set("User-Agent", u.userAgent)
|
req.Header.Set("User-Agent", u.userAgent)
|
||||||
@@ -143,14 +132,7 @@ func (dm *dockerManager) getDockerStats(cacheTimeMs uint16) ([]*container.Stats,
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect Podman and Windows from Server header
|
dm.isWindows = strings.Contains(resp.Header.Get("Server"), "windows")
|
||||||
serverHeader := resp.Header.Get("Server")
|
|
||||||
if !dm.usingPodman && detectPodmanFromHeader(serverHeader) {
|
|
||||||
dm.setIsPodman()
|
|
||||||
}
|
|
||||||
dm.isWindows = strings.Contains(serverHeader, "windows")
|
|
||||||
|
|
||||||
dm.ensureDockerVersionChecked()
|
|
||||||
|
|
||||||
containersLength := len(dm.apiContainerList)
|
containersLength := len(dm.apiContainerList)
|
||||||
|
|
||||||
@@ -302,7 +284,7 @@ func (dm *dockerManager) cycleNetworkDeltasForCacheTime(cacheTimeMs uint16) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// calculateNetworkStats calculates network sent/receive deltas using DeltaTracker
|
// calculateNetworkStats calculates network sent/receive deltas using DeltaTracker
|
||||||
func (dm *dockerManager) calculateNetworkStats(ctr *container.ApiInfo, apiStats *container.ApiStats, name string, cacheTimeMs uint16) (uint64, uint64) {
|
func (dm *dockerManager) calculateNetworkStats(ctr *container.ApiInfo, apiStats *container.ApiStats, stats *container.Stats, initialized bool, name string, cacheTimeMs uint16) (uint64, uint64) {
|
||||||
var total_sent, total_recv uint64
|
var total_sent, total_recv uint64
|
||||||
for _, v := range apiStats.Networks {
|
for _, v := range apiStats.Networks {
|
||||||
total_sent += v.TxBytes
|
total_sent += v.TxBytes
|
||||||
@@ -321,11 +303,10 @@ func (dm *dockerManager) calculateNetworkStats(ctr *container.ApiInfo, apiStats
|
|||||||
sent_delta_raw := sentTracker.Delta(ctr.IdShort)
|
sent_delta_raw := sentTracker.Delta(ctr.IdShort)
|
||||||
recv_delta_raw := recvTracker.Delta(ctr.IdShort)
|
recv_delta_raw := recvTracker.Delta(ctr.IdShort)
|
||||||
|
|
||||||
// Calculate bytes per second using per-cache-time read time to avoid
|
// Calculate bytes per second independently for Tx and Rx if we have previous data
|
||||||
// interference between different cache intervals (e.g. 1000ms vs 60000ms)
|
|
||||||
var sent_delta, recv_delta uint64
|
var sent_delta, recv_delta uint64
|
||||||
if prevReadTime, ok := dm.lastNetworkReadTime[cacheTimeMs][ctr.IdShort]; ok {
|
if initialized {
|
||||||
millisecondsElapsed := uint64(time.Since(prevReadTime).Milliseconds())
|
millisecondsElapsed := uint64(time.Since(stats.PrevReadTime).Milliseconds())
|
||||||
if millisecondsElapsed > 0 {
|
if millisecondsElapsed > 0 {
|
||||||
if sent_delta_raw > 0 {
|
if sent_delta_raw > 0 {
|
||||||
sent_delta = sent_delta_raw * 1000 / millisecondsElapsed
|
sent_delta = sent_delta_raw * 1000 / millisecondsElapsed
|
||||||
@@ -357,58 +338,15 @@ func validateCpuPercentage(cpuPct float64, containerName string) error {
|
|||||||
|
|
||||||
// updateContainerStatsValues updates the final stats values
|
// updateContainerStatsValues updates the final stats values
|
||||||
func updateContainerStatsValues(stats *container.Stats, cpuPct float64, usedMemory uint64, sent_delta, recv_delta uint64, readTime time.Time) {
|
func updateContainerStatsValues(stats *container.Stats, cpuPct float64, usedMemory uint64, sent_delta, recv_delta uint64, readTime time.Time) {
|
||||||
stats.Cpu = utils.TwoDecimals(cpuPct)
|
stats.Cpu = twoDecimals(cpuPct)
|
||||||
stats.Mem = utils.BytesToMegabytes(float64(usedMemory))
|
stats.Mem = bytesToMegabytes(float64(usedMemory))
|
||||||
stats.Bandwidth = [2]uint64{sent_delta, recv_delta}
|
stats.Bandwidth = [2]uint64{sent_delta, recv_delta}
|
||||||
// TODO(0.19+): stop populating NetworkSent/NetworkRecv (deprecated in 0.18.3)
|
// TODO(0.19+): stop populating NetworkSent/NetworkRecv (deprecated in 0.18.3)
|
||||||
stats.NetworkSent = utils.BytesToMegabytes(float64(sent_delta))
|
stats.NetworkSent = bytesToMegabytes(float64(sent_delta))
|
||||||
stats.NetworkRecv = utils.BytesToMegabytes(float64(recv_delta))
|
stats.NetworkRecv = bytesToMegabytes(float64(recv_delta))
|
||||||
stats.PrevReadTime = readTime
|
stats.PrevReadTime = readTime
|
||||||
}
|
}
|
||||||
|
|
||||||
// convertContainerPortsToString formats the ports of a container into a sorted, deduplicated string.
|
|
||||||
// ctr.Ports is nilled out after processing so the slice is not accidentally reused.
|
|
||||||
func convertContainerPortsToString(ctr *container.ApiInfo) string {
|
|
||||||
if len(ctr.Ports) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
sort.Slice(ctr.Ports, func(i, j int) bool {
|
|
||||||
if ctr.Ports[i].PublicPort != ctr.Ports[j].PublicPort {
|
|
||||||
return ctr.Ports[i].PublicPort < ctr.Ports[j].PublicPort
|
|
||||||
}
|
|
||||||
return ctr.Ports[i].IP < ctr.Ports[j].IP
|
|
||||||
})
|
|
||||||
var builder strings.Builder
|
|
||||||
seen := make(map[string]struct{})
|
|
||||||
for _, p := range ctr.Ports {
|
|
||||||
if p.PublicPort == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
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(", ")
|
|
||||||
}
|
|
||||||
switch p.IP {
|
|
||||||
case "0.0.0.0", "::":
|
|
||||||
default:
|
|
||||||
builder.WriteString(p.IP)
|
|
||||||
builder.WriteByte(':')
|
|
||||||
}
|
|
||||||
builder.WriteString(strconv.Itoa(int(p.PublicPort)))
|
|
||||||
}
|
|
||||||
// clear ports slice so it doesn't get reused and blend into next response
|
|
||||||
ctr.Ports = nil
|
|
||||||
return builder.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseDockerStatus(status string) (string, container.DockerHealth) {
|
func parseDockerStatus(status string) (string, container.DockerHealth) {
|
||||||
trimmed := strings.TrimSpace(status)
|
trimmed := strings.TrimSpace(status)
|
||||||
if trimmed == "" {
|
if trimmed == "" {
|
||||||
@@ -428,60 +366,22 @@ func parseDockerStatus(status string) (string, container.DockerHealth) {
|
|||||||
statusText = trimmed
|
statusText = trimmed
|
||||||
}
|
}
|
||||||
|
|
||||||
healthText := strings.TrimSpace(strings.TrimSuffix(trimmed[openIdx+1:], ")"))
|
healthText := strings.ToLower(strings.TrimSpace(strings.TrimSuffix(trimmed[openIdx+1:], ")")))
|
||||||
// Some Docker statuses include a "health:" prefix inside the parentheses.
|
// Some Docker statuses include a "health:" prefix inside the parentheses.
|
||||||
// Strip it so it maps correctly to the known health states.
|
// Strip it so it maps correctly to the known health states.
|
||||||
if colonIdx := strings.IndexRune(healthText, ':'); colonIdx != -1 {
|
if colonIdx := strings.IndexRune(healthText, ':'); colonIdx != -1 {
|
||||||
prefix := strings.ToLower(strings.TrimSpace(healthText[:colonIdx]))
|
prefix := strings.TrimSpace(healthText[:colonIdx])
|
||||||
if prefix == "health" || prefix == "health status" {
|
if prefix == "health" || prefix == "health status" {
|
||||||
healthText = strings.TrimSpace(healthText[colonIdx+1:])
|
healthText = strings.TrimSpace(healthText[colonIdx+1:])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if health, ok := parseDockerHealthStatus(healthText); ok {
|
if health, ok := container.DockerHealthStrings[healthText]; ok {
|
||||||
return statusText, health
|
return statusText, health
|
||||||
}
|
}
|
||||||
|
|
||||||
return trimmed, container.DockerHealthNone
|
return trimmed, container.DockerHealthNone
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseDockerHealthStatus maps Docker health status strings to container.DockerHealth values
|
|
||||||
func parseDockerHealthStatus(status string) (container.DockerHealth, bool) {
|
|
||||||
health, ok := container.DockerHealthStrings[strings.ToLower(strings.TrimSpace(status))]
|
|
||||||
return health, ok
|
|
||||||
}
|
|
||||||
|
|
||||||
// getPodmanContainerHealth fetches container health status from the container inspect endpoint.
|
|
||||||
// Used for Podman which doesn't provide health status in the /containers/json endpoint as of March 2026.
|
|
||||||
// https://github.com/containers/podman/issues/27786
|
|
||||||
func (dm *dockerManager) getPodmanContainerHealth(containerID string) (container.DockerHealth, error) {
|
|
||||||
resp, err := dm.client.Get(fmt.Sprintf("http://localhost/containers/%s/json", url.PathEscape(containerID)))
|
|
||||||
if err != nil {
|
|
||||||
return container.DockerHealthNone, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return container.DockerHealthNone, fmt.Errorf("container inspect request failed: %s", resp.Status)
|
|
||||||
}
|
|
||||||
|
|
||||||
var inspectInfo struct {
|
|
||||||
State struct {
|
|
||||||
Health struct {
|
|
||||||
Status string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&inspectInfo); err != nil {
|
|
||||||
return container.DockerHealthNone, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if health, ok := parseDockerHealthStatus(inspectInfo.State.Health.Status); ok {
|
|
||||||
return health, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return container.DockerHealthNone, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Updates stats for individual container with cache-time-aware delta tracking
|
// Updates stats for individual container with cache-time-aware delta tracking
|
||||||
func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo, cacheTimeMs uint16) error {
|
func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo, cacheTimeMs uint16) error {
|
||||||
name := ctr.Names[0][1:]
|
name := ctr.Names[0][1:]
|
||||||
@@ -491,39 +391,23 @@ func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo, cacheTimeM
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
statusText, health := parseDockerStatus(ctr.Status)
|
|
||||||
|
|
||||||
// Docker exposes Health.Status on /containers/json in API 1.52+.
|
|
||||||
// Podman currently requires falling back to the inspect endpoint as of March 2026.
|
|
||||||
// https://github.com/containers/podman/issues/27786
|
|
||||||
if ctr.Health.Status != "" {
|
|
||||||
if h, ok := parseDockerHealthStatus(ctr.Health.Status); ok {
|
|
||||||
health = h
|
|
||||||
}
|
|
||||||
} else if dm.usingPodman {
|
|
||||||
if podmanHealth, err := dm.getPodmanContainerHealth(ctr.IdShort); err == nil {
|
|
||||||
health = podmanHealth
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dm.containerStatsMutex.Lock()
|
dm.containerStatsMutex.Lock()
|
||||||
defer dm.containerStatsMutex.Unlock()
|
defer dm.containerStatsMutex.Unlock()
|
||||||
|
|
||||||
// add empty values if they doesn't exist in map
|
// add empty values if they doesn't exist in map
|
||||||
stats, initialized := dm.containerStatsMap[ctr.IdShort]
|
stats, initialized := dm.containerStatsMap[ctr.IdShort]
|
||||||
if !initialized {
|
if !initialized {
|
||||||
stats = &container.Stats{Name: name, Id: ctr.IdShort, Image: ctr.Image}
|
stats = &container.Stats{Image: ctr.Image}
|
||||||
dm.containerStatsMap[ctr.IdShort] = stats
|
dm.containerStatsMap[ctr.IdShort] = stats
|
||||||
}
|
}
|
||||||
|
|
||||||
stats.Id = ctr.IdShort
|
stats.Id = ctr.IdShort
|
||||||
|
stats.Name = name
|
||||||
|
|
||||||
|
statusText, health := parseDockerStatus(ctr.Status)
|
||||||
stats.Status = statusText
|
stats.Status = statusText
|
||||||
stats.Health = health
|
stats.Health = health
|
||||||
|
|
||||||
if len(ctr.Ports) > 0 {
|
|
||||||
stats.Ports = convertContainerPortsToString(ctr)
|
|
||||||
}
|
|
||||||
|
|
||||||
// reset current stats
|
// reset current stats
|
||||||
stats.Cpu = 0
|
stats.Cpu = 0
|
||||||
stats.Mem = 0
|
stats.Mem = 0
|
||||||
@@ -544,18 +428,11 @@ func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo, cacheTimeM
|
|||||||
// Get previous CPU values
|
// Get previous CPU values
|
||||||
prevCpuContainer, prevCpuSystem := dm.getCpuPreviousValues(cacheTimeMs, ctr.IdShort)
|
prevCpuContainer, prevCpuSystem := dm.getCpuPreviousValues(cacheTimeMs, ctr.IdShort)
|
||||||
|
|
||||||
// Calculate CPU percentage based on platform.
|
// Calculate CPU percentage based on platform
|
||||||
// Podman reports system_cpu_usage from cgroup cpu.stat (not /proc/stat), so it reflects
|
|
||||||
// only cgroup-tracked activity rather than total host capacity. Use a time-based method
|
|
||||||
// instead so the result is comparable to host CPU utilization. See:
|
|
||||||
// https://github.com/henrygd/beszel/issues/2049
|
|
||||||
var cpuPct float64
|
var cpuPct float64
|
||||||
if dm.isWindows {
|
if dm.isWindows {
|
||||||
prevRead := dm.lastCpuReadTime[cacheTimeMs][ctr.IdShort]
|
prevRead := dm.lastCpuReadTime[cacheTimeMs][ctr.IdShort]
|
||||||
cpuPct = res.CalculateCpuPercentWindows(prevCpuContainer, prevRead)
|
cpuPct = res.CalculateCpuPercentWindows(prevCpuContainer, prevRead)
|
||||||
} else if dm.usingPodman && res.CPUStats.OnlineCPUs > 0 {
|
|
||||||
prevRead := dm.lastCpuReadTime[cacheTimeMs][ctr.IdShort]
|
|
||||||
cpuPct = res.CalculateCpuPercentPodman(prevCpuContainer, prevRead)
|
|
||||||
} else {
|
} else {
|
||||||
cpuPct = res.CalculateCpuPercentLinux(prevCpuContainer, prevCpuSystem)
|
cpuPct = res.CalculateCpuPercentLinux(prevCpuContainer, prevCpuSystem)
|
||||||
}
|
}
|
||||||
@@ -577,13 +454,7 @@ func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo, cacheTimeM
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Calculate network stats using DeltaTracker
|
// Calculate network stats using DeltaTracker
|
||||||
sent_delta, recv_delta := dm.calculateNetworkStats(ctr, res, name, cacheTimeMs)
|
sent_delta, recv_delta := dm.calculateNetworkStats(ctr, res, stats, initialized, name, cacheTimeMs)
|
||||||
|
|
||||||
// Store per-cache-time network read time for next rate calculation
|
|
||||||
if dm.lastNetworkReadTime[cacheTimeMs] == nil {
|
|
||||||
dm.lastNetworkReadTime[cacheTimeMs] = make(map[string]time.Time)
|
|
||||||
}
|
|
||||||
dm.lastNetworkReadTime[cacheTimeMs][ctr.IdShort] = time.Now()
|
|
||||||
|
|
||||||
// Store current network values for legacy compatibility
|
// Store current network values for legacy compatibility
|
||||||
var total_sent, total_recv uint64
|
var total_sent, total_recv uint64
|
||||||
@@ -615,14 +486,11 @@ func (dm *dockerManager) deleteContainerStatsSync(id string) {
|
|||||||
for ct := range dm.lastCpuReadTime {
|
for ct := range dm.lastCpuReadTime {
|
||||||
delete(dm.lastCpuReadTime[ct], id)
|
delete(dm.lastCpuReadTime[ct], id)
|
||||||
}
|
}
|
||||||
for ct := range dm.lastNetworkReadTime {
|
|
||||||
delete(dm.lastNetworkReadTime[ct], id)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Creates a new http client for Docker or Podman API
|
// Creates a new http client for Docker or Podman API
|
||||||
func newDockerManager(agent *Agent) *dockerManager {
|
func newDockerManager() *dockerManager {
|
||||||
dockerHost, exists := utils.GetEnv("DOCKER_HOST")
|
dockerHost, exists := GetEnv("DOCKER_HOST")
|
||||||
if exists {
|
if exists {
|
||||||
// return nil if set to empty string
|
// return nil if set to empty string
|
||||||
if dockerHost == "" {
|
if dockerHost == "" {
|
||||||
@@ -658,7 +526,7 @@ func newDockerManager(agent *Agent) *dockerManager {
|
|||||||
|
|
||||||
// configurable timeout
|
// configurable timeout
|
||||||
timeout := time.Millisecond * time.Duration(dockerTimeoutMs)
|
timeout := time.Millisecond * time.Duration(dockerTimeoutMs)
|
||||||
if t, set := utils.GetEnv("DOCKER_TIMEOUT"); set {
|
if t, set := GetEnv("DOCKER_TIMEOUT"); set {
|
||||||
timeout, err = time.ParseDuration(t)
|
timeout, err = time.ParseDuration(t)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error(err.Error())
|
slog.Error(err.Error())
|
||||||
@@ -675,7 +543,7 @@ func newDockerManager(agent *Agent) *dockerManager {
|
|||||||
|
|
||||||
// Read container exclusion patterns from environment variable
|
// Read container exclusion patterns from environment variable
|
||||||
var excludeContainers []string
|
var excludeContainers []string
|
||||||
if excludeStr, set := utils.GetEnv("EXCLUDE_CONTAINERS"); set && excludeStr != "" {
|
if excludeStr, set := GetEnv("EXCLUDE_CONTAINERS"); set && excludeStr != "" {
|
||||||
parts := strings.SplitSeq(excludeStr, ",")
|
parts := strings.SplitSeq(excludeStr, ",")
|
||||||
for part := range parts {
|
for part := range parts {
|
||||||
trimmed := strings.TrimSpace(part)
|
trimmed := strings.TrimSpace(part)
|
||||||
@@ -687,7 +555,6 @@ func newDockerManager(agent *Agent) *dockerManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
manager := &dockerManager{
|
manager := &dockerManager{
|
||||||
agent: agent,
|
|
||||||
client: &http.Client{
|
client: &http.Client{
|
||||||
Timeout: timeout,
|
Timeout: timeout,
|
||||||
Transport: userAgentTransport,
|
Transport: userAgentTransport,
|
||||||
@@ -704,55 +571,51 @@ func newDockerManager(agent *Agent) *dockerManager {
|
|||||||
lastCpuReadTime: make(map[uint16]map[string]time.Time),
|
lastCpuReadTime: make(map[uint16]map[string]time.Time),
|
||||||
networkSentTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
|
networkSentTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
|
||||||
networkRecvTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
|
networkRecvTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
|
||||||
lastNetworkReadTime: make(map[uint16]map[string]time.Time),
|
retrySleep: time.Sleep,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Best-effort startup probe. If the engine is not ready yet, getDockerStats will
|
// If using podman, return client
|
||||||
// retry after the first successful /containers/json request.
|
if strings.Contains(dockerHost, "podman") {
|
||||||
_, _ = manager.checkDockerVersion()
|
manager.usingPodman = true
|
||||||
|
manager.goodDockerVersion = true
|
||||||
|
return manager
|
||||||
|
}
|
||||||
|
|
||||||
|
// run version check in goroutine to avoid blocking (server may not be ready and requires retries)
|
||||||
|
go manager.checkDockerVersion()
|
||||||
|
|
||||||
|
// give version check a chance to complete before returning
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
|
||||||
return manager
|
return manager
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkDockerVersion checks Docker version and sets goodDockerVersion if at least 25.0.0.
|
// checkDockerVersion checks Docker version and sets goodDockerVersion if at least 25.0.0.
|
||||||
// Versions before 25.0.0 have a bug with one-shot which requires all requests to be made in one batch.
|
// Versions before 25.0.0 have a bug with one-shot which requires all requests to be made in one batch.
|
||||||
func (dm *dockerManager) checkDockerVersion() (bool, error) {
|
func (dm *dockerManager) checkDockerVersion() {
|
||||||
resp, err := dm.client.Get("http://localhost/version")
|
var err error
|
||||||
if err != nil {
|
var resp *http.Response
|
||||||
return false, err
|
var versionInfo struct {
|
||||||
|
Version string `json:"Version"`
|
||||||
}
|
}
|
||||||
if resp.StatusCode != http.StatusOK {
|
const versionMaxTries = 2
|
||||||
status := resp.Status
|
for i := 1; i <= versionMaxTries; i++ {
|
||||||
resp.Body.Close()
|
resp, err = dm.client.Get("http://localhost/version")
|
||||||
return false, fmt.Errorf("docker version request failed: %s", status)
|
if err == nil && resp.StatusCode == http.StatusOK {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if resp != nil {
|
||||||
|
resp.Body.Close()
|
||||||
|
}
|
||||||
|
if i < versionMaxTries {
|
||||||
|
slog.Debug("Failed to get Docker version; retrying", "attempt", i, "err", err, "response", resp)
|
||||||
|
dm.retrySleep(5 * time.Second)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
if err != nil || resp.StatusCode != http.StatusOK {
|
||||||
var versionInfo dockerVersionResponse
|
|
||||||
serverHeader := resp.Header.Get("Server")
|
|
||||||
if err := dm.decode(resp, &versionInfo); err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
dm.applyDockerVersionInfo(serverHeader, &versionInfo)
|
|
||||||
dm.dockerVersionChecked = true
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ensureDockerVersionChecked retries the version probe after a successful
|
|
||||||
// container list request.
|
|
||||||
func (dm *dockerManager) ensureDockerVersionChecked() {
|
|
||||||
if dm.dockerVersionChecked {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, err := dm.checkDockerVersion(); err != nil {
|
if err := dm.decode(resp, &versionInfo); err != nil {
|
||||||
slog.Debug("Failed to get Docker version", "err", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// applyDockerVersionInfo updates version-dependent behavior from engine metadata.
|
|
||||||
func (dm *dockerManager) applyDockerVersionInfo(serverHeader string, versionInfo *dockerVersionResponse) {
|
|
||||||
if detectPodmanEngine(serverHeader, versionInfo) {
|
|
||||||
dm.setIsPodman()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// if version > 24, one-shot works correctly and we can limit concurrent operations
|
// if version > 24, one-shot works correctly and we can limit concurrent operations
|
||||||
@@ -763,18 +626,20 @@ func (dm *dockerManager) applyDockerVersionInfo(serverHeader string, versionInfo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decodes a Docker API JSON response using a reusable buffer. Not thread safe.
|
// Decodes Docker API JSON response using a reusable buffer and decoder. Not thread safe.
|
||||||
func (dm *dockerManager) decode(resp *http.Response, d any) error {
|
func (dm *dockerManager) decode(resp *http.Response, d any) error {
|
||||||
if dm.buf == nil {
|
if dm.buf == nil {
|
||||||
// initialize buffer with 256kb starting size
|
// initialize buffer with 256kb starting size
|
||||||
dm.buf = bytes.NewBuffer(make([]byte, 0, 1024*256))
|
dm.buf = bytes.NewBuffer(make([]byte, 0, 1024*256))
|
||||||
|
dm.decoder = json.NewDecoder(dm.buf)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
defer dm.buf.Reset()
|
defer dm.buf.Reset()
|
||||||
if _, err := dm.buf.ReadFrom(resp.Body); err != nil {
|
_, err := dm.buf.ReadFrom(resp.Body)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return json.Unmarshal(dm.buf.Bytes(), d)
|
return dm.decoder.Decode(d)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test docker / podman sockets and return if one exists
|
// Test docker / podman sockets and return if one exists
|
||||||
@@ -976,46 +841,3 @@ func (dm *dockerManager) GetHostInfo() (info container.HostInfo, err error) {
|
|||||||
func (dm *dockerManager) IsPodman() bool {
|
func (dm *dockerManager) IsPodman() bool {
|
||||||
return dm.usingPodman
|
return dm.usingPodman
|
||||||
}
|
}
|
||||||
|
|
||||||
// setIsPodman sets the manager to Podman mode and updates system details accordingly.
|
|
||||||
func (dm *dockerManager) setIsPodman() {
|
|
||||||
if dm.usingPodman {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
dm.usingPodman = true
|
|
||||||
dm.goodDockerVersion = true
|
|
||||||
dm.dockerVersionChecked = true
|
|
||||||
// keep system details updated - this may be detected late if server isn't ready when
|
|
||||||
// agent starts, so make sure we notify the hub if this happens later.
|
|
||||||
if dm.agent != nil {
|
|
||||||
dm.agent.updateSystemDetails(func(details *system.Details) {
|
|
||||||
details.Podman = true
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// detectPodmanFromHeader identifies Podman from the Docker API server header.
|
|
||||||
func detectPodmanFromHeader(server string) bool {
|
|
||||||
return strings.HasPrefix(server, "Libpod")
|
|
||||||
}
|
|
||||||
|
|
||||||
// detectPodmanFromVersion identifies Podman from the version payload.
|
|
||||||
func detectPodmanFromVersion(versionInfo *dockerVersionResponse) bool {
|
|
||||||
if versionInfo == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, component := range versionInfo.Components {
|
|
||||||
if strings.HasPrefix(component.Name, "Podman") {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// detectPodmanEngine checks both header and version metadata for Podman.
|
|
||||||
func detectPodmanEngine(serverHeader string, versionInfo *dockerVersionResponse) bool {
|
|
||||||
if detectPodmanFromHeader(serverHeader) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return detectPodmanFromVersion(versionInfo)
|
|
||||||
}
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,6 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/agent/utils"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/smart"
|
"github.com/henrygd/beszel/internal/entities/smart"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -142,9 +141,9 @@ func readEmmcHealth(blockName string) (emmcHealth, bool) {
|
|||||||
out.lifeA = lifeA
|
out.lifeA = lifeA
|
||||||
out.lifeB = lifeB
|
out.lifeB = lifeB
|
||||||
|
|
||||||
out.model = utils.ReadStringFile(filepath.Join(deviceDir, "name"))
|
out.model = readStringFile(filepath.Join(deviceDir, "name"))
|
||||||
out.serial = utils.ReadStringFile(filepath.Join(deviceDir, "serial"))
|
out.serial = readStringFile(filepath.Join(deviceDir, "serial"))
|
||||||
out.revision = utils.ReadStringFile(filepath.Join(deviceDir, "prv"))
|
out.revision = readStringFile(filepath.Join(deviceDir, "prv"))
|
||||||
|
|
||||||
if capBytes, ok := readBlockCapacityBytes(blockName); ok {
|
if capBytes, ok := readBlockCapacityBytes(blockName); ok {
|
||||||
out.capacity = capBytes
|
out.capacity = capBytes
|
||||||
@@ -154,7 +153,7 @@ func readEmmcHealth(blockName string) (emmcHealth, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func readLifeTime(deviceDir string) (uint8, uint8, bool) {
|
func readLifeTime(deviceDir string) (uint8, uint8, bool) {
|
||||||
if content, ok := utils.ReadStringFileOK(filepath.Join(deviceDir, "life_time")); ok {
|
if content, ok := readStringFileOK(filepath.Join(deviceDir, "life_time")); ok {
|
||||||
a, b, ok := parseHexBytePair(content)
|
a, b, ok := parseHexBytePair(content)
|
||||||
return a, b, ok
|
return a, b, ok
|
||||||
}
|
}
|
||||||
@@ -171,7 +170,7 @@ func readBlockCapacityBytes(blockName string) (uint64, bool) {
|
|||||||
sizePath := filepath.Join(emmcSysfsRoot, "class", "block", blockName, "size")
|
sizePath := filepath.Join(emmcSysfsRoot, "class", "block", blockName, "size")
|
||||||
lbsPath := filepath.Join(emmcSysfsRoot, "class", "block", blockName, "queue", "logical_block_size")
|
lbsPath := filepath.Join(emmcSysfsRoot, "class", "block", blockName, "queue", "logical_block_size")
|
||||||
|
|
||||||
sizeStr, ok := utils.ReadStringFileOK(sizePath)
|
sizeStr, ok := readStringFileOK(sizePath)
|
||||||
if !ok {
|
if !ok {
|
||||||
return 0, false
|
return 0, false
|
||||||
}
|
}
|
||||||
@@ -180,7 +179,7 @@ func readBlockCapacityBytes(blockName string) (uint64, bool) {
|
|||||||
return 0, false
|
return 0, false
|
||||||
}
|
}
|
||||||
|
|
||||||
lbsStr, ok := utils.ReadStringFileOK(lbsPath)
|
lbsStr, ok := readStringFileOK(lbsPath)
|
||||||
logicalBlockSize := uint64(512)
|
logicalBlockSize := uint64(512)
|
||||||
if ok {
|
if ok {
|
||||||
if parsed, err := strconv.ParseUint(lbsStr, 10, 64); err == nil && parsed > 0 {
|
if parsed, err := strconv.ParseUint(lbsStr, 10, 64); err == nil && parsed > 0 {
|
||||||
@@ -192,7 +191,7 @@ func readBlockCapacityBytes(blockName string) (uint64, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func readHexByteFile(path string) (uint8, bool) {
|
func readHexByteFile(path string) (uint8, bool) {
|
||||||
content, ok := utils.ReadStringFileOK(path)
|
content, ok := readStringFileOK(path)
|
||||||
if !ok {
|
if !ok {
|
||||||
return 0, false
|
return 0, false
|
||||||
}
|
}
|
||||||
|
|||||||
117
agent/fans.go
117
agent/fans.go
@@ -1,117 +0,0 @@
|
|||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"log/slog"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/agent/utils"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
|
||||||
)
|
|
||||||
|
|
||||||
type fanSensor struct {
|
|
||||||
key, path string
|
|
||||||
}
|
|
||||||
|
|
||||||
var getFanSensors = newFanSensorCache(hwmonRoot)
|
|
||||||
|
|
||||||
func newFanSensorCache(root string) func() ([]fanSensor, error) {
|
|
||||||
return sync.OnceValues(func() ([]fanSensor, error) {
|
|
||||||
return discoverHwmonFans(root)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateFans populates systemStats.Fans from the host's hwmon sysfs tree.
|
|
||||||
// No-op on platforms where hwmon isn't available (see fans_other.go).
|
|
||||||
func (a *Agent) updateFans(systemStats *system.Stats) {
|
|
||||||
if hwmonRoot == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
sensors, err := getFanSensors()
|
|
||||||
if err != nil {
|
|
||||||
slog.Debug("Error reading fans", "err", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fans := readFanSensors(sensors)
|
|
||||||
if len(fans) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
systemStats.Fans = fans
|
|
||||||
// Note: Commented out because we don't currently use this value in the UI.
|
|
||||||
// Compute the single "dashboard" value used by the FanSpeed alert.
|
|
||||||
// Per-sensor RPMs live in Stats.Fans and drive the multi-line FanChart
|
|
||||||
// in the UI; the alert path only needs one number to compare against
|
|
||||||
// the user's threshold, so we use the highest RPM across all fans
|
|
||||||
// a.systemInfo.DashboardFan = 0
|
|
||||||
// for _, rpm := range fans {
|
|
||||||
// if rpm > a.systemInfo.DashboardFan {
|
|
||||||
// a.systemInfo.DashboardFan = rpm
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
|
|
||||||
// readHwmonFans walks the given hwmon root (typically /sys/class/hwmon) and
|
|
||||||
// returns a map of "<chip>_<label-or-fan-idx>" → RPM for every fan*_input
|
|
||||||
// file it finds. Zero RPM is retained because it can represent a real fan that
|
|
||||||
// has stopped; negative and malformed readings are ignored.
|
|
||||||
func readHwmonFans(root string) (map[string]uint16, error) {
|
|
||||||
sensors, err := discoverHwmonFans(root)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return readFanSensors(sensors), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func discoverHwmonFans(root string) ([]fanSensor, error) {
|
|
||||||
entries, err := os.ReadDir(root)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var sensors []fanSensor
|
|
||||||
for _, entry := range entries {
|
|
||||||
chipDir := filepath.Join(root, entry.Name())
|
|
||||||
sensorDir := chipDir
|
|
||||||
inputs, _ := filepath.Glob(filepath.Join(sensorDir, "fan*_input"))
|
|
||||||
|
|
||||||
// Some legacy hwmon drivers (notably applesmc) register a hwmon class
|
|
||||||
// device but create fan attributes on the parent platform device. In
|
|
||||||
// sysfs that parent is exposed through hwmonN/device.
|
|
||||||
if len(inputs) == 0 {
|
|
||||||
deviceDir := filepath.Join(chipDir, "device")
|
|
||||||
if deviceInputs, _ := filepath.Glob(filepath.Join(deviceDir, "fan*_input")); len(deviceInputs) > 0 {
|
|
||||||
sensorDir = deviceDir
|
|
||||||
inputs = deviceInputs
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
chipName := utils.ReadStringFile(filepath.Join(sensorDir, "name"))
|
|
||||||
if chipName == "" {
|
|
||||||
chipName = utils.ReadStringFile(filepath.Join(chipDir, "name"))
|
|
||||||
}
|
|
||||||
if chipName == "" {
|
|
||||||
chipName = entry.Name()
|
|
||||||
}
|
|
||||||
for _, inputPath := range inputs {
|
|
||||||
base := strings.TrimSuffix(filepath.Base(inputPath), "_input")
|
|
||||||
label := utils.ReadStringFile(filepath.Join(sensorDir, 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
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
//go:build linux
|
|
||||||
|
|
||||||
package agent
|
|
||||||
|
|
||||||
// hwmonRoot is the sysfs entry point for hardware monitor chips. Each
|
|
||||||
// subdirectory (hwmon0, hwmon1, …) is one chip; fan*_input files inside it
|
|
||||||
// expose RPM readings.
|
|
||||||
const hwmonRoot = "/sys/class/hwmon"
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
//go:build !linux
|
|
||||||
|
|
||||||
package agent
|
|
||||||
|
|
||||||
// hwmonRoot is empty on non-Linux platforms — fan RPM reporting via sysfs
|
|
||||||
// hwmon is Linux-specific. updateFans() short-circuits when this is empty.
|
|
||||||
const hwmonRoot = ""
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
//go:build testing
|
|
||||||
|
|
||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
// writeFile creates path with parents and writes contents.
|
|
||||||
func writeFile(t *testing.T, path, contents string) {
|
|
||||||
t.Helper()
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(path, []byte(contents), 0o644))
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestReadHwmonFans verifies the /sys/class/hwmon walker:
|
|
||||||
// - picks up fan*_input from every chip,
|
|
||||||
// - keys entries by chip name + sensor label (or fan idx if no label),
|
|
||||||
// - retains 0 RPM for stopped fans,
|
|
||||||
// - tolerates chips with no fan files at all.
|
|
||||||
func TestReadHwmonFans(t *testing.T) {
|
|
||||||
root := t.TempDir()
|
|
||||||
|
|
||||||
// hwmon0: Raspberry Pi 5 active cooler — one fan, no label.
|
|
||||||
writeFile(t, filepath.Join(root, "hwmon0", "name"), "pwmfan\n")
|
|
||||||
writeFile(t, filepath.Join(root, "hwmon0", "fan1_input"), "6500\n")
|
|
||||||
|
|
||||||
// hwmon1: a thermal-only chip, no fan files. Must not error.
|
|
||||||
writeFile(t, filepath.Join(root, "hwmon1", "name"), "cpu_thermal\n")
|
|
||||||
writeFile(t, filepath.Join(root, "hwmon1", "temp1_input"), "55000\n")
|
|
||||||
|
|
||||||
// hwmon2: two fans — one stopped (0 RPM) and one labeled "chassis".
|
|
||||||
writeFile(t, filepath.Join(root, "hwmon2", "name"), "nct6798\n")
|
|
||||||
writeFile(t, filepath.Join(root, "hwmon2", "fan1_input"), "0\n")
|
|
||||||
writeFile(t, filepath.Join(root, "hwmon2", "fan2_input"), "1200\n")
|
|
||||||
writeFile(t, filepath.Join(root, "hwmon2", "fan2_label"), "chassis\n")
|
|
||||||
|
|
||||||
fans, err := readHwmonFans(root)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
assert.Equal(t, map[string]uint16{
|
|
||||||
"pwmfan_fan1": 6500,
|
|
||||||
"nct6798_fan1": 0,
|
|
||||||
"nct6798_chassis": 1200,
|
|
||||||
}, fans)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestReadHwmonFansLegacyParent verifies legacy hwmon layouts such as applesmc,
|
|
||||||
// where the hwmon class node exists but fan attributes live on hwmonN/device.
|
|
||||||
func TestReadHwmonFansLegacyParent(t *testing.T) {
|
|
||||||
root := t.TempDir()
|
|
||||||
deviceDir := filepath.Join(root, "devices", "applesmc.768")
|
|
||||||
writeFile(t, filepath.Join(deviceDir, "name"), "applesmc\n")
|
|
||||||
writeFile(t, filepath.Join(deviceDir, "fan1_input"), "1202\n")
|
|
||||||
writeFile(t, filepath.Join(deviceDir, "fan1_label"), "Exhaust\n")
|
|
||||||
|
|
||||||
chipDir := filepath.Join(root, "hwmon1")
|
|
||||||
require.NoError(t, os.MkdirAll(chipDir, 0o755))
|
|
||||||
require.NoError(t, os.Symlink(deviceDir, filepath.Join(chipDir, "device")))
|
|
||||||
|
|
||||||
fans, err := readHwmonFans(root)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, map[string]uint16{"applesmc_Exhaust": 1202}, 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)
|
|
||||||
}
|
|
||||||
@@ -50,9 +50,6 @@ func generateFingerprint(hostname, cpuModel string) string {
|
|||||||
if info, err := cpu.Info(); err == nil && len(info) > 0 {
|
if info, err := cpu.Info(); err == nil && len(info) > 0 {
|
||||||
cpuModel = info[0].ModelName
|
cpuModel = info[0].ModelName
|
||||||
}
|
}
|
||||||
if cpuModel == "" {
|
|
||||||
cpuModel = getCpuModelFromCpuinfo()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
fingerprint = hostname + cpuModel
|
fingerprint = hostname + cpuModel
|
||||||
}
|
}
|
||||||
|
|||||||
41
agent/fs_utils.go
Normal file
41
agent/fs_utils.go
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// readStringFile returns trimmed file contents or empty string on error.
|
||||||
|
func readStringFile(path string) string {
|
||||||
|
content, _ := readStringFileOK(path)
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
// readStringFileOK returns trimmed file contents and read success.
|
||||||
|
func readStringFileOK(path string) (string, bool) {
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(b)), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// fileExists reports whether the given path exists.
|
||||||
|
func fileExists(path string) bool {
|
||||||
|
_, err := os.Stat(path)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readUintFile parses a decimal uint64 value from a file.
|
||||||
|
func readUintFile(path string) (uint64, bool) {
|
||||||
|
raw, ok := readStringFileOK(path)
|
||||||
|
if !ok {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
parsed, err := strconv.ParseUint(raw, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return parsed, true
|
||||||
|
}
|
||||||
65
agent/gpu.go
65
agent/gpu.go
@@ -15,7 +15,6 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/agent/utils"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -48,8 +47,6 @@ type GPUManager struct {
|
|||||||
// Per-cache-key tracking for delta calculations
|
// Per-cache-key tracking for delta calculations
|
||||||
// cacheKey -> gpuId -> snapshot of last count/usage/power values
|
// cacheKey -> gpuId -> snapshot of last count/usage/power values
|
||||||
lastSnapshots map[uint16]map[string]*gpuSnapshot
|
lastSnapshots map[uint16]map[string]*gpuSnapshot
|
||||||
// Per-card energy snapshots for Intel sysfs power calculation.
|
|
||||||
intelSysfsEnergySnapshots map[string]intelSysfsEnergySnapshot
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// gpuSnapshot stores the last observed incremental values for delta tracking
|
// gpuSnapshot stores the last observed incremental values for delta tracking
|
||||||
@@ -92,7 +89,6 @@ const (
|
|||||||
collectorSourceNVML collectorSource = "nvml"
|
collectorSourceNVML collectorSource = "nvml"
|
||||||
collectorSourceNvidiaSMI collectorSource = collectorSource(nvidiaSmiCmd)
|
collectorSourceNvidiaSMI collectorSource = collectorSource(nvidiaSmiCmd)
|
||||||
collectorSourceIntelGpuTop collectorSource = collectorSource(intelGpuStatsCmd)
|
collectorSourceIntelGpuTop collectorSource = collectorSource(intelGpuStatsCmd)
|
||||||
collectorSourceIntelSysfs collectorSource = "intel_sysfs"
|
|
||||||
collectorSourceAmdSysfs collectorSource = "amd_sysfs"
|
collectorSourceAmdSysfs collectorSource = "amd_sysfs"
|
||||||
collectorSourceRocmSMI collectorSource = collectorSource(rocmSmiCmd)
|
collectorSourceRocmSMI collectorSource = collectorSource(rocmSmiCmd)
|
||||||
collectorSourceMacmon collectorSource = collectorSource(macmonCmd)
|
collectorSourceMacmon collectorSource = collectorSource(macmonCmd)
|
||||||
@@ -109,7 +105,6 @@ func isValidCollectorSource(source collectorSource) bool {
|
|||||||
collectorSourceNVML,
|
collectorSourceNVML,
|
||||||
collectorSourceNvidiaSMI,
|
collectorSourceNvidiaSMI,
|
||||||
collectorSourceIntelGpuTop,
|
collectorSourceIntelGpuTop,
|
||||||
collectorSourceIntelSysfs,
|
|
||||||
collectorSourceAmdSysfs,
|
collectorSourceAmdSysfs,
|
||||||
collectorSourceRocmSMI,
|
collectorSourceRocmSMI,
|
||||||
collectorSourceMacmon,
|
collectorSourceMacmon,
|
||||||
@@ -126,8 +121,6 @@ type gpuCapabilities struct {
|
|||||||
hasAmdSysfs bool
|
hasAmdSysfs bool
|
||||||
hasTegrastats bool
|
hasTegrastats bool
|
||||||
hasIntelGpuTop bool
|
hasIntelGpuTop bool
|
||||||
hasXe bool
|
|
||||||
hasIntelSysfs bool
|
|
||||||
hasNvtop bool
|
hasNvtop bool
|
||||||
hasMacmon bool
|
hasMacmon bool
|
||||||
hasPowermetrics bool
|
hasPowermetrics bool
|
||||||
@@ -298,8 +291,8 @@ func (gm *GPUManager) parseAmdData(output []byte) bool {
|
|||||||
}
|
}
|
||||||
gpu := gm.GpuDataMap[id]
|
gpu := gm.GpuDataMap[id]
|
||||||
gpu.Temperature, _ = strconv.ParseFloat(v.Temperature, 64)
|
gpu.Temperature, _ = strconv.ParseFloat(v.Temperature, 64)
|
||||||
gpu.MemoryUsed = utils.BytesToMegabytes(memoryUsage)
|
gpu.MemoryUsed = bytesToMegabytes(memoryUsage)
|
||||||
gpu.MemoryTotal = utils.BytesToMegabytes(totalMemory)
|
gpu.MemoryTotal = bytesToMegabytes(totalMemory)
|
||||||
gpu.Usage += usage
|
gpu.Usage += usage
|
||||||
gpu.Power += power
|
gpu.Power += power
|
||||||
gpu.Count++
|
gpu.Count++
|
||||||
@@ -373,17 +366,16 @@ func (gm *GPUManager) calculateGPUAverage(id string, gpu *system.GPUData, cacheK
|
|||||||
gpuAvg := *gpu
|
gpuAvg := *gpu
|
||||||
deltaUsage, deltaPower, deltaPowerPkg := gm.calculateDeltas(gpu, lastSnapshot)
|
deltaUsage, deltaPower, deltaPowerPkg := gm.calculateDeltas(gpu, lastSnapshot)
|
||||||
|
|
||||||
gpuAvg.Power = utils.TwoDecimals(deltaPower / float64(deltaCount))
|
gpuAvg.Power = twoDecimals(deltaPower / float64(deltaCount))
|
||||||
|
|
||||||
gpuAvg.PowerPkg = utils.TwoDecimals(deltaPowerPkg / float64(deltaCount))
|
|
||||||
|
|
||||||
if gpu.Engines != nil {
|
if gpu.Engines != nil {
|
||||||
// make fresh map for averaged engine metrics to avoid mutating
|
// make fresh map for averaged engine metrics to avoid mutating
|
||||||
// the accumulator map stored in gm.GpuDataMap
|
// the accumulator map stored in gm.GpuDataMap
|
||||||
gpuAvg.Engines = make(map[string]float64, len(gpu.Engines))
|
gpuAvg.Engines = make(map[string]float64, len(gpu.Engines))
|
||||||
gpuAvg.Usage = gm.calculateIntelGPUUsage(&gpuAvg, gpu, lastSnapshot, deltaCount)
|
gpuAvg.Usage = gm.calculateIntelGPUUsage(&gpuAvg, gpu, lastSnapshot, deltaCount)
|
||||||
|
gpuAvg.PowerPkg = twoDecimals(deltaPowerPkg / float64(deltaCount))
|
||||||
} else {
|
} else {
|
||||||
gpuAvg.Usage = utils.TwoDecimals(deltaUsage / float64(deltaCount))
|
gpuAvg.Usage = twoDecimals(deltaUsage / float64(deltaCount))
|
||||||
}
|
}
|
||||||
|
|
||||||
gm.lastAvgData[id] = gpuAvg
|
gm.lastAvgData[id] = gpuAvg
|
||||||
@@ -418,17 +410,17 @@ func (gm *GPUManager) calculateIntelGPUUsage(gpuAvg, gpu *system.GPUData, lastSn
|
|||||||
} else {
|
} else {
|
||||||
deltaEngine = engine
|
deltaEngine = engine
|
||||||
}
|
}
|
||||||
gpuAvg.Engines[name] = utils.TwoDecimals(deltaEngine / float64(deltaCount))
|
gpuAvg.Engines[name] = twoDecimals(deltaEngine / float64(deltaCount))
|
||||||
maxEngineUsage = max(maxEngineUsage, deltaEngine/float64(deltaCount))
|
maxEngineUsage = max(maxEngineUsage, deltaEngine/float64(deltaCount))
|
||||||
}
|
}
|
||||||
return utils.TwoDecimals(maxEngineUsage)
|
return twoDecimals(maxEngineUsage)
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateInstantaneousValues updates values that should reflect current state, not averages
|
// updateInstantaneousValues updates values that should reflect current state, not averages
|
||||||
func (gm *GPUManager) updateInstantaneousValues(gpuAvg *system.GPUData, gpu *system.GPUData) {
|
func (gm *GPUManager) updateInstantaneousValues(gpuAvg *system.GPUData, gpu *system.GPUData) {
|
||||||
gpuAvg.Temperature = utils.TwoDecimals(gpu.Temperature)
|
gpuAvg.Temperature = twoDecimals(gpu.Temperature)
|
||||||
gpuAvg.MemoryUsed = utils.TwoDecimals(gpu.MemoryUsed)
|
gpuAvg.MemoryUsed = twoDecimals(gpu.MemoryUsed)
|
||||||
gpuAvg.MemoryTotal = utils.TwoDecimals(gpu.MemoryTotal)
|
gpuAvg.MemoryTotal = twoDecimals(gpu.MemoryTotal)
|
||||||
}
|
}
|
||||||
|
|
||||||
// storeSnapshot saves the current GPU state for this cache key
|
// storeSnapshot saves the current GPU state for this cache key
|
||||||
@@ -451,8 +443,6 @@ func (gm *GPUManager) storeSnapshot(id string, gpu *system.GPUData, cacheKey uin
|
|||||||
func (gm *GPUManager) discoverGpuCapabilities() gpuCapabilities {
|
func (gm *GPUManager) discoverGpuCapabilities() gpuCapabilities {
|
||||||
caps := gpuCapabilities{
|
caps := gpuCapabilities{
|
||||||
hasAmdSysfs: gm.hasAmdSysfs(),
|
hasAmdSysfs: gm.hasAmdSysfs(),
|
||||||
hasXe: gm.hasXe(),
|
|
||||||
hasIntelSysfs: gm.hasIntelSysfs(),
|
|
||||||
}
|
}
|
||||||
if _, err := exec.LookPath(nvidiaSmiCmd); err == nil {
|
if _, err := exec.LookPath(nvidiaSmiCmd); err == nil {
|
||||||
caps.hasNvidiaSmi = true
|
caps.hasNvidiaSmi = true
|
||||||
@@ -470,7 +460,7 @@ func (gm *GPUManager) discoverGpuCapabilities() gpuCapabilities {
|
|||||||
caps.hasNvtop = true
|
caps.hasNvtop = true
|
||||||
}
|
}
|
||||||
if runtime.GOOS == "darwin" {
|
if runtime.GOOS == "darwin" {
|
||||||
if _, err := utils.LookPathHomebrew(macmonCmd); err == nil {
|
if _, err := exec.LookPath(macmonCmd); err == nil {
|
||||||
caps.hasMacmon = true
|
caps.hasMacmon = true
|
||||||
}
|
}
|
||||||
if _, err := exec.LookPath(powermetricsCmd); err == nil {
|
if _, err := exec.LookPath(powermetricsCmd); err == nil {
|
||||||
@@ -481,7 +471,7 @@ func (gm *GPUManager) discoverGpuCapabilities() gpuCapabilities {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func hasAnyGpuCollector(caps gpuCapabilities) bool {
|
func hasAnyGpuCollector(caps gpuCapabilities) bool {
|
||||||
return caps.hasNvidiaSmi || caps.hasRocmSmi || caps.hasAmdSysfs || caps.hasTegrastats || caps.hasIntelGpuTop || caps.hasIntelSysfs || caps.hasNvtop || caps.hasMacmon || caps.hasPowermetrics
|
return caps.hasNvidiaSmi || caps.hasRocmSmi || caps.hasAmdSysfs || caps.hasTegrastats || caps.hasIntelGpuTop || caps.hasNvtop || caps.hasMacmon || caps.hasPowermetrics
|
||||||
}
|
}
|
||||||
|
|
||||||
func (gm *GPUManager) startIntelCollector() {
|
func (gm *GPUManager) startIntelCollector() {
|
||||||
@@ -551,7 +541,7 @@ func (gm *GPUManager) collectorDefinitions(caps gpuCapabilities) map[collectorSo
|
|||||||
return map[collectorSource]collectorDefinition{
|
return map[collectorSource]collectorDefinition{
|
||||||
collectorSourceNVML: {
|
collectorSourceNVML: {
|
||||||
group: collectorGroupNvidia,
|
group: collectorGroupNvidia,
|
||||||
available: true,
|
available: caps.hasNvidiaSmi,
|
||||||
start: func(_ func()) bool {
|
start: func(_ func()) bool {
|
||||||
return gm.startNvmlCollector()
|
return gm.startNvmlCollector()
|
||||||
},
|
},
|
||||||
@@ -572,13 +562,6 @@ func (gm *GPUManager) collectorDefinitions(caps gpuCapabilities) map[collectorSo
|
|||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
collectorSourceIntelSysfs: {
|
|
||||||
group: collectorGroupIntel,
|
|
||||||
available: caps.hasIntelSysfs,
|
|
||||||
start: func(_ func()) bool {
|
|
||||||
return gm.startIntelSysfsCollector()
|
|
||||||
},
|
|
||||||
},
|
|
||||||
collectorSourceAmdSysfs: {
|
collectorSourceAmdSysfs: {
|
||||||
group: collectorGroupAmd,
|
group: collectorGroupAmd,
|
||||||
available: caps.hasAmdSysfs,
|
available: caps.hasAmdSysfs,
|
||||||
@@ -704,7 +687,7 @@ func (gm *GPUManager) resolveLegacyCollectorPriority(caps gpuCapabilities) []col
|
|||||||
priorities := make([]collectorSource, 0, 4)
|
priorities := make([]collectorSource, 0, 4)
|
||||||
|
|
||||||
if caps.hasNvidiaSmi && !caps.hasTegrastats {
|
if caps.hasNvidiaSmi && !caps.hasTegrastats {
|
||||||
if nvml, _ := utils.GetEnv("NVML"); nvml == "true" {
|
if nvml, _ := GetEnv("NVML"); nvml == "true" {
|
||||||
priorities = append(priorities, collectorSourceNVML, collectorSourceNvidiaSMI)
|
priorities = append(priorities, collectorSourceNVML, collectorSourceNvidiaSMI)
|
||||||
} else {
|
} else {
|
||||||
priorities = append(priorities, collectorSourceNvidiaSMI)
|
priorities = append(priorities, collectorSourceNvidiaSMI)
|
||||||
@@ -712,7 +695,7 @@ func (gm *GPUManager) resolveLegacyCollectorPriority(caps gpuCapabilities) []col
|
|||||||
}
|
}
|
||||||
|
|
||||||
if caps.hasRocmSmi {
|
if caps.hasRocmSmi {
|
||||||
if val, _ := utils.GetEnv("AMD_SYSFS"); val == "true" {
|
if val, _ := GetEnv("AMD_SYSFS"); val == "true" {
|
||||||
priorities = append(priorities, collectorSourceAmdSysfs)
|
priorities = append(priorities, collectorSourceAmdSysfs)
|
||||||
} else {
|
} else {
|
||||||
priorities = append(priorities, collectorSourceRocmSMI)
|
priorities = append(priorities, collectorSourceRocmSMI)
|
||||||
@@ -721,12 +704,9 @@ func (gm *GPUManager) resolveLegacyCollectorPriority(caps gpuCapabilities) []col
|
|||||||
priorities = append(priorities, collectorSourceAmdSysfs)
|
priorities = append(priorities, collectorSourceAmdSysfs)
|
||||||
}
|
}
|
||||||
|
|
||||||
if caps.hasIntelGpuTop && !caps.hasXe {
|
if caps.hasIntelGpuTop {
|
||||||
priorities = append(priorities, collectorSourceIntelGpuTop)
|
priorities = append(priorities, collectorSourceIntelGpuTop)
|
||||||
}
|
}
|
||||||
if caps.hasIntelSysfs {
|
|
||||||
priorities = append(priorities, collectorSourceIntelSysfs)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apple collectors are currently opt-in only for testing.
|
// Apple collectors are currently opt-in only for testing.
|
||||||
// Enable them with GPU_COLLECTOR=macmon or GPU_COLLECTOR=powermetrics.
|
// Enable them with GPU_COLLECTOR=macmon or GPU_COLLECTOR=powermetrics.
|
||||||
@@ -748,11 +728,14 @@ func (gm *GPUManager) resolveLegacyCollectorPriority(caps gpuCapabilities) []col
|
|||||||
|
|
||||||
// NewGPUManager creates and initializes a new GPUManager
|
// NewGPUManager creates and initializes a new GPUManager
|
||||||
func NewGPUManager() (*GPUManager, error) {
|
func NewGPUManager() (*GPUManager, error) {
|
||||||
if skipGPU, _ := utils.GetEnv("SKIP_GPU"); skipGPU == "true" {
|
if skipGPU, _ := GetEnv("SKIP_GPU"); skipGPU == "true" {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
var gm GPUManager
|
var gm GPUManager
|
||||||
caps := gm.discoverGpuCapabilities()
|
caps := gm.discoverGpuCapabilities()
|
||||||
|
if !hasAnyGpuCollector(caps) {
|
||||||
|
return nil, fmt.Errorf(noGPUFoundMsg)
|
||||||
|
}
|
||||||
gm.GpuDataMap = make(map[string]*system.GPUData)
|
gm.GpuDataMap = make(map[string]*system.GPUData)
|
||||||
|
|
||||||
// Jetson devices should always use tegrastats (ignore GPU_COLLECTOR).
|
// Jetson devices should always use tegrastats (ignore GPU_COLLECTOR).
|
||||||
@@ -761,8 +744,8 @@ func NewGPUManager() (*GPUManager, error) {
|
|||||||
return &gm, nil
|
return &gm, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Respect explicit collector selection before capability auto-detection.
|
// if GPU_COLLECTOR is set, start user-defined collectors.
|
||||||
if collectorConfig, ok := utils.GetEnv("GPU_COLLECTOR"); ok && strings.TrimSpace(collectorConfig) != "" {
|
if collectorConfig, ok := GetEnv("GPU_COLLECTOR"); ok && strings.TrimSpace(collectorConfig) != "" {
|
||||||
priorities := parseCollectorPriority(collectorConfig)
|
priorities := parseCollectorPriority(collectorConfig)
|
||||||
if gm.startCollectorsByPriority(priorities, caps) == 0 {
|
if gm.startCollectorsByPriority(priorities, caps) == 0 {
|
||||||
return nil, fmt.Errorf("no configured GPU collectors are available")
|
return nil, fmt.Errorf("no configured GPU collectors are available")
|
||||||
@@ -770,10 +753,6 @@ func NewGPUManager() (*GPUManager, error) {
|
|||||||
return &gm, nil
|
return &gm, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if !hasAnyGpuCollector(caps) {
|
|
||||||
return nil, fmt.Errorf(noGPUFoundMsg)
|
|
||||||
}
|
|
||||||
|
|
||||||
// auto-detect and start collectors when GPU_COLLECTOR is unset.
|
// auto-detect and start collectors when GPU_COLLECTOR is unset.
|
||||||
if gm.startCollectorsByPriority(gm.resolveLegacyCollectorPriority(caps), caps) == 0 {
|
if gm.startCollectorsByPriority(gm.resolveLegacyCollectorPriority(caps), caps) == 0 {
|
||||||
return nil, fmt.Errorf(noGPUFoundMsg)
|
return nil, fmt.Errorf(noGPUFoundMsg)
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/agent/utils"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -33,8 +32,8 @@ func (gm *GPUManager) hasAmdSysfs() bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
for _, vendorPath := range cards {
|
for _, vendorPath := range cards {
|
||||||
vendor, err := utils.ReadStringFileLimited(vendorPath, 64)
|
vendor, err := os.ReadFile(vendorPath)
|
||||||
if err == nil && vendor == "0x1002" {
|
if err == nil && strings.TrimSpace(string(vendor)) == "0x1002" {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -88,11 +87,12 @@ func (gm *GPUManager) collectAmdStats() error {
|
|||||||
|
|
||||||
// isAmdGpu checks whether a DRM card path belongs to AMD vendor ID 0x1002.
|
// isAmdGpu checks whether a DRM card path belongs to AMD vendor ID 0x1002.
|
||||||
func isAmdGpu(cardPath string) bool {
|
func isAmdGpu(cardPath string) bool {
|
||||||
vendor, err := utils.ReadStringFileLimited(filepath.Join(cardPath, "device/vendor"), 64)
|
vendorPath := filepath.Join(cardPath, "device/vendor")
|
||||||
|
vendor, err := os.ReadFile(vendorPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return vendor == "0x1002"
|
return strings.TrimSpace(string(vendor)) == "0x1002"
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateAmdGpuData reads GPU metrics from sysfs and updates the GPU data map.
|
// updateAmdGpuData reads GPU metrics from sysfs and updates the GPU data map.
|
||||||
@@ -144,8 +144,8 @@ func (gm *GPUManager) updateAmdGpuData(cardPath string) bool {
|
|||||||
if usageErr == nil {
|
if usageErr == nil {
|
||||||
gpu.Usage += usage
|
gpu.Usage += usage
|
||||||
}
|
}
|
||||||
gpu.MemoryUsed = utils.BytesToMegabytes(memUsed)
|
gpu.MemoryUsed = bytesToMegabytes(memUsed)
|
||||||
gpu.MemoryTotal = utils.BytesToMegabytes(memTotal)
|
gpu.MemoryTotal = bytesToMegabytes(memTotal)
|
||||||
gpu.Temperature = temp
|
gpu.Temperature = temp
|
||||||
gpu.Power += power
|
gpu.Power += power
|
||||||
gpu.Count++
|
gpu.Count++
|
||||||
@@ -154,12 +154,11 @@ func (gm *GPUManager) updateAmdGpuData(cardPath string) bool {
|
|||||||
|
|
||||||
// readSysfsFloat reads and parses a numeric value from a sysfs file.
|
// readSysfsFloat reads and parses a numeric value from a sysfs file.
|
||||||
func readSysfsFloat(path string) (float64, error) {
|
func readSysfsFloat(path string) (float64, error) {
|
||||||
val, err := utils.ReadStringFileLimited(path, 64)
|
val, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Debug("Failed to read sysfs value", "path", path, "error", err)
|
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
return strconv.ParseFloat(val, 64)
|
return strconv.ParseFloat(strings.TrimSpace(string(val)), 64)
|
||||||
}
|
}
|
||||||
|
|
||||||
// normalizeHexID normalizes hex IDs by trimming spaces, lowercasing, and dropping 0x.
|
// normalizeHexID normalizes hex IDs by trimming spaces, lowercasing, and dropping 0x.
|
||||||
@@ -274,16 +273,16 @@ func cacheMissingAmdgpuName(deviceID, revisionID string) {
|
|||||||
// Falls back to showing the raw device ID if not found in the lookup table.
|
// Falls back to showing the raw device ID if not found in the lookup table.
|
||||||
func getAmdGpuName(devicePath string) string {
|
func getAmdGpuName(devicePath string) string {
|
||||||
// Try product_name first (works for some enterprise GPUs)
|
// Try product_name first (works for some enterprise GPUs)
|
||||||
if prod, err := utils.ReadStringFileLimited(filepath.Join(devicePath, "product_name"), 128); err == nil {
|
if prod, err := os.ReadFile(filepath.Join(devicePath, "product_name")); err == nil {
|
||||||
return prod
|
return strings.TrimSpace(string(prod))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read PCI device ID and look it up
|
// Read PCI device ID and look it up
|
||||||
if deviceID, err := utils.ReadStringFileLimited(filepath.Join(devicePath, "device"), 64); err == nil {
|
if deviceID, err := os.ReadFile(filepath.Join(devicePath, "device")); err == nil {
|
||||||
id := normalizeHexID(deviceID)
|
id := normalizeHexID(string(deviceID))
|
||||||
revision := ""
|
revision := ""
|
||||||
if rev, revErr := utils.ReadStringFileLimited(filepath.Join(devicePath, "revision"), 64); revErr == nil {
|
if revBytes, revErr := os.ReadFile(filepath.Join(devicePath, "revision")); revErr == nil {
|
||||||
revision = normalizeHexID(rev)
|
revision = normalizeHexID(string(revBytes))
|
||||||
}
|
}
|
||||||
|
|
||||||
if name, found, done := getCachedAmdgpuName(id, revision); found {
|
if name, found, done := getCachedAmdgpuName(id, revision); found {
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/agent/utils"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -129,14 +128,14 @@ func TestUpdateAmdGpuDataWithFakeSysfs(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "sums vram and gtt when gtt is present",
|
name: "sums vram and gtt when gtt is present",
|
||||||
writeGTT: true,
|
writeGTT: true,
|
||||||
wantMemoryUsed: utils.BytesToMegabytes(1073741824 + 536870912),
|
wantMemoryUsed: bytesToMegabytes(1073741824 + 536870912),
|
||||||
wantMemoryTotal: utils.BytesToMegabytes(2147483648 + 4294967296),
|
wantMemoryTotal: bytesToMegabytes(2147483648 + 4294967296),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "falls back to vram when gtt is missing",
|
name: "falls back to vram when gtt is missing",
|
||||||
writeGTT: false,
|
writeGTT: false,
|
||||||
wantMemoryUsed: utils.BytesToMegabytes(1073741824),
|
wantMemoryUsed: bytesToMegabytes(1073741824),
|
||||||
wantMemoryTotal: utils.BytesToMegabytes(2147483648),
|
wantMemoryTotal: bytesToMegabytes(2147483648),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/agent/utils"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -172,11 +171,7 @@ type macmonSample struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (gm *GPUManager) collectMacmonPipe() (err error) {
|
func (gm *GPUManager) collectMacmonPipe() (err error) {
|
||||||
macmonPath, err := utils.LookPathHomebrew(macmonCmd)
|
cmd := exec.Command(macmonCmd, "pipe", "-i", strconv.Itoa(macmonIntervalMs))
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
cmd := exec.Command(macmonPath, "pipe", "-i", strconv.Itoa(macmonIntervalMs))
|
|
||||||
// Avoid blocking if macmon writes to stderr.
|
// Avoid blocking if macmon writes to stderr.
|
||||||
cmd.Stderr = io.Discard
|
cmd.Stderr = io.Discard
|
||||||
stdout, err := cmd.StdoutPipe()
|
stdout, err := cmd.StdoutPipe()
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/agent/utils"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -53,7 +52,7 @@ func (gm *GPUManager) updateIntelFromStats(sample *intelGpuStats) bool {
|
|||||||
func (gm *GPUManager) collectIntelStats() (err error) {
|
func (gm *GPUManager) collectIntelStats() (err error) {
|
||||||
// Build command arguments, optionally selecting a device via -d
|
// Build command arguments, optionally selecting a device via -d
|
||||||
args := []string{"-s", intelGpuStatsInterval, "-l"}
|
args := []string{"-s", intelGpuStatsInterval, "-l"}
|
||||||
if dev, ok := utils.GetEnv("INTEL_GPU_DEVICE"); ok && dev != "" {
|
if dev, ok := GetEnv("INTEL_GPU_DEVICE"); ok && dev != "" {
|
||||||
args = append(args, "-d", dev)
|
args = append(args, "-d", dev)
|
||||||
}
|
}
|
||||||
cmd := exec.Command(intelGpuStatsCmd, args...)
|
cmd := exec.Command(intelGpuStatsCmd, args...)
|
||||||
|
|||||||
@@ -1,280 +0,0 @@
|
|||||||
//go:build linux
|
|
||||||
|
|
||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/agent/utils"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
drmSysfsRoot = "/sys/class/drm"
|
|
||||||
intelSysfsNow = time.Now
|
|
||||||
)
|
|
||||||
|
|
||||||
type intelSysfsEnergySnapshot struct {
|
|
||||||
microjoules uint64
|
|
||||||
timestamp time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
type intelSysfsCard struct {
|
|
||||||
cardPath string
|
|
||||||
hwmonDir string
|
|
||||||
}
|
|
||||||
|
|
||||||
// hasIntelSysfs returns true if any Intel DRM card exposes an hwmon energy counter.
|
|
||||||
func (gm *GPUManager) hasIntelSysfs() bool {
|
|
||||||
cards, err := discoverIntelSysfsCards()
|
|
||||||
return err == nil && len(cards) > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// startIntelSysfsCollector starts Intel GPU collection via sysfs.
|
|
||||||
func (gm *GPUManager) startIntelSysfsCollector() bool {
|
|
||||||
go func() {
|
|
||||||
if err := gm.collectIntelSysfsStats(); err != nil {
|
|
||||||
slog.Warn("Error collecting Intel GPU data via sysfs", "err", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// collectIntelSysfsStats collects Intel GPU metrics directly from DRM sysfs / hwmon.
|
|
||||||
func (gm *GPUManager) collectIntelSysfsStats() error {
|
|
||||||
sysfsPollInterval := 3000 * time.Millisecond
|
|
||||||
cards, err := discoverIntelSysfsCards()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if len(cards) == 0 {
|
|
||||||
return errNoValidData
|
|
||||||
}
|
|
||||||
|
|
||||||
slog.Debug("Using sysfs for Intel GPU data collection", "cards", len(cards))
|
|
||||||
for _, card := range cards {
|
|
||||||
slog.Debug("Intel sysfs card detected", "card", filepath.Base(card.cardPath), "hwmon", card.hwmonDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
failures := 0
|
|
||||||
for {
|
|
||||||
hasData := false
|
|
||||||
for _, card := range cards {
|
|
||||||
if gm.updateIntelSysfsGpuData(card.cardPath, card.hwmonDir) {
|
|
||||||
hasData = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !hasData {
|
|
||||||
failures++
|
|
||||||
if failures > maxFailureRetries {
|
|
||||||
return errNoValidData
|
|
||||||
}
|
|
||||||
slog.Warn("No Intel GPU data from sysfs", "failures", failures)
|
|
||||||
time.Sleep(retryWaitTime)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
failures = 0
|
|
||||||
time.Sleep(sysfsPollInterval)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func discoverIntelSysfsCards() ([]intelSysfsCard, error) {
|
|
||||||
paths, err := filepath.Glob(filepath.Join(drmSysfsRoot, "card*"))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var cards []intelSysfsCard
|
|
||||||
for _, cardPath := range paths {
|
|
||||||
if strings.Contains(filepath.Base(cardPath), "-") || !isIntelGpu(cardPath) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
hwmonDir := findIntelEnergyHwmon(filepath.Join(cardPath, "device"))
|
|
||||||
if hwmonDir == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
cards = append(cards, intelSysfsCard{cardPath: cardPath, hwmonDir: hwmonDir})
|
|
||||||
}
|
|
||||||
return cards, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func isIntelGpu(cardPath string) bool {
|
|
||||||
vendor, err := utils.ReadStringFileLimited(filepath.Join(cardPath, "device/vendor"), 64)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return strings.EqualFold(strings.TrimSpace(vendor), "0x8086")
|
|
||||||
}
|
|
||||||
|
|
||||||
func findIntelEnergyHwmon(devicePath string) string {
|
|
||||||
hwmons, _ := filepath.Glob(filepath.Join(devicePath, "hwmon/hwmon*"))
|
|
||||||
var fallback string
|
|
||||||
for _, hwmonDir := range hwmons {
|
|
||||||
if !sysfsFileExists(filepath.Join(hwmonDir, "energy1_input")) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if name, err := utils.ReadStringFileLimited(filepath.Join(hwmonDir, "name"), 64); err == nil && strings.EqualFold(strings.TrimSpace(name), "xe") {
|
|
||||||
return hwmonDir
|
|
||||||
}
|
|
||||||
if fallback == "" {
|
|
||||||
fallback = hwmonDir
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
func sysfsFileExists(path string) bool {
|
|
||||||
_, err := utils.ReadStringFileLimited(path, 1)
|
|
||||||
return err == nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateIntelSysfsGpuData reads GPU metrics from sysfs and updates the GPU data map.
|
|
||||||
// Returns true if the required energy counter was read successfully.
|
|
||||||
func (gm *GPUManager) updateIntelSysfsGpuData(cardPath, hwmonDir string) bool {
|
|
||||||
devicePath := filepath.Join(cardPath, "device")
|
|
||||||
id := filepath.Base(cardPath)
|
|
||||||
|
|
||||||
energy, err := readSysfsUint(filepath.Join(hwmonDir, "energy1_input"))
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
now := intelSysfsNow()
|
|
||||||
power, hasPower := gm.calculateIntelSysfsPower(id, energy, now)
|
|
||||||
powerPkg, hasPowerPkg := gm.readIntelSysfsPowerPkg(id, hwmonDir, now)
|
|
||||||
temp := readIntelSysfsTemperature(hwmonDir)
|
|
||||||
usage, usageErr := readOptionalSysfsFloat(filepath.Join(devicePath, "gpu_busy_percent"))
|
|
||||||
memUsed, memUsedErr := readFirstOptionalSysfsFloat(
|
|
||||||
filepath.Join(devicePath, "mem_info_vram_used"),
|
|
||||||
filepath.Join(devicePath, "mem_info_lmem_used"),
|
|
||||||
filepath.Join(devicePath, "mem_info_local_mem_used"),
|
|
||||||
)
|
|
||||||
memTotal, memTotalErr := readFirstOptionalSysfsFloat(
|
|
||||||
filepath.Join(devicePath, "mem_info_vram_total"),
|
|
||||||
filepath.Join(devicePath, "mem_info_lmem_total"),
|
|
||||||
filepath.Join(devicePath, "mem_info_local_mem_total"),
|
|
||||||
)
|
|
||||||
|
|
||||||
gm.Lock()
|
|
||||||
defer gm.Unlock()
|
|
||||||
|
|
||||||
gpu, ok := gm.GpuDataMap[id]
|
|
||||||
if !ok {
|
|
||||||
gpu = &system.GPUData{Name: getIntelSysfsGpuName(cardPath)}
|
|
||||||
gm.GpuDataMap[id] = gpu
|
|
||||||
}
|
|
||||||
|
|
||||||
if usageErr == nil {
|
|
||||||
gpu.Usage += usage
|
|
||||||
}
|
|
||||||
if memUsedErr == nil {
|
|
||||||
gpu.MemoryUsed = utils.BytesToMegabytes(memUsed)
|
|
||||||
}
|
|
||||||
if memTotalErr == nil {
|
|
||||||
gpu.MemoryTotal = utils.BytesToMegabytes(memTotal)
|
|
||||||
}
|
|
||||||
if temp > 0 {
|
|
||||||
gpu.Temperature = temp
|
|
||||||
}
|
|
||||||
if hasPower {
|
|
||||||
gpu.Power += power
|
|
||||||
slog.Debug("Computed Intel sysfs GPU power", "card", id, "watts", power)
|
|
||||||
}
|
|
||||||
if hasPowerPkg {
|
|
||||||
gpu.PowerPkg += powerPkg
|
|
||||||
}
|
|
||||||
gpu.Count++
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (gm *GPUManager) calculateIntelSysfsPower(cardID string, microjoules uint64, timestamp time.Time) (float64, bool) {
|
|
||||||
if gm.intelSysfsEnergySnapshots == nil {
|
|
||||||
gm.intelSysfsEnergySnapshots = make(map[string]intelSysfsEnergySnapshot)
|
|
||||||
}
|
|
||||||
|
|
||||||
last, ok := gm.intelSysfsEnergySnapshots[cardID]
|
|
||||||
gm.intelSysfsEnergySnapshots[cardID] = intelSysfsEnergySnapshot{microjoules: microjoules, timestamp: timestamp}
|
|
||||||
if !ok {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
if microjoules < last.microjoules {
|
|
||||||
slog.Debug("Intel sysfs energy counter reset", "card", cardID)
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
elapsed := timestamp.Sub(last.timestamp).Seconds()
|
|
||||||
if elapsed <= 0 {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
delta := microjoules - last.microjoules
|
|
||||||
return float64(delta) / 1_000_000.0 / elapsed, true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (gm *GPUManager) readIntelSysfsPowerPkg(cardID, hwmonDir string, timestamp time.Time) (float64, bool) {
|
|
||||||
energyPaths, _ := filepath.Glob(filepath.Join(hwmonDir, "energy*_input"))
|
|
||||||
for _, path := range energyPaths {
|
|
||||||
if filepath.Base(path) == "energy1_input" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
energy, err := readSysfsUint(path)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
return gm.calculateIntelSysfsPower(cardID+":"+filepath.Base(path), energy, timestamp)
|
|
||||||
}
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
|
|
||||||
func readIntelSysfsTemperature(hwmonDir string) float64 {
|
|
||||||
tempPaths, _ := filepath.Glob(filepath.Join(hwmonDir, "temp*_input"))
|
|
||||||
for _, path := range tempPaths {
|
|
||||||
temp, err := readSysfsFloat(path)
|
|
||||||
if err == nil && temp > 0 {
|
|
||||||
return temp / 1000.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func readSysfsUint(path string) (uint64, error) {
|
|
||||||
val, err := utils.ReadStringFileLimited(path, 64)
|
|
||||||
if err != nil {
|
|
||||||
slog.Debug("Failed to read sysfs value", "path", path, "error", err)
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return strconv.ParseUint(strings.TrimSpace(val), 10, 64)
|
|
||||||
}
|
|
||||||
|
|
||||||
func readOptionalSysfsFloat(path string) (float64, error) {
|
|
||||||
val, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return strconv.ParseFloat(strings.TrimSpace(string(val)), 64)
|
|
||||||
}
|
|
||||||
|
|
||||||
func readFirstOptionalSysfsFloat(paths ...string) (float64, error) {
|
|
||||||
for _, path := range paths {
|
|
||||||
val, err := readOptionalSysfsFloat(path)
|
|
||||||
if err == nil {
|
|
||||||
return val, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0, fmt.Errorf("no sysfs values found")
|
|
||||||
}
|
|
||||||
|
|
||||||
func getIntelSysfsGpuName(cardPath string) string {
|
|
||||||
devicePath := filepath.Join(cardPath, "device")
|
|
||||||
if product, err := utils.ReadStringFileLimited(filepath.Join(devicePath, "product_name"), 128); err == nil && strings.TrimSpace(product) != "" {
|
|
||||||
return strings.TrimSpace(product)
|
|
||||||
}
|
|
||||||
if name, err := utils.ReadStringFileLimited(filepath.Join(devicePath, "name"), 128); err == nil && strings.TrimSpace(name) != "" {
|
|
||||||
return strings.TrimSpace(name)
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("Intel GPU %s", filepath.Base(cardPath))
|
|
||||||
}
|
|
||||||
@@ -1,217 +0,0 @@
|
|||||||
//go:build linux
|
|
||||||
|
|
||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/agent/utils"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func setupIntelSysfsTest(t *testing.T) (root, cardPath, hwmonPath string) {
|
|
||||||
t.Helper()
|
|
||||||
root = t.TempDir()
|
|
||||||
oldRoot := drmSysfsRoot
|
|
||||||
drmSysfsRoot = root
|
|
||||||
t.Cleanup(func() {
|
|
||||||
drmSysfsRoot = oldRoot
|
|
||||||
})
|
|
||||||
|
|
||||||
cardPath = filepath.Join(root, "card0")
|
|
||||||
devicePath := filepath.Join(cardPath, "device")
|
|
||||||
hwmonPath = filepath.Join(devicePath, "hwmon", "hwmon0")
|
|
||||||
require.NoError(t, os.MkdirAll(hwmonPath, 0o755))
|
|
||||||
return root, cardPath, hwmonPath
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeIntelSysfsFile(t *testing.T, basePath, name, content string) {
|
|
||||||
t.Helper()
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(basePath, name), []byte(content), 0o644))
|
|
||||||
}
|
|
||||||
|
|
||||||
func setIntelSysfsTime(t *testing.T, now time.Time) {
|
|
||||||
t.Helper()
|
|
||||||
oldNow := intelSysfsNow
|
|
||||||
intelSysfsNow = func() time.Time { return now }
|
|
||||||
t.Cleanup(func() {
|
|
||||||
intelSysfsNow = oldNow
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIntelSysfsDetectsIntelCardWithEnergy(t *testing.T) {
|
|
||||||
_, cardPath, hwmonPath := setupIntelSysfsTest(t)
|
|
||||||
devicePath := filepath.Join(cardPath, "device")
|
|
||||||
writeIntelSysfsFile(t, devicePath, "vendor", "0x8086\n")
|
|
||||||
writeIntelSysfsFile(t, hwmonPath, "name", "xe\n")
|
|
||||||
writeIntelSysfsFile(t, hwmonPath, "energy1_input", "1000000\n")
|
|
||||||
|
|
||||||
gm := &GPUManager{}
|
|
||||||
assert.True(t, gm.hasIntelSysfs())
|
|
||||||
|
|
||||||
cards, err := discoverIntelSysfsCards()
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, cards, 1)
|
|
||||||
assert.Equal(t, cardPath, cards[0].cardPath)
|
|
||||||
assert.Equal(t, hwmonPath, cards[0].hwmonDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIntelSysfsRejectsNonIntelCard(t *testing.T) {
|
|
||||||
_, cardPath, hwmonPath := setupIntelSysfsTest(t)
|
|
||||||
devicePath := filepath.Join(cardPath, "device")
|
|
||||||
writeIntelSysfsFile(t, devicePath, "vendor", "0x1002\n")
|
|
||||||
writeIntelSysfsFile(t, hwmonPath, "name", "xe\n")
|
|
||||||
writeIntelSysfsFile(t, hwmonPath, "energy1_input", "1000000\n")
|
|
||||||
|
|
||||||
gm := &GPUManager{}
|
|
||||||
assert.False(t, gm.hasIntelSysfs())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIntelSysfsRequiresEnergyInput(t *testing.T) {
|
|
||||||
_, cardPath, hwmonPath := setupIntelSysfsTest(t)
|
|
||||||
devicePath := filepath.Join(cardPath, "device")
|
|
||||||
writeIntelSysfsFile(t, devicePath, "vendor", "0x8086\n")
|
|
||||||
writeIntelSysfsFile(t, hwmonPath, "name", "xe\n")
|
|
||||||
|
|
||||||
gm := &GPUManager{}
|
|
||||||
assert.False(t, gm.hasIntelSysfs())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIntelSysfsFirstSampleInitializesWithoutBogusPower(t *testing.T) {
|
|
||||||
_, cardPath, hwmonPath := setupIntelSysfsTest(t)
|
|
||||||
devicePath := filepath.Join(cardPath, "device")
|
|
||||||
writeIntelSysfsFile(t, devicePath, "vendor", "0x8086\n")
|
|
||||||
writeIntelSysfsFile(t, hwmonPath, "name", "xe\n")
|
|
||||||
writeIntelSysfsFile(t, hwmonPath, "energy1_input", "1000000\n")
|
|
||||||
setIntelSysfsTime(t, time.Unix(100, 0))
|
|
||||||
|
|
||||||
gm := &GPUManager{GpuDataMap: make(map[string]*system.GPUData)}
|
|
||||||
ok := gm.updateIntelSysfsGpuData(cardPath, hwmonPath)
|
|
||||||
require.True(t, ok)
|
|
||||||
|
|
||||||
gpu := gm.GpuDataMap["card0"]
|
|
||||||
require.NotNil(t, gpu)
|
|
||||||
assert.Equal(t, "Intel GPU card0", gpu.Name)
|
|
||||||
assert.Equal(t, 0.0, gpu.Power)
|
|
||||||
assert.Equal(t, 1.0, gpu.Count)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIntelSysfsSecondSampleComputesWatts(t *testing.T) {
|
|
||||||
_, cardPath, hwmonPath := setupIntelSysfsTest(t)
|
|
||||||
devicePath := filepath.Join(cardPath, "device")
|
|
||||||
writeIntelSysfsFile(t, devicePath, "vendor", "0x8086\n")
|
|
||||||
writeIntelSysfsFile(t, hwmonPath, "energy1_input", "1000000\n")
|
|
||||||
|
|
||||||
gm := &GPUManager{GpuDataMap: make(map[string]*system.GPUData)}
|
|
||||||
oldNow := intelSysfsNow
|
|
||||||
intelSysfsNow = func() time.Time { return time.Unix(100, 0) }
|
|
||||||
t.Cleanup(func() { intelSysfsNow = oldNow })
|
|
||||||
require.True(t, gm.updateIntelSysfsGpuData(cardPath, hwmonPath))
|
|
||||||
|
|
||||||
writeIntelSysfsFile(t, hwmonPath, "energy1_input", "6000000\n")
|
|
||||||
intelSysfsNow = func() time.Time { return time.Unix(102, 0) }
|
|
||||||
require.True(t, gm.updateIntelSysfsGpuData(cardPath, hwmonPath))
|
|
||||||
|
|
||||||
gpu := gm.GpuDataMap["card0"]
|
|
||||||
require.NotNil(t, gpu)
|
|
||||||
assert.Equal(t, 2.5, gpu.Power)
|
|
||||||
assert.Equal(t, 2.0, gpu.Count)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIntelSysfsSecondEnergyCounterMapsToPowerPkg(t *testing.T) {
|
|
||||||
_, cardPath, hwmonPath := setupIntelSysfsTest(t)
|
|
||||||
devicePath := filepath.Join(cardPath, "device")
|
|
||||||
writeIntelSysfsFile(t, devicePath, "vendor", "0x8086\n")
|
|
||||||
writeIntelSysfsFile(t, hwmonPath, "energy1_input", "1000000\n")
|
|
||||||
writeIntelSysfsFile(t, hwmonPath, "energy2_input", "2000000\n")
|
|
||||||
|
|
||||||
oldNow := intelSysfsNow
|
|
||||||
t.Cleanup(func() { intelSysfsNow = oldNow })
|
|
||||||
gm := &GPUManager{GpuDataMap: make(map[string]*system.GPUData)}
|
|
||||||
intelSysfsNow = func() time.Time { return time.Unix(100, 0) }
|
|
||||||
require.True(t, gm.updateIntelSysfsGpuData(cardPath, hwmonPath))
|
|
||||||
|
|
||||||
writeIntelSysfsFile(t, hwmonPath, "energy1_input", "2000000\n")
|
|
||||||
writeIntelSysfsFile(t, hwmonPath, "energy2_input", "8000000\n")
|
|
||||||
intelSysfsNow = func() time.Time { return time.Unix(102, 0) }
|
|
||||||
require.True(t, gm.updateIntelSysfsGpuData(cardPath, hwmonPath))
|
|
||||||
|
|
||||||
gpu := gm.GpuDataMap["card0"]
|
|
||||||
require.NotNil(t, gpu)
|
|
||||||
assert.Equal(t, 0.5, gpu.Power)
|
|
||||||
assert.Equal(t, 3.0, gpu.PowerPkg)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIntelSysfsCounterResetSkipsOneSample(t *testing.T) {
|
|
||||||
gm := &GPUManager{}
|
|
||||||
power, ok := gm.calculateIntelSysfsPower("card0", 5000000, time.Unix(100, 0))
|
|
||||||
assert.False(t, ok)
|
|
||||||
assert.Equal(t, 0.0, power)
|
|
||||||
|
|
||||||
power, ok = gm.calculateIntelSysfsPower("card0", 1000000, time.Unix(101, 0))
|
|
||||||
assert.False(t, ok)
|
|
||||||
assert.Equal(t, 0.0, power)
|
|
||||||
|
|
||||||
power, ok = gm.calculateIntelSysfsPower("card0", 3000000, time.Unix(103, 0))
|
|
||||||
assert.True(t, ok)
|
|
||||||
assert.Equal(t, 1.0, power)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIntelSysfsTempInputMapsToCelsius(t *testing.T) {
|
|
||||||
_, cardPath, hwmonPath := setupIntelSysfsTest(t)
|
|
||||||
devicePath := filepath.Join(cardPath, "device")
|
|
||||||
writeIntelSysfsFile(t, devicePath, "vendor", "0x8086\n")
|
|
||||||
writeIntelSysfsFile(t, hwmonPath, "energy1_input", "1000000\n")
|
|
||||||
writeIntelSysfsFile(t, hwmonPath, "temp1_input", "43500\n")
|
|
||||||
setIntelSysfsTime(t, time.Unix(100, 0))
|
|
||||||
|
|
||||||
gm := &GPUManager{GpuDataMap: make(map[string]*system.GPUData)}
|
|
||||||
require.True(t, gm.updateIntelSysfsGpuData(cardPath, hwmonPath))
|
|
||||||
|
|
||||||
gpu := gm.GpuDataMap["card0"]
|
|
||||||
require.NotNil(t, gpu)
|
|
||||||
assert.Equal(t, 43.5, gpu.Temperature)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIntelSysfsMissingOptionalFilesDoNotFail(t *testing.T) {
|
|
||||||
_, cardPath, hwmonPath := setupIntelSysfsTest(t)
|
|
||||||
devicePath := filepath.Join(cardPath, "device")
|
|
||||||
writeIntelSysfsFile(t, devicePath, "vendor", "0x8086\n")
|
|
||||||
writeIntelSysfsFile(t, hwmonPath, "energy1_input", "1000000\n")
|
|
||||||
setIntelSysfsTime(t, time.Unix(100, 0))
|
|
||||||
|
|
||||||
gm := &GPUManager{GpuDataMap: make(map[string]*system.GPUData)}
|
|
||||||
require.True(t, gm.updateIntelSysfsGpuData(cardPath, hwmonPath))
|
|
||||||
|
|
||||||
gpu := gm.GpuDataMap["card0"]
|
|
||||||
require.NotNil(t, gpu)
|
|
||||||
assert.Equal(t, 0.0, gpu.Usage)
|
|
||||||
assert.Equal(t, 0.0, gpu.MemoryUsed)
|
|
||||||
assert.Equal(t, 0.0, gpu.MemoryTotal)
|
|
||||||
assert.Equal(t, 0.0, gpu.Temperature)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIntelSysfsMapsOpportunisticMemoryAndUsage(t *testing.T) {
|
|
||||||
_, cardPath, hwmonPath := setupIntelSysfsTest(t)
|
|
||||||
devicePath := filepath.Join(cardPath, "device")
|
|
||||||
writeIntelSysfsFile(t, devicePath, "vendor", "0x8086\n")
|
|
||||||
writeIntelSysfsFile(t, devicePath, "gpu_busy_percent", "37\n")
|
|
||||||
writeIntelSysfsFile(t, devicePath, "mem_info_lmem_used", "1073741824\n")
|
|
||||||
writeIntelSysfsFile(t, devicePath, "mem_info_lmem_total", "2147483648\n")
|
|
||||||
writeIntelSysfsFile(t, hwmonPath, "energy1_input", "1000000\n")
|
|
||||||
setIntelSysfsTime(t, time.Unix(100, 0))
|
|
||||||
|
|
||||||
gm := &GPUManager{GpuDataMap: make(map[string]*system.GPUData)}
|
|
||||||
require.True(t, gm.updateIntelSysfsGpuData(cardPath, hwmonPath))
|
|
||||||
|
|
||||||
gpu := gm.GpuDataMap["card0"]
|
|
||||||
require.NotNil(t, gpu)
|
|
||||||
assert.Equal(t, 37.0, gpu.Usage)
|
|
||||||
assert.Equal(t, utils.BytesToMegabytes(1073741824), gpu.MemoryUsed)
|
|
||||||
assert.Equal(t, utils.BytesToMegabytes(2147483648), gpu.MemoryTotal)
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
//go:build !linux
|
|
||||||
|
|
||||||
package agent
|
|
||||||
|
|
||||||
type intelSysfsEnergySnapshot struct{}
|
|
||||||
|
|
||||||
func (gm *GPUManager) hasIntelSysfs() bool {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (gm *GPUManager) startIntelSysfsCollector() bool {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
@@ -5,12 +5,10 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/agent/utils"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -49,14 +47,9 @@ func (gm *GPUManager) updateNvtopSnapshots(snapshots []nvtopSnapshot) bool {
|
|||||||
|
|
||||||
valid := false
|
valid := false
|
||||||
usedIDs := make(map[string]struct{}, len(snapshots))
|
usedIDs := make(map[string]struct{}, len(snapshots))
|
||||||
var xeName string
|
|
||||||
for i, sample := range snapshots {
|
for i, sample := range snapshots {
|
||||||
// nvtop leaves device_name unset on xe devices.
|
|
||||||
if sample.DeviceName == "" {
|
if sample.DeviceName == "" {
|
||||||
if xeName == "" {
|
continue
|
||||||
xeName = xeGpuName()
|
|
||||||
}
|
|
||||||
sample.DeviceName = xeName
|
|
||||||
}
|
}
|
||||||
indexID := "n" + strconv.Itoa(i)
|
indexID := "n" + strconv.Itoa(i)
|
||||||
id := indexID
|
id := indexID
|
||||||
@@ -87,10 +80,10 @@ func (gm *GPUManager) updateNvtopSnapshots(snapshots []nvtopSnapshot) bool {
|
|||||||
gpu.Temperature = parseNvtopNumber(*sample.Temp)
|
gpu.Temperature = parseNvtopNumber(*sample.Temp)
|
||||||
}
|
}
|
||||||
if sample.MemUsed != nil {
|
if sample.MemUsed != nil {
|
||||||
gpu.MemoryUsed = utils.BytesToMegabytes(parseNvtopNumber(*sample.MemUsed))
|
gpu.MemoryUsed = bytesToMegabytes(parseNvtopNumber(*sample.MemUsed))
|
||||||
}
|
}
|
||||||
if sample.MemTotal != nil {
|
if sample.MemTotal != nil {
|
||||||
gpu.MemoryTotal = utils.BytesToMegabytes(parseNvtopNumber(*sample.MemTotal))
|
gpu.MemoryTotal = bytesToMegabytes(parseNvtopNumber(*sample.MemTotal))
|
||||||
}
|
}
|
||||||
if sample.GpuUtil != nil {
|
if sample.GpuUtil != nil {
|
||||||
gpu.Usage += parseNvtopNumber(*sample.GpuUtil)
|
gpu.Usage += parseNvtopNumber(*sample.GpuUtil)
|
||||||
@@ -164,38 +157,3 @@ func (gm *GPUManager) startNvtopCollector(interval string, onFailure func()) {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
// xeDevicePath returns the sysfs device path of the first xe GPU, or "".
|
|
||||||
func xeDevicePath() string {
|
|
||||||
cards, err := filepath.Glob("/sys/class/drm/card*")
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
for _, card := range cards {
|
|
||||||
if strings.Contains(filepath.Base(card), "-") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if uevent, err := utils.ReadStringFileLimited(filepath.Join(card, "device", "uevent"), 4096); err == nil && strings.Contains(uevent, "DRIVER=xe") {
|
|
||||||
return filepath.Join(card, "device")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (gm *GPUManager) hasXe() bool {
|
|
||||||
return xeDevicePath() != ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// xeGpuName names an xe GPU from its PCI device id; nvtop leaves device_name unset on xe.
|
|
||||||
func xeGpuName() string {
|
|
||||||
devicePath := xeDevicePath()
|
|
||||||
if devicePath == "" {
|
|
||||||
return "GPU"
|
|
||||||
}
|
|
||||||
id, err := utils.ReadStringFileLimited(filepath.Join(devicePath, "device"), 64)
|
|
||||||
if err != nil {
|
|
||||||
return "GPU"
|
|
||||||
}
|
|
||||||
id = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(id, "0x")))
|
|
||||||
return "Intel GPU (" + id + ")"
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/agent/utils"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -266,8 +265,8 @@ func TestParseNvtopData(t *testing.T) {
|
|||||||
assert.Equal(t, 48.0, g0.Temperature)
|
assert.Equal(t, 48.0, g0.Temperature)
|
||||||
assert.Equal(t, 5.0, g0.Usage)
|
assert.Equal(t, 5.0, g0.Usage)
|
||||||
assert.Equal(t, 13.0, g0.Power)
|
assert.Equal(t, 13.0, g0.Power)
|
||||||
assert.Equal(t, utils.BytesToMegabytes(349372416), g0.MemoryUsed)
|
assert.Equal(t, bytesToMegabytes(349372416), g0.MemoryUsed)
|
||||||
assert.Equal(t, utils.BytesToMegabytes(4294967296), g0.MemoryTotal)
|
assert.Equal(t, bytesToMegabytes(4294967296), g0.MemoryTotal)
|
||||||
assert.Equal(t, 1.0, g0.Count)
|
assert.Equal(t, 1.0, g0.Count)
|
||||||
|
|
||||||
g1, ok := gm.GpuDataMap["n1"]
|
g1, ok := gm.GpuDataMap["n1"]
|
||||||
@@ -276,8 +275,8 @@ func TestParseNvtopData(t *testing.T) {
|
|||||||
assert.Equal(t, 48.0, g1.Temperature)
|
assert.Equal(t, 48.0, g1.Temperature)
|
||||||
assert.Equal(t, 12.0, g1.Usage)
|
assert.Equal(t, 12.0, g1.Usage)
|
||||||
assert.Equal(t, 9.0, g1.Power)
|
assert.Equal(t, 9.0, g1.Power)
|
||||||
assert.Equal(t, utils.BytesToMegabytes(1213784064), g1.MemoryUsed)
|
assert.Equal(t, bytesToMegabytes(1213784064), g1.MemoryUsed)
|
||||||
assert.Equal(t, utils.BytesToMegabytes(16929173504), g1.MemoryTotal)
|
assert.Equal(t, bytesToMegabytes(16929173504), g1.MemoryTotal)
|
||||||
assert.Equal(t, 1.0, g1.Count)
|
assert.Equal(t, 1.0, g1.Count)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,12 +331,11 @@ func TestUpdateNvtopSnapshotsKeepsDeviceAssociationWhenOrderChanges(t *testing.T
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestParseCollectorPriority(t *testing.T) {
|
func TestParseCollectorPriority(t *testing.T) {
|
||||||
got := parseCollectorPriority(" nvml, nvidia-smi, intel_gpu_top, intel_sysfs, amd_sysfs, nvtop, rocm-smi, bad ")
|
got := parseCollectorPriority(" nvml, nvidia-smi, intel_gpu_top, amd_sysfs, nvtop, rocm-smi, bad ")
|
||||||
want := []collectorSource{
|
want := []collectorSource{
|
||||||
collectorSourceNVML,
|
collectorSourceNVML,
|
||||||
collectorSourceNvidiaSMI,
|
collectorSourceNvidiaSMI,
|
||||||
collectorSourceIntelGpuTop,
|
collectorSourceIntelGpuTop,
|
||||||
collectorSourceIntelSysfs,
|
|
||||||
collectorSourceAmdSysfs,
|
collectorSourceAmdSysfs,
|
||||||
collectorSourceNVTop,
|
collectorSourceNVTop,
|
||||||
collectorSourceRocmSMI,
|
collectorSourceRocmSMI,
|
||||||
@@ -1084,6 +1082,8 @@ func TestCalculateGPUAverage(t *testing.T) {
|
|||||||
|
|
||||||
func TestGPUCapabilitiesAndLegacyPriority(t *testing.T) {
|
func TestGPUCapabilitiesAndLegacyPriority(t *testing.T) {
|
||||||
// Save original PATH
|
// Save original PATH
|
||||||
|
origPath := os.Getenv("PATH")
|
||||||
|
defer os.Setenv("PATH", origPath)
|
||||||
hasAmdSysfs := (&GPUManager{}).hasAmdSysfs()
|
hasAmdSysfs := (&GPUManager{}).hasAmdSysfs()
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
@@ -1177,7 +1177,7 @@ echo "[]"`
|
|||||||
{
|
{
|
||||||
name: "no gpu tools available",
|
name: "no gpu tools available",
|
||||||
setupCommands: func(_ string) error {
|
setupCommands: func(_ string) error {
|
||||||
t.Setenv("PATH", "")
|
os.Setenv("PATH", "")
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
wantErr: true,
|
wantErr: true,
|
||||||
@@ -1187,7 +1187,7 @@ echo "[]"`
|
|||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
t.Setenv("PATH", tempDir)
|
os.Setenv("PATH", tempDir)
|
||||||
if err := tt.setupCommands(tempDir); err != nil {
|
if err := tt.setupCommands(tempDir); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -1233,9 +1233,13 @@ echo "[]"`
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCollectorStartHelpers(t *testing.T) {
|
func TestCollectorStartHelpers(t *testing.T) {
|
||||||
|
// Save original PATH
|
||||||
|
origPath := os.Getenv("PATH")
|
||||||
|
defer os.Setenv("PATH", origPath)
|
||||||
|
|
||||||
// Set up temp dir with the commands
|
// Set up temp dir with the commands
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
t.Setenv("PATH", dir)
|
os.Setenv("PATH", dir)
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -1365,8 +1369,11 @@ echo '[{"device_name":"NVIDIA Test GPU","temp":"52C","power_draw":"31W","gpu_uti
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestNewGPUManagerPriorityNvtopFallback(t *testing.T) {
|
func TestNewGPUManagerPriorityNvtopFallback(t *testing.T) {
|
||||||
|
origPath := os.Getenv("PATH")
|
||||||
|
defer os.Setenv("PATH", origPath)
|
||||||
|
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
t.Setenv("PATH", dir)
|
os.Setenv("PATH", dir)
|
||||||
t.Setenv("BESZEL_AGENT_GPU_COLLECTOR", "nvtop,nvidia-smi")
|
t.Setenv("BESZEL_AGENT_GPU_COLLECTOR", "nvtop,nvidia-smi")
|
||||||
|
|
||||||
nvtopPath := filepath.Join(dir, "nvtop")
|
nvtopPath := filepath.Join(dir, "nvtop")
|
||||||
@@ -1391,8 +1398,11 @@ echo "0, NVIDIA Priority GPU, 45, 512, 2048, 12, 25"`
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestNewGPUManagerPriorityMixedCollectors(t *testing.T) {
|
func TestNewGPUManagerPriorityMixedCollectors(t *testing.T) {
|
||||||
|
origPath := os.Getenv("PATH")
|
||||||
|
defer os.Setenv("PATH", origPath)
|
||||||
|
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
t.Setenv("PATH", dir)
|
os.Setenv("PATH", dir)
|
||||||
t.Setenv("BESZEL_AGENT_GPU_COLLECTOR", "intel_gpu_top,rocm-smi")
|
t.Setenv("BESZEL_AGENT_GPU_COLLECTOR", "intel_gpu_top,rocm-smi")
|
||||||
|
|
||||||
intelPath := filepath.Join(dir, "intel_gpu_top")
|
intelPath := filepath.Join(dir, "intel_gpu_top")
|
||||||
@@ -1422,8 +1432,11 @@ echo '{"card0": {"Temperature (Sensor edge) (C)": "49.0", "Current Socket Graphi
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestNewGPUManagerPriorityNvmlFallbackToNvidiaSmi(t *testing.T) {
|
func TestNewGPUManagerPriorityNvmlFallbackToNvidiaSmi(t *testing.T) {
|
||||||
|
origPath := os.Getenv("PATH")
|
||||||
|
defer os.Setenv("PATH", origPath)
|
||||||
|
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
t.Setenv("PATH", dir)
|
os.Setenv("PATH", dir)
|
||||||
t.Setenv("BESZEL_AGENT_GPU_COLLECTOR", "nvml,nvidia-smi")
|
t.Setenv("BESZEL_AGENT_GPU_COLLECTOR", "nvml,nvidia-smi")
|
||||||
|
|
||||||
nvidiaPath := filepath.Join(dir, "nvidia-smi")
|
nvidiaPath := filepath.Join(dir, "nvidia-smi")
|
||||||
@@ -1442,8 +1455,11 @@ echo "0, NVIDIA Fallback GPU, 41, 256, 1024, 8, 14"`
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestNewGPUManagerConfiguredCollectorsMustStart(t *testing.T) {
|
func TestNewGPUManagerConfiguredCollectorsMustStart(t *testing.T) {
|
||||||
|
origPath := os.Getenv("PATH")
|
||||||
|
defer os.Setenv("PATH", origPath)
|
||||||
|
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
t.Setenv("PATH", dir)
|
os.Setenv("PATH", dir)
|
||||||
|
|
||||||
t.Run("configured valid collector unavailable", func(t *testing.T) {
|
t.Run("configured valid collector unavailable", func(t *testing.T) {
|
||||||
t.Setenv("BESZEL_AGENT_GPU_COLLECTOR", "nvidia-smi")
|
t.Setenv("BESZEL_AGENT_GPU_COLLECTOR", "nvidia-smi")
|
||||||
@@ -1462,28 +1478,12 @@ func TestNewGPUManagerConfiguredCollectorsMustStart(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCollectorDefinitionsNvmlDoesNotRequireNvidiaSmi(t *testing.T) {
|
|
||||||
gm := &GPUManager{}
|
|
||||||
definitions := gm.collectorDefinitions(gpuCapabilities{})
|
|
||||||
require.Contains(t, definitions, collectorSourceNVML)
|
|
||||||
assert.True(t, definitions[collectorSourceNVML].available)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewGPUManagerConfiguredNvmlBypassesCapabilityGate(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
t.Setenv("PATH", dir)
|
|
||||||
t.Setenv("BESZEL_AGENT_GPU_COLLECTOR", "nvml")
|
|
||||||
|
|
||||||
gm, err := NewGPUManager()
|
|
||||||
require.Nil(t, gm)
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "no configured GPU collectors are available")
|
|
||||||
assert.NotContains(t, err.Error(), noGPUFoundMsg)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewGPUManagerJetsonIgnoresCollectorConfig(t *testing.T) {
|
func TestNewGPUManagerJetsonIgnoresCollectorConfig(t *testing.T) {
|
||||||
|
origPath := os.Getenv("PATH")
|
||||||
|
defer os.Setenv("PATH", origPath)
|
||||||
|
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
t.Setenv("PATH", dir)
|
os.Setenv("PATH", dir)
|
||||||
t.Setenv("BESZEL_AGENT_GPU_COLLECTOR", "nvidia-smi")
|
t.Setenv("BESZEL_AGENT_GPU_COLLECTOR", "nvidia-smi")
|
||||||
|
|
||||||
tegraPath := filepath.Join(dir, "tegrastats")
|
tegraPath := filepath.Join(dir, "tegrastats")
|
||||||
@@ -1718,8 +1718,12 @@ func TestIntelUpdateFromStats(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestIntelCollectorStreaming(t *testing.T) {
|
func TestIntelCollectorStreaming(t *testing.T) {
|
||||||
|
// Save and override PATH
|
||||||
|
origPath := os.Getenv("PATH")
|
||||||
|
defer os.Setenv("PATH", origPath)
|
||||||
|
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
t.Setenv("PATH", dir)
|
os.Setenv("PATH", dir)
|
||||||
|
|
||||||
// Create a fake intel_gpu_top that prints -l format with four samples (first will be skipped) and exits
|
// Create a fake intel_gpu_top that prints -l format with four samples (first will be skipped) and exits
|
||||||
scriptPath := filepath.Join(dir, "intel_gpu_top")
|
scriptPath := filepath.Join(dir, "intel_gpu_top")
|
||||||
|
|||||||
@@ -166,16 +166,14 @@ type GetSmartDataHandler struct{}
|
|||||||
|
|
||||||
func (h *GetSmartDataHandler) Handle(hctx *HandlerContext) error {
|
func (h *GetSmartDataHandler) Handle(hctx *HandlerContext) error {
|
||||||
if hctx.Agent.smartManager == nil {
|
if hctx.Agent.smartManager == nil {
|
||||||
return hctx.SendResponse(smart.SmartDataResponse{Data: map[string]smart.SmartData{}}, hctx.RequestID)
|
// return empty map to indicate no data
|
||||||
|
return hctx.SendResponse(map[string]smart.SmartData{}, hctx.RequestID)
|
||||||
}
|
}
|
||||||
complete, err := hctx.Agent.smartManager.Refresh(false)
|
if err := hctx.Agent.smartManager.Refresh(false); err != nil {
|
||||||
if err != nil {
|
|
||||||
slog.Debug("smart refresh failed", "err", err)
|
slog.Debug("smart refresh failed", "err", err)
|
||||||
}
|
}
|
||||||
return hctx.SendResponse(smart.SmartDataResponse{
|
data := hctx.Agent.smartManager.GetCurrentData()
|
||||||
Data: hctx.Agent.smartManager.GetCurrentData(),
|
return hctx.SendResponse(data, hctx.RequestID)
|
||||||
Complete: complete,
|
|
||||||
}, hctx.RequestID)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
|
|
||||||
"github.com/fxamacker/cbor/v2"
|
"github.com/fxamacker/cbor/v2"
|
||||||
"github.com/henrygd/beszel/internal/common"
|
"github.com/henrygd/beszel/internal/common"
|
||||||
"github.com/henrygd/beszel/internal/entities/smart"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -18,18 +17,6 @@ type MockHandler struct {
|
|||||||
handleFunc func(ctx *HandlerContext) error
|
handleFunc func(ctx *HandlerContext) error
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewAgentResponseSmartData(t *testing.T) {
|
|
||||||
response := newAgentResponse(smart.SmartDataResponse{
|
|
||||||
Data: map[string]smart.SmartData{
|
|
||||||
"AAA": {SerialNumber: "AAA"},
|
|
||||||
},
|
|
||||||
Complete: true,
|
|
||||||
}, nil)
|
|
||||||
|
|
||||||
assert.Equal(t, "AAA", response.SmartData["AAA"].SerialNumber)
|
|
||||||
assert.True(t, response.SmartComplete)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MockHandler) Handle(ctx *HandlerContext) error {
|
func (m *MockHandler) Handle(ctx *HandlerContext) error {
|
||||||
if m.handleFunc != nil {
|
if m.handleFunc != nil {
|
||||||
return m.handleFunc(ctx)
|
return m.handleFunc(ctx)
|
||||||
|
|||||||
@@ -8,6 +8,6 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="LibreHardwareMonitorLib" Version="0.9.6" />
|
<PackageReference Include="LibreHardwareMonitorLib" Version="0.9.5" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/agent/utils"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/smart"
|
"github.com/henrygd/beszel/internal/entities/smart"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,17 +16,15 @@ import (
|
|||||||
var mdraidSysfsRoot = "/sys"
|
var mdraidSysfsRoot = "/sys"
|
||||||
|
|
||||||
type mdraidHealth struct {
|
type mdraidHealth struct {
|
||||||
level string
|
level string
|
||||||
arrayState string
|
arrayState string
|
||||||
degraded uint64
|
degraded uint64
|
||||||
faultyDisks uint64
|
raidDisks uint64
|
||||||
populatedDisks uint64
|
syncAction string
|
||||||
raidDisks uint64
|
syncCompleted string
|
||||||
syncAction string
|
syncSpeed string
|
||||||
syncCompleted string
|
mismatchCnt uint64
|
||||||
syncSpeed string
|
capacity uint64
|
||||||
mismatchCnt uint64
|
|
||||||
capacity uint64
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// scanMdraidDevices discovers Linux md arrays exposed in sysfs.
|
// scanMdraidDevices discovers Linux md arrays exposed in sysfs.
|
||||||
@@ -45,7 +42,7 @@ func scanMdraidDevices() []*DeviceInfo {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
mdDir := filepath.Join(blockDir, name, "md")
|
mdDir := filepath.Join(blockDir, name, "md")
|
||||||
if !utils.FileExists(filepath.Join(mdDir, "array_state")) {
|
if !fileExists(filepath.Join(mdDir, "array_state")) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,9 +91,6 @@ func (sm *SmartManager) collectMdraidHealth(deviceInfo *DeviceInfo) (bool, error
|
|||||||
if health.degraded > 0 {
|
if health.degraded > 0 {
|
||||||
attrs = append(attrs, &smart.SmartAttribute{Name: "Degraded", RawValue: health.degraded})
|
attrs = append(attrs, &smart.SmartAttribute{Name: "Degraded", RawValue: health.degraded})
|
||||||
}
|
}
|
||||||
if health.faultyDisks > 0 {
|
|
||||||
attrs = append(attrs, &smart.SmartAttribute{Name: "FaultyDisks", RawValue: health.faultyDisks})
|
|
||||||
}
|
|
||||||
if health.syncAction != "" {
|
if health.syncAction != "" {
|
||||||
attrs = append(attrs, &smart.SmartAttribute{Name: "SyncAction", RawString: health.syncAction})
|
attrs = append(attrs, &smart.SmartAttribute{Name: "SyncAction", RawString: health.syncAction})
|
||||||
}
|
}
|
||||||
@@ -140,25 +134,24 @@ func readMdraidHealth(blockName string) (mdraidHealth, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mdDir := filepath.Join(mdraidSysfsRoot, "block", blockName, "md")
|
mdDir := filepath.Join(mdraidSysfsRoot, "block", blockName, "md")
|
||||||
arrayState, okState := utils.ReadStringFileOK(filepath.Join(mdDir, "array_state"))
|
arrayState, okState := readStringFileOK(filepath.Join(mdDir, "array_state"))
|
||||||
if !okState {
|
if !okState {
|
||||||
return out, false
|
return out, false
|
||||||
}
|
}
|
||||||
|
|
||||||
out.arrayState = arrayState
|
out.arrayState = arrayState
|
||||||
out.level = utils.ReadStringFile(filepath.Join(mdDir, "level"))
|
out.level = readStringFile(filepath.Join(mdDir, "level"))
|
||||||
out.syncAction = utils.ReadStringFile(filepath.Join(mdDir, "sync_action"))
|
out.syncAction = readStringFile(filepath.Join(mdDir, "sync_action"))
|
||||||
out.syncCompleted = utils.ReadStringFile(filepath.Join(mdDir, "sync_completed"))
|
out.syncCompleted = readStringFile(filepath.Join(mdDir, "sync_completed"))
|
||||||
out.syncSpeed = utils.ReadStringFile(filepath.Join(mdDir, "sync_speed"))
|
out.syncSpeed = readStringFile(filepath.Join(mdDir, "sync_speed"))
|
||||||
|
|
||||||
if val, ok := utils.ReadUintFile(filepath.Join(mdDir, "raid_disks")); ok {
|
if val, ok := readUintFile(filepath.Join(mdDir, "raid_disks")); ok {
|
||||||
out.raidDisks = val
|
out.raidDisks = val
|
||||||
}
|
}
|
||||||
if val, ok := utils.ReadUintFile(filepath.Join(mdDir, "degraded")); ok {
|
if val, ok := readUintFile(filepath.Join(mdDir, "degraded")); ok {
|
||||||
out.degraded = val
|
out.degraded = val
|
||||||
}
|
}
|
||||||
out.faultyDisks, out.populatedDisks = countMdraidMemberStates(blockName, mdraidSysfsRoot)
|
if val, ok := readUintFile(filepath.Join(mdDir, "mismatch_cnt")); ok {
|
||||||
if val, ok := utils.ReadUintFile(filepath.Join(mdDir, "mismatch_cnt")); ok {
|
|
||||||
out.mismatchCnt = val
|
out.mismatchCnt = val
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,35 +169,11 @@ func mdraidSmartStatus(health mdraidHealth) string {
|
|||||||
case "inactive", "faulty", "broken", "stopped":
|
case "inactive", "faulty", "broken", "stopped":
|
||||||
return "FAILED"
|
return "FAILED"
|
||||||
}
|
}
|
||||||
// During rebuild/recovery, arrays are often temporarily degraded; report as
|
|
||||||
// warning instead of hard failure while synchronization is in progress.
|
|
||||||
syncAction := strings.ToLower(strings.TrimSpace(health.syncAction))
|
|
||||||
switch syncAction {
|
|
||||||
case "resync", "recover", "reshape":
|
|
||||||
return "WARNING"
|
|
||||||
}
|
|
||||||
// Use actual faulty member count rather than the degraded counter, which
|
|
||||||
// equals raid_disks minus active_disks. On QNAP systems raid_disks may be
|
|
||||||
// set to a large value (e.g. 32) while only a few slots are ever used,
|
|
||||||
// making degraded misleadingly large despite zero failed disks.
|
|
||||||
if health.faultyDisks > 0 {
|
|
||||||
return "FAILED"
|
|
||||||
}
|
|
||||||
if health.degraded > 0 {
|
if health.degraded > 0 {
|
||||||
if isSparseSlotDegraded(health) {
|
|
||||||
// A sysfs snapshot cannot distinguish reserved slots from a removed
|
|
||||||
// member on sparse arrays, so report the ambiguity as a warning.
|
|
||||||
return "WARNING"
|
|
||||||
}
|
|
||||||
return "FAILED"
|
return "FAILED"
|
||||||
}
|
}
|
||||||
if health.mismatchCnt > 0 {
|
switch strings.ToLower(strings.TrimSpace(health.syncAction)) {
|
||||||
return "WARNING"
|
case "resync", "recover", "reshape", "check", "repair":
|
||||||
}
|
|
||||||
// "check" scans for consistency problems without repairing mismatches.
|
|
||||||
// With no mismatches, keep it green while reporting progress attributes.
|
|
||||||
switch syncAction {
|
|
||||||
case "repair":
|
|
||||||
return "WARNING"
|
return "WARNING"
|
||||||
}
|
}
|
||||||
switch state {
|
switch state {
|
||||||
@@ -214,43 +183,6 @@ func mdraidSmartStatus(health mdraidHealth) string {
|
|||||||
return "UNKNOWN"
|
return "UNKNOWN"
|
||||||
}
|
}
|
||||||
|
|
||||||
// countMdraidMemberStates reads member device directories under
|
|
||||||
// block/<name>/md and returns how many are explicitly marked "faulty", plus
|
|
||||||
// how many are populated at all (regardless of state). populatedDisks lets
|
|
||||||
// callers distinguish RAID slots that were never used (QNAP reserves far
|
|
||||||
// more raid_disks than it ever populates) from members that went missing.
|
|
||||||
func countMdraidMemberStates(blockName, root string) (faultyDisks, populatedDisks uint64) {
|
|
||||||
devDir := filepath.Join(root, "block", blockName, "md")
|
|
||||||
entries, err := os.ReadDir(devDir)
|
|
||||||
if err != nil {
|
|
||||||
return 0, 0
|
|
||||||
}
|
|
||||||
for _, ent := range entries {
|
|
||||||
if !strings.HasPrefix(ent.Name(), "dev-") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
populatedDisks++
|
|
||||||
statePath := filepath.Join(devDir, ent.Name(), "state")
|
|
||||||
state := utils.ReadStringFile(statePath)
|
|
||||||
if strings.Contains(state, "faulty") {
|
|
||||||
faultyDisks++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return faultyDisks, populatedDisks
|
|
||||||
}
|
|
||||||
|
|
||||||
// isSparseSlotDegraded reports whether a non-zero "degraded" count may be
|
|
||||||
// explained by RAID slots that were never populated. QNAP configures system
|
|
||||||
// arrays with raid_disks set to a large fixed maximum (e.g. 32) far beyond the
|
|
||||||
// handful of slots it ever populates, so sparse slots outnumber populated ones.
|
|
||||||
func isSparseSlotDegraded(health mdraidHealth) bool {
|
|
||||||
if health.populatedDisks == 0 || health.raidDisks <= health.populatedDisks {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
sparseSlots := health.raidDisks - health.populatedDisks
|
|
||||||
return sparseSlots > health.populatedDisks
|
|
||||||
}
|
|
||||||
|
|
||||||
// isMdraidBlockName matches /dev/mdN-style block device names.
|
// isMdraidBlockName matches /dev/mdN-style block device names.
|
||||||
func isMdraidBlockName(name string) bool {
|
func isMdraidBlockName(name string) bool {
|
||||||
if !strings.HasPrefix(name, "md") {
|
if !strings.HasPrefix(name, "md") {
|
||||||
@@ -273,7 +205,7 @@ func readMdraidBlockCapacityBytes(blockName, root string) (uint64, bool) {
|
|||||||
sizePath := filepath.Join(root, "block", blockName, "size")
|
sizePath := filepath.Join(root, "block", blockName, "size")
|
||||||
lbsPath := filepath.Join(root, "block", blockName, "queue", "logical_block_size")
|
lbsPath := filepath.Join(root, "block", blockName, "queue", "logical_block_size")
|
||||||
|
|
||||||
sizeStr, ok := utils.ReadStringFileOK(sizePath)
|
sizeStr, ok := readStringFileOK(sizePath)
|
||||||
if !ok {
|
if !ok {
|
||||||
return 0, false
|
return 0, false
|
||||||
}
|
}
|
||||||
@@ -283,7 +215,7 @@ func readMdraidBlockCapacityBytes(blockName, root string) (uint64, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
logicalBlockSize := uint64(512)
|
logicalBlockSize := uint64(512)
|
||||||
if lbsStr, ok := utils.ReadStringFileOK(lbsPath); ok {
|
if lbsStr, ok := readStringFileOK(lbsPath); ok {
|
||||||
if parsed, err := strconv.ParseUint(lbsStr, 10, 64); err == nil && parsed > 0 {
|
if parsed, err := strconv.ParseUint(lbsStr, 10, 64); err == nil && parsed > 0 {
|
||||||
logicalBlockSize = parsed
|
logicalBlockSize = parsed
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,15 +40,6 @@ func TestMdraidMockSysfsScanAndCollect(t *testing.T) {
|
|||||||
write(filepath.Join(mdDir, "sync_completed"), "10%\n")
|
write(filepath.Join(mdDir, "sync_completed"), "10%\n")
|
||||||
write(filepath.Join(mdDir, "sync_speed"), "100M\n")
|
write(filepath.Join(mdDir, "sync_speed"), "100M\n")
|
||||||
write(filepath.Join(mdDir, "mismatch_cnt"), "0\n")
|
write(filepath.Join(mdDir, "mismatch_cnt"), "0\n")
|
||||||
|
|
||||||
// Simulate two healthy member devices (no faulty state).
|
|
||||||
for _, dev := range []string{"dev-sda", "dev-sdb"} {
|
|
||||||
devPath := filepath.Join(mdDir, dev)
|
|
||||||
if err := os.MkdirAll(devPath, 0o755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
write(filepath.Join(devPath, "state"), "in_sync\n")
|
|
||||||
}
|
|
||||||
write(filepath.Join(queueDir, "logical_block_size"), "512\n")
|
write(filepath.Join(queueDir, "logical_block_size"), "512\n")
|
||||||
write(filepath.Join(tmp, "block", "md0", "size"), "2048\n")
|
write(filepath.Join(tmp, "block", "md0", "size"), "2048\n")
|
||||||
|
|
||||||
@@ -90,93 +81,16 @@ func TestMdraidMockSysfsScanAndCollect(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCountMdraidMemberStates(t *testing.T) {
|
|
||||||
tmp := t.TempDir()
|
|
||||||
|
|
||||||
write := func(path, content string) {
|
|
||||||
t.Helper()
|
|
||||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mdDir := filepath.Join(tmp, "block", "md0", "md")
|
|
||||||
|
|
||||||
// No dev-* entries: zero faulty, zero populated.
|
|
||||||
if faulty, populated := countMdraidMemberStates("md0", tmp); faulty != 0 || populated != 0 {
|
|
||||||
t.Fatalf("no members: got (faulty=%d populated=%d), want (0,0)", faulty, populated)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Two healthy members.
|
|
||||||
write(filepath.Join(mdDir, "dev-sda", "state"), "in_sync\n")
|
|
||||||
write(filepath.Join(mdDir, "dev-sdb", "state"), "in_sync\n")
|
|
||||||
if faulty, populated := countMdraidMemberStates("md0", tmp); faulty != 0 || populated != 2 {
|
|
||||||
t.Fatalf("all in_sync: got (faulty=%d populated=%d), want (0,2)", faulty, populated)
|
|
||||||
}
|
|
||||||
|
|
||||||
// One faulty member.
|
|
||||||
write(filepath.Join(mdDir, "dev-sdb", "state"), "faulty\n")
|
|
||||||
if faulty, populated := countMdraidMemberStates("md0", tmp); faulty != 1 || populated != 2 {
|
|
||||||
t.Fatalf("one faulty: got (faulty=%d populated=%d), want (1,2)", faulty, populated)
|
|
||||||
}
|
|
||||||
|
|
||||||
// QNAP-style: 28 degraded slots but no dev-* entries for them, 4 in_sync.
|
|
||||||
write(filepath.Join(mdDir, "dev-sdb", "state"), "in_sync\n")
|
|
||||||
write(filepath.Join(mdDir, "dev-sdc", "state"), "in_sync\n")
|
|
||||||
write(filepath.Join(mdDir, "dev-sdd", "state"), "in_sync\n")
|
|
||||||
if faulty, populated := countMdraidMemberStates("md0", tmp); faulty != 0 || populated != 4 {
|
|
||||||
t.Fatalf("qnap sparse: got (faulty=%d populated=%d), want (0,4)", faulty, populated)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMdraidSmartStatus(t *testing.T) {
|
func TestMdraidSmartStatus(t *testing.T) {
|
||||||
if got := mdraidSmartStatus(mdraidHealth{arrayState: "inactive"}); got != "FAILED" {
|
if got := mdraidSmartStatus(mdraidHealth{arrayState: "inactive"}); got != "FAILED" {
|
||||||
t.Fatalf("mdraidSmartStatus(inactive) = %q, want FAILED", got)
|
t.Fatalf("mdraidSmartStatus(inactive) = %q, want FAILED", got)
|
||||||
}
|
}
|
||||||
if got := mdraidSmartStatus(mdraidHealth{arrayState: "active", degraded: 1, faultyDisks: 1, syncAction: "recover"}); got != "WARNING" {
|
if got := mdraidSmartStatus(mdraidHealth{arrayState: "active", degraded: 1}); got != "FAILED" {
|
||||||
t.Fatalf("mdraidSmartStatus(degraded+recover) = %q, want WARNING", got)
|
t.Fatalf("mdraidSmartStatus(degraded) = %q, want FAILED", got)
|
||||||
}
|
|
||||||
if got := mdraidSmartStatus(mdraidHealth{arrayState: "active", degraded: 1, faultyDisks: 1}); got != "FAILED" {
|
|
||||||
t.Fatalf("mdraidSmartStatus(degraded+faulty) = %q, want FAILED", got)
|
|
||||||
}
|
|
||||||
// QNAP-style: raid_disks=32 but only 4 populated; degraded=28 but no faulty devices.
|
|
||||||
if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean", degraded: 28, faultyDisks: 0, raidDisks: 32, populatedDisks: 4}); got != "WARNING" {
|
|
||||||
t.Fatalf("mdraidSmartStatus(qnap sparse) = %q, want WARNING", got)
|
|
||||||
}
|
|
||||||
// A member disappearing from the same sparse array is indistinguishable
|
|
||||||
// from another reserved slot, so it must not be reported as healthy.
|
|
||||||
if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean", degraded: 29, faultyDisks: 0, raidDisks: 32, populatedDisks: 3}); got != "WARNING" {
|
|
||||||
t.Fatalf("mdraidSmartStatus(qnap sparse missing member) = %q, want WARNING", got)
|
|
||||||
}
|
|
||||||
// A genuinely missing member (removed dev-* entry, not just an unpopulated
|
|
||||||
// QNAP reserve slot) must still fail: raid_disks=4, only 3 populated, all
|
|
||||||
// of them in_sync, so faultyDisks==0 but degraded==1.
|
|
||||||
if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean", degraded: 1, faultyDisks: 0, raidDisks: 4, populatedDisks: 3}); got != "FAILED" {
|
|
||||||
t.Fatalf("mdraidSmartStatus(missing member) = %q, want FAILED", got)
|
|
||||||
}
|
|
||||||
// Degraded with no member-state info at all (e.g. sysfs read failed) must
|
|
||||||
// still fail rather than being silently treated as a sparse QNAP array.
|
|
||||||
if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean", degraded: 1, faultyDisks: 0, raidDisks: 4, populatedDisks: 0}); got != "FAILED" {
|
|
||||||
t.Fatalf("mdraidSmartStatus(degraded, no member info) = %q, want FAILED", got)
|
|
||||||
}
|
}
|
||||||
if got := mdraidSmartStatus(mdraidHealth{arrayState: "active", syncAction: "recover"}); got != "WARNING" {
|
if got := mdraidSmartStatus(mdraidHealth{arrayState: "active", syncAction: "recover"}); got != "WARNING" {
|
||||||
t.Fatalf("mdraidSmartStatus(recover) = %q, want WARNING", got)
|
t.Fatalf("mdraidSmartStatus(recover) = %q, want WARNING", got)
|
||||||
}
|
}
|
||||||
if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean", syncAction: "check"}); got != "PASSED" {
|
|
||||||
t.Fatalf("mdraidSmartStatus(clean+check) = %q, want PASSED", got)
|
|
||||||
}
|
|
||||||
if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean", syncAction: "check", mismatchCnt: 1}); got != "WARNING" {
|
|
||||||
t.Fatalf("mdraidSmartStatus(clean+check+mismatch) = %q, want WARNING", got)
|
|
||||||
}
|
|
||||||
if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean", mismatchCnt: 1}); got != "WARNING" {
|
|
||||||
t.Fatalf("mdraidSmartStatus(clean+mismatch) = %q, want WARNING", got)
|
|
||||||
}
|
|
||||||
if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean", syncAction: "repair"}); got != "WARNING" {
|
|
||||||
t.Fatalf("mdraidSmartStatus(repair) = %q, want WARNING", got)
|
|
||||||
}
|
|
||||||
if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean"}); got != "PASSED" {
|
if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean"}); got != "PASSED" {
|
||||||
t.Fatalf("mdraidSmartStatus(clean) = %q, want PASSED", got)
|
t.Fatalf("mdraidSmartStatus(clean) = %q, want PASSED", got)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/agent/deltatracker"
|
"github.com/henrygd/beszel/agent/deltatracker"
|
||||||
"github.com/henrygd/beszel/agent/utils"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
psutilNet "github.com/shirou/gopsutil/v4/net"
|
psutilNet "github.com/shirou/gopsutil/v4/net"
|
||||||
)
|
)
|
||||||
@@ -95,7 +94,7 @@ func (a *Agent) initializeNetIoStats() {
|
|||||||
a.netInterfaces = make(map[string]struct{}, 0)
|
a.netInterfaces = make(map[string]struct{}, 0)
|
||||||
|
|
||||||
// parse NICS env var for whitelist / blacklist
|
// parse NICS env var for whitelist / blacklist
|
||||||
nicsEnvVal, nicsEnvExists := utils.GetEnv("NICS")
|
nicsEnvVal, nicsEnvExists := GetEnv("NICS")
|
||||||
var nicCfg *NicConfig
|
var nicCfg *NicConfig
|
||||||
if nicsEnvExists {
|
if nicsEnvExists {
|
||||||
nicCfg = newNicConfig(nicsEnvVal)
|
nicCfg = newNicConfig(nicsEnvVal)
|
||||||
@@ -104,7 +103,10 @@ func (a *Agent) initializeNetIoStats() {
|
|||||||
// get current network I/O stats and record valid interfaces
|
// get current network I/O stats and record valid interfaces
|
||||||
if netIO, err := psutilNet.IOCounters(true); err == nil {
|
if netIO, err := psutilNet.IOCounters(true); err == nil {
|
||||||
for _, v := range netIO {
|
for _, v := range netIO {
|
||||||
if skipNetworkInterface(v, nicCfg) {
|
if nicsEnvExists && !isValidNic(v.Name, nicCfg) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if a.skipNetworkInterface(v) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
slog.Info("Detected network interface", "name", v.Name, "sent", v.BytesSent, "recv", v.BytesRecv)
|
slog.Info("Detected network interface", "name", v.Name, "sent", v.BytesSent, "recv", v.BytesRecv)
|
||||||
@@ -213,8 +215,10 @@ func (a *Agent) applyNetworkTotals(
|
|||||||
totalBytesSent, totalBytesRecv uint64,
|
totalBytesSent, totalBytesRecv uint64,
|
||||||
bytesSentPerSecond, bytesRecvPerSecond uint64,
|
bytesSentPerSecond, bytesRecvPerSecond uint64,
|
||||||
) {
|
) {
|
||||||
if bytesSentPerSecond > 10_000_000_000 || bytesRecvPerSecond > 10_000_000_000 {
|
networkSentPs := bytesToMegabytes(float64(bytesSentPerSecond))
|
||||||
slog.Warn("Invalid net stats. Resetting.", "sent", bytesSentPerSecond, "recv", bytesRecvPerSecond)
|
networkRecvPs := bytesToMegabytes(float64(bytesRecvPerSecond))
|
||||||
|
if networkSentPs > 10_000 || networkRecvPs > 10_000 {
|
||||||
|
slog.Warn("Invalid net stats. Resetting.", "sent", networkSentPs, "recv", networkRecvPs)
|
||||||
for _, v := range netIO {
|
for _, v := range netIO {
|
||||||
if _, exists := a.netInterfaces[v.Name]; !exists {
|
if _, exists := a.netInterfaces[v.Name]; !exists {
|
||||||
continue
|
continue
|
||||||
@@ -224,29 +228,21 @@ func (a *Agent) applyNetworkTotals(
|
|||||||
a.initializeNetIoStats()
|
a.initializeNetIoStats()
|
||||||
delete(a.netIoStats, cacheTimeMs)
|
delete(a.netIoStats, cacheTimeMs)
|
||||||
delete(a.netInterfaceDeltaTrackers, cacheTimeMs)
|
delete(a.netInterfaceDeltaTrackers, cacheTimeMs)
|
||||||
|
systemStats.NetworkSent = 0
|
||||||
|
systemStats.NetworkRecv = 0
|
||||||
systemStats.Bandwidth[0], systemStats.Bandwidth[1] = 0, 0
|
systemStats.Bandwidth[0], systemStats.Bandwidth[1] = 0, 0
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
systemStats.NetworkSent = networkSentPs
|
||||||
|
systemStats.NetworkRecv = networkRecvPs
|
||||||
systemStats.Bandwidth[0], systemStats.Bandwidth[1] = bytesSentPerSecond, bytesRecvPerSecond
|
systemStats.Bandwidth[0], systemStats.Bandwidth[1] = bytesSentPerSecond, bytesRecvPerSecond
|
||||||
nis.BytesSent = totalBytesSent
|
nis.BytesSent = totalBytesSent
|
||||||
nis.BytesRecv = totalBytesRecv
|
nis.BytesRecv = totalBytesRecv
|
||||||
a.netIoStats[cacheTimeMs] = nis
|
a.netIoStats[cacheTimeMs] = nis
|
||||||
}
|
}
|
||||||
|
|
||||||
// skipNetworkInterface returns true if the network interface should be ignored.
|
func (a *Agent) skipNetworkInterface(v psutilNet.IOCountersStat) bool {
|
||||||
func skipNetworkInterface(v psutilNet.IOCountersStat, nicCfg *NicConfig) bool {
|
|
||||||
if nicCfg != nil {
|
|
||||||
if !isValidNic(v.Name, nicCfg) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
// In whitelist mode, we honor explicit inclusion without auto-filtering.
|
|
||||||
if !nicCfg.isBlacklist {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
// In blacklist mode, still apply the auto-filter below.
|
|
||||||
}
|
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case strings.HasPrefix(v.Name, "lo"),
|
case strings.HasPrefix(v.Name, "lo"),
|
||||||
strings.HasPrefix(v.Name, "docker"),
|
strings.HasPrefix(v.Name, "docker"),
|
||||||
|
|||||||
@@ -261,39 +261,6 @@ func TestNewNicConfig(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
func TestSkipNetworkInterface(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
nic psutilNet.IOCountersStat
|
|
||||||
nicCfg *NicConfig
|
|
||||||
expectSkip bool
|
|
||||||
}{
|
|
||||||
{"loopback lo", psutilNet.IOCountersStat{Name: "lo", BytesSent: 100, BytesRecv: 100}, nil, true},
|
|
||||||
{"loopback lo0", psutilNet.IOCountersStat{Name: "lo0", BytesSent: 100, BytesRecv: 100}, nil, true},
|
|
||||||
{"docker prefix", psutilNet.IOCountersStat{Name: "docker0", BytesSent: 100, BytesRecv: 100}, nil, true},
|
|
||||||
{"br- prefix", psutilNet.IOCountersStat{Name: "br-lan", BytesSent: 100, BytesRecv: 100}, nil, true},
|
|
||||||
{"veth prefix", psutilNet.IOCountersStat{Name: "veth0abc", BytesSent: 100, BytesRecv: 100}, nil, true},
|
|
||||||
{"bond prefix", psutilNet.IOCountersStat{Name: "bond0", BytesSent: 100, BytesRecv: 100}, nil, true},
|
|
||||||
{"cali prefix", psutilNet.IOCountersStat{Name: "cali1234", BytesSent: 100, BytesRecv: 100}, nil, true},
|
|
||||||
{"zero BytesRecv", psutilNet.IOCountersStat{Name: "eth0", BytesSent: 100, BytesRecv: 0}, nil, true},
|
|
||||||
{"zero BytesSent", psutilNet.IOCountersStat{Name: "eth0", BytesSent: 0, BytesRecv: 100}, nil, true},
|
|
||||||
{"both zero", psutilNet.IOCountersStat{Name: "eth0", BytesSent: 0, BytesRecv: 0}, nil, true},
|
|
||||||
{"normal eth0", psutilNet.IOCountersStat{Name: "eth0", BytesSent: 100, BytesRecv: 200}, nil, false},
|
|
||||||
{"normal wlan0", psutilNet.IOCountersStat{Name: "wlan0", BytesSent: 1, BytesRecv: 1}, nil, false},
|
|
||||||
{"whitelist overrides skip (docker)", psutilNet.IOCountersStat{Name: "docker0", BytesSent: 100, BytesRecv: 100}, newNicConfig("docker0"), false},
|
|
||||||
{"whitelist overrides skip (lo)", psutilNet.IOCountersStat{Name: "lo", BytesSent: 100, BytesRecv: 100}, newNicConfig("lo"), false},
|
|
||||||
{"whitelist exclusion", psutilNet.IOCountersStat{Name: "eth1", BytesSent: 100, BytesRecv: 100}, newNicConfig("eth0"), true},
|
|
||||||
{"blacklist skip lo", psutilNet.IOCountersStat{Name: "lo", BytesSent: 100, BytesRecv: 100}, newNicConfig("-eth0"), true},
|
|
||||||
{"blacklist explicit eth0", psutilNet.IOCountersStat{Name: "eth0", BytesSent: 100, BytesRecv: 100}, newNicConfig("-eth0"), true},
|
|
||||||
{"blacklist allow eth1", psutilNet.IOCountersStat{Name: "eth1", BytesSent: 100, BytesRecv: 100}, newNicConfig("-eth0"), false},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
assert.Equal(t, tt.expectSkip, skipNetworkInterface(tt.nic, tt.nicCfg))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEnsureNetworkInterfacesMap(t *testing.T) {
|
func TestEnsureNetworkInterfacesMap(t *testing.T) {
|
||||||
var a Agent
|
var a Agent
|
||||||
var stats system.Stats
|
var stats system.Stats
|
||||||
@@ -416,6 +383,8 @@ func TestApplyNetworkTotals(t *testing.T) {
|
|||||||
totalBytesSent uint64
|
totalBytesSent uint64
|
||||||
totalBytesRecv uint64
|
totalBytesRecv uint64
|
||||||
expectReset bool
|
expectReset bool
|
||||||
|
expectedNetworkSent float64
|
||||||
|
expectedNetworkRecv float64
|
||||||
expectedBandwidthSent uint64
|
expectedBandwidthSent uint64
|
||||||
expectedBandwidthRecv uint64
|
expectedBandwidthRecv uint64
|
||||||
}{
|
}{
|
||||||
@@ -426,6 +395,8 @@ func TestApplyNetworkTotals(t *testing.T) {
|
|||||||
totalBytesSent: 10000000,
|
totalBytesSent: 10000000,
|
||||||
totalBytesRecv: 20000000,
|
totalBytesRecv: 20000000,
|
||||||
expectReset: false,
|
expectReset: false,
|
||||||
|
expectedNetworkSent: 0.95, // ~1 MB/s rounded to 2 decimals
|
||||||
|
expectedNetworkRecv: 1.91, // ~2 MB/s rounded to 2 decimals
|
||||||
expectedBandwidthSent: 1000000,
|
expectedBandwidthSent: 1000000,
|
||||||
expectedBandwidthRecv: 2000000,
|
expectedBandwidthRecv: 2000000,
|
||||||
},
|
},
|
||||||
@@ -453,6 +424,18 @@ func TestApplyNetworkTotals(t *testing.T) {
|
|||||||
totalBytesRecv: 20000000,
|
totalBytesRecv: 20000000,
|
||||||
expectReset: true,
|
expectReset: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "Valid network stats - at threshold boundary",
|
||||||
|
bytesSentPerSecond: 10485750000, // ~9999.99 MB/s (rounds to 9999.99)
|
||||||
|
bytesRecvPerSecond: 10485750000, // ~9999.99 MB/s (rounds to 9999.99)
|
||||||
|
totalBytesSent: 10000000,
|
||||||
|
totalBytesRecv: 20000000,
|
||||||
|
expectReset: false,
|
||||||
|
expectedNetworkSent: 9999.99,
|
||||||
|
expectedNetworkRecv: 9999.99,
|
||||||
|
expectedBandwidthSent: 10485750000,
|
||||||
|
expectedBandwidthRecv: 10485750000,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "Zero values",
|
name: "Zero values",
|
||||||
bytesSentPerSecond: 0,
|
bytesSentPerSecond: 0,
|
||||||
@@ -460,6 +443,8 @@ func TestApplyNetworkTotals(t *testing.T) {
|
|||||||
totalBytesSent: 0,
|
totalBytesSent: 0,
|
||||||
totalBytesRecv: 0,
|
totalBytesRecv: 0,
|
||||||
expectReset: false,
|
expectReset: false,
|
||||||
|
expectedNetworkSent: 0.0,
|
||||||
|
expectedNetworkRecv: 0.0,
|
||||||
expectedBandwidthSent: 0,
|
expectedBandwidthSent: 0,
|
||||||
expectedBandwidthRecv: 0,
|
expectedBandwidthRecv: 0,
|
||||||
},
|
},
|
||||||
@@ -496,10 +481,14 @@ func TestApplyNetworkTotals(t *testing.T) {
|
|||||||
// Should have reset network tracking state - maps cleared and stats zeroed
|
// Should have reset network tracking state - maps cleared and stats zeroed
|
||||||
assert.NotContains(t, a.netIoStats, cacheTimeMs, "cache entry should be cleared after reset")
|
assert.NotContains(t, a.netIoStats, cacheTimeMs, "cache entry should be cleared after reset")
|
||||||
assert.NotContains(t, a.netInterfaceDeltaTrackers, cacheTimeMs, "tracker should be cleared on reset")
|
assert.NotContains(t, a.netInterfaceDeltaTrackers, cacheTimeMs, "tracker should be cleared on reset")
|
||||||
|
assert.Zero(t, systemStats.NetworkSent)
|
||||||
|
assert.Zero(t, systemStats.NetworkRecv)
|
||||||
assert.Zero(t, systemStats.Bandwidth[0])
|
assert.Zero(t, systemStats.Bandwidth[0])
|
||||||
assert.Zero(t, systemStats.Bandwidth[1])
|
assert.Zero(t, systemStats.Bandwidth[1])
|
||||||
} else {
|
} else {
|
||||||
// Should have applied stats
|
// Should have applied stats
|
||||||
|
assert.Equal(t, tt.expectedNetworkSent, systemStats.NetworkSent)
|
||||||
|
assert.Equal(t, tt.expectedNetworkRecv, systemStats.NetworkRecv)
|
||||||
assert.Equal(t, tt.expectedBandwidthSent, systemStats.Bandwidth[0])
|
assert.Equal(t, tt.expectedBandwidthSent, systemStats.Bandwidth[0])
|
||||||
assert.Equal(t, tt.expectedBandwidthRecv, systemStats.Bandwidth[1])
|
assert.Equal(t, tt.expectedBandwidthRecv, systemStats.Bandwidth[1])
|
||||||
|
|
||||||
|
|||||||
177
agent/pve.go
Normal file
177
agent/pve.go
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/entities/container"
|
||||||
|
|
||||||
|
"github.com/luthermonson/go-proxmox"
|
||||||
|
)
|
||||||
|
|
||||||
|
type pveManager struct {
|
||||||
|
client *proxmox.Client // Client to query PVE API
|
||||||
|
nodeName string // Cluster node name
|
||||||
|
cpuCount int // CPU count on node
|
||||||
|
nodeStatsMap map[string]*container.PveNodeStats // Keeps track of pve node stats
|
||||||
|
lastInitTry time.Time // Last time node initialization was attempted
|
||||||
|
}
|
||||||
|
|
||||||
|
// newPVEManager creates a new PVE manager - may return nil if required environment variables
|
||||||
|
// are not set or if there is an error connecting to the API
|
||||||
|
func newPVEManager() *pveManager {
|
||||||
|
url, exists := GetEnv("PROXMOX_URL")
|
||||||
|
if !exists {
|
||||||
|
url = "https://localhost:8006/api2/json"
|
||||||
|
}
|
||||||
|
const nodeEnvVar = "PROXMOX_NODE"
|
||||||
|
const tokenIDEnvVar = "PROXMOX_TOKENID"
|
||||||
|
const secretEnvVar = "PROXMOX_SECRET"
|
||||||
|
|
||||||
|
nodeName, nodeNameExists := GetEnv(nodeEnvVar)
|
||||||
|
tokenID, tokenIDExists := GetEnv(tokenIDEnvVar)
|
||||||
|
secret, secretExists := GetEnv(secretEnvVar)
|
||||||
|
|
||||||
|
if !nodeNameExists || !tokenIDExists || !secretExists {
|
||||||
|
slog.Debug("Proxmox env vars unset", nodeEnvVar, nodeNameExists, tokenIDEnvVar, tokenIDExists, secretEnvVar, secretExists)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PROXMOX_INSECURE_TLS defaults to true; set to "false" to enable TLS verification
|
||||||
|
insecureTLS := true
|
||||||
|
if val, exists := GetEnv("PROXMOX_INSECURE_TLS"); exists {
|
||||||
|
insecureTLS = val != "false"
|
||||||
|
}
|
||||||
|
|
||||||
|
httpClient := http.Client{
|
||||||
|
Transport: &http.Transport{
|
||||||
|
TLSClientConfig: &tls.Config{
|
||||||
|
InsecureSkipVerify: insecureTLS,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
client := proxmox.NewClient(url,
|
||||||
|
proxmox.WithHTTPClient(&httpClient),
|
||||||
|
proxmox.WithAPIToken(tokenID, secret),
|
||||||
|
)
|
||||||
|
|
||||||
|
pveManager := pveManager{
|
||||||
|
client: client,
|
||||||
|
nodeName: nodeName,
|
||||||
|
nodeStatsMap: make(map[string]*container.PveNodeStats),
|
||||||
|
}
|
||||||
|
|
||||||
|
return &pveManager
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureInitialized checks if the PVE manager is initialized and attempts to initialize it if not.
|
||||||
|
// It returns an error if initialization fails or if a retry is pending.
|
||||||
|
func (pm *pveManager) ensureInitialized(ctx context.Context) error {
|
||||||
|
if pm.client == nil {
|
||||||
|
return errors.New("PVE client not configured")
|
||||||
|
}
|
||||||
|
if pm.cpuCount > 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if time.Since(pm.lastInitTry) < 30*time.Second {
|
||||||
|
return errors.New("PVE initialization retry pending")
|
||||||
|
}
|
||||||
|
pm.lastInitTry = time.Now()
|
||||||
|
|
||||||
|
node, err := pm.client.Node(ctx, pm.nodeName)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if node.CPUInfo.CPUs <= 0 {
|
||||||
|
return errors.New("node returned zero CPUs")
|
||||||
|
}
|
||||||
|
|
||||||
|
pm.cpuCount = node.CPUInfo.CPUs
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getPVEStats returns stats for all running VMs/LXCs
|
||||||
|
func (pm *pveManager) getPVEStats() ([]*container.PveNodeStats, error) {
|
||||||
|
if err := pm.ensureInitialized(context.Background()); err != nil {
|
||||||
|
slog.Warn("Proxmox API unavailable", "err", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cluster, err := pm.client.Cluster(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Error getting cluster", "err", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resources, err := cluster.Resources(context.Background(), "vm")
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Error getting resources", "err", err, "resources", resources)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
containersLength := len(resources)
|
||||||
|
resourceIds := make(map[string]struct{}, containersLength)
|
||||||
|
|
||||||
|
// only include running vms and lxcs on selected node
|
||||||
|
for _, resource := range resources {
|
||||||
|
if resource.Node == pm.nodeName && resource.Status == "running" {
|
||||||
|
resourceIds[resource.ID] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// remove invalid container stats
|
||||||
|
for id := range pm.nodeStatsMap {
|
||||||
|
if _, exists := resourceIds[id]; !exists {
|
||||||
|
delete(pm.nodeStatsMap, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// populate stats
|
||||||
|
stats := make([]*container.PveNodeStats, 0, len(resourceIds))
|
||||||
|
for _, resource := range resources {
|
||||||
|
if _, exists := resourceIds[resource.ID]; !exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
resourceStats, initialized := pm.nodeStatsMap[resource.ID]
|
||||||
|
if !initialized {
|
||||||
|
resourceStats = &container.PveNodeStats{}
|
||||||
|
pm.nodeStatsMap[resource.ID] = resourceStats
|
||||||
|
}
|
||||||
|
resourceStats.Name = resource.Name
|
||||||
|
resourceStats.Id = resource.ID
|
||||||
|
resourceStats.Type = resource.Type
|
||||||
|
resourceStats.MaxCPU = resource.MaxCPU
|
||||||
|
resourceStats.MaxMem = resource.MaxMem
|
||||||
|
resourceStats.Uptime = resource.Uptime
|
||||||
|
resourceStats.DiskRead = resource.DiskRead
|
||||||
|
resourceStats.DiskWrite = resource.DiskWrite
|
||||||
|
resourceStats.Disk = resource.MaxDisk
|
||||||
|
|
||||||
|
// prevent first run from sending all prev sent/recv bytes
|
||||||
|
total_sent := resource.NetOut
|
||||||
|
total_recv := resource.NetIn
|
||||||
|
var sent_delta, recv_delta float64
|
||||||
|
if initialized {
|
||||||
|
secondsElapsed := time.Since(resourceStats.PrevReadTime).Seconds()
|
||||||
|
if secondsElapsed > 0 {
|
||||||
|
sent_delta = float64(total_sent-resourceStats.PrevNet.Sent) / secondsElapsed
|
||||||
|
recv_delta = float64(total_recv-resourceStats.PrevNet.Recv) / secondsElapsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resourceStats.PrevNet.Sent = total_sent
|
||||||
|
resourceStats.PrevNet.Recv = total_recv
|
||||||
|
resourceStats.PrevReadTime = time.Now()
|
||||||
|
|
||||||
|
// Update final stats values
|
||||||
|
resourceStats.Cpu = twoDecimals(100.0 * resource.CPU * float64(resource.MaxCPU) / float64(pm.cpuCount))
|
||||||
|
resourceStats.Mem = bytesToMegabytes(float64(resource.Mem))
|
||||||
|
resourceStats.Bandwidth = [2]uint64{uint64(sent_delta), uint64(recv_delta)}
|
||||||
|
resourceStats.NetOut = total_sent
|
||||||
|
resourceStats.NetIn = total_recv
|
||||||
|
|
||||||
|
stats = append(stats, resourceStats)
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
92
agent/pve_test.go
Normal file
92
agent/pve_test.go
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/entities/container"
|
||||||
|
"github.com/luthermonson/go-proxmox"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewPVEManagerDoesNotConnectAtStartup(t *testing.T) {
|
||||||
|
t.Setenv("BESZEL_AGENT_PROXMOX_URL", "https://127.0.0.1:1/api2/json")
|
||||||
|
t.Setenv("BESZEL_AGENT_PROXMOX_NODE", "pve")
|
||||||
|
t.Setenv("BESZEL_AGENT_PROXMOX_TOKENID", "root@pam!test")
|
||||||
|
t.Setenv("BESZEL_AGENT_PROXMOX_SECRET", "secret")
|
||||||
|
|
||||||
|
pm := newPVEManager()
|
||||||
|
require.NotNil(t, pm)
|
||||||
|
assert.Zero(t, pm.cpuCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPVEManagerRetriesInitialization(t *testing.T) {
|
||||||
|
var nodeRequests atomic.Int32
|
||||||
|
var clusterRequests atomic.Int32
|
||||||
|
|
||||||
|
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/api2/json/nodes/pve/status":
|
||||||
|
nodeRequests.Add(1)
|
||||||
|
fmt.Fprint(w, `{"data":{"cpuinfo":{"cpus":8}}}`)
|
||||||
|
case "/api2/json/cluster/status":
|
||||||
|
fmt.Fprint(w, `{"data":[{"type":"cluster","name":"test-cluster","id":"test-cluster","version":1,"quorate":1}]}`)
|
||||||
|
case "/api2/json/cluster/resources":
|
||||||
|
clusterRequests.Add(1)
|
||||||
|
fmt.Fprint(w, `{"data":[{"id":"qemu/101","type":"qemu","node":"pve","status":"running","name":"vm-101","cpu":0.5,"maxcpu":4,"maxmem":4096,"mem":2048,"netin":1024,"netout":2048,"diskread":10,"diskwrite":20,"maxdisk":8192,"uptime":60}]}`)
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
pm := &pveManager{
|
||||||
|
client: proxmox.NewClient(server.URL+"/api2/json",
|
||||||
|
proxmox.WithHTTPClient(&http.Client{
|
||||||
|
Transport: &failOnceRoundTripper{
|
||||||
|
base: server.Client().Transport,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
proxmox.WithAPIToken("root@pam!test", "secret"),
|
||||||
|
),
|
||||||
|
nodeName: "pve",
|
||||||
|
nodeStatsMap: make(map[string]*container.PveNodeStats),
|
||||||
|
}
|
||||||
|
|
||||||
|
stats, err := pm.getPVEStats()
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Nil(t, stats)
|
||||||
|
assert.Zero(t, pm.cpuCount)
|
||||||
|
|
||||||
|
pm.lastInitTry = time.Now().Add(-31 * time.Second)
|
||||||
|
stats, err = pm.getPVEStats()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, stats, 1)
|
||||||
|
assert.Equal(t, int32(1), nodeRequests.Load())
|
||||||
|
assert.Equal(t, int32(1), clusterRequests.Load())
|
||||||
|
assert.Equal(t, 8, pm.cpuCount)
|
||||||
|
assert.Equal(t, "qemu/101", stats[0].Id)
|
||||||
|
assert.Equal(t, 25.0, stats[0].Cpu)
|
||||||
|
assert.Equal(t, uint64(1024), stats[0].NetIn)
|
||||||
|
assert.Equal(t, uint64(2048), stats[0].NetOut)
|
||||||
|
}
|
||||||
|
|
||||||
|
type failOnceRoundTripper struct {
|
||||||
|
base http.RoundTripper
|
||||||
|
failed atomic.Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rt *failOnceRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
if req.URL.Path == "/api2/json/nodes/pve/status" && !rt.failed.Swap(true) {
|
||||||
|
return nil, errors.New("dial tcp 127.0.0.1:8006: connect: connection refused")
|
||||||
|
}
|
||||||
|
return rt.base.RoundTrip(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ http.RoundTripper = (*failOnceRoundTripper)(nil)
|
||||||
@@ -21,9 +21,6 @@ func newAgentResponse(data any, requestID *uint32) common.AgentResponse {
|
|||||||
response.String = &v
|
response.String = &v
|
||||||
case map[string]smart.SmartData:
|
case map[string]smart.SmartData:
|
||||||
response.SmartData = v
|
response.SmartData = v
|
||||||
case smart.SmartDataResponse:
|
|
||||||
response.SmartData = v.Data
|
|
||||||
response.SmartComplete = v.Complete
|
|
||||||
case systemd.ServiceDetails:
|
case systemd.ServiceDetails:
|
||||||
response.ServiceInfo = v
|
response.ServiceInfo = v
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -2,67 +2,48 @@ package agent
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"path"
|
"path"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/agent/utils"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
|
||||||
"github.com/shirou/gopsutil/v4/common"
|
"github.com/shirou/gopsutil/v4/common"
|
||||||
"github.com/shirou/gopsutil/v4/sensors"
|
"github.com/shirou/gopsutil/v4/sensors"
|
||||||
)
|
)
|
||||||
|
|
||||||
var errTemperatureFetchTimeout = errors.New("temperature collection timed out")
|
|
||||||
|
|
||||||
// Matches sensors.TemperaturesWithContext to allow for panic recovery (gopsutil/issues/1832)
|
|
||||||
type getTempsFn func(ctx context.Context) ([]sensors.TemperatureStat, error)
|
|
||||||
|
|
||||||
type SensorConfig struct {
|
type SensorConfig struct {
|
||||||
context context.Context
|
context context.Context
|
||||||
sensors map[string]struct{}
|
sensors map[string]struct{}
|
||||||
primarySensor string
|
primarySensor string
|
||||||
timeout time.Duration
|
|
||||||
isBlacklist bool
|
isBlacklist bool
|
||||||
hasWildcards bool
|
hasWildcards bool
|
||||||
skipCollection bool
|
skipCollection bool
|
||||||
firstRun bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Agent) newSensorConfig() *SensorConfig {
|
func (a *Agent) newSensorConfig() *SensorConfig {
|
||||||
primarySensor, _ := utils.GetEnv("PRIMARY_SENSOR")
|
primarySensor, _ := GetEnv("PRIMARY_SENSOR")
|
||||||
sysSensors, _ := utils.GetEnv("SYS_SENSORS")
|
sysSensors, _ := GetEnv("SYS_SENSORS")
|
||||||
sensorsEnvVal, sensorsSet := utils.GetEnv("SENSORS")
|
sensorsEnvVal, sensorsSet := GetEnv("SENSORS")
|
||||||
skipCollection := sensorsSet && sensorsEnvVal == ""
|
skipCollection := sensorsSet && sensorsEnvVal == ""
|
||||||
sensorsTimeout, _ := utils.GetEnv("SENSORS_TIMEOUT")
|
|
||||||
|
|
||||||
return a.newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal, sensorsTimeout, skipCollection)
|
return a.newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal, skipCollection)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Matches sensors.TemperaturesWithContext to allow for panic recovery (gopsutil/issues/1832)
|
||||||
|
type getTempsFn func(ctx context.Context) ([]sensors.TemperatureStat, error)
|
||||||
|
|
||||||
// newSensorConfigWithEnv creates a SensorConfig with the provided environment variables
|
// newSensorConfigWithEnv creates a SensorConfig with the provided environment variables
|
||||||
// sensorsSet indicates if the SENSORS environment variable was explicitly set (even to empty string)
|
// sensorsSet indicates if the SENSORS environment variable was explicitly set (even to empty string)
|
||||||
func (a *Agent) newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal, sensorsTimeout string, skipCollection bool) *SensorConfig {
|
func (a *Agent) newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal string, skipCollection bool) *SensorConfig {
|
||||||
timeout := 2 * time.Second
|
|
||||||
if sensorsTimeout != "" {
|
|
||||||
if d, err := time.ParseDuration(sensorsTimeout); err == nil {
|
|
||||||
timeout = d
|
|
||||||
} else {
|
|
||||||
slog.Warn("Invalid SENSORS_TIMEOUT", "value", sensorsTimeout)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
config := &SensorConfig{
|
config := &SensorConfig{
|
||||||
context: context.Background(),
|
context: context.Background(),
|
||||||
primarySensor: primarySensor,
|
primarySensor: primarySensor,
|
||||||
timeout: timeout,
|
|
||||||
skipCollection: skipCollection,
|
skipCollection: skipCollection,
|
||||||
firstRun: true,
|
|
||||||
sensors: make(map[string]struct{}),
|
sensors: make(map[string]struct{}),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,12 +85,10 @@ func (a *Agent) updateTemperatures(systemStats *system.Stats) {
|
|||||||
// reset high temp
|
// reset high temp
|
||||||
a.systemInfo.DashboardTemp = 0
|
a.systemInfo.DashboardTemp = 0
|
||||||
|
|
||||||
temps, err := a.getTempsWithTimeout(getSensorTemps)
|
temps, err := a.getTempsWithPanicRecovery(getSensorTemps)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// retry once on panic (gopsutil/issues/1832)
|
// retry once on panic (gopsutil/issues/1832)
|
||||||
if !errors.Is(err, errTemperatureFetchTimeout) {
|
temps, err = a.getTempsWithPanicRecovery(getSensorTemps)
|
||||||
temps, err = a.getTempsWithTimeout(getSensorTemps)
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("Error updating temperatures", "err", err)
|
slog.Warn("Error updating temperatures", "err", err)
|
||||||
if len(systemStats.Temperatures) > 0 {
|
if len(systemStats.Temperatures) > 0 {
|
||||||
@@ -156,7 +135,7 @@ func (a *Agent) updateTemperatures(systemStats *system.Stats) {
|
|||||||
case sensorName:
|
case sensorName:
|
||||||
a.systemInfo.DashboardTemp = sensor.Temperature
|
a.systemInfo.DashboardTemp = sensor.Temperature
|
||||||
}
|
}
|
||||||
systemStats.Temperatures[sensorName] = utils.TwoDecimals(sensor.Temperature)
|
systemStats.Temperatures[sensorName] = twoDecimals(sensor.Temperature)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,34 +151,6 @@ func (a *Agent) getTempsWithPanicRecovery(getTemps getTempsFn) (temps []sensors.
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Agent) getTempsWithTimeout(getTemps getTempsFn) ([]sensors.TemperatureStat, error) {
|
|
||||||
type result struct {
|
|
||||||
temps []sensors.TemperatureStat
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use a longer timeout on the first run to allow for initialization
|
|
||||||
// (e.g. Windows LHM subprocess startup)
|
|
||||||
timeout := a.sensorConfig.timeout
|
|
||||||
if a.sensorConfig.firstRun {
|
|
||||||
a.sensorConfig.firstRun = false
|
|
||||||
timeout = 10 * time.Second
|
|
||||||
}
|
|
||||||
|
|
||||||
resultCh := make(chan result, 1)
|
|
||||||
go func() {
|
|
||||||
temps, err := a.getTempsWithPanicRecovery(getTemps)
|
|
||||||
resultCh <- result{temps: temps, err: err}
|
|
||||||
}()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case res := <-resultCh:
|
|
||||||
return res.temps, res.err
|
|
||||||
case <-time.After(timeout):
|
|
||||||
return nil, errTemperatureFetchTimeout
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// isValidSensor checks if a sensor is valid based on the sensor name and the sensor config
|
// isValidSensor checks if a sensor is valid based on the sensor name and the sensor config
|
||||||
func isValidSensor(sensorName string, config *SensorConfig) bool {
|
func isValidSensor(sensorName string, config *SensorConfig) bool {
|
||||||
// if no sensors configured, everything is valid
|
// if no sensors configured, everything is valid
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//go:build !windows && !freebsd
|
//go:build !windows
|
||||||
|
|
||||||
package agent
|
package agent
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
//go:build freebsd
|
|
||||||
|
|
||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"github.com/shirou/gopsutil/v4/sensors"
|
|
||||||
"golang.org/x/sys/unix"
|
|
||||||
)
|
|
||||||
|
|
||||||
var getSensorTemps = func(ctx context.Context) ([]sensors.TemperatureStat, error) {
|
|
||||||
return getFreeBSDSensorTemps(ctx, unix.SysctlUint32)
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
//go:build freebsd || testing
|
|
||||||
|
|
||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/shirou/gopsutil/v4/sensors"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
freebsdZeroCelsiusDeciKelvin = 2731
|
|
||||||
freebsdAcpiThermalZoneCount = 16
|
|
||||||
)
|
|
||||||
|
|
||||||
type freebsdSysctlUintReader func(name string) (uint32, error)
|
|
||||||
|
|
||||||
func getFreeBSDSensorTemps(ctx context.Context, readSysctl freebsdSysctlUintReader) ([]sensors.TemperatureStat, error) {
|
|
||||||
cpuCount, err := readSysctl("hw.ncpu")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
temps := make([]sensors.TemperatureStat, 0, int(cpuCount)+freebsdAcpiThermalZoneCount)
|
|
||||||
for cpu := range cpuCount {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return temps, ctx.Err()
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
sysctlName := fmt.Sprintf("dev.cpu.%d.temperature", cpu)
|
|
||||||
value, err := readSysctl(sysctlName)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
temp, ok := freebsdDeciKelvinToCelsius(value)
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
temps = append(temps, sensors.TemperatureStat{
|
|
||||||
SensorKey: fmt.Sprintf("cpu.%d", cpu),
|
|
||||||
Temperature: temp,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
for zone := 0; zone < freebsdAcpiThermalZoneCount; zone++ {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return temps, ctx.Err()
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
sysctlName := fmt.Sprintf("hw.acpi.thermal.tz%d.temperature", zone)
|
|
||||||
value, err := readSysctl(sysctlName)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
temp, ok := freebsdDeciKelvinToCelsius(value)
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
temps = append(temps, sensors.TemperatureStat{
|
|
||||||
SensorKey: fmt.Sprintf("acpi.thermal.tz%d", zone),
|
|
||||||
Temperature: temp,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return temps, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func freebsdDeciKelvinToCelsius(value uint32) (float64, bool) {
|
|
||||||
if value <= freebsdZeroCelsiusDeciKelvin {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
temp := float64(int64(value)-freebsdZeroCelsiusDeciKelvin) / 10
|
|
||||||
if temp <= 0 || temp >= 200 {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
return temp, true
|
|
||||||
}
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
//go:build testing
|
|
||||||
|
|
||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
var errFakeFreeBSDSysctlNotFound = errors.New("sysctl not found")
|
|
||||||
|
|
||||||
type fakeFreeBSDSysctls struct {
|
|
||||||
values map[string]uint32
|
|
||||||
errs map[string]error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f fakeFreeBSDSysctls) read(name string) (uint32, error) {
|
|
||||||
if err, ok := f.errs[name]; ok {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
if value, ok := f.values[name]; ok {
|
|
||||||
return value, nil
|
|
||||||
}
|
|
||||||
return 0, errFakeFreeBSDSysctlNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFreeBSDDeciKelvinToCelsius(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
value uint32
|
|
||||||
expected float64
|
|
||||||
ok bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "45 Celsius",
|
|
||||||
value: 3181,
|
|
||||||
expected: 45,
|
|
||||||
ok: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "fractional Celsius",
|
|
||||||
value: 3186,
|
|
||||||
expected: 45.5,
|
|
||||||
ok: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "zero deci-Kelvin",
|
|
||||||
value: 0,
|
|
||||||
ok: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "zero Celsius",
|
|
||||||
value: freebsdZeroCelsiusDeciKelvin,
|
|
||||||
ok: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "below zero Celsius",
|
|
||||||
value: freebsdZeroCelsiusDeciKelvin - 1,
|
|
||||||
ok: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "invalid signed integer",
|
|
||||||
value: 1<<32 - 1,
|
|
||||||
ok: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "unreasonably high Celsius",
|
|
||||||
value: freebsdZeroCelsiusDeciKelvin + 2000,
|
|
||||||
ok: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
result, ok := freebsdDeciKelvinToCelsius(tt.value)
|
|
||||||
assert.Equal(t, tt.ok, ok)
|
|
||||||
assert.InDelta(t, tt.expected, result, 0.001)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetFreeBSDSensorTemps(t *testing.T) {
|
|
||||||
reader := fakeFreeBSDSysctls{
|
|
||||||
values: map[string]uint32{
|
|
||||||
"hw.ncpu": 4,
|
|
||||||
"dev.cpu.0.temperature": 3231,
|
|
||||||
"dev.cpu.1.temperature": 3242,
|
|
||||||
"dev.cpu.3.temperature": freebsdZeroCelsiusDeciKelvin,
|
|
||||||
"hw.acpi.thermal.tz0.temperature": 3101,
|
|
||||||
"hw.acpi.thermal.tz2.temperature": 3116,
|
|
||||||
"hw.acpi.thermal.tz3.temperature": freebsdZeroCelsiusDeciKelvin,
|
|
||||||
"unrelated.sensor.value": 9999,
|
|
||||||
"dev.cpu.99.temperature": 9999,
|
|
||||||
"dev.amdtemp.0.core0.foo": 9999,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
temps, err := getFreeBSDSensorTemps(context.Background(), reader.read)
|
|
||||||
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, temps, 4)
|
|
||||||
assert.Equal(t, "cpu.0", temps[0].SensorKey)
|
|
||||||
assert.InDelta(t, 50.0, temps[0].Temperature, 0.001)
|
|
||||||
assert.Equal(t, "cpu.1", temps[1].SensorKey)
|
|
||||||
assert.InDelta(t, 51.1, temps[1].Temperature, 0.001)
|
|
||||||
assert.Equal(t, "acpi.thermal.tz0", temps[2].SensorKey)
|
|
||||||
assert.InDelta(t, 37.0, temps[2].Temperature, 0.001)
|
|
||||||
assert.Equal(t, "acpi.thermal.tz2", temps[3].SensorKey)
|
|
||||||
assert.InDelta(t, 38.5, temps[3].Temperature, 0.001)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetFreeBSDSensorTempsCpuCountError(t *testing.T) {
|
|
||||||
reader := fakeFreeBSDSysctls{
|
|
||||||
errs: map[string]error{
|
|
||||||
"hw.ncpu": errors.New("permission denied"),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
temps, err := getFreeBSDSensorTemps(context.Background(), reader.read)
|
|
||||||
|
|
||||||
assert.Nil(t, temps)
|
|
||||||
assert.EqualError(t, err, "permission denied")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetFreeBSDSensorTempsNoTemperatureSysctls(t *testing.T) {
|
|
||||||
reader := fakeFreeBSDSysctls{
|
|
||||||
values: map[string]uint32{"hw.ncpu": 2},
|
|
||||||
}
|
|
||||||
|
|
||||||
temps, err := getFreeBSDSensorTemps(context.Background(), reader.read)
|
|
||||||
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Empty(t, temps)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetFreeBSDSensorTempsAcpiOnly(t *testing.T) {
|
|
||||||
reader := fakeFreeBSDSysctls{
|
|
||||||
values: map[string]uint32{
|
|
||||||
"hw.ncpu": 0,
|
|
||||||
"hw.acpi.thermal.tz0.temperature": 3081,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
temps, err := getFreeBSDSensorTemps(context.Background(), reader.read)
|
|
||||||
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, temps, 1)
|
|
||||||
assert.Equal(t, "acpi.thermal.tz0", temps[0].SensorKey)
|
|
||||||
assert.InDelta(t, 35.0, temps[0].Temperature, 0.001)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetFreeBSDSensorTempsContextCancelled(t *testing.T) {
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
cancel()
|
|
||||||
reader := fakeFreeBSDSysctls{
|
|
||||||
values: map[string]uint32{"hw.ncpu": 2},
|
|
||||||
}
|
|
||||||
|
|
||||||
temps, err := getFreeBSDSensorTemps(ctx, reader.read)
|
|
||||||
|
|
||||||
assert.Empty(t, temps)
|
|
||||||
assert.ErrorIs(t, err, context.Canceled)
|
|
||||||
}
|
|
||||||
@@ -5,8 +5,8 @@ package agent
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
|
||||||
@@ -168,7 +168,6 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
|
|||||||
primarySensor string
|
primarySensor string
|
||||||
sysSensors string
|
sysSensors string
|
||||||
sensors string
|
sensors string
|
||||||
sensorsTimeout string
|
|
||||||
skipCollection bool
|
skipCollection bool
|
||||||
expectedConfig *SensorConfig
|
expectedConfig *SensorConfig
|
||||||
}{
|
}{
|
||||||
@@ -180,37 +179,12 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
|
|||||||
expectedConfig: &SensorConfig{
|
expectedConfig: &SensorConfig{
|
||||||
context: context.Background(),
|
context: context.Background(),
|
||||||
primarySensor: "",
|
primarySensor: "",
|
||||||
timeout: 2 * time.Second,
|
|
||||||
sensors: map[string]struct{}{},
|
sensors: map[string]struct{}{},
|
||||||
isBlacklist: false,
|
isBlacklist: false,
|
||||||
hasWildcards: false,
|
hasWildcards: false,
|
||||||
skipCollection: false,
|
skipCollection: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "Custom timeout",
|
|
||||||
primarySensor: "",
|
|
||||||
sysSensors: "",
|
|
||||||
sensors: "",
|
|
||||||
sensorsTimeout: "5s",
|
|
||||||
expectedConfig: &SensorConfig{
|
|
||||||
context: context.Background(),
|
|
||||||
timeout: 5 * time.Second,
|
|
||||||
sensors: map[string]struct{}{},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Invalid timeout falls back to default",
|
|
||||||
primarySensor: "",
|
|
||||||
sysSensors: "",
|
|
||||||
sensors: "",
|
|
||||||
sensorsTimeout: "notaduration",
|
|
||||||
expectedConfig: &SensorConfig{
|
|
||||||
context: context.Background(),
|
|
||||||
timeout: 2 * time.Second,
|
|
||||||
sensors: map[string]struct{}{},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: "Explicitly set to empty string",
|
name: "Explicitly set to empty string",
|
||||||
primarySensor: "",
|
primarySensor: "",
|
||||||
@@ -220,7 +194,6 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
|
|||||||
expectedConfig: &SensorConfig{
|
expectedConfig: &SensorConfig{
|
||||||
context: context.Background(),
|
context: context.Background(),
|
||||||
primarySensor: "",
|
primarySensor: "",
|
||||||
timeout: 2 * time.Second,
|
|
||||||
sensors: map[string]struct{}{},
|
sensors: map[string]struct{}{},
|
||||||
isBlacklist: false,
|
isBlacklist: false,
|
||||||
hasWildcards: false,
|
hasWildcards: false,
|
||||||
@@ -235,7 +208,6 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
|
|||||||
expectedConfig: &SensorConfig{
|
expectedConfig: &SensorConfig{
|
||||||
context: context.Background(),
|
context: context.Background(),
|
||||||
primarySensor: "cpu_temp",
|
primarySensor: "cpu_temp",
|
||||||
timeout: 2 * time.Second,
|
|
||||||
sensors: map[string]struct{}{},
|
sensors: map[string]struct{}{},
|
||||||
isBlacklist: false,
|
isBlacklist: false,
|
||||||
hasWildcards: false,
|
hasWildcards: false,
|
||||||
@@ -249,7 +221,6 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
|
|||||||
expectedConfig: &SensorConfig{
|
expectedConfig: &SensorConfig{
|
||||||
context: context.Background(),
|
context: context.Background(),
|
||||||
primarySensor: "cpu_temp",
|
primarySensor: "cpu_temp",
|
||||||
timeout: 2 * time.Second,
|
|
||||||
sensors: map[string]struct{}{
|
sensors: map[string]struct{}{
|
||||||
"cpu_temp": {},
|
"cpu_temp": {},
|
||||||
"gpu_temp": {},
|
"gpu_temp": {},
|
||||||
@@ -266,7 +237,6 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
|
|||||||
expectedConfig: &SensorConfig{
|
expectedConfig: &SensorConfig{
|
||||||
context: context.Background(),
|
context: context.Background(),
|
||||||
primarySensor: "cpu_temp",
|
primarySensor: "cpu_temp",
|
||||||
timeout: 2 * time.Second,
|
|
||||||
sensors: map[string]struct{}{
|
sensors: map[string]struct{}{
|
||||||
"cpu_temp": {},
|
"cpu_temp": {},
|
||||||
"gpu_temp": {},
|
"gpu_temp": {},
|
||||||
@@ -283,7 +253,6 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
|
|||||||
expectedConfig: &SensorConfig{
|
expectedConfig: &SensorConfig{
|
||||||
context: context.Background(),
|
context: context.Background(),
|
||||||
primarySensor: "cpu_temp",
|
primarySensor: "cpu_temp",
|
||||||
timeout: 2 * time.Second,
|
|
||||||
sensors: map[string]struct{}{
|
sensors: map[string]struct{}{
|
||||||
"cpu_*": {},
|
"cpu_*": {},
|
||||||
"gpu_temp": {},
|
"gpu_temp": {},
|
||||||
@@ -300,7 +269,6 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
|
|||||||
expectedConfig: &SensorConfig{
|
expectedConfig: &SensorConfig{
|
||||||
context: context.Background(),
|
context: context.Background(),
|
||||||
primarySensor: "cpu_temp",
|
primarySensor: "cpu_temp",
|
||||||
timeout: 2 * time.Second,
|
|
||||||
sensors: map[string]struct{}{
|
sensors: map[string]struct{}{
|
||||||
"cpu_*": {},
|
"cpu_*": {},
|
||||||
"gpu_temp": {},
|
"gpu_temp": {},
|
||||||
@@ -316,7 +284,6 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
|
|||||||
sensors: "cpu_temp",
|
sensors: "cpu_temp",
|
||||||
expectedConfig: &SensorConfig{
|
expectedConfig: &SensorConfig{
|
||||||
primarySensor: "cpu_temp",
|
primarySensor: "cpu_temp",
|
||||||
timeout: 2 * time.Second,
|
|
||||||
sensors: map[string]struct{}{
|
sensors: map[string]struct{}{
|
||||||
"cpu_temp": {},
|
"cpu_temp": {},
|
||||||
},
|
},
|
||||||
@@ -328,7 +295,7 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
result := agent.newSensorConfigWithEnv(tt.primarySensor, tt.sysSensors, tt.sensors, tt.sensorsTimeout, tt.skipCollection)
|
result := agent.newSensorConfigWithEnv(tt.primarySensor, tt.sysSensors, tt.sensors, tt.skipCollection)
|
||||||
|
|
||||||
// Check primary sensor
|
// Check primary sensor
|
||||||
assert.Equal(t, tt.expectedConfig.primarySensor, result.primarySensor)
|
assert.Equal(t, tt.expectedConfig.primarySensor, result.primarySensor)
|
||||||
@@ -347,7 +314,6 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
|
|||||||
// Check flags
|
// Check flags
|
||||||
assert.Equal(t, tt.expectedConfig.isBlacklist, result.isBlacklist)
|
assert.Equal(t, tt.expectedConfig.isBlacklist, result.isBlacklist)
|
||||||
assert.Equal(t, tt.expectedConfig.hasWildcards, result.hasWildcards)
|
assert.Equal(t, tt.expectedConfig.hasWildcards, result.hasWildcards)
|
||||||
assert.Equal(t, tt.expectedConfig.timeout, result.timeout)
|
|
||||||
|
|
||||||
// Check context
|
// Check context
|
||||||
if tt.sysSensors != "" {
|
if tt.sysSensors != "" {
|
||||||
@@ -363,18 +329,40 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestNewSensorConfig(t *testing.T) {
|
func TestNewSensorConfig(t *testing.T) {
|
||||||
|
// Save original environment variables
|
||||||
|
originalPrimary, hasPrimary := os.LookupEnv("BESZEL_AGENT_PRIMARY_SENSOR")
|
||||||
|
originalSys, hasSys := os.LookupEnv("BESZEL_AGENT_SYS_SENSORS")
|
||||||
|
originalSensors, hasSensors := os.LookupEnv("BESZEL_AGENT_SENSORS")
|
||||||
|
|
||||||
|
// Restore environment variables after the test
|
||||||
|
defer func() {
|
||||||
|
// Clean up test environment variables
|
||||||
|
os.Unsetenv("BESZEL_AGENT_PRIMARY_SENSOR")
|
||||||
|
os.Unsetenv("BESZEL_AGENT_SYS_SENSORS")
|
||||||
|
os.Unsetenv("BESZEL_AGENT_SENSORS")
|
||||||
|
|
||||||
|
// Restore original values if they existed
|
||||||
|
if hasPrimary {
|
||||||
|
os.Setenv("BESZEL_AGENT_PRIMARY_SENSOR", originalPrimary)
|
||||||
|
}
|
||||||
|
if hasSys {
|
||||||
|
os.Setenv("BESZEL_AGENT_SYS_SENSORS", originalSys)
|
||||||
|
}
|
||||||
|
if hasSensors {
|
||||||
|
os.Setenv("BESZEL_AGENT_SENSORS", originalSensors)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
// Set test environment variables
|
// Set test environment variables
|
||||||
t.Setenv("BESZEL_AGENT_PRIMARY_SENSOR", "test_primary")
|
os.Setenv("BESZEL_AGENT_PRIMARY_SENSOR", "test_primary")
|
||||||
t.Setenv("BESZEL_AGENT_SYS_SENSORS", "/test/path")
|
os.Setenv("BESZEL_AGENT_SYS_SENSORS", "/test/path")
|
||||||
t.Setenv("BESZEL_AGENT_SENSORS", "test_sensor1,test_*,test_sensor3")
|
os.Setenv("BESZEL_AGENT_SENSORS", "test_sensor1,test_*,test_sensor3")
|
||||||
t.Setenv("BESZEL_AGENT_SENSORS_TIMEOUT", "7s")
|
|
||||||
|
|
||||||
agent := &Agent{}
|
agent := &Agent{}
|
||||||
result := agent.newSensorConfig()
|
result := agent.newSensorConfig()
|
||||||
|
|
||||||
// Verify results
|
// Verify results
|
||||||
assert.Equal(t, "test_primary", result.primarySensor)
|
assert.Equal(t, "test_primary", result.primarySensor)
|
||||||
assert.Equal(t, 7*time.Second, result.timeout)
|
|
||||||
assert.NotNil(t, result.sensors)
|
assert.NotNil(t, result.sensors)
|
||||||
assert.Equal(t, 3, len(result.sensors))
|
assert.Equal(t, 3, len(result.sensors))
|
||||||
assert.True(t, result.hasWildcards)
|
assert.True(t, result.hasWildcards)
|
||||||
@@ -563,59 +551,3 @@ func TestGetTempsWithPanicRecovery(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetTempsWithTimeout(t *testing.T) {
|
|
||||||
agent := &Agent{
|
|
||||||
sensorConfig: &SensorConfig{
|
|
||||||
context: context.Background(),
|
|
||||||
timeout: 10 * time.Millisecond,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Run("returns temperatures before timeout", func(t *testing.T) {
|
|
||||||
temps, err := agent.getTempsWithTimeout(func(ctx context.Context) ([]sensors.TemperatureStat, error) {
|
|
||||||
return []sensors.TemperatureStat{{SensorKey: "cpu_temp", Temperature: 42}}, nil
|
|
||||||
})
|
|
||||||
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, temps, 1)
|
|
||||||
assert.Equal(t, "cpu_temp", temps[0].SensorKey)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("returns timeout error when collector hangs", func(t *testing.T) {
|
|
||||||
temps, err := agent.getTempsWithTimeout(func(ctx context.Context) ([]sensors.TemperatureStat, error) {
|
|
||||||
time.Sleep(50 * time.Millisecond)
|
|
||||||
return []sensors.TemperatureStat{{SensorKey: "cpu_temp", Temperature: 42}}, nil
|
|
||||||
})
|
|
||||||
|
|
||||||
assert.Nil(t, temps)
|
|
||||||
assert.ErrorIs(t, err, errTemperatureFetchTimeout)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpdateTemperaturesSkipsOnTimeout(t *testing.T) {
|
|
||||||
agent := &Agent{
|
|
||||||
systemInfo: system.Info{DashboardTemp: 99},
|
|
||||||
sensorConfig: &SensorConfig{
|
|
||||||
context: context.Background(),
|
|
||||||
timeout: 10 * time.Millisecond,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Cleanup(func() {
|
|
||||||
getSensorTemps = sensors.TemperaturesWithContext
|
|
||||||
})
|
|
||||||
getSensorTemps = func(ctx context.Context) ([]sensors.TemperatureStat, error) {
|
|
||||||
time.Sleep(50 * time.Millisecond)
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
stats := &system.Stats{
|
|
||||||
Temperatures: map[string]float64{"stale": 50},
|
|
||||||
}
|
|
||||||
|
|
||||||
agent.updateTemperatures(stats)
|
|
||||||
|
|
||||||
assert.Equal(t, 0.0, agent.systemInfo.DashboardTemp)
|
|
||||||
assert.Equal(t, map[string]float64{}, stats.Temperatures)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel"
|
"github.com/henrygd/beszel"
|
||||||
"github.com/henrygd/beszel/agent/utils"
|
|
||||||
"github.com/henrygd/beszel/internal/common"
|
"github.com/henrygd/beszel/internal/common"
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
|
||||||
@@ -29,12 +28,15 @@ type ServerOptions struct {
|
|||||||
Keys []gossh.PublicKey // SSH public keys for authentication
|
Keys []gossh.PublicKey // SSH public keys for authentication
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hubVersions caches hub versions by session ID to avoid repeated parsing.
|
||||||
|
var hubVersions map[string]semver.Version
|
||||||
|
|
||||||
// StartServer starts the SSH server with the provided options.
|
// StartServer starts the SSH server with the provided options.
|
||||||
// It configures the server with secure defaults, sets up authentication,
|
// It configures the server with secure defaults, sets up authentication,
|
||||||
// and begins listening for connections. Returns an error if the server
|
// and begins listening for connections. Returns an error if the server
|
||||||
// is already running or if there's an issue starting the server.
|
// is already running or if there's an issue starting the server.
|
||||||
func (a *Agent) StartServer(opts ServerOptions) error {
|
func (a *Agent) StartServer(opts ServerOptions) error {
|
||||||
if disableSSH, _ := utils.GetEnv("DISABLE_SSH"); disableSSH == "true" {
|
if disableSSH, _ := GetEnv("DISABLE_SSH"); disableSSH == "true" {
|
||||||
return errors.New("SSH disabled")
|
return errors.New("SSH disabled")
|
||||||
}
|
}
|
||||||
if a.server != nil {
|
if a.server != nil {
|
||||||
@@ -96,15 +98,24 @@ func (a *Agent) StartServer(opts ServerOptions) error {
|
|||||||
return a.server.Serve(ln)
|
return a.server.Serve(ln)
|
||||||
}
|
}
|
||||||
|
|
||||||
// getHubVersion extracts the hub version from the SSH client version string
|
// getHubVersion retrieves and caches the hub version for a given session.
|
||||||
// for a given session. Returns a zero version if parsing fails.
|
// It extracts the version from the SSH client version string and caches
|
||||||
func (a *Agent) getHubVersion(sessionCtx ssh.Context) semver.Version {
|
// it to avoid repeated parsing. Returns a zero version if parsing fails.
|
||||||
clientVersion := sessionCtx.Value(ssh.ContextKeyClientVersion)
|
func (a *Agent) getHubVersion(sessionId string, sessionCtx ssh.Context) semver.Version {
|
||||||
if versionStr, ok := clientVersion.(string); ok {
|
if hubVersions == nil {
|
||||||
hubVersion, _ := extractHubVersion(versionStr)
|
hubVersions = make(map[string]semver.Version, 1)
|
||||||
|
}
|
||||||
|
hubVersion, ok := hubVersions[sessionId]
|
||||||
|
if ok {
|
||||||
return hubVersion
|
return hubVersion
|
||||||
}
|
}
|
||||||
return semver.Version{}
|
// Extract hub version from SSH client version
|
||||||
|
clientVersion := sessionCtx.Value(ssh.ContextKeyClientVersion)
|
||||||
|
if versionStr, ok := clientVersion.(string); ok {
|
||||||
|
hubVersion, _ = extractHubVersion(versionStr)
|
||||||
|
}
|
||||||
|
hubVersions[sessionId] = hubVersion
|
||||||
|
return hubVersion
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleSession handles an incoming SSH session by gathering system statistics
|
// handleSession handles an incoming SSH session by gathering system statistics
|
||||||
@@ -115,8 +126,9 @@ func (a *Agent) handleSession(s ssh.Session) {
|
|||||||
a.connectionManager.eventChan <- SSHConnect
|
a.connectionManager.eventChan <- SSHConnect
|
||||||
|
|
||||||
sessionCtx := s.Context()
|
sessionCtx := s.Context()
|
||||||
|
sessionID := sessionCtx.SessionID()
|
||||||
|
|
||||||
hubVersion := a.getHubVersion(sessionCtx)
|
hubVersion := a.getHubVersion(sessionID, sessionCtx)
|
||||||
|
|
||||||
// Legacy one-shot behavior for older hubs
|
// Legacy one-shot behavior for older hubs
|
||||||
if hubVersion.LT(beszel.MinVersionAgentResponse) {
|
if hubVersion.LT(beszel.MinVersionAgentResponse) {
|
||||||
@@ -180,7 +192,7 @@ func (a *Agent) handleSSHRequest(w io.Writer, req *common.HubRequest[cbor.RawMes
|
|||||||
|
|
||||||
// handleLegacyStats serves the legacy one-shot stats payload for older hubs
|
// handleLegacyStats serves the legacy one-shot stats payload for older hubs
|
||||||
func (a *Agent) handleLegacyStats(w io.Writer, hubVersion semver.Version) error {
|
func (a *Agent) handleLegacyStats(w io.Writer, hubVersion semver.Version) error {
|
||||||
stats := a.gatherStats(common.DataRequestOptions{CacheTimeMs: defaultDataCacheTimeMs})
|
stats := a.gatherStats(common.DataRequestOptions{CacheTimeMs: 60_000})
|
||||||
return a.writeToSession(w, stats, hubVersion)
|
return a.writeToSession(w, stats, hubVersion)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,11 +238,11 @@ func ParseKeys(input string) ([]gossh.PublicKey, error) {
|
|||||||
// and finally defaults to ":45876".
|
// and finally defaults to ":45876".
|
||||||
func GetAddress(addr string) string {
|
func GetAddress(addr string) string {
|
||||||
if addr == "" {
|
if addr == "" {
|
||||||
addr, _ = utils.GetEnv("LISTEN")
|
addr, _ = GetEnv("LISTEN")
|
||||||
}
|
}
|
||||||
if addr == "" {
|
if addr == "" {
|
||||||
// Legacy PORT environment variable support
|
// Legacy PORT environment variable support
|
||||||
addr, _ = utils.GetEnv("PORT")
|
addr, _ = GetEnv("PORT")
|
||||||
}
|
}
|
||||||
if addr == "" {
|
if addr == "" {
|
||||||
return ":45876"
|
return ":45876"
|
||||||
@@ -246,7 +258,7 @@ func GetAddress(addr string) string {
|
|||||||
// It checks the NETWORK environment variable first, then infers from
|
// It checks the NETWORK environment variable first, then infers from
|
||||||
// the address format: addresses starting with "/" are "unix", others are "tcp".
|
// the address format: addresses starting with "/" are "unix", others are "tcp".
|
||||||
func GetNetwork(addr string) string {
|
func GetNetwork(addr string) string {
|
||||||
if network, ok := utils.GetEnv("NETWORK"); ok && network != "" {
|
if network, ok := GetEnv("NETWORK"); ok && network != "" {
|
||||||
return network
|
return network
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(addr, "/") {
|
if strings.HasPrefix(addr, "/") {
|
||||||
|
|||||||
@@ -183,7 +183,8 @@ func TestStartServer(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestStartServerDisableSSH(t *testing.T) {
|
func TestStartServerDisableSSH(t *testing.T) {
|
||||||
t.Setenv("BESZEL_AGENT_DISABLE_SSH", "true")
|
os.Setenv("BESZEL_AGENT_DISABLE_SSH", "true")
|
||||||
|
defer os.Unsetenv("BESZEL_AGENT_DISABLE_SSH")
|
||||||
|
|
||||||
agent, err := NewAgent("")
|
agent, err := NewAgent("")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -404,23 +405,27 @@ func TestGetHubVersion(t *testing.T) {
|
|||||||
clientVersion: "SSH-2.0-beszel_0.12.0",
|
clientVersion: "SSH-2.0-beszel_0.12.0",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test first call - should extract version
|
// Test first call - should extract and cache version
|
||||||
version := agent.getHubVersion(mockCtx)
|
version := agent.getHubVersion("test-session-123", mockCtx)
|
||||||
assert.Equal(t, "0.12.0", version.String())
|
assert.Equal(t, "0.12.0", version.String())
|
||||||
|
|
||||||
// Test that version reflects the current client version (no stale caching)
|
// Test second call - should return cached version
|
||||||
mockCtx.clientVersion = "SSH-2.0-beszel_0.11.0"
|
mockCtx.clientVersion = "SSH-2.0-beszel_0.11.0" // Change version but should still return cached
|
||||||
version = agent.getHubVersion(mockCtx)
|
version = agent.getHubVersion("test-session-123", mockCtx)
|
||||||
|
assert.Equal(t, "0.12.0", version.String()) // Should still be cached version
|
||||||
|
|
||||||
|
// Test different session - should extract new version
|
||||||
|
version = agent.getHubVersion("different-session", mockCtx)
|
||||||
assert.Equal(t, "0.11.0", version.String())
|
assert.Equal(t, "0.11.0", version.String())
|
||||||
|
|
||||||
// Test with invalid version string (non-beszel client)
|
// Test with invalid version string (non-beszel client)
|
||||||
mockCtx.clientVersion = "SSH-2.0-OpenSSH_8.0"
|
mockCtx.clientVersion = "SSH-2.0-OpenSSH_8.0"
|
||||||
version = agent.getHubVersion(mockCtx)
|
version = agent.getHubVersion("invalid-session", mockCtx)
|
||||||
assert.Equal(t, "0.0.0", version.String()) // Should be empty version for non-beszel clients
|
assert.Equal(t, "0.0.0", version.String()) // Should be empty version for non-beszel clients
|
||||||
|
|
||||||
// Test with no client version
|
// Test with no client version
|
||||||
mockCtx.clientVersion = ""
|
mockCtx.clientVersion = ""
|
||||||
version = agent.getHubVersion(mockCtx)
|
version = agent.getHubVersion("no-version-session", mockCtx)
|
||||||
assert.True(t, version.EQ(semver.Version{})) // Should be empty version
|
assert.True(t, version.EQ(semver.Version{})) // Should be empty version
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -497,6 +502,9 @@ func TestWriteToSessionEncoding(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// Reset the global hubVersions map to ensure clean state for each test
|
||||||
|
hubVersions = nil
|
||||||
|
|
||||||
agent, err := NewAgent("")
|
agent, err := NewAgent("")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
@@ -551,6 +559,10 @@ func TestWriteToSessionEncoding(t *testing.T) {
|
|||||||
|
|
||||||
// Helper function to create test data for encoding tests
|
// Helper function to create test data for encoding tests
|
||||||
func createTestCombinedData() *system.CombinedData {
|
func createTestCombinedData() *system.CombinedData {
|
||||||
|
var stats = container.Stats{}
|
||||||
|
stats.Name = "test-container"
|
||||||
|
stats.Cpu = 10.5
|
||||||
|
stats.Mem = 1073741824 // 1GB
|
||||||
return &system.CombinedData{
|
return &system.CombinedData{
|
||||||
Stats: system.Stats{
|
Stats: system.Stats{
|
||||||
Cpu: 25.5,
|
Cpu: 25.5,
|
||||||
@@ -569,37 +581,44 @@ func createTestCombinedData() *system.CombinedData {
|
|||||||
AgentVersion: "0.12.0",
|
AgentVersion: "0.12.0",
|
||||||
},
|
},
|
||||||
Containers: []*container.Stats{
|
Containers: []*container.Stats{
|
||||||
{
|
&stats,
|
||||||
Name: "test-container",
|
|
||||||
Cpu: 10.5,
|
|
||||||
Mem: 1073741824, // 1GB
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestGetHubVersionConcurrent guards against a regression of the
|
func TestHubVersionCaching(t *testing.T) {
|
||||||
// "concurrent map writes" panic previously caused by a shared, unsynchronized
|
// Reset the global hubVersions map to ensure clean state
|
||||||
// hubVersions cache (see https://github.com/henrygd/beszel/issues/2128).
|
hubVersions = nil
|
||||||
// getHubVersion no longer shares mutable state between sessions, so calling
|
|
||||||
// it concurrently from many goroutines must be safe under `go test -race`.
|
|
||||||
func TestGetHubVersionConcurrent(t *testing.T) {
|
|
||||||
agent, err := NewAgent("")
|
agent, err := NewAgent("")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
const goroutines = 50
|
ctx1 := &mockSSHContext{
|
||||||
var wg sync.WaitGroup
|
sessionID: "session1",
|
||||||
wg.Add(goroutines)
|
clientVersion: "SSH-2.0-beszel_0.12.0",
|
||||||
for i := 0; i < goroutines; i++ {
|
|
||||||
go func(i int) {
|
|
||||||
defer wg.Done()
|
|
||||||
ctx := &mockSSHContext{
|
|
||||||
sessionID: fmt.Sprintf("session-%d", i),
|
|
||||||
clientVersion: "SSH-2.0-beszel_0.12.0",
|
|
||||||
}
|
|
||||||
version := agent.getHubVersion(ctx)
|
|
||||||
assert.Equal(t, "0.12.0", version.String())
|
|
||||||
}(i)
|
|
||||||
}
|
}
|
||||||
wg.Wait()
|
ctx2 := &mockSSHContext{
|
||||||
|
sessionID: "session2",
|
||||||
|
clientVersion: "SSH-2.0-beszel_0.11.0",
|
||||||
|
}
|
||||||
|
|
||||||
|
// First calls should cache the versions
|
||||||
|
v1 := agent.getHubVersion("session1", ctx1)
|
||||||
|
v2 := agent.getHubVersion("session2", ctx2)
|
||||||
|
|
||||||
|
assert.Equal(t, "0.12.0", v1.String())
|
||||||
|
assert.Equal(t, "0.11.0", v2.String())
|
||||||
|
|
||||||
|
// Verify caching by changing context but keeping same session ID
|
||||||
|
ctx1.clientVersion = "SSH-2.0-beszel_0.10.0"
|
||||||
|
v1Cached := agent.getHubVersion("session1", ctx1)
|
||||||
|
assert.Equal(t, "0.12.0", v1Cached.String()) // Should still be cached version
|
||||||
|
|
||||||
|
// New session should get new version
|
||||||
|
ctx3 := &mockSSHContext{
|
||||||
|
sessionID: "session3",
|
||||||
|
clientVersion: "SSH-2.0-beszel_0.13.0",
|
||||||
|
}
|
||||||
|
v3 := agent.getHubVersion("session3", ctx3)
|
||||||
|
assert.Equal(t, "0.13.0", v3.String())
|
||||||
}
|
}
|
||||||
|
|||||||
238
agent/smart.go
238
agent/smart.go
@@ -18,22 +18,18 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/agent/utils"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/smart"
|
"github.com/henrygd/beszel/internal/entities/smart"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SmartManager manages data collection for SMART devices
|
// SmartManager manages data collection for SMART devices
|
||||||
type SmartManager struct {
|
type SmartManager struct {
|
||||||
sync.Mutex
|
sync.Mutex
|
||||||
SmartDataMap map[string]*smart.SmartData
|
SmartDataMap map[string]*smart.SmartData
|
||||||
SmartDevices []*DeviceInfo
|
SmartDevices []*DeviceInfo
|
||||||
refreshMutex sync.Mutex
|
refreshMutex sync.Mutex
|
||||||
lastScanTime time.Time
|
lastScanTime time.Time
|
||||||
smartctlPath string
|
smartctlPath string
|
||||||
excludedDevices map[string]struct{}
|
excludedDevices map[string]struct{}
|
||||||
darwinNvmeOnce sync.Once
|
|
||||||
darwinNvmeCapacity map[string]uint64 // serial → bytes cache, written once via darwinNvmeOnce
|
|
||||||
darwinNvmeProvider func() ([]byte, error) // overridable for testing
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type scanOutput struct {
|
type scanOutput struct {
|
||||||
@@ -55,11 +51,6 @@ type DeviceInfo struct {
|
|||||||
typeVerified bool
|
typeVerified bool
|
||||||
// parserType holds the parser type (nvme, sat, scsi) that last succeeded.
|
// parserType holds the parser type (nvme, sat, scsi) that last succeeded.
|
||||||
parserType string
|
parserType string
|
||||||
// explicitType reports whether Type came from an explicit ":type" hint in
|
|
||||||
// SMART_DEVICES. Such a type is a deliberate user override and must always be
|
|
||||||
// passed to smartctl via -d, even for scsi/ata where a scan-detected type is
|
|
||||||
// otherwise left off (see smartctlArgs and issue #1345).
|
|
||||||
explicitType bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// deviceKey is a composite key for a device, used to identify a device uniquely.
|
// deviceKey is a composite key for a device, used to identify a device uniquely.
|
||||||
@@ -70,9 +61,8 @@ type deviceKey struct {
|
|||||||
|
|
||||||
var errNoValidSmartData = fmt.Errorf("no valid SMART data found") // Error for missing data
|
var errNoValidSmartData = fmt.Errorf("no valid SMART data found") // Error for missing data
|
||||||
|
|
||||||
// Refresh updates SMART data for all known devices and reports whether every
|
// Refresh updates SMART data for all known devices
|
||||||
// discovered device was collected successfully.
|
func (sm *SmartManager) Refresh(forceScan bool) error {
|
||||||
func (sm *SmartManager) Refresh(forceScan bool) (bool, error) {
|
|
||||||
sm.refreshMutex.Lock()
|
sm.refreshMutex.Lock()
|
||||||
defer sm.refreshMutex.Unlock()
|
defer sm.refreshMutex.Unlock()
|
||||||
|
|
||||||
@@ -93,7 +83,7 @@ func (sm *SmartManager) Refresh(forceScan bool) (bool, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return scanErr == nil && collectErr == nil, sm.resolveRefreshError(scanErr, collectErr)
|
return sm.resolveRefreshError(scanErr, collectErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// devicesSnapshot returns a copy of the current device slice to avoid iterating
|
// devicesSnapshot returns a copy of the current device slice to avoid iterating
|
||||||
@@ -166,7 +156,7 @@ func (sm *SmartManager) ScanDevices(force bool) error {
|
|||||||
currentDevices := sm.devicesSnapshot()
|
currentDevices := sm.devicesSnapshot()
|
||||||
|
|
||||||
var configuredDevices []*DeviceInfo
|
var configuredDevices []*DeviceInfo
|
||||||
if configuredRaw, ok := utils.GetEnv("SMART_DEVICES"); ok {
|
if configuredRaw, ok := GetEnv("SMART_DEVICES"); ok {
|
||||||
slog.Info("SMART_DEVICES", "value", configuredRaw)
|
slog.Info("SMART_DEVICES", "value", configuredRaw)
|
||||||
config := strings.TrimSpace(configuredRaw)
|
config := strings.TrimSpace(configuredRaw)
|
||||||
if config == "" {
|
if config == "" {
|
||||||
@@ -232,7 +222,7 @@ func (sm *SmartManager) ScanDevices(force bool) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (sm *SmartManager) parseConfiguredDevices(config string) ([]*DeviceInfo, error) {
|
func (sm *SmartManager) parseConfiguredDevices(config string) ([]*DeviceInfo, error) {
|
||||||
splitChar, _ := utils.GetEnv("SMART_DEVICES_SEPARATOR")
|
splitChar := os.Getenv("SMART_DEVICES_SEPARATOR")
|
||||||
if splitChar == "" {
|
if splitChar == "" {
|
||||||
splitChar = ","
|
splitChar = ","
|
||||||
}
|
}
|
||||||
@@ -257,9 +247,8 @@ func (sm *SmartManager) parseConfiguredDevices(config string) ([]*DeviceInfo, er
|
|||||||
}
|
}
|
||||||
|
|
||||||
devices = append(devices, &DeviceInfo{
|
devices = append(devices, &DeviceInfo{
|
||||||
Name: name,
|
Name: name,
|
||||||
Type: devType,
|
Type: devType,
|
||||||
explicitType: devType != "",
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,7 +260,7 @@ func (sm *SmartManager) parseConfiguredDevices(config string) ([]*DeviceInfo, er
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (sm *SmartManager) refreshExcludedDevices() {
|
func (sm *SmartManager) refreshExcludedDevices() {
|
||||||
rawValue, _ := utils.GetEnv("EXCLUDE_SMART")
|
rawValue, _ := GetEnv("EXCLUDE_SMART")
|
||||||
sm.excludedDevices = make(map[string]struct{})
|
sm.excludedDevices = make(map[string]struct{})
|
||||||
|
|
||||||
for entry := range strings.SplitSeq(rawValue, ",") {
|
for entry := range strings.SplitSeq(rawValue, ",") {
|
||||||
@@ -375,15 +364,9 @@ func (sm *SmartManager) parseSmartOutput(deviceInfo *DeviceInfo, output []byte)
|
|||||||
Type string
|
Type string
|
||||||
Parse func([]byte) (bool, int)
|
Parse func([]byte) (bool, int)
|
||||||
}{
|
}{
|
||||||
{Type: "nvme", Parse: func(output []byte) (bool, int) {
|
{Type: "nvme", Parse: sm.parseSmartForNvme},
|
||||||
return sm.parseSmartForNvme(output, deviceInfo.Type)
|
{Type: "sat", Parse: sm.parseSmartForSata},
|
||||||
}},
|
{Type: "scsi", Parse: sm.parseSmartForScsi},
|
||||||
{Type: "sat", Parse: func(output []byte) (bool, int) {
|
|
||||||
return sm.parseSmartForSata(output, deviceInfo.Type)
|
|
||||||
}},
|
|
||||||
{Type: "scsi", Parse: func(output []byte) (bool, int) {
|
|
||||||
return sm.parseSmartForScsi(output, deviceInfo.Type)
|
|
||||||
}},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
deviceType := normalizeParserType(deviceInfo.parserType)
|
deviceType := normalizeParserType(deviceInfo.parserType)
|
||||||
@@ -492,11 +475,10 @@ func (sm *SmartManager) CollectSmart(deviceInfo *DeviceInfo) error {
|
|||||||
return errNoValidSmartData
|
return errNoValidSmartData
|
||||||
}
|
}
|
||||||
|
|
||||||
// slog.Info("collecting SMART data", "device", deviceInfo.Name, "type", deviceInfo.Type, "has_existing_data", sm.hasDataForDevice(deviceInfo))
|
// slog.Info("collecting SMART data", "device", deviceInfo.Name, "type", deviceInfo.Type, "has_existing_data", sm.hasDataForDevice(deviceInfo.Name))
|
||||||
|
|
||||||
// Check if we have existing data for this exact device identity. Multiple
|
// Check if we have any existing data for this device
|
||||||
// bridge slots can share a path, so a name-only match is not sufficient.
|
hasExistingData := sm.hasDataForDevice(deviceInfo.Name)
|
||||||
hasExistingData := sm.hasDataForDevice(deviceInfo)
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -572,9 +554,7 @@ func (sm *SmartManager) smartctlArgs(deviceInfo *DeviceInfo, includeStandby bool
|
|||||||
deviceType = strings.ToLower(deviceInfo.Type)
|
deviceType = strings.ToLower(deviceInfo.Type)
|
||||||
parserType = strings.ToLower(deviceInfo.parserType)
|
parserType = strings.ToLower(deviceInfo.parserType)
|
||||||
// types sometimes misidentified in scan; see github.com/henrygd/beszel/issues/1345
|
// types sometimes misidentified in scan; see github.com/henrygd/beszel/issues/1345
|
||||||
// An explicit SMART_DEVICES ":type" hint is a deliberate override, so always
|
if deviceType != "" && deviceType != "scsi" && deviceType != "ata" {
|
||||||
// pass it through; otherwise scsi/ata are left off so smartctl can auto-detect.
|
|
||||||
if deviceType != "" && (deviceInfo.explicitType || (deviceType != "scsi" && deviceType != "ata")) {
|
|
||||||
args = append(args, "-d", deviceInfo.Type)
|
args = append(args, "-d", deviceInfo.Type)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -599,18 +579,14 @@ func (sm *SmartManager) smartctlArgs(deviceInfo *DeviceInfo, includeStandby bool
|
|||||||
return args
|
return args
|
||||||
}
|
}
|
||||||
|
|
||||||
// hasDataForDevice checks if we have cached SMART data for a specific device identity.
|
// hasDataForDevice checks if we have cached SMART data for a specific device
|
||||||
func (sm *SmartManager) hasDataForDevice(deviceInfo *DeviceInfo) bool {
|
func (sm *SmartManager) hasDataForDevice(deviceName string) bool {
|
||||||
if deviceInfo == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
sm.Lock()
|
sm.Lock()
|
||||||
defer sm.Unlock()
|
defer sm.Unlock()
|
||||||
|
|
||||||
deviceKey := makeDeviceKey(deviceInfo.Name, deviceInfo.Type)
|
// Check if any cached data has this device name
|
||||||
for _, data := range sm.SmartDataMap {
|
for _, data := range sm.SmartDataMap {
|
||||||
if data != nil && makeDeviceKey(data.DiskName, data.DiskType) == deviceKey {
|
if data != nil && data.DiskName == deviceName {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -683,9 +659,6 @@ func mergeDeviceLists(existing, scanned, configured []*DeviceInfo) []*DeviceInfo
|
|||||||
target.Type = prev.Type
|
target.Type = prev.Type
|
||||||
target.typeVerified = true
|
target.typeVerified = true
|
||||||
target.parserType = prev.parserType
|
target.parserType = prev.parserType
|
||||||
if prev.explicitType {
|
|
||||||
target.explicitType = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// applyConfiguredMetadata updates a matched device with any configured
|
// applyConfiguredMetadata updates a matched device with any configured
|
||||||
@@ -699,9 +672,6 @@ func mergeDeviceLists(existing, scanned, configured []*DeviceInfo) []*DeviceInfo
|
|||||||
existingDev.typeVerified = false
|
existingDev.typeVerified = false
|
||||||
existingDev.parserType = normalizeParserType(newType)
|
existingDev.parserType = normalizeParserType(newType)
|
||||||
}
|
}
|
||||||
if configuredDev.explicitType {
|
|
||||||
existingDev.explicitType = true
|
|
||||||
}
|
|
||||||
if configuredDev.InfoName != "" {
|
if configuredDev.InfoName != "" {
|
||||||
existingDev.InfoName = configuredDev.InfoName
|
existingDev.InfoName = configuredDev.InfoName
|
||||||
}
|
}
|
||||||
@@ -758,14 +728,7 @@ func mergeDeviceLists(existing, scanned, configured []*DeviceInfo) []*DeviceInfo
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if existingDev := deviceIndexByName[configuredDevice.Name]; existingDev != nil {
|
if existingDev := deviceIndexByName[configuredDevice.Name]; existingDev != nil {
|
||||||
oldKey := makeDeviceKey(existingDev.Name, existingDev.Type)
|
|
||||||
if prev := existingIndex[key]; prev != nil {
|
|
||||||
preserveVerifiedType(existingDev, prev)
|
|
||||||
}
|
|
||||||
applyConfiguredMetadata(existingDev, configuredDevice)
|
applyConfiguredMetadata(existingDev, configuredDevice)
|
||||||
delete(deviceIndex, oldKey)
|
|
||||||
deviceIndex[makeDeviceKey(existingDev.Name, existingDev.Type)] = existingDev
|
|
||||||
delete(deviceIndexByName, configuredDevice.Name)
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -869,11 +832,9 @@ func (sm *SmartManager) isVirtualDeviceFromStrings(fields ...string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseSmartForSata parses the output of smartctl --all -j for SATA/ATA devices and updates the SmartDataMap.
|
// parseSmartForSata parses the output of smartctl --all -j for SATA/ATA devices and updates the SmartDataMap
|
||||||
// deviceType is the exact type used to identify and query the device; when set,
|
|
||||||
// it takes precedence over the generic type reported by smartctl.
|
|
||||||
// Returns hasValidData and exitStatus
|
// Returns hasValidData and exitStatus
|
||||||
func (sm *SmartManager) parseSmartForSata(output []byte, deviceType string) (bool, int) {
|
func (sm *SmartManager) parseSmartForSata(output []byte) (bool, int) {
|
||||||
var data smart.SmartInfoForSata
|
var data smart.SmartInfoForSata
|
||||||
|
|
||||||
if err := json.Unmarshal(output, &data); err != nil {
|
if err := json.Unmarshal(output, &data); err != nil {
|
||||||
@@ -909,20 +870,14 @@ func (sm *SmartManager) parseSmartForSata(output []byte, deviceType string) (boo
|
|||||||
smartData.FirmwareVersion = data.FirmwareVersion
|
smartData.FirmwareVersion = data.FirmwareVersion
|
||||||
smartData.Capacity = data.UserCapacity.Bytes
|
smartData.Capacity = data.UserCapacity.Bytes
|
||||||
smartData.Temperature = data.Temperature.Current
|
smartData.Temperature = data.Temperature.Current
|
||||||
|
if smartData.Temperature == 0 {
|
||||||
|
if temp, ok := temperatureFromAtaDeviceStatistics(data.AtaDeviceStatistics); ok {
|
||||||
|
smartData.Temperature = temp
|
||||||
|
}
|
||||||
|
}
|
||||||
smartData.SmartStatus = getSmartStatus(smartData.Temperature, data.SmartStatus.Passed)
|
smartData.SmartStatus = getSmartStatus(smartData.Temperature, data.SmartStatus.Passed)
|
||||||
smartData.DiskName = data.Device.Name
|
smartData.DiskName = data.Device.Name
|
||||||
smartData.DiskType = data.Device.Type
|
smartData.DiskType = data.Device.Type
|
||||||
if deviceType != "" {
|
|
||||||
smartData.DiskType = deviceType
|
|
||||||
}
|
|
||||||
|
|
||||||
// get values from ata_device_statistics if necessary
|
|
||||||
var ataDeviceStats smart.AtaDeviceStatistics
|
|
||||||
if smartData.Temperature == 0 {
|
|
||||||
if temp := findAtaDeviceStatisticsValue(&data, &ataDeviceStats, 5, "Current Temperature", 0, 255); temp != nil {
|
|
||||||
smartData.Temperature = uint8(*temp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// update SmartAttributes
|
// update SmartAttributes
|
||||||
smartData.Attributes = make([]*smart.SmartAttribute, 0, len(data.AtaSmartAttributes.Table))
|
smartData.Attributes = make([]*smart.SmartAttribute, 0, len(data.AtaSmartAttributes.Table))
|
||||||
@@ -958,20 +913,23 @@ func getSmartStatus(temperature uint8, passed bool) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func temperatureFromAtaDeviceStatistics(stats smart.AtaDeviceStatistics) (uint8, bool) {
|
||||||
|
entry := findAtaDeviceStatisticsEntry(stats, 5, "Current Temperature")
|
||||||
|
if entry == nil || entry.Value == nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
if *entry.Value > 255 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return uint8(*entry.Value), true
|
||||||
|
}
|
||||||
|
|
||||||
// findAtaDeviceStatisticsEntry centralizes ATA devstat lookups so additional
|
// findAtaDeviceStatisticsEntry centralizes ATA devstat lookups so additional
|
||||||
// metrics can be pulled from the same structure in the future.
|
// metrics can be pulled from the same structure in the future.
|
||||||
func findAtaDeviceStatisticsValue(data *smart.SmartInfoForSata, ataDeviceStats *smart.AtaDeviceStatistics, entryNumber uint8, entryName string, minValue, maxValue int64) *int64 {
|
func findAtaDeviceStatisticsEntry(stats smart.AtaDeviceStatistics, pageNumber uint8, entryName string) *smart.AtaDeviceStatisticsEntry {
|
||||||
if len(ataDeviceStats.Pages) == 0 {
|
for pageIdx := range stats.Pages {
|
||||||
if len(data.AtaDeviceStatistics) == 0 {
|
page := &stats.Pages[pageIdx]
|
||||||
return nil
|
if page.Number != pageNumber {
|
||||||
}
|
|
||||||
if err := json.Unmarshal(data.AtaDeviceStatistics, ataDeviceStats); err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for pageIdx := range ataDeviceStats.Pages {
|
|
||||||
page := &ataDeviceStats.Pages[pageIdx]
|
|
||||||
if page.Number != entryNumber {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for entryIdx := range page.Table {
|
for entryIdx := range page.Table {
|
||||||
@@ -979,16 +937,13 @@ func findAtaDeviceStatisticsValue(data *smart.SmartInfoForSata, ataDeviceStats *
|
|||||||
if !strings.EqualFold(entry.Name, entryName) {
|
if !strings.EqualFold(entry.Name, entryName) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if entry.Value == nil || *entry.Value < minValue || *entry.Value > maxValue {
|
return entry
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return entry.Value
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sm *SmartManager) parseSmartForScsi(output []byte, deviceType string) (bool, int) {
|
func (sm *SmartManager) parseSmartForScsi(output []byte) (bool, int) {
|
||||||
var data smart.SmartInfoForScsi
|
var data smart.SmartInfoForScsi
|
||||||
|
|
||||||
if err := json.Unmarshal(output, &data); err != nil {
|
if err := json.Unmarshal(output, &data); err != nil {
|
||||||
@@ -1023,9 +978,6 @@ func (sm *SmartManager) parseSmartForScsi(output []byte, deviceType string) (boo
|
|||||||
smartData.SmartStatus = getSmartStatus(smartData.Temperature, data.SmartStatus.Passed)
|
smartData.SmartStatus = getSmartStatus(smartData.Temperature, data.SmartStatus.Passed)
|
||||||
smartData.DiskName = data.Device.Name
|
smartData.DiskName = data.Device.Name
|
||||||
smartData.DiskType = data.Device.Type
|
smartData.DiskType = data.Device.Type
|
||||||
if deviceType != "" {
|
|
||||||
smartData.DiskType = deviceType
|
|
||||||
}
|
|
||||||
|
|
||||||
attributes := make([]*smart.SmartAttribute, 0, 10)
|
attributes := make([]*smart.SmartAttribute, 0, 10)
|
||||||
attributes = append(attributes, &smart.SmartAttribute{Name: "PowerOnHours", RawValue: data.PowerOnTime.Hours})
|
attributes = append(attributes, &smart.SmartAttribute{Name: "PowerOnHours", RawValue: data.PowerOnTime.Hours})
|
||||||
@@ -1077,57 +1029,9 @@ func parseScsiGigabytesProcessed(value string) int64 {
|
|||||||
return parsed
|
return parsed
|
||||||
}
|
}
|
||||||
|
|
||||||
// lookupDarwinNvmeCapacity returns the capacity in bytes for a given NVMe serial number on Darwin.
|
// parseSmartForNvme parses the output of smartctl --all -j /dev/nvmeX and updates the SmartDataMap
|
||||||
// It uses system_profiler SPNVMeDataType to get capacity since Apple SSDs don't report user_capacity
|
|
||||||
// via smartctl. Results are cached after the first call via sync.Once.
|
|
||||||
func (sm *SmartManager) lookupDarwinNvmeCapacity(serial string) uint64 {
|
|
||||||
sm.darwinNvmeOnce.Do(func() {
|
|
||||||
sm.darwinNvmeCapacity = make(map[string]uint64)
|
|
||||||
|
|
||||||
provider := sm.darwinNvmeProvider
|
|
||||||
if provider == nil {
|
|
||||||
provider = func() ([]byte, error) {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
return exec.CommandContext(ctx, "system_profiler", "SPNVMeDataType", "-json").Output()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
out, err := provider()
|
|
||||||
if err != nil {
|
|
||||||
slog.Debug("system_profiler NVMe lookup failed", "err", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var result struct {
|
|
||||||
SPNVMeDataType []struct {
|
|
||||||
Items []struct {
|
|
||||||
DeviceSerial string `json:"device_serial"`
|
|
||||||
SizeInBytes uint64 `json:"size_in_bytes"`
|
|
||||||
} `json:"_items"`
|
|
||||||
} `json:"SPNVMeDataType"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(out, &result); err != nil {
|
|
||||||
slog.Debug("system_profiler NVMe parse failed", "err", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, controller := range result.SPNVMeDataType {
|
|
||||||
for _, item := range controller.Items {
|
|
||||||
if item.DeviceSerial != "" && item.SizeInBytes > 0 {
|
|
||||||
sm.darwinNvmeCapacity[item.DeviceSerial] = item.SizeInBytes
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return sm.darwinNvmeCapacity[serial]
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseSmartForNvme parses the output of smartctl --all -j /dev/nvmeX and updates the SmartDataMap.
|
|
||||||
// deviceType is the exact type used to identify and query the device; when set,
|
|
||||||
// it takes precedence over the generic type reported by smartctl.
|
|
||||||
// Returns hasValidData and exitStatus
|
// Returns hasValidData and exitStatus
|
||||||
func (sm *SmartManager) parseSmartForNvme(output []byte, deviceType string) (bool, int) {
|
func (sm *SmartManager) parseSmartForNvme(output []byte) (bool, int) {
|
||||||
data := &smart.SmartInfoForNvme{}
|
data := &smart.SmartInfoForNvme{}
|
||||||
|
|
||||||
if err := json.Unmarshal(output, &data); err != nil {
|
if err := json.Unmarshal(output, &data); err != nil {
|
||||||
@@ -1161,19 +1065,10 @@ func (sm *SmartManager) parseSmartForNvme(output []byte, deviceType string) (boo
|
|||||||
smartData.SerialNumber = data.SerialNumber
|
smartData.SerialNumber = data.SerialNumber
|
||||||
smartData.FirmwareVersion = data.FirmwareVersion
|
smartData.FirmwareVersion = data.FirmwareVersion
|
||||||
smartData.Capacity = data.UserCapacity.Bytes
|
smartData.Capacity = data.UserCapacity.Bytes
|
||||||
if smartData.Capacity == 0 {
|
|
||||||
smartData.Capacity = data.NVMeTotalCapacity
|
|
||||||
}
|
|
||||||
if smartData.Capacity == 0 && (runtime.GOOS == "darwin" || sm.darwinNvmeProvider != nil) {
|
|
||||||
smartData.Capacity = sm.lookupDarwinNvmeCapacity(data.SerialNumber)
|
|
||||||
}
|
|
||||||
smartData.Temperature = data.NVMeSmartHealthInformationLog.Temperature
|
smartData.Temperature = data.NVMeSmartHealthInformationLog.Temperature
|
||||||
smartData.SmartStatus = getSmartStatus(smartData.Temperature, data.SmartStatus.Passed)
|
smartData.SmartStatus = getSmartStatus(smartData.Temperature, data.SmartStatus.Passed)
|
||||||
smartData.DiskName = data.Device.Name
|
smartData.DiskName = data.Device.Name
|
||||||
smartData.DiskType = data.Device.Type
|
smartData.DiskType = data.Device.Type
|
||||||
if deviceType != "" {
|
|
||||||
smartData.DiskType = deviceType
|
|
||||||
}
|
|
||||||
|
|
||||||
// nvme attributes does not follow the same format as ata attributes,
|
// nvme attributes does not follow the same format as ata attributes,
|
||||||
// so we manually map each field to SmartAttributes
|
// so we manually map each field to SmartAttributes
|
||||||
@@ -1205,21 +1100,32 @@ func (sm *SmartManager) parseSmartForNvme(output []byte, deviceType string) (boo
|
|||||||
|
|
||||||
// detectSmartctl checks if smartctl is installed, returns an error if not
|
// detectSmartctl checks if smartctl is installed, returns an error if not
|
||||||
func (sm *SmartManager) detectSmartctl() (string, error) {
|
func (sm *SmartManager) detectSmartctl() (string, error) {
|
||||||
if runtime.GOOS == "windows" {
|
isWindows := runtime.GOOS == "windows"
|
||||||
// Load embedded smartctl.exe for Windows amd64 builds.
|
|
||||||
if runtime.GOARCH == "amd64" {
|
// Load embedded smartctl.exe for Windows amd64 builds.
|
||||||
if path, err := ensureEmbeddedSmartctl(); err == nil {
|
if isWindows && runtime.GOARCH == "amd64" {
|
||||||
return path, nil
|
if path, err := ensureEmbeddedSmartctl(); err == nil {
|
||||||
}
|
return path, nil
|
||||||
}
|
}
|
||||||
// Try to find smartctl in the default installation location
|
}
|
||||||
const location = "C:\\Program Files\\smartmontools\\bin\\smartctl.exe"
|
|
||||||
|
if path, err := exec.LookPath("smartctl"); err == nil {
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
locations := []string{}
|
||||||
|
if isWindows {
|
||||||
|
locations = append(locations,
|
||||||
|
"C:\\Program Files\\smartmontools\\bin\\smartctl.exe",
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
locations = append(locations, "/opt/homebrew/bin/smartctl")
|
||||||
|
}
|
||||||
|
for _, location := range locations {
|
||||||
if _, err := os.Stat(location); err == nil {
|
if _, err := os.Stat(location); err == nil {
|
||||||
return location, nil
|
return location, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return "", errors.New("smartctl not found")
|
||||||
return utils.LookPathHomebrew("smartctl")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// isNvmeControllerPath checks if the path matches an NVMe controller pattern
|
// isNvmeControllerPath checks if the path matches an NVMe controller pattern
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ func TestParseSmartForScsi(t *testing.T) {
|
|||||||
SmartDataMap: make(map[string]*smart.SmartData),
|
SmartDataMap: make(map[string]*smart.SmartData),
|
||||||
}
|
}
|
||||||
|
|
||||||
hasData, exitStatus := sm.parseSmartForScsi(data, "")
|
hasData, exitStatus := sm.parseSmartForScsi(data)
|
||||||
if !hasData {
|
if !hasData {
|
||||||
t.Fatalf("expected SCSI data to parse successfully")
|
t.Fatalf("expected SCSI data to parse successfully")
|
||||||
}
|
}
|
||||||
@@ -69,7 +69,7 @@ func TestParseSmartForSata(t *testing.T) {
|
|||||||
SmartDataMap: make(map[string]*smart.SmartData),
|
SmartDataMap: make(map[string]*smart.SmartData),
|
||||||
}
|
}
|
||||||
|
|
||||||
hasData, exitStatus := sm.parseSmartForSata(data, "")
|
hasData, exitStatus := sm.parseSmartForSata(data)
|
||||||
require.True(t, hasData)
|
require.True(t, hasData)
|
||||||
assert.Equal(t, 64, exitStatus)
|
assert.Equal(t, 64, exitStatus)
|
||||||
|
|
||||||
@@ -112,7 +112,7 @@ func TestParseSmartForSataDeviceStatisticsTemperature(t *testing.T) {
|
|||||||
}`)
|
}`)
|
||||||
|
|
||||||
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
|
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
|
||||||
hasData, exitStatus := sm.parseSmartForSata(jsonPayload, "")
|
hasData, exitStatus := sm.parseSmartForSata(jsonPayload)
|
||||||
require.True(t, hasData)
|
require.True(t, hasData)
|
||||||
assert.Equal(t, 0, exitStatus)
|
assert.Equal(t, 0, exitStatus)
|
||||||
|
|
||||||
@@ -121,78 +121,6 @@ func TestParseSmartForSataDeviceStatisticsTemperature(t *testing.T) {
|
|||||||
assert.Equal(t, uint8(22), deviceData.Temperature)
|
assert.Equal(t, uint8(22), deviceData.Temperature)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseSmartForSataAtaDeviceStatistics(t *testing.T) {
|
|
||||||
// tests that ata_device_statistics values are parsed correctly
|
|
||||||
jsonPayload := []byte(`{
|
|
||||||
"smartctl": {"exit_status": 0},
|
|
||||||
"device": {"name": "/dev/sdb", "type": "sat"},
|
|
||||||
"model_name": "SanDisk SSD U110 16GB",
|
|
||||||
"serial_number": "lksjfh23lhj",
|
|
||||||
"firmware_version": "U21B001",
|
|
||||||
"user_capacity": {"bytes": 16013942784},
|
|
||||||
"smart_status": {"passed": true},
|
|
||||||
"ata_smart_attributes": {"table": []},
|
|
||||||
"ata_device_statistics": {
|
|
||||||
"pages": [
|
|
||||||
{
|
|
||||||
"number": 5,
|
|
||||||
"name": "Temperature Statistics",
|
|
||||||
"table": [
|
|
||||||
{"name": "Current Temperature", "value": 43, "flags": {"valid": true}},
|
|
||||||
{"name": "Specified Minimum Operating Temperature", "value": -20, "flags": {"valid": true}}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}`)
|
|
||||||
|
|
||||||
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
|
|
||||||
hasData, exitStatus := sm.parseSmartForSata(jsonPayload, "")
|
|
||||||
require.True(t, hasData)
|
|
||||||
assert.Equal(t, 0, exitStatus)
|
|
||||||
|
|
||||||
deviceData, ok := sm.SmartDataMap["lksjfh23lhj"]
|
|
||||||
require.True(t, ok, "expected smart data entry for serial lksjfh23lhj")
|
|
||||||
assert.Equal(t, uint8(43), deviceData.Temperature)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseSmartForSataNegativeDeviceStatistics(t *testing.T) {
|
|
||||||
// Tests that negative values in ata_device_statistics (e.g. min operating temp)
|
|
||||||
// do not cause the entire SAT parser to fail.
|
|
||||||
jsonPayload := []byte(`{
|
|
||||||
"smartctl": {"exit_status": 0},
|
|
||||||
"device": {"name": "/dev/sdb", "type": "sat"},
|
|
||||||
"model_name": "SanDisk SSD U110 16GB",
|
|
||||||
"serial_number": "NEGATIVE123",
|
|
||||||
"firmware_version": "U21B001",
|
|
||||||
"user_capacity": {"bytes": 16013942784},
|
|
||||||
"smart_status": {"passed": true},
|
|
||||||
"temperature": {"current": 38},
|
|
||||||
"ata_smart_attributes": {"table": []},
|
|
||||||
"ata_device_statistics": {
|
|
||||||
"pages": [
|
|
||||||
{
|
|
||||||
"number": 5,
|
|
||||||
"name": "Temperature Statistics",
|
|
||||||
"table": [
|
|
||||||
{"name": "Current Temperature", "value": 38, "flags": {"valid": true}},
|
|
||||||
{"name": "Specified Minimum Operating Temperature", "value": -20, "flags": {"valid": true}}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}`)
|
|
||||||
|
|
||||||
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
|
|
||||||
hasData, exitStatus := sm.parseSmartForSata(jsonPayload, "")
|
|
||||||
require.True(t, hasData)
|
|
||||||
assert.Equal(t, 0, exitStatus)
|
|
||||||
|
|
||||||
deviceData, ok := sm.SmartDataMap["NEGATIVE123"]
|
|
||||||
require.True(t, ok, "expected smart data entry for serial NEGATIVE123")
|
|
||||||
assert.Equal(t, uint8(38), deviceData.Temperature)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseSmartForSataParentheticalRawValue(t *testing.T) {
|
func TestParseSmartForSataParentheticalRawValue(t *testing.T) {
|
||||||
jsonPayload := []byte(`{
|
jsonPayload := []byte(`{
|
||||||
"smartctl": {"exit_status": 0},
|
"smartctl": {"exit_status": 0},
|
||||||
@@ -223,7 +151,7 @@ func TestParseSmartForSataParentheticalRawValue(t *testing.T) {
|
|||||||
|
|
||||||
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
|
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
|
||||||
|
|
||||||
hasData, exitStatus := sm.parseSmartForSata(jsonPayload, "")
|
hasData, exitStatus := sm.parseSmartForSata(jsonPayload)
|
||||||
require.True(t, hasData)
|
require.True(t, hasData)
|
||||||
assert.Equal(t, 0, exitStatus)
|
assert.Equal(t, 0, exitStatus)
|
||||||
|
|
||||||
@@ -245,7 +173,7 @@ func TestParseSmartForNvme(t *testing.T) {
|
|||||||
SmartDataMap: make(map[string]*smart.SmartData),
|
SmartDataMap: make(map[string]*smart.SmartData),
|
||||||
}
|
}
|
||||||
|
|
||||||
hasData, exitStatus := sm.parseSmartForNvme(data, "")
|
hasData, exitStatus := sm.parseSmartForNvme(data)
|
||||||
require.True(t, hasData)
|
require.True(t, hasData)
|
||||||
assert.Equal(t, 0, exitStatus)
|
assert.Equal(t, 0, exitStatus)
|
||||||
|
|
||||||
@@ -268,15 +196,13 @@ func TestParseSmartForNvme(t *testing.T) {
|
|||||||
func TestHasDataForDevice(t *testing.T) {
|
func TestHasDataForDevice(t *testing.T) {
|
||||||
sm := &SmartManager{
|
sm := &SmartManager{
|
||||||
SmartDataMap: map[string]*smart.SmartData{
|
SmartDataMap: map[string]*smart.SmartData{
|
||||||
"serial-1": {DiskName: "/dev/sda", DiskType: "jms56x,0"},
|
"serial-1": {DiskName: "/dev/sda"},
|
||||||
"serial-2": nil,
|
"serial-2": nil,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
assert.True(t, sm.hasDataForDevice(&DeviceInfo{Name: "/dev/sda", Type: "jms56x,0"}))
|
assert.True(t, sm.hasDataForDevice("/dev/sda"))
|
||||||
assert.False(t, sm.hasDataForDevice(&DeviceInfo{Name: "/dev/sda", Type: "jms56x,1"}))
|
assert.False(t, sm.hasDataForDevice("/dev/sdb"))
|
||||||
assert.False(t, sm.hasDataForDevice(&DeviceInfo{Name: "/dev/sdb", Type: "jms56x,0"}))
|
|
||||||
assert.False(t, sm.hasDataForDevice(nil))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDevicesSnapshotReturnsCopy(t *testing.T) {
|
func TestDevicesSnapshotReturnsCopy(t *testing.T) {
|
||||||
@@ -394,81 +320,6 @@ func TestSmartctlArgs(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestSmartctlArgsExplicitType verifies that an explicit SMART_DEVICES type hint
|
|
||||||
// is always passed to smartctl via -d, while a scan-detected scsi/ata type is
|
|
||||||
// still left off so smartctl can auto-detect it (see issue #1345).
|
|
||||||
func TestSmartctlArgsExplicitType(t *testing.T) {
|
|
||||||
sm := &SmartManager{}
|
|
||||||
|
|
||||||
// Scan-detected scsi: -d is intentionally omitted.
|
|
||||||
scanScsi := &DeviceInfo{Name: "/dev/sda", Type: "scsi"}
|
|
||||||
assert.Equal(t,
|
|
||||||
[]string{"-a", "--json=c", "/dev/sda"},
|
|
||||||
sm.smartctlArgs(scanScsi, false),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Explicit scsi from SMART_DEVICES: -d scsi must be passed.
|
|
||||||
explicitScsi := &DeviceInfo{Name: "/dev/sda", Type: "scsi", explicitType: true}
|
|
||||||
assert.Equal(t,
|
|
||||||
[]string{"-d", "scsi", "-a", "--json=c", "/dev/sda"},
|
|
||||||
sm.smartctlArgs(explicitScsi, false),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Explicit ata from SMART_DEVICES: -d ata must be passed (devstat still added).
|
|
||||||
explicitAta := &DeviceInfo{Name: "/dev/sdb", Type: "ata", explicitType: true}
|
|
||||||
assert.Equal(t,
|
|
||||||
[]string{"-d", "ata", "-a", "--json=c", "-l", "devstat", "/dev/sdb"},
|
|
||||||
sm.smartctlArgs(explicitAta, false),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSmartDevicesExplicitTypeFlowsToSmartctlArgs is a regression test for
|
|
||||||
// issue #2072: an explicit SMART_DEVICES type (e.g. /dev/sda:scsi) must win over
|
|
||||||
// a wrong scan-detected type (sat) and be handed to smartctl as -d scsi.
|
|
||||||
func TestSmartDevicesExplicitTypeFlowsToSmartctlArgs(t *testing.T) {
|
|
||||||
sm := &SmartManager{}
|
|
||||||
|
|
||||||
configured, err := sm.parseConfiguredDevices("/dev/sda:scsi")
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, configured, 1)
|
|
||||||
assert.True(t, configured[0].explicitType)
|
|
||||||
|
|
||||||
// smartctl --scan misreports this USB drive as sat, which fails on it.
|
|
||||||
scanned := []*DeviceInfo{
|
|
||||||
{Name: "/dev/sda", Type: "sat", Protocol: "ATA"},
|
|
||||||
}
|
|
||||||
|
|
||||||
merged := mergeDeviceLists(nil, scanned, configured)
|
|
||||||
require.Len(t, merged, 1)
|
|
||||||
|
|
||||||
device := merged[0]
|
|
||||||
assert.Equal(t, "scsi", device.Type, "configured type should win over scan-detected sat")
|
|
||||||
assert.True(t, device.explicitType, "explicit hint must survive the merge")
|
|
||||||
|
|
||||||
assert.Equal(t,
|
|
||||||
[]string{"-d", "scsi", "-a", "--json=c", "/dev/sda"},
|
|
||||||
sm.smartctlArgs(device, false),
|
|
||||||
"explicit scsi type must be passed to smartctl, not dropped",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestMergeDeviceListsPreservesExplicitTypeAcrossRescan ensures a verified,
|
|
||||||
// explicitly-typed device keeps its explicit flag when a later scan re-reports
|
|
||||||
// it with a different auto-detected type.
|
|
||||||
func TestMergeDeviceListsPreservesExplicitTypeAcrossRescan(t *testing.T) {
|
|
||||||
existing := []*DeviceInfo{
|
|
||||||
{Name: "/dev/sda", Type: "scsi", parserType: "scsi", typeVerified: true, explicitType: true},
|
|
||||||
}
|
|
||||||
scanned := []*DeviceInfo{
|
|
||||||
{Name: "/dev/sda", Type: "sat"},
|
|
||||||
}
|
|
||||||
|
|
||||||
merged := mergeDeviceLists(existing, scanned, nil)
|
|
||||||
require.Len(t, merged, 1)
|
|
||||||
assert.Equal(t, "scsi", merged[0].Type)
|
|
||||||
assert.True(t, merged[0].explicitType, "explicit type flag should survive a rescan")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResolveRefreshError(t *testing.T) {
|
func TestResolveRefreshError(t *testing.T) {
|
||||||
scanErr := errors.New("scan failed")
|
scanErr := errors.New("scan failed")
|
||||||
collectErr := errors.New("collect failed")
|
collectErr := errors.New("collect failed")
|
||||||
@@ -611,74 +462,6 @@ func TestMergeDeviceListsPrefersConfigured(t *testing.T) {
|
|||||||
assert.Equal(t, "sat", byName["/dev/sdb"].Type)
|
assert.Equal(t, "sat", byName["/dev/sdb"].Type)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMergeDeviceListsExpandsConfiguredDevicesWithSamePath(t *testing.T) {
|
|
||||||
scanned := []*DeviceInfo{
|
|
||||||
{Name: "/dev/sdb", Type: "sat", InfoName: "scan-info", Protocol: "ATA"},
|
|
||||||
}
|
|
||||||
configured := []*DeviceInfo{
|
|
||||||
{Name: "/dev/sdb", Type: "jms56x,0", explicitType: true},
|
|
||||||
{Name: "/dev/sdb", Type: "jms56x,1", explicitType: true},
|
|
||||||
}
|
|
||||||
|
|
||||||
merged := mergeDeviceLists(nil, scanned, configured)
|
|
||||||
require.Len(t, merged, 2)
|
|
||||||
|
|
||||||
byKey := make(map[deviceKey]*DeviceInfo, len(merged))
|
|
||||||
for _, device := range merged {
|
|
||||||
byKey[makeDeviceKey(device.Name, device.Type)] = device
|
|
||||||
}
|
|
||||||
|
|
||||||
first := byKey[makeDeviceKey("/dev/sdb", "jms56x,0")]
|
|
||||||
require.NotNil(t, first)
|
|
||||||
assert.Equal(t, "scan-info", first.InfoName)
|
|
||||||
assert.Equal(t, "ATA", first.Protocol)
|
|
||||||
assert.True(t, first.explicitType)
|
|
||||||
|
|
||||||
second := byKey[makeDeviceKey("/dev/sdb", "jms56x,1")]
|
|
||||||
require.NotNil(t, second)
|
|
||||||
assert.True(t, second.explicitType)
|
|
||||||
assert.NotContains(t, byKey, makeDeviceKey("/dev/sdb", "sat"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMergeDeviceListsPreservesSamePathVerificationAcrossRescan(t *testing.T) {
|
|
||||||
existing := []*DeviceInfo{
|
|
||||||
{Name: "/dev/sdb", Type: "jms56x,0", parserType: "sat", typeVerified: true, explicitType: true},
|
|
||||||
{Name: "/dev/sdb", Type: "jms56x,1", parserType: "sat", typeVerified: true, explicitType: true},
|
|
||||||
}
|
|
||||||
scanned := []*DeviceInfo{
|
|
||||||
{Name: "/dev/sdb", Type: "sat", Protocol: "ATA"},
|
|
||||||
}
|
|
||||||
configured := []*DeviceInfo{
|
|
||||||
{Name: "/dev/sdb", Type: "jms56x,0", explicitType: true},
|
|
||||||
{Name: "/dev/sdb", Type: "jms56x,1", explicitType: true},
|
|
||||||
}
|
|
||||||
|
|
||||||
merged := mergeDeviceLists(existing, scanned, configured)
|
|
||||||
require.Len(t, merged, 2)
|
|
||||||
byKey := make(map[deviceKey]*DeviceInfo, len(merged))
|
|
||||||
for _, device := range merged {
|
|
||||||
byKey[makeDeviceKey(device.Name, device.Type)] = device
|
|
||||||
assert.True(t, device.typeVerified, device.Type)
|
|
||||||
assert.Equal(t, "sat", device.parserType, device.Type)
|
|
||||||
assert.True(t, device.explicitType, device.Type)
|
|
||||||
}
|
|
||||||
assert.Contains(t, byKey, makeDeviceKey("/dev/sdb", "jms56x,0"))
|
|
||||||
assert.Contains(t, byKey, makeDeviceKey("/dev/sdb", "jms56x,1"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMergeDeviceListsDeduplicatesConfiguredIdentityAfterRekey(t *testing.T) {
|
|
||||||
scanned := []*DeviceInfo{{Name: "/dev/sdb", Type: "sat"}}
|
|
||||||
configured := []*DeviceInfo{
|
|
||||||
{Name: "/dev/sdb", Type: "jms56x,0", explicitType: true},
|
|
||||||
{Name: "/dev/sdb", Type: "jms56x,0", explicitType: true},
|
|
||||||
}
|
|
||||||
|
|
||||||
merged := mergeDeviceLists(nil, scanned, configured)
|
|
||||||
require.Len(t, merged, 1)
|
|
||||||
assert.Equal(t, "/dev/sdb", merged[0].Name)
|
|
||||||
assert.Equal(t, "jms56x,0", merged[0].Type)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMergeDeviceListsPreservesVerification(t *testing.T) {
|
func TestMergeDeviceListsPreservesVerification(t *testing.T) {
|
||||||
existing := []*DeviceInfo{
|
existing := []*DeviceInfo{
|
||||||
{Name: "/dev/sda", Type: "sat+megaraid", parserType: "sat", typeVerified: true},
|
{Name: "/dev/sda", Type: "sat+megaraid", parserType: "sat", typeVerified: true},
|
||||||
@@ -823,20 +606,6 @@ func TestParseSmartOutputKeepsCustomType(t *testing.T) {
|
|||||||
assert.Equal(t, "sat+megaraid", device.Type)
|
assert.Equal(t, "sat+megaraid", device.Type)
|
||||||
assert.Equal(t, "sat", device.parserType)
|
assert.Equal(t, "sat", device.parserType)
|
||||||
assert.True(t, device.typeVerified)
|
assert.True(t, device.typeVerified)
|
||||||
assert.Equal(t, "sat+megaraid", sm.SmartDataMap["9C40918040082"].DiskType)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseSmartOutputDoesNotNormalizeDeviceIdentity(t *testing.T) {
|
|
||||||
fixturePath := filepath.Join("test-data", "smart", "sda.json")
|
|
||||||
data, err := os.ReadFile(fixturePath)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
|
|
||||||
device := &DeviceInfo{Name: "/dev/sda", Type: "ata", explicitType: true}
|
|
||||||
|
|
||||||
require.True(t, sm.parseSmartOutput(device, data))
|
|
||||||
assert.Equal(t, "sat", device.parserType)
|
|
||||||
assert.Equal(t, "ata", sm.SmartDataMap["9C40918040082"].DiskType)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseSmartOutputResetsVerificationOnFailure(t *testing.T) {
|
func TestParseSmartOutputResetsVerificationOnFailure(t *testing.T) {
|
||||||
@@ -958,182 +727,6 @@ func TestIsVirtualDeviceScsi(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFindAtaDeviceStatisticsValue(t *testing.T) {
|
|
||||||
val42 := int64(42)
|
|
||||||
val100 := int64(100)
|
|
||||||
valMinus20 := int64(-20)
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
data smart.SmartInfoForSata
|
|
||||||
ataDeviceStats smart.AtaDeviceStatistics
|
|
||||||
entryNumber uint8
|
|
||||||
entryName string
|
|
||||||
minValue int64
|
|
||||||
maxValue int64
|
|
||||||
expectedValue *int64
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "value in ataDeviceStats",
|
|
||||||
ataDeviceStats: smart.AtaDeviceStatistics{
|
|
||||||
Pages: []smart.AtaDeviceStatisticsPage{
|
|
||||||
{
|
|
||||||
Number: 5,
|
|
||||||
Table: []smart.AtaDeviceStatisticsEntry{
|
|
||||||
{Name: "Current Temperature", Value: &val42},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
entryNumber: 5,
|
|
||||||
entryName: "Current Temperature",
|
|
||||||
minValue: 0,
|
|
||||||
maxValue: 100,
|
|
||||||
expectedValue: &val42,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "value unmarshaled from data",
|
|
||||||
data: smart.SmartInfoForSata{
|
|
||||||
AtaDeviceStatistics: []byte(`{"pages":[{"number":5,"table":[{"name":"Current Temperature","value":100}]}]}`),
|
|
||||||
},
|
|
||||||
entryNumber: 5,
|
|
||||||
entryName: "Current Temperature",
|
|
||||||
minValue: 0,
|
|
||||||
maxValue: 255,
|
|
||||||
expectedValue: &val100,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "value out of range (too high)",
|
|
||||||
ataDeviceStats: smart.AtaDeviceStatistics{
|
|
||||||
Pages: []smart.AtaDeviceStatisticsPage{
|
|
||||||
{
|
|
||||||
Number: 5,
|
|
||||||
Table: []smart.AtaDeviceStatisticsEntry{
|
|
||||||
{Name: "Current Temperature", Value: &val100},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
entryNumber: 5,
|
|
||||||
entryName: "Current Temperature",
|
|
||||||
minValue: 0,
|
|
||||||
maxValue: 50,
|
|
||||||
expectedValue: nil,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "value out of range (too low)",
|
|
||||||
ataDeviceStats: smart.AtaDeviceStatistics{
|
|
||||||
Pages: []smart.AtaDeviceStatisticsPage{
|
|
||||||
{
|
|
||||||
Number: 5,
|
|
||||||
Table: []smart.AtaDeviceStatisticsEntry{
|
|
||||||
{Name: "Min Temp", Value: &valMinus20},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
entryNumber: 5,
|
|
||||||
entryName: "Min Temp",
|
|
||||||
minValue: 0,
|
|
||||||
maxValue: 100,
|
|
||||||
expectedValue: nil,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "no statistics available",
|
|
||||||
data: smart.SmartInfoForSata{},
|
|
||||||
entryNumber: 5,
|
|
||||||
entryName: "Current Temperature",
|
|
||||||
minValue: 0,
|
|
||||||
maxValue: 255,
|
|
||||||
expectedValue: nil,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "wrong page number",
|
|
||||||
ataDeviceStats: smart.AtaDeviceStatistics{
|
|
||||||
Pages: []smart.AtaDeviceStatisticsPage{
|
|
||||||
{
|
|
||||||
Number: 1,
|
|
||||||
Table: []smart.AtaDeviceStatisticsEntry{
|
|
||||||
{Name: "Current Temperature", Value: &val42},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
entryNumber: 5,
|
|
||||||
entryName: "Current Temperature",
|
|
||||||
minValue: 0,
|
|
||||||
maxValue: 100,
|
|
||||||
expectedValue: nil,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "wrong entry name",
|
|
||||||
ataDeviceStats: smart.AtaDeviceStatistics{
|
|
||||||
Pages: []smart.AtaDeviceStatisticsPage{
|
|
||||||
{
|
|
||||||
Number: 5,
|
|
||||||
Table: []smart.AtaDeviceStatisticsEntry{
|
|
||||||
{Name: "Other Stat", Value: &val42},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
entryNumber: 5,
|
|
||||||
entryName: "Current Temperature",
|
|
||||||
minValue: 0,
|
|
||||||
maxValue: 100,
|
|
||||||
expectedValue: nil,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "case insensitive name match",
|
|
||||||
ataDeviceStats: smart.AtaDeviceStatistics{
|
|
||||||
Pages: []smart.AtaDeviceStatisticsPage{
|
|
||||||
{
|
|
||||||
Number: 5,
|
|
||||||
Table: []smart.AtaDeviceStatisticsEntry{
|
|
||||||
{Name: "CURRENT TEMPERATURE", Value: &val42},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
entryNumber: 5,
|
|
||||||
entryName: "Current Temperature",
|
|
||||||
minValue: 0,
|
|
||||||
maxValue: 100,
|
|
||||||
expectedValue: &val42,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "entry value is nil",
|
|
||||||
ataDeviceStats: smart.AtaDeviceStatistics{
|
|
||||||
Pages: []smart.AtaDeviceStatisticsPage{
|
|
||||||
{
|
|
||||||
Number: 5,
|
|
||||||
Table: []smart.AtaDeviceStatisticsEntry{
|
|
||||||
{Name: "Current Temperature", Value: nil},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
entryNumber: 5,
|
|
||||||
entryName: "Current Temperature",
|
|
||||||
minValue: 0,
|
|
||||||
maxValue: 100,
|
|
||||||
expectedValue: nil,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
result := findAtaDeviceStatisticsValue(&tt.data, &tt.ataDeviceStats, tt.entryNumber, tt.entryName, tt.minValue, tt.maxValue)
|
|
||||||
if tt.expectedValue == nil {
|
|
||||||
assert.Nil(t, result)
|
|
||||||
} else {
|
|
||||||
require.NotNil(t, result)
|
|
||||||
assert.Equal(t, *tt.expectedValue, *result)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRefreshExcludedDevices(t *testing.T) {
|
func TestRefreshExcludedDevices(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -1194,7 +787,7 @@ func TestRefreshExcludedDevices(t *testing.T) {
|
|||||||
t.Setenv("EXCLUDE_SMART", tt.envValue)
|
t.Setenv("EXCLUDE_SMART", tt.envValue)
|
||||||
} else {
|
} else {
|
||||||
// Ensure env var is not set for empty test
|
// Ensure env var is not set for empty test
|
||||||
t.Setenv("EXCLUDE_SMART", "")
|
os.Unsetenv("EXCLUDE_SMART")
|
||||||
}
|
}
|
||||||
|
|
||||||
sm := &SmartManager{}
|
sm := &SmartManager{}
|
||||||
@@ -1358,81 +951,3 @@ func TestIsNvmeControllerPath(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseSmartForNvmeAppleSSD(t *testing.T) {
|
|
||||||
// Apple SSDs don't report user_capacity via smartctl; capacity should be fetched
|
|
||||||
// from system_profiler via the darwinNvmeProvider fallback.
|
|
||||||
fixturePath := filepath.Join("test-data", "smart", "apple_nvme.json")
|
|
||||||
data, err := os.ReadFile(fixturePath)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
providerCalls := 0
|
|
||||||
fakeProvider := func() ([]byte, error) {
|
|
||||||
providerCalls++
|
|
||||||
return []byte(`{
|
|
||||||
"SPNVMeDataType": [{
|
|
||||||
"_items": [{
|
|
||||||
"device_serial": "0ba0147940253c15",
|
|
||||||
"size_in_bytes": 251000193024
|
|
||||||
}]
|
|
||||||
}]
|
|
||||||
}`), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
sm := &SmartManager{
|
|
||||||
SmartDataMap: make(map[string]*smart.SmartData),
|
|
||||||
darwinNvmeProvider: fakeProvider,
|
|
||||||
}
|
|
||||||
|
|
||||||
hasData, _ := sm.parseSmartForNvme(data, "")
|
|
||||||
require.True(t, hasData)
|
|
||||||
|
|
||||||
deviceData, ok := sm.SmartDataMap["0ba0147940253c15"]
|
|
||||||
require.True(t, ok)
|
|
||||||
assert.Equal(t, "APPLE SSD AP0256Q", deviceData.ModelName)
|
|
||||||
assert.Equal(t, uint64(251000193024), deviceData.Capacity)
|
|
||||||
assert.Equal(t, uint8(42), deviceData.Temperature)
|
|
||||||
assert.Equal(t, "PASSED", deviceData.SmartStatus)
|
|
||||||
assert.Equal(t, 1, providerCalls, "system_profiler should be called once")
|
|
||||||
|
|
||||||
// Second parse: provider should NOT be called again (cache hit)
|
|
||||||
_, _ = sm.parseSmartForNvme(data, "")
|
|
||||||
assert.Equal(t, 1, providerCalls, "system_profiler should not be called again after caching")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLookupDarwinNvmeCapacityMultipleDisks(t *testing.T) {
|
|
||||||
fakeProvider := func() ([]byte, error) {
|
|
||||||
return []byte(`{
|
|
||||||
"SPNVMeDataType": [
|
|
||||||
{
|
|
||||||
"_items": [
|
|
||||||
{"device_serial": "serial-disk0", "size_in_bytes": 251000193024},
|
|
||||||
{"device_serial": "serial-disk1", "size_in_bytes": 1000204886016}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"_items": [
|
|
||||||
{"device_serial": "serial-disk2", "size_in_bytes": 512110190592}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}`), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
sm := &SmartManager{darwinNvmeProvider: fakeProvider}
|
|
||||||
assert.Equal(t, uint64(251000193024), sm.lookupDarwinNvmeCapacity("serial-disk0"))
|
|
||||||
assert.Equal(t, uint64(1000204886016), sm.lookupDarwinNvmeCapacity("serial-disk1"))
|
|
||||||
assert.Equal(t, uint64(512110190592), sm.lookupDarwinNvmeCapacity("serial-disk2"))
|
|
||||||
assert.Equal(t, uint64(0), sm.lookupDarwinNvmeCapacity("unknown-serial"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLookupDarwinNvmeCapacityProviderError(t *testing.T) {
|
|
||||||
fakeProvider := func() ([]byte, error) {
|
|
||||||
return nil, errors.New("system_profiler not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
sm := &SmartManager{darwinNvmeProvider: fakeProvider}
|
|
||||||
assert.Equal(t, uint64(0), sm.lookupDarwinNvmeCapacity("any-serial"))
|
|
||||||
// Cache should be initialized even on error so we don't retry (Once already fired)
|
|
||||||
assert.NotNil(t, sm.darwinNvmeCapacity)
|
|
||||||
}
|
|
||||||
|
|||||||
183
agent/system.go
183
agent/system.go
@@ -4,15 +4,14 @@ import (
|
|||||||
"bufio"
|
"bufio"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel"
|
"github.com/henrygd/beszel"
|
||||||
"github.com/henrygd/beszel/agent/battery"
|
"github.com/henrygd/beszel/agent/battery"
|
||||||
"github.com/henrygd/beszel/agent/utils"
|
|
||||||
"github.com/henrygd/beszel/agent/zfs"
|
"github.com/henrygd/beszel/agent/zfs"
|
||||||
"github.com/henrygd/beszel/internal/entities/container"
|
"github.com/henrygd/beszel/internal/entities/container"
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
@@ -23,6 +22,13 @@ import (
|
|||||||
"github.com/shirou/gopsutil/v4/mem"
|
"github.com/shirou/gopsutil/v4/mem"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// prevDisk stores previous per-device disk counters for a given cache interval
|
||||||
|
type prevDisk struct {
|
||||||
|
readBytes uint64
|
||||||
|
writeBytes uint64
|
||||||
|
at time.Time
|
||||||
|
}
|
||||||
|
|
||||||
// Sets initial / non-changing values about the host system
|
// Sets initial / non-changing values about the host system
|
||||||
func (a *Agent) refreshSystemDetails() {
|
func (a *Agent) refreshSystemDetails() {
|
||||||
a.systemInfo.AgentVersion = beszel.Version
|
a.systemInfo.AgentVersion = beszel.Version
|
||||||
@@ -79,12 +85,6 @@ func (a *Agent) refreshSystemDetails() {
|
|||||||
if info, err := cpu.Info(); err == nil && len(info) > 0 {
|
if info, err := cpu.Info(); err == nil && len(info) > 0 {
|
||||||
a.systemDetails.CpuModel = info[0].ModelName
|
a.systemDetails.CpuModel = info[0].ModelName
|
||||||
}
|
}
|
||||||
// gopsutil doesn't parse the "cpu model" field from /proc/cpuinfo, which
|
|
||||||
// is the only source of the CPU model name on MIPS. Fall back to reading
|
|
||||||
// it directly when ModelName is empty.
|
|
||||||
if a.systemDetails.CpuModel == "" {
|
|
||||||
a.systemDetails.CpuModel = getCpuModelFromCpuinfo()
|
|
||||||
}
|
|
||||||
// cores / threads
|
// cores / threads
|
||||||
cores, _ := cpu.Counts(false)
|
cores, _ := cpu.Counts(false)
|
||||||
threads := hostInfo.NCPU
|
threads := hostInfo.NCPU
|
||||||
@@ -114,51 +114,26 @@ func (a *Agent) refreshSystemDetails() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// attachSystemDetails returns details only for fresh default-interval responses.
|
|
||||||
func (a *Agent) attachSystemDetails(data *system.CombinedData, cacheTimeMs uint16, includeRequested bool) *system.CombinedData {
|
|
||||||
if cacheTimeMs != defaultDataCacheTimeMs || (!includeRequested && !a.detailsDirty) {
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
// copy data to avoid adding details to the original cached struct
|
|
||||||
response := *data
|
|
||||||
response.Details = &a.systemDetails
|
|
||||||
a.detailsDirty = false
|
|
||||||
return &response
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateSystemDetails applies a mutation to the static details payload and marks
|
|
||||||
// it for inclusion on the next fresh default-interval response.
|
|
||||||
func (a *Agent) updateSystemDetails(updateFunc func(details *system.Details)) {
|
|
||||||
updateFunc(&a.systemDetails)
|
|
||||||
a.detailsDirty = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Returns current info, stats about the host system
|
// Returns current info, stats about the host system
|
||||||
func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
|
func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
|
||||||
var systemStats system.Stats
|
var systemStats system.Stats
|
||||||
|
|
||||||
// battery
|
// battery
|
||||||
if batteries, err := battery.GetBatteryStats(); err == nil {
|
if batteryPercent, batteryState, err := battery.GetBatteryStats(); err == nil {
|
||||||
systemStats.Batteries = make(map[string]uint8, len(batteries))
|
systemStats.Battery[0] = batteryPercent
|
||||||
for _, device := range batteries {
|
systemStats.Battery[1] = batteryState
|
||||||
systemStats.Batteries[device.Name] = device.Percent
|
|
||||||
}
|
|
||||||
if primary, ok := battery.Primary(batteries); ok {
|
|
||||||
systemStats.Battery = [2]uint8{primary.Percent, primary.State}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// cpu metrics
|
// cpu metrics
|
||||||
cpuMetrics, err := getCpuMetrics(cacheTimeMs)
|
cpuMetrics, err := getCpuMetrics(cacheTimeMs)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
systemStats.Cpu = utils.TwoDecimals(cpuMetrics.Total)
|
systemStats.Cpu = twoDecimals(cpuMetrics.Total)
|
||||||
systemStats.CpuBreakdown = []float64{
|
systemStats.CpuBreakdown = []float64{
|
||||||
utils.TwoDecimals(cpuMetrics.User),
|
twoDecimals(cpuMetrics.User),
|
||||||
utils.TwoDecimals(cpuMetrics.System),
|
twoDecimals(cpuMetrics.System),
|
||||||
utils.TwoDecimals(cpuMetrics.Iowait),
|
twoDecimals(cpuMetrics.Iowait),
|
||||||
utils.TwoDecimals(cpuMetrics.Steal),
|
twoDecimals(cpuMetrics.Steal),
|
||||||
utils.TwoDecimals(cpuMetrics.Idle),
|
twoDecimals(cpuMetrics.Idle),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
slog.Error("Error getting cpu metrics", "err", err)
|
slog.Error("Error getting cpu metrics", "err", err)
|
||||||
@@ -181,11 +156,21 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
|
|||||||
|
|
||||||
// memory
|
// memory
|
||||||
if v, err := mem.VirtualMemory(); err == nil {
|
if v, err := mem.VirtualMemory(); err == nil {
|
||||||
used, cacheBuff, swapUsed := calculateHostMemoryUsage(v, a.memCalc == "htop")
|
|
||||||
// swap
|
// swap
|
||||||
systemStats.Swap = utils.BytesToGigabytes(v.SwapTotal)
|
systemStats.Swap = bytesToGigabytes(v.SwapTotal)
|
||||||
systemStats.SwapUsed = utils.BytesToGigabytes(swapUsed)
|
systemStats.SwapUsed = bytesToGigabytes(v.SwapTotal - v.SwapFree - v.SwapCached)
|
||||||
v.Used = used
|
// cache + buffers value for default mem calculation
|
||||||
|
// note: gopsutil automatically adds SReclaimable to v.Cached
|
||||||
|
cacheBuff := v.Cached + v.Buffers - v.Shared
|
||||||
|
if cacheBuff <= 0 {
|
||||||
|
cacheBuff = max(v.Total-v.Free-v.Used, 0)
|
||||||
|
}
|
||||||
|
// htop memory calculation overrides (likely outdated as of mid 2025)
|
||||||
|
if a.memCalc == "htop" {
|
||||||
|
// cacheBuff = v.Cached + v.Buffers - v.Shared
|
||||||
|
v.Used = v.Total - (v.Free + cacheBuff)
|
||||||
|
v.UsedPercent = float64(v.Used) / float64(v.Total) * 100.0
|
||||||
|
}
|
||||||
// if a.memCalc == "legacy" {
|
// if a.memCalc == "legacy" {
|
||||||
// v.Used = v.Total - v.Free - v.Buffers - v.Cached
|
// v.Used = v.Total - v.Free - v.Buffers - v.Cached
|
||||||
// cacheBuff = v.Total - v.Free - v.Used
|
// cacheBuff = v.Total - v.Free - v.Used
|
||||||
@@ -195,18 +180,14 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
|
|||||||
if a.zfs {
|
if a.zfs {
|
||||||
if arcSize, _ := zfs.ARCSize(); arcSize > 0 && arcSize < v.Used {
|
if arcSize, _ := zfs.ARCSize(); arcSize > 0 && arcSize < v.Used {
|
||||||
v.Used = v.Used - arcSize
|
v.Used = v.Used - arcSize
|
||||||
systemStats.MemZfsArc = utils.BytesToGigabytes(arcSize)
|
v.UsedPercent = float64(v.Used) / float64(v.Total) * 100.0
|
||||||
|
systemStats.MemZfsArc = bytesToGigabytes(arcSize)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if v.Total > 0 {
|
systemStats.Mem = bytesToGigabytes(v.Total)
|
||||||
v.UsedPercent = float64(v.Used) / float64(v.Total) * 100.0
|
systemStats.MemBuffCache = bytesToGigabytes(cacheBuff)
|
||||||
} else {
|
systemStats.MemUsed = bytesToGigabytes(v.Used)
|
||||||
v.UsedPercent = 0
|
systemStats.MemPct = twoDecimals(v.UsedPercent)
|
||||||
}
|
|
||||||
systemStats.Mem = utils.BytesToGigabytes(v.Total)
|
|
||||||
systemStats.MemBuffCache = utils.BytesToGigabytes(cacheBuff)
|
|
||||||
systemStats.MemUsed = utils.BytesToGigabytes(v.Used)
|
|
||||||
systemStats.MemPct = utils.TwoDecimals(v.UsedPercent)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// disk usage
|
// disk usage
|
||||||
@@ -222,9 +203,6 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
|
|||||||
// TODO: maybe refactor to methods on systemStats
|
// TODO: maybe refactor to methods on systemStats
|
||||||
a.updateTemperatures(&systemStats)
|
a.updateTemperatures(&systemStats)
|
||||||
|
|
||||||
// fan speeds (Linux-only; sysfs hwmon)
|
|
||||||
a.updateFans(&systemStats)
|
|
||||||
|
|
||||||
// GPU data
|
// GPU data
|
||||||
if a.gpuManager != nil {
|
if a.gpuManager != nil {
|
||||||
// reset high gpu percent
|
// reset high gpu percent
|
||||||
@@ -265,98 +243,13 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
|
|||||||
a.systemInfo.MemPct = systemStats.MemPct
|
a.systemInfo.MemPct = systemStats.MemPct
|
||||||
a.systemInfo.DiskPct = systemStats.DiskPct
|
a.systemInfo.DiskPct = systemStats.DiskPct
|
||||||
a.systemInfo.Battery = systemStats.Battery
|
a.systemInfo.Battery = systemStats.Battery
|
||||||
a.systemInfo.Uptime, _ = getUptime()
|
a.systemInfo.Uptime, _ = host.Uptime()
|
||||||
a.systemInfo.BandwidthBytes = systemStats.Bandwidth[0] + systemStats.Bandwidth[1]
|
a.systemInfo.BandwidthBytes = systemStats.Bandwidth[0] + systemStats.Bandwidth[1]
|
||||||
a.systemInfo.Threads = a.systemDetails.Threads
|
a.systemInfo.Threads = a.systemDetails.Threads
|
||||||
|
|
||||||
return systemStats
|
return systemStats
|
||||||
}
|
}
|
||||||
|
|
||||||
// cpuModelFallbackKeys are the field names to look for in /proc/cpuinfo when
|
|
||||||
// gopsutil fails to return a ModelName. The "cpu model" key is used on MIPS
|
|
||||||
// (e.g. "MIPS 1004Kc V2.15"), while "system type" provides SoC information
|
|
||||||
// on various embedded architectures.
|
|
||||||
var cpuModelFallbackKeys = []string{"cpu model", "system type"}
|
|
||||||
|
|
||||||
// getCpuModelFromCpuinfo reads /proc/cpuinfo and returns a CPU model string.
|
|
||||||
// This is a fallback for architectures where gopsutil's cpu.Info() does not
|
|
||||||
// populate ModelName, most notably MIPS.
|
|
||||||
func getCpuModelFromCpuinfo() string {
|
|
||||||
file, err := os.Open("/proc/cpuinfo")
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
return parseCpuModel(file)
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseCpuModel scans r (expected to be /proc/cpuinfo content) and returns
|
|
||||||
// a combined CPU model string. It collects values from all matching keys
|
|
||||||
// and joins them with " / " when multiple are found.
|
|
||||||
func parseCpuModel(r io.Reader) string {
|
|
||||||
lines := readLines(r)
|
|
||||||
var parts []string
|
|
||||||
for _, key := range cpuModelFallbackKeys {
|
|
||||||
for _, line := range lines {
|
|
||||||
after, found := strings.CutPrefix(line, key)
|
|
||||||
if !found {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
after = strings.TrimSpace(after)
|
|
||||||
if len(after) < 2 || after[0] != ':' {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if value := strings.TrimSpace(after[1:]); value != "" {
|
|
||||||
parts = append(parts, value)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return strings.Join(parts, " / ")
|
|
||||||
}
|
|
||||||
|
|
||||||
// readLines reads all lines from r into a slice.
|
|
||||||
func readLines(r io.Reader) []string {
|
|
||||||
scanner := bufio.NewScanner(r)
|
|
||||||
var lines []string
|
|
||||||
for scanner.Scan() {
|
|
||||||
lines = append(lines, scanner.Text())
|
|
||||||
}
|
|
||||||
return lines
|
|
||||||
}
|
|
||||||
|
|
||||||
// calculateHostMemoryUsage derives counters defensively because /proc/meminfo may
|
|
||||||
// change while gopsutil reads it. Invalid unsigned subtractions saturate at zero.
|
|
||||||
func calculateHostMemoryUsage(v *mem.VirtualMemoryStat, htop bool) (used, cacheBuff, swapUsed uint64) {
|
|
||||||
used = v.Used
|
|
||||||
if used > v.Total {
|
|
||||||
used = saturatingSub(v.Total, v.Available)
|
|
||||||
}
|
|
||||||
|
|
||||||
// gopsutil automatically adds SReclaimable to Cached.
|
|
||||||
cacheBuff = min(v.Cached, v.Total)
|
|
||||||
cacheBuff += min(v.Buffers, v.Total-cacheBuff)
|
|
||||||
cacheBuff = saturatingSub(cacheBuff, min(v.Shared, v.Total))
|
|
||||||
if v.Cached == 0 && v.Buffers == 0 {
|
|
||||||
cacheBuff = saturatingSub(v.Total, v.Free, used)
|
|
||||||
}
|
|
||||||
if htop {
|
|
||||||
used = saturatingSub(v.Total, v.Free, cacheBuff)
|
|
||||||
}
|
|
||||||
return used, cacheBuff, saturatingSub(v.SwapTotal, v.SwapFree, v.SwapCached)
|
|
||||||
}
|
|
||||||
|
|
||||||
// saturatingSub subtracts each value, returning zero on underflow.
|
|
||||||
func saturatingSub(value uint64, subtrahends ...uint64) uint64 {
|
|
||||||
for _, subtrahend := range subtrahends {
|
|
||||||
if subtrahend > value {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
value -= subtrahend
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
// getOsPrettyName attempts to get the pretty OS name from /etc/os-release on Linux systems
|
// getOsPrettyName attempts to get the pretty OS name from /etc/os-release on Linux systems
|
||||||
func getOsPrettyName() (string, error) {
|
func getOsPrettyName() (string, error) {
|
||||||
file, err := os.Open("/etc/os-release")
|
file, err := os.Open("/etc/os-release")
|
||||||
|
|||||||
@@ -1,194 +0,0 @@
|
|||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"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"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestGatherStatsDoesNotAttachDetailsToCachedRequests(t *testing.T) {
|
|
||||||
agent := &Agent{
|
|
||||||
cache: NewSystemDataCache(),
|
|
||||||
systemDetails: system.Details{Hostname: "updated-host", Podman: true},
|
|
||||||
detailsDirty: true,
|
|
||||||
}
|
|
||||||
cached := &system.CombinedData{
|
|
||||||
Info: system.Info{Hostname: "cached-host"},
|
|
||||||
}
|
|
||||||
agent.cache.Set(cached, defaultDataCacheTimeMs)
|
|
||||||
|
|
||||||
response := agent.gatherStats(common.DataRequestOptions{CacheTimeMs: defaultDataCacheTimeMs})
|
|
||||||
|
|
||||||
assert.Same(t, cached, response)
|
|
||||||
assert.Nil(t, response.Details)
|
|
||||||
assert.True(t, agent.detailsDirty)
|
|
||||||
assert.Equal(t, "cached-host", response.Info.Hostname)
|
|
||||||
assert.Nil(t, cached.Details)
|
|
||||||
|
|
||||||
secondResponse := agent.gatherStats(common.DataRequestOptions{CacheTimeMs: defaultDataCacheTimeMs})
|
|
||||||
assert.Same(t, cached, secondResponse)
|
|
||||||
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{}
|
|
||||||
|
|
||||||
agent.updateSystemDetails(func(details *system.Details) {
|
|
||||||
details.Hostname = "updated-host"
|
|
||||||
details.Podman = true
|
|
||||||
})
|
|
||||||
|
|
||||||
assert.True(t, agent.detailsDirty)
|
|
||||||
assert.Equal(t, "updated-host", agent.systemDetails.Hostname)
|
|
||||||
assert.True(t, agent.systemDetails.Podman)
|
|
||||||
|
|
||||||
original := &system.CombinedData{}
|
|
||||||
realTimeResponse := agent.attachSystemDetails(original, 1000, true)
|
|
||||||
assert.Same(t, original, realTimeResponse)
|
|
||||||
assert.Nil(t, realTimeResponse.Details)
|
|
||||||
assert.True(t, agent.detailsDirty)
|
|
||||||
|
|
||||||
response := agent.attachSystemDetails(original, defaultDataCacheTimeMs, false)
|
|
||||||
require.NotNil(t, response.Details)
|
|
||||||
assert.NotSame(t, original, response)
|
|
||||||
assert.Equal(t, "updated-host", response.Details.Hostname)
|
|
||||||
assert.True(t, response.Details.Podman)
|
|
||||||
assert.False(t, agent.detailsDirty)
|
|
||||||
assert.Nil(t, original.Details)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseCpuModel(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
input string
|
|
||||||
expected string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "MIPS with both cpu model and system type",
|
|
||||||
input: `system type : MediaTek MT7621 ver:1 eco:3
|
|
||||||
machine : ASUS RT-AX53U
|
|
||||||
processor : 0
|
|
||||||
cpu model : MIPS 1004Kc V2.15
|
|
||||||
BogoMIPS : 586.13
|
|
||||||
wait instruction : yes`,
|
|
||||||
expected: "MIPS 1004Kc V2.15 / MediaTek MT7621 ver:1 eco:3",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "MIPS with different SoC",
|
|
||||||
input: `system type : Atheros AR7161 rev 2
|
|
||||||
machine : NETGEAR WNDR3700
|
|
||||||
processor : 0
|
|
||||||
cpu model : MIPS 24Kc V7.4
|
|
||||||
BogoMIPS : 452.19`,
|
|
||||||
expected: "MIPS 24Kc V7.4 / Atheros AR7161 rev 2",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "only system type when cpu model missing",
|
|
||||||
input: `system type : Broadcom BCM47xx
|
|
||||||
processor : 0
|
|
||||||
BogoMIPS : 296.11`,
|
|
||||||
expected: "Broadcom BCM47xx",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "only cpu model when system type missing",
|
|
||||||
input: `processor : 0
|
|
||||||
cpu model : MIPS 34Kc V2.15
|
|
||||||
BogoMIPS : 300.00`,
|
|
||||||
expected: "MIPS 34Kc V2.15",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "x86 cpuinfo returns empty",
|
|
||||||
input: `processor : 0
|
|
||||||
vendor_id : GenuineIntel
|
|
||||||
cpu family : 6
|
|
||||||
model : 142
|
|
||||||
model name : Intel(R) Core(TM) i5-8250U CPU @ 1.60GHz
|
|
||||||
stepping : 10`,
|
|
||||||
expected: "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty input",
|
|
||||||
input: "",
|
|
||||||
expected: "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "cpu model with extra whitespace",
|
|
||||||
input: `processor : 0
|
|
||||||
cpu model : MIPS 34Kc V2.15
|
|
||||||
BogoMIPS : 300.00`,
|
|
||||||
expected: "MIPS 34Kc V2.15",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "cpu model without value",
|
|
||||||
input: `processor : 0
|
|
||||||
cpu model :
|
|
||||||
BogoMIPS : 300.00`,
|
|
||||||
expected: "",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
result := parseCpuModel(strings.NewReader(tt.input))
|
|
||||||
assert.Equal(t, tt.expected, result)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -15,7 +15,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/coreos/go-systemd/v22/dbus"
|
"github.com/coreos/go-systemd/v22/dbus"
|
||||||
"github.com/henrygd/beszel/agent/utils"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/systemd"
|
"github.com/henrygd/beszel/internal/entities/systemd"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -50,7 +49,7 @@ func isSystemdAvailable() bool {
|
|||||||
|
|
||||||
// newSystemdManager creates a new systemdManager.
|
// newSystemdManager creates a new systemdManager.
|
||||||
func newSystemdManager() (*systemdManager, error) {
|
func newSystemdManager() (*systemdManager, error) {
|
||||||
if skipSystemd, _ := utils.GetEnv("SKIP_SYSTEMD"); skipSystemd == "true" {
|
if skipSystemd, _ := GetEnv("SKIP_SYSTEMD"); skipSystemd == "true" {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,13 +294,13 @@ func unescapeServiceName(name string) string {
|
|||||||
// otherwise defaults to "*service".
|
// otherwise defaults to "*service".
|
||||||
func getServicePatterns() []string {
|
func getServicePatterns() []string {
|
||||||
patterns := []string{}
|
patterns := []string{}
|
||||||
if envPatterns, _ := utils.GetEnv("SERVICE_PATTERNS"); envPatterns != "" {
|
if envPatterns, _ := GetEnv("SERVICE_PATTERNS"); envPatterns != "" {
|
||||||
for pattern := range strings.SplitSeq(envPatterns, ",") {
|
for pattern := range strings.SplitSeq(envPatterns, ",") {
|
||||||
pattern = strings.TrimSpace(pattern)
|
pattern = strings.TrimSpace(pattern)
|
||||||
if pattern == "" {
|
if pattern == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if !strings.HasSuffix(pattern, "timer") && !strings.HasSuffix(pattern, ".service") {
|
if !strings.HasSuffix(pattern, ".service") {
|
||||||
pattern += ".service"
|
pattern += ".service"
|
||||||
}
|
}
|
||||||
patterns = append(patterns, pattern)
|
patterns = append(patterns, pattern)
|
||||||
|
|||||||
@@ -156,23 +156,20 @@ func TestGetServicePatterns(t *testing.T) {
|
|||||||
expected: []string{"*nginx*.service", "*apache*.service"},
|
expected: []string{"*nginx*.service", "*apache*.service"},
|
||||||
cleanupEnvVars: true,
|
cleanupEnvVars: true,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "opt into timer monitoring",
|
|
||||||
prefixedEnv: "nginx.service,docker,apache.timer",
|
|
||||||
unprefixedEnv: "",
|
|
||||||
expected: []string{"nginx.service", "docker.service", "apache.timer"},
|
|
||||||
cleanupEnvVars: true,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// Clean up any existing env vars
|
||||||
|
os.Unsetenv("BESZEL_AGENT_SERVICE_PATTERNS")
|
||||||
|
os.Unsetenv("SERVICE_PATTERNS")
|
||||||
|
|
||||||
// Set up environment variables
|
// Set up environment variables
|
||||||
if tt.prefixedEnv != "" {
|
if tt.prefixedEnv != "" {
|
||||||
t.Setenv("BESZEL_AGENT_SERVICE_PATTERNS", tt.prefixedEnv)
|
os.Setenv("BESZEL_AGENT_SERVICE_PATTERNS", tt.prefixedEnv)
|
||||||
}
|
}
|
||||||
if tt.unprefixedEnv != "" {
|
if tt.unprefixedEnv != "" {
|
||||||
t.Setenv("SERVICE_PATTERNS", tt.unprefixedEnv)
|
os.Setenv("SERVICE_PATTERNS", tt.unprefixedEnv)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run the function
|
// Run the function
|
||||||
@@ -180,6 +177,12 @@ func TestGetServicePatterns(t *testing.T) {
|
|||||||
|
|
||||||
// Verify results
|
// Verify results
|
||||||
assert.Equal(t, tt.expected, result, "Patterns should match expected values")
|
assert.Equal(t, tt.expected, result, "Patterns should match expected values")
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
if tt.cleanupEnvVars {
|
||||||
|
os.Unsetenv("BESZEL_AGENT_SERVICE_PATTERNS")
|
||||||
|
os.Unsetenv("SERVICE_PATTERNS")
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
{
|
|
||||||
"json_format_version": [1, 0],
|
|
||||||
"smartctl": {
|
|
||||||
"version": [7, 4],
|
|
||||||
"argv": ["smartctl", "-aix", "-j", "IOService:/AppleARMPE/arm-io@10F00000/AppleT810xIO/ans@77400000/AppleASCWrapV4/iop-ans-nub/RTBuddy(ANS2)/RTBuddyService/AppleANS3NVMeController/NS_01@1"],
|
|
||||||
"exit_status": 4
|
|
||||||
},
|
|
||||||
"device": {
|
|
||||||
"name": "IOService:/AppleARMPE/arm-io@10F00000/AppleT810xIO/ans@77400000/AppleASCWrapV4/iop-ans-nub/RTBuddy(ANS2)/RTBuddyService/AppleANS3NVMeController/NS_01@1",
|
|
||||||
"info_name": "IOService:/AppleARMPE/arm-io@10F00000/AppleT810xIO/ans@77400000/AppleASCWrapV4/iop-ans-nub/RTBuddy(ANS2)/RTBuddyService/AppleANS3NVMeController/NS_01@1",
|
|
||||||
"type": "nvme",
|
|
||||||
"protocol": "NVMe"
|
|
||||||
},
|
|
||||||
"model_name": "APPLE SSD AP0256Q",
|
|
||||||
"serial_number": "0ba0147940253c15",
|
|
||||||
"firmware_version": "555",
|
|
||||||
"smart_support": {
|
|
||||||
"available": true,
|
|
||||||
"enabled": true
|
|
||||||
},
|
|
||||||
"smart_status": {
|
|
||||||
"passed": true,
|
|
||||||
"nvme": {
|
|
||||||
"value": 0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"nvme_smart_health_information_log": {
|
|
||||||
"critical_warning": 0,
|
|
||||||
"temperature": 42,
|
|
||||||
"available_spare": 100,
|
|
||||||
"available_spare_threshold": 99,
|
|
||||||
"percentage_used": 1,
|
|
||||||
"data_units_read": 270189386,
|
|
||||||
"data_units_written": 166753862,
|
|
||||||
"host_reads": 7543766995,
|
|
||||||
"host_writes": 3761621926,
|
|
||||||
"controller_busy_time": 0,
|
|
||||||
"power_cycles": 366,
|
|
||||||
"power_on_hours": 2850,
|
|
||||||
"unsafe_shutdowns": 195,
|
|
||||||
"media_errors": 0,
|
|
||||||
"num_err_log_entries": 0
|
|
||||||
},
|
|
||||||
"temperature": {
|
|
||||||
"current": 42
|
|
||||||
},
|
|
||||||
"power_cycle_count": 366,
|
|
||||||
"power_on_time": {
|
|
||||||
"hours": 2850
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
//go:build linux
|
|
||||||
|
|
||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"math"
|
|
||||||
"os"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/shirou/gopsutil/v4/host"
|
|
||||||
)
|
|
||||||
|
|
||||||
// uptimeFilePath is a variable so tests can point it at a fixture.
|
|
||||||
var uptimeFilePath = "/proc/uptime"
|
|
||||||
|
|
||||||
// getUptime returns the system uptime in seconds.
|
|
||||||
//
|
|
||||||
// This reads /proc/uptime instead of using host.Uptime(), which calls the
|
|
||||||
// sysinfo(2) syscall. Inside an LXC container lxcfs virtualizes /proc/uptime
|
|
||||||
// but cannot intercept a syscall, so sysinfo(2) reports the host's uptime
|
|
||||||
// rather than the container's.
|
|
||||||
//
|
|
||||||
// Falls back to host.Uptime() if /proc/uptime is missing or unparseable, so
|
|
||||||
// behavior is unchanged anywhere the file isn't available.
|
|
||||||
func getUptime() (uint64, error) {
|
|
||||||
data, err := os.ReadFile(uptimeFilePath)
|
|
||||||
if err != nil {
|
|
||||||
return host.Uptime()
|
|
||||||
}
|
|
||||||
fields := strings.Fields(string(data))
|
|
||||||
if len(fields) == 0 {
|
|
||||||
return host.Uptime()
|
|
||||||
}
|
|
||||||
seconds, err := strconv.ParseFloat(fields[0], 64)
|
|
||||||
if err != nil ||
|
|
||||||
math.IsNaN(seconds) ||
|
|
||||||
math.IsInf(seconds, 0) ||
|
|
||||||
seconds < 0 ||
|
|
||||||
seconds >= 1<<64 {
|
|
||||||
return host.Uptime()
|
|
||||||
}
|
|
||||||
return uint64(seconds), nil
|
|
||||||
}
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
//go:build linux
|
|
||||||
|
|
||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestGetUptimeFromProc(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
contents string
|
|
||||||
want uint64
|
|
||||||
}{
|
|
||||||
{"typical", "12345.67 98765.43\n", 12345},
|
|
||||||
{"zero", "0.00 0.00\n", 0},
|
|
||||||
{"no trailing newline", "42.99 7.00", 42},
|
|
||||||
{"single field", "600.5", 600},
|
|
||||||
{"large value", "266030.12 1000000.00\n", 266030},
|
|
||||||
}
|
|
||||||
|
|
||||||
prev := uptimeFilePath
|
|
||||||
t.Cleanup(func() { uptimeFilePath = prev })
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
path := filepath.Join(t.TempDir(), "uptime")
|
|
||||||
if err := os.WriteFile(path, []byte(tt.contents), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
uptimeFilePath = path
|
|
||||||
|
|
||||||
got, err := getUptime()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("getUptime() returned error: %v", err)
|
|
||||||
}
|
|
||||||
if got != tt.want {
|
|
||||||
t.Errorf("getUptime() = %d, want %d", got, tt.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeUptime(contents string) func(t *testing.T) string {
|
|
||||||
return func(t *testing.T) string {
|
|
||||||
path := filepath.Join(t.TempDir(), "uptime")
|
|
||||||
if err := os.WriteFile(path, []byte(contents), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Malformed, missing, or out-of-range input must fall back to host.Uptime()
|
|
||||||
// rather than returning a bogus value, so the agent still reports something sane.
|
|
||||||
func TestGetUptimeFallsBack(t *testing.T) {
|
|
||||||
prev := uptimeFilePath
|
|
||||||
t.Cleanup(func() { uptimeFilePath = prev })
|
|
||||||
|
|
||||||
for _, tt := range []struct {
|
|
||||||
name string
|
|
||||||
prepare func(t *testing.T) string
|
|
||||||
}{
|
|
||||||
{"missing file", func(t *testing.T) string {
|
|
||||||
return filepath.Join(t.TempDir(), "does-not-exist")
|
|
||||||
}},
|
|
||||||
{"empty file", func(t *testing.T) string {
|
|
||||||
path := filepath.Join(t.TempDir(), "uptime")
|
|
||||||
if err := os.WriteFile(path, nil, 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
return path
|
|
||||||
}},
|
|
||||||
{"unparseable", func(t *testing.T) string {
|
|
||||||
path := filepath.Join(t.TempDir(), "uptime")
|
|
||||||
if err := os.WriteFile(path, []byte("not-a-number 1.0\n"), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
return path
|
|
||||||
}},
|
|
||||||
{"NaN", writeUptime("NaN 1.0\n")},
|
|
||||||
{"positive infinity", writeUptime("+Inf 1.0\n")},
|
|
||||||
{"negative infinity", writeUptime("-Inf 1.0\n")},
|
|
||||||
{"negative", writeUptime("-42.5 1.0\n")},
|
|
||||||
{"exceeds uint64 range", writeUptime("1e20 1.0\n")},
|
|
||||||
} {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
uptimeFilePath = tt.prepare(t)
|
|
||||||
|
|
||||||
got, err := getUptime()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("getUptime() returned error: %v", err)
|
|
||||||
}
|
|
||||||
if got == 0 {
|
|
||||||
t.Error("getUptime() = 0, expected fallback to host.Uptime()")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
//go:build !linux
|
|
||||||
|
|
||||||
package agent
|
|
||||||
|
|
||||||
import "github.com/shirou/gopsutil/v4/host"
|
|
||||||
|
|
||||||
// getUptime returns the system uptime in seconds.
|
|
||||||
func getUptime() (uint64, error) {
|
|
||||||
return host.Uptime()
|
|
||||||
}
|
|
||||||
15
agent/utils.go
Normal file
15
agent/utils.go
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import "math"
|
||||||
|
|
||||||
|
func bytesToMegabytes(b float64) float64 {
|
||||||
|
return twoDecimals(b / 1048576)
|
||||||
|
}
|
||||||
|
|
||||||
|
func bytesToGigabytes(b uint64) float64 {
|
||||||
|
return twoDecimals(float64(b) / 1073741824)
|
||||||
|
}
|
||||||
|
|
||||||
|
func twoDecimals(value float64) float64 {
|
||||||
|
return math.Round(value*100) / 100
|
||||||
|
}
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
// Package utils provides utility functions for the agent.
|
|
||||||
package utils
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"math"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"runtime"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// GetEnv retrieves an environment variable with a "BESZEL_AGENT_" prefix, or falls back to the unprefixed key.
|
|
||||||
func GetEnv(key string) (value string, exists bool) {
|
|
||||||
if value, exists = os.LookupEnv("BESZEL_AGENT_" + key); exists {
|
|
||||||
return value, exists
|
|
||||||
}
|
|
||||||
return os.LookupEnv(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
// BytesToMegabytes converts bytes to megabytes and rounds to two decimal places.
|
|
||||||
func BytesToMegabytes(b float64) float64 {
|
|
||||||
return TwoDecimals(b / 1048576)
|
|
||||||
}
|
|
||||||
|
|
||||||
// BytesToGigabytes converts bytes to gigabytes and rounds to two decimal places.
|
|
||||||
func BytesToGigabytes(b uint64) float64 {
|
|
||||||
return TwoDecimals(float64(b) / 1073741824)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TwoDecimals rounds a float64 value to two decimal places.
|
|
||||||
func TwoDecimals(value float64) float64 {
|
|
||||||
return math.Round(value*100) / 100
|
|
||||||
}
|
|
||||||
|
|
||||||
// func RoundFloat(val float64, precision uint) float64 {
|
|
||||||
// ratio := math.Pow(10, float64(precision))
|
|
||||||
// return math.Round(val*ratio) / ratio
|
|
||||||
// }
|
|
||||||
|
|
||||||
// ReadStringFile returns trimmed file contents or empty string on error.
|
|
||||||
func ReadStringFile(path string) string {
|
|
||||||
content, _ := ReadStringFileOK(path)
|
|
||||||
return content
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReadStringFileOK returns trimmed file contents and read success.
|
|
||||||
func ReadStringFileOK(path string) (string, bool) {
|
|
||||||
b, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(string(b)), true
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReadStringFileLimited reads a file into a string with a maximum size (in bytes) to avoid
|
|
||||||
// allocating large buffers and potential panics with pseudo-files when the size is misreported.
|
|
||||||
func ReadStringFileLimited(path string, maxSize int) (string, error) {
|
|
||||||
f, err := os.Open(path)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
|
|
||||||
buf := make([]byte, maxSize)
|
|
||||||
n, err := f.Read(buf)
|
|
||||||
if err != nil && err != io.EOF {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if n < 0 {
|
|
||||||
return "", fmt.Errorf("%s returned negative bytes: %d", path, n)
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(string(buf[:n])), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// FileExists reports whether the given path exists.
|
|
||||||
func FileExists(path string) bool {
|
|
||||||
_, err := os.Stat(path)
|
|
||||||
return err == nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReadUintFile parses a decimal uint64 value from a file.
|
|
||||||
func ReadUintFile(path string) (uint64, bool) {
|
|
||||||
raw, ok := ReadStringFileOK(path)
|
|
||||||
if !ok {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
parsed, err := strconv.ParseUint(raw, 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
return parsed, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// LookPathHomebrew is like exec.LookPath but also checks Homebrew paths.
|
|
||||||
func LookPathHomebrew(file string) (string, error) {
|
|
||||||
foundPath, lookPathErr := exec.LookPath(file)
|
|
||||||
if lookPathErr == nil {
|
|
||||||
return foundPath, nil
|
|
||||||
}
|
|
||||||
var homebrewPath string
|
|
||||||
switch runtime.GOOS {
|
|
||||||
case "darwin":
|
|
||||||
homebrewPath = filepath.Join("/opt", "homebrew", "bin", file)
|
|
||||||
case "linux":
|
|
||||||
homebrewPath = filepath.Join("/home", "linuxbrew", ".linuxbrew", "bin", file)
|
|
||||||
}
|
|
||||||
if homebrewPath != "" {
|
|
||||||
if _, err := os.Stat(homebrewPath); err == nil {
|
|
||||||
return homebrewPath, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "", lookPathErr
|
|
||||||
}
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
package utils
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestTwoDecimals(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
input float64
|
|
||||||
expected float64
|
|
||||||
}{
|
|
||||||
{"round down", 1.234, 1.23},
|
|
||||||
{"round half up", 1.235, 1.24}, // math.Round rounds half up
|
|
||||||
{"no rounding needed", 1.23, 1.23},
|
|
||||||
{"negative number", -1.235, -1.24}, // math.Round rounds half up (more negative)
|
|
||||||
{"zero", 0.0, 0.0},
|
|
||||||
{"large number", 123.456, 123.46}, // rounds 5 up
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
result := TwoDecimals(tt.input)
|
|
||||||
assert.Equal(t, tt.expected, result)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBytesToMegabytes(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
input float64
|
|
||||||
expected float64
|
|
||||||
}{
|
|
||||||
{"1 MB", 1048576, 1.0},
|
|
||||||
{"512 KB", 524288, 0.5},
|
|
||||||
{"zero", 0, 0},
|
|
||||||
{"large value", 1073741824, 1024}, // 1 GB = 1024 MB
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
result := BytesToMegabytes(tt.input)
|
|
||||||
assert.Equal(t, tt.expected, result)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBytesToGigabytes(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
input uint64
|
|
||||||
expected float64
|
|
||||||
}{
|
|
||||||
{"1 GB", 1073741824, 1.0},
|
|
||||||
{"512 MB", 536870912, 0.5},
|
|
||||||
{"0 GB", 0, 0},
|
|
||||||
{"2 GB", 2147483648, 2.0},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
result := BytesToGigabytes(tt.input)
|
|
||||||
assert.Equal(t, tt.expected, result)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFileFunctions(t *testing.T) {
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
testFilePath := filepath.Join(tmpDir, "test.txt")
|
|
||||||
testContent := "hello world"
|
|
||||||
|
|
||||||
// Test FileExists (false)
|
|
||||||
assert.False(t, FileExists(testFilePath))
|
|
||||||
|
|
||||||
// Test ReadStringFileOK (false)
|
|
||||||
content, ok := ReadStringFileOK(testFilePath)
|
|
||||||
assert.False(t, ok)
|
|
||||||
assert.Empty(t, content)
|
|
||||||
|
|
||||||
// Test ReadStringFile (empty)
|
|
||||||
assert.Empty(t, ReadStringFile(testFilePath))
|
|
||||||
|
|
||||||
// Write file
|
|
||||||
err := os.WriteFile(testFilePath, []byte(testContent+"\n "), 0644)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Test FileExists (true)
|
|
||||||
assert.True(t, FileExists(testFilePath))
|
|
||||||
|
|
||||||
// Test ReadStringFileOK (true)
|
|
||||||
content, ok = ReadStringFileOK(testFilePath)
|
|
||||||
assert.True(t, ok)
|
|
||||||
assert.Equal(t, testContent, content)
|
|
||||||
|
|
||||||
// Test ReadStringFile (content)
|
|
||||||
assert.Equal(t, testContent, ReadStringFile(testFilePath))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReadUintFile(t *testing.T) {
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
|
|
||||||
t.Run("valid uint", func(t *testing.T) {
|
|
||||||
path := filepath.Join(tmpDir, "uint.txt")
|
|
||||||
os.WriteFile(path, []byte(" 12345\n"), 0644)
|
|
||||||
val, ok := ReadUintFile(path)
|
|
||||||
assert.True(t, ok)
|
|
||||||
assert.Equal(t, uint64(12345), val)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("invalid uint", func(t *testing.T) {
|
|
||||||
path := filepath.Join(tmpDir, "invalid.txt")
|
|
||||||
os.WriteFile(path, []byte("abc"), 0644)
|
|
||||||
val, ok := ReadUintFile(path)
|
|
||||||
assert.False(t, ok)
|
|
||||||
assert.Equal(t, uint64(0), val)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("missing file", func(t *testing.T) {
|
|
||||||
path := filepath.Join(tmpDir, "missing.txt")
|
|
||||||
val, ok := ReadUintFile(path)
|
|
||||||
assert.False(t, ok)
|
|
||||||
assert.Equal(t, uint64(0), val)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetEnv(t *testing.T) {
|
|
||||||
key := "TEST_VAR"
|
|
||||||
prefixedKey := "BESZEL_AGENT_" + key
|
|
||||||
|
|
||||||
t.Run("prefixed variable exists", func(t *testing.T) {
|
|
||||||
t.Setenv(prefixedKey, "prefixed_val")
|
|
||||||
t.Setenv(key, "unprefixed_val")
|
|
||||||
|
|
||||||
val, exists := GetEnv(key)
|
|
||||||
assert.True(t, exists)
|
|
||||||
assert.Equal(t, "prefixed_val", val)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("only unprefixed variable exists", func(t *testing.T) {
|
|
||||||
t.Setenv(key, "unprefixed_val")
|
|
||||||
|
|
||||||
val, exists := GetEnv(key)
|
|
||||||
assert.True(t, exists)
|
|
||||||
assert.Equal(t, "unprefixed_val", val)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("neither variable exists", func(t *testing.T) {
|
|
||||||
val, exists := GetEnv(key)
|
|
||||||
assert.False(t, exists)
|
|
||||||
assert.Empty(t, val)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,7 @@ import "github.com/blang/semver"
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// Version is the current version of the application.
|
// Version is the current version of the application.
|
||||||
Version = "0.18.8"
|
Version = "0.18.4"
|
||||||
// AppName is the name of the application.
|
// AppName is the name of the application.
|
||||||
AppName = "beszel"
|
AppName = "beszel"
|
||||||
)
|
)
|
||||||
|
|||||||
78
go.mod
78
go.mod
@@ -1,67 +1,75 @@
|
|||||||
module github.com/henrygd/beszel
|
module github.com/henrygd/beszel
|
||||||
|
|
||||||
go 1.26.6
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/blang/semver v3.5.1+incompatible
|
github.com/blang/semver v3.5.1+incompatible
|
||||||
github.com/coreos/go-systemd/v22 v22.7.0
|
github.com/coreos/go-systemd/v22 v22.7.0
|
||||||
github.com/ebitengine/purego v0.10.2
|
github.com/distatus/battery v0.11.0
|
||||||
github.com/fxamacker/cbor/v2 v2.9.2
|
github.com/ebitengine/purego v0.9.1
|
||||||
|
github.com/fxamacker/cbor/v2 v2.9.0
|
||||||
github.com/gliderlabs/ssh v0.3.8
|
github.com/gliderlabs/ssh v0.3.8
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/lxzan/gws v1.10.1
|
github.com/luthermonson/go-proxmox v0.4.0
|
||||||
github.com/nicholas-fedor/shoutrrr v0.17.0
|
github.com/lxzan/gws v1.8.9
|
||||||
|
github.com/nicholas-fedor/shoutrrr v0.13.2
|
||||||
github.com/pocketbase/dbx v1.12.0
|
github.com/pocketbase/dbx v1.12.0
|
||||||
github.com/pocketbase/pocketbase v0.39.11
|
github.com/pocketbase/pocketbase v0.36.4
|
||||||
github.com/shirou/gopsutil/v4 v4.26.7
|
github.com/shirou/gopsutil/v4 v4.26.1
|
||||||
github.com/spf13/cast v1.10.0
|
github.com/spf13/cast v1.10.0
|
||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
github.com/spf13/pflag v1.0.10
|
github.com/spf13/pflag v1.0.10
|
||||||
github.com/stretchr/testify v1.12.0
|
github.com/stretchr/testify v1.11.1
|
||||||
golang.org/x/crypto v0.55.0
|
golang.org/x/crypto v0.48.0
|
||||||
golang.org/x/exp v0.0.0-20260813180055-c1d0aacb2297
|
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa
|
||||||
golang.org/x/net v0.58.0
|
golang.org/x/sys v0.41.0
|
||||||
golang.org/x/sys v0.47.0
|
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
howett.net/plist v1.0.1
|
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
|
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
|
||||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
|
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
|
||||||
|
github.com/buger/goterm v1.0.4 // indirect
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
github.com/disintegration/imaging v1.6.2 // indirect
|
github.com/disintegration/imaging v1.6.2 // indirect
|
||||||
|
github.com/diskfs/go-diskfs v1.7.0 // indirect
|
||||||
|
github.com/djherbis/times v1.6.0 // indirect
|
||||||
|
github.com/dolthub/maphash v0.1.0 // indirect
|
||||||
github.com/domodwyer/mailyak/v3 v3.6.2 // indirect
|
github.com/domodwyer/mailyak/v3 v3.6.2 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/eclipse/paho.golang v0.23.0 // indirect
|
github.com/fatih/color v1.18.0 // indirect
|
||||||
github.com/fatih/color v1.19.0 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||||
github.com/fsnotify/fsnotify v1.10.1 // indirect
|
github.com/ganigeorgiev/fexpr v0.5.0 // 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-ole/go-ole v1.3.0 // indirect
|
||||||
|
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect
|
||||||
github.com/go-sql-driver/mysql v1.9.1 // indirect
|
github.com/go-sql-driver/mysql v1.9.1 // indirect
|
||||||
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||||
github.com/gorilla/websocket v1.5.3 // indirect
|
github.com/gorilla/websocket v1.4.2 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/klauspost/compress v1.19.2 // indirect
|
github.com/jinzhu/copier v0.3.4 // indirect
|
||||||
github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 // indirect
|
github.com/klauspost/compress v1.18.4 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.15 // indirect
|
github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
github.com/magefile/mage v1.14.0 // indirect
|
||||||
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
github.com/pocketbase/ozzo-validation/v4 v4.3.0 // indirect
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6 // indirect
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/tklauser/go-sysconf v0.4.0 // indirect
|
github.com/tklauser/go-sysconf v0.3.16 // indirect
|
||||||
github.com/tklauser/numcpus v0.12.0 // indirect
|
github.com/tklauser/numcpus v0.11.0 // indirect
|
||||||
github.com/x448/float16 v0.8.4 // indirect
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
golang.org/x/image v0.45.0 // indirect
|
golang.org/x/image v0.36.0 // indirect
|
||||||
golang.org/x/oauth2 v0.36.0 // indirect
|
golang.org/x/net v0.50.0 // indirect
|
||||||
golang.org/x/sync v0.22.0 // indirect
|
golang.org/x/oauth2 v0.35.0 // indirect
|
||||||
golang.org/x/term v0.45.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/text v0.41.0 // indirect
|
golang.org/x/term v0.40.0 // indirect
|
||||||
modernc.org/libc v1.74.1 // indirect
|
golang.org/x/text v0.34.0 // indirect
|
||||||
|
howett.net/plist v1.0.1 // indirect
|
||||||
|
modernc.org/libc v1.67.6 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.12.0 // indirect
|
modernc.org/memory v1.11.0 // indirect
|
||||||
modernc.org/sqlite v1.55.0 // indirect
|
modernc.org/sqlite v1.45.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
230
go.sum
230
go.sum
@@ -1,7 +1,9 @@
|
|||||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||||
github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
|
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
|
||||||
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||||
|
github.com/anchore/go-lzo v0.1.0 h1:NgAacnzqPeGH49Ky19QKLBZEuFRqtTG9cdaucc3Vncs=
|
||||||
|
github.com/anchore/go-lzo v0.1.0/go.mod h1:3kLx0bve2oN1iDwgM1U5zGku1Tfbdb0No5qp1eL1fIk=
|
||||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
|
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
|
||||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
|
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
|
||||||
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=
|
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=
|
||||||
@@ -9,44 +11,58 @@ github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3d
|
|||||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
|
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
|
||||||
github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ=
|
github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ=
|
||||||
github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
|
github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
|
||||||
|
github.com/buger/goterm v1.0.4 h1:Z9YvGmOih81P0FbVtEYTFF6YsSgxSUKEhf/f9bTMXbY=
|
||||||
|
github.com/buger/goterm v1.0.4/go.mod h1:HiFWV3xnkolgrBV3mY8m0X0Pumt4zg4QhbdOzQtB8tE=
|
||||||
github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA=
|
github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA=
|
||||||
github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w=
|
github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||||
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||||
|
github.com/diskfs/go-diskfs v1.7.0 h1:vonWmt5CMowXwUc79jWyGrf2DIMeoOjkLlMnQYGVOs8=
|
||||||
|
github.com/diskfs/go-diskfs v1.7.0/go.mod h1:LhQyXqOugWFRahYUSw47NyZJPezFzB9UELwhpszLP/k=
|
||||||
|
github.com/distatus/battery v0.11.0 h1:KJk89gz90Iq/wJtbjjM9yUzBXV+ASV/EG2WOOL7N8lc=
|
||||||
|
github.com/distatus/battery v0.11.0/go.mod h1:KmVkE8A8hpIX4T78QRdMktYpEp35QfOL8A8dwZBxq2k=
|
||||||
|
github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=
|
||||||
|
github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0=
|
||||||
|
github.com/dolthub/maphash v0.1.0 h1:bsQ7JsF4FkkWyrP3oCnFJgrCUAFbFf3kOl4L/QxPDyQ=
|
||||||
|
github.com/dolthub/maphash v0.1.0/go.mod h1:gkg4Ch4CdCDu5h6PMriVLawB7koZ+5ijb9puGMV50a4=
|
||||||
github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8=
|
github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8=
|
||||||
github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c=
|
github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/ebitengine/purego v0.10.2 h1:W809HbnvzAxgdm+aOvlSekrM16wGCdT/e76+9tS7gzE=
|
github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
|
||||||
github.com/ebitengine/purego v0.10.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||||
github.com/eclipse/paho.golang v0.23.0 h1:KHgl2wz6EJo7cMBmkuhpt7C576vP+kpPv7jjvSyR6Mk=
|
github.com/elliotwutingfeng/asciiset v0.0.0-20230602022725-51bbb787efab h1:h1UgjJdAAhj+uPL68n7XASS6bU+07ZX1WJvVS2eyoeY=
|
||||||
github.com/eclipse/paho.golang v0.23.0/go.mod h1:nQRhTkoZv8EAiNs5UU0/WdQIx2NrnWUpL9nsGJTQN04=
|
github.com/elliotwutingfeng/asciiset v0.0.0-20230602022725-51bbb787efab/go.mod h1:GLo/8fDswSAniFG+BFIaiSPcK610jyzgEhWYPQwuQdw=
|
||||||
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
|
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||||
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
|
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||||
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||||
github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
|
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||||
github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI=
|
github.com/ganigeorgiev/fexpr v0.5.0 h1:XA9JxtTE/Xm+g/JFI6RfZEHSiQlk+1glLvRK1Lpv/Tk=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ=
|
github.com/ganigeorgiev/fexpr v0.5.0/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
|
||||||
github.com/ganigeorgiev/fexpr v0.6.0 h1:Fza3O/QMBKEudUvxV862qe6GjxM60GJjjKytdp+VQus=
|
|
||||||
github.com/ganigeorgiev/fexpr v0.6.0/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
|
|
||||||
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
||||||
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
||||||
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||||
|
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 h1:byhDUpfEwjsVQb1vBunvIjh2BHQ9ead57VkAEY4V+Es=
|
||||||
|
github.com/go-ozzo/ozzo-validation/v4 v4.3.0/go.mod h1:2NKgrcHl3z6cJs+3Oo940FPRiTzuqKbvfrL2RxCj6Ew=
|
||||||
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
|
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
|
||||||
github.com/go-sql-driver/mysql v1.9.1 h1:FrjNGn/BsJQjVRuSa8CBrM5BWA9BWoXXat3KrtSb/iI=
|
github.com/go-sql-driver/mysql v1.9.1 h1:FrjNGn/BsJQjVRuSa8CBrM5BWA9BWoXXat3KrtSb/iI=
|
||||||
github.com/go-sql-driver/mysql v1.9.1/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
github.com/go-sql-driver/mysql v1.9.1/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||||
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||||
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
||||||
|
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
|
||||||
|
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||||
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
||||||
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||||
@@ -54,57 +70,73 @@ github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArs
|
|||||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc=
|
||||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
|
||||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
|
github.com/h2non/gock v1.2.0 h1:K6ol8rfrRkUOefooBC8elXoaNGYkpp7y2qcxGG6BzUE=
|
||||||
|
github.com/h2non/gock v1.2.0/go.mod h1:tNhoxHYW2W42cYkYb1WqzdbYIieALC99kpYr7rH/BQk=
|
||||||
|
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw=
|
||||||
|
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
github.com/jarcoal/httpmock v1.4.2 h1:dKwiP/9zITCPfBLsDn3kchbSOu16JrnxtVEmL0fPRcI=
|
github.com/jarcoal/httpmock v1.4.1 h1:0Ju+VCFuARfFlhVXFc2HxlcQkfB+Xq12/EotHko+x2A=
|
||||||
github.com/jarcoal/httpmock v1.4.2/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0=
|
github.com/jarcoal/httpmock v1.4.1/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0=
|
||||||
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||||
github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8=
|
github.com/jinzhu/copier v0.3.4 h1:mfU6jI9PtCeUjkjQ322dlff9ELjGDu975C2p/nrubVI=
|
||||||
github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
github.com/jinzhu/copier v0.3.4/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg=
|
||||||
|
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
|
||||||
|
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 h1:eveIIGn4BGM3qknO74omf6HYr30/exH+eVUTuAgwjZ0=
|
github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 h1:PTw+yKnXcOFCR6+8hHTyWBeQ/P4Nb7dd4/0ohEcWQuM=
|
||||||
github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||||
github.com/lxzan/gws v1.10.1 h1:1xG+tDOV0lgDeVPf0wNT74u3cn0K3LpcavRrTPTrMwQ=
|
github.com/luthermonson/go-proxmox v0.4.0 h1:LKXpG9d64zTaQF79wV0kfOnnSwIcdG39m7sc4ga+XZs=
|
||||||
github.com/lxzan/gws v1.10.1/go.mod h1:gXHSCPmTGryWJ4icuqy8Yho32E4YIMHH0fkDRYJRbdc=
|
github.com/luthermonson/go-proxmox v0.4.0/go.mod h1:U6dAkJ+iiwaeb1g/LMWpWuWN4nmvWeXhmoMuYJMumS4=
|
||||||
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
|
github.com/lxzan/gws v1.8.9 h1:VU3SGUeWlQrEwfUSfokcZep8mdg/BrUF+y73YYshdBM=
|
||||||
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
github.com/lxzan/gws v1.8.9/go.mod h1:d9yHaR1eDTBHagQC6KY7ycUOaz5KWeqQtP3xu7aMK8Y=
|
||||||
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
github.com/magefile/mage v1.14.0 h1:6QDX3g6z1YvJ4olPhT1wksUcSa/V0a1B+pJb73fBjyo=
|
||||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
github.com/magefile/mage v1.14.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
|
||||||
|
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
|
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
github.com/nicholas-fedor/shoutrrr v0.17.0 h1:xfp3z5QbE8jXvUhUEwWDk47SJ/b912VoB8MJJDU+q4E=
|
github.com/nicholas-fedor/shoutrrr v0.13.2 h1:hfsYBIqSFYGg92pZP5CXk/g7/OJIkLYmiUnRl+AD1IA=
|
||||||
github.com/nicholas-fedor/shoutrrr v0.17.0/go.mod h1:s4ldyLs6uwBy9lIjYrY+8lyTqJtPvZSrILw0CyMLock=
|
github.com/nicholas-fedor/shoutrrr v0.13.2/go.mod h1:ZqzV3gY/Wj6AvWs1etlO7+yKbh4iptSbeL8avBpMQbA=
|
||||||
github.com/onsi/ginkgo/v2 v2.32.0 h1:Hw7s2pVrQo/8Yz5N77qdnpHaoc+c6cC9WIV1Jce+J6E=
|
github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI=
|
||||||
github.com/onsi/ginkgo/v2 v2.32.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44=
|
github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE=
|
||||||
github.com/onsi/gomega v1.42.1 h1:iN1rCUX+44NZ1Dc97MPoeFYbFR0vh8zxoxMFwKdyZ6I=
|
github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28=
|
||||||
github.com/onsi/gomega v1.42.1/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg=
|
github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg=
|
||||||
|
github.com/pierrec/lz4/v4 v4.1.17 h1:kV4Ip+/hUBC+8T6+2EgburRtkE9ef4nbY3f4dFhGjMc=
|
||||||
|
github.com/pierrec/lz4/v4 v4.1.17/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||||
|
github.com/pkg/xattr v0.4.9 h1:5883YPCtkSd8LFbs13nXplj9g9tlrwoJRjgpgMu1/fE=
|
||||||
|
github.com/pkg/xattr v0.4.9/go.mod h1:di8WF84zAKk8jzR1UBTEWh9AUlIZZ7M/JNt8e9B6ktU=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.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 h1:/oLErM+A0b4xI0PWTGPqSDVjzix48PqI/bng2l0PzoA=
|
||||||
github.com/pocketbase/dbx v1.12.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
github.com/pocketbase/dbx v1.12.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
||||||
github.com/pocketbase/ozzo-validation/v4 v4.3.0 h1:uKBDVma7bZqgR2a6AwE+k9hkuDFfiZMpBHQdZ1z3iQs=
|
github.com/pocketbase/pocketbase v0.36.4 h1:zTjRZbp2WfTOJJfb+pFRWa200UaQwxZYt8RzkFMlAZ4=
|
||||||
github.com/pocketbase/ozzo-validation/v4 v4.3.0/go.mod h1:6XNjSTw/Jb2F8LOkKO3oyzIWExbrGiYoS4uVxVwz90g=
|
github.com/pocketbase/pocketbase v0.36.4/go.mod h1:9CiezhRudd9FZGa5xZa53QZBTNxc5vvw/FGG+diAECI=
|
||||||
github.com/pocketbase/pocketbase v0.39.11 h1:cl/Kh13ukof/4BAEku3OozYrLl85M5/bmH62Ny4szFc=
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||||
github.com/pocketbase/pocketbase v0.39.11/go.mod h1:5CaCvp/52fZJ5/qyYsqpCGyVue/kjez889cQAATD2cY=
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||||
github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6 h1:jL3a8soXdzuTCcRnKhOmtcsVOObdDTFf4O2B403HPRU=
|
|
||||||
github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/shirou/gopsutil/v4 v4.26.7 h1:IXzpHz/dkMRYAhKkOXr1HB6SuzWU3eoyyeWe7g3bNZc=
|
github.com/shirou/gopsutil/v4 v4.26.1 h1:TOkEyriIXk2HX9d4isZJtbjXbEjf5qyKPAzbzY0JWSo=
|
||||||
github.com/shirou/gopsutil/v4 v4.26.7/go.mod h1:5O9FjBiXoTDFatIWjZZosqj4pV0DRtLx598xGbBehzM=
|
github.com/shirou/gopsutil/v4 v4.26.1/go.mod h1:medLI9/UNAb0dOI9Q3/7yWSqKkj00u+1tgY8nvv41pc=
|
||||||
|
github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af h1:Sp5TG9f7K39yfB+If0vjp97vuT74F72r8hfRpP8jLU0=
|
||||||
|
github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||||
@@ -113,57 +145,59 @@ github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An
|
|||||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
|
|
||||||
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
|
|
||||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
github.com/stretchr/testify v1.12.0 h1:K6Mr6jO9JICuend/5xzTM03ydSV3vdNRYAdPSukj8uI=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.12.0/go.mod h1:bOYBZb5qJ00vPzWfIqBUZPaxK8jWiXc6d3ErP4Ca9Gw=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU=
|
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
|
||||||
github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI=
|
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
|
||||||
github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4=
|
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
|
||||||
github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg=
|
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
|
||||||
|
github.com/ulikunitz/xz v0.5.11 h1:kpFauv27b6ynzBNT/Xy+1k+fK4WswhN/6PN5WhFAGw8=
|
||||||
|
github.com/ulikunitz/xz v0.5.11/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
|
||||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||||
golang.org/x/exp v0.0.0-20260813180055-c1d0aacb2297 h1:YXnL44eJ77R+ji4/ooy8UsXIhz+lbi2Qgdlc8iRN0gY=
|
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
|
||||||
golang.org/x/exp v0.0.0-20260813180055-c1d0aacb2297/go.mod h1:Mkmymgv+uMpSQ/XxJ/7GpdrdYoqm3u72jEbpCLiJmNk=
|
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
|
||||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||||
golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
|
golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
|
||||||
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
|
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
|
||||||
golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs=
|
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||||
golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE=
|
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||||
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
||||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210331175145-43e1dd70ce54/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||||
|
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
|
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||||
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
|
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||||
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||||
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
@@ -173,30 +207,30 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM=
|
howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM=
|
||||||
howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
|
howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
|
||||||
modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc=
|
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||||
modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
|
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
|
||||||
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
|
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
|
||||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
||||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
|
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
|
||||||
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ=
|
modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI=
|
||||||
modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os=
|
modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=
|
||||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
modernc.org/memory v1.12.0 h1:twkmYNkGXCvtYWzoux02jtK6eovjZbdI0uHFUYp6kuU=
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
modernc.org/memory v1.12.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
modernc.org/sqlite v1.55.0 h1:hIFh0MCH0rGinQ/4KYb5/UbCkRkb+UP+OkLCVWa5MTM=
|
modernc.org/sqlite v1.45.0 h1:r51cSGzKpbptxnby+EIIz5fop4VuE4qFoVEjNvWoObs=
|
||||||
modernc.org/sqlite v1.55.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=
|
modernc.org/sqlite v1.45.0/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
|
||||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
|||||||
@@ -21,9 +21,9 @@ type hubLike interface {
|
|||||||
|
|
||||||
type AlertManager struct {
|
type AlertManager struct {
|
||||||
hub hubLike
|
hub hubLike
|
||||||
stopOnce sync.Once
|
alertQueue chan alertTask
|
||||||
|
stopChan chan struct{}
|
||||||
pendingAlerts sync.Map
|
pendingAlerts sync.Map
|
||||||
alertsCache *AlertsCache
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type AlertMessageData struct {
|
type AlertMessageData struct {
|
||||||
@@ -40,23 +40,16 @@ type UserNotificationSettings struct {
|
|||||||
Webhooks []string `json:"webhooks"`
|
Webhooks []string `json:"webhooks"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SystemAlertFsStats struct {
|
|
||||||
DiskTotal float64 `json:"d"`
|
|
||||||
DiskUsed float64 `json:"du"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Values pulled from system_stats.stats that are relevant to alerts.
|
|
||||||
type SystemAlertStats struct {
|
type SystemAlertStats struct {
|
||||||
Cpu float64 `json:"cpu"`
|
Cpu float64 `json:"cpu"`
|
||||||
Mem float64 `json:"mp"`
|
Mem float64 `json:"mp"`
|
||||||
Disk float64 `json:"dp"`
|
Disk float64 `json:"dp"`
|
||||||
Bandwidth [2]uint64 `json:"b"`
|
NetSent float64 `json:"ns"`
|
||||||
|
NetRecv float64 `json:"nr"`
|
||||||
GPU map[string]SystemAlertGPUData `json:"g"`
|
GPU map[string]SystemAlertGPUData `json:"g"`
|
||||||
Temperatures map[string]float32 `json:"t"`
|
Temperatures map[string]float32 `json:"t"`
|
||||||
LoadAvg [3]float64 `json:"la"`
|
LoadAvg [3]float64 `json:"la"`
|
||||||
Battery [2]uint8 `json:"bat"`
|
Battery [2]uint8 `json:"bat"`
|
||||||
Batteries map[string]uint8 `json:"bats"`
|
|
||||||
ExtraFs map[string]SystemAlertFsStats `json:"efs"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type SystemAlertGPUData struct {
|
type SystemAlertGPUData struct {
|
||||||
@@ -65,7 +58,7 @@ type SystemAlertGPUData struct {
|
|||||||
|
|
||||||
type SystemAlertData struct {
|
type SystemAlertData struct {
|
||||||
systemRecord *core.Record
|
systemRecord *core.Record
|
||||||
alertData CachedAlertData
|
alertRecord *core.Record
|
||||||
name string
|
name string
|
||||||
unit string
|
unit string
|
||||||
val float64
|
val float64
|
||||||
@@ -99,10 +92,12 @@ var supportsTitle = map[string]struct{}{
|
|||||||
// NewAlertManager creates a new AlertManager instance.
|
// NewAlertManager creates a new AlertManager instance.
|
||||||
func NewAlertManager(app hubLike) *AlertManager {
|
func NewAlertManager(app hubLike) *AlertManager {
|
||||||
am := &AlertManager{
|
am := &AlertManager{
|
||||||
hub: app,
|
hub: app,
|
||||||
alertsCache: NewAlertsCache(app),
|
alertQueue: make(chan alertTask, 5),
|
||||||
|
stopChan: make(chan struct{}),
|
||||||
}
|
}
|
||||||
am.bindEvents()
|
am.bindEvents()
|
||||||
|
go am.startWorker()
|
||||||
return am
|
return am
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,19 +106,6 @@ func (am *AlertManager) bindEvents() {
|
|||||||
am.hub.OnRecordAfterUpdateSuccess("alerts").BindFunc(updateHistoryOnAlertUpdate)
|
am.hub.OnRecordAfterUpdateSuccess("alerts").BindFunc(updateHistoryOnAlertUpdate)
|
||||||
am.hub.OnRecordAfterDeleteSuccess("alerts").BindFunc(resolveHistoryOnAlertDelete)
|
am.hub.OnRecordAfterDeleteSuccess("alerts").BindFunc(resolveHistoryOnAlertDelete)
|
||||||
am.hub.OnRecordAfterUpdateSuccess("smart_devices").BindFunc(am.handleSmartDeviceAlert)
|
am.hub.OnRecordAfterUpdateSuccess("smart_devices").BindFunc(am.handleSmartDeviceAlert)
|
||||||
|
|
||||||
am.hub.OnServe().BindFunc(func(e *core.ServeEvent) error {
|
|
||||||
// Populate all alerts into cache on startup
|
|
||||||
_ = am.alertsCache.PopulateFromDB(true)
|
|
||||||
|
|
||||||
if err := resolveStatusAlerts(e.App); err != nil {
|
|
||||||
e.App.Logger().Error("Failed to resolve stale status alerts", "err", err)
|
|
||||||
}
|
|
||||||
if err := am.restorePendingStatusAlerts(); err != nil {
|
|
||||||
e.App.Logger().Error("Failed to restore pending status alerts", "err", err)
|
|
||||||
}
|
|
||||||
return e.Next()
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsNotificationSilenced checks if a notification should be silenced based on configured quiet hours
|
// IsNotificationSilenced checks if a notification should be silenced based on configured quiet hours
|
||||||
@@ -277,14 +259,13 @@ func (am *AlertManager) SendShoutrrrAlert(notificationUrl, title, message, link,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add link
|
// Add link
|
||||||
switch scheme {
|
if scheme == "ntfy" {
|
||||||
case "ntfy":
|
|
||||||
queryParams.Add("Actions", fmt.Sprintf("view, %s, %s", linkText, link))
|
queryParams.Add("Actions", fmt.Sprintf("view, %s, %s", linkText, link))
|
||||||
case "lark":
|
} else if scheme == "lark" {
|
||||||
queryParams.Add("link", link)
|
queryParams.Add("link", link)
|
||||||
case "bark":
|
} else if scheme == "bark" {
|
||||||
queryParams.Add("url", link)
|
queryParams.Add("url", link)
|
||||||
default:
|
} else {
|
||||||
message += "\n\n" + link
|
message += "\n\n" + link
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,12 +284,17 @@ func (am *AlertManager) SendShoutrrrAlert(notificationUrl, title, message, link,
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// setAlertTriggered updates the "triggered" status of an alert record in the database
|
func (am *AlertManager) SendTestNotification(e *core.RequestEvent) error {
|
||||||
func (am *AlertManager) setAlertTriggered(alert CachedAlertData, triggered bool) error {
|
var data struct {
|
||||||
alertRecord, err := am.hub.FindRecordById("alerts", alert.Id)
|
URL string `json:"url"`
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
alertRecord.Set("triggered", triggered)
|
err := e.BindBody(&data)
|
||||||
return am.hub.Save(alertRecord)
|
if err != nil || data.URL == "" {
|
||||||
|
return e.BadRequestError("URL is required", err)
|
||||||
|
}
|
||||||
|
err = am.SendShoutrrrAlert(data.URL, "Test Alert", "This is a notification from Beszel.", am.hub.Settings().Meta.AppURL, "View Beszel")
|
||||||
|
if err != nil {
|
||||||
|
return e.JSON(200, map[string]string{"err": err.Error()})
|
||||||
|
}
|
||||||
|
return e.JSON(200, map[string]bool{"err": false})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,7 @@ package alerts
|
|||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"net"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"slices"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/pocketbase/dbx"
|
"github.com/pocketbase/dbx"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
@@ -121,72 +117,3 @@ func DeleteUserAlerts(e *core.RequestEvent) error {
|
|||||||
|
|
||||||
return e.JSON(http.StatusOK, map[string]any{"success": true, "count": numDeleted})
|
return e.JSON(http.StatusOK, map[string]any{"success": true, "count": numDeleted})
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendTestNotification handles API request to send a test notification to a specified Shoutrrr URL
|
|
||||||
func (am *AlertManager) SendTestNotification(e *core.RequestEvent) error {
|
|
||||||
var data struct {
|
|
||||||
URL string `json:"url"`
|
|
||||||
}
|
|
||||||
err := e.BindBody(&data)
|
|
||||||
if err != nil || data.URL == "" {
|
|
||||||
return e.BadRequestError("URL is required", err)
|
|
||||||
}
|
|
||||||
// Only allow admins to send test notifications to internal URLs
|
|
||||||
if !e.Auth.IsSuperuser() && e.Auth.GetString("role") != "admin" {
|
|
||||||
internalURL, err := isInternalURL(data.URL)
|
|
||||||
if err != nil {
|
|
||||||
return e.BadRequestError(err.Error(), nil)
|
|
||||||
}
|
|
||||||
if internalURL {
|
|
||||||
return e.ForbiddenError("Only admins can send to internal destinations", nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
err = am.SendShoutrrrAlert(data.URL, "Test Alert", "This is a notification from Beszel.", am.hub.Settings().Meta.AppURL, "View Beszel")
|
|
||||||
if err != nil {
|
|
||||||
return e.JSON(200, map[string]string{"err": err.Error()})
|
|
||||||
}
|
|
||||||
return e.JSON(200, map[string]bool{"err": false})
|
|
||||||
}
|
|
||||||
|
|
||||||
// isInternalURL checks if the given shoutrrr URL points to an internal destination (localhost or private IP)
|
|
||||||
func isInternalURL(rawURL string) (bool, error) {
|
|
||||||
parsedURL, err := url.Parse(rawURL)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
host := parsedURL.Hostname()
|
|
||||||
if host == "" {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.EqualFold(host, "localhost") {
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if ip := net.ParseIP(host); ip != nil {
|
|
||||||
return isInternalIP(ip), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Some Shoutrrr URLs use the host position for service identifiers rather than a
|
|
||||||
// network hostname (for example, discord://token@webhookid). Restrict DNS lookups
|
|
||||||
// to names that look like actual hostnames so valid service URLs keep working.
|
|
||||||
if !strings.Contains(host, ".") {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
ips, err := net.LookupIP(host)
|
|
||||||
if err != nil {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if slices.ContainsFunc(ips, isInternalIP) {
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func isInternalIP(ip net.IP) bool {
|
|
||||||
return ip.IsPrivate() || ip.IsLoopback() || ip.IsUnspecified()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,501 +0,0 @@
|
|||||||
//go:build testing
|
|
||||||
|
|
||||||
package alerts_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/alerts"
|
|
||||||
beszelTests "github.com/henrygd/beszel/internal/tests"
|
|
||||||
pbTests "github.com/pocketbase/pocketbase/tests"
|
|
||||||
|
|
||||||
"github.com/pocketbase/dbx"
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
// marshal to json and return an io.Reader (for use in ApiScenario.Body)
|
|
||||||
func jsonReader(v any) io.Reader {
|
|
||||||
data, err := json.Marshal(v)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
return bytes.NewReader(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsInternalURL(t *testing.T) {
|
|
||||||
testCases := []struct {
|
|
||||||
name string
|
|
||||||
url string
|
|
||||||
internal bool
|
|
||||||
}{
|
|
||||||
{name: "loopback ipv4", url: "generic://127.0.0.1", internal: true},
|
|
||||||
{name: "localhost hostname", url: "generic://localhost", internal: true},
|
|
||||||
{name: "localhost hostname", url: "generic+http://localhost/api/v1/postStuff", internal: true},
|
|
||||||
{name: "localhost hostname", url: "generic+http://127.0.0.1:8080/api/v1/postStuff", internal: true},
|
|
||||||
{name: "localhost hostname", url: "generic+https://beszel.dev/api/v1/postStuff", internal: false},
|
|
||||||
{name: "public ipv4", url: "generic://8.8.8.8", internal: false},
|
|
||||||
{name: "token style service url", url: "discord://abc123@123456789", internal: false},
|
|
||||||
{name: "single label service url", url: "slack://token@team/channel", internal: false},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, testCase := range testCases {
|
|
||||||
t.Run(testCase.name, func(t *testing.T) {
|
|
||||||
internal, err := alerts.IsInternalURL(testCase.url)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Equal(t, testCase.internal, internal)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUserAlertsApi(t *testing.T) {
|
|
||||||
hub, _ := beszelTests.NewTestHub(t.TempDir())
|
|
||||||
defer hub.Cleanup()
|
|
||||||
|
|
||||||
hub.StartHub()
|
|
||||||
|
|
||||||
user1, _ := beszelTests.CreateUser(hub, "alertstest@example.com", "password")
|
|
||||||
user1Token, _ := user1.NewAuthToken()
|
|
||||||
|
|
||||||
user2, _ := beszelTests.CreateUser(hub, "alertstest2@example.com", "password")
|
|
||||||
user2Token, _ := user2.NewAuthToken()
|
|
||||||
|
|
||||||
system1, _ := beszelTests.CreateRecord(hub, "systems", map[string]any{
|
|
||||||
"name": "system1",
|
|
||||||
"users": []string{user1.Id},
|
|
||||||
"host": "127.0.0.1",
|
|
||||||
})
|
|
||||||
|
|
||||||
system2, _ := beszelTests.CreateRecord(hub, "systems", map[string]any{
|
|
||||||
"name": "system2",
|
|
||||||
"users": []string{user1.Id, user2.Id},
|
|
||||||
"host": "127.0.0.2",
|
|
||||||
})
|
|
||||||
|
|
||||||
userRecords, _ := hub.CountRecords("users")
|
|
||||||
assert.EqualValues(t, 2, userRecords, "all users should be created")
|
|
||||||
|
|
||||||
systemRecords, _ := hub.CountRecords("systems")
|
|
||||||
assert.EqualValues(t, 2, systemRecords, "all systems should be created")
|
|
||||||
|
|
||||||
testAppFactory := func(t testing.TB) *pbTests.TestApp {
|
|
||||||
return hub.TestApp
|
|
||||||
}
|
|
||||||
|
|
||||||
scenarios := []beszelTests.ApiScenario{
|
|
||||||
// {
|
|
||||||
// Name: "GET not implemented - returns index",
|
|
||||||
// Method: http.MethodGet,
|
|
||||||
// URL: "/api/beszel/user-alerts",
|
|
||||||
// ExpectedStatus: 200,
|
|
||||||
// ExpectedContent: []string{"<html ", "globalThis.BESZEL"},
|
|
||||||
// TestAppFactory: testAppFactory,
|
|
||||||
// },
|
|
||||||
{
|
|
||||||
Name: "POST no auth",
|
|
||||||
Method: http.MethodPost,
|
|
||||||
URL: "/api/beszel/user-alerts",
|
|
||||||
ExpectedStatus: 401,
|
|
||||||
ExpectedContent: []string{"requires valid"},
|
|
||||||
TestAppFactory: testAppFactory,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "POST no body",
|
|
||||||
Method: http.MethodPost,
|
|
||||||
URL: "/api/beszel/user-alerts",
|
|
||||||
Headers: map[string]string{
|
|
||||||
"Authorization": user1Token,
|
|
||||||
},
|
|
||||||
ExpectedStatus: 400,
|
|
||||||
ExpectedContent: []string{"Bad data"},
|
|
||||||
TestAppFactory: testAppFactory,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "POST bad data",
|
|
||||||
Method: http.MethodPost,
|
|
||||||
URL: "/api/beszel/user-alerts",
|
|
||||||
Headers: map[string]string{
|
|
||||||
"Authorization": user1Token,
|
|
||||||
},
|
|
||||||
ExpectedStatus: 400,
|
|
||||||
ExpectedContent: []string{"Bad data"},
|
|
||||||
TestAppFactory: testAppFactory,
|
|
||||||
Body: jsonReader(map[string]any{
|
|
||||||
"invalidField": "this should cause validation error",
|
|
||||||
"threshold": "not a number",
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "POST malformed JSON",
|
|
||||||
Method: http.MethodPost,
|
|
||||||
URL: "/api/beszel/user-alerts",
|
|
||||||
Headers: map[string]string{
|
|
||||||
"Authorization": user1Token,
|
|
||||||
},
|
|
||||||
ExpectedStatus: 400,
|
|
||||||
ExpectedContent: []string{"Bad data"},
|
|
||||||
TestAppFactory: testAppFactory,
|
|
||||||
Body: strings.NewReader(`{"alertType": "cpu", "threshold": 80, "enabled": true,}`),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "POST valid alert data multiple systems",
|
|
||||||
Method: http.MethodPost,
|
|
||||||
URL: "/api/beszel/user-alerts",
|
|
||||||
Headers: map[string]string{
|
|
||||||
"Authorization": user1Token,
|
|
||||||
},
|
|
||||||
ExpectedStatus: 200,
|
|
||||||
ExpectedContent: []string{"\"success\":true"},
|
|
||||||
TestAppFactory: testAppFactory,
|
|
||||||
Body: jsonReader(map[string]any{
|
|
||||||
"name": "CPU",
|
|
||||||
"value": 69,
|
|
||||||
"min": 9,
|
|
||||||
"systems": []string{system1.Id, system2.Id},
|
|
||||||
"overwrite": false,
|
|
||||||
}),
|
|
||||||
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
|
||||||
// check total alerts
|
|
||||||
alerts, _ := app.CountRecords("alerts")
|
|
||||||
assert.EqualValues(t, 2, alerts, "should have 2 alerts")
|
|
||||||
// check alert has correct values
|
|
||||||
matchingAlerts, _ := app.CountRecords("alerts", dbx.HashExp{"name": "CPU", "user": user1.Id, "system": system1.Id, "value": 69, "min": 9})
|
|
||||||
assert.EqualValues(t, 1, matchingAlerts, "should have 1 alert")
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "POST valid alert data single system",
|
|
||||||
Method: http.MethodPost,
|
|
||||||
URL: "/api/beszel/user-alerts",
|
|
||||||
Headers: map[string]string{
|
|
||||||
"Authorization": user1Token,
|
|
||||||
},
|
|
||||||
ExpectedStatus: 200,
|
|
||||||
ExpectedContent: []string{"\"success\":true"},
|
|
||||||
TestAppFactory: testAppFactory,
|
|
||||||
Body: jsonReader(map[string]any{
|
|
||||||
"name": "Memory",
|
|
||||||
"systems": []string{system1.Id},
|
|
||||||
"value": 90,
|
|
||||||
"min": 10,
|
|
||||||
}),
|
|
||||||
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
|
||||||
user1Alerts, _ := app.CountRecords("alerts", dbx.HashExp{"user": user1.Id})
|
|
||||||
assert.EqualValues(t, 3, user1Alerts, "should have 3 alerts")
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "Overwrite: false, should not overwrite existing alert",
|
|
||||||
Method: http.MethodPost,
|
|
||||||
URL: "/api/beszel/user-alerts",
|
|
||||||
Headers: map[string]string{
|
|
||||||
"Authorization": user1Token,
|
|
||||||
},
|
|
||||||
ExpectedStatus: 200,
|
|
||||||
ExpectedContent: []string{"\"success\":true"},
|
|
||||||
TestAppFactory: testAppFactory,
|
|
||||||
Body: jsonReader(map[string]any{
|
|
||||||
"name": "CPU",
|
|
||||||
"value": 45,
|
|
||||||
"min": 5,
|
|
||||||
"systems": []string{system1.Id},
|
|
||||||
"overwrite": false,
|
|
||||||
}),
|
|
||||||
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
|
||||||
beszelTests.ClearCollection(t, app, "alerts")
|
|
||||||
beszelTests.CreateRecord(app, "alerts", map[string]any{
|
|
||||||
"name": "CPU",
|
|
||||||
"system": system1.Id,
|
|
||||||
"user": user1.Id,
|
|
||||||
"value": 80,
|
|
||||||
"min": 10,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
|
||||||
alerts, _ := app.CountRecords("alerts")
|
|
||||||
assert.EqualValues(t, 1, alerts, "should have 1 alert")
|
|
||||||
alert, _ := app.FindFirstRecordByFilter("alerts", "name = 'CPU' && user = {:user}", dbx.Params{"user": user1.Id})
|
|
||||||
assert.EqualValues(t, 80, alert.Get("value"), "should have 80 as value")
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "Overwrite: true, should overwrite existing alert",
|
|
||||||
Method: http.MethodPost,
|
|
||||||
URL: "/api/beszel/user-alerts",
|
|
||||||
Headers: map[string]string{
|
|
||||||
"Authorization": user2Token,
|
|
||||||
},
|
|
||||||
ExpectedStatus: 200,
|
|
||||||
ExpectedContent: []string{"\"success\":true"},
|
|
||||||
TestAppFactory: testAppFactory,
|
|
||||||
Body: jsonReader(map[string]any{
|
|
||||||
"name": "CPU",
|
|
||||||
"value": 45,
|
|
||||||
"min": 5,
|
|
||||||
"systems": []string{system2.Id},
|
|
||||||
"overwrite": true,
|
|
||||||
}),
|
|
||||||
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
|
||||||
beszelTests.ClearCollection(t, app, "alerts")
|
|
||||||
beszelTests.CreateRecord(app, "alerts", map[string]any{
|
|
||||||
"name": "CPU",
|
|
||||||
"system": system2.Id,
|
|
||||||
"user": user2.Id,
|
|
||||||
"value": 80,
|
|
||||||
"min": 10,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
|
||||||
alerts, _ := app.CountRecords("alerts")
|
|
||||||
assert.EqualValues(t, 1, alerts, "should have 1 alert")
|
|
||||||
alert, _ := app.FindFirstRecordByFilter("alerts", "name = 'CPU' && user = {:user}", dbx.Params{"user": user2.Id})
|
|
||||||
assert.EqualValues(t, 45, alert.Get("value"), "should have 45 as value")
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "DELETE no auth",
|
|
||||||
Method: http.MethodDelete,
|
|
||||||
URL: "/api/beszel/user-alerts",
|
|
||||||
ExpectedStatus: 401,
|
|
||||||
ExpectedContent: []string{"requires valid"},
|
|
||||||
TestAppFactory: testAppFactory,
|
|
||||||
Body: jsonReader(map[string]any{
|
|
||||||
"name": "CPU",
|
|
||||||
"systems": []string{system1.Id},
|
|
||||||
}),
|
|
||||||
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
|
||||||
beszelTests.ClearCollection(t, app, "alerts")
|
|
||||||
beszelTests.CreateRecord(app, "alerts", map[string]any{
|
|
||||||
"name": "CPU",
|
|
||||||
"system": system1.Id,
|
|
||||||
"user": user1.Id,
|
|
||||||
"value": 80,
|
|
||||||
"min": 10,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
|
||||||
alerts, _ := app.CountRecords("alerts")
|
|
||||||
assert.EqualValues(t, 1, alerts, "should have 1 alert")
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "DELETE alert",
|
|
||||||
Method: http.MethodDelete,
|
|
||||||
URL: "/api/beszel/user-alerts",
|
|
||||||
Headers: map[string]string{
|
|
||||||
"Authorization": user1Token,
|
|
||||||
},
|
|
||||||
ExpectedStatus: 200,
|
|
||||||
ExpectedContent: []string{"\"count\":1", "\"success\":true"},
|
|
||||||
TestAppFactory: testAppFactory,
|
|
||||||
Body: jsonReader(map[string]any{
|
|
||||||
"name": "CPU",
|
|
||||||
"systems": []string{system1.Id},
|
|
||||||
}),
|
|
||||||
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
|
||||||
beszelTests.ClearCollection(t, app, "alerts")
|
|
||||||
beszelTests.CreateRecord(app, "alerts", map[string]any{
|
|
||||||
"name": "CPU",
|
|
||||||
"system": system1.Id,
|
|
||||||
"user": user1.Id,
|
|
||||||
"value": 80,
|
|
||||||
"min": 10,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
|
||||||
alerts, _ := app.CountRecords("alerts")
|
|
||||||
assert.Zero(t, alerts, "should have 0 alerts")
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "DELETE alert multiple systems",
|
|
||||||
Method: http.MethodDelete,
|
|
||||||
URL: "/api/beszel/user-alerts",
|
|
||||||
Headers: map[string]string{
|
|
||||||
"Authorization": user1Token,
|
|
||||||
},
|
|
||||||
ExpectedStatus: 200,
|
|
||||||
ExpectedContent: []string{"\"count\":2", "\"success\":true"},
|
|
||||||
TestAppFactory: testAppFactory,
|
|
||||||
Body: jsonReader(map[string]any{
|
|
||||||
"name": "Memory",
|
|
||||||
"systems": []string{system1.Id, system2.Id},
|
|
||||||
}),
|
|
||||||
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
|
||||||
beszelTests.ClearCollection(t, app, "alerts")
|
|
||||||
for _, systemId := range []string{system1.Id, system2.Id} {
|
|
||||||
_, err := beszelTests.CreateRecord(app, "alerts", map[string]any{
|
|
||||||
"name": "Memory",
|
|
||||||
"system": systemId,
|
|
||||||
"user": user1.Id,
|
|
||||||
"value": 90,
|
|
||||||
"min": 10,
|
|
||||||
})
|
|
||||||
assert.NoError(t, err, "should create alert")
|
|
||||||
}
|
|
||||||
alerts, _ := app.CountRecords("alerts")
|
|
||||||
assert.EqualValues(t, 2, alerts, "should have 2 alerts")
|
|
||||||
},
|
|
||||||
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
|
||||||
alerts, _ := app.CountRecords("alerts")
|
|
||||||
assert.Zero(t, alerts, "should have 0 alerts")
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "User 2 should not be able to delete alert of user 1",
|
|
||||||
Method: http.MethodDelete,
|
|
||||||
URL: "/api/beszel/user-alerts",
|
|
||||||
Headers: map[string]string{
|
|
||||||
"Authorization": user2Token,
|
|
||||||
},
|
|
||||||
ExpectedStatus: 200,
|
|
||||||
ExpectedContent: []string{"\"count\":1", "\"success\":true"},
|
|
||||||
TestAppFactory: testAppFactory,
|
|
||||||
Body: jsonReader(map[string]any{
|
|
||||||
"name": "CPU",
|
|
||||||
"systems": []string{system2.Id},
|
|
||||||
}),
|
|
||||||
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
|
||||||
beszelTests.ClearCollection(t, app, "alerts")
|
|
||||||
for _, user := range []string{user1.Id, user2.Id} {
|
|
||||||
beszelTests.CreateRecord(app, "alerts", map[string]any{
|
|
||||||
"name": "CPU",
|
|
||||||
"system": system2.Id,
|
|
||||||
"user": user,
|
|
||||||
"value": 80,
|
|
||||||
"min": 10,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
alerts, _ := app.CountRecords("alerts")
|
|
||||||
assert.EqualValues(t, 2, alerts, "should have 2 alerts")
|
|
||||||
user1AlertCount, _ := app.CountRecords("alerts", dbx.HashExp{"user": user1.Id})
|
|
||||||
assert.EqualValues(t, 1, user1AlertCount, "should have 1 alert")
|
|
||||||
user2AlertCount, _ := app.CountRecords("alerts", dbx.HashExp{"user": user2.Id})
|
|
||||||
assert.EqualValues(t, 1, user2AlertCount, "should have 1 alert")
|
|
||||||
},
|
|
||||||
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
|
||||||
user1AlertCount, _ := app.CountRecords("alerts", dbx.HashExp{"user": user1.Id})
|
|
||||||
assert.EqualValues(t, 1, user1AlertCount, "should have 1 alert")
|
|
||||||
user2AlertCount, _ := app.CountRecords("alerts", dbx.HashExp{"user": user2.Id})
|
|
||||||
assert.Zero(t, user2AlertCount, "should have 0 alerts")
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, scenario := range scenarios {
|
|
||||||
scenario.Test(t)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
func TestSendTestNotification(t *testing.T) {
|
|
||||||
hub, user := beszelTests.GetHubWithUser(t)
|
|
||||||
defer hub.Cleanup()
|
|
||||||
|
|
||||||
userToken, err := user.NewAuthToken()
|
|
||||||
|
|
||||||
adminUser, err := beszelTests.CreateUserWithRole(hub, "admin@example.com", "password123", "admin")
|
|
||||||
assert.NoError(t, err, "Failed to create admin user")
|
|
||||||
adminUserToken, err := adminUser.NewAuthToken()
|
|
||||||
|
|
||||||
superuser, err := beszelTests.CreateSuperuser(hub, "superuser@example.com", "password123")
|
|
||||||
assert.NoError(t, err, "Failed to create superuser")
|
|
||||||
superuserToken, err := superuser.NewAuthToken()
|
|
||||||
assert.NoError(t, err, "Failed to create superuser auth token")
|
|
||||||
|
|
||||||
testAppFactory := func(t testing.TB) *pbTests.TestApp {
|
|
||||||
return hub.TestApp
|
|
||||||
}
|
|
||||||
|
|
||||||
scenarios := []beszelTests.ApiScenario{
|
|
||||||
{
|
|
||||||
Name: "POST /test-notification - no auth should fail",
|
|
||||||
Method: http.MethodPost,
|
|
||||||
URL: "/api/beszel/test-notification",
|
|
||||||
ExpectedStatus: 401,
|
|
||||||
ExpectedContent: []string{"requires valid"},
|
|
||||||
TestAppFactory: testAppFactory,
|
|
||||||
Body: jsonReader(map[string]any{
|
|
||||||
"url": "generic://127.0.0.1",
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "POST /test-notification - with external auth should succeed",
|
|
||||||
Method: http.MethodPost,
|
|
||||||
URL: "/api/beszel/test-notification",
|
|
||||||
TestAppFactory: testAppFactory,
|
|
||||||
Headers: map[string]string{
|
|
||||||
"Authorization": userToken,
|
|
||||||
},
|
|
||||||
Body: jsonReader(map[string]any{
|
|
||||||
"url": "generic://8.8.8.8",
|
|
||||||
}),
|
|
||||||
ExpectedStatus: 200,
|
|
||||||
ExpectedContent: []string{"\"err\":"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "POST /test-notification - local url with user auth should fail",
|
|
||||||
Method: http.MethodPost,
|
|
||||||
URL: "/api/beszel/test-notification",
|
|
||||||
TestAppFactory: testAppFactory,
|
|
||||||
Headers: map[string]string{
|
|
||||||
"Authorization": userToken,
|
|
||||||
},
|
|
||||||
Body: jsonReader(map[string]any{
|
|
||||||
"url": "generic://localhost:8010",
|
|
||||||
}),
|
|
||||||
ExpectedStatus: 403,
|
|
||||||
ExpectedContent: []string{"Only admins"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "POST /test-notification - internal url with user auth should fail",
|
|
||||||
Method: http.MethodPost,
|
|
||||||
URL: "/api/beszel/test-notification",
|
|
||||||
TestAppFactory: testAppFactory,
|
|
||||||
Headers: map[string]string{
|
|
||||||
"Authorization": userToken,
|
|
||||||
},
|
|
||||||
Body: jsonReader(map[string]any{
|
|
||||||
"url": "generic+http://192.168.0.5",
|
|
||||||
}),
|
|
||||||
ExpectedStatus: 403,
|
|
||||||
ExpectedContent: []string{"Only admins"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "POST /test-notification - internal url with admin auth should succeed",
|
|
||||||
Method: http.MethodPost,
|
|
||||||
URL: "/api/beszel/test-notification",
|
|
||||||
TestAppFactory: testAppFactory,
|
|
||||||
Headers: map[string]string{
|
|
||||||
"Authorization": adminUserToken,
|
|
||||||
},
|
|
||||||
Body: jsonReader(map[string]any{
|
|
||||||
"url": "generic://127.0.0.1",
|
|
||||||
}),
|
|
||||||
ExpectedStatus: 200,
|
|
||||||
ExpectedContent: []string{"\"err\":"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "POST /test-notification - internal url with superuser auth should succeed",
|
|
||||||
Method: http.MethodPost,
|
|
||||||
URL: "/api/beszel/test-notification",
|
|
||||||
TestAppFactory: testAppFactory,
|
|
||||||
Headers: map[string]string{
|
|
||||||
"Authorization": superuserToken,
|
|
||||||
},
|
|
||||||
Body: jsonReader(map[string]any{
|
|
||||||
"url": "generic://127.0.0.1",
|
|
||||||
}),
|
|
||||||
ExpectedStatus: 200,
|
|
||||||
ExpectedContent: []string{"\"err\":"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, scenario := range scenarios {
|
|
||||||
scenario.Test(t)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
package alerts
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/pocketbase/dbx"
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
|
||||||
"github.com/pocketbase/pocketbase/tools/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
// CachedAlertData represents the relevant fields of an alert record for status checking and updates.
|
|
||||||
type CachedAlertData struct {
|
|
||||||
Id string
|
|
||||||
SystemID string
|
|
||||||
UserID string
|
|
||||||
Name string
|
|
||||||
Value float64
|
|
||||||
Triggered bool
|
|
||||||
Min uint8
|
|
||||||
// Created types.DateTime
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *CachedAlertData) PopulateFromRecord(record *core.Record) {
|
|
||||||
a.Id = record.Id
|
|
||||||
a.SystemID = record.GetString("system")
|
|
||||||
a.UserID = record.GetString("user")
|
|
||||||
a.Name = record.GetString("name")
|
|
||||||
a.Value = record.GetFloat("value")
|
|
||||||
a.Triggered = record.GetBool("triggered")
|
|
||||||
a.Min = uint8(record.GetInt("min"))
|
|
||||||
// a.Created = record.GetDateTime("created")
|
|
||||||
}
|
|
||||||
|
|
||||||
// AlertsCache provides an in-memory cache for system alerts.
|
|
||||||
type AlertsCache struct {
|
|
||||||
app core.App
|
|
||||||
store *store.Store[string, *store.Store[string, CachedAlertData]]
|
|
||||||
populated bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewAlertsCache creates a new instance of SystemAlertsCache.
|
|
||||||
func NewAlertsCache(app core.App) *AlertsCache {
|
|
||||||
c := AlertsCache{
|
|
||||||
app: app,
|
|
||||||
store: store.New(map[string]*store.Store[string, CachedAlertData]{}),
|
|
||||||
}
|
|
||||||
return c.bindEvents()
|
|
||||||
}
|
|
||||||
|
|
||||||
// bindEvents sets up event listeners to keep the cache in sync with database changes.
|
|
||||||
func (c *AlertsCache) bindEvents() *AlertsCache {
|
|
||||||
c.app.OnRecordAfterUpdateSuccess("alerts").BindFunc(func(e *core.RecordEvent) error {
|
|
||||||
// c.Delete(e.Record.Original()) // this would be needed if the system field on an existing alert was changed, however we don't currently allow that in the UI so we'll leave it commented out
|
|
||||||
c.Update(e.Record)
|
|
||||||
return e.Next()
|
|
||||||
})
|
|
||||||
c.app.OnRecordAfterDeleteSuccess("alerts").BindFunc(func(e *core.RecordEvent) error {
|
|
||||||
c.Delete(e.Record)
|
|
||||||
return e.Next()
|
|
||||||
})
|
|
||||||
c.app.OnRecordAfterCreateSuccess("alerts").BindFunc(func(e *core.RecordEvent) error {
|
|
||||||
c.Update(e.Record)
|
|
||||||
return e.Next()
|
|
||||||
})
|
|
||||||
return c
|
|
||||||
}
|
|
||||||
|
|
||||||
// PopulateFromDB clears current entries and loads all alerts from the database into the cache.
|
|
||||||
func (c *AlertsCache) PopulateFromDB(force bool) error {
|
|
||||||
if !force && c.populated {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
records, err := c.app.FindAllRecords("alerts")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
c.store.RemoveAll()
|
|
||||||
for _, record := range records {
|
|
||||||
c.Update(record)
|
|
||||||
}
|
|
||||||
c.populated = true
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update adds or updates an alert record in the cache.
|
|
||||||
func (c *AlertsCache) Update(record *core.Record) {
|
|
||||||
systemID := record.GetString("system")
|
|
||||||
if systemID == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
systemStore, ok := c.store.GetOk(systemID)
|
|
||||||
if !ok {
|
|
||||||
systemStore = store.New(map[string]CachedAlertData{})
|
|
||||||
c.store.Set(systemID, systemStore)
|
|
||||||
}
|
|
||||||
var ca CachedAlertData
|
|
||||||
ca.PopulateFromRecord(record)
|
|
||||||
systemStore.Set(record.Id, ca)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete removes an alert record from the cache.
|
|
||||||
func (c *AlertsCache) Delete(record *core.Record) {
|
|
||||||
systemID := record.GetString("system")
|
|
||||||
if systemID == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if systemStore, ok := c.store.GetOk(systemID); ok {
|
|
||||||
systemStore.Remove(record.Id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetSystemAlerts returns all alerts for the specified system, lazy-loading if necessary.
|
|
||||||
func (c *AlertsCache) GetSystemAlerts(systemID string) []CachedAlertData {
|
|
||||||
systemStore, ok := c.store.GetOk(systemID)
|
|
||||||
if !ok {
|
|
||||||
// Populate cache for this system
|
|
||||||
records, err := c.app.FindAllRecords("alerts", dbx.NewExp("system={:system}", dbx.Params{"system": systemID}))
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
systemStore = store.New(map[string]CachedAlertData{})
|
|
||||||
for _, record := range records {
|
|
||||||
var ca CachedAlertData
|
|
||||||
ca.PopulateFromRecord(record)
|
|
||||||
systemStore.Set(record.Id, ca)
|
|
||||||
}
|
|
||||||
c.store.Set(systemID, systemStore)
|
|
||||||
}
|
|
||||||
all := systemStore.GetAll()
|
|
||||||
alerts := make([]CachedAlertData, 0, len(all))
|
|
||||||
for _, alert := range all {
|
|
||||||
alerts = append(alerts, alert)
|
|
||||||
}
|
|
||||||
return alerts
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetAlert returns a specific alert by its ID from the cache.
|
|
||||||
func (c *AlertsCache) GetAlert(systemID, alertID string) (CachedAlertData, bool) {
|
|
||||||
if systemStore, ok := c.store.GetOk(systemID); ok {
|
|
||||||
return systemStore.GetOk(alertID)
|
|
||||||
}
|
|
||||||
return CachedAlertData{}, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetAlertsByName returns all alerts of a specific type for the specified system.
|
|
||||||
func (c *AlertsCache) GetAlertsByName(systemID, alertName string) []CachedAlertData {
|
|
||||||
allAlerts := c.GetSystemAlerts(systemID)
|
|
||||||
var alerts []CachedAlertData
|
|
||||||
for _, record := range allAlerts {
|
|
||||||
if record.Name == alertName {
|
|
||||||
alerts = append(alerts, record)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return alerts
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetAlertsExcludingNames returns all alerts for the specified system excluding the given types.
|
|
||||||
func (c *AlertsCache) GetAlertsExcludingNames(systemID string, excludedNames ...string) []CachedAlertData {
|
|
||||||
excludeMap := make(map[string]struct{})
|
|
||||||
for _, name := range excludedNames {
|
|
||||||
excludeMap[name] = struct{}{}
|
|
||||||
}
|
|
||||||
allAlerts := c.GetSystemAlerts(systemID)
|
|
||||||
var alerts []CachedAlertData
|
|
||||||
for _, record := range allAlerts {
|
|
||||||
if _, excluded := excludeMap[record.Name]; !excluded {
|
|
||||||
alerts = append(alerts, record)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return alerts
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refresh returns the latest cached copy for an alert snapshot if it still exists.
|
|
||||||
func (c *AlertsCache) Refresh(alert CachedAlertData) (CachedAlertData, bool) {
|
|
||||||
if alert.Id == "" {
|
|
||||||
return CachedAlertData{}, false
|
|
||||||
}
|
|
||||||
return c.GetAlert(alert.SystemID, alert.Id)
|
|
||||||
}
|
|
||||||
@@ -1,215 +0,0 @@
|
|||||||
//go:build testing
|
|
||||||
|
|
||||||
package alerts_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/alerts"
|
|
||||||
beszelTests "github.com/henrygd/beszel/internal/tests"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestSystemAlertsCachePopulateAndFilter(t *testing.T) {
|
|
||||||
hub, user := beszelTests.GetHubWithUser(t)
|
|
||||||
defer hub.Cleanup()
|
|
||||||
|
|
||||||
systems, err := beszelTests.CreateSystems(hub, 2, user.Id, "up")
|
|
||||||
require.NoError(t, err)
|
|
||||||
system1 := systems[0]
|
|
||||||
system2 := systems[1]
|
|
||||||
|
|
||||||
statusAlert, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{
|
|
||||||
"name": "Status",
|
|
||||||
"system": system1.Id,
|
|
||||||
"user": user.Id,
|
|
||||||
"min": 1,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
cpuAlert, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{
|
|
||||||
"name": "CPU",
|
|
||||||
"system": system1.Id,
|
|
||||||
"user": user.Id,
|
|
||||||
"value": 80,
|
|
||||||
"min": 1,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
memoryAlert, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{
|
|
||||||
"name": "Memory",
|
|
||||||
"system": system2.Id,
|
|
||||||
"user": user.Id,
|
|
||||||
"value": 90,
|
|
||||||
"min": 1,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
cache := alerts.NewAlertsCache(hub)
|
|
||||||
cache.PopulateFromDB(false)
|
|
||||||
|
|
||||||
statusAlerts := cache.GetAlertsByName(system1.Id, "Status")
|
|
||||||
require.Len(t, statusAlerts, 1)
|
|
||||||
assert.Equal(t, statusAlert.Id, statusAlerts[0].Id)
|
|
||||||
|
|
||||||
nonStatusAlerts := cache.GetAlertsExcludingNames(system1.Id, "Status")
|
|
||||||
require.Len(t, nonStatusAlerts, 1)
|
|
||||||
assert.Equal(t, cpuAlert.Id, nonStatusAlerts[0].Id)
|
|
||||||
|
|
||||||
system2Alerts := cache.GetSystemAlerts(system2.Id)
|
|
||||||
require.Len(t, system2Alerts, 1)
|
|
||||||
assert.Equal(t, memoryAlert.Id, system2Alerts[0].Id)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSystemAlertsCacheLazyLoadUpdateAndDelete(t *testing.T) {
|
|
||||||
hub, user := beszelTests.GetHubWithUser(t)
|
|
||||||
defer hub.Cleanup()
|
|
||||||
|
|
||||||
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
|
|
||||||
require.NoError(t, err)
|
|
||||||
systemRecord := systems[0]
|
|
||||||
|
|
||||||
statusAlert, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{
|
|
||||||
"name": "Status",
|
|
||||||
"system": systemRecord.Id,
|
|
||||||
"user": user.Id,
|
|
||||||
"min": 1,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
cache := alerts.NewAlertsCache(hub)
|
|
||||||
require.Len(t, cache.GetSystemAlerts(systemRecord.Id), 1, "first lookup should lazy-load alerts for the system")
|
|
||||||
|
|
||||||
cpuAlert, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{
|
|
||||||
"name": "CPU",
|
|
||||||
"system": systemRecord.Id,
|
|
||||||
"user": user.Id,
|
|
||||||
"value": 80,
|
|
||||||
"min": 1,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
cache.Update(cpuAlert)
|
|
||||||
|
|
||||||
nonStatusAlerts := cache.GetAlertsExcludingNames(systemRecord.Id, "Status")
|
|
||||||
require.Len(t, nonStatusAlerts, 1)
|
|
||||||
assert.Equal(t, cpuAlert.Id, nonStatusAlerts[0].Id)
|
|
||||||
|
|
||||||
cache.Delete(statusAlert)
|
|
||||||
assert.Empty(t, cache.GetAlertsByName(systemRecord.Id, "Status"), "deleted alerts should be removed from the in-memory cache")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSystemAlertsCacheRefreshReturnsLatestCopy(t *testing.T) {
|
|
||||||
hub, user := beszelTests.GetHubWithUser(t)
|
|
||||||
defer hub.Cleanup()
|
|
||||||
|
|
||||||
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
|
|
||||||
require.NoError(t, err)
|
|
||||||
system := systems[0]
|
|
||||||
|
|
||||||
alert, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{
|
|
||||||
"name": "Status",
|
|
||||||
"system": system.Id,
|
|
||||||
"user": user.Id,
|
|
||||||
"min": 1,
|
|
||||||
"triggered": false,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
cache := alerts.NewAlertsCache(hub)
|
|
||||||
snapshot := cache.GetSystemAlerts(system.Id)[0]
|
|
||||||
assert.False(t, snapshot.Triggered)
|
|
||||||
|
|
||||||
alert.Set("triggered", true)
|
|
||||||
require.NoError(t, hub.Save(alert))
|
|
||||||
|
|
||||||
refreshed, ok := cache.Refresh(snapshot)
|
|
||||||
require.True(t, ok)
|
|
||||||
assert.Equal(t, snapshot.Id, refreshed.Id)
|
|
||||||
assert.True(t, refreshed.Triggered, "refresh should return the updated cached value rather than the stale snapshot")
|
|
||||||
|
|
||||||
require.NoError(t, hub.Delete(alert))
|
|
||||||
_, ok = cache.Refresh(snapshot)
|
|
||||||
assert.False(t, ok, "refresh should report false when the cached alert no longer exists")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAlertManagerCacheLifecycle(t *testing.T) {
|
|
||||||
hub, user := beszelTests.GetHubWithUser(t)
|
|
||||||
defer hub.Cleanup()
|
|
||||||
|
|
||||||
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
|
|
||||||
require.NoError(t, err)
|
|
||||||
system := systems[0]
|
|
||||||
|
|
||||||
// Create an alert
|
|
||||||
alert, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{
|
|
||||||
"name": "CPU",
|
|
||||||
"system": system.Id,
|
|
||||||
"user": user.Id,
|
|
||||||
"value": 80,
|
|
||||||
"min": 1,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
am := hub.AlertManager
|
|
||||||
cache := am.GetSystemAlertsCache()
|
|
||||||
|
|
||||||
// Verify it's in cache (it should be since CreateRecord triggers the event)
|
|
||||||
assert.Len(t, cache.GetSystemAlerts(system.Id), 1)
|
|
||||||
assert.Equal(t, alert.Id, cache.GetSystemAlerts(system.Id)[0].Id)
|
|
||||||
assert.EqualValues(t, 80, cache.GetSystemAlerts(system.Id)[0].Value)
|
|
||||||
|
|
||||||
// Update the alert through PocketBase to trigger events
|
|
||||||
alert.Set("value", 85)
|
|
||||||
require.NoError(t, hub.Save(alert))
|
|
||||||
|
|
||||||
// Check if updated value is reflected (or at least that it's still there)
|
|
||||||
cachedAlerts := cache.GetSystemAlerts(system.Id)
|
|
||||||
assert.Len(t, cachedAlerts, 1)
|
|
||||||
assert.EqualValues(t, 85, cachedAlerts[0].Value)
|
|
||||||
|
|
||||||
// Delete the alert through PocketBase to trigger events
|
|
||||||
require.NoError(t, hub.Delete(alert))
|
|
||||||
|
|
||||||
// Verify it's removed from cache
|
|
||||||
assert.Empty(t, cache.GetSystemAlerts(system.Id), "alert should be removed from cache after PocketBase delete")
|
|
||||||
}
|
|
||||||
|
|
||||||
// func TestAlertManagerCacheMovesAlertToNewSystemOnUpdate(t *testing.T) {
|
|
||||||
// hub, user := beszelTests.GetHubWithUser(t)
|
|
||||||
// defer hub.Cleanup()
|
|
||||||
|
|
||||||
// systems, err := beszelTests.CreateSystems(hub, 2, user.Id, "up")
|
|
||||||
// require.NoError(t, err)
|
|
||||||
// system1 := systems[0]
|
|
||||||
// system2 := systems[1]
|
|
||||||
|
|
||||||
// alert, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{
|
|
||||||
// "name": "CPU",
|
|
||||||
// "system": system1.Id,
|
|
||||||
// "user": user.Id,
|
|
||||||
// "value": 80,
|
|
||||||
// "min": 1,
|
|
||||||
// })
|
|
||||||
// require.NoError(t, err)
|
|
||||||
|
|
||||||
// am := hub.AlertManager
|
|
||||||
// cache := am.GetSystemAlertsCache()
|
|
||||||
|
|
||||||
// // Initially in system1 cache
|
|
||||||
// assert.Len(t, cache.Get(system1.Id), 1)
|
|
||||||
// assert.Empty(t, cache.Get(system2.Id))
|
|
||||||
|
|
||||||
// // Move alert to system2
|
|
||||||
// alert.Set("system", system2.Id)
|
|
||||||
// require.NoError(t, hub.Save(alert))
|
|
||||||
|
|
||||||
// // DEBUG: print if it is found
|
|
||||||
// // fmt.Printf("system1 alerts after update: %v\n", cache.Get(system1.Id))
|
|
||||||
|
|
||||||
// // Should be removed from system1 and present in system2
|
|
||||||
// assert.Empty(t, cache.GetType(system1.Id, "CPU"), "updated alerts should be evicted from the previous system cache")
|
|
||||||
// require.Len(t, cache.Get(system2.Id), 1)
|
|
||||||
// assert.Equal(t, alert.Id, cache.Get(system2.Id)[0].Id)
|
|
||||||
// }
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
//go:build testing
|
|
||||||
|
|
||||||
package alerts_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
|
||||||
beszelTests "github.com/henrygd/beszel/internal/tests"
|
|
||||||
|
|
||||||
"github.com/pocketbase/dbx"
|
|
||||||
"github.com/pocketbase/pocketbase/tools/types"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestDiskAlertExtraFsMultiMinute tests that multi-minute disk alerts correctly use
|
|
||||||
// historical per-minute values for extra (non-root) filesystems, not the current live snapshot.
|
|
||||||
func TestDiskAlertExtraFsMultiMinute(t *testing.T) {
|
|
||||||
hub, user := beszelTests.GetHubWithUser(t)
|
|
||||||
defer hub.Cleanup()
|
|
||||||
|
|
||||||
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
|
|
||||||
require.NoError(t, err)
|
|
||||||
systemRecord := systems[0]
|
|
||||||
|
|
||||||
// Disk alert: threshold 80%, min=2 (requires historical averaging)
|
|
||||||
diskAlert, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{
|
|
||||||
"name": "Disk",
|
|
||||||
"system": systemRecord.Id,
|
|
||||||
"user": user.Id,
|
|
||||||
"value": 80, // threshold: 80%
|
|
||||||
"min": 2, // 2 minutes - requires historical averaging
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.False(t, diskAlert.GetBool("triggered"), "Alert should not be triggered initially")
|
|
||||||
|
|
||||||
am := hub.GetAlertManager()
|
|
||||||
now := time.Now().UTC()
|
|
||||||
|
|
||||||
extraFsHigh := map[string]*system.FsStats{
|
|
||||||
"/mnt/data": {DiskTotal: 1000, DiskUsed: 920}, // 92% - above threshold
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert 4 historical records spread over 3 minutes (same pattern as battery tests).
|
|
||||||
// The oldest record must predate (now - 2min) so the alert time window is valid.
|
|
||||||
recordTimes := []time.Duration{
|
|
||||||
-180 * time.Second, // 3 min ago - anchors oldest record before alert.time
|
|
||||||
-90 * time.Second,
|
|
||||||
-60 * time.Second,
|
|
||||||
-30 * time.Second,
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, offset := range recordTimes {
|
|
||||||
stats := system.Stats{
|
|
||||||
DiskPct: 30, // root disk at 30% - below threshold
|
|
||||||
ExtraFs: extraFsHigh,
|
|
||||||
}
|
|
||||||
statsJSON, _ := json.Marshal(stats)
|
|
||||||
|
|
||||||
recordTime := now.Add(offset)
|
|
||||||
record, err := beszelTests.CreateRecord(hub, "system_stats", map[string]any{
|
|
||||||
"system": systemRecord.Id,
|
|
||||||
"type": "1m",
|
|
||||||
"stats": string(statsJSON),
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
record.SetRaw("created", recordTime.Format(types.DefaultDateLayout))
|
|
||||||
err = hub.SaveNoValidate(record)
|
|
||||||
require.NoError(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
combinedDataHigh := &system.CombinedData{
|
|
||||||
Stats: system.Stats{
|
|
||||||
DiskPct: 30,
|
|
||||||
ExtraFs: extraFsHigh,
|
|
||||||
},
|
|
||||||
Info: system.Info{
|
|
||||||
DiskPct: 30,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
systemRecord.Set("updated", now)
|
|
||||||
err = hub.SaveNoValidate(systemRecord)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
err = am.HandleSystemAlerts(systemRecord, combinedDataHigh)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
time.Sleep(20 * time.Millisecond)
|
|
||||||
|
|
||||||
diskAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": diskAlert.Id})
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.True(t, diskAlert.GetBool("triggered"),
|
|
||||||
"Alert SHOULD be triggered when extra disk average (92%%) exceeds threshold (80%%)")
|
|
||||||
|
|
||||||
// --- Resolution: extra disk drops to 50%, alert should resolve ---
|
|
||||||
|
|
||||||
extraFsLow := map[string]*system.FsStats{
|
|
||||||
"/mnt/data": {DiskTotal: 1000, DiskUsed: 500}, // 50% - below threshold
|
|
||||||
}
|
|
||||||
|
|
||||||
newNow := now.Add(2 * time.Minute)
|
|
||||||
recordTimesLow := []time.Duration{
|
|
||||||
-180 * time.Second,
|
|
||||||
-90 * time.Second,
|
|
||||||
-60 * time.Second,
|
|
||||||
-30 * time.Second,
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, offset := range recordTimesLow {
|
|
||||||
stats := system.Stats{
|
|
||||||
DiskPct: 30,
|
|
||||||
ExtraFs: extraFsLow,
|
|
||||||
}
|
|
||||||
statsJSON, _ := json.Marshal(stats)
|
|
||||||
|
|
||||||
recordTime := newNow.Add(offset)
|
|
||||||
record, err := beszelTests.CreateRecord(hub, "system_stats", map[string]any{
|
|
||||||
"system": systemRecord.Id,
|
|
||||||
"type": "1m",
|
|
||||||
"stats": string(statsJSON),
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
record.SetRaw("created", recordTime.Format(types.DefaultDateLayout))
|
|
||||||
err = hub.SaveNoValidate(record)
|
|
||||||
require.NoError(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
combinedDataLow := &system.CombinedData{
|
|
||||||
Stats: system.Stats{
|
|
||||||
DiskPct: 30,
|
|
||||||
ExtraFs: extraFsLow,
|
|
||||||
},
|
|
||||||
Info: system.Info{
|
|
||||||
DiskPct: 30,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
systemRecord.Set("updated", newNow)
|
|
||||||
err = hub.SaveNoValidate(systemRecord)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
err = am.HandleSystemAlerts(systemRecord, combinedDataLow)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
time.Sleep(20 * time.Millisecond)
|
|
||||||
|
|
||||||
diskAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": diskAlert.Id})
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.False(t, diskAlert.GetBool("triggered"),
|
|
||||||
"Alert should be resolved when extra disk average (50%%) drops below threshold (80%%)")
|
|
||||||
}
|
|
||||||
@@ -49,7 +49,7 @@ func TestAlertSilencedOneTime(t *testing.T) {
|
|||||||
|
|
||||||
// Get alert manager
|
// Get alert manager
|
||||||
am := alerts.NewAlertManager(hub)
|
am := alerts.NewAlertManager(hub)
|
||||||
defer am.Stop()
|
defer am.StopWorker()
|
||||||
|
|
||||||
// Test that alert is silenced
|
// Test that alert is silenced
|
||||||
silenced := am.IsNotificationSilenced(user.Id, system.Id)
|
silenced := am.IsNotificationSilenced(user.Id, system.Id)
|
||||||
@@ -106,7 +106,7 @@ func TestAlertSilencedDaily(t *testing.T) {
|
|||||||
|
|
||||||
// Get alert manager
|
// Get alert manager
|
||||||
am := alerts.NewAlertManager(hub)
|
am := alerts.NewAlertManager(hub)
|
||||||
defer am.Stop()
|
defer am.StopWorker()
|
||||||
|
|
||||||
// Get current hour and create a window that includes current time
|
// Get current hour and create a window that includes current time
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
@@ -170,7 +170,7 @@ func TestAlertSilencedDailyMidnightCrossing(t *testing.T) {
|
|||||||
|
|
||||||
// Get alert manager
|
// Get alert manager
|
||||||
am := alerts.NewAlertManager(hub)
|
am := alerts.NewAlertManager(hub)
|
||||||
defer am.Stop()
|
defer am.StopWorker()
|
||||||
|
|
||||||
// Create a window that crosses midnight: 22:00 - 02:00
|
// Create a window that crosses midnight: 22:00 - 02:00
|
||||||
startTime := time.Date(2000, 1, 1, 22, 0, 0, 0, time.UTC)
|
startTime := time.Date(2000, 1, 1, 22, 0, 0, 0, time.UTC)
|
||||||
@@ -211,7 +211,7 @@ func TestAlertSilencedGlobal(t *testing.T) {
|
|||||||
|
|
||||||
// Get alert manager
|
// Get alert manager
|
||||||
am := alerts.NewAlertManager(hub)
|
am := alerts.NewAlertManager(hub)
|
||||||
defer am.Stop()
|
defer am.StopWorker()
|
||||||
|
|
||||||
// Create a global quiet hours window (no system specified)
|
// Create a global quiet hours window (no system specified)
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
@@ -250,7 +250,7 @@ func TestAlertSilencedSystemSpecific(t *testing.T) {
|
|||||||
|
|
||||||
// Get alert manager
|
// Get alert manager
|
||||||
am := alerts.NewAlertManager(hub)
|
am := alerts.NewAlertManager(hub)
|
||||||
defer am.Stop()
|
defer am.StopWorker()
|
||||||
|
|
||||||
// Create a system-specific quiet hours window for system1 only
|
// Create a system-specific quiet hours window for system1 only
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
@@ -296,7 +296,7 @@ func TestAlertSilencedMultiUser(t *testing.T) {
|
|||||||
|
|
||||||
// Get alert manager
|
// Get alert manager
|
||||||
am := alerts.NewAlertManager(hub)
|
am := alerts.NewAlertManager(hub)
|
||||||
defer am.Stop()
|
defer am.StopWorker()
|
||||||
|
|
||||||
// Create a quiet hours window for user1 only
|
// Create a quiet hours window for user1 only
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
@@ -322,9 +322,8 @@ func TestAlertSilencedMultiUser(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertSilencedWithActualAlert(t *testing.T) {
|
func TestAlertSilencedWithActualAlert(t *testing.T) {
|
||||||
hub, user := beszelTests.GetHubWithUser(t)
|
|
||||||
|
|
||||||
synctest.Test(t, func(t *testing.T) {
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
hub, user := beszelTests.GetHubWithUser(t)
|
||||||
defer hub.Cleanup()
|
defer hub.Cleanup()
|
||||||
|
|
||||||
// Create a system
|
// Create a system
|
||||||
@@ -418,7 +417,7 @@ func TestAlertSilencedNoWindows(t *testing.T) {
|
|||||||
|
|
||||||
// Get alert manager
|
// Get alert manager
|
||||||
am := alerts.NewAlertManager(hub)
|
am := alerts.NewAlertManager(hub)
|
||||||
defer am.Stop()
|
defer am.StopWorker()
|
||||||
|
|
||||||
// Without any quiet hours windows, alert should NOT be silenced
|
// Without any quiet hours windows, alert should NOT be silenced
|
||||||
silenced := am.IsNotificationSilenced(user.Id, system.Id)
|
silenced := am.IsNotificationSilenced(user.Id, system.Id)
|
||||||
|
|||||||
@@ -5,28 +5,67 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/pocketbase/dbx"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
type alertInfo struct {
|
type alertTask struct {
|
||||||
systemName string
|
action string // "schedule" or "cancel"
|
||||||
alertData CachedAlertData
|
systemName string
|
||||||
expireTime time.Time
|
alertRecord *core.Record
|
||||||
timer *time.Timer
|
delay time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop cancels all pending status alert timers.
|
type alertInfo struct {
|
||||||
func (am *AlertManager) Stop() {
|
systemName string
|
||||||
am.stopOnce.Do(func() {
|
alertRecord *core.Record
|
||||||
am.pendingAlerts.Range(func(key, value any) bool {
|
expireTime time.Time
|
||||||
info := value.(*alertInfo)
|
}
|
||||||
if info.timer != nil {
|
|
||||||
info.timer.Stop()
|
// startWorker is a long-running goroutine that processes alert tasks
|
||||||
|
// every x seconds. It must be running to process status alerts.
|
||||||
|
func (am *AlertManager) startWorker() {
|
||||||
|
processPendingAlerts := time.Tick(15 * time.Second)
|
||||||
|
|
||||||
|
// check for status alerts that are not resolved when system comes up
|
||||||
|
// (can be removed if we figure out core bug in #1052)
|
||||||
|
checkStatusAlerts := time.Tick(561 * time.Second)
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-am.stopChan:
|
||||||
|
return
|
||||||
|
case task := <-am.alertQueue:
|
||||||
|
switch task.action {
|
||||||
|
case "schedule":
|
||||||
|
am.pendingAlerts.Store(task.alertRecord.Id, &alertInfo{
|
||||||
|
systemName: task.systemName,
|
||||||
|
alertRecord: task.alertRecord,
|
||||||
|
expireTime: time.Now().Add(task.delay),
|
||||||
|
})
|
||||||
|
case "cancel":
|
||||||
|
am.pendingAlerts.Delete(task.alertRecord.Id)
|
||||||
}
|
}
|
||||||
am.pendingAlerts.Delete(key)
|
case <-checkStatusAlerts:
|
||||||
return true
|
resolveStatusAlerts(am.hub)
|
||||||
})
|
case <-processPendingAlerts:
|
||||||
})
|
// Check for expired alerts every tick
|
||||||
|
now := time.Now()
|
||||||
|
for key, value := range am.pendingAlerts.Range {
|
||||||
|
info := value.(*alertInfo)
|
||||||
|
if now.After(info.expireTime) {
|
||||||
|
// Downtime delay has passed, process alert
|
||||||
|
am.sendStatusAlert("down", info.systemName, info.alertRecord)
|
||||||
|
am.pendingAlerts.Delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopWorker shuts down the AlertManager.worker goroutine
|
||||||
|
func (am *AlertManager) StopWorker() {
|
||||||
|
close(am.stopChan)
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleStatusAlerts manages the logic when system status changes.
|
// HandleStatusAlerts manages the logic when system status changes.
|
||||||
@@ -35,116 +74,82 @@ func (am *AlertManager) HandleStatusAlerts(newStatus string, systemRecord *core.
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
alerts := am.alertsCache.GetAlertsByName(systemRecord.Id, "Status")
|
alertRecords, err := am.getSystemStatusAlerts(systemRecord.Id)
|
||||||
if len(alerts) == 0 {
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(alertRecords) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
systemName := systemRecord.GetString("name")
|
systemName := systemRecord.GetString("name")
|
||||||
if newStatus == "down" {
|
if newStatus == "down" {
|
||||||
am.handleSystemDown(systemName, alerts)
|
am.handleSystemDown(systemName, alertRecords)
|
||||||
} else {
|
} else {
|
||||||
am.handleSystemUp(systemName, alerts)
|
am.handleSystemUp(systemName, alertRecords)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleSystemDown manages the logic when a system status changes to "down". It schedules pending alerts for each alert record.
|
// getSystemStatusAlerts retrieves all "Status" alert records for a given system ID.
|
||||||
func (am *AlertManager) handleSystemDown(systemName string, alerts []CachedAlertData) {
|
func (am *AlertManager) getSystemStatusAlerts(systemID string) ([]*core.Record, error) {
|
||||||
for _, alertData := range alerts {
|
alertRecords, err := am.hub.FindAllRecords("alerts", dbx.HashExp{
|
||||||
min := max(1, int(alertData.Min))
|
"system": systemID,
|
||||||
am.schedulePendingStatusAlert(systemName, alertData, time.Duration(min)*time.Minute)
|
"name": "Status",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
|
return alertRecords, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// schedulePendingStatusAlert sets up a timer to send a "down" alert after the specified delay if the system is still down.
|
// Schedules delayed "down" alerts for each alert record.
|
||||||
// It returns true if the alert was scheduled, or false if an alert was already pending for the given alert record.
|
func (am *AlertManager) handleSystemDown(systemName string, alertRecords []*core.Record) {
|
||||||
func (am *AlertManager) schedulePendingStatusAlert(systemName string, alertData CachedAlertData, delay time.Duration) bool {
|
for _, alertRecord := range alertRecords {
|
||||||
alert := &alertInfo{
|
// Continue if alert is already scheduled
|
||||||
systemName: systemName,
|
if _, exists := am.pendingAlerts.Load(alertRecord.Id); exists {
|
||||||
alertData: alertData,
|
continue
|
||||||
expireTime: time.Now().Add(delay),
|
}
|
||||||
|
// Schedule by adding to queue
|
||||||
|
min := max(1, alertRecord.GetInt("min"))
|
||||||
|
am.alertQueue <- alertTask{
|
||||||
|
action: "schedule",
|
||||||
|
systemName: systemName,
|
||||||
|
alertRecord: alertRecord,
|
||||||
|
delay: time.Duration(min) * time.Minute,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
storedAlert, loaded := am.pendingAlerts.LoadOrStore(alertData.Id, alert)
|
|
||||||
if loaded {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
stored := storedAlert.(*alertInfo)
|
|
||||||
stored.timer = time.AfterFunc(time.Until(stored.expireTime), func() {
|
|
||||||
am.processPendingAlert(alertData.Id)
|
|
||||||
})
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleSystemUp manages the logic when a system status changes to "up".
|
// handleSystemUp manages the logic when a system status changes to "up".
|
||||||
// It cancels any pending alerts and sends "up" alerts.
|
// It cancels any pending alerts and sends "up" alerts.
|
||||||
func (am *AlertManager) handleSystemUp(systemName string, alerts []CachedAlertData) {
|
func (am *AlertManager) handleSystemUp(systemName string, alertRecords []*core.Record) {
|
||||||
for _, alertData := range alerts {
|
for _, alertRecord := range alertRecords {
|
||||||
|
alertRecordID := alertRecord.Id
|
||||||
// If alert exists for record, delete and continue (down alert not sent)
|
// If alert exists for record, delete and continue (down alert not sent)
|
||||||
if am.cancelPendingAlert(alertData.Id) {
|
if _, exists := am.pendingAlerts.Load(alertRecordID); exists {
|
||||||
|
am.alertQueue <- alertTask{
|
||||||
|
action: "cancel",
|
||||||
|
alertRecord: alertRecord,
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if !alertData.Triggered {
|
// No alert scheduled for this record, send "up" alert
|
||||||
continue
|
if err := am.sendStatusAlert("up", systemName, alertRecord); err != nil {
|
||||||
}
|
|
||||||
if err := am.sendStatusAlert("up", systemName, alertData); err != nil {
|
|
||||||
am.hub.Logger().Error("Failed to send alert", "err", err)
|
am.hub.Logger().Error("Failed to send alert", "err", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// cancelPendingAlert stops the timer and removes the pending alert for the given alert ID. Returns true if a pending alert was found and cancelled.
|
|
||||||
func (am *AlertManager) cancelPendingAlert(alertID string) bool {
|
|
||||||
value, loaded := am.pendingAlerts.LoadAndDelete(alertID)
|
|
||||||
if !loaded {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
info := value.(*alertInfo)
|
|
||||||
if info.timer != nil {
|
|
||||||
info.timer.Stop()
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// CancelPendingStatusAlerts cancels all pending status alert timers for a given system.
|
|
||||||
// This is called when a system is paused to prevent delayed alerts from firing.
|
|
||||||
func (am *AlertManager) CancelPendingStatusAlerts(systemID string) {
|
|
||||||
am.pendingAlerts.Range(func(key, value any) bool {
|
|
||||||
info := value.(*alertInfo)
|
|
||||||
if info.alertData.SystemID == systemID {
|
|
||||||
am.cancelPendingAlert(key.(string))
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// processPendingAlert sends a "down" alert if the pending alert has expired and the system is still down.
|
|
||||||
func (am *AlertManager) processPendingAlert(alertID string) {
|
|
||||||
value, loaded := am.pendingAlerts.LoadAndDelete(alertID)
|
|
||||||
if !loaded {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
info := value.(*alertInfo)
|
|
||||||
refreshedAlertData, ok := am.alertsCache.Refresh(info.alertData)
|
|
||||||
if !ok || refreshedAlertData.Triggered {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := am.sendStatusAlert("down", info.systemName, refreshedAlertData); err != nil {
|
|
||||||
am.hub.Logger().Error("Failed to send alert", "err", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// sendStatusAlert sends a status alert ("up" or "down") to the users associated with the alert records.
|
// sendStatusAlert sends a status alert ("up" or "down") to the users associated with the alert records.
|
||||||
func (am *AlertManager) sendStatusAlert(alertStatus string, systemName string, alertData CachedAlertData) error {
|
func (am *AlertManager) sendStatusAlert(alertStatus string, systemName string, alertRecord *core.Record) error {
|
||||||
// Update trigger state for alert record before sending alert
|
switch alertStatus {
|
||||||
triggered := alertStatus == "down"
|
case "up":
|
||||||
if err := am.setAlertTriggered(alertData, triggered); err != nil {
|
alertRecord.Set("triggered", false)
|
||||||
return err
|
case "down":
|
||||||
|
alertRecord.Set("triggered", true)
|
||||||
}
|
}
|
||||||
|
am.hub.Save(alertRecord)
|
||||||
|
|
||||||
var emoji string
|
var emoji string
|
||||||
if alertStatus == "up" {
|
if alertStatus == "up" {
|
||||||
@@ -157,10 +162,10 @@ func (am *AlertManager) sendStatusAlert(alertStatus string, systemName string, a
|
|||||||
message := strings.TrimSuffix(title, emoji)
|
message := strings.TrimSuffix(title, emoji)
|
||||||
|
|
||||||
// Get system ID for the link
|
// Get system ID for the link
|
||||||
systemID := alertData.SystemID
|
systemID := alertRecord.GetString("system")
|
||||||
|
|
||||||
return am.SendAlert(AlertMessageData{
|
return am.SendAlert(AlertMessageData{
|
||||||
UserID: alertData.UserID,
|
UserID: alertRecord.GetString("user"),
|
||||||
SystemID: systemID,
|
SystemID: systemID,
|
||||||
Title: title,
|
Title: title,
|
||||||
Message: message,
|
Message: message,
|
||||||
@@ -169,8 +174,8 @@ func (am *AlertManager) sendStatusAlert(alertStatus string, systemName string, a
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveStatusAlerts resolves any triggered status alerts that weren't resolved
|
// resolveStatusAlerts resolves any status alerts that weren't resolved
|
||||||
// when system came up (https://github.com/henrygd/beszel/issues/1052).
|
// when system came up (https://github.com/henrygd/beszel/issues/1052)
|
||||||
func resolveStatusAlerts(app core.App) error {
|
func resolveStatusAlerts(app core.App) error {
|
||||||
db := app.DB()
|
db := app.DB()
|
||||||
// Find all active status alerts where the system is actually up
|
// Find all active status alerts where the system is actually up
|
||||||
@@ -200,40 +205,3 @@ func resolveStatusAlerts(app core.App) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// restorePendingStatusAlerts re-queues untriggered status alerts for systems that
|
|
||||||
// are still down after a hub restart. This rebuilds the lost in-memory timer state.
|
|
||||||
func (am *AlertManager) restorePendingStatusAlerts() error {
|
|
||||||
type pendingStatusAlert struct {
|
|
||||||
AlertID string `db:"alert_id"`
|
|
||||||
SystemID string `db:"system_id"`
|
|
||||||
SystemName string `db:"system_name"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var pending []pendingStatusAlert
|
|
||||||
err := am.hub.DB().NewQuery(`
|
|
||||||
SELECT a.id AS alert_id, a.system AS system_id, s.name AS system_name
|
|
||||||
FROM alerts a
|
|
||||||
JOIN systems s ON a.system = s.id
|
|
||||||
WHERE a.name = 'Status'
|
|
||||||
AND a.triggered = false
|
|
||||||
AND s.status = 'down'
|
|
||||||
`).All(&pending)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Make sure cache is populated before trying to restore pending alerts
|
|
||||||
_ = am.alertsCache.PopulateFromDB(false)
|
|
||||||
|
|
||||||
for _, item := range pending {
|
|
||||||
alertData, ok := am.alertsCache.GetAlert(item.SystemID, item.AlertID)
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
min := max(1, int(alertData.Min))
|
|
||||||
am.schedulePendingStatusAlert(item.SystemName, alertData, time.Duration(min)*time.Minute)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -11,11 +11,15 @@ import (
|
|||||||
"github.com/pocketbase/dbx"
|
"github.com/pocketbase/dbx"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
"github.com/pocketbase/pocketbase/tools/types"
|
"github.com/pocketbase/pocketbase/tools/types"
|
||||||
|
"github.com/spf13/cast"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *system.CombinedData) error {
|
func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *system.CombinedData) error {
|
||||||
alerts := am.alertsCache.GetAlertsExcludingNames(systemRecord.Id, "Status")
|
alertRecords, err := am.hub.FindAllRecords("alerts",
|
||||||
if len(alerts) == 0 {
|
dbx.NewExp("system={:system} AND name!='Status'", dbx.Params{"system": systemRecord.Id}),
|
||||||
|
)
|
||||||
|
if err != nil || len(alertRecords) == 0 {
|
||||||
|
// log.Println("no alerts found for system")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,8 +27,8 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
|
|||||||
now := systemRecord.GetDateTime("updated").Time().UTC()
|
now := systemRecord.GetDateTime("updated").Time().UTC()
|
||||||
oldestTime := now
|
oldestTime := now
|
||||||
|
|
||||||
for _, alertData := range alerts {
|
for _, alertRecord := range alertRecords {
|
||||||
name := alertData.Name
|
name := alertRecord.GetString("name")
|
||||||
var val float64
|
var val float64
|
||||||
unit := "%"
|
unit := "%"
|
||||||
|
|
||||||
@@ -63,14 +67,14 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
|
|||||||
case "GPU":
|
case "GPU":
|
||||||
val = data.Info.GpuPct
|
val = data.Info.GpuPct
|
||||||
case "Battery":
|
case "Battery":
|
||||||
if !hasRepresentativeBattery(data.Stats.Battery, data.Stats.Batteries) {
|
if data.Stats.Battery[0] == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
val = float64(data.Stats.Battery[0])
|
val = float64(data.Stats.Battery[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
triggered := alertData.Triggered
|
triggered := alertRecord.GetBool("triggered")
|
||||||
threshold := alertData.Value
|
threshold := alertRecord.GetFloat("value")
|
||||||
|
|
||||||
// Battery alert has inverted logic: trigger when value is BELOW threshold
|
// Battery alert has inverted logic: trigger when value is BELOW threshold
|
||||||
lowAlert := isLowAlert(name)
|
lowAlert := isLowAlert(name)
|
||||||
@@ -88,11 +92,11 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
min := max(1, alertData.Min)
|
min := max(1, cast.ToUint8(alertRecord.Get("min")))
|
||||||
|
|
||||||
alert := SystemAlertData{
|
alert := SystemAlertData{
|
||||||
systemRecord: systemRecord,
|
systemRecord: systemRecord,
|
||||||
alertData: alertData,
|
alertRecord: alertRecord,
|
||||||
name: name,
|
name: name,
|
||||||
unit: unit,
|
unit: unit,
|
||||||
val: val,
|
val: val,
|
||||||
@@ -125,7 +129,7 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
|
|||||||
Created types.DateTime `db:"created"`
|
Created types.DateTime `db:"created"`
|
||||||
}{}
|
}{}
|
||||||
|
|
||||||
err := am.hub.DB().
|
err = am.hub.DB().
|
||||||
Select("stats", "created").
|
Select("stats", "created").
|
||||||
From("system_stats").
|
From("system_stats").
|
||||||
Where(dbx.NewExp(
|
Where(dbx.NewExp(
|
||||||
@@ -167,7 +171,6 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
|
|||||||
stat := systemStats[i]
|
stat := systemStats[i]
|
||||||
// subtract 10 seconds to give a small time buffer
|
// subtract 10 seconds to give a small time buffer
|
||||||
systemStatsCreation := stat.Created.Time().Add(-time.Second * 10)
|
systemStatsCreation := stat.Created.Time().Add(-time.Second * 10)
|
||||||
stats = SystemAlertStats{}
|
|
||||||
if err := json.Unmarshal(stat.Stats, &stats); err != nil {
|
if err := json.Unmarshal(stat.Stats, &stats); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -189,24 +192,22 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
|
|||||||
case "Memory":
|
case "Memory":
|
||||||
alert.val += stats.Mem
|
alert.val += stats.Mem
|
||||||
case "Bandwidth":
|
case "Bandwidth":
|
||||||
alert.val += float64(stats.Bandwidth[0]+stats.Bandwidth[1]) / (1024 * 1024)
|
alert.val += stats.NetSent + stats.NetRecv
|
||||||
case "Disk":
|
case "Disk":
|
||||||
if alert.mapSums == nil {
|
if alert.mapSums == nil {
|
||||||
alert.mapSums = make(map[string]float32, len(stats.ExtraFs)+1)
|
alert.mapSums = make(map[string]float32, len(data.Stats.ExtraFs)+1)
|
||||||
}
|
}
|
||||||
// add root disk
|
// add root disk
|
||||||
if _, ok := alert.mapSums["root"]; !ok {
|
if _, ok := alert.mapSums["root"]; !ok {
|
||||||
alert.mapSums["root"] = 0.0
|
alert.mapSums["root"] = 0.0
|
||||||
}
|
}
|
||||||
alert.mapSums["root"] += float32(stats.Disk)
|
alert.mapSums["root"] += float32(stats.Disk)
|
||||||
// add extra disks from historical record
|
// add extra disks
|
||||||
for key, fs := range stats.ExtraFs {
|
for key, fs := range data.Stats.ExtraFs {
|
||||||
if fs.DiskTotal > 0 {
|
if _, ok := alert.mapSums[key]; !ok {
|
||||||
if _, ok := alert.mapSums[key]; !ok {
|
alert.mapSums[key] = 0.0
|
||||||
alert.mapSums[key] = 0.0
|
|
||||||
}
|
|
||||||
alert.mapSums[key] += float32(fs.DiskUsed / fs.DiskTotal * 100)
|
|
||||||
}
|
}
|
||||||
|
alert.mapSums[key] += float32(fs.DiskUsed / fs.DiskTotal * 100)
|
||||||
}
|
}
|
||||||
case "Temperature":
|
case "Temperature":
|
||||||
if alert.mapSums == nil {
|
if alert.mapSums == nil {
|
||||||
@@ -236,9 +237,6 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
|
|||||||
}
|
}
|
||||||
alert.val += maxUsage
|
alert.val += maxUsage
|
||||||
case "Battery":
|
case "Battery":
|
||||||
if !hasRepresentativeBattery(stats.Battery, stats.Batteries) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
alert.val += float64(stats.Battery[0])
|
alert.val += float64(stats.Battery[0])
|
||||||
default:
|
default:
|
||||||
continue
|
continue
|
||||||
@@ -301,10 +299,6 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func hasRepresentativeBattery(legacy [2]uint8, batteries map[string]uint8) bool {
|
|
||||||
return legacy != [2]uint8{} || len(batteries) > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func (am *AlertManager) sendSystemAlert(alert SystemAlertData) {
|
func (am *AlertManager) sendSystemAlert(alert SystemAlertData) {
|
||||||
// log.Printf("Sending alert %s: val %f | count %d | threshold %f\n", alert.name, alert.val, alert.count, alert.threshold)
|
// log.Printf("Sending alert %s: val %f | count %d | threshold %f\n", alert.name, alert.val, alert.count, alert.threshold)
|
||||||
systemName := alert.systemRecord.GetString("name")
|
systemName := alert.systemRecord.GetString("name")
|
||||||
@@ -348,12 +342,13 @@ func (am *AlertManager) sendSystemAlert(alert SystemAlertData) {
|
|||||||
}
|
}
|
||||||
body := fmt.Sprintf("%s averaged %.2f%s for the previous %v %s.", alert.descriptor, alert.val, alert.unit, alert.min, minutesLabel)
|
body := fmt.Sprintf("%s averaged %.2f%s for the previous %v %s.", alert.descriptor, alert.val, alert.unit, alert.min, minutesLabel)
|
||||||
|
|
||||||
if err := am.setAlertTriggered(alert.alertData, alert.triggered); err != nil {
|
alert.alertRecord.Set("triggered", alert.triggered)
|
||||||
|
if err := am.hub.Save(alert.alertRecord); err != nil {
|
||||||
// app.Logger().Error("failed to save alert record", "err", err)
|
// app.Logger().Error("failed to save alert record", "err", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
am.SendAlert(AlertMessageData{
|
am.SendAlert(AlertMessageData{
|
||||||
UserID: alert.alertData.UserID,
|
UserID: alert.alertRecord.GetString("user"),
|
||||||
SystemID: alert.systemRecord.Id,
|
SystemID: alert.systemRecord.Id,
|
||||||
Title: subject,
|
Title: subject,
|
||||||
Message: body,
|
Message: body,
|
||||||
|
|||||||
@@ -1,216 +0,0 @@
|
|||||||
//go:build testing
|
|
||||||
|
|
||||||
package alerts_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"testing/synctest"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
|
||||||
beszelTests "github.com/henrygd/beszel/internal/tests"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
type systemAlertValueSetter[T any] func(info *system.Info, stats *system.Stats, value T)
|
|
||||||
|
|
||||||
type systemAlertTestFixture struct {
|
|
||||||
hub *beszelTests.TestHub
|
|
||||||
alertID string
|
|
||||||
submit func(*system.CombinedData) error
|
|
||||||
}
|
|
||||||
|
|
||||||
func createCombinedData[T any](value T, setValue systemAlertValueSetter[T]) *system.CombinedData {
|
|
||||||
var data system.CombinedData
|
|
||||||
setValue(&data.Info, &data.Stats, value)
|
|
||||||
return &data
|
|
||||||
}
|
|
||||||
|
|
||||||
func newSystemAlertTestFixture(t *testing.T, alertName string, min int, threshold float64) *systemAlertTestFixture {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
hub, user := beszelTests.GetHubWithUser(t)
|
|
||||||
|
|
||||||
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
|
|
||||||
require.NoError(t, err)
|
|
||||||
systemRecord := systems[0]
|
|
||||||
|
|
||||||
sysManagerSystem, err := hub.GetSystemManager().GetSystemFromStore(systemRecord.Id)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotNil(t, sysManagerSystem)
|
|
||||||
sysManagerSystem.StopUpdater()
|
|
||||||
|
|
||||||
userSettings, err := hub.FindFirstRecordByFilter("user_settings", "user={:user}", map[string]any{"user": user.Id})
|
|
||||||
require.NoError(t, err)
|
|
||||||
userSettings.Set("settings", `{"emails":["test@example.com"],"webhooks":[]}`)
|
|
||||||
require.NoError(t, hub.Save(userSettings))
|
|
||||||
|
|
||||||
alertRecord, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{
|
|
||||||
"name": alertName,
|
|
||||||
"system": systemRecord.Id,
|
|
||||||
"user": user.Id,
|
|
||||||
"min": min,
|
|
||||||
"value": threshold,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
assert.False(t, alertRecord.GetBool("triggered"), "Alert should not be triggered initially")
|
|
||||||
|
|
||||||
alertsCache := hub.GetAlertManager().GetSystemAlertsCache()
|
|
||||||
cachedAlerts := alertsCache.GetAlertsExcludingNames(systemRecord.Id, "Status")
|
|
||||||
assert.Len(t, cachedAlerts, 1, "Alert should be in cache")
|
|
||||||
|
|
||||||
return &systemAlertTestFixture{
|
|
||||||
hub: hub,
|
|
||||||
alertID: alertRecord.Id,
|
|
||||||
submit: func(data *system.CombinedData) error {
|
|
||||||
_, err := sysManagerSystem.CreateRecords(data)
|
|
||||||
return err
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (fixture *systemAlertTestFixture) cleanup() {
|
|
||||||
fixture.hub.Cleanup()
|
|
||||||
}
|
|
||||||
|
|
||||||
func submitValue[T any](fixture *systemAlertTestFixture, t *testing.T, value T, setValue systemAlertValueSetter[T]) {
|
|
||||||
t.Helper()
|
|
||||||
require.NoError(t, fixture.submit(createCombinedData(value, setValue)))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (fixture *systemAlertTestFixture) assertTriggered(t *testing.T, triggered bool, message string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
alertRecord, err := fixture.hub.FindRecordById("alerts", fixture.alertID)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, triggered, alertRecord.GetBool("triggered"), message)
|
|
||||||
}
|
|
||||||
|
|
||||||
func waitForSystemAlert(d time.Duration) {
|
|
||||||
time.Sleep(d)
|
|
||||||
synctest.Wait()
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
|
||||||
submitValue(fixture, t, triggerValue, setValue)
|
|
||||||
waitForSystemAlert(time.Second)
|
|
||||||
|
|
||||||
fixture.assertTriggered(t, true, "Alert should be triggered")
|
|
||||||
assert.Equal(t, 1, fixture.hub.TestMailer.TotalSend(), "An email should have been sent")
|
|
||||||
|
|
||||||
submitValue(fixture, t, resolveValue, setValue)
|
|
||||||
waitForSystemAlert(time.Second)
|
|
||||||
|
|
||||||
fixture.assertTriggered(t, false, "Alert should be untriggered")
|
|
||||||
assert.Equal(t, 2, fixture.hub.TestMailer.TotalSend(), "A second email should have been sent for untriggering the alert")
|
|
||||||
|
|
||||||
waitForSystemAlert(time.Minute)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
|
||||||
submitValue(fixture, t, baselineValue, setValue)
|
|
||||||
waitForSystemAlert(time.Minute + time.Second)
|
|
||||||
fixture.assertTriggered(t, false, "Alert should not be triggered yet")
|
|
||||||
|
|
||||||
submitValue(fixture, t, triggerValue, setValue)
|
|
||||||
waitForSystemAlert(time.Minute)
|
|
||||||
fixture.assertTriggered(t, false, "Alert should not be triggered until the history window is full")
|
|
||||||
|
|
||||||
submitValue(fixture, t, triggerValue, setValue)
|
|
||||||
waitForSystemAlert(time.Second)
|
|
||||||
fixture.assertTriggered(t, true, "Alert should be triggered")
|
|
||||||
assert.Equal(t, 1, fixture.hub.TestMailer.TotalSend(), "An email should have been sent")
|
|
||||||
|
|
||||||
submitValue(fixture, t, resolveValue, setValue)
|
|
||||||
waitForSystemAlert(time.Second)
|
|
||||||
fixture.assertTriggered(t, false, "Alert should be untriggered")
|
|
||||||
assert.Equal(t, 2, fixture.hub.TestMailer.TotalSend(), "A second email should have been sent for untriggering the alert")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func setCPUAlertValue(info *system.Info, stats *system.Stats, value float64) {
|
|
||||||
info.Cpu = value
|
|
||||||
stats.Cpu = value
|
|
||||||
}
|
|
||||||
|
|
||||||
func setMemoryAlertValue(info *system.Info, stats *system.Stats, value float64) {
|
|
||||||
info.MemPct = value
|
|
||||||
stats.MemPct = value
|
|
||||||
}
|
|
||||||
|
|
||||||
func setDiskAlertValue(info *system.Info, stats *system.Stats, value float64) {
|
|
||||||
info.DiskPct = value
|
|
||||||
stats.DiskPct = value
|
|
||||||
}
|
|
||||||
|
|
||||||
func setBandwidthAlertValue(info *system.Info, stats *system.Stats, value [2]uint64) {
|
|
||||||
info.BandwidthBytes = value[0] + value[1]
|
|
||||||
stats.Bandwidth = value
|
|
||||||
}
|
|
||||||
|
|
||||||
func megabytesToBytes(mb uint64) uint64 {
|
|
||||||
return mb * 1024 * 1024
|
|
||||||
}
|
|
||||||
|
|
||||||
func setGPUAlertValue(info *system.Info, stats *system.Stats, value float64) {
|
|
||||||
info.GpuPct = value
|
|
||||||
stats.GPUData = map[string]system.GPUData{
|
|
||||||
"GPU0": {Usage: value},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func setTemperatureAlertValue(info *system.Info, stats *system.Stats, value float64) {
|
|
||||||
info.DashboardTemp = value
|
|
||||||
stats.Temperatures = map[string]float64{
|
|
||||||
"Temp0": value,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func setLoadAvgAlertValue(info *system.Info, stats *system.Stats, value [3]float64) {
|
|
||||||
info.LoadAvg = value
|
|
||||||
stats.LoadAvg = value
|
|
||||||
}
|
|
||||||
|
|
||||||
func setBatteryAlertValue(info *system.Info, stats *system.Stats, value [2]uint8) {
|
|
||||||
info.Battery = value
|
|
||||||
stats.Battery = value
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSystemAlertsOneMin(t *testing.T) {
|
|
||||||
testOneMinuteSystemAlert(t, "CPU", 50, setCPUAlertValue, 51, 49)
|
|
||||||
testOneMinuteSystemAlert(t, "Memory", 50, setMemoryAlertValue, 51, 49)
|
|
||||||
testOneMinuteSystemAlert(t, "Disk", 50, setDiskAlertValue, 51, 49)
|
|
||||||
testOneMinuteSystemAlert(t, "Bandwidth", 50, setBandwidthAlertValue, [2]uint64{megabytesToBytes(26), megabytesToBytes(25)}, [2]uint64{megabytesToBytes(25), megabytesToBytes(24)})
|
|
||||||
testOneMinuteSystemAlert(t, "GPU", 50, setGPUAlertValue, 51, 49)
|
|
||||||
testOneMinuteSystemAlert(t, "Temperature", 70, setTemperatureAlertValue, 71, 69)
|
|
||||||
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{0, 1}, [2]uint8{21, 0})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSystemAlertsTwoMin(t *testing.T) {
|
|
||||||
testMultiMinuteSystemAlert(t, "CPU", 50, 2, setCPUAlertValue, 10, 51, 48)
|
|
||||||
testMultiMinuteSystemAlert(t, "Memory", 50, 2, setMemoryAlertValue, 10, 51, 48)
|
|
||||||
testMultiMinuteSystemAlert(t, "Disk", 50, 2, setDiskAlertValue, 10, 51, 48)
|
|
||||||
testMultiMinuteSystemAlert(t, "Bandwidth", 50, 2, setBandwidthAlertValue, [2]uint64{megabytesToBytes(10), megabytesToBytes(10)}, [2]uint64{megabytesToBytes(26), megabytesToBytes(25)}, [2]uint64{megabytesToBytes(10), megabytesToBytes(10)})
|
|
||||||
testMultiMinuteSystemAlert(t, "GPU", 50, 2, setGPUAlertValue, 10, 51, 48)
|
|
||||||
testMultiMinuteSystemAlert(t, "Temperature", 70, 2, setTemperatureAlertValue, 10, 71, 67)
|
|
||||||
testMultiMinuteSystemAlert(t, "LoadAvg1", 4, 2, setLoadAvgAlertValue, [3]float64{0, 0, 0}, [3]float64{4.1, 0, 0}, [3]float64{3.5, 0, 0})
|
|
||||||
testMultiMinuteSystemAlert(t, "LoadAvg5", 4, 2, setLoadAvgAlertValue, [3]float64{0, 2, 0}, [3]float64{0, 4.1, 0}, [3]float64{0, 3.5, 0})
|
|
||||||
testMultiMinuteSystemAlert(t, "LoadAvg15", 4, 2, setLoadAvgAlertValue, [3]float64{0, 0, 2}, [3]float64{0, 0, 4.1}, [3]float64{0, 0, 3.5})
|
|
||||||
testMultiMinuteSystemAlert(t, "Battery", 20, 2, setBatteryAlertValue, [2]uint8{21, 0}, [2]uint8{19, 0}, [2]uint8{25, 1})
|
|
||||||
}
|
|
||||||
@@ -3,21 +3,456 @@
|
|||||||
package alerts_test
|
package alerts_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"testing/synctest"
|
"testing/synctest"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/alerts"
|
||||||
beszelTests "github.com/henrygd/beszel/internal/tests"
|
beszelTests "github.com/henrygd/beszel/internal/tests"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/alerts"
|
|
||||||
"github.com/pocketbase/dbx"
|
"github.com/pocketbase/dbx"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
pbTests "github.com/pocketbase/pocketbase/tests"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAlertsHistory(t *testing.T) {
|
// marshal to json and return an io.Reader (for use in ApiScenario.Body)
|
||||||
hub, user := beszelTests.GetHubWithUser(t)
|
func jsonReader(v any) io.Reader {
|
||||||
|
data, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return bytes.NewReader(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserAlertsApi(t *testing.T) {
|
||||||
|
hub, _ := beszelTests.NewTestHub(t.TempDir())
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
hub.StartHub()
|
||||||
|
|
||||||
|
user1, _ := beszelTests.CreateUser(hub, "alertstest@example.com", "password")
|
||||||
|
user1Token, _ := user1.NewAuthToken()
|
||||||
|
|
||||||
|
user2, _ := beszelTests.CreateUser(hub, "alertstest2@example.com", "password")
|
||||||
|
user2Token, _ := user2.NewAuthToken()
|
||||||
|
|
||||||
|
system1, _ := beszelTests.CreateRecord(hub, "systems", map[string]any{
|
||||||
|
"name": "system1",
|
||||||
|
"users": []string{user1.Id},
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
})
|
||||||
|
|
||||||
|
system2, _ := beszelTests.CreateRecord(hub, "systems", map[string]any{
|
||||||
|
"name": "system2",
|
||||||
|
"users": []string{user1.Id, user2.Id},
|
||||||
|
"host": "127.0.0.2",
|
||||||
|
})
|
||||||
|
|
||||||
|
userRecords, _ := hub.CountRecords("users")
|
||||||
|
assert.EqualValues(t, 2, userRecords, "all users should be created")
|
||||||
|
|
||||||
|
systemRecords, _ := hub.CountRecords("systems")
|
||||||
|
assert.EqualValues(t, 2, systemRecords, "all systems should be created")
|
||||||
|
|
||||||
|
testAppFactory := func(t testing.TB) *pbTests.TestApp {
|
||||||
|
return hub.TestApp
|
||||||
|
}
|
||||||
|
|
||||||
|
scenarios := []beszelTests.ApiScenario{
|
||||||
|
// {
|
||||||
|
// Name: "GET not implemented - returns index",
|
||||||
|
// Method: http.MethodGet,
|
||||||
|
// URL: "/api/beszel/user-alerts",
|
||||||
|
// ExpectedStatus: 200,
|
||||||
|
// ExpectedContent: []string{"<html ", "globalThis.BESZEL"},
|
||||||
|
// TestAppFactory: testAppFactory,
|
||||||
|
// },
|
||||||
|
{
|
||||||
|
Name: "POST no auth",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
ExpectedStatus: 401,
|
||||||
|
ExpectedContent: []string{"requires valid"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "POST no body",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": user1Token,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 400,
|
||||||
|
ExpectedContent: []string{"Bad data"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "POST bad data",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": user1Token,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 400,
|
||||||
|
ExpectedContent: []string{"Bad data"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"invalidField": "this should cause validation error",
|
||||||
|
"threshold": "not a number",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "POST malformed JSON",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": user1Token,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 400,
|
||||||
|
ExpectedContent: []string{"Bad data"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: strings.NewReader(`{"alertType": "cpu", "threshold": 80, "enabled": true,}`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "POST valid alert data multiple systems",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": user1Token,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"\"success\":true"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"value": 69,
|
||||||
|
"min": 9,
|
||||||
|
"systems": []string{system1.Id, system2.Id},
|
||||||
|
"overwrite": false,
|
||||||
|
}),
|
||||||
|
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||||
|
// check total alerts
|
||||||
|
alerts, _ := app.CountRecords("alerts")
|
||||||
|
assert.EqualValues(t, 2, alerts, "should have 2 alerts")
|
||||||
|
// check alert has correct values
|
||||||
|
matchingAlerts, _ := app.CountRecords("alerts", dbx.HashExp{"name": "CPU", "user": user1.Id, "system": system1.Id, "value": 69, "min": 9})
|
||||||
|
assert.EqualValues(t, 1, matchingAlerts, "should have 1 alert")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "POST valid alert data single system",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": user1Token,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"\"success\":true"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"name": "Memory",
|
||||||
|
"systems": []string{system1.Id},
|
||||||
|
"value": 90,
|
||||||
|
"min": 10,
|
||||||
|
}),
|
||||||
|
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||||
|
user1Alerts, _ := app.CountRecords("alerts", dbx.HashExp{"user": user1.Id})
|
||||||
|
assert.EqualValues(t, 3, user1Alerts, "should have 3 alerts")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Overwrite: false, should not overwrite existing alert",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": user1Token,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"\"success\":true"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"value": 45,
|
||||||
|
"min": 5,
|
||||||
|
"systems": []string{system1.Id},
|
||||||
|
"overwrite": false,
|
||||||
|
}),
|
||||||
|
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||||
|
beszelTests.ClearCollection(t, app, "alerts")
|
||||||
|
beszelTests.CreateRecord(app, "alerts", map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"system": system1.Id,
|
||||||
|
"user": user1.Id,
|
||||||
|
"value": 80,
|
||||||
|
"min": 10,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||||
|
alerts, _ := app.CountRecords("alerts")
|
||||||
|
assert.EqualValues(t, 1, alerts, "should have 1 alert")
|
||||||
|
alert, _ := app.FindFirstRecordByFilter("alerts", "name = 'CPU' && user = {:user}", dbx.Params{"user": user1.Id})
|
||||||
|
assert.EqualValues(t, 80, alert.Get("value"), "should have 80 as value")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Overwrite: true, should overwrite existing alert",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": user2Token,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"\"success\":true"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"value": 45,
|
||||||
|
"min": 5,
|
||||||
|
"systems": []string{system2.Id},
|
||||||
|
"overwrite": true,
|
||||||
|
}),
|
||||||
|
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||||
|
beszelTests.ClearCollection(t, app, "alerts")
|
||||||
|
beszelTests.CreateRecord(app, "alerts", map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"system": system2.Id,
|
||||||
|
"user": user2.Id,
|
||||||
|
"value": 80,
|
||||||
|
"min": 10,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||||
|
alerts, _ := app.CountRecords("alerts")
|
||||||
|
assert.EqualValues(t, 1, alerts, "should have 1 alert")
|
||||||
|
alert, _ := app.FindFirstRecordByFilter("alerts", "name = 'CPU' && user = {:user}", dbx.Params{"user": user2.Id})
|
||||||
|
assert.EqualValues(t, 45, alert.Get("value"), "should have 45 as value")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "DELETE no auth",
|
||||||
|
Method: http.MethodDelete,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
ExpectedStatus: 401,
|
||||||
|
ExpectedContent: []string{"requires valid"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"systems": []string{system1.Id},
|
||||||
|
}),
|
||||||
|
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||||
|
beszelTests.ClearCollection(t, app, "alerts")
|
||||||
|
beszelTests.CreateRecord(app, "alerts", map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"system": system1.Id,
|
||||||
|
"user": user1.Id,
|
||||||
|
"value": 80,
|
||||||
|
"min": 10,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||||
|
alerts, _ := app.CountRecords("alerts")
|
||||||
|
assert.EqualValues(t, 1, alerts, "should have 1 alert")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "DELETE alert",
|
||||||
|
Method: http.MethodDelete,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": user1Token,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"\"count\":1", "\"success\":true"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"systems": []string{system1.Id},
|
||||||
|
}),
|
||||||
|
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||||
|
beszelTests.ClearCollection(t, app, "alerts")
|
||||||
|
beszelTests.CreateRecord(app, "alerts", map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"system": system1.Id,
|
||||||
|
"user": user1.Id,
|
||||||
|
"value": 80,
|
||||||
|
"min": 10,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||||
|
alerts, _ := app.CountRecords("alerts")
|
||||||
|
assert.Zero(t, alerts, "should have 0 alerts")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "DELETE alert multiple systems",
|
||||||
|
Method: http.MethodDelete,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": user1Token,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"\"count\":2", "\"success\":true"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"name": "Memory",
|
||||||
|
"systems": []string{system1.Id, system2.Id},
|
||||||
|
}),
|
||||||
|
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||||
|
beszelTests.ClearCollection(t, app, "alerts")
|
||||||
|
for _, systemId := range []string{system1.Id, system2.Id} {
|
||||||
|
_, err := beszelTests.CreateRecord(app, "alerts", map[string]any{
|
||||||
|
"name": "Memory",
|
||||||
|
"system": systemId,
|
||||||
|
"user": user1.Id,
|
||||||
|
"value": 90,
|
||||||
|
"min": 10,
|
||||||
|
})
|
||||||
|
assert.NoError(t, err, "should create alert")
|
||||||
|
}
|
||||||
|
alerts, _ := app.CountRecords("alerts")
|
||||||
|
assert.EqualValues(t, 2, alerts, "should have 2 alerts")
|
||||||
|
},
|
||||||
|
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||||
|
alerts, _ := app.CountRecords("alerts")
|
||||||
|
assert.Zero(t, alerts, "should have 0 alerts")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "User 2 should not be able to delete alert of user 1",
|
||||||
|
Method: http.MethodDelete,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": user2Token,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"\"count\":1", "\"success\":true"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"systems": []string{system2.Id},
|
||||||
|
}),
|
||||||
|
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||||
|
beszelTests.ClearCollection(t, app, "alerts")
|
||||||
|
for _, user := range []string{user1.Id, user2.Id} {
|
||||||
|
beszelTests.CreateRecord(app, "alerts", map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"system": system2.Id,
|
||||||
|
"user": user,
|
||||||
|
"value": 80,
|
||||||
|
"min": 10,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
alerts, _ := app.CountRecords("alerts")
|
||||||
|
assert.EqualValues(t, 2, alerts, "should have 2 alerts")
|
||||||
|
user1AlertCount, _ := app.CountRecords("alerts", dbx.HashExp{"user": user1.Id})
|
||||||
|
assert.EqualValues(t, 1, user1AlertCount, "should have 1 alert")
|
||||||
|
user2AlertCount, _ := app.CountRecords("alerts", dbx.HashExp{"user": user2.Id})
|
||||||
|
assert.EqualValues(t, 1, user2AlertCount, "should have 1 alert")
|
||||||
|
},
|
||||||
|
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||||
|
user1AlertCount, _ := app.CountRecords("alerts", dbx.HashExp{"user": user1.Id})
|
||||||
|
assert.EqualValues(t, 1, user1AlertCount, "should have 1 alert")
|
||||||
|
user2AlertCount, _ := app.CountRecords("alerts", dbx.HashExp{"user": user2.Id})
|
||||||
|
assert.Zero(t, user2AlertCount, "should have 0 alerts")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
scenario.Test(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatusAlerts(t *testing.T) {
|
||||||
synctest.Test(t, func(t *testing.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")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
var alerts []*core.Record
|
||||||
|
for i, system := range systems {
|
||||||
|
alert, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{
|
||||||
|
"name": "Status",
|
||||||
|
"system": system.Id,
|
||||||
|
"user": user.Id,
|
||||||
|
"min": i + 1,
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
alerts = append(alerts, alert)
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
|
||||||
|
for _, alert := range alerts {
|
||||||
|
assert.False(t, alert.GetBool("triggered"), "Alert should not be triggered immediately")
|
||||||
|
}
|
||||||
|
if hub.TestMailer.TotalSend() != 0 {
|
||||||
|
assert.Zero(t, hub.TestMailer.TotalSend(), "Expected 0 messages, got %d", hub.TestMailer.TotalSend())
|
||||||
|
}
|
||||||
|
for _, system := range systems {
|
||||||
|
assert.EqualValues(t, "paused", system.GetString("status"), "System should be paused")
|
||||||
|
}
|
||||||
|
for _, system := range systems {
|
||||||
|
system.Set("status", "up")
|
||||||
|
err = hub.SaveNoValidate(system)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
assert.EqualValues(t, 0, hub.GetPendingAlertsCount(), "should have 0 alerts in the pendingAlerts map")
|
||||||
|
for _, system := range systems {
|
||||||
|
system.Set("status", "down")
|
||||||
|
err = hub.SaveNoValidate(system)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
// after 30 seconds, should have 4 alerts in the pendingAlerts map, no triggered alerts
|
||||||
|
time.Sleep(time.Second * 30)
|
||||||
|
assert.EqualValues(t, 4, hub.GetPendingAlertsCount(), "should have 4 alerts in the pendingAlerts map")
|
||||||
|
triggeredCount, err := hub.CountRecords("alerts", dbx.HashExp{"triggered": true})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.EqualValues(t, 0, triggeredCount, "should have 0 alert triggered")
|
||||||
|
assert.EqualValues(t, 0, hub.TestMailer.TotalSend(), "should have 0 messages sent")
|
||||||
|
// after 1:30 seconds, should have 1 triggered alert and 3 pending alerts
|
||||||
|
time.Sleep(time.Second * 60)
|
||||||
|
assert.EqualValues(t, 3, hub.GetPendingAlertsCount(), "should have 3 alerts in the pendingAlerts map")
|
||||||
|
triggeredCount, err = hub.CountRecords("alerts", dbx.HashExp{"triggered": true})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.EqualValues(t, 1, triggeredCount, "should have 1 alert triggered")
|
||||||
|
assert.EqualValues(t, 1, hub.TestMailer.TotalSend(), "should have 1 messages sent")
|
||||||
|
// after 2:30 seconds, should have 2 triggered alerts and 2 pending alerts
|
||||||
|
time.Sleep(time.Second * 60)
|
||||||
|
assert.EqualValues(t, 2, hub.GetPendingAlertsCount(), "should have 2 alerts in the pendingAlerts map")
|
||||||
|
triggeredCount, err = hub.CountRecords("alerts", dbx.HashExp{"triggered": true})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.EqualValues(t, 2, triggeredCount, "should have 2 alert triggered")
|
||||||
|
assert.EqualValues(t, 2, hub.TestMailer.TotalSend(), "should have 2 messages sent")
|
||||||
|
// now we will bring the remaning systems back up
|
||||||
|
for _, system := range systems {
|
||||||
|
system.Set("status", "up")
|
||||||
|
err = hub.SaveNoValidate(system)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
// should have 0 alerts in the pendingAlerts map and 0 alerts triggered
|
||||||
|
assert.EqualValues(t, 0, hub.GetPendingAlertsCount(), "should have 0 alerts in the pendingAlerts map")
|
||||||
|
triggeredCount, err = hub.CountRecords("alerts", dbx.HashExp{"triggered": true})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Zero(t, triggeredCount, "should have 0 alert triggered")
|
||||||
|
// 4 messages sent, 2 down alerts and 2 up alerts for first 2 systems
|
||||||
|
assert.EqualValues(t, 4, hub.TestMailer.TotalSend(), "should have 4 messages sent")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertsHistory(t *testing.T) {
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
hub, user := beszelTests.GetHubWithUser(t)
|
||||||
defer hub.Cleanup()
|
defer hub.Cleanup()
|
||||||
|
|
||||||
// Create systems and alerts
|
// Create systems and alerts
|
||||||
@@ -143,46 +578,102 @@ func TestAlertsHistory(t *testing.T) {
|
|||||||
assert.EqualValues(t, 2, totalHistoryCount, "Should have 2 total alert history records")
|
assert.EqualValues(t, 2, totalHistoryCount, "Should have 2 total alert history records")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
func TestResolveStatusAlerts(t *testing.T) {
|
||||||
func TestSetAlertTriggered(t *testing.T) {
|
hub, user := beszelTests.GetHubWithUser(t)
|
||||||
hub, _ := beszelTests.NewTestHub(t.TempDir())
|
|
||||||
defer hub.Cleanup()
|
defer hub.Cleanup()
|
||||||
|
|
||||||
hub.StartHub()
|
// Create a systemUp
|
||||||
|
systemUp, err := beszelTests.CreateRecord(hub, "systems", map[string]any{
|
||||||
user, _ := beszelTests.CreateUser(hub, "test@example.com", "password")
|
"name": "test-system",
|
||||||
system, _ := beszelTests.CreateRecord(hub, "systems", map[string]any{
|
"users": []string{user.Id},
|
||||||
"name": "test-system",
|
"host": "127.0.0.1",
|
||||||
"users": []string{user.Id},
|
"status": "up",
|
||||||
"host": "127.0.0.1",
|
|
||||||
})
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
alertRecord, _ := beszelTests.CreateRecord(hub, "alerts", map[string]any{
|
systemDown, err := beszelTests.CreateRecord(hub, "systems", map[string]any{
|
||||||
"name": "CPU",
|
"name": "test-system-2",
|
||||||
"system": system.Id,
|
"users": []string{user.Id},
|
||||||
"user": user.Id,
|
"host": "127.0.0.2",
|
||||||
"value": 80,
|
"status": "up",
|
||||||
"triggered": false,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
am := alerts.NewAlertManager(hub)
|
|
||||||
|
|
||||||
var alert alerts.CachedAlertData
|
|
||||||
alert.PopulateFromRecord(alertRecord)
|
|
||||||
|
|
||||||
// Test triggering the alert
|
|
||||||
err := am.SetAlertTriggered(alert, true)
|
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
updatedRecord, err := hub.FindRecordById("alerts", alert.Id)
|
// Create a status alertUp for the system
|
||||||
assert.NoError(t, err)
|
alertUp, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{
|
||||||
assert.True(t, updatedRecord.GetBool("triggered"))
|
"name": "Status",
|
||||||
|
"system": systemUp.Id,
|
||||||
// Test un-triggering the alert
|
"user": user.Id,
|
||||||
err = am.SetAlertTriggered(alert, false)
|
"min": 1,
|
||||||
|
})
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
updatedRecord, err = hub.FindRecordById("alerts", alert.Id)
|
alertDown, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{
|
||||||
|
"name": "Status",
|
||||||
|
"system": systemDown.Id,
|
||||||
|
"user": user.Id,
|
||||||
|
"min": 1,
|
||||||
|
})
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.False(t, updatedRecord.GetBool("triggered"))
|
|
||||||
|
// Verify alert is not triggered initially
|
||||||
|
assert.False(t, alertUp.GetBool("triggered"), "Alert should not be triggered initially")
|
||||||
|
|
||||||
|
// Set the system to 'up' (this should not trigger the alert)
|
||||||
|
systemUp.Set("status", "up")
|
||||||
|
err = hub.SaveNoValidate(systemUp)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
systemDown.Set("status", "down")
|
||||||
|
err = hub.SaveNoValidate(systemDown)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Wait a moment for any processing
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
|
||||||
|
// Verify alertUp is still not triggered after setting system to up
|
||||||
|
alertUp, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": alertUp.Id})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.False(t, alertUp.GetBool("triggered"), "Alert should not be triggered when system is up")
|
||||||
|
|
||||||
|
// Manually set both alerts triggered to true
|
||||||
|
alertUp.Set("triggered", true)
|
||||||
|
err = hub.SaveNoValidate(alertUp)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
alertDown.Set("triggered", true)
|
||||||
|
err = hub.SaveNoValidate(alertDown)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify we have exactly one alert with triggered true
|
||||||
|
triggeredCount, err := hub.CountRecords("alerts", dbx.HashExp{"triggered": true})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.EqualValues(t, 2, triggeredCount, "Should have exactly two alerts with triggered true")
|
||||||
|
|
||||||
|
// Verify the specific alertUp is triggered
|
||||||
|
alertUp, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": alertUp.Id})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.True(t, alertUp.GetBool("triggered"), "Alert should be triggered")
|
||||||
|
|
||||||
|
// Verify we have two unresolved alert history records
|
||||||
|
alertHistoryCount, err := hub.CountRecords("alerts_history", dbx.HashExp{"resolved": ""})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.EqualValues(t, 2, alertHistoryCount, "Should have exactly two unresolved alert history records")
|
||||||
|
|
||||||
|
err = alerts.ResolveStatusAlerts(hub)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify alertUp is not triggered after resolving
|
||||||
|
alertUp, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": alertUp.Id})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.False(t, alertUp.GetBool("triggered"), "Alert should not be triggered after resolving")
|
||||||
|
// Verify alertDown is still triggered
|
||||||
|
alertDown, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": alertDown.Id})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.True(t, alertDown.GetBool("triggered"), "Alert should still be triggered after resolving")
|
||||||
|
|
||||||
|
// Verify we have one unresolved alert history record
|
||||||
|
alertHistoryCount, err = hub.CountRecords("alerts_history", dbx.HashExp{"resolved": ""})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.EqualValues(t, 1, alertHistoryCount, "Should have exactly one unresolved alert history record")
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,18 +9,6 @@ import (
|
|||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewTestAlertManagerWithoutWorker(app hubLike) *AlertManager {
|
|
||||||
return &AlertManager{
|
|
||||||
hub: app,
|
|
||||||
alertsCache: NewAlertsCache(app),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetSystemAlertsCache returns the internal system alerts cache.
|
|
||||||
func (am *AlertManager) GetSystemAlertsCache() *AlertsCache {
|
|
||||||
return am.alertsCache
|
|
||||||
}
|
|
||||||
|
|
||||||
func (am *AlertManager) GetAlertManager() *AlertManager {
|
func (am *AlertManager) GetAlertManager() *AlertManager {
|
||||||
return am
|
return am
|
||||||
}
|
}
|
||||||
@@ -39,18 +27,19 @@ func (am *AlertManager) GetPendingAlertsCount() int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ProcessPendingAlerts manually processes all expired alerts (for testing)
|
// ProcessPendingAlerts manually processes all expired alerts (for testing)
|
||||||
func (am *AlertManager) ProcessPendingAlerts() ([]CachedAlertData, error) {
|
func (am *AlertManager) ProcessPendingAlerts() ([]*core.Record, error) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
var lastErr error
|
var lastErr error
|
||||||
var processedAlerts []CachedAlertData
|
var processedAlerts []*core.Record
|
||||||
am.pendingAlerts.Range(func(key, value any) bool {
|
am.pendingAlerts.Range(func(key, value any) bool {
|
||||||
info := value.(*alertInfo)
|
info := value.(*alertInfo)
|
||||||
if now.After(info.expireTime) {
|
if now.After(info.expireTime) {
|
||||||
if info.timer != nil {
|
// Downtime delay has passed, process alert
|
||||||
info.timer.Stop()
|
if err := am.sendStatusAlert("down", info.systemName, info.alertRecord); err != nil {
|
||||||
|
lastErr = err
|
||||||
}
|
}
|
||||||
am.processPendingAlert(key.(string))
|
processedAlerts = append(processedAlerts, info.alertRecord)
|
||||||
processedAlerts = append(processedAlerts, info.alertData)
|
am.pendingAlerts.Delete(key)
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
@@ -67,35 +56,6 @@ func (am *AlertManager) ForceExpirePendingAlerts() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (am *AlertManager) ResetPendingAlertTimer(alertID string, delay time.Duration) bool {
|
|
||||||
value, loaded := am.pendingAlerts.Load(alertID)
|
|
||||||
if !loaded {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
info := value.(*alertInfo)
|
|
||||||
if info.timer != nil {
|
|
||||||
info.timer.Stop()
|
|
||||||
}
|
|
||||||
info.expireTime = time.Now().Add(delay)
|
|
||||||
info.timer = time.AfterFunc(delay, func() {
|
|
||||||
am.processPendingAlert(alertID)
|
|
||||||
})
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func ResolveStatusAlerts(app core.App) error {
|
func ResolveStatusAlerts(app core.App) error {
|
||||||
return resolveStatusAlerts(app)
|
return resolveStatusAlerts(app)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (am *AlertManager) RestorePendingStatusAlerts() error {
|
|
||||||
return am.restorePendingStatusAlerts()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (am *AlertManager) SetAlertTriggered(alert CachedAlertData, triggered bool) error {
|
|
||||||
return am.setAlertTriggered(alert, triggered)
|
|
||||||
}
|
|
||||||
|
|
||||||
func IsInternalURL(rawURL string) (bool, error) {
|
|
||||||
return isInternalURL(rawURL)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
"github.com/henrygd/beszel"
|
"github.com/henrygd/beszel"
|
||||||
"github.com/henrygd/beszel/agent"
|
"github.com/henrygd/beszel/agent"
|
||||||
"github.com/henrygd/beszel/agent/health"
|
"github.com/henrygd/beszel/agent/health"
|
||||||
"github.com/henrygd/beszel/agent/utils"
|
|
||||||
"github.com/spf13/pflag"
|
"github.com/spf13/pflag"
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
)
|
)
|
||||||
@@ -117,12 +116,12 @@ func (opts *cmdOptions) loadPublicKeys() ([]ssh.PublicKey, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Try environment variable
|
// Try environment variable
|
||||||
if key, ok := utils.GetEnv("KEY"); ok && key != "" {
|
if key, ok := agent.GetEnv("KEY"); ok && key != "" {
|
||||||
return agent.ParseKeys(key)
|
return agent.ParseKeys(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try key file
|
// Try key file
|
||||||
keyFile, ok := utils.GetEnv("KEY_FILE")
|
keyFile, ok := agent.GetEnv("KEY_FILE")
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmt.Errorf("no key provided: must set -key flag, KEY env var, or KEY_FILE env var. Use 'beszel-agent help' for usage")
|
return nil, fmt.Errorf("no key provided: must set -key flag, KEY env var, or KEY_FILE env var. Use 'beszel-agent help' for usage")
|
||||||
}
|
}
|
||||||
@@ -195,6 +194,6 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := a.Start(serverConfig); err != nil {
|
if err := a.Start(serverConfig); err != nil {
|
||||||
log.Fatal("Failed to start: ", err)
|
log.Fatal("Failed to start server: ", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
baseApp := getBaseApp()
|
baseApp := getBaseApp()
|
||||||
hub := hub.NewHub(baseApp)
|
h := hub.NewHub(baseApp)
|
||||||
if err := hub.StartHub(); err != nil {
|
if err := h.StartHub(); err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,8 +42,7 @@ type AgentResponse struct {
|
|||||||
SmartData map[string]smart.SmartData `cbor:"5,keyasint,omitempty,omitzero"` // Legacy (<= 0.17)
|
SmartData map[string]smart.SmartData `cbor:"5,keyasint,omitempty,omitzero"` // Legacy (<= 0.17)
|
||||||
ServiceInfo systemd.ServiceDetails `cbor:"6,keyasint,omitempty,omitzero"` // Legacy (<= 0.17)
|
ServiceInfo systemd.ServiceDetails `cbor:"6,keyasint,omitempty,omitzero"` // Legacy (<= 0.17)
|
||||||
// Data is the generic response payload for new endpoints (0.18+)
|
// Data is the generic response payload for new endpoints (0.18+)
|
||||||
Data cbor.RawMessage `cbor:"7,keyasint,omitempty,omitzero"`
|
Data cbor.RawMessage `cbor:"7,keyasint,omitempty,omitzero"`
|
||||||
SmartComplete bool `cbor:"8,keyasint,omitempty,omitzero"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type FingerprintRequest struct {
|
type FingerprintRequest struct {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ FROM alpine:3.23
|
|||||||
|
|
||||||
COPY --from=builder /agent /agent
|
COPY --from=builder /agent /agent
|
||||||
|
|
||||||
RUN apk add --no-cache -X https://dl-cdn.alpinelinux.org/alpine/edge/testing igt-gpu-tools nvtop smartmontools
|
RUN apk add --no-cache -X https://dl-cdn.alpinelinux.org/alpine/edge/testing igt-gpu-tools smartmontools
|
||||||
|
|
||||||
# Ensure data persistence across container recreations
|
# Ensure data persistence across container recreations
|
||||||
VOLUME ["/var/lib/beszel-agent"]
|
VOLUME ["/var/lib/beszel-agent"]
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
FROM --platform=$BUILDPLATFORM golang:bookworm AS builder
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY ../go.mod ../go.sum ./
|
|
||||||
RUN go mod download
|
|
||||||
|
|
||||||
# Copy source files
|
|
||||||
COPY . ./
|
|
||||||
|
|
||||||
# Build
|
|
||||||
ARG TARGETOS=linux
|
|
||||||
ARG TARGETARCH
|
|
||||||
ARG TARGETVARIANT
|
|
||||||
|
|
||||||
RUN set -eux; \
|
|
||||||
if [ "$TARGETARCH" = "arm" ] && [ -n "$TARGETVARIANT" ]; then \
|
|
||||||
export GOARM="${TARGETVARIANT#v}"; \
|
|
||||||
fi; \
|
|
||||||
CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH \
|
|
||||||
go build -tags glibc -ldflags "-w -s" -o /agent ./internal/cmd/agent
|
|
||||||
|
|
||||||
# --------------------------
|
|
||||||
# Smartmontools builder stage
|
|
||||||
# --------------------------
|
|
||||||
FROM --platform=$TARGETPLATFORM debian:bookworm-slim AS smartmontools-builder
|
|
||||||
|
|
||||||
# Keep smartmontools 7.5 built from source to match the current NVIDIA agent image behavior.
|
|
||||||
# A simpler Debian package based approach is also possible:
|
|
||||||
#
|
|
||||||
# RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
# smartmontools \
|
|
||||||
# && rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
wget \
|
|
||||||
ca-certificates \
|
|
||||||
build-essential \
|
|
||||||
make \
|
|
||||||
g++ \
|
|
||||||
&& wget https://downloads.sourceforge.net/project/smartmontools/smartmontools/7.5/smartmontools-7.5.tar.gz \
|
|
||||||
&& tar zxvf smartmontools-7.5.tar.gz \
|
|
||||||
&& cd smartmontools-7.5 \
|
|
||||||
&& ./configure --prefix=/usr --sysconfdir=/etc \
|
|
||||||
&& make \
|
|
||||||
&& make install \
|
|
||||||
&& rm -rf /smartmontools-7.5* \
|
|
||||||
&& apt-get remove -y wget build-essential \
|
|
||||||
&& apt-get autoremove -y \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Copy smartmontools binary, data files, and required runtime libraries
|
|
||||||
RUN set -eux; \
|
|
||||||
mkdir -p /out/rootfs/usr/share /out/rootfs/lib /out/rootfs/lib64 /out/rootfs/usr/lib; \
|
|
||||||
if [ -d /usr/share/smartmontools ]; then \
|
|
||||||
cp -a /usr/share/smartmontools /out/rootfs/usr/share/; \
|
|
||||||
fi; \
|
|
||||||
ldd /usr/sbin/smartctl \
|
|
||||||
| awk '{print $3}' \
|
|
||||||
| grep '^/' \
|
|
||||||
| xargs -r -I '{}' sh -c 'mkdir -p "/out/rootfs$(dirname "{}")"; cp -v "{}" "/out/rootfs{}"'; \
|
|
||||||
interp="$(ldd /usr/sbin/smartctl | awk "/ld-linux/ {print \$1}")"; \
|
|
||||||
if [ -n "$interp" ] && [ -e "$interp" ]; then \
|
|
||||||
mkdir -p "/out/rootfs$(dirname "$interp")"; \
|
|
||||||
cp -v "$interp" "/out/rootfs$interp"; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --------------------------
|
|
||||||
# Final image: lightweight multi-arch NVIDIA agent (slim)
|
|
||||||
# --------------------------
|
|
||||||
FROM --platform=$TARGETPLATFORM gcr.io/distroless/base-debian12
|
|
||||||
COPY --from=builder /agent /agent
|
|
||||||
|
|
||||||
# AMD GPU name lookup (used by agent on hybrid laptops when /usr/share/libdrm/amdgpu.ids is read)
|
|
||||||
COPY --from=builder /app/agent/test-data/amdgpu.ids /usr/share/libdrm/amdgpu.ids
|
|
||||||
|
|
||||||
# Copy smartmontools binaries and config files
|
|
||||||
COPY --from=smartmontools-builder /usr/sbin/smartctl /usr/sbin/smartctl
|
|
||||||
COPY --from=smartmontools-builder /out/rootfs/ /
|
|
||||||
|
|
||||||
# nvidia-smi is intentionally not bundled.
|
|
||||||
# Mount the host binary instead, for example:
|
|
||||||
# - /usr/bin/nvidia-smi:/usr/bin/nvidia-smi:ro
|
|
||||||
|
|
||||||
# Ensure data persistence across container recreations
|
|
||||||
VOLUME ["/var/lib/beszel-agent"]
|
|
||||||
|
|
||||||
WORKDIR /var/lib/beszel-agent
|
|
||||||
|
|
||||||
ENTRYPOINT ["/agent"]
|
|
||||||
@@ -10,19 +10,10 @@ type ApiInfo struct {
|
|||||||
Status string
|
Status string
|
||||||
State string
|
State string
|
||||||
Image string
|
Image string
|
||||||
Health struct {
|
|
||||||
Status string
|
|
||||||
// FailingStreak int
|
|
||||||
}
|
|
||||||
Ports []struct {
|
|
||||||
// PrivatePort uint16
|
|
||||||
PublicPort uint16
|
|
||||||
IP string
|
|
||||||
// Type string
|
|
||||||
}
|
|
||||||
// ImageID string
|
// ImageID string
|
||||||
// Command string
|
// Command string
|
||||||
// Created int64
|
// Created int64
|
||||||
|
// Ports []Port
|
||||||
// SizeRw int64 `json:",omitempty"`
|
// SizeRw int64 `json:",omitempty"`
|
||||||
// SizeRootFs int64 `json:",omitempty"`
|
// SizeRootFs int64 `json:",omitempty"`
|
||||||
// Labels map[string]string
|
// Labels map[string]string
|
||||||
@@ -52,17 +43,6 @@ type HostInfo struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *ApiStats) CalculateCpuPercentLinux(prevCpuContainer uint64, prevCpuSystem uint64) float64 {
|
func (s *ApiStats) CalculateCpuPercentLinux(prevCpuContainer uint64, prevCpuSystem uint64) float64 {
|
||||||
// A counter can read lower than the stored previous value when a stats
|
|
||||||
// response is processed after a newer one for the same container, or when an
|
|
||||||
// accounting counter resets. Unsigned subtraction wraps to ~2^64 instead of
|
|
||||||
// going negative: on the container counter that surfaces as an absurd
|
|
||||||
// percentage the caller rejects, discarding the whole sample; on the system
|
|
||||||
// counter it inflates the divisor and silently reports near-zero CPU.
|
|
||||||
// Treat either direction as a new baseline.
|
|
||||||
if s.CPUStats.CPUUsage.TotalUsage < prevCpuContainer || s.CPUStats.SystemUsage < prevCpuSystem {
|
|
||||||
return 0.0
|
|
||||||
}
|
|
||||||
|
|
||||||
cpuDelta := s.CPUStats.CPUUsage.TotalUsage - prevCpuContainer
|
cpuDelta := s.CPUStats.CPUUsage.TotalUsage - prevCpuContainer
|
||||||
systemDelta := s.CPUStats.SystemUsage - prevCpuSystem
|
systemDelta := s.CPUStats.SystemUsage - prevCpuSystem
|
||||||
|
|
||||||
@@ -74,30 +54,6 @@ func (s *ApiStats) CalculateCpuPercentLinux(prevCpuContainer uint64, prevCpuSyst
|
|||||||
return float64(cpuDelta) / float64(systemDelta) * 100.0
|
return float64(cpuDelta) / float64(systemDelta) * 100.0
|
||||||
}
|
}
|
||||||
|
|
||||||
// CalculateCpuPercentPodman calculates CPU percentage for Podman containers.
|
|
||||||
// Podman populates system_cpu_usage from cgroup cpu.stat rather than /proc/stat, so it
|
|
||||||
// represents only cgroup-accounted activity, not total host CPU capacity. Using it as
|
|
||||||
// a denominator inflates the result. Instead we use elapsed wall-clock time × online_cpus,
|
|
||||||
// matching the approach used for Windows and recommended in:
|
|
||||||
// https://github.com/henrygd/beszel/issues/2049
|
|
||||||
func (s *ApiStats) CalculateCpuPercentPodman(prevCpuContainer uint64, prevRead time.Time) float64 {
|
|
||||||
if prevCpuContainer == 0 || s.CPUStats.OnlineCPUs == 0 {
|
|
||||||
return 0.0
|
|
||||||
}
|
|
||||||
// Treat a reset or out-of-order counter as a new baseline instead of
|
|
||||||
// allowing unsigned subtraction to wrap to an enormous percentage.
|
|
||||||
if s.CPUStats.CPUUsage.TotalUsage < prevCpuContainer {
|
|
||||||
return 0.0
|
|
||||||
}
|
|
||||||
cpuDelta := s.CPUStats.CPUUsage.TotalUsage - prevCpuContainer
|
|
||||||
elapsedNs := uint64(s.Read.Sub(prevRead).Nanoseconds())
|
|
||||||
systemCapacity := elapsedNs * uint64(s.CPUStats.OnlineCPUs)
|
|
||||||
if systemCapacity == 0 {
|
|
||||||
return 0.0
|
|
||||||
}
|
|
||||||
return float64(cpuDelta) / float64(systemCapacity) * 100.0
|
|
||||||
}
|
|
||||||
|
|
||||||
// from: https://github.com/docker/cli/blob/master/cli/command/container/stats_helpers.go#L185
|
// from: https://github.com/docker/cli/blob/master/cli/command/container/stats_helpers.go#L185
|
||||||
func (s *ApiStats) CalculateCpuPercentWindows(prevCpuUsage uint64, prevRead time.Time) float64 {
|
func (s *ApiStats) CalculateCpuPercentWindows(prevCpuUsage uint64, prevRead time.Time) float64 {
|
||||||
// Max number of 100ns intervals between the previous time read and now
|
// Max number of 100ns intervals between the previous time read and now
|
||||||
@@ -105,11 +61,7 @@ func (s *ApiStats) CalculateCpuPercentWindows(prevCpuUsage uint64, prevRead time
|
|||||||
possIntervals /= 100 // Convert to number of 100ns intervals
|
possIntervals /= 100 // Convert to number of 100ns intervals
|
||||||
possIntervals *= uint64(s.NumProcs) // Multiple by the number of processors
|
possIntervals *= uint64(s.NumProcs) // Multiple by the number of processors
|
||||||
|
|
||||||
// Intervals used. Same rollback guard as the Linux path: an out-of-order or
|
// Intervals used
|
||||||
// reset counter would wrap the subtraction to ~2^64.
|
|
||||||
if s.CPUStats.CPUUsage.TotalUsage < prevCpuUsage {
|
|
||||||
return 0.0
|
|
||||||
}
|
|
||||||
intervalsUsed := s.CPUStats.CPUUsage.TotalUsage - prevCpuUsage
|
intervalsUsed := s.CPUStats.CPUUsage.TotalUsage - prevCpuUsage
|
||||||
|
|
||||||
// Percentage avoiding divide-by-zero
|
// Percentage avoiding divide-by-zero
|
||||||
@@ -122,10 +74,8 @@ func (s *ApiStats) CalculateCpuPercentWindows(prevCpuUsage uint64, prevRead time
|
|||||||
type CPUStats struct {
|
type CPUStats struct {
|
||||||
// CPU Usage. Linux and Windows.
|
// CPU Usage. Linux and Windows.
|
||||||
CPUUsage CPUUsage `json:"cpu_usage"`
|
CPUUsage CPUUsage `json:"cpu_usage"`
|
||||||
// System Usage. Linux only. Populated from /proc/stat on Docker; from cgroup cpu.stat on Podman.
|
// System Usage. Linux only.
|
||||||
SystemUsage uint64 `json:"system_cpu_usage,omitempty"`
|
SystemUsage uint64 `json:"system_cpu_usage,omitempty"`
|
||||||
// Number of online CPUs. Linux only. Used by Podman for time-based CPU calculation.
|
|
||||||
OnlineCPUs uint32 `json:"online_cpus,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type CPUUsage struct {
|
type CPUUsage struct {
|
||||||
@@ -177,23 +127,43 @@ var DockerHealthStrings = map[string]DockerHealth{
|
|||||||
"unhealthy": DockerHealthUnhealthy,
|
"unhealthy": DockerHealthUnhealthy,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Docker container stats
|
// SharedCoreMetrics contains fields that are common to both container Stats and PveNodeStats
|
||||||
type Stats struct {
|
type SharedCoreMetrics struct {
|
||||||
Name string `json:"n" cbor:"0,keyasint"`
|
Name string `json:"n" cbor:"0,keyasint"`
|
||||||
Cpu float64 `json:"c" cbor:"1,keyasint"`
|
Cpu float64 `json:"c" cbor:"1,keyasint"`
|
||||||
Mem float64 `json:"m" cbor:"2,keyasint"`
|
Mem float64 `json:"m" cbor:"2,keyasint"`
|
||||||
NetworkSent float64 `json:"ns,omitzero" cbor:"3,keyasint,omitzero"` // deprecated 0.18.3 (MB) - keep field for old agents/records
|
NetworkSent float64 `json:"ns,omitzero" cbor:"3,keyasint,omitzero"` // deprecated 0.18.3 (MB) - keep field for old agents/records
|
||||||
NetworkRecv float64 `json:"nr,omitzero" cbor:"4,keyasint,omitzero"` // deprecated 0.18.3 (MB) - keep field for old agents/records
|
NetworkRecv float64 `json:"nr,omitzero" cbor:"4,keyasint,omitzero"` // deprecated 0.18.3 (MB) - keep field for old agents/records
|
||||||
Bandwidth [2]uint64 `json:"b,omitzero" cbor:"9,keyasint,omitzero"` // [sent bytes, recv bytes]
|
Id string `json:"-" cbor:"7,keyasint"`
|
||||||
|
Bandwidth [2]uint64 `json:"b,omitzero" cbor:"9,keyasint,omitzero"` // [sent bytes, recv bytes]
|
||||||
Health DockerHealth `json:"-" cbor:"5,keyasint"`
|
|
||||||
Status string `json:"-" cbor:"6,keyasint"`
|
|
||||||
Id string `json:"-" cbor:"7,keyasint"`
|
|
||||||
Image string `json:"-" cbor:"8,keyasint"`
|
|
||||||
Ports string `json:"-" cbor:"10,keyasint"`
|
|
||||||
// PrevCpu [2]uint64 `json:"-"`
|
|
||||||
CpuSystem uint64 `json:"-"`
|
|
||||||
CpuContainer uint64 `json:"-"`
|
|
||||||
PrevNet prevNetStats `json:"-"`
|
PrevNet prevNetStats `json:"-"`
|
||||||
PrevReadTime time.Time `json:"-"`
|
PrevReadTime time.Time `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stats holds data specific to docker containers for the containers table
|
||||||
|
type Stats struct {
|
||||||
|
SharedCoreMetrics // used to populate stats field in container_stats
|
||||||
|
|
||||||
|
// fields used for containers table
|
||||||
|
|
||||||
|
Health DockerHealth `json:"-" cbor:"5,keyasint"`
|
||||||
|
Status string `json:"-" cbor:"6,keyasint"`
|
||||||
|
Image string `json:"-" cbor:"8,keyasint"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PveNodeStats holds data specific to PVE nodes for the pve_vms table
|
||||||
|
type PveNodeStats struct {
|
||||||
|
SharedCoreMetrics // used to populate stats field in pve_stats
|
||||||
|
|
||||||
|
// fields used for pve_vms table
|
||||||
|
|
||||||
|
MaxCPU uint64 `json:"-" cbor:"10,keyasint,omitzero"` // PVE: max vCPU count
|
||||||
|
MaxMem uint64 `json:"-" cbor:"11,keyasint,omitzero"` // PVE: max memory bytes
|
||||||
|
Uptime uint64 `json:"-" cbor:"12,keyasint,omitzero"` // PVE: uptime in seconds
|
||||||
|
Type string `json:"-" cbor:"13,keyasint,omitzero"` // PVE: resource type (e.g. "qemu" or "lxc")
|
||||||
|
DiskRead uint64 `json:"-" cbor:"14,keyasint,omitzero"` // PVE: cumulative disk read bytes
|
||||||
|
DiskWrite uint64 `json:"-" cbor:"15,keyasint,omitzero"` // PVE: cumulative disk write bytes
|
||||||
|
Disk uint64 `json:"-" cbor:"16,keyasint,omitzero"` // PVE: allocated disk size in bytes
|
||||||
|
NetOut uint64 `json:"-" cbor:"17,keyasint,omitzero"` // PVE: cumulative bytes sent by VM
|
||||||
|
NetIn uint64 `json:"-" cbor:"18,keyasint,omitzero"` // PVE: cumulative bytes received by VM
|
||||||
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user