mirror of
https://github.com/henrygd/beszel.git
synced 2026-08-18 08:17:47 +02:00
Compare commits
62 Commits
l10n_main_
...
dev-probes
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3534552d37 | ||
|
|
723401819f | ||
|
|
2ea576c989 | ||
|
|
526a2c6aab | ||
|
|
aaa8eb773f | ||
|
|
099935e78e | ||
|
|
d2eb3b259a | ||
|
|
b89314889d | ||
|
|
04e2b8b974 | ||
|
|
891b03426f | ||
|
|
b182b699d7 | ||
|
|
e65a4a515e | ||
|
|
df249b24f6 | ||
|
|
788483ac56 | ||
|
|
f830665984 | ||
|
|
af49ebf2df | ||
|
|
0378023b6f | ||
|
|
89ac8dc585 | ||
|
|
9896bcdf43 | ||
|
|
ddd47e67ac | ||
|
|
027159420c | ||
|
|
e154123511 | ||
|
|
9f7c1b22bb | ||
|
|
0d440e5fb9 | ||
|
|
5fc774666f | ||
|
|
8f03cbf11c | ||
|
|
1c5808f430 | ||
|
|
a35cc6ef39 | ||
|
|
16e0f6c4a2 | ||
|
|
6472af1ba4 | ||
|
|
e931165566 | ||
|
|
48fe407292 | ||
|
|
a95376b4a2 | ||
|
|
732983493a | ||
|
|
264b17f429 | ||
|
|
cef5ab10a5 | ||
|
|
3a881e1d5e | ||
|
|
209bb4ebb4 | ||
|
|
e71ffd4d2a | ||
|
|
ea19ef6334 | ||
|
|
40da2b4358 | ||
|
|
d0d5912d85 | ||
|
|
4162186ae0 | ||
|
|
578ba985e9 | ||
|
|
485830452e | ||
|
|
2fd00cd0b5 | ||
|
|
853a294157 | ||
|
|
aa9ab49654 | ||
|
|
9a5959b57e | ||
|
|
50f8548479 | ||
|
|
bc0581ea61 | ||
|
|
fab5e8a656 | ||
|
|
3a0896e57e | ||
|
|
7fdc403470 | ||
|
|
e833d44c43 | ||
|
|
77dd4bdaf5 | ||
|
|
ecba63c4bb | ||
|
|
f9feaf5343 | ||
|
|
ddf5e925c8 | ||
|
|
865e6db90f | ||
|
|
a42d899e64 | ||
|
|
3eaf12a7d5 |
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@v6
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v7
|
uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version: stable
|
go-version: 1.26.x
|
||||||
# cached: false
|
# cached: false
|
||||||
- name: Get official govulncheck
|
- name: Get official govulncheck
|
||||||
run: go install golang.org/x/vuln/cmd/govulncheck@latest
|
run: go install golang.org/x/vuln/cmd/govulncheck@latest
|
||||||
|
|||||||
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
|
|
||||||
|
|||||||
@@ -22,9 +22,6 @@ builds:
|
|||||||
- amd64
|
- amd64
|
||||||
- arm64
|
- arm64
|
||||||
- arm
|
- arm
|
||||||
goarm:
|
|
||||||
- "6"
|
|
||||||
- "7"
|
|
||||||
ignore:
|
ignore:
|
||||||
- goos: windows
|
- goos: windows
|
||||||
goarch: arm64
|
goarch: arm64
|
||||||
@@ -34,8 +31,6 @@ builds:
|
|||||||
goarch: arm64
|
goarch: arm64
|
||||||
- goos: freebsd
|
- goos: freebsd
|
||||||
goarch: arm
|
goarch: arm
|
||||||
- goos: darwin
|
|
||||||
goarch: arm
|
|
||||||
|
|
||||||
- id: beszel-agent
|
- id: beszel-agent
|
||||||
binary: beszel-agent
|
binary: beszel-agent
|
||||||
@@ -57,10 +52,6 @@ builds:
|
|||||||
- mipsle
|
- mipsle
|
||||||
- mips
|
- mips
|
||||||
- ppc64le
|
- ppc64le
|
||||||
goarm:
|
|
||||||
- "5"
|
|
||||||
- "6"
|
|
||||||
- "7"
|
|
||||||
gomips:
|
gomips:
|
||||||
- hardfloat
|
- hardfloat
|
||||||
- softfloat
|
- softfloat
|
||||||
@@ -80,8 +71,6 @@ builds:
|
|||||||
gomips: hardfloat
|
gomips: hardfloat
|
||||||
- goos: windows
|
- goos: windows
|
||||||
goarch: arm
|
goarch: arm
|
||||||
- goos: darwin
|
|
||||||
goarch: arm
|
|
||||||
- goos: darwin
|
- goos: darwin
|
||||||
goarch: riscv64
|
goarch: riscv64
|
||||||
- goos: windows
|
- goos: windows
|
||||||
|
|||||||
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
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ type Agent struct {
|
|||||||
keys []gossh.PublicKey // SSH public keys
|
keys []gossh.PublicKey // SSH public keys
|
||||||
smartManager *SmartManager // Manages SMART data
|
smartManager *SmartManager // Manages SMART data
|
||||||
systemdManager *systemdManager // Manages systemd services
|
systemdManager *systemdManager // Manages systemd services
|
||||||
|
probeManager *ProbeManager // Manages network probes
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAgent creates a new agent with the given data directory for persisting data.
|
// NewAgent creates a new agent with the given data directory for persisting data.
|
||||||
@@ -121,6 +122,9 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
|
|||||||
// initialize handler registry
|
// initialize handler registry
|
||||||
agent.handlerRegistry = NewHandlerRegistry()
|
agent.handlerRegistry = NewHandlerRegistry()
|
||||||
|
|
||||||
|
// initialize probe manager
|
||||||
|
agent.probeManager = newProbeManager()
|
||||||
|
|
||||||
// initialize disk info
|
// initialize disk info
|
||||||
agent.initializeDiskInfo()
|
agent.initializeDiskInfo()
|
||||||
|
|
||||||
@@ -178,6 +182,11 @@ func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedD
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if a.probeManager != nil {
|
||||||
|
data.Probes = a.probeManager.GetResults(cacheTimeMs)
|
||||||
|
slog.Debug("Probes", "data", data.Probes)
|
||||||
|
}
|
||||||
|
|
||||||
// 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 == defaultDataCacheTimeMs {
|
||||||
totalCount := uint16(a.systemdManager.getServiceStatsCount())
|
totalCount := uint16(a.systemdManager.getServiceStatsCount())
|
||||||
|
|||||||
@@ -1,13 +1,6 @@
|
|||||||
// Package battery provides battery information for the host and connected devices.
|
// Package battery provides functions to check if the system has a battery and return the charge state and percentage.
|
||||||
package battery
|
package battery
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"sort"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
const (
|
||||||
stateUnknown uint8 = iota
|
stateUnknown uint8 = iota
|
||||||
stateEmpty
|
stateEmpty
|
||||||
@@ -16,55 +9,3 @@ const (
|
|||||||
stateDischarging
|
stateDischarging
|
||||||
stateIdle
|
stateIdle
|
||||||
)
|
)
|
||||||
|
|
||||||
// Battery is a readable battery reported by the operating system.
|
|
||||||
type Battery struct {
|
|
||||||
Name string
|
|
||||||
Percent uint8
|
|
||||||
State uint8
|
|
||||||
FullChargeCapacity uint64
|
|
||||||
HasFullChargeCapacity bool
|
|
||||||
System bool
|
|
||||||
}
|
|
||||||
|
|
||||||
var errNoBatteries = errors.New("no readable batteries")
|
|
||||||
|
|
||||||
// normalizeBatteries supplies stable fallback names and disambiguates duplicates.
|
|
||||||
func normalizeBatteries(batteries []Battery) []Battery {
|
|
||||||
nameCounts := make(map[string]int, len(batteries))
|
|
||||||
for i := range batteries {
|
|
||||||
name := strings.TrimSpace(batteries[i].Name)
|
|
||||||
if name == "" {
|
|
||||||
name = "Battery " + strconv.Itoa(i+1)
|
|
||||||
}
|
|
||||||
nameCounts[name]++
|
|
||||||
if nameCounts[name] > 1 {
|
|
||||||
name += " (" + strconv.Itoa(nameCounts[name]) + ")"
|
|
||||||
}
|
|
||||||
batteries[i].Name = name
|
|
||||||
}
|
|
||||||
return batteries
|
|
||||||
}
|
|
||||||
|
|
||||||
// Primary returns the representative battery. Reported full-charge capacity wins,
|
|
||||||
// then system-scoped devices, then name for deterministic ties.
|
|
||||||
func Primary(batteries []Battery) (Battery, bool) {
|
|
||||||
if len(batteries) == 0 {
|
|
||||||
return Battery{}, false
|
|
||||||
}
|
|
||||||
ordered := append([]Battery(nil), batteries...)
|
|
||||||
sort.SliceStable(ordered, func(i, j int) bool {
|
|
||||||
a, b := ordered[i], ordered[j]
|
|
||||||
if a.HasFullChargeCapacity != b.HasFullChargeCapacity {
|
|
||||||
return a.HasFullChargeCapacity
|
|
||||||
}
|
|
||||||
if a.HasFullChargeCapacity && a.FullChargeCapacity != b.FullChargeCapacity {
|
|
||||||
return a.FullChargeCapacity > b.FullChargeCapacity
|
|
||||||
}
|
|
||||||
if a.System != b.System {
|
|
||||||
return a.System
|
|
||||||
}
|
|
||||||
return a.Name < b.Name
|
|
||||||
})
|
|
||||||
return ordered[0], true
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,7 +3,11 @@
|
|||||||
package battery
|
package battery
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"math"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"howett.net/plist"
|
"howett.net/plist"
|
||||||
)
|
)
|
||||||
@@ -31,46 +35,62 @@ func readMacBatteries() ([]macBattery, error) {
|
|||||||
return batteries, nil
|
return batteries, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func HasReadableBattery() bool {
|
// HasReadableBattery checks if the system has a battery and returns true if it does.
|
||||||
batteries, _ := GetBatteryStats()
|
var HasReadableBattery = sync.OnceValue(func() bool {
|
||||||
return len(batteries) > 0
|
systemHasBattery := false
|
||||||
|
batteries, err := readMacBatteries()
|
||||||
|
slog.Debug("Batteries", "batteries", batteries, "err", err)
|
||||||
|
for _, bat := range batteries {
|
||||||
|
if bat.MaxCapacity > 0 {
|
||||||
|
systemHasBattery = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return systemHasBattery
|
||||||
|
})
|
||||||
|
|
||||||
|
// GetBatteryStats returns the current battery percent and charge state.
|
||||||
|
// Uses CurrentCapacity/MaxCapacity to match the value macOS displays.
|
||||||
|
func GetBatteryStats() (batteryPercent uint8, batteryState uint8, err error) {
|
||||||
|
if !HasReadableBattery() {
|
||||||
|
return batteryPercent, batteryState, errors.ErrUnsupported
|
||||||
|
}
|
||||||
|
batteries, err := readMacBatteries()
|
||||||
|
if len(batteries) == 0 {
|
||||||
|
return batteryPercent, batteryState, errors.New("no batteries")
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetBatteryStats returns every readable battery reported by macOS.
|
totalCapacity := 0
|
||||||
func GetBatteryStats() ([]Battery, error) {
|
totalCharge := 0
|
||||||
batteries, err := readMacBatteries()
|
batteryState = math.MaxUint8
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if len(batteries) == 0 {
|
|
||||||
return nil, errNoBatteries
|
|
||||||
}
|
|
||||||
result := make([]Battery, 0, len(batteries))
|
|
||||||
for _, bat := range batteries {
|
for _, bat := range batteries {
|
||||||
if bat.MaxCapacity <= 0 {
|
if bat.MaxCapacity == 0 {
|
||||||
// skip ghost batteries with 0 capacity
|
// skip ghost batteries with 0 capacity
|
||||||
// https://github.com/distatus/battery/issues/34
|
// https://github.com/distatus/battery/issues/34
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
percent := min(max(float64(bat.CurrentCapacity)/float64(bat.MaxCapacity)*100, 0), 100)
|
totalCapacity += bat.MaxCapacity
|
||||||
state := stateUnknown
|
totalCharge += min(bat.CurrentCapacity, bat.MaxCapacity)
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case !bat.ExternalConnected:
|
case !bat.ExternalConnected:
|
||||||
state = stateDischarging
|
batteryState = stateDischarging
|
||||||
case bat.IsCharging:
|
case bat.IsCharging:
|
||||||
state = stateCharging
|
batteryState = stateCharging
|
||||||
case bat.CurrentCapacity == 0:
|
case bat.CurrentCapacity == 0:
|
||||||
state = stateEmpty
|
batteryState = stateEmpty
|
||||||
case !bat.FullyCharged:
|
case !bat.FullyCharged:
|
||||||
state = stateIdle
|
batteryState = stateIdle
|
||||||
default:
|
default:
|
||||||
state = stateFull
|
batteryState = stateFull
|
||||||
}
|
}
|
||||||
result = append(result, Battery{Name: "Primary", Percent: uint8(percent), State: state,
|
|
||||||
FullChargeCapacity: uint64(bat.MaxCapacity), HasFullChargeCapacity: true, System: true})
|
|
||||||
}
|
}
|
||||||
if len(result) == 0 {
|
|
||||||
return nil, errNoBatteries
|
if totalCapacity == 0 || batteryState == math.MaxUint8 {
|
||||||
|
return batteryPercent, batteryState, errors.New("no battery capacity")
|
||||||
}
|
}
|
||||||
return normalizeBatteries(result), nil
|
|
||||||
|
batteryPercent = uint8(float64(totalCharge) / float64(totalCapacity) * 100)
|
||||||
|
return batteryPercent, batteryState, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,19 +3,58 @@
|
|||||||
package battery
|
package battery
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"math"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/agent/utils"
|
"github.com/henrygd/beszel/agent/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
var batteryRoot = "/sys/class/power_supply"
|
// getBatteryPaths returns the paths of all batteries in /sys/class/power_supply
|
||||||
|
var getBatteryPaths func() ([]string, error)
|
||||||
|
|
||||||
// HasReadableBattery reports whether collection currently finds a readable battery.
|
// HasReadableBattery checks if the system has a battery and returns true if it does.
|
||||||
func HasReadableBattery() bool {
|
var HasReadableBattery func() bool
|
||||||
batteries, _ := GetBatteryStats()
|
|
||||||
return len(batteries) > 0
|
func init() {
|
||||||
|
resetBatteryState("/sys/class/power_supply")
|
||||||
|
}
|
||||||
|
|
||||||
|
// resetBatteryState resets the sync.Once functions to a fresh state.
|
||||||
|
// Tests call this after swapping sysfsPowerSupply so the new path is picked up.
|
||||||
|
func resetBatteryState(sysfsPowerSupplyPath string) {
|
||||||
|
getBatteryPaths = sync.OnceValues(func() ([]string, error) {
|
||||||
|
entries, err := os.ReadDir(sysfsPowerSupplyPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var paths []string
|
||||||
|
for _, e := range entries {
|
||||||
|
path := filepath.Join(sysfsPowerSupplyPath, e.Name())
|
||||||
|
if utils.ReadStringFile(filepath.Join(path, "type")) == "Battery" {
|
||||||
|
paths = append(paths, path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return paths, nil
|
||||||
|
})
|
||||||
|
HasReadableBattery = sync.OnceValue(func() bool {
|
||||||
|
systemHasBattery := false
|
||||||
|
paths, err := getBatteryPaths()
|
||||||
|
for _, path := range paths {
|
||||||
|
if _, ok := utils.ReadStringFileOK(filepath.Join(path, "capacity")); ok {
|
||||||
|
systemHasBattery = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !systemHasBattery {
|
||||||
|
slog.Debug("No battery found", "err", err)
|
||||||
|
}
|
||||||
|
return systemHasBattery
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseSysfsState(status string) uint8 {
|
func parseSysfsState(status string) uint8 {
|
||||||
@@ -35,18 +74,26 @@ func parseSysfsState(status string) uint8 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetBatteryStats re-enumerates power supplies and returns every readable battery.
|
// GetBatteryStats returns the current battery percent and charge state.
|
||||||
func GetBatteryStats() ([]Battery, error) {
|
// Reads /sys/class/power_supply/*/capacity directly so the kernel-reported
|
||||||
entries, err := os.ReadDir(batteryRoot)
|
// value is used, which is always 0-100 and matches what the OS displays.
|
||||||
|
func GetBatteryStats() (batteryPercent uint8, batteryState uint8, err error) {
|
||||||
|
if !HasReadableBattery() {
|
||||||
|
return batteryPercent, batteryState, errors.ErrUnsupported
|
||||||
|
}
|
||||||
|
paths, err := getBatteryPaths()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return batteryPercent, batteryState, err
|
||||||
}
|
}
|
||||||
batteries := make([]Battery, 0, len(entries))
|
if len(paths) == 0 {
|
||||||
for _, entry := range entries {
|
return batteryPercent, batteryState, errors.New("no batteries")
|
||||||
path := filepath.Join(batteryRoot, entry.Name())
|
|
||||||
if utils.ReadStringFile(filepath.Join(path, "type")) != "Battery" {
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
batteryState = math.MaxUint8
|
||||||
|
totalPercent := 0
|
||||||
|
count := 0
|
||||||
|
|
||||||
|
for _, path := range paths {
|
||||||
capStr, ok := utils.ReadStringFileOK(filepath.Join(path, "capacity"))
|
capStr, ok := utils.ReadStringFileOK(filepath.Join(path, "capacity"))
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
@@ -55,31 +102,19 @@ func GetBatteryStats() ([]Battery, error) {
|
|||||||
if parseErr != nil {
|
if parseErr != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
cap = min(max(cap, 0), 100)
|
totalPercent += cap
|
||||||
name := utils.ReadStringFile(filepath.Join(path, "model_name"))
|
count++
|
||||||
if name == "" {
|
|
||||||
name = utils.ReadStringFile(filepath.Join(path, "model"))
|
state := parseSysfsState(utils.ReadStringFile(filepath.Join(path, "status")))
|
||||||
}
|
if state != stateUnknown {
|
||||||
if name == "" {
|
batteryState = state
|
||||||
name = entry.Name()
|
|
||||||
}
|
|
||||||
battery := Battery{
|
|
||||||
Name: name,
|
|
||||||
Percent: uint8(cap),
|
|
||||||
State: parseSysfsState(utils.ReadStringFile(filepath.Join(path, "status"))),
|
|
||||||
System: utils.ReadStringFile(filepath.Join(path, "scope")) != "Device",
|
|
||||||
}
|
|
||||||
for _, fullName := range []string{"charge_full", "energy_full"} {
|
|
||||||
if parsed, ok := utils.ReadUintFile(filepath.Join(path, fullName)); ok && parsed > 0 {
|
|
||||||
battery.FullChargeCapacity = parsed
|
|
||||||
battery.HasFullChargeCapacity = true
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
batteries = append(batteries, battery)
|
|
||||||
|
if count == 0 || batteryState == math.MaxUint8 {
|
||||||
|
return batteryPercent, batteryState, errors.New("no battery capacity")
|
||||||
}
|
}
|
||||||
if len(batteries) == 0 {
|
|
||||||
return nil, errNoBatteries
|
batteryPercent = uint8(totalPercent / count)
|
||||||
}
|
return batteryPercent, batteryState, nil
|
||||||
return normalizeBatteries(batteries), nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,102 +8,194 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type fakeBattery struct{ id, name, capacity, status, full, scope string }
|
// setupFakeSysfs creates a temporary sysfs-like tree under t.TempDir(),
|
||||||
|
// swaps sysfsPowerSupply, resets the sync.Once caches, and restores
|
||||||
|
// everything on cleanup. Returns a helper to create battery directories.
|
||||||
|
func setupFakeSysfs(t *testing.T) (tmpDir string, addBattery func(name, capacity, status string)) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
func setupFakeSysfs(t *testing.T) (string, func(fakeBattery)) {
|
tmp := t.TempDir()
|
||||||
|
resetBatteryState(tmp)
|
||||||
|
|
||||||
|
write := func(path, content string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
root := t.TempDir()
|
dir := filepath.Dir(path)
|
||||||
previousRoot := batteryRoot
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
batteryRoot = root
|
t.Fatal(err)
|
||||||
t.Cleanup(func() { batteryRoot = previousRoot })
|
}
|
||||||
write := func(path, value string) {
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addBattery = func(name, capacity, status string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
|
batDir := filepath.Join(tmp, name)
|
||||||
require.NoError(t, os.WriteFile(path, []byte(value), 0o644))
|
write(filepath.Join(batDir, "type"), "Battery")
|
||||||
|
write(filepath.Join(batDir, "capacity"), capacity)
|
||||||
|
write(filepath.Join(batDir, "status"), status)
|
||||||
}
|
}
|
||||||
add := func(b fakeBattery) {
|
|
||||||
t.Helper()
|
return tmp, addBattery
|
||||||
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) {
|
func TestParseSysfsState(t *testing.T) {
|
||||||
assert.Equal(t, stateEmpty, parseSysfsState("Empty"))
|
tests := []struct {
|
||||||
assert.Equal(t, stateFull, parseSysfsState("Full"))
|
input string
|
||||||
assert.Equal(t, stateCharging, parseSysfsState("Charging"))
|
want uint8
|
||||||
assert.Equal(t, stateDischarging, parseSysfsState("Discharging"))
|
}{
|
||||||
assert.Equal(t, stateIdle, parseSysfsState("Not charging"))
|
{"Empty", stateEmpty},
|
||||||
assert.Equal(t, stateUnknown, parseSysfsState("SomethingElse"))
|
{"Full", stateFull},
|
||||||
|
{"Charging", stateCharging},
|
||||||
|
{"Discharging", stateDischarging},
|
||||||
|
{"Not charging", stateIdle},
|
||||||
|
{"", stateUnknown},
|
||||||
|
{"SomethingElse", stateUnknown},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
assert.Equal(t, tt.want, parseSysfsState(tt.input), "parseSysfsState(%q)", tt.input)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetBatteryStatsMultipleNamedAndPrimary(t *testing.T) {
|
func TestGetBatteryStats_SingleBattery(t *testing.T) {
|
||||||
_, add := setupFakeSysfs(t)
|
_, addBattery := setupFakeSysfs(t)
|
||||||
add(fakeBattery{id: "BAT0", name: "Primary", capacity: "105", status: "Charging", full: "5000", scope: "System"})
|
addBattery("BAT0", "72", "Discharging")
|
||||||
add(fakeBattery{id: "hidpp_battery_0", name: "MX Keys S", capacity: "55", status: "Unknown", full: "900", scope: "Device"})
|
|
||||||
batteries, err := GetBatteryStats()
|
pct, state, err := GetBatteryStats()
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
require.Len(t, batteries, 2)
|
assert.Equal(t, uint8(72), pct)
|
||||||
assert.Equal(t, "Primary", batteries[0].Name)
|
assert.Equal(t, stateDischarging, state)
|
||||||
assert.Equal(t, uint8(100), batteries[0].Percent)
|
|
||||||
assert.Equal(t, stateUnknown, batteries[1].State)
|
|
||||||
primary, ok := Primary(batteries)
|
|
||||||
require.True(t, ok)
|
|
||||||
assert.Equal(t, "Primary", primary.Name)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetBatteryStatsFallbackDuplicatesAndUnreadable(t *testing.T) {
|
func TestGetBatteryStats_MultipleBatteries(t *testing.T) {
|
||||||
root, add := setupFakeSysfs(t)
|
_, addBattery := setupFakeSysfs(t)
|
||||||
add(fakeBattery{id: "BAT0", name: "Keyboard", capacity: "80", status: "Discharging"})
|
addBattery("BAT0", "80", "Charging")
|
||||||
add(fakeBattery{id: "BAT1", name: "Keyboard", capacity: "-4", status: "SomethingWeird"})
|
addBattery("BAT1", "40", "Charging")
|
||||||
add(fakeBattery{id: "BAT2", capacity: "not-a-number", status: "Charging"})
|
|
||||||
add(fakeBattery{id: "BAT3", capacity: "42", status: "Full"})
|
pct, state, err := GetBatteryStats()
|
||||||
ac := filepath.Join(root, "AC0")
|
assert.NoError(t, err)
|
||||||
require.NoError(t, os.MkdirAll(ac, 0o755))
|
// average of 80 and 40 = 60
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(ac, "type"), []byte("Mains"), 0o644))
|
assert.EqualValues(t, 60, pct)
|
||||||
batteries, err := GetBatteryStats()
|
assert.Equal(t, stateCharging, state)
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, batteries, 3)
|
|
||||||
assert.Equal(t, "Keyboard", batteries[0].Name)
|
|
||||||
assert.Equal(t, "Keyboard (2)", batteries[1].Name)
|
|
||||||
assert.Equal(t, uint8(0), batteries[1].Percent)
|
|
||||||
assert.Equal(t, "BAT3", batteries[2].Name)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetBatteryStatsHotPlugReenumerates(t *testing.T) {
|
func TestGetBatteryStats_FullBattery(t *testing.T) {
|
||||||
_, add := setupFakeSysfs(t)
|
_, addBattery := setupFakeSysfs(t)
|
||||||
_, err := GetBatteryStats()
|
addBattery("BAT0", "100", "Full")
|
||||||
|
|
||||||
|
pct, state, err := GetBatteryStats()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, uint8(100), pct)
|
||||||
|
assert.Equal(t, stateFull, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetBatteryStats_EmptyBattery(t *testing.T) {
|
||||||
|
_, addBattery := setupFakeSysfs(t)
|
||||||
|
addBattery("BAT0", "0", "Empty")
|
||||||
|
|
||||||
|
pct, state, err := GetBatteryStats()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, uint8(0), pct)
|
||||||
|
assert.Equal(t, stateEmpty, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetBatteryStats_NotCharging(t *testing.T) {
|
||||||
|
_, addBattery := setupFakeSysfs(t)
|
||||||
|
addBattery("BAT0", "80", "Not charging")
|
||||||
|
|
||||||
|
pct, state, err := GetBatteryStats()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, uint8(80), pct)
|
||||||
|
assert.Equal(t, stateIdle, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetBatteryStats_NoBatteries(t *testing.T) {
|
||||||
|
setupFakeSysfs(t) // empty directory, no batteries
|
||||||
|
|
||||||
|
_, _, err := GetBatteryStats()
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
assert.False(t, HasReadableBattery())
|
}
|
||||||
add(fakeBattery{id: "BAT0", capacity: "64", status: "Discharging"})
|
|
||||||
batteries, err := GetBatteryStats()
|
func TestGetBatteryStats_NonBatterySupplyIgnored(t *testing.T) {
|
||||||
require.NoError(t, err)
|
tmp, addBattery := setupFakeSysfs(t)
|
||||||
|
|
||||||
|
// Add a real battery
|
||||||
|
addBattery("BAT0", "55", "Charging")
|
||||||
|
|
||||||
|
// Add an AC adapter (type != Battery) - should be ignored
|
||||||
|
acDir := filepath.Join(tmp, "AC0")
|
||||||
|
if err := os.MkdirAll(acDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(acDir, "type"), []byte("Mains"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pct, state, err := GetBatteryStats()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, uint8(55), pct)
|
||||||
|
assert.Equal(t, stateCharging, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetBatteryStats_InvalidCapacitySkipped(t *testing.T) {
|
||||||
|
tmp, addBattery := setupFakeSysfs(t)
|
||||||
|
|
||||||
|
// One battery with valid capacity
|
||||||
|
addBattery("BAT0", "90", "Discharging")
|
||||||
|
|
||||||
|
// Another with invalid capacity text
|
||||||
|
badDir := filepath.Join(tmp, "BAT1")
|
||||||
|
if err := os.MkdirAll(badDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(badDir, "type"), []byte("Battery"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(badDir, "capacity"), []byte("not-a-number"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(badDir, "status"), []byte("Discharging"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pct, _, err := GetBatteryStats()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
// Only BAT0 counted
|
||||||
|
assert.Equal(t, uint8(90), pct)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetBatteryStats_UnknownStatusOnly(t *testing.T) {
|
||||||
|
_, addBattery := setupFakeSysfs(t)
|
||||||
|
addBattery("BAT0", "50", "SomethingWeird")
|
||||||
|
|
||||||
|
_, _, err := GetBatteryStats()
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHasReadableBattery_True(t *testing.T) {
|
||||||
|
_, addBattery := setupFakeSysfs(t)
|
||||||
|
addBattery("BAT0", "50", "Charging")
|
||||||
|
|
||||||
assert.True(t, HasReadableBattery())
|
assert.True(t, HasReadableBattery())
|
||||||
require.Len(t, batteries, 1)
|
|
||||||
assert.Equal(t, uint8(64), batteries[0].Percent)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetBatteryStatsNoReadableCapacity(t *testing.T) {
|
func TestHasReadableBattery_False(t *testing.T) {
|
||||||
_, add := setupFakeSysfs(t)
|
setupFakeSysfs(t) // no batteries
|
||||||
add(fakeBattery{id: "BAT0", status: "Charging"})
|
|
||||||
_, err := GetBatteryStats()
|
assert.False(t, HasReadableBattery())
|
||||||
assert.Error(t, err)
|
}
|
||||||
|
|
||||||
|
func TestHasReadableBattery_NoCapacityFile(t *testing.T) {
|
||||||
|
tmp, _ := setupFakeSysfs(t)
|
||||||
|
|
||||||
|
// Battery dir with type file but no capacity file
|
||||||
|
batDir := filepath.Join(tmp, "BAT0")
|
||||||
|
err := os.MkdirAll(batDir, 0o755)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
err = os.WriteFile(filepath.Join(batDir, "type"), []byte("Battery"), 0o644)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
assert.False(t, HasReadableBattery())
|
assert.False(t, HasReadableBattery())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,6 @@ func HasReadableBattery() bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetBatteryStats() ([]Battery, error) {
|
func GetBatteryStats() (uint8, uint8, error) {
|
||||||
return nil, errors.ErrUnsupported
|
return 0, 0, errors.ErrUnsupported
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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})
|
|
||||||
}
|
|
||||||
@@ -7,6 +7,9 @@ package battery
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"math"
|
||||||
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
"unsafe"
|
"unsafe"
|
||||||
|
|
||||||
@@ -76,7 +79,7 @@ var (
|
|||||||
setupDiDestroyDeviceInfoList = setupapi.NewProc("SetupDiDestroyDeviceInfoList")
|
setupDiDestroyDeviceInfoList = setupapi.NewProc("SetupDiDestroyDeviceInfoList")
|
||||||
)
|
)
|
||||||
|
|
||||||
// winBatteryGet reads one battery by index.
|
// winBatteryGet reads one battery by index. Returns (fullCapacity, currentCapacity, state, error).
|
||||||
// Returns error == errNotFound when there are no more batteries.
|
// Returns error == errNotFound when there are no more batteries.
|
||||||
var errNotFound = errors.New("no more batteries")
|
var errNotFound = errors.New("no more batteries")
|
||||||
|
|
||||||
@@ -119,7 +122,7 @@ func readWinBatteryState(powerState uint32) uint8 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func winBatteryGet(idx int) (Battery, error) {
|
func winBatteryGet(idx int) (full, current uint32, state uint8, err error) {
|
||||||
hdev, err := setupDiSetup(
|
hdev, err := setupDiSetup(
|
||||||
setupDiGetClassDevsW,
|
setupDiGetClassDevsW,
|
||||||
4,
|
4,
|
||||||
@@ -129,7 +132,7 @@ func winBatteryGet(idx int) (Battery, error) {
|
|||||||
0, 0,
|
0, 0,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Battery{}, err
|
return 0, 0, stateUnknown, err
|
||||||
}
|
}
|
||||||
defer syscall.SyscallN(setupDiDestroyDeviceInfoList.Addr(), hdev)
|
defer syscall.SyscallN(setupDiDestroyDeviceInfoList.Addr(), hdev)
|
||||||
|
|
||||||
@@ -145,10 +148,10 @@ func winBatteryGet(idx int) (Battery, error) {
|
|||||||
0,
|
0,
|
||||||
)
|
)
|
||||||
if errno == 259 { // ERROR_NO_MORE_ITEMS
|
if errno == 259 { // ERROR_NO_MORE_ITEMS
|
||||||
return Battery{}, errNotFound
|
return 0, 0, stateUnknown, errNotFound
|
||||||
}
|
}
|
||||||
if errno != 0 {
|
if errno != 0 {
|
||||||
return Battery{}, errno
|
return 0, 0, stateUnknown, errno
|
||||||
}
|
}
|
||||||
|
|
||||||
var cbRequired uint32
|
var cbRequired uint32
|
||||||
@@ -162,7 +165,7 @@ func winBatteryGet(idx int) (Battery, error) {
|
|||||||
0,
|
0,
|
||||||
)
|
)
|
||||||
if errno != 0 && errno != 122 { // ERROR_INSUFFICIENT_BUFFER
|
if errno != 0 && errno != 122 { // ERROR_INSUFFICIENT_BUFFER
|
||||||
return Battery{}, errno
|
return 0, 0, stateUnknown, errno
|
||||||
}
|
}
|
||||||
didd := make([]uint16, cbRequired/2)
|
didd := make([]uint16, cbRequired/2)
|
||||||
cbSize := (*uint32)(unsafe.Pointer(&didd[0]))
|
cbSize := (*uint32)(unsafe.Pointer(&didd[0]))
|
||||||
@@ -182,7 +185,7 @@ func winBatteryGet(idx int) (Battery, error) {
|
|||||||
0,
|
0,
|
||||||
)
|
)
|
||||||
if errno != 0 {
|
if errno != 0 {
|
||||||
return Battery{}, errno
|
return 0, 0, stateUnknown, errno
|
||||||
}
|
}
|
||||||
devicePath := &didd[2:][0]
|
devicePath := &didd[2:][0]
|
||||||
|
|
||||||
@@ -196,7 +199,7 @@ func winBatteryGet(idx int) (Battery, error) {
|
|||||||
0,
|
0,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Battery{}, err
|
return 0, 0, stateUnknown, err
|
||||||
}
|
}
|
||||||
defer windows.CloseHandle(handle)
|
defer windows.CloseHandle(handle)
|
||||||
|
|
||||||
@@ -213,7 +216,7 @@ func winBatteryGet(idx int) (Battery, error) {
|
|||||||
&dwOut, nil,
|
&dwOut, nil,
|
||||||
)
|
)
|
||||||
if err != nil || bqi.BatteryTag == 0 {
|
if err != nil || bqi.BatteryTag == 0 {
|
||||||
return Battery{}, errors.New("battery tag not returned")
|
return 0, 0, stateUnknown, errors.New("battery tag not returned")
|
||||||
}
|
}
|
||||||
|
|
||||||
var bi batteryInformation
|
var bi batteryInformation
|
||||||
@@ -226,21 +229,7 @@ func winBatteryGet(idx int) (Battery, error) {
|
|||||||
uint32(unsafe.Sizeof(bi)),
|
uint32(unsafe.Sizeof(bi)),
|
||||||
&dwOut, nil,
|
&dwOut, nil,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return Battery{}, err
|
return 0, 0, stateUnknown, err
|
||||||
}
|
|
||||||
|
|
||||||
// BatteryDeviceName is optional, so retain the deterministic fallback on error.
|
|
||||||
name := ""
|
|
||||||
nameQuery := bqi
|
|
||||||
nameQuery.InformationLevel = 4 // BatteryDeviceName
|
|
||||||
nameBuffer := make([]uint16, 128)
|
|
||||||
if err := windows.DeviceIoControl(
|
|
||||||
handle, 2703428,
|
|
||||||
(*byte)(unsafe.Pointer(&nameQuery)), uint32(unsafe.Sizeof(nameQuery)),
|
|
||||||
(*byte)(unsafe.Pointer(&nameBuffer[0])), uint32(len(nameBuffer)*2),
|
|
||||||
&dwOut, nil,
|
|
||||||
); err == nil {
|
|
||||||
name = windows.UTF16ToString(nameBuffer)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bws := batteryWaitStatus{BatteryTag: bqi.BatteryTag}
|
bws := batteryWaitStatus{BatteryTag: bqi.BatteryTag}
|
||||||
@@ -254,38 +243,56 @@ func winBatteryGet(idx int) (Battery, error) {
|
|||||||
uint32(unsafe.Sizeof(bs)),
|
uint32(unsafe.Sizeof(bs)),
|
||||||
&dwOut, nil,
|
&dwOut, nil,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return Battery{}, err
|
return 0, 0, stateUnknown, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if bs.Capacity == 0xffffffff || bi.FullChargedCapacity == 0 || bi.FullChargedCapacity == 0xffffffff {
|
if bs.Capacity == 0xffffffff { // BATTERY_UNKNOWN_CAPACITY
|
||||||
return Battery{}, errors.New("battery capacity unknown")
|
return 0, 0, stateUnknown, errors.New("battery capacity unknown")
|
||||||
}
|
}
|
||||||
percent := min(float64(bs.Capacity)/float64(bi.FullChargedCapacity)*100, 100)
|
|
||||||
return Battery{Name: name, Percent: uint8(percent), State: readWinBatteryState(bs.PowerState),
|
return bi.FullChargedCapacity, bs.Capacity, readWinBatteryState(bs.PowerState), nil
|
||||||
FullChargeCapacity: uint64(bi.FullChargedCapacity), HasFullChargeCapacity: true, System: true}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// HasReadableBattery checks if the system has a battery and returns true if it does.
|
// HasReadableBattery checks if the system has a battery and returns true if it does.
|
||||||
func HasReadableBattery() bool {
|
var HasReadableBattery = sync.OnceValue(func() bool {
|
||||||
batteries, _ := GetBatteryStats()
|
systemHasBattery := false
|
||||||
return len(batteries) > 0
|
full, _, _, err := winBatteryGet(0)
|
||||||
|
if err == nil && full > 0 {
|
||||||
|
systemHasBattery = true
|
||||||
|
}
|
||||||
|
if !systemHasBattery {
|
||||||
|
slog.Debug("No battery found", "err", err)
|
||||||
|
}
|
||||||
|
return systemHasBattery
|
||||||
|
})
|
||||||
|
|
||||||
|
// GetBatteryStats returns the current battery percent and charge state.
|
||||||
|
func GetBatteryStats() (batteryPercent uint8, batteryState uint8, err error) {
|
||||||
|
if !HasReadableBattery() {
|
||||||
|
return batteryPercent, batteryState, errors.ErrUnsupported
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetBatteryStats returns every readable battery reported by Windows.
|
totalFull := uint32(0)
|
||||||
func GetBatteryStats() ([]Battery, error) {
|
totalCurrent := uint32(0)
|
||||||
batteries := make([]Battery, 0, 2)
|
batteryState = math.MaxUint8
|
||||||
|
|
||||||
for i := 0; ; i++ {
|
for i := 0; ; i++ {
|
||||||
battery, bErr := winBatteryGet(i)
|
full, current, state, bErr := winBatteryGet(i)
|
||||||
if errors.Is(bErr, errNotFound) {
|
if errors.Is(bErr, errNotFound) {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if bErr != nil {
|
if bErr != nil || full == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
batteries = append(batteries, battery)
|
totalFull += full
|
||||||
|
totalCurrent += min(current, full)
|
||||||
|
batteryState = state
|
||||||
}
|
}
|
||||||
if len(batteries) == 0 {
|
|
||||||
return nil, errNoBatteries
|
if totalFull == 0 || batteryState == math.MaxUint8 {
|
||||||
|
return batteryPercent, batteryState, errors.New("no battery capacity")
|
||||||
}
|
}
|
||||||
return normalizeBatteries(batteries), nil
|
|
||||||
|
batteryPercent = uint8(float64(totalCurrent) / float64(totalFull) * 100)
|
||||||
|
return batteryPercent, batteryState, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,8 +53,8 @@ func newWebSocketClient(agent *Agent) (client *WebSocketClient, err error) {
|
|||||||
client = &WebSocketClient{}
|
client = &WebSocketClient{}
|
||||||
|
|
||||||
client.hubURL, err = url.Parse(hubURLStr)
|
client.hubURL, err = url.Parse(hubURLStr)
|
||||||
if err != nil || client.hubURL.Host == "" {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("invalid HUB_URL %q: must include scheme and host (e.g. http://hub.example.com:8090)", hubURLStr)
|
return nil, errors.New("invalid hub URL")
|
||||||
}
|
}
|
||||||
// get registration token
|
// get registration token
|
||||||
client.token, err = getToken()
|
client.token, err = getToken()
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -141,6 +141,7 @@ func (c *ConnectionManager) Start(serverOptions ServerOptions) error {
|
|||||||
// }
|
// }
|
||||||
func (c *ConnectionManager) stop() error {
|
func (c *ConnectionManager) stop() error {
|
||||||
_ = c.agent.StopServer()
|
_ = c.agent.StopServer()
|
||||||
|
c.agent.probeManager.Stop()
|
||||||
c.closeWebSocket()
|
c.closeWebSocket()
|
||||||
return health.CleanUp()
|
return health.CleanUp()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ type dockerManager struct {
|
|||||||
dockerVersionChecked bool // Whether a version probe has completed successfully
|
dockerVersionChecked bool // Whether a version probe has completed successfully
|
||||||
isWindows bool // Whether the Docker Engine API is running on Windows
|
isWindows bool // Whether the Docker Engine API is running on Windows
|
||||||
buf *bytes.Buffer // Buffer to store and read response bodies
|
buf *bytes.Buffer // Buffer to store and read response bodies
|
||||||
|
decoder *json.Decoder // Reusable JSON decoder that reads from buf
|
||||||
apiStats *container.ApiStats // Reusable API stats object
|
apiStats *container.ApiStats // Reusable API stats object
|
||||||
excludeContainers []string // Patterns to exclude containers by name
|
excludeContainers []string // Patterns to exclude containers by name
|
||||||
usingPodman bool // Whether the Docker Engine API is running on Podman
|
usingPodman bool // Whether the Docker Engine API is running on Podman
|
||||||
@@ -373,26 +374,16 @@ func convertContainerPortsToString(ctr *container.ApiInfo) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
sort.Slice(ctr.Ports, func(i, j int) bool {
|
sort.Slice(ctr.Ports, func(i, j int) bool {
|
||||||
if ctr.Ports[i].PublicPort != ctr.Ports[j].PublicPort {
|
|
||||||
return ctr.Ports[i].PublicPort < ctr.Ports[j].PublicPort
|
return ctr.Ports[i].PublicPort < ctr.Ports[j].PublicPort
|
||||||
}
|
|
||||||
return ctr.Ports[i].IP < ctr.Ports[j].IP
|
|
||||||
})
|
})
|
||||||
var builder strings.Builder
|
var builder strings.Builder
|
||||||
seen := make(map[string]struct{})
|
seenPorts := make(map[uint16]struct{})
|
||||||
for _, p := range ctr.Ports {
|
for _, p := range ctr.Ports {
|
||||||
if p.PublicPort == 0 {
|
_, ok := seenPorts[p.PublicPort]
|
||||||
|
if p.PublicPort == 0 || ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
keyIP := p.IP
|
seenPorts[p.PublicPort] = struct{}{}
|
||||||
if keyIP == "0.0.0.0" || keyIP == "::" {
|
|
||||||
keyIP = ""
|
|
||||||
}
|
|
||||||
key := keyIP + ":" + strconv.Itoa(int(p.PublicPort))
|
|
||||||
if _, ok := seen[key]; ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[key] = struct{}{}
|
|
||||||
if builder.Len() > 0 {
|
if builder.Len() > 0 {
|
||||||
builder.WriteString(", ")
|
builder.WriteString(", ")
|
||||||
}
|
}
|
||||||
@@ -756,18 +747,20 @@ func (dm *dockerManager) applyDockerVersionInfo(serverHeader string, versionInfo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decodes a Docker API JSON response using a reusable buffer. Not thread safe.
|
// Decodes Docker API JSON response using a reusable buffer and decoder. Not thread safe.
|
||||||
func (dm *dockerManager) decode(resp *http.Response, d any) error {
|
func (dm *dockerManager) decode(resp *http.Response, d any) error {
|
||||||
if dm.buf == nil {
|
if dm.buf == nil {
|
||||||
// initialize buffer with 256kb starting size
|
// initialize buffer with 256kb starting size
|
||||||
dm.buf = bytes.NewBuffer(make([]byte, 0, 1024*256))
|
dm.buf = bytes.NewBuffer(make([]byte, 0, 1024*256))
|
||||||
|
dm.decoder = json.NewDecoder(dm.buf)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
defer dm.buf.Reset()
|
defer dm.buf.Reset()
|
||||||
if _, err := dm.buf.ReadFrom(resp.Body); err != nil {
|
_, err := dm.buf.ReadFrom(resp.Body)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return json.Unmarshal(dm.buf.Bytes(), d)
|
return dm.decoder.Decode(d)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test docker / podman sockets and return if one exists
|
// Test docker / podman sockets and return if one exists
|
||||||
|
|||||||
@@ -804,24 +804,6 @@ func TestGetDockerStatsRetriesVersionCheckUntilSuccess(t *testing.T) {
|
|||||||
assert.Equal(t, 2, requestCounts["/version"])
|
assert.Equal(t, 2, requestCounts["/version"])
|
||||||
}
|
}
|
||||||
|
|
||||||
// A failed decode must not break later decodes. Previously the reused json.Decoder
|
|
||||||
// stayed desynced after one truncated response, breaking decode until restart.
|
|
||||||
func TestDecodeRecoversFromError(t *testing.T) {
|
|
||||||
dm := &dockerManager{}
|
|
||||||
|
|
||||||
// truncated JSON: body reads fine, decode fails
|
|
||||||
var bad []container.ApiInfo
|
|
||||||
err := dm.decode(&http.Response{Body: io.NopCloser(strings.NewReader(`[{"Id":"abc`))}, &bad)
|
|
||||||
require.Error(t, err)
|
|
||||||
|
|
||||||
// the next decode must still succeed
|
|
||||||
var good []container.ApiInfo
|
|
||||||
err = dm.decode(&http.Response{Body: io.NopCloser(strings.NewReader(`[{"Id":"abcdef012345","Names":["/ok"]}]`))}, &good)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, good, 1)
|
|
||||||
assert.Equal(t, "abcdef012345", good[0].Id)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCycleCpuDeltas(t *testing.T) {
|
func TestCycleCpuDeltas(t *testing.T) {
|
||||||
dm := &dockerManager{
|
dm := &dockerManager{
|
||||||
lastCpuContainer: map[uint16]map[string]uint64{
|
lastCpuContainer: map[uint16]map[string]uint64{
|
||||||
@@ -1021,44 +1003,6 @@ func TestCpuPercentageCalculationWithRealData(t *testing.T) {
|
|||||||
assert.InDelta(t, expectedPct, actualPct, 0.01)
|
assert.InDelta(t, expectedPct, actualPct, 0.01)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCpuPercentageHandlesCounterRollback(t *testing.T) {
|
|
||||||
// If a stats response is processed after a newer one for the same container,
|
|
||||||
// or an accounting counter resets, the current total can be lower than the
|
|
||||||
// stored previous value. Unsigned subtraction wraps to ~2^64 instead of
|
|
||||||
// going negative, so the percentage explodes, validateCpuPercentage rejects
|
|
||||||
// the sample, and the whole collection is discarded - network stats too.
|
|
||||||
stats := &container.ApiStats{
|
|
||||||
CPUStats: container.CPUStats{
|
|
||||||
CPUUsage: container.CPUUsage{TotalUsage: 1_000_000},
|
|
||||||
SystemUsage: 20_000_000,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Container counter went backwards.
|
|
||||||
assert.Equal(t, 0.0, stats.CalculateCpuPercentLinux(2_000_000, 10_000_000))
|
|
||||||
// System counter went backwards.
|
|
||||||
assert.Equal(t, 0.0, stats.CalculateCpuPercentLinux(500_000, 30_000_000))
|
|
||||||
// A normal forward sample is unaffected: 500000 / 10000000 * 100 = 5%.
|
|
||||||
assert.InDelta(t, 5.0, stats.CalculateCpuPercentLinux(500_000, 10_000_000), 0.001)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCpuPercentageWindowsHandlesCounterRollback(t *testing.T) {
|
|
||||||
now := time.Now()
|
|
||||||
stats := &container.ApiStats{
|
|
||||||
Read: now,
|
|
||||||
NumProcs: 4,
|
|
||||||
CPUStats: container.CPUStats{
|
|
||||||
CPUUsage: container.CPUUsage{TotalUsage: 1_000_000},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
prevRead := now.Add(-time.Second)
|
|
||||||
|
|
||||||
// Container counter went backwards.
|
|
||||||
assert.Equal(t, 0.0, stats.CalculateCpuPercentWindows(2_000_000, prevRead))
|
|
||||||
// A normal forward sample is unaffected.
|
|
||||||
assert.Greater(t, stats.CalculateCpuPercentWindows(500_000, prevRead), 0.0)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNetworkStatsCalculationWithRealData(t *testing.T) {
|
func TestNetworkStatsCalculationWithRealData(t *testing.T) {
|
||||||
// Create synthetic test data to avoid timing issues
|
// Create synthetic test data to avoid timing issues
|
||||||
apiStats1 := &container.ApiStats{
|
apiStats1 := &container.ApiStats{
|
||||||
@@ -1920,14 +1864,6 @@ func TestConvertContainerPortsToString(t *testing.T) {
|
|||||||
},
|
},
|
||||||
expected: "80, 443",
|
expected: "80, 443",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "ipv4 and ipv6 wildcard bindings are deduplicated",
|
|
||||||
ports: []port{
|
|
||||||
{PublicPort: 80, IP: "0.0.0.0"},
|
|
||||||
{PublicPort: 80, IP: "::"},
|
|
||||||
},
|
|
||||||
expected: "80",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: "multiple ports with different IPs",
|
name: "multiple ports with different IPs",
|
||||||
ports: []port{
|
ports: []port{
|
||||||
@@ -1936,22 +1872,6 @@ func TestConvertContainerPortsToString(t *testing.T) {
|
|||||||
},
|
},
|
||||||
expected: "80, 1.2.3.4:443",
|
expected: "80, 1.2.3.4:443",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "same port bound to multiple IPs shows all entries",
|
|
||||||
ports: []port{
|
|
||||||
{PublicPort: 65533, IP: "172.16.151.72"},
|
|
||||||
{PublicPort: 65533, IP: "172.16.156.25"},
|
|
||||||
},
|
|
||||||
expected: "172.16.151.72:65533, 172.16.156.25:65533",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "same port bound to IPv4 and IPv6",
|
|
||||||
ports: []port{
|
|
||||||
{PublicPort: 65534, IP: "172.16.151.72"},
|
|
||||||
{PublicPort: 65534, IP: "fd04:38e2:98c6:3fd::72"},
|
|
||||||
},
|
|
||||||
expected: "172.16.151.72:65534, fd04:38e2:98c6:3fd::72:65534",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: "ports slice is nilled after call",
|
name: "ports slice is nilled after call",
|
||||||
ports: []port{
|
ports: []port{
|
||||||
|
|||||||
101
agent/fans.go
101
agent/fans.go
@@ -1,101 +0,0 @@
|
|||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"log/slog"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/agent/utils"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
|
||||||
)
|
|
||||||
|
|
||||||
type fanSensor struct {
|
|
||||||
key, path string
|
|
||||||
}
|
|
||||||
|
|
||||||
var getFanSensors = newFanSensorCache(hwmonRoot)
|
|
||||||
|
|
||||||
func newFanSensorCache(root string) func() ([]fanSensor, error) {
|
|
||||||
return sync.OnceValues(func() ([]fanSensor, error) {
|
|
||||||
return discoverHwmonFans(root)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateFans populates systemStats.Fans from the host's hwmon sysfs tree.
|
|
||||||
// No-op on platforms where hwmon isn't available (see fans_other.go).
|
|
||||||
func (a *Agent) updateFans(systemStats *system.Stats) {
|
|
||||||
if hwmonRoot == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
sensors, err := getFanSensors()
|
|
||||||
if err != nil {
|
|
||||||
slog.Debug("Error reading fans", "err", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fans := readFanSensors(sensors)
|
|
||||||
if len(fans) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
systemStats.Fans = fans
|
|
||||||
// Note: Commented out because we don't currently use this value in the UI.
|
|
||||||
// Compute the single "dashboard" value used by the FanSpeed alert.
|
|
||||||
// Per-sensor RPMs live in Stats.Fans and drive the multi-line FanChart
|
|
||||||
// in the UI; the alert path only needs one number to compare against
|
|
||||||
// the user's threshold, so we use the highest RPM across all fans
|
|
||||||
// a.systemInfo.DashboardFan = 0
|
|
||||||
// for _, rpm := range fans {
|
|
||||||
// if rpm > a.systemInfo.DashboardFan {
|
|
||||||
// a.systemInfo.DashboardFan = rpm
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
|
|
||||||
// readHwmonFans walks the given hwmon root (typically /sys/class/hwmon) and
|
|
||||||
// returns a map of "<chip>_<label-or-fan-idx>" → RPM for every fan*_input
|
|
||||||
// file it finds. Zero RPM is retained because it can represent a real fan that
|
|
||||||
// has stopped; negative and malformed readings are ignored.
|
|
||||||
func readHwmonFans(root string) (map[string]uint16, error) {
|
|
||||||
sensors, err := discoverHwmonFans(root)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return readFanSensors(sensors), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func discoverHwmonFans(root string) ([]fanSensor, error) {
|
|
||||||
entries, err := os.ReadDir(root)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var sensors []fanSensor
|
|
||||||
for _, entry := range entries {
|
|
||||||
chipDir := filepath.Join(root, entry.Name())
|
|
||||||
chipName := utils.ReadStringFile(filepath.Join(chipDir, "name"))
|
|
||||||
if chipName == "" {
|
|
||||||
chipName = entry.Name()
|
|
||||||
}
|
|
||||||
inputs, _ := filepath.Glob(filepath.Join(chipDir, "fan*_input"))
|
|
||||||
for _, inputPath := range inputs {
|
|
||||||
base := strings.TrimSuffix(filepath.Base(inputPath), "_input")
|
|
||||||
label := utils.ReadStringFile(filepath.Join(chipDir, base+"_label"))
|
|
||||||
key := chipName + "_" + base
|
|
||||||
if label != "" {
|
|
||||||
key = chipName + "_" + label
|
|
||||||
}
|
|
||||||
sensors = append(sensors, fanSensor{key, inputPath})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return sensors, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func readFanSensors(sensors []fanSensor) map[string]uint16 {
|
|
||||||
fans := make(map[string]uint16, len(sensors))
|
|
||||||
for _, sensor := range sensors {
|
|
||||||
if rpm, ok := utils.ReadUintFile(sensor.path); ok {
|
|
||||||
fans[sensor.key] = uint16(rpm)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fans
|
|
||||||
}
|
|
||||||
@@ -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,87 +0,0 @@
|
|||||||
//go:build testing
|
|
||||||
|
|
||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
// writeFile creates path with parents and writes contents.
|
|
||||||
func writeFile(t *testing.T, path, contents string) {
|
|
||||||
t.Helper()
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(path, []byte(contents), 0o644))
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestReadHwmonFans verifies the /sys/class/hwmon walker:
|
|
||||||
// - picks up fan*_input from every chip,
|
|
||||||
// - keys entries by chip name + sensor label (or fan idx if no label),
|
|
||||||
// - retains 0 RPM for stopped fans,
|
|
||||||
// - tolerates chips with no fan files at all.
|
|
||||||
func TestReadHwmonFans(t *testing.T) {
|
|
||||||
root := t.TempDir()
|
|
||||||
|
|
||||||
// hwmon0: Raspberry Pi 5 active cooler — one fan, no label.
|
|
||||||
writeFile(t, filepath.Join(root, "hwmon0", "name"), "pwmfan\n")
|
|
||||||
writeFile(t, filepath.Join(root, "hwmon0", "fan1_input"), "6500\n")
|
|
||||||
|
|
||||||
// hwmon1: a thermal-only chip, no fan files. Must not error.
|
|
||||||
writeFile(t, filepath.Join(root, "hwmon1", "name"), "cpu_thermal\n")
|
|
||||||
writeFile(t, filepath.Join(root, "hwmon1", "temp1_input"), "55000\n")
|
|
||||||
|
|
||||||
// hwmon2: two fans — one stopped (0 RPM) and one labeled "chassis".
|
|
||||||
writeFile(t, filepath.Join(root, "hwmon2", "name"), "nct6798\n")
|
|
||||||
writeFile(t, filepath.Join(root, "hwmon2", "fan1_input"), "0\n")
|
|
||||||
writeFile(t, filepath.Join(root, "hwmon2", "fan2_input"), "1200\n")
|
|
||||||
writeFile(t, filepath.Join(root, "hwmon2", "fan2_label"), "chassis\n")
|
|
||||||
|
|
||||||
fans, err := readHwmonFans(root)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
assert.Equal(t, map[string]uint16{
|
|
||||||
"pwmfan_fan1": 6500,
|
|
||||||
"nct6798_fan1": 0,
|
|
||||||
"nct6798_chassis": 1200,
|
|
||||||
}, fans)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestReadHwmonFansMissingRoot returns an error rather than panicking when the
|
|
||||||
// hwmon root doesn't exist (e.g. running on a kernel without hwmon support).
|
|
||||||
func TestReadHwmonFansMissingRoot(t *testing.T) {
|
|
||||||
_, err := readHwmonFans(filepath.Join(t.TempDir(), "does-not-exist"))
|
|
||||||
assert.Error(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestReadHwmonFansEmpty returns an empty map (not nil error) when the root
|
|
||||||
// exists but contains no chips at all.
|
|
||||||
func TestReadHwmonFansEmpty(t *testing.T) {
|
|
||||||
root := t.TempDir()
|
|
||||||
fans, err := readHwmonFans(root)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Empty(t, fans)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFanDiscoveryCache(t *testing.T) {
|
|
||||||
root := t.TempDir()
|
|
||||||
input := filepath.Join(root, "hwmon0", "fan1_input")
|
|
||||||
writeFile(t, filepath.Join(root, "hwmon0", "name"), "chip\n")
|
|
||||||
writeFile(t, input, "1000\n")
|
|
||||||
|
|
||||||
getSensors := newFanSensorCache(root)
|
|
||||||
sensors, err := getSensors()
|
|
||||||
require.NoError(t, err)
|
|
||||||
fans := readFanSensors(sensors)
|
|
||||||
assert.Equal(t, uint16(1000), fans["chip_fan1"])
|
|
||||||
|
|
||||||
writeFile(t, input, "1200\n")
|
|
||||||
writeFile(t, filepath.Join(root, "hwmon0", "fan1_label"), "case\n")
|
|
||||||
sensors, err = getSensors()
|
|
||||||
require.NoError(t, err)
|
|
||||||
fans = readFanSensors(sensors)
|
|
||||||
assert.Equal(t, map[string]uint16{"chip_fan1": 1200}, fans)
|
|
||||||
}
|
|
||||||
25
agent/gpu.go
25
agent/gpu.go
@@ -48,8 +48,6 @@ type GPUManager struct {
|
|||||||
// Per-cache-key tracking for delta calculations
|
// Per-cache-key tracking for delta calculations
|
||||||
// cacheKey -> gpuId -> snapshot of last count/usage/power values
|
// cacheKey -> gpuId -> snapshot of last count/usage/power values
|
||||||
lastSnapshots map[uint16]map[string]*gpuSnapshot
|
lastSnapshots map[uint16]map[string]*gpuSnapshot
|
||||||
// Per-card energy snapshots for Intel sysfs power calculation.
|
|
||||||
intelSysfsEnergySnapshots map[string]intelSysfsEnergySnapshot
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// gpuSnapshot stores the last observed incremental values for delta tracking
|
// gpuSnapshot stores the last observed incremental values for delta tracking
|
||||||
@@ -92,7 +90,6 @@ const (
|
|||||||
collectorSourceNVML collectorSource = "nvml"
|
collectorSourceNVML collectorSource = "nvml"
|
||||||
collectorSourceNvidiaSMI collectorSource = collectorSource(nvidiaSmiCmd)
|
collectorSourceNvidiaSMI collectorSource = collectorSource(nvidiaSmiCmd)
|
||||||
collectorSourceIntelGpuTop collectorSource = collectorSource(intelGpuStatsCmd)
|
collectorSourceIntelGpuTop collectorSource = collectorSource(intelGpuStatsCmd)
|
||||||
collectorSourceIntelSysfs collectorSource = "intel_sysfs"
|
|
||||||
collectorSourceAmdSysfs collectorSource = "amd_sysfs"
|
collectorSourceAmdSysfs collectorSource = "amd_sysfs"
|
||||||
collectorSourceRocmSMI collectorSource = collectorSource(rocmSmiCmd)
|
collectorSourceRocmSMI collectorSource = collectorSource(rocmSmiCmd)
|
||||||
collectorSourceMacmon collectorSource = collectorSource(macmonCmd)
|
collectorSourceMacmon collectorSource = collectorSource(macmonCmd)
|
||||||
@@ -109,7 +106,6 @@ func isValidCollectorSource(source collectorSource) bool {
|
|||||||
collectorSourceNVML,
|
collectorSourceNVML,
|
||||||
collectorSourceNvidiaSMI,
|
collectorSourceNvidiaSMI,
|
||||||
collectorSourceIntelGpuTop,
|
collectorSourceIntelGpuTop,
|
||||||
collectorSourceIntelSysfs,
|
|
||||||
collectorSourceAmdSysfs,
|
collectorSourceAmdSysfs,
|
||||||
collectorSourceRocmSMI,
|
collectorSourceRocmSMI,
|
||||||
collectorSourceMacmon,
|
collectorSourceMacmon,
|
||||||
@@ -126,8 +122,6 @@ type gpuCapabilities struct {
|
|||||||
hasAmdSysfs bool
|
hasAmdSysfs bool
|
||||||
hasTegrastats bool
|
hasTegrastats bool
|
||||||
hasIntelGpuTop bool
|
hasIntelGpuTop bool
|
||||||
hasXe bool
|
|
||||||
hasIntelSysfs bool
|
|
||||||
hasNvtop bool
|
hasNvtop bool
|
||||||
hasMacmon bool
|
hasMacmon bool
|
||||||
hasPowermetrics bool
|
hasPowermetrics bool
|
||||||
@@ -375,13 +369,12 @@ func (gm *GPUManager) calculateGPUAverage(id string, gpu *system.GPUData, cacheK
|
|||||||
|
|
||||||
gpuAvg.Power = utils.TwoDecimals(deltaPower / float64(deltaCount))
|
gpuAvg.Power = utils.TwoDecimals(deltaPower / float64(deltaCount))
|
||||||
|
|
||||||
gpuAvg.PowerPkg = utils.TwoDecimals(deltaPowerPkg / float64(deltaCount))
|
|
||||||
|
|
||||||
if gpu.Engines != nil {
|
if gpu.Engines != nil {
|
||||||
// make fresh map for averaged engine metrics to avoid mutating
|
// make fresh map for averaged engine metrics to avoid mutating
|
||||||
// the accumulator map stored in gm.GpuDataMap
|
// the accumulator map stored in gm.GpuDataMap
|
||||||
gpuAvg.Engines = make(map[string]float64, len(gpu.Engines))
|
gpuAvg.Engines = make(map[string]float64, len(gpu.Engines))
|
||||||
gpuAvg.Usage = gm.calculateIntelGPUUsage(&gpuAvg, gpu, lastSnapshot, deltaCount)
|
gpuAvg.Usage = gm.calculateIntelGPUUsage(&gpuAvg, gpu, lastSnapshot, deltaCount)
|
||||||
|
gpuAvg.PowerPkg = utils.TwoDecimals(deltaPowerPkg / float64(deltaCount))
|
||||||
} else {
|
} else {
|
||||||
gpuAvg.Usage = utils.TwoDecimals(deltaUsage / float64(deltaCount))
|
gpuAvg.Usage = utils.TwoDecimals(deltaUsage / float64(deltaCount))
|
||||||
}
|
}
|
||||||
@@ -451,8 +444,6 @@ func (gm *GPUManager) storeSnapshot(id string, gpu *system.GPUData, cacheKey uin
|
|||||||
func (gm *GPUManager) discoverGpuCapabilities() gpuCapabilities {
|
func (gm *GPUManager) discoverGpuCapabilities() gpuCapabilities {
|
||||||
caps := gpuCapabilities{
|
caps := gpuCapabilities{
|
||||||
hasAmdSysfs: gm.hasAmdSysfs(),
|
hasAmdSysfs: gm.hasAmdSysfs(),
|
||||||
hasXe: gm.hasXe(),
|
|
||||||
hasIntelSysfs: gm.hasIntelSysfs(),
|
|
||||||
}
|
}
|
||||||
if _, err := exec.LookPath(nvidiaSmiCmd); err == nil {
|
if _, err := exec.LookPath(nvidiaSmiCmd); err == nil {
|
||||||
caps.hasNvidiaSmi = true
|
caps.hasNvidiaSmi = true
|
||||||
@@ -481,7 +472,7 @@ func (gm *GPUManager) discoverGpuCapabilities() gpuCapabilities {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func hasAnyGpuCollector(caps gpuCapabilities) bool {
|
func hasAnyGpuCollector(caps gpuCapabilities) bool {
|
||||||
return caps.hasNvidiaSmi || caps.hasRocmSmi || caps.hasAmdSysfs || caps.hasTegrastats || caps.hasIntelGpuTop || caps.hasIntelSysfs || caps.hasNvtop || caps.hasMacmon || caps.hasPowermetrics
|
return caps.hasNvidiaSmi || caps.hasRocmSmi || caps.hasAmdSysfs || caps.hasTegrastats || caps.hasIntelGpuTop || caps.hasNvtop || caps.hasMacmon || caps.hasPowermetrics
|
||||||
}
|
}
|
||||||
|
|
||||||
func (gm *GPUManager) startIntelCollector() {
|
func (gm *GPUManager) startIntelCollector() {
|
||||||
@@ -572,13 +563,6 @@ func (gm *GPUManager) collectorDefinitions(caps gpuCapabilities) map[collectorSo
|
|||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
collectorSourceIntelSysfs: {
|
|
||||||
group: collectorGroupIntel,
|
|
||||||
available: caps.hasIntelSysfs,
|
|
||||||
start: func(_ func()) bool {
|
|
||||||
return gm.startIntelSysfsCollector()
|
|
||||||
},
|
|
||||||
},
|
|
||||||
collectorSourceAmdSysfs: {
|
collectorSourceAmdSysfs: {
|
||||||
group: collectorGroupAmd,
|
group: collectorGroupAmd,
|
||||||
available: caps.hasAmdSysfs,
|
available: caps.hasAmdSysfs,
|
||||||
@@ -721,12 +705,9 @@ func (gm *GPUManager) resolveLegacyCollectorPriority(caps gpuCapabilities) []col
|
|||||||
priorities = append(priorities, collectorSourceAmdSysfs)
|
priorities = append(priorities, collectorSourceAmdSysfs)
|
||||||
}
|
}
|
||||||
|
|
||||||
if caps.hasIntelGpuTop && !caps.hasXe {
|
if caps.hasIntelGpuTop {
|
||||||
priorities = append(priorities, collectorSourceIntelGpuTop)
|
priorities = append(priorities, collectorSourceIntelGpuTop)
|
||||||
}
|
}
|
||||||
if caps.hasIntelSysfs {
|
|
||||||
priorities = append(priorities, collectorSourceIntelSysfs)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apple collectors are currently opt-in only for testing.
|
// Apple collectors are currently opt-in only for testing.
|
||||||
// Enable them with GPU_COLLECTOR=macmon or GPU_COLLECTOR=powermetrics.
|
// Enable them with GPU_COLLECTOR=macmon or GPU_COLLECTOR=powermetrics.
|
||||||
|
|||||||
@@ -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,7 +5,6 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -49,14 +48,9 @@ func (gm *GPUManager) updateNvtopSnapshots(snapshots []nvtopSnapshot) bool {
|
|||||||
|
|
||||||
valid := false
|
valid := false
|
||||||
usedIDs := make(map[string]struct{}, len(snapshots))
|
usedIDs := make(map[string]struct{}, len(snapshots))
|
||||||
var xeName string
|
|
||||||
for i, sample := range snapshots {
|
for i, sample := range snapshots {
|
||||||
// nvtop leaves device_name unset on xe devices.
|
|
||||||
if sample.DeviceName == "" {
|
if sample.DeviceName == "" {
|
||||||
if xeName == "" {
|
continue
|
||||||
xeName = xeGpuName()
|
|
||||||
}
|
|
||||||
sample.DeviceName = xeName
|
|
||||||
}
|
}
|
||||||
indexID := "n" + strconv.Itoa(i)
|
indexID := "n" + strconv.Itoa(i)
|
||||||
id := indexID
|
id := indexID
|
||||||
@@ -164,38 +158,3 @@ func (gm *GPUManager) startNvtopCollector(interval string, onFailure func()) {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
// xeDevicePath returns the sysfs device path of the first xe GPU, or "".
|
|
||||||
func xeDevicePath() string {
|
|
||||||
cards, err := filepath.Glob("/sys/class/drm/card*")
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
for _, card := range cards {
|
|
||||||
if strings.Contains(filepath.Base(card), "-") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if uevent, err := utils.ReadStringFileLimited(filepath.Join(card, "device", "uevent"), 4096); err == nil && strings.Contains(uevent, "DRIVER=xe") {
|
|
||||||
return filepath.Join(card, "device")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (gm *GPUManager) hasXe() bool {
|
|
||||||
return xeDevicePath() != ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// xeGpuName names an xe GPU from its PCI device id; nvtop leaves device_name unset on xe.
|
|
||||||
func xeGpuName() string {
|
|
||||||
devicePath := xeDevicePath()
|
|
||||||
if devicePath == "" {
|
|
||||||
return "GPU"
|
|
||||||
}
|
|
||||||
id, err := utils.ReadStringFileLimited(filepath.Join(devicePath, "device"), 64)
|
|
||||||
if err != nil {
|
|
||||||
return "GPU"
|
|
||||||
}
|
|
||||||
id = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(id, "0x")))
|
|
||||||
return "Intel GPU (" + id + ")"
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -332,12 +332,11 @@ func TestUpdateNvtopSnapshotsKeepsDeviceAssociationWhenOrderChanges(t *testing.T
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestParseCollectorPriority(t *testing.T) {
|
func TestParseCollectorPriority(t *testing.T) {
|
||||||
got := parseCollectorPriority(" nvml, nvidia-smi, intel_gpu_top, intel_sysfs, amd_sysfs, nvtop, rocm-smi, bad ")
|
got := parseCollectorPriority(" nvml, nvidia-smi, intel_gpu_top, amd_sysfs, nvtop, rocm-smi, bad ")
|
||||||
want := []collectorSource{
|
want := []collectorSource{
|
||||||
collectorSourceNVML,
|
collectorSourceNVML,
|
||||||
collectorSourceNvidiaSMI,
|
collectorSourceNvidiaSMI,
|
||||||
collectorSourceIntelGpuTop,
|
collectorSourceIntelGpuTop,
|
||||||
collectorSourceIntelSysfs,
|
|
||||||
collectorSourceAmdSysfs,
|
collectorSourceAmdSysfs,
|
||||||
collectorSourceNVTop,
|
collectorSourceNVTop,
|
||||||
collectorSourceRocmSMI,
|
collectorSourceRocmSMI,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ 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/probe"
|
||||||
"github.com/henrygd/beszel/internal/entities/smart"
|
"github.com/henrygd/beszel/internal/entities/smart"
|
||||||
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -51,6 +52,7 @@ func NewHandlerRegistry() *HandlerRegistry {
|
|||||||
registry.Register(common.GetContainerInfo, &GetContainerInfoHandler{})
|
registry.Register(common.GetContainerInfo, &GetContainerInfoHandler{})
|
||||||
registry.Register(common.GetSmartData, &GetSmartDataHandler{})
|
registry.Register(common.GetSmartData, &GetSmartDataHandler{})
|
||||||
registry.Register(common.GetSystemdInfo, &GetSystemdInfoHandler{})
|
registry.Register(common.GetSystemdInfo, &GetSystemdInfoHandler{})
|
||||||
|
registry.Register(common.SyncNetworkProbes, &SyncNetworkProbesHandler{})
|
||||||
|
|
||||||
return registry
|
return registry
|
||||||
}
|
}
|
||||||
@@ -166,16 +168,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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////
|
||||||
@@ -205,3 +205,21 @@ func (h *GetSystemdInfoHandler) Handle(hctx *HandlerContext) error {
|
|||||||
|
|
||||||
return hctx.SendResponse(details, hctx.RequestID)
|
return hctx.SendResponse(details, hctx.RequestID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////
|
||||||
|
////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
// SyncNetworkProbesHandler handles probe configuration sync from hub
|
||||||
|
type SyncNetworkProbesHandler struct{}
|
||||||
|
|
||||||
|
func (h *SyncNetworkProbesHandler) Handle(hctx *HandlerContext) error {
|
||||||
|
var req probe.SyncRequest
|
||||||
|
if err := cbor.Unmarshal(hctx.Request.Data, &req); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp, err := hctx.Agent.probeManager.HandleSyncRequest(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return hctx.SendResponse(resp, 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)
|
||||||
|
|||||||
@@ -180,13 +180,8 @@ func mdraidSmartStatus(health mdraidHealth) string {
|
|||||||
if health.degraded > 0 {
|
if health.degraded > 0 {
|
||||||
return "FAILED"
|
return "FAILED"
|
||||||
}
|
}
|
||||||
if health.mismatchCnt > 0 {
|
|
||||||
return "WARNING"
|
|
||||||
}
|
|
||||||
// "check" scans for consistency problems without repairing mismatches.
|
|
||||||
// With no mismatches, keep it green while reporting progress attributes.
|
|
||||||
switch syncAction {
|
switch syncAction {
|
||||||
case "repair":
|
case "check", "repair":
|
||||||
return "WARNING"
|
return "WARNING"
|
||||||
}
|
}
|
||||||
switch state {
|
switch state {
|
||||||
|
|||||||
@@ -94,18 +94,6 @@ func TestMdraidSmartStatus(t *testing.T) {
|
|||||||
if got := mdraidSmartStatus(mdraidHealth{arrayState: "active", syncAction: "recover"}); got != "WARNING" {
|
if got := mdraidSmartStatus(mdraidHealth{arrayState: "active", syncAction: "recover"}); got != "WARNING" {
|
||||||
t.Fatalf("mdraidSmartStatus(recover) = %q, want WARNING", got)
|
t.Fatalf("mdraidSmartStatus(recover) = %q, want WARNING", got)
|
||||||
}
|
}
|
||||||
if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean", syncAction: "check"}); got != "PASSED" {
|
|
||||||
t.Fatalf("mdraidSmartStatus(clean+check) = %q, want PASSED", got)
|
|
||||||
}
|
|
||||||
if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean", syncAction: "check", mismatchCnt: 1}); got != "WARNING" {
|
|
||||||
t.Fatalf("mdraidSmartStatus(clean+check+mismatch) = %q, want WARNING", got)
|
|
||||||
}
|
|
||||||
if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean", mismatchCnt: 1}); got != "WARNING" {
|
|
||||||
t.Fatalf("mdraidSmartStatus(clean+mismatch) = %q, want WARNING", got)
|
|
||||||
}
|
|
||||||
if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean", syncAction: "repair"}); got != "WARNING" {
|
|
||||||
t.Fatalf("mdraidSmartStatus(repair) = %q, want WARNING", got)
|
|
||||||
}
|
|
||||||
if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean"}); got != "PASSED" {
|
if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean"}); got != "PASSED" {
|
||||||
t.Fatalf("mdraidSmartStatus(clean) = %q, want PASSED", got)
|
t.Fatalf("mdraidSmartStatus(clean) = %q, want PASSED", got)
|
||||||
}
|
}
|
||||||
|
|||||||
538
agent/probe.go
Normal file
538
agent/probe.go
Normal file
@@ -0,0 +1,538 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"math/rand"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
// "strconv"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/entities/probe"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Probes run at user-defined intervals (e.g., every 10s).
|
||||||
|
// To keep memory usage low and constant, data is stored in two layers:
|
||||||
|
// 1. Raw samples: The most recent individual results (kept for probeRawRetention).
|
||||||
|
// 2. Minute buckets: A ring buffer of 61 buckets, each representing one
|
||||||
|
// wall-clock minute. Samples collected within the same minute are aggregated
|
||||||
|
// (sum, min, max, count) into a single bucket.
|
||||||
|
//
|
||||||
|
// Short-term requests (<= 70s) use raw samples.
|
||||||
|
// Long-term requests (up to 1h) use the minute buckets to avoid storing thousands
|
||||||
|
// of individual data points.
|
||||||
|
|
||||||
|
const (
|
||||||
|
// probeRawRetention is the duration to keep individual samples
|
||||||
|
probeRawRetention = 61 * time.Second
|
||||||
|
// probeMinuteBucketLen is the number of 1-minute buckets to keep (1 hour + 1 for partials)
|
||||||
|
probeMinuteBucketLen int32 = 61
|
||||||
|
)
|
||||||
|
|
||||||
|
// ProbeManager manages network probe tasks.
|
||||||
|
type ProbeManager struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
probes map[string]*probeTask // key = probe.Config.Key()
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// probeTask owns retention buffers and cancellation for a single probe config.
|
||||||
|
type probeTask struct {
|
||||||
|
config probe.Config
|
||||||
|
cancel chan struct{}
|
||||||
|
mu sync.Mutex
|
||||||
|
samples []probeSample
|
||||||
|
buckets [probeMinuteBucketLen]probeBucket
|
||||||
|
}
|
||||||
|
|
||||||
|
// probeSample stores one probe attempt and its collection time.
|
||||||
|
type probeSample struct {
|
||||||
|
responseUs int64 // -1 means loss
|
||||||
|
timestamp time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// probeBucket stores one minute of aggregated probe data.
|
||||||
|
type probeBucket struct {
|
||||||
|
minute int32
|
||||||
|
filled bool
|
||||||
|
stats probeAggregate
|
||||||
|
}
|
||||||
|
|
||||||
|
// probeAggregate accumulates successful response stats and total sample counts.
|
||||||
|
type probeAggregate struct {
|
||||||
|
sumUs int64
|
||||||
|
minUs int64
|
||||||
|
maxUs int64
|
||||||
|
totalCount int64
|
||||||
|
successCount int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func newProbeManager() *ProbeManager {
|
||||||
|
return &ProbeManager{
|
||||||
|
probes: make(map[string]*probeTask),
|
||||||
|
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newProbeTask(config probe.Config) *probeTask {
|
||||||
|
return &probeTask{
|
||||||
|
config: config,
|
||||||
|
cancel: make(chan struct{}),
|
||||||
|
samples: make([]probeSample, 0, 64),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newProbeTaskFromExisting(config probe.Config, existing *probeTask) *probeTask {
|
||||||
|
task := newProbeTask(config)
|
||||||
|
if existing == nil {
|
||||||
|
return task
|
||||||
|
}
|
||||||
|
|
||||||
|
existing.mu.Lock()
|
||||||
|
defer existing.mu.Unlock()
|
||||||
|
task.samples = append(task.samples, existing.samples...)
|
||||||
|
task.buckets = existing.buckets
|
||||||
|
return task
|
||||||
|
}
|
||||||
|
|
||||||
|
// newProbeAggregate initializes an aggregate with an unset minimum value.
|
||||||
|
func newProbeAggregate() probeAggregate {
|
||||||
|
return probeAggregate{minUs: math.MaxInt64}
|
||||||
|
}
|
||||||
|
|
||||||
|
// addResponse folds a single probe sample into the aggregate.
|
||||||
|
func (agg *probeAggregate) addResponse(responseUs int64) {
|
||||||
|
agg.totalCount++
|
||||||
|
if responseUs < 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
agg.successCount++
|
||||||
|
agg.sumUs += responseUs
|
||||||
|
if responseUs < agg.minUs {
|
||||||
|
agg.minUs = responseUs
|
||||||
|
}
|
||||||
|
if responseUs > agg.maxUs {
|
||||||
|
agg.maxUs = responseUs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// addAggregate merges another aggregate into this one.
|
||||||
|
func (agg *probeAggregate) addAggregate(other probeAggregate) {
|
||||||
|
if other.totalCount == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
agg.totalCount += other.totalCount
|
||||||
|
agg.successCount += other.successCount
|
||||||
|
agg.sumUs += other.sumUs
|
||||||
|
if other.successCount == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if agg.minUs == math.MaxInt64 || other.minUs < agg.minUs {
|
||||||
|
agg.minUs = other.minUs
|
||||||
|
}
|
||||||
|
if other.maxUs > agg.maxUs {
|
||||||
|
agg.maxUs = other.maxUs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasData reports whether the aggregate contains any samples.
|
||||||
|
func (agg probeAggregate) hasData() bool {
|
||||||
|
return agg.totalCount > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// result converts the aggregate into the probe result format.
|
||||||
|
func (agg probeAggregate) result() probe.Result {
|
||||||
|
avg := agg.avgResponse()
|
||||||
|
result := probe.Result{
|
||||||
|
AvgResponse: avg,
|
||||||
|
MinResponse: agg.minUs,
|
||||||
|
MaxResponse: agg.maxUs,
|
||||||
|
PacketLoss: agg.lossPercentage(),
|
||||||
|
}
|
||||||
|
if agg.successCount == 0 {
|
||||||
|
result.MinResponse, result.MaxResponse = 0, 0
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// avgResponse returns the rounded average of successful samples.
|
||||||
|
func (agg probeAggregate) avgResponse() int64 {
|
||||||
|
if agg.successCount == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return agg.sumUs / agg.successCount
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// lossPercentage returns the rounded failure rate for the aggregate.
|
||||||
|
func (agg probeAggregate) lossPercentage() float64 {
|
||||||
|
if agg.totalCount == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return math.Round(float64(agg.totalCount-agg.successCount)/float64(agg.totalCount)*10000) / 100
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncProbes replaces all probe tasks with the given configs.
|
||||||
|
func (pm *ProbeManager) SyncProbes(configs []probe.Config) {
|
||||||
|
pm.mu.Lock()
|
||||||
|
defer pm.mu.Unlock()
|
||||||
|
|
||||||
|
// Build set of new keys
|
||||||
|
newKeys := make(map[string]probe.Config, len(configs))
|
||||||
|
for _, cfg := range configs {
|
||||||
|
if cfg.ID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
newKeys[cfg.ID] = cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop removed probes
|
||||||
|
for key, task := range pm.probes {
|
||||||
|
if _, exists := newKeys[key]; !exists {
|
||||||
|
close(task.cancel)
|
||||||
|
delete(pm.probes, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start new probes and restart tasks whose config changed.
|
||||||
|
for key, cfg := range newKeys {
|
||||||
|
task, exists := pm.probes[key]
|
||||||
|
if exists && task.config == cfg {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
close(task.cancel)
|
||||||
|
}
|
||||||
|
task = newProbeTaskFromExisting(cfg, task)
|
||||||
|
pm.probes[key] = task
|
||||||
|
go pm.runProbe(task, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleSyncRequest applies a full or incremental probe sync request.
|
||||||
|
func (pm *ProbeManager) HandleSyncRequest(req probe.SyncRequest) (probe.SyncResponse, error) {
|
||||||
|
switch req.Action {
|
||||||
|
case probe.SyncActionReplace:
|
||||||
|
pm.SyncProbes(req.Configs)
|
||||||
|
return probe.SyncResponse{}, nil
|
||||||
|
case probe.SyncActionUpsert:
|
||||||
|
result, err := pm.UpsertProbe(req.Config, req.RunNow)
|
||||||
|
if err != nil {
|
||||||
|
return probe.SyncResponse{}, err
|
||||||
|
}
|
||||||
|
if result == nil {
|
||||||
|
return probe.SyncResponse{}, nil
|
||||||
|
}
|
||||||
|
return probe.SyncResponse{Result: *result}, nil
|
||||||
|
case probe.SyncActionDelete:
|
||||||
|
if req.Config.ID == "" {
|
||||||
|
return probe.SyncResponse{}, errors.New("missing probe ID for delete")
|
||||||
|
}
|
||||||
|
pm.DeleteProbe(req.Config.ID)
|
||||||
|
return probe.SyncResponse{}, nil
|
||||||
|
default:
|
||||||
|
return probe.SyncResponse{}, fmt.Errorf("unknown probe sync action: %d", req.Action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpsertProbe creates or replaces a single probe task.
|
||||||
|
func (pm *ProbeManager) UpsertProbe(config probe.Config, runNow bool) (*probe.Result, error) {
|
||||||
|
if config.ID == "" {
|
||||||
|
return nil, errors.New("missing probe ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
pm.mu.Lock()
|
||||||
|
task, exists := pm.probes[config.ID]
|
||||||
|
startTask := false
|
||||||
|
if exists && task.config == config {
|
||||||
|
pm.mu.Unlock()
|
||||||
|
if !runNow {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return pm.runProbeNow(task), nil
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
close(task.cancel)
|
||||||
|
}
|
||||||
|
task = newProbeTaskFromExisting(config, task)
|
||||||
|
pm.probes[config.ID] = task
|
||||||
|
startTask = true
|
||||||
|
pm.mu.Unlock()
|
||||||
|
|
||||||
|
if runNow {
|
||||||
|
result := pm.runProbeNow(task)
|
||||||
|
if startTask {
|
||||||
|
go pm.runProbe(task, false)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
if startTask {
|
||||||
|
go pm.runProbe(task, false)
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteProbe stops and removes a single probe task.
|
||||||
|
func (pm *ProbeManager) DeleteProbe(id string) {
|
||||||
|
if id == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pm.mu.Lock()
|
||||||
|
defer pm.mu.Unlock()
|
||||||
|
if task, exists := pm.probes[id]; exists {
|
||||||
|
close(task.cancel)
|
||||||
|
delete(pm.probes, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetResults returns aggregated results for all probes over the last supplied duration in ms.
|
||||||
|
func (pm *ProbeManager) GetResults(durationMs uint16) map[string]probe.Result {
|
||||||
|
pm.mu.RLock()
|
||||||
|
defer pm.mu.RUnlock()
|
||||||
|
|
||||||
|
results := make(map[string]probe.Result, len(pm.probes))
|
||||||
|
now := time.Now()
|
||||||
|
duration := time.Duration(durationMs) * time.Millisecond
|
||||||
|
|
||||||
|
for _, task := range pm.probes {
|
||||||
|
task.mu.Lock()
|
||||||
|
result, ok := task.resultLocked(duration, now)
|
||||||
|
task.mu.Unlock()
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
results[task.config.ID] = result
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop stops all probe tasks.
|
||||||
|
func (pm *ProbeManager) Stop() {
|
||||||
|
pm.mu.Lock()
|
||||||
|
defer pm.mu.Unlock()
|
||||||
|
for key, task := range pm.probes {
|
||||||
|
close(task.cancel)
|
||||||
|
delete(pm.probes, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runProbe executes a single probe task in a loop.
|
||||||
|
func (pm *ProbeManager) runProbe(task *probeTask, runNow bool) {
|
||||||
|
interval := time.Duration(task.config.Interval) * time.Second
|
||||||
|
if interval < time.Second {
|
||||||
|
interval = 30 * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
stagger := getStagger(interval.Milliseconds())
|
||||||
|
|
||||||
|
slog.Debug("starting probe task", "target", task.config.Target, "delay", stagger.String(), "interval", interval.String())
|
||||||
|
|
||||||
|
if runNow {
|
||||||
|
pm.executeProbe(task)
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-task.cancel:
|
||||||
|
// slog.Info("removed probe", "target", task.config.Target)
|
||||||
|
return
|
||||||
|
case <-time.After(stagger):
|
||||||
|
pm.executeProbe(task)
|
||||||
|
}
|
||||||
|
|
||||||
|
ticker := time.Tick(interval)
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-task.cancel:
|
||||||
|
// slog.Info("removed probe", "target", task.config.Target)
|
||||||
|
return
|
||||||
|
case <-ticker:
|
||||||
|
pm.executeProbe(task)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getStagger returns a random duration between intervalSeconds/2 and intervalSeconds to stagger initial probe executions
|
||||||
|
func getStagger(intervalMilli int64) time.Duration {
|
||||||
|
intervalMilliInt := int(intervalMilli)
|
||||||
|
randomDelayInt := rand.Intn(intervalMilliInt)
|
||||||
|
if randomDelayInt < intervalMilliInt/2 {
|
||||||
|
randomDelayInt += intervalMilliInt / 2
|
||||||
|
}
|
||||||
|
return time.Duration(randomDelayInt) * time.Millisecond
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pm *ProbeManager) runProbeNow(task *probeTask) *probe.Result {
|
||||||
|
pm.executeProbe(task)
|
||||||
|
task.mu.Lock()
|
||||||
|
defer task.mu.Unlock()
|
||||||
|
result, ok := task.resultLocked(time.Minute, time.Now())
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &result
|
||||||
|
}
|
||||||
|
|
||||||
|
// resultLocked returns the aggregated probe result for the requested duration along with a bool indicating whether any data was available.
|
||||||
|
func (task *probeTask) resultLocked(duration time.Duration, now time.Time) (probe.Result, bool) {
|
||||||
|
agg := task.aggregateLocked(duration, now)
|
||||||
|
hourAgg := task.aggregateLocked(time.Hour, now)
|
||||||
|
if !agg.hasData() {
|
||||||
|
return probe.Result{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
result := agg.result()
|
||||||
|
|
||||||
|
result.AvgResponse1h = hourAgg.avgResponse()
|
||||||
|
result.MinResponse1h = hourAgg.minUs
|
||||||
|
result.MaxResponse1h = hourAgg.maxUs
|
||||||
|
result.PacketLoss1h = hourAgg.lossPercentage()
|
||||||
|
|
||||||
|
if hourAgg.successCount == 0 {
|
||||||
|
result.MinResponse1h, result.MaxResponse1h = 0, 0
|
||||||
|
}
|
||||||
|
return result, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// aggregateLocked collects probe data for the requested time window.
|
||||||
|
func (task *probeTask) aggregateLocked(duration time.Duration, now time.Time) probeAggregate {
|
||||||
|
cutoff := now.Add(-duration)
|
||||||
|
// Keep short windows exact; longer windows read from minute buckets to avoid raw-sample retention.
|
||||||
|
if duration <= probeRawRetention {
|
||||||
|
return aggregateSamplesSince(task.samples, cutoff)
|
||||||
|
}
|
||||||
|
return aggregateBucketsSince(task.buckets[:], cutoff, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
// aggregateSamplesSince aggregates raw samples newer than the cutoff.
|
||||||
|
func aggregateSamplesSince(samples []probeSample, cutoff time.Time) probeAggregate {
|
||||||
|
agg := newProbeAggregate()
|
||||||
|
for _, sample := range samples {
|
||||||
|
if sample.timestamp.Before(cutoff) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
agg.addResponse(sample.responseUs)
|
||||||
|
}
|
||||||
|
return agg
|
||||||
|
}
|
||||||
|
|
||||||
|
// aggregateBucketsSince aggregates minute buckets overlapping the requested window.
|
||||||
|
func aggregateBucketsSince(buckets []probeBucket, cutoff, now time.Time) probeAggregate {
|
||||||
|
agg := newProbeAggregate()
|
||||||
|
startMinute := int32(cutoff.Unix() / 60)
|
||||||
|
endMinute := int32(now.Unix() / 60)
|
||||||
|
for _, bucket := range buckets {
|
||||||
|
if !bucket.filled || bucket.minute < startMinute || bucket.minute > endMinute {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
agg.addAggregate(bucket.stats)
|
||||||
|
}
|
||||||
|
return agg
|
||||||
|
}
|
||||||
|
|
||||||
|
// addSampleLocked stores a fresh sample in both raw and per-minute retention buffers.
|
||||||
|
func (task *probeTask) addSampleLocked(sample probeSample) {
|
||||||
|
cutoff := sample.timestamp.Add(-probeRawRetention)
|
||||||
|
start := 0
|
||||||
|
for i := range task.samples {
|
||||||
|
if !task.samples[i].timestamp.Before(cutoff) {
|
||||||
|
start = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if i == len(task.samples)-1 {
|
||||||
|
start = len(task.samples)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if start > 0 {
|
||||||
|
size := copy(task.samples, task.samples[start:])
|
||||||
|
task.samples = task.samples[:size]
|
||||||
|
}
|
||||||
|
task.samples = append(task.samples, sample)
|
||||||
|
|
||||||
|
minute := int32(sample.timestamp.Unix() / 60)
|
||||||
|
// Each slot stores one wall-clock minute, so the ring stays fixed-size at ~1h per probe.
|
||||||
|
bucket := &task.buckets[minute%probeMinuteBucketLen]
|
||||||
|
if !bucket.filled || bucket.minute != minute {
|
||||||
|
bucket.minute = minute
|
||||||
|
bucket.filled = true
|
||||||
|
bucket.stats = newProbeAggregate()
|
||||||
|
}
|
||||||
|
bucket.stats.addResponse(sample.responseUs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// executeProbe runs the configured probe and records the sample.
|
||||||
|
func (pm *ProbeManager) executeProbe(task *probeTask) {
|
||||||
|
// slog.Info("running probe", "id", task.config.ID, "interval", task.config.Interval)
|
||||||
|
var responseUs int64
|
||||||
|
var err error
|
||||||
|
|
||||||
|
switch task.config.Protocol {
|
||||||
|
case "icmp":
|
||||||
|
responseUs, err = probeICMP(task.config.Target)
|
||||||
|
case "tcp":
|
||||||
|
responseUs, err = probeTCP(task.config.Target, task.config.Port)
|
||||||
|
case "http":
|
||||||
|
responseUs, err = probeHTTP(pm.httpClient, task.config.Target)
|
||||||
|
default:
|
||||||
|
slog.Warn("unknown probe protocol", "protocol", task.config.Protocol)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("probe failed", "err", err, "target", task.config.Target, "protocol", task.config.Protocol)
|
||||||
|
}
|
||||||
|
|
||||||
|
sample := probeSample{
|
||||||
|
responseUs: responseUs,
|
||||||
|
timestamp: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
task.mu.Lock()
|
||||||
|
task.addSampleLocked(sample)
|
||||||
|
task.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// probeTCP measures pure TCP handshake response (excluding DNS resolution).
|
||||||
|
// Returns -1 and an error on failure.
|
||||||
|
func probeTCP(target string, port uint16) (int64, error) {
|
||||||
|
// Resolve DNS first, outside the timing window
|
||||||
|
ips, err := net.LookupHost(target)
|
||||||
|
if err != nil || len(ips) == 0 {
|
||||||
|
return -1, err
|
||||||
|
}
|
||||||
|
addr := net.JoinHostPort(ips[0], fmt.Sprintf("%d", port))
|
||||||
|
|
||||||
|
// Measure only the TCP handshake
|
||||||
|
start := time.Now()
|
||||||
|
conn, err := net.DialTimeout("tcp", addr, 3*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
return -1, err
|
||||||
|
}
|
||||||
|
conn.Close()
|
||||||
|
return time.Since(start).Microseconds(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// probeHTTP measures HTTP GET request response in microseconds. Returns -1 and an error on failure.
|
||||||
|
func probeHTTP(client *http.Client, url string) (int64, error) {
|
||||||
|
if client == nil {
|
||||||
|
client = http.DefaultClient
|
||||||
|
}
|
||||||
|
start := time.Now()
|
||||||
|
resp, err := client.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return -1, err
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
return -1, fmt.Errorf("HTTP error: %s", resp.Status)
|
||||||
|
}
|
||||||
|
return time.Since(start).Microseconds(), nil
|
||||||
|
}
|
||||||
241
agent/probe_ping.go
Normal file
241
agent/probe_ping.go
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"math"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"regexp"
|
||||||
|
"runtime"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/net/icmp"
|
||||||
|
"golang.org/x/net/ipv4"
|
||||||
|
"golang.org/x/net/ipv6"
|
||||||
|
|
||||||
|
"log/slog"
|
||||||
|
)
|
||||||
|
|
||||||
|
var pingTimeRegex = regexp.MustCompile(`time[=<]([\d.]+)\s*ms`)
|
||||||
|
|
||||||
|
type icmpPacketConn interface {
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
||||||
|
// icmpMethod tracks which ICMP approach to use. Once a method succeeds or
|
||||||
|
// all native methods fail, the choice is cached so subsequent probes skip
|
||||||
|
// the trial-and-error overhead.
|
||||||
|
type icmpMethod uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
icmpUntried icmpMethod = iota // haven't tried yet
|
||||||
|
icmpRaw // privileged raw socket
|
||||||
|
icmpDatagram // unprivileged datagram socket
|
||||||
|
icmpExecFallback // shell out to system ping command
|
||||||
|
)
|
||||||
|
|
||||||
|
// icmpFamily holds the network parameters and cached detection result for one address family.
|
||||||
|
type icmpFamily struct {
|
||||||
|
rawNetwork string // e.g. "ip4:icmp" or "ip6:ipv6-icmp"
|
||||||
|
dgramNetwork string // e.g. "udp4" or "udp6"
|
||||||
|
listenAddr string // "0.0.0.0" or "::"
|
||||||
|
echoType icmp.Type // outgoing echo request type
|
||||||
|
replyType icmp.Type // expected echo reply type
|
||||||
|
proto int // IANA protocol number for parsing replies
|
||||||
|
isIPv6 bool
|
||||||
|
mode icmpMethod // cached detection result (guarded by icmpModeMu)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
icmpV4 = icmpFamily{
|
||||||
|
rawNetwork: "ip4:icmp",
|
||||||
|
dgramNetwork: "udp4",
|
||||||
|
listenAddr: "0.0.0.0",
|
||||||
|
echoType: ipv4.ICMPTypeEcho,
|
||||||
|
replyType: ipv4.ICMPTypeEchoReply,
|
||||||
|
proto: 1,
|
||||||
|
}
|
||||||
|
icmpV6 = icmpFamily{
|
||||||
|
rawNetwork: "ip6:ipv6-icmp",
|
||||||
|
dgramNetwork: "udp6",
|
||||||
|
listenAddr: "::",
|
||||||
|
echoType: ipv6.ICMPTypeEchoRequest,
|
||||||
|
replyType: ipv6.ICMPTypeEchoReply,
|
||||||
|
proto: 58,
|
||||||
|
isIPv6: true,
|
||||||
|
}
|
||||||
|
icmpModeMu sync.Mutex
|
||||||
|
icmpListen = func(network, listenAddr string) (icmpPacketConn, error) {
|
||||||
|
return icmp.ListenPacket(network, listenAddr)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// probeICMP sends an ICMP echo request and measures round-trip response.
|
||||||
|
// Supports both IPv4 and IPv6 targets. The ICMP method (raw socket,
|
||||||
|
// unprivileged datagram, or exec fallback) is detected once per address
|
||||||
|
// family and cached for subsequent probes.
|
||||||
|
// Returns response in microseconds, or -1 and an error on failure.
|
||||||
|
func probeICMP(target string) (int64, error) {
|
||||||
|
family, ip, err := resolveICMPTarget(target)
|
||||||
|
if err != nil {
|
||||||
|
return -1, err
|
||||||
|
}
|
||||||
|
|
||||||
|
icmpModeMu.Lock()
|
||||||
|
if family.mode == icmpUntried {
|
||||||
|
family.mode = detectICMPMode(family, icmpListen)
|
||||||
|
}
|
||||||
|
mode := family.mode
|
||||||
|
icmpModeMu.Unlock()
|
||||||
|
|
||||||
|
switch mode {
|
||||||
|
case icmpRaw:
|
||||||
|
return probeICMPNative(family.rawNetwork, family, &net.IPAddr{IP: ip})
|
||||||
|
case icmpDatagram:
|
||||||
|
return probeICMPNative(family.dgramNetwork, family, &net.UDPAddr{IP: ip})
|
||||||
|
case icmpExecFallback:
|
||||||
|
return probeICMPExec(target, family.isIPv6)
|
||||||
|
default:
|
||||||
|
return -1, errors.New("unsupported ICMP mode")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveICMPTarget resolves a target hostname or IP to determine the address
|
||||||
|
// family and concrete IP address. Prefers IPv4 for dual-stack hostnames.
|
||||||
|
func resolveICMPTarget(target string) (*icmpFamily, net.IP, error) {
|
||||||
|
if ip := net.ParseIP(target); ip != nil {
|
||||||
|
if ip.To4() != nil {
|
||||||
|
return &icmpV4, ip.To4(), nil
|
||||||
|
}
|
||||||
|
return &icmpV6, ip, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ips, err := net.LookupIP(target)
|
||||||
|
if err != nil || len(ips) == 0 {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
for _, ip := range ips {
|
||||||
|
if v4 := ip.To4(); v4 != nil {
|
||||||
|
return &icmpV4, v4, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &icmpV6, ips[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectICMPMode(family *icmpFamily, listen func(network, listenAddr string) (icmpPacketConn, error)) icmpMethod {
|
||||||
|
label := "IPv4"
|
||||||
|
if family.isIPv6 {
|
||||||
|
label = "IPv6"
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := listen(family.rawNetwork, family.listenAddr)
|
||||||
|
slog.Debug("ICMP raw socket test", "family", label, "err", err)
|
||||||
|
if err == nil {
|
||||||
|
conn.Close()
|
||||||
|
return icmpRaw
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err = listen(family.dgramNetwork, family.listenAddr)
|
||||||
|
slog.Debug("ICMP datagram socket test", "family", label, "err", err)
|
||||||
|
if err == nil {
|
||||||
|
conn.Close()
|
||||||
|
return icmpDatagram
|
||||||
|
}
|
||||||
|
|
||||||
|
return icmpExecFallback
|
||||||
|
}
|
||||||
|
|
||||||
|
// probeICMPNative sends an ICMP echo request using Go's x/net/icmp package.
|
||||||
|
func probeICMPNative(network string, family *icmpFamily, dst net.Addr) (int64, error) {
|
||||||
|
conn, err := icmp.ListenPacket(network, family.listenAddr)
|
||||||
|
if err != nil {
|
||||||
|
return -1, err
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
// Build ICMP echo request
|
||||||
|
msg := &icmp.Message{
|
||||||
|
Type: family.echoType,
|
||||||
|
Code: 0,
|
||||||
|
Body: &icmp.Echo{
|
||||||
|
ID: os.Getpid() & 0xffff,
|
||||||
|
Seq: 1,
|
||||||
|
Data: []byte("beszel-probe"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
msgBytes, err := msg.Marshal(nil)
|
||||||
|
if err != nil {
|
||||||
|
return -1, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set deadline before sending
|
||||||
|
conn.SetDeadline(time.Now().Add(3 * time.Second))
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
if _, err := conn.WriteTo(msgBytes, dst); err != nil {
|
||||||
|
return -1, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read reply
|
||||||
|
buf := make([]byte, 1500)
|
||||||
|
for {
|
||||||
|
n, _, err := conn.ReadFrom(buf)
|
||||||
|
if err != nil {
|
||||||
|
return -1, err
|
||||||
|
}
|
||||||
|
|
||||||
|
reply, err := icmp.ParseMessage(family.proto, buf[:n])
|
||||||
|
if err != nil {
|
||||||
|
return -1, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if reply.Type == family.replyType {
|
||||||
|
return time.Since(start).Microseconds(), nil
|
||||||
|
}
|
||||||
|
// Ignore non-echo-reply messages (e.g. destination unreachable) and keep reading
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// probeICMPExec falls back to the system ping command. Returns -1 and an error on failure.
|
||||||
|
func probeICMPExec(target string, isIPv6 bool) (int64, error) {
|
||||||
|
var cmd *exec.Cmd
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "windows":
|
||||||
|
if isIPv6 {
|
||||||
|
cmd = exec.Command("ping", "-6", "-n", "1", "-w", "3000", target)
|
||||||
|
} else {
|
||||||
|
cmd = exec.Command("ping", "-n", "1", "-w", "3000", target)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if isIPv6 {
|
||||||
|
cmd = exec.Command("ping", "-6", "-c", "1", "-W", "3", target)
|
||||||
|
} else {
|
||||||
|
cmd = exec.Command("ping", "-c", "1", "-W", "3", target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
// If ping fails but we got output, still try to parse
|
||||||
|
if len(output) == 0 {
|
||||||
|
return -1, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
matches := pingTimeRegex.FindSubmatch(output)
|
||||||
|
if len(matches) >= 2 {
|
||||||
|
if ms, err := strconv.ParseFloat(string(matches[1]), 64); err == nil {
|
||||||
|
return int64(math.Round(ms * 1000)), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: use wall clock time if ping succeeded but parsing failed
|
||||||
|
if err == nil {
|
||||||
|
return time.Since(start).Microseconds(), nil
|
||||||
|
}
|
||||||
|
return -1, err
|
||||||
|
}
|
||||||
121
agent/probe_ping_test.go
Normal file
121
agent/probe_ping_test.go
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
//go:build testing
|
||||||
|
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
type testICMPPacketConn struct{}
|
||||||
|
|
||||||
|
func (testICMPPacketConn) Close() error { return nil }
|
||||||
|
|
||||||
|
func TestDetectICMPMode(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
family *icmpFamily
|
||||||
|
rawErr error
|
||||||
|
udpErr error
|
||||||
|
want icmpMethod
|
||||||
|
wantNetworks []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "IPv4 prefers raw socket when available",
|
||||||
|
family: &icmpV4,
|
||||||
|
want: icmpRaw,
|
||||||
|
wantNetworks: []string{"ip4:icmp"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "IPv4 uses datagram when raw unavailable",
|
||||||
|
family: &icmpV4,
|
||||||
|
rawErr: errors.New("operation not permitted"),
|
||||||
|
want: icmpDatagram,
|
||||||
|
wantNetworks: []string{"ip4:icmp", "udp4"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "IPv4 falls back to exec when both unavailable",
|
||||||
|
family: &icmpV4,
|
||||||
|
rawErr: errors.New("operation not permitted"),
|
||||||
|
udpErr: errors.New("protocol not supported"),
|
||||||
|
want: icmpExecFallback,
|
||||||
|
wantNetworks: []string{"ip4:icmp", "udp4"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "IPv6 prefers raw socket when available",
|
||||||
|
family: &icmpV6,
|
||||||
|
want: icmpRaw,
|
||||||
|
wantNetworks: []string{"ip6:ipv6-icmp"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "IPv6 uses datagram when raw unavailable",
|
||||||
|
family: &icmpV6,
|
||||||
|
rawErr: errors.New("operation not permitted"),
|
||||||
|
want: icmpDatagram,
|
||||||
|
wantNetworks: []string{"ip6:ipv6-icmp", "udp6"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "IPv6 falls back to exec when both unavailable",
|
||||||
|
family: &icmpV6,
|
||||||
|
rawErr: errors.New("operation not permitted"),
|
||||||
|
udpErr: errors.New("protocol not supported"),
|
||||||
|
want: icmpExecFallback,
|
||||||
|
wantNetworks: []string{"ip6:ipv6-icmp", "udp6"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
calls := make([]string, 0, 2)
|
||||||
|
listen := func(network, listenAddr string) (icmpPacketConn, error) {
|
||||||
|
require.Equal(t, tt.family.listenAddr, listenAddr)
|
||||||
|
calls = append(calls, network)
|
||||||
|
switch network {
|
||||||
|
case tt.family.rawNetwork:
|
||||||
|
if tt.rawErr != nil {
|
||||||
|
return nil, tt.rawErr
|
||||||
|
}
|
||||||
|
case tt.family.dgramNetwork:
|
||||||
|
if tt.udpErr != nil {
|
||||||
|
return nil, tt.udpErr
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected network %q", network)
|
||||||
|
}
|
||||||
|
return testICMPPacketConn{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, tt.want, detectICMPMode(tt.family, listen))
|
||||||
|
assert.Equal(t, tt.wantNetworks, calls)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveICMPTarget(t *testing.T) {
|
||||||
|
t.Run("IPv4 literal", func(t *testing.T) {
|
||||||
|
family, ip, err := resolveICMPTarget("127.0.0.1")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, family)
|
||||||
|
assert.False(t, family.isIPv6)
|
||||||
|
assert.Equal(t, "127.0.0.1", ip.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("IPv6 literal", func(t *testing.T) {
|
||||||
|
family, ip, err := resolveICMPTarget("::1")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, family)
|
||||||
|
assert.True(t, family.isIPv6)
|
||||||
|
assert.Equal(t, "::1", ip.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("IPv4-mapped IPv6 resolves as IPv4", func(t *testing.T) {
|
||||||
|
family, ip, err := resolveICMPTarget("::ffff:127.0.0.1")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, family)
|
||||||
|
assert.False(t, family.isIPv6)
|
||||||
|
assert.Equal(t, "127.0.0.1", ip.String())
|
||||||
|
})
|
||||||
|
}
|
||||||
356
agent/probe_test.go
Normal file
356
agent/probe_test.go
Normal file
@@ -0,0 +1,356 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/entities/probe"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProbeTaskAggregateLockedUsesRawSamplesForShortWindows(t *testing.T) {
|
||||||
|
now := time.Date(2026, time.April, 21, 12, 0, 0, 0, time.UTC)
|
||||||
|
task := &probeTask{}
|
||||||
|
|
||||||
|
task.addSampleLocked(probeSample{responseUs: 10, timestamp: now.Add(-90 * time.Second)})
|
||||||
|
task.addSampleLocked(probeSample{responseUs: 20, timestamp: now.Add(-30 * time.Second)})
|
||||||
|
task.addSampleLocked(probeSample{responseUs: -1, timestamp: now.Add(-10 * time.Second)})
|
||||||
|
|
||||||
|
agg := task.aggregateLocked(time.Minute, now)
|
||||||
|
require.True(t, agg.hasData())
|
||||||
|
assert.Equal(t, int64(2), agg.totalCount)
|
||||||
|
assert.Equal(t, int64(1), agg.successCount)
|
||||||
|
result := agg.result()
|
||||||
|
assert.Equal(t, int64(20), result.AvgResponse)
|
||||||
|
assert.Equal(t, int64(20), result.MinResponse)
|
||||||
|
assert.Equal(t, int64(20), result.MaxResponse)
|
||||||
|
assert.Equal(t, 50.0, result.PacketLoss)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProbeTaskAggregateLockedUsesMinuteBucketsForLongWindows(t *testing.T) {
|
||||||
|
now := time.Date(2026, time.April, 21, 12, 0, 30, 0, time.UTC)
|
||||||
|
task := &probeTask{}
|
||||||
|
|
||||||
|
task.addSampleLocked(probeSample{responseUs: 10, timestamp: now.Add(-11 * time.Minute)})
|
||||||
|
task.addSampleLocked(probeSample{responseUs: 20, timestamp: now.Add(-9 * time.Minute)})
|
||||||
|
task.addSampleLocked(probeSample{responseUs: 40, timestamp: now.Add(-5 * time.Minute)})
|
||||||
|
task.addSampleLocked(probeSample{responseUs: -1, timestamp: now.Add(-90 * time.Second)})
|
||||||
|
task.addSampleLocked(probeSample{responseUs: 30, timestamp: now.Add(-30 * time.Second)})
|
||||||
|
|
||||||
|
agg := task.aggregateLocked(10*time.Minute, now)
|
||||||
|
require.True(t, agg.hasData())
|
||||||
|
assert.Equal(t, int64(4), agg.totalCount)
|
||||||
|
assert.Equal(t, int64(3), agg.successCount)
|
||||||
|
result := agg.result()
|
||||||
|
assert.Equal(t, int64(30), result.AvgResponse)
|
||||||
|
assert.Equal(t, int64(20), result.MinResponse)
|
||||||
|
assert.Equal(t, int64(40), result.MaxResponse)
|
||||||
|
assert.Equal(t, 25.0, result.PacketLoss)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProbeTaskAddSampleLockedTrimsRawSamplesButKeepsBucketHistory(t *testing.T) {
|
||||||
|
now := time.Date(2026, time.April, 21, 12, 0, 0, 0, time.UTC)
|
||||||
|
task := &probeTask{}
|
||||||
|
|
||||||
|
task.addSampleLocked(probeSample{responseUs: 10, timestamp: now.Add(-10 * time.Minute)})
|
||||||
|
task.addSampleLocked(probeSample{responseUs: 20, timestamp: now})
|
||||||
|
|
||||||
|
require.Len(t, task.samples, 1)
|
||||||
|
assert.Equal(t, int64(20), task.samples[0].responseUs)
|
||||||
|
|
||||||
|
agg := task.aggregateLocked(10*time.Minute, now)
|
||||||
|
require.True(t, agg.hasData())
|
||||||
|
assert.Equal(t, int64(2), agg.totalCount)
|
||||||
|
assert.Equal(t, int64(2), agg.successCount)
|
||||||
|
result := agg.result()
|
||||||
|
assert.Equal(t, int64(15), result.AvgResponse)
|
||||||
|
assert.Equal(t, int64(10), result.MinResponse)
|
||||||
|
assert.Equal(t, int64(20), result.MaxResponse)
|
||||||
|
assert.Equal(t, 0.0, result.PacketLoss)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProbeManagerGetResultsIncludesHourResponseRange(t *testing.T) {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
task := &probeTask{config: probe.Config{ID: "probe-1"}}
|
||||||
|
task.addSampleLocked(probeSample{responseUs: 10, timestamp: now.Add(-30 * time.Minute)})
|
||||||
|
task.addSampleLocked(probeSample{responseUs: 20, timestamp: now.Add(-9 * time.Minute)})
|
||||||
|
task.addSampleLocked(probeSample{responseUs: 40, timestamp: now.Add(-5 * time.Minute)})
|
||||||
|
task.addSampleLocked(probeSample{responseUs: 30, timestamp: now.Add(-50 * time.Second)})
|
||||||
|
task.addSampleLocked(probeSample{responseUs: -1, timestamp: now.Add(-30 * time.Second)})
|
||||||
|
|
||||||
|
pm := &ProbeManager{probes: map[string]*probeTask{"icmp:example.com": task}}
|
||||||
|
|
||||||
|
results := pm.GetResults(uint16(time.Minute / time.Millisecond))
|
||||||
|
result, ok := results["probe-1"]
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, int64(30), result.AvgResponse)
|
||||||
|
assert.Equal(t, int64(25), result.AvgResponse1h)
|
||||||
|
assert.Equal(t, int64(30), result.MinResponse)
|
||||||
|
assert.Equal(t, int64(10), result.MinResponse1h)
|
||||||
|
assert.Equal(t, int64(30), result.MaxResponse)
|
||||||
|
assert.Equal(t, int64(40), result.MaxResponse1h)
|
||||||
|
assert.Equal(t, 50.0, result.PacketLoss)
|
||||||
|
assert.Equal(t, 20.0, result.PacketLoss1h)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProbeManagerGetResultsIncludesLossOnlyHourData(t *testing.T) {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
task := &probeTask{config: probe.Config{ID: "probe-1"}}
|
||||||
|
task.addSampleLocked(probeSample{responseUs: -1, timestamp: now.Add(-30 * time.Second)})
|
||||||
|
task.addSampleLocked(probeSample{responseUs: -1, timestamp: now.Add(-10 * time.Second)})
|
||||||
|
|
||||||
|
pm := &ProbeManager{probes: map[string]*probeTask{"icmp:example.com": task}}
|
||||||
|
|
||||||
|
results := pm.GetResults(uint16(time.Minute / time.Millisecond))
|
||||||
|
result, ok := results["probe-1"]
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, int64(0), result.AvgResponse)
|
||||||
|
assert.Equal(t, int64(0), result.AvgResponse1h)
|
||||||
|
assert.Equal(t, int64(0), result.MinResponse)
|
||||||
|
assert.Equal(t, int64(0), result.MinResponse1h)
|
||||||
|
assert.Equal(t, int64(0), result.MaxResponse)
|
||||||
|
assert.Equal(t, int64(0), result.MaxResponse1h)
|
||||||
|
assert.Equal(t, 100.0, result.PacketLoss)
|
||||||
|
assert.Equal(t, 100.0, result.PacketLoss1h)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProbeConfigResultKeyUsesSyncedID(t *testing.T) {
|
||||||
|
cfg := probe.Config{ID: "probe-1", Target: "1.1.1.1", Protocol: "icmp", Interval: 10}
|
||||||
|
assert.Equal(t, "probe-1", cfg.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProbeManagerSyncProbesSkipsConfigsWithoutStableID(t *testing.T) {
|
||||||
|
validCfg := probe.Config{ID: "probe-1", Target: "ignored", Protocol: "noop", Interval: 10}
|
||||||
|
invalidCfg := probe.Config{Target: "ignored", Protocol: "noop", Interval: 10}
|
||||||
|
|
||||||
|
pm := newProbeManager()
|
||||||
|
pm.SyncProbes([]probe.Config{validCfg, invalidCfg})
|
||||||
|
defer pm.Stop()
|
||||||
|
|
||||||
|
_, validExists := pm.probes[validCfg.ID]
|
||||||
|
_, invalidExists := pm.probes[invalidCfg.ID]
|
||||||
|
assert.True(t, validExists)
|
||||||
|
assert.False(t, invalidExists)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProbeManagerSyncProbesStopsRemovedTasksButKeepsExisting(t *testing.T) {
|
||||||
|
keepCfg := probe.Config{ID: "probe-1", Target: "ignored", Protocol: "noop", Interval: 10}
|
||||||
|
removeCfg := probe.Config{ID: "probe-2", Target: "ignored", Protocol: "noop", Interval: 10}
|
||||||
|
|
||||||
|
keptTask := &probeTask{config: keepCfg, cancel: make(chan struct{})}
|
||||||
|
removedTask := &probeTask{config: removeCfg, cancel: make(chan struct{})}
|
||||||
|
pm := &ProbeManager{
|
||||||
|
probes: map[string]*probeTask{
|
||||||
|
keepCfg.ID: keptTask,
|
||||||
|
removeCfg.ID: removedTask,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
pm.SyncProbes([]probe.Config{keepCfg})
|
||||||
|
|
||||||
|
assert.Same(t, keptTask, pm.probes[keepCfg.ID])
|
||||||
|
_, exists := pm.probes[removeCfg.ID]
|
||||||
|
assert.False(t, exists)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-removedTask.cancel:
|
||||||
|
default:
|
||||||
|
t.Fatal("expected removed probe task to be cancelled")
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-keptTask.cancel:
|
||||||
|
t.Fatal("expected existing probe task to remain active")
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProbeManagerSyncProbesRestartsChangedConfig(t *testing.T) {
|
||||||
|
originalCfg := probe.Config{ID: "probe-1", Target: "ignored-a", Protocol: "noop", Interval: 10}
|
||||||
|
updatedCfg := probe.Config{ID: "probe-1", Target: "ignored-b", Protocol: "noop", Interval: 10}
|
||||||
|
originalTask := &probeTask{config: originalCfg, cancel: make(chan struct{})}
|
||||||
|
pm := &ProbeManager{
|
||||||
|
probes: map[string]*probeTask{
|
||||||
|
originalCfg.ID: originalTask,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
pm.SyncProbes([]probe.Config{updatedCfg})
|
||||||
|
defer pm.Stop()
|
||||||
|
|
||||||
|
restartedTask := pm.probes[updatedCfg.ID]
|
||||||
|
assert.NotSame(t, originalTask, restartedTask)
|
||||||
|
assert.Equal(t, updatedCfg, restartedTask.config)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-originalTask.cancel:
|
||||||
|
default:
|
||||||
|
t.Fatal("expected changed probe task to be cancelled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProbeManagerApplySyncUpsertRunsImmediatelyAndReturnsResult(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
pm := &ProbeManager{
|
||||||
|
probes: make(map[string]*probeTask),
|
||||||
|
httpClient: server.Client(),
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := pm.HandleSyncRequest(probe.SyncRequest{
|
||||||
|
Action: probe.SyncActionUpsert,
|
||||||
|
Config: probe.Config{ID: "probe-1", Target: server.URL, Protocol: "http", Interval: 10},
|
||||||
|
RunNow: true,
|
||||||
|
})
|
||||||
|
defer pm.Stop()
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.GreaterOrEqual(t, resp.Result.AvgResponse, int64(0))
|
||||||
|
assert.Equal(t, 0.0, resp.Result.PacketLoss)
|
||||||
|
assert.Equal(t, 0.0, resp.Result.PacketLoss1h)
|
||||||
|
|
||||||
|
task := pm.probes["probe-1"]
|
||||||
|
require.NotNil(t, task)
|
||||||
|
task.mu.Lock()
|
||||||
|
defer task.mu.Unlock()
|
||||||
|
require.Len(t, task.samples, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProbeManagerUpsertProbeKeepsHistoryWhenOnlyIntervalChanges(t *testing.T) {
|
||||||
|
originalCfg := probe.Config{ID: "probe-1", Target: "1.1.1.1", Protocol: "icmp", Interval: 10}
|
||||||
|
updatedCfg := probe.Config{ID: "probe-1", Target: "1.1.1.1", Protocol: "icmp", Interval: 30}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
existingTask := &probeTask{config: originalCfg, cancel: make(chan struct{})}
|
||||||
|
existingTask.addSampleLocked(probeSample{responseUs: 12, timestamp: now.Add(-50 * time.Minute)})
|
||||||
|
existingTask.addSampleLocked(probeSample{responseUs: 24, timestamp: now.Add(-30 * time.Second)})
|
||||||
|
|
||||||
|
pm := &ProbeManager{
|
||||||
|
probes: map[string]*probeTask{originalCfg.ID: existingTask},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := pm.UpsertProbe(updatedCfg, false)
|
||||||
|
defer pm.Stop()
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Nil(t, result)
|
||||||
|
|
||||||
|
updatedTask := pm.probes[updatedCfg.ID]
|
||||||
|
require.NotNil(t, updatedTask)
|
||||||
|
assert.NotSame(t, existingTask, updatedTask)
|
||||||
|
assert.Equal(t, updatedCfg, updatedTask.config)
|
||||||
|
|
||||||
|
updatedTask.mu.Lock()
|
||||||
|
defer updatedTask.mu.Unlock()
|
||||||
|
require.Len(t, updatedTask.samples, 1)
|
||||||
|
assert.Equal(t, int64(24), updatedTask.samples[0].responseUs)
|
||||||
|
|
||||||
|
agg := updatedTask.aggregateLocked(time.Hour, now)
|
||||||
|
require.True(t, agg.hasData())
|
||||||
|
assert.Equal(t, int64(2), agg.totalCount)
|
||||||
|
assert.Equal(t, int64(2), agg.successCount)
|
||||||
|
assert.Equal(t, int64(18), agg.avgResponse())
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-existingTask.cancel:
|
||||||
|
default:
|
||||||
|
t.Fatal("expected original probe task to be cancelled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProbeManagerApplySyncDeleteRemovesTask(t *testing.T) {
|
||||||
|
config := probe.Config{ID: "probe-1", Target: "1.1.1.1", Protocol: "icmp", Interval: 10}
|
||||||
|
task := &probeTask{config: config, cancel: make(chan struct{})}
|
||||||
|
pm := &ProbeManager{
|
||||||
|
probes: map[string]*probeTask{config.ID: task},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := pm.HandleSyncRequest(probe.SyncRequest{
|
||||||
|
Action: probe.SyncActionDelete,
|
||||||
|
Config: probe.Config{ID: config.ID},
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, exists := pm.probes[config.ID]
|
||||||
|
assert.False(t, exists)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-task.cancel:
|
||||||
|
default:
|
||||||
|
t.Fatal("expected deleted probe task to be cancelled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProbeManagerGetRandomDelay(t *testing.T) {
|
||||||
|
for i := 1000; i < 360_000; i += 1000 {
|
||||||
|
delay := getStagger(int64(i))
|
||||||
|
assert.GreaterOrEqual(t, delay, time.Duration(i/2)*time.Millisecond)
|
||||||
|
assert.LessOrEqual(t, delay, time.Duration(i)*time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProbeHTTP(t *testing.T) {
|
||||||
|
t.Run("success", func(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
responseUs, err := probeHTTP(server.Client(), server.URL)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.GreaterOrEqual(t, responseUs, int64(0))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("server error", func(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Error(w, "boom", http.StatusInternalServerError)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
responseUs, err := probeHTTP(server.Client(), server.URL)
|
||||||
|
assert.Equal(t, int64(-1), responseUs)
|
||||||
|
require.Error(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProbeTCP(t *testing.T) {
|
||||||
|
t.Run("success", func(t *testing.T) {
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer listener.Close()
|
||||||
|
|
||||||
|
accepted := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(accepted)
|
||||||
|
conn, err := listener.Accept()
|
||||||
|
if err == nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
port := uint16(listener.Addr().(*net.TCPAddr).Port)
|
||||||
|
responseUs, err := probeTCP("127.0.0.1", port)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.GreaterOrEqual(t, responseUs, int64(0))
|
||||||
|
<-accepted
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("connection failure", func(t *testing.T) {
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
port := uint16(listener.Addr().(*net.TCPAddr).Port)
|
||||||
|
require.NoError(t, listener.Close())
|
||||||
|
|
||||||
|
responseUs, err := probeTCP("127.0.0.1", port)
|
||||||
|
assert.Equal(t, int64(-1), responseUs)
|
||||||
|
require.Error(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -29,6 +29,9 @@ type ServerOptions struct {
|
|||||||
Keys []gossh.PublicKey // SSH public keys for authentication
|
Keys []gossh.PublicKey // SSH public keys for authentication
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hubVersions caches hub versions by session ID to avoid repeated parsing.
|
||||||
|
var hubVersions map[string]semver.Version
|
||||||
|
|
||||||
// StartServer starts the SSH server with the provided options.
|
// StartServer starts the SSH server with the provided options.
|
||||||
// It configures the server with secure defaults, sets up authentication,
|
// It configures the server with secure defaults, sets up authentication,
|
||||||
// and begins listening for connections. Returns an error if the server
|
// and begins listening for connections. Returns an error if the server
|
||||||
@@ -96,15 +99,24 @@ func (a *Agent) StartServer(opts ServerOptions) error {
|
|||||||
return a.server.Serve(ln)
|
return a.server.Serve(ln)
|
||||||
}
|
}
|
||||||
|
|
||||||
// getHubVersion extracts the hub version from the SSH client version string
|
// getHubVersion retrieves and caches the hub version for a given session.
|
||||||
// for a given session. Returns a zero version if parsing fails.
|
// It extracts the version from the SSH client version string and caches
|
||||||
func (a *Agent) getHubVersion(sessionCtx ssh.Context) semver.Version {
|
// it to avoid repeated parsing. Returns a zero version if parsing fails.
|
||||||
clientVersion := sessionCtx.Value(ssh.ContextKeyClientVersion)
|
func (a *Agent) getHubVersion(sessionId string, sessionCtx ssh.Context) semver.Version {
|
||||||
if versionStr, ok := clientVersion.(string); ok {
|
if hubVersions == nil {
|
||||||
hubVersion, _ := extractHubVersion(versionStr)
|
hubVersions = make(map[string]semver.Version, 1)
|
||||||
|
}
|
||||||
|
hubVersion, ok := hubVersions[sessionId]
|
||||||
|
if ok {
|
||||||
return hubVersion
|
return hubVersion
|
||||||
}
|
}
|
||||||
return semver.Version{}
|
// Extract hub version from SSH client version
|
||||||
|
clientVersion := sessionCtx.Value(ssh.ContextKeyClientVersion)
|
||||||
|
if versionStr, ok := clientVersion.(string); ok {
|
||||||
|
hubVersion, _ = extractHubVersion(versionStr)
|
||||||
|
}
|
||||||
|
hubVersions[sessionId] = hubVersion
|
||||||
|
return hubVersion
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleSession handles an incoming SSH session by gathering system statistics
|
// handleSession handles an incoming SSH session by gathering system statistics
|
||||||
@@ -115,8 +127,9 @@ func (a *Agent) handleSession(s ssh.Session) {
|
|||||||
a.connectionManager.eventChan <- SSHConnect
|
a.connectionManager.eventChan <- SSHConnect
|
||||||
|
|
||||||
sessionCtx := s.Context()
|
sessionCtx := s.Context()
|
||||||
|
sessionID := sessionCtx.SessionID()
|
||||||
|
|
||||||
hubVersion := a.getHubVersion(sessionCtx)
|
hubVersion := a.getHubVersion(sessionID, sessionCtx)
|
||||||
|
|
||||||
// Legacy one-shot behavior for older hubs
|
// Legacy one-shot behavior for older hubs
|
||||||
if hubVersion.LT(beszel.MinVersionAgentResponse) {
|
if hubVersion.LT(beszel.MinVersionAgentResponse) {
|
||||||
|
|||||||
@@ -404,23 +404,27 @@ func TestGetHubVersion(t *testing.T) {
|
|||||||
clientVersion: "SSH-2.0-beszel_0.12.0",
|
clientVersion: "SSH-2.0-beszel_0.12.0",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test first call - should extract version
|
// Test first call - should extract and cache version
|
||||||
version := agent.getHubVersion(mockCtx)
|
version := agent.getHubVersion("test-session-123", mockCtx)
|
||||||
assert.Equal(t, "0.12.0", version.String())
|
assert.Equal(t, "0.12.0", version.String())
|
||||||
|
|
||||||
// Test that version reflects the current client version (no stale caching)
|
// Test second call - should return cached version
|
||||||
mockCtx.clientVersion = "SSH-2.0-beszel_0.11.0"
|
mockCtx.clientVersion = "SSH-2.0-beszel_0.11.0" // Change version but should still return cached
|
||||||
version = agent.getHubVersion(mockCtx)
|
version = agent.getHubVersion("test-session-123", mockCtx)
|
||||||
|
assert.Equal(t, "0.12.0", version.String()) // Should still be cached version
|
||||||
|
|
||||||
|
// Test different session - should extract new version
|
||||||
|
version = agent.getHubVersion("different-session", mockCtx)
|
||||||
assert.Equal(t, "0.11.0", version.String())
|
assert.Equal(t, "0.11.0", version.String())
|
||||||
|
|
||||||
// Test with invalid version string (non-beszel client)
|
// Test with invalid version string (non-beszel client)
|
||||||
mockCtx.clientVersion = "SSH-2.0-OpenSSH_8.0"
|
mockCtx.clientVersion = "SSH-2.0-OpenSSH_8.0"
|
||||||
version = agent.getHubVersion(mockCtx)
|
version = agent.getHubVersion("invalid-session", mockCtx)
|
||||||
assert.Equal(t, "0.0.0", version.String()) // Should be empty version for non-beszel clients
|
assert.Equal(t, "0.0.0", version.String()) // Should be empty version for non-beszel clients
|
||||||
|
|
||||||
// Test with no client version
|
// Test with no client version
|
||||||
mockCtx.clientVersion = ""
|
mockCtx.clientVersion = ""
|
||||||
version = agent.getHubVersion(mockCtx)
|
version = agent.getHubVersion("no-version-session", mockCtx)
|
||||||
assert.True(t, version.EQ(semver.Version{})) // Should be empty version
|
assert.True(t, version.EQ(semver.Version{})) // Should be empty version
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -497,6 +501,9 @@ func TestWriteToSessionEncoding(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// Reset the global hubVersions map to ensure clean state for each test
|
||||||
|
hubVersions = nil
|
||||||
|
|
||||||
agent, err := NewAgent("")
|
agent, err := NewAgent("")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
@@ -578,28 +585,39 @@ func createTestCombinedData() *system.CombinedData {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestGetHubVersionConcurrent guards against a regression of the
|
func TestHubVersionCaching(t *testing.T) {
|
||||||
// "concurrent map writes" panic previously caused by a shared, unsynchronized
|
// Reset the global hubVersions map to ensure clean state
|
||||||
// hubVersions cache (see https://github.com/henrygd/beszel/issues/2128).
|
hubVersions = nil
|
||||||
// getHubVersion no longer shares mutable state between sessions, so calling
|
|
||||||
// it concurrently from many goroutines must be safe under `go test -race`.
|
|
||||||
func TestGetHubVersionConcurrent(t *testing.T) {
|
|
||||||
agent, err := NewAgent("")
|
agent, err := NewAgent("")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
const goroutines = 50
|
ctx1 := &mockSSHContext{
|
||||||
var wg sync.WaitGroup
|
sessionID: "session1",
|
||||||
wg.Add(goroutines)
|
|
||||||
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",
|
clientVersion: "SSH-2.0-beszel_0.12.0",
|
||||||
}
|
}
|
||||||
version := agent.getHubVersion(ctx)
|
ctx2 := &mockSSHContext{
|
||||||
assert.Equal(t, "0.12.0", version.String())
|
sessionID: "session2",
|
||||||
}(i)
|
clientVersion: "SSH-2.0-beszel_0.11.0",
|
||||||
}
|
}
|
||||||
wg.Wait()
|
|
||||||
|
// 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())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,11 +55,6 @@ type DeviceInfo struct {
|
|||||||
typeVerified bool
|
typeVerified bool
|
||||||
// parserType holds the parser type (nvme, sat, scsi) that last succeeded.
|
// parserType holds the parser type (nvme, sat, scsi) that last succeeded.
|
||||||
parserType string
|
parserType string
|
||||||
// explicitType reports whether Type came from an explicit ":type" hint in
|
|
||||||
// SMART_DEVICES. Such a type is a deliberate user override and must always be
|
|
||||||
// passed to smartctl via -d, even for scsi/ata where a scan-detected type is
|
|
||||||
// otherwise left off (see smartctlArgs and issue #1345).
|
|
||||||
explicitType bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// deviceKey is a composite key for a device, used to identify a device uniquely.
|
// deviceKey is a composite key for a device, used to identify a device uniquely.
|
||||||
@@ -70,9 +65,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 +87,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
|
||||||
@@ -259,7 +253,6 @@ func (sm *SmartManager) parseConfiguredDevices(config string) ([]*DeviceInfo, er
|
|||||||
devices = append(devices, &DeviceInfo{
|
devices = append(devices, &DeviceInfo{
|
||||||
Name: name,
|
Name: name,
|
||||||
Type: devType,
|
Type: devType,
|
||||||
explicitType: devType != "",
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -375,15 +368,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 +479,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 +558,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 +583,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 +663,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 +676,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 +732,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 +836,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 {
|
||||||
@@ -912,9 +877,6 @@ func (sm *SmartManager) parseSmartForSata(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
|
|
||||||
}
|
|
||||||
|
|
||||||
// get values from ata_device_statistics if necessary
|
// get values from ata_device_statistics if necessary
|
||||||
var ataDeviceStats smart.AtaDeviceStatistics
|
var ataDeviceStats smart.AtaDeviceStatistics
|
||||||
@@ -988,7 +950,7 @@ func findAtaDeviceStatisticsValue(data *smart.SmartInfoForSata, ataDeviceStats *
|
|||||||
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 +985,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})
|
||||||
@@ -1123,11 +1082,9 @@ func (sm *SmartManager) lookupDarwinNvmeCapacity(serial string) uint64 {
|
|||||||
return sm.darwinNvmeCapacity[serial]
|
return sm.darwinNvmeCapacity[serial]
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseSmartForNvme parses the output of smartctl --all -j /dev/nvmeX and updates the SmartDataMap.
|
// parseSmartForNvme parses the output of smartctl --all -j /dev/nvmeX and updates the SmartDataMap
|
||||||
// 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 {
|
||||||
@@ -1171,9 +1128,6 @@ func (sm *SmartManager) parseSmartForNvme(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
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
@@ -147,7 +147,7 @@ func TestParseSmartForSataAtaDeviceStatistics(t *testing.T) {
|
|||||||
}`)
|
}`)
|
||||||
|
|
||||||
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
|
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
|
||||||
hasData, exitStatus := sm.parseSmartForSata(jsonPayload, "")
|
hasData, exitStatus := sm.parseSmartForSata(jsonPayload)
|
||||||
require.True(t, hasData)
|
require.True(t, hasData)
|
||||||
assert.Equal(t, 0, exitStatus)
|
assert.Equal(t, 0, exitStatus)
|
||||||
|
|
||||||
@@ -184,7 +184,7 @@ func TestParseSmartForSataNegativeDeviceStatistics(t *testing.T) {
|
|||||||
}`)
|
}`)
|
||||||
|
|
||||||
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
|
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
|
||||||
hasData, exitStatus := sm.parseSmartForSata(jsonPayload, "")
|
hasData, exitStatus := sm.parseSmartForSata(jsonPayload)
|
||||||
require.True(t, hasData)
|
require.True(t, hasData)
|
||||||
assert.Equal(t, 0, exitStatus)
|
assert.Equal(t, 0, exitStatus)
|
||||||
|
|
||||||
@@ -223,7 +223,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 +245,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 +268,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 +392,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 +534,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 +678,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) {
|
||||||
@@ -1384,7 +1225,7 @@ func TestParseSmartForNvmeAppleSSD(t *testing.T) {
|
|||||||
darwinNvmeProvider: fakeProvider,
|
darwinNvmeProvider: fakeProvider,
|
||||||
}
|
}
|
||||||
|
|
||||||
hasData, _ := sm.parseSmartForNvme(data, "")
|
hasData, _ := sm.parseSmartForNvme(data)
|
||||||
require.True(t, hasData)
|
require.True(t, hasData)
|
||||||
|
|
||||||
deviceData, ok := sm.SmartDataMap["0ba0147940253c15"]
|
deviceData, ok := sm.SmartDataMap["0ba0147940253c15"]
|
||||||
@@ -1396,7 +1237,7 @@ func TestParseSmartForNvmeAppleSSD(t *testing.T) {
|
|||||||
assert.Equal(t, 1, providerCalls, "system_profiler should be called once")
|
assert.Equal(t, 1, providerCalls, "system_profiler should be called once")
|
||||||
|
|
||||||
// Second parse: provider should NOT be called again (cache hit)
|
// Second parse: provider should NOT be called again (cache hit)
|
||||||
_, _ = sm.parseSmartForNvme(data, "")
|
_, _ = sm.parseSmartForNvme(data)
|
||||||
assert.Equal(t, 1, providerCalls, "system_profiler should not be called again after caching")
|
assert.Equal(t, 1, providerCalls, "system_profiler should not be called again after caching")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -132,14 +132,9 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
|
|||||||
var systemStats system.Stats
|
var systemStats system.Stats
|
||||||
|
|
||||||
// battery
|
// battery
|
||||||
if batteries, err := battery.GetBatteryStats(); err == nil {
|
if batteryPercent, batteryState, err := battery.GetBatteryStats(); err == nil {
|
||||||
systemStats.Batteries = make(map[string]uint8, len(batteries))
|
systemStats.Battery[0] = batteryPercent
|
||||||
for _, device := range batteries {
|
systemStats.Battery[1] = batteryState
|
||||||
systemStats.Batteries[device.Name] = device.Percent
|
|
||||||
}
|
|
||||||
if primary, ok := battery.Primary(batteries); ok {
|
|
||||||
systemStats.Battery = [2]uint8{primary.Percent, primary.State}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// cpu metrics
|
// cpu metrics
|
||||||
@@ -174,11 +169,21 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
|
|||||||
|
|
||||||
// memory
|
// memory
|
||||||
if v, err := mem.VirtualMemory(); err == nil {
|
if v, err := mem.VirtualMemory(); err == nil {
|
||||||
used, cacheBuff, swapUsed := calculateHostMemoryUsage(v, a.memCalc == "htop")
|
|
||||||
// swap
|
// swap
|
||||||
systemStats.Swap = utils.BytesToGigabytes(v.SwapTotal)
|
systemStats.Swap = utils.BytesToGigabytes(v.SwapTotal)
|
||||||
systemStats.SwapUsed = utils.BytesToGigabytes(swapUsed)
|
systemStats.SwapUsed = utils.BytesToGigabytes(v.SwapTotal - v.SwapFree - v.SwapCached)
|
||||||
v.Used = used
|
// cache + buffers value for default mem calculation
|
||||||
|
// note: gopsutil automatically adds SReclaimable to v.Cached
|
||||||
|
cacheBuff := v.Cached + v.Buffers - v.Shared
|
||||||
|
if cacheBuff <= 0 {
|
||||||
|
cacheBuff = max(v.Total-v.Free-v.Used, 0)
|
||||||
|
}
|
||||||
|
// htop memory calculation overrides (likely outdated as of mid 2025)
|
||||||
|
if a.memCalc == "htop" {
|
||||||
|
// cacheBuff = v.Cached + v.Buffers - v.Shared
|
||||||
|
v.Used = v.Total - (v.Free + cacheBuff)
|
||||||
|
v.UsedPercent = float64(v.Used) / float64(v.Total) * 100.0
|
||||||
|
}
|
||||||
// if a.memCalc == "legacy" {
|
// if a.memCalc == "legacy" {
|
||||||
// v.Used = v.Total - v.Free - v.Buffers - v.Cached
|
// v.Used = v.Total - v.Free - v.Buffers - v.Cached
|
||||||
// cacheBuff = v.Total - v.Free - v.Used
|
// cacheBuff = v.Total - v.Free - v.Used
|
||||||
@@ -188,14 +193,10 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
|
|||||||
if a.zfs {
|
if a.zfs {
|
||||||
if arcSize, _ := zfs.ARCSize(); arcSize > 0 && arcSize < v.Used {
|
if arcSize, _ := zfs.ARCSize(); arcSize > 0 && arcSize < v.Used {
|
||||||
v.Used = v.Used - arcSize
|
v.Used = v.Used - arcSize
|
||||||
|
v.UsedPercent = float64(v.Used) / float64(v.Total) * 100.0
|
||||||
systemStats.MemZfsArc = utils.BytesToGigabytes(arcSize)
|
systemStats.MemZfsArc = utils.BytesToGigabytes(arcSize)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if v.Total > 0 {
|
|
||||||
v.UsedPercent = float64(v.Used) / float64(v.Total) * 100.0
|
|
||||||
} else {
|
|
||||||
v.UsedPercent = 0
|
|
||||||
}
|
|
||||||
systemStats.Mem = utils.BytesToGigabytes(v.Total)
|
systemStats.Mem = utils.BytesToGigabytes(v.Total)
|
||||||
systemStats.MemBuffCache = utils.BytesToGigabytes(cacheBuff)
|
systemStats.MemBuffCache = utils.BytesToGigabytes(cacheBuff)
|
||||||
systemStats.MemUsed = utils.BytesToGigabytes(v.Used)
|
systemStats.MemUsed = utils.BytesToGigabytes(v.Used)
|
||||||
@@ -215,9 +216,6 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
|
|||||||
// TODO: maybe refactor to methods on systemStats
|
// TODO: maybe refactor to methods on systemStats
|
||||||
a.updateTemperatures(&systemStats)
|
a.updateTemperatures(&systemStats)
|
||||||
|
|
||||||
// fan speeds (Linux-only; sysfs hwmon)
|
|
||||||
a.updateFans(&systemStats)
|
|
||||||
|
|
||||||
// GPU data
|
// GPU data
|
||||||
if a.gpuManager != nil {
|
if a.gpuManager != nil {
|
||||||
// reset high gpu percent
|
// reset high gpu percent
|
||||||
@@ -265,38 +263,6 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
|
|||||||
return systemStats
|
return systemStats
|
||||||
}
|
}
|
||||||
|
|
||||||
// calculateHostMemoryUsage derives counters defensively because /proc/meminfo may
|
|
||||||
// change while gopsutil reads it. Invalid unsigned subtractions saturate at zero.
|
|
||||||
func calculateHostMemoryUsage(v *mem.VirtualMemoryStat, htop bool) (used, cacheBuff, swapUsed uint64) {
|
|
||||||
used = v.Used
|
|
||||||
if used > v.Total {
|
|
||||||
used = saturatingSub(v.Total, v.Available)
|
|
||||||
}
|
|
||||||
|
|
||||||
// gopsutil automatically adds SReclaimable to Cached.
|
|
||||||
cacheBuff = min(v.Cached, v.Total)
|
|
||||||
cacheBuff += min(v.Buffers, v.Total-cacheBuff)
|
|
||||||
cacheBuff = saturatingSub(cacheBuff, min(v.Shared, v.Total))
|
|
||||||
if v.Cached == 0 && v.Buffers == 0 {
|
|
||||||
cacheBuff = saturatingSub(v.Total, v.Free, used)
|
|
||||||
}
|
|
||||||
if htop {
|
|
||||||
used = saturatingSub(v.Total, v.Free, cacheBuff)
|
|
||||||
}
|
|
||||||
return used, cacheBuff, saturatingSub(v.SwapTotal, v.SwapFree, v.SwapCached)
|
|
||||||
}
|
|
||||||
|
|
||||||
// saturatingSub subtracts each value, returning zero on underflow.
|
|
||||||
func saturatingSub(value uint64, subtrahends ...uint64) uint64 {
|
|
||||||
for _, subtrahend := range subtrahends {
|
|
||||||
if subtrahend > value {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
value -= subtrahend
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
// getOsPrettyName attempts to get the pretty OS name from /etc/os-release on Linux systems
|
// getOsPrettyName attempts to get the pretty OS name from /etc/os-release on Linux systems
|
||||||
func getOsPrettyName() (string, error) {
|
func getOsPrettyName() (string, error) {
|
||||||
file, err := os.Open("/etc/os-release")
|
file, err := os.Open("/etc/os-release")
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
|
|
||||||
"github.com/henrygd/beszel/internal/common"
|
"github.com/henrygd/beszel/internal/common"
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
"github.com/shirou/gopsutil/v4/mem"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
@@ -34,59 +33,6 @@ func TestGatherStatsDoesNotAttachDetailsToCachedRequests(t *testing.T) {
|
|||||||
assert.Nil(t, secondResponse.Details)
|
assert.Nil(t, secondResponse.Details)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCalculateHostMemoryUsage(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
memory mem.VirtualMemoryStat
|
|
||||||
htop bool
|
|
||||||
used, cacheBuff, swapUsed uint64
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "normal",
|
|
||||||
memory: mem.VirtualMemoryStat{Total: 100, Available: 40, Used: 60, Free: 20, Cached: 25, Buffers: 10, Shared: 5, SwapTotal: 20, SwapFree: 8, SwapCached: 2},
|
|
||||||
used: 60,
|
|
||||||
cacheBuff: 30,
|
|
||||||
swapUsed: 10,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "inconsistent counters saturate",
|
|
||||||
memory: mem.VirtualMemoryStat{Total: 100, Available: 110, Used: ^uint64(0) - 9, Free: 90, Cached: 5, Buffers: 10, Shared: 20, SwapTotal: 10, SwapFree: 9, SwapCached: 2},
|
|
||||||
used: 0,
|
|
||||||
cacheBuff: 0,
|
|
||||||
swapUsed: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "htop subtraction saturates",
|
|
||||||
memory: mem.VirtualMemoryStat{Total: 100, Available: 20, Used: 80, Free: 90, Cached: 20, Buffers: 5, SwapTotal: 30, SwapFree: 10, SwapCached: 5},
|
|
||||||
htop: true,
|
|
||||||
used: 0,
|
|
||||||
cacheBuff: 25,
|
|
||||||
swapUsed: 15,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "zero cache from shared cancellation does not fall back",
|
|
||||||
memory: mem.VirtualMemoryStat{Total: 100, Used: 60, Free: 10, Cached: 20, Buffers: 10, Shared: 30},
|
|
||||||
used: 60,
|
|
||||||
cacheBuff: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "absent cache counters use fallback",
|
|
||||||
memory: mem.VirtualMemoryStat{Total: 100, Used: 60, Free: 10},
|
|
||||||
used: 60,
|
|
||||||
cacheBuff: 30,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
used, cacheBuff, swapUsed := calculateHostMemoryUsage(&tt.memory, tt.htop)
|
|
||||||
assert.Equal(t, tt.used, used)
|
|
||||||
assert.Equal(t, tt.cacheBuff, cacheBuff)
|
|
||||||
assert.Equal(t, tt.swapUsed, swapUsed)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpdateSystemDetailsMarksDetailsDirty(t *testing.T) {
|
func TestUpdateSystemDetailsMarksDetailsDirty(t *testing.T) {
|
||||||
agent := &Agent{}
|
agent := &Agent{}
|
||||||
|
|
||||||
|
|||||||
@@ -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.7"
|
||||||
// AppName is the name of the application.
|
// AppName is the name of the application.
|
||||||
AppName = "beszel"
|
AppName = "beszel"
|
||||||
)
|
)
|
||||||
|
|||||||
61
go.mod
61
go.mod
@@ -1,27 +1,27 @@
|
|||||||
module github.com/henrygd/beszel
|
module github.com/henrygd/beszel
|
||||||
|
|
||||||
go 1.26.6
|
go 1.26.1
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/blang/semver v3.5.1+incompatible
|
github.com/blang/semver v3.5.1+incompatible
|
||||||
github.com/coreos/go-systemd/v22 v22.7.0
|
github.com/coreos/go-systemd/v22 v22.7.0
|
||||||
github.com/ebitengine/purego v0.10.2
|
github.com/ebitengine/purego v0.10.0
|
||||||
github.com/fxamacker/cbor/v2 v2.9.2
|
github.com/fxamacker/cbor/v2 v2.9.0
|
||||||
github.com/gliderlabs/ssh v0.3.8
|
github.com/gliderlabs/ssh v0.3.8
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/lxzan/gws v1.10.1
|
github.com/lxzan/gws v1.9.1
|
||||||
github.com/nicholas-fedor/shoutrrr v0.17.0
|
github.com/nicholas-fedor/shoutrrr v0.14.3
|
||||||
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.8
|
||||||
github.com/shirou/gopsutil/v4 v4.26.7
|
github.com/shirou/gopsutil/v4 v4.26.3
|
||||||
github.com/spf13/cast v1.10.0
|
github.com/spf13/cast v1.10.0
|
||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
github.com/spf13/pflag v1.0.10
|
github.com/spf13/pflag v1.0.10
|
||||||
github.com/stretchr/testify v1.12.0
|
github.com/stretchr/testify v1.11.1
|
||||||
golang.org/x/crypto v0.55.0
|
golang.org/x/crypto v0.49.0
|
||||||
golang.org/x/exp v0.0.0-20260813180055-c1d0aacb2297
|
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90
|
||||||
golang.org/x/net v0.58.0
|
golang.org/x/net v0.52.0
|
||||||
golang.org/x/sys v0.47.0
|
golang.org/x/sys v0.42.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
howett.net/plist v1.0.1
|
howett.net/plist v1.0.1
|
||||||
)
|
)
|
||||||
@@ -29,39 +29,40 @@ require (
|
|||||||
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/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/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/eclipse/paho.golang v0.23.0 // indirect
|
||||||
github.com/fatih/color v1.19.0 // indirect
|
github.com/fatih/color v1.19.0 // indirect
|
||||||
github.com/fsnotify/fsnotify v1.10.1 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.15 // indirect
|
github.com/ganigeorgiev/fexpr v0.5.0 // indirect
|
||||||
github.com/ganigeorgiev/fexpr v0.6.0 // indirect
|
|
||||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||||
|
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect
|
||||||
github.com/go-sql-driver/mysql v1.9.1 // indirect
|
github.com/go-sql-driver/mysql v1.9.1 // indirect
|
||||||
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||||
github.com/gorilla/websocket v1.5.3 // indirect
|
github.com/gorilla/websocket v1.5.3 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/klauspost/compress v1.19.2 // indirect
|
github.com/klauspost/compress v1.18.5 // indirect
|
||||||
github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 // indirect
|
github.com/lufia/plan9stats v0.0.0-20260324052639-156f7da3f749 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.15 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
github.com/mattn/go-isatty v0.0.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.38.0 // indirect
|
||||||
golang.org/x/oauth2 v0.36.0 // indirect
|
golang.org/x/oauth2 v0.36.0 // indirect
|
||||||
golang.org/x/sync v0.22.0 // indirect
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
golang.org/x/term v0.45.0 // indirect
|
golang.org/x/term v0.41.0 // indirect
|
||||||
golang.org/x/text v0.41.0 // indirect
|
golang.org/x/text v0.35.0 // indirect
|
||||||
modernc.org/libc v1.74.1 // indirect
|
modernc.org/libc v1.70.0 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.12.0 // indirect
|
modernc.org/memory v1.11.0 // indirect
|
||||||
modernc.org/sqlite v1.55.0 // indirect
|
modernc.org/sqlite v1.48.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
170
go.sum
170
go.sum
@@ -1,7 +1,7 @@
|
|||||||
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/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=
|
||||||
@@ -13,35 +13,37 @@ github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj
|
|||||||
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/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.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
|
||||||
github.com/ebitengine/purego v0.10.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||||
github.com/eclipse/paho.golang v0.23.0 h1:KHgl2wz6EJo7cMBmkuhpt7C576vP+kpPv7jjvSyR6Mk=
|
github.com/eclipse/paho.golang v0.23.0 h1:KHgl2wz6EJo7cMBmkuhpt7C576vP+kpPv7jjvSyR6Mk=
|
||||||
github.com/eclipse/paho.golang v0.23.0/go.mod h1:nQRhTkoZv8EAiNs5UU0/WdQIx2NrnWUpL9nsGJTQN04=
|
github.com/eclipse/paho.golang v0.23.0/go.mod h1:nQRhTkoZv8EAiNs5UU0/WdQIx2NrnWUpL9nsGJTQN04=
|
||||||
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
|
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
|
||||||
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
|
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
|
||||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||||
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||||
github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
|
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||||
github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI=
|
github.com/ganigeorgiev/fexpr v0.5.0 h1:XA9JxtTE/Xm+g/JFI6RfZEHSiQlk+1glLvRK1Lpv/Tk=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ=
|
github.com/ganigeorgiev/fexpr v0.5.0/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
|
||||||
github.com/ganigeorgiev/fexpr v0.6.0 h1:Fza3O/QMBKEudUvxV862qe6GjxM60GJjjKytdp+VQus=
|
|
||||||
github.com/ganigeorgiev/fexpr v0.6.0/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
|
|
||||||
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
||||||
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
||||||
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||||
|
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 h1:byhDUpfEwjsVQb1vBunvIjh2BHQ9ead57VkAEY4V+Es=
|
||||||
|
github.com/go-ozzo/ozzo-validation/v4 v4.3.0/go.mod h1:2NKgrcHl3z6cJs+3Oo940FPRiTzuqKbvfrL2RxCj6Ew=
|
||||||
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
|
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
|
||||||
github.com/go-sql-driver/mysql v1.9.1 h1:FrjNGn/BsJQjVRuSa8CBrM5BWA9BWoXXat3KrtSb/iI=
|
github.com/go-sql-driver/mysql v1.9.1 h1:FrjNGn/BsJQjVRuSa8CBrM5BWA9BWoXXat3KrtSb/iI=
|
||||||
github.com/go-sql-driver/mysql v1.9.1/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
github.com/go-sql-driver/mysql v1.9.1/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||||
@@ -54,8 +56,8 @@ github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArs
|
|||||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc h1:VBbFa1lDYWEeV5FZKUiYKYT0VxCp9twUmmaq9eb8sXw=
|
||||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
@@ -64,47 +66,47 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs
|
|||||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
github.com/jarcoal/httpmock v1.4.2 h1:dKwiP/9zITCPfBLsDn3kchbSOu16JrnxtVEmL0fPRcI=
|
github.com/jarcoal/httpmock v1.4.1 h1:0Ju+VCFuARfFlhVXFc2HxlcQkfB+Xq12/EotHko+x2A=
|
||||||
github.com/jarcoal/httpmock v1.4.2/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0=
|
github.com/jarcoal/httpmock v1.4.1/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0=
|
||||||
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||||
github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8=
|
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||||
github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 h1:eveIIGn4BGM3qknO74omf6HYr30/exH+eVUTuAgwjZ0=
|
github.com/lufia/plan9stats v0.0.0-20260324052639-156f7da3f749 h1:Qj3hTcdWH8uMZDI41HNuTuJN525C7NBrbtH5kSO6fPk=
|
||||||
github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
github.com/lufia/plan9stats v0.0.0-20260324052639-156f7da3f749/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||||
github.com/lxzan/gws v1.10.1 h1:1xG+tDOV0lgDeVPf0wNT74u3cn0K3LpcavRrTPTrMwQ=
|
github.com/lxzan/gws v1.9.1 h1:4lbIp4cW0hOLP3ejFHR/uWRy741AURx7oKkNNi2OT9o=
|
||||||
github.com/lxzan/gws v1.10.1/go.mod h1:gXHSCPmTGryWJ4icuqy8Yho32E4YIMHH0fkDRYJRbdc=
|
github.com/lxzan/gws v1.9.1/go.mod h1:gXHSCPmTGryWJ4icuqy8Yho32E4YIMHH0fkDRYJRbdc=
|
||||||
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
|
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
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.14.3 h1:aBX2iw9a7jl5wfHd3bi9LnS5ucoYIy6KcLH9XVF+gig=
|
||||||
github.com/nicholas-fedor/shoutrrr v0.17.0/go.mod h1:s4ldyLs6uwBy9lIjYrY+8lyTqJtPvZSrILw0CyMLock=
|
github.com/nicholas-fedor/shoutrrr v0.14.3/go.mod h1:U7IywBkLpBV7rgn8iLbQ9/LklJG1gm24bFv5cXXsDKs=
|
||||||
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/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.8 h1:gCNqoesZ44saYOD3J7edhi5nDwUWKyQG7boM/kVwz2c=
|
||||||
github.com/pocketbase/ozzo-validation/v4 v4.3.0/go.mod h1:6XNjSTw/Jb2F8LOkKO3oyzIWExbrGiYoS4uVxVwz90g=
|
github.com/pocketbase/pocketbase v0.36.8/go.mod h1:OY4WaXbP0WnF/EXoBbboWJK+ZSZ1A85tiA0sjrTKxTA=
|
||||||
github.com/pocketbase/pocketbase v0.39.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.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc=
|
||||||
github.com/shirou/gopsutil/v4 v4.26.7/go.mod h1:5O9FjBiXoTDFatIWjZZosqj4pV0DRtLx598xGbBehzM=
|
github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
|
||||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||||
@@ -116,53 +118,53 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
|
|||||||
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
|
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/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/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
|
||||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||||
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||||
golang.org/x/exp v0.0.0-20260813180055-c1d0aacb2297 h1:YXnL44eJ77R+ji4/ooy8UsXIhz+lbi2Qgdlc8iRN0gY=
|
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA=
|
||||||
golang.org/x/exp v0.0.0-20260813180055-c1d0aacb2297/go.mod h1:Mkmymgv+uMpSQ/XxJ/7GpdrdYoqm3u72jEbpCLiJmNk=
|
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ=
|
||||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||||
golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
|
golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE=
|
||||||
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
|
golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
|
||||||
golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs=
|
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||||
golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE=
|
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
||||||
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.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||||
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||||
|
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||||
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.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||||
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.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||||
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
|
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||||
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
@@ -173,30 +175,30 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM=
|
howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM=
|
||||||
howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
|
howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
|
||||||
modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc=
|
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||||
modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
|
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
|
||||||
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
|
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
|
||||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
|
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||||
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ=
|
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
|
||||||
modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os=
|
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
|
||||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
modernc.org/memory v1.12.0 h1:twkmYNkGXCvtYWzoux02jtK6eovjZbdI0uHFUYp6kuU=
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
modernc.org/memory v1.12.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
modernc.org/sqlite v1.55.0 h1:hIFh0MCH0rGinQ/4KYb5/UbCkRkb+UP+OkLCVWa5MTM=
|
modernc.org/sqlite v1.48.0 h1:ElZyLop3Q2mHYk5IFPPXADejZrlHu7APbpB0sF78bq4=
|
||||||
modernc.org/sqlite v1.55.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=
|
modernc.org/sqlite v1.48.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
|
||||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
|||||||
@@ -55,7 +55,6 @@ type SystemAlertStats struct {
|
|||||||
Temperatures map[string]float32 `json:"t"`
|
Temperatures map[string]float32 `json:"t"`
|
||||||
LoadAvg [3]float64 `json:"la"`
|
LoadAvg [3]float64 `json:"la"`
|
||||||
Battery [2]uint8 `json:"bat"`
|
Battery [2]uint8 `json:"bat"`
|
||||||
Batteries map[string]uint8 `json:"bats"`
|
|
||||||
ExtraFs map[string]SystemAlertFsStats `json:"efs"`
|
ExtraFs map[string]SystemAlertFsStats `json:"efs"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -29,9 +29,8 @@ func setStatusAlertEmail(t *testing.T, hub core.App, userID, email string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestStatusAlerts(t *testing.T) {
|
func TestStatusAlerts(t *testing.T) {
|
||||||
hub, user := beszelTests.GetHubWithUser(t)
|
|
||||||
|
|
||||||
synctest.Test(t, func(t *testing.T) {
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
hub, user := beszelTests.GetHubWithUser(t)
|
||||||
defer hub.Cleanup()
|
defer hub.Cleanup()
|
||||||
|
|
||||||
systems, err := beszelTests.CreateSystems(hub, 4, user.Id, "paused")
|
systems, err := beszelTests.CreateSystems(hub, 4, user.Id, "paused")
|
||||||
@@ -235,9 +234,8 @@ func TestHandleStatusAlertsDoesNotSendRecoveryWhileDownIsOnlyPending(t *testing.
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestStatusAlertTimerCancellationPreventsBoundaryDelivery(t *testing.T) {
|
func TestStatusAlertTimerCancellationPreventsBoundaryDelivery(t *testing.T) {
|
||||||
hub, user := beszelTests.GetHubWithUser(t)
|
|
||||||
|
|
||||||
synctest.Test(t, func(t *testing.T) {
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
hub, user := beszelTests.GetHubWithUser(t)
|
||||||
defer hub.Cleanup()
|
defer hub.Cleanup()
|
||||||
|
|
||||||
userSettings, err := hub.FindFirstRecordByFilter("user_settings", "user={:user}", map[string]any{"user": user.Id})
|
userSettings, err := hub.FindFirstRecordByFilter("user_settings", "user={:user}", map[string]any{"user": user.Id})
|
||||||
@@ -338,9 +336,8 @@ func TestStatusAlertDownFiresAfterDelayExpires(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestStatusAlertMultipleUsersRespectDifferentMinutes(t *testing.T) {
|
func TestStatusAlertMultipleUsersRespectDifferentMinutes(t *testing.T) {
|
||||||
hub, user1 := beszelTests.GetHubWithUser(t)
|
|
||||||
|
|
||||||
synctest.Test(t, func(t *testing.T) {
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
hub, user1 := beszelTests.GetHubWithUser(t)
|
||||||
defer hub.Cleanup()
|
defer hub.Cleanup()
|
||||||
|
|
||||||
setStatusAlertEmail(t, hub, user1.Id, "user1@example.com")
|
setStatusAlertEmail(t, hub, user1.Id, "user1@example.com")
|
||||||
@@ -426,9 +423,8 @@ func TestStatusAlertMultipleUsersRespectDifferentMinutes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestStatusAlertMultipleUsersRecoveryBetweenMinutesOnlyAlertsEarlierUser(t *testing.T) {
|
func TestStatusAlertMultipleUsersRecoveryBetweenMinutesOnlyAlertsEarlierUser(t *testing.T) {
|
||||||
hub, user1 := beszelTests.GetHubWithUser(t)
|
|
||||||
|
|
||||||
synctest.Test(t, func(t *testing.T) {
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
hub, user1 := beszelTests.GetHubWithUser(t)
|
||||||
defer hub.Cleanup()
|
defer hub.Cleanup()
|
||||||
|
|
||||||
setStatusAlertEmail(t, hub, user1.Id, "user1@example.com")
|
setStatusAlertEmail(t, hub, user1.Id, "user1@example.com")
|
||||||
@@ -820,9 +816,8 @@ func TestResolveStatusAlerts(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertsHistoryStatus(t *testing.T) {
|
func TestAlertsHistoryStatus(t *testing.T) {
|
||||||
hub, user := beszelTests.GetHubWithUser(t)
|
|
||||||
|
|
||||||
synctest.Test(t, func(t *testing.T) {
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
hub, user := beszelTests.GetHubWithUser(t)
|
||||||
defer hub.Cleanup()
|
defer hub.Cleanup()
|
||||||
|
|
||||||
// Create a system
|
// Create a system
|
||||||
@@ -887,9 +882,8 @@ func TestAlertsHistoryStatus(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestStatusAlertClearedBeforeSend(t *testing.T) {
|
func TestStatusAlertClearedBeforeSend(t *testing.T) {
|
||||||
hub, user := beszelTests.GetHubWithUser(t)
|
|
||||||
|
|
||||||
synctest.Test(t, func(t *testing.T) {
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
hub, user := beszelTests.GetHubWithUser(t)
|
||||||
defer hub.Cleanup()
|
defer hub.Cleanup()
|
||||||
|
|
||||||
// Create a system
|
// Create a system
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
|
|||||||
case "GPU":
|
case "GPU":
|
||||||
val = data.Info.GpuPct
|
val = data.Info.GpuPct
|
||||||
case "Battery":
|
case "Battery":
|
||||||
if !hasRepresentativeBattery(data.Stats.Battery, data.Stats.Batteries) {
|
if data.Stats.Battery[0] == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
val = float64(data.Stats.Battery[0])
|
val = float64(data.Stats.Battery[0])
|
||||||
@@ -167,7 +167,6 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
|
|||||||
stat := systemStats[i]
|
stat := systemStats[i]
|
||||||
// subtract 10 seconds to give a small time buffer
|
// subtract 10 seconds to give a small time buffer
|
||||||
systemStatsCreation := stat.Created.Time().Add(-time.Second * 10)
|
systemStatsCreation := stat.Created.Time().Add(-time.Second * 10)
|
||||||
stats = SystemAlertStats{}
|
|
||||||
if err := json.Unmarshal(stat.Stats, &stats); err != nil {
|
if err := json.Unmarshal(stat.Stats, &stats); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -236,9 +235,6 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
|
|||||||
}
|
}
|
||||||
alert.val += maxUsage
|
alert.val += maxUsage
|
||||||
case "Battery":
|
case "Battery":
|
||||||
if !hasRepresentativeBattery(stats.Battery, stats.Batteries) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
alert.val += float64(stats.Battery[0])
|
alert.val += float64(stats.Battery[0])
|
||||||
default:
|
default:
|
||||||
continue
|
continue
|
||||||
@@ -301,10 +297,6 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func hasRepresentativeBattery(legacy [2]uint8, batteries map[string]uint8) bool {
|
|
||||||
return legacy != [2]uint8{} || len(batteries) > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func (am *AlertManager) sendSystemAlert(alert SystemAlertData) {
|
func (am *AlertManager) sendSystemAlert(alert SystemAlertData) {
|
||||||
// log.Printf("Sending alert %s: val %f | count %d | threshold %f\n", alert.name, alert.val, alert.count, alert.threshold)
|
// log.Printf("Sending alert %s: val %f | count %d | threshold %f\n", alert.name, alert.val, alert.count, alert.threshold)
|
||||||
systemName := alert.systemRecord.GetString("name")
|
systemName := alert.systemRecord.GetString("name")
|
||||||
|
|||||||
@@ -95,10 +95,11 @@ func waitForSystemAlert(d time.Duration) {
|
|||||||
|
|
||||||
func testOneMinuteSystemAlert[T any](t *testing.T, alertName string, threshold float64, setValue systemAlertValueSetter[T], triggerValue, resolveValue T) {
|
func testOneMinuteSystemAlert[T any](t *testing.T, alertName string, threshold float64, setValue systemAlertValueSetter[T], triggerValue, resolveValue T) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
fixture := newSystemAlertTestFixture(t, alertName, 1, threshold)
|
fixture := newSystemAlertTestFixture(t, alertName, 1, threshold)
|
||||||
defer fixture.cleanup()
|
defer fixture.cleanup()
|
||||||
|
|
||||||
synctest.Test(t, func(t *testing.T) {
|
|
||||||
submitValue(fixture, t, triggerValue, setValue)
|
submitValue(fixture, t, triggerValue, setValue)
|
||||||
waitForSystemAlert(time.Second)
|
waitForSystemAlert(time.Second)
|
||||||
|
|
||||||
@@ -117,10 +118,11 @@ func testOneMinuteSystemAlert[T any](t *testing.T, alertName string, threshold f
|
|||||||
|
|
||||||
func testMultiMinuteSystemAlert[T any](t *testing.T, alertName string, threshold float64, min int, setValue systemAlertValueSetter[T], baselineValue, triggerValue, resolveValue T) {
|
func testMultiMinuteSystemAlert[T any](t *testing.T, alertName string, threshold float64, min int, setValue systemAlertValueSetter[T], baselineValue, triggerValue, resolveValue T) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
fixture := newSystemAlertTestFixture(t, alertName, min, threshold)
|
fixture := newSystemAlertTestFixture(t, alertName, min, threshold)
|
||||||
defer fixture.cleanup()
|
defer fixture.cleanup()
|
||||||
|
|
||||||
synctest.Test(t, func(t *testing.T) {
|
|
||||||
submitValue(fixture, t, baselineValue, setValue)
|
submitValue(fixture, t, baselineValue, setValue)
|
||||||
waitForSystemAlert(time.Minute + time.Second)
|
waitForSystemAlert(time.Minute + time.Second)
|
||||||
fixture.assertTriggered(t, false, "Alert should not be triggered yet")
|
fixture.assertTriggered(t, false, "Alert should not be triggered yet")
|
||||||
@@ -199,7 +201,7 @@ func TestSystemAlertsOneMin(t *testing.T) {
|
|||||||
testOneMinuteSystemAlert(t, "LoadAvg1", 4, setLoadAvgAlertValue, [3]float64{4.1, 0, 0}, [3]float64{3.9, 0, 0})
|
testOneMinuteSystemAlert(t, "LoadAvg1", 4, setLoadAvgAlertValue, [3]float64{4.1, 0, 0}, [3]float64{3.9, 0, 0})
|
||||||
testOneMinuteSystemAlert(t, "LoadAvg5", 4, setLoadAvgAlertValue, [3]float64{0, 4.1, 0}, [3]float64{0, 3.9, 0})
|
testOneMinuteSystemAlert(t, "LoadAvg5", 4, setLoadAvgAlertValue, [3]float64{0, 4.1, 0}, [3]float64{0, 3.9, 0})
|
||||||
testOneMinuteSystemAlert(t, "LoadAvg15", 4, setLoadAvgAlertValue, [3]float64{0, 0, 4.1}, [3]float64{0, 0, 3.9})
|
testOneMinuteSystemAlert(t, "LoadAvg15", 4, setLoadAvgAlertValue, [3]float64{0, 0, 4.1}, [3]float64{0, 0, 3.9})
|
||||||
testOneMinuteSystemAlert(t, "Battery", 20, setBatteryAlertValue, [2]uint8{0, 1}, [2]uint8{21, 0})
|
testOneMinuteSystemAlert(t, "Battery", 20, setBatteryAlertValue, [2]uint8{19, 0}, [2]uint8{21, 0})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSystemAlertsTwoMin(t *testing.T) {
|
func TestSystemAlertsTwoMin(t *testing.T) {
|
||||||
|
|||||||
@@ -15,9 +15,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestAlertsHistory(t *testing.T) {
|
func TestAlertsHistory(t *testing.T) {
|
||||||
hub, user := beszelTests.GetHubWithUser(t)
|
|
||||||
|
|
||||||
synctest.Test(t, func(t *testing.T) {
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
hub, user := beszelTests.GetHubWithUser(t)
|
||||||
defer hub.Cleanup()
|
defer hub.Cleanup()
|
||||||
|
|
||||||
// Create systems and alerts
|
// Create systems and alerts
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ const (
|
|||||||
GetSmartData
|
GetSmartData
|
||||||
// Request detailed systemd service info from agent
|
// Request detailed systemd service info from agent
|
||||||
GetSystemdInfo
|
GetSystemdInfo
|
||||||
|
// Sync network probe configuration to agent
|
||||||
|
SyncNetworkProbes
|
||||||
// Add new actions here...
|
// Add new actions here...
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -43,7 +45,6 @@ type AgentResponse struct {
|
|||||||
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"]
|
|
||||||
@@ -52,17 +52,6 @@ type HostInfo struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *ApiStats) CalculateCpuPercentLinux(prevCpuContainer uint64, prevCpuSystem uint64) float64 {
|
func (s *ApiStats) CalculateCpuPercentLinux(prevCpuContainer uint64, prevCpuSystem uint64) float64 {
|
||||||
// A counter can read lower than the stored previous value when a stats
|
|
||||||
// response is processed after a newer one for the same container, or when an
|
|
||||||
// accounting counter resets. Unsigned subtraction wraps to ~2^64 instead of
|
|
||||||
// going negative: on the container counter that surfaces as an absurd
|
|
||||||
// percentage the caller rejects, discarding the whole sample; on the system
|
|
||||||
// counter it inflates the divisor and silently reports near-zero CPU.
|
|
||||||
// Treat either direction as a new baseline.
|
|
||||||
if s.CPUStats.CPUUsage.TotalUsage < prevCpuContainer || s.CPUStats.SystemUsage < prevCpuSystem {
|
|
||||||
return 0.0
|
|
||||||
}
|
|
||||||
|
|
||||||
cpuDelta := s.CPUStats.CPUUsage.TotalUsage - prevCpuContainer
|
cpuDelta := s.CPUStats.CPUUsage.TotalUsage - prevCpuContainer
|
||||||
systemDelta := s.CPUStats.SystemUsage - prevCpuSystem
|
systemDelta := s.CPUStats.SystemUsage - prevCpuSystem
|
||||||
|
|
||||||
@@ -81,11 +70,7 @@ func (s *ApiStats) CalculateCpuPercentWindows(prevCpuUsage uint64, prevRead time
|
|||||||
possIntervals /= 100 // Convert to number of 100ns intervals
|
possIntervals /= 100 // Convert to number of 100ns intervals
|
||||||
possIntervals *= uint64(s.NumProcs) // Multiple by the number of processors
|
possIntervals *= uint64(s.NumProcs) // Multiple by the number of processors
|
||||||
|
|
||||||
// Intervals used. Same rollback guard as the Linux path: an out-of-order or
|
// Intervals used
|
||||||
// reset counter would wrap the subtraction to ~2^64.
|
|
||||||
if s.CPUStats.CPUUsage.TotalUsage < prevCpuUsage {
|
|
||||||
return 0.0
|
|
||||||
}
|
|
||||||
intervalsUsed := s.CPUStats.CPUUsage.TotalUsage - prevCpuUsage
|
intervalsUsed := s.CPUStats.CPUUsage.TotalUsage - prevCpuUsage
|
||||||
|
|
||||||
// Percentage avoiding divide-by-zero
|
// Percentage avoiding divide-by-zero
|
||||||
|
|||||||
83
internal/entities/probe/probe.go
Normal file
83
internal/entities/probe/probe.go
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
package probe
|
||||||
|
|
||||||
|
type SyncAction uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
// SyncActionReplace indicates a full sync where the provided configs should replace all existing probes for the system.
|
||||||
|
SyncActionReplace SyncAction = iota
|
||||||
|
// SyncActionUpsert indicates an incremental sync where the provided config should be added or updated.
|
||||||
|
SyncActionUpsert
|
||||||
|
// SyncActionDelete indicates an incremental sync where the provided config should be removed.
|
||||||
|
SyncActionDelete
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config defines a network probe task sent from hub to agent.
|
||||||
|
type Config struct {
|
||||||
|
// ID is the stable network_probes record ID generated by the hub.
|
||||||
|
ID string `cbor:"0,keyasint"`
|
||||||
|
Target string `cbor:"1,keyasint"`
|
||||||
|
Protocol string `cbor:"2,keyasint"` // "icmp", "tcp", or "http"
|
||||||
|
Port uint16 `cbor:"3,keyasint,omitempty"`
|
||||||
|
Interval uint16 `cbor:"4,keyasint"` // seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncRequest defines an incremental or full probe sync request sent to the agent.
|
||||||
|
type SyncRequest struct {
|
||||||
|
Action SyncAction `cbor:"0,keyasint"`
|
||||||
|
Config Config `cbor:"1,keyasint,omitempty"`
|
||||||
|
Configs []Config `cbor:"2,keyasint,omitempty"`
|
||||||
|
RunNow bool `cbor:"3,keyasint,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncResponse returns the immediate result for an upsert when requested.
|
||||||
|
type SyncResponse struct {
|
||||||
|
Result Result `cbor:"0,keyasint,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result holds aggregated probe results for a single target.
|
||||||
|
//
|
||||||
|
// 0: avg response in microseconds
|
||||||
|
//
|
||||||
|
// 1: 1h average response in microseconds
|
||||||
|
//
|
||||||
|
// 2: min response in microseconds
|
||||||
|
//
|
||||||
|
// 3: 1h min response in microseconds
|
||||||
|
//
|
||||||
|
// 4: max response in microseconds
|
||||||
|
//
|
||||||
|
// 5: 1h max response in microseconds
|
||||||
|
//
|
||||||
|
// 6: packet loss percentage (0-100)
|
||||||
|
//
|
||||||
|
// 7: 1h packet loss percentage (0-100)
|
||||||
|
type Result struct {
|
||||||
|
AvgResponse int64 `cbor:"0,keyasint,omitempty"`
|
||||||
|
AvgResponse1h int64 `cbor:"1,keyasint,omitempty"`
|
||||||
|
MinResponse int64 `cbor:"2,keyasint,omitempty"`
|
||||||
|
MinResponse1h int64 `cbor:"3,keyasint,omitempty"`
|
||||||
|
MaxResponse int64 `cbor:"4,keyasint,omitempty"`
|
||||||
|
MaxResponse1h int64 `cbor:"5,keyasint,omitempty"`
|
||||||
|
PacketLoss float64 `cbor:"6,keyasint,omitempty"`
|
||||||
|
PacketLoss1h float64 `cbor:"7,keyasint,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stats holds only 1m values for a single target, which are used for charts.
|
||||||
|
//
|
||||||
|
// 0: avg response in microseconds
|
||||||
|
//
|
||||||
|
// 1: min response in microseconds
|
||||||
|
//
|
||||||
|
// 2: max response in microseconds
|
||||||
|
//
|
||||||
|
// 3: packet loss percentage (0-100)
|
||||||
|
type Stats []float64
|
||||||
|
|
||||||
|
func (s Stats) FromResult(result Result) Stats {
|
||||||
|
return Stats{
|
||||||
|
float64(result.AvgResponse),
|
||||||
|
float64(result.MinResponse),
|
||||||
|
float64(result.MaxResponse),
|
||||||
|
result.PacketLoss,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -531,13 +531,6 @@ type SmartData struct {
|
|||||||
Attributes []*SmartAttribute `json:"a,omitempty" cbor:"9,keyasint,omitempty"`
|
Attributes []*SmartAttribute `json:"a,omitempty" cbor:"9,keyasint,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SmartDataResponse contains the collected data and whether every discovered
|
|
||||||
// device was collected. Older agents omit Complete, so hubs must not prune from it.
|
|
||||||
type SmartDataResponse struct {
|
|
||||||
Data map[string]SmartData `json:"data" cbor:"0,keyasint"`
|
|
||||||
Complete bool `json:"complete" cbor:"1,keyasint,omitempty"` // Whether every discovered device was collected
|
|
||||||
}
|
|
||||||
|
|
||||||
type SmartAttribute struct {
|
type SmartAttribute struct {
|
||||||
ID uint16 `json:"id,omitempty" cbor:"0,keyasint,omitempty"`
|
ID uint16 `json:"id,omitempty" cbor:"0,keyasint,omitempty"`
|
||||||
Name string `json:"n" cbor:"1,keyasint"`
|
Name string `json:"n" cbor:"1,keyasint"`
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/container"
|
"github.com/henrygd/beszel/internal/entities/container"
|
||||||
|
"github.com/henrygd/beszel/internal/entities/probe"
|
||||||
"github.com/henrygd/beszel/internal/entities/systemd"
|
"github.com/henrygd/beszel/internal/entities/systemd"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -33,8 +34,6 @@ type Stats struct {
|
|||||||
MaxNetworkSent float64 `json:"nsm,omitempty" cbor:"-"`
|
MaxNetworkSent float64 `json:"nsm,omitempty" cbor:"-"`
|
||||||
MaxNetworkRecv float64 `json:"nrm,omitempty" cbor:"-"`
|
MaxNetworkRecv float64 `json:"nrm,omitempty" cbor:"-"`
|
||||||
Temperatures map[string]float64 `json:"t,omitempty" cbor:"20,keyasint,omitempty"`
|
Temperatures map[string]float64 `json:"t,omitempty" cbor:"20,keyasint,omitempty"`
|
||||||
Fans map[string]uint16 `json:"f,omitempty" cbor:"36,keyasint,omitempty"`
|
|
||||||
Batteries map[string]uint8 `json:"bats,omitempty" cbor:"37,keyasint,omitempty"`
|
|
||||||
ExtraFs map[string]*FsStats `json:"efs,omitempty" cbor:"21,keyasint,omitempty"`
|
ExtraFs map[string]*FsStats `json:"efs,omitempty" cbor:"21,keyasint,omitempty"`
|
||||||
GPUData map[string]GPUData `json:"g,omitempty" cbor:"22,keyasint,omitempty"`
|
GPUData map[string]GPUData `json:"g,omitempty" cbor:"22,keyasint,omitempty"`
|
||||||
// LoadAvg1 float64 `json:"l1,omitempty" cbor:"23,keyasint,omitempty"`
|
// LoadAvg1 float64 `json:"l1,omitempty" cbor:"23,keyasint,omitempty"`
|
||||||
@@ -44,7 +43,7 @@ type Stats struct {
|
|||||||
MaxBandwidth [2]uint64 `json:"bm,omitzero" cbor:"-"` // [sent bytes, recv bytes]
|
MaxBandwidth [2]uint64 `json:"bm,omitzero" cbor:"-"` // [sent bytes, recv bytes]
|
||||||
// TODO: remove other load fields in future release in favor of load avg array
|
// TODO: remove other load fields in future release in favor of load avg array
|
||||||
LoadAvg [3]float64 `json:"la,omitempty" cbor:"28,keyasint"`
|
LoadAvg [3]float64 `json:"la,omitempty" cbor:"28,keyasint"`
|
||||||
Battery [2]uint8 `json:"bat,omitzero" cbor:"29,keyasint,omitzero"` // [percent, charge state]
|
Battery [2]uint8 `json:"bat,omitzero" cbor:"29,keyasint,omitzero"` // [percent, charge state, current]
|
||||||
NetworkInterfaces map[string][4]uint64 `json:"ni,omitempty" cbor:"31,keyasint,omitempty"` // [upload bytes, download bytes, total upload, total download]
|
NetworkInterfaces map[string][4]uint64 `json:"ni,omitempty" cbor:"31,keyasint,omitempty"` // [upload bytes, download bytes, total upload, total download]
|
||||||
DiskIO [2]uint64 `json:"dio,omitzero" cbor:"32,keyasint,omitzero"` // [read bytes, write bytes]
|
DiskIO [2]uint64 `json:"dio,omitzero" cbor:"32,keyasint,omitzero"` // [read bytes, write bytes]
|
||||||
MaxDiskIO [2]uint64 `json:"diom,omitzero" cbor:"-"` // [max read bytes, max write bytes]
|
MaxDiskIO [2]uint64 `json:"diom,omitzero" cbor:"-"` // [max read bytes, max write bytes]
|
||||||
@@ -181,4 +180,5 @@ type CombinedData struct {
|
|||||||
Containers []*container.Stats `json:"container" cbor:"2,keyasint"`
|
Containers []*container.Stats `json:"container" cbor:"2,keyasint"`
|
||||||
SystemdServices []*systemd.Service `json:"systemd,omitempty" cbor:"3,keyasint,omitempty"`
|
SystemdServices []*systemd.Service `json:"systemd,omitempty" cbor:"3,keyasint,omitempty"`
|
||||||
Details *Details `cbor:"4,keyasint,omitempty"`
|
Details *Details `cbor:"4,keyasint,omitempty"`
|
||||||
|
Probes map[string]probe.Result `cbor:"5,keyasint,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
package system
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/fxamacker/cbor/v2"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestStatsBatteryTransport(t *testing.T) {
|
|
||||||
stats := Stats{Battery: [2]uint8{0, 1}, Batteries: map[string]uint8{"Primary": 0, "Mouse": 75}}
|
|
||||||
|
|
||||||
jsonData, err := json.Marshal(stats)
|
|
||||||
require.NoError(t, err)
|
|
||||||
var jsonPayload map[string]any
|
|
||||||
require.NoError(t, json.Unmarshal(jsonData, &jsonPayload))
|
|
||||||
assert.Equal(t, []any{float64(0), float64(1)}, jsonPayload["bat"])
|
|
||||||
assert.Equal(t, map[string]any{"Primary": float64(0), "Mouse": float64(75)}, jsonPayload["bats"])
|
|
||||||
|
|
||||||
cborData, err := cbor.Marshal(stats)
|
|
||||||
require.NoError(t, err)
|
|
||||||
var decoded Stats
|
|
||||||
require.NoError(t, cbor.Unmarshal(cborData, &decoded))
|
|
||||||
assert.Equal(t, stats.Battery, decoded.Battery)
|
|
||||||
assert.Equal(t, stats.Batteries, decoded.Batteries)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStatsLegacyBatteryPayload(t *testing.T) {
|
|
||||||
data, err := json.Marshal(Stats{Battery: [2]uint8{50, 4}})
|
|
||||||
require.NoError(t, err)
|
|
||||||
var payload map[string]any
|
|
||||||
require.NoError(t, json.Unmarshal(data, &payload))
|
|
||||||
assert.Contains(t, payload, "bat")
|
|
||||||
assert.NotContains(t, payload, "bats")
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
package ghupdate
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
func verifyAssetChecksum(path, digest string) error {
|
|
||||||
algorithm, expectedHex, ok := strings.Cut(digest, ":")
|
|
||||||
if !ok || algorithm == "" || expectedHex == "" {
|
|
||||||
return fmt.Errorf("invalid release digest %q", digest)
|
|
||||||
}
|
|
||||||
if !strings.EqualFold(algorithm, "sha256") {
|
|
||||||
return fmt.Errorf("unsupported release digest algorithm %q", algorithm)
|
|
||||||
}
|
|
||||||
|
|
||||||
expected, err := hex.DecodeString(expectedHex)
|
|
||||||
if err != nil || len(expected) != sha256.Size {
|
|
||||||
return fmt.Errorf("invalid SHA-256 release digest %q", digest)
|
|
||||||
}
|
|
||||||
|
|
||||||
file, err := os.Open(path)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to open release for checksum verification: %w", err)
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
hash := sha256.New()
|
|
||||||
if _, err := io.Copy(hash, file); err != nil {
|
|
||||||
return fmt.Errorf("failed to calculate release checksum: %w", err)
|
|
||||||
}
|
|
||||||
actual := hash.Sum(nil)
|
|
||||||
if !bytes.Equal(actual, expected) {
|
|
||||||
return fmt.Errorf("release checksum mismatch: expected %s, got %s", expectedHex, hex.EncodeToString(actual))
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
package ghupdate
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestVerifyAssetChecksum(t *testing.T) {
|
|
||||||
path := filepath.Join(t.TempDir(), "asset")
|
|
||||||
if err := os.WriteFile(path, []byte("beszel release asset"), 0600); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
digest string
|
|
||||||
wantErr string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "valid",
|
|
||||||
digest: "sha256:2316f86af2c3af2f0ef595ad5359cdd19b329d4829a5425460ca9ffbf92671ab",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "mismatch",
|
|
||||||
digest: "sha256:0316f86af2c3af2f0ef595ad5359cdd19b329d4829a5425460ca9ffbf92671ab",
|
|
||||||
wantErr: "checksum mismatch",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "malformed",
|
|
||||||
digest: "sha256:not-a-checksum",
|
|
||||||
wantErr: "invalid SHA-256",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "missing",
|
|
||||||
wantErr: "invalid release digest",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "unsupported algorithm",
|
|
||||||
digest: "sha512:2316f86af2c3af2f0ef595ad5359cdd19b329d4829a5425460ca9ffbf92671ab",
|
|
||||||
wantErr: "unsupported",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
err := verifyAssetChecksum(path, tt.digest)
|
|
||||||
if tt.wantErr == "" && err != nil {
|
|
||||||
t.Fatalf("expected checksum to verify, got %v", err)
|
|
||||||
}
|
|
||||||
if tt.wantErr != "" && (err == nil || !strings.Contains(err.Error(), tt.wantErr)) {
|
|
||||||
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -46,23 +46,18 @@ func extractTarGz(srcPath, destDir string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
path, err := archivePath(destDir, header.Name)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if header.Typeflag == tar.TypeDir {
|
if header.Typeflag == tar.TypeDir {
|
||||||
if err := os.MkdirAll(path, 0755); err != nil {
|
if err := os.MkdirAll(filepath.Join(destDir, header.Name), 0755); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(filepath.Join(destDir, header.Name)), 0755); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
outFile, err := os.Create(path)
|
outFile, err := os.Create(filepath.Join(destDir, header.Name))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -77,14 +72,6 @@ func extractTarGz(srcPath, destDir string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// archivePath returns a path within destDir, rejecting path traversal entries.
|
|
||||||
func archivePath(destDir, name string) (string, error) {
|
|
||||||
if !filepath.IsLocal(name) {
|
|
||||||
return "", fmt.Errorf("invalid file path: %q", name)
|
|
||||||
}
|
|
||||||
return filepath.Join(destDir, name), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractZip extracts the zip archive at "src" to "dest".
|
// extractZip extracts the zip archive at "src" to "dest".
|
||||||
//
|
//
|
||||||
// Note that only dirs and regular files will be extracted.
|
// Note that only dirs and regular files will be extracted.
|
||||||
@@ -97,6 +84,9 @@ func extractZip(src, dest string) error {
|
|||||||
}
|
}
|
||||||
defer zr.Close()
|
defer zr.Close()
|
||||||
|
|
||||||
|
// normalize dest path to check later for Zip Slip
|
||||||
|
dest = filepath.Clean(dest) + string(os.PathSeparator)
|
||||||
|
|
||||||
for _, f := range zr.File {
|
for _, f := range zr.File {
|
||||||
err := extractFile(f, dest)
|
err := extractFile(f, dest)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -110,9 +100,11 @@ func extractZip(src, dest string) error {
|
|||||||
// extractFile extracts the provided zipFile into "basePath/zipFileName" path,
|
// extractFile extracts the provided zipFile into "basePath/zipFileName" path,
|
||||||
// creating all the necessary path directories.
|
// creating all the necessary path directories.
|
||||||
func extractFile(zipFile *zip.File, basePath string) error {
|
func extractFile(zipFile *zip.File, basePath string) error {
|
||||||
path, err := archivePath(basePath, zipFile.Name)
|
path := filepath.Join(basePath, zipFile.Name)
|
||||||
if err != nil {
|
|
||||||
return err
|
// check for Zip Slip
|
||||||
|
if !strings.HasPrefix(path, basePath) {
|
||||||
|
return fmt.Errorf("invalid file path: %s", path)
|
||||||
}
|
}
|
||||||
|
|
||||||
r, err := zipFile.Open()
|
r, err := zipFile.Open()
|
||||||
|
|||||||
@@ -135,33 +135,21 @@ func (p *updater) update() (updated bool, err error) {
|
|||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := os.MkdirAll(p.config.DataDir, 0755); err != nil {
|
releaseDir := filepath.Join(p.config.DataDir, ".beszel_update")
|
||||||
return false, fmt.Errorf("failed to create update data directory: %w", err)
|
|
||||||
}
|
|
||||||
releaseDir, err := os.MkdirTemp(p.config.DataDir, ".beszel_update-")
|
|
||||||
if err != nil {
|
|
||||||
return false, fmt.Errorf("failed to create update directory: %w", err)
|
|
||||||
}
|
|
||||||
defer os.RemoveAll(releaseDir)
|
defer os.RemoveAll(releaseDir)
|
||||||
|
|
||||||
ColorPrintf(ColorYellow, "Downloading %s...", asset.Name)
|
ColorPrintf(ColorYellow, "Downloading %s...", asset.Name)
|
||||||
|
|
||||||
// download the release asset
|
// download the release asset
|
||||||
assetPath, err := archivePath(releaseDir, asset.Name)
|
assetPath := filepath.Join(releaseDir, asset.Name)
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if err := downloadFile(p.config.Context, p.config.HttpClient, asset.DownloadUrl, assetPath, p.config.UseMirror); err != nil {
|
if err := downloadFile(p.config.Context, p.config.HttpClient, asset.DownloadUrl, assetPath, p.config.UseMirror); err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
ColorPrint(ColorYellow, "Verifying checksum...")
|
|
||||||
if err := verifyAssetChecksum(assetPath, asset.Digest); err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
ColorPrintf(ColorYellow, "Extracting %s...", asset.Name)
|
ColorPrintf(ColorYellow, "Extracting %s...", asset.Name)
|
||||||
|
|
||||||
extractDir := filepath.Join(releaseDir, "extracted")
|
extractDir := filepath.Join(releaseDir, "extracted_"+asset.Name)
|
||||||
|
defer os.RemoveAll(extractDir)
|
||||||
|
|
||||||
// Extract the archive (automatically detects format)
|
// Extract the archive (automatically detects format)
|
||||||
if err := extract(assetPath, extractDir); err != nil {
|
if err := extract(assetPath, extractDir); err != nil {
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
package ghupdate
|
package ghupdate
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"archive/tar"
|
|
||||||
"compress/gzip"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
@@ -46,59 +43,3 @@ func TestExtractFailure(t *testing.T) {
|
|||||||
t.Fatal("Expected Extract to fail due to missing tar.gz file")
|
t.Fatal("Expected Extract to fail due to missing tar.gz file")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestArchivePath(t *testing.T) {
|
|
||||||
destDir := t.TempDir()
|
|
||||||
for _, name := range []string{
|
|
||||||
"",
|
|
||||||
"..",
|
|
||||||
filepath.Join("..", "file"),
|
|
||||||
filepath.Join("dir", "..", "..", "file"),
|
|
||||||
string(os.PathSeparator) + filepath.Join("tmp", "file"),
|
|
||||||
} {
|
|
||||||
if _, err := archivePath(destDir, name); err == nil {
|
|
||||||
t.Errorf("expected %q to be rejected", name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
name := filepath.Join("dir", "file")
|
|
||||||
if path, err := archivePath(destDir, name); err != nil || path != filepath.Join(destDir, name) {
|
|
||||||
t.Errorf("archivePath(%q) = %q, %v", name, path, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractTarGzRejectsPathTraversal(t *testing.T) {
|
|
||||||
testDir := t.TempDir()
|
|
||||||
archivePath := filepath.Join(testDir, "malicious.tar.gz")
|
|
||||||
destDir := filepath.Join(testDir, "extract")
|
|
||||||
escapedPath := filepath.Join(testDir, "escaped")
|
|
||||||
|
|
||||||
archive, err := os.Create(archivePath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
gz := gzip.NewWriter(archive)
|
|
||||||
tw := tar.NewWriter(gz)
|
|
||||||
if err := tw.WriteHeader(&tar.Header{Name: "../escaped", Mode: 0600, Size: 1}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if _, err := tw.Write([]byte("x")); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := tw.Close(); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := gz.Close(); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := archive.Close(); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := extract(archivePath, destDir); err == nil {
|
|
||||||
t.Fatal("expected path traversal archive to be rejected")
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(escapedPath); !os.IsNotExist(err) {
|
|
||||||
t.Fatalf("path traversal wrote %s", escapedPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
type releaseAsset struct {
|
type releaseAsset struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
DownloadUrl string `json:"browser_download_url"`
|
DownloadUrl string `json:"browser_download_url"`
|
||||||
Digest string `json:"digest"`
|
|
||||||
Id int `json:"id"`
|
Id int `json:"id"`
|
||||||
Size int `json:"size"`
|
Size int `json:"size"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -317,9 +317,6 @@ func getRealIP(r *http.Request) string {
|
|||||||
if ip := r.Header.Get("CF-Connecting-IP"); ip != "" {
|
if ip := r.Header.Get("CF-Connecting-IP"); ip != "" {
|
||||||
return ip
|
return ip
|
||||||
}
|
}
|
||||||
if ip := r.Header.Get("X-Real-IP"); ip != "" {
|
|
||||||
return ip
|
|
||||||
}
|
|
||||||
if ip := r.Header.Get("X-Forwarded-For"); ip != "" {
|
if ip := r.Header.Get("X-Forwarded-For"); ip != "" {
|
||||||
// X-Forwarded-For can contain a comma-separated list: "client_ip, proxy1, proxy2"
|
// X-Forwarded-For can contain a comma-separated list: "client_ip, proxy1, proxy2"
|
||||||
// Take the first one
|
// Take the first one
|
||||||
|
|||||||
@@ -1796,24 +1796,6 @@ func TestGetRealIP(t *testing.T) {
|
|||||||
remoteAddr: "127.0.0.1:12345",
|
remoteAddr: "127.0.0.1:12345",
|
||||||
expectedIP: "192.168.1.8",
|
expectedIP: "192.168.1.8",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "X-Real-IP header",
|
|
||||||
headers: map[string]string{"X-Real-IP": "10.8.0.4"},
|
|
||||||
remoteAddr: "172.21.0.1:12345",
|
|
||||||
expectedIP: "10.8.0.4",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "X-Real-IP takes precedence over X-Forwarded-For",
|
|
||||||
headers: map[string]string{"X-Real-IP": "10.8.0.4", "X-Forwarded-For": "10.8.0.5"},
|
|
||||||
remoteAddr: "172.21.0.1:12345",
|
|
||||||
expectedIP: "10.8.0.4",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "CF-Connecting-IP takes precedence over X-Real-IP",
|
|
||||||
headers: map[string]string{"CF-Connecting-IP": "1.2.3.4", "X-Real-IP": "10.8.0.4"},
|
|
||||||
remoteAddr: "172.21.0.1:12345",
|
|
||||||
expectedIP: "1.2.3.4",
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range testCases {
|
for _, tc := range testCases {
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ func setCollectionAuthSettings(app core.App) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := applyCollectionRules(app, []string{"containers", "container_stats", "system_stats", "systemd_services"}, collectionRules{
|
if err := applyCollectionRules(app, []string{"containers", "container_stats", "system_stats", "systemd_services", "network_probe_stats"}, collectionRules{
|
||||||
list: &systemScopedReadRule,
|
list: &systemScopedReadRule,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -92,7 +92,7 @@ func setCollectionAuthSettings(app core.App) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := applyCollectionRules(app, []string{"fingerprints"}, collectionRules{
|
if err := applyCollectionRules(app, []string{"fingerprints", "network_probes"}, collectionRules{
|
||||||
list: &systemScopedReadRule,
|
list: &systemScopedReadRule,
|
||||||
view: &systemScopedReadRule,
|
view: &systemScopedReadRule,
|
||||||
create: &systemScopedWriteRule,
|
create: &systemScopedWriteRule,
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ func createConfigTestFingerprint(app core.App, systemID, token, fingerprint stri
|
|||||||
|
|
||||||
// TestConfigSyncWithTokens tests the config.SyncSystems function with various token scenarios
|
// TestConfigSyncWithTokens tests the config.SyncSystems function with various token scenarios
|
||||||
func TestConfigSyncWithTokens(t *testing.T) {
|
func TestConfigSyncWithTokens(t *testing.T) {
|
||||||
testHub, err := tests.NewTestHub(t.TempDir())
|
testHub, err := tests.NewTestHub()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer testHub.Cleanup()
|
defer testHub.Cleanup()
|
||||||
|
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ func (h *Hub) StartHub() error {
|
|||||||
}
|
}
|
||||||
// register middlewares
|
// register middlewares
|
||||||
h.registerMiddlewares(e)
|
h.registerMiddlewares(e)
|
||||||
|
// bind events that aren't set up in different
|
||||||
// register api routes
|
// register api routes
|
||||||
if err := h.registerApiRoutes(e); err != nil {
|
if err := h.registerApiRoutes(e); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -109,6 +110,8 @@ func (h *Hub) StartHub() error {
|
|||||||
h.App.OnRecordCreate("users").BindFunc(h.um.InitializeUserRole)
|
h.App.OnRecordCreate("users").BindFunc(h.um.InitializeUserRole)
|
||||||
h.App.OnRecordCreate("user_settings").BindFunc(h.um.InitializeUserSettings)
|
h.App.OnRecordCreate("user_settings").BindFunc(h.um.InitializeUserSettings)
|
||||||
|
|
||||||
|
bindNetworkProbesEvents(h)
|
||||||
|
|
||||||
pb, ok := h.App.(*pocketbase.PocketBase)
|
pb, ok := h.App.(*pocketbase.PocketBase)
|
||||||
if !ok {
|
if !ok {
|
||||||
return errors.New("not a pocketbase app")
|
return errors.New("not a pocketbase app")
|
||||||
|
|||||||
155
internal/hub/probes.go
Normal file
155
internal/hub/probes.go
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
package hub
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/entities/probe"
|
||||||
|
"github.com/henrygd/beszel/internal/hub/systems"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// generateProbeID creates a stable hash ID for a probe based on its configuration and the system it belongs to.
|
||||||
|
func generateProbeID(systemId string, config probe.Config) string {
|
||||||
|
args := []string{systemId, config.Target, config.Protocol}
|
||||||
|
// only use port for TCP probes, since for other protocols it's not relevant as standalone value
|
||||||
|
if config.Protocol == "tcp" {
|
||||||
|
args = append(args, strconv.FormatUint(uint64(config.Port), 10))
|
||||||
|
}
|
||||||
|
return systems.MakeStableHashId(args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// bindNetworkProbesEvents keeps probe records and agent probe state in sync.
|
||||||
|
func bindNetworkProbesEvents(hub *Hub) {
|
||||||
|
// on create, make sure the id is set to a stable hash
|
||||||
|
hub.OnRecordCreate("network_probes").BindFunc(func(e *core.RecordEvent) error {
|
||||||
|
systemID := e.Record.GetString("system")
|
||||||
|
config := probeConfigFromRecord(e.Record)
|
||||||
|
id := generateProbeID(systemID, *config)
|
||||||
|
e.Record.Set("id", id)
|
||||||
|
return e.Next()
|
||||||
|
})
|
||||||
|
|
||||||
|
// sync probe to agent on creation and persist the first result immediately when available
|
||||||
|
hub.OnRecordAfterCreateSuccess("network_probes").BindFunc(func(e *core.RecordEvent) error {
|
||||||
|
err := e.Next()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !e.Record.GetBool("enabled") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// if system connected, run the probe immediately
|
||||||
|
// if not, return and wait for the system to connect and sync probes on reg schedule
|
||||||
|
system, err := hub.sm.GetSystem(e.Record.GetString("system"))
|
||||||
|
if err == nil && system.Status == "up" {
|
||||||
|
go hub.upsertNetworkProbe(e.Record, true)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
|
||||||
|
// On API update requests, if the probe config changed in a way that requires a new ID, create a new
|
||||||
|
// record with the new ID and delete the old one. Otherwise, just update the existing probe on the agent.
|
||||||
|
hub.OnRecordUpdateRequest("network_probes").BindFunc(func(e *core.RecordRequestEvent) error {
|
||||||
|
systemID := e.Record.GetString("system")
|
||||||
|
// only tcp uses port - set other protocols port to zero
|
||||||
|
if e.Record.GetString("protocol") != "tcp" {
|
||||||
|
e.Record.Set("port", 0)
|
||||||
|
}
|
||||||
|
ID := generateProbeID(systemID, *probeConfigFromRecord(e.Record))
|
||||||
|
if ID != e.Record.Id {
|
||||||
|
newRecord := copyProbeToNewRecord(e.Record, ID)
|
||||||
|
if err := e.App.Save(newRecord); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := e.App.Delete(e.Record); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
err := e.Next()
|
||||||
|
if e.Record.GetBool("enabled") {
|
||||||
|
// if the probe is enabled, sync the updated config to the agent now
|
||||||
|
runNow := !e.Record.Original().GetBool("enabled")
|
||||||
|
err = hub.upsertNetworkProbe(e.Record, runNow)
|
||||||
|
} else {
|
||||||
|
// if the probe is paused, remove it from the agent
|
||||||
|
err = hub.deleteNetworkProbe(e.Record)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
hub.Logger().Warn("failed to sync updated probe", "system", systemID, "probe", e.Record.Id, "err", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// sync probe to agent on delete
|
||||||
|
hub.OnRecordAfterDeleteSuccess("network_probes").BindFunc(func(e *core.RecordEvent) error {
|
||||||
|
if err := hub.deleteNetworkProbe(e.Record); err != nil {
|
||||||
|
hub.Logger().Warn("failed to delete probe on agent", "system", e.Record.GetString("system"), "probe", e.Record.Id, "err", err)
|
||||||
|
}
|
||||||
|
return e.Next()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// probeConfigFromRecord builds a probe config from a network_probes record.
|
||||||
|
func probeConfigFromRecord(record *core.Record) *probe.Config {
|
||||||
|
return &probe.Config{
|
||||||
|
ID: record.Id,
|
||||||
|
Target: record.GetString("target"),
|
||||||
|
Protocol: record.GetString("protocol"),
|
||||||
|
Port: uint16(record.GetInt("port")),
|
||||||
|
Interval: uint16(record.GetInt("interval")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// setProbeResultFields stores the latest probe result values on the record.
|
||||||
|
func setProbeResultFields(record *core.Record, result probe.Result) {
|
||||||
|
nowString := time.Now().UTC().Format(types.DefaultDateLayout)
|
||||||
|
record.Set("res", result.AvgResponse)
|
||||||
|
record.Set("resAvg1h", result.AvgResponse1h)
|
||||||
|
record.Set("resMin1h", result.MinResponse1h)
|
||||||
|
record.Set("resMax1h", result.MaxResponse1h)
|
||||||
|
record.Set("loss1h", result.PacketLoss1h)
|
||||||
|
record.Set("updated", nowString)
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyProbeToNewRecord creates a new record with the same field values as the old one.
|
||||||
|
// This is used when the probe config changes in a way that requires a new ID, so we need
|
||||||
|
// to create a new record with the new ID and delete the old one.
|
||||||
|
func copyProbeToNewRecord(oldRecord *core.Record, newID string) *core.Record {
|
||||||
|
collection := oldRecord.Collection()
|
||||||
|
newRecord := core.NewRecord(collection)
|
||||||
|
newRecord.Id = newID
|
||||||
|
fields := []string{"system", "name", "target", "protocol", "port", "interval", "enabled"}
|
||||||
|
for _, field := range fields {
|
||||||
|
newRecord.Set(field, oldRecord.Get(field))
|
||||||
|
}
|
||||||
|
return newRecord
|
||||||
|
}
|
||||||
|
|
||||||
|
// upsertNetworkProbe creates or updates the record's probe on the target system. If runNow
|
||||||
|
// is true, it will also trigger an immediate probe run and update the record with the result.
|
||||||
|
func (h *Hub) upsertNetworkProbe(record *core.Record, runNow bool) error {
|
||||||
|
systemID := record.GetString("system")
|
||||||
|
system, err := h.sm.GetSystem(systemID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
result, err := system.UpsertNetworkProbe(*probeConfigFromRecord(record), runNow)
|
||||||
|
if err != nil || result == nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
setProbeResultFields(record, *result)
|
||||||
|
return h.App.SaveNoValidate(record)
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteNetworkProbe removes the record's probe from the target system.
|
||||||
|
func (h *Hub) deleteNetworkProbe(record *core.Record) error {
|
||||||
|
systemID := record.GetString("system")
|
||||||
|
system, err := h.sm.GetSystem(systemID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return system.DeleteNetworkProbe(record.Id)
|
||||||
|
}
|
||||||
155
internal/hub/probes_test.go
Normal file
155
internal/hub/probes_test.go
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
package hub
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/entities/probe"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGenerateProbeID(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
systemID string
|
||||||
|
config probe.Config
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "HTTP probe on example.com",
|
||||||
|
systemID: "sys123",
|
||||||
|
config: probe.Config{
|
||||||
|
Protocol: "http",
|
||||||
|
Target: "example.com",
|
||||||
|
Port: 0,
|
||||||
|
Interval: 60,
|
||||||
|
},
|
||||||
|
expected: "a20a5827",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "HTTP probe on example.com with different port",
|
||||||
|
systemID: "sys123",
|
||||||
|
config: probe.Config{
|
||||||
|
Protocol: "http",
|
||||||
|
Target: "example.com",
|
||||||
|
Port: 8080,
|
||||||
|
Interval: 60,
|
||||||
|
},
|
||||||
|
expected: "a20a5827",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "HTTP probe on example.com with different system ID",
|
||||||
|
systemID: "sys1234",
|
||||||
|
config: probe.Config{
|
||||||
|
Protocol: "http",
|
||||||
|
Target: "example.com",
|
||||||
|
Port: 80,
|
||||||
|
Interval: 60,
|
||||||
|
},
|
||||||
|
expected: "ab602ae7",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Same probe, different interval",
|
||||||
|
systemID: "sys1234",
|
||||||
|
config: probe.Config{
|
||||||
|
Protocol: "http",
|
||||||
|
Target: "example.com",
|
||||||
|
Port: 80,
|
||||||
|
Interval: 120,
|
||||||
|
},
|
||||||
|
expected: "ab602ae7",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ICMP probe on 1.1.1.1",
|
||||||
|
systemID: "sys456",
|
||||||
|
config: probe.Config{
|
||||||
|
Protocol: "icmp",
|
||||||
|
Target: "1.1.1.1",
|
||||||
|
Port: 0,
|
||||||
|
Interval: 10,
|
||||||
|
},
|
||||||
|
expected: "6d13a4a4",
|
||||||
|
}, {
|
||||||
|
name: "ICMP probe on 1.1.1.1 with different system ID",
|
||||||
|
systemID: "sys4567",
|
||||||
|
config: probe.Config{
|
||||||
|
Protocol: "icmp",
|
||||||
|
Target: "1.1.1.1",
|
||||||
|
Port: 0,
|
||||||
|
Interval: 10,
|
||||||
|
},
|
||||||
|
expected: "ddd6c81",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "TCP probe on example.com with port 443",
|
||||||
|
systemID: "sys789",
|
||||||
|
config: probe.Config{
|
||||||
|
Protocol: "tcp",
|
||||||
|
Target: "example.com",
|
||||||
|
Port: 443,
|
||||||
|
Interval: 30,
|
||||||
|
},
|
||||||
|
expected: "677b991",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "TCP probe on example.com with port 8443",
|
||||||
|
systemID: "sys789",
|
||||||
|
config: probe.Config{
|
||||||
|
Protocol: "tcp",
|
||||||
|
Target: "example.com",
|
||||||
|
Port: 8443,
|
||||||
|
Interval: 30,
|
||||||
|
},
|
||||||
|
expected: "84167969",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := generateProbeID(tt.systemID, tt.config)
|
||||||
|
assert.Equal(t, tt.expected, got, "generateProbeID() = %v, want %v", got, tt.expected)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCopyProbeToNewRecordDropsResultFields(t *testing.T) {
|
||||||
|
hub, testApp, err := createTestHub(t)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer cleanupTestHub(hub, testApp)
|
||||||
|
|
||||||
|
collection, err := hub.FindCachedCollectionByNameOrId("network_probes")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
oldRecord := core.NewRecord(collection)
|
||||||
|
oldRecord.Load(map[string]any{
|
||||||
|
"system": "sys123",
|
||||||
|
"name": "Example",
|
||||||
|
"target": "https://example.com",
|
||||||
|
"protocol": "http",
|
||||||
|
"port": 443,
|
||||||
|
"interval": 60,
|
||||||
|
"enabled": true,
|
||||||
|
"res": 1200,
|
||||||
|
"resAvg1h": 1300,
|
||||||
|
"resMin1h": 900,
|
||||||
|
"resMax1h": 1600,
|
||||||
|
"loss1h": 5,
|
||||||
|
"updated": "2026-04-29 12:00:00.000Z",
|
||||||
|
})
|
||||||
|
|
||||||
|
newRecord := copyProbeToNewRecord(oldRecord, "next12345")
|
||||||
|
|
||||||
|
assert.Equal(t, "next12345", newRecord.Id)
|
||||||
|
assert.Equal(t, "Example", newRecord.GetString("name"))
|
||||||
|
assert.Equal(t, "https://example.com", newRecord.GetString("target"))
|
||||||
|
assert.Equal(t, "http", newRecord.GetString("protocol"))
|
||||||
|
assert.Equal(t, 443, newRecord.GetInt("port"))
|
||||||
|
assert.True(t, newRecord.GetBool("enabled"))
|
||||||
|
assert.Zero(t, newRecord.GetFloat("res"))
|
||||||
|
assert.Zero(t, newRecord.GetFloat("resAvg1h"))
|
||||||
|
assert.Zero(t, newRecord.GetFloat("resMin1h"))
|
||||||
|
assert.Zero(t, newRecord.GetFloat("resMax1h"))
|
||||||
|
assert.Zero(t, newRecord.GetFloat("loss1h"))
|
||||||
|
assert.Equal(t, "", newRecord.GetString("updated"))
|
||||||
|
}
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
//go:build testing
|
|
||||||
|
|
||||||
package systems
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"testing"
|
|
||||||
"testing/synctest"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestRunWithTimeout covers the guard added for issue #2041: the per-system SSH
|
|
||||||
// data exchange must never block the updater indefinitely on a dead connection.
|
|
||||||
func TestRunWithTimeout(t *testing.T) {
|
|
||||||
t.Run("returns the operation result when it completes before the timeout", func(t *testing.T) {
|
|
||||||
synctest.Test(t, func(t *testing.T) {
|
|
||||||
wantErr := errors.New("boom")
|
|
||||||
onTimeoutCalled := false
|
|
||||||
|
|
||||||
retry, err := runWithTimeout(10*time.Second, func() (bool, error) {
|
|
||||||
return true, wantErr
|
|
||||||
}, func() { onTimeoutCalled = true })
|
|
||||||
|
|
||||||
assert.True(t, retry, "should return the operation's retry value")
|
|
||||||
assert.Equal(t, wantErr, err, "should return the operation's error")
|
|
||||||
assert.False(t, onTimeoutCalled, "onTimeout must not fire when the op completes")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("times out and tears down the connection when the op blocks", func(t *testing.T) {
|
|
||||||
synctest.Test(t, func(t *testing.T) {
|
|
||||||
// unblock simulates a half-open connection: the op is stuck reading a
|
|
||||||
// response that never arrives until the connection is torn down.
|
|
||||||
unblock := make(chan struct{})
|
|
||||||
onTimeoutCalled := false
|
|
||||||
start := time.Now()
|
|
||||||
|
|
||||||
retry, err := runWithTimeout(5*time.Second, func() (bool, error) {
|
|
||||||
<-unblock
|
|
||||||
return false, nil
|
|
||||||
}, func() {
|
|
||||||
onTimeoutCalled = true
|
|
||||||
close(unblock) // tearing down the connection releases the blocked read
|
|
||||||
})
|
|
||||||
|
|
||||||
assert.Equal(t, 5*time.Second, time.Since(start), "should return exactly at the timeout")
|
|
||||||
assert.True(t, retry, "a timeout should be retryable so the next tick re-dials")
|
|
||||||
assert.Error(t, err, "a timeout must surface an error so the system is set down")
|
|
||||||
assert.True(t, onTimeoutCalled, "onTimeout must fire so the dead connection is closed")
|
|
||||||
|
|
||||||
synctest.Wait() // ensure the released op goroutine exits cleanly
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
"github.com/henrygd/beszel/internal/hub/ws"
|
"github.com/henrygd/beszel/internal/hub/ws"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/container"
|
"github.com/henrygd/beszel/internal/entities/container"
|
||||||
|
"github.com/henrygd/beszel/internal/entities/probe"
|
||||||
"github.com/henrygd/beszel/internal/entities/smart"
|
"github.com/henrygd/beszel/internal/entities/smart"
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
"github.com/henrygd/beszel/internal/entities/systemd"
|
"github.com/henrygd/beszel/internal/entities/systemd"
|
||||||
@@ -29,6 +30,8 @@ import (
|
|||||||
"github.com/lxzan/gws"
|
"github.com/lxzan/gws"
|
||||||
"github.com/pocketbase/dbx"
|
"github.com/pocketbase/dbx"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/security"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/types"
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -56,7 +59,7 @@ func (sm *SystemManager) NewSystem(systemId string) *System {
|
|||||||
Id: systemId,
|
Id: systemId,
|
||||||
data: &system.CombinedData{},
|
data: &system.CombinedData{},
|
||||||
}
|
}
|
||||||
system.ctx, system.cancel = system.getContext(sm.ctx)
|
system.ctx, system.cancel = system.getContext()
|
||||||
return system
|
return system
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,10 +82,7 @@ func (sys *System) StartUpdater() {
|
|||||||
} else {
|
} else {
|
||||||
// if the system does not have a websocket connection, wait before updating
|
// if the system does not have a websocket connection, wait before updating
|
||||||
// to allow the agent to connect via websocket (makes sure fingerprint is set).
|
// to allow the agent to connect via websocket (makes sure fingerprint is set).
|
||||||
if !waitForContext(sys.ctx, 11*time.Second) {
|
time.Sleep(11 * time.Second)
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// update immediately if system is not paused (only for ws connections)
|
// update immediately if system is not paused (only for ws connections)
|
||||||
@@ -241,6 +241,12 @@ func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if data.Probes != nil {
|
||||||
|
if err := updateNetworkProbesRecords(txApp, data.Probes, sys.Id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// update system record (do this last because it triggers alerts and we need above records to be inserted first)
|
// update system record (do this last because it triggers alerts and we need above records to be inserted first)
|
||||||
systemRecord.Set("status", up)
|
systemRecord.Set("status", up)
|
||||||
systemRecord.Set("info", data.Info)
|
systemRecord.Set("info", data.Info)
|
||||||
@@ -292,7 +298,7 @@ func createSystemdStatsRecords(app core.App, data []*systemd.Service, systemId s
|
|||||||
for i, service := range data {
|
for i, service := range data {
|
||||||
suffix := fmt.Sprintf("%d", i)
|
suffix := fmt.Sprintf("%d", i)
|
||||||
valueStrings = append(valueStrings, fmt.Sprintf("({:id%[1]s}, {:system}, {:name%[1]s}, {:state%[1]s}, {:sub%[1]s}, {:cpu%[1]s}, {:cpuPeak%[1]s}, {:memory%[1]s}, {:memPeak%[1]s}, {:updated})", suffix))
|
valueStrings = append(valueStrings, fmt.Sprintf("({:id%[1]s}, {:system}, {:name%[1]s}, {:state%[1]s}, {:sub%[1]s}, {:cpu%[1]s}, {:cpuPeak%[1]s}, {:memory%[1]s}, {:memPeak%[1]s}, {:updated})", suffix))
|
||||||
params["id"+suffix] = makeStableHashId(systemId, service.Name)
|
params["id"+suffix] = MakeStableHashId(systemId, service.Name)
|
||||||
params["name"+suffix] = service.Name
|
params["name"+suffix] = service.Name
|
||||||
params["state"+suffix] = service.State
|
params["state"+suffix] = service.State
|
||||||
params["sub"+suffix] = service.Sub
|
params["sub"+suffix] = service.Sub
|
||||||
@@ -309,6 +315,97 @@ func createSystemdStatsRecords(app core.App, data []*systemd.Service, systemId s
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func updateNetworkProbesRecords(app core.App, probeResults map[string]probe.Result, systemId string) error {
|
||||||
|
if len(probeResults) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
const probeCollectionName = "network_probes"
|
||||||
|
|
||||||
|
// If realtime updates are active, we save via PocketBase records to trigger realtime events.
|
||||||
|
// Otherwise we can do a more efficient direct update via SQL
|
||||||
|
realtimeActive := utils.RealtimeActiveForCollection(app, probeCollectionName, func(filterQuery string) bool {
|
||||||
|
return !strings.Contains(filterQuery, "system") || strings.Contains(filterQuery, systemId)
|
||||||
|
})
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
nowMilli := now.UnixMilli()
|
||||||
|
nowString := now.Format(types.DefaultDateLayout)
|
||||||
|
var db dbx.Builder
|
||||||
|
var updateQuery *dbx.Query
|
||||||
|
if !realtimeActive {
|
||||||
|
db = app.DB()
|
||||||
|
probeFields := []string{"res", "resMin1h", "resMax1h", "resAvg1h", "loss1h", "updated"}
|
||||||
|
setClauses := make([]string, len(probeFields))
|
||||||
|
for i, f := range probeFields {
|
||||||
|
setClauses[i] = fmt.Sprintf("%s={:%s}", f, f)
|
||||||
|
}
|
||||||
|
queryString := fmt.Sprintf("UPDATE %s SET %s WHERE id={:id}", probeCollectionName, strings.Join(setClauses, ", "))
|
||||||
|
updateQuery = db.NewQuery(queryString)
|
||||||
|
}
|
||||||
|
|
||||||
|
// update network_probes records
|
||||||
|
for id, result := range probeResults {
|
||||||
|
probeData := map[string]any{
|
||||||
|
"id": id,
|
||||||
|
"res": result.AvgResponse,
|
||||||
|
"resAvg1h": result.AvgResponse1h,
|
||||||
|
"resMin1h": result.MinResponse1h,
|
||||||
|
"resMax1h": result.MaxResponse1h,
|
||||||
|
"loss1h": result.PacketLoss1h,
|
||||||
|
"updated": nowString,
|
||||||
|
}
|
||||||
|
switch realtimeActive {
|
||||||
|
case true:
|
||||||
|
var record *core.Record
|
||||||
|
record, err = app.FindRecordById(probeCollectionName, id)
|
||||||
|
if err == nil {
|
||||||
|
record.Load(probeData)
|
||||||
|
err = app.SaveNoValidate(record)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
_, err = updateQuery.Bind(dbx.Params(probeData)).Execute()
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
app.Logger().Warn("Failed to update probe", "system", systemId, "probe", id, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handle stats collection as well
|
||||||
|
const statsCollectionName = "network_probe_stats"
|
||||||
|
|
||||||
|
// we don't need the hour values for the stats collection
|
||||||
|
stats := make(map[string]probe.Stats, len(probeResults))
|
||||||
|
for key, result := range probeResults {
|
||||||
|
stats[key] = probe.Stats{}.FromResult(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
statsRecordData := map[string]any{
|
||||||
|
"system": systemId,
|
||||||
|
"type": "1m",
|
||||||
|
"created": nowMilli,
|
||||||
|
}
|
||||||
|
var statsJson types.JSONRaw
|
||||||
|
if err = statsJson.Scan(stats); err == nil {
|
||||||
|
statsRecordData["stats"] = statsJson
|
||||||
|
switch realtimeActive {
|
||||||
|
case true:
|
||||||
|
collection, _ := app.FindCachedCollectionByNameOrId(statsCollectionName)
|
||||||
|
record := core.NewRecord(collection)
|
||||||
|
record.Load(statsRecordData)
|
||||||
|
err = app.SaveNoValidate(record)
|
||||||
|
default:
|
||||||
|
statsRecordData["id"] = security.PseudorandomStringWithAlphabet(10, core.DefaultIdAlphabet)
|
||||||
|
_, err = db.Insert(statsCollectionName, dbx.Params(statsRecordData)).Execute()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
app.Logger().Error("Failed to update probe stats", "system", systemId, "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// createContainerRecords creates container records
|
// createContainerRecords creates container records
|
||||||
func createContainerRecords(app core.App, data []*container.Stats, systemId string) error {
|
func createContainerRecords(app core.App, data []*container.Stats, systemId string) error {
|
||||||
if len(data) == 0 {
|
if len(data) == 0 {
|
||||||
@@ -351,9 +448,6 @@ func (sys *System) getRecord(app core.App) (*core.Record, error) {
|
|||||||
record, err := app.FindRecordById("systems", sys.Id)
|
record, err := app.FindRecordById("systems", sys.Id)
|
||||||
if err != nil || record == nil {
|
if err != nil || record == nil {
|
||||||
_ = sys.manager.RemoveSystem(sys.Id)
|
_ = sys.manager.RemoveSystem(sys.Id)
|
||||||
if err == nil {
|
|
||||||
err = fmt.Errorf("system record %s not found", sys.Id)
|
|
||||||
}
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return record, nil
|
return record, nil
|
||||||
@@ -383,16 +477,10 @@ func (sys *System) HasUser(app core.App, user *core.Record) bool {
|
|||||||
// setDown marks a system as down in the database.
|
// setDown marks a system as down in the database.
|
||||||
// It takes the original error that caused the system to go down and returns any error
|
// It takes the original error that caused the system to go down and returns any error
|
||||||
// encountered during the process of updating the system status.
|
// encountered during the process of updating the system status.
|
||||||
// It is a no-op if the system's context has been cancelled.
|
|
||||||
func (sys *System) setDown(originalError error) error {
|
func (sys *System) setDown(originalError error) error {
|
||||||
if sys.Status == down || sys.Status == paused {
|
if sys.Status == down || sys.Status == paused {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
// the updater can race shutdown, and the app may already be disposed by the
|
|
||||||
// time we get here, so don't touch the database once the context is cancelled
|
|
||||||
if sys.ctx != nil && sys.ctx.Err() != nil {
|
|
||||||
return sys.ctx.Err()
|
|
||||||
}
|
|
||||||
record, err := sys.getRecord(sys.manager.hub)
|
record, err := sys.getRecord(sys.manager.hub)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -400,14 +488,13 @@ func (sys *System) setDown(originalError error) error {
|
|||||||
if originalError != nil {
|
if originalError != nil {
|
||||||
sys.manager.hub.Logger().Error("System down", "system", record.GetString("name"), "err", originalError)
|
sys.manager.hub.Logger().Error("System down", "system", record.GetString("name"), "err", originalError)
|
||||||
}
|
}
|
||||||
sys.detailsFetched.Store(false)
|
|
||||||
record.Set("status", down)
|
record.Set("status", down)
|
||||||
return sys.manager.hub.SaveNoValidate(record)
|
return sys.manager.hub.SaveNoValidate(record)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sys *System) getContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
func (sys *System) getContext() (context.Context, context.CancelFunc) {
|
||||||
if sys.ctx == nil {
|
if sys.ctx == nil {
|
||||||
sys.ctx, sys.cancel = context.WithCancel(ctx)
|
sys.ctx, sys.cancel = context.WithCancel(context.Background())
|
||||||
}
|
}
|
||||||
return sys.ctx, sys.cancel
|
return sys.ctx, sys.cancel
|
||||||
}
|
}
|
||||||
@@ -544,21 +631,16 @@ func (sys *System) FetchSystemdInfoFromAgent(serviceName string) (systemd.Servic
|
|||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// FetchSmartDataFromAgent fetches SMART data from the agent.
|
// FetchSmartDataFromAgent fetches SMART data from the agent
|
||||||
func (sys *System) FetchSmartDataFromAgent() (smart.SmartDataResponse, error) {
|
func (sys *System) FetchSmartDataFromAgent() (map[string]smart.SmartData, error) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
if sys.agentVersion.LT(beszel.MinVersionAgentResponse) {
|
var result map[string]smart.SmartData
|
||||||
var data map[string]smart.SmartData
|
|
||||||
err := sys.request(ctx, common.GetSmartData, nil, &data)
|
|
||||||
return smart.SmartDataResponse{Data: data}, err
|
|
||||||
}
|
|
||||||
var result smart.SmartDataResponse
|
|
||||||
err := sys.request(ctx, common.GetSmartData, nil, &result)
|
err := sys.request(ctx, common.GetSmartData, nil, &result)
|
||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeStableHashId(strings ...string) string {
|
func MakeStableHashId(strings ...string) string {
|
||||||
hash := fnv.New32a()
|
hash := fnv.New32a()
|
||||||
for _, str := range strings {
|
for _, str := range strings {
|
||||||
hash.Write([]byte(str))
|
hash.Write([]byte(str))
|
||||||
@@ -641,17 +723,10 @@ func (sys *System) runSSHOperation(timeout time.Duration, retries int, operation
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bound the whole operation. A half-open TCP connection (a dead peer that
|
retry, opErr := func() (bool, error) {
|
||||||
// never sends RST/FIN) or a wedged agent that accepts the session but
|
|
||||||
// never writes a response would otherwise block the read forever. Because
|
|
||||||
// StartUpdater runs update() synchronously on its ticker, that stalls the
|
|
||||||
// per-system updater indefinitely with no error and no re-dial until the
|
|
||||||
// hub is restarted (issue #2041). On timeout we tear down the connection
|
|
||||||
// so the blocked read unwinds and the system is re-dialed on the next tick.
|
|
||||||
retry, opErr := runWithTimeout(sshOperationTimeout, func() (bool, error) {
|
|
||||||
defer session.Close()
|
defer session.Close()
|
||||||
return operation(session)
|
return operation(session)
|
||||||
}, sys.closeSSHConnection)
|
}()
|
||||||
|
|
||||||
if opErr == nil {
|
if opErr == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -670,43 +745,6 @@ func (sys *System) runSSHOperation(timeout time.Duration, retries int, operation
|
|||||||
return fmt.Errorf("ssh operation failed")
|
return fmt.Errorf("ssh operation failed")
|
||||||
}
|
}
|
||||||
|
|
||||||
// sshOperationTimeout bounds a single SSH data exchange (send request, read
|
|
||||||
// response, wait for the remote command to exit). It is more generous than the
|
|
||||||
// session-creation timeout to tolerate briefly slow agents, but is kept well
|
|
||||||
// under the collection interval so a stalled connection is detected and
|
|
||||||
// re-dialed within one cycle (see issue #2041).
|
|
||||||
const sshOperationTimeout = 20 * time.Second
|
|
||||||
|
|
||||||
// runWithTimeout runs op in a goroutine and returns its result, or, if op does
|
|
||||||
// not finish within timeout, calls onTimeout (used to tear down the connection
|
|
||||||
// so a blocked op can unwind) and returns a retryable timeout error. This
|
|
||||||
// guarantees the caller can never block indefinitely on a dead SSH connection.
|
|
||||||
func runWithTimeout(timeout time.Duration, op func() (bool, error), onTimeout func()) (retry bool, err error) {
|
|
||||||
type opResult struct {
|
|
||||||
retry bool
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
// Buffered so the op goroutine never leaks even when we return on timeout.
|
|
||||||
done := make(chan opResult, 1)
|
|
||||||
go func() {
|
|
||||||
r, e := op()
|
|
||||||
done <- opResult{retry: r, err: e}
|
|
||||||
}()
|
|
||||||
|
|
||||||
timer := time.NewTimer(timeout)
|
|
||||||
defer timer.Stop()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case res := <-done:
|
|
||||||
return res.retry, res.err
|
|
||||||
case <-timer.C:
|
|
||||||
if onTimeout != nil {
|
|
||||||
onTimeout()
|
|
||||||
}
|
|
||||||
return true, fmt.Errorf("ssh operation timed out after %s", timeout)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// createSSHClient creates a new SSH client for the system
|
// createSSHClient creates a new SSH client for the system
|
||||||
func (s *System) createSSHClient() error {
|
func (s *System) createSSHClient() error {
|
||||||
if s.manager.sshConfig == nil {
|
if s.manager.sshConfig == nil {
|
||||||
@@ -722,7 +760,7 @@ func (s *System) createSSHClient() error {
|
|||||||
host = net.JoinHostPort(host, s.Port)
|
host = net.JoinHostPort(host, s.Port)
|
||||||
}
|
}
|
||||||
var err error
|
var err error
|
||||||
s.client, err = dialSSHWithKeepAlive(network, host, s.manager.sshConfig)
|
s.client, err = ssh.Dial(network, host, s.manager.sshConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -731,34 +769,6 @@ func (s *System) createSSHClient() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// sshKeepAliveInterval is the TCP keep-alive idle interval for SSH connections
|
|
||||||
// to agents. Enabling OS-level keep-alives lets the hub eventually detect a
|
|
||||||
// dead peer on an otherwise idle connection instead of trusting it forever.
|
|
||||||
// This is a backstop for genuine network death; an application-level wedge
|
|
||||||
// (agent process hung while its kernel keeps ACKing) is caught by the
|
|
||||||
// per-operation timeout in runSSHOperation instead (see issue #2041).
|
|
||||||
const sshKeepAliveInterval = 30 * time.Second
|
|
||||||
|
|
||||||
// dialSSHWithKeepAlive dials an SSH connection like ssh.Dial, but enables TCP
|
|
||||||
// keep-alive on the underlying connection so half-open connections are
|
|
||||||
// eventually detected by the operating system.
|
|
||||||
func dialSSHWithKeepAlive(network, addr string, config *ssh.ClientConfig) (*ssh.Client, error) {
|
|
||||||
dialer := net.Dialer{
|
|
||||||
Timeout: config.Timeout,
|
|
||||||
KeepAlive: sshKeepAliveInterval,
|
|
||||||
}
|
|
||||||
conn, err := dialer.Dial(network, addr)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
sshConn, chans, reqs, err := ssh.NewClientConn(conn, addr, config)
|
|
||||||
if err != nil {
|
|
||||||
_ = conn.Close()
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return ssh.NewClient(sshConn, chans, reqs), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// createSessionWithTimeout creates a new SSH session with a timeout to avoid hanging
|
// createSessionWithTimeout creates a new SSH session with a timeout to avoid hanging
|
||||||
// in case of network issues
|
// in case of network issues
|
||||||
func (sys *System) createSessionWithTimeout(timeout time.Duration) (*ssh.Session, error) {
|
func (sys *System) createSessionWithTimeout(timeout time.Duration) (*ssh.Session, error) {
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
package systems
|
package systems
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/hub/ws"
|
"github.com/henrygd/beszel/internal/hub/ws"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/entities/probe"
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
"github.com/henrygd/beszel/internal/hub/expirymap"
|
"github.com/henrygd/beszel/internal/hub/expirymap"
|
||||||
|
|
||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"github.com/henrygd/beszel"
|
"github.com/henrygd/beszel"
|
||||||
|
|
||||||
"github.com/blang/semver"
|
"github.com/blang/semver"
|
||||||
|
"github.com/pocketbase/dbx"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
"github.com/pocketbase/pocketbase/tools/store"
|
"github.com/pocketbase/pocketbase/tools/store"
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
@@ -46,8 +47,6 @@ type SystemManager struct {
|
|||||||
systems *store.Store[string, *System] // Thread-safe store of active systems
|
systems *store.Store[string, *System] // Thread-safe store of active systems
|
||||||
sshConfig *ssh.ClientConfig // SSH client configuration for system connections
|
sshConfig *ssh.ClientConfig // SSH client configuration for system connections
|
||||||
smartFetchMap *expirymap.ExpiryMap[smartFetchState] // Stores last SMART fetch time/result; TTL is only for cleanup
|
smartFetchMap *expirymap.ExpiryMap[smartFetchState] // Stores last SMART fetch time/result; TTL is only for cleanup
|
||||||
ctx context.Context // Cancelled when the app terminates
|
|
||||||
cancel context.CancelFunc // Cancels ctx and all child system contexts
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// hubLike defines the interface requirements for the hub dependency.
|
// hubLike defines the interface requirements for the hub dependency.
|
||||||
@@ -63,13 +62,11 @@ type hubLike interface {
|
|||||||
// NewSystemManager creates a new SystemManager instance with the provided hub.
|
// NewSystemManager creates a new SystemManager instance with the provided hub.
|
||||||
// The hub must implement the hubLike interface to provide database and alert functionality.
|
// The hub must implement the hubLike interface to provide database and alert functionality.
|
||||||
func NewSystemManager(hub hubLike) *SystemManager {
|
func NewSystemManager(hub hubLike) *SystemManager {
|
||||||
sm := &SystemManager{
|
return &SystemManager{
|
||||||
systems: store.New(map[string]*System{}),
|
systems: store.New(map[string]*System{}),
|
||||||
hub: hub,
|
hub: hub,
|
||||||
smartFetchMap: expirymap.New[smartFetchState](time.Hour),
|
smartFetchMap: expirymap.New[smartFetchState](time.Hour),
|
||||||
}
|
}
|
||||||
sm.ctx, sm.cancel = context.WithCancel(context.Background())
|
|
||||||
return sm
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSystem returns a system by ID from the store
|
// GetSystem returns a system by ID from the store
|
||||||
@@ -108,9 +105,7 @@ func (sm *SystemManager) Initialize() error {
|
|||||||
sleepTime := time.Duration(delta) * time.Millisecond
|
sleepTime := time.Duration(delta) * time.Millisecond
|
||||||
|
|
||||||
for _, system := range systems {
|
for _, system := range systems {
|
||||||
if !waitForContext(sm.ctx, sleepTime) {
|
time.Sleep(sleepTime)
|
||||||
return
|
|
||||||
}
|
|
||||||
_ = sm.AddSystem(system)
|
_ = sm.AddSystem(system)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
@@ -128,13 +123,6 @@ func (sm *SystemManager) bindEventHooks() {
|
|||||||
sm.hub.OnRecordAfterUpdateSuccess("fingerprints").BindFunc(sm.onTokenRotated)
|
sm.hub.OnRecordAfterUpdateSuccess("fingerprints").BindFunc(sm.onTokenRotated)
|
||||||
sm.hub.OnRealtimeSubscribeRequest().BindFunc(sm.onRealtimeSubscribeRequest)
|
sm.hub.OnRealtimeSubscribeRequest().BindFunc(sm.onRealtimeSubscribeRequest)
|
||||||
sm.hub.OnRealtimeConnectRequest().BindFunc(sm.onRealtimeConnectRequest)
|
sm.hub.OnRealtimeConnectRequest().BindFunc(sm.onRealtimeConnectRequest)
|
||||||
sm.hub.OnTerminate().BindFunc(sm.onTerminate)
|
|
||||||
}
|
|
||||||
|
|
||||||
// onTerminate cancels SystemManager context on app shutdown
|
|
||||||
func (sm *SystemManager) onTerminate(e *core.TerminateEvent) error {
|
|
||||||
sm.cancel()
|
|
||||||
return e.Next()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// onTokenRotated handles fingerprint token rotation events.
|
// onTokenRotated handles fingerprint token rotation events.
|
||||||
@@ -261,7 +249,7 @@ func (sm *SystemManager) AddSystem(sys *System) error {
|
|||||||
|
|
||||||
// Initialize system for monitoring
|
// Initialize system for monitoring
|
||||||
sys.manager = sm
|
sys.manager = sm
|
||||||
sys.ctx, sys.cancel = sys.getContext(sm.ctx)
|
sys.ctx, sys.cancel = sys.getContext()
|
||||||
sys.data = &system.CombinedData{}
|
sys.data = &system.CombinedData{}
|
||||||
sm.systems.Set(sys.Id, sys)
|
sm.systems.Set(sys.Id, sys)
|
||||||
|
|
||||||
@@ -331,6 +319,17 @@ func (sm *SystemManager) AddWebSocketSystem(systemId string, agentVersion semver
|
|||||||
if err := sm.AddRecord(systemRecord, system); err != nil {
|
if err := sm.AddRecord(systemRecord, system); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sync network probes to the newly connected agent
|
||||||
|
go func() {
|
||||||
|
configs := sm.GetProbeConfigsForSystem(systemId)
|
||||||
|
if len(configs) > 0 {
|
||||||
|
if err := system.SyncNetworkProbes(configs); err != nil {
|
||||||
|
sm.hub.Logger().Warn("failed to sync probes to agent", "system", systemId, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,6 +342,16 @@ func (sm *SystemManager) resetFailedSmartFetchState(systemID string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetProbeConfigsForSystem returns all enabled probe configs for a system.
|
||||||
|
func (sm *SystemManager) GetProbeConfigsForSystem(systemID string) []probe.Config {
|
||||||
|
var configs []probe.Config
|
||||||
|
_ = sm.hub.DB().
|
||||||
|
NewQuery("SELECT id, target, protocol, port, interval FROM network_probes WHERE system = {:system} AND enabled = true").
|
||||||
|
Bind(dbx.Params{"system": systemID}).
|
||||||
|
All(&configs)
|
||||||
|
return configs
|
||||||
|
}
|
||||||
|
|
||||||
// createSSHClientConfig initializes the SSH client configuration for connecting to an agent's server
|
// createSSHClientConfig initializes the SSH client configuration for connecting to an agent's server
|
||||||
func (sm *SystemManager) createSSHClientConfig() error {
|
func (sm *SystemManager) createSSHClientConfig() error {
|
||||||
privateKey, err := sm.hub.GetSSHKey("")
|
privateKey, err := sm.hub.GetSSHKey("")
|
||||||
@@ -386,15 +395,3 @@ func deactivateAlerts(app core.App, systemID string) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// waitForContext waits for delay or returns early when ctx is cancelled.
|
|
||||||
func waitForContext(ctx context.Context, delay time.Duration) bool {
|
|
||||||
timer := time.NewTimer(delay)
|
|
||||||
defer timer.Stop()
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return false
|
|
||||||
case <-timer.C:
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
48
internal/hub/systems/system_probes.go
Normal file
48
internal/hub/systems/system_probes.go
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
package systems
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/common"
|
||||||
|
"github.com/henrygd/beszel/internal/entities/probe"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SyncNetworkProbes sends probe configurations to the agent.
|
||||||
|
func (sys *System) SyncNetworkProbes(configs []probe.Config) error {
|
||||||
|
_, err := sys.syncNetworkProbes(probe.SyncRequest{Action: probe.SyncActionReplace, Configs: configs})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpsertNetworkProbe sends a single probe configuration change to the agent.
|
||||||
|
func (sys *System) UpsertNetworkProbe(config probe.Config, runNow bool) (*probe.Result, error) {
|
||||||
|
resp, err := sys.syncNetworkProbes(probe.SyncRequest{
|
||||||
|
Action: probe.SyncActionUpsert,
|
||||||
|
Config: config,
|
||||||
|
RunNow: runNow,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.Result == (probe.Result{}) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
result := resp.Result
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteNetworkProbe removes a single probe task from the agent.
|
||||||
|
func (sys *System) DeleteNetworkProbe(id string) error {
|
||||||
|
_, err := sys.syncNetworkProbes(probe.SyncRequest{
|
||||||
|
Action: probe.SyncActionDelete,
|
||||||
|
Config: probe.Config{ID: id},
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sys *System) syncNetworkProbes(req probe.SyncRequest) (probe.SyncResponse, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
var result probe.SyncResponse
|
||||||
|
return result, sys.request(ctx, common.SyncNetworkProbes, req, &result)
|
||||||
|
}
|
||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/smart"
|
"github.com/henrygd/beszel/internal/entities/smart"
|
||||||
"github.com/pocketbase/dbx"
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -18,13 +17,13 @@ type smartFetchState struct {
|
|||||||
|
|
||||||
// FetchAndSaveSmartDevices fetches SMART data from the agent and saves it to the database
|
// FetchAndSaveSmartDevices fetches SMART data from the agent and saves it to the database
|
||||||
func (sys *System) FetchAndSaveSmartDevices() error {
|
func (sys *System) FetchAndSaveSmartDevices() error {
|
||||||
response, err := sys.FetchSmartDataFromAgent()
|
smartData, err := sys.FetchSmartDataFromAgent()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
sys.recordSmartFetchResult(err, 0)
|
sys.recordSmartFetchResult(err, 0)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
err = sys.saveSmartDevices(response.Data, response.Complete)
|
err = sys.saveSmartDevices(smartData)
|
||||||
sys.recordSmartFetchResult(err, len(response.Data))
|
sys.recordSmartFetchResult(err, len(smartData))
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,9 +61,8 @@ func (sys *System) smartFetchInterval() time.Duration {
|
|||||||
return time.Hour
|
return time.Hour
|
||||||
}
|
}
|
||||||
|
|
||||||
// saveSmartDevices saves SMART device data and, after a complete refresh,
|
// saveSmartDevices saves SMART device data to the smart_devices collection
|
||||||
// removes rows for devices that are no longer reported.
|
func (sys *System) saveSmartDevices(smartData map[string]smart.SmartData) error {
|
||||||
func (sys *System) saveSmartDevices(smartData map[string]smart.SmartData, complete bool) error {
|
|
||||||
if len(smartData) == 0 {
|
if len(smartData) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -75,49 +73,20 @@ func (sys *System) saveSmartDevices(smartData map[string]smart.SmartData, comple
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
currentIDs := make(map[string]struct{}, len(smartData))
|
|
||||||
for deviceKey := range smartData {
|
|
||||||
currentIDs[makeStableHashId(sys.Id, deviceKey)] = struct{}{}
|
|
||||||
}
|
|
||||||
|
|
||||||
err = hub.RunInTransaction(func(txApp core.App) error {
|
|
||||||
if complete {
|
|
||||||
existing, err := txApp.FindRecordsByFilter(
|
|
||||||
collection,
|
|
||||||
"system = {:system}",
|
|
||||||
"",
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
dbx.Params{"system": sys.Id},
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
for _, record := range existing {
|
|
||||||
if _, ok := currentIDs[record.Id]; ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := txApp.Delete(record); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for deviceKey, device := range smartData {
|
for deviceKey, device := range smartData {
|
||||||
if err := sys.upsertSmartDeviceRecord(txApp, collection, deviceKey, device); err != nil {
|
if err := sys.upsertSmartDeviceRecord(collection, deviceKey, device); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
})
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sys *System) upsertSmartDeviceRecord(app core.App, collection *core.Collection, deviceKey string, device smart.SmartData) error {
|
func (sys *System) upsertSmartDeviceRecord(collection *core.Collection, deviceKey string, device smart.SmartData) error {
|
||||||
recordID := makeStableHashId(sys.Id, deviceKey)
|
hub := sys.manager.hub
|
||||||
|
recordID := MakeStableHashId(sys.Id, deviceKey)
|
||||||
|
|
||||||
record, err := app.FindRecordById(collection, recordID)
|
record, err := hub.FindRecordById(collection, recordID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if !errors.Is(err, sql.ErrNoRows) {
|
if !errors.Is(err, sql.ErrNoRows) {
|
||||||
return err
|
return err
|
||||||
@@ -145,7 +114,7 @@ func (sys *System) upsertSmartDeviceRecord(app core.App, collection *core.Collec
|
|||||||
record.Set("cycles", powerCycles)
|
record.Set("cycles", powerCycles)
|
||||||
record.Set("attributes", device.Attributes)
|
record.Set("attributes", device.Attributes)
|
||||||
|
|
||||||
return app.SaveNoValidate(record)
|
return hub.SaveNoValidate(record)
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractPowerMetrics extracts power on hours and power cycles from SMART attributes
|
// extractPowerMetrics extracts power on hours and power cycles from SMART attributes
|
||||||
|
|||||||
@@ -7,53 +7,10 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/smart"
|
|
||||||
esystem "github.com/henrygd/beszel/internal/entities/system"
|
|
||||||
"github.com/henrygd/beszel/internal/hub/expirymap"
|
"github.com/henrygd/beszel/internal/hub/expirymap"
|
||||||
_ "github.com/henrygd/beszel/internal/migrations"
|
|
||||||
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
|
||||||
pbtests "github.com/pocketbase/pocketbase/tests"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"golang.org/x/crypto/ssh"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// stubHub implements hubLike using a plain pocketbase test app, so
|
|
||||||
// smart-device DB tests can run in-package without an import cycle to
|
|
||||||
// internal/hub (which imports this package).
|
|
||||||
type stubHub struct{ core.App }
|
|
||||||
|
|
||||||
func (stubHub) GetSSHKey(dataDir string) (ssh.Signer, error) { return nil, nil }
|
|
||||||
func (stubHub) HandleSystemAlerts(systemRecord *core.Record, data *esystem.CombinedData) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
func (stubHub) HandleStatusAlerts(status string, systemRecord *core.Record) error { return nil }
|
|
||||||
func (stubHub) CancelPendingStatusAlerts(systemID string) {}
|
|
||||||
|
|
||||||
// newTestSystemWithHub creates a System backed by a real (temp) database, along
|
|
||||||
// with a matching "systems" record, for tests that need to exercise DB reads/writes.
|
|
||||||
func newTestSystemWithHub(t *testing.T) (*System, *pbtests.TestApp) {
|
|
||||||
t.Helper()
|
|
||||||
testApp, err := pbtests.NewTestApp(t.TempDir())
|
|
||||||
require.NoError(t, err)
|
|
||||||
t.Cleanup(testApp.Cleanup)
|
|
||||||
|
|
||||||
sm := &SystemManager{hub: stubHub{testApp}, smartFetchMap: expirymap.New[smartFetchState](time.Hour)}
|
|
||||||
t.Cleanup(sm.smartFetchMap.StopCleaner)
|
|
||||||
|
|
||||||
col, err := testApp.FindCachedCollectionByNameOrId("systems")
|
|
||||||
require.NoError(t, err)
|
|
||||||
systemRecord := core.NewRecord(col)
|
|
||||||
systemRecord.Set("name", "test-system")
|
|
||||||
systemRecord.Set("host", "127.0.0.1")
|
|
||||||
systemRecord.Set("port", "45876")
|
|
||||||
require.NoError(t, testApp.SaveNoValidate(systemRecord))
|
|
||||||
|
|
||||||
sys := &System{Id: systemRecord.Id, manager: sm}
|
|
||||||
return sys, testApp
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRecordSmartFetchResult(t *testing.T) {
|
func TestRecordSmartFetchResult(t *testing.T) {
|
||||||
sm := &SystemManager{smartFetchMap: expirymap.New[smartFetchState](time.Hour)}
|
sm := &SystemManager{smartFetchMap: expirymap.New[smartFetchState](time.Hour)}
|
||||||
t.Cleanup(sm.smartFetchMap.StopCleaner)
|
t.Cleanup(sm.smartFetchMap.StopCleaner)
|
||||||
@@ -135,95 +92,3 @@ func TestResetFailedSmartFetchState(t *testing.T) {
|
|||||||
_, ok = sm.smartFetchMap.GetOk("system-1")
|
_, ok = sm.smartFetchMap.GetOk("system-1")
|
||||||
assert.True(t, ok, "expected successful smart fetch state to be preserved")
|
assert.True(t, ok, "expected successful smart fetch state to be preserved")
|
||||||
}
|
}
|
||||||
|
|
||||||
// countSmartDeviceRecords returns the number of smart_devices rows for the given system.
|
|
||||||
func countSmartDeviceRecords(t *testing.T, app core.App, systemID string) []*core.Record {
|
|
||||||
t.Helper()
|
|
||||||
records, err := app.FindAllRecords("smart_devices", nil)
|
|
||||||
require.NoError(t, err)
|
|
||||||
var forSystem []*core.Record
|
|
||||||
for _, r := range records {
|
|
||||||
if r.GetString("system") == systemID {
|
|
||||||
forSystem = append(forSystem, r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return forSystem
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSaveSmartDevices_RemovesStaleDevices(t *testing.T) {
|
|
||||||
sys, testApp := newTestSystemWithHub(t)
|
|
||||||
|
|
||||||
// first fetch reports two devices: sda (serial AAA) and sdb (serial BBB)
|
|
||||||
err := sys.saveSmartDevices(map[string]smart.SmartData{
|
|
||||||
"AAA": {SerialNumber: "AAA", DiskName: "sda", ModelName: "Disk A"},
|
|
||||||
"BBB": {SerialNumber: "BBB", DiskName: "sdb", ModelName: "Disk B"},
|
|
||||||
}, true)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
records := countSmartDeviceRecords(t, testApp, sys.Id)
|
|
||||||
require.Len(t, records, 2, "expected both devices to be saved")
|
|
||||||
|
|
||||||
var recordA *core.Record
|
|
||||||
for _, r := range records {
|
|
||||||
if r.GetString("serial") == "AAA" {
|
|
||||||
recordA = r
|
|
||||||
}
|
|
||||||
}
|
|
||||||
require.NotNil(t, recordA, "expected to find device AAA")
|
|
||||||
originalID := recordA.Id
|
|
||||||
|
|
||||||
deleteEvents := 0
|
|
||||||
testApp.OnRecordAfterDeleteSuccess("smart_devices").BindFunc(func(e *core.RecordEvent) error {
|
|
||||||
deleteEvents++
|
|
||||||
return e.Next()
|
|
||||||
})
|
|
||||||
|
|
||||||
// A complete refresh confirms that BBB is gone, so remove it through
|
|
||||||
// PocketBase and notify realtime subscribers.
|
|
||||||
err = sys.saveSmartDevices(map[string]smart.SmartData{
|
|
||||||
"AAA": {SerialNumber: "AAA", DiskName: "sda", ModelName: "Disk A", Temperature: 42},
|
|
||||||
}, true)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
records = countSmartDeviceRecords(t, testApp, sys.Id)
|
|
||||||
require.Len(t, records, 1, "expected stale device BBB to be removed")
|
|
||||||
assert.Equal(t, "AAA", records[0].GetString("serial"))
|
|
||||||
assert.Equal(t, originalID, records[0].Id, "expected existing device to be updated in place, not recreated")
|
|
||||||
assert.EqualValues(t, 42, records[0].GetInt("temp"))
|
|
||||||
assert.Equal(t, 1, deleteEvents, "expected PocketBase delete hooks to run")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSaveSmartDevices_IncompleteDataDoesNotRemoveDevices(t *testing.T) {
|
|
||||||
sys, testApp := newTestSystemWithHub(t)
|
|
||||||
|
|
||||||
require.NoError(t, sys.saveSmartDevices(map[string]smart.SmartData{
|
|
||||||
"AAA": {SerialNumber: "AAA", DiskName: "sda"},
|
|
||||||
"BBB": {SerialNumber: "BBB", DiskName: "sdb"},
|
|
||||||
}, true))
|
|
||||||
|
|
||||||
// AAA was collected but BBB failed. The response is useful for updating AAA,
|
|
||||||
// but it is not authoritative enough to remove BBB.
|
|
||||||
require.NoError(t, sys.saveSmartDevices(map[string]smart.SmartData{
|
|
||||||
"AAA": {SerialNumber: "AAA", DiskName: "sda", Temperature: 42},
|
|
||||||
}, false))
|
|
||||||
|
|
||||||
assert.Len(t, countSmartDeviceRecords(t, testApp, sys.Id), 2)
|
|
||||||
recordA, err := testApp.FindRecordById("smart_devices", makeStableHashId(sys.Id, "AAA"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.EqualValues(t, 42, recordA.GetInt("temp"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSaveSmartDevices_EmptyDataIsNoop(t *testing.T) {
|
|
||||||
sys, testApp := newTestSystemWithHub(t)
|
|
||||||
|
|
||||||
err := sys.saveSmartDevices(map[string]smart.SmartData{
|
|
||||||
"AAA": {SerialNumber: "AAA", DiskName: "sda"},
|
|
||||||
}, true)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
err = sys.saveSmartDevices(map[string]smart.SmartData{}, true)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
records := countSmartDeviceRecords(t, testApp, sys.Id)
|
|
||||||
assert.Len(t, records, 1, "empty fetch result should not delete existing devices")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ func TestGetSystemdServiceId(t *testing.T) {
|
|||||||
serviceName := "nginx.service"
|
serviceName := "nginx.service"
|
||||||
|
|
||||||
// Call multiple times and ensure same result
|
// Call multiple times and ensure same result
|
||||||
id1 := makeStableHashId(systemId, serviceName)
|
id1 := MakeStableHashId(systemId, serviceName)
|
||||||
id2 := makeStableHashId(systemId, serviceName)
|
id2 := MakeStableHashId(systemId, serviceName)
|
||||||
id3 := makeStableHashId(systemId, serviceName)
|
id3 := MakeStableHashId(systemId, serviceName)
|
||||||
|
|
||||||
assert.Equal(t, id1, id2)
|
assert.Equal(t, id1, id2)
|
||||||
assert.Equal(t, id2, id3)
|
assert.Equal(t, id2, id3)
|
||||||
@@ -29,10 +29,10 @@ func TestGetSystemdServiceId(t *testing.T) {
|
|||||||
serviceName1 := "nginx.service"
|
serviceName1 := "nginx.service"
|
||||||
serviceName2 := "apache.service"
|
serviceName2 := "apache.service"
|
||||||
|
|
||||||
id1 := makeStableHashId(systemId1, serviceName1)
|
id1 := MakeStableHashId(systemId1, serviceName1)
|
||||||
id2 := makeStableHashId(systemId2, serviceName1)
|
id2 := MakeStableHashId(systemId2, serviceName1)
|
||||||
id3 := makeStableHashId(systemId1, serviceName2)
|
id3 := MakeStableHashId(systemId1, serviceName2)
|
||||||
id4 := makeStableHashId(systemId2, serviceName2)
|
id4 := MakeStableHashId(systemId2, serviceName2)
|
||||||
|
|
||||||
// All IDs should be different
|
// All IDs should be different
|
||||||
assert.NotEqual(t, id1, id2)
|
assert.NotEqual(t, id1, id2)
|
||||||
@@ -56,14 +56,14 @@ func TestGetSystemdServiceId(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range testCases {
|
for _, tc := range testCases {
|
||||||
id := makeStableHashId(tc.systemId, tc.serviceName)
|
id := MakeStableHashId(tc.systemId, tc.serviceName)
|
||||||
// FNV-32 produces 8 hex characters
|
// FNV-32 produces 8 hex characters
|
||||||
assert.Len(t, id, 8, "ID should be 8 characters for systemId='%s', serviceName='%s'", tc.systemId, tc.serviceName)
|
assert.Len(t, id, 8, "ID should be 8 characters for systemId='%s', serviceName='%s'", tc.systemId, tc.serviceName)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("hexadecimal output", func(t *testing.T) {
|
t.Run("hexadecimal output", func(t *testing.T) {
|
||||||
id := makeStableHashId("test-system", "test-service")
|
id := MakeStableHashId("test-system", "test-service")
|
||||||
assert.NotEmpty(t, id)
|
assert.NotEmpty(t, id)
|
||||||
|
|
||||||
// Should only contain hexadecimal characters
|
// Should only contain hexadecimal characters
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
package systems
|
package systems
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
@@ -158,17 +157,3 @@ func TestCombinedData_MigrateDeprecatedFields(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSetDownAfterContextCancelled(t *testing.T) {
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
cancel()
|
|
||||||
// manager is nil on purpose: setDown must bail out before touching the app
|
|
||||||
sys := &System{Status: up, ctx: ctx}
|
|
||||||
|
|
||||||
if err := sys.setDown(nil); err != context.Canceled {
|
|
||||||
t.Fatalf("expected context.Canceled, got %v", err)
|
|
||||||
}
|
|
||||||
if sys.Status != up {
|
|
||||||
t.Fatalf("status should be untouched, got %q", sys.Status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ func TestSystemManagerNew(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
synctest.Test(t, func(t *testing.T) {
|
synctest.Test(t, func(t *testing.T) {
|
||||||
sm.ResetContextForTesting()
|
|
||||||
sm.Initialize()
|
sm.Initialize()
|
||||||
|
|
||||||
record, err := tests.CreateRecord(hub, "systems", map[string]any{
|
record, err := tests.CreateRecord(hub, "systems", map[string]any{
|
||||||
@@ -113,8 +112,6 @@ func TestSystemManagerNew(t *testing.T) {
|
|||||||
assert.False(t, sm.HasSystem(record.Id), "System should not exist in the store after deletion")
|
assert.False(t, sm.HasSystem(record.Id), "System should not exist in the store after deletion")
|
||||||
})
|
})
|
||||||
|
|
||||||
// The following subtests run outside the synctest bubble.
|
|
||||||
sm.ResetContextForTesting()
|
|
||||||
testOld(t, hub)
|
testOld(t, hub)
|
||||||
|
|
||||||
synctest.Test(t, func(t *testing.T) {
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
|||||||
@@ -117,11 +117,6 @@ func (sm *SystemManager) RemoveAllSystems() {
|
|||||||
sm.smartFetchMap.StopCleaner()
|
sm.smartFetchMap.StopCleaner()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResetContextForTesting replaces the manager context for a new synctest bubble.
|
|
||||||
func (sm *SystemManager) ResetContextForTesting() {
|
|
||||||
sm.ctx, sm.cancel = context.WithCancel(context.Background())
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *System) StopUpdater() {
|
func (s *System) StopUpdater() {
|
||||||
s.cancel()
|
s.cancel()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,23 +88,15 @@ func unmarshalLegacyResponse(resp common.AgentResponse, action common.WebSocketA
|
|||||||
*d = *resp.String
|
*d = *resp.String
|
||||||
return nil
|
return nil
|
||||||
case common.GetSmartData:
|
case common.GetSmartData:
|
||||||
switch d := dest.(type) {
|
d, ok := dest.(*map[string]smart.SmartData)
|
||||||
case *map[string]smart.SmartData:
|
if !ok {
|
||||||
|
return fmt.Errorf("unexpected dest type for GetSmartData: %T", dest)
|
||||||
|
}
|
||||||
if resp.SmartData == nil {
|
if resp.SmartData == nil {
|
||||||
return errors.New("no SMART data in response")
|
return errors.New("no SMART data in response")
|
||||||
}
|
}
|
||||||
*d = resp.SmartData
|
*d = resp.SmartData
|
||||||
return nil
|
return nil
|
||||||
case *smart.SmartDataResponse:
|
|
||||||
if resp.SmartData == nil {
|
|
||||||
return errors.New("no SMART data in response")
|
|
||||||
}
|
|
||||||
d.Data = resp.SmartData
|
|
||||||
d.Complete = resp.SmartComplete
|
|
||||||
return nil
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("unexpected dest type for GetSmartData: %T", dest)
|
|
||||||
}
|
|
||||||
case common.GetSystemdInfo:
|
case common.GetSystemdInfo:
|
||||||
d, ok := dest.(*systemd.ServiceDetails)
|
d, ok := dest.(*systemd.ServiceDetails)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
package transport
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/common"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/smart"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestUnmarshalSmartDataResponse(t *testing.T) {
|
|
||||||
for _, test := range []struct {
|
|
||||||
name string
|
|
||||||
complete bool
|
|
||||||
}{
|
|
||||||
{name: "complete response", complete: true},
|
|
||||||
{name: "older agent defaults to incomplete", complete: false},
|
|
||||||
} {
|
|
||||||
t.Run(test.name, func(t *testing.T) {
|
|
||||||
response := common.AgentResponse{
|
|
||||||
SmartData: map[string]smart.SmartData{
|
|
||||||
"AAA": {SerialNumber: "AAA"},
|
|
||||||
},
|
|
||||||
SmartComplete: test.complete,
|
|
||||||
}
|
|
||||||
var result smart.SmartDataResponse
|
|
||||||
require.NoError(t, UnmarshalResponse(response, common.GetSmartData, &result))
|
|
||||||
assert.Equal(t, test.complete, result.Complete)
|
|
||||||
assert.Equal(t, "AAA", result.Data["AAA"].SerialNumber)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
// Package utils provides utility functions for the hub.
|
// Package utils provides utility functions for the hub.
|
||||||
package utils
|
package utils
|
||||||
|
|
||||||
import "os"
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
)
|
||||||
|
|
||||||
// GetEnv retrieves an environment variable with a "BESZEL_HUB_" prefix, or falls back to the unprefixed key.
|
// GetEnv retrieves an environment variable with a "BESZEL_HUB_" prefix, or falls back to the unprefixed key.
|
||||||
func GetEnv(key string) (value string, exists bool) {
|
func GetEnv(key string) (value string, exists bool) {
|
||||||
@@ -10,3 +14,26 @@ func GetEnv(key string) (value string, exists bool) {
|
|||||||
}
|
}
|
||||||
return os.LookupEnv(key)
|
return os.LookupEnv(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// realtimeActiveForCollection checks if there are active WebSocket subscriptions for the given collection.
|
||||||
|
func RealtimeActiveForCollection(app core.App, collectionName string, validateFn func(filterQuery string) bool) bool {
|
||||||
|
broker := app.SubscriptionsBroker()
|
||||||
|
if broker.TotalClients() == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, client := range broker.Clients() {
|
||||||
|
subs := client.Subscriptions(collectionName)
|
||||||
|
if len(subs) > 0 {
|
||||||
|
if validateFn == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for k := range subs {
|
||||||
|
filter := subs[k].Query["filter"]
|
||||||
|
if validateFn(filter) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|||||||
@@ -1699,6 +1699,288 @@ func init() {
|
|||||||
"type": "base",
|
"type": "base",
|
||||||
"updateRule": null,
|
"updateRule": null,
|
||||||
"viewRule": null
|
"viewRule": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"createRule": null,
|
||||||
|
"deleteRule": null,
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "[a-z0-9]{10}",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text3208210256",
|
||||||
|
"max": 10,
|
||||||
|
"min": 6,
|
||||||
|
"name": "id",
|
||||||
|
"pattern": "^[a-z0-9]+$",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": true,
|
||||||
|
"required": true,
|
||||||
|
"system": true,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cascadeDelete": true,
|
||||||
|
"collectionId": "2hz5ncl8tizk5nx",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "np_system",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"minSelect": 0,
|
||||||
|
"name": "system",
|
||||||
|
"presentable": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "relation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "np_name",
|
||||||
|
"max": 200,
|
||||||
|
"min": 0,
|
||||||
|
"name": "name",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "np_target",
|
||||||
|
"max": 500,
|
||||||
|
"min": 1,
|
||||||
|
"name": "target",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "np_protocol",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"name": "protocol",
|
||||||
|
"presentable": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "select",
|
||||||
|
"values": [
|
||||||
|
"icmp",
|
||||||
|
"tcp",
|
||||||
|
"http"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "np_port",
|
||||||
|
"max": 65535,
|
||||||
|
"min": 0,
|
||||||
|
"name": "port",
|
||||||
|
"onlyInt": true,
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "np_interval",
|
||||||
|
"max": 3600,
|
||||||
|
"min": 1,
|
||||||
|
"name": "interval",
|
||||||
|
"onlyInt": true,
|
||||||
|
"presentable": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "number926446584",
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"name": "res",
|
||||||
|
"onlyInt": false,
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "number1006954605",
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"name": "resAvg1h",
|
||||||
|
"onlyInt": false,
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "number4267669802",
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"name": "resMin1h",
|
||||||
|
"onlyInt": false,
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "number591433223",
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"name": "resMax1h",
|
||||||
|
"onlyInt": false,
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "number3726709001",
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"name": "loss1h",
|
||||||
|
"onlyInt": false,
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "np_enabled",
|
||||||
|
"name": "enabled",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "bool"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate2990389176",
|
||||||
|
"name": "created",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": false,
|
||||||
|
"presentable": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "autodate"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "date3332085495",
|
||||||
|
"max": "",
|
||||||
|
"min": "",
|
||||||
|
"name": "updated",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "date"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"id": "np_probes_001",
|
||||||
|
"indexes": [
|
||||||
|
"CREATE INDEX ` + "`" + `idx_np_system_enabled` + "`" + ` ON ` + "`" + `network_probes` + "`" + ` (` + "`" + `system` + "`" + `)"
|
||||||
|
],
|
||||||
|
"listRule": null,
|
||||||
|
"name": "network_probes",
|
||||||
|
"system": false,
|
||||||
|
"type": "base",
|
||||||
|
"updateRule": null,
|
||||||
|
"viewRule": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"createRule": null,
|
||||||
|
"deleteRule": null,
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "[a-z0-9]{10}",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text3208210256",
|
||||||
|
"max": 10,
|
||||||
|
"min": 10,
|
||||||
|
"name": "id",
|
||||||
|
"pattern": "^[a-z0-9]+$",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": true,
|
||||||
|
"required": true,
|
||||||
|
"system": true,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cascadeDelete": true,
|
||||||
|
"collectionId": "2hz5ncl8tizk5nx",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "nps_system",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"minSelect": 0,
|
||||||
|
"name": "system",
|
||||||
|
"presentable": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "relation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "nps_stats",
|
||||||
|
"maxSize": 2000000,
|
||||||
|
"name": "stats",
|
||||||
|
"presentable": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "nps_type",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"name": "type",
|
||||||
|
"presentable": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "select",
|
||||||
|
"values": [
|
||||||
|
"1m",
|
||||||
|
"10m",
|
||||||
|
"20m",
|
||||||
|
"120m",
|
||||||
|
"480m"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "number2990389176",
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"name": "created",
|
||||||
|
"onlyInt": false,
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"id": "np_stats_001",
|
||||||
|
"indexes": [
|
||||||
|
"CREATE INDEX ` + "`" + `idx_nps_system_type_created` + "`" + ` ON ` + "`" + `network_probe_stats` + "`" + ` (\n ` + "`" + `system` + "`" + `,\n ` + "`" + `type` + "`" + `,\n ` + "`" + `created` + "`" + `\n)"
|
||||||
|
],
|
||||||
|
"listRule": null,
|
||||||
|
"name": "network_probe_stats",
|
||||||
|
"system": false,
|
||||||
|
"type": "base",
|
||||||
|
"updateRule": null,
|
||||||
|
"viewRule": null
|
||||||
}
|
}
|
||||||
]`
|
]`
|
||||||
|
|
||||||
57
internal/records/probe_averaging_test.go
Normal file
57
internal/records/probe_averaging_test.go
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
//go:build testing
|
||||||
|
|
||||||
|
package records_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/records"
|
||||||
|
"github.com/henrygd/beszel/internal/tests"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAverageProbeStats(t *testing.T) {
|
||||||
|
hub, err := tests.NewTestHub(t.TempDir())
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
rm := records.NewRecordManager(hub)
|
||||||
|
user, err := tests.CreateUser(hub, "probe-avg@example.com", "testtesttest")
|
||||||
|
require.NoError(t, err)
|
||||||
|
system, err := tests.CreateRecord(hub, "systems", map[string]any{
|
||||||
|
"name": "probe-avg-system",
|
||||||
|
"host": "localhost",
|
||||||
|
"port": "45876",
|
||||||
|
"status": "up",
|
||||||
|
"users": []string{user.Id},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
recordA, err := tests.CreateRecord(hub, "network_probe_stats", map[string]any{
|
||||||
|
"system": system.Id,
|
||||||
|
"type": "1m",
|
||||||
|
"stats": `{"icmp:1.1.1.1":[10,5,20,1.5]}`,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
recordB, err := tests.CreateRecord(hub, "network_probe_stats", map[string]any{
|
||||||
|
"system": system.Id,
|
||||||
|
"type": "1m",
|
||||||
|
"stats": `{"icmp:1.1.1.1":[22.5,10,60,0]}`,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
result := rm.AverageProbeStats(hub.DB(), records.RecordIds{
|
||||||
|
{Id: recordA.Id},
|
||||||
|
{Id: recordB.Id},
|
||||||
|
})
|
||||||
|
|
||||||
|
stats, ok := result["icmp:1.1.1.1"]
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Len(t, stats, 4)
|
||||||
|
assert.InDelta(t, 16.25, stats[0], 0.001) // avg of avg
|
||||||
|
assert.InDelta(t, 5, stats[1], 0.001) // min of mins
|
||||||
|
assert.InDelta(t, 60, stats[2], 0.001) // max of maxes
|
||||||
|
assert.InDelta(t, 0.75, stats[3], 0.001) // avg of packet loss
|
||||||
|
}
|
||||||
@@ -3,15 +3,17 @@ package records
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log/slog"
|
"log"
|
||||||
"math"
|
"math"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/container"
|
"github.com/henrygd/beszel/internal/entities/container"
|
||||||
|
"github.com/henrygd/beszel/internal/entities/probe"
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
|
||||||
"github.com/pocketbase/dbx"
|
"github.com/pocketbase/dbx"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
type RecordManager struct {
|
type RecordManager struct {
|
||||||
@@ -39,7 +41,7 @@ type StatsRecord struct {
|
|||||||
|
|
||||||
// Create longer records by averaging shorter records
|
// Create longer records by averaging shorter records
|
||||||
func (rm *RecordManager) CreateLongerRecords() {
|
func (rm *RecordManager) CreateLongerRecords() {
|
||||||
// start := time.Now()
|
now := time.Now().UTC()
|
||||||
longerRecordData := []LongerRecordData{
|
longerRecordData := []LongerRecordData{
|
||||||
{
|
{
|
||||||
shorterType: "1m",
|
shorterType: "1m",
|
||||||
@@ -68,18 +70,20 @@ func (rm *RecordManager) CreateLongerRecords() {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
// wrap the operations in a transaction
|
// wrap the operations in a transaction
|
||||||
// Pocketbase cron does not handle errors, log them here.
|
|
||||||
rm.app.RunInTransaction(func(txApp core.App) error {
|
rm.app.RunInTransaction(func(txApp core.App) error {
|
||||||
var err error
|
var err error
|
||||||
collections := [2]*core.Collection{}
|
|
||||||
|
collections := [3]*core.Collection{}
|
||||||
collections[0], err = txApp.FindCachedCollectionByNameOrId("system_stats")
|
collections[0], err = txApp.FindCachedCollectionByNameOrId("system_stats")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Error finding cached collection using system stats:", "err", err)
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
collections[1], err = txApp.FindCachedCollectionByNameOrId("container_stats")
|
collections[1], err = txApp.FindCachedCollectionByNameOrId("container_stats")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Error finding cached collection using container stats:", "err", err)
|
return err
|
||||||
|
}
|
||||||
|
collections[2], err = txApp.FindCachedCollectionByNameOrId("network_probe_stats")
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var systems RecordIds
|
var systems RecordIds
|
||||||
@@ -94,58 +98,74 @@ func (rm *RecordManager) CreateLongerRecords() {
|
|||||||
recordData := longerRecordData[i]
|
recordData := longerRecordData[i]
|
||||||
// log.Println("processing longer record type", recordData.longerType)
|
// log.Println("processing longer record type", recordData.longerType)
|
||||||
// add one minute padding for longer records because they are created slightly later than the job start time
|
// add one minute padding for longer records because they are created slightly later than the job start time
|
||||||
longerRecordPeriod := time.Now().UTC().Add(recordData.longerTimeDuration + time.Minute)
|
longerRecordPeriod := now.Add(recordData.longerTimeDuration + time.Minute)
|
||||||
// shorter records are created independently of longer records, so we shouldn't need to add padding
|
// shorter records are created independently of longer records, so we shouldn't need to add padding
|
||||||
shorterRecordPeriod := time.Now().UTC().Add(recordData.longerTimeDuration)
|
shorterRecordPeriod := now.Add(recordData.longerTimeDuration)
|
||||||
// loop through both collections
|
// loop through both collections
|
||||||
for _, collection := range collections {
|
for _, collection := range collections {
|
||||||
// check creation time of last longer record if not 10m, since 10m is created every run
|
// check creation time of last longer record if not 10m, since 10m is created every run
|
||||||
if recordData.longerType != "10m" {
|
if recordData.longerType != "10m" {
|
||||||
count, err := txApp.CountRecords(
|
var existingRecord struct {
|
||||||
collection.Id,
|
Id string
|
||||||
dbx.NewExp(
|
}
|
||||||
"system = {:system} AND type = {:type} AND created > {:created}",
|
|
||||||
dbx.Params{"type": recordData.longerType, "system": system.Id, "created": longerRecordPeriod},
|
params := dbx.Params{
|
||||||
),
|
"type": recordData.longerType,
|
||||||
)
|
"system": system.Id,
|
||||||
|
"created": getCreatedTimeField(collection.Name, longerRecordPeriod),
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = db.Select("id").
|
||||||
|
From(collection.Name).
|
||||||
|
Where(dbx.NewExp("system = {:system} AND type = {:type} AND created > {:created}", params)).
|
||||||
|
Limit(1).
|
||||||
|
One(&existingRecord)
|
||||||
|
|
||||||
// continue if longer record exists
|
// continue if longer record exists
|
||||||
if err != nil || count > 0 {
|
if existingRecord.Id != "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// get shorter records from the past x minutes
|
// get shorter records from the past x minutes
|
||||||
var recordIds RecordIds
|
var recordIds RecordIds
|
||||||
|
|
||||||
err := txApp.DB().
|
params := dbx.Params{
|
||||||
Select("id").
|
|
||||||
From(collection.Name).
|
|
||||||
AndWhere(dbx.NewExp(
|
|
||||||
"system={:system} AND type={:type} AND created > {:created}",
|
|
||||||
dbx.Params{
|
|
||||||
"type": recordData.shorterType,
|
"type": recordData.shorterType,
|
||||||
"system": system.Id,
|
"system": system.Id,
|
||||||
"created": shorterRecordPeriod,
|
"created": getCreatedTimeField(collection.Name, shorterRecordPeriod),
|
||||||
},
|
}
|
||||||
|
|
||||||
|
_ = txApp.DB().
|
||||||
|
Select("id").
|
||||||
|
From(collection.Name).
|
||||||
|
Where(dbx.NewExp(
|
||||||
|
"system={:system} AND type={:type} AND created > {:created}",
|
||||||
|
params,
|
||||||
)).
|
)).
|
||||||
All(&recordIds)
|
All(&recordIds)
|
||||||
|
|
||||||
// continue if not enough shorter records
|
// continue if not enough shorter records
|
||||||
if err != nil || len(recordIds) < recordData.minShorterRecords {
|
if len(recordIds) < recordData.minShorterRecords {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// average the shorter records and create longer record
|
// average the shorter records and create longer record
|
||||||
longerRecord := core.NewRecord(collection)
|
longerRecord := core.NewRecord(collection)
|
||||||
longerRecord.Set("system", system.Id)
|
longerRecord.Set("system", system.Id)
|
||||||
longerRecord.Set("type", recordData.longerType)
|
longerRecord.Set("type", recordData.longerType)
|
||||||
|
// network_probe_stats uses created as unix timestamp in milliseconds, so we need to set it manually here instead of relying on the default created field
|
||||||
|
if collection.Name == "network_probe_stats" {
|
||||||
|
longerRecord.Set("created", now.UnixMilli())
|
||||||
|
}
|
||||||
switch collection.Name {
|
switch collection.Name {
|
||||||
case "system_stats":
|
case "system_stats":
|
||||||
longerRecord.Set("stats", rm.AverageSystemStats(db, recordIds))
|
longerRecord.Set("stats", rm.AverageSystemStats(db, recordIds))
|
||||||
case "container_stats":
|
case "container_stats":
|
||||||
|
|
||||||
longerRecord.Set("stats", rm.AverageContainerStats(db, recordIds))
|
longerRecord.Set("stats", rm.AverageContainerStats(db, recordIds))
|
||||||
|
case "network_probe_stats":
|
||||||
|
longerRecord.Set("stats", rm.AverageProbeStats(db, recordIds))
|
||||||
}
|
}
|
||||||
if err := txApp.SaveNoValidate(longerRecord); err != nil {
|
if err := txApp.SaveNoValidate(longerRecord); err != nil {
|
||||||
slog.Error("failed to save longer record", "err", err)
|
log.Println("failed to save longer record", "err", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -154,7 +174,14 @@ func (rm *RecordManager) CreateLongerRecords() {
|
|||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
// log.Println("finished creating longer records", "time (ms)", time.Since(start).Milliseconds())
|
// slog.Info("finished creating longer records", "time (ms)", time.Since(now).Milliseconds())
|
||||||
|
}
|
||||||
|
|
||||||
|
func getCreatedTimeField(collectionName string, period time.Time) any {
|
||||||
|
if collectionName == "network_probe_stats" {
|
||||||
|
return period.UnixMilli()
|
||||||
|
}
|
||||||
|
return period.Format(types.DefaultDateLayout)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate the average stats of a list of system_stats records without reflect
|
// Calculate the average stats of a list of system_stats records without reflect
|
||||||
@@ -186,16 +213,11 @@ func AverageSystemStatsSlice(records []system.Stats) system.Stats {
|
|||||||
|
|
||||||
// necessary because uint8 is not big enough for the sum
|
// necessary because uint8 is not big enough for the sum
|
||||||
batterySum := 0
|
batterySum := 0
|
||||||
batteryCount := 0
|
|
||||||
batterySums := make(map[string]uint64)
|
|
||||||
batteryCounts := make(map[string]uint64)
|
|
||||||
// accumulate per-core usage across records
|
// accumulate per-core usage across records
|
||||||
var cpuCoresSums []uint64
|
var cpuCoresSums []uint64
|
||||||
// accumulate cpu breakdown [user, system, iowait, steal, idle]
|
// accumulate cpu breakdown [user, system, iowait, steal, idle]
|
||||||
var cpuBreakdownSums []float64
|
var cpuBreakdownSums []float64
|
||||||
tempCount := float64(0)
|
tempCount := float64(0)
|
||||||
var fanSums map[string]uint64
|
|
||||||
fanCount := uint64(0)
|
|
||||||
|
|
||||||
// Accumulate totals
|
// Accumulate totals
|
||||||
for i := range records {
|
for i := range records {
|
||||||
@@ -235,15 +257,8 @@ func AverageSystemStatsSlice(records []system.Stats) system.Stats {
|
|||||||
for i := range stats.DiskIoStats {
|
for i := range stats.DiskIoStats {
|
||||||
sum.DiskIoStats[i] += stats.DiskIoStats[i]
|
sum.DiskIoStats[i] += stats.DiskIoStats[i]
|
||||||
}
|
}
|
||||||
if hasBattery(stats.Battery, stats.Batteries) {
|
|
||||||
batterySum += int(stats.Battery[0])
|
batterySum += int(stats.Battery[0])
|
||||||
batteryCount++
|
|
||||||
sum.Battery[1] = stats.Battery[1]
|
sum.Battery[1] = stats.Battery[1]
|
||||||
}
|
|
||||||
for name, percent := range stats.Batteries {
|
|
||||||
batterySums[name] += uint64(percent)
|
|
||||||
batteryCounts[name]++
|
|
||||||
}
|
|
||||||
|
|
||||||
// accumulate per-core usage if present
|
// accumulate per-core usage if present
|
||||||
if stats.CpuCoresUsage != nil {
|
if stats.CpuCoresUsage != nil {
|
||||||
@@ -294,17 +309,6 @@ func AverageSystemStatsSlice(records []system.Stats) system.Stats {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Accumulate fan speeds
|
|
||||||
if stats.Fans != nil {
|
|
||||||
if fanSums == nil {
|
|
||||||
fanSums = make(map[string]uint64, len(stats.Fans))
|
|
||||||
}
|
|
||||||
fanCount++
|
|
||||||
for key, value := range stats.Fans {
|
|
||||||
fanSums[key] += uint64(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Accumulate extra filesystem stats
|
// Accumulate extra filesystem stats
|
||||||
if stats.ExtraFs != nil {
|
if stats.ExtraFs != nil {
|
||||||
if sum.ExtraFs == nil {
|
if sum.ExtraFs == nil {
|
||||||
@@ -389,15 +393,7 @@ func AverageSystemStatsSlice(records []system.Stats) system.Stats {
|
|||||||
sum.LoadAvg[2] = twoDecimals(sum.LoadAvg[2] / count)
|
sum.LoadAvg[2] = twoDecimals(sum.LoadAvg[2] / count)
|
||||||
sum.Bandwidth[0] = sum.Bandwidth[0] / uint64(count)
|
sum.Bandwidth[0] = sum.Bandwidth[0] / uint64(count)
|
||||||
sum.Bandwidth[1] = sum.Bandwidth[1] / uint64(count)
|
sum.Bandwidth[1] = sum.Bandwidth[1] / uint64(count)
|
||||||
if batteryCount > 0 {
|
sum.Battery[0] = uint8(batterySum / int(count))
|
||||||
sum.Battery[0] = uint8(batterySum / batteryCount)
|
|
||||||
}
|
|
||||||
if len(batterySums) > 0 {
|
|
||||||
sum.Batteries = make(map[string]uint8, len(batterySums))
|
|
||||||
for name, total := range batterySums {
|
|
||||||
sum.Batteries[name] = uint8(total / batteryCounts[name])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Average network interfaces
|
// Average network interfaces
|
||||||
if sum.NetworkInterfaces != nil {
|
if sum.NetworkInterfaces != nil {
|
||||||
@@ -418,14 +414,6 @@ func AverageSystemStatsSlice(records []system.Stats) system.Stats {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Average fan speeds
|
|
||||||
if fanSums != nil && fanCount > 0 {
|
|
||||||
sum.Fans = make(map[string]uint16, len(fanSums))
|
|
||||||
for key, value := range fanSums {
|
|
||||||
sum.Fans[key] = uint16(value / fanCount)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Average extra filesystem stats
|
// Average extra filesystem stats
|
||||||
if sum.ExtraFs != nil {
|
if sum.ExtraFs != nil {
|
||||||
for key := range sum.ExtraFs {
|
for key := range sum.ExtraFs {
|
||||||
@@ -485,10 +473,6 @@ func AverageSystemStatsSlice(records []system.Stats) system.Stats {
|
|||||||
return sum
|
return sum
|
||||||
}
|
}
|
||||||
|
|
||||||
func hasBattery(legacy [2]uint8, batteries map[string]uint8) bool {
|
|
||||||
return legacy != [2]uint8{} || len(batteries) > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate the average stats of a list of container_stats records
|
// Calculate the average stats of a list of container_stats records
|
||||||
func (rm *RecordManager) AverageContainerStats(db dbx.Builder, records RecordIds) []container.Stats {
|
func (rm *RecordManager) AverageContainerStats(db dbx.Builder, records RecordIds) []container.Stats {
|
||||||
allStats := make([][]container.Stats, 0, len(records))
|
allStats := make([][]container.Stats, 0, len(records))
|
||||||
@@ -546,6 +530,80 @@ func AverageContainerStatsSlice(records [][]container.Stats) []container.Stats {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AverageProbeStats averages probe stats across multiple records.
|
||||||
|
// For each probe key: avg of average fields, min of mins, and max of maxes.
|
||||||
|
func (rm *RecordManager) AverageProbeStats(db dbx.Builder, records RecordIds) map[string]probe.Stats {
|
||||||
|
type probeValues struct {
|
||||||
|
sums probe.Stats
|
||||||
|
counts []int
|
||||||
|
}
|
||||||
|
|
||||||
|
query := db.NewQuery("SELECT stats FROM network_probe_stats WHERE id = {:id}")
|
||||||
|
|
||||||
|
// accumulate sums for each probe key across records
|
||||||
|
sums := make(map[string]*probeValues)
|
||||||
|
var row StatsRecord
|
||||||
|
for _, rec := range records {
|
||||||
|
row.Stats = row.Stats[:0]
|
||||||
|
query.Bind(dbx.Params{"id": rec.Id}).One(&row)
|
||||||
|
var rawStats map[string]probe.Stats
|
||||||
|
if err := json.Unmarshal(row.Stats, &rawStats); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for key, vals := range rawStats {
|
||||||
|
s, ok := sums[key]
|
||||||
|
if !ok {
|
||||||
|
s = &probeValues{sums: make(probe.Stats, len(vals)), counts: make([]int, len(vals))}
|
||||||
|
sums[key] = s
|
||||||
|
}
|
||||||
|
if len(vals) > len(s.sums) {
|
||||||
|
expandedSums := make(probe.Stats, len(vals))
|
||||||
|
copy(expandedSums, s.sums)
|
||||||
|
s.sums = expandedSums
|
||||||
|
|
||||||
|
expandedCounts := make([]int, len(vals))
|
||||||
|
copy(expandedCounts, s.counts)
|
||||||
|
s.counts = expandedCounts
|
||||||
|
}
|
||||||
|
for i := range vals {
|
||||||
|
switch i {
|
||||||
|
case 1: // min fields
|
||||||
|
if s.counts[i] == 0 || vals[i] < s.sums[i] {
|
||||||
|
s.sums[i] = vals[i]
|
||||||
|
}
|
||||||
|
case 2: // max fields
|
||||||
|
if s.counts[i] == 0 || vals[i] > s.sums[i] {
|
||||||
|
s.sums[i] = vals[i]
|
||||||
|
}
|
||||||
|
default: // average fields
|
||||||
|
s.sums[i] += vals[i]
|
||||||
|
}
|
||||||
|
s.counts[i]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// compute final averages
|
||||||
|
result := make(map[string]probe.Stats, len(sums))
|
||||||
|
for key, s := range sums {
|
||||||
|
if len(s.counts) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for i := range s.sums {
|
||||||
|
switch i {
|
||||||
|
case 1, 2: // min and max fields should not be averaged
|
||||||
|
continue
|
||||||
|
default:
|
||||||
|
if s.counts[i] > 0 {
|
||||||
|
s.sums[i] = twoDecimals(s.sums[i] / float64(s.counts[i]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result[key] = s.sums
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
/* Round float to two decimals */
|
/* Round float to two decimals */
|
||||||
func twoDecimals(value float64) float64 {
|
func twoDecimals(value float64) float64 {
|
||||||
return math.Round(value*100) / 100
|
return math.Round(value*100) / 100
|
||||||
|
|||||||
@@ -291,28 +291,6 @@ func TestAverageSystemStatsSlice_Temperatures(t *testing.T) {
|
|||||||
assert.Equal(t, 80.0, result.Temperatures["gpu"])
|
assert.Equal(t, 80.0, result.Temperatures["gpu"])
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tests that fan speeds are averaged and records without fan data are excluded.
|
|
||||||
func TestAverageSystemStatsSlice_Fans(t *testing.T) {
|
|
||||||
input := []system.Stats{
|
|
||||||
{
|
|
||||||
Fans: map[string]uint16{"cpu": 60_000, "case": 1_000},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Fans: map[string]uint16{"cpu": 50_000, "case": 2_000},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// No fan data - should not affect fan averaging
|
|
||||||
Cpu: 30.0,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
result := records.AverageSystemStatsSlice(input)
|
|
||||||
|
|
||||||
require.NotNil(t, result.Fans)
|
|
||||||
assert.Equal(t, uint16(55_000), result.Fans["cpu"])
|
|
||||||
assert.Equal(t, uint16(1_500), result.Fans["case"])
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAverageSystemStatsSlice_NetworkInterfaces(t *testing.T) {
|
func TestAverageSystemStatsSlice_NetworkInterfaces(t *testing.T) {
|
||||||
input := []system.Stats{
|
input := []system.Stats{
|
||||||
{
|
{
|
||||||
@@ -602,28 +580,6 @@ func TestAverageSystemStatsSlice_BatteryLastChargeState(t *testing.T) {
|
|||||||
assert.Equal(t, uint8(0), result.Battery[1]) // last record's charge state
|
assert.Equal(t, uint8(0), result.Battery[1]) // last record's charge state
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAverageSystemStatsSlice_BatteriesIndependentSamples(t *testing.T) {
|
|
||||||
input := []system.Stats{
|
|
||||||
{Battery: [2]uint8{80, 4}, Batteries: map[string]uint8{"Primary": 80, "Mouse": 0}},
|
|
||||||
{Battery: [2]uint8{60, 3}, Batteries: map[string]uint8{"Primary": 60}},
|
|
||||||
{Battery: [2]uint8{30, 4}, Batteries: map[string]uint8{"Mouse": 40}},
|
|
||||||
{},
|
|
||||||
}
|
|
||||||
result := records.AverageSystemStatsSlice(input)
|
|
||||||
assert.Equal(t, map[string]uint8{"Primary": 70, "Mouse": 20}, result.Batteries)
|
|
||||||
assert.Equal(t, uint8(56), result.Battery[0], "representative battery excludes absent samples")
|
|
||||||
assert.Equal(t, uint8(4), result.Battery[1], "representative state comes from its latest sample")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAverageSystemStatsSlice_ZeroRepresentativeBattery(t *testing.T) {
|
|
||||||
result := records.AverageSystemStatsSlice([]system.Stats{
|
|
||||||
{Battery: [2]uint8{0, 1}, Batteries: map[string]uint8{"Primary": 0}},
|
|
||||||
{},
|
|
||||||
})
|
|
||||||
assert.Equal(t, [2]uint8{0, 1}, result.Battery)
|
|
||||||
assert.Equal(t, map[string]uint8{"Primary": 0}, result.Batteries)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAverageSystemStatsSlice_ThreeRecordsRounding(t *testing.T) {
|
func TestAverageSystemStatsSlice_ThreeRecordsRounding(t *testing.T) {
|
||||||
input := []system.Stats{
|
input := []system.Stats{
|
||||||
{Cpu: 10.0, Mem: 8.0},
|
{Cpu: 10.0, Mem: 8.0},
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package records
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/pocketbase/dbx"
|
"github.com/pocketbase/dbx"
|
||||||
@@ -12,7 +11,6 @@ import (
|
|||||||
|
|
||||||
// Delete old records
|
// Delete old records
|
||||||
func (rm *RecordManager) DeleteOldRecords() {
|
func (rm *RecordManager) DeleteOldRecords() {
|
||||||
// Pocketbase cron does not handle errors, log them here.
|
|
||||||
rm.app.RunInTransaction(func(txApp core.App) error {
|
rm.app.RunInTransaction(func(txApp core.App) error {
|
||||||
err := deleteOldSystemStats(txApp)
|
err := deleteOldSystemStats(txApp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -60,7 +58,7 @@ func deleteOldAlertsHistory(app core.App, countToKeep, countBeforeDeletion int)
|
|||||||
// Deletes system_stats records older than what is displayed in the UI
|
// Deletes system_stats records older than what is displayed in the UI
|
||||||
func deleteOldSystemStats(app core.App) error {
|
func deleteOldSystemStats(app core.App) error {
|
||||||
// Collections to process
|
// Collections to process
|
||||||
collections := [2]string{"system_stats", "container_stats"}
|
collections := [3]string{"system_stats", "container_stats", "network_probe_stats"}
|
||||||
|
|
||||||
// Record types and their retention periods
|
// Record types and their retention periods
|
||||||
type RecordDeletionData struct {
|
type RecordDeletionData struct {
|
||||||
@@ -76,26 +74,19 @@ func deleteOldSystemStats(app core.App) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
|
db := app.DB()
|
||||||
|
|
||||||
for _, collection := range collections {
|
for _, collection := range collections {
|
||||||
// Build the WHERE clause
|
query := db.Delete(collection, dbx.NewExp("type={:type} AND created<{:created}"))
|
||||||
var conditionParts []string
|
for _, rd := range recordData {
|
||||||
var params dbx.Params = make(map[string]any)
|
if _, err := query.Bind(dbx.Params{
|
||||||
for i := range recordData {
|
"type": rd.recordType,
|
||||||
rd := recordData[i]
|
"created": getCreatedTimeField(collection, now.Add(-rd.retention)),
|
||||||
// Create parameterized condition for this record type
|
}).Execute(); err != nil {
|
||||||
dateParam := fmt.Sprintf("date%d", i)
|
|
||||||
conditionParts = append(conditionParts, fmt.Sprintf("(type = '%s' AND created < {:%s})", rd.recordType, dateParam))
|
|
||||||
params[dateParam] = now.Add(-rd.retention)
|
|
||||||
}
|
|
||||||
// Combine conditions with OR
|
|
||||||
conditionStr := strings.Join(conditionParts, " OR ")
|
|
||||||
// Construct and execute the full raw query
|
|
||||||
rawQuery := fmt.Sprintf("DELETE FROM %s WHERE %s", collection, conditionStr)
|
|
||||||
if _, err := app.DB().NewQuery(rawQuery).Bind(params).Execute(); err != nil {
|
|
||||||
return fmt.Errorf("failed to delete from %s: %v", collection, err)
|
return fmt.Errorf("failed to delete from %s: %v", collection, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user