mirror of
https://github.com/henrygd/beszel.git
synced 2026-03-22 13:36:16 +01:00
Compare commits
1 Commits
v0.18.0
...
encoding/j
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47360c5bf1 |
@@ -1,48 +0,0 @@
|
|||||||
# Node.js dependencies
|
|
||||||
node_modules
|
|
||||||
internalsite/node_modules
|
|
||||||
|
|
||||||
# Go build artifacts and binaries
|
|
||||||
build
|
|
||||||
dist
|
|
||||||
*.exe
|
|
||||||
beszel-agent
|
|
||||||
beszel_data*
|
|
||||||
pb_data
|
|
||||||
data
|
|
||||||
temp
|
|
||||||
|
|
||||||
# Development and IDE files
|
|
||||||
.vscode
|
|
||||||
.idea*
|
|
||||||
*.swc
|
|
||||||
__debug_*
|
|
||||||
|
|
||||||
# Git and version control
|
|
||||||
.git
|
|
||||||
.gitignore
|
|
||||||
|
|
||||||
# Documentation and supplemental files
|
|
||||||
*.md
|
|
||||||
supplemental
|
|
||||||
freebsd-port
|
|
||||||
|
|
||||||
# Test files (exclude from production builds)
|
|
||||||
*_test.go
|
|
||||||
coverage
|
|
||||||
|
|
||||||
# Docker files
|
|
||||||
dockerfile_*
|
|
||||||
|
|
||||||
# Temporary files
|
|
||||||
*.tmp
|
|
||||||
*.bak
|
|
||||||
*.log
|
|
||||||
|
|
||||||
# OS specific files
|
|
||||||
.DS_Store
|
|
||||||
Thumbs.db
|
|
||||||
|
|
||||||
# .NET build artifacts
|
|
||||||
agent/lhm/obj
|
|
||||||
agent/lhm/bin
|
|
||||||
143
.github/workflows/docker-images.yml
vendored
143
.github/workflows/docker-images.yml
vendored
@@ -10,141 +10,51 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
max-parallel: 5
|
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
# henrygd/beszel
|
|
||||||
- image: henrygd/beszel
|
- image: henrygd/beszel
|
||||||
dockerfile: ./internal/dockerfile_hub
|
context: ./beszel
|
||||||
|
dockerfile: ./beszel/dockerfile_hub
|
||||||
|
registry: docker.io
|
||||||
|
username_secret: DOCKERHUB_USERNAME
|
||||||
|
password_secret: DOCKERHUB_TOKEN
|
||||||
|
|
||||||
|
- image: henrygd/beszel-agent
|
||||||
|
context: ./beszel
|
||||||
|
dockerfile: ./beszel/dockerfile_agent
|
||||||
registry: docker.io
|
registry: docker.io
|
||||||
username_secret: DOCKERHUB_USERNAME
|
username_secret: DOCKERHUB_USERNAME
|
||||||
password_secret: DOCKERHUB_TOKEN
|
password_secret: DOCKERHUB_TOKEN
|
||||||
tags: |
|
|
||||||
type=raw,value=edge
|
|
||||||
type=semver,pattern={{version}}
|
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
|
||||||
type=semver,pattern={{major}}
|
|
||||||
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
|
|
||||||
|
|
||||||
# henrygd/beszel-agent:alpine
|
|
||||||
- image: henrygd/beszel-agent
|
|
||||||
dockerfile: ./internal/dockerfile_agent_alpine
|
|
||||||
registry: docker.io
|
|
||||||
username_secret: DOCKERHUB_USERNAME
|
|
||||||
password_secret: DOCKERHUB_TOKEN
|
|
||||||
tags: |
|
|
||||||
type=raw,value=alpine
|
|
||||||
type=semver,pattern={{version}}-alpine
|
|
||||||
type=semver,pattern={{major}}.{{minor}}-alpine
|
|
||||||
type=semver,pattern={{major}}-alpine
|
|
||||||
|
|
||||||
# henrygd/beszel-agent-nvidia
|
|
||||||
- image: henrygd/beszel-agent-nvidia
|
- image: henrygd/beszel-agent-nvidia
|
||||||
dockerfile: ./internal/dockerfile_agent_nvidia
|
context: ./beszel
|
||||||
|
dockerfile: ./beszel/dockerfile_agent_nvidia
|
||||||
platforms: linux/amd64
|
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
|
||||||
tags: |
|
|
||||||
type=raw,value=edge
|
|
||||||
type=semver,pattern={{version}}
|
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
|
||||||
type=semver,pattern={{major}}
|
|
||||||
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
|
|
||||||
|
|
||||||
# henrygd/beszel-agent-intel
|
|
||||||
- image: henrygd/beszel-agent-intel
|
|
||||||
dockerfile: ./internal/dockerfile_agent_intel
|
|
||||||
platforms: linux/amd64
|
|
||||||
registry: docker.io
|
|
||||||
username_secret: DOCKERHUB_USERNAME
|
|
||||||
password_secret: DOCKERHUB_TOKEN
|
|
||||||
tags: |
|
|
||||||
type=raw,value=edge
|
|
||||||
type=semver,pattern={{version}}
|
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
|
||||||
type=semver,pattern={{major}}
|
|
||||||
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
|
|
||||||
|
|
||||||
# ghcr.io/henrygd/beszel
|
|
||||||
- image: ghcr.io/${{ github.repository }}/beszel
|
- image: ghcr.io/${{ github.repository }}/beszel
|
||||||
dockerfile: ./internal/dockerfile_hub
|
context: ./beszel
|
||||||
|
dockerfile: ./beszel/dockerfile_hub
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password_secret: GITHUB_TOKEN
|
password_secret: GITHUB_TOKEN
|
||||||
tags: |
|
|
||||||
type=raw,value=edge
|
|
||||||
type=semver,pattern={{version}}
|
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
|
||||||
type=semver,pattern={{major}}
|
|
||||||
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
|
|
||||||
|
|
||||||
# ghcr.io/henrygd/beszel-agent
|
|
||||||
- image: ghcr.io/${{ github.repository }}/beszel-agent
|
- image: ghcr.io/${{ github.repository }}/beszel-agent
|
||||||
dockerfile: ./internal/dockerfile_agent
|
context: ./beszel
|
||||||
|
dockerfile: ./beszel/dockerfile_agent
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password_secret: GITHUB_TOKEN
|
password_secret: GITHUB_TOKEN
|
||||||
tags: |
|
|
||||||
type=raw,value=edge
|
|
||||||
type=raw,value=latest
|
|
||||||
type=semver,pattern={{version}}
|
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
|
||||||
type=semver,pattern={{major}}
|
|
||||||
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
|
|
||||||
|
|
||||||
# ghcr.io/henrygd/beszel-agent-nvidia
|
|
||||||
- image: ghcr.io/${{ github.repository }}/beszel-agent-nvidia
|
- image: ghcr.io/${{ github.repository }}/beszel-agent-nvidia
|
||||||
dockerfile: ./internal/dockerfile_agent_nvidia
|
context: ./beszel
|
||||||
|
dockerfile: ./beszel/dockerfile_agent_nvidia
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password_secret: GITHUB_TOKEN
|
password_secret: GITHUB_TOKEN
|
||||||
tags: |
|
|
||||||
type=raw,value=edge
|
|
||||||
type=semver,pattern={{version}}
|
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
|
||||||
type=semver,pattern={{major}}
|
|
||||||
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
|
|
||||||
|
|
||||||
# ghcr.io/henrygd/beszel-agent-intel
|
|
||||||
- image: ghcr.io/${{ github.repository }}/beszel-agent-intel
|
|
||||||
dockerfile: ./internal/dockerfile_agent_intel
|
|
||||||
platforms: linux/amd64
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password_secret: GITHUB_TOKEN
|
|
||||||
tags: |
|
|
||||||
type=raw,value=edge
|
|
||||||
type=semver,pattern={{version}}
|
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
|
||||||
type=semver,pattern={{major}}
|
|
||||||
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
|
|
||||||
|
|
||||||
# ghcr.io/henrygd/beszel-agent:alpine
|
|
||||||
- image: ghcr.io/${{ github.repository }}/beszel-agent
|
|
||||||
dockerfile: ./internal/dockerfile_agent_alpine
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password_secret: GITHUB_TOKEN
|
|
||||||
tags: |
|
|
||||||
type=raw,value=alpine
|
|
||||||
type=semver,pattern={{version}}-alpine
|
|
||||||
type=semver,pattern={{major}}.{{minor}}-alpine
|
|
||||||
type=semver,pattern={{major}}-alpine
|
|
||||||
|
|
||||||
# henrygd/beszel-agent (keep at bottom so it gets built after :alpine and gets the latest tag)
|
|
||||||
- image: henrygd/beszel-agent
|
|
||||||
dockerfile: ./internal/dockerfile_agent
|
|
||||||
registry: docker.io
|
|
||||||
username_secret: DOCKERHUB_USERNAME
|
|
||||||
password_secret: DOCKERHUB_TOKEN
|
|
||||||
tags: |
|
|
||||||
type=raw,value=edge
|
|
||||||
type=semver,pattern={{version}}
|
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
|
||||||
type=semver,pattern={{major}}
|
|
||||||
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -158,10 +68,10 @@ jobs:
|
|||||||
uses: oven-sh/setup-bun@v2
|
uses: oven-sh/setup-bun@v2
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --no-save --cwd ./internal/site
|
run: bun install --no-save --cwd ./beszel/site
|
||||||
|
|
||||||
- name: Build site
|
- name: Build site
|
||||||
run: bun run --cwd ./internal/site build
|
run: bun run --cwd ./beszel/site build
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v3
|
||||||
@@ -174,13 +84,16 @@ jobs:
|
|||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: ${{ matrix.image }}
|
images: ${{ matrix.image }}
|
||||||
tags: ${{ matrix.tags }}
|
tags: |
|
||||||
|
type=raw,value=edge
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
type=semver,pattern={{major}}
|
||||||
|
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
|
||||||
|
|
||||||
# https://github.com/docker/login-action
|
# https://github.com/docker/login-action
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
env:
|
if: github.event_name != 'pull_request'
|
||||||
password_secret_exists: ${{ secrets[matrix.password_secret] != '' && 'true' || 'false' }}
|
|
||||||
if: github.event_name != 'pull_request' && env.password_secret_exists == 'true'
|
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ matrix.username || secrets[matrix.username_secret] }}
|
username: ${{ matrix.username || secrets[matrix.username_secret] }}
|
||||||
@@ -192,9 +105,9 @@ jobs:
|
|||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: ./
|
context: "${{ matrix.context }}"
|
||||||
file: ${{ matrix.dockerfile }}
|
file: ${{ matrix.dockerfile }}
|
||||||
platforms: ${{ matrix.platforms || 'linux/amd64,linux/arm64,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' }}
|
||||||
tags: ${{ steps.metadata.outputs.tags }}
|
tags: ${{ steps.metadata.outputs.tags }}
|
||||||
labels: ${{ steps.metadata.outputs.labels }}
|
labels: ${{ steps.metadata.outputs.labels }}
|
||||||
|
|||||||
17
.github/workflows/inactivity-actions.yml
vendored
17
.github/workflows/inactivity-actions.yml
vendored
@@ -10,25 +10,12 @@ permissions:
|
|||||||
pull-requests: write
|
pull-requests: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lock-inactive:
|
|
||||||
name: Lock Inactive Issues
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
steps:
|
|
||||||
- uses: klaasnicolaas/action-inactivity-lock@v1.1.3
|
|
||||||
id: lock
|
|
||||||
with:
|
|
||||||
days-inactive-issues: 14
|
|
||||||
lock-reason-issues: ""
|
|
||||||
# Action can not skip PRs, set it to 100 years to cover it.
|
|
||||||
days-inactive-prs: 36524
|
|
||||||
lock-reason-prs: ""
|
|
||||||
|
|
||||||
close-stale:
|
close-stale:
|
||||||
name: Close Stale Issues
|
name: Close Stale Issues
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- name: Close Stale Issues
|
- name: Close Stale Issues
|
||||||
uses: actions/stale@v10
|
uses: actions/stale@v9
|
||||||
with:
|
with:
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
@@ -45,8 +32,6 @@ jobs:
|
|||||||
# Timing
|
# Timing
|
||||||
days-before-issue-stale: 14
|
days-before-issue-stale: 14
|
||||||
days-before-issue-close: 7
|
days-before-issue-close: 7
|
||||||
# Action can not skip PRs, set it to 100 years to cover it.
|
|
||||||
days-before-pr-stale: 36524
|
|
||||||
|
|
||||||
# Labels
|
# Labels
|
||||||
stale-issue-label: 'stale'
|
stale-issue-label: 'stale'
|
||||||
|
|||||||
9
.github/workflows/release.yml
vendored
9
.github/workflows/release.yml
vendored
@@ -21,10 +21,10 @@ jobs:
|
|||||||
uses: oven-sh/setup-bun@v2
|
uses: oven-sh/setup-bun@v2
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --no-save --cwd ./internal/site
|
run: bun install --no-save --cwd ./beszel/site
|
||||||
|
|
||||||
- name: Build site
|
- name: Build site
|
||||||
run: bun run --cwd ./internal/site build
|
run: bun run --cwd ./beszel/site build
|
||||||
|
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
@@ -38,17 +38,16 @@ jobs:
|
|||||||
|
|
||||||
- name: Build .NET LHM executable for Windows sensors
|
- name: Build .NET LHM executable for Windows sensors
|
||||||
run: |
|
run: |
|
||||||
dotnet build -c Release ./agent/lhm/beszel_lhm.csproj
|
dotnet build -c Release ./beszel/internal/agent/lhm/beszel_lhm.csproj
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
- name: GoReleaser beszel
|
- name: GoReleaser beszel
|
||||||
uses: goreleaser/goreleaser-action@v6
|
uses: goreleaser/goreleaser-action@v6
|
||||||
with:
|
with:
|
||||||
workdir: ./
|
workdir: ./beszel
|
||||||
distribution: goreleaser
|
distribution: goreleaser
|
||||||
version: latest
|
version: latest
|
||||||
args: release --clean
|
args: release --clean
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.TOKEN || secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.TOKEN || secrets.GITHUB_TOKEN }}
|
||||||
WINGET_TOKEN: ${{ secrets.WINGET_TOKEN }}
|
WINGET_TOKEN: ${{ secrets.WINGET_TOKEN }}
|
||||||
IS_FORK: ${{ github.repository_owner != 'henrygd' }}
|
|
||||||
|
|||||||
8
.github/workflows/vulncheck.yml
vendored
8
.github/workflows/vulncheck.yml
vendored
@@ -15,7 +15,7 @@ permissions:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
vulncheck:
|
vulncheck:
|
||||||
name: VulnCheck
|
name: Analysis
|
||||||
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
|
||||||
@@ -23,11 +23,11 @@ jobs:
|
|||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: 1.25.x
|
go-version: 1.24.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
|
||||||
shell: bash
|
shell: bash
|
||||||
- name: Run govulncheck
|
- name: Run govulncheck
|
||||||
run: govulncheck -show verbose ./...
|
run: govulncheck -C ./beszel -show verbose ./...
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|||||||
12
.gitignore
vendored
12
.gitignore
vendored
@@ -8,15 +8,15 @@ beszel_data
|
|||||||
beszel_data*
|
beszel_data*
|
||||||
dist
|
dist
|
||||||
*.exe
|
*.exe
|
||||||
internal/cmd/hub/hub
|
beszel/cmd/hub/hub
|
||||||
internal/cmd/agent/agent
|
beszel/cmd/agent/agent
|
||||||
node_modules
|
node_modules
|
||||||
build
|
beszel/build
|
||||||
*timestamp*
|
*timestamp*
|
||||||
.swc
|
.swc
|
||||||
internal/site/src/locales/**/*.ts
|
beszel/site/src/locales/**/*.ts
|
||||||
*.bak
|
*.bak
|
||||||
__debug_*
|
__debug_*
|
||||||
agent/lhm/obj
|
beszel/internal/agent/lhm/obj
|
||||||
agent/lhm/bin
|
beszel/internal/agent/lhm/bin
|
||||||
dockerfile_agent_dev
|
dockerfile_agent_dev
|
||||||
|
|||||||
108
Makefile
108
Makefile
@@ -1,108 +0,0 @@
|
|||||||
# Default OS/ARCH values
|
|
||||||
OS ?= $(shell go env GOOS)
|
|
||||||
ARCH ?= $(shell go env GOARCH)
|
|
||||||
# Skip building the web UI if true
|
|
||||||
SKIP_WEB ?= false
|
|
||||||
|
|
||||||
# Set executable extension based on target OS
|
|
||||||
EXE_EXT := $(if $(filter windows,$(OS)),.exe,)
|
|
||||||
|
|
||||||
.PHONY: tidy build-agent build-hub build-hub-dev build clean lint dev-server dev-agent dev-hub dev generate-locales fetch-smartctl-conditional
|
|
||||||
.DEFAULT_GOAL := build
|
|
||||||
|
|
||||||
clean:
|
|
||||||
go clean
|
|
||||||
rm -rf ./build
|
|
||||||
|
|
||||||
lint:
|
|
||||||
golangci-lint run
|
|
||||||
|
|
||||||
test: export GOEXPERIMENT=synctest
|
|
||||||
test:
|
|
||||||
go test -tags=testing ./...
|
|
||||||
|
|
||||||
tidy:
|
|
||||||
go mod tidy
|
|
||||||
|
|
||||||
build-web-ui:
|
|
||||||
@if command -v bun >/dev/null 2>&1; then \
|
|
||||||
bun install --cwd ./internal/site && \
|
|
||||||
bun run --cwd ./internal/site build; \
|
|
||||||
else \
|
|
||||||
npm install --prefix ./internal/site && \
|
|
||||||
npm run --prefix ./internal/site build; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Conditional .NET build - only for Windows
|
|
||||||
build-dotnet-conditional:
|
|
||||||
@if [ "$(OS)" = "windows" ]; then \
|
|
||||||
echo "Building .NET executable for Windows..."; \
|
|
||||||
if command -v dotnet >/dev/null 2>&1; then \
|
|
||||||
rm -rf ./agent/lhm/bin; \
|
|
||||||
dotnet build -c Release ./agent/lhm/beszel_lhm.csproj; \
|
|
||||||
else \
|
|
||||||
echo "Error: dotnet not found. Install .NET SDK to build Windows agent."; \
|
|
||||||
exit 1; \
|
|
||||||
fi; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Download smartctl.exe at build time for Windows (skips if already present)
|
|
||||||
fetch-smartctl-conditional:
|
|
||||||
@if [ "$(OS)" = "windows" ]; then \
|
|
||||||
go generate -run fetchsmartctl ./agent; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Update build-agent to include conditional .NET build
|
|
||||||
build-agent: tidy build-dotnet-conditional fetch-smartctl-conditional
|
|
||||||
GOOS=$(OS) GOARCH=$(ARCH) go build -o ./build/beszel-agent_$(OS)_$(ARCH)$(EXE_EXT) -ldflags "-w -s" ./internal/cmd/agent
|
|
||||||
|
|
||||||
build-hub: tidy $(if $(filter false,$(SKIP_WEB)),build-web-ui)
|
|
||||||
GOOS=$(OS) GOARCH=$(ARCH) go build -o ./build/beszel_$(OS)_$(ARCH)$(EXE_EXT) -ldflags "-w -s" ./internal/cmd/hub
|
|
||||||
|
|
||||||
build-hub-dev: tidy
|
|
||||||
mkdir -p ./internal/site/dist && touch ./internal/site/dist/index.html
|
|
||||||
GOOS=$(OS) GOARCH=$(ARCH) go build -tags development -o ./build/beszel-dev_$(OS)_$(ARCH)$(EXE_EXT) -ldflags "-w -s" ./internal/cmd/hub
|
|
||||||
|
|
||||||
build: build-agent build-hub
|
|
||||||
|
|
||||||
generate-locales:
|
|
||||||
@if [ ! -f ./internal/site/src/locales/en/en.ts ]; then \
|
|
||||||
echo "Generating locales..."; \
|
|
||||||
command -v bun >/dev/null 2>&1 && cd ./internal/site && bun install && bun run sync || cd ./internal/site && npm install && npm run sync; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
dev-server: generate-locales
|
|
||||||
cd ./internal/site
|
|
||||||
@if command -v bun >/dev/null 2>&1; then \
|
|
||||||
cd ./internal/site && bun run dev --host 0.0.0.0; \
|
|
||||||
else \
|
|
||||||
cd ./internal/site && npm run dev --host 0.0.0.0; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
dev-hub: export ENV=dev
|
|
||||||
dev-hub:
|
|
||||||
mkdir -p ./internal/site/dist && touch ./internal/site/dist/index.html
|
|
||||||
@if command -v entr >/dev/null 2>&1; then \
|
|
||||||
find ./internal -type f -name '*.go' | entr -r -s "cd ./internal/cmd/hub && go run -tags development . serve --http 0.0.0.0:8090"; \
|
|
||||||
else \
|
|
||||||
cd ./internal/cmd/hub && go run -tags development . serve --http 0.0.0.0:8090; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
dev-agent:
|
|
||||||
@if command -v entr >/dev/null 2>&1; then \
|
|
||||||
find ./internal/cmd/agent/*.go ./agent/*.go | entr -r go run github.com/henrygd/beszel/internal/cmd/agent; \
|
|
||||||
else \
|
|
||||||
go run github.com/henrygd/beszel/internal/cmd/agent; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
build-dotnet:
|
|
||||||
@if command -v dotnet >/dev/null 2>&1; then \
|
|
||||||
rm -rf ./agent/lhm/bin; \
|
|
||||||
dotnet build -c Release ./agent/lhm/beszel_lhm.csproj; \
|
|
||||||
else \
|
|
||||||
echo "dotnet not found"; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
|
|
||||||
# KEY="..." make -j dev
|
|
||||||
dev: dev-server dev-hub dev-agent
|
|
||||||
265
agent/agent.go
265
agent/agent.go
@@ -1,265 +0,0 @@
|
|||||||
// Package agent implements the Beszel monitoring agent that collects and serves system metrics.
|
|
||||||
//
|
|
||||||
// The agent runs on monitored systems and communicates collected data
|
|
||||||
// to the Beszel hub for centralized monitoring and alerting.
|
|
||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"log/slog"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gliderlabs/ssh"
|
|
||||||
"github.com/henrygd/beszel"
|
|
||||||
"github.com/henrygd/beszel/agent/deltatracker"
|
|
||||||
"github.com/henrygd/beszel/internal/common"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
|
||||||
"github.com/shirou/gopsutil/v4/host"
|
|
||||||
gossh "golang.org/x/crypto/ssh"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Agent struct {
|
|
||||||
sync.Mutex // Used to lock agent while collecting data
|
|
||||||
debug bool // true if LOG_LEVEL is set to debug
|
|
||||||
zfs bool // true if system has arcstats
|
|
||||||
memCalc string // Memory calculation formula
|
|
||||||
fsNames []string // List of filesystem device names being monitored
|
|
||||||
fsStats map[string]*system.FsStats // Keeps track of disk stats for each filesystem
|
|
||||||
diskPrev map[uint16]map[string]prevDisk // Previous disk I/O counters per cache interval
|
|
||||||
diskUsageCacheDuration time.Duration // How long to cache disk usage (to avoid waking sleeping disks)
|
|
||||||
lastDiskUsageUpdate time.Time // Last time disk usage was collected
|
|
||||||
netInterfaces map[string]struct{} // Stores all valid network interfaces
|
|
||||||
netIoStats map[uint16]system.NetIoStats // Keeps track of bandwidth usage per cache interval
|
|
||||||
netInterfaceDeltaTrackers map[uint16]*deltatracker.DeltaTracker[string, uint64] // Per-cache-time NIC delta trackers
|
|
||||||
dockerManager *dockerManager // Manages Docker API requests
|
|
||||||
sensorConfig *SensorConfig // Sensors config
|
|
||||||
systemInfo system.Info // Host system info (dynamic)
|
|
||||||
systemDetails system.Details // Host system details (static, once-per-connection)
|
|
||||||
gpuManager *GPUManager // Manages GPU data
|
|
||||||
cache *systemDataCache // Cache for system stats based on cache time
|
|
||||||
connectionManager *ConnectionManager // Channel to signal connection events
|
|
||||||
handlerRegistry *HandlerRegistry // Registry for routing incoming messages
|
|
||||||
server *ssh.Server // SSH server
|
|
||||||
dataDir string // Directory for persisting data
|
|
||||||
keys []gossh.PublicKey // SSH public keys
|
|
||||||
smartManager *SmartManager // Manages SMART data
|
|
||||||
systemdManager *systemdManager // Manages systemd services
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewAgent creates a new agent with the given data directory for persisting data.
|
|
||||||
// If the data directory is not set, it will attempt to find the optimal directory.
|
|
||||||
func NewAgent(dataDir ...string) (agent *Agent, err error) {
|
|
||||||
agent = &Agent{
|
|
||||||
fsStats: make(map[string]*system.FsStats),
|
|
||||||
cache: NewSystemDataCache(),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize disk I/O previous counters storage
|
|
||||||
agent.diskPrev = make(map[uint16]map[string]prevDisk)
|
|
||||||
// Initialize per-cache-time network tracking structures
|
|
||||||
agent.netIoStats = make(map[uint16]system.NetIoStats)
|
|
||||||
agent.netInterfaceDeltaTrackers = make(map[uint16]*deltatracker.DeltaTracker[string, uint64])
|
|
||||||
|
|
||||||
agent.dataDir, err = getDataDir(dataDir...)
|
|
||||||
if err != nil {
|
|
||||||
slog.Warn("Data directory not found")
|
|
||||||
} else {
|
|
||||||
slog.Info("Data directory", "path", agent.dataDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
agent.memCalc, _ = GetEnv("MEM_CALC")
|
|
||||||
agent.sensorConfig = agent.newSensorConfig()
|
|
||||||
|
|
||||||
// Parse disk usage cache duration (e.g., "15m", "1h") to avoid waking sleeping disks
|
|
||||||
if diskUsageCache, exists := GetEnv("DISK_USAGE_CACHE"); exists {
|
|
||||||
if duration, err := time.ParseDuration(diskUsageCache); err == nil {
|
|
||||||
agent.diskUsageCacheDuration = duration
|
|
||||||
slog.Info("DISK_USAGE_CACHE", "duration", duration)
|
|
||||||
} else {
|
|
||||||
slog.Warn("Invalid DISK_USAGE_CACHE", "err", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set up slog with a log level determined by the LOG_LEVEL env var
|
|
||||||
if logLevelStr, exists := GetEnv("LOG_LEVEL"); exists {
|
|
||||||
switch strings.ToLower(logLevelStr) {
|
|
||||||
case "debug":
|
|
||||||
agent.debug = true
|
|
||||||
slog.SetLogLoggerLevel(slog.LevelDebug)
|
|
||||||
case "warn":
|
|
||||||
slog.SetLogLoggerLevel(slog.LevelWarn)
|
|
||||||
case "error":
|
|
||||||
slog.SetLogLoggerLevel(slog.LevelError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
slog.Debug(beszel.Version)
|
|
||||||
|
|
||||||
// initialize docker manager
|
|
||||||
agent.dockerManager = newDockerManager()
|
|
||||||
|
|
||||||
// initialize system info
|
|
||||||
agent.refreshSystemDetails()
|
|
||||||
|
|
||||||
// SMART_INTERVAL env var to update smart data at this interval
|
|
||||||
if smartIntervalEnv, exists := GetEnv("SMART_INTERVAL"); exists {
|
|
||||||
if duration, err := time.ParseDuration(smartIntervalEnv); err == nil && duration > 0 {
|
|
||||||
agent.systemDetails.SmartInterval = duration
|
|
||||||
slog.Info("SMART_INTERVAL", "duration", duration)
|
|
||||||
} else {
|
|
||||||
slog.Warn("Invalid SMART_INTERVAL", "err", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// initialize connection manager
|
|
||||||
agent.connectionManager = newConnectionManager(agent)
|
|
||||||
|
|
||||||
// initialize handler registry
|
|
||||||
agent.handlerRegistry = NewHandlerRegistry()
|
|
||||||
|
|
||||||
// initialize disk info
|
|
||||||
agent.initializeDiskInfo()
|
|
||||||
|
|
||||||
// initialize net io stats
|
|
||||||
agent.initializeNetIoStats()
|
|
||||||
|
|
||||||
agent.systemdManager, err = newSystemdManager()
|
|
||||||
if err != nil {
|
|
||||||
slog.Debug("Systemd", "err", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
agent.smartManager, err = NewSmartManager()
|
|
||||||
if err != nil {
|
|
||||||
slog.Debug("SMART", "err", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// initialize GPU manager
|
|
||||||
agent.gpuManager, err = NewGPUManager()
|
|
||||||
if err != nil {
|
|
||||||
slog.Debug("GPU", "err", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// if debugging, print stats
|
|
||||||
if agent.debug {
|
|
||||||
slog.Debug("Stats", "data", agent.gatherStats(common.DataRequestOptions{CacheTimeMs: 60_000, IncludeDetails: true}))
|
|
||||||
}
|
|
||||||
|
|
||||||
return agent, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetEnv retrieves an environment variable with a "BESZEL_AGENT_" prefix, or falls back to the unprefixed key.
|
|
||||||
func GetEnv(key string) (value string, exists bool) {
|
|
||||||
if value, exists = os.LookupEnv("BESZEL_AGENT_" + key); exists {
|
|
||||||
return value, exists
|
|
||||||
}
|
|
||||||
// Fallback to the old unprefixed key
|
|
||||||
return os.LookupEnv(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedData {
|
|
||||||
a.Lock()
|
|
||||||
defer a.Unlock()
|
|
||||||
|
|
||||||
cacheTimeMs := options.CacheTimeMs
|
|
||||||
data, isCached := a.cache.Get(cacheTimeMs)
|
|
||||||
if isCached {
|
|
||||||
slog.Debug("Cached data", "cacheTimeMs", cacheTimeMs)
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
*data = system.CombinedData{
|
|
||||||
Stats: a.getSystemStats(cacheTimeMs),
|
|
||||||
Info: a.systemInfo,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Include static system details only when requested
|
|
||||||
if options.IncludeDetails {
|
|
||||||
data.Details = &a.systemDetails
|
|
||||||
}
|
|
||||||
|
|
||||||
// slog.Info("System data", "data", data, "cacheTimeMs", cacheTimeMs)
|
|
||||||
|
|
||||||
if a.dockerManager != nil {
|
|
||||||
if containerStats, err := a.dockerManager.getDockerStats(cacheTimeMs); err == nil {
|
|
||||||
data.Containers = containerStats
|
|
||||||
slog.Debug("Containers", "data", data.Containers)
|
|
||||||
} else {
|
|
||||||
slog.Debug("Containers", "err", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// skip updating systemd services if cache time is not the default 60sec interval
|
|
||||||
if a.systemdManager != nil && cacheTimeMs == 60_000 {
|
|
||||||
totalCount := uint16(a.systemdManager.getServiceStatsCount())
|
|
||||||
if totalCount > 0 {
|
|
||||||
numFailed := a.systemdManager.getFailedServiceCount()
|
|
||||||
data.Info.Services = []uint16{totalCount, numFailed}
|
|
||||||
}
|
|
||||||
if a.systemdManager.hasFreshStats {
|
|
||||||
data.SystemdServices = a.systemdManager.getServiceStats(nil, false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data.Stats.ExtraFs = make(map[string]*system.FsStats)
|
|
||||||
data.Info.ExtraFsPct = make(map[string]float64)
|
|
||||||
for name, stats := range a.fsStats {
|
|
||||||
if !stats.Root && stats.DiskTotal > 0 {
|
|
||||||
// Use custom name if available, otherwise use device name
|
|
||||||
key := name
|
|
||||||
if stats.Name != "" {
|
|
||||||
key = stats.Name
|
|
||||||
}
|
|
||||||
data.Stats.ExtraFs[key] = stats
|
|
||||||
// Add percentages to Info struct for dashboard
|
|
||||||
if stats.DiskTotal > 0 {
|
|
||||||
pct := twoDecimals((stats.DiskUsed / stats.DiskTotal) * 100)
|
|
||||||
data.Info.ExtraFsPct[key] = pct
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
slog.Debug("Extra FS", "data", data.Stats.ExtraFs)
|
|
||||||
|
|
||||||
a.cache.Set(data, cacheTimeMs)
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
// StartAgent initializes and starts the agent with optional WebSocket connection
|
|
||||||
func (a *Agent) Start(serverOptions ServerOptions) error {
|
|
||||||
a.keys = serverOptions.Keys
|
|
||||||
return a.connectionManager.Start(serverOptions)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *Agent) getFingerprint() string {
|
|
||||||
// first look for a fingerprint in the data directory
|
|
||||||
if a.dataDir != "" {
|
|
||||||
if fp, err := os.ReadFile(filepath.Join(a.dataDir, "fingerprint")); err == nil {
|
|
||||||
return string(fp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// if no fingerprint is found, generate one
|
|
||||||
fingerprint, err := host.HostID()
|
|
||||||
// we ignore a commonly known "product_uuid" known not to be unique
|
|
||||||
if err != nil || fingerprint == "" || fingerprint == "03000200-0400-0500-0006-000700080009" {
|
|
||||||
fingerprint = a.systemDetails.Hostname + a.systemDetails.CpuModel
|
|
||||||
}
|
|
||||||
|
|
||||||
// hash fingerprint
|
|
||||||
sum := sha256.Sum256([]byte(fingerprint))
|
|
||||||
fingerprint = hex.EncodeToString(sum[:24])
|
|
||||||
|
|
||||||
// save fingerprint to data directory
|
|
||||||
if a.dataDir != "" {
|
|
||||||
err = os.WriteFile(filepath.Join(a.dataDir, "fingerprint"), []byte(fingerprint), 0644)
|
|
||||||
if err != nil {
|
|
||||||
slog.Warn("Failed to save fingerprint", "err", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return fingerprint
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
|
||||||
)
|
|
||||||
|
|
||||||
type systemDataCache struct {
|
|
||||||
sync.RWMutex
|
|
||||||
cache map[uint16]*cacheNode
|
|
||||||
}
|
|
||||||
|
|
||||||
type cacheNode struct {
|
|
||||||
data *system.CombinedData
|
|
||||||
lastUpdate time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewSystemDataCache creates a cache keyed by the polling interval in milliseconds.
|
|
||||||
func NewSystemDataCache() *systemDataCache {
|
|
||||||
return &systemDataCache{
|
|
||||||
cache: make(map[uint16]*cacheNode),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get returns cached combined data when the entry is still considered fresh.
|
|
||||||
func (c *systemDataCache) Get(cacheTimeMs uint16) (stats *system.CombinedData, isCached bool) {
|
|
||||||
c.RLock()
|
|
||||||
defer c.RUnlock()
|
|
||||||
|
|
||||||
node, ok := c.cache[cacheTimeMs]
|
|
||||||
if !ok {
|
|
||||||
return &system.CombinedData{}, false
|
|
||||||
}
|
|
||||||
// allowedSkew := time.Second
|
|
||||||
// isFresh := time.Since(node.lastUpdate) < time.Duration(cacheTimeMs)*time.Millisecond-allowedSkew
|
|
||||||
// allow a 50% skew of the cache time
|
|
||||||
isFresh := time.Since(node.lastUpdate) < time.Duration(cacheTimeMs/2)*time.Millisecond
|
|
||||||
return node.data, isFresh
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set stores the latest combined data snapshot for the given interval.
|
|
||||||
func (c *systemDataCache) Set(data *system.CombinedData, cacheTimeMs uint16) {
|
|
||||||
c.Lock()
|
|
||||||
defer c.Unlock()
|
|
||||||
|
|
||||||
node, ok := c.cache[cacheTimeMs]
|
|
||||||
if !ok {
|
|
||||||
node = &cacheNode{}
|
|
||||||
c.cache[cacheTimeMs] = node
|
|
||||||
}
|
|
||||||
node.data = data
|
|
||||||
node.lastUpdate = time.Now()
|
|
||||||
}
|
|
||||||
@@ -1,246 +0,0 @@
|
|||||||
//go:build testing
|
|
||||||
// +build testing
|
|
||||||
|
|
||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"testing/synctest"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/container"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func createTestCacheData() *system.CombinedData {
|
|
||||||
return &system.CombinedData{
|
|
||||||
Stats: system.Stats{
|
|
||||||
Cpu: 50.5,
|
|
||||||
Mem: 8192,
|
|
||||||
DiskTotal: 100000,
|
|
||||||
},
|
|
||||||
Info: system.Info{
|
|
||||||
AgentVersion: "0.12.0",
|
|
||||||
},
|
|
||||||
Containers: []*container.Stats{
|
|
||||||
{
|
|
||||||
Name: "test-container",
|
|
||||||
Cpu: 25.0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewSystemDataCache(t *testing.T) {
|
|
||||||
cache := NewSystemDataCache()
|
|
||||||
require.NotNil(t, cache)
|
|
||||||
assert.NotNil(t, cache.cache)
|
|
||||||
assert.Empty(t, cache.cache)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCacheGetSet(t *testing.T) {
|
|
||||||
cache := NewSystemDataCache()
|
|
||||||
data := createTestCacheData()
|
|
||||||
|
|
||||||
// Test setting data
|
|
||||||
cache.Set(data, 1000) // 1 second cache
|
|
||||||
|
|
||||||
// Test getting fresh data
|
|
||||||
retrieved, isCached := cache.Get(1000)
|
|
||||||
assert.True(t, isCached)
|
|
||||||
assert.Equal(t, data, retrieved)
|
|
||||||
|
|
||||||
// Test getting non-existent cache key
|
|
||||||
_, isCached = cache.Get(2000)
|
|
||||||
assert.False(t, isCached)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCacheFreshness(t *testing.T) {
|
|
||||||
cache := NewSystemDataCache()
|
|
||||||
data := createTestCacheData()
|
|
||||||
|
|
||||||
testCases := []struct {
|
|
||||||
name string
|
|
||||||
cacheTimeMs uint16
|
|
||||||
sleepMs time.Duration
|
|
||||||
expectFresh bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "fresh data - well within cache time",
|
|
||||||
cacheTimeMs: 1000, // 1 second
|
|
||||||
sleepMs: 100, // 100ms
|
|
||||||
expectFresh: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "fresh data - at 50% of cache time boundary",
|
|
||||||
cacheTimeMs: 1000, // 1 second, 50% = 500ms
|
|
||||||
sleepMs: 499, // just under 500ms
|
|
||||||
expectFresh: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "stale data - exactly at 50% cache time",
|
|
||||||
cacheTimeMs: 1000, // 1 second, 50% = 500ms
|
|
||||||
sleepMs: 500, // exactly 500ms
|
|
||||||
expectFresh: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "stale data - well beyond cache time",
|
|
||||||
cacheTimeMs: 1000, // 1 second
|
|
||||||
sleepMs: 800, // 800ms
|
|
||||||
expectFresh: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "short cache time",
|
|
||||||
cacheTimeMs: 200, // 200ms, 50% = 100ms
|
|
||||||
sleepMs: 150, // 150ms > 100ms
|
|
||||||
expectFresh: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range testCases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
synctest.Test(t, func(t *testing.T) {
|
|
||||||
// Set data
|
|
||||||
cache.Set(data, tc.cacheTimeMs)
|
|
||||||
|
|
||||||
// Wait for the specified duration
|
|
||||||
if tc.sleepMs > 0 {
|
|
||||||
time.Sleep(tc.sleepMs * time.Millisecond)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check freshness
|
|
||||||
_, isCached := cache.Get(tc.cacheTimeMs)
|
|
||||||
assert.Equal(t, tc.expectFresh, isCached)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCacheMultipleIntervals(t *testing.T) {
|
|
||||||
synctest.Test(t, func(t *testing.T) {
|
|
||||||
cache := NewSystemDataCache()
|
|
||||||
data1 := createTestCacheData()
|
|
||||||
data2 := &system.CombinedData{
|
|
||||||
Stats: system.Stats{
|
|
||||||
Cpu: 75.0,
|
|
||||||
Mem: 16384,
|
|
||||||
},
|
|
||||||
Info: system.Info{
|
|
||||||
AgentVersion: "0.12.0",
|
|
||||||
},
|
|
||||||
Containers: []*container.Stats{},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set data for different intervals
|
|
||||||
cache.Set(data1, 500) // 500ms cache
|
|
||||||
cache.Set(data2, 1000) // 1000ms cache
|
|
||||||
|
|
||||||
// Both should be fresh immediately
|
|
||||||
retrieved1, isCached1 := cache.Get(500)
|
|
||||||
assert.True(t, isCached1)
|
|
||||||
assert.Equal(t, data1, retrieved1)
|
|
||||||
|
|
||||||
retrieved2, isCached2 := cache.Get(1000)
|
|
||||||
assert.True(t, isCached2)
|
|
||||||
assert.Equal(t, data2, retrieved2)
|
|
||||||
|
|
||||||
// Wait 300ms - 500ms cache should be stale (250ms threshold), 1000ms should still be fresh (500ms threshold)
|
|
||||||
time.Sleep(300 * time.Millisecond)
|
|
||||||
|
|
||||||
_, isCached1 = cache.Get(500)
|
|
||||||
assert.False(t, isCached1)
|
|
||||||
|
|
||||||
_, isCached2 = cache.Get(1000)
|
|
||||||
assert.True(t, isCached2)
|
|
||||||
|
|
||||||
// Wait another 300ms (total 600ms) - now 1000ms cache should also be stale
|
|
||||||
time.Sleep(300 * time.Millisecond)
|
|
||||||
_, isCached2 = cache.Get(1000)
|
|
||||||
assert.False(t, isCached2)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCacheOverwrite(t *testing.T) {
|
|
||||||
cache := NewSystemDataCache()
|
|
||||||
data1 := createTestCacheData()
|
|
||||||
data2 := &system.CombinedData{
|
|
||||||
Stats: system.Stats{
|
|
||||||
Cpu: 90.0,
|
|
||||||
Mem: 32768,
|
|
||||||
},
|
|
||||||
Info: system.Info{
|
|
||||||
AgentVersion: "0.12.0",
|
|
||||||
},
|
|
||||||
Containers: []*container.Stats{},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set initial data
|
|
||||||
cache.Set(data1, 1000)
|
|
||||||
retrieved, isCached := cache.Get(1000)
|
|
||||||
assert.True(t, isCached)
|
|
||||||
assert.Equal(t, data1, retrieved)
|
|
||||||
|
|
||||||
// Overwrite with new data
|
|
||||||
cache.Set(data2, 1000)
|
|
||||||
retrieved, isCached = cache.Get(1000)
|
|
||||||
assert.True(t, isCached)
|
|
||||||
assert.Equal(t, data2, retrieved)
|
|
||||||
assert.NotEqual(t, data1, retrieved)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCacheMiss(t *testing.T) {
|
|
||||||
synctest.Test(t, func(t *testing.T) {
|
|
||||||
cache := NewSystemDataCache()
|
|
||||||
|
|
||||||
// Test getting from empty cache
|
|
||||||
_, isCached := cache.Get(1000)
|
|
||||||
assert.False(t, isCached)
|
|
||||||
|
|
||||||
// Set data for one interval
|
|
||||||
data := createTestCacheData()
|
|
||||||
cache.Set(data, 1000)
|
|
||||||
|
|
||||||
// Test getting different interval
|
|
||||||
_, isCached = cache.Get(2000)
|
|
||||||
assert.False(t, isCached)
|
|
||||||
|
|
||||||
// Test getting after data has expired
|
|
||||||
time.Sleep(600 * time.Millisecond) // 600ms > 500ms (50% of 1000ms)
|
|
||||||
_, isCached = cache.Get(1000)
|
|
||||||
assert.False(t, isCached)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCacheZeroInterval(t *testing.T) {
|
|
||||||
cache := NewSystemDataCache()
|
|
||||||
data := createTestCacheData()
|
|
||||||
|
|
||||||
// Set with zero interval - should allow immediate cache
|
|
||||||
cache.Set(data, 0)
|
|
||||||
|
|
||||||
// With 0 interval, 50% is 0, so it should never be considered fresh
|
|
||||||
// (time.Since(lastUpdate) >= 0, which is not < 0)
|
|
||||||
_, isCached := cache.Get(0)
|
|
||||||
assert.False(t, isCached)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCacheLargeInterval(t *testing.T) {
|
|
||||||
synctest.Test(t, func(t *testing.T) {
|
|
||||||
cache := NewSystemDataCache()
|
|
||||||
data := createTestCacheData()
|
|
||||||
|
|
||||||
// Test with maximum uint16 value
|
|
||||||
cache.Set(data, 65535) // ~65 seconds
|
|
||||||
|
|
||||||
// Should be fresh immediately
|
|
||||||
_, isCached := cache.Get(65535)
|
|
||||||
assert.True(t, isCached)
|
|
||||||
|
|
||||||
// Should still be fresh after a short time
|
|
||||||
time.Sleep(100 * time.Millisecond)
|
|
||||||
_, isCached = cache.Get(65535)
|
|
||||||
assert.True(t, isCached)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
//go:build !freebsd
|
|
||||||
|
|
||||||
// Package battery provides functions to check if the system has a battery and to get the battery stats.
|
|
||||||
package battery
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"log/slog"
|
|
||||||
"math"
|
|
||||||
|
|
||||||
"github.com/distatus/battery"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
systemHasBattery = false
|
|
||||||
haveCheckedBattery = false
|
|
||||||
)
|
|
||||||
|
|
||||||
// HasReadableBattery checks if the system has a battery and returns true if it does.
|
|
||||||
func HasReadableBattery() bool {
|
|
||||||
if haveCheckedBattery {
|
|
||||||
return systemHasBattery
|
|
||||||
}
|
|
||||||
haveCheckedBattery = true
|
|
||||||
batteries, err := battery.GetAll()
|
|
||||||
for _, bat := range batteries {
|
|
||||||
if bat != nil && (bat.Full > 0 || bat.Design > 0) {
|
|
||||||
systemHasBattery = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !systemHasBattery {
|
|
||||||
slog.Debug("No battery found", "err", err)
|
|
||||||
}
|
|
||||||
return systemHasBattery
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetBatteryStats returns the current battery percent and charge state
|
|
||||||
// percent = (current charge of all batteries) / (sum of designed/full capacity of all batteries)
|
|
||||||
func GetBatteryStats() (batteryPercent uint8, batteryState uint8, err error) {
|
|
||||||
if !HasReadableBattery() {
|
|
||||||
return batteryPercent, batteryState, errors.ErrUnsupported
|
|
||||||
}
|
|
||||||
batteries, err := battery.GetAll()
|
|
||||||
// we'll handle errors later by skipping batteries with errors, rather
|
|
||||||
// than skipping everything because of the presence of some errors.
|
|
||||||
if len(batteries) == 0 {
|
|
||||||
return batteryPercent, batteryState, errors.New("no batteries")
|
|
||||||
}
|
|
||||||
|
|
||||||
totalCapacity := float64(0)
|
|
||||||
totalCharge := float64(0)
|
|
||||||
errs, partialErrs := err.(battery.Errors)
|
|
||||||
|
|
||||||
batteryState = math.MaxUint8
|
|
||||||
|
|
||||||
for i, bat := range batteries {
|
|
||||||
if partialErrs && errs[i] != nil {
|
|
||||||
// if there were some errors, like missing data, skip it
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if bat == nil || bat.Full == 0 {
|
|
||||||
// skip batteries with no capacity. Charge is unlikely to ever be zero, but
|
|
||||||
// we can't guarantee that, so don't skip based on charge.
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
totalCapacity += bat.Full
|
|
||||||
totalCharge += bat.Current
|
|
||||||
if bat.State.Raw >= 0 {
|
|
||||||
batteryState = uint8(bat.State.Raw)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if totalCapacity == 0 || batteryState == math.MaxUint8 {
|
|
||||||
// for macs there's sometimes a ghost battery with 0 capacity
|
|
||||||
// https://github.com/distatus/battery/issues/34
|
|
||||||
// Instead of skipping over those batteries, we'll check for total 0 capacity
|
|
||||||
// and return an error. This also prevents a divide by zero.
|
|
||||||
return batteryPercent, batteryState, errors.New("no battery capacity")
|
|
||||||
}
|
|
||||||
|
|
||||||
batteryPercent = uint8(totalCharge / totalCapacity * 100)
|
|
||||||
return batteryPercent, batteryState, nil
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
//go:build freebsd
|
|
||||||
|
|
||||||
package battery
|
|
||||||
|
|
||||||
import "errors"
|
|
||||||
|
|
||||||
func HasReadableBattery() bool {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetBatteryStats() (uint8, uint8, error) {
|
|
||||||
return 0, 0, errors.ErrUnsupported
|
|
||||||
}
|
|
||||||
134
agent/cpu.go
134
agent/cpu.go
@@ -1,134 +0,0 @@
|
|||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"math"
|
|
||||||
"runtime"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
|
||||||
"github.com/shirou/gopsutil/v4/cpu"
|
|
||||||
)
|
|
||||||
|
|
||||||
var lastCpuTimes = make(map[uint16]cpu.TimesStat)
|
|
||||||
var lastPerCoreCpuTimes = make(map[uint16][]cpu.TimesStat)
|
|
||||||
|
|
||||||
// init initializes the CPU monitoring by storing the initial CPU times
|
|
||||||
// for the default 60-second cache interval.
|
|
||||||
func init() {
|
|
||||||
if times, err := cpu.Times(false); err == nil {
|
|
||||||
lastCpuTimes[60000] = times[0]
|
|
||||||
}
|
|
||||||
if perCoreTimes, err := cpu.Times(true); err == nil {
|
|
||||||
lastPerCoreCpuTimes[60000] = perCoreTimes
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// CpuMetrics contains detailed CPU usage breakdown
|
|
||||||
type CpuMetrics struct {
|
|
||||||
Total float64
|
|
||||||
User float64
|
|
||||||
System float64
|
|
||||||
Iowait float64
|
|
||||||
Steal float64
|
|
||||||
Idle float64
|
|
||||||
}
|
|
||||||
|
|
||||||
// getCpuMetrics calculates detailed CPU usage metrics using cached previous measurements.
|
|
||||||
// It returns percentages for total, user, system, iowait, and steal time.
|
|
||||||
func getCpuMetrics(cacheTimeMs uint16) (CpuMetrics, error) {
|
|
||||||
times, err := cpu.Times(false)
|
|
||||||
if err != nil || len(times) == 0 {
|
|
||||||
return CpuMetrics{}, err
|
|
||||||
}
|
|
||||||
// if cacheTimeMs is not in lastCpuTimes, use 60000 as fallback lastCpuTime
|
|
||||||
if _, ok := lastCpuTimes[cacheTimeMs]; !ok {
|
|
||||||
lastCpuTimes[cacheTimeMs] = lastCpuTimes[60000]
|
|
||||||
}
|
|
||||||
|
|
||||||
t1 := lastCpuTimes[cacheTimeMs]
|
|
||||||
t2 := times[0]
|
|
||||||
|
|
||||||
t1All, _ := getAllBusy(t1)
|
|
||||||
t2All, _ := getAllBusy(t2)
|
|
||||||
|
|
||||||
totalDelta := t2All - t1All
|
|
||||||
if totalDelta <= 0 {
|
|
||||||
return CpuMetrics{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
metrics := CpuMetrics{
|
|
||||||
Total: calculateBusy(t1, t2),
|
|
||||||
User: clampPercent((t2.User - t1.User) / totalDelta * 100),
|
|
||||||
System: clampPercent((t2.System - t1.System) / totalDelta * 100),
|
|
||||||
Iowait: clampPercent((t2.Iowait - t1.Iowait) / totalDelta * 100),
|
|
||||||
Steal: clampPercent((t2.Steal - t1.Steal) / totalDelta * 100),
|
|
||||||
Idle: clampPercent((t2.Idle - t1.Idle) / totalDelta * 100),
|
|
||||||
}
|
|
||||||
|
|
||||||
lastCpuTimes[cacheTimeMs] = times[0]
|
|
||||||
return metrics, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// clampPercent ensures the percentage is between 0 and 100
|
|
||||||
func clampPercent(value float64) float64 {
|
|
||||||
return math.Min(100, math.Max(0, value))
|
|
||||||
}
|
|
||||||
|
|
||||||
// getPerCoreCpuUsage calculates per-core CPU busy usage as integer percentages (0-100).
|
|
||||||
// It uses cached previous measurements for the provided cache interval.
|
|
||||||
func getPerCoreCpuUsage(cacheTimeMs uint16) (system.Uint8Slice, error) {
|
|
||||||
perCoreTimes, err := cpu.Times(true)
|
|
||||||
if err != nil || len(perCoreTimes) == 0 {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize cache if needed
|
|
||||||
if _, ok := lastPerCoreCpuTimes[cacheTimeMs]; !ok {
|
|
||||||
lastPerCoreCpuTimes[cacheTimeMs] = lastPerCoreCpuTimes[60000]
|
|
||||||
}
|
|
||||||
|
|
||||||
lastTimes := lastPerCoreCpuTimes[cacheTimeMs]
|
|
||||||
|
|
||||||
// Limit to the number of cores available in both samples
|
|
||||||
length := len(perCoreTimes)
|
|
||||||
if len(lastTimes) < length {
|
|
||||||
length = len(lastTimes)
|
|
||||||
}
|
|
||||||
|
|
||||||
usage := make([]uint8, length)
|
|
||||||
for i := 0; i < length; i++ {
|
|
||||||
t1 := lastTimes[i]
|
|
||||||
t2 := perCoreTimes[i]
|
|
||||||
usage[i] = uint8(math.Round(calculateBusy(t1, t2)))
|
|
||||||
}
|
|
||||||
|
|
||||||
lastPerCoreCpuTimes[cacheTimeMs] = perCoreTimes
|
|
||||||
return usage, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// calculateBusy calculates the CPU busy percentage between two time points.
|
|
||||||
// It computes the ratio of busy time to total time elapsed between t1 and t2,
|
|
||||||
// returning a percentage clamped between 0 and 100.
|
|
||||||
func calculateBusy(t1, t2 cpu.TimesStat) float64 {
|
|
||||||
t1All, t1Busy := getAllBusy(t1)
|
|
||||||
t2All, t2Busy := getAllBusy(t2)
|
|
||||||
|
|
||||||
if t2All <= t1All || t2Busy <= t1Busy {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return clampPercent((t2Busy - t1Busy) / (t2All - t1All) * 100)
|
|
||||||
}
|
|
||||||
|
|
||||||
// getAllBusy calculates the total CPU time and busy CPU time from CPU times statistics.
|
|
||||||
// On Linux, it excludes guest and guest_nice time from the total to match kernel behavior.
|
|
||||||
// Returns total CPU time and busy CPU time (total minus idle and I/O wait time).
|
|
||||||
func getAllBusy(t cpu.TimesStat) (float64, float64) {
|
|
||||||
tot := t.Total()
|
|
||||||
if runtime.GOOS == "linux" {
|
|
||||||
tot -= t.Guest // Linux 2.6.24+
|
|
||||||
tot -= t.GuestNice // Linux 3.2.0+
|
|
||||||
}
|
|
||||||
|
|
||||||
busy := tot - t.Idle - t.Iowait
|
|
||||||
|
|
||||||
return tot, busy
|
|
||||||
}
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
// Package deltatracker provides a tracker for calculating differences in numeric values over time.
|
|
||||||
package deltatracker
|
|
||||||
|
|
||||||
import (
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"golang.org/x/exp/constraints"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Numeric is a constraint that permits any integer or floating-point type.
|
|
||||||
type Numeric interface {
|
|
||||||
constraints.Integer | constraints.Float
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeltaTracker is a generic, thread-safe tracker for calculating differences
|
|
||||||
// in numeric values over time.
|
|
||||||
// K is the key type (e.g., int, string).
|
|
||||||
// V is the value type (e.g., int, int64, float32, float64).
|
|
||||||
type DeltaTracker[K comparable, V Numeric] struct {
|
|
||||||
sync.RWMutex
|
|
||||||
current map[K]V
|
|
||||||
previous map[K]V
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewDeltaTracker creates a new generic tracker.
|
|
||||||
func NewDeltaTracker[K comparable, V Numeric]() *DeltaTracker[K, V] {
|
|
||||||
return &DeltaTracker[K, V]{
|
|
||||||
current: make(map[K]V),
|
|
||||||
previous: make(map[K]V),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set records the current value for a given ID.
|
|
||||||
func (t *DeltaTracker[K, V]) Set(id K, value V) {
|
|
||||||
t.Lock()
|
|
||||||
defer t.Unlock()
|
|
||||||
t.current[id] = value
|
|
||||||
}
|
|
||||||
|
|
||||||
// Snapshot returns a copy of the current map.
|
|
||||||
// func (t *DeltaTracker[K, V]) Snapshot() map[K]V {
|
|
||||||
// t.RLock()
|
|
||||||
// defer t.RUnlock()
|
|
||||||
|
|
||||||
// copyMap := make(map[K]V, len(t.current))
|
|
||||||
// maps.Copy(copyMap, t.current)
|
|
||||||
// return copyMap
|
|
||||||
// }
|
|
||||||
|
|
||||||
// Deltas returns a map of all calculated deltas for the current interval.
|
|
||||||
func (t *DeltaTracker[K, V]) Deltas() map[K]V {
|
|
||||||
t.RLock()
|
|
||||||
defer t.RUnlock()
|
|
||||||
|
|
||||||
deltas := make(map[K]V)
|
|
||||||
for id, currentVal := range t.current {
|
|
||||||
if previousVal, ok := t.previous[id]; ok {
|
|
||||||
deltas[id] = currentVal - previousVal
|
|
||||||
} else {
|
|
||||||
deltas[id] = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return deltas
|
|
||||||
}
|
|
||||||
|
|
||||||
// Previous returns the previously recorded value for the given key, if it exists.
|
|
||||||
func (t *DeltaTracker[K, V]) Previous(id K) (V, bool) {
|
|
||||||
t.RLock()
|
|
||||||
defer t.RUnlock()
|
|
||||||
|
|
||||||
value, ok := t.previous[id]
|
|
||||||
return value, ok
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delta returns the delta for a single key.
|
|
||||||
// Returns 0 if the key doesn't exist or has no previous value.
|
|
||||||
func (t *DeltaTracker[K, V]) Delta(id K) V {
|
|
||||||
t.RLock()
|
|
||||||
defer t.RUnlock()
|
|
||||||
|
|
||||||
currentVal, currentOk := t.current[id]
|
|
||||||
if !currentOk {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
previousVal, previousOk := t.previous[id]
|
|
||||||
if !previousOk {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
return currentVal - previousVal
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cycle prepares the tracker for the next interval.
|
|
||||||
func (t *DeltaTracker[K, V]) Cycle() {
|
|
||||||
t.Lock()
|
|
||||||
defer t.Unlock()
|
|
||||||
t.previous = t.current
|
|
||||||
t.current = make(map[K]V)
|
|
||||||
}
|
|
||||||
@@ -1,217 +0,0 @@
|
|||||||
package deltatracker
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
func ExampleDeltaTracker() {
|
|
||||||
tracker := NewDeltaTracker[string, int]()
|
|
||||||
tracker.Set("key1", 10)
|
|
||||||
tracker.Set("key2", 20)
|
|
||||||
tracker.Cycle()
|
|
||||||
tracker.Set("key1", 15)
|
|
||||||
tracker.Set("key2", 30)
|
|
||||||
fmt.Println(tracker.Delta("key1"))
|
|
||||||
fmt.Println(tracker.Delta("key2"))
|
|
||||||
fmt.Println(tracker.Deltas())
|
|
||||||
// Output: 5
|
|
||||||
// 10
|
|
||||||
// map[key1:5 key2:10]
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewDeltaTracker(t *testing.T) {
|
|
||||||
tracker := NewDeltaTracker[string, int]()
|
|
||||||
assert.NotNil(t, tracker)
|
|
||||||
assert.Empty(t, tracker.current)
|
|
||||||
assert.Empty(t, tracker.previous)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSet(t *testing.T) {
|
|
||||||
tracker := NewDeltaTracker[string, int]()
|
|
||||||
tracker.Set("key1", 10)
|
|
||||||
|
|
||||||
tracker.RLock()
|
|
||||||
defer tracker.RUnlock()
|
|
||||||
|
|
||||||
assert.Equal(t, 10, tracker.current["key1"])
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDeltas(t *testing.T) {
|
|
||||||
tracker := NewDeltaTracker[string, int]()
|
|
||||||
|
|
||||||
// Test with no previous values
|
|
||||||
tracker.Set("key1", 10)
|
|
||||||
tracker.Set("key2", 20)
|
|
||||||
|
|
||||||
deltas := tracker.Deltas()
|
|
||||||
assert.Equal(t, 0, deltas["key1"])
|
|
||||||
assert.Equal(t, 0, deltas["key2"])
|
|
||||||
|
|
||||||
// Cycle to move current to previous
|
|
||||||
tracker.Cycle()
|
|
||||||
|
|
||||||
// Set new values and check deltas
|
|
||||||
tracker.Set("key1", 15) // Delta should be 5 (15-10)
|
|
||||||
tracker.Set("key2", 25) // Delta should be 5 (25-20)
|
|
||||||
tracker.Set("key3", 30) // New key, delta should be 0
|
|
||||||
|
|
||||||
deltas = tracker.Deltas()
|
|
||||||
assert.Equal(t, 5, deltas["key1"])
|
|
||||||
assert.Equal(t, 5, deltas["key2"])
|
|
||||||
assert.Equal(t, 0, deltas["key3"])
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCycle(t *testing.T) {
|
|
||||||
tracker := NewDeltaTracker[string, int]()
|
|
||||||
|
|
||||||
tracker.Set("key1", 10)
|
|
||||||
tracker.Set("key2", 20)
|
|
||||||
|
|
||||||
// Verify current has values
|
|
||||||
tracker.RLock()
|
|
||||||
assert.Equal(t, 10, tracker.current["key1"])
|
|
||||||
assert.Equal(t, 20, tracker.current["key2"])
|
|
||||||
assert.Empty(t, tracker.previous)
|
|
||||||
tracker.RUnlock()
|
|
||||||
|
|
||||||
tracker.Cycle()
|
|
||||||
|
|
||||||
// After cycle, previous should have the old current values
|
|
||||||
// and current should be empty
|
|
||||||
tracker.RLock()
|
|
||||||
assert.Empty(t, tracker.current)
|
|
||||||
assert.Equal(t, 10, tracker.previous["key1"])
|
|
||||||
assert.Equal(t, 20, tracker.previous["key2"])
|
|
||||||
tracker.RUnlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCompleteWorkflow(t *testing.T) {
|
|
||||||
tracker := NewDeltaTracker[string, int]()
|
|
||||||
|
|
||||||
// First interval
|
|
||||||
tracker.Set("server1", 100)
|
|
||||||
tracker.Set("server2", 200)
|
|
||||||
|
|
||||||
// Get deltas for first interval (should be zero)
|
|
||||||
firstDeltas := tracker.Deltas()
|
|
||||||
assert.Equal(t, 0, firstDeltas["server1"])
|
|
||||||
assert.Equal(t, 0, firstDeltas["server2"])
|
|
||||||
|
|
||||||
// Cycle to next interval
|
|
||||||
tracker.Cycle()
|
|
||||||
|
|
||||||
// Second interval
|
|
||||||
tracker.Set("server1", 150) // Delta: 50
|
|
||||||
tracker.Set("server2", 180) // Delta: -20
|
|
||||||
tracker.Set("server3", 300) // New server, delta: 300
|
|
||||||
|
|
||||||
secondDeltas := tracker.Deltas()
|
|
||||||
assert.Equal(t, 50, secondDeltas["server1"])
|
|
||||||
assert.Equal(t, -20, secondDeltas["server2"])
|
|
||||||
assert.Equal(t, 0, secondDeltas["server3"])
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDeltaTrackerWithDifferentTypes(t *testing.T) {
|
|
||||||
// Test with int64
|
|
||||||
intTracker := NewDeltaTracker[string, int64]()
|
|
||||||
intTracker.Set("pid1", 1000)
|
|
||||||
intTracker.Cycle()
|
|
||||||
intTracker.Set("pid1", 1200)
|
|
||||||
intDeltas := intTracker.Deltas()
|
|
||||||
assert.Equal(t, int64(200), intDeltas["pid1"])
|
|
||||||
|
|
||||||
// Test with float64
|
|
||||||
floatTracker := NewDeltaTracker[string, float64]()
|
|
||||||
floatTracker.Set("cpu1", 1.5)
|
|
||||||
floatTracker.Cycle()
|
|
||||||
floatTracker.Set("cpu1", 2.7)
|
|
||||||
floatDeltas := floatTracker.Deltas()
|
|
||||||
assert.InDelta(t, 1.2, floatDeltas["cpu1"], 0.0001)
|
|
||||||
|
|
||||||
// Test with int keys
|
|
||||||
pidTracker := NewDeltaTracker[int, int64]()
|
|
||||||
pidTracker.Set(101, 20000)
|
|
||||||
pidTracker.Cycle()
|
|
||||||
pidTracker.Set(101, 22500)
|
|
||||||
pidDeltas := pidTracker.Deltas()
|
|
||||||
assert.Equal(t, int64(2500), pidDeltas[101])
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDelta(t *testing.T) {
|
|
||||||
tracker := NewDeltaTracker[string, int]()
|
|
||||||
|
|
||||||
// Test getting delta for non-existent key
|
|
||||||
result := tracker.Delta("nonexistent")
|
|
||||||
assert.Equal(t, 0, result)
|
|
||||||
|
|
||||||
// Test getting delta for key with no previous value
|
|
||||||
tracker.Set("key1", 10)
|
|
||||||
result = tracker.Delta("key1")
|
|
||||||
assert.Equal(t, 0, result)
|
|
||||||
|
|
||||||
// Cycle to move current to previous
|
|
||||||
tracker.Cycle()
|
|
||||||
|
|
||||||
// Test getting delta for key with previous value
|
|
||||||
tracker.Set("key1", 15)
|
|
||||||
result = tracker.Delta("key1")
|
|
||||||
assert.Equal(t, 5, result)
|
|
||||||
|
|
||||||
// Test getting delta for key that exists in previous but not current
|
|
||||||
result = tracker.Delta("key1")
|
|
||||||
assert.Equal(t, 5, result) // Should still return 5
|
|
||||||
|
|
||||||
// Test getting delta for key that exists in current but not previous
|
|
||||||
tracker.Set("key2", 20)
|
|
||||||
result = tracker.Delta("key2")
|
|
||||||
assert.Equal(t, 0, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDeltaWithDifferentTypes(t *testing.T) {
|
|
||||||
// Test with int64
|
|
||||||
intTracker := NewDeltaTracker[string, int64]()
|
|
||||||
intTracker.Set("pid1", 1000)
|
|
||||||
intTracker.Cycle()
|
|
||||||
intTracker.Set("pid1", 1200)
|
|
||||||
result := intTracker.Delta("pid1")
|
|
||||||
assert.Equal(t, int64(200), result)
|
|
||||||
|
|
||||||
// Test with float64
|
|
||||||
floatTracker := NewDeltaTracker[string, float64]()
|
|
||||||
floatTracker.Set("cpu1", 1.5)
|
|
||||||
floatTracker.Cycle()
|
|
||||||
floatTracker.Set("cpu1", 2.7)
|
|
||||||
floatResult := floatTracker.Delta("cpu1")
|
|
||||||
assert.InDelta(t, 1.2, floatResult, 0.0001)
|
|
||||||
|
|
||||||
// Test with int keys
|
|
||||||
pidTracker := NewDeltaTracker[int, int64]()
|
|
||||||
pidTracker.Set(101, 20000)
|
|
||||||
pidTracker.Cycle()
|
|
||||||
pidTracker.Set(101, 22500)
|
|
||||||
pidResult := pidTracker.Delta(101)
|
|
||||||
assert.Equal(t, int64(2500), pidResult)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDeltaConcurrentAccess(t *testing.T) {
|
|
||||||
tracker := NewDeltaTracker[string, int]()
|
|
||||||
|
|
||||||
// Set initial values
|
|
||||||
tracker.Set("key1", 10)
|
|
||||||
tracker.Set("key2", 20)
|
|
||||||
tracker.Cycle()
|
|
||||||
|
|
||||||
// Set new values
|
|
||||||
tracker.Set("key1", 15)
|
|
||||||
tracker.Set("key2", 25)
|
|
||||||
|
|
||||||
// Test concurrent access safety
|
|
||||||
result1 := tracker.Delta("key1")
|
|
||||||
result2 := tracker.Delta("key2")
|
|
||||||
|
|
||||||
assert.Equal(t, 5, result1)
|
|
||||||
assert.Equal(t, 5, result2)
|
|
||||||
}
|
|
||||||
362
agent/disk.go
362
agent/disk.go
@@ -1,362 +0,0 @@
|
|||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"log/slog"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"runtime"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
|
||||||
|
|
||||||
"github.com/shirou/gopsutil/v4/disk"
|
|
||||||
)
|
|
||||||
|
|
||||||
// parseFilesystemEntry parses a filesystem entry in the format "device__customname"
|
|
||||||
// Returns the device/filesystem part and the custom name part
|
|
||||||
func parseFilesystemEntry(entry string) (device, customName string) {
|
|
||||||
entry = strings.TrimSpace(entry)
|
|
||||||
if parts := strings.SplitN(entry, "__", 2); len(parts) == 2 {
|
|
||||||
device = strings.TrimSpace(parts[0])
|
|
||||||
customName = strings.TrimSpace(parts[1])
|
|
||||||
} else {
|
|
||||||
device = entry
|
|
||||||
}
|
|
||||||
return device, customName
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sets up the filesystems to monitor for disk usage and I/O.
|
|
||||||
func (a *Agent) initializeDiskInfo() {
|
|
||||||
filesystem, _ := GetEnv("FILESYSTEM")
|
|
||||||
efPath := "/extra-filesystems"
|
|
||||||
hasRoot := false
|
|
||||||
isWindows := runtime.GOOS == "windows"
|
|
||||||
|
|
||||||
partitions, err := disk.Partitions(false)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("Error getting disk partitions", "err", err)
|
|
||||||
}
|
|
||||||
slog.Debug("Disk", "partitions", partitions)
|
|
||||||
|
|
||||||
// trim trailing backslash for Windows devices (#1361)
|
|
||||||
if isWindows {
|
|
||||||
for i, p := range partitions {
|
|
||||||
partitions[i].Device = strings.TrimSuffix(p.Device, "\\")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ioContext := context.WithValue(a.sensorsContext,
|
|
||||||
// common.EnvKey, common.EnvMap{common.HostProcEnvKey: "/tmp/testproc"},
|
|
||||||
// )
|
|
||||||
// diskIoCounters, err := disk.IOCountersWithContext(ioContext)
|
|
||||||
|
|
||||||
diskIoCounters, err := disk.IOCounters()
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("Error getting diskstats", "err", err)
|
|
||||||
}
|
|
||||||
slog.Debug("Disk I/O", "diskstats", diskIoCounters)
|
|
||||||
|
|
||||||
// Helper function to add a filesystem to fsStats if it doesn't exist
|
|
||||||
addFsStat := func(device, mountpoint string, root bool, customName ...string) {
|
|
||||||
var key string
|
|
||||||
if isWindows {
|
|
||||||
key = device
|
|
||||||
} else {
|
|
||||||
key = filepath.Base(device)
|
|
||||||
}
|
|
||||||
var ioMatch bool
|
|
||||||
if _, exists := a.fsStats[key]; !exists {
|
|
||||||
if root {
|
|
||||||
slog.Info("Detected root device", "name", key)
|
|
||||||
// Check if root device is in /proc/diskstats, use fallback if not
|
|
||||||
if _, ioMatch = diskIoCounters[key]; !ioMatch {
|
|
||||||
key, ioMatch = findIoDevice(filesystem, diskIoCounters, a.fsStats)
|
|
||||||
if !ioMatch {
|
|
||||||
slog.Info("Using I/O fallback", "device", device, "mountpoint", mountpoint, "fallback", key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Check if non-root has diskstats and fall back to folder name if not
|
|
||||||
// Scenario: device is encrypted and named luks-2bcb02be-999d-4417-8d18-5c61e660fb6e - not in /proc/diskstats.
|
|
||||||
// However, the device can be specified by mounting folder from luks device at /extra-filesystems/sda1
|
|
||||||
if _, ioMatch = diskIoCounters[key]; !ioMatch {
|
|
||||||
efBase := filepath.Base(mountpoint)
|
|
||||||
if _, ioMatch = diskIoCounters[efBase]; ioMatch {
|
|
||||||
key = efBase
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fsStats := &system.FsStats{Root: root, Mountpoint: mountpoint}
|
|
||||||
if len(customName) > 0 && customName[0] != "" {
|
|
||||||
fsStats.Name = customName[0]
|
|
||||||
}
|
|
||||||
a.fsStats[key] = fsStats
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the appropriate root mount point for this system
|
|
||||||
rootMountPoint := a.getRootMountPoint()
|
|
||||||
|
|
||||||
// Use FILESYSTEM env var to find root filesystem
|
|
||||||
if filesystem != "" {
|
|
||||||
for _, p := range partitions {
|
|
||||||
if strings.HasSuffix(p.Device, filesystem) || p.Mountpoint == filesystem {
|
|
||||||
addFsStat(p.Device, p.Mountpoint, true)
|
|
||||||
hasRoot = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !hasRoot {
|
|
||||||
slog.Warn("Partition details not found", "filesystem", filesystem)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add EXTRA_FILESYSTEMS env var values to fsStats
|
|
||||||
if extraFilesystems, exists := GetEnv("EXTRA_FILESYSTEMS"); exists {
|
|
||||||
for _, fsEntry := range strings.Split(extraFilesystems, ",") {
|
|
||||||
// Parse custom name from format: device__customname
|
|
||||||
fs, customName := parseFilesystemEntry(fsEntry)
|
|
||||||
|
|
||||||
found := false
|
|
||||||
for _, p := range partitions {
|
|
||||||
if strings.HasSuffix(p.Device, fs) || p.Mountpoint == fs {
|
|
||||||
addFsStat(p.Device, p.Mountpoint, false, customName)
|
|
||||||
found = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// if not in partitions, test if we can get disk usage
|
|
||||||
if !found {
|
|
||||||
if _, err := disk.Usage(fs); err == nil {
|
|
||||||
addFsStat(filepath.Base(fs), fs, false, customName)
|
|
||||||
} else {
|
|
||||||
slog.Error("Invalid filesystem", "name", fs, "err", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process partitions for various mount points
|
|
||||||
for _, p := range partitions {
|
|
||||||
// fmt.Println(p.Device, p.Mountpoint)
|
|
||||||
// Binary root fallback or docker root fallback
|
|
||||||
if !hasRoot && (p.Mountpoint == rootMountPoint || (p.Mountpoint == "/etc/hosts" && strings.HasPrefix(p.Device, "/dev"))) {
|
|
||||||
fs, match := findIoDevice(filepath.Base(p.Device), diskIoCounters, a.fsStats)
|
|
||||||
if match {
|
|
||||||
addFsStat(fs, p.Mountpoint, true)
|
|
||||||
hasRoot = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if device is in /extra-filesystems
|
|
||||||
if strings.HasPrefix(p.Mountpoint, efPath) {
|
|
||||||
device, customName := parseFilesystemEntry(p.Mountpoint)
|
|
||||||
addFsStat(device, p.Mountpoint, false, customName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check all folders in /extra-filesystems and add them if not already present
|
|
||||||
if folders, err := os.ReadDir(efPath); err == nil {
|
|
||||||
existingMountpoints := make(map[string]bool)
|
|
||||||
for _, stats := range a.fsStats {
|
|
||||||
existingMountpoints[stats.Mountpoint] = true
|
|
||||||
}
|
|
||||||
for _, folder := range folders {
|
|
||||||
if folder.IsDir() {
|
|
||||||
mountpoint := filepath.Join(efPath, folder.Name())
|
|
||||||
slog.Debug("/extra-filesystems", "mountpoint", mountpoint)
|
|
||||||
if !existingMountpoints[mountpoint] {
|
|
||||||
device, customName := parseFilesystemEntry(folder.Name())
|
|
||||||
addFsStat(device, mountpoint, false, customName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no root filesystem set, use fallback
|
|
||||||
if !hasRoot {
|
|
||||||
rootDevice, _ := findIoDevice(filepath.Base(filesystem), diskIoCounters, a.fsStats)
|
|
||||||
slog.Info("Root disk", "mountpoint", rootMountPoint, "io", rootDevice)
|
|
||||||
a.fsStats[rootDevice] = &system.FsStats{Root: true, Mountpoint: rootMountPoint}
|
|
||||||
}
|
|
||||||
|
|
||||||
a.initializeDiskIoStats(diskIoCounters)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Returns matching device from /proc/diskstats,
|
|
||||||
// or the device with the most reads if no match is found.
|
|
||||||
// bool is true if a match was found.
|
|
||||||
func findIoDevice(filesystem string, diskIoCounters map[string]disk.IOCountersStat, fsStats map[string]*system.FsStats) (string, bool) {
|
|
||||||
var maxReadBytes uint64
|
|
||||||
maxReadDevice := "/"
|
|
||||||
for _, d := range diskIoCounters {
|
|
||||||
if d.Name == filesystem || (d.Label != "" && d.Label == filesystem) {
|
|
||||||
return d.Name, true
|
|
||||||
}
|
|
||||||
if d.ReadBytes > maxReadBytes {
|
|
||||||
// don't use if device already exists in fsStats
|
|
||||||
if _, exists := fsStats[d.Name]; !exists {
|
|
||||||
maxReadBytes = d.ReadBytes
|
|
||||||
maxReadDevice = d.Name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return maxReadDevice, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sets start values for disk I/O stats.
|
|
||||||
func (a *Agent) initializeDiskIoStats(diskIoCounters map[string]disk.IOCountersStat) {
|
|
||||||
for device, stats := range a.fsStats {
|
|
||||||
// skip if not in diskIoCounters
|
|
||||||
d, exists := diskIoCounters[device]
|
|
||||||
if !exists {
|
|
||||||
slog.Warn("Device not found in diskstats", "name", device)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// populate initial values
|
|
||||||
stats.Time = time.Now()
|
|
||||||
stats.TotalRead = d.ReadBytes
|
|
||||||
stats.TotalWrite = d.WriteBytes
|
|
||||||
// add to list of valid io device names
|
|
||||||
a.fsNames = append(a.fsNames, device)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Updates disk usage statistics for all monitored filesystems
|
|
||||||
func (a *Agent) updateDiskUsage(systemStats *system.Stats) {
|
|
||||||
// Check if we should skip extra filesystem collection to avoid waking sleeping disks.
|
|
||||||
// Root filesystem is always updated since it can't be sleeping while the agent runs.
|
|
||||||
// Always collect on first call (lastDiskUsageUpdate is zero) or if caching is disabled.
|
|
||||||
cacheExtraFs := a.diskUsageCacheDuration > 0 &&
|
|
||||||
!a.lastDiskUsageUpdate.IsZero() &&
|
|
||||||
time.Since(a.lastDiskUsageUpdate) < a.diskUsageCacheDuration
|
|
||||||
|
|
||||||
// disk usage
|
|
||||||
for _, stats := range a.fsStats {
|
|
||||||
// Skip non-root filesystems if caching is active
|
|
||||||
if cacheExtraFs && !stats.Root {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if d, err := disk.Usage(stats.Mountpoint); err == nil {
|
|
||||||
stats.DiskTotal = bytesToGigabytes(d.Total)
|
|
||||||
stats.DiskUsed = bytesToGigabytes(d.Used)
|
|
||||||
if stats.Root {
|
|
||||||
systemStats.DiskTotal = bytesToGigabytes(d.Total)
|
|
||||||
systemStats.DiskUsed = bytesToGigabytes(d.Used)
|
|
||||||
systemStats.DiskPct = twoDecimals(d.UsedPercent)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// reset stats if error (likely unmounted)
|
|
||||||
slog.Error("Error getting disk stats", "name", stats.Mountpoint, "err", err)
|
|
||||||
stats.DiskTotal = 0
|
|
||||||
stats.DiskUsed = 0
|
|
||||||
stats.TotalRead = 0
|
|
||||||
stats.TotalWrite = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the last disk usage update time when we've collected extra filesystems
|
|
||||||
if !cacheExtraFs {
|
|
||||||
a.lastDiskUsageUpdate = time.Now()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Updates disk I/O statistics for all monitored filesystems
|
|
||||||
func (a *Agent) updateDiskIo(cacheTimeMs uint16, systemStats *system.Stats) {
|
|
||||||
// disk i/o (cache-aware per interval)
|
|
||||||
if ioCounters, err := disk.IOCounters(a.fsNames...); err == nil {
|
|
||||||
// Ensure map for this interval exists
|
|
||||||
if _, ok := a.diskPrev[cacheTimeMs]; !ok {
|
|
||||||
a.diskPrev[cacheTimeMs] = make(map[string]prevDisk)
|
|
||||||
}
|
|
||||||
now := time.Now()
|
|
||||||
for name, d := range ioCounters {
|
|
||||||
stats := a.fsStats[d.Name]
|
|
||||||
if stats == nil {
|
|
||||||
// skip devices not tracked
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Previous snapshot for this interval and device
|
|
||||||
prev, hasPrev := a.diskPrev[cacheTimeMs][name]
|
|
||||||
if !hasPrev {
|
|
||||||
// Seed from agent-level fsStats if present, else seed from current
|
|
||||||
prev = prevDisk{readBytes: stats.TotalRead, writeBytes: stats.TotalWrite, at: stats.Time}
|
|
||||||
if prev.at.IsZero() {
|
|
||||||
prev = prevDisk{readBytes: d.ReadBytes, writeBytes: d.WriteBytes, at: now}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
msElapsed := uint64(now.Sub(prev.at).Milliseconds())
|
|
||||||
if msElapsed < 100 {
|
|
||||||
// Avoid division by zero or clock issues; update snapshot and continue
|
|
||||||
a.diskPrev[cacheTimeMs][name] = prevDisk{readBytes: d.ReadBytes, writeBytes: d.WriteBytes, at: now}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
diskIORead := (d.ReadBytes - prev.readBytes) * 1000 / msElapsed
|
|
||||||
diskIOWrite := (d.WriteBytes - prev.writeBytes) * 1000 / msElapsed
|
|
||||||
readMbPerSecond := bytesToMegabytes(float64(diskIORead))
|
|
||||||
writeMbPerSecond := bytesToMegabytes(float64(diskIOWrite))
|
|
||||||
|
|
||||||
// validate values
|
|
||||||
if readMbPerSecond > 50_000 || writeMbPerSecond > 50_000 {
|
|
||||||
slog.Warn("Invalid disk I/O. Resetting.", "name", d.Name, "read", readMbPerSecond, "write", writeMbPerSecond)
|
|
||||||
// Reset interval snapshot and seed from current
|
|
||||||
a.diskPrev[cacheTimeMs][name] = prevDisk{readBytes: d.ReadBytes, writeBytes: d.WriteBytes, at: now}
|
|
||||||
// also refresh agent baseline to avoid future negatives
|
|
||||||
a.initializeDiskIoStats(ioCounters)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update per-interval snapshot
|
|
||||||
a.diskPrev[cacheTimeMs][name] = prevDisk{readBytes: d.ReadBytes, writeBytes: d.WriteBytes, at: now}
|
|
||||||
|
|
||||||
// Update global fsStats baseline for cross-interval correctness
|
|
||||||
stats.Time = now
|
|
||||||
stats.TotalRead = d.ReadBytes
|
|
||||||
stats.TotalWrite = d.WriteBytes
|
|
||||||
stats.DiskReadPs = readMbPerSecond
|
|
||||||
stats.DiskWritePs = writeMbPerSecond
|
|
||||||
stats.DiskReadBytes = diskIORead
|
|
||||||
stats.DiskWriteBytes = diskIOWrite
|
|
||||||
|
|
||||||
if stats.Root {
|
|
||||||
systemStats.DiskReadPs = stats.DiskReadPs
|
|
||||||
systemStats.DiskWritePs = stats.DiskWritePs
|
|
||||||
systemStats.DiskIO[0] = diskIORead
|
|
||||||
systemStats.DiskIO[1] = diskIOWrite
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// getRootMountPoint returns the appropriate root mount point for the system
|
|
||||||
// For immutable systems like Fedora Silverblue, it returns /sysroot instead of /
|
|
||||||
func (a *Agent) getRootMountPoint() string {
|
|
||||||
// 1. Check if /etc/os-release contains indicators of an immutable system
|
|
||||||
if osReleaseContent, err := os.ReadFile("/etc/os-release"); err == nil {
|
|
||||||
content := string(osReleaseContent)
|
|
||||||
if strings.Contains(content, "fedora") && strings.Contains(content, "silverblue") ||
|
|
||||||
strings.Contains(content, "coreos") ||
|
|
||||||
strings.Contains(content, "flatcar") ||
|
|
||||||
strings.Contains(content, "rhel-atomic") ||
|
|
||||||
strings.Contains(content, "centos-atomic") {
|
|
||||||
// Verify that /sysroot exists before returning it
|
|
||||||
if _, err := os.Stat("/sysroot"); err == nil {
|
|
||||||
return "/sysroot"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Check if /run/ostree is present (ostree-based systems like Silverblue)
|
|
||||||
if _, err := os.Stat("/run/ostree"); err == nil {
|
|
||||||
// Verify that /sysroot exists before returning it
|
|
||||||
if _, err := os.Stat("/sysroot"); err == nil {
|
|
||||||
return "/sysroot"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return "/"
|
|
||||||
}
|
|
||||||
@@ -1,319 +0,0 @@
|
|||||||
//go:build testing
|
|
||||||
// +build testing
|
|
||||||
|
|
||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
|
||||||
"github.com/shirou/gopsutil/v4/disk"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestParseFilesystemEntry(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
input string
|
|
||||||
expectedFs string
|
|
||||||
expectedName string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "simple device name",
|
|
||||||
input: "sda1",
|
|
||||||
expectedFs: "sda1",
|
|
||||||
expectedName: "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "device with custom name",
|
|
||||||
input: "sda1__my-storage",
|
|
||||||
expectedFs: "sda1",
|
|
||||||
expectedName: "my-storage",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "full device path with custom name",
|
|
||||||
input: "/dev/sdb1__backup-drive",
|
|
||||||
expectedFs: "/dev/sdb1",
|
|
||||||
expectedName: "backup-drive",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "NVMe device with custom name",
|
|
||||||
input: "nvme0n1p2__fast-ssd",
|
|
||||||
expectedFs: "nvme0n1p2",
|
|
||||||
expectedName: "fast-ssd",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "whitespace trimmed",
|
|
||||||
input: " sda2__trimmed-name ",
|
|
||||||
expectedFs: "sda2",
|
|
||||||
expectedName: "trimmed-name",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty custom name",
|
|
||||||
input: "sda3__",
|
|
||||||
expectedFs: "sda3",
|
|
||||||
expectedName: "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty device name",
|
|
||||||
input: "__just-custom",
|
|
||||||
expectedFs: "",
|
|
||||||
expectedName: "just-custom",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "multiple underscores in custom name",
|
|
||||||
input: "sda1__my_custom_drive",
|
|
||||||
expectedFs: "sda1",
|
|
||||||
expectedName: "my_custom_drive",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "custom name with spaces",
|
|
||||||
input: "sda1__My Storage Drive",
|
|
||||||
expectedFs: "sda1",
|
|
||||||
expectedName: "My Storage Drive",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
fsEntry := strings.TrimSpace(tt.input)
|
|
||||||
var fs, customName string
|
|
||||||
if parts := strings.SplitN(fsEntry, "__", 2); len(parts) == 2 {
|
|
||||||
fs = strings.TrimSpace(parts[0])
|
|
||||||
customName = strings.TrimSpace(parts[1])
|
|
||||||
} else {
|
|
||||||
fs = fsEntry
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal(t, tt.expectedFs, fs)
|
|
||||||
assert.Equal(t, tt.expectedName, customName)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInitializeDiskInfoWithCustomNames(t *testing.T) {
|
|
||||||
// Set up environment variables
|
|
||||||
oldEnv := os.Getenv("EXTRA_FILESYSTEMS")
|
|
||||||
defer func() {
|
|
||||||
if oldEnv != "" {
|
|
||||||
os.Setenv("EXTRA_FILESYSTEMS", oldEnv)
|
|
||||||
} else {
|
|
||||||
os.Unsetenv("EXTRA_FILESYSTEMS")
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Test with custom names
|
|
||||||
os.Setenv("EXTRA_FILESYSTEMS", "sda1__my-storage,/dev/sdb1__backup-drive,nvme0n1p2")
|
|
||||||
|
|
||||||
// Mock disk partitions (we'll just test the parsing logic)
|
|
||||||
// Since the actual disk operations are system-dependent, we'll focus on the parsing
|
|
||||||
testCases := []struct {
|
|
||||||
envValue string
|
|
||||||
expectedFs []string
|
|
||||||
expectedNames map[string]string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
envValue: "sda1__my-storage,sdb1__backup-drive",
|
|
||||||
expectedFs: []string{"sda1", "sdb1"},
|
|
||||||
expectedNames: map[string]string{
|
|
||||||
"sda1": "my-storage",
|
|
||||||
"sdb1": "backup-drive",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
envValue: "sda1,nvme0n1p2__fast-ssd",
|
|
||||||
expectedFs: []string{"sda1", "nvme0n1p2"},
|
|
||||||
expectedNames: map[string]string{
|
|
||||||
"nvme0n1p2": "fast-ssd",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range testCases {
|
|
||||||
t.Run("env_"+tc.envValue, func(t *testing.T) {
|
|
||||||
os.Setenv("EXTRA_FILESYSTEMS", tc.envValue)
|
|
||||||
|
|
||||||
// Create mock partitions that would match our test cases
|
|
||||||
partitions := []disk.PartitionStat{}
|
|
||||||
for _, fs := range tc.expectedFs {
|
|
||||||
if strings.HasPrefix(fs, "/dev/") {
|
|
||||||
partitions = append(partitions, disk.PartitionStat{
|
|
||||||
Device: fs,
|
|
||||||
Mountpoint: fs,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
partitions = append(partitions, disk.PartitionStat{
|
|
||||||
Device: "/dev/" + fs,
|
|
||||||
Mountpoint: "/" + fs,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test the parsing logic by calling the relevant part
|
|
||||||
// We'll create a simplified version to test just the parsing
|
|
||||||
extraFilesystems := tc.envValue
|
|
||||||
for _, fsEntry := range strings.Split(extraFilesystems, ",") {
|
|
||||||
// Parse the entry
|
|
||||||
fsEntry = strings.TrimSpace(fsEntry)
|
|
||||||
var fs, customName string
|
|
||||||
if parts := strings.SplitN(fsEntry, "__", 2); len(parts) == 2 {
|
|
||||||
fs = strings.TrimSpace(parts[0])
|
|
||||||
customName = strings.TrimSpace(parts[1])
|
|
||||||
} else {
|
|
||||||
fs = fsEntry
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify the device is in our expected list
|
|
||||||
assert.Contains(t, tc.expectedFs, fs, "parsed device should be in expected list")
|
|
||||||
|
|
||||||
// Check if custom name should exist
|
|
||||||
if expectedName, exists := tc.expectedNames[fs]; exists {
|
|
||||||
assert.Equal(t, expectedName, customName, "custom name should match expected")
|
|
||||||
} else {
|
|
||||||
assert.Empty(t, customName, "custom name should be empty when not expected")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFsStatsWithCustomNames(t *testing.T) {
|
|
||||||
// Test that FsStats properly stores custom names
|
|
||||||
fsStats := &system.FsStats{
|
|
||||||
Mountpoint: "/mnt/storage",
|
|
||||||
Name: "my-custom-storage",
|
|
||||||
DiskTotal: 100.0,
|
|
||||||
DiskUsed: 50.0,
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal(t, "my-custom-storage", fsStats.Name)
|
|
||||||
assert.Equal(t, "/mnt/storage", fsStats.Mountpoint)
|
|
||||||
assert.Equal(t, 100.0, fsStats.DiskTotal)
|
|
||||||
assert.Equal(t, 50.0, fsStats.DiskUsed)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtraFsKeyGeneration(t *testing.T) {
|
|
||||||
// Test the logic for generating ExtraFs keys with custom names
|
|
||||||
testCases := []struct {
|
|
||||||
name string
|
|
||||||
deviceName string
|
|
||||||
customName string
|
|
||||||
expectedKey string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "with custom name",
|
|
||||||
deviceName: "sda1",
|
|
||||||
customName: "my-storage",
|
|
||||||
expectedKey: "my-storage",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "without custom name",
|
|
||||||
deviceName: "sda1",
|
|
||||||
customName: "",
|
|
||||||
expectedKey: "sda1",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty custom name falls back to device",
|
|
||||||
deviceName: "nvme0n1p2",
|
|
||||||
customName: "",
|
|
||||||
expectedKey: "nvme0n1p2",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range testCases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
// Simulate the key generation logic from agent.go
|
|
||||||
key := tc.deviceName
|
|
||||||
if tc.customName != "" {
|
|
||||||
key = tc.customName
|
|
||||||
}
|
|
||||||
assert.Equal(t, tc.expectedKey, key)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDiskUsageCaching(t *testing.T) {
|
|
||||||
t.Run("caching disabled updates all filesystems", func(t *testing.T) {
|
|
||||||
agent := &Agent{
|
|
||||||
fsStats: map[string]*system.FsStats{
|
|
||||||
"sda": {Root: true, Mountpoint: "/"},
|
|
||||||
"sdb": {Root: false, Mountpoint: "/mnt/storage"},
|
|
||||||
},
|
|
||||||
diskUsageCacheDuration: 0, // caching disabled
|
|
||||||
}
|
|
||||||
|
|
||||||
var stats system.Stats
|
|
||||||
agent.updateDiskUsage(&stats)
|
|
||||||
|
|
||||||
// Both should be updated (non-zero values from disk.Usage)
|
|
||||||
// Root stats should be populated in systemStats
|
|
||||||
assert.True(t, agent.lastDiskUsageUpdate.IsZero() || !agent.lastDiskUsageUpdate.IsZero(),
|
|
||||||
"lastDiskUsageUpdate should be set when caching is disabled")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("caching enabled always updates root filesystem", func(t *testing.T) {
|
|
||||||
agent := &Agent{
|
|
||||||
fsStats: map[string]*system.FsStats{
|
|
||||||
"sda": {Root: true, Mountpoint: "/", DiskTotal: 100, DiskUsed: 50},
|
|
||||||
"sdb": {Root: false, Mountpoint: "/mnt/storage", DiskTotal: 200, DiskUsed: 100},
|
|
||||||
},
|
|
||||||
diskUsageCacheDuration: 1 * time.Hour,
|
|
||||||
lastDiskUsageUpdate: time.Now(), // cache is fresh
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store original extra fs values
|
|
||||||
originalExtraTotal := agent.fsStats["sdb"].DiskTotal
|
|
||||||
originalExtraUsed := agent.fsStats["sdb"].DiskUsed
|
|
||||||
|
|
||||||
var stats system.Stats
|
|
||||||
agent.updateDiskUsage(&stats)
|
|
||||||
|
|
||||||
// Root should be updated (systemStats populated from disk.Usage call)
|
|
||||||
// We can't easily check if disk.Usage was called, but we verify the flow works
|
|
||||||
|
|
||||||
// Extra filesystem should retain cached values (not reset)
|
|
||||||
assert.Equal(t, originalExtraTotal, agent.fsStats["sdb"].DiskTotal,
|
|
||||||
"extra filesystem DiskTotal should be unchanged when cached")
|
|
||||||
assert.Equal(t, originalExtraUsed, agent.fsStats["sdb"].DiskUsed,
|
|
||||||
"extra filesystem DiskUsed should be unchanged when cached")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("first call always updates all filesystems", func(t *testing.T) {
|
|
||||||
agent := &Agent{
|
|
||||||
fsStats: map[string]*system.FsStats{
|
|
||||||
"sda": {Root: true, Mountpoint: "/"},
|
|
||||||
"sdb": {Root: false, Mountpoint: "/mnt/storage"},
|
|
||||||
},
|
|
||||||
diskUsageCacheDuration: 1 * time.Hour,
|
|
||||||
// lastDiskUsageUpdate is zero (first call)
|
|
||||||
}
|
|
||||||
|
|
||||||
var stats system.Stats
|
|
||||||
agent.updateDiskUsage(&stats)
|
|
||||||
|
|
||||||
// After first call, lastDiskUsageUpdate should be set
|
|
||||||
assert.False(t, agent.lastDiskUsageUpdate.IsZero(),
|
|
||||||
"lastDiskUsageUpdate should be set after first call")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("expired cache updates extra filesystems", func(t *testing.T) {
|
|
||||||
agent := &Agent{
|
|
||||||
fsStats: map[string]*system.FsStats{
|
|
||||||
"sda": {Root: true, Mountpoint: "/"},
|
|
||||||
"sdb": {Root: false, Mountpoint: "/mnt/storage"},
|
|
||||||
},
|
|
||||||
diskUsageCacheDuration: 1 * time.Millisecond,
|
|
||||||
lastDiskUsageUpdate: time.Now().Add(-1 * time.Second), // cache expired
|
|
||||||
}
|
|
||||||
|
|
||||||
var stats system.Stats
|
|
||||||
agent.updateDiskUsage(&stats)
|
|
||||||
|
|
||||||
// lastDiskUsageUpdate should be refreshed since cache expired
|
|
||||||
assert.True(t, time.Since(agent.lastDiskUsageUpdate) < time.Second,
|
|
||||||
"lastDiskUsageUpdate should be refreshed when cache expires")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
773
agent/docker.go
773
agent/docker.go
@@ -1,773 +0,0 @@
|
|||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/binary"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"log/slog"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"os"
|
|
||||||
"path"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/agent/deltatracker"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/container"
|
|
||||||
|
|
||||||
"github.com/blang/semver"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ansiEscapePattern matches ANSI escape sequences (colors, cursor movement, etc.)
|
|
||||||
// This includes CSI sequences like \x1b[...m and simple escapes like \x1b[K
|
|
||||||
var ansiEscapePattern = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b[@-Z\\-_]`)
|
|
||||||
|
|
||||||
const (
|
|
||||||
// Docker API timeout in milliseconds
|
|
||||||
dockerTimeoutMs = 2100
|
|
||||||
// Maximum realistic network speed (5 GB/s) to detect bad deltas
|
|
||||||
maxNetworkSpeedBps uint64 = 5e9
|
|
||||||
// Maximum conceivable memory usage of a container (100TB) to detect bad memory stats
|
|
||||||
maxMemoryUsage uint64 = 100 * 1024 * 1024 * 1024 * 1024
|
|
||||||
// Number of log lines to request when fetching container logs
|
|
||||||
dockerLogsTail = 200
|
|
||||||
// Maximum size of a single log frame (1MB) to prevent memory exhaustion
|
|
||||||
// A single log line larger than 1MB is likely an error or misconfiguration
|
|
||||||
maxLogFrameSize = 1024 * 1024
|
|
||||||
// Maximum total log content size (5MB) to prevent memory exhaustion
|
|
||||||
// This provides a reasonable limit for network transfer and browser rendering
|
|
||||||
maxTotalLogSize = 5 * 1024 * 1024
|
|
||||||
)
|
|
||||||
|
|
||||||
type dockerManager struct {
|
|
||||||
client *http.Client // Client to query Docker API
|
|
||||||
wg sync.WaitGroup // WaitGroup to wait for all goroutines to finish
|
|
||||||
sem chan struct{} // Semaphore to limit concurrent container requests
|
|
||||||
containerStatsMutex sync.RWMutex // Mutex to prevent concurrent access to containerStatsMap
|
|
||||||
apiContainerList []*container.ApiInfo // List of containers from Docker API
|
|
||||||
containerStatsMap map[string]*container.Stats // Keeps track of container stats
|
|
||||||
validIds map[string]struct{} // Map of valid container ids, used to prune invalid containers from containerStatsMap
|
|
||||||
goodDockerVersion bool // Whether docker version is at least 25.0.0 (one-shot works correctly)
|
|
||||||
isWindows bool // Whether the Docker Engine API is running on Windows
|
|
||||||
buf *bytes.Buffer // Buffer to store and read response bodies
|
|
||||||
decoder *json.Decoder // Reusable JSON decoder that reads from buf
|
|
||||||
apiStats *container.ApiStats // Reusable API stats object
|
|
||||||
excludeContainers []string // Patterns to exclude containers by name
|
|
||||||
usingPodman bool // Whether the Docker Engine API is running on Podman
|
|
||||||
|
|
||||||
// Cache-time-aware tracking for CPU stats (similar to cpu.go)
|
|
||||||
// Maps cache time intervals to container-specific CPU usage tracking
|
|
||||||
lastCpuContainer map[uint16]map[string]uint64 // cacheTimeMs -> containerId -> last cpu container usage
|
|
||||||
lastCpuSystem map[uint16]map[string]uint64 // cacheTimeMs -> containerId -> last cpu system usage
|
|
||||||
lastCpuReadTime map[uint16]map[string]time.Time // cacheTimeMs -> containerId -> last read time (Windows)
|
|
||||||
|
|
||||||
// Network delta trackers - one per cache time to avoid interference
|
|
||||||
// cacheTimeMs -> DeltaTracker for network bytes sent/received
|
|
||||||
networkSentTrackers map[uint16]*deltatracker.DeltaTracker[string, uint64]
|
|
||||||
networkRecvTrackers map[uint16]*deltatracker.DeltaTracker[string, uint64]
|
|
||||||
}
|
|
||||||
|
|
||||||
// userAgentRoundTripper is a custom http.RoundTripper that adds a User-Agent header to all requests
|
|
||||||
type userAgentRoundTripper struct {
|
|
||||||
rt http.RoundTripper
|
|
||||||
userAgent string
|
|
||||||
}
|
|
||||||
|
|
||||||
// RoundTrip implements the http.RoundTripper interface
|
|
||||||
func (u *userAgentRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
||||||
req.Header.Set("User-Agent", u.userAgent)
|
|
||||||
return u.rt.RoundTrip(req)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add goroutine to the queue
|
|
||||||
func (d *dockerManager) queue() {
|
|
||||||
d.wg.Add(1)
|
|
||||||
if d.goodDockerVersion {
|
|
||||||
d.sem <- struct{}{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove goroutine from the queue
|
|
||||||
func (d *dockerManager) dequeue() {
|
|
||||||
d.wg.Done()
|
|
||||||
if d.goodDockerVersion {
|
|
||||||
<-d.sem
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// shouldExcludeContainer checks if a container name matches any exclusion pattern
|
|
||||||
func (dm *dockerManager) shouldExcludeContainer(name string) bool {
|
|
||||||
if len(dm.excludeContainers) == 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, pattern := range dm.excludeContainers {
|
|
||||||
if match, _ := path.Match(pattern, name); match {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Returns stats for all running containers with cache-time-aware delta tracking
|
|
||||||
func (dm *dockerManager) getDockerStats(cacheTimeMs uint16) ([]*container.Stats, error) {
|
|
||||||
resp, err := dm.client.Get("http://localhost/containers/json")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
dm.apiContainerList = dm.apiContainerList[:0]
|
|
||||||
if err := dm.decode(resp, &dm.apiContainerList); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
dm.isWindows = strings.Contains(resp.Header.Get("Server"), "windows")
|
|
||||||
|
|
||||||
containersLength := len(dm.apiContainerList)
|
|
||||||
|
|
||||||
// store valid ids to clean up old container ids from map
|
|
||||||
if dm.validIds == nil {
|
|
||||||
dm.validIds = make(map[string]struct{}, containersLength)
|
|
||||||
} else {
|
|
||||||
clear(dm.validIds)
|
|
||||||
}
|
|
||||||
|
|
||||||
var failedContainers []*container.ApiInfo
|
|
||||||
|
|
||||||
for _, ctr := range dm.apiContainerList {
|
|
||||||
ctr.IdShort = ctr.Id[:12]
|
|
||||||
|
|
||||||
// Skip this container if it matches the exclusion pattern
|
|
||||||
if dm.shouldExcludeContainer(ctr.Names[0][1:]) {
|
|
||||||
slog.Debug("Excluding container", "name", ctr.Names[0][1:])
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
dm.validIds[ctr.IdShort] = struct{}{}
|
|
||||||
// check if container is less than 1 minute old (possible restart)
|
|
||||||
// note: can't use Created field because it's not updated on restart
|
|
||||||
if strings.Contains(ctr.Status, "second") {
|
|
||||||
// if so, remove old container data
|
|
||||||
dm.deleteContainerStatsSync(ctr.IdShort)
|
|
||||||
}
|
|
||||||
dm.queue()
|
|
||||||
go func(ctr *container.ApiInfo) {
|
|
||||||
defer dm.dequeue()
|
|
||||||
err := dm.updateContainerStats(ctr, cacheTimeMs)
|
|
||||||
// if error, delete from map and add to failed list to retry
|
|
||||||
if err != nil {
|
|
||||||
dm.containerStatsMutex.Lock()
|
|
||||||
delete(dm.containerStatsMap, ctr.IdShort)
|
|
||||||
failedContainers = append(failedContainers, ctr)
|
|
||||||
dm.containerStatsMutex.Unlock()
|
|
||||||
}
|
|
||||||
}(ctr)
|
|
||||||
}
|
|
||||||
|
|
||||||
dm.wg.Wait()
|
|
||||||
|
|
||||||
// retry failed containers separately so we can run them in parallel (docker 24 bug)
|
|
||||||
if len(failedContainers) > 0 {
|
|
||||||
slog.Debug("Retrying failed containers", "count", len(failedContainers))
|
|
||||||
for i := range failedContainers {
|
|
||||||
ctr := failedContainers[i]
|
|
||||||
dm.queue()
|
|
||||||
go func(ctr *container.ApiInfo) {
|
|
||||||
defer dm.dequeue()
|
|
||||||
if err2 := dm.updateContainerStats(ctr, cacheTimeMs); err2 != nil {
|
|
||||||
slog.Error("Error getting container stats", "err", err2)
|
|
||||||
}
|
|
||||||
}(ctr)
|
|
||||||
}
|
|
||||||
dm.wg.Wait()
|
|
||||||
}
|
|
||||||
|
|
||||||
// populate final stats and remove old / invalid container stats
|
|
||||||
stats := make([]*container.Stats, 0, containersLength)
|
|
||||||
for id, v := range dm.containerStatsMap {
|
|
||||||
if _, exists := dm.validIds[id]; !exists {
|
|
||||||
delete(dm.containerStatsMap, id)
|
|
||||||
} else {
|
|
||||||
stats = append(stats, v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// prepare network trackers for next interval for this cache time
|
|
||||||
dm.cycleNetworkDeltasForCacheTime(cacheTimeMs)
|
|
||||||
|
|
||||||
return stats, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// initializeCpuTracking initializes CPU tracking maps for a specific cache time interval
|
|
||||||
func (dm *dockerManager) initializeCpuTracking(cacheTimeMs uint16) {
|
|
||||||
// Initialize cache time maps if they don't exist
|
|
||||||
if dm.lastCpuContainer[cacheTimeMs] == nil {
|
|
||||||
dm.lastCpuContainer[cacheTimeMs] = make(map[string]uint64)
|
|
||||||
}
|
|
||||||
if dm.lastCpuSystem[cacheTimeMs] == nil {
|
|
||||||
dm.lastCpuSystem[cacheTimeMs] = make(map[string]uint64)
|
|
||||||
}
|
|
||||||
// Ensure the outer map exists before indexing
|
|
||||||
if dm.lastCpuReadTime == nil {
|
|
||||||
dm.lastCpuReadTime = make(map[uint16]map[string]time.Time)
|
|
||||||
}
|
|
||||||
if dm.lastCpuReadTime[cacheTimeMs] == nil {
|
|
||||||
dm.lastCpuReadTime[cacheTimeMs] = make(map[string]time.Time)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// getCpuPreviousValues returns previous CPU values for a container and cache time interval
|
|
||||||
func (dm *dockerManager) getCpuPreviousValues(cacheTimeMs uint16, containerId string) (uint64, uint64) {
|
|
||||||
return dm.lastCpuContainer[cacheTimeMs][containerId], dm.lastCpuSystem[cacheTimeMs][containerId]
|
|
||||||
}
|
|
||||||
|
|
||||||
// setCpuCurrentValues stores current CPU values for a container and cache time interval
|
|
||||||
func (dm *dockerManager) setCpuCurrentValues(cacheTimeMs uint16, containerId string, cpuContainer, cpuSystem uint64) {
|
|
||||||
dm.lastCpuContainer[cacheTimeMs][containerId] = cpuContainer
|
|
||||||
dm.lastCpuSystem[cacheTimeMs][containerId] = cpuSystem
|
|
||||||
}
|
|
||||||
|
|
||||||
// calculateMemoryUsage calculates memory usage from Docker API stats
|
|
||||||
func calculateMemoryUsage(apiStats *container.ApiStats, isWindows bool) (uint64, error) {
|
|
||||||
if isWindows {
|
|
||||||
return apiStats.MemoryStats.PrivateWorkingSet, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
memCache := apiStats.MemoryStats.Stats.InactiveFile
|
|
||||||
if memCache == 0 {
|
|
||||||
memCache = apiStats.MemoryStats.Stats.Cache
|
|
||||||
}
|
|
||||||
|
|
||||||
usedDelta := apiStats.MemoryStats.Usage - memCache
|
|
||||||
if usedDelta <= 0 || usedDelta > maxMemoryUsage {
|
|
||||||
return 0, fmt.Errorf("bad memory stats")
|
|
||||||
}
|
|
||||||
|
|
||||||
return usedDelta, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// getNetworkTracker returns the DeltaTracker for a specific cache time, creating it if needed
|
|
||||||
func (dm *dockerManager) getNetworkTracker(cacheTimeMs uint16, isSent bool) *deltatracker.DeltaTracker[string, uint64] {
|
|
||||||
var trackers map[uint16]*deltatracker.DeltaTracker[string, uint64]
|
|
||||||
if isSent {
|
|
||||||
trackers = dm.networkSentTrackers
|
|
||||||
} else {
|
|
||||||
trackers = dm.networkRecvTrackers
|
|
||||||
}
|
|
||||||
|
|
||||||
if trackers[cacheTimeMs] == nil {
|
|
||||||
trackers[cacheTimeMs] = deltatracker.NewDeltaTracker[string, uint64]()
|
|
||||||
}
|
|
||||||
|
|
||||||
return trackers[cacheTimeMs]
|
|
||||||
}
|
|
||||||
|
|
||||||
// cycleNetworkDeltasForCacheTime cycles the network delta trackers for a specific cache time
|
|
||||||
func (dm *dockerManager) cycleNetworkDeltasForCacheTime(cacheTimeMs uint16) {
|
|
||||||
if dm.networkSentTrackers[cacheTimeMs] != nil {
|
|
||||||
dm.networkSentTrackers[cacheTimeMs].Cycle()
|
|
||||||
}
|
|
||||||
if dm.networkRecvTrackers[cacheTimeMs] != nil {
|
|
||||||
dm.networkRecvTrackers[cacheTimeMs].Cycle()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// calculateNetworkStats calculates network sent/receive deltas using DeltaTracker
|
|
||||||
func (dm *dockerManager) calculateNetworkStats(ctr *container.ApiInfo, apiStats *container.ApiStats, stats *container.Stats, initialized bool, name string, cacheTimeMs uint16) (uint64, uint64) {
|
|
||||||
var total_sent, total_recv uint64
|
|
||||||
for _, v := range apiStats.Networks {
|
|
||||||
total_sent += v.TxBytes
|
|
||||||
total_recv += v.RxBytes
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the DeltaTracker for this specific cache time
|
|
||||||
sentTracker := dm.getNetworkTracker(cacheTimeMs, true)
|
|
||||||
recvTracker := dm.getNetworkTracker(cacheTimeMs, false)
|
|
||||||
|
|
||||||
// Set current values in the cache-time-specific DeltaTracker
|
|
||||||
sentTracker.Set(ctr.IdShort, total_sent)
|
|
||||||
recvTracker.Set(ctr.IdShort, total_recv)
|
|
||||||
|
|
||||||
// Get deltas (bytes since last measurement)
|
|
||||||
sent_delta_raw := sentTracker.Delta(ctr.IdShort)
|
|
||||||
recv_delta_raw := recvTracker.Delta(ctr.IdShort)
|
|
||||||
|
|
||||||
// Calculate bytes per second independently for Tx and Rx if we have previous data
|
|
||||||
var sent_delta, recv_delta uint64
|
|
||||||
if initialized {
|
|
||||||
millisecondsElapsed := uint64(time.Since(stats.PrevReadTime).Milliseconds())
|
|
||||||
if millisecondsElapsed > 0 {
|
|
||||||
if sent_delta_raw > 0 {
|
|
||||||
sent_delta = sent_delta_raw * 1000 / millisecondsElapsed
|
|
||||||
if sent_delta > maxNetworkSpeedBps {
|
|
||||||
slog.Warn("Bad network delta", "container", name)
|
|
||||||
sent_delta = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if recv_delta_raw > 0 {
|
|
||||||
recv_delta = recv_delta_raw * 1000 / millisecondsElapsed
|
|
||||||
if recv_delta > maxNetworkSpeedBps {
|
|
||||||
slog.Warn("Bad network delta", "container", name)
|
|
||||||
recv_delta = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return sent_delta, recv_delta
|
|
||||||
}
|
|
||||||
|
|
||||||
// validateCpuPercentage checks if CPU percentage is within valid range
|
|
||||||
func validateCpuPercentage(cpuPct float64, containerName string) error {
|
|
||||||
if cpuPct > 100 {
|
|
||||||
return fmt.Errorf("%s cpu pct greater than 100: %+v", containerName, cpuPct)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateContainerStatsValues updates the final stats values
|
|
||||||
func updateContainerStatsValues(stats *container.Stats, cpuPct float64, usedMemory uint64, sent_delta, recv_delta uint64, readTime time.Time) {
|
|
||||||
stats.Cpu = twoDecimals(cpuPct)
|
|
||||||
stats.Mem = bytesToMegabytes(float64(usedMemory))
|
|
||||||
stats.NetworkSent = bytesToMegabytes(float64(sent_delta))
|
|
||||||
stats.NetworkRecv = bytesToMegabytes(float64(recv_delta))
|
|
||||||
stats.PrevReadTime = readTime
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseDockerStatus(status string) (string, container.DockerHealth) {
|
|
||||||
trimmed := strings.TrimSpace(status)
|
|
||||||
if trimmed == "" {
|
|
||||||
return "", container.DockerHealthNone
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove "About " from status
|
|
||||||
trimmed = strings.Replace(trimmed, "About ", "", 1)
|
|
||||||
|
|
||||||
openIdx := strings.LastIndex(trimmed, "(")
|
|
||||||
if openIdx == -1 || !strings.HasSuffix(trimmed, ")") {
|
|
||||||
return trimmed, container.DockerHealthNone
|
|
||||||
}
|
|
||||||
|
|
||||||
statusText := strings.TrimSpace(trimmed[:openIdx])
|
|
||||||
if statusText == "" {
|
|
||||||
statusText = trimmed
|
|
||||||
}
|
|
||||||
|
|
||||||
healthText := strings.ToLower(strings.TrimSpace(strings.TrimSuffix(trimmed[openIdx+1:], ")")))
|
|
||||||
// Some Docker statuses include a "health:" prefix inside the parentheses.
|
|
||||||
// Strip it so it maps correctly to the known health states.
|
|
||||||
if colonIdx := strings.IndexRune(healthText, ':'); colonIdx != -1 {
|
|
||||||
prefix := strings.TrimSpace(healthText[:colonIdx])
|
|
||||||
if prefix == "health" || prefix == "health status" {
|
|
||||||
healthText = strings.TrimSpace(healthText[colonIdx+1:])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if health, ok := container.DockerHealthStrings[healthText]; ok {
|
|
||||||
return statusText, health
|
|
||||||
}
|
|
||||||
|
|
||||||
return trimmed, container.DockerHealthNone
|
|
||||||
}
|
|
||||||
|
|
||||||
// Updates stats for individual container with cache-time-aware delta tracking
|
|
||||||
func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo, cacheTimeMs uint16) error {
|
|
||||||
name := ctr.Names[0][1:]
|
|
||||||
|
|
||||||
resp, err := dm.client.Get(fmt.Sprintf("http://localhost/containers/%s/stats?stream=0&one-shot=1", ctr.IdShort))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
dm.containerStatsMutex.Lock()
|
|
||||||
defer dm.containerStatsMutex.Unlock()
|
|
||||||
|
|
||||||
// add empty values if they doesn't exist in map
|
|
||||||
stats, initialized := dm.containerStatsMap[ctr.IdShort]
|
|
||||||
if !initialized {
|
|
||||||
stats = &container.Stats{Name: name, Id: ctr.IdShort, Image: ctr.Image}
|
|
||||||
dm.containerStatsMap[ctr.IdShort] = stats
|
|
||||||
}
|
|
||||||
|
|
||||||
stats.Id = ctr.IdShort
|
|
||||||
|
|
||||||
statusText, health := parseDockerStatus(ctr.Status)
|
|
||||||
stats.Status = statusText
|
|
||||||
stats.Health = health
|
|
||||||
|
|
||||||
// reset current stats
|
|
||||||
stats.Cpu = 0
|
|
||||||
stats.Mem = 0
|
|
||||||
stats.NetworkSent = 0
|
|
||||||
stats.NetworkRecv = 0
|
|
||||||
|
|
||||||
res := dm.apiStats
|
|
||||||
res.Networks = nil
|
|
||||||
if err := dm.decode(resp, res); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize CPU tracking for this cache time interval
|
|
||||||
dm.initializeCpuTracking(cacheTimeMs)
|
|
||||||
|
|
||||||
// Get previous CPU values
|
|
||||||
prevCpuContainer, prevCpuSystem := dm.getCpuPreviousValues(cacheTimeMs, ctr.IdShort)
|
|
||||||
|
|
||||||
// Calculate CPU percentage based on platform
|
|
||||||
var cpuPct float64
|
|
||||||
if dm.isWindows {
|
|
||||||
prevRead := dm.lastCpuReadTime[cacheTimeMs][ctr.IdShort]
|
|
||||||
cpuPct = res.CalculateCpuPercentWindows(prevCpuContainer, prevRead)
|
|
||||||
} else {
|
|
||||||
cpuPct = res.CalculateCpuPercentLinux(prevCpuContainer, prevCpuSystem)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate memory usage
|
|
||||||
usedMemory, err := calculateMemoryUsage(res, dm.isWindows)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("%s - %w - see https://github.com/henrygd/beszel/issues/144", name, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store current CPU stats for next calculation
|
|
||||||
currentCpuContainer := res.CPUStats.CPUUsage.TotalUsage
|
|
||||||
currentCpuSystem := res.CPUStats.SystemUsage
|
|
||||||
dm.setCpuCurrentValues(cacheTimeMs, ctr.IdShort, currentCpuContainer, currentCpuSystem)
|
|
||||||
|
|
||||||
// Validate CPU percentage
|
|
||||||
if err := validateCpuPercentage(cpuPct, name); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate network stats using DeltaTracker
|
|
||||||
sent_delta, recv_delta := dm.calculateNetworkStats(ctr, res, stats, initialized, name, cacheTimeMs)
|
|
||||||
|
|
||||||
// Store current network values for legacy compatibility
|
|
||||||
var total_sent, total_recv uint64
|
|
||||||
for _, v := range res.Networks {
|
|
||||||
total_sent += v.TxBytes
|
|
||||||
total_recv += v.RxBytes
|
|
||||||
}
|
|
||||||
stats.PrevNet.Sent, stats.PrevNet.Recv = total_sent, total_recv
|
|
||||||
|
|
||||||
// Update final stats values
|
|
||||||
updateContainerStatsValues(stats, cpuPct, usedMemory, sent_delta, recv_delta, res.Read)
|
|
||||||
// store per-cache-time read time for Windows CPU percent calc
|
|
||||||
dm.lastCpuReadTime[cacheTimeMs][ctr.IdShort] = res.Read
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete container stats from map using mutex
|
|
||||||
func (dm *dockerManager) deleteContainerStatsSync(id string) {
|
|
||||||
dm.containerStatsMutex.Lock()
|
|
||||||
defer dm.containerStatsMutex.Unlock()
|
|
||||||
delete(dm.containerStatsMap, id)
|
|
||||||
for ct := range dm.lastCpuContainer {
|
|
||||||
delete(dm.lastCpuContainer[ct], id)
|
|
||||||
}
|
|
||||||
for ct := range dm.lastCpuSystem {
|
|
||||||
delete(dm.lastCpuSystem[ct], id)
|
|
||||||
}
|
|
||||||
for ct := range dm.lastCpuReadTime {
|
|
||||||
delete(dm.lastCpuReadTime[ct], id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Creates a new http client for Docker or Podman API
|
|
||||||
func newDockerManager() *dockerManager {
|
|
||||||
dockerHost, exists := GetEnv("DOCKER_HOST")
|
|
||||||
if exists {
|
|
||||||
// return nil if set to empty string
|
|
||||||
if dockerHost == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
dockerHost = getDockerHost()
|
|
||||||
}
|
|
||||||
|
|
||||||
parsedURL, err := url.Parse(dockerHost)
|
|
||||||
if err != nil {
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
transport := &http.Transport{
|
|
||||||
DisableCompression: true,
|
|
||||||
MaxConnsPerHost: 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
switch parsedURL.Scheme {
|
|
||||||
case "unix":
|
|
||||||
transport.DialContext = func(ctx context.Context, proto, addr string) (net.Conn, error) {
|
|
||||||
return (&net.Dialer{}).DialContext(ctx, "unix", parsedURL.Path)
|
|
||||||
}
|
|
||||||
case "tcp", "http", "https":
|
|
||||||
transport.DialContext = func(ctx context.Context, proto, addr string) (net.Conn, error) {
|
|
||||||
return (&net.Dialer{}).DialContext(ctx, "tcp", parsedURL.Host)
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
slog.Error("Invalid DOCKER_HOST", "scheme", parsedURL.Scheme)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// configurable timeout
|
|
||||||
timeout := time.Millisecond * time.Duration(dockerTimeoutMs)
|
|
||||||
if t, set := GetEnv("DOCKER_TIMEOUT"); set {
|
|
||||||
timeout, err = time.ParseDuration(t)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error(err.Error())
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
slog.Info("DOCKER_TIMEOUT", "timeout", timeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Custom user-agent to avoid docker bug: https://github.com/docker/for-mac/issues/7575
|
|
||||||
userAgentTransport := &userAgentRoundTripper{
|
|
||||||
rt: transport,
|
|
||||||
userAgent: "Docker-Client/",
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read container exclusion patterns from environment variable
|
|
||||||
var excludeContainers []string
|
|
||||||
if excludeStr, set := GetEnv("EXCLUDE_CONTAINERS"); set && excludeStr != "" {
|
|
||||||
parts := strings.SplitSeq(excludeStr, ",")
|
|
||||||
for part := range parts {
|
|
||||||
trimmed := strings.TrimSpace(part)
|
|
||||||
if trimmed != "" {
|
|
||||||
excludeContainers = append(excludeContainers, trimmed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
slog.Info("EXCLUDE_CONTAINERS", "patterns", excludeContainers)
|
|
||||||
}
|
|
||||||
|
|
||||||
manager := &dockerManager{
|
|
||||||
client: &http.Client{
|
|
||||||
Timeout: timeout,
|
|
||||||
Transport: userAgentTransport,
|
|
||||||
},
|
|
||||||
containerStatsMap: make(map[string]*container.Stats),
|
|
||||||
sem: make(chan struct{}, 5),
|
|
||||||
apiContainerList: []*container.ApiInfo{},
|
|
||||||
apiStats: &container.ApiStats{},
|
|
||||||
excludeContainers: excludeContainers,
|
|
||||||
|
|
||||||
// Initialize cache-time-aware tracking structures
|
|
||||||
lastCpuContainer: make(map[uint16]map[string]uint64),
|
|
||||||
lastCpuSystem: make(map[uint16]map[string]uint64),
|
|
||||||
lastCpuReadTime: make(map[uint16]map[string]time.Time),
|
|
||||||
networkSentTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
|
|
||||||
networkRecvTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
|
|
||||||
}
|
|
||||||
|
|
||||||
// If using podman, return client
|
|
||||||
if strings.Contains(dockerHost, "podman") {
|
|
||||||
manager.usingPodman = true
|
|
||||||
manager.goodDockerVersion = true
|
|
||||||
return manager
|
|
||||||
}
|
|
||||||
|
|
||||||
// this can take up to 5 seconds with retry, so run in goroutine
|
|
||||||
go manager.checkDockerVersion()
|
|
||||||
|
|
||||||
// give version check a chance to complete before returning
|
|
||||||
time.Sleep(50 * time.Millisecond)
|
|
||||||
|
|
||||||
return manager
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkDockerVersion checks Docker version and sets goodDockerVersion if at least 25.0.0.
|
|
||||||
// Versions before 25.0.0 have a bug with one-shot which requires all requests to be made in one batch.
|
|
||||||
func (dm *dockerManager) checkDockerVersion() {
|
|
||||||
var err error
|
|
||||||
var resp *http.Response
|
|
||||||
var versionInfo struct {
|
|
||||||
Version string `json:"Version"`
|
|
||||||
}
|
|
||||||
const versionMaxTries = 2
|
|
||||||
for i := 1; i <= versionMaxTries; i++ {
|
|
||||||
resp, err = dm.client.Get("http://localhost/version")
|
|
||||||
if err == nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if resp != nil {
|
|
||||||
resp.Body.Close()
|
|
||||||
}
|
|
||||||
if i < versionMaxTries {
|
|
||||||
slog.Debug("Failed to get Docker version; retrying", "attempt", i, "error", err)
|
|
||||||
time.Sleep(5 * time.Second)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := dm.decode(resp, &versionInfo); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// if version > 24, one-shot works correctly and we can limit concurrent operations
|
|
||||||
if dockerVersion, err := semver.Parse(versionInfo.Version); err == nil && dockerVersion.Major > 24 {
|
|
||||||
dm.goodDockerVersion = true
|
|
||||||
} else {
|
|
||||||
slog.Info(fmt.Sprintf("Docker %s is outdated. Upgrade if possible. See https://github.com/henrygd/beszel/issues/58", versionInfo.Version))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decodes Docker API JSON response using a reusable buffer and decoder. Not thread safe.
|
|
||||||
func (dm *dockerManager) decode(resp *http.Response, d any) error {
|
|
||||||
if dm.buf == nil {
|
|
||||||
// initialize buffer with 256kb starting size
|
|
||||||
dm.buf = bytes.NewBuffer(make([]byte, 0, 1024*256))
|
|
||||||
dm.decoder = json.NewDecoder(dm.buf)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
defer dm.buf.Reset()
|
|
||||||
_, err := dm.buf.ReadFrom(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return dm.decoder.Decode(d)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test docker / podman sockets and return if one exists
|
|
||||||
func getDockerHost() string {
|
|
||||||
scheme := "unix://"
|
|
||||||
socks := []string{"/var/run/docker.sock", fmt.Sprintf("/run/user/%v/podman/podman.sock", os.Getuid())}
|
|
||||||
for _, sock := range socks {
|
|
||||||
if _, err := os.Stat(sock); err == nil {
|
|
||||||
return scheme + sock
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return scheme + socks[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
// getContainerInfo fetches the inspection data for a container
|
|
||||||
func (dm *dockerManager) getContainerInfo(ctx context.Context, containerID string) ([]byte, error) {
|
|
||||||
endpoint := fmt.Sprintf("http://localhost/containers/%s/json", containerID)
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := dm.client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
|
||||||
return nil, fmt.Errorf("container info request failed: %s: %s", resp.Status, strings.TrimSpace(string(body)))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove sensitive environment variables from Config.Env
|
|
||||||
var containerInfo map[string]any
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&containerInfo); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if config, ok := containerInfo["Config"].(map[string]any); ok {
|
|
||||||
delete(config, "Env")
|
|
||||||
}
|
|
||||||
|
|
||||||
return json.Marshal(containerInfo)
|
|
||||||
}
|
|
||||||
|
|
||||||
// getLogs fetches the logs for a container
|
|
||||||
func (dm *dockerManager) getLogs(ctx context.Context, containerID string) (string, error) {
|
|
||||||
endpoint := fmt.Sprintf("http://localhost/containers/%s/logs?stdout=1&stderr=1&tail=%d", containerID, dockerLogsTail)
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := dm.client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
|
||||||
return "", fmt.Errorf("logs request failed: %s: %s", resp.Status, strings.TrimSpace(string(body)))
|
|
||||||
}
|
|
||||||
|
|
||||||
var builder strings.Builder
|
|
||||||
multiplexed := resp.Header.Get("Content-Type") == "application/vnd.docker.multiplexed-stream"
|
|
||||||
if err := decodeDockerLogStream(resp.Body, &builder, multiplexed); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Strip ANSI escape sequences from logs for clean display in web UI
|
|
||||||
logs := builder.String()
|
|
||||||
if strings.Contains(logs, "\x1b") {
|
|
||||||
logs = ansiEscapePattern.ReplaceAllString(logs, "")
|
|
||||||
}
|
|
||||||
return logs, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func decodeDockerLogStream(reader io.Reader, builder *strings.Builder, multiplexed bool) error {
|
|
||||||
if !multiplexed {
|
|
||||||
_, err := io.Copy(builder, io.LimitReader(reader, maxTotalLogSize))
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
const headerSize = 8
|
|
||||||
var header [headerSize]byte
|
|
||||||
totalBytesRead := 0
|
|
||||||
|
|
||||||
for {
|
|
||||||
if _, err := io.ReadFull(reader, header[:]); err != nil {
|
|
||||||
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
frameLen := binary.BigEndian.Uint32(header[4:])
|
|
||||||
if frameLen == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prevent memory exhaustion from excessively large frames
|
|
||||||
if frameLen > maxLogFrameSize {
|
|
||||||
return fmt.Errorf("log frame size (%d) exceeds maximum (%d)", frameLen, maxLogFrameSize)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if reading this frame would exceed total log size limit
|
|
||||||
if totalBytesRead+int(frameLen) > maxTotalLogSize {
|
|
||||||
// Read and discard remaining data to avoid blocking
|
|
||||||
_, _ = io.CopyN(io.Discard, reader, int64(frameLen))
|
|
||||||
slog.Debug("Truncating logs: limit reached", "read", totalBytesRead, "limit", maxTotalLogSize)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
n, err := io.CopyN(builder, reader, int64(frameLen))
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
totalBytesRead += int(n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetHostInfo fetches the system info from Docker
|
|
||||||
func (dm *dockerManager) GetHostInfo() (info container.HostInfo, err error) {
|
|
||||||
resp, err := dm.client.Get("http://localhost/info")
|
|
||||||
if err != nil {
|
|
||||||
return info, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
|
|
||||||
return info, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return info, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (dm *dockerManager) IsPodman() bool {
|
|
||||||
return dm.usingPodman
|
|
||||||
}
|
|
||||||
1243
agent/docker_test.go
1243
agent/docker_test.go
File diff suppressed because it is too large
Load Diff
@@ -1,206 +0,0 @@
|
|||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"io"
|
|
||||||
"os/exec"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
intelGpuStatsCmd string = "intel_gpu_top"
|
|
||||||
intelGpuStatsInterval string = "3300" // in milliseconds
|
|
||||||
)
|
|
||||||
|
|
||||||
type intelGpuStats struct {
|
|
||||||
PowerGPU float64
|
|
||||||
PowerPkg float64
|
|
||||||
Engines map[string]float64
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateIntelFromStats updates aggregated GPU data from a single intelGpuStats sample
|
|
||||||
func (gm *GPUManager) updateIntelFromStats(sample *intelGpuStats) bool {
|
|
||||||
gm.Lock()
|
|
||||||
defer gm.Unlock()
|
|
||||||
|
|
||||||
// only one gpu for now - cmd doesn't provide all by default
|
|
||||||
gpuData, ok := gm.GpuDataMap["0"]
|
|
||||||
if !ok {
|
|
||||||
gpuData = &system.GPUData{Name: "GPU", Engines: make(map[string]float64)}
|
|
||||||
gm.GpuDataMap["0"] = gpuData
|
|
||||||
}
|
|
||||||
|
|
||||||
gpuData.Power += sample.PowerGPU
|
|
||||||
gpuData.PowerPkg += sample.PowerPkg
|
|
||||||
|
|
||||||
if gpuData.Engines == nil {
|
|
||||||
gpuData.Engines = make(map[string]float64, len(sample.Engines))
|
|
||||||
}
|
|
||||||
for name, engine := range sample.Engines {
|
|
||||||
gpuData.Engines[name] += engine
|
|
||||||
}
|
|
||||||
|
|
||||||
gpuData.Count++
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// collectIntelStats executes intel_gpu_top in text mode (-l) and parses the output
|
|
||||||
func (gm *GPUManager) collectIntelStats() (err error) {
|
|
||||||
// Build command arguments, optionally selecting a device via -d
|
|
||||||
args := []string{"-s", intelGpuStatsInterval, "-l"}
|
|
||||||
if dev, ok := GetEnv("INTEL_GPU_DEVICE"); ok && dev != "" {
|
|
||||||
args = append(args, "-d", dev)
|
|
||||||
}
|
|
||||||
cmd := exec.Command(intelGpuStatsCmd, args...)
|
|
||||||
// Avoid blocking if intel_gpu_top writes to stderr
|
|
||||||
cmd.Stderr = io.Discard
|
|
||||||
stdout, err := cmd.StdoutPipe()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := cmd.Start(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure we always reap the child to avoid zombies on any return path and
|
|
||||||
// propagate a non-zero exit code if no other error was set.
|
|
||||||
defer func() {
|
|
||||||
// Best-effort close of the pipe (unblock the child if it writes)
|
|
||||||
_ = stdout.Close()
|
|
||||||
if cmd.ProcessState == nil || !cmd.ProcessState.Exited() {
|
|
||||||
_ = cmd.Process.Kill()
|
|
||||||
}
|
|
||||||
if waitErr := cmd.Wait(); err == nil && waitErr != nil {
|
|
||||||
err = waitErr
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
scanner := bufio.NewScanner(stdout)
|
|
||||||
var header1 string
|
|
||||||
var engineNames []string
|
|
||||||
var friendlyNames []string
|
|
||||||
var preEngineCols int
|
|
||||||
var powerIndex int
|
|
||||||
var hadDataRow bool
|
|
||||||
// skip first data row because it sometimes has erroneous data
|
|
||||||
var skippedFirstDataRow bool
|
|
||||||
|
|
||||||
for scanner.Scan() {
|
|
||||||
line := strings.TrimSpace(scanner.Text())
|
|
||||||
if line == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// first header line
|
|
||||||
if strings.HasPrefix(line, "Freq") {
|
|
||||||
header1 = line
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// second header line
|
|
||||||
if strings.HasPrefix(line, "req") {
|
|
||||||
engineNames, friendlyNames, powerIndex, preEngineCols = gm.parseIntelHeaders(header1, line)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Data row
|
|
||||||
if !skippedFirstDataRow {
|
|
||||||
skippedFirstDataRow = true
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
sample, err := gm.parseIntelData(line, engineNames, friendlyNames, powerIndex, preEngineCols)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
hadDataRow = true
|
|
||||||
gm.updateIntelFromStats(&sample)
|
|
||||||
}
|
|
||||||
if scanErr := scanner.Err(); scanErr != nil {
|
|
||||||
return scanErr
|
|
||||||
}
|
|
||||||
if !hadDataRow {
|
|
||||||
return errNoValidData
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (gm *GPUManager) parseIntelHeaders(header1 string, header2 string) (engineNames []string, friendlyNames []string, powerIndex int, preEngineCols int) {
|
|
||||||
// Build indexes
|
|
||||||
h1 := strings.Fields(header1)
|
|
||||||
h2 := strings.Fields(header2)
|
|
||||||
powerIndex = -1 // Initialize to -1, will be set to actual index if found
|
|
||||||
// Collect engine names from header1
|
|
||||||
for _, col := range h1 {
|
|
||||||
key := strings.TrimRightFunc(col, func(r rune) bool {
|
|
||||||
return (r >= '0' && r <= '9') || r == '/'
|
|
||||||
})
|
|
||||||
var friendly string
|
|
||||||
switch key {
|
|
||||||
case "RCS":
|
|
||||||
friendly = "Render/3D"
|
|
||||||
case "BCS":
|
|
||||||
friendly = "Blitter"
|
|
||||||
case "VCS":
|
|
||||||
friendly = "Video"
|
|
||||||
case "VECS":
|
|
||||||
friendly = "VideoEnhance"
|
|
||||||
case "CCS":
|
|
||||||
friendly = "Compute"
|
|
||||||
default:
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
engineNames = append(engineNames, key)
|
|
||||||
friendlyNames = append(friendlyNames, friendly)
|
|
||||||
}
|
|
||||||
// find power gpu index among pre-engine columns
|
|
||||||
if n := len(engineNames); n > 0 {
|
|
||||||
preEngineCols = max(len(h2)-3*n, 0)
|
|
||||||
limit := min(len(h2), preEngineCols)
|
|
||||||
for i := range limit {
|
|
||||||
if strings.EqualFold(h2[i], "gpu") {
|
|
||||||
powerIndex = i
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return engineNames, friendlyNames, powerIndex, preEngineCols
|
|
||||||
}
|
|
||||||
|
|
||||||
func (gm *GPUManager) parseIntelData(line string, engineNames []string, friendlyNames []string, powerIndex int, preEngineCols int) (sample intelGpuStats, err error) {
|
|
||||||
fields := strings.Fields(line)
|
|
||||||
if len(fields) == 0 {
|
|
||||||
return sample, errNoValidData
|
|
||||||
}
|
|
||||||
// Make sure row has enough columns for engines
|
|
||||||
if need := preEngineCols + 3*len(engineNames); len(fields) < need {
|
|
||||||
return sample, errNoValidData
|
|
||||||
}
|
|
||||||
if powerIndex >= 0 && powerIndex < len(fields) {
|
|
||||||
if v, perr := strconv.ParseFloat(fields[powerIndex], 64); perr == nil {
|
|
||||||
sample.PowerGPU = v
|
|
||||||
}
|
|
||||||
if v, perr := strconv.ParseFloat(fields[powerIndex+1], 64); perr == nil {
|
|
||||||
sample.PowerPkg = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(engineNames) > 0 {
|
|
||||||
sample.Engines = make(map[string]float64, len(engineNames))
|
|
||||||
for k := range engineNames {
|
|
||||||
base := preEngineCols + 3*k
|
|
||||||
if base < len(fields) {
|
|
||||||
busy := 0.0
|
|
||||||
if v, e := strconv.ParseFloat(fields[base], 64); e == nil {
|
|
||||||
busy = v
|
|
||||||
}
|
|
||||||
cur := sample.Engines[friendlyNames[k]]
|
|
||||||
sample.Engines[friendlyNames[k]] = cur + busy
|
|
||||||
} else {
|
|
||||||
sample.Engines[friendlyNames[k]] = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return sample, nil
|
|
||||||
}
|
|
||||||
@@ -1,224 +0,0 @@
|
|||||||
//go:build (linux || windows) && (amd64 || arm64)
|
|
||||||
|
|
||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
"unsafe"
|
|
||||||
|
|
||||||
"github.com/ebitengine/purego"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
|
||||||
)
|
|
||||||
|
|
||||||
// NVML constants and types
|
|
||||||
const (
|
|
||||||
nvmlSuccess int = 0
|
|
||||||
)
|
|
||||||
|
|
||||||
type nvmlDevice uintptr
|
|
||||||
|
|
||||||
type nvmlReturn int
|
|
||||||
|
|
||||||
type nvmlMemoryV1 struct {
|
|
||||||
Total uint64
|
|
||||||
Free uint64
|
|
||||||
Used uint64
|
|
||||||
}
|
|
||||||
|
|
||||||
type nvmlMemoryV2 struct {
|
|
||||||
Version uint32
|
|
||||||
Total uint64
|
|
||||||
Reserved uint64
|
|
||||||
Free uint64
|
|
||||||
Used uint64
|
|
||||||
}
|
|
||||||
|
|
||||||
type nvmlUtilization struct {
|
|
||||||
Gpu uint32
|
|
||||||
Memory uint32
|
|
||||||
}
|
|
||||||
|
|
||||||
type nvmlPciInfo struct {
|
|
||||||
BusId [16]byte
|
|
||||||
Domain uint32
|
|
||||||
Bus uint32
|
|
||||||
Device uint32
|
|
||||||
PciDeviceId uint32
|
|
||||||
PciSubSystemId uint32
|
|
||||||
}
|
|
||||||
|
|
||||||
// NVML function signatures
|
|
||||||
var (
|
|
||||||
nvmlInit func() nvmlReturn
|
|
||||||
nvmlShutdown func() nvmlReturn
|
|
||||||
nvmlDeviceGetCount func(count *uint32) nvmlReturn
|
|
||||||
nvmlDeviceGetHandleByIndex func(index uint32, device *nvmlDevice) nvmlReturn
|
|
||||||
nvmlDeviceGetName func(device nvmlDevice, name *byte, length uint32) nvmlReturn
|
|
||||||
nvmlDeviceGetMemoryInfo func(device nvmlDevice, memory uintptr) nvmlReturn
|
|
||||||
nvmlDeviceGetUtilizationRates func(device nvmlDevice, utilization *nvmlUtilization) nvmlReturn
|
|
||||||
nvmlDeviceGetTemperature func(device nvmlDevice, sensorType int, temp *uint32) nvmlReturn
|
|
||||||
nvmlDeviceGetPowerUsage func(device nvmlDevice, power *uint32) nvmlReturn
|
|
||||||
nvmlDeviceGetPciInfo func(device nvmlDevice, pci *nvmlPciInfo) nvmlReturn
|
|
||||||
nvmlErrorString func(result nvmlReturn) string
|
|
||||||
)
|
|
||||||
|
|
||||||
type nvmlCollector struct {
|
|
||||||
gm *GPUManager
|
|
||||||
lib uintptr
|
|
||||||
devices []nvmlDevice
|
|
||||||
bdfs []string
|
|
||||||
isV2 bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *nvmlCollector) init() error {
|
|
||||||
slog.Debug("NVML: Initializing")
|
|
||||||
libPath := getNVMLPath()
|
|
||||||
|
|
||||||
lib, err := openLibrary(libPath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to load %s: %w", libPath, err)
|
|
||||||
}
|
|
||||||
c.lib = lib
|
|
||||||
|
|
||||||
purego.RegisterLibFunc(&nvmlInit, lib, "nvmlInit")
|
|
||||||
purego.RegisterLibFunc(&nvmlShutdown, lib, "nvmlShutdown")
|
|
||||||
purego.RegisterLibFunc(&nvmlDeviceGetCount, lib, "nvmlDeviceGetCount")
|
|
||||||
purego.RegisterLibFunc(&nvmlDeviceGetHandleByIndex, lib, "nvmlDeviceGetHandleByIndex")
|
|
||||||
purego.RegisterLibFunc(&nvmlDeviceGetName, lib, "nvmlDeviceGetName")
|
|
||||||
// Try to get v2 memory info, fallback to v1 if not available
|
|
||||||
if hasSymbol(lib, "nvmlDeviceGetMemoryInfo_v2") {
|
|
||||||
c.isV2 = true
|
|
||||||
purego.RegisterLibFunc(&nvmlDeviceGetMemoryInfo, lib, "nvmlDeviceGetMemoryInfo_v2")
|
|
||||||
} else {
|
|
||||||
purego.RegisterLibFunc(&nvmlDeviceGetMemoryInfo, lib, "nvmlDeviceGetMemoryInfo")
|
|
||||||
}
|
|
||||||
purego.RegisterLibFunc(&nvmlDeviceGetUtilizationRates, lib, "nvmlDeviceGetUtilizationRates")
|
|
||||||
purego.RegisterLibFunc(&nvmlDeviceGetTemperature, lib, "nvmlDeviceGetTemperature")
|
|
||||||
purego.RegisterLibFunc(&nvmlDeviceGetPowerUsage, lib, "nvmlDeviceGetPowerUsage")
|
|
||||||
purego.RegisterLibFunc(&nvmlDeviceGetPciInfo, lib, "nvmlDeviceGetPciInfo")
|
|
||||||
purego.RegisterLibFunc(&nvmlErrorString, lib, "nvmlErrorString")
|
|
||||||
|
|
||||||
if ret := nvmlInit(); ret != nvmlReturn(nvmlSuccess) {
|
|
||||||
return fmt.Errorf("nvmlInit failed: %v", ret)
|
|
||||||
}
|
|
||||||
|
|
||||||
var count uint32
|
|
||||||
if ret := nvmlDeviceGetCount(&count); ret != nvmlReturn(nvmlSuccess) {
|
|
||||||
return fmt.Errorf("nvmlDeviceGetCount failed: %v", ret)
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := uint32(0); i < count; i++ {
|
|
||||||
var device nvmlDevice
|
|
||||||
if ret := nvmlDeviceGetHandleByIndex(i, &device); ret == nvmlReturn(nvmlSuccess) {
|
|
||||||
c.devices = append(c.devices, device)
|
|
||||||
// Get BDF for power state check
|
|
||||||
var pci nvmlPciInfo
|
|
||||||
if ret := nvmlDeviceGetPciInfo(device, &pci); ret == nvmlReturn(nvmlSuccess) {
|
|
||||||
busID := string(pci.BusId[:])
|
|
||||||
if idx := strings.Index(busID, "\x00"); idx != -1 {
|
|
||||||
busID = busID[:idx]
|
|
||||||
}
|
|
||||||
c.bdfs = append(c.bdfs, strings.ToLower(busID))
|
|
||||||
} else {
|
|
||||||
c.bdfs = append(c.bdfs, "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *nvmlCollector) start() {
|
|
||||||
defer nvmlShutdown()
|
|
||||||
ticker := time.Tick(3 * time.Second)
|
|
||||||
|
|
||||||
for range ticker {
|
|
||||||
c.collect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *nvmlCollector) collect() {
|
|
||||||
c.gm.Lock()
|
|
||||||
defer c.gm.Unlock()
|
|
||||||
|
|
||||||
for i, device := range c.devices {
|
|
||||||
id := fmt.Sprintf("%d", i)
|
|
||||||
bdf := c.bdfs[i]
|
|
||||||
|
|
||||||
// Update GPUDataMap
|
|
||||||
if _, ok := c.gm.GpuDataMap[id]; !ok {
|
|
||||||
var nameBuf [64]byte
|
|
||||||
if ret := nvmlDeviceGetName(device, &nameBuf[0], 64); ret != nvmlReturn(nvmlSuccess) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
name := string(nameBuf[:strings.Index(string(nameBuf[:]), "\x00")])
|
|
||||||
name = strings.TrimPrefix(name, "NVIDIA ")
|
|
||||||
c.gm.GpuDataMap[id] = &system.GPUData{Name: strings.TrimSuffix(name, " Laptop GPU")}
|
|
||||||
}
|
|
||||||
gpu := c.gm.GpuDataMap[id]
|
|
||||||
|
|
||||||
if bdf != "" && !c.isGPUActive(bdf) {
|
|
||||||
slog.Debug("NVML: GPU is suspended, skipping", "bdf", bdf)
|
|
||||||
gpu.Temperature = 0
|
|
||||||
gpu.MemoryUsed = 0
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Utilization
|
|
||||||
var utilization nvmlUtilization
|
|
||||||
if ret := nvmlDeviceGetUtilizationRates(device, &utilization); ret != nvmlReturn(nvmlSuccess) {
|
|
||||||
slog.Debug("NVML: Utilization failed (GPU likely suspended)", "bdf", bdf, "ret", ret)
|
|
||||||
gpu.Temperature = 0
|
|
||||||
gpu.MemoryUsed = 0
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
slog.Debug("NVML: Collecting data for GPU", "bdf", bdf)
|
|
||||||
|
|
||||||
// Temperature
|
|
||||||
var temp uint32
|
|
||||||
nvmlDeviceGetTemperature(device, 0, &temp) // 0 is NVML_TEMPERATURE_GPU
|
|
||||||
|
|
||||||
// Memory: only poll if GPU is active to avoid leaving D3cold state (#1522)
|
|
||||||
if utilization.Gpu > 0 {
|
|
||||||
var usedMem, totalMem uint64
|
|
||||||
if c.isV2 {
|
|
||||||
var memory nvmlMemoryV2
|
|
||||||
memory.Version = 0x02000028 // (2 << 24) | 40 bytes
|
|
||||||
if ret := nvmlDeviceGetMemoryInfo(device, uintptr(unsafe.Pointer(&memory))); ret != nvmlReturn(nvmlSuccess) {
|
|
||||||
slog.Debug("NVML: MemoryInfo_v2 failed", "bdf", bdf, "ret", ret)
|
|
||||||
} else {
|
|
||||||
usedMem = memory.Used
|
|
||||||
totalMem = memory.Total
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
var memory nvmlMemoryV1
|
|
||||||
if ret := nvmlDeviceGetMemoryInfo(device, uintptr(unsafe.Pointer(&memory))); ret != nvmlReturn(nvmlSuccess) {
|
|
||||||
slog.Debug("NVML: MemoryInfo failed", "bdf", bdf, "ret", ret)
|
|
||||||
} else {
|
|
||||||
usedMem = memory.Used
|
|
||||||
totalMem = memory.Total
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if totalMem > 0 {
|
|
||||||
gpu.MemoryUsed = float64(usedMem) / 1024 / 1024 / mebibytesInAMegabyte
|
|
||||||
gpu.MemoryTotal = float64(totalMem) / 1024 / 1024 / mebibytesInAMegabyte
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
slog.Debug("NVML: Skipping memory info (utilization=0)", "bdf", bdf)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Power
|
|
||||||
var power uint32
|
|
||||||
nvmlDeviceGetPowerUsage(device, &power)
|
|
||||||
|
|
||||||
gpu.Temperature = float64(temp)
|
|
||||||
gpu.Usage += float64(utilization.Gpu)
|
|
||||||
gpu.Power += float64(power) / 1000.0
|
|
||||||
gpu.Count++
|
|
||||||
slog.Debug("NVML: Collected data", "gpu", gpu)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
//go:build linux && (amd64 || arm64)
|
|
||||||
|
|
||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"log/slog"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/ebitengine/purego"
|
|
||||||
)
|
|
||||||
|
|
||||||
func openLibrary(name string) (uintptr, error) {
|
|
||||||
return purego.Dlopen(name, purego.RTLD_NOW|purego.RTLD_GLOBAL)
|
|
||||||
}
|
|
||||||
|
|
||||||
func getNVMLPath() string {
|
|
||||||
return "libnvidia-ml.so.1"
|
|
||||||
}
|
|
||||||
|
|
||||||
func hasSymbol(lib uintptr, symbol string) bool {
|
|
||||||
_, err := purego.Dlsym(lib, symbol)
|
|
||||||
return err == nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *nvmlCollector) isGPUActive(bdf string) bool {
|
|
||||||
// runtime_status
|
|
||||||
statusPath := filepath.Join("/sys/bus/pci/devices", bdf, "power/runtime_status")
|
|
||||||
status, err := os.ReadFile(statusPath)
|
|
||||||
if err != nil {
|
|
||||||
slog.Debug("NVML: Can't read runtime_status", "bdf", bdf, "err", err)
|
|
||||||
return true // Assume active if we can't read status
|
|
||||||
}
|
|
||||||
statusStr := strings.TrimSpace(string(status))
|
|
||||||
if statusStr != "active" && statusStr != "resuming" {
|
|
||||||
slog.Debug("NVML: GPU not active", "bdf", bdf, "status", statusStr)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// power_state (D0 check)
|
|
||||||
// Find any drm card device power_state
|
|
||||||
pstatePathPattern := filepath.Join("/sys/bus/pci/devices", bdf, "drm/card*/device/power_state")
|
|
||||||
matches, _ := filepath.Glob(pstatePathPattern)
|
|
||||||
if len(matches) > 0 {
|
|
||||||
pstate, err := os.ReadFile(matches[0])
|
|
||||||
if err == nil {
|
|
||||||
pstateStr := strings.TrimSpace(string(pstate))
|
|
||||||
if pstateStr != "D0" {
|
|
||||||
slog.Debug("NVML: GPU not in D0 state", "bdf", bdf, "pstate", pstateStr)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
//go:build (!linux && !windows) || (!amd64 && !arm64)
|
|
||||||
|
|
||||||
package agent
|
|
||||||
|
|
||||||
import "fmt"
|
|
||||||
|
|
||||||
type nvmlCollector struct {
|
|
||||||
gm *GPUManager
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *nvmlCollector) init() error {
|
|
||||||
return fmt.Errorf("nvml not supported on this platform")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *nvmlCollector) start() {}
|
|
||||||
|
|
||||||
func (c *nvmlCollector) collect() {}
|
|
||||||
|
|
||||||
func openLibrary(name string) (uintptr, error) {
|
|
||||||
return 0, fmt.Errorf("nvml not supported on this platform")
|
|
||||||
}
|
|
||||||
|
|
||||||
func getNVMLPath() string {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func hasSymbol(lib uintptr, symbol string) bool {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *nvmlCollector) isGPUActive(bdf string) bool {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
//go:build windows && (amd64 || arm64)
|
|
||||||
|
|
||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"golang.org/x/sys/windows"
|
|
||||||
)
|
|
||||||
|
|
||||||
func openLibrary(name string) (uintptr, error) {
|
|
||||||
handle, err := windows.LoadLibrary(name)
|
|
||||||
return uintptr(handle), err
|
|
||||||
}
|
|
||||||
|
|
||||||
func getNVMLPath() string {
|
|
||||||
return "nvml.dll"
|
|
||||||
}
|
|
||||||
|
|
||||||
func hasSymbol(lib uintptr, symbol string) bool {
|
|
||||||
_, err := windows.GetProcAddress(windows.Handle(lib), symbol)
|
|
||||||
return err == nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *nvmlCollector) isGPUActive(bdf string) bool {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
1702
agent/gpu_test.go
1702
agent/gpu_test.go
File diff suppressed because it is too large
Load Diff
@@ -1,205 +0,0 @@
|
|||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/fxamacker/cbor/v2"
|
|
||||||
"github.com/henrygd/beszel/internal/common"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/smart"
|
|
||||||
|
|
||||||
"log/slog"
|
|
||||||
)
|
|
||||||
|
|
||||||
// HandlerContext provides context for request handlers
|
|
||||||
type HandlerContext struct {
|
|
||||||
Client *WebSocketClient
|
|
||||||
Agent *Agent
|
|
||||||
Request *common.HubRequest[cbor.RawMessage]
|
|
||||||
RequestID *uint32
|
|
||||||
HubVerified bool
|
|
||||||
// SendResponse abstracts how a handler sends responses (WS or SSH)
|
|
||||||
SendResponse func(data any, requestID *uint32) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// RequestHandler defines the interface for handling specific websocket request types
|
|
||||||
type RequestHandler interface {
|
|
||||||
// Handle processes the request and returns an error if unsuccessful
|
|
||||||
Handle(hctx *HandlerContext) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// Responder sends handler responses back to the hub (over WS or SSH)
|
|
||||||
type Responder interface {
|
|
||||||
SendResponse(data any, requestID *uint32) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandlerRegistry manages the mapping between actions and their handlers
|
|
||||||
type HandlerRegistry struct {
|
|
||||||
handlers map[common.WebSocketAction]RequestHandler
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewHandlerRegistry creates a new handler registry with default handlers
|
|
||||||
func NewHandlerRegistry() *HandlerRegistry {
|
|
||||||
registry := &HandlerRegistry{
|
|
||||||
handlers: make(map[common.WebSocketAction]RequestHandler),
|
|
||||||
}
|
|
||||||
|
|
||||||
registry.Register(common.GetData, &GetDataHandler{})
|
|
||||||
registry.Register(common.CheckFingerprint, &CheckFingerprintHandler{})
|
|
||||||
registry.Register(common.GetContainerLogs, &GetContainerLogsHandler{})
|
|
||||||
registry.Register(common.GetContainerInfo, &GetContainerInfoHandler{})
|
|
||||||
registry.Register(common.GetSmartData, &GetSmartDataHandler{})
|
|
||||||
registry.Register(common.GetSystemdInfo, &GetSystemdInfoHandler{})
|
|
||||||
|
|
||||||
return registry
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register registers a handler for a specific action type
|
|
||||||
func (hr *HandlerRegistry) Register(action common.WebSocketAction, handler RequestHandler) {
|
|
||||||
hr.handlers[action] = handler
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle routes the request to the appropriate handler
|
|
||||||
func (hr *HandlerRegistry) Handle(hctx *HandlerContext) error {
|
|
||||||
handler, exists := hr.handlers[hctx.Request.Action]
|
|
||||||
if !exists {
|
|
||||||
return fmt.Errorf("unknown action: %d", hctx.Request.Action)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check verification requirement - default to requiring verification
|
|
||||||
if hctx.Request.Action != common.CheckFingerprint && !hctx.HubVerified {
|
|
||||||
return errors.New("hub not verified")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log handler execution for debugging
|
|
||||||
// slog.Debug("Executing handler", "action", hctx.Request.Action)
|
|
||||||
|
|
||||||
return handler.Handle(hctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetHandler returns the handler for a specific action
|
|
||||||
func (hr *HandlerRegistry) GetHandler(action common.WebSocketAction) (RequestHandler, bool) {
|
|
||||||
handler, exists := hr.handlers[action]
|
|
||||||
return handler, exists
|
|
||||||
}
|
|
||||||
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
|
||||||
|
|
||||||
// GetDataHandler handles system data requests
|
|
||||||
type GetDataHandler struct{}
|
|
||||||
|
|
||||||
func (h *GetDataHandler) Handle(hctx *HandlerContext) error {
|
|
||||||
var options common.DataRequestOptions
|
|
||||||
_ = cbor.Unmarshal(hctx.Request.Data, &options)
|
|
||||||
|
|
||||||
sysStats := hctx.Agent.gatherStats(options)
|
|
||||||
return hctx.SendResponse(sysStats, hctx.RequestID)
|
|
||||||
}
|
|
||||||
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
|
||||||
|
|
||||||
// CheckFingerprintHandler handles authentication challenges
|
|
||||||
type CheckFingerprintHandler struct{}
|
|
||||||
|
|
||||||
func (h *CheckFingerprintHandler) Handle(hctx *HandlerContext) error {
|
|
||||||
return hctx.Client.handleAuthChallenge(hctx.Request, hctx.RequestID)
|
|
||||||
}
|
|
||||||
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
|
||||||
|
|
||||||
// GetContainerLogsHandler handles container log requests
|
|
||||||
type GetContainerLogsHandler struct{}
|
|
||||||
|
|
||||||
func (h *GetContainerLogsHandler) Handle(hctx *HandlerContext) error {
|
|
||||||
if hctx.Agent.dockerManager == nil {
|
|
||||||
return hctx.SendResponse("", hctx.RequestID)
|
|
||||||
}
|
|
||||||
|
|
||||||
var req common.ContainerLogsRequest
|
|
||||||
if err := cbor.Unmarshal(hctx.Request.Data, &req); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
logContent, err := hctx.Agent.dockerManager.getLogs(ctx, req.ContainerID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return hctx.SendResponse(logContent, hctx.RequestID)
|
|
||||||
}
|
|
||||||
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
|
||||||
|
|
||||||
// GetContainerInfoHandler handles container info requests
|
|
||||||
type GetContainerInfoHandler struct{}
|
|
||||||
|
|
||||||
func (h *GetContainerInfoHandler) Handle(hctx *HandlerContext) error {
|
|
||||||
if hctx.Agent.dockerManager == nil {
|
|
||||||
return hctx.SendResponse("", hctx.RequestID)
|
|
||||||
}
|
|
||||||
|
|
||||||
var req common.ContainerInfoRequest
|
|
||||||
if err := cbor.Unmarshal(hctx.Request.Data, &req); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
info, err := hctx.Agent.dockerManager.getContainerInfo(ctx, req.ContainerID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return hctx.SendResponse(string(info), hctx.RequestID)
|
|
||||||
}
|
|
||||||
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
|
||||||
|
|
||||||
// GetSmartDataHandler handles SMART data requests
|
|
||||||
type GetSmartDataHandler struct{}
|
|
||||||
|
|
||||||
func (h *GetSmartDataHandler) Handle(hctx *HandlerContext) error {
|
|
||||||
if hctx.Agent.smartManager == nil {
|
|
||||||
// return empty map to indicate no data
|
|
||||||
return hctx.SendResponse(map[string]smart.SmartData{}, hctx.RequestID)
|
|
||||||
}
|
|
||||||
if err := hctx.Agent.smartManager.Refresh(false); err != nil {
|
|
||||||
slog.Debug("smart refresh failed", "err", err)
|
|
||||||
}
|
|
||||||
data := hctx.Agent.smartManager.GetCurrentData()
|
|
||||||
return hctx.SendResponse(data, hctx.RequestID)
|
|
||||||
}
|
|
||||||
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
|
||||||
|
|
||||||
// GetSystemdInfoHandler handles detailed systemd service info requests
|
|
||||||
type GetSystemdInfoHandler struct{}
|
|
||||||
|
|
||||||
func (h *GetSystemdInfoHandler) Handle(hctx *HandlerContext) error {
|
|
||||||
if hctx.Agent.systemdManager == nil {
|
|
||||||
return errors.ErrUnsupported
|
|
||||||
}
|
|
||||||
|
|
||||||
var req common.SystemdInfoRequest
|
|
||||||
if err := cbor.Unmarshal(hctx.Request.Data, &req); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if req.ServiceName == "" {
|
|
||||||
return errors.New("service name is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
details, err := hctx.Agent.systemdManager.getServiceDetails(req.ServiceName)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return hctx.SendResponse(details, hctx.RequestID)
|
|
||||||
}
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
//go:build testing
|
|
||||||
// +build testing
|
|
||||||
|
|
||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/fxamacker/cbor/v2"
|
|
||||||
"github.com/henrygd/beszel/internal/common"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
// MockHandler for testing
|
|
||||||
type MockHandler struct {
|
|
||||||
requiresVerification bool
|
|
||||||
description string
|
|
||||||
handleFunc func(ctx *HandlerContext) error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MockHandler) Handle(ctx *HandlerContext) error {
|
|
||||||
if m.handleFunc != nil {
|
|
||||||
return m.handleFunc(ctx)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MockHandler) RequiresVerification() bool {
|
|
||||||
return m.requiresVerification
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandlerRegistry tests the handler registry functionality
|
|
||||||
func TestHandlerRegistry(t *testing.T) {
|
|
||||||
t.Run("default registration", func(t *testing.T) {
|
|
||||||
registry := NewHandlerRegistry()
|
|
||||||
|
|
||||||
// Check default handlers are registered
|
|
||||||
getDataHandler, exists := registry.GetHandler(common.GetData)
|
|
||||||
assert.True(t, exists)
|
|
||||||
assert.IsType(t, &GetDataHandler{}, getDataHandler)
|
|
||||||
|
|
||||||
fingerprintHandler, exists := registry.GetHandler(common.CheckFingerprint)
|
|
||||||
assert.True(t, exists)
|
|
||||||
assert.IsType(t, &CheckFingerprintHandler{}, fingerprintHandler)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("custom handler registration", func(t *testing.T) {
|
|
||||||
registry := NewHandlerRegistry()
|
|
||||||
mockHandler := &MockHandler{
|
|
||||||
requiresVerification: true,
|
|
||||||
description: "Test handler",
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register a custom handler for a mock action
|
|
||||||
const mockAction common.WebSocketAction = 99
|
|
||||||
registry.Register(mockAction, mockHandler)
|
|
||||||
|
|
||||||
// Verify registration
|
|
||||||
handler, exists := registry.GetHandler(mockAction)
|
|
||||||
assert.True(t, exists)
|
|
||||||
assert.Equal(t, mockHandler, handler)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("unknown action", func(t *testing.T) {
|
|
||||||
registry := NewHandlerRegistry()
|
|
||||||
ctx := &HandlerContext{
|
|
||||||
Request: &common.HubRequest[cbor.RawMessage]{
|
|
||||||
Action: common.WebSocketAction(255), // Unknown action
|
|
||||||
},
|
|
||||||
HubVerified: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
err := registry.Handle(ctx)
|
|
||||||
assert.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "unknown action: 255")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("verification required", func(t *testing.T) {
|
|
||||||
registry := NewHandlerRegistry()
|
|
||||||
ctx := &HandlerContext{
|
|
||||||
Request: &common.HubRequest[cbor.RawMessage]{
|
|
||||||
Action: common.GetData, // Requires verification
|
|
||||||
},
|
|
||||||
HubVerified: false, // Not verified
|
|
||||||
}
|
|
||||||
|
|
||||||
err := registry.Handle(ctx)
|
|
||||||
assert.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "hub not verified")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestCheckFingerprintHandler tests the CheckFingerprint handler
|
|
||||||
func TestCheckFingerprintHandler(t *testing.T) {
|
|
||||||
handler := &CheckFingerprintHandler{}
|
|
||||||
|
|
||||||
t.Run("handle with invalid data", func(t *testing.T) {
|
|
||||||
client := &WebSocketClient{}
|
|
||||||
ctx := &HandlerContext{
|
|
||||||
Client: client,
|
|
||||||
HubVerified: false,
|
|
||||||
Request: &common.HubRequest[cbor.RawMessage]{
|
|
||||||
Action: common.CheckFingerprint,
|
|
||||||
Data: cbor.RawMessage{}, // Empty/invalid data
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should fail to decode the fingerprint request
|
|
||||||
err := handler.Handle(ctx)
|
|
||||||
assert.Error(t, err)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
259
agent/network.go
259
agent/network.go
@@ -1,259 +0,0 @@
|
|||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
|
||||||
"path"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/agent/deltatracker"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
|
||||||
psutilNet "github.com/shirou/gopsutil/v4/net"
|
|
||||||
)
|
|
||||||
|
|
||||||
// NicConfig controls inclusion/exclusion of network interfaces via the NICS env var
|
|
||||||
//
|
|
||||||
// Behavior mirrors SensorConfig's matching logic:
|
|
||||||
// - Leading '-' means blacklist mode; otherwise whitelist mode
|
|
||||||
// - Supports '*' wildcards using path.Match
|
|
||||||
// - In whitelist mode with an empty list, no NICs are selected
|
|
||||||
// - In blacklist mode with an empty list, all NICs are selected
|
|
||||||
type NicConfig struct {
|
|
||||||
nics map[string]struct{}
|
|
||||||
isBlacklist bool
|
|
||||||
hasWildcards bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func newNicConfig(nicsEnvVal string) *NicConfig {
|
|
||||||
cfg := &NicConfig{
|
|
||||||
nics: make(map[string]struct{}),
|
|
||||||
}
|
|
||||||
if strings.HasPrefix(nicsEnvVal, "-") {
|
|
||||||
cfg.isBlacklist = true
|
|
||||||
nicsEnvVal = nicsEnvVal[1:]
|
|
||||||
}
|
|
||||||
for nic := range strings.SplitSeq(nicsEnvVal, ",") {
|
|
||||||
nic = strings.TrimSpace(nic)
|
|
||||||
if nic != "" {
|
|
||||||
cfg.nics[nic] = struct{}{}
|
|
||||||
if strings.Contains(nic, "*") {
|
|
||||||
cfg.hasWildcards = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return cfg
|
|
||||||
}
|
|
||||||
|
|
||||||
// isValidNic determines if a NIC should be included based on NicConfig rules
|
|
||||||
func isValidNic(nicName string, cfg *NicConfig) bool {
|
|
||||||
// Empty list behavior differs by mode: blacklist: allow all; whitelist: allow none
|
|
||||||
if len(cfg.nics) == 0 {
|
|
||||||
return cfg.isBlacklist
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exact match: return true if whitelist, false if blacklist
|
|
||||||
if _, exactMatch := cfg.nics[nicName]; exactMatch {
|
|
||||||
return !cfg.isBlacklist
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no wildcards, return true if blacklist, false if whitelist
|
|
||||||
if !cfg.hasWildcards {
|
|
||||||
return cfg.isBlacklist
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for wildcard patterns
|
|
||||||
for pattern := range cfg.nics {
|
|
||||||
if !strings.Contains(pattern, "*") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if match, _ := path.Match(pattern, nicName); match {
|
|
||||||
return !cfg.isBlacklist
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return cfg.isBlacklist
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *Agent) updateNetworkStats(cacheTimeMs uint16, systemStats *system.Stats) {
|
|
||||||
// network stats
|
|
||||||
a.ensureNetInterfacesInitialized()
|
|
||||||
|
|
||||||
a.ensureNetworkInterfacesMap(systemStats)
|
|
||||||
|
|
||||||
if netIO, err := psutilNet.IOCounters(true); err == nil {
|
|
||||||
nis, msElapsed := a.loadAndTickNetBaseline(cacheTimeMs)
|
|
||||||
totalBytesSent, totalBytesRecv := a.sumAndTrackPerNicDeltas(cacheTimeMs, msElapsed, netIO, systemStats)
|
|
||||||
bytesSentPerSecond, bytesRecvPerSecond := a.computeBytesPerSecond(msElapsed, totalBytesSent, totalBytesRecv, nis)
|
|
||||||
a.applyNetworkTotals(cacheTimeMs, netIO, systemStats, nis, totalBytesSent, totalBytesRecv, bytesSentPerSecond, bytesRecvPerSecond)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *Agent) initializeNetIoStats() {
|
|
||||||
// reset valid network interfaces
|
|
||||||
a.netInterfaces = make(map[string]struct{}, 0)
|
|
||||||
|
|
||||||
// parse NICS env var for whitelist / blacklist
|
|
||||||
nicsEnvVal, nicsEnvExists := GetEnv("NICS")
|
|
||||||
var nicCfg *NicConfig
|
|
||||||
if nicsEnvExists {
|
|
||||||
nicCfg = newNicConfig(nicsEnvVal)
|
|
||||||
}
|
|
||||||
|
|
||||||
// get current network I/O stats and record valid interfaces
|
|
||||||
if netIO, err := psutilNet.IOCounters(true); err == nil {
|
|
||||||
for _, v := range netIO {
|
|
||||||
if nicsEnvExists && !isValidNic(v.Name, nicCfg) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if a.skipNetworkInterface(v) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
slog.Info("Detected network interface", "name", v.Name, "sent", v.BytesSent, "recv", v.BytesRecv)
|
|
||||||
// store as a valid network interface
|
|
||||||
a.netInterfaces[v.Name] = struct{}{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset per-cache-time trackers and baselines so they will reinitialize on next use
|
|
||||||
a.netInterfaceDeltaTrackers = make(map[uint16]*deltatracker.DeltaTracker[string, uint64])
|
|
||||||
a.netIoStats = make(map[uint16]system.NetIoStats)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ensureNetInterfacesInitialized re-initializes NICs if none are currently tracked
|
|
||||||
func (a *Agent) ensureNetInterfacesInitialized() {
|
|
||||||
if len(a.netInterfaces) == 0 {
|
|
||||||
// if no network interfaces, initialize again
|
|
||||||
// this is a fix if agent started before network is online (#466)
|
|
||||||
// maybe refactor this in the future to not cache interface names at all so we
|
|
||||||
// don't miss an interface that's been added after agent started in any circumstance
|
|
||||||
a.initializeNetIoStats()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ensureNetworkInterfacesMap ensures systemStats.NetworkInterfaces map exists
|
|
||||||
func (a *Agent) ensureNetworkInterfacesMap(systemStats *system.Stats) {
|
|
||||||
if systemStats.NetworkInterfaces == nil {
|
|
||||||
systemStats.NetworkInterfaces = make(map[string][4]uint64, 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadAndTickNetBaseline returns the NetIoStats baseline and milliseconds elapsed, updating time
|
|
||||||
func (a *Agent) loadAndTickNetBaseline(cacheTimeMs uint16) (netIoStat system.NetIoStats, msElapsed uint64) {
|
|
||||||
netIoStat = a.netIoStats[cacheTimeMs]
|
|
||||||
if netIoStat.Time.IsZero() {
|
|
||||||
netIoStat.Time = time.Now()
|
|
||||||
msElapsed = 0
|
|
||||||
} else {
|
|
||||||
msElapsed = uint64(time.Since(netIoStat.Time).Milliseconds())
|
|
||||||
netIoStat.Time = time.Now()
|
|
||||||
}
|
|
||||||
return netIoStat, msElapsed
|
|
||||||
}
|
|
||||||
|
|
||||||
// sumAndTrackPerNicDeltas accumulates totals and records per-NIC up/down deltas into systemStats
|
|
||||||
func (a *Agent) sumAndTrackPerNicDeltas(cacheTimeMs uint16, msElapsed uint64, netIO []psutilNet.IOCountersStat, systemStats *system.Stats) (totalBytesSent, totalBytesRecv uint64) {
|
|
||||||
tracker := a.netInterfaceDeltaTrackers[cacheTimeMs]
|
|
||||||
if tracker == nil {
|
|
||||||
tracker = deltatracker.NewDeltaTracker[string, uint64]()
|
|
||||||
a.netInterfaceDeltaTrackers[cacheTimeMs] = tracker
|
|
||||||
}
|
|
||||||
tracker.Cycle()
|
|
||||||
|
|
||||||
for _, v := range netIO {
|
|
||||||
if _, exists := a.netInterfaces[v.Name]; !exists {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
totalBytesSent += v.BytesSent
|
|
||||||
totalBytesRecv += v.BytesRecv
|
|
||||||
|
|
||||||
var upDelta, downDelta uint64
|
|
||||||
upKey, downKey := fmt.Sprintf("%sup", v.Name), fmt.Sprintf("%sdown", v.Name)
|
|
||||||
tracker.Set(upKey, v.BytesSent)
|
|
||||||
tracker.Set(downKey, v.BytesRecv)
|
|
||||||
if msElapsed > 0 {
|
|
||||||
if prevVal, ok := tracker.Previous(upKey); ok {
|
|
||||||
var deltaBytes uint64
|
|
||||||
if v.BytesSent >= prevVal {
|
|
||||||
deltaBytes = v.BytesSent - prevVal
|
|
||||||
} else {
|
|
||||||
deltaBytes = v.BytesSent
|
|
||||||
}
|
|
||||||
upDelta = deltaBytes * 1000 / msElapsed
|
|
||||||
}
|
|
||||||
if prevVal, ok := tracker.Previous(downKey); ok {
|
|
||||||
var deltaBytes uint64
|
|
||||||
if v.BytesRecv >= prevVal {
|
|
||||||
deltaBytes = v.BytesRecv - prevVal
|
|
||||||
} else {
|
|
||||||
deltaBytes = v.BytesRecv
|
|
||||||
}
|
|
||||||
downDelta = deltaBytes * 1000 / msElapsed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
systemStats.NetworkInterfaces[v.Name] = [4]uint64{upDelta, downDelta, v.BytesSent, v.BytesRecv}
|
|
||||||
}
|
|
||||||
|
|
||||||
return totalBytesSent, totalBytesRecv
|
|
||||||
}
|
|
||||||
|
|
||||||
// computeBytesPerSecond calculates per-second totals from elapsed time and totals
|
|
||||||
func (a *Agent) computeBytesPerSecond(msElapsed, totalBytesSent, totalBytesRecv uint64, nis system.NetIoStats) (bytesSentPerSecond, bytesRecvPerSecond uint64) {
|
|
||||||
if msElapsed > 0 {
|
|
||||||
bytesSentPerSecond = (totalBytesSent - nis.BytesSent) * 1000 / msElapsed
|
|
||||||
bytesRecvPerSecond = (totalBytesRecv - nis.BytesRecv) * 1000 / msElapsed
|
|
||||||
}
|
|
||||||
return bytesSentPerSecond, bytesRecvPerSecond
|
|
||||||
}
|
|
||||||
|
|
||||||
// applyNetworkTotals validates and writes computed network stats, or resets on anomaly
|
|
||||||
func (a *Agent) applyNetworkTotals(
|
|
||||||
cacheTimeMs uint16,
|
|
||||||
netIO []psutilNet.IOCountersStat,
|
|
||||||
systemStats *system.Stats,
|
|
||||||
nis system.NetIoStats,
|
|
||||||
totalBytesSent, totalBytesRecv uint64,
|
|
||||||
bytesSentPerSecond, bytesRecvPerSecond uint64,
|
|
||||||
) {
|
|
||||||
networkSentPs := bytesToMegabytes(float64(bytesSentPerSecond))
|
|
||||||
networkRecvPs := bytesToMegabytes(float64(bytesRecvPerSecond))
|
|
||||||
if networkSentPs > 10_000 || networkRecvPs > 10_000 {
|
|
||||||
slog.Warn("Invalid net stats. Resetting.", "sent", networkSentPs, "recv", networkRecvPs)
|
|
||||||
for _, v := range netIO {
|
|
||||||
if _, exists := a.netInterfaces[v.Name]; !exists {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
slog.Info(v.Name, "recv", v.BytesRecv, "sent", v.BytesSent)
|
|
||||||
}
|
|
||||||
a.initializeNetIoStats()
|
|
||||||
delete(a.netIoStats, cacheTimeMs)
|
|
||||||
delete(a.netInterfaceDeltaTrackers, cacheTimeMs)
|
|
||||||
systemStats.NetworkSent = 0
|
|
||||||
systemStats.NetworkRecv = 0
|
|
||||||
systemStats.Bandwidth[0], systemStats.Bandwidth[1] = 0, 0
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
systemStats.NetworkSent = networkSentPs
|
|
||||||
systemStats.NetworkRecv = networkRecvPs
|
|
||||||
systemStats.Bandwidth[0], systemStats.Bandwidth[1] = bytesSentPerSecond, bytesRecvPerSecond
|
|
||||||
nis.BytesSent = totalBytesSent
|
|
||||||
nis.BytesRecv = totalBytesRecv
|
|
||||||
a.netIoStats[cacheTimeMs] = nis
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *Agent) skipNetworkInterface(v psutilNet.IOCountersStat) bool {
|
|
||||||
switch {
|
|
||||||
case strings.HasPrefix(v.Name, "lo"),
|
|
||||||
strings.HasPrefix(v.Name, "docker"),
|
|
||||||
strings.HasPrefix(v.Name, "br-"),
|
|
||||||
strings.HasPrefix(v.Name, "veth"),
|
|
||||||
strings.HasPrefix(v.Name, "bond"),
|
|
||||||
strings.HasPrefix(v.Name, "cali"),
|
|
||||||
v.BytesRecv == 0,
|
|
||||||
v.BytesSent == 0:
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,502 +0,0 @@
|
|||||||
//go:build testing
|
|
||||||
|
|
||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/agent/deltatracker"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
|
||||||
psutilNet "github.com/shirou/gopsutil/v4/net"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestIsValidNic(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
nicName string
|
|
||||||
config *NicConfig
|
|
||||||
expectedValid bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "Whitelist - NIC in list",
|
|
||||||
nicName: "eth0",
|
|
||||||
config: &NicConfig{
|
|
||||||
nics: map[string]struct{}{"eth0": {}},
|
|
||||||
isBlacklist: false,
|
|
||||||
},
|
|
||||||
expectedValid: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Whitelist - NIC not in list",
|
|
||||||
nicName: "wlan0",
|
|
||||||
config: &NicConfig{
|
|
||||||
nics: map[string]struct{}{"eth0": {}},
|
|
||||||
isBlacklist: false,
|
|
||||||
},
|
|
||||||
expectedValid: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Blacklist - NIC in list",
|
|
||||||
nicName: "eth0",
|
|
||||||
config: &NicConfig{
|
|
||||||
nics: map[string]struct{}{"eth0": {}},
|
|
||||||
isBlacklist: true,
|
|
||||||
},
|
|
||||||
expectedValid: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Blacklist - NIC not in list",
|
|
||||||
nicName: "wlan0",
|
|
||||||
config: &NicConfig{
|
|
||||||
nics: map[string]struct{}{"eth0": {}},
|
|
||||||
isBlacklist: true,
|
|
||||||
},
|
|
||||||
expectedValid: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Whitelist with wildcard - matching pattern",
|
|
||||||
nicName: "eth1",
|
|
||||||
config: &NicConfig{
|
|
||||||
nics: map[string]struct{}{"eth*": {}},
|
|
||||||
isBlacklist: false,
|
|
||||||
hasWildcards: true,
|
|
||||||
},
|
|
||||||
expectedValid: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Whitelist with wildcard - non-matching pattern",
|
|
||||||
nicName: "wlan0",
|
|
||||||
config: &NicConfig{
|
|
||||||
nics: map[string]struct{}{"eth*": {}},
|
|
||||||
isBlacklist: false,
|
|
||||||
hasWildcards: true,
|
|
||||||
},
|
|
||||||
expectedValid: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Blacklist with wildcard - matching pattern",
|
|
||||||
nicName: "eth1",
|
|
||||||
config: &NicConfig{
|
|
||||||
nics: map[string]struct{}{"eth*": {}},
|
|
||||||
isBlacklist: true,
|
|
||||||
hasWildcards: true,
|
|
||||||
},
|
|
||||||
expectedValid: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Blacklist with wildcard - non-matching pattern",
|
|
||||||
nicName: "wlan0",
|
|
||||||
config: &NicConfig{
|
|
||||||
nics: map[string]struct{}{"eth*": {}},
|
|
||||||
isBlacklist: true,
|
|
||||||
hasWildcards: true,
|
|
||||||
},
|
|
||||||
expectedValid: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Empty whitelist config - no NICs allowed",
|
|
||||||
nicName: "eth0",
|
|
||||||
config: &NicConfig{
|
|
||||||
nics: map[string]struct{}{},
|
|
||||||
isBlacklist: false,
|
|
||||||
},
|
|
||||||
expectedValid: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Empty blacklist config - all NICs allowed",
|
|
||||||
nicName: "eth0",
|
|
||||||
config: &NicConfig{
|
|
||||||
nics: map[string]struct{}{},
|
|
||||||
isBlacklist: true,
|
|
||||||
},
|
|
||||||
expectedValid: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Multiple patterns - exact match",
|
|
||||||
nicName: "eth0",
|
|
||||||
config: &NicConfig{
|
|
||||||
nics: map[string]struct{}{"eth0": {}, "wlan*": {}},
|
|
||||||
isBlacklist: false,
|
|
||||||
},
|
|
||||||
expectedValid: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Multiple patterns - wildcard match",
|
|
||||||
nicName: "wlan1",
|
|
||||||
config: &NicConfig{
|
|
||||||
nics: map[string]struct{}{"eth0": {}, "wlan*": {}},
|
|
||||||
isBlacklist: false,
|
|
||||||
hasWildcards: true,
|
|
||||||
},
|
|
||||||
expectedValid: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Multiple patterns - no match",
|
|
||||||
nicName: "bond0",
|
|
||||||
config: &NicConfig{
|
|
||||||
nics: map[string]struct{}{"eth0": {}, "wlan*": {}},
|
|
||||||
isBlacklist: false,
|
|
||||||
hasWildcards: true,
|
|
||||||
},
|
|
||||||
expectedValid: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
result := isValidNic(tt.nicName, tt.config)
|
|
||||||
assert.Equal(t, tt.expectedValid, result)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewNicConfig(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
nicsEnvVal string
|
|
||||||
expectedCfg *NicConfig
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "Empty string",
|
|
||||||
nicsEnvVal: "",
|
|
||||||
expectedCfg: &NicConfig{
|
|
||||||
nics: map[string]struct{}{},
|
|
||||||
isBlacklist: false,
|
|
||||||
hasWildcards: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Single NIC whitelist",
|
|
||||||
nicsEnvVal: "eth0",
|
|
||||||
expectedCfg: &NicConfig{
|
|
||||||
nics: map[string]struct{}{"eth0": {}},
|
|
||||||
isBlacklist: false,
|
|
||||||
hasWildcards: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Multiple NICs whitelist",
|
|
||||||
nicsEnvVal: "eth0,wlan0",
|
|
||||||
expectedCfg: &NicConfig{
|
|
||||||
nics: map[string]struct{}{"eth0": {}, "wlan0": {}},
|
|
||||||
isBlacklist: false,
|
|
||||||
hasWildcards: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Blacklist mode",
|
|
||||||
nicsEnvVal: "-eth0,wlan0",
|
|
||||||
expectedCfg: &NicConfig{
|
|
||||||
nics: map[string]struct{}{"eth0": {}, "wlan0": {}},
|
|
||||||
isBlacklist: true,
|
|
||||||
hasWildcards: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "With wildcards",
|
|
||||||
nicsEnvVal: "eth*,wlan0",
|
|
||||||
expectedCfg: &NicConfig{
|
|
||||||
nics: map[string]struct{}{"eth*": {}, "wlan0": {}},
|
|
||||||
isBlacklist: false,
|
|
||||||
hasWildcards: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Blacklist with wildcards",
|
|
||||||
nicsEnvVal: "-eth*,wlan0",
|
|
||||||
expectedCfg: &NicConfig{
|
|
||||||
nics: map[string]struct{}{"eth*": {}, "wlan0": {}},
|
|
||||||
isBlacklist: true,
|
|
||||||
hasWildcards: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "With whitespace",
|
|
||||||
nicsEnvVal: "eth0, wlan0 , eth1",
|
|
||||||
expectedCfg: &NicConfig{
|
|
||||||
nics: map[string]struct{}{"eth0": {}, "wlan0": {}, "eth1": {}},
|
|
||||||
isBlacklist: false,
|
|
||||||
hasWildcards: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Only wildcards",
|
|
||||||
nicsEnvVal: "eth*,wlan*",
|
|
||||||
expectedCfg: &NicConfig{
|
|
||||||
nics: map[string]struct{}{"eth*": {}, "wlan*": {}},
|
|
||||||
isBlacklist: false,
|
|
||||||
hasWildcards: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Leading dash only",
|
|
||||||
nicsEnvVal: "-",
|
|
||||||
expectedCfg: &NicConfig{
|
|
||||||
nics: map[string]struct{}{},
|
|
||||||
isBlacklist: true,
|
|
||||||
hasWildcards: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Mixed exact and wildcard",
|
|
||||||
nicsEnvVal: "eth0,br-*",
|
|
||||||
expectedCfg: &NicConfig{
|
|
||||||
nics: map[string]struct{}{"eth0": {}, "br-*": {}},
|
|
||||||
isBlacklist: false,
|
|
||||||
hasWildcards: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
cfg := newNicConfig(tt.nicsEnvVal)
|
|
||||||
require.NotNil(t, cfg)
|
|
||||||
assert.Equal(t, tt.expectedCfg.isBlacklist, cfg.isBlacklist)
|
|
||||||
assert.Equal(t, tt.expectedCfg.hasWildcards, cfg.hasWildcards)
|
|
||||||
assert.Equal(t, tt.expectedCfg.nics, cfg.nics)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
func TestEnsureNetworkInterfacesMap(t *testing.T) {
|
|
||||||
var a Agent
|
|
||||||
var stats system.Stats
|
|
||||||
|
|
||||||
// Initially nil
|
|
||||||
assert.Nil(t, stats.NetworkInterfaces)
|
|
||||||
// Ensure map is created
|
|
||||||
a.ensureNetworkInterfacesMap(&stats)
|
|
||||||
assert.NotNil(t, stats.NetworkInterfaces)
|
|
||||||
// Idempotent
|
|
||||||
a.ensureNetworkInterfacesMap(&stats)
|
|
||||||
assert.NotNil(t, stats.NetworkInterfaces)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoadAndTickNetBaseline(t *testing.T) {
|
|
||||||
a := &Agent{netIoStats: make(map[uint16]system.NetIoStats)}
|
|
||||||
|
|
||||||
// First call initializes time and returns 0 elapsed
|
|
||||||
ni, elapsed := a.loadAndTickNetBaseline(100)
|
|
||||||
assert.Equal(t, uint64(0), elapsed)
|
|
||||||
assert.False(t, ni.Time.IsZero())
|
|
||||||
|
|
||||||
// Store back what loadAndTick returns to mimic updateNetworkStats behavior
|
|
||||||
a.netIoStats[100] = ni
|
|
||||||
|
|
||||||
time.Sleep(2 * time.Millisecond)
|
|
||||||
|
|
||||||
// Next call should produce >= 0 elapsed and update time
|
|
||||||
ni2, elapsed2 := a.loadAndTickNetBaseline(100)
|
|
||||||
assert.True(t, elapsed2 > 0)
|
|
||||||
assert.False(t, ni2.Time.IsZero())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestComputeBytesPerSecond(t *testing.T) {
|
|
||||||
a := &Agent{}
|
|
||||||
|
|
||||||
// No elapsed -> zero rate
|
|
||||||
bytesUp, bytesDown := a.computeBytesPerSecond(0, 2000, 3000, system.NetIoStats{BytesSent: 1000, BytesRecv: 1000})
|
|
||||||
assert.Equal(t, uint64(0), bytesUp)
|
|
||||||
assert.Equal(t, uint64(0), bytesDown)
|
|
||||||
|
|
||||||
// With elapsed -> per-second calculation
|
|
||||||
bytesUp, bytesDown = a.computeBytesPerSecond(500, 6000, 11000, system.NetIoStats{BytesSent: 1000, BytesRecv: 1000})
|
|
||||||
// (6000-1000)*1000/500 = 10000; (11000-1000)*1000/500 = 20000
|
|
||||||
assert.Equal(t, uint64(10000), bytesUp)
|
|
||||||
assert.Equal(t, uint64(20000), bytesDown)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSumAndTrackPerNicDeltas(t *testing.T) {
|
|
||||||
a := &Agent{
|
|
||||||
netInterfaces: map[string]struct{}{"eth0": {}, "wlan0": {}},
|
|
||||||
netInterfaceDeltaTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Two samples for same cache interval to verify delta behavior
|
|
||||||
cache := uint16(42)
|
|
||||||
net1 := []psutilNet.IOCountersStat{{Name: "eth0", BytesSent: 1000, BytesRecv: 2000}}
|
|
||||||
stats1 := &system.Stats{}
|
|
||||||
a.ensureNetworkInterfacesMap(stats1)
|
|
||||||
tx1, rx1 := a.sumAndTrackPerNicDeltas(cache, 0, net1, stats1)
|
|
||||||
assert.Equal(t, uint64(1000), tx1)
|
|
||||||
assert.Equal(t, uint64(2000), rx1)
|
|
||||||
|
|
||||||
// Second cycle with elapsed, larger counters -> deltas computed inside
|
|
||||||
net2 := []psutilNet.IOCountersStat{{Name: "eth0", BytesSent: 4000, BytesRecv: 9000}}
|
|
||||||
stats := &system.Stats{}
|
|
||||||
a.ensureNetworkInterfacesMap(stats)
|
|
||||||
tx2, rx2 := a.sumAndTrackPerNicDeltas(cache, 1000, net2, stats)
|
|
||||||
assert.Equal(t, uint64(4000), tx2)
|
|
||||||
assert.Equal(t, uint64(9000), rx2)
|
|
||||||
// Up/Down deltas per second should be (4000-1000)/1s = 3000 and (9000-2000)/1s = 7000
|
|
||||||
ni, ok := stats.NetworkInterfaces["eth0"]
|
|
||||||
assert.True(t, ok)
|
|
||||||
assert.Equal(t, uint64(3000), ni[0])
|
|
||||||
assert.Equal(t, uint64(7000), ni[1])
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSumAndTrackPerNicDeltasHandlesCounterReset(t *testing.T) {
|
|
||||||
a := &Agent{
|
|
||||||
netInterfaces: map[string]struct{}{"eth0": {}},
|
|
||||||
netInterfaceDeltaTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
|
|
||||||
}
|
|
||||||
|
|
||||||
cache := uint16(77)
|
|
||||||
|
|
||||||
// First interval establishes baseline values
|
|
||||||
initial := []psutilNet.IOCountersStat{{Name: "eth0", BytesSent: 4_000, BytesRecv: 6_000}}
|
|
||||||
statsInitial := &system.Stats{}
|
|
||||||
a.ensureNetworkInterfacesMap(statsInitial)
|
|
||||||
_, _ = a.sumAndTrackPerNicDeltas(cache, 0, initial, statsInitial)
|
|
||||||
|
|
||||||
// Second interval increments counters normally so previous snapshot gets populated
|
|
||||||
increment := []psutilNet.IOCountersStat{{Name: "eth0", BytesSent: 9_000, BytesRecv: 11_000}}
|
|
||||||
statsIncrement := &system.Stats{}
|
|
||||||
a.ensureNetworkInterfacesMap(statsIncrement)
|
|
||||||
_, _ = a.sumAndTrackPerNicDeltas(cache, 1_000, increment, statsIncrement)
|
|
||||||
|
|
||||||
niIncrement, ok := statsIncrement.NetworkInterfaces["eth0"]
|
|
||||||
require.True(t, ok)
|
|
||||||
assert.Equal(t, uint64(5_000), niIncrement[0])
|
|
||||||
assert.Equal(t, uint64(5_000), niIncrement[1])
|
|
||||||
|
|
||||||
// Third interval simulates counter reset (values drop below previous totals)
|
|
||||||
reset := []psutilNet.IOCountersStat{{Name: "eth0", BytesSent: 1_200, BytesRecv: 1_500}}
|
|
||||||
statsReset := &system.Stats{}
|
|
||||||
a.ensureNetworkInterfacesMap(statsReset)
|
|
||||||
_, _ = a.sumAndTrackPerNicDeltas(cache, 1_000, reset, statsReset)
|
|
||||||
|
|
||||||
niReset, ok := statsReset.NetworkInterfaces["eth0"]
|
|
||||||
require.True(t, ok)
|
|
||||||
assert.Equal(t, uint64(1_200), niReset[0], "upload delta should match new counter value after reset")
|
|
||||||
assert.Equal(t, uint64(1_500), niReset[1], "download delta should match new counter value after reset")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestApplyNetworkTotals(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
bytesSentPerSecond uint64
|
|
||||||
bytesRecvPerSecond uint64
|
|
||||||
totalBytesSent uint64
|
|
||||||
totalBytesRecv uint64
|
|
||||||
expectReset bool
|
|
||||||
expectedNetworkSent float64
|
|
||||||
expectedNetworkRecv float64
|
|
||||||
expectedBandwidthSent uint64
|
|
||||||
expectedBandwidthRecv uint64
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "Valid network stats - normal values",
|
|
||||||
bytesSentPerSecond: 1000000, // 1 MB/s
|
|
||||||
bytesRecvPerSecond: 2000000, // 2 MB/s
|
|
||||||
totalBytesSent: 10000000,
|
|
||||||
totalBytesRecv: 20000000,
|
|
||||||
expectReset: false,
|
|
||||||
expectedNetworkSent: 0.95, // ~1 MB/s rounded to 2 decimals
|
|
||||||
expectedNetworkRecv: 1.91, // ~2 MB/s rounded to 2 decimals
|
|
||||||
expectedBandwidthSent: 1000000,
|
|
||||||
expectedBandwidthRecv: 2000000,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Invalid network stats - sent exceeds threshold",
|
|
||||||
bytesSentPerSecond: 11000000000, // ~10.5 GB/s > 10 GB/s threshold
|
|
||||||
bytesRecvPerSecond: 1000000, // 1 MB/s
|
|
||||||
totalBytesSent: 10000000,
|
|
||||||
totalBytesRecv: 20000000,
|
|
||||||
expectReset: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Invalid network stats - recv exceeds threshold",
|
|
||||||
bytesSentPerSecond: 1000000, // 1 MB/s
|
|
||||||
bytesRecvPerSecond: 11000000000, // ~10.5 GB/s > 10 GB/s threshold
|
|
||||||
totalBytesSent: 10000000,
|
|
||||||
totalBytesRecv: 20000000,
|
|
||||||
expectReset: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Invalid network stats - both exceed threshold",
|
|
||||||
bytesSentPerSecond: 12000000000, // ~11.4 GB/s
|
|
||||||
bytesRecvPerSecond: 13000000000, // ~12.4 GB/s
|
|
||||||
totalBytesSent: 10000000,
|
|
||||||
totalBytesRecv: 20000000,
|
|
||||||
expectReset: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Valid network stats - at threshold boundary",
|
|
||||||
bytesSentPerSecond: 10485750000, // ~9999.99 MB/s (rounds to 9999.99)
|
|
||||||
bytesRecvPerSecond: 10485750000, // ~9999.99 MB/s (rounds to 9999.99)
|
|
||||||
totalBytesSent: 10000000,
|
|
||||||
totalBytesRecv: 20000000,
|
|
||||||
expectReset: false,
|
|
||||||
expectedNetworkSent: 9999.99,
|
|
||||||
expectedNetworkRecv: 9999.99,
|
|
||||||
expectedBandwidthSent: 10485750000,
|
|
||||||
expectedBandwidthRecv: 10485750000,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Zero values",
|
|
||||||
bytesSentPerSecond: 0,
|
|
||||||
bytesRecvPerSecond: 0,
|
|
||||||
totalBytesSent: 0,
|
|
||||||
totalBytesRecv: 0,
|
|
||||||
expectReset: false,
|
|
||||||
expectedNetworkSent: 0.0,
|
|
||||||
expectedNetworkRecv: 0.0,
|
|
||||||
expectedBandwidthSent: 0,
|
|
||||||
expectedBandwidthRecv: 0,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
// Setup agent with initialized maps
|
|
||||||
a := &Agent{
|
|
||||||
netInterfaces: make(map[string]struct{}),
|
|
||||||
netIoStats: make(map[uint16]system.NetIoStats),
|
|
||||||
netInterfaceDeltaTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
|
|
||||||
}
|
|
||||||
|
|
||||||
cacheTimeMs := uint16(100)
|
|
||||||
netIO := []psutilNet.IOCountersStat{
|
|
||||||
{Name: "eth0", BytesSent: 1000, BytesRecv: 2000},
|
|
||||||
}
|
|
||||||
systemStats := &system.Stats{}
|
|
||||||
nis := system.NetIoStats{}
|
|
||||||
|
|
||||||
a.applyNetworkTotals(
|
|
||||||
cacheTimeMs,
|
|
||||||
netIO,
|
|
||||||
systemStats,
|
|
||||||
nis,
|
|
||||||
tt.totalBytesSent,
|
|
||||||
tt.totalBytesRecv,
|
|
||||||
tt.bytesSentPerSecond,
|
|
||||||
tt.bytesRecvPerSecond,
|
|
||||||
)
|
|
||||||
|
|
||||||
if tt.expectReset {
|
|
||||||
// Should have reset network tracking state - maps cleared and stats zeroed
|
|
||||||
assert.NotContains(t, a.netIoStats, cacheTimeMs, "cache entry should be cleared after reset")
|
|
||||||
assert.NotContains(t, a.netInterfaceDeltaTrackers, cacheTimeMs, "tracker should be cleared on reset")
|
|
||||||
assert.Zero(t, systemStats.NetworkSent)
|
|
||||||
assert.Zero(t, systemStats.NetworkRecv)
|
|
||||||
assert.Zero(t, systemStats.Bandwidth[0])
|
|
||||||
assert.Zero(t, systemStats.Bandwidth[1])
|
|
||||||
} else {
|
|
||||||
// Should have applied stats
|
|
||||||
assert.Equal(t, tt.expectedNetworkSent, systemStats.NetworkSent)
|
|
||||||
assert.Equal(t, tt.expectedNetworkRecv, systemStats.NetworkRecv)
|
|
||||||
assert.Equal(t, tt.expectedBandwidthSent, systemStats.Bandwidth[0])
|
|
||||||
assert.Equal(t, tt.expectedBandwidthRecv, systemStats.Bandwidth[1])
|
|
||||||
|
|
||||||
// Should have updated NetIoStats
|
|
||||||
updatedNis := a.netIoStats[cacheTimeMs]
|
|
||||||
assert.Equal(t, tt.totalBytesSent, updatedNis.BytesSent)
|
|
||||||
assert.Equal(t, tt.totalBytesRecv, updatedNis.BytesRecv)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/fxamacker/cbor/v2"
|
|
||||||
"github.com/henrygd/beszel/internal/common"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/smart"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/systemd"
|
|
||||||
)
|
|
||||||
|
|
||||||
// newAgentResponse creates an AgentResponse using legacy typed fields.
|
|
||||||
// This maintains backward compatibility with <= 0.17 hubs that expect specific fields.
|
|
||||||
func newAgentResponse(data any, requestID *uint32) common.AgentResponse {
|
|
||||||
response := common.AgentResponse{Id: requestID}
|
|
||||||
switch v := data.(type) {
|
|
||||||
case *system.CombinedData:
|
|
||||||
response.SystemData = v
|
|
||||||
case *common.FingerprintResponse:
|
|
||||||
response.Fingerprint = v
|
|
||||||
case string:
|
|
||||||
response.String = &v
|
|
||||||
case map[string]smart.SmartData:
|
|
||||||
response.SmartData = v
|
|
||||||
case systemd.ServiceDetails:
|
|
||||||
response.ServiceInfo = v
|
|
||||||
default:
|
|
||||||
// For unknown types, use the generic Data field
|
|
||||||
response.Data, _ = cbor.Marshal(data)
|
|
||||||
}
|
|
||||||
return response
|
|
||||||
}
|
|
||||||
1024
agent/smart.go
1024
agent/smart.go
File diff suppressed because it is too large
Load Diff
@@ -1,9 +0,0 @@
|
|||||||
//go:build !windows
|
|
||||||
|
|
||||||
package agent
|
|
||||||
|
|
||||||
import "errors"
|
|
||||||
|
|
||||||
func ensureEmbeddedSmartctl() (string, error) {
|
|
||||||
return "", errors.ErrUnsupported
|
|
||||||
}
|
|
||||||
@@ -1,815 +0,0 @@
|
|||||||
//go:build testing
|
|
||||||
// +build testing
|
|
||||||
|
|
||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/smart"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestParseSmartForScsi(t *testing.T) {
|
|
||||||
fixturePath := filepath.Join("test-data", "smart", "scsi.json")
|
|
||||||
data, err := os.ReadFile(fixturePath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed reading fixture: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
sm := &SmartManager{
|
|
||||||
SmartDataMap: make(map[string]*smart.SmartData),
|
|
||||||
}
|
|
||||||
|
|
||||||
hasData, exitStatus := sm.parseSmartForScsi(data)
|
|
||||||
if !hasData {
|
|
||||||
t.Fatalf("expected SCSI data to parse successfully")
|
|
||||||
}
|
|
||||||
if exitStatus != 0 {
|
|
||||||
t.Fatalf("expected exit status 0, got %d", exitStatus)
|
|
||||||
}
|
|
||||||
|
|
||||||
deviceData, ok := sm.SmartDataMap["9YHSDH9B"]
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("expected smart data entry for serial 9YHSDH9B")
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal(t, deviceData.ModelName, "YADRO WUH721414AL4204")
|
|
||||||
assert.Equal(t, deviceData.SerialNumber, "9YHSDH9B")
|
|
||||||
assert.Equal(t, deviceData.FirmwareVersion, "C240")
|
|
||||||
assert.Equal(t, deviceData.DiskName, "/dev/sde")
|
|
||||||
assert.Equal(t, deviceData.DiskType, "scsi")
|
|
||||||
assert.EqualValues(t, deviceData.Temperature, 34)
|
|
||||||
assert.Equal(t, deviceData.SmartStatus, "PASSED")
|
|
||||||
assert.EqualValues(t, deviceData.Capacity, 14000519643136)
|
|
||||||
|
|
||||||
if len(deviceData.Attributes) == 0 {
|
|
||||||
t.Fatalf("expected attributes to be populated")
|
|
||||||
}
|
|
||||||
|
|
||||||
assertAttrValue(t, deviceData.Attributes, "PowerOnHours", 458)
|
|
||||||
assertAttrValue(t, deviceData.Attributes, "PowerOnMinutes", 25)
|
|
||||||
assertAttrValue(t, deviceData.Attributes, "GrownDefectList", 0)
|
|
||||||
assertAttrValue(t, deviceData.Attributes, "StartStopCycles", 2)
|
|
||||||
assertAttrValue(t, deviceData.Attributes, "LoadUnloadCycles", 418)
|
|
||||||
assertAttrValue(t, deviceData.Attributes, "ReadGigabytesProcessed", 3641)
|
|
||||||
assertAttrValue(t, deviceData.Attributes, "WriteGigabytesProcessed", 2124590)
|
|
||||||
assertAttrValue(t, deviceData.Attributes, "VerifyGigabytesProcessed", 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseSmartForSata(t *testing.T) {
|
|
||||||
fixturePath := filepath.Join("test-data", "smart", "sda.json")
|
|
||||||
data, err := os.ReadFile(fixturePath)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
sm := &SmartManager{
|
|
||||||
SmartDataMap: make(map[string]*smart.SmartData),
|
|
||||||
}
|
|
||||||
|
|
||||||
hasData, exitStatus := sm.parseSmartForSata(data)
|
|
||||||
require.True(t, hasData)
|
|
||||||
assert.Equal(t, 64, exitStatus)
|
|
||||||
|
|
||||||
deviceData, ok := sm.SmartDataMap["9C40918040082"]
|
|
||||||
require.True(t, ok, "expected smart data entry for serial 9C40918040082")
|
|
||||||
|
|
||||||
assert.Equal(t, "P3-2TB", deviceData.ModelName)
|
|
||||||
assert.Equal(t, "X0104A0", deviceData.FirmwareVersion)
|
|
||||||
assert.Equal(t, "/dev/sda", deviceData.DiskName)
|
|
||||||
assert.Equal(t, "sat", deviceData.DiskType)
|
|
||||||
assert.Equal(t, uint8(31), deviceData.Temperature)
|
|
||||||
assert.Equal(t, "PASSED", deviceData.SmartStatus)
|
|
||||||
assert.Equal(t, uint64(2048408248320), deviceData.Capacity)
|
|
||||||
if assert.NotEmpty(t, deviceData.Attributes) {
|
|
||||||
assertAttrValue(t, deviceData.Attributes, "Temperature_Celsius", 31)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseSmartForSataParentheticalRawValue(t *testing.T) {
|
|
||||||
jsonPayload := []byte(`{
|
|
||||||
"smartctl": {"exit_status": 0},
|
|
||||||
"device": {"name": "/dev/sdz", "type": "sat"},
|
|
||||||
"model_name": "Example",
|
|
||||||
"serial_number": "PARENTHESES123",
|
|
||||||
"firmware_version": "1.0",
|
|
||||||
"user_capacity": {"bytes": 1024},
|
|
||||||
"smart_status": {"passed": true},
|
|
||||||
"temperature": {"current": 25},
|
|
||||||
"ata_smart_attributes": {
|
|
||||||
"table": [
|
|
||||||
{
|
|
||||||
"id": 9,
|
|
||||||
"name": "Power_On_Hours",
|
|
||||||
"value": 93,
|
|
||||||
"worst": 55,
|
|
||||||
"thresh": 0,
|
|
||||||
"when_failed": "",
|
|
||||||
"raw": {
|
|
||||||
"value": 57891864217128,
|
|
||||||
"string": "39925 (212 206 0)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}`)
|
|
||||||
|
|
||||||
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
|
|
||||||
|
|
||||||
hasData, exitStatus := sm.parseSmartForSata(jsonPayload)
|
|
||||||
require.True(t, hasData)
|
|
||||||
assert.Equal(t, 0, exitStatus)
|
|
||||||
|
|
||||||
data, ok := sm.SmartDataMap["PARENTHESES123"]
|
|
||||||
require.True(t, ok)
|
|
||||||
require.Len(t, data.Attributes, 1)
|
|
||||||
|
|
||||||
attr := data.Attributes[0]
|
|
||||||
assert.Equal(t, uint64(39925), attr.RawValue)
|
|
||||||
assert.Equal(t, "39925 (212 206 0)", attr.RawString)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseSmartForNvme(t *testing.T) {
|
|
||||||
fixturePath := filepath.Join("test-data", "smart", "nvme0.json")
|
|
||||||
data, err := os.ReadFile(fixturePath)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
sm := &SmartManager{
|
|
||||||
SmartDataMap: make(map[string]*smart.SmartData),
|
|
||||||
}
|
|
||||||
|
|
||||||
hasData, exitStatus := sm.parseSmartForNvme(data)
|
|
||||||
require.True(t, hasData)
|
|
||||||
assert.Equal(t, 0, exitStatus)
|
|
||||||
|
|
||||||
deviceData, ok := sm.SmartDataMap["2024031600129"]
|
|
||||||
require.True(t, ok, "expected smart data entry for serial 2024031600129")
|
|
||||||
|
|
||||||
assert.Equal(t, "PELADN 512GB", deviceData.ModelName)
|
|
||||||
assert.Equal(t, "VC2S038E", deviceData.FirmwareVersion)
|
|
||||||
assert.Equal(t, "/dev/nvme0", deviceData.DiskName)
|
|
||||||
assert.Equal(t, "nvme", deviceData.DiskType)
|
|
||||||
assert.Equal(t, uint8(61), deviceData.Temperature)
|
|
||||||
assert.Equal(t, "PASSED", deviceData.SmartStatus)
|
|
||||||
assert.Equal(t, uint64(512110190592), deviceData.Capacity)
|
|
||||||
if assert.NotEmpty(t, deviceData.Attributes) {
|
|
||||||
assertAttrValue(t, deviceData.Attributes, "PercentageUsed", 0)
|
|
||||||
assertAttrValue(t, deviceData.Attributes, "DataUnitsWritten", 16040567)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHasDataForDevice(t *testing.T) {
|
|
||||||
sm := &SmartManager{
|
|
||||||
SmartDataMap: map[string]*smart.SmartData{
|
|
||||||
"serial-1": {DiskName: "/dev/sda"},
|
|
||||||
"serial-2": nil,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.True(t, sm.hasDataForDevice("/dev/sda"))
|
|
||||||
assert.False(t, sm.hasDataForDevice("/dev/sdb"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDevicesSnapshotReturnsCopy(t *testing.T) {
|
|
||||||
originalDevice := &DeviceInfo{Name: "/dev/sda"}
|
|
||||||
sm := &SmartManager{
|
|
||||||
SmartDevices: []*DeviceInfo{
|
|
||||||
originalDevice,
|
|
||||||
{Name: "/dev/sdb"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
snapshot := sm.devicesSnapshot()
|
|
||||||
require.Len(t, snapshot, 2)
|
|
||||||
|
|
||||||
sm.SmartDevices[0] = &DeviceInfo{Name: "/dev/sdz"}
|
|
||||||
assert.Equal(t, "/dev/sda", snapshot[0].Name)
|
|
||||||
|
|
||||||
snapshot[1] = &DeviceInfo{Name: "/dev/nvme0"}
|
|
||||||
assert.Equal(t, "/dev/sdb", sm.SmartDevices[1].Name)
|
|
||||||
|
|
||||||
sm.SmartDevices = append(sm.SmartDevices, &DeviceInfo{Name: "/dev/nvme1"})
|
|
||||||
assert.Len(t, snapshot, 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestScanDevicesWithEnvOverride(t *testing.T) {
|
|
||||||
t.Setenv("SMART_DEVICES", "/dev/sda:sat, /dev/nvme0:nvme")
|
|
||||||
|
|
||||||
sm := &SmartManager{
|
|
||||||
SmartDataMap: make(map[string]*smart.SmartData),
|
|
||||||
}
|
|
||||||
|
|
||||||
err := sm.ScanDevices(true)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
require.Len(t, sm.SmartDevices, 2)
|
|
||||||
assert.Equal(t, "/dev/sda", sm.SmartDevices[0].Name)
|
|
||||||
assert.Equal(t, "sat", sm.SmartDevices[0].Type)
|
|
||||||
assert.Equal(t, "/dev/nvme0", sm.SmartDevices[1].Name)
|
|
||||||
assert.Equal(t, "nvme", sm.SmartDevices[1].Type)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestScanDevicesWithEnvOverrideInvalid(t *testing.T) {
|
|
||||||
t.Setenv("SMART_DEVICES", ":sat")
|
|
||||||
|
|
||||||
sm := &SmartManager{
|
|
||||||
SmartDataMap: make(map[string]*smart.SmartData),
|
|
||||||
}
|
|
||||||
|
|
||||||
err := sm.ScanDevices(true)
|
|
||||||
require.Error(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestScanDevicesWithEnvOverrideEmpty(t *testing.T) {
|
|
||||||
t.Setenv("SMART_DEVICES", " ")
|
|
||||||
|
|
||||||
sm := &SmartManager{
|
|
||||||
SmartDataMap: make(map[string]*smart.SmartData),
|
|
||||||
}
|
|
||||||
|
|
||||||
err := sm.ScanDevices(true)
|
|
||||||
assert.ErrorIs(t, err, errNoValidSmartData)
|
|
||||||
assert.Empty(t, sm.SmartDevices)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSmartctlArgsWithoutType(t *testing.T) {
|
|
||||||
device := &DeviceInfo{Name: "/dev/sda"}
|
|
||||||
|
|
||||||
sm := &SmartManager{}
|
|
||||||
|
|
||||||
args := sm.smartctlArgs(device, true)
|
|
||||||
assert.Equal(t, []string{"-a", "--json=c", "-n", "standby", "/dev/sda"}, args)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSmartctlArgs(t *testing.T) {
|
|
||||||
sm := &SmartManager{}
|
|
||||||
|
|
||||||
sataDevice := &DeviceInfo{Name: "/dev/sda", Type: "sat"}
|
|
||||||
assert.Equal(t,
|
|
||||||
[]string{"-d", "sat", "-a", "--json=c", "-n", "standby", "/dev/sda"},
|
|
||||||
sm.smartctlArgs(sataDevice, true),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert.Equal(t,
|
|
||||||
[]string{"-d", "sat", "-a", "--json=c", "/dev/sda"},
|
|
||||||
sm.smartctlArgs(sataDevice, false),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert.Equal(t,
|
|
||||||
[]string{"-a", "--json=c", "-n", "standby"},
|
|
||||||
sm.smartctlArgs(nil, true),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResolveRefreshError(t *testing.T) {
|
|
||||||
scanErr := errors.New("scan failed")
|
|
||||||
collectErr := errors.New("collect failed")
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
devices []*DeviceInfo
|
|
||||||
data map[string]*smart.SmartData
|
|
||||||
scanErr error
|
|
||||||
collectErr error
|
|
||||||
expectedErr error
|
|
||||||
expectNoErr bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "no devices returns scan error",
|
|
||||||
devices: nil,
|
|
||||||
data: make(map[string]*smart.SmartData),
|
|
||||||
scanErr: scanErr,
|
|
||||||
expectedErr: scanErr,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "has data ignores errors",
|
|
||||||
devices: []*DeviceInfo{{Name: "/dev/sda"}},
|
|
||||||
data: map[string]*smart.SmartData{"serial": {}},
|
|
||||||
scanErr: scanErr,
|
|
||||||
collectErr: collectErr,
|
|
||||||
expectNoErr: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "collect error preferred",
|
|
||||||
devices: []*DeviceInfo{{Name: "/dev/sda"}},
|
|
||||||
data: make(map[string]*smart.SmartData),
|
|
||||||
collectErr: collectErr,
|
|
||||||
expectedErr: collectErr,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "scan error returned when no data",
|
|
||||||
devices: []*DeviceInfo{{Name: "/dev/sda"}},
|
|
||||||
data: make(map[string]*smart.SmartData),
|
|
||||||
scanErr: scanErr,
|
|
||||||
expectedErr: scanErr,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "no errors returns sentinel",
|
|
||||||
devices: []*DeviceInfo{{Name: "/dev/sda"}},
|
|
||||||
data: make(map[string]*smart.SmartData),
|
|
||||||
expectedErr: errNoValidSmartData,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "no devices collect error",
|
|
||||||
devices: nil,
|
|
||||||
data: make(map[string]*smart.SmartData),
|
|
||||||
collectErr: collectErr,
|
|
||||||
expectedErr: collectErr,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
sm := &SmartManager{
|
|
||||||
SmartDevices: tt.devices,
|
|
||||||
SmartDataMap: tt.data,
|
|
||||||
}
|
|
||||||
|
|
||||||
err := sm.resolveRefreshError(tt.scanErr, tt.collectErr)
|
|
||||||
if tt.expectNoErr {
|
|
||||||
assert.NoError(t, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if tt.expectedErr == nil {
|
|
||||||
assert.NoError(t, err)
|
|
||||||
} else {
|
|
||||||
assert.Equal(t, tt.expectedErr, err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseScan(t *testing.T) {
|
|
||||||
sm := &SmartManager{
|
|
||||||
SmartDataMap: map[string]*smart.SmartData{
|
|
||||||
"serial-active": {DiskName: "/dev/sda"},
|
|
||||||
"serial-stale": {DiskName: "/dev/sdb"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
scanJSON := []byte(`{
|
|
||||||
"devices": [
|
|
||||||
{"name": "/dev/sda", "type": "sat", "info_name": "/dev/sda [SAT]", "protocol": "ATA"},
|
|
||||||
{"name": "/dev/nvme0", "type": "nvme", "info_name": "/dev/nvme0", "protocol": "NVMe"}
|
|
||||||
]
|
|
||||||
}`)
|
|
||||||
|
|
||||||
devices, hasData := sm.parseScan(scanJSON)
|
|
||||||
assert.True(t, hasData)
|
|
||||||
|
|
||||||
sm.updateSmartDevices(devices)
|
|
||||||
|
|
||||||
require.Len(t, sm.SmartDevices, 2)
|
|
||||||
assert.Equal(t, "/dev/sda", sm.SmartDevices[0].Name)
|
|
||||||
assert.Equal(t, "sat", sm.SmartDevices[0].Type)
|
|
||||||
assert.Equal(t, "/dev/nvme0", sm.SmartDevices[1].Name)
|
|
||||||
assert.Equal(t, "nvme", sm.SmartDevices[1].Type)
|
|
||||||
|
|
||||||
_, activeExists := sm.SmartDataMap["serial-active"]
|
|
||||||
assert.True(t, activeExists, "active smart data should be preserved when device path remains")
|
|
||||||
|
|
||||||
_, staleExists := sm.SmartDataMap["serial-stale"]
|
|
||||||
assert.False(t, staleExists, "stale smart data entry should be removed when device path disappears")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMergeDeviceListsPrefersConfigured(t *testing.T) {
|
|
||||||
scanned := []*DeviceInfo{
|
|
||||||
{Name: "/dev/sda", Type: "sat", InfoName: "scan-info", Protocol: "ATA"},
|
|
||||||
{Name: "/dev/nvme0", Type: "nvme"},
|
|
||||||
}
|
|
||||||
|
|
||||||
configured := []*DeviceInfo{
|
|
||||||
{Name: "/dev/sda", Type: "sat-override"},
|
|
||||||
{Name: "/dev/sdb", Type: "sat"},
|
|
||||||
}
|
|
||||||
|
|
||||||
merged := mergeDeviceLists(nil, scanned, configured)
|
|
||||||
require.Len(t, merged, 3)
|
|
||||||
|
|
||||||
byName := make(map[string]*DeviceInfo, len(merged))
|
|
||||||
for _, dev := range merged {
|
|
||||||
byName[dev.Name] = dev
|
|
||||||
}
|
|
||||||
|
|
||||||
require.Contains(t, byName, "/dev/sda")
|
|
||||||
assert.Equal(t, "sat-override", byName["/dev/sda"].Type, "configured type should override scanned type")
|
|
||||||
assert.Equal(t, "scan-info", byName["/dev/sda"].InfoName, "scan metadata should be preserved when config does not provide it")
|
|
||||||
|
|
||||||
require.Contains(t, byName, "/dev/nvme0")
|
|
||||||
assert.Equal(t, "nvme", byName["/dev/nvme0"].Type)
|
|
||||||
|
|
||||||
require.Contains(t, byName, "/dev/sdb")
|
|
||||||
assert.Equal(t, "sat", byName["/dev/sdb"].Type)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMergeDeviceListsPreservesVerification(t *testing.T) {
|
|
||||||
existing := []*DeviceInfo{
|
|
||||||
{Name: "/dev/sda", Type: "sat+megaraid", parserType: "sat", typeVerified: true},
|
|
||||||
}
|
|
||||||
|
|
||||||
scanned := []*DeviceInfo{
|
|
||||||
{Name: "/dev/sda", Type: "nvme"},
|
|
||||||
}
|
|
||||||
|
|
||||||
merged := mergeDeviceLists(existing, scanned, nil)
|
|
||||||
require.Len(t, merged, 1)
|
|
||||||
|
|
||||||
device := merged[0]
|
|
||||||
assert.True(t, device.typeVerified)
|
|
||||||
assert.Equal(t, "sat", device.parserType)
|
|
||||||
assert.Equal(t, "sat+megaraid", device.Type)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMergeDeviceListsUpdatesTypeWhenUnverified(t *testing.T) {
|
|
||||||
existing := []*DeviceInfo{
|
|
||||||
{Name: "/dev/sda", Type: "sat", parserType: "sat", typeVerified: false},
|
|
||||||
}
|
|
||||||
|
|
||||||
scanned := []*DeviceInfo{
|
|
||||||
{Name: "/dev/sda", Type: "nvme"},
|
|
||||||
}
|
|
||||||
|
|
||||||
merged := mergeDeviceLists(existing, scanned, nil)
|
|
||||||
require.Len(t, merged, 1)
|
|
||||||
|
|
||||||
device := merged[0]
|
|
||||||
assert.False(t, device.typeVerified)
|
|
||||||
assert.Equal(t, "nvme", device.Type)
|
|
||||||
assert.Equal(t, "", device.parserType)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseSmartOutputMarksVerified(t *testing.T) {
|
|
||||||
fixturePath := filepath.Join("test-data", "smart", "nvme0.json")
|
|
||||||
data, err := os.ReadFile(fixturePath)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
|
|
||||||
device := &DeviceInfo{Name: "/dev/nvme0"}
|
|
||||||
|
|
||||||
require.True(t, sm.parseSmartOutput(device, data))
|
|
||||||
assert.Equal(t, "nvme", device.Type)
|
|
||||||
assert.Equal(t, "nvme", device.parserType)
|
|
||||||
assert.True(t, device.typeVerified)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseSmartOutputKeepsCustomType(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: "sat+megaraid"}
|
|
||||||
|
|
||||||
require.True(t, sm.parseSmartOutput(device, data))
|
|
||||||
assert.Equal(t, "sat+megaraid", device.Type)
|
|
||||||
assert.Equal(t, "sat", device.parserType)
|
|
||||||
assert.True(t, device.typeVerified)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseSmartOutputResetsVerificationOnFailure(t *testing.T) {
|
|
||||||
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
|
|
||||||
device := &DeviceInfo{Name: "/dev/sda", Type: "sat", parserType: "sat", typeVerified: true}
|
|
||||||
|
|
||||||
assert.False(t, sm.parseSmartOutput(device, []byte("not json")))
|
|
||||||
assert.False(t, device.typeVerified)
|
|
||||||
assert.Equal(t, "sat", device.parserType)
|
|
||||||
}
|
|
||||||
|
|
||||||
func assertAttrValue(t *testing.T, attributes []*smart.SmartAttribute, name string, expected uint64) {
|
|
||||||
t.Helper()
|
|
||||||
attr := findAttr(attributes, name)
|
|
||||||
if attr == nil {
|
|
||||||
t.Fatalf("expected attribute %s to be present", name)
|
|
||||||
}
|
|
||||||
if attr.RawValue != expected {
|
|
||||||
t.Fatalf("unexpected attribute %s value: got %d, want %d", name, attr.RawValue, expected)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func findAttr(attributes []*smart.SmartAttribute, name string) *smart.SmartAttribute {
|
|
||||||
for _, attr := range attributes {
|
|
||||||
if attr != nil && attr.Name == name {
|
|
||||||
return attr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsVirtualDevice(t *testing.T) {
|
|
||||||
sm := &SmartManager{}
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
vendor string
|
|
||||||
product string
|
|
||||||
model string
|
|
||||||
expected bool
|
|
||||||
}{
|
|
||||||
{"regular drive", "SEAGATE", "ST1000DM003", "ST1000DM003-1CH162", false},
|
|
||||||
{"qemu virtual", "QEMU", "QEMU HARDDISK", "QEMU HARDDISK", true},
|
|
||||||
{"virtualbox virtual", "VBOX", "HARDDISK", "VBOX HARDDISK", true},
|
|
||||||
{"vmware virtual", "VMWARE", "Virtual disk", "VMWARE Virtual disk", true},
|
|
||||||
{"virtual in model", "ATA", "VIRTUAL", "VIRTUAL DISK", true},
|
|
||||||
{"iet virtual", "IET", "VIRTUAL-DISK", "VIRTUAL-DISK", true},
|
|
||||||
{"hyper-v virtual", "MSFT", "VIRTUAL HD", "VIRTUAL HD", true},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
data := &smart.SmartInfoForSata{
|
|
||||||
ScsiVendor: tt.vendor,
|
|
||||||
ScsiProduct: tt.product,
|
|
||||||
ModelName: tt.model,
|
|
||||||
}
|
|
||||||
result := sm.isVirtualDevice(data)
|
|
||||||
assert.Equal(t, tt.expected, result)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsVirtualDeviceNvme(t *testing.T) {
|
|
||||||
sm := &SmartManager{}
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
model string
|
|
||||||
expected bool
|
|
||||||
}{
|
|
||||||
{"regular nvme", "Samsung SSD 970 EVO Plus 1TB", false},
|
|
||||||
{"qemu virtual", "QEMU NVMe Ctrl", true},
|
|
||||||
{"virtualbox virtual", "VBOX NVMe", true},
|
|
||||||
{"vmware virtual", "VMWARE NVMe", true},
|
|
||||||
{"virtual in model", "Virtual NVMe Device", true},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
data := &smart.SmartInfoForNvme{
|
|
||||||
ModelName: tt.model,
|
|
||||||
}
|
|
||||||
result := sm.isVirtualDeviceNvme(data)
|
|
||||||
assert.Equal(t, tt.expected, result)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsVirtualDeviceScsi(t *testing.T) {
|
|
||||||
sm := &SmartManager{}
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
vendor string
|
|
||||||
product string
|
|
||||||
model string
|
|
||||||
expected bool
|
|
||||||
}{
|
|
||||||
{"regular scsi", "SEAGATE", "ST1000DM003", "ST1000DM003-1CH162", false},
|
|
||||||
{"qemu virtual", "QEMU", "QEMU HARDDISK", "QEMU HARDDISK", true},
|
|
||||||
{"virtualbox virtual", "VBOX", "HARDDISK", "VBOX HARDDISK", true},
|
|
||||||
{"vmware virtual", "VMWARE", "Virtual disk", "VMWARE Virtual disk", true},
|
|
||||||
{"virtual in model", "ATA", "VIRTUAL", "VIRTUAL DISK", true},
|
|
||||||
{"iet virtual", "IET", "VIRTUAL-DISK", "VIRTUAL-DISK", true},
|
|
||||||
{"hyper-v virtual", "MSFT", "VIRTUAL HD", "VIRTUAL HD", true},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
data := &smart.SmartInfoForScsi{
|
|
||||||
ScsiVendor: tt.vendor,
|
|
||||||
ScsiProduct: tt.product,
|
|
||||||
ScsiModelName: tt.model,
|
|
||||||
}
|
|
||||||
result := sm.isVirtualDeviceScsi(data)
|
|
||||||
assert.Equal(t, tt.expected, result)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRefreshExcludedDevices(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
envValue string
|
|
||||||
expectedDevs map[string]struct{}
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "empty env",
|
|
||||||
envValue: "",
|
|
||||||
expectedDevs: map[string]struct{}{},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "single device",
|
|
||||||
envValue: "/dev/sda",
|
|
||||||
expectedDevs: map[string]struct{}{
|
|
||||||
"/dev/sda": {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "multiple devices",
|
|
||||||
envValue: "/dev/sda,/dev/sdb,/dev/nvme0",
|
|
||||||
expectedDevs: map[string]struct{}{
|
|
||||||
"/dev/sda": {},
|
|
||||||
"/dev/sdb": {},
|
|
||||||
"/dev/nvme0": {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "devices with whitespace",
|
|
||||||
envValue: " /dev/sda , /dev/sdb , /dev/nvme0 ",
|
|
||||||
expectedDevs: map[string]struct{}{
|
|
||||||
"/dev/sda": {},
|
|
||||||
"/dev/sdb": {},
|
|
||||||
"/dev/nvme0": {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "duplicate devices",
|
|
||||||
envValue: "/dev/sda,/dev/sdb,/dev/sda",
|
|
||||||
expectedDevs: map[string]struct{}{
|
|
||||||
"/dev/sda": {},
|
|
||||||
"/dev/sdb": {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty entries and whitespace",
|
|
||||||
envValue: "/dev/sda,, /dev/sdb , , ",
|
|
||||||
expectedDevs: map[string]struct{}{
|
|
||||||
"/dev/sda": {},
|
|
||||||
"/dev/sdb": {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
if tt.envValue != "" {
|
|
||||||
t.Setenv("EXCLUDE_SMART", tt.envValue)
|
|
||||||
} else {
|
|
||||||
// Ensure env var is not set for empty test
|
|
||||||
os.Unsetenv("EXCLUDE_SMART")
|
|
||||||
}
|
|
||||||
|
|
||||||
sm := &SmartManager{}
|
|
||||||
sm.refreshExcludedDevices()
|
|
||||||
|
|
||||||
assert.Equal(t, tt.expectedDevs, sm.excludedDevices)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsExcludedDevice(t *testing.T) {
|
|
||||||
sm := &SmartManager{
|
|
||||||
excludedDevices: map[string]struct{}{
|
|
||||||
"/dev/sda": {},
|
|
||||||
"/dev/nvme0": {},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
deviceName string
|
|
||||||
expectedBool bool
|
|
||||||
}{
|
|
||||||
{"excluded device sda", "/dev/sda", true},
|
|
||||||
{"excluded device nvme0", "/dev/nvme0", true},
|
|
||||||
{"non-excluded device sdb", "/dev/sdb", false},
|
|
||||||
{"non-excluded device nvme1", "/dev/nvme1", false},
|
|
||||||
{"empty device name", "", false},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
result := sm.isExcludedDevice(tt.deviceName)
|
|
||||||
assert.Equal(t, tt.expectedBool, result)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFilterExcludedDevices(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
excludedDevs map[string]struct{}
|
|
||||||
inputDevices []*DeviceInfo
|
|
||||||
expectedDevs []*DeviceInfo
|
|
||||||
expectedLength int
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "no exclusions",
|
|
||||||
excludedDevs: map[string]struct{}{},
|
|
||||||
inputDevices: []*DeviceInfo{
|
|
||||||
{Name: "/dev/sda"},
|
|
||||||
{Name: "/dev/sdb"},
|
|
||||||
{Name: "/dev/nvme0"},
|
|
||||||
},
|
|
||||||
expectedDevs: []*DeviceInfo{
|
|
||||||
{Name: "/dev/sda"},
|
|
||||||
{Name: "/dev/sdb"},
|
|
||||||
{Name: "/dev/nvme0"},
|
|
||||||
},
|
|
||||||
expectedLength: 3,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "some devices excluded",
|
|
||||||
excludedDevs: map[string]struct{}{
|
|
||||||
"/dev/sda": {},
|
|
||||||
"/dev/nvme0": {},
|
|
||||||
},
|
|
||||||
inputDevices: []*DeviceInfo{
|
|
||||||
{Name: "/dev/sda"},
|
|
||||||
{Name: "/dev/sdb"},
|
|
||||||
{Name: "/dev/nvme0"},
|
|
||||||
{Name: "/dev/nvme1"},
|
|
||||||
},
|
|
||||||
expectedDevs: []*DeviceInfo{
|
|
||||||
{Name: "/dev/sdb"},
|
|
||||||
{Name: "/dev/nvme1"},
|
|
||||||
},
|
|
||||||
expectedLength: 2,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "all devices excluded",
|
|
||||||
excludedDevs: map[string]struct{}{
|
|
||||||
"/dev/sda": {},
|
|
||||||
"/dev/sdb": {},
|
|
||||||
},
|
|
||||||
inputDevices: []*DeviceInfo{
|
|
||||||
{Name: "/dev/sda"},
|
|
||||||
{Name: "/dev/sdb"},
|
|
||||||
},
|
|
||||||
expectedDevs: []*DeviceInfo{},
|
|
||||||
expectedLength: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "nil devices",
|
|
||||||
excludedDevs: map[string]struct{}{},
|
|
||||||
inputDevices: nil,
|
|
||||||
expectedDevs: []*DeviceInfo{},
|
|
||||||
expectedLength: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "filter nil and empty name devices",
|
|
||||||
excludedDevs: map[string]struct{}{
|
|
||||||
"/dev/sda": {},
|
|
||||||
},
|
|
||||||
inputDevices: []*DeviceInfo{
|
|
||||||
{Name: "/dev/sda"},
|
|
||||||
nil,
|
|
||||||
{Name: ""},
|
|
||||||
{Name: "/dev/sdb"},
|
|
||||||
},
|
|
||||||
expectedDevs: []*DeviceInfo{
|
|
||||||
{Name: "/dev/sdb"},
|
|
||||||
},
|
|
||||||
expectedLength: 1,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
sm := &SmartManager{
|
|
||||||
excludedDevices: tt.excludedDevs,
|
|
||||||
}
|
|
||||||
|
|
||||||
result := sm.filterExcludedDevices(tt.inputDevices)
|
|
||||||
|
|
||||||
assert.Len(t, result, tt.expectedLength)
|
|
||||||
assert.Equal(t, tt.expectedDevs, result)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsNvmeControllerPath(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
path string
|
|
||||||
expected bool
|
|
||||||
}{
|
|
||||||
// Controller paths (should return true)
|
|
||||||
{"/dev/nvme0", true},
|
|
||||||
{"/dev/nvme1", true},
|
|
||||||
{"/dev/nvme10", true},
|
|
||||||
{"nvme0", true},
|
|
||||||
|
|
||||||
// Namespace paths (should return false)
|
|
||||||
{"/dev/nvme0n1", false},
|
|
||||||
{"/dev/nvme1n1", false},
|
|
||||||
{"/dev/nvme0n1p1", false},
|
|
||||||
{"nvme0n1", false},
|
|
||||||
|
|
||||||
// Non-NVMe paths (should return false)
|
|
||||||
{"/dev/sda", false},
|
|
||||||
{"/dev/sda1", false},
|
|
||||||
{"/dev/hda", false},
|
|
||||||
{"", false},
|
|
||||||
{"/dev/nvme", false},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.path, func(t *testing.T) {
|
|
||||||
result := isNvmeControllerPath(tt.path)
|
|
||||||
assert.Equal(t, tt.expected, result, "path: %s", tt.path)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
//go:build windows
|
|
||||||
|
|
||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
_ "embed"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sync"
|
|
||||||
)
|
|
||||||
|
|
||||||
//go:embed smartmontools/smartctl.exe
|
|
||||||
var embeddedSmartctl []byte
|
|
||||||
|
|
||||||
var (
|
|
||||||
smartctlOnce sync.Once
|
|
||||||
smartctlPath string
|
|
||||||
smartctlErr error
|
|
||||||
)
|
|
||||||
|
|
||||||
func ensureEmbeddedSmartctl() (string, error) {
|
|
||||||
smartctlOnce.Do(func() {
|
|
||||||
destDir := filepath.Join(os.TempDir(), "beszel", "smartmontools")
|
|
||||||
if err := os.MkdirAll(destDir, 0o755); err != nil {
|
|
||||||
smartctlErr = fmt.Errorf("failed to create smartctl directory: %w", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
destPath := filepath.Join(destDir, "smartctl.exe")
|
|
||||||
if err := os.WriteFile(destPath, embeddedSmartctl, 0o755); err != nil {
|
|
||||||
smartctlErr = fmt.Errorf("failed to write embedded smartctl: %w", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
smartctlPath = destPath
|
|
||||||
})
|
|
||||||
|
|
||||||
return smartctlPath, smartctlErr
|
|
||||||
}
|
|
||||||
298
agent/system.go
298
agent/system.go
@@ -1,298 +0,0 @@
|
|||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
|
||||||
"os"
|
|
||||||
"runtime"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel"
|
|
||||||
"github.com/henrygd/beszel/agent/battery"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/container"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
|
||||||
|
|
||||||
"github.com/shirou/gopsutil/v4/cpu"
|
|
||||||
"github.com/shirou/gopsutil/v4/host"
|
|
||||||
"github.com/shirou/gopsutil/v4/load"
|
|
||||||
"github.com/shirou/gopsutil/v4/mem"
|
|
||||||
)
|
|
||||||
|
|
||||||
// prevDisk stores previous per-device disk counters for a given cache interval
|
|
||||||
type prevDisk struct {
|
|
||||||
readBytes uint64
|
|
||||||
writeBytes uint64
|
|
||||||
at time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sets initial / non-changing values about the host system
|
|
||||||
func (a *Agent) refreshSystemDetails() {
|
|
||||||
a.systemInfo.AgentVersion = beszel.Version
|
|
||||||
|
|
||||||
// get host info from Docker if available
|
|
||||||
var hostInfo container.HostInfo
|
|
||||||
|
|
||||||
if a.dockerManager != nil {
|
|
||||||
a.systemDetails.Podman = a.dockerManager.IsPodman()
|
|
||||||
hostInfo, _ = a.dockerManager.GetHostInfo()
|
|
||||||
}
|
|
||||||
|
|
||||||
a.systemDetails.Hostname, _ = os.Hostname()
|
|
||||||
if arch, err := host.KernelArch(); err == nil {
|
|
||||||
a.systemDetails.Arch = arch
|
|
||||||
} else {
|
|
||||||
a.systemDetails.Arch = runtime.GOARCH
|
|
||||||
}
|
|
||||||
|
|
||||||
platform, _, version, _ := host.PlatformInformation()
|
|
||||||
|
|
||||||
if platform == "darwin" {
|
|
||||||
a.systemDetails.Os = system.Darwin
|
|
||||||
a.systemDetails.OsName = fmt.Sprintf("macOS %s", version)
|
|
||||||
} else if strings.Contains(platform, "indows") {
|
|
||||||
a.systemDetails.Os = system.Windows
|
|
||||||
a.systemDetails.OsName = strings.Replace(platform, "Microsoft ", "", 1)
|
|
||||||
a.systemDetails.Kernel = version
|
|
||||||
} else if platform == "freebsd" {
|
|
||||||
a.systemDetails.Os = system.Freebsd
|
|
||||||
a.systemDetails.Kernel, _ = host.KernelVersion()
|
|
||||||
if prettyName, err := getOsPrettyName(); err == nil {
|
|
||||||
a.systemDetails.OsName = prettyName
|
|
||||||
} else {
|
|
||||||
a.systemDetails.OsName = "FreeBSD"
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
a.systemDetails.Os = system.Linux
|
|
||||||
a.systemDetails.OsName = hostInfo.OperatingSystem
|
|
||||||
if a.systemDetails.OsName == "" {
|
|
||||||
if prettyName, err := getOsPrettyName(); err == nil {
|
|
||||||
a.systemDetails.OsName = prettyName
|
|
||||||
} else {
|
|
||||||
a.systemDetails.OsName = platform
|
|
||||||
}
|
|
||||||
}
|
|
||||||
a.systemDetails.Kernel = hostInfo.KernelVersion
|
|
||||||
if a.systemDetails.Kernel == "" {
|
|
||||||
a.systemDetails.Kernel, _ = host.KernelVersion()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// cpu model
|
|
||||||
if info, err := cpu.Info(); err == nil && len(info) > 0 {
|
|
||||||
a.systemDetails.CpuModel = info[0].ModelName
|
|
||||||
}
|
|
||||||
// cores / threads
|
|
||||||
cores, _ := cpu.Counts(false)
|
|
||||||
threads := hostInfo.NCPU
|
|
||||||
if threads == 0 {
|
|
||||||
threads, _ = cpu.Counts(true)
|
|
||||||
}
|
|
||||||
// in lxc, logical cores reflects container limits, so use that as cores if lower
|
|
||||||
if threads > 0 && threads < cores {
|
|
||||||
cores = threads
|
|
||||||
}
|
|
||||||
a.systemDetails.Cores = cores
|
|
||||||
a.systemDetails.Threads = threads
|
|
||||||
|
|
||||||
// total memory
|
|
||||||
a.systemDetails.MemoryTotal = hostInfo.MemTotal
|
|
||||||
if a.systemDetails.MemoryTotal == 0 {
|
|
||||||
if v, err := mem.VirtualMemory(); err == nil {
|
|
||||||
a.systemDetails.MemoryTotal = v.Total
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// zfs
|
|
||||||
if _, err := getARCSize(); err != nil {
|
|
||||||
slog.Debug("Not monitoring ZFS ARC", "err", err)
|
|
||||||
} else {
|
|
||||||
a.zfs = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Returns current info, stats about the host system
|
|
||||||
func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
|
|
||||||
var systemStats system.Stats
|
|
||||||
|
|
||||||
// battery
|
|
||||||
if batteryPercent, batteryState, err := battery.GetBatteryStats(); err == nil {
|
|
||||||
systemStats.Battery[0] = batteryPercent
|
|
||||||
systemStats.Battery[1] = batteryState
|
|
||||||
}
|
|
||||||
|
|
||||||
// cpu metrics
|
|
||||||
cpuMetrics, err := getCpuMetrics(cacheTimeMs)
|
|
||||||
if err == nil {
|
|
||||||
systemStats.Cpu = twoDecimals(cpuMetrics.Total)
|
|
||||||
systemStats.CpuBreakdown = []float64{
|
|
||||||
twoDecimals(cpuMetrics.User),
|
|
||||||
twoDecimals(cpuMetrics.System),
|
|
||||||
twoDecimals(cpuMetrics.Iowait),
|
|
||||||
twoDecimals(cpuMetrics.Steal),
|
|
||||||
twoDecimals(cpuMetrics.Idle),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
slog.Error("Error getting cpu metrics", "err", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// per-core cpu usage
|
|
||||||
if perCoreUsage, err := getPerCoreCpuUsage(cacheTimeMs); err == nil {
|
|
||||||
systemStats.CpuCoresUsage = perCoreUsage
|
|
||||||
}
|
|
||||||
|
|
||||||
// load average
|
|
||||||
if avgstat, err := load.Avg(); err == nil {
|
|
||||||
systemStats.LoadAvg[0] = avgstat.Load1
|
|
||||||
systemStats.LoadAvg[1] = avgstat.Load5
|
|
||||||
systemStats.LoadAvg[2] = avgstat.Load15
|
|
||||||
slog.Debug("Load average", "5m", avgstat.Load5, "15m", avgstat.Load15)
|
|
||||||
} else {
|
|
||||||
slog.Error("Error getting load average", "err", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// memory
|
|
||||||
if v, err := mem.VirtualMemory(); err == nil {
|
|
||||||
// swap
|
|
||||||
systemStats.Swap = bytesToGigabytes(v.SwapTotal)
|
|
||||||
systemStats.SwapUsed = bytesToGigabytes(v.SwapTotal - v.SwapFree - v.SwapCached)
|
|
||||||
// cache + buffers value for default mem calculation
|
|
||||||
// note: gopsutil automatically adds SReclaimable to v.Cached
|
|
||||||
cacheBuff := v.Cached + v.Buffers - v.Shared
|
|
||||||
if cacheBuff <= 0 {
|
|
||||||
cacheBuff = max(v.Total-v.Free-v.Used, 0)
|
|
||||||
}
|
|
||||||
// htop memory calculation overrides (likely outdated as of mid 2025)
|
|
||||||
if a.memCalc == "htop" {
|
|
||||||
// cacheBuff = v.Cached + v.Buffers - v.Shared
|
|
||||||
v.Used = v.Total - (v.Free + cacheBuff)
|
|
||||||
v.UsedPercent = float64(v.Used) / float64(v.Total) * 100.0
|
|
||||||
}
|
|
||||||
// if a.memCalc == "legacy" {
|
|
||||||
// v.Used = v.Total - v.Free - v.Buffers - v.Cached
|
|
||||||
// cacheBuff = v.Total - v.Free - v.Used
|
|
||||||
// v.UsedPercent = float64(v.Used) / float64(v.Total) * 100.0
|
|
||||||
// }
|
|
||||||
// subtract ZFS ARC size from used memory and add as its own category
|
|
||||||
if a.zfs {
|
|
||||||
if arcSize, _ := getARCSize(); arcSize > 0 && arcSize < v.Used {
|
|
||||||
v.Used = v.Used - arcSize
|
|
||||||
v.UsedPercent = float64(v.Used) / float64(v.Total) * 100.0
|
|
||||||
systemStats.MemZfsArc = bytesToGigabytes(arcSize)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
systemStats.Mem = bytesToGigabytes(v.Total)
|
|
||||||
systemStats.MemBuffCache = bytesToGigabytes(cacheBuff)
|
|
||||||
systemStats.MemUsed = bytesToGigabytes(v.Used)
|
|
||||||
systemStats.MemPct = twoDecimals(v.UsedPercent)
|
|
||||||
}
|
|
||||||
|
|
||||||
// disk usage
|
|
||||||
a.updateDiskUsage(&systemStats)
|
|
||||||
|
|
||||||
// disk i/o (cache-aware per interval)
|
|
||||||
a.updateDiskIo(cacheTimeMs, &systemStats)
|
|
||||||
|
|
||||||
// network stats (per cache interval)
|
|
||||||
a.updateNetworkStats(cacheTimeMs, &systemStats)
|
|
||||||
|
|
||||||
// temperatures
|
|
||||||
// TODO: maybe refactor to methods on systemStats
|
|
||||||
a.updateTemperatures(&systemStats)
|
|
||||||
|
|
||||||
// GPU data
|
|
||||||
if a.gpuManager != nil {
|
|
||||||
// reset high gpu percent
|
|
||||||
a.systemInfo.GpuPct = 0
|
|
||||||
// get current GPU data
|
|
||||||
if gpuData := a.gpuManager.GetCurrentData(cacheTimeMs); len(gpuData) > 0 {
|
|
||||||
systemStats.GPUData = gpuData
|
|
||||||
|
|
||||||
// add temperatures
|
|
||||||
if systemStats.Temperatures == nil {
|
|
||||||
systemStats.Temperatures = make(map[string]float64, len(gpuData))
|
|
||||||
}
|
|
||||||
highestTemp := 0.0
|
|
||||||
for _, gpu := range gpuData {
|
|
||||||
if gpu.Temperature > 0 {
|
|
||||||
systemStats.Temperatures[gpu.Name] = gpu.Temperature
|
|
||||||
if a.sensorConfig.primarySensor == gpu.Name {
|
|
||||||
a.systemInfo.DashboardTemp = gpu.Temperature
|
|
||||||
}
|
|
||||||
if gpu.Temperature > highestTemp {
|
|
||||||
highestTemp = gpu.Temperature
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// update high gpu percent for dashboard
|
|
||||||
a.systemInfo.GpuPct = max(a.systemInfo.GpuPct, gpu.Usage)
|
|
||||||
}
|
|
||||||
// use highest temp for dashboard temp if dashboard temp is unset
|
|
||||||
if a.systemInfo.DashboardTemp == 0 {
|
|
||||||
a.systemInfo.DashboardTemp = highestTemp
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// update system info
|
|
||||||
a.systemInfo.ConnectionType = a.connectionManager.ConnectionType
|
|
||||||
a.systemInfo.Cpu = systemStats.Cpu
|
|
||||||
a.systemInfo.LoadAvg = systemStats.LoadAvg
|
|
||||||
a.systemInfo.MemPct = systemStats.MemPct
|
|
||||||
a.systemInfo.DiskPct = systemStats.DiskPct
|
|
||||||
a.systemInfo.Battery = systemStats.Battery
|
|
||||||
a.systemInfo.Uptime, _ = host.Uptime()
|
|
||||||
a.systemInfo.BandwidthBytes = systemStats.Bandwidth[0] + systemStats.Bandwidth[1]
|
|
||||||
a.systemInfo.Threads = a.systemDetails.Threads
|
|
||||||
|
|
||||||
return systemStats
|
|
||||||
}
|
|
||||||
|
|
||||||
// Returns the size of the ZFS ARC memory cache in bytes
|
|
||||||
func getARCSize() (uint64, error) {
|
|
||||||
file, err := os.Open("/proc/spl/kstat/zfs/arcstats")
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
// Scan the lines
|
|
||||||
scanner := bufio.NewScanner(file)
|
|
||||||
for scanner.Scan() {
|
|
||||||
line := scanner.Text()
|
|
||||||
if strings.HasPrefix(line, "size") {
|
|
||||||
// Example line: size 4 15032385536
|
|
||||||
fields := strings.Fields(line)
|
|
||||||
if len(fields) < 3 {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
// Return the size as uint64
|
|
||||||
return strconv.ParseUint(fields[2], 10, 64)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0, fmt.Errorf("failed to parse size field")
|
|
||||||
}
|
|
||||||
|
|
||||||
// getOsPrettyName attempts to get the pretty OS name from /etc/os-release on Linux systems
|
|
||||||
func getOsPrettyName() (string, error) {
|
|
||||||
file, err := os.Open("/etc/os-release")
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
scanner := bufio.NewScanner(file)
|
|
||||||
for scanner.Scan() {
|
|
||||||
line := scanner.Text()
|
|
||||||
if after, ok := strings.CutPrefix(line, "PRETTY_NAME="); ok {
|
|
||||||
value := after
|
|
||||||
value = strings.Trim(value, `"`)
|
|
||||||
return value, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", errors.New("pretty name not found")
|
|
||||||
}
|
|
||||||
299
agent/systemd.go
299
agent/systemd.go
@@ -1,299 +0,0 @@
|
|||||||
//go:build linux
|
|
||||||
|
|
||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"log/slog"
|
|
||||||
"maps"
|
|
||||||
"math"
|
|
||||||
"os"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/coreos/go-systemd/v22/dbus"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/systemd"
|
|
||||||
)
|
|
||||||
|
|
||||||
var errNoActiveTime = errors.New("no active time")
|
|
||||||
|
|
||||||
// systemdManager manages the collection of systemd service statistics.
|
|
||||||
type systemdManager struct {
|
|
||||||
sync.Mutex
|
|
||||||
serviceStatsMap map[string]*systemd.Service
|
|
||||||
isRunning bool
|
|
||||||
hasFreshStats bool
|
|
||||||
patterns []string
|
|
||||||
}
|
|
||||||
|
|
||||||
// isSystemdAvailable checks if systemd is used on the system to avoid unnecessary connection attempts (#1548)
|
|
||||||
func isSystemdAvailable() bool {
|
|
||||||
paths := []string{
|
|
||||||
"/run/systemd/system",
|
|
||||||
"/run/dbus/system_bus_socket",
|
|
||||||
"/var/run/dbus/system_bus_socket",
|
|
||||||
}
|
|
||||||
for _, path := range paths {
|
|
||||||
if _, err := os.Stat(path); err == nil {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if data, err := os.ReadFile("/proc/1/comm"); err == nil {
|
|
||||||
return strings.TrimSpace(string(data)) == "systemd"
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// newSystemdManager creates a new systemdManager.
|
|
||||||
func newSystemdManager() (*systemdManager, error) {
|
|
||||||
if skipSystemd, _ := GetEnv("SKIP_SYSTEMD"); skipSystemd == "true" {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if systemd is available on the system before attempting connection
|
|
||||||
if !isSystemdAvailable() {
|
|
||||||
slog.Debug("Systemd not available")
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
conn, err := dbus.NewSystemConnectionContext(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
slog.Debug("Error connecting to systemd", "err", err, "ref", "https://beszel.dev/guide/systemd")
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
manager := &systemdManager{
|
|
||||||
serviceStatsMap: make(map[string]*systemd.Service),
|
|
||||||
patterns: getServicePatterns(),
|
|
||||||
}
|
|
||||||
|
|
||||||
manager.startWorker(conn)
|
|
||||||
|
|
||||||
return manager, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (sm *systemdManager) startWorker(conn *dbus.Conn) {
|
|
||||||
if sm.isRunning {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
sm.isRunning = true
|
|
||||||
// prime the service stats map with the current services
|
|
||||||
_ = sm.getServiceStats(conn, true)
|
|
||||||
// update the services every 10 minutes
|
|
||||||
go func() {
|
|
||||||
for {
|
|
||||||
time.Sleep(time.Minute * 10)
|
|
||||||
_ = sm.getServiceStats(nil, true)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
// getServiceStatsCount returns the number of systemd services.
|
|
||||||
func (sm *systemdManager) getServiceStatsCount() int {
|
|
||||||
return len(sm.serviceStatsMap)
|
|
||||||
}
|
|
||||||
|
|
||||||
// getFailedServiceCount returns the number of systemd services in a failed state.
|
|
||||||
func (sm *systemdManager) getFailedServiceCount() uint16 {
|
|
||||||
sm.Lock()
|
|
||||||
defer sm.Unlock()
|
|
||||||
count := uint16(0)
|
|
||||||
for _, service := range sm.serviceStatsMap {
|
|
||||||
if service.State == systemd.StatusFailed {
|
|
||||||
count++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return count
|
|
||||||
}
|
|
||||||
|
|
||||||
// getServiceStats collects statistics for all running systemd services.
|
|
||||||
func (sm *systemdManager) getServiceStats(conn *dbus.Conn, refresh bool) []*systemd.Service {
|
|
||||||
// start := time.Now()
|
|
||||||
// defer func() {
|
|
||||||
// slog.Info("systemdManager.getServiceStats", "duration", time.Since(start))
|
|
||||||
// }()
|
|
||||||
|
|
||||||
var services []*systemd.Service
|
|
||||||
var err error
|
|
||||||
|
|
||||||
if !refresh {
|
|
||||||
// return nil
|
|
||||||
sm.Lock()
|
|
||||||
defer sm.Unlock()
|
|
||||||
for _, service := range sm.serviceStatsMap {
|
|
||||||
services = append(services, service)
|
|
||||||
}
|
|
||||||
sm.hasFreshStats = false
|
|
||||||
return services
|
|
||||||
}
|
|
||||||
|
|
||||||
if conn == nil || !conn.Connected() {
|
|
||||||
conn, err = dbus.NewSystemConnectionContext(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
defer conn.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
units, err := conn.ListUnitsByPatternsContext(context.Background(), []string{"loaded"}, sm.patterns)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("Error listing systemd service units", "err", err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, unit := range units {
|
|
||||||
service, err := sm.updateServiceStats(conn, unit)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
services = append(services, service)
|
|
||||||
}
|
|
||||||
sm.hasFreshStats = true
|
|
||||||
return services
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateServiceStats updates the statistics for a single systemd service.
|
|
||||||
func (sm *systemdManager) updateServiceStats(conn *dbus.Conn, unit dbus.UnitStatus) (*systemd.Service, error) {
|
|
||||||
sm.Lock()
|
|
||||||
defer sm.Unlock()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// if service has never been active (no active since time), skip it
|
|
||||||
if activeEnterTsProp, err := conn.GetUnitTypePropertyContext(ctx, unit.Name, "Unit", "ActiveEnterTimestamp"); err == nil {
|
|
||||||
if ts, ok := activeEnterTsProp.Value.Value().(uint64); !ok || ts == 0 || ts == math.MaxUint64 {
|
|
||||||
return nil, errNoActiveTime
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
service, serviceExists := sm.serviceStatsMap[unit.Name]
|
|
||||||
if !serviceExists {
|
|
||||||
service = &systemd.Service{Name: unescapeServiceName(strings.TrimSuffix(unit.Name, ".service"))}
|
|
||||||
sm.serviceStatsMap[unit.Name] = service
|
|
||||||
}
|
|
||||||
|
|
||||||
memPeak := service.MemPeak
|
|
||||||
if memPeakProp, err := conn.GetUnitTypePropertyContext(ctx, unit.Name, "Service", "MemoryPeak"); err == nil {
|
|
||||||
// If memPeak is MaxUint64 the api is saying it's not available
|
|
||||||
if v, ok := memPeakProp.Value.Value().(uint64); ok && v != math.MaxUint64 {
|
|
||||||
memPeak = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var memUsage uint64
|
|
||||||
if memProp, err := conn.GetUnitTypePropertyContext(ctx, unit.Name, "Service", "MemoryCurrent"); err == nil {
|
|
||||||
// If memUsage is MaxUint64 the api is saying it's not available
|
|
||||||
if v, ok := memProp.Value.Value().(uint64); ok && v != math.MaxUint64 {
|
|
||||||
memUsage = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
service.State = systemd.ParseServiceStatus(unit.ActiveState)
|
|
||||||
service.Sub = systemd.ParseServiceSubState(unit.SubState)
|
|
||||||
|
|
||||||
// some systems always return 0 for mem peak, so we should update the peak if the current usage is greater
|
|
||||||
if memUsage > memPeak {
|
|
||||||
memPeak = memUsage
|
|
||||||
}
|
|
||||||
|
|
||||||
var cpuUsage uint64
|
|
||||||
if cpuProp, err := conn.GetUnitTypePropertyContext(ctx, unit.Name, "Service", "CPUUsageNSec"); err == nil {
|
|
||||||
if v, ok := cpuProp.Value.Value().(uint64); ok {
|
|
||||||
cpuUsage = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
service.Mem = memUsage
|
|
||||||
if memPeak > service.MemPeak {
|
|
||||||
service.MemPeak = memPeak
|
|
||||||
}
|
|
||||||
service.UpdateCPUPercent(cpuUsage)
|
|
||||||
|
|
||||||
return service, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// getServiceDetails collects extended information for a specific systemd service.
|
|
||||||
func (sm *systemdManager) getServiceDetails(serviceName string) (systemd.ServiceDetails, error) {
|
|
||||||
conn, err := dbus.NewSystemConnectionContext(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer conn.Close()
|
|
||||||
|
|
||||||
unitName := serviceName
|
|
||||||
if !strings.HasSuffix(unitName, ".service") {
|
|
||||||
unitName += ".service"
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
props, err := conn.GetUnitPropertiesContext(ctx, unitName)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start with all unit properties
|
|
||||||
details := make(systemd.ServiceDetails)
|
|
||||||
maps.Copy(details, props)
|
|
||||||
|
|
||||||
// // Add service-specific properties
|
|
||||||
servicePropNames := []string{
|
|
||||||
"MainPID", "ExecMainPID", "TasksCurrent", "TasksMax",
|
|
||||||
"MemoryCurrent", "MemoryPeak", "MemoryLimit", "CPUUsageNSec",
|
|
||||||
"NRestarts", "ExecMainStartTimestampRealtime", "Result",
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, propName := range servicePropNames {
|
|
||||||
if variant, err := conn.GetUnitTypePropertyContext(ctx, unitName, "Service", propName); err == nil {
|
|
||||||
value := variant.Value.Value()
|
|
||||||
// Check if the value is MaxUint64, which indicates unlimited/infinite
|
|
||||||
if uint64Value, ok := value.(uint64); ok && uint64Value == math.MaxUint64 {
|
|
||||||
// Set to nil to indicate unlimited - frontend will handle this appropriately
|
|
||||||
details[propName] = nil
|
|
||||||
} else {
|
|
||||||
details[propName] = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return details, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// unescapeServiceName unescapes systemd service names that contain C-style escape sequences like \x2d
|
|
||||||
func unescapeServiceName(name string) string {
|
|
||||||
if !strings.Contains(name, "\\x") {
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
unescaped, err := strconv.Unquote("\"" + name + "\"")
|
|
||||||
if err != nil {
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
return unescaped
|
|
||||||
}
|
|
||||||
|
|
||||||
// getServicePatterns returns the list of service patterns to match.
|
|
||||||
// It reads from the SERVICE_PATTERNS environment variable if set,
|
|
||||||
// otherwise defaults to "*service".
|
|
||||||
func getServicePatterns() []string {
|
|
||||||
patterns := []string{}
|
|
||||||
if envPatterns, _ := GetEnv("SERVICE_PATTERNS"); envPatterns != "" {
|
|
||||||
for pattern := range strings.SplitSeq(envPatterns, ",") {
|
|
||||||
pattern = strings.TrimSpace(pattern)
|
|
||||||
if pattern == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !strings.HasSuffix(pattern, ".service") {
|
|
||||||
pattern += ".service"
|
|
||||||
}
|
|
||||||
patterns = append(patterns, pattern)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(patterns) == 0 {
|
|
||||||
patterns = []string{"*.service"}
|
|
||||||
}
|
|
||||||
return patterns
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
//go:build !linux
|
|
||||||
|
|
||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/systemd"
|
|
||||||
)
|
|
||||||
|
|
||||||
// systemdManager manages the collection of systemd service statistics.
|
|
||||||
type systemdManager struct {
|
|
||||||
hasFreshStats bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// newSystemdManager creates a new systemdManager.
|
|
||||||
func newSystemdManager() (*systemdManager, error) {
|
|
||||||
return &systemdManager{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// getServiceStats returns nil for non-linux systems.
|
|
||||||
func (sm *systemdManager) getServiceStats(conn any, refresh bool) []*systemd.Service {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// getServiceStatsCount returns 0 for non-linux systems.
|
|
||||||
func (sm *systemdManager) getServiceStatsCount() int {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// getFailedServiceCount returns 0 for non-linux systems.
|
|
||||||
func (sm *systemdManager) getFailedServiceCount() uint16 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func (sm *systemdManager) getServiceDetails(string) (systemd.ServiceDetails, error) {
|
|
||||||
return nil, errors.New("systemd manager unavailable")
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
//go:build !linux && testing
|
|
||||||
|
|
||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestNewSystemdManager(t *testing.T) {
|
|
||||||
manager, err := newSystemdManager()
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.NotNil(t, manager)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSystemdManagerGetServiceStats(t *testing.T) {
|
|
||||||
manager, err := newSystemdManager()
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Test with refresh = true
|
|
||||||
result := manager.getServiceStats(true)
|
|
||||||
assert.Nil(t, result)
|
|
||||||
|
|
||||||
// Test with refresh = false
|
|
||||||
result = manager.getServiceStats(false)
|
|
||||||
assert.Nil(t, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSystemdManagerGetServiceDetails(t *testing.T) {
|
|
||||||
manager, err := newSystemdManager()
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
result, err := manager.getServiceDetails("any-service")
|
|
||||||
assert.Error(t, err)
|
|
||||||
assert.Equal(t, "systemd manager unavailable", err.Error())
|
|
||||||
assert.Nil(t, result)
|
|
||||||
|
|
||||||
// Test with empty service name
|
|
||||||
result, err = manager.getServiceDetails("")
|
|
||||||
assert.Error(t, err)
|
|
||||||
assert.Equal(t, "systemd manager unavailable", err.Error())
|
|
||||||
assert.Nil(t, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSystemdManagerFields(t *testing.T) {
|
|
||||||
manager, err := newSystemdManager()
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// The non-linux manager should be a simple struct with no special fields
|
|
||||||
// We can't test private fields directly, but we can test the methods work
|
|
||||||
assert.NotNil(t, manager)
|
|
||||||
}
|
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
//go:build linux && testing
|
|
||||||
|
|
||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestUnescapeServiceName(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
input string
|
|
||||||
expected string
|
|
||||||
}{
|
|
||||||
{"nginx.service", "nginx.service"}, // No escaping needed
|
|
||||||
{"test\\x2dwith\\x2ddashes.service", "test-with-dashes.service"}, // \x2d is dash
|
|
||||||
{"service\\x20with\\x20spaces.service", "service with spaces.service"}, // \x20 is space
|
|
||||||
{"mixed\\x2dand\\x2dnormal", "mixed-and-normal"}, // Mixed escaped and normal
|
|
||||||
{"no-escape-here", "no-escape-here"}, // No escape sequences
|
|
||||||
{"", ""}, // Empty string
|
|
||||||
{"\\x2d\\x2d", "--"}, // Multiple escapes
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, test := range tests {
|
|
||||||
t.Run(test.input, func(t *testing.T) {
|
|
||||||
result := unescapeServiceName(test.input)
|
|
||||||
assert.Equal(t, test.expected, result)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUnescapeServiceNameInvalid(t *testing.T) {
|
|
||||||
// Test invalid escape sequences - should return original string
|
|
||||||
invalidInputs := []string{
|
|
||||||
"invalid\\x", // Incomplete escape
|
|
||||||
"invalid\\xZZ", // Invalid hex
|
|
||||||
"invalid\\x2", // Incomplete hex
|
|
||||||
"invalid\\xyz", // Not a valid escape
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, input := range invalidInputs {
|
|
||||||
t.Run(input, func(t *testing.T) {
|
|
||||||
result := unescapeServiceName(input)
|
|
||||||
assert.Equal(t, input, result, "Invalid escape sequences should return original string")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsSystemdAvailable(t *testing.T) {
|
|
||||||
// Note: This test's result will vary based on the actual system running the tests
|
|
||||||
// On systems with systemd, it should return true
|
|
||||||
// On systems without systemd, it should return false
|
|
||||||
result := isSystemdAvailable()
|
|
||||||
|
|
||||||
// Check if either the /run/systemd/system directory exists or PID 1 is systemd
|
|
||||||
runSystemdExists := false
|
|
||||||
if _, err := os.Stat("/run/systemd/system"); err == nil {
|
|
||||||
runSystemdExists = true
|
|
||||||
}
|
|
||||||
|
|
||||||
pid1IsSystemd := false
|
|
||||||
if data, err := os.ReadFile("/proc/1/comm"); err == nil {
|
|
||||||
pid1IsSystemd = strings.TrimSpace(string(data)) == "systemd"
|
|
||||||
}
|
|
||||||
|
|
||||||
expected := runSystemdExists || pid1IsSystemd
|
|
||||||
|
|
||||||
assert.Equal(t, expected, result, "isSystemdAvailable should correctly detect systemd presence")
|
|
||||||
|
|
||||||
// Log the result for informational purposes
|
|
||||||
if result {
|
|
||||||
t.Log("Systemd is available on this system")
|
|
||||||
} else {
|
|
||||||
t.Log("Systemd is not available on this system")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetServicePatterns(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
prefixedEnv string
|
|
||||||
unprefixedEnv string
|
|
||||||
expected []string
|
|
||||||
cleanupEnvVars bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "default when no env var set",
|
|
||||||
prefixedEnv: "",
|
|
||||||
unprefixedEnv: "",
|
|
||||||
expected: []string{"*.service"},
|
|
||||||
cleanupEnvVars: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "single pattern with prefixed env",
|
|
||||||
prefixedEnv: "nginx",
|
|
||||||
unprefixedEnv: "",
|
|
||||||
expected: []string{"nginx.service"},
|
|
||||||
cleanupEnvVars: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "single pattern with unprefixed env",
|
|
||||||
prefixedEnv: "",
|
|
||||||
unprefixedEnv: "nginx",
|
|
||||||
expected: []string{"nginx.service"},
|
|
||||||
cleanupEnvVars: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "prefixed env takes precedence",
|
|
||||||
prefixedEnv: "nginx",
|
|
||||||
unprefixedEnv: "apache",
|
|
||||||
expected: []string{"nginx.service"},
|
|
||||||
cleanupEnvVars: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "multiple patterns",
|
|
||||||
prefixedEnv: "nginx,apache,postgresql",
|
|
||||||
unprefixedEnv: "",
|
|
||||||
expected: []string{"nginx.service", "apache.service", "postgresql.service"},
|
|
||||||
cleanupEnvVars: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "patterns with .service suffix",
|
|
||||||
prefixedEnv: "nginx.service,apache.service",
|
|
||||||
unprefixedEnv: "",
|
|
||||||
expected: []string{"nginx.service", "apache.service"},
|
|
||||||
cleanupEnvVars: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "mixed patterns with and without suffix",
|
|
||||||
prefixedEnv: "nginx.service,apache,postgresql.service",
|
|
||||||
unprefixedEnv: "",
|
|
||||||
expected: []string{"nginx.service", "apache.service", "postgresql.service"},
|
|
||||||
cleanupEnvVars: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "patterns with whitespace",
|
|
||||||
prefixedEnv: " nginx , apache , postgresql ",
|
|
||||||
unprefixedEnv: "",
|
|
||||||
expected: []string{"nginx.service", "apache.service", "postgresql.service"},
|
|
||||||
cleanupEnvVars: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty patterns are skipped",
|
|
||||||
prefixedEnv: "nginx,,apache, ,postgresql",
|
|
||||||
unprefixedEnv: "",
|
|
||||||
expected: []string{"nginx.service", "apache.service", "postgresql.service"},
|
|
||||||
cleanupEnvVars: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "wildcard pattern",
|
|
||||||
prefixedEnv: "*nginx*,*apache*",
|
|
||||||
unprefixedEnv: "",
|
|
||||||
expected: []string{"*nginx*.service", "*apache*.service"},
|
|
||||||
cleanupEnvVars: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
// Clean up any existing env vars
|
|
||||||
os.Unsetenv("BESZEL_AGENT_SERVICE_PATTERNS")
|
|
||||||
os.Unsetenv("SERVICE_PATTERNS")
|
|
||||||
|
|
||||||
// Set up environment variables
|
|
||||||
if tt.prefixedEnv != "" {
|
|
||||||
os.Setenv("BESZEL_AGENT_SERVICE_PATTERNS", tt.prefixedEnv)
|
|
||||||
}
|
|
||||||
if tt.unprefixedEnv != "" {
|
|
||||||
os.Setenv("SERVICE_PATTERNS", tt.unprefixedEnv)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run the function
|
|
||||||
result := getServicePatterns()
|
|
||||||
|
|
||||||
// Verify results
|
|
||||||
assert.Equal(t, tt.expected, result, "Patterns should match expected values")
|
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
if tt.cleanupEnvVars {
|
|
||||||
os.Unsetenv("BESZEL_AGENT_SERVICE_PATTERNS")
|
|
||||||
os.Unsetenv("SERVICE_PATTERNS")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
{
|
|
||||||
"cpu_stats": {
|
|
||||||
"cpu_usage": {
|
|
||||||
"total_usage": 312055276000
|
|
||||||
},
|
|
||||||
"system_cpu_usage": 1366399830000000
|
|
||||||
},
|
|
||||||
"memory_stats": {
|
|
||||||
"usage": 507400192,
|
|
||||||
"stats": {
|
|
||||||
"inactive_file": 165130240
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"networks": {
|
|
||||||
"eth0": {
|
|
||||||
"tx_bytes": 20376558,
|
|
||||||
"rx_bytes": 537029455
|
|
||||||
},
|
|
||||||
"eth1": {
|
|
||||||
"tx_bytes": 2003766,
|
|
||||||
"rx_bytes": 6241
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
{
|
|
||||||
"cpu_stats": {
|
|
||||||
"cpu_usage": {
|
|
||||||
"total_usage": 314891801000
|
|
||||||
},
|
|
||||||
"system_cpu_usage": 1368474900000000
|
|
||||||
},
|
|
||||||
"memory_stats": {
|
|
||||||
"usage": 507400192,
|
|
||||||
"stats": {
|
|
||||||
"inactive_file": 165130240
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"networks": {
|
|
||||||
"eth0": {
|
|
||||||
"tx_bytes": 20376558,
|
|
||||||
"rx_bytes": 537029455
|
|
||||||
},
|
|
||||||
"eth1": {
|
|
||||||
"tx_bytes": 2003766,
|
|
||||||
"rx_bytes": 6241
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,272 +0,0 @@
|
|||||||
{
|
|
||||||
"json_format_version": [
|
|
||||||
1,
|
|
||||||
0
|
|
||||||
],
|
|
||||||
"smartctl": {
|
|
||||||
"version": [
|
|
||||||
7,
|
|
||||||
5
|
|
||||||
],
|
|
||||||
"pre_release": false,
|
|
||||||
"svn_revision": "5714",
|
|
||||||
"platform_info": "x86_64-linux-6.17.1-2-cachyos",
|
|
||||||
"build_info": "(local build)",
|
|
||||||
"argv": [
|
|
||||||
"smartctl",
|
|
||||||
"-aj",
|
|
||||||
"/dev/nvme0"
|
|
||||||
],
|
|
||||||
"exit_status": 0
|
|
||||||
},
|
|
||||||
"local_time": {
|
|
||||||
"time_t": 1761507494,
|
|
||||||
"asctime": "Sun Oct 26 15:38:14 2025 EDT"
|
|
||||||
},
|
|
||||||
"device": {
|
|
||||||
"name": "/dev/nvme0",
|
|
||||||
"info_name": "/dev/nvme0",
|
|
||||||
"type": "nvme",
|
|
||||||
"protocol": "NVMe"
|
|
||||||
},
|
|
||||||
"model_name": "PELADN 512GB",
|
|
||||||
"serial_number": "2024031600129",
|
|
||||||
"firmware_version": "VC2S038E",
|
|
||||||
"nvme_pci_vendor": {
|
|
||||||
"id": 4332,
|
|
||||||
"subsystem_id": 4332
|
|
||||||
},
|
|
||||||
"nvme_ieee_oui_identifier": 57420,
|
|
||||||
"nvme_controller_id": 1,
|
|
||||||
"nvme_version": {
|
|
||||||
"string": "1.4",
|
|
||||||
"value": 66560
|
|
||||||
},
|
|
||||||
"nvme_number_of_namespaces": 1,
|
|
||||||
"nvme_namespaces": [
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"size": {
|
|
||||||
"blocks": 1000215216,
|
|
||||||
"bytes": 512110190592
|
|
||||||
},
|
|
||||||
"capacity": {
|
|
||||||
"blocks": 1000215216,
|
|
||||||
"bytes": 512110190592
|
|
||||||
},
|
|
||||||
"utilization": {
|
|
||||||
"blocks": 1000215216,
|
|
||||||
"bytes": 512110190592
|
|
||||||
},
|
|
||||||
"formatted_lba_size": 512,
|
|
||||||
"eui64": {
|
|
||||||
"oui": 57420,
|
|
||||||
"ext_id": 112094110470
|
|
||||||
},
|
|
||||||
"features": {
|
|
||||||
"value": 0,
|
|
||||||
"thin_provisioning": false,
|
|
||||||
"na_fields": false,
|
|
||||||
"dealloc_or_unwritten_block_error": false,
|
|
||||||
"uid_reuse": false,
|
|
||||||
"np_fields": false,
|
|
||||||
"other": 0
|
|
||||||
},
|
|
||||||
"lba_formats": [
|
|
||||||
{
|
|
||||||
"formatted": true,
|
|
||||||
"data_bytes": 512,
|
|
||||||
"metadata_bytes": 0,
|
|
||||||
"relative_performance": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"user_capacity": {
|
|
||||||
"blocks": 1000215216,
|
|
||||||
"bytes": 512110190592
|
|
||||||
},
|
|
||||||
"logical_block_size": 512,
|
|
||||||
"smart_support": {
|
|
||||||
"available": true,
|
|
||||||
"enabled": true
|
|
||||||
},
|
|
||||||
"nvme_firmware_update_capabilities": {
|
|
||||||
"value": 2,
|
|
||||||
"slots": 1,
|
|
||||||
"first_slot_is_read_only": false,
|
|
||||||
"activiation_without_reset": false,
|
|
||||||
"multiple_update_detection": false,
|
|
||||||
"other": 0
|
|
||||||
},
|
|
||||||
"nvme_optional_admin_commands": {
|
|
||||||
"value": 23,
|
|
||||||
"security_send_receive": true,
|
|
||||||
"format_nvm": true,
|
|
||||||
"firmware_download": true,
|
|
||||||
"namespace_management": false,
|
|
||||||
"self_test": true,
|
|
||||||
"directives": false,
|
|
||||||
"mi_send_receive": false,
|
|
||||||
"virtualization_management": false,
|
|
||||||
"doorbell_buffer_config": false,
|
|
||||||
"get_lba_status": false,
|
|
||||||
"command_and_feature_lockdown": false,
|
|
||||||
"other": 0
|
|
||||||
},
|
|
||||||
"nvme_optional_nvm_commands": {
|
|
||||||
"value": 94,
|
|
||||||
"compare": false,
|
|
||||||
"write_uncorrectable": true,
|
|
||||||
"dataset_management": true,
|
|
||||||
"write_zeroes": true,
|
|
||||||
"save_select_feature_nonzero": true,
|
|
||||||
"reservations": false,
|
|
||||||
"timestamp": true,
|
|
||||||
"verify": false,
|
|
||||||
"copy": false,
|
|
||||||
"other": 0
|
|
||||||
},
|
|
||||||
"nvme_log_page_attributes": {
|
|
||||||
"value": 2,
|
|
||||||
"smart_health_per_namespace": false,
|
|
||||||
"commands_effects_log": true,
|
|
||||||
"extended_get_log_page_cmd": false,
|
|
||||||
"telemetry_log": false,
|
|
||||||
"persistent_event_log": false,
|
|
||||||
"supported_log_pages_log": false,
|
|
||||||
"telemetry_data_area_4": false,
|
|
||||||
"other": 0
|
|
||||||
},
|
|
||||||
"nvme_maximum_data_transfer_pages": 32,
|
|
||||||
"nvme_composite_temperature_threshold": {
|
|
||||||
"warning": 100,
|
|
||||||
"critical": 110
|
|
||||||
},
|
|
||||||
"temperature": {
|
|
||||||
"op_limit_max": 100,
|
|
||||||
"critical_limit_max": 110,
|
|
||||||
"current": 61
|
|
||||||
},
|
|
||||||
"nvme_power_states": [
|
|
||||||
{
|
|
||||||
"non_operational_state": false,
|
|
||||||
"relative_read_latency": 0,
|
|
||||||
"relative_read_throughput": 0,
|
|
||||||
"relative_write_latency": 0,
|
|
||||||
"relative_write_throughput": 0,
|
|
||||||
"entry_latency_us": 230000,
|
|
||||||
"exit_latency_us": 50000,
|
|
||||||
"max_power": {
|
|
||||||
"value": 800,
|
|
||||||
"scale": 2,
|
|
||||||
"units_per_watt": 100
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"non_operational_state": false,
|
|
||||||
"relative_read_latency": 1,
|
|
||||||
"relative_read_throughput": 1,
|
|
||||||
"relative_write_latency": 1,
|
|
||||||
"relative_write_throughput": 1,
|
|
||||||
"entry_latency_us": 4000,
|
|
||||||
"exit_latency_us": 50000,
|
|
||||||
"max_power": {
|
|
||||||
"value": 400,
|
|
||||||
"scale": 2,
|
|
||||||
"units_per_watt": 100
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"non_operational_state": false,
|
|
||||||
"relative_read_latency": 2,
|
|
||||||
"relative_read_throughput": 2,
|
|
||||||
"relative_write_latency": 2,
|
|
||||||
"relative_write_throughput": 2,
|
|
||||||
"entry_latency_us": 4000,
|
|
||||||
"exit_latency_us": 250000,
|
|
||||||
"max_power": {
|
|
||||||
"value": 300,
|
|
||||||
"scale": 2,
|
|
||||||
"units_per_watt": 100
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"non_operational_state": true,
|
|
||||||
"relative_read_latency": 3,
|
|
||||||
"relative_read_throughput": 3,
|
|
||||||
"relative_write_latency": 3,
|
|
||||||
"relative_write_throughput": 3,
|
|
||||||
"entry_latency_us": 5000,
|
|
||||||
"exit_latency_us": 10000,
|
|
||||||
"max_power": {
|
|
||||||
"value": 300,
|
|
||||||
"scale": 1,
|
|
||||||
"units_per_watt": 10000
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"non_operational_state": true,
|
|
||||||
"relative_read_latency": 4,
|
|
||||||
"relative_read_throughput": 4,
|
|
||||||
"relative_write_latency": 4,
|
|
||||||
"relative_write_throughput": 4,
|
|
||||||
"entry_latency_us": 54000,
|
|
||||||
"exit_latency_us": 45000,
|
|
||||||
"max_power": {
|
|
||||||
"value": 50,
|
|
||||||
"scale": 1,
|
|
||||||
"units_per_watt": 10000
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"smart_status": {
|
|
||||||
"passed": true,
|
|
||||||
"nvme": {
|
|
||||||
"value": 0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"nvme_smart_health_information_log": {
|
|
||||||
"nsid": -1,
|
|
||||||
"critical_warning": 0,
|
|
||||||
"temperature": 61,
|
|
||||||
"available_spare": 100,
|
|
||||||
"available_spare_threshold": 32,
|
|
||||||
"percentage_used": 0,
|
|
||||||
"data_units_read": 6573104,
|
|
||||||
"data_units_written": 16040567,
|
|
||||||
"host_reads": 63241130,
|
|
||||||
"host_writes": 253050006,
|
|
||||||
"controller_busy_time": 0,
|
|
||||||
"power_cycles": 430,
|
|
||||||
"power_on_hours": 4399,
|
|
||||||
"unsafe_shutdowns": 44,
|
|
||||||
"media_errors": 0,
|
|
||||||
"num_err_log_entries": 0,
|
|
||||||
"warning_temp_time": 0,
|
|
||||||
"critical_comp_time": 0
|
|
||||||
},
|
|
||||||
"spare_available": {
|
|
||||||
"current_percent": 100,
|
|
||||||
"threshold_percent": 32
|
|
||||||
},
|
|
||||||
"endurance_used": {
|
|
||||||
"current_percent": 0
|
|
||||||
},
|
|
||||||
"power_cycle_count": 430,
|
|
||||||
"power_on_time": {
|
|
||||||
"hours": 4399
|
|
||||||
},
|
|
||||||
"nvme_error_information_log": {
|
|
||||||
"size": 8,
|
|
||||||
"read": 8,
|
|
||||||
"unread": 0
|
|
||||||
},
|
|
||||||
"nvme_self_test_log": {
|
|
||||||
"nsid": -1,
|
|
||||||
"current_self_test_operation": {
|
|
||||||
"value": 0,
|
|
||||||
"string": "No self-test in progress"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
{
|
|
||||||
"json_format_version": [
|
|
||||||
1,
|
|
||||||
0
|
|
||||||
],
|
|
||||||
"smartctl": {
|
|
||||||
"version": [
|
|
||||||
7,
|
|
||||||
5
|
|
||||||
],
|
|
||||||
"pre_release": false,
|
|
||||||
"svn_revision": "5714",
|
|
||||||
"platform_info": "x86_64-linux-6.17.1-2-cachyos",
|
|
||||||
"build_info": "(local build)",
|
|
||||||
"argv": [
|
|
||||||
"smartctl",
|
|
||||||
"--scan",
|
|
||||||
"-j"
|
|
||||||
],
|
|
||||||
"exit_status": 0
|
|
||||||
},
|
|
||||||
"devices": [
|
|
||||||
{
|
|
||||||
"name": "/dev/sda",
|
|
||||||
"info_name": "/dev/sda [SAT]",
|
|
||||||
"type": "sat",
|
|
||||||
"protocol": "ATA"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "/dev/nvme0",
|
|
||||||
"info_name": "/dev/nvme0",
|
|
||||||
"type": "nvme",
|
|
||||||
"protocol": "NVMe"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
{
|
|
||||||
"json_format_version": [
|
|
||||||
1,
|
|
||||||
0
|
|
||||||
],
|
|
||||||
"smartctl": {
|
|
||||||
"version": [
|
|
||||||
7,
|
|
||||||
3
|
|
||||||
],
|
|
||||||
"svn_revision": "5338",
|
|
||||||
"platform_info": "x86_64-linux-6.12.43+deb12-amd64",
|
|
||||||
"build_info": "(local build)",
|
|
||||||
"argv": [
|
|
||||||
"smartctl",
|
|
||||||
"-aj",
|
|
||||||
"/dev/sde"
|
|
||||||
],
|
|
||||||
"exit_status": 0
|
|
||||||
},
|
|
||||||
"local_time": {
|
|
||||||
"time_t": 1761502142,
|
|
||||||
"asctime": "Sun Oct 21 21:09:02 2025 MSK"
|
|
||||||
},
|
|
||||||
"device": {
|
|
||||||
"name": "/dev/sde",
|
|
||||||
"info_name": "/dev/sde",
|
|
||||||
"type": "scsi",
|
|
||||||
"protocol": "SCSI"
|
|
||||||
},
|
|
||||||
"scsi_vendor": "YADRO",
|
|
||||||
"scsi_product": "WUH721414AL4204",
|
|
||||||
"scsi_model_name": "YADRO WUH721414AL4204",
|
|
||||||
"scsi_revision": "C240",
|
|
||||||
"scsi_version": "SPC-4",
|
|
||||||
"user_capacity": {
|
|
||||||
"blocks": 3418095616,
|
|
||||||
"bytes": 14000519643136
|
|
||||||
},
|
|
||||||
"logical_block_size": 4096,
|
|
||||||
"scsi_lb_provisioning": {
|
|
||||||
"name": "fully provisioned",
|
|
||||||
"value": 0,
|
|
||||||
"management_enabled": {
|
|
||||||
"name": "LBPME",
|
|
||||||
"value": 0
|
|
||||||
},
|
|
||||||
"read_zeros": {
|
|
||||||
"name": "LBPRZ",
|
|
||||||
"value": 0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"rotation_rate": 7200,
|
|
||||||
"form_factor": {
|
|
||||||
"scsi_value": 2,
|
|
||||||
"name": "3.5 inches"
|
|
||||||
},
|
|
||||||
"logical_unit_id": "0x5000cca29063dc00",
|
|
||||||
"serial_number": "9YHSDH9B",
|
|
||||||
"device_type": {
|
|
||||||
"scsi_terminology": "Peripheral Device Type [PDT]",
|
|
||||||
"scsi_value": 0,
|
|
||||||
"name": "disk"
|
|
||||||
},
|
|
||||||
"scsi_transport_protocol": {
|
|
||||||
"name": "SAS (SPL-4)",
|
|
||||||
"value": 6
|
|
||||||
},
|
|
||||||
"smart_support": {
|
|
||||||
"available": true,
|
|
||||||
"enabled": true
|
|
||||||
},
|
|
||||||
"temperature_warning": {
|
|
||||||
"enabled": true
|
|
||||||
},
|
|
||||||
"smart_status": {
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
"temperature": {
|
|
||||||
"current": 34,
|
|
||||||
"drive_trip": 85
|
|
||||||
},
|
|
||||||
"power_on_time": {
|
|
||||||
"hours": 458,
|
|
||||||
"minutes": 25
|
|
||||||
},
|
|
||||||
"scsi_start_stop_cycle_counter": {
|
|
||||||
"year_of_manufacture": "2022",
|
|
||||||
"week_of_manufacture": "41",
|
|
||||||
"specified_cycle_count_over_device_lifetime": 50000,
|
|
||||||
"accumulated_start_stop_cycles": 2,
|
|
||||||
"specified_load_unload_count_over_device_lifetime": 600000,
|
|
||||||
"accumulated_load_unload_cycles": 418
|
|
||||||
},
|
|
||||||
"scsi_grown_defect_list": 0,
|
|
||||||
"scsi_error_counter_log": {
|
|
||||||
"read": {
|
|
||||||
"errors_corrected_by_eccfast": 0,
|
|
||||||
"errors_corrected_by_eccdelayed": 0,
|
|
||||||
"errors_corrected_by_rereads_rewrites": 0,
|
|
||||||
"total_errors_corrected": 0,
|
|
||||||
"correction_algorithm_invocations": 346,
|
|
||||||
"gigabytes_processed": "3,641",
|
|
||||||
"total_uncorrected_errors": 0
|
|
||||||
},
|
|
||||||
"write": {
|
|
||||||
"errors_corrected_by_eccfast": 0,
|
|
||||||
"errors_corrected_by_eccdelayed": 0,
|
|
||||||
"errors_corrected_by_rereads_rewrites": 0,
|
|
||||||
"total_errors_corrected": 0,
|
|
||||||
"correction_algorithm_invocations": 4052,
|
|
||||||
"gigabytes_processed": "2124,590",
|
|
||||||
"total_uncorrected_errors": 0
|
|
||||||
},
|
|
||||||
"verify": {
|
|
||||||
"errors_corrected_by_eccfast": 0,
|
|
||||||
"errors_corrected_by_eccdelayed": 0,
|
|
||||||
"errors_corrected_by_rereads_rewrites": 0,
|
|
||||||
"total_errors_corrected": 0,
|
|
||||||
"correction_algorithm_invocations": 223,
|
|
||||||
"gigabytes_processed": "0,000",
|
|
||||||
"total_uncorrected_errors": 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,17 +0,0 @@
|
|||||||
{
|
|
||||||
"ID": "7TRN:IPZB:QYBB:VPBQ:UMPP:KARE:6ZNR:XE6T:7EWV:PKF4:ZOJD:TPYS",
|
|
||||||
"Containers": 14,
|
|
||||||
"ContainersRunning": 3,
|
|
||||||
"ContainersPaused": 1,
|
|
||||||
"ContainersStopped": 10,
|
|
||||||
"Images": 508,
|
|
||||||
"Driver": "overlay2",
|
|
||||||
"KernelVersion": "6.8.0-31-generic",
|
|
||||||
"OperatingSystem": "Ubuntu 24.04 LTS",
|
|
||||||
"OSVersion": "24.04",
|
|
||||||
"OSType": "linux",
|
|
||||||
"Architecture": "x86_64",
|
|
||||||
"NCPU": 4,
|
|
||||||
"MemTotal": 2095882240,
|
|
||||||
"ServerVersion": "27.0.1"
|
|
||||||
}
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/sha1"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"flag"
|
|
||||||
"fmt"
|
|
||||||
"hash"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Download smartctl.exe from the given URL and save it to the given destination.
|
|
||||||
// This is used to embed smartctl.exe in the Windows build.
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
url := flag.String("url", "", "URL to download smartctl.exe from (required)")
|
|
||||||
out := flag.String("out", "", "Destination path for smartctl.exe (required)")
|
|
||||||
sha := flag.String("sha", "", "Optional SHA1/SHA256 checksum for integrity validation")
|
|
||||||
force := flag.Bool("force", false, "Force re-download even if destination exists")
|
|
||||||
flag.Parse()
|
|
||||||
|
|
||||||
if *url == "" || *out == "" {
|
|
||||||
fatalf("-url and -out are required")
|
|
||||||
}
|
|
||||||
|
|
||||||
if !*force {
|
|
||||||
if info, err := os.Stat(*out); err == nil && info.Size() > 0 {
|
|
||||||
fmt.Println("smartctl.exe already present, skipping download")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := downloadFile(*url, *out, *sha); err != nil {
|
|
||||||
fatalf("download failed: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func downloadFile(url, dest, shaHex string) error {
|
|
||||||
// Prepare destination
|
|
||||||
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
|
||||||
return fmt.Errorf("create dir: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// HTTP client
|
|
||||||
client := &http.Client{Timeout: 60 * time.Second}
|
|
||||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("new request: %w", err)
|
|
||||||
}
|
|
||||||
req.Header.Set("User-Agent", "beszel-fetchsmartctl/1.0")
|
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("http get: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
||||||
return fmt.Errorf("unexpected HTTP status: %s", resp.Status)
|
|
||||||
}
|
|
||||||
|
|
||||||
tmp := dest + ".tmp"
|
|
||||||
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("open tmp: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine hash algorithm based on length (SHA1=40, SHA256=64)
|
|
||||||
var hasher hash.Hash
|
|
||||||
if shaHex := strings.TrimSpace(shaHex); shaHex != "" {
|
|
||||||
cleanSha := strings.ToLower(strings.ReplaceAll(shaHex, " ", ""))
|
|
||||||
switch len(cleanSha) {
|
|
||||||
case 40:
|
|
||||||
hasher = sha1.New()
|
|
||||||
case 64:
|
|
||||||
hasher = sha256.New()
|
|
||||||
default:
|
|
||||||
f.Close()
|
|
||||||
os.Remove(tmp)
|
|
||||||
return fmt.Errorf("unsupported hash length: %d (expected 40 for SHA1 or 64 for SHA256)", len(cleanSha))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var mw io.Writer = f
|
|
||||||
if hasher != nil {
|
|
||||||
mw = io.MultiWriter(f, hasher)
|
|
||||||
}
|
|
||||||
if _, err := io.Copy(mw, resp.Body); err != nil {
|
|
||||||
f.Close()
|
|
||||||
os.Remove(tmp)
|
|
||||||
return fmt.Errorf("write tmp: %w", err)
|
|
||||||
}
|
|
||||||
if err := f.Close(); err != nil {
|
|
||||||
os.Remove(tmp)
|
|
||||||
return fmt.Errorf("close tmp: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if hasher != nil && shaHex != "" {
|
|
||||||
cleanSha := strings.ToLower(strings.ReplaceAll(strings.TrimSpace(shaHex), " ", ""))
|
|
||||||
got := strings.ToLower(hex.EncodeToString(hasher.Sum(nil)))
|
|
||||||
if got != cleanSha {
|
|
||||||
os.Remove(tmp)
|
|
||||||
return fmt.Errorf("hash mismatch: got %s want %s", got, cleanSha)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Make executable and move into place
|
|
||||||
if err := os.Chmod(tmp, 0o755); err != nil {
|
|
||||||
os.Remove(tmp)
|
|
||||||
return fmt.Errorf("chmod: %w", err)
|
|
||||||
}
|
|
||||||
if err := os.Rename(tmp, dest); err != nil {
|
|
||||||
os.Remove(tmp)
|
|
||||||
return fmt.Errorf("rename: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("smartctl.exe downloaded to", dest)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func fatalf(format string, a ...any) {
|
|
||||||
fmt.Fprintf(os.Stderr, format+"\n", a...)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
168
agent/update.go
168
agent/update.go
@@ -1,168 +0,0 @@
|
|||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"runtime"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/ghupdate"
|
|
||||||
)
|
|
||||||
|
|
||||||
// restarter knows how to restart the beszel-agent service.
|
|
||||||
type restarter interface {
|
|
||||||
Restart() error
|
|
||||||
}
|
|
||||||
|
|
||||||
type systemdRestarter struct{ cmd string }
|
|
||||||
|
|
||||||
func (s *systemdRestarter) Restart() error {
|
|
||||||
// Only restart if the service is active
|
|
||||||
if err := exec.Command(s.cmd, "is-active", "beszel-agent.service").Run(); err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
ghupdate.ColorPrint(ghupdate.ColorYellow, "Restarting beszel-agent.service via systemd…")
|
|
||||||
return exec.Command(s.cmd, "restart", "beszel-agent.service").Run()
|
|
||||||
}
|
|
||||||
|
|
||||||
type openRCRestarter struct{ cmd string }
|
|
||||||
|
|
||||||
func (o *openRCRestarter) Restart() error {
|
|
||||||
if err := exec.Command(o.cmd, "beszel-agent", "status").Run(); err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
ghupdate.ColorPrint(ghupdate.ColorYellow, "Restarting beszel-agent via OpenRC…")
|
|
||||||
return exec.Command(o.cmd, "beszel-agent", "restart").Run()
|
|
||||||
}
|
|
||||||
|
|
||||||
type openWRTRestarter struct{ cmd string }
|
|
||||||
|
|
||||||
func (w *openWRTRestarter) Restart() error {
|
|
||||||
// https://openwrt.org/docs/guide-user/base-system/managing_services?s[]=service
|
|
||||||
if err := exec.Command("/etc/init.d/beszel-agent", "running").Run(); err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
ghupdate.ColorPrint(ghupdate.ColorYellow, "Restarting beszel-agent via procd…")
|
|
||||||
return exec.Command("/etc/init.d/beszel-agent", "restart").Run()
|
|
||||||
}
|
|
||||||
|
|
||||||
type freeBSDRestarter struct{ cmd string }
|
|
||||||
|
|
||||||
func (f *freeBSDRestarter) Restart() error {
|
|
||||||
if err := exec.Command(f.cmd, "beszel-agent", "status").Run(); err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
ghupdate.ColorPrint(ghupdate.ColorYellow, "Restarting beszel-agent via FreeBSD rc…")
|
|
||||||
return exec.Command(f.cmd, "beszel-agent", "restart").Run()
|
|
||||||
}
|
|
||||||
|
|
||||||
func detectRestarter() restarter {
|
|
||||||
if path, err := exec.LookPath("systemctl"); err == nil {
|
|
||||||
return &systemdRestarter{cmd: path}
|
|
||||||
}
|
|
||||||
if path, err := exec.LookPath("rc-service"); err == nil {
|
|
||||||
return &openRCRestarter{cmd: path}
|
|
||||||
}
|
|
||||||
if path, err := exec.LookPath("procd"); err == nil {
|
|
||||||
return &openWRTRestarter{cmd: path}
|
|
||||||
}
|
|
||||||
if path, err := exec.LookPath("service"); err == nil {
|
|
||||||
if runtime.GOOS == "freebsd" {
|
|
||||||
return &freeBSDRestarter{cmd: path}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update checks GitHub for a newer release of beszel-agent, applies it,
|
|
||||||
// fixes SELinux context if needed, and restarts the service.
|
|
||||||
func Update(useMirror bool) error {
|
|
||||||
exePath, _ := os.Executable()
|
|
||||||
|
|
||||||
dataDir, err := getDataDir()
|
|
||||||
if err != nil {
|
|
||||||
dataDir = os.TempDir()
|
|
||||||
}
|
|
||||||
updated, err := ghupdate.Update(ghupdate.Config{
|
|
||||||
ArchiveExecutable: "beszel-agent",
|
|
||||||
DataDir: dataDir,
|
|
||||||
UseMirror: useMirror,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
if !updated {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// make sure the file is executable
|
|
||||||
if err := os.Chmod(exePath, 0755); err != nil {
|
|
||||||
ghupdate.ColorPrintf(ghupdate.ColorYellow, "Warning: failed to set executable permissions: %v", err)
|
|
||||||
}
|
|
||||||
// set ownership to beszel:beszel if possible
|
|
||||||
if chownPath, err := exec.LookPath("chown"); err == nil {
|
|
||||||
if err := exec.Command(chownPath, "beszel:beszel", exePath).Run(); err != nil {
|
|
||||||
ghupdate.ColorPrintf(ghupdate.ColorYellow, "Warning: failed to set file ownership: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6) Fix SELinux context if necessary
|
|
||||||
if err := handleSELinuxContext(exePath); err != nil {
|
|
||||||
ghupdate.ColorPrintf(ghupdate.ColorYellow, "Warning: SELinux context handling: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 7) Restart service if running under a recognised init system
|
|
||||||
if r := detectRestarter(); r != nil {
|
|
||||||
if err := r.Restart(); err != nil {
|
|
||||||
ghupdate.ColorPrintf(ghupdate.ColorYellow, "Warning: failed to restart service: %v", err)
|
|
||||||
ghupdate.ColorPrint(ghupdate.ColorYellow, "Please restart the service manually.")
|
|
||||||
} else {
|
|
||||||
ghupdate.ColorPrint(ghupdate.ColorGreen, "Service restarted successfully")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
ghupdate.ColorPrint(ghupdate.ColorYellow, "No supported init system detected; please restart manually if needed.")
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleSELinuxContext restores or applies the correct SELinux label to the binary.
|
|
||||||
func handleSELinuxContext(path string) error {
|
|
||||||
out, err := exec.Command("getenforce").Output()
|
|
||||||
if err != nil {
|
|
||||||
// SELinux not enabled or getenforce not available
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
state := strings.TrimSpace(string(out))
|
|
||||||
if state == "Disabled" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
ghupdate.ColorPrint(ghupdate.ColorYellow, "SELinux is enabled; applying context…")
|
|
||||||
var errs []string
|
|
||||||
|
|
||||||
// Try persistent context via semanage+restorecon
|
|
||||||
if semanagePath, err := exec.LookPath("semanage"); err == nil {
|
|
||||||
if err := exec.Command(semanagePath, "fcontext", "-a", "-t", "bin_t", path).Run(); err != nil {
|
|
||||||
errs = append(errs, "semanage fcontext failed: "+err.Error())
|
|
||||||
} else if restoreconPath, err := exec.LookPath("restorecon"); err == nil {
|
|
||||||
if err := exec.Command(restoreconPath, "-v", path).Run(); err != nil {
|
|
||||||
errs = append(errs, "restorecon failed: "+err.Error())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to temporary context via chcon
|
|
||||||
if chconPath, err := exec.LookPath("chcon"); err == nil {
|
|
||||||
if err := exec.Command(chconPath, "-t", "bin_t", path).Run(); err != nil {
|
|
||||||
errs = append(errs, "chcon failed: "+err.Error())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(errs) > 0 {
|
|
||||||
return fmt.Errorf("SELinux context errors: %s", strings.Join(errs, "; "))
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
18
beszel.go
18
beszel.go
@@ -1,18 +0,0 @@
|
|||||||
// Package beszel provides core application constants and version information
|
|
||||||
// which are used throughout the application.
|
|
||||||
package beszel
|
|
||||||
|
|
||||||
import "github.com/blang/semver"
|
|
||||||
|
|
||||||
const (
|
|
||||||
// Version is the current version of the application.
|
|
||||||
Version = "0.18.0"
|
|
||||||
// AppName is the name of the application.
|
|
||||||
AppName = "beszel"
|
|
||||||
)
|
|
||||||
|
|
||||||
// MinVersionCbor is the minimum supported version for CBOR compatibility.
|
|
||||||
var MinVersionCbor = semver.MustParse("0.12.0")
|
|
||||||
|
|
||||||
// MinVersionAgentResponse is the minimum supported version for AgentResponse compatibility.
|
|
||||||
var MinVersionAgentResponse = semver.MustParse("0.13.0")
|
|
||||||
@@ -5,36 +5,24 @@ project_name: beszel
|
|||||||
before:
|
before:
|
||||||
hooks:
|
hooks:
|
||||||
- go mod tidy
|
- go mod tidy
|
||||||
- go generate -run fetchsmartctl ./agent
|
|
||||||
|
|
||||||
builds:
|
builds:
|
||||||
- id: beszel
|
- id: beszel
|
||||||
binary: beszel
|
binary: beszel
|
||||||
main: internal/cmd/hub/hub.go
|
main: cmd/hub/hub.go
|
||||||
env:
|
env:
|
||||||
- CGO_ENABLED=0
|
- CGO_ENABLED=0
|
||||||
goos:
|
goos:
|
||||||
- linux
|
- linux
|
||||||
- darwin
|
- darwin
|
||||||
- windows
|
|
||||||
- freebsd
|
|
||||||
goarch:
|
goarch:
|
||||||
- amd64
|
- amd64
|
||||||
- arm64
|
- arm64
|
||||||
- arm
|
- arm
|
||||||
ignore:
|
|
||||||
- goos: windows
|
|
||||||
goarch: arm64
|
|
||||||
- goos: windows
|
|
||||||
goarch: arm
|
|
||||||
- goos: freebsd
|
|
||||||
goarch: arm64
|
|
||||||
- goos: freebsd
|
|
||||||
goarch: arm
|
|
||||||
|
|
||||||
- id: beszel-agent
|
- id: beszel-agent
|
||||||
binary: beszel-agent
|
binary: beszel-agent
|
||||||
main: internal/cmd/agent/agent.go
|
main: cmd/agent/agent.go
|
||||||
env:
|
env:
|
||||||
- CGO_ENABLED=0
|
- CGO_ENABLED=0
|
||||||
goos:
|
goos:
|
||||||
@@ -50,25 +38,12 @@ builds:
|
|||||||
- mips64
|
- mips64
|
||||||
- riscv64
|
- riscv64
|
||||||
- mipsle
|
- mipsle
|
||||||
- mips
|
|
||||||
- ppc64le
|
- ppc64le
|
||||||
gomips:
|
|
||||||
- hardfloat
|
|
||||||
- softfloat
|
|
||||||
ignore:
|
ignore:
|
||||||
- goos: freebsd
|
- goos: freebsd
|
||||||
goarch: arm
|
goarch: arm
|
||||||
- goos: openbsd
|
- goos: openbsd
|
||||||
goarch: arm
|
goarch: arm
|
||||||
- goos: linux
|
|
||||||
goarch: mips64
|
|
||||||
gomips: softfloat
|
|
||||||
- goos: linux
|
|
||||||
goarch: mipsle
|
|
||||||
gomips: hardfloat
|
|
||||||
- goos: linux
|
|
||||||
goarch: mips
|
|
||||||
gomips: hardfloat
|
|
||||||
- goos: windows
|
- goos: windows
|
||||||
goarch: arm
|
goarch: arm
|
||||||
- goos: darwin
|
- goos: darwin
|
||||||
@@ -79,7 +54,7 @@ builds:
|
|||||||
archives:
|
archives:
|
||||||
- id: beszel-agent
|
- id: beszel-agent
|
||||||
formats: [tar.gz]
|
formats: [tar.gz]
|
||||||
ids:
|
builds:
|
||||||
- beszel-agent
|
- beszel-agent
|
||||||
name_template: >-
|
name_template: >-
|
||||||
{{ .Binary }}_
|
{{ .Binary }}_
|
||||||
@@ -91,15 +66,12 @@ archives:
|
|||||||
|
|
||||||
- id: beszel
|
- id: beszel
|
||||||
formats: [tar.gz]
|
formats: [tar.gz]
|
||||||
ids:
|
builds:
|
||||||
- beszel
|
- beszel
|
||||||
name_template: >-
|
name_template: >-
|
||||||
{{ .Binary }}_
|
{{ .Binary }}_
|
||||||
{{- .Os }}_
|
{{- .Os }}_
|
||||||
{{- .Arch }}
|
{{- .Arch }}
|
||||||
format_overrides:
|
|
||||||
- goos: windows
|
|
||||||
formats: [zip]
|
|
||||||
|
|
||||||
nfpms:
|
nfpms:
|
||||||
- id: beszel-agent
|
- id: beszel-agent
|
||||||
@@ -113,33 +85,33 @@ nfpms:
|
|||||||
API access.
|
API access.
|
||||||
maintainer: henrygd <hank@henrygd.me>
|
maintainer: henrygd <hank@henrygd.me>
|
||||||
section: net
|
section: net
|
||||||
ids:
|
builds:
|
||||||
- beszel-agent
|
- beszel-agent
|
||||||
formats:
|
formats:
|
||||||
- deb
|
- deb
|
||||||
contents:
|
contents:
|
||||||
- src: ./supplemental/debian/beszel-agent.service
|
- src: ../supplemental/debian/beszel-agent.service
|
||||||
dst: lib/systemd/system/beszel-agent.service
|
dst: lib/systemd/system/beszel-agent.service
|
||||||
packager: deb
|
packager: deb
|
||||||
- src: ./supplemental/debian/copyright
|
- src: ../supplemental/debian/copyright
|
||||||
dst: usr/share/doc/beszel-agent/copyright
|
dst: usr/share/doc/beszel-agent/copyright
|
||||||
packager: deb
|
packager: deb
|
||||||
- src: ./supplemental/debian/lintian-overrides
|
- src: ../supplemental/debian/lintian-overrides
|
||||||
dst: usr/share/lintian/overrides/beszel-agent
|
dst: usr/share/lintian/overrides/beszel-agent
|
||||||
packager: deb
|
packager: deb
|
||||||
scripts:
|
scripts:
|
||||||
postinstall: ./supplemental/debian/postinstall.sh
|
postinstall: ../supplemental/debian/postinstall.sh
|
||||||
preremove: ./supplemental/debian/prerm.sh
|
preremove: ../supplemental/debian/prerm.sh
|
||||||
postremove: ./supplemental/debian/postrm.sh
|
postremove: ../supplemental/debian/postrm.sh
|
||||||
deb:
|
deb:
|
||||||
predepends:
|
predepends:
|
||||||
- adduser
|
- adduser
|
||||||
- debconf
|
- debconf
|
||||||
scripts:
|
scripts:
|
||||||
templates: ./supplemental/debian/templates
|
templates: ../supplemental/debian/templates
|
||||||
# Currently broken due to a bug in goreleaser
|
# Currently broken due to a bug in goreleaser
|
||||||
# https://github.com/goreleaser/goreleaser/issues/5487
|
# https://github.com/goreleaser/goreleaser/issues/5487
|
||||||
#config: ./supplemental/debian/config.sh
|
#config: ../supplemental/debian/config.sh
|
||||||
|
|
||||||
scoops:
|
scoops:
|
||||||
- ids: [beszel-agent]
|
- ids: [beszel-agent]
|
||||||
@@ -150,7 +122,6 @@ scoops:
|
|||||||
homepage: "https://beszel.dev"
|
homepage: "https://beszel.dev"
|
||||||
description: "Agent for Beszel, a lightweight server monitoring platform."
|
description: "Agent for Beszel, a lightweight server monitoring platform."
|
||||||
license: MIT
|
license: MIT
|
||||||
skip_upload: '{{ if eq (tolower .Env.IS_FORK) "true" }}true{{ else }}auto{{ end }}'
|
|
||||||
|
|
||||||
# # Needs choco installed, so doesn't build on linux / default gh workflow :(
|
# # Needs choco installed, so doesn't build on linux / default gh workflow :(
|
||||||
# chocolateys:
|
# chocolateys:
|
||||||
@@ -184,7 +155,7 @@ brews:
|
|||||||
homepage: "https://beszel.dev"
|
homepage: "https://beszel.dev"
|
||||||
description: "Agent for Beszel, a lightweight server monitoring platform."
|
description: "Agent for Beszel, a lightweight server monitoring platform."
|
||||||
license: MIT
|
license: MIT
|
||||||
skip_upload: '{{ if eq (tolower .Env.IS_FORK) "true" }}true{{ else }}auto{{ end }}'
|
skip_upload: auto
|
||||||
extra_install: |
|
extra_install: |
|
||||||
(bin/"beszel-agent-launcher").write <<~EOS
|
(bin/"beszel-agent-launcher").write <<~EOS
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
@@ -216,7 +187,7 @@ winget:
|
|||||||
release_notes_url: "https://github.com/henrygd/beszel/releases/tag/v{{ .Version }}"
|
release_notes_url: "https://github.com/henrygd/beszel/releases/tag/v{{ .Version }}"
|
||||||
publisher_support_url: "https://github.com/henrygd/beszel/issues"
|
publisher_support_url: "https://github.com/henrygd/beszel/issues"
|
||||||
short_description: "Agent for Beszel, a lightweight server monitoring platform."
|
short_description: "Agent for Beszel, a lightweight server monitoring platform."
|
||||||
skip_upload: '{{ if eq (tolower .Env.IS_FORK) "true" }}true{{ else }}auto{{ end }}'
|
skip_upload: auto
|
||||||
description: |
|
description: |
|
||||||
Beszel is a lightweight server monitoring platform that includes Docker
|
Beszel is a lightweight server monitoring platform that includes Docker
|
||||||
statistics, historical data, and alert functions. It has a friendly web
|
statistics, historical data, and alert functions. It has a friendly web
|
||||||
100
beszel/Makefile
Normal file
100
beszel/Makefile
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
# Default OS/ARCH values
|
||||||
|
OS ?= $(shell go env GOOS)
|
||||||
|
ARCH ?= $(shell go env GOARCH)
|
||||||
|
# Skip building the web UI if true
|
||||||
|
SKIP_WEB ?= false
|
||||||
|
|
||||||
|
# Set executable extension based on target OS
|
||||||
|
EXE_EXT := $(if $(filter windows,$(OS)),.exe,)
|
||||||
|
|
||||||
|
.PHONY: tidy build-agent build-hub build clean lint dev-server dev-agent dev-hub dev generate-locales
|
||||||
|
.DEFAULT_GOAL := build
|
||||||
|
|
||||||
|
clean:
|
||||||
|
go clean
|
||||||
|
rm -rf ./build
|
||||||
|
|
||||||
|
lint:
|
||||||
|
golangci-lint run
|
||||||
|
|
||||||
|
test: export GOEXPERIMENT=synctest,jsonv2
|
||||||
|
test:
|
||||||
|
go test -tags=testing ./...
|
||||||
|
|
||||||
|
tidy:
|
||||||
|
go mod tidy
|
||||||
|
|
||||||
|
build-web-ui:
|
||||||
|
@if command -v bun >/dev/null 2>&1; then \
|
||||||
|
bun install --cwd ./site && \
|
||||||
|
bun run --cwd ./site build; \
|
||||||
|
else \
|
||||||
|
npm install --prefix ./site && \
|
||||||
|
npm run --prefix ./site build; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Conditional .NET build - only for Windows
|
||||||
|
build-dotnet-conditional:
|
||||||
|
@if [ "$(OS)" = "windows" ]; then \
|
||||||
|
echo "Building .NET executable for Windows..."; \
|
||||||
|
if command -v dotnet >/dev/null 2>&1; then \
|
||||||
|
rm -rf ./internal/agent/lhm/bin; \
|
||||||
|
dotnet build -c Release ./internal/agent/lhm/beszel_lhm.csproj; \
|
||||||
|
else \
|
||||||
|
echo "Error: dotnet not found. Install .NET SDK to build Windows agent."; \
|
||||||
|
exit 1; \
|
||||||
|
fi; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Update build-agent to include conditional .NET build
|
||||||
|
build-agent: tidy build-dotnet-conditional
|
||||||
|
GOOS=$(OS) GOARCH=$(ARCH) go build -o ./build/beszel-agent_$(OS)_$(ARCH)$(EXE_EXT) -ldflags "-w -s" beszel/cmd/agent
|
||||||
|
|
||||||
|
build-hub: tidy $(if $(filter false,$(SKIP_WEB)),build-web-ui)
|
||||||
|
GOOS=$(OS) GOARCH=$(ARCH) go build -o ./build/beszel_$(OS)_$(ARCH)$(EXE_EXT) -ldflags "-w -s" beszel/cmd/hub
|
||||||
|
|
||||||
|
build: build-agent build-hub
|
||||||
|
|
||||||
|
generate-locales:
|
||||||
|
@if [ ! -f ./site/src/locales/en/en.ts ]; then \
|
||||||
|
echo "Generating locales..."; \
|
||||||
|
command -v bun >/dev/null 2>&1 && cd ./site && bun install && bun run sync || cd ./site && npm install && npm run sync; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
dev-server: generate-locales
|
||||||
|
cd ./site
|
||||||
|
@if command -v bun >/dev/null 2>&1; then \
|
||||||
|
cd ./site && bun run dev --host 0.0.0.0; \
|
||||||
|
else \
|
||||||
|
cd ./site && npm run dev --host 0.0.0.0; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
dev-hub: export ENV=dev
|
||||||
|
dev-hub: export GOEXPERIMENT=jsonv2
|
||||||
|
dev-hub:
|
||||||
|
mkdir -p ./site/dist && touch ./site/dist/index.html
|
||||||
|
@if command -v entr >/dev/null 2>&1; then \
|
||||||
|
find ./cmd/hub/*.go ./internal/{alerts,hub,records,users}/*.go | entr -r -s "cd ./cmd/hub && go run . serve --http 0.0.0.0:8090"; \
|
||||||
|
else \
|
||||||
|
cd ./cmd/hub && go run . serve --http 0.0.0.0:8090; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
dev-agent: export GOEXPERIMENT=jsonv2
|
||||||
|
dev-agent:
|
||||||
|
@if command -v entr >/dev/null 2>&1; then \
|
||||||
|
find ./cmd/agent/*.go ./internal/agent/*.go | entr -r go run beszel/cmd/agent; \
|
||||||
|
else \
|
||||||
|
go run beszel/cmd/agent; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
build-dotnet:
|
||||||
|
@if command -v dotnet >/dev/null 2>&1; then \
|
||||||
|
rm -rf ./internal/agent/lhm/bin; \
|
||||||
|
dotnet build -c Release ./internal/agent/lhm/beszel_lhm.csproj; \
|
||||||
|
else \
|
||||||
|
echo "dotnet not found"; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# KEY="..." make -j dev
|
||||||
|
dev: dev-server dev-hub dev-agent
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"beszel"
|
||||||
|
"beszel/internal/agent"
|
||||||
|
"beszel/internal/agent/health"
|
||||||
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/henrygd/beszel"
|
|
||||||
"github.com/henrygd/beszel/agent"
|
|
||||||
"github.com/henrygd/beszel/agent/health"
|
|
||||||
"github.com/spf13/pflag"
|
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,23 +17,43 @@ import (
|
|||||||
type cmdOptions struct {
|
type cmdOptions struct {
|
||||||
key string // key is the public key(s) for SSH authentication.
|
key string // key is the public key(s) for SSH authentication.
|
||||||
listen string // listen is the address or port to listen on.
|
listen string // listen is the address or port to listen on.
|
||||||
hubURL string // hubURL is the URL of the Beszel hub.
|
|
||||||
token string // token is the token to use for authentication.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// parse parses the command line flags and populates the config struct.
|
// parse parses the command line flags and populates the config struct.
|
||||||
// It returns true if a subcommand was handled and the program should exit.
|
// It returns true if a subcommand was handled and the program should exit.
|
||||||
func (opts *cmdOptions) parse() bool {
|
func (opts *cmdOptions) parse() bool {
|
||||||
|
flag.StringVar(&opts.key, "key", "", "Public key(s) for SSH authentication")
|
||||||
|
flag.StringVar(&opts.listen, "listen", "", "Address or port to listen on")
|
||||||
|
|
||||||
|
flag.Usage = func() {
|
||||||
|
builder := strings.Builder{}
|
||||||
|
builder.WriteString("Usage: ")
|
||||||
|
builder.WriteString(os.Args[0])
|
||||||
|
builder.WriteString(" [command] [flags]\n")
|
||||||
|
builder.WriteString("\nCommands:\n")
|
||||||
|
builder.WriteString(" health Check if the agent is running\n")
|
||||||
|
builder.WriteString(" help Display this help message\n")
|
||||||
|
builder.WriteString(" update Update to the latest version\n")
|
||||||
|
builder.WriteString("\nFlags:\n")
|
||||||
|
fmt.Print(builder.String())
|
||||||
|
flag.PrintDefaults()
|
||||||
|
}
|
||||||
|
|
||||||
subcommand := ""
|
subcommand := ""
|
||||||
if len(os.Args) > 1 {
|
if len(os.Args) > 1 {
|
||||||
subcommand = os.Args[1]
|
subcommand = os.Args[1]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subcommands that don't require any pflag parsing
|
|
||||||
switch subcommand {
|
switch subcommand {
|
||||||
case "-v", "version":
|
case "-v", "version":
|
||||||
fmt.Println(beszel.AppName+"-agent", beszel.Version)
|
fmt.Println(beszel.AppName+"-agent", beszel.Version)
|
||||||
return true
|
return true
|
||||||
|
case "help":
|
||||||
|
flag.Usage()
|
||||||
|
return true
|
||||||
|
case "update":
|
||||||
|
agent.Update()
|
||||||
|
return true
|
||||||
case "health":
|
case "health":
|
||||||
err := health.Check()
|
err := health.Check()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -43,64 +63,7 @@ func (opts *cmdOptions) parse() bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// pflag.CommandLine.ParseErrorsWhitelist.UnknownFlags = true
|
flag.Parse()
|
||||||
pflag.StringVarP(&opts.key, "key", "k", "", "Public key(s) for SSH authentication")
|
|
||||||
pflag.StringVarP(&opts.listen, "listen", "l", "", "Address or port to listen on")
|
|
||||||
pflag.StringVarP(&opts.hubURL, "url", "u", "", "URL of the Beszel hub")
|
|
||||||
pflag.StringVarP(&opts.token, "token", "t", "", "Token to use for authentication")
|
|
||||||
chinaMirrors := pflag.BoolP("china-mirrors", "c", false, "Use mirror for update (gh.beszel.dev) instead of GitHub")
|
|
||||||
help := pflag.BoolP("help", "h", false, "Show this help message")
|
|
||||||
|
|
||||||
// Convert old single-dash long flags to double-dash for backward compatibility
|
|
||||||
flagsToConvert := []string{"key", "listen", "url", "token"}
|
|
||||||
for i, arg := range os.Args {
|
|
||||||
for _, flag := range flagsToConvert {
|
|
||||||
singleDash := "-" + flag
|
|
||||||
doubleDash := "--" + flag
|
|
||||||
if arg == singleDash {
|
|
||||||
os.Args[i] = doubleDash
|
|
||||||
break
|
|
||||||
} else if strings.HasPrefix(arg, singleDash+"=") {
|
|
||||||
os.Args[i] = doubleDash + arg[len(singleDash):]
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pflag.Usage = func() {
|
|
||||||
builder := strings.Builder{}
|
|
||||||
builder.WriteString("Usage: ")
|
|
||||||
builder.WriteString(os.Args[0])
|
|
||||||
builder.WriteString(" [command] [flags]\n")
|
|
||||||
builder.WriteString("\nCommands:\n")
|
|
||||||
builder.WriteString(" health Check if the agent is running\n")
|
|
||||||
// builder.WriteString(" help Display this help message\n")
|
|
||||||
builder.WriteString(" update Update to the latest version\n")
|
|
||||||
builder.WriteString("\nFlags:\n")
|
|
||||||
fmt.Print(builder.String())
|
|
||||||
pflag.PrintDefaults()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse all arguments with pflag
|
|
||||||
pflag.Parse()
|
|
||||||
|
|
||||||
// Must run after pflag.Parse()
|
|
||||||
switch {
|
|
||||||
case *help || subcommand == "help":
|
|
||||||
pflag.Usage()
|
|
||||||
return true
|
|
||||||
case subcommand == "update":
|
|
||||||
agent.Update(*chinaMirrors)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set environment variables from CLI flags (if provided)
|
|
||||||
if opts.hubURL != "" {
|
|
||||||
os.Setenv("HUB_URL", opts.hubURL)
|
|
||||||
}
|
|
||||||
if opts.token != "" {
|
|
||||||
os.Setenv("TOKEN", opts.token)
|
|
||||||
}
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,14 +1,13 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"beszel/internal/agent"
|
||||||
"crypto/ed25519"
|
"crypto/ed25519"
|
||||||
|
"flag"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/agent"
|
|
||||||
|
|
||||||
"github.com/spf13/pflag"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
@@ -246,7 +245,7 @@ func TestParseFlags(t *testing.T) {
|
|||||||
oldArgs := os.Args
|
oldArgs := os.Args
|
||||||
defer func() {
|
defer func() {
|
||||||
os.Args = oldArgs
|
os.Args = oldArgs
|
||||||
pflag.CommandLine = pflag.NewFlagSet(os.Args[0], pflag.ExitOnError)
|
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
@@ -270,22 +269,6 @@ func TestParseFlags(t *testing.T) {
|
|||||||
listen: "",
|
listen: "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "key flag double dash",
|
|
||||||
args: []string{"cmd", "--key", "testkey"},
|
|
||||||
expected: cmdOptions{
|
|
||||||
key: "testkey",
|
|
||||||
listen: "",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "key flag short",
|
|
||||||
args: []string{"cmd", "-k", "testkey"},
|
|
||||||
expected: cmdOptions{
|
|
||||||
key: "testkey",
|
|
||||||
listen: "",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: "addr flag only",
|
name: "addr flag only",
|
||||||
args: []string{"cmd", "-listen", ":8080"},
|
args: []string{"cmd", "-listen", ":8080"},
|
||||||
@@ -294,22 +277,6 @@ func TestParseFlags(t *testing.T) {
|
|||||||
listen: ":8080",
|
listen: ":8080",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "addr flag double dash",
|
|
||||||
args: []string{"cmd", "--listen", ":8080"},
|
|
||||||
expected: cmdOptions{
|
|
||||||
key: "",
|
|
||||||
listen: ":8080",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "addr flag short",
|
|
||||||
args: []string{"cmd", "-l", ":8080"},
|
|
||||||
expected: cmdOptions{
|
|
||||||
key: "",
|
|
||||||
listen: ":8080",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: "both flags",
|
name: "both flags",
|
||||||
args: []string{"cmd", "-key", "testkey", "-listen", ":8080"},
|
args: []string{"cmd", "-key", "testkey", "-listen", ":8080"},
|
||||||
@@ -323,12 +290,12 @@ func TestParseFlags(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 flags for each test
|
// Reset flags for each test
|
||||||
pflag.CommandLine = pflag.NewFlagSet(tt.args[0], pflag.ExitOnError)
|
flag.CommandLine = flag.NewFlagSet(tt.args[0], flag.ExitOnError)
|
||||||
os.Args = tt.args
|
os.Args = tt.args
|
||||||
|
|
||||||
var opts cmdOptions
|
var opts cmdOptions
|
||||||
opts.parse()
|
opts.parse()
|
||||||
pflag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
assert.Equal(t, tt.expected, opts)
|
assert.Equal(t, tt.expected, opts)
|
||||||
})
|
})
|
||||||
@@ -1,16 +1,15 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"beszel"
|
||||||
|
"beszel/internal/hub"
|
||||||
|
_ "beszel/migrations"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel"
|
|
||||||
"github.com/henrygd/beszel/internal/hub"
|
|
||||||
_ "github.com/henrygd/beszel/internal/migrations"
|
|
||||||
|
|
||||||
"github.com/pocketbase/pocketbase"
|
"github.com/pocketbase/pocketbase"
|
||||||
"github.com/pocketbase/pocketbase/plugins/migratecmd"
|
"github.com/pocketbase/pocketbase/plugins/migratecmd"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
@@ -46,13 +45,11 @@ func getBaseApp() *pocketbase.PocketBase {
|
|||||||
baseApp.RootCmd.Use = beszel.AppName
|
baseApp.RootCmd.Use = beszel.AppName
|
||||||
baseApp.RootCmd.Short = ""
|
baseApp.RootCmd.Short = ""
|
||||||
// add update command
|
// add update command
|
||||||
updateCmd := &cobra.Command{
|
baseApp.RootCmd.AddCommand(&cobra.Command{
|
||||||
Use: "update",
|
Use: "update",
|
||||||
Short: "Update " + beszel.AppName + " to the latest version",
|
Short: "Update " + beszel.AppName + " to the latest version",
|
||||||
Run: hub.Update,
|
Run: hub.Update,
|
||||||
}
|
})
|
||||||
updateCmd.Flags().Bool("china-mirrors", false, "Use mirror (gh.beszel.dev) instead of GitHub")
|
|
||||||
baseApp.RootCmd.AddCommand(updateCmd)
|
|
||||||
// add health command
|
// add health command
|
||||||
baseApp.RootCmd.AddCommand(newHealthCmd())
|
baseApp.RootCmd.AddCommand(newHealthCmd())
|
||||||
|
|
||||||
@@ -2,15 +2,15 @@ FROM --platform=$BUILDPLATFORM golang:alpine AS builder
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY ../go.mod ../go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
# RUN go mod download
|
||||||
|
COPY *.go ./
|
||||||
# Copy source files
|
COPY cmd ./cmd
|
||||||
COPY . ./
|
COPY internal ./internal
|
||||||
|
|
||||||
# Build
|
# Build
|
||||||
ARG TARGETOS TARGETARCH
|
ARG TARGETOS TARGETARCH
|
||||||
RUN CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /agent ./internal/cmd/agent
|
RUN CGO_ENABLED=0 GOGC=75 GOEXPERIMENT=jsonv2 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /agent ./cmd/agent
|
||||||
|
|
||||||
RUN rm -rf /tmp/*
|
RUN rm -rf /tmp/*
|
||||||
|
|
||||||
@@ -23,7 +23,4 @@ COPY --from=builder /agent /agent
|
|||||||
# this is so we don't need to create the /tmp directory in the scratch container
|
# this is so we don't need to create the /tmp directory in the scratch container
|
||||||
COPY --from=builder /tmp /tmp
|
COPY --from=builder /tmp /tmp
|
||||||
|
|
||||||
# Ensure data persistence across container recreations
|
|
||||||
VOLUME ["/var/lib/beszel-agent"]
|
|
||||||
|
|
||||||
ENTRYPOINT ["/agent"]
|
ENTRYPOINT ["/agent"]
|
||||||
21
beszel/dockerfile_agent_nvidia
Normal file
21
beszel/dockerfile_agent_nvidia
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
FROM --platform=$BUILDPLATFORM golang:alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
# RUN go mod download
|
||||||
|
COPY *.go ./
|
||||||
|
COPY cmd ./cmd
|
||||||
|
COPY internal ./internal
|
||||||
|
|
||||||
|
# Build
|
||||||
|
ARG TARGETOS TARGETARCH
|
||||||
|
RUN CGO_ENABLED=0 GOGC=75 GOEXPERIMENT=jsonv2 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /agent ./cmd/agent
|
||||||
|
|
||||||
|
# --------------------------
|
||||||
|
# Final image: GPU-enabled agent with nvidia-smi
|
||||||
|
# --------------------------
|
||||||
|
FROM nvidia/cuda:12.9.1-base-ubuntu22.04
|
||||||
|
COPY --from=builder /agent /agent
|
||||||
|
|
||||||
|
ENTRYPOINT ["/agent"]
|
||||||
@@ -3,11 +3,16 @@ FROM --platform=$BUILDPLATFORM golang:alpine AS builder
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Download Go modules
|
# Download Go modules
|
||||||
COPY ../go.mod ../go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
# Copy source files
|
# Copy source files
|
||||||
COPY . ./
|
COPY *.go ./
|
||||||
|
COPY cmd ./cmd
|
||||||
|
COPY internal ./internal
|
||||||
|
COPY migrations ./migrations
|
||||||
|
COPY site/dist ./site/dist
|
||||||
|
COPY site/*.go ./site
|
||||||
|
|
||||||
RUN apk add --no-cache \
|
RUN apk add --no-cache \
|
||||||
unzip \
|
unzip \
|
||||||
@@ -17,7 +22,7 @@ RUN update-ca-certificates
|
|||||||
|
|
||||||
# Build
|
# Build
|
||||||
ARG TARGETOS TARGETARCH
|
ARG TARGETOS TARGETARCH
|
||||||
RUN CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /beszel ./internal/cmd/hub
|
RUN CGO_ENABLED=0 GOEXPERIMENT=jsonv2 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /beszel ./cmd/hub
|
||||||
|
|
||||||
# ? -------------------------
|
# ? -------------------------
|
||||||
FROM scratch
|
FROM scratch
|
||||||
@@ -25,9 +30,6 @@ FROM scratch
|
|||||||
COPY --from=builder /beszel /
|
COPY --from=builder /beszel /
|
||||||
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
|
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
|
||||||
|
|
||||||
# Ensure data persistence across container recreations
|
|
||||||
VOLUME ["/beszel_data"]
|
|
||||||
|
|
||||||
EXPOSE 8090
|
EXPOSE 8090
|
||||||
|
|
||||||
ENTRYPOINT [ "/beszel" ]
|
ENTRYPOINT [ "/beszel" ]
|
||||||
@@ -1,27 +1,26 @@
|
|||||||
module github.com/henrygd/beszel
|
module beszel
|
||||||
|
|
||||||
go 1.25.5
|
go 1.24.4
|
||||||
|
|
||||||
|
// lock shoutrrr to specific version to allow review before updating
|
||||||
|
replace github.com/nicholas-fedor/shoutrrr => github.com/nicholas-fedor/shoutrrr v0.8.8
|
||||||
|
|
||||||
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.6.0
|
|
||||||
github.com/distatus/battery v0.11.0
|
|
||||||
github.com/ebitengine/purego v0.9.1
|
|
||||||
github.com/fxamacker/cbor/v2 v2.9.0
|
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.8.9
|
github.com/lxzan/gws v1.8.9
|
||||||
github.com/nicholas-fedor/shoutrrr v0.13.1
|
github.com/nicholas-fedor/shoutrrr v0.8.17
|
||||||
github.com/pocketbase/dbx v1.11.0
|
github.com/pocketbase/dbx v1.11.0
|
||||||
github.com/pocketbase/pocketbase v0.35.1
|
github.com/pocketbase/pocketbase v0.29.2
|
||||||
github.com/shirou/gopsutil/v4 v4.25.12
|
github.com/rhysd/go-github-selfupdate v1.2.3
|
||||||
github.com/spf13/cast v1.10.0
|
github.com/shirou/gopsutil/v4 v4.25.7
|
||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cast v1.9.2
|
||||||
github.com/spf13/pflag v1.0.10
|
github.com/spf13/cobra v1.9.1
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.10.0
|
||||||
golang.org/x/crypto v0.46.0
|
golang.org/x/crypto v0.41.0
|
||||||
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93
|
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b
|
||||||
golang.org/x/sys v0.40.0
|
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -33,36 +32,41 @@ require (
|
|||||||
github.com/dolthub/maphash v0.1.0 // indirect
|
github.com/dolthub/maphash v0.1.0 // indirect
|
||||||
github.com/domodwyer/mailyak/v3 v3.6.2 // indirect
|
github.com/domodwyer/mailyak/v3 v3.6.2 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/ebitengine/purego v0.8.4 // indirect
|
||||||
github.com/fatih/color v1.18.0 // indirect
|
github.com/fatih/color v1.18.0 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
|
||||||
github.com/ganigeorgiev/fexpr v0.5.0 // indirect
|
github.com/ganigeorgiev/fexpr v0.5.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-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/golang-jwt/jwt/v5 v5.3.0 // indirect
|
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
|
||||||
|
github.com/google/go-github/v30 v30.1.0 // indirect
|
||||||
|
github.com/google/go-querystring v1.1.0 // indirect
|
||||||
|
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/klauspost/compress v1.18.2 // indirect
|
github.com/klauspost/compress v1.18.0 // indirect
|
||||||
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect
|
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // 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.3.16 // indirect
|
github.com/spf13/pflag v1.0.7 // indirect
|
||||||
github.com/tklauser/numcpus v0.11.0 // indirect
|
github.com/tcnksm/go-gitconfig v0.1.2 // indirect
|
||||||
|
github.com/tklauser/go-sysconf v0.3.15 // indirect
|
||||||
|
github.com/tklauser/numcpus v0.10.0 // indirect
|
||||||
|
github.com/ulikunitz/xz v0.5.13 // 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.34.0 // indirect
|
golang.org/x/image v0.30.0 // indirect
|
||||||
golang.org/x/net v0.48.0 // indirect
|
golang.org/x/net v0.43.0 // indirect
|
||||||
golang.org/x/oauth2 v0.34.0 // indirect
|
golang.org/x/oauth2 v0.30.0 // indirect
|
||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.16.0 // indirect
|
||||||
golang.org/x/term v0.39.0 // indirect
|
golang.org/x/sys v0.35.0 // indirect
|
||||||
golang.org/x/text v0.33.0 // indirect
|
golang.org/x/text v0.28.0 // indirect
|
||||||
howett.net/plist v1.0.1 // indirect
|
modernc.org/libc v1.66.3 // indirect
|
||||||
modernc.org/libc v1.66.10 // indirect
|
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // indirect
|
modernc.org/memory v1.11.0 // indirect
|
||||||
modernc.org/sqlite v1.43.0 // indirect
|
modernc.org/sqlite v1.38.2 // indirect
|
||||||
)
|
)
|
||||||
234
beszel/go.sum
Normal file
234
beszel/go.sum
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||||
|
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||||
|
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/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=
|
||||||
|
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
|
||||||
|
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
|
||||||
|
github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ=
|
||||||
|
github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
|
||||||
|
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.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/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||||
|
github.com/dolthub/maphash v0.1.0 h1:bsQ7JsF4FkkWyrP3oCnFJgrCUAFbFf3kOl4L/QxPDyQ=
|
||||||
|
github.com/dolthub/maphash v0.1.0/go.mod h1:gkg4Ch4CdCDu5h6PMriVLawB7koZ+5ijb9puGMV50a4=
|
||||||
|
github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8=
|
||||||
|
github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw=
|
||||||
|
github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||||
|
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||||
|
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||||
|
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
|
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||||
|
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||||
|
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
|
||||||
|
github.com/ganigeorgiev/fexpr v0.5.0 h1:XA9JxtTE/Xm+g/JFI6RfZEHSiQlk+1glLvRK1Lpv/Tk=
|
||||||
|
github.com/ganigeorgiev/fexpr v0.5.0/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
|
||||||
|
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
||||||
|
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
||||||
|
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||||
|
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
|
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||||
|
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||||
|
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||||
|
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 h1:byhDUpfEwjsVQb1vBunvIjh2BHQ9ead57VkAEY4V+Es=
|
||||||
|
github.com/go-ozzo/ozzo-validation/v4 v4.3.0/go.mod h1:2NKgrcHl3z6cJs+3Oo940FPRiTzuqKbvfrL2RxCj6Ew=
|
||||||
|
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
|
||||||
|
github.com/go-sql-driver/mysql v1.9.1 h1:FrjNGn/BsJQjVRuSa8CBrM5BWA9BWoXXat3KrtSb/iI=
|
||||||
|
github.com/go-sql-driver/mysql v1.9.1/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||||
|
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||||
|
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
|
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
|
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
|
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
|
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
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-github/v30 v30.1.0 h1:VLDx+UolQICEOKu2m4uAoMti1SxuEBAl7RSEG16L+Oo=
|
||||||
|
github.com/google/go-github/v30 v30.1.0/go.mod h1:n8jBpHl45a/rlBUtRJMOG4GhNADUQFEufcolZ95JfU8=
|
||||||
|
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
|
||||||
|
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
|
||||||
|
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
|
||||||
|
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8=
|
||||||
|
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||||
|
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/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||||
|
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf h1:WfD7VjIE6z8dIvMsI4/s+1qr5EL+zoIGev1BQj1eoJ8=
|
||||||
|
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf/go.mod h1:hyb9oH7vZsitZCiBt0ZvifOrB+qc8PS5IiilCIb87rg=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
|
github.com/jarcoal/httpmock v1.4.0 h1:BvhqnH0JAYbNudL2GMJKgOHe2CtKlzJ/5rWKyp+hc2k=
|
||||||
|
github.com/jarcoal/httpmock v1.4.0/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0=
|
||||||
|
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||||
|
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||||
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr325bN2FD2ISlRRztXibcX6e8f5FR5Dc=
|
||||||
|
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||||
|
github.com/lxzan/gws v1.8.9 h1:VU3SGUeWlQrEwfUSfokcZep8mdg/BrUF+y73YYshdBM=
|
||||||
|
github.com/lxzan/gws v1.8.9/go.mod h1:d9yHaR1eDTBHagQC6KY7ycUOaz5KWeqQtP3xu7aMK8Y=
|
||||||
|
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
|
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||||
|
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/nicholas-fedor/shoutrrr v0.8.8 h1:F/oyoatWK5cbHPPgkjRZrA0262TP7KWuUQz9KskRtR8=
|
||||||
|
github.com/nicholas-fedor/shoutrrr v0.8.8/go.mod h1:T30Y+eoZFEjDk4HtOItcHQioZSOe3Z6a6aNfSz6jc5c=
|
||||||
|
github.com/onsi/ginkgo v1.6.0 h1:Ix8l273rp3QzYgXSR+c8d1fTG7UPgYkOSELPhiY/YGw=
|
||||||
|
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||||
|
github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus=
|
||||||
|
github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8=
|
||||||
|
github.com/onsi/gomega v1.4.2/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
||||||
|
github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y=
|
||||||
|
github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0=
|
||||||
|
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.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU=
|
||||||
|
github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
||||||
|
github.com/pocketbase/pocketbase v0.29.2 h1:MghVgLYy/xh9lBwHtteNSYjYOvHKYD+dS9pzUzOP79Q=
|
||||||
|
github.com/pocketbase/pocketbase v0.29.2/go.mod h1:QZPKtMCWfiDJb0aLhwgj7ZOr6O8tusbui2EhTFAHThU=
|
||||||
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||||
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/rhysd/go-github-selfupdate v1.2.3 h1:iaa+J202f+Nc+A8zi75uccC8Wg3omaM7HDeimXA22Ag=
|
||||||
|
github.com/rhysd/go-github-selfupdate v1.2.3/go.mod h1:mp/N8zj6jFfBQy/XMYoWsmfzxazpPAODuqarmPDe2Rg=
|
||||||
|
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||||
|
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||||
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
|
github.com/shirou/gopsutil/v4 v4.25.7 h1:bNb2JuqKuAu3tRlPv5piSmBZyMfecwQ+t/ILq+1JqVM=
|
||||||
|
github.com/shirou/gopsutil/v4 v4.25.7/go.mod h1:XV/egmwJtd3ZQjBpJVY5kndsiOO4IRqy9TQnmm6VP7U=
|
||||||
|
github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE=
|
||||||
|
github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||||
|
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
|
||||||
|
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
|
||||||
|
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M=
|
||||||
|
github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/tcnksm/go-gitconfig v0.1.2 h1:iiDhRitByXAEyjgBqsKi9QU4o2TNtv9kPP3RgPgXBPw=
|
||||||
|
github.com/tcnksm/go-gitconfig v0.1.2/go.mod h1:/8EhP4H7oJZdIPyT+/UIsG87kTzrzM4UsLGSItWYCpE=
|
||||||
|
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
|
||||||
|
github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
|
||||||
|
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
|
||||||
|
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
|
||||||
|
github.com/ulikunitz/xz v0.5.9/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||||
|
github.com/ulikunitz/xz v0.5.13 h1:ar98gWrjf4H1ev05fYP/o29PDZw9DrI3niHtnEqyuXA=
|
||||||
|
github.com/ulikunitz/xz v0.5.13/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||||
|
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||||
|
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||||
|
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||||
|
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||||
|
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
|
||||||
|
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
|
||||||
|
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
||||||
|
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
|
||||||
|
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b h1:DXr+pvt3nC887026GRP39Ej11UATqWDmWuS99x26cD0=
|
||||||
|
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4=
|
||||||
|
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||||
|
golang.org/x/image v0.30.0 h1:jD5RhkmVAnjqaCUXfbGBrn3lpxbknfN9w2UhHHU+5B4=
|
||||||
|
golang.org/x/image v0.30.0/go.mod h1:SAEUTxCCMWSrJcCy/4HwavEsfZZJlYxeHLc6tTiAe/c=
|
||||||
|
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
|
||||||
|
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
|
||||||
|
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||||
|
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
|
||||||
|
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||||
|
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||||
|
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
||||||
|
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||||
|
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||||
|
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/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-20191026070338-33540a1f6037/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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||||
|
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
|
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
||||||
|
golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
|
||||||
|
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
|
||||||
|
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.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||||
|
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
||||||
|
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||||
|
google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||||
|
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||||
|
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||||
|
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||||
|
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||||
|
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM=
|
||||||
|
modernc.org/cc/v4 v4.26.3 h1:yEN8dzrkRFnn4PUUKXLYIqVf2PJYAEjMTFjO3BDGc3I=
|
||||||
|
modernc.org/cc/v4 v4.26.3/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
|
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
|
||||||
|
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
|
||||||
|
modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM=
|
||||||
|
modernc.org/fileutil v1.3.15 h1:rJAXTP6ilMW/1+kzDiqmBlHLWszheUFXIyGQIAvjJpY=
|
||||||
|
modernc.org/fileutil v1.3.15/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||||
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
|
modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
|
||||||
|
modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
|
||||||
|
modernc.org/libc v1.66.7 h1:rjhZ8OSCybKWxS1CJr0hikpEi6Vg+944Ouyrd+bQsoY=
|
||||||
|
modernc.org/libc v1.66.7/go.mod h1:ln6tbWX0NH+mzApEoDRvilBvAWFt1HX7AUA4VDdVDPM=
|
||||||
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||||
|
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
|
modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek=
|
||||||
|
modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
|
||||||
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
182
beszel/internal/agent/agent.go
Normal file
182
beszel/internal/agent/agent.go
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
// Package agent handles the agent's SSH server and system stats collection.
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"beszel"
|
||||||
|
"beszel/internal/entities/system"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gliderlabs/ssh"
|
||||||
|
"github.com/shirou/gopsutil/v4/host"
|
||||||
|
gossh "golang.org/x/crypto/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Agent struct {
|
||||||
|
sync.Mutex // Used to lock agent while collecting data
|
||||||
|
debug bool // true if LOG_LEVEL is set to debug
|
||||||
|
zfs bool // true if system has arcstats
|
||||||
|
memCalc string // Memory calculation formula
|
||||||
|
fsNames []string // List of filesystem device names being monitored
|
||||||
|
fsStats map[string]*system.FsStats // Keeps track of disk stats for each filesystem
|
||||||
|
netInterfaces map[string]struct{} // Stores all valid network interfaces
|
||||||
|
netIoStats system.NetIoStats // Keeps track of bandwidth usage
|
||||||
|
dockerManager *dockerManager // Manages Docker API requests
|
||||||
|
sensorConfig *SensorConfig // Sensors config
|
||||||
|
systemInfo system.Info // Host system info
|
||||||
|
gpuManager *GPUManager // Manages GPU data
|
||||||
|
cache *SessionCache // Cache for system stats based on primary session ID
|
||||||
|
connectionManager *ConnectionManager // Channel to signal connection events
|
||||||
|
server *ssh.Server // SSH server
|
||||||
|
dataDir string // Directory for persisting data
|
||||||
|
keys []gossh.PublicKey // SSH public keys
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAgent creates a new agent with the given data directory for persisting data.
|
||||||
|
// If the data directory is not set, it will attempt to find the optimal directory.
|
||||||
|
func NewAgent(dataDir ...string) (agent *Agent, err error) {
|
||||||
|
agent = &Agent{
|
||||||
|
fsStats: make(map[string]*system.FsStats),
|
||||||
|
cache: NewSessionCache(69 * time.Second),
|
||||||
|
}
|
||||||
|
|
||||||
|
agent.dataDir, err = getDataDir(dataDir...)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("Data directory not found")
|
||||||
|
} else {
|
||||||
|
slog.Info("Data directory", "path", agent.dataDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
agent.memCalc, _ = GetEnv("MEM_CALC")
|
||||||
|
agent.sensorConfig = agent.newSensorConfig()
|
||||||
|
// Set up slog with a log level determined by the LOG_LEVEL env var
|
||||||
|
if logLevelStr, exists := GetEnv("LOG_LEVEL"); exists {
|
||||||
|
switch strings.ToLower(logLevelStr) {
|
||||||
|
case "debug":
|
||||||
|
agent.debug = true
|
||||||
|
slog.SetLogLoggerLevel(slog.LevelDebug)
|
||||||
|
case "warn":
|
||||||
|
slog.SetLogLoggerLevel(slog.LevelWarn)
|
||||||
|
case "error":
|
||||||
|
slog.SetLogLoggerLevel(slog.LevelError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Debug(beszel.Version)
|
||||||
|
|
||||||
|
// initialize system info
|
||||||
|
agent.initializeSystemInfo()
|
||||||
|
|
||||||
|
// initialize connection manager
|
||||||
|
agent.connectionManager = newConnectionManager(agent)
|
||||||
|
|
||||||
|
// initialize disk info
|
||||||
|
agent.initializeDiskInfo()
|
||||||
|
|
||||||
|
// initialize net io stats
|
||||||
|
agent.initializeNetIoStats()
|
||||||
|
|
||||||
|
// initialize docker manager
|
||||||
|
agent.dockerManager = newDockerManager(agent)
|
||||||
|
|
||||||
|
// initialize GPU manager
|
||||||
|
if gm, err := NewGPUManager(); err != nil {
|
||||||
|
slog.Debug("GPU", "err", err)
|
||||||
|
} else {
|
||||||
|
agent.gpuManager = gm
|
||||||
|
}
|
||||||
|
|
||||||
|
// if debugging, print stats
|
||||||
|
if agent.debug {
|
||||||
|
slog.Debug("Stats", "data", agent.gatherStats(""))
|
||||||
|
}
|
||||||
|
|
||||||
|
return agent, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetEnv retrieves an environment variable with a "BESZEL_AGENT_" prefix, or falls back to the unprefixed key.
|
||||||
|
func GetEnv(key string) (value string, exists bool) {
|
||||||
|
if value, exists = os.LookupEnv("BESZEL_AGENT_" + key); exists {
|
||||||
|
return value, exists
|
||||||
|
}
|
||||||
|
// Fallback to the old unprefixed key
|
||||||
|
return os.LookupEnv(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Agent) gatherStats(sessionID string) *system.CombinedData {
|
||||||
|
a.Lock()
|
||||||
|
defer a.Unlock()
|
||||||
|
|
||||||
|
data, isCached := a.cache.Get(sessionID)
|
||||||
|
if isCached {
|
||||||
|
slog.Debug("Cached data", "session", sessionID)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
*data = system.CombinedData{
|
||||||
|
Stats: a.getSystemStats(),
|
||||||
|
Info: a.systemInfo,
|
||||||
|
}
|
||||||
|
slog.Debug("System data", "data", data)
|
||||||
|
|
||||||
|
if a.dockerManager != nil {
|
||||||
|
if containerStats, err := a.dockerManager.getDockerStats(); err == nil {
|
||||||
|
data.Containers = containerStats
|
||||||
|
slog.Debug("Containers", "data", data.Containers)
|
||||||
|
} else {
|
||||||
|
slog.Debug("Containers", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data.Stats.ExtraFs = make(map[string]*system.FsStats)
|
||||||
|
for name, stats := range a.fsStats {
|
||||||
|
if !stats.Root && stats.DiskTotal > 0 {
|
||||||
|
data.Stats.ExtraFs[name] = stats
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slog.Debug("Extra FS", "data", data.Stats.ExtraFs)
|
||||||
|
|
||||||
|
a.cache.Set(sessionID, data)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartAgent initializes and starts the agent with optional WebSocket connection
|
||||||
|
func (a *Agent) Start(serverOptions ServerOptions) error {
|
||||||
|
a.keys = serverOptions.Keys
|
||||||
|
return a.connectionManager.Start(serverOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Agent) getFingerprint() string {
|
||||||
|
// first look for a fingerprint in the data directory
|
||||||
|
if a.dataDir != "" {
|
||||||
|
if fp, err := os.ReadFile(filepath.Join(a.dataDir, "fingerprint")); err == nil {
|
||||||
|
return string(fp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// if no fingerprint is found, generate one
|
||||||
|
fingerprint, err := host.HostID()
|
||||||
|
if err != nil || fingerprint == "" {
|
||||||
|
fingerprint = a.systemInfo.Hostname + a.systemInfo.CpuModel
|
||||||
|
}
|
||||||
|
|
||||||
|
// hash fingerprint
|
||||||
|
sum := sha256.Sum256([]byte(fingerprint))
|
||||||
|
fingerprint = hex.EncodeToString(sum[:24])
|
||||||
|
|
||||||
|
// save fingerprint to data directory
|
||||||
|
if a.dataDir != "" {
|
||||||
|
err = os.WriteFile(filepath.Join(a.dataDir, "fingerprint"), []byte(fingerprint), 0644)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("Failed to save fingerprint", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fingerprint
|
||||||
|
}
|
||||||
36
beszel/internal/agent/agent_cache.go
Normal file
36
beszel/internal/agent/agent_cache.go
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"beszel/internal/entities/system"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Not thread safe since we only access from gatherStats which is already locked
|
||||||
|
type SessionCache struct {
|
||||||
|
data *system.CombinedData
|
||||||
|
lastUpdate time.Time
|
||||||
|
primarySession string
|
||||||
|
leaseTime time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSessionCache(leaseTime time.Duration) *SessionCache {
|
||||||
|
return &SessionCache{
|
||||||
|
leaseTime: leaseTime,
|
||||||
|
data: &system.CombinedData{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SessionCache) Get(sessionID string) (stats *system.CombinedData, isCached bool) {
|
||||||
|
if sessionID != c.primarySession && time.Since(c.lastUpdate) < c.leaseTime {
|
||||||
|
return c.data, true
|
||||||
|
}
|
||||||
|
return c.data, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SessionCache) Set(sessionID string, data *system.CombinedData) {
|
||||||
|
if data != nil {
|
||||||
|
*c.data = *data
|
||||||
|
}
|
||||||
|
c.primarySession = sessionID
|
||||||
|
c.lastUpdate = time.Now()
|
||||||
|
}
|
||||||
88
beszel/internal/agent/agent_cache_test.go
Normal file
88
beszel/internal/agent/agent_cache_test.go
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
//go:build testing
|
||||||
|
// +build testing
|
||||||
|
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"beszel/internal/entities/system"
|
||||||
|
"testing"
|
||||||
|
"testing/synctest"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSessionCache_GetSet(t *testing.T) {
|
||||||
|
synctest.Run(func() {
|
||||||
|
cache := NewSessionCache(69 * time.Second)
|
||||||
|
|
||||||
|
testData := &system.CombinedData{
|
||||||
|
Info: system.Info{
|
||||||
|
Hostname: "test-host",
|
||||||
|
Cores: 4,
|
||||||
|
},
|
||||||
|
Stats: system.Stats{
|
||||||
|
Cpu: 50.0,
|
||||||
|
MemPct: 30.0,
|
||||||
|
DiskPct: 40.0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test initial state - should not be cached
|
||||||
|
data, isCached := cache.Get("session1")
|
||||||
|
assert.False(t, isCached, "Expected no cached data initially")
|
||||||
|
assert.NotNil(t, data, "Expected data to be initialized")
|
||||||
|
// Set data for session1
|
||||||
|
cache.Set("session1", testData)
|
||||||
|
|
||||||
|
time.Sleep(15 * time.Second)
|
||||||
|
|
||||||
|
// Get data for a different session - should be cached
|
||||||
|
data, isCached = cache.Get("session2")
|
||||||
|
assert.True(t, isCached, "Expected data to be cached for non-primary session")
|
||||||
|
require.NotNil(t, data, "Expected cached data to be returned")
|
||||||
|
assert.Equal(t, "test-host", data.Info.Hostname, "Hostname should match test data")
|
||||||
|
assert.Equal(t, 4, data.Info.Cores, "Cores should match test data")
|
||||||
|
assert.Equal(t, 50.0, data.Stats.Cpu, "CPU should match test data")
|
||||||
|
assert.Equal(t, 30.0, data.Stats.MemPct, "Memory percentage should match test data")
|
||||||
|
assert.Equal(t, 40.0, data.Stats.DiskPct, "Disk percentage should match test data")
|
||||||
|
|
||||||
|
time.Sleep(10 * time.Second)
|
||||||
|
|
||||||
|
// Get data for the primary session - should not be cached
|
||||||
|
data, isCached = cache.Get("session1")
|
||||||
|
assert.False(t, isCached, "Expected data not to be cached for primary session")
|
||||||
|
require.NotNil(t, data, "Expected data to be returned even if not cached")
|
||||||
|
assert.Equal(t, "test-host", data.Info.Hostname, "Hostname should match test data")
|
||||||
|
// if not cached, agent will update the data
|
||||||
|
cache.Set("session1", testData)
|
||||||
|
|
||||||
|
time.Sleep(45 * time.Second)
|
||||||
|
|
||||||
|
// Get data for a different session - should still be cached
|
||||||
|
_, isCached = cache.Get("session2")
|
||||||
|
assert.True(t, isCached, "Expected data to be cached for non-primary session")
|
||||||
|
|
||||||
|
// Wait for the lease to expire
|
||||||
|
time.Sleep(30 * time.Second)
|
||||||
|
|
||||||
|
// Get data for session2 - should not be cached
|
||||||
|
_, isCached = cache.Get("session2")
|
||||||
|
assert.False(t, isCached, "Expected data not to be cached after lease expiration")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionCache_NilData(t *testing.T) {
|
||||||
|
// Create a new SessionCache
|
||||||
|
cache := NewSessionCache(30 * time.Second)
|
||||||
|
|
||||||
|
// Test setting nil data (should not panic)
|
||||||
|
assert.NotPanics(t, func() {
|
||||||
|
cache.Set("session1", nil)
|
||||||
|
}, "Setting nil data should not panic")
|
||||||
|
|
||||||
|
// Get data - should not be nil even though we set nil
|
||||||
|
data, _ := cache.Get("session2")
|
||||||
|
assert.NotNil(t, data, "Expected data to not be nil after setting nil data")
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"beszel"
|
||||||
|
"beszel/internal/common"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -13,9 +15,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel"
|
|
||||||
"github.com/henrygd/beszel/internal/common"
|
|
||||||
|
|
||||||
"github.com/fxamacker/cbor/v2"
|
"github.com/fxamacker/cbor/v2"
|
||||||
"github.com/lxzan/gws"
|
"github.com/lxzan/gws"
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
@@ -85,7 +84,7 @@ func getToken() (string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return strings.TrimSpace(string(tokenBytes)), nil
|
return string(tokenBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// getOptions returns the WebSocket client options, creating them if necessary.
|
// getOptions returns the WebSocket client options, creating them if necessary.
|
||||||
@@ -142,9 +141,7 @@ func (client *WebSocketClient) OnOpen(conn *gws.Conn) {
|
|||||||
// OnClose handles WebSocket connection closure.
|
// OnClose handles WebSocket connection closure.
|
||||||
// It logs the closure reason and notifies the connection manager.
|
// It logs the closure reason and notifies the connection manager.
|
||||||
func (client *WebSocketClient) OnClose(conn *gws.Conn, err error) {
|
func (client *WebSocketClient) OnClose(conn *gws.Conn, err error) {
|
||||||
if err != nil {
|
slog.Warn("Connection closed", "err", strings.TrimPrefix(err.Error(), "gws: "))
|
||||||
slog.Warn("Connection closed", "err", strings.TrimPrefix(err.Error(), "gws: "))
|
|
||||||
}
|
|
||||||
client.agent.connectionManager.eventChan <- WebSocketDisconnect
|
client.agent.connectionManager.eventChan <- WebSocketDisconnect
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,15 +155,11 @@ func (client *WebSocketClient) OnMessage(conn *gws.Conn, message *gws.Message) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var HubRequest common.HubRequest[cbor.RawMessage]
|
if err := cbor.NewDecoder(message.Data).Decode(client.hubRequest); err != nil {
|
||||||
|
|
||||||
err := cbor.Unmarshal(message.Data.Bytes(), &HubRequest)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("Error parsing message", "err", err)
|
slog.Error("Error parsing message", "err", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err := client.handleHubRequest(client.hubRequest); err != nil {
|
||||||
if err := client.handleHubRequest(&HubRequest, HubRequest.Id); err != nil {
|
|
||||||
slog.Error("Error handling message", "err", err)
|
slog.Error("Error handling message", "err", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -179,7 +172,7 @@ func (client *WebSocketClient) OnPing(conn *gws.Conn, message []byte) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// handleAuthChallenge verifies the authenticity of the hub and returns the system's fingerprint.
|
// handleAuthChallenge verifies the authenticity of the hub and returns the system's fingerprint.
|
||||||
func (client *WebSocketClient) handleAuthChallenge(msg *common.HubRequest[cbor.RawMessage], requestID *uint32) (err error) {
|
func (client *WebSocketClient) handleAuthChallenge(msg *common.HubRequest[cbor.RawMessage]) (err error) {
|
||||||
var authRequest common.FingerprintRequest
|
var authRequest common.FingerprintRequest
|
||||||
if err := cbor.Unmarshal(msg.Data, &authRequest); err != nil {
|
if err := cbor.Unmarshal(msg.Data, &authRequest); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -197,13 +190,12 @@ func (client *WebSocketClient) handleAuthChallenge(msg *common.HubRequest[cbor.R
|
|||||||
}
|
}
|
||||||
|
|
||||||
if authRequest.NeedSysInfo {
|
if authRequest.NeedSysInfo {
|
||||||
response.Name, _ = GetEnv("SYSTEM_NAME")
|
response.Hostname = client.agent.systemInfo.Hostname
|
||||||
response.Hostname = client.agent.systemDetails.Hostname
|
|
||||||
serverAddr := client.agent.connectionManager.serverOptions.Addr
|
serverAddr := client.agent.connectionManager.serverOptions.Addr
|
||||||
_, response.Port, _ = net.SplitHostPort(serverAddr)
|
_, response.Port, _ = net.SplitHostPort(serverAddr)
|
||||||
}
|
}
|
||||||
|
|
||||||
return client.sendResponse(response, requestID)
|
return client.sendMessage(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
// verifySignature verifies the signature of the token using the public keys.
|
// verifySignature verifies the signature of the token using the public keys.
|
||||||
@@ -228,17 +220,25 @@ func (client *WebSocketClient) Close() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleHubRequest routes the request to the appropriate handler using the handler registry.
|
// handleHubRequest routes the request to the appropriate handler.
|
||||||
func (client *WebSocketClient) handleHubRequest(msg *common.HubRequest[cbor.RawMessage], requestID *uint32) error {
|
// It ensures the hub is verified before processing most requests.
|
||||||
ctx := &HandlerContext{
|
func (client *WebSocketClient) handleHubRequest(msg *common.HubRequest[cbor.RawMessage]) error {
|
||||||
Client: client,
|
if !client.hubVerified && msg.Action != common.CheckFingerprint {
|
||||||
Agent: client.agent,
|
return errors.New("hub not verified")
|
||||||
Request: msg,
|
|
||||||
RequestID: requestID,
|
|
||||||
HubVerified: client.hubVerified,
|
|
||||||
SendResponse: client.sendResponse,
|
|
||||||
}
|
}
|
||||||
return client.agent.handlerRegistry.Handle(ctx)
|
switch msg.Action {
|
||||||
|
case common.GetData:
|
||||||
|
return client.sendSystemData()
|
||||||
|
case common.CheckFingerprint:
|
||||||
|
return client.handleAuthChallenge(msg)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendSystemData gathers and sends current system statistics to the hub.
|
||||||
|
func (client *WebSocketClient) sendSystemData() error {
|
||||||
|
sysStats := client.agent.gatherStats(client.token)
|
||||||
|
return client.sendMessage(sysStats)
|
||||||
}
|
}
|
||||||
|
|
||||||
// sendMessage encodes the given data to CBOR and sends it as a binary message over the WebSocket connection to the hub.
|
// sendMessage encodes the given data to CBOR and sends it as a binary message over the WebSocket connection to the hub.
|
||||||
@@ -247,25 +247,7 @@ func (client *WebSocketClient) sendMessage(data any) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
err = client.Conn.WriteMessage(gws.OpcodeBinary, bytes)
|
return client.Conn.WriteMessage(gws.OpcodeBinary, bytes)
|
||||||
if err != nil {
|
|
||||||
// If writing fails (e.g., broken pipe due to network issues),
|
|
||||||
// close the connection to trigger reconnection logic (#1263)
|
|
||||||
client.Close()
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// sendResponse sends a response with optional request ID.
|
|
||||||
// For ID-based requests, we must populate legacy typed fields for backward
|
|
||||||
// compatibility with older hubs (<= 0.17) that don't read the generic Data field.
|
|
||||||
func (client *WebSocketClient) sendResponse(data any, requestID *uint32) error {
|
|
||||||
if requestID != nil {
|
|
||||||
response := newAgentResponse(data, requestID)
|
|
||||||
return client.sendMessage(response)
|
|
||||||
}
|
|
||||||
// Legacy format - send data directly
|
|
||||||
return client.sendMessage(data)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// getUserAgent returns one of two User-Agent strings based on current time.
|
// getUserAgent returns one of two User-Agent strings based on current time.
|
||||||
@@ -4,6 +4,8 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"beszel"
|
||||||
|
"beszel/internal/common"
|
||||||
"crypto/ed25519"
|
"crypto/ed25519"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
@@ -11,10 +13,6 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/common"
|
|
||||||
|
|
||||||
"github.com/fxamacker/cbor/v2"
|
"github.com/fxamacker/cbor/v2"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -301,7 +299,7 @@ func TestWebSocketClient_HandleHubRequest(t *testing.T) {
|
|||||||
Data: cbor.RawMessage{},
|
Data: cbor.RawMessage{},
|
||||||
}
|
}
|
||||||
|
|
||||||
err := client.handleHubRequest(hubRequest, nil)
|
err := client.handleHubRequest(hubRequest)
|
||||||
|
|
||||||
if tc.expectError {
|
if tc.expectError {
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
@@ -537,25 +535,4 @@ func TestGetToken(t *testing.T) {
|
|||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, "", token, "Empty file should return empty string")
|
assert.Equal(t, "", token, "Empty file should return empty string")
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("strips whitespace from TOKEN_FILE", func(t *testing.T) {
|
|
||||||
unsetEnvVars()
|
|
||||||
|
|
||||||
tokenWithWhitespace := " test-token-with-whitespace \n\t"
|
|
||||||
expectedToken := "test-token-with-whitespace"
|
|
||||||
tokenFile, err := os.CreateTemp("", "token-test-*.txt")
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer os.Remove(tokenFile.Name())
|
|
||||||
|
|
||||||
_, err = tokenFile.WriteString(tokenWithWhitespace)
|
|
||||||
require.NoError(t, err)
|
|
||||||
tokenFile.Close()
|
|
||||||
|
|
||||||
os.Setenv("TOKEN_FILE", tokenFile.Name())
|
|
||||||
defer os.Unsetenv("TOKEN_FILE")
|
|
||||||
|
|
||||||
token, err := getToken()
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Equal(t, expectedToken, token, "Whitespace should be stripped from token file content")
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
@@ -1,29 +1,26 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"beszel/internal/agent/health"
|
||||||
"errors"
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/agent/health"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// ConnectionManager manages the connection state and events for the agent.
|
// ConnectionManager manages the connection state and events for the agent.
|
||||||
// It handles both WebSocket and SSH connections, automatically switching between
|
// It handles both WebSocket and SSH connections, automatically switching between
|
||||||
// them based on availability and managing reconnection attempts.
|
// them based on availability and managing reconnection attempts.
|
||||||
type ConnectionManager struct {
|
type ConnectionManager struct {
|
||||||
agent *Agent // Reference to the parent agent
|
agent *Agent // Reference to the parent agent
|
||||||
State ConnectionState // Current connection state
|
State ConnectionState // Current connection state
|
||||||
eventChan chan ConnectionEvent // Channel for connection events
|
eventChan chan ConnectionEvent // Channel for connection events
|
||||||
wsClient *WebSocketClient // WebSocket client for hub communication
|
wsClient *WebSocketClient // WebSocket client for hub communication
|
||||||
serverOptions ServerOptions // Configuration for SSH server
|
serverOptions ServerOptions // Configuration for SSH server
|
||||||
wsTicker *time.Ticker // Ticker for WebSocket connection attempts
|
wsTicker *time.Ticker // Ticker for WebSocket connection attempts
|
||||||
isConnecting bool // Prevents multiple simultaneous reconnection attempts
|
isConnecting bool // Prevents multiple simultaneous reconnection attempts
|
||||||
ConnectionType system.ConnectionType
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConnectionState represents the current connection state of the agent.
|
// ConnectionState represents the current connection state of the agent.
|
||||||
@@ -146,18 +143,15 @@ func (c *ConnectionManager) handleStateChange(newState ConnectionState) {
|
|||||||
switch newState {
|
switch newState {
|
||||||
case WebSocketConnected:
|
case WebSocketConnected:
|
||||||
slog.Info("WebSocket connected", "host", c.wsClient.hubURL.Host)
|
slog.Info("WebSocket connected", "host", c.wsClient.hubURL.Host)
|
||||||
c.ConnectionType = system.ConnectionTypeWebSocket
|
|
||||||
c.stopWsTicker()
|
c.stopWsTicker()
|
||||||
_ = c.agent.StopServer()
|
_ = c.agent.StopServer()
|
||||||
c.isConnecting = false
|
c.isConnecting = false
|
||||||
case SSHConnected:
|
case SSHConnected:
|
||||||
// stop new ws connection attempts
|
// stop new ws connection attempts
|
||||||
slog.Info("SSH connection established")
|
slog.Info("SSH connection established")
|
||||||
c.ConnectionType = system.ConnectionTypeSSH
|
|
||||||
c.stopWsTicker()
|
c.stopWsTicker()
|
||||||
c.isConnecting = false
|
c.isConnecting = false
|
||||||
case Disconnected:
|
case Disconnected:
|
||||||
c.ConnectionType = system.ConnectionTypeNone
|
|
||||||
if c.isConnecting {
|
if c.isConnecting {
|
||||||
// Already handling reconnection, avoid duplicate attempts
|
// Already handling reconnection, avoid duplicate attempts
|
||||||
return
|
return
|
||||||
190
beszel/internal/agent/disk.go
Normal file
190
beszel/internal/agent/disk.go
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"beszel/internal/entities/system"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/shirou/gopsutil/v4/disk"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sets up the filesystems to monitor for disk usage and I/O.
|
||||||
|
func (a *Agent) initializeDiskInfo() {
|
||||||
|
filesystem, _ := GetEnv("FILESYSTEM")
|
||||||
|
efPath := "/extra-filesystems"
|
||||||
|
hasRoot := false
|
||||||
|
|
||||||
|
partitions, err := disk.Partitions(false)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Error getting disk partitions", "err", err)
|
||||||
|
}
|
||||||
|
slog.Debug("Disk", "partitions", partitions)
|
||||||
|
|
||||||
|
// ioContext := context.WithValue(a.sensorsContext,
|
||||||
|
// common.EnvKey, common.EnvMap{common.HostProcEnvKey: "/tmp/testproc"},
|
||||||
|
// )
|
||||||
|
// diskIoCounters, err := disk.IOCountersWithContext(ioContext)
|
||||||
|
|
||||||
|
diskIoCounters, err := disk.IOCounters()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Error getting diskstats", "err", err)
|
||||||
|
}
|
||||||
|
slog.Debug("Disk I/O", "diskstats", diskIoCounters)
|
||||||
|
|
||||||
|
// Helper function to add a filesystem to fsStats if it doesn't exist
|
||||||
|
addFsStat := func(device, mountpoint string, root bool) {
|
||||||
|
var key string
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
key = device
|
||||||
|
} else {
|
||||||
|
key = filepath.Base(device)
|
||||||
|
}
|
||||||
|
var ioMatch bool
|
||||||
|
if _, exists := a.fsStats[key]; !exists {
|
||||||
|
if root {
|
||||||
|
slog.Info("Detected root device", "name", key)
|
||||||
|
// Check if root device is in /proc/diskstats, use fallback if not
|
||||||
|
if _, ioMatch = diskIoCounters[key]; !ioMatch {
|
||||||
|
key, ioMatch = findIoDevice(filesystem, diskIoCounters, a.fsStats)
|
||||||
|
if !ioMatch {
|
||||||
|
slog.Info("Using I/O fallback", "device", device, "mountpoint", mountpoint, "fallback", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Check if non-root has diskstats and fall back to folder name if not
|
||||||
|
// Scenario: device is encrypted and named luks-2bcb02be-999d-4417-8d18-5c61e660fb6e - not in /proc/diskstats.
|
||||||
|
// However, the device can be specified by mounting folder from luks device at /extra-filesystems/sda1
|
||||||
|
if _, ioMatch = diskIoCounters[key]; !ioMatch {
|
||||||
|
efBase := filepath.Base(mountpoint)
|
||||||
|
if _, ioMatch = diskIoCounters[efBase]; ioMatch {
|
||||||
|
key = efBase
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.fsStats[key] = &system.FsStats{Root: root, Mountpoint: mountpoint}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use FILESYSTEM env var to find root filesystem
|
||||||
|
if filesystem != "" {
|
||||||
|
for _, p := range partitions {
|
||||||
|
if strings.HasSuffix(p.Device, filesystem) || p.Mountpoint == filesystem {
|
||||||
|
addFsStat(p.Device, p.Mountpoint, true)
|
||||||
|
hasRoot = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasRoot {
|
||||||
|
slog.Warn("Partition details not found", "filesystem", filesystem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add EXTRA_FILESYSTEMS env var values to fsStats
|
||||||
|
if extraFilesystems, exists := GetEnv("EXTRA_FILESYSTEMS"); exists {
|
||||||
|
for _, fs := range strings.Split(extraFilesystems, ",") {
|
||||||
|
found := false
|
||||||
|
for _, p := range partitions {
|
||||||
|
if strings.HasSuffix(p.Device, fs) || p.Mountpoint == fs {
|
||||||
|
addFsStat(p.Device, p.Mountpoint, false)
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// if not in partitions, test if we can get disk usage
|
||||||
|
if !found {
|
||||||
|
if _, err := disk.Usage(fs); err == nil {
|
||||||
|
addFsStat(filepath.Base(fs), fs, false)
|
||||||
|
} else {
|
||||||
|
slog.Error("Invalid filesystem", "name", fs, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process partitions for various mount points
|
||||||
|
for _, p := range partitions {
|
||||||
|
// fmt.Println(p.Device, p.Mountpoint)
|
||||||
|
// Binary root fallback or docker root fallback
|
||||||
|
if !hasRoot && (p.Mountpoint == "/" || (p.Mountpoint == "/etc/hosts" && strings.HasPrefix(p.Device, "/dev"))) {
|
||||||
|
fs, match := findIoDevice(filepath.Base(p.Device), diskIoCounters, a.fsStats)
|
||||||
|
if match {
|
||||||
|
addFsStat(fs, p.Mountpoint, true)
|
||||||
|
hasRoot = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if device is in /extra-filesystems
|
||||||
|
if strings.HasPrefix(p.Mountpoint, efPath) {
|
||||||
|
addFsStat(p.Device, p.Mountpoint, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check all folders in /extra-filesystems and add them if not already present
|
||||||
|
if folders, err := os.ReadDir(efPath); err == nil {
|
||||||
|
existingMountpoints := make(map[string]bool)
|
||||||
|
for _, stats := range a.fsStats {
|
||||||
|
existingMountpoints[stats.Mountpoint] = true
|
||||||
|
}
|
||||||
|
for _, folder := range folders {
|
||||||
|
if folder.IsDir() {
|
||||||
|
mountpoint := filepath.Join(efPath, folder.Name())
|
||||||
|
slog.Debug("/extra-filesystems", "mountpoint", mountpoint)
|
||||||
|
if !existingMountpoints[mountpoint] {
|
||||||
|
addFsStat(folder.Name(), mountpoint, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no root filesystem set, use fallback
|
||||||
|
if !hasRoot {
|
||||||
|
rootDevice, _ := findIoDevice(filepath.Base(filesystem), diskIoCounters, a.fsStats)
|
||||||
|
slog.Info("Root disk", "mountpoint", "/", "io", rootDevice)
|
||||||
|
a.fsStats[rootDevice] = &system.FsStats{Root: true, Mountpoint: "/"}
|
||||||
|
}
|
||||||
|
|
||||||
|
a.initializeDiskIoStats(diskIoCounters)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns matching device from /proc/diskstats,
|
||||||
|
// or the device with the most reads if no match is found.
|
||||||
|
// bool is true if a match was found.
|
||||||
|
func findIoDevice(filesystem string, diskIoCounters map[string]disk.IOCountersStat, fsStats map[string]*system.FsStats) (string, bool) {
|
||||||
|
var maxReadBytes uint64
|
||||||
|
maxReadDevice := "/"
|
||||||
|
for _, d := range diskIoCounters {
|
||||||
|
if d.Name == filesystem || (d.Label != "" && d.Label == filesystem) {
|
||||||
|
return d.Name, true
|
||||||
|
}
|
||||||
|
if d.ReadBytes > maxReadBytes {
|
||||||
|
// don't use if device already exists in fsStats
|
||||||
|
if _, exists := fsStats[d.Name]; !exists {
|
||||||
|
maxReadBytes = d.ReadBytes
|
||||||
|
maxReadDevice = d.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return maxReadDevice, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sets start values for disk I/O stats.
|
||||||
|
func (a *Agent) initializeDiskIoStats(diskIoCounters map[string]disk.IOCountersStat) {
|
||||||
|
for device, stats := range a.fsStats {
|
||||||
|
// skip if not in diskIoCounters
|
||||||
|
d, exists := diskIoCounters[device]
|
||||||
|
if !exists {
|
||||||
|
slog.Warn("Device not found in diskstats", "name", device)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// populate initial values
|
||||||
|
stats.Time = time.Now()
|
||||||
|
stats.TotalRead = d.ReadBytes
|
||||||
|
stats.TotalWrite = d.WriteBytes
|
||||||
|
// add to list of valid io device names
|
||||||
|
a.fsNames = append(a.fsNames, device)
|
||||||
|
}
|
||||||
|
}
|
||||||
367
beszel/internal/agent/docker.go
Normal file
367
beszel/internal/agent/docker.go
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"beszel/internal/entities/container"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json/v2"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/blang/semver"
|
||||||
|
)
|
||||||
|
|
||||||
|
type dockerManager struct {
|
||||||
|
client *http.Client // Client to query Docker API
|
||||||
|
wg sync.WaitGroup // WaitGroup to wait for all goroutines to finish
|
||||||
|
sem chan struct{} // Semaphore to limit concurrent container requests
|
||||||
|
containerStatsMutex sync.RWMutex // Mutex to prevent concurrent access to containerStatsMap
|
||||||
|
apiContainerList []*container.ApiInfo // List of containers from Docker API (no pointer)
|
||||||
|
containerStatsMap map[string]*container.Stats // Keeps track of container stats
|
||||||
|
validIds map[string]struct{} // Map of valid container ids, used to prune invalid containers from containerStatsMap
|
||||||
|
goodDockerVersion bool // Whether docker version is at least 25.0.0 (one-shot works correctly)
|
||||||
|
isWindows bool // Whether the Docker Engine API is running on Windows
|
||||||
|
buf *bytes.Buffer // Buffer to store and read response bodies
|
||||||
|
apiStats *container.ApiStats // Reusable API stats object
|
||||||
|
}
|
||||||
|
|
||||||
|
// userAgentRoundTripper is a custom http.RoundTripper that adds a User-Agent header to all requests
|
||||||
|
type userAgentRoundTripper struct {
|
||||||
|
rt http.RoundTripper
|
||||||
|
userAgent string
|
||||||
|
}
|
||||||
|
|
||||||
|
// RoundTrip implements the http.RoundTripper interface
|
||||||
|
func (u *userAgentRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
req.Header.Set("User-Agent", u.userAgent)
|
||||||
|
return u.rt.RoundTrip(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add goroutine to the queue
|
||||||
|
func (d *dockerManager) queue() {
|
||||||
|
d.wg.Add(1)
|
||||||
|
if d.goodDockerVersion {
|
||||||
|
d.sem <- struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove goroutine from the queue
|
||||||
|
func (d *dockerManager) dequeue() {
|
||||||
|
d.wg.Done()
|
||||||
|
if d.goodDockerVersion {
|
||||||
|
<-d.sem
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns stats for all running containers
|
||||||
|
func (dm *dockerManager) getDockerStats() ([]*container.Stats, error) {
|
||||||
|
resp, err := dm.client.Get("http://localhost/containers/json")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
dm.apiContainerList = dm.apiContainerList[:0]
|
||||||
|
if err := dm.decode(resp, &dm.apiContainerList); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
dm.isWindows = strings.Contains(resp.Header.Get("Server"), "windows")
|
||||||
|
|
||||||
|
containersLength := len(dm.apiContainerList)
|
||||||
|
|
||||||
|
// store valid ids to clean up old container ids from map
|
||||||
|
if dm.validIds == nil {
|
||||||
|
dm.validIds = make(map[string]struct{}, containersLength)
|
||||||
|
} else {
|
||||||
|
clear(dm.validIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
var failedContainers []*container.ApiInfo
|
||||||
|
|
||||||
|
for i := range dm.apiContainerList {
|
||||||
|
ctr := dm.apiContainerList[i]
|
||||||
|
ctr.IdShort = ctr.Id[:12]
|
||||||
|
dm.validIds[ctr.IdShort] = struct{}{}
|
||||||
|
// check if container is less than 1 minute old (possible restart)
|
||||||
|
// note: can't use Created field because it's not updated on restart
|
||||||
|
if strings.Contains(ctr.Status, "second") {
|
||||||
|
// if so, remove old container data
|
||||||
|
dm.deleteContainerStatsSync(ctr.IdShort)
|
||||||
|
}
|
||||||
|
dm.queue()
|
||||||
|
go func() {
|
||||||
|
defer dm.dequeue()
|
||||||
|
err := dm.updateContainerStats(ctr)
|
||||||
|
// if error, delete from map and add to failed list to retry
|
||||||
|
if err != nil {
|
||||||
|
dm.containerStatsMutex.Lock()
|
||||||
|
delete(dm.containerStatsMap, ctr.IdShort)
|
||||||
|
failedContainers = append(failedContainers, ctr)
|
||||||
|
dm.containerStatsMutex.Unlock()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
dm.wg.Wait()
|
||||||
|
|
||||||
|
// retry failed containers separately so we can run them in parallel (docker 24 bug)
|
||||||
|
if len(failedContainers) > 0 {
|
||||||
|
slog.Debug("Retrying failed containers", "count", len(failedContainers))
|
||||||
|
for i := range failedContainers {
|
||||||
|
ctr := failedContainers[i]
|
||||||
|
dm.queue()
|
||||||
|
go func() {
|
||||||
|
defer dm.dequeue()
|
||||||
|
err = dm.updateContainerStats(ctr)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Error getting container stats", "err", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
dm.wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// populate final stats and remove old / invalid container stats
|
||||||
|
stats := make([]*container.Stats, 0, containersLength)
|
||||||
|
for id, v := range dm.containerStatsMap {
|
||||||
|
if _, exists := dm.validIds[id]; !exists {
|
||||||
|
delete(dm.containerStatsMap, id)
|
||||||
|
} else {
|
||||||
|
stats = append(stats, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Updates stats for individual container
|
||||||
|
func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo) error {
|
||||||
|
name := ctr.Names[0][1:]
|
||||||
|
|
||||||
|
resp, err := dm.client.Get("http://localhost/containers/" + ctr.IdShort + "/stats?stream=0&one-shot=1")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
dm.containerStatsMutex.Lock()
|
||||||
|
defer dm.containerStatsMutex.Unlock()
|
||||||
|
|
||||||
|
// add empty values if they doesn't exist in map
|
||||||
|
stats, initialized := dm.containerStatsMap[ctr.IdShort]
|
||||||
|
if !initialized {
|
||||||
|
stats = &container.Stats{Name: name}
|
||||||
|
dm.containerStatsMap[ctr.IdShort] = stats
|
||||||
|
}
|
||||||
|
|
||||||
|
// reset current stats
|
||||||
|
stats.Cpu = 0
|
||||||
|
stats.Mem = 0
|
||||||
|
stats.NetworkSent = 0
|
||||||
|
stats.NetworkRecv = 0
|
||||||
|
|
||||||
|
// docker host container stats response
|
||||||
|
// res := dm.getApiStats()
|
||||||
|
// defer dm.putApiStats(res)
|
||||||
|
//
|
||||||
|
|
||||||
|
res := dm.apiStats
|
||||||
|
res.Networks = nil
|
||||||
|
if err := dm.decode(resp, res); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// calculate cpu and memory stats
|
||||||
|
var usedMemory uint64
|
||||||
|
var cpuPct float64
|
||||||
|
|
||||||
|
// store current cpu stats
|
||||||
|
prevCpuContainer, prevCpuSystem := stats.CpuContainer, stats.CpuSystem
|
||||||
|
stats.CpuContainer = res.CPUStats.CPUUsage.TotalUsage
|
||||||
|
stats.CpuSystem = res.CPUStats.SystemUsage
|
||||||
|
|
||||||
|
if dm.isWindows {
|
||||||
|
usedMemory = res.MemoryStats.PrivateWorkingSet
|
||||||
|
cpuPct = res.CalculateCpuPercentWindows(prevCpuContainer, stats.PrevReadTime)
|
||||||
|
} else {
|
||||||
|
// check if container has valid data, otherwise may be in restart loop (#103)
|
||||||
|
if res.MemoryStats.Usage == 0 {
|
||||||
|
return fmt.Errorf("%s - no memory stats - see https://github.com/henrygd/beszel/issues/144", name)
|
||||||
|
}
|
||||||
|
memCache := res.MemoryStats.Stats.InactiveFile
|
||||||
|
if memCache == 0 {
|
||||||
|
memCache = res.MemoryStats.Stats.Cache
|
||||||
|
}
|
||||||
|
usedMemory = res.MemoryStats.Usage - memCache
|
||||||
|
|
||||||
|
cpuPct = res.CalculateCpuPercentLinux(prevCpuContainer, prevCpuSystem)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cpuPct > 100 {
|
||||||
|
return fmt.Errorf("%s cpu pct greater than 100: %+v", name, cpuPct)
|
||||||
|
}
|
||||||
|
|
||||||
|
// network
|
||||||
|
var total_sent, total_recv uint64
|
||||||
|
for _, v := range res.Networks {
|
||||||
|
total_sent += v.TxBytes
|
||||||
|
total_recv += v.RxBytes
|
||||||
|
}
|
||||||
|
var sent_delta, recv_delta uint64
|
||||||
|
millisecondsElapsed := uint64(time.Since(stats.PrevReadTime).Milliseconds())
|
||||||
|
if initialized && millisecondsElapsed > 0 {
|
||||||
|
// get bytes per second
|
||||||
|
sent_delta = (total_sent - stats.PrevNet.Sent) * 1000 / millisecondsElapsed
|
||||||
|
recv_delta = (total_recv - stats.PrevNet.Recv) * 1000 / millisecondsElapsed
|
||||||
|
// check for unrealistic network values (> 5GB/s)
|
||||||
|
if sent_delta > 5e9 || recv_delta > 5e9 {
|
||||||
|
slog.Warn("Bad network delta", "container", name)
|
||||||
|
sent_delta, recv_delta = 0, 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stats.PrevNet.Sent, stats.PrevNet.Recv = total_sent, total_recv
|
||||||
|
|
||||||
|
stats.Cpu = twoDecimals(cpuPct)
|
||||||
|
stats.Mem = bytesToMegabytes(float64(usedMemory))
|
||||||
|
stats.NetworkSent = bytesToMegabytes(float64(sent_delta))
|
||||||
|
stats.NetworkRecv = bytesToMegabytes(float64(recv_delta))
|
||||||
|
stats.PrevReadTime = res.Read
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete container stats from map using mutex
|
||||||
|
func (dm *dockerManager) deleteContainerStatsSync(id string) {
|
||||||
|
dm.containerStatsMutex.Lock()
|
||||||
|
defer dm.containerStatsMutex.Unlock()
|
||||||
|
delete(dm.containerStatsMap, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Creates a new http client for Docker or Podman API
|
||||||
|
func newDockerManager(a *Agent) *dockerManager {
|
||||||
|
dockerHost, exists := GetEnv("DOCKER_HOST")
|
||||||
|
if exists {
|
||||||
|
// return nil if set to empty string
|
||||||
|
if dockerHost == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
dockerHost = getDockerHost()
|
||||||
|
}
|
||||||
|
|
||||||
|
parsedURL, err := url.Parse(dockerHost)
|
||||||
|
if err != nil {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
transport := &http.Transport{
|
||||||
|
DisableCompression: true,
|
||||||
|
MaxConnsPerHost: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
switch parsedURL.Scheme {
|
||||||
|
case "unix":
|
||||||
|
transport.DialContext = func(ctx context.Context, proto, addr string) (net.Conn, error) {
|
||||||
|
return (&net.Dialer{}).DialContext(ctx, "unix", parsedURL.Path)
|
||||||
|
}
|
||||||
|
case "tcp", "http", "https":
|
||||||
|
transport.DialContext = func(ctx context.Context, proto, addr string) (net.Conn, error) {
|
||||||
|
return (&net.Dialer{}).DialContext(ctx, "tcp", parsedURL.Host)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
slog.Error("Invalid DOCKER_HOST", "scheme", parsedURL.Scheme)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// configurable timeout
|
||||||
|
timeout := time.Millisecond * 2100
|
||||||
|
if t, set := GetEnv("DOCKER_TIMEOUT"); set {
|
||||||
|
timeout, err = time.ParseDuration(t)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error(err.Error())
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
slog.Info("DOCKER_TIMEOUT", "timeout", timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom user-agent to avoid docker bug: https://github.com/docker/for-mac/issues/7575
|
||||||
|
userAgentTransport := &userAgentRoundTripper{
|
||||||
|
rt: transport,
|
||||||
|
userAgent: "Docker-Client/",
|
||||||
|
}
|
||||||
|
|
||||||
|
manager := &dockerManager{
|
||||||
|
client: &http.Client{
|
||||||
|
Timeout: timeout,
|
||||||
|
Transport: userAgentTransport,
|
||||||
|
},
|
||||||
|
containerStatsMap: make(map[string]*container.Stats),
|
||||||
|
sem: make(chan struct{}, 5),
|
||||||
|
apiContainerList: []*container.ApiInfo{},
|
||||||
|
apiStats: &container.ApiStats{},
|
||||||
|
}
|
||||||
|
|
||||||
|
// If using podman, return client
|
||||||
|
if strings.Contains(dockerHost, "podman") {
|
||||||
|
a.systemInfo.Podman = true
|
||||||
|
manager.goodDockerVersion = true
|
||||||
|
return manager
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check docker version
|
||||||
|
// (versions before 25.0.0 have a bug with one-shot which requires all requests to be made in one batch)
|
||||||
|
var versionInfo struct {
|
||||||
|
Version string `json:"Version"`
|
||||||
|
}
|
||||||
|
resp, err := manager.client.Get("http://localhost/version")
|
||||||
|
if err != nil {
|
||||||
|
return manager
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := manager.decode(resp, &versionInfo); err != nil {
|
||||||
|
return manager
|
||||||
|
}
|
||||||
|
|
||||||
|
// if version > 24, one-shot works correctly and we can limit concurrent operations
|
||||||
|
if dockerVersion, err := semver.Parse(versionInfo.Version); err == nil && dockerVersion.Major > 24 {
|
||||||
|
manager.goodDockerVersion = true
|
||||||
|
} else {
|
||||||
|
slog.Info(fmt.Sprintf("Docker %s is outdated. Upgrade if possible. See https://github.com/henrygd/beszel/issues/58", versionInfo.Version))
|
||||||
|
}
|
||||||
|
|
||||||
|
return manager
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decodes Docker API JSON response using a reusable buffer and decoder. Not thread safe.
|
||||||
|
func (dm *dockerManager) decode(resp *http.Response, d any) error {
|
||||||
|
if dm.buf == nil {
|
||||||
|
// initialize buffer with 128kb starting size
|
||||||
|
dm.buf = bytes.NewBuffer(make([]byte, 0, 1024*128))
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
dm.buf.Reset()
|
||||||
|
_, err := dm.buf.ReadFrom(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return json.Unmarshal(dm.buf.Bytes(), d)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test docker / podman sockets and return if one exists
|
||||||
|
func getDockerHost() string {
|
||||||
|
scheme := "unix://"
|
||||||
|
socks := []string{"/var/run/docker.sock", fmt.Sprintf("/run/user/%v/podman/podman.sock", os.Getuid())}
|
||||||
|
for _, sock := range socks {
|
||||||
|
if _, err := os.Stat(sock); err == nil {
|
||||||
|
return scheme + sock
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return scheme + socks[0]
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"beszel/internal/entities/system"
|
||||||
"bufio"
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json/v2"
|
||||||
"fmt"
|
"fmt"
|
||||||
"maps"
|
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -13,9 +13,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"golang.org/x/exp/slog"
|
||||||
|
|
||||||
"log/slog"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -28,10 +26,13 @@ const (
|
|||||||
nvidiaSmiInterval string = "4" // in seconds
|
nvidiaSmiInterval string = "4" // in seconds
|
||||||
tegraStatsInterval string = "3700" // in milliseconds
|
tegraStatsInterval string = "3700" // in milliseconds
|
||||||
rocmSmiInterval time.Duration = 4300 * time.Millisecond
|
rocmSmiInterval time.Duration = 4300 * time.Millisecond
|
||||||
|
|
||||||
// Command retry and timeout constants
|
// Command retry and timeout constants
|
||||||
retryWaitTime time.Duration = 5 * time.Second
|
retryWaitTime time.Duration = 5 * time.Second
|
||||||
maxFailureRetries int = 5
|
maxFailureRetries int = 5
|
||||||
|
|
||||||
|
cmdBufferSize uint16 = 10 * 1024
|
||||||
|
|
||||||
// Unit Conversions
|
// Unit Conversions
|
||||||
mebibytesInAMegabyte float64 = 1.024 // nvidia-smi reports memory in MiB
|
mebibytesInAMegabyte float64 = 1.024 // nvidia-smi reports memory in MiB
|
||||||
milliwattsInAWatt float64 = 1000.0 // tegrastats reports power in mW
|
milliwattsInAWatt float64 = 1000.0 // tegrastats reports power in mW
|
||||||
@@ -40,33 +41,16 @@ const (
|
|||||||
// GPUManager manages data collection for GPUs (either Nvidia or AMD)
|
// GPUManager manages data collection for GPUs (either Nvidia or AMD)
|
||||||
type GPUManager struct {
|
type GPUManager struct {
|
||||||
sync.Mutex
|
sync.Mutex
|
||||||
nvidiaSmi bool
|
nvidiaSmi bool
|
||||||
rocmSmi bool
|
rocmSmi bool
|
||||||
tegrastats bool
|
tegrastats bool
|
||||||
intelGpuStats bool
|
GpuDataMap map[string]*system.GPUData
|
||||||
nvml bool
|
|
||||||
GpuDataMap map[string]*system.GPUData
|
|
||||||
// lastAvgData stores the last calculated averages for each GPU
|
|
||||||
// Used when a collection happens before new data arrives (Count == 0)
|
|
||||||
lastAvgData map[string]system.GPUData
|
|
||||||
// Per-cache-key tracking for delta calculations
|
|
||||||
// cacheKey -> gpuId -> snapshot of last count/usage/power values
|
|
||||||
lastSnapshots map[uint16]map[string]*gpuSnapshot
|
|
||||||
}
|
|
||||||
|
|
||||||
// gpuSnapshot stores the last observed incremental values for delta tracking
|
|
||||||
type gpuSnapshot struct {
|
|
||||||
count uint32
|
|
||||||
usage float64
|
|
||||||
power float64
|
|
||||||
powerPkg float64
|
|
||||||
engines map[string]float64
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// RocmSmiJson represents the JSON structure of rocm-smi output
|
// RocmSmiJson represents the JSON structure of rocm-smi output
|
||||||
type RocmSmiJson struct {
|
type RocmSmiJson struct {
|
||||||
ID string `json:"GUID"`
|
ID string `json:"GUID"`
|
||||||
Name string `json:"Card series"`
|
Name string `json:"Card Series"`
|
||||||
Temperature string `json:"Temperature (Sensor edge) (C)"`
|
Temperature string `json:"Temperature (Sensor edge) (C)"`
|
||||||
MemoryUsed string `json:"VRAM Total Used Memory (B)"`
|
MemoryUsed string `json:"VRAM Total Used Memory (B)"`
|
||||||
MemoryTotal string `json:"VRAM Total Memory (B)"`
|
MemoryTotal string `json:"VRAM Total Memory (B)"`
|
||||||
@@ -81,7 +65,6 @@ type gpuCollector struct {
|
|||||||
cmdArgs []string
|
cmdArgs []string
|
||||||
parse func([]byte) bool // returns true if valid data was found
|
parse func([]byte) bool // returns true if valid data was found
|
||||||
buf []byte
|
buf []byte
|
||||||
bufSize uint16
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var errNoValidData = fmt.Errorf("no valid GPU data found") // Error for missing data
|
var errNoValidData = fmt.Errorf("no valid GPU data found") // Error for missing data
|
||||||
@@ -115,7 +98,7 @@ func (c *gpuCollector) collect() error {
|
|||||||
|
|
||||||
scanner := bufio.NewScanner(stdout)
|
scanner := bufio.NewScanner(stdout)
|
||||||
if c.buf == nil {
|
if c.buf == nil {
|
||||||
c.buf = make([]byte, 0, c.bufSize)
|
c.buf = make([]byte, 0, cmdBufferSize)
|
||||||
}
|
}
|
||||||
scanner.Buffer(c.buf, bufio.MaxScanTokenSize)
|
scanner.Buffer(c.buf, bufio.MaxScanTokenSize)
|
||||||
|
|
||||||
@@ -246,21 +229,36 @@ func (gm *GPUManager) parseAmdData(output []byte) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetCurrentData returns GPU utilization data averaged since the last call with this cacheKey
|
// sums and resets the current GPU utilization data since the last update
|
||||||
func (gm *GPUManager) GetCurrentData(cacheKey uint16) map[string]system.GPUData {
|
func (gm *GPUManager) GetCurrentData() map[string]system.GPUData {
|
||||||
gm.Lock()
|
gm.Lock()
|
||||||
defer gm.Unlock()
|
defer gm.Unlock()
|
||||||
|
|
||||||
gm.initializeSnapshots(cacheKey)
|
// check for GPUs with the same name
|
||||||
nameCounts := gm.countGPUNames()
|
nameCounts := make(map[string]int)
|
||||||
|
for _, gpu := range gm.GpuDataMap {
|
||||||
|
nameCounts[gpu.Name]++
|
||||||
|
}
|
||||||
|
|
||||||
|
// copy / reset the data
|
||||||
gpuData := make(map[string]system.GPUData, len(gm.GpuDataMap))
|
gpuData := make(map[string]system.GPUData, len(gm.GpuDataMap))
|
||||||
for id, gpu := range gm.GpuDataMap {
|
for id, gpu := range gm.GpuDataMap {
|
||||||
gpuAvg := gm.calculateGPUAverage(id, gpu, cacheKey)
|
gpuAvg := *gpu
|
||||||
gm.updateInstantaneousValues(&gpuAvg, gpu)
|
|
||||||
gm.storeSnapshot(id, gpu, cacheKey)
|
|
||||||
|
|
||||||
// Append id to name if there are multiple GPUs with the same name
|
gpuAvg.Temperature = twoDecimals(gpu.Temperature)
|
||||||
|
gpuAvg.MemoryUsed = twoDecimals(gpu.MemoryUsed)
|
||||||
|
gpuAvg.MemoryTotal = twoDecimals(gpu.MemoryTotal)
|
||||||
|
|
||||||
|
// avoid division by zero
|
||||||
|
if gpu.Count > 0 {
|
||||||
|
gpuAvg.Usage = twoDecimals(gpu.Usage / gpu.Count)
|
||||||
|
gpuAvg.Power = twoDecimals(gpu.Power / gpu.Count)
|
||||||
|
}
|
||||||
|
|
||||||
|
// reset accumulators in the original
|
||||||
|
gpu.Usage, gpu.Power, gpu.Count = 0, 0, 0
|
||||||
|
|
||||||
|
// append id to the name if there are multiple GPUs with the same name
|
||||||
if nameCounts[gpu.Name] > 1 {
|
if nameCounts[gpu.Name] > 1 {
|
||||||
gpuAvg.Name = fmt.Sprintf("%s %s", gpu.Name, id)
|
gpuAvg.Name = fmt.Sprintf("%s %s", gpu.Name, id)
|
||||||
}
|
}
|
||||||
@@ -270,120 +268,6 @@ func (gm *GPUManager) GetCurrentData(cacheKey uint16) map[string]system.GPUData
|
|||||||
return gpuData
|
return gpuData
|
||||||
}
|
}
|
||||||
|
|
||||||
// initializeSnapshots ensures snapshot maps are initialized for the given cache key
|
|
||||||
func (gm *GPUManager) initializeSnapshots(cacheKey uint16) {
|
|
||||||
if gm.lastAvgData == nil {
|
|
||||||
gm.lastAvgData = make(map[string]system.GPUData)
|
|
||||||
}
|
|
||||||
if gm.lastSnapshots == nil {
|
|
||||||
gm.lastSnapshots = make(map[uint16]map[string]*gpuSnapshot)
|
|
||||||
}
|
|
||||||
if gm.lastSnapshots[cacheKey] == nil {
|
|
||||||
gm.lastSnapshots[cacheKey] = make(map[string]*gpuSnapshot)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// countGPUNames returns a map of GPU names to their occurrence count
|
|
||||||
func (gm *GPUManager) countGPUNames() map[string]int {
|
|
||||||
nameCounts := make(map[string]int)
|
|
||||||
for _, gpu := range gm.GpuDataMap {
|
|
||||||
nameCounts[gpu.Name]++
|
|
||||||
}
|
|
||||||
return nameCounts
|
|
||||||
}
|
|
||||||
|
|
||||||
// calculateGPUAverage computes the average GPU metrics since the last snapshot for this cache key
|
|
||||||
func (gm *GPUManager) calculateGPUAverage(id string, gpu *system.GPUData, cacheKey uint16) system.GPUData {
|
|
||||||
lastSnapshot := gm.lastSnapshots[cacheKey][id]
|
|
||||||
currentCount := uint32(gpu.Count)
|
|
||||||
deltaCount := gm.calculateDeltaCount(currentCount, lastSnapshot)
|
|
||||||
|
|
||||||
// If no new data arrived
|
|
||||||
if deltaCount == 0 {
|
|
||||||
// If GPU appears suspended (instantaneous values are 0), return zero values
|
|
||||||
// Otherwise return last known average for temporary collection gaps
|
|
||||||
if gpu.Temperature == 0 && gpu.MemoryUsed == 0 {
|
|
||||||
return system.GPUData{Name: gpu.Name}
|
|
||||||
}
|
|
||||||
return gm.lastAvgData[id] // zero value if not found
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate new average
|
|
||||||
gpuAvg := *gpu
|
|
||||||
deltaUsage, deltaPower, deltaPowerPkg := gm.calculateDeltas(gpu, lastSnapshot)
|
|
||||||
|
|
||||||
gpuAvg.Power = twoDecimals(deltaPower / float64(deltaCount))
|
|
||||||
|
|
||||||
if gpu.Engines != nil {
|
|
||||||
// make fresh map for averaged engine metrics to avoid mutating
|
|
||||||
// the accumulator map stored in gm.GpuDataMap
|
|
||||||
gpuAvg.Engines = make(map[string]float64, len(gpu.Engines))
|
|
||||||
gpuAvg.Usage = gm.calculateIntelGPUUsage(&gpuAvg, gpu, lastSnapshot, deltaCount)
|
|
||||||
gpuAvg.PowerPkg = twoDecimals(deltaPowerPkg / float64(deltaCount))
|
|
||||||
} else {
|
|
||||||
gpuAvg.Usage = twoDecimals(deltaUsage / float64(deltaCount))
|
|
||||||
}
|
|
||||||
|
|
||||||
gm.lastAvgData[id] = gpuAvg
|
|
||||||
return gpuAvg
|
|
||||||
}
|
|
||||||
|
|
||||||
// calculateDeltaCount returns the change in count since the last snapshot
|
|
||||||
func (gm *GPUManager) calculateDeltaCount(currentCount uint32, lastSnapshot *gpuSnapshot) uint32 {
|
|
||||||
if lastSnapshot != nil {
|
|
||||||
return currentCount - lastSnapshot.count
|
|
||||||
}
|
|
||||||
return currentCount
|
|
||||||
}
|
|
||||||
|
|
||||||
// calculateDeltas computes the change in usage, power, and powerPkg since the last snapshot
|
|
||||||
func (gm *GPUManager) calculateDeltas(gpu *system.GPUData, lastSnapshot *gpuSnapshot) (deltaUsage, deltaPower, deltaPowerPkg float64) {
|
|
||||||
if lastSnapshot != nil {
|
|
||||||
return gpu.Usage - lastSnapshot.usage,
|
|
||||||
gpu.Power - lastSnapshot.power,
|
|
||||||
gpu.PowerPkg - lastSnapshot.powerPkg
|
|
||||||
}
|
|
||||||
return gpu.Usage, gpu.Power, gpu.PowerPkg
|
|
||||||
}
|
|
||||||
|
|
||||||
// calculateIntelGPUUsage computes Intel GPU usage from engine metrics and returns max engine usage
|
|
||||||
func (gm *GPUManager) calculateIntelGPUUsage(gpuAvg, gpu *system.GPUData, lastSnapshot *gpuSnapshot, deltaCount uint32) float64 {
|
|
||||||
maxEngineUsage := 0.0
|
|
||||||
for name, engine := range gpu.Engines {
|
|
||||||
var deltaEngine float64
|
|
||||||
if lastSnapshot != nil && lastSnapshot.engines != nil {
|
|
||||||
deltaEngine = engine - lastSnapshot.engines[name]
|
|
||||||
} else {
|
|
||||||
deltaEngine = engine
|
|
||||||
}
|
|
||||||
gpuAvg.Engines[name] = twoDecimals(deltaEngine / float64(deltaCount))
|
|
||||||
maxEngineUsage = max(maxEngineUsage, deltaEngine/float64(deltaCount))
|
|
||||||
}
|
|
||||||
return twoDecimals(maxEngineUsage)
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateInstantaneousValues updates values that should reflect current state, not averages
|
|
||||||
func (gm *GPUManager) updateInstantaneousValues(gpuAvg *system.GPUData, gpu *system.GPUData) {
|
|
||||||
gpuAvg.Temperature = twoDecimals(gpu.Temperature)
|
|
||||||
gpuAvg.MemoryUsed = twoDecimals(gpu.MemoryUsed)
|
|
||||||
gpuAvg.MemoryTotal = twoDecimals(gpu.MemoryTotal)
|
|
||||||
}
|
|
||||||
|
|
||||||
// storeSnapshot saves the current GPU state for this cache key
|
|
||||||
func (gm *GPUManager) storeSnapshot(id string, gpu *system.GPUData, cacheKey uint16) {
|
|
||||||
snapshot := &gpuSnapshot{
|
|
||||||
count: uint32(gpu.Count),
|
|
||||||
usage: gpu.Usage,
|
|
||||||
power: gpu.Power,
|
|
||||||
powerPkg: gpu.PowerPkg,
|
|
||||||
}
|
|
||||||
if gpu.Engines != nil {
|
|
||||||
snapshot.engines = make(map[string]float64, len(gpu.Engines))
|
|
||||||
maps.Copy(snapshot.engines, gpu.Engines)
|
|
||||||
}
|
|
||||||
gm.lastSnapshots[cacheKey][id] = snapshot
|
|
||||||
}
|
|
||||||
|
|
||||||
// detectGPUs checks for the presence of GPU management tools (nvidia-smi, rocm-smi, tegrastats)
|
// detectGPUs checks for the presence of GPU management tools (nvidia-smi, rocm-smi, tegrastats)
|
||||||
// in the system path. It sets the corresponding flags in the GPUManager struct if any of these
|
// in the system path. It sets the corresponding flags in the GPUManager struct if any of these
|
||||||
// tools are found. If none of the tools are found, it returns an error indicating that no GPU
|
// tools are found. If none of the tools are found, it returns an error indicating that no GPU
|
||||||
@@ -399,37 +283,18 @@ func (gm *GPUManager) detectGPUs() error {
|
|||||||
gm.tegrastats = true
|
gm.tegrastats = true
|
||||||
gm.nvidiaSmi = false
|
gm.nvidiaSmi = false
|
||||||
}
|
}
|
||||||
if _, err := exec.LookPath(intelGpuStatsCmd); err == nil {
|
if gm.nvidiaSmi || gm.rocmSmi || gm.tegrastats {
|
||||||
gm.intelGpuStats = true
|
|
||||||
}
|
|
||||||
if gm.nvidiaSmi || gm.rocmSmi || gm.tegrastats || gm.intelGpuStats || gm.nvml {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return fmt.Errorf("no GPU found - install nvidia-smi, rocm-smi, tegrastats, or intel_gpu_top")
|
return fmt.Errorf("no GPU found - install nvidia-smi, rocm-smi, or tegrastats")
|
||||||
}
|
}
|
||||||
|
|
||||||
// startCollector starts the appropriate GPU data collector based on the command
|
// startCollector starts the appropriate GPU data collector based on the command
|
||||||
func (gm *GPUManager) startCollector(command string) {
|
func (gm *GPUManager) startCollector(command string) {
|
||||||
collector := gpuCollector{
|
collector := gpuCollector{
|
||||||
name: command,
|
name: command,
|
||||||
bufSize: 10 * 1024,
|
|
||||||
}
|
}
|
||||||
switch command {
|
switch command {
|
||||||
case intelGpuStatsCmd:
|
|
||||||
go func() {
|
|
||||||
failures := 0
|
|
||||||
for {
|
|
||||||
if err := gm.collectIntelStats(); err != nil {
|
|
||||||
failures++
|
|
||||||
if failures > maxFailureRetries {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
slog.Warn("Error collecting Intel GPU data; see https://beszel.dev/guide/gpu", "err", err)
|
|
||||||
time.Sleep(retryWaitTime)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
case nvidiaSmiCmd:
|
case nvidiaSmiCmd:
|
||||||
collector.cmdArgs = []string{
|
collector.cmdArgs = []string{
|
||||||
"-l", nvidiaSmiInterval,
|
"-l", nvidiaSmiInterval,
|
||||||
@@ -463,9 +328,6 @@ func (gm *GPUManager) startCollector(command string) {
|
|||||||
|
|
||||||
// NewGPUManager creates and initializes a new GPUManager
|
// NewGPUManager creates and initializes a new GPUManager
|
||||||
func NewGPUManager() (*GPUManager, error) {
|
func NewGPUManager() (*GPUManager, error) {
|
||||||
if skipGPU, _ := GetEnv("SKIP_GPU"); skipGPU == "true" {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
var gm GPUManager
|
var gm GPUManager
|
||||||
if err := gm.detectGPUs(); err != nil {
|
if err := gm.detectGPUs(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -473,20 +335,7 @@ func NewGPUManager() (*GPUManager, error) {
|
|||||||
gm.GpuDataMap = make(map[string]*system.GPUData)
|
gm.GpuDataMap = make(map[string]*system.GPUData)
|
||||||
|
|
||||||
if gm.nvidiaSmi {
|
if gm.nvidiaSmi {
|
||||||
if nvml, _ := GetEnv("NVML"); nvml == "true" {
|
gm.startCollector(nvidiaSmiCmd)
|
||||||
gm.nvml = true
|
|
||||||
gm.nvidiaSmi = false
|
|
||||||
collector := &nvmlCollector{gm: &gm}
|
|
||||||
if err := collector.init(); err == nil {
|
|
||||||
go collector.start()
|
|
||||||
} else {
|
|
||||||
slog.Warn("Failed to initialize NVML, falling back to nvidia-smi", "err", err)
|
|
||||||
gm.nvidiaSmi = true
|
|
||||||
gm.startCollector(nvidiaSmiCmd)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
gm.startCollector(nvidiaSmiCmd)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if gm.rocmSmi {
|
if gm.rocmSmi {
|
||||||
gm.startCollector(rocmSmiCmd)
|
gm.startCollector(rocmSmiCmd)
|
||||||
@@ -494,9 +343,6 @@ func NewGPUManager() (*GPUManager, error) {
|
|||||||
if gm.tegrastats {
|
if gm.tegrastats {
|
||||||
gm.startCollector(tegraStatsCmd)
|
gm.startCollector(tegraStatsCmd)
|
||||||
}
|
}
|
||||||
if gm.intelGpuStats {
|
|
||||||
gm.startCollector(intelGpuStatsCmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &gm, nil
|
return &gm, nil
|
||||||
}
|
}
|
||||||
793
beszel/internal/agent/gpu_test.go
Normal file
793
beszel/internal/agent/gpu_test.go
Normal file
@@ -0,0 +1,793 @@
|
|||||||
|
//go:build testing
|
||||||
|
// +build testing
|
||||||
|
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"beszel/internal/entities/system"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseNvidiaData(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
wantData map[string]system.GPUData
|
||||||
|
wantValid bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid multi-gpu data",
|
||||||
|
input: "0, NVIDIA GeForce RTX 3050 Ti Laptop GPU, 48, 12, 4096, 26.3, 12.73\n1, NVIDIA A100-PCIE-40GB, 38, 74, 40960, [N/A], 36.79",
|
||||||
|
wantData: map[string]system.GPUData{
|
||||||
|
"0": {
|
||||||
|
Name: "GeForce RTX 3050 Ti",
|
||||||
|
Temperature: 48.0,
|
||||||
|
MemoryUsed: 12.0 / 1.024,
|
||||||
|
MemoryTotal: 4096.0 / 1.024,
|
||||||
|
Usage: 26.3,
|
||||||
|
Power: 12.73,
|
||||||
|
Count: 1,
|
||||||
|
},
|
||||||
|
"1": {
|
||||||
|
Name: "A100-PCIE-40GB",
|
||||||
|
Temperature: 38.0,
|
||||||
|
MemoryUsed: 74.0 / 1.024,
|
||||||
|
MemoryTotal: 40960.0 / 1.024,
|
||||||
|
Usage: 0.0,
|
||||||
|
Power: 36.79,
|
||||||
|
Count: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantValid: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "more valid multi-gpu data",
|
||||||
|
input: `0, NVIDIA A10, 45, 19676, 23028, 0, 58.98
|
||||||
|
1, NVIDIA A10, 45, 19638, 23028, 0, 62.35
|
||||||
|
2, NVIDIA A10, 44, 21700, 23028, 0, 59.57
|
||||||
|
3, NVIDIA A10, 45, 18222, 23028, 0, 61.76`,
|
||||||
|
wantData: map[string]system.GPUData{
|
||||||
|
"0": {
|
||||||
|
Name: "A10",
|
||||||
|
Temperature: 45.0,
|
||||||
|
MemoryUsed: 19676.0 / 1.024,
|
||||||
|
MemoryTotal: 23028.0 / 1.024,
|
||||||
|
Usage: 0.0,
|
||||||
|
Power: 58.98,
|
||||||
|
Count: 1,
|
||||||
|
},
|
||||||
|
"1": {
|
||||||
|
Name: "A10",
|
||||||
|
Temperature: 45.0,
|
||||||
|
MemoryUsed: 19638.0 / 1.024,
|
||||||
|
MemoryTotal: 23028.0 / 1.024,
|
||||||
|
Usage: 0.0,
|
||||||
|
Power: 62.35,
|
||||||
|
Count: 1,
|
||||||
|
},
|
||||||
|
"2": {
|
||||||
|
Name: "A10",
|
||||||
|
Temperature: 44.0,
|
||||||
|
MemoryUsed: 21700.0 / 1.024,
|
||||||
|
MemoryTotal: 23028.0 / 1.024,
|
||||||
|
Usage: 0.0,
|
||||||
|
Power: 59.57,
|
||||||
|
Count: 1,
|
||||||
|
},
|
||||||
|
"3": {
|
||||||
|
Name: "A10",
|
||||||
|
Temperature: 45.0,
|
||||||
|
MemoryUsed: 18222.0 / 1.024,
|
||||||
|
MemoryTotal: 23028.0 / 1.024,
|
||||||
|
Usage: 0.0,
|
||||||
|
Power: 61.76,
|
||||||
|
Count: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantValid: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty input",
|
||||||
|
input: "",
|
||||||
|
wantData: map[string]system.GPUData{},
|
||||||
|
wantValid: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "malformed data",
|
||||||
|
input: "bad, data, here",
|
||||||
|
wantData: map[string]system.GPUData{},
|
||||||
|
wantValid: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
gm := &GPUManager{
|
||||||
|
GpuDataMap: make(map[string]*system.GPUData),
|
||||||
|
}
|
||||||
|
valid := gm.parseNvidiaData([]byte(tt.input))
|
||||||
|
assert.Equal(t, tt.wantValid, valid)
|
||||||
|
|
||||||
|
if tt.wantValid {
|
||||||
|
for id, want := range tt.wantData {
|
||||||
|
got := gm.GpuDataMap[id]
|
||||||
|
require.NotNil(t, got)
|
||||||
|
assert.Equal(t, want.Name, got.Name)
|
||||||
|
assert.InDelta(t, want.Temperature, got.Temperature, 0.01)
|
||||||
|
assert.InDelta(t, want.MemoryUsed, got.MemoryUsed, 0.01)
|
||||||
|
assert.InDelta(t, want.MemoryTotal, got.MemoryTotal, 0.01)
|
||||||
|
assert.InDelta(t, want.Usage, got.Usage, 0.01)
|
||||||
|
assert.InDelta(t, want.Power, got.Power, 0.01)
|
||||||
|
assert.Equal(t, want.Count, got.Count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseAmdData(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
wantData map[string]system.GPUData
|
||||||
|
wantValid bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid single gpu data",
|
||||||
|
input: `{
|
||||||
|
"card0": {
|
||||||
|
"GUID": "34756",
|
||||||
|
"Temperature (Sensor edge) (C)": "47.0",
|
||||||
|
"Current Socket Graphics Package Power (W)": "9.215",
|
||||||
|
"GPU use (%)": "0",
|
||||||
|
"VRAM Total Memory (B)": "536870912",
|
||||||
|
"VRAM Total Used Memory (B)": "482263040",
|
||||||
|
"Card Series": "Rembrandt [Radeon 680M]"
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
wantData: map[string]system.GPUData{
|
||||||
|
"34756": {
|
||||||
|
Name: "Rembrandt [Radeon 680M]",
|
||||||
|
Temperature: 47.0,
|
||||||
|
MemoryUsed: 482263040.0 / (1024 * 1024),
|
||||||
|
MemoryTotal: 536870912.0 / (1024 * 1024),
|
||||||
|
Usage: 0.0,
|
||||||
|
Power: 9.215,
|
||||||
|
Count: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantValid: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid multi gpu data",
|
||||||
|
input: `{
|
||||||
|
"card0": {
|
||||||
|
"GUID": "34756",
|
||||||
|
"Temperature (Sensor edge) (C)": "47.0",
|
||||||
|
"Current Socket Graphics Package Power (W)": "9.215",
|
||||||
|
"GPU use (%)": "0",
|
||||||
|
"VRAM Total Memory (B)": "536870912",
|
||||||
|
"VRAM Total Used Memory (B)": "482263040",
|
||||||
|
"Card Series": "Rembrandt [Radeon 680M]"
|
||||||
|
},
|
||||||
|
"card1": {
|
||||||
|
"GUID": "38294",
|
||||||
|
"Temperature (Sensor edge) (C)": "49.0",
|
||||||
|
"Temperature (Sensor junction) (C)": "49.0",
|
||||||
|
"Temperature (Sensor memory) (C)": "62.0",
|
||||||
|
"Average Graphics Package Power (W)": "19.0",
|
||||||
|
"GPU use (%)": "20.3",
|
||||||
|
"VRAM Total Memory (B)": "25753026560",
|
||||||
|
"VRAM Total Used Memory (B)": "794341376",
|
||||||
|
"Card Series": "Navi 31 [Radeon RX 7900 XT]"
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
wantData: map[string]system.GPUData{
|
||||||
|
"34756": {
|
||||||
|
Name: "Rembrandt [Radeon 680M]",
|
||||||
|
Temperature: 47.0,
|
||||||
|
MemoryUsed: 482263040.0 / (1024 * 1024),
|
||||||
|
MemoryTotal: 536870912.0 / (1024 * 1024),
|
||||||
|
Usage: 0.0,
|
||||||
|
Power: 9.215,
|
||||||
|
Count: 1,
|
||||||
|
},
|
||||||
|
"38294": {
|
||||||
|
Name: "Navi 31 [Radeon RX 7900 XT]",
|
||||||
|
Temperature: 49.0,
|
||||||
|
MemoryUsed: 794341376.0 / (1024 * 1024),
|
||||||
|
MemoryTotal: 25753026560.0 / (1024 * 1024),
|
||||||
|
Usage: 20.3,
|
||||||
|
Power: 19.0,
|
||||||
|
Count: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantValid: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid json",
|
||||||
|
input: "{bad json",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid json",
|
||||||
|
input: "{bad json",
|
||||||
|
wantData: map[string]system.GPUData{},
|
||||||
|
wantValid: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
gm := &GPUManager{
|
||||||
|
GpuDataMap: make(map[string]*system.GPUData),
|
||||||
|
}
|
||||||
|
valid := gm.parseAmdData([]byte(tt.input))
|
||||||
|
assert.Equal(t, tt.wantValid, valid)
|
||||||
|
|
||||||
|
if tt.wantValid {
|
||||||
|
for id, want := range tt.wantData {
|
||||||
|
got := gm.GpuDataMap[id]
|
||||||
|
require.NotNil(t, got)
|
||||||
|
assert.Equal(t, want.Name, got.Name)
|
||||||
|
assert.InDelta(t, want.Temperature, got.Temperature, 0.01)
|
||||||
|
assert.InDelta(t, want.MemoryUsed, got.MemoryUsed, 0.01)
|
||||||
|
assert.InDelta(t, want.MemoryTotal, got.MemoryTotal, 0.01)
|
||||||
|
assert.InDelta(t, want.Usage, got.Usage, 0.01)
|
||||||
|
assert.InDelta(t, want.Power, got.Power, 0.01)
|
||||||
|
assert.Equal(t, want.Count, got.Count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseJetsonData(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
wantMetrics *system.GPUData
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid data",
|
||||||
|
input: "11-14-2024 22:54:33 RAM 4300/30698MB GR3D_FREQ 45% tj@52.468C VDD_GPU_SOC 2171mW",
|
||||||
|
wantMetrics: &system.GPUData{
|
||||||
|
Name: "GPU",
|
||||||
|
MemoryUsed: 4300.0,
|
||||||
|
MemoryTotal: 30698.0,
|
||||||
|
Usage: 45.0,
|
||||||
|
Temperature: 52.468,
|
||||||
|
Power: 2.171,
|
||||||
|
Count: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "more valid data",
|
||||||
|
input: "11-15-2024 08:38:09 RAM 6185/7620MB (lfb 8x2MB) SWAP 851/3810MB (cached 1MB) CPU [15%@729,11%@729,14%@729,13%@729,11%@729,8%@729] EMC_FREQ 43%@2133 GR3D_FREQ 63%@[621] NVDEC off NVJPG off NVJPG1 off VIC off OFA off APE 200 cpu@53.968C soc2@52.437C soc0@50.75C gpu@53.343C tj@53.968C soc1@51.656C VDD_IN 12479mW/12479mW VDD_CPU_GPU_CV 4667mW/4667mW VDD_SOC 2817mW/2817mW",
|
||||||
|
wantMetrics: &system.GPUData{
|
||||||
|
Name: "GPU",
|
||||||
|
MemoryUsed: 6185.0,
|
||||||
|
MemoryTotal: 7620.0,
|
||||||
|
Usage: 63.0,
|
||||||
|
Temperature: 53.968,
|
||||||
|
Power: 4.667,
|
||||||
|
Count: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "orin nano",
|
||||||
|
input: "06-18-2025 11:25:24 RAM 3452/7620MB (lfb 25x4MB) SWAP 1518/16384MB (cached 174MB) CPU [1%@1420,2%@1420,0%@1420,2%@1420,2%@729,1%@729] GR3D_FREQ 0% cpu@50.031C soc2@49.031C soc0@50C gpu@49.031C tj@50.25C soc1@50.25C VDD_IN 4824mW/4824mW VDD_CPU_GPU_CV 518mW/518mW VDD_SOC 1475mW/1475mW",
|
||||||
|
wantMetrics: &system.GPUData{
|
||||||
|
Name: "GPU",
|
||||||
|
MemoryUsed: 3452.0,
|
||||||
|
MemoryTotal: 7620.0,
|
||||||
|
Usage: 0.0,
|
||||||
|
Temperature: 50.25,
|
||||||
|
Power: 0.518,
|
||||||
|
Count: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing temperature",
|
||||||
|
input: "11-14-2024 22:54:33 RAM 4300/30698MB GR3D_FREQ 45% VDD_GPU_SOC 2171mW",
|
||||||
|
wantMetrics: &system.GPUData{
|
||||||
|
Name: "GPU",
|
||||||
|
MemoryUsed: 4300.0,
|
||||||
|
MemoryTotal: 30698.0,
|
||||||
|
Usage: 45.0,
|
||||||
|
Power: 2.171,
|
||||||
|
Count: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
gm := &GPUManager{
|
||||||
|
GpuDataMap: make(map[string]*system.GPUData),
|
||||||
|
}
|
||||||
|
parser := gm.getJetsonParser()
|
||||||
|
valid := parser([]byte(tt.input))
|
||||||
|
assert.Equal(t, true, valid)
|
||||||
|
|
||||||
|
got := gm.GpuDataMap["0"]
|
||||||
|
require.NotNil(t, got)
|
||||||
|
assert.Equal(t, tt.wantMetrics.Name, got.Name)
|
||||||
|
assert.InDelta(t, tt.wantMetrics.MemoryUsed, got.MemoryUsed, 0.01)
|
||||||
|
assert.InDelta(t, tt.wantMetrics.MemoryTotal, got.MemoryTotal, 0.01)
|
||||||
|
assert.InDelta(t, tt.wantMetrics.Usage, got.Usage, 0.01)
|
||||||
|
if tt.wantMetrics.Temperature > 0 {
|
||||||
|
assert.InDelta(t, tt.wantMetrics.Temperature, got.Temperature, 0.01)
|
||||||
|
}
|
||||||
|
assert.InDelta(t, tt.wantMetrics.Power, got.Power, 0.01)
|
||||||
|
assert.Equal(t, tt.wantMetrics.Count, got.Count)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetCurrentData(t *testing.T) {
|
||||||
|
t.Run("calculates averages and resets accumulators", func(t *testing.T) {
|
||||||
|
gm := &GPUManager{
|
||||||
|
GpuDataMap: map[string]*system.GPUData{
|
||||||
|
"0": {
|
||||||
|
Name: "GPU1",
|
||||||
|
Temperature: 50,
|
||||||
|
MemoryUsed: 2048,
|
||||||
|
MemoryTotal: 4096,
|
||||||
|
Usage: 100, // 100 over 2 counts = 50 avg
|
||||||
|
Power: 200, // 200 over 2 counts = 100 avg
|
||||||
|
Count: 2,
|
||||||
|
},
|
||||||
|
"1": {
|
||||||
|
Name: "GPU1",
|
||||||
|
Temperature: 60,
|
||||||
|
MemoryUsed: 3072,
|
||||||
|
MemoryTotal: 8192,
|
||||||
|
Usage: 30,
|
||||||
|
Power: 60,
|
||||||
|
Count: 1,
|
||||||
|
},
|
||||||
|
"2": {
|
||||||
|
Name: "GPU 2",
|
||||||
|
Temperature: 70,
|
||||||
|
MemoryUsed: 4096,
|
||||||
|
MemoryTotal: 8192,
|
||||||
|
Usage: 200,
|
||||||
|
Power: 400,
|
||||||
|
Count: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := gm.GetCurrentData()
|
||||||
|
|
||||||
|
// Verify name disambiguation
|
||||||
|
assert.Equal(t, "GPU1 0", result["0"].Name)
|
||||||
|
assert.Equal(t, "GPU1 1", result["1"].Name)
|
||||||
|
assert.Equal(t, "GPU 2", result["2"].Name)
|
||||||
|
|
||||||
|
// Check averaged values in the result
|
||||||
|
assert.InDelta(t, 50.0, result["0"].Usage, 0.01)
|
||||||
|
assert.InDelta(t, 100.0, result["0"].Power, 0.01)
|
||||||
|
assert.InDelta(t, 30.0, result["1"].Usage, 0.01)
|
||||||
|
assert.InDelta(t, 60.0, result["1"].Power, 0.01)
|
||||||
|
|
||||||
|
// Verify that accumulators in the original map are reset
|
||||||
|
assert.Equal(t, float64(0), gm.GpuDataMap["0"].Count, "GPU 0 Count should be reset")
|
||||||
|
assert.Equal(t, float64(0), gm.GpuDataMap["0"].Usage, "GPU 0 Usage should be reset")
|
||||||
|
assert.Equal(t, float64(0), gm.GpuDataMap["0"].Power, "GPU 0 Power should be reset")
|
||||||
|
assert.Equal(t, float64(0), gm.GpuDataMap["1"].Count, "GPU 1 Count should be reset")
|
||||||
|
assert.Equal(t, float64(0), gm.GpuDataMap["1"].Usage, "GPU 1 Usage should be reset")
|
||||||
|
assert.Equal(t, float64(0), gm.GpuDataMap["1"].Power, "GPU 1 Power should be reset")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("handles zero count without panicking", func(t *testing.T) {
|
||||||
|
gm := &GPUManager{
|
||||||
|
GpuDataMap: map[string]*system.GPUData{
|
||||||
|
"0": {
|
||||||
|
Name: "TestGPU",
|
||||||
|
Count: 0,
|
||||||
|
Usage: 0,
|
||||||
|
Power: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var result map[string]system.GPUData
|
||||||
|
assert.NotPanics(t, func() {
|
||||||
|
result = gm.GetCurrentData()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Check that usage and power are 0
|
||||||
|
assert.Equal(t, 0.0, result["0"].Usage)
|
||||||
|
assert.Equal(t, 0.0, result["0"].Power)
|
||||||
|
|
||||||
|
// Verify reset count
|
||||||
|
assert.Equal(t, float64(0), gm.GpuDataMap["0"].Count)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectGPUs(t *testing.T) {
|
||||||
|
// Save original PATH
|
||||||
|
origPath := os.Getenv("PATH")
|
||||||
|
defer os.Setenv("PATH", origPath)
|
||||||
|
|
||||||
|
// Set up temp dir with the commands
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
os.Setenv("PATH", tempDir)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
setupCommands func() error
|
||||||
|
wantNvidiaSmi bool
|
||||||
|
wantRocmSmi bool
|
||||||
|
wantTegrastats bool
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "nvidia-smi not available",
|
||||||
|
setupCommands: func() error {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
wantNvidiaSmi: false,
|
||||||
|
wantRocmSmi: false,
|
||||||
|
wantTegrastats: false,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nvidia-smi available",
|
||||||
|
setupCommands: func() error {
|
||||||
|
path := filepath.Join(tempDir, "nvidia-smi")
|
||||||
|
script := `#!/bin/sh
|
||||||
|
echo "test"`
|
||||||
|
if err := os.WriteFile(path, []byte(script), 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
wantNvidiaSmi: true,
|
||||||
|
wantTegrastats: false,
|
||||||
|
wantRocmSmi: false,
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "rocm-smi available",
|
||||||
|
setupCommands: func() error {
|
||||||
|
path := filepath.Join(tempDir, "rocm-smi")
|
||||||
|
script := `#!/bin/sh
|
||||||
|
echo "test"`
|
||||||
|
if err := os.WriteFile(path, []byte(script), 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
wantNvidiaSmi: true,
|
||||||
|
wantRocmSmi: true,
|
||||||
|
wantTegrastats: false,
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tegrastats available",
|
||||||
|
setupCommands: func() error {
|
||||||
|
path := filepath.Join(tempDir, "tegrastats")
|
||||||
|
script := `#!/bin/sh
|
||||||
|
echo "test"`
|
||||||
|
if err := os.WriteFile(path, []byte(script), 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
wantNvidiaSmi: false,
|
||||||
|
wantRocmSmi: true,
|
||||||
|
wantTegrastats: true,
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no gpu tools available",
|
||||||
|
setupCommands: func() error {
|
||||||
|
os.Setenv("PATH", "")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if err := tt.setupCommands(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gm := &GPUManager{}
|
||||||
|
err := gm.detectGPUs()
|
||||||
|
|
||||||
|
t.Logf("nvidiaSmi: %v, rocmSmi: %v, tegrastats: %v", gm.nvidiaSmi, gm.rocmSmi, gm.tegrastats)
|
||||||
|
|
||||||
|
if tt.wantErr {
|
||||||
|
assert.Error(t, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, tt.wantNvidiaSmi, gm.nvidiaSmi)
|
||||||
|
assert.Equal(t, tt.wantRocmSmi, gm.rocmSmi)
|
||||||
|
assert.Equal(t, tt.wantTegrastats, gm.tegrastats)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartCollector(t *testing.T) {
|
||||||
|
// Save original PATH
|
||||||
|
origPath := os.Getenv("PATH")
|
||||||
|
defer os.Setenv("PATH", origPath)
|
||||||
|
|
||||||
|
// Set up temp dir with the commands
|
||||||
|
dir := t.TempDir()
|
||||||
|
os.Setenv("PATH", dir)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
command string
|
||||||
|
setup func(t *testing.T) error
|
||||||
|
validate func(t *testing.T, gm *GPUManager)
|
||||||
|
gm *GPUManager
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "nvidia-smi collector",
|
||||||
|
command: "nvidia-smi",
|
||||||
|
setup: func(t *testing.T) error {
|
||||||
|
path := filepath.Join(dir, "nvidia-smi")
|
||||||
|
script := `#!/bin/sh
|
||||||
|
echo "0, NVIDIA Test GPU, 50, 1024, 4096, 25, 100"`
|
||||||
|
if err := os.WriteFile(path, []byte(script), 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
validate: func(t *testing.T, gm *GPUManager) {
|
||||||
|
gpu, exists := gm.GpuDataMap["0"]
|
||||||
|
assert.True(t, exists)
|
||||||
|
if exists {
|
||||||
|
assert.Equal(t, "Test GPU", gpu.Name)
|
||||||
|
assert.Equal(t, 50.0, gpu.Temperature)
|
||||||
|
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "rocm-smi collector",
|
||||||
|
command: "rocm-smi",
|
||||||
|
setup: func(t *testing.T) error {
|
||||||
|
path := filepath.Join(dir, "rocm-smi")
|
||||||
|
script := `#!/bin/sh
|
||||||
|
echo '{"card0": {"Temperature (Sensor edge) (C)": "49.0", "Current Socket Graphics Package Power (W)": "28.159", "GPU use (%)": "0", "VRAM Total Memory (B)": "536870912", "VRAM Total Used Memory (B)": "445550592", "Card Series": "Rembrandt [Radeon 680M]", "Card Model": "0x1681", "Card Vendor": "Advanced Micro Devices, Inc. [AMD/ATI]", "Card SKU": "REMBRANDT", "Subsystem ID": "0x8a22", "Device Rev": "0xc8", "Node ID": "1", "GUID": "34756", "GFX Version": "gfx1035"}}'`
|
||||||
|
if err := os.WriteFile(path, []byte(script), 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
validate: func(t *testing.T, gm *GPUManager) {
|
||||||
|
gpu, exists := gm.GpuDataMap["34756"]
|
||||||
|
assert.True(t, exists)
|
||||||
|
if exists {
|
||||||
|
assert.Equal(t, "Rembrandt [Radeon 680M]", gpu.Name)
|
||||||
|
assert.InDelta(t, 49.0, gpu.Temperature, 0.01)
|
||||||
|
assert.InDelta(t, 28.159, gpu.Power, 0.01)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tegrastats collector",
|
||||||
|
command: "tegrastats",
|
||||||
|
setup: func(t *testing.T) error {
|
||||||
|
path := filepath.Join(dir, "tegrastats")
|
||||||
|
script := `#!/bin/sh
|
||||||
|
echo "11-14-2024 22:54:33 RAM 1024/4096MB GR3D_FREQ 80% tj@70C VDD_GPU_SOC 1000mW"`
|
||||||
|
if err := os.WriteFile(path, []byte(script), 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
validate: func(t *testing.T, gm *GPUManager) {
|
||||||
|
gpu, exists := gm.GpuDataMap["0"]
|
||||||
|
assert.True(t, exists)
|
||||||
|
if exists {
|
||||||
|
assert.InDelta(t, 70.0, gpu.Temperature, 0.1)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
gm: &GPUManager{
|
||||||
|
GpuDataMap: map[string]*system.GPUData{
|
||||||
|
"0": {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if err := tt.setup(t); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if tt.gm == nil {
|
||||||
|
tt.gm = &GPUManager{
|
||||||
|
GpuDataMap: make(map[string]*system.GPUData),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tt.gm.startCollector(tt.command)
|
||||||
|
time.Sleep(50 * time.Millisecond) // Give collector time to run
|
||||||
|
tt.validate(t, tt.gm)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAccumulationTableDriven tests the accumulation behavior for all three GPU types
|
||||||
|
func TestAccumulation(t *testing.T) {
|
||||||
|
type expectedGPUValues struct {
|
||||||
|
temperature float64
|
||||||
|
memoryUsed float64
|
||||||
|
memoryTotal float64
|
||||||
|
usage float64
|
||||||
|
power float64
|
||||||
|
count float64
|
||||||
|
avgUsage float64
|
||||||
|
avgPower float64
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
initialGPUData map[string]*system.GPUData
|
||||||
|
dataSamples [][]byte
|
||||||
|
parser func(*GPUManager) func([]byte) bool
|
||||||
|
expectedValues map[string]expectedGPUValues
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Jetson GPU accumulation",
|
||||||
|
initialGPUData: map[string]*system.GPUData{
|
||||||
|
"0": {
|
||||||
|
Name: "Jetson",
|
||||||
|
Temperature: 0,
|
||||||
|
Usage: 0,
|
||||||
|
Power: 0,
|
||||||
|
Count: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
dataSamples: [][]byte{
|
||||||
|
[]byte("11-14-2024 22:54:33 RAM 1024/4096MB GR3D_FREQ 30% tj@50.5C VDD_GPU_SOC 1000mW"),
|
||||||
|
[]byte("11-14-2024 22:54:33 RAM 1024/4096MB GR3D_FREQ 40% tj@60.5C VDD_GPU_SOC 1200mW"),
|
||||||
|
[]byte("11-14-2024 22:54:33 RAM 1024/4096MB GR3D_FREQ 50% tj@70.5C VDD_GPU_SOC 1400mW"),
|
||||||
|
},
|
||||||
|
parser: func(gm *GPUManager) func([]byte) bool {
|
||||||
|
return gm.getJetsonParser()
|
||||||
|
},
|
||||||
|
expectedValues: map[string]expectedGPUValues{
|
||||||
|
"0": {
|
||||||
|
temperature: 70.5, // Last value
|
||||||
|
memoryUsed: 1024, // Last value
|
||||||
|
memoryTotal: 4096, // Last value
|
||||||
|
usage: 120.0, // Accumulated: 30 + 40 + 50
|
||||||
|
power: 3.6, // Accumulated: 1.0 + 1.2 + 1.4
|
||||||
|
count: 3,
|
||||||
|
avgUsage: 40.0, // 120 / 3
|
||||||
|
avgPower: 1.2, // 3.6 / 3
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "NVIDIA GPU accumulation",
|
||||||
|
initialGPUData: map[string]*system.GPUData{
|
||||||
|
// NVIDIA parser will create the GPU data entries
|
||||||
|
},
|
||||||
|
dataSamples: [][]byte{
|
||||||
|
[]byte("0, NVIDIA GeForce RTX 3080, 50, 5000, 10000, 30, 200"),
|
||||||
|
[]byte("0, NVIDIA GeForce RTX 3080, 60, 6000, 10000, 40, 250"),
|
||||||
|
[]byte("0, NVIDIA GeForce RTX 3080, 70, 7000, 10000, 50, 300"),
|
||||||
|
},
|
||||||
|
parser: func(gm *GPUManager) func([]byte) bool {
|
||||||
|
return gm.parseNvidiaData
|
||||||
|
},
|
||||||
|
expectedValues: map[string]expectedGPUValues{
|
||||||
|
"0": {
|
||||||
|
temperature: 70.0, // Last value
|
||||||
|
memoryUsed: 7000.0 / 1.024, // Last value
|
||||||
|
memoryTotal: 10000.0 / 1.024, // Last value
|
||||||
|
usage: 120.0, // Accumulated: 30 + 40 + 50
|
||||||
|
power: 750.0, // Accumulated: 200 + 250 + 300
|
||||||
|
count: 3,
|
||||||
|
avgUsage: 40.0, // 120 / 3
|
||||||
|
avgPower: 250.0, // 750 / 3
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "AMD GPU accumulation",
|
||||||
|
initialGPUData: map[string]*system.GPUData{
|
||||||
|
// AMD parser will create the GPU data entries
|
||||||
|
},
|
||||||
|
dataSamples: [][]byte{
|
||||||
|
[]byte(`{"card0": {"GUID": "34756", "Temperature (Sensor edge) (C)": "50.0", "Current Socket Graphics Package Power (W)": "100.0", "GPU use (%)": "30", "VRAM Total Memory (B)": "10737418240", "VRAM Total Used Memory (B)": "1073741824", "Card Series": "Radeon RX 6800"}}`),
|
||||||
|
[]byte(`{"card0": {"GUID": "34756", "Temperature (Sensor edge) (C)": "60.0", "Current Socket Graphics Package Power (W)": "150.0", "GPU use (%)": "40", "VRAM Total Memory (B)": "10737418240", "VRAM Total Used Memory (B)": "2147483648", "Card Series": "Radeon RX 6800"}}`),
|
||||||
|
[]byte(`{"card0": {"GUID": "34756", "Temperature (Sensor edge) (C)": "70.0", "Current Socket Graphics Package Power (W)": "200.0", "GPU use (%)": "50", "VRAM Total Memory (B)": "10737418240", "VRAM Total Used Memory (B)": "3221225472", "Card Series": "Radeon RX 6800"}}`),
|
||||||
|
},
|
||||||
|
parser: func(gm *GPUManager) func([]byte) bool {
|
||||||
|
return gm.parseAmdData
|
||||||
|
},
|
||||||
|
expectedValues: map[string]expectedGPUValues{
|
||||||
|
"34756": {
|
||||||
|
temperature: 70.0, // Last value
|
||||||
|
memoryUsed: 3221225472.0 / (1024 * 1024), // Last value
|
||||||
|
memoryTotal: 10737418240.0 / (1024 * 1024), // Last value
|
||||||
|
usage: 120.0, // Accumulated: 30 + 40 + 50
|
||||||
|
power: 450.0, // Accumulated: 100 + 150 + 200
|
||||||
|
count: 3,
|
||||||
|
avgUsage: 40.0, // 120 / 3
|
||||||
|
avgPower: 150.0, // 450 / 3
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// Create a new GPUManager for each test
|
||||||
|
gm := &GPUManager{
|
||||||
|
GpuDataMap: tt.initialGPUData,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the parser function
|
||||||
|
parser := tt.parser(gm)
|
||||||
|
|
||||||
|
// Process each data sample
|
||||||
|
for i, sample := range tt.dataSamples {
|
||||||
|
valid := parser(sample)
|
||||||
|
assert.True(t, valid, "Sample %d should be valid", i)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check accumulated values
|
||||||
|
for id, expected := range tt.expectedValues {
|
||||||
|
gpu, exists := gm.GpuDataMap[id]
|
||||||
|
assert.True(t, exists, "GPU with ID %s should exist", id)
|
||||||
|
if !exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.InDelta(t, expected.temperature, gpu.Temperature, 0.01, "Temperature should match")
|
||||||
|
assert.InDelta(t, expected.memoryUsed, gpu.MemoryUsed, 0.01, "Memory used should match")
|
||||||
|
assert.InDelta(t, expected.memoryTotal, gpu.MemoryTotal, 0.01, "Memory total should match")
|
||||||
|
assert.InDelta(t, expected.usage, gpu.Usage, 0.01, "Usage should match")
|
||||||
|
assert.InDelta(t, expected.power, gpu.Power, 0.01, "Power should match")
|
||||||
|
assert.Equal(t, expected.count, gpu.Count, "Count should match")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify average calculation in GetCurrentData
|
||||||
|
result := gm.GetCurrentData()
|
||||||
|
for id, expected := range tt.expectedValues {
|
||||||
|
gpu, exists := result[id]
|
||||||
|
assert.True(t, exists, "GPU with ID %s should exist in GetCurrentData result", id)
|
||||||
|
if !exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.InDelta(t, expected.temperature, gpu.Temperature, 0.01, "Temperature in GetCurrentData should match")
|
||||||
|
assert.InDelta(t, expected.avgUsage, gpu.Usage, 0.01, "Average usage in GetCurrentData should match")
|
||||||
|
assert.InDelta(t, expected.avgPower, gpu.Power, 0.01, "Average power in GetCurrentData should match")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify that accumulators in the original map are reset
|
||||||
|
for id := range tt.expectedValues {
|
||||||
|
gpu, exists := gm.GpuDataMap[id]
|
||||||
|
assert.True(t, exists, "GPU with ID %s should still exist after GetCurrentData", id)
|
||||||
|
if !exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
assert.Equal(t, float64(0), gpu.Count, "Count should be reset for GPU ID %s", id)
|
||||||
|
assert.Equal(t, float64(0), gpu.Usage, "Usage should be reset for GPU ID %s", id)
|
||||||
|
assert.Equal(t, float64(0), gpu.Power, "Power should be reset for GPU ID %s", id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,7 +39,7 @@ func TestHealth(t *testing.T) {
|
|||||||
// This test uses synctest to simulate time passing.
|
// This test uses synctest to simulate time passing.
|
||||||
// NOTE: This test requires GOEXPERIMENT=synctest to run.
|
// NOTE: This test requires GOEXPERIMENT=synctest to run.
|
||||||
t.Run("check with simulated time", func(t *testing.T) {
|
t.Run("check with simulated time", func(t *testing.T) {
|
||||||
synctest.Test(t, func(t *testing.T) {
|
synctest.Run(func() {
|
||||||
// Update the file to set the initial timestamp.
|
// Update the file to set the initial timestamp.
|
||||||
require.NoError(t, Update(), "Update() failed inside synctest")
|
require.NoError(t, Update(), "Update() failed inside synctest")
|
||||||
|
|
||||||
67
beszel/internal/agent/network.go
Normal file
67
beszel/internal/agent/network.go
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
psutilNet "github.com/shirou/gopsutil/v4/net"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a *Agent) initializeNetIoStats() {
|
||||||
|
// reset valid network interfaces
|
||||||
|
a.netInterfaces = make(map[string]struct{}, 0)
|
||||||
|
|
||||||
|
// map of network interface names passed in via NICS env var
|
||||||
|
var nicsMap map[string]struct{}
|
||||||
|
nics, nicsEnvExists := GetEnv("NICS")
|
||||||
|
if nicsEnvExists {
|
||||||
|
nicsMap = make(map[string]struct{}, 0)
|
||||||
|
for nic := range strings.SplitSeq(nics, ",") {
|
||||||
|
nicsMap[nic] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// reset network I/O stats
|
||||||
|
a.netIoStats.BytesSent = 0
|
||||||
|
a.netIoStats.BytesRecv = 0
|
||||||
|
|
||||||
|
// get intial network I/O stats
|
||||||
|
if netIO, err := psutilNet.IOCounters(true); err == nil {
|
||||||
|
a.netIoStats.Time = time.Now()
|
||||||
|
for _, v := range netIO {
|
||||||
|
switch {
|
||||||
|
// skip if nics exists and the interface is not in the list
|
||||||
|
case nicsEnvExists:
|
||||||
|
if _, nameInNics := nicsMap[v.Name]; !nameInNics {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// otherwise run the interface name through the skipNetworkInterface function
|
||||||
|
default:
|
||||||
|
if a.skipNetworkInterface(v) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slog.Info("Detected network interface", "name", v.Name, "sent", v.BytesSent, "recv", v.BytesRecv)
|
||||||
|
a.netIoStats.BytesSent += v.BytesSent
|
||||||
|
a.netIoStats.BytesRecv += v.BytesRecv
|
||||||
|
// store as a valid network interface
|
||||||
|
a.netInterfaces[v.Name] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Agent) skipNetworkInterface(v psutilNet.IOCountersStat) bool {
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(v.Name, "lo"),
|
||||||
|
strings.HasPrefix(v.Name, "docker"),
|
||||||
|
strings.HasPrefix(v.Name, "br-"),
|
||||||
|
strings.HasPrefix(v.Name, "veth"),
|
||||||
|
strings.HasPrefix(v.Name, "bond"),
|
||||||
|
v.BytesRecv == 0,
|
||||||
|
v.BytesSent == 0:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"beszel/internal/entities/system"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -10,8 +11,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
|
||||||
|
|
||||||
"github.com/shirou/gopsutil/v4/common"
|
"github.com/shirou/gopsutil/v4/common"
|
||||||
"github.com/shirou/gopsutil/v4/sensors"
|
"github.com/shirou/gopsutil/v4/sensors"
|
||||||
)
|
)
|
||||||
@@ -4,13 +4,12 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"beszel/internal/entities/system"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
|
||||||
|
|
||||||
"github.com/shirou/gopsutil/v4/common"
|
"github.com/shirou/gopsutil/v4/common"
|
||||||
"github.com/shirou/gopsutil/v4/sensors"
|
"github.com/shirou/gopsutil/v4/sensors"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -46,10 +46,9 @@ var lhmFs embed.FS
|
|||||||
var (
|
var (
|
||||||
beszelLhm *lhmProcess
|
beszelLhm *lhmProcess
|
||||||
beszelLhmOnce sync.Once
|
beszelLhmOnce sync.Once
|
||||||
useLHM = os.Getenv("LHM") == "true"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var errNoSensors = errors.New("no sensors found (try running as admin with LHM=true)")
|
var errNoSensors = errors.New("no sensors found (try running as admin)")
|
||||||
|
|
||||||
// newlhmProcess copies the embedded LHM executable to a temporary directory and starts it.
|
// newlhmProcess copies the embedded LHM executable to a temporary directory and starts it.
|
||||||
func newlhmProcess() (*lhmProcess, error) {
|
func newlhmProcess() (*lhmProcess, error) {
|
||||||
@@ -140,7 +139,7 @@ func (lhm *lhmProcess) cleanupProcess() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (lhm *lhmProcess) getTemps(ctx context.Context) (temps []sensors.TemperatureStat, err error) {
|
func (lhm *lhmProcess) getTemps(ctx context.Context) (temps []sensors.TemperatureStat, err error) {
|
||||||
if !useLHM || lhm.stoppedNoSensors {
|
if lhm.stoppedNoSensors {
|
||||||
// Fall back to gopsutil if we can't get sensors from LHM
|
// Fall back to gopsutil if we can't get sensors from LHM
|
||||||
return sensors.TemperaturesWithContext(ctx)
|
return sensors.TemperaturesWithContext(ctx)
|
||||||
}
|
}
|
||||||
@@ -223,10 +222,6 @@ func getSensorTemps(ctx context.Context) (temps []sensors.TemperatureStat, err e
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
if !useLHM {
|
|
||||||
return sensors.TemperaturesWithContext(ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize process once
|
// Initialize process once
|
||||||
beszelLhmOnce.Do(func() {
|
beszelLhmOnce.Do(func() {
|
||||||
beszelLhm, err = newlhmProcess()
|
beszelLhm, err = newlhmProcess()
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"beszel"
|
||||||
|
"beszel/internal/common"
|
||||||
|
"beszel/internal/entities/system"
|
||||||
|
"encoding/json/jsontext"
|
||||||
|
"encoding/json/v2"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -11,10 +15,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel"
|
|
||||||
"github.com/henrygd/beszel/internal/common"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
|
||||||
|
|
||||||
"github.com/blang/semver"
|
"github.com/blang/semver"
|
||||||
"github.com/fxamacker/cbor/v2"
|
"github.com/fxamacker/cbor/v2"
|
||||||
"github.com/gliderlabs/ssh"
|
"github.com/gliderlabs/ssh"
|
||||||
@@ -127,70 +127,15 @@ func (a *Agent) handleSession(s ssh.Session) {
|
|||||||
|
|
||||||
hubVersion := a.getHubVersion(sessionID, sessionCtx)
|
hubVersion := a.getHubVersion(sessionID, sessionCtx)
|
||||||
|
|
||||||
// Legacy one-shot behavior for older hubs
|
stats := a.gatherStats(sessionID)
|
||||||
if hubVersion.LT(beszel.MinVersionAgentResponse) {
|
|
||||||
if err := a.handleLegacyStats(s, hubVersion); err != nil {
|
|
||||||
slog.Error("Error encoding stats", "err", err)
|
|
||||||
s.Exit(1)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var req common.HubRequest[cbor.RawMessage]
|
err := a.writeToSession(s, stats, hubVersion)
|
||||||
if err := cbor.NewDecoder(s).Decode(&req); err != nil {
|
if err != nil {
|
||||||
// Fallback to legacy one-shot if the first decode fails
|
slog.Error("Error encoding stats", "err", err, "stats", stats)
|
||||||
if err2 := a.handleLegacyStats(s, hubVersion); err2 != nil {
|
|
||||||
slog.Error("Error encoding stats (fallback)", "err", err2)
|
|
||||||
s.Exit(1)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.Exit(0)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := a.handleSSHRequest(s, &req); err != nil {
|
|
||||||
slog.Error("SSH request handling failed", "err", err)
|
|
||||||
s.Exit(1)
|
s.Exit(1)
|
||||||
return
|
} else {
|
||||||
|
s.Exit(0)
|
||||||
}
|
}
|
||||||
s.Exit(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleSSHRequest builds a handler context and dispatches to the shared registry
|
|
||||||
func (a *Agent) handleSSHRequest(w io.Writer, req *common.HubRequest[cbor.RawMessage]) error {
|
|
||||||
// SSH does not support fingerprint auth action
|
|
||||||
if req.Action == common.CheckFingerprint {
|
|
||||||
return cbor.NewEncoder(w).Encode(common.AgentResponse{Error: "unsupported action"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// responder that writes AgentResponse to stdout
|
|
||||||
// Uses legacy typed fields for backward compatibility with <= 0.17
|
|
||||||
sshResponder := func(data any, requestID *uint32) error {
|
|
||||||
response := newAgentResponse(data, requestID)
|
|
||||||
return cbor.NewEncoder(w).Encode(response)
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := &HandlerContext{
|
|
||||||
Client: nil,
|
|
||||||
Agent: a,
|
|
||||||
Request: req,
|
|
||||||
RequestID: nil,
|
|
||||||
HubVerified: true,
|
|
||||||
SendResponse: sshResponder,
|
|
||||||
}
|
|
||||||
|
|
||||||
if handler, ok := a.handlerRegistry.GetHandler(req.Action); ok {
|
|
||||||
if err := handler.Handle(ctx); err != nil {
|
|
||||||
return cbor.NewEncoder(w).Encode(common.AgentResponse{Error: err.Error()})
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return cbor.NewEncoder(w).Encode(common.AgentResponse{Error: fmt.Sprintf("unknown action: %d", req.Action)})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleLegacyStats serves the legacy one-shot stats payload for older hubs
|
|
||||||
func (a *Agent) handleLegacyStats(w io.Writer, hubVersion semver.Version) error {
|
|
||||||
stats := a.gatherStats(common.DataRequestOptions{CacheTimeMs: 60_000})
|
|
||||||
return a.writeToSession(w, stats, hubVersion)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeToSession encodes and writes system statistics to the session.
|
// writeToSession encodes and writes system statistics to the session.
|
||||||
@@ -200,7 +145,7 @@ func (a *Agent) writeToSession(w io.Writer, stats *system.CombinedData, hubVersi
|
|||||||
if hubVersion.GTE(beszel.MinVersionCbor) {
|
if hubVersion.GTE(beszel.MinVersionCbor) {
|
||||||
return cbor.NewEncoder(w).Encode(stats)
|
return cbor.NewEncoder(w).Encode(stats)
|
||||||
}
|
}
|
||||||
return json.NewEncoder(w).Encode(stats)
|
return json.MarshalEncode(jsontext.NewEncoder(w), stats)
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractHubVersion extracts the beszel version from SSH client version string.
|
// extractHubVersion extracts the beszel version from SSH client version string.
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"beszel/internal/entities/container"
|
||||||
|
"beszel/internal/entities/system"
|
||||||
"context"
|
"context"
|
||||||
"crypto/ed25519"
|
"crypto/ed25519"
|
||||||
"encoding/json"
|
"encoding/json/v2"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
@@ -13,9 +15,6 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/container"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
|
||||||
|
|
||||||
"github.com/blang/semver"
|
"github.com/blang/semver"
|
||||||
"github.com/fxamacker/cbor/v2"
|
"github.com/fxamacker/cbor/v2"
|
||||||
"github.com/gliderlabs/ssh"
|
"github.com/gliderlabs/ssh"
|
||||||
@@ -513,7 +512,7 @@ func TestWriteToSessionEncoding(t *testing.T) {
|
|||||||
err = json.Unmarshal([]byte(encodedData), &decodedJson)
|
err = json.Unmarshal([]byte(encodedData), &decodedJson)
|
||||||
assert.Error(t, err, "Should not be valid JSON data")
|
assert.Error(t, err, "Should not be valid JSON data")
|
||||||
|
|
||||||
assert.Equal(t, testData.Details.Hostname, decodedCbor.Details.Hostname)
|
assert.Equal(t, testData.Info.Hostname, decodedCbor.Info.Hostname)
|
||||||
assert.Equal(t, testData.Stats.Cpu, decodedCbor.Stats.Cpu)
|
assert.Equal(t, testData.Stats.Cpu, decodedCbor.Stats.Cpu)
|
||||||
} else {
|
} else {
|
||||||
// Should be JSON - try to decode as JSON
|
// Should be JSON - try to decode as JSON
|
||||||
@@ -526,7 +525,7 @@ func TestWriteToSessionEncoding(t *testing.T) {
|
|||||||
assert.Error(t, err, "Should not be valid CBOR data")
|
assert.Error(t, err, "Should not be valid CBOR data")
|
||||||
|
|
||||||
// Verify the decoded JSON data matches our test data
|
// Verify the decoded JSON data matches our test data
|
||||||
assert.Equal(t, testData.Details.Hostname, decodedJson.Details.Hostname)
|
assert.Equal(t, testData.Info.Hostname, decodedJson.Info.Hostname)
|
||||||
assert.Equal(t, testData.Stats.Cpu, decodedJson.Stats.Cpu)
|
assert.Equal(t, testData.Stats.Cpu, decodedJson.Stats.Cpu)
|
||||||
|
|
||||||
// Verify it looks like JSON (starts with '{' and contains readable field names)
|
// Verify it looks like JSON (starts with '{' and contains readable field names)
|
||||||
@@ -550,12 +549,13 @@ func createTestCombinedData() *system.CombinedData {
|
|||||||
DiskUsed: 549755813888, // 512GB
|
DiskUsed: 549755813888, // 512GB
|
||||||
DiskPct: 50.0,
|
DiskPct: 50.0,
|
||||||
},
|
},
|
||||||
Details: &system.Details{
|
|
||||||
Hostname: "test-host",
|
|
||||||
},
|
|
||||||
Info: system.Info{
|
Info: system.Info{
|
||||||
|
Hostname: "test-host",
|
||||||
|
Cores: 8,
|
||||||
|
CpuModel: "Test CPU Model",
|
||||||
Uptime: 3600,
|
Uptime: 3600,
|
||||||
AgentVersion: "0.12.0",
|
AgentVersion: "0.12.0",
|
||||||
|
Os: system.Linux,
|
||||||
},
|
},
|
||||||
Containers: []*container.Stats{
|
Containers: []*container.Stats{
|
||||||
{
|
{
|
||||||
299
beszel/internal/agent/system.go
Normal file
299
beszel/internal/agent/system.go
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"beszel"
|
||||||
|
"beszel/internal/entities/system"
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/shirou/gopsutil/v4/cpu"
|
||||||
|
"github.com/shirou/gopsutil/v4/disk"
|
||||||
|
"github.com/shirou/gopsutil/v4/host"
|
||||||
|
"github.com/shirou/gopsutil/v4/load"
|
||||||
|
"github.com/shirou/gopsutil/v4/mem"
|
||||||
|
psutilNet "github.com/shirou/gopsutil/v4/net"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sets initial / non-changing values about the host system
|
||||||
|
func (a *Agent) initializeSystemInfo() {
|
||||||
|
a.systemInfo.AgentVersion = beszel.Version
|
||||||
|
a.systemInfo.Hostname, _ = os.Hostname()
|
||||||
|
|
||||||
|
platform, _, version, _ := host.PlatformInformation()
|
||||||
|
|
||||||
|
if platform == "darwin" {
|
||||||
|
a.systemInfo.KernelVersion = version
|
||||||
|
a.systemInfo.Os = system.Darwin
|
||||||
|
} else if strings.Contains(platform, "indows") {
|
||||||
|
a.systemInfo.KernelVersion = strings.Replace(platform, "Microsoft ", "", 1) + " " + version
|
||||||
|
a.systemInfo.Os = system.Windows
|
||||||
|
} else if platform == "freebsd" {
|
||||||
|
a.systemInfo.Os = system.Freebsd
|
||||||
|
a.systemInfo.KernelVersion = version
|
||||||
|
} else {
|
||||||
|
a.systemInfo.Os = system.Linux
|
||||||
|
}
|
||||||
|
|
||||||
|
if a.systemInfo.KernelVersion == "" {
|
||||||
|
a.systemInfo.KernelVersion, _ = host.KernelVersion()
|
||||||
|
}
|
||||||
|
|
||||||
|
// cpu model
|
||||||
|
if info, err := cpu.Info(); err == nil && len(info) > 0 {
|
||||||
|
a.systemInfo.CpuModel = info[0].ModelName
|
||||||
|
}
|
||||||
|
// cores / threads
|
||||||
|
a.systemInfo.Cores, _ = cpu.Counts(false)
|
||||||
|
if threads, err := cpu.Counts(true); err == nil {
|
||||||
|
if threads > 0 && threads < a.systemInfo.Cores {
|
||||||
|
// in lxc logical cores reflects container limits, so use that as cores if lower
|
||||||
|
a.systemInfo.Cores = threads
|
||||||
|
} else {
|
||||||
|
a.systemInfo.Threads = threads
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// zfs
|
||||||
|
if _, err := getARCSize(); err == nil {
|
||||||
|
a.zfs = true
|
||||||
|
} else {
|
||||||
|
slog.Debug("Not monitoring ZFS ARC", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns current info, stats about the host system
|
||||||
|
func (a *Agent) getSystemStats() system.Stats {
|
||||||
|
systemStats := system.Stats{}
|
||||||
|
|
||||||
|
// cpu percent
|
||||||
|
cpuPct, err := cpu.Percent(0, false)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Error getting cpu percent", "err", err)
|
||||||
|
} else if len(cpuPct) > 0 {
|
||||||
|
systemStats.Cpu = twoDecimals(cpuPct[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// load average
|
||||||
|
if avgstat, err := load.Avg(); err == nil {
|
||||||
|
// TODO: remove these in future release in favor of load avg array
|
||||||
|
systemStats.LoadAvg[0] = avgstat.Load1
|
||||||
|
systemStats.LoadAvg[1] = avgstat.Load5
|
||||||
|
systemStats.LoadAvg[2] = avgstat.Load15
|
||||||
|
slog.Debug("Load average", "5m", avgstat.Load5, "15m", avgstat.Load15)
|
||||||
|
} else {
|
||||||
|
slog.Error("Error getting load average", "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// memory
|
||||||
|
if v, err := mem.VirtualMemory(); err == nil {
|
||||||
|
// swap
|
||||||
|
systemStats.Swap = bytesToGigabytes(v.SwapTotal)
|
||||||
|
systemStats.SwapUsed = bytesToGigabytes(v.SwapTotal - v.SwapFree - v.SwapCached)
|
||||||
|
// cache + buffers value for default mem calculation
|
||||||
|
cacheBuff := v.Total - v.Free - v.Used
|
||||||
|
// htop memory calculation overrides
|
||||||
|
if a.memCalc == "htop" {
|
||||||
|
// note: gopsutil automatically adds SReclaimable to v.Cached
|
||||||
|
cacheBuff = v.Cached + v.Buffers - v.Shared
|
||||||
|
v.Used = v.Total - (v.Free + cacheBuff)
|
||||||
|
v.UsedPercent = float64(v.Used) / float64(v.Total) * 100.0
|
||||||
|
}
|
||||||
|
// subtract ZFS ARC size from used memory and add as its own category
|
||||||
|
if a.zfs {
|
||||||
|
if arcSize, _ := getARCSize(); arcSize > 0 && arcSize < v.Used {
|
||||||
|
v.Used = v.Used - arcSize
|
||||||
|
v.UsedPercent = float64(v.Used) / float64(v.Total) * 100.0
|
||||||
|
systemStats.MemZfsArc = bytesToGigabytes(arcSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
systemStats.Mem = bytesToGigabytes(v.Total)
|
||||||
|
systemStats.MemBuffCache = bytesToGigabytes(cacheBuff)
|
||||||
|
systemStats.MemUsed = bytesToGigabytes(v.Used)
|
||||||
|
systemStats.MemPct = twoDecimals(v.UsedPercent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// disk usage
|
||||||
|
for _, stats := range a.fsStats {
|
||||||
|
if d, err := disk.Usage(stats.Mountpoint); err == nil {
|
||||||
|
stats.DiskTotal = bytesToGigabytes(d.Total)
|
||||||
|
stats.DiskUsed = bytesToGigabytes(d.Used)
|
||||||
|
if stats.Root {
|
||||||
|
systemStats.DiskTotal = bytesToGigabytes(d.Total)
|
||||||
|
systemStats.DiskUsed = bytesToGigabytes(d.Used)
|
||||||
|
systemStats.DiskPct = twoDecimals(d.UsedPercent)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// reset stats if error (likely unmounted)
|
||||||
|
slog.Error("Error getting disk stats", "name", stats.Mountpoint, "err", err)
|
||||||
|
stats.DiskTotal = 0
|
||||||
|
stats.DiskUsed = 0
|
||||||
|
stats.TotalRead = 0
|
||||||
|
stats.TotalWrite = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// disk i/o
|
||||||
|
if ioCounters, err := disk.IOCounters(a.fsNames...); err == nil {
|
||||||
|
for _, d := range ioCounters {
|
||||||
|
stats := a.fsStats[d.Name]
|
||||||
|
if stats == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
secondsElapsed := time.Since(stats.Time).Seconds()
|
||||||
|
readPerSecond := bytesToMegabytes(float64(d.ReadBytes-stats.TotalRead) / secondsElapsed)
|
||||||
|
writePerSecond := bytesToMegabytes(float64(d.WriteBytes-stats.TotalWrite) / secondsElapsed)
|
||||||
|
// check for invalid values and reset stats if so
|
||||||
|
if readPerSecond < 0 || writePerSecond < 0 || readPerSecond > 50_000 || writePerSecond > 50_000 {
|
||||||
|
slog.Warn("Invalid disk I/O. Resetting.", "name", d.Name, "read", readPerSecond, "write", writePerSecond)
|
||||||
|
a.initializeDiskIoStats(ioCounters)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
stats.Time = time.Now()
|
||||||
|
stats.DiskReadPs = readPerSecond
|
||||||
|
stats.DiskWritePs = writePerSecond
|
||||||
|
stats.TotalRead = d.ReadBytes
|
||||||
|
stats.TotalWrite = d.WriteBytes
|
||||||
|
// if root filesystem, update system stats
|
||||||
|
if stats.Root {
|
||||||
|
systemStats.DiskReadPs = stats.DiskReadPs
|
||||||
|
systemStats.DiskWritePs = stats.DiskWritePs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// network stats
|
||||||
|
if len(a.netInterfaces) == 0 {
|
||||||
|
// if no network interfaces, initialize again
|
||||||
|
// this is a fix if agent started before network is online (#466)
|
||||||
|
// maybe refactor this in the future to not cache interface names at all so we
|
||||||
|
// don't miss an interface that's been added after agent started in any circumstance
|
||||||
|
a.initializeNetIoStats()
|
||||||
|
}
|
||||||
|
if netIO, err := psutilNet.IOCounters(true); err == nil {
|
||||||
|
msElapsed := uint64(time.Since(a.netIoStats.Time).Milliseconds())
|
||||||
|
a.netIoStats.Time = time.Now()
|
||||||
|
totalBytesSent := uint64(0)
|
||||||
|
totalBytesRecv := uint64(0)
|
||||||
|
// sum all bytes sent and received
|
||||||
|
for _, v := range netIO {
|
||||||
|
// skip if not in valid network interfaces list
|
||||||
|
if _, exists := a.netInterfaces[v.Name]; !exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
totalBytesSent += v.BytesSent
|
||||||
|
totalBytesRecv += v.BytesRecv
|
||||||
|
}
|
||||||
|
// add to systemStats
|
||||||
|
var bytesSentPerSecond, bytesRecvPerSecond uint64
|
||||||
|
if msElapsed > 0 {
|
||||||
|
bytesSentPerSecond = (totalBytesSent - a.netIoStats.BytesSent) * 1000 / msElapsed
|
||||||
|
bytesRecvPerSecond = (totalBytesRecv - a.netIoStats.BytesRecv) * 1000 / msElapsed
|
||||||
|
}
|
||||||
|
networkSentPs := bytesToMegabytes(float64(bytesSentPerSecond))
|
||||||
|
networkRecvPs := bytesToMegabytes(float64(bytesRecvPerSecond))
|
||||||
|
// add check for issue (#150) where sent is a massive number
|
||||||
|
if networkSentPs > 10_000 || networkRecvPs > 10_000 {
|
||||||
|
slog.Warn("Invalid net stats. Resetting.", "sent", networkSentPs, "recv", networkRecvPs)
|
||||||
|
for _, v := range netIO {
|
||||||
|
if _, exists := a.netInterfaces[v.Name]; !exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
slog.Info(v.Name, "recv", v.BytesRecv, "sent", v.BytesSent)
|
||||||
|
}
|
||||||
|
// reset network I/O stats
|
||||||
|
a.initializeNetIoStats()
|
||||||
|
} else {
|
||||||
|
systemStats.NetworkSent = networkSentPs
|
||||||
|
systemStats.NetworkRecv = networkRecvPs
|
||||||
|
systemStats.Bandwidth[0], systemStats.Bandwidth[1] = bytesSentPerSecond, bytesRecvPerSecond
|
||||||
|
// update netIoStats
|
||||||
|
a.netIoStats.BytesSent = totalBytesSent
|
||||||
|
a.netIoStats.BytesRecv = totalBytesRecv
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// temperatures
|
||||||
|
// TODO: maybe refactor to methods on systemStats
|
||||||
|
a.updateTemperatures(&systemStats)
|
||||||
|
|
||||||
|
// GPU data
|
||||||
|
if a.gpuManager != nil {
|
||||||
|
// reset high gpu percent
|
||||||
|
a.systemInfo.GpuPct = 0
|
||||||
|
// get current GPU data
|
||||||
|
if gpuData := a.gpuManager.GetCurrentData(); len(gpuData) > 0 {
|
||||||
|
systemStats.GPUData = gpuData
|
||||||
|
|
||||||
|
// add temperatures
|
||||||
|
if systemStats.Temperatures == nil {
|
||||||
|
systemStats.Temperatures = make(map[string]float64, len(gpuData))
|
||||||
|
}
|
||||||
|
highestTemp := 0.0
|
||||||
|
for _, gpu := range gpuData {
|
||||||
|
if gpu.Temperature > 0 {
|
||||||
|
systemStats.Temperatures[gpu.Name] = gpu.Temperature
|
||||||
|
if a.sensorConfig.primarySensor == gpu.Name {
|
||||||
|
a.systemInfo.DashboardTemp = gpu.Temperature
|
||||||
|
}
|
||||||
|
if gpu.Temperature > highestTemp {
|
||||||
|
highestTemp = gpu.Temperature
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// update high gpu percent for dashboard
|
||||||
|
a.systemInfo.GpuPct = max(a.systemInfo.GpuPct, gpu.Usage)
|
||||||
|
}
|
||||||
|
// use highest temp for dashboard temp if dashboard temp is unset
|
||||||
|
if a.systemInfo.DashboardTemp == 0 {
|
||||||
|
a.systemInfo.DashboardTemp = highestTemp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// update base system info
|
||||||
|
a.systemInfo.Cpu = systemStats.Cpu
|
||||||
|
a.systemInfo.LoadAvg = systemStats.LoadAvg
|
||||||
|
// TODO: remove these in future release in favor of load avg array
|
||||||
|
a.systemInfo.LoadAvg1 = systemStats.LoadAvg[0]
|
||||||
|
a.systemInfo.LoadAvg5 = systemStats.LoadAvg[1]
|
||||||
|
a.systemInfo.LoadAvg15 = systemStats.LoadAvg[2]
|
||||||
|
a.systemInfo.MemPct = systemStats.MemPct
|
||||||
|
a.systemInfo.DiskPct = systemStats.DiskPct
|
||||||
|
a.systemInfo.Uptime, _ = host.Uptime()
|
||||||
|
// TODO: in future release, remove MB bandwidth values in favor of bytes
|
||||||
|
a.systemInfo.Bandwidth = twoDecimals(systemStats.NetworkSent + systemStats.NetworkRecv)
|
||||||
|
a.systemInfo.BandwidthBytes = systemStats.Bandwidth[0] + systemStats.Bandwidth[1]
|
||||||
|
slog.Debug("sysinfo", "data", a.systemInfo)
|
||||||
|
|
||||||
|
return systemStats
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the size of the ZFS ARC memory cache in bytes
|
||||||
|
func getARCSize() (uint64, error) {
|
||||||
|
file, err := os.Open("/proc/spl/kstat/zfs/arcstats")
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
// Scan the lines
|
||||||
|
scanner := bufio.NewScanner(file)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
if strings.HasPrefix(line, "size") {
|
||||||
|
// Example line: size 4 15032385536
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) < 3 {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
// Return the size as uint64
|
||||||
|
return strconv.ParseUint(fields[2], 10, 64)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0, fmt.Errorf("failed to parse size field")
|
||||||
|
}
|
||||||
56
beszel/internal/agent/update.go
Normal file
56
beszel/internal/agent/update.go
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"beszel"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/blang/semver"
|
||||||
|
"github.com/rhysd/go-github-selfupdate/selfupdate"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Update updates beszel-agent to the latest version
|
||||||
|
func Update() {
|
||||||
|
var latest *selfupdate.Release
|
||||||
|
var found bool
|
||||||
|
var err error
|
||||||
|
currentVersion := semver.MustParse(beszel.Version)
|
||||||
|
fmt.Println("beszel-agent", currentVersion)
|
||||||
|
fmt.Println("Checking for updates...")
|
||||||
|
updater, _ := selfupdate.NewUpdater(selfupdate.Config{
|
||||||
|
Filters: []string{"beszel-agent"},
|
||||||
|
})
|
||||||
|
latest, found, err = updater.DetectLatest("henrygd/beszel")
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("Error checking for updates:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
fmt.Println("No updates found")
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Latest version:", latest.Version)
|
||||||
|
|
||||||
|
if latest.Version.LTE(currentVersion) {
|
||||||
|
fmt.Println("You are up to date")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var binaryPath string
|
||||||
|
fmt.Printf("Updating from %s to %s...\n", currentVersion, latest.Version)
|
||||||
|
binaryPath, err = os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("Error getting binary path:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
err = selfupdate.UpdateTo(latest.AssetURL, binaryPath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("Please try rerunning with sudo. Error:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Printf("Successfully updated to %s\n\n%s\n", latest.Version, strings.TrimSpace(latest.ReleaseNotes))
|
||||||
|
}
|
||||||
@@ -28,7 +28,6 @@ type AlertManager struct {
|
|||||||
|
|
||||||
type AlertMessageData struct {
|
type AlertMessageData struct {
|
||||||
UserID string
|
UserID string
|
||||||
SystemID string
|
|
||||||
Title string
|
Title string
|
||||||
Message string
|
Message string
|
||||||
Link string
|
Link string
|
||||||
@@ -41,19 +40,13 @@ type UserNotificationSettings struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type SystemAlertStats struct {
|
type SystemAlertStats struct {
|
||||||
Cpu float64 `json:"cpu"`
|
Cpu float64 `json:"cpu"`
|
||||||
Mem float64 `json:"mp"`
|
Mem float64 `json:"mp"`
|
||||||
Disk float64 `json:"dp"`
|
Disk float64 `json:"dp"`
|
||||||
NetSent float64 `json:"ns"`
|
NetSent float64 `json:"ns"`
|
||||||
NetRecv float64 `json:"nr"`
|
NetRecv float64 `json:"nr"`
|
||||||
GPU map[string]SystemAlertGPUData `json:"g"`
|
Temperatures map[string]float32 `json:"t"`
|
||||||
Temperatures map[string]float32 `json:"t"`
|
LoadAvg [3]float64 `json:"la"`
|
||||||
LoadAvg [3]float64 `json:"la"`
|
|
||||||
Battery [2]uint8 `json:"bat"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type SystemAlertGPUData struct {
|
|
||||||
Usage float64 `json:"u"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type SystemAlertData struct {
|
type SystemAlertData struct {
|
||||||
@@ -79,6 +72,7 @@ var supportsTitle = map[string]struct{}{
|
|||||||
"ifttt": {},
|
"ifttt": {},
|
||||||
"join": {},
|
"join": {},
|
||||||
"lark": {},
|
"lark": {},
|
||||||
|
"matrix": {},
|
||||||
"ntfy": {},
|
"ntfy": {},
|
||||||
"opsgenie": {},
|
"opsgenie": {},
|
||||||
"pushbullet": {},
|
"pushbullet": {},
|
||||||
@@ -93,7 +87,7 @@ var supportsTitle = map[string]struct{}{
|
|||||||
func NewAlertManager(app hubLike) *AlertManager {
|
func NewAlertManager(app hubLike) *AlertManager {
|
||||||
am := &AlertManager{
|
am := &AlertManager{
|
||||||
hub: app,
|
hub: app,
|
||||||
alertQueue: make(chan alertTask, 5),
|
alertQueue: make(chan alertTask),
|
||||||
stopChan: make(chan struct{}),
|
stopChan: make(chan struct{}),
|
||||||
}
|
}
|
||||||
am.bindEvents()
|
am.bindEvents()
|
||||||
@@ -105,84 +99,10 @@ func NewAlertManager(app hubLike) *AlertManager {
|
|||||||
func (am *AlertManager) bindEvents() {
|
func (am *AlertManager) bindEvents() {
|
||||||
am.hub.OnRecordAfterUpdateSuccess("alerts").BindFunc(updateHistoryOnAlertUpdate)
|
am.hub.OnRecordAfterUpdateSuccess("alerts").BindFunc(updateHistoryOnAlertUpdate)
|
||||||
am.hub.OnRecordAfterDeleteSuccess("alerts").BindFunc(resolveHistoryOnAlertDelete)
|
am.hub.OnRecordAfterDeleteSuccess("alerts").BindFunc(resolveHistoryOnAlertDelete)
|
||||||
am.hub.OnRecordAfterUpdateSuccess("smart_devices").BindFunc(am.handleSmartDeviceAlert)
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsNotificationSilenced checks if a notification should be silenced based on configured quiet hours
|
|
||||||
func (am *AlertManager) IsNotificationSilenced(userID, systemID string) bool {
|
|
||||||
// Query for quiet hours windows that match this user and system
|
|
||||||
// Include both global windows (system is null/empty) and system-specific windows
|
|
||||||
var filter string
|
|
||||||
var params dbx.Params
|
|
||||||
|
|
||||||
if systemID == "" {
|
|
||||||
// If no systemID provided, only check global windows
|
|
||||||
filter = "user={:user} AND system=''"
|
|
||||||
params = dbx.Params{"user": userID}
|
|
||||||
} else {
|
|
||||||
// Check both global and system-specific windows
|
|
||||||
filter = "user={:user} AND (system='' OR system={:system})"
|
|
||||||
params = dbx.Params{
|
|
||||||
"user": userID,
|
|
||||||
"system": systemID,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
quietHourWindows, err := am.hub.FindAllRecords("quiet_hours", dbx.NewExp(filter, params))
|
|
||||||
if err != nil || len(quietHourWindows) == 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now().UTC()
|
|
||||||
|
|
||||||
for _, window := range quietHourWindows {
|
|
||||||
windowType := window.GetString("type")
|
|
||||||
start := window.GetDateTime("start").Time()
|
|
||||||
end := window.GetDateTime("end").Time()
|
|
||||||
|
|
||||||
if windowType == "daily" {
|
|
||||||
// For daily recurring windows, extract just the time portion and compare
|
|
||||||
// The start/end are stored as full datetime but we only care about HH:MM
|
|
||||||
startHour, startMin, _ := start.Clock()
|
|
||||||
endHour, endMin, _ := end.Clock()
|
|
||||||
nowHour, nowMin, _ := now.Clock()
|
|
||||||
|
|
||||||
// Convert to minutes since midnight for easier comparison
|
|
||||||
startMinutes := startHour*60 + startMin
|
|
||||||
endMinutes := endHour*60 + endMin
|
|
||||||
nowMinutes := nowHour*60 + nowMin
|
|
||||||
|
|
||||||
// Handle case where window crosses midnight
|
|
||||||
if endMinutes < startMinutes {
|
|
||||||
// Window crosses midnight (e.g., 23:00 - 01:00)
|
|
||||||
if nowMinutes >= startMinutes || nowMinutes < endMinutes {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Normal case (e.g., 09:00 - 17:00)
|
|
||||||
if nowMinutes >= startMinutes && nowMinutes < endMinutes {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// One-time window: check if current time is within the date range
|
|
||||||
if (now.After(start) || now.Equal(start)) && now.Before(end) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendAlert sends an alert to the user
|
// SendAlert sends an alert to the user
|
||||||
func (am *AlertManager) SendAlert(data AlertMessageData) error {
|
func (am *AlertManager) SendAlert(data AlertMessageData) error {
|
||||||
// Check if alert is silenced
|
|
||||||
if am.IsNotificationSilenced(data.UserID, data.SystemID) {
|
|
||||||
am.hub.Logger().Info("Notification silenced", "user", data.UserID, "system", data.SystemID, "title", data.Title)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// get user settings
|
// get user settings
|
||||||
record, err := am.hub.FindFirstRecordByFilter(
|
record, err := am.hub.FindFirstRecordByFilter(
|
||||||
"user_settings", "user={:user}",
|
"user_settings", "user={:user}",
|
||||||
@@ -42,10 +42,21 @@ func updateHistoryOnAlertUpdate(e *core.RecordEvent) error {
|
|||||||
|
|
||||||
// resolveAlertHistoryRecord sets the resolved field to the current time
|
// resolveAlertHistoryRecord sets the resolved field to the current time
|
||||||
func resolveAlertHistoryRecord(app core.App, alertRecordID string) error {
|
func resolveAlertHistoryRecord(app core.App, alertRecordID string) error {
|
||||||
alertHistoryRecord, err := app.FindFirstRecordByFilter("alerts_history", "alert_id={:alert_id} && resolved=null", dbx.Params{"alert_id": alertRecordID})
|
alertHistoryRecords, err := app.FindRecordsByFilter(
|
||||||
if err != nil || alertHistoryRecord == nil {
|
"alerts_history",
|
||||||
|
"alert_id={:alert_id} && resolved=null",
|
||||||
|
"-created",
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
dbx.Params{"alert_id": alertRecordID},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if len(alertHistoryRecords) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
alertHistoryRecord := alertHistoryRecords[0] // there should be only one record
|
||||||
alertHistoryRecord.Set("resolved", time.Now().UTC())
|
alertHistoryRecord.Set("resolved", time.Now().UTC())
|
||||||
err = app.Save(alertHistoryRecord)
|
err = app.Save(alertHistoryRecord)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -25,12 +25,7 @@ type alertInfo struct {
|
|||||||
// startWorker is a long-running goroutine that processes alert tasks
|
// startWorker is a long-running goroutine that processes alert tasks
|
||||||
// every x seconds. It must be running to process status alerts.
|
// every x seconds. It must be running to process status alerts.
|
||||||
func (am *AlertManager) startWorker() {
|
func (am *AlertManager) startWorker() {
|
||||||
processPendingAlerts := time.Tick(15 * time.Second)
|
tick := time.Tick(15 * time.Second)
|
||||||
|
|
||||||
// check for status alerts that are not resolved when system comes up
|
|
||||||
// (can be removed if we figure out core bug in #1052)
|
|
||||||
checkStatusAlerts := time.Tick(561 * time.Second)
|
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-am.stopChan:
|
case <-am.stopChan:
|
||||||
@@ -46,9 +41,7 @@ func (am *AlertManager) startWorker() {
|
|||||||
case "cancel":
|
case "cancel":
|
||||||
am.pendingAlerts.Delete(task.alertRecord.Id)
|
am.pendingAlerts.Delete(task.alertRecord.Id)
|
||||||
}
|
}
|
||||||
case <-checkStatusAlerts:
|
case <-tick:
|
||||||
resolveStatusAlerts(am.hub)
|
|
||||||
case <-processPendingAlerts:
|
|
||||||
// Check for expired alerts every tick
|
// Check for expired alerts every tick
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
for key, value := range am.pendingAlerts.Range {
|
for key, value := range am.pendingAlerts.Range {
|
||||||
@@ -161,47 +154,19 @@ func (am *AlertManager) sendStatusAlert(alertStatus string, systemName string, a
|
|||||||
title := fmt.Sprintf("Connection to %s is %s %v", systemName, alertStatus, emoji)
|
title := fmt.Sprintf("Connection to %s is %s %v", systemName, alertStatus, emoji)
|
||||||
message := strings.TrimSuffix(title, emoji)
|
message := strings.TrimSuffix(title, emoji)
|
||||||
|
|
||||||
// Get system ID for the link
|
// if errs := am.hub.ExpandRecord(alertRecord, []string{"user"}, nil); len(errs) > 0 {
|
||||||
systemID := alertRecord.GetString("system")
|
// return errs["user"]
|
||||||
|
// }
|
||||||
|
// user := alertRecord.ExpandedOne("user")
|
||||||
|
// if user == nil {
|
||||||
|
// return nil
|
||||||
|
// }
|
||||||
|
|
||||||
return am.SendAlert(AlertMessageData{
|
return am.SendAlert(AlertMessageData{
|
||||||
UserID: alertRecord.GetString("user"),
|
UserID: alertRecord.GetString("user"),
|
||||||
SystemID: systemID,
|
|
||||||
Title: title,
|
Title: title,
|
||||||
Message: message,
|
Message: message,
|
||||||
Link: am.hub.MakeLink("system", systemID),
|
Link: am.hub.MakeLink("system", systemName),
|
||||||
LinkText: "View " + systemName,
|
LinkText: "View " + systemName,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveStatusAlerts resolves any status alerts that weren't resolved
|
|
||||||
// when system came up (https://github.com/henrygd/beszel/issues/1052)
|
|
||||||
func resolveStatusAlerts(app core.App) error {
|
|
||||||
db := app.DB()
|
|
||||||
// Find all active status alerts where the system is actually up
|
|
||||||
var alertIds []string
|
|
||||||
err := db.NewQuery(`
|
|
||||||
SELECT a.id
|
|
||||||
FROM alerts a
|
|
||||||
JOIN systems s ON a.system = s.id
|
|
||||||
WHERE a.name = 'Status'
|
|
||||||
AND a.triggered = true
|
|
||||||
AND s.status = 'up'
|
|
||||||
`).Column(&alertIds)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
// resolve all matching alert records
|
|
||||||
for _, alertId := range alertIds {
|
|
||||||
alert, err := app.FindRecordById("alerts", alertId)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
alert.Set("triggered", false)
|
|
||||||
err = app.Save(alert)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,12 @@
|
|||||||
package alerts
|
package alerts
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"beszel/internal/entities/system"
|
||||||
|
"encoding/json/v2"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"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"
|
"github.com/pocketbase/pocketbase/tools/types"
|
||||||
@@ -64,32 +63,17 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
|
|||||||
case "LoadAvg15":
|
case "LoadAvg15":
|
||||||
val = data.Info.LoadAvg[2]
|
val = data.Info.LoadAvg[2]
|
||||||
unit = ""
|
unit = ""
|
||||||
case "GPU":
|
|
||||||
val = data.Info.GpuPct
|
|
||||||
case "Battery":
|
|
||||||
if data.Stats.Battery[0] == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
val = float64(data.Stats.Battery[0])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
triggered := alertRecord.GetBool("triggered")
|
triggered := alertRecord.GetBool("triggered")
|
||||||
threshold := alertRecord.GetFloat("value")
|
threshold := alertRecord.GetFloat("value")
|
||||||
|
|
||||||
// Battery alert has inverted logic: trigger when value is BELOW threshold
|
|
||||||
lowAlert := isLowAlert(name)
|
|
||||||
|
|
||||||
// CONTINUE
|
// CONTINUE
|
||||||
// For normal alerts: IF not triggered and curValue <= threshold, OR triggered and curValue > threshold
|
// IF alert is not triggered and curValue is less than threshold
|
||||||
// For low alerts (Battery): IF not triggered and curValue >= threshold, OR triggered and curValue < threshold
|
// OR alert is triggered and curValue is greater than threshold
|
||||||
if lowAlert {
|
if (!triggered && val <= threshold) || (triggered && val > threshold) {
|
||||||
if (!triggered && val >= threshold) || (triggered && val < threshold) {
|
// log.Printf("Skipping alert %s: val %f | threshold %f | triggered %v\n", name, val, threshold, triggered)
|
||||||
continue
|
continue
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (!triggered && val <= threshold) || (triggered && val > threshold) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
min := max(1, cast.ToUint8(alertRecord.Get("min")))
|
min := max(1, cast.ToUint8(alertRecord.Get("min")))
|
||||||
@@ -107,11 +91,7 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
|
|||||||
|
|
||||||
// send alert immediately if min is 1 - no need to sum up values.
|
// send alert immediately if min is 1 - no need to sum up values.
|
||||||
if min == 1 {
|
if min == 1 {
|
||||||
if lowAlert {
|
alert.triggered = val > threshold
|
||||||
alert.triggered = val < threshold
|
|
||||||
} else {
|
|
||||||
alert.triggered = val > threshold
|
|
||||||
}
|
|
||||||
go am.sendSystemAlert(alert)
|
go am.sendSystemAlert(alert)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -225,19 +205,6 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
|
|||||||
alert.val += stats.LoadAvg[1]
|
alert.val += stats.LoadAvg[1]
|
||||||
case "LoadAvg15":
|
case "LoadAvg15":
|
||||||
alert.val += stats.LoadAvg[2]
|
alert.val += stats.LoadAvg[2]
|
||||||
case "GPU":
|
|
||||||
if len(stats.GPU) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
maxUsage := 0.0
|
|
||||||
for _, gpu := range stats.GPU {
|
|
||||||
if gpu.Usage > maxUsage {
|
|
||||||
maxUsage = gpu.Usage
|
|
||||||
}
|
|
||||||
}
|
|
||||||
alert.val += maxUsage
|
|
||||||
case "Battery":
|
|
||||||
alert.val += float64(stats.Battery[0])
|
|
||||||
default:
|
default:
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -275,24 +242,12 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
|
|||||||
// log.Printf("%s: val %f | count %d | min-count %f | threshold %f\n", alert.name, alert.val, alert.count, minCount, alert.threshold)
|
// log.Printf("%s: val %f | count %d | min-count %f | threshold %f\n", alert.name, alert.val, alert.count, minCount, alert.threshold)
|
||||||
// pass through alert if count is greater than or equal to minCount
|
// pass through alert if count is greater than or equal to minCount
|
||||||
if float32(alert.count) >= minCount {
|
if float32(alert.count) >= minCount {
|
||||||
// Battery alert has inverted logic: trigger when value is BELOW threshold
|
if !alert.triggered && alert.val > alert.threshold {
|
||||||
lowAlert := isLowAlert(alert.name)
|
alert.triggered = true
|
||||||
if lowAlert {
|
go am.sendSystemAlert(alert)
|
||||||
if !alert.triggered && alert.val < alert.threshold {
|
} else if alert.triggered && alert.val <= alert.threshold {
|
||||||
alert.triggered = true
|
alert.triggered = false
|
||||||
go am.sendSystemAlert(alert)
|
go am.sendSystemAlert(alert)
|
||||||
} else if alert.triggered && alert.val >= alert.threshold {
|
|
||||||
alert.triggered = false
|
|
||||||
go am.sendSystemAlert(alert)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if !alert.triggered && alert.val > alert.threshold {
|
|
||||||
alert.triggered = true
|
|
||||||
go am.sendSystemAlert(alert)
|
|
||||||
} else if alert.triggered && alert.val <= alert.threshold {
|
|
||||||
alert.triggered = false
|
|
||||||
go am.sendSystemAlert(alert)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -312,26 +267,17 @@ func (am *AlertManager) sendSystemAlert(alert SystemAlertData) {
|
|||||||
alert.name = after + "m Load"
|
alert.name = after + "m Load"
|
||||||
}
|
}
|
||||||
|
|
||||||
// make title alert name lowercase if not CPU or GPU
|
// make title alert name lowercase if not CPU
|
||||||
titleAlertName := alert.name
|
titleAlertName := alert.name
|
||||||
if titleAlertName != "CPU" && titleAlertName != "GPU" {
|
if titleAlertName != "CPU" {
|
||||||
titleAlertName = strings.ToLower(titleAlertName)
|
titleAlertName = strings.ToLower(titleAlertName)
|
||||||
}
|
}
|
||||||
|
|
||||||
var subject string
|
var subject string
|
||||||
lowAlert := isLowAlert(alert.name)
|
|
||||||
if alert.triggered {
|
if alert.triggered {
|
||||||
if lowAlert {
|
subject = fmt.Sprintf("%s %s above threshold", systemName, titleAlertName)
|
||||||
subject = fmt.Sprintf("%s %s below threshold", systemName, titleAlertName)
|
|
||||||
} else {
|
|
||||||
subject = fmt.Sprintf("%s %s above threshold", systemName, titleAlertName)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
if lowAlert {
|
subject = fmt.Sprintf("%s %s below threshold", systemName, titleAlertName)
|
||||||
subject = fmt.Sprintf("%s %s above threshold", systemName, titleAlertName)
|
|
||||||
} else {
|
|
||||||
subject = fmt.Sprintf("%s %s below threshold", systemName, titleAlertName)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
minutesLabel := "minute"
|
minutesLabel := "minute"
|
||||||
if alert.min > 1 {
|
if alert.min > 1 {
|
||||||
@@ -349,14 +295,9 @@ func (am *AlertManager) sendSystemAlert(alert SystemAlertData) {
|
|||||||
}
|
}
|
||||||
am.SendAlert(AlertMessageData{
|
am.SendAlert(AlertMessageData{
|
||||||
UserID: alert.alertRecord.GetString("user"),
|
UserID: alert.alertRecord.GetString("user"),
|
||||||
SystemID: alert.systemRecord.Id,
|
|
||||||
Title: subject,
|
Title: subject,
|
||||||
Message: body,
|
Message: body,
|
||||||
Link: am.hub.MakeLink("system", alert.systemRecord.Id),
|
Link: am.hub.MakeLink("system", systemName),
|
||||||
LinkText: "View " + systemName,
|
LinkText: "View " + systemName,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func isLowAlert(name string) bool {
|
|
||||||
return name == "Battery"
|
|
||||||
}
|
|
||||||
368
beszel/internal/alerts/alerts_test.go
Normal file
368
beszel/internal/alerts/alerts_test.go
Normal file
@@ -0,0 +1,368 @@
|
|||||||
|
//go:build testing
|
||||||
|
// +build testing
|
||||||
|
|
||||||
|
package alerts_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
beszelTests "beszel/internal/tests"
|
||||||
|
|
||||||
|
"github.com/pocketbase/dbx"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
pbTests "github.com/pocketbase/pocketbase/tests"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// marshal to json and return an io.Reader (for use in ApiScenario.Body)
|
||||||
|
func jsonReader(v any) io.Reader {
|
||||||
|
data, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return bytes.NewReader(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserAlertsApi(t *testing.T) {
|
||||||
|
hub, _ := beszelTests.NewTestHub(t.TempDir())
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
hub.StartHub()
|
||||||
|
|
||||||
|
user1, _ := beszelTests.CreateUser(hub, "alertstest@example.com", "password")
|
||||||
|
user1Token, _ := user1.NewAuthToken()
|
||||||
|
|
||||||
|
user2, _ := beszelTests.CreateUser(hub, "alertstest2@example.com", "password")
|
||||||
|
user2Token, _ := user2.NewAuthToken()
|
||||||
|
|
||||||
|
system1, _ := beszelTests.CreateRecord(hub, "systems", map[string]any{
|
||||||
|
"name": "system1",
|
||||||
|
"users": []string{user1.Id},
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
})
|
||||||
|
|
||||||
|
system2, _ := beszelTests.CreateRecord(hub, "systems", map[string]any{
|
||||||
|
"name": "system2",
|
||||||
|
"users": []string{user1.Id, user2.Id},
|
||||||
|
"host": "127.0.0.2",
|
||||||
|
})
|
||||||
|
|
||||||
|
userRecords, _ := hub.CountRecords("users")
|
||||||
|
assert.EqualValues(t, 2, userRecords, "all users should be created")
|
||||||
|
|
||||||
|
systemRecords, _ := hub.CountRecords("systems")
|
||||||
|
assert.EqualValues(t, 2, systemRecords, "all systems should be created")
|
||||||
|
|
||||||
|
testAppFactory := func(t testing.TB) *pbTests.TestApp {
|
||||||
|
return hub.TestApp
|
||||||
|
}
|
||||||
|
|
||||||
|
scenarios := []beszelTests.ApiScenario{
|
||||||
|
{
|
||||||
|
Name: "GET not implemented - returns index",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"<html ", "globalThis.BESZEL"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "POST no auth",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
ExpectedStatus: 401,
|
||||||
|
ExpectedContent: []string{"requires valid"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "POST no body",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": user1Token,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 400,
|
||||||
|
ExpectedContent: []string{"Bad data"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "POST bad data",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": user1Token,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 400,
|
||||||
|
ExpectedContent: []string{"Bad data"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"invalidField": "this should cause validation error",
|
||||||
|
"threshold": "not a number",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "POST malformed JSON",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": user1Token,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 400,
|
||||||
|
ExpectedContent: []string{"Bad data"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: strings.NewReader(`{"alertType": "cpu", "threshold": 80, "enabled": true,}`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "POST valid alert data multiple systems",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": user1Token,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"\"success\":true"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"value": 69,
|
||||||
|
"min": 9,
|
||||||
|
"systems": []string{system1.Id, system2.Id},
|
||||||
|
"overwrite": false,
|
||||||
|
}),
|
||||||
|
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||||
|
// check total alerts
|
||||||
|
alerts, _ := app.CountRecords("alerts")
|
||||||
|
assert.EqualValues(t, 2, alerts, "should have 2 alerts")
|
||||||
|
// check alert has correct values
|
||||||
|
matchingAlerts, _ := app.CountRecords("alerts", dbx.HashExp{"name": "CPU", "user": user1.Id, "system": system1.Id, "value": 69, "min": 9})
|
||||||
|
assert.EqualValues(t, 1, matchingAlerts, "should have 1 alert")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "POST valid alert data single system",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": user1Token,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"\"success\":true"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"name": "Memory",
|
||||||
|
"systems": []string{system1.Id},
|
||||||
|
"value": 90,
|
||||||
|
"min": 10,
|
||||||
|
}),
|
||||||
|
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||||
|
user1Alerts, _ := app.CountRecords("alerts", dbx.HashExp{"user": user1.Id})
|
||||||
|
assert.EqualValues(t, 3, user1Alerts, "should have 3 alerts")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Overwrite: false, should not overwrite existing alert",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": user1Token,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"\"success\":true"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"value": 45,
|
||||||
|
"min": 5,
|
||||||
|
"systems": []string{system1.Id},
|
||||||
|
"overwrite": false,
|
||||||
|
}),
|
||||||
|
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||||
|
beszelTests.ClearCollection(t, app, "alerts")
|
||||||
|
beszelTests.CreateRecord(app, "alerts", map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"system": system1.Id,
|
||||||
|
"user": user1.Id,
|
||||||
|
"value": 80,
|
||||||
|
"min": 10,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||||
|
alerts, _ := app.CountRecords("alerts")
|
||||||
|
assert.EqualValues(t, 1, alerts, "should have 1 alert")
|
||||||
|
alert, _ := app.FindFirstRecordByFilter("alerts", "name = 'CPU' && user = {:user}", dbx.Params{"user": user1.Id})
|
||||||
|
assert.EqualValues(t, 80, alert.Get("value"), "should have 80 as value")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Overwrite: true, should overwrite existing alert",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": user2Token,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"\"success\":true"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"value": 45,
|
||||||
|
"min": 5,
|
||||||
|
"systems": []string{system2.Id},
|
||||||
|
"overwrite": true,
|
||||||
|
}),
|
||||||
|
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||||
|
beszelTests.ClearCollection(t, app, "alerts")
|
||||||
|
beszelTests.CreateRecord(app, "alerts", map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"system": system2.Id,
|
||||||
|
"user": user2.Id,
|
||||||
|
"value": 80,
|
||||||
|
"min": 10,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||||
|
alerts, _ := app.CountRecords("alerts")
|
||||||
|
assert.EqualValues(t, 1, alerts, "should have 1 alert")
|
||||||
|
alert, _ := app.FindFirstRecordByFilter("alerts", "name = 'CPU' && user = {:user}", dbx.Params{"user": user2.Id})
|
||||||
|
assert.EqualValues(t, 45, alert.Get("value"), "should have 45 as value")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "DELETE no auth",
|
||||||
|
Method: http.MethodDelete,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
ExpectedStatus: 401,
|
||||||
|
ExpectedContent: []string{"requires valid"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"systems": []string{system1.Id},
|
||||||
|
}),
|
||||||
|
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||||
|
beszelTests.ClearCollection(t, app, "alerts")
|
||||||
|
beszelTests.CreateRecord(app, "alerts", map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"system": system1.Id,
|
||||||
|
"user": user1.Id,
|
||||||
|
"value": 80,
|
||||||
|
"min": 10,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||||
|
alerts, _ := app.CountRecords("alerts")
|
||||||
|
assert.EqualValues(t, 1, alerts, "should have 1 alert")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "DELETE alert",
|
||||||
|
Method: http.MethodDelete,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": user1Token,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"\"count\":1", "\"success\":true"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"systems": []string{system1.Id},
|
||||||
|
}),
|
||||||
|
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||||
|
beszelTests.ClearCollection(t, app, "alerts")
|
||||||
|
beszelTests.CreateRecord(app, "alerts", map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"system": system1.Id,
|
||||||
|
"user": user1.Id,
|
||||||
|
"value": 80,
|
||||||
|
"min": 10,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||||
|
alerts, _ := app.CountRecords("alerts")
|
||||||
|
assert.Zero(t, alerts, "should have 0 alerts")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "DELETE alert multiple systems",
|
||||||
|
Method: http.MethodDelete,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": user1Token,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"\"count\":2", "\"success\":true"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"name": "Memory",
|
||||||
|
"systems": []string{system1.Id, system2.Id},
|
||||||
|
}),
|
||||||
|
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||||
|
beszelTests.ClearCollection(t, app, "alerts")
|
||||||
|
for _, systemId := range []string{system1.Id, system2.Id} {
|
||||||
|
_, err := beszelTests.CreateRecord(app, "alerts", map[string]any{
|
||||||
|
"name": "Memory",
|
||||||
|
"system": systemId,
|
||||||
|
"user": user1.Id,
|
||||||
|
"value": 90,
|
||||||
|
"min": 10,
|
||||||
|
})
|
||||||
|
assert.NoError(t, err, "should create alert")
|
||||||
|
}
|
||||||
|
alerts, _ := app.CountRecords("alerts")
|
||||||
|
assert.EqualValues(t, 2, alerts, "should have 2 alerts")
|
||||||
|
},
|
||||||
|
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||||
|
alerts, _ := app.CountRecords("alerts")
|
||||||
|
assert.Zero(t, alerts, "should have 0 alerts")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "User 2 should not be able to delete alert of user 1",
|
||||||
|
Method: http.MethodDelete,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": user2Token,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"\"count\":1", "\"success\":true"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"systems": []string{system2.Id},
|
||||||
|
}),
|
||||||
|
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||||
|
beszelTests.ClearCollection(t, app, "alerts")
|
||||||
|
for _, user := range []string{user1.Id, user2.Id} {
|
||||||
|
beszelTests.CreateRecord(app, "alerts", map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"system": system2.Id,
|
||||||
|
"user": user,
|
||||||
|
"value": 80,
|
||||||
|
"min": 10,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
alerts, _ := app.CountRecords("alerts")
|
||||||
|
assert.EqualValues(t, 2, alerts, "should have 2 alerts")
|
||||||
|
user1AlertCount, _ := app.CountRecords("alerts", dbx.HashExp{"user": user1.Id})
|
||||||
|
assert.EqualValues(t, 1, user1AlertCount, "should have 1 alert")
|
||||||
|
user2AlertCount, _ := app.CountRecords("alerts", dbx.HashExp{"user": user2.Id})
|
||||||
|
assert.EqualValues(t, 1, user2AlertCount, "should have 1 alert")
|
||||||
|
},
|
||||||
|
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||||
|
user1AlertCount, _ := app.CountRecords("alerts", dbx.HashExp{"user": user1.Id})
|
||||||
|
assert.EqualValues(t, 1, user1AlertCount, "should have 1 alert")
|
||||||
|
user2AlertCount, _ := app.CountRecords("alerts", dbx.HashExp{"user": user2.Id})
|
||||||
|
assert.Zero(t, user2AlertCount, "should have 0 alerts")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
scenario.Test(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
32
beszel/internal/common/common-ws.go
Normal file
32
beszel/internal/common/common-ws.go
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
type WebSocketAction = uint8
|
||||||
|
|
||||||
|
// Not implemented yet
|
||||||
|
// type AgentError = uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Request system data from agent
|
||||||
|
GetData WebSocketAction = iota
|
||||||
|
// Check the fingerprint of the agent
|
||||||
|
CheckFingerprint
|
||||||
|
)
|
||||||
|
|
||||||
|
// HubRequest defines the structure for requests sent from hub to agent.
|
||||||
|
type HubRequest[T any] struct {
|
||||||
|
Action WebSocketAction `cbor:"0,keyasint"`
|
||||||
|
Data T `cbor:"1,keyasint,omitempty,omitzero"`
|
||||||
|
// Error AgentError `cbor:"error,omitempty,omitzero"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FingerprintRequest struct {
|
||||||
|
Signature []byte `cbor:"0,keyasint"`
|
||||||
|
NeedSysInfo bool `cbor:"1,keyasint"` // For universal token system creation
|
||||||
|
}
|
||||||
|
|
||||||
|
type FingerprintResponse struct {
|
||||||
|
Fingerprint string `cbor:"0,keyasint"`
|
||||||
|
// Optional system info for universal token system creation
|
||||||
|
Hostname string `cbor:"1,keyasint,omitempty,omitzero"`
|
||||||
|
Port string `cbor:"2,keyasint,omitempty,omitzero"`
|
||||||
|
}
|
||||||
@@ -8,8 +8,7 @@ type ApiInfo struct {
|
|||||||
IdShort string
|
IdShort string
|
||||||
Names []string
|
Names []string
|
||||||
Status string
|
Status string
|
||||||
State string
|
// Image string
|
||||||
Image string
|
|
||||||
// ImageID string
|
// ImageID string
|
||||||
// Command string
|
// Command string
|
||||||
// Created int64
|
// Created int64
|
||||||
@@ -17,6 +16,7 @@ type ApiInfo struct {
|
|||||||
// SizeRw int64 `json:",omitempty"`
|
// SizeRw int64 `json:",omitempty"`
|
||||||
// SizeRootFs int64 `json:",omitempty"`
|
// SizeRootFs int64 `json:",omitempty"`
|
||||||
// Labels map[string]string
|
// Labels map[string]string
|
||||||
|
// State string
|
||||||
// HostConfig struct {
|
// HostConfig struct {
|
||||||
// NetworkMode string `json:",omitempty"`
|
// NetworkMode string `json:",omitempty"`
|
||||||
// Annotations map[string]string `json:",omitempty"`
|
// Annotations map[string]string `json:",omitempty"`
|
||||||
@@ -34,14 +34,6 @@ type ApiStats struct {
|
|||||||
MemoryStats MemoryStats `json:"memory_stats"`
|
MemoryStats MemoryStats `json:"memory_stats"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Docker system info from /info API endpoint
|
|
||||||
type HostInfo struct {
|
|
||||||
OperatingSystem string `json:"OperatingSystem"`
|
|
||||||
KernelVersion string `json:"KernelVersion"`
|
|
||||||
NCPU int `json:"NCPU"`
|
|
||||||
MemTotal uint64 `json:"MemTotal"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ApiStats) CalculateCpuPercentLinux(prevCpuContainer uint64, prevCpuSystem uint64) float64 {
|
func (s *ApiStats) CalculateCpuPercentLinux(prevCpuContainer uint64, prevCpuSystem uint64) float64 {
|
||||||
cpuDelta := s.CPUStats.CPUUsage.TotalUsage - prevCpuContainer
|
cpuDelta := s.CPUStats.CPUUsage.TotalUsage - prevCpuContainer
|
||||||
systemDelta := s.CPUStats.SystemUsage - prevCpuSystem
|
systemDelta := s.CPUStats.SystemUsage - prevCpuSystem
|
||||||
@@ -111,22 +103,6 @@ type prevNetStats struct {
|
|||||||
Recv uint64
|
Recv uint64
|
||||||
}
|
}
|
||||||
|
|
||||||
type DockerHealth = uint8
|
|
||||||
|
|
||||||
const (
|
|
||||||
DockerHealthNone DockerHealth = iota
|
|
||||||
DockerHealthStarting
|
|
||||||
DockerHealthHealthy
|
|
||||||
DockerHealthUnhealthy
|
|
||||||
)
|
|
||||||
|
|
||||||
var DockerHealthStrings = map[string]DockerHealth{
|
|
||||||
"none": DockerHealthNone,
|
|
||||||
"starting": DockerHealthStarting,
|
|
||||||
"healthy": DockerHealthHealthy,
|
|
||||||
"unhealthy": DockerHealthUnhealthy,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Docker container stats
|
// Docker container stats
|
||||||
type Stats struct {
|
type Stats struct {
|
||||||
Name string `json:"n" cbor:"0,keyasint"`
|
Name string `json:"n" cbor:"0,keyasint"`
|
||||||
@@ -134,11 +110,6 @@ type Stats struct {
|
|||||||
Mem float64 `json:"m" cbor:"2,keyasint"`
|
Mem float64 `json:"m" cbor:"2,keyasint"`
|
||||||
NetworkSent float64 `json:"ns" cbor:"3,keyasint"`
|
NetworkSent float64 `json:"ns" cbor:"3,keyasint"`
|
||||||
NetworkRecv float64 `json:"nr" cbor:"4,keyasint"`
|
NetworkRecv float64 `json:"nr" cbor:"4,keyasint"`
|
||||||
|
|
||||||
Health DockerHealth `json:"-" cbor:"5,keyasint"`
|
|
||||||
Status string `json:"-" cbor:"6,keyasint"`
|
|
||||||
Id string `json:"-" cbor:"7,keyasint"`
|
|
||||||
Image string `json:"-" cbor:"8,keyasint"`
|
|
||||||
// PrevCpu [2]uint64 `json:"-"`
|
// PrevCpu [2]uint64 `json:"-"`
|
||||||
CpuSystem uint64 `json:"-"`
|
CpuSystem uint64 `json:"-"`
|
||||||
CpuContainer uint64 `json:"-"`
|
CpuContainer uint64 `json:"-"`
|
||||||
112
beszel/internal/entities/system/system.go
Normal file
112
beszel/internal/entities/system/system.go
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
package system
|
||||||
|
|
||||||
|
// TODO: this is confusing, make common package with common/types common/helpers etc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"beszel/internal/entities/container"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Stats struct {
|
||||||
|
Cpu float64 `json:"cpu" cbor:"0,keyasint"`
|
||||||
|
MaxCpu float64 `json:"cpum,omitempty" cbor:"1,keyasint,omitempty"`
|
||||||
|
Mem float64 `json:"m" cbor:"2,keyasint"`
|
||||||
|
MemUsed float64 `json:"mu" cbor:"3,keyasint"`
|
||||||
|
MemPct float64 `json:"mp" cbor:"4,keyasint"`
|
||||||
|
MemBuffCache float64 `json:"mb" cbor:"5,keyasint"`
|
||||||
|
MemZfsArc float64 `json:"mz,omitempty" cbor:"6,keyasint,omitempty"` // ZFS ARC memory
|
||||||
|
Swap float64 `json:"s,omitempty" cbor:"7,keyasint,omitempty"`
|
||||||
|
SwapUsed float64 `json:"su,omitempty" cbor:"8,keyasint,omitempty"`
|
||||||
|
DiskTotal float64 `json:"d" cbor:"9,keyasint"`
|
||||||
|
DiskUsed float64 `json:"du" cbor:"10,keyasint"`
|
||||||
|
DiskPct float64 `json:"dp" cbor:"11,keyasint"`
|
||||||
|
DiskReadPs float64 `json:"dr" cbor:"12,keyasint"`
|
||||||
|
DiskWritePs float64 `json:"dw" cbor:"13,keyasint"`
|
||||||
|
MaxDiskReadPs float64 `json:"drm,omitempty" cbor:"14,keyasint,omitempty"`
|
||||||
|
MaxDiskWritePs float64 `json:"dwm,omitempty" cbor:"15,keyasint,omitempty"`
|
||||||
|
NetworkSent float64 `json:"ns" cbor:"16,keyasint"`
|
||||||
|
NetworkRecv float64 `json:"nr" cbor:"17,keyasint"`
|
||||||
|
MaxNetworkSent float64 `json:"nsm,omitempty" cbor:"18,keyasint,omitempty"`
|
||||||
|
MaxNetworkRecv float64 `json:"nrm,omitempty" cbor:"19,keyasint,omitempty"`
|
||||||
|
Temperatures map[string]float64 `json:"t,omitempty" cbor:"20,keyasint,omitempty"`
|
||||||
|
ExtraFs map[string]*FsStats `json:"efs,omitempty" cbor:"21,keyasint,omitempty"`
|
||||||
|
GPUData map[string]GPUData `json:"g,omitempty" cbor:"22,keyasint,omitempty"`
|
||||||
|
LoadAvg1 float64 `json:"l1,omitempty" cbor:"23,keyasint,omitempty"`
|
||||||
|
LoadAvg5 float64 `json:"l5,omitempty" cbor:"24,keyasint,omitempty"`
|
||||||
|
LoadAvg15 float64 `json:"l15,omitempty" cbor:"25,keyasint,omitempty"`
|
||||||
|
Bandwidth [2]uint64 `json:"b,omitzero" cbor:"26,keyasint,omitzero"` // [sent bytes, recv bytes]
|
||||||
|
MaxBandwidth [2]uint64 `json:"bm,omitzero" cbor:"27,keyasint,omitzero"` // [sent bytes, recv bytes]
|
||||||
|
LoadAvg [3]float64 `json:"la,omitempty" cbor:"28,keyasint"`
|
||||||
|
// TODO: remove other load fields in future release in favor of load avg array
|
||||||
|
}
|
||||||
|
|
||||||
|
type GPUData struct {
|
||||||
|
Name string `json:"n" cbor:"0,keyasint"`
|
||||||
|
Temperature float64 `json:"-"`
|
||||||
|
MemoryUsed float64 `json:"mu,omitempty" cbor:"1,keyasint,omitempty"`
|
||||||
|
MemoryTotal float64 `json:"mt,omitempty" cbor:"2,keyasint,omitempty"`
|
||||||
|
Usage float64 `json:"u" cbor:"3,keyasint"`
|
||||||
|
Power float64 `json:"p,omitempty" cbor:"4,keyasint,omitempty"`
|
||||||
|
Count float64 `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FsStats struct {
|
||||||
|
Time time.Time `json:"-"`
|
||||||
|
Root bool `json:"-"`
|
||||||
|
Mountpoint string `json:"-"`
|
||||||
|
DiskTotal float64 `json:"d" cbor:"0,keyasint"`
|
||||||
|
DiskUsed float64 `json:"du" cbor:"1,keyasint"`
|
||||||
|
TotalRead uint64 `json:"-"`
|
||||||
|
TotalWrite uint64 `json:"-"`
|
||||||
|
DiskReadPs float64 `json:"r" cbor:"2,keyasint"`
|
||||||
|
DiskWritePs float64 `json:"w" cbor:"3,keyasint"`
|
||||||
|
MaxDiskReadPS float64 `json:"rm,omitempty" cbor:"4,keyasint,omitempty"`
|
||||||
|
MaxDiskWritePS float64 `json:"wm,omitempty" cbor:"5,keyasint,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type NetIoStats struct {
|
||||||
|
BytesRecv uint64
|
||||||
|
BytesSent uint64
|
||||||
|
Time time.Time
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Os = uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
Linux Os = iota
|
||||||
|
Darwin
|
||||||
|
Windows
|
||||||
|
Freebsd
|
||||||
|
)
|
||||||
|
|
||||||
|
type Info struct {
|
||||||
|
Hostname string `json:"h" cbor:"0,keyasint"`
|
||||||
|
KernelVersion string `json:"k,omitempty" cbor:"1,keyasint,omitempty"`
|
||||||
|
Cores int `json:"c" cbor:"2,keyasint"`
|
||||||
|
Threads int `json:"t,omitempty" cbor:"3,keyasint,omitempty"`
|
||||||
|
CpuModel string `json:"m" cbor:"4,keyasint"`
|
||||||
|
Uptime uint64 `json:"u" cbor:"5,keyasint"`
|
||||||
|
Cpu float64 `json:"cpu" cbor:"6,keyasint"`
|
||||||
|
MemPct float64 `json:"mp" cbor:"7,keyasint"`
|
||||||
|
DiskPct float64 `json:"dp" cbor:"8,keyasint"`
|
||||||
|
Bandwidth float64 `json:"b" cbor:"9,keyasint"`
|
||||||
|
AgentVersion string `json:"v" cbor:"10,keyasint"`
|
||||||
|
Podman bool `json:"p,omitempty" cbor:"11,keyasint,omitempty"`
|
||||||
|
GpuPct float64 `json:"g,omitempty" cbor:"12,keyasint,omitempty"`
|
||||||
|
DashboardTemp float64 `json:"dt,omitempty" cbor:"13,keyasint,omitempty"`
|
||||||
|
Os Os `json:"os" cbor:"14,keyasint"`
|
||||||
|
LoadAvg1 float64 `json:"l1,omitempty" cbor:"15,keyasint,omitempty"`
|
||||||
|
LoadAvg5 float64 `json:"l5,omitempty" cbor:"16,keyasint,omitempty"`
|
||||||
|
LoadAvg15 float64 `json:"l15,omitempty" cbor:"17,keyasint,omitempty"`
|
||||||
|
BandwidthBytes uint64 `json:"bb" cbor:"18,keyasint"`
|
||||||
|
LoadAvg [3]float64 `json:"la,omitempty" cbor:"19,keyasint"`
|
||||||
|
// TODO: remove load fields in future release in favor of load avg array
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final data structure to return to the hub
|
||||||
|
type CombinedData struct {
|
||||||
|
Stats Stats `json:"stats" cbor:"0,keyasint"`
|
||||||
|
Info Info `json:"info" cbor:"1,keyasint"`
|
||||||
|
Containers []*container.Stats `json:"container" cbor:"2,keyasint"`
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
package hub
|
package hub
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"beszel/internal/common"
|
||||||
|
"beszel/internal/hub/expirymap"
|
||||||
|
"beszel/internal/hub/ws"
|
||||||
"errors"
|
"errors"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -9,10 +11,6 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/common"
|
|
||||||
"github.com/henrygd/beszel/internal/hub/expirymap"
|
|
||||||
"github.com/henrygd/beszel/internal/hub/ws"
|
|
||||||
|
|
||||||
"github.com/blang/semver"
|
"github.com/blang/semver"
|
||||||
"github.com/lxzan/gws"
|
"github.com/lxzan/gws"
|
||||||
"github.com/pocketbase/dbx"
|
"github.com/pocketbase/dbx"
|
||||||
@@ -66,15 +64,6 @@ func (acr *agentConnectRequest) agentConnect() (err error) {
|
|||||||
|
|
||||||
// Check if token is an active universal token
|
// Check if token is an active universal token
|
||||||
acr.userId, acr.isUniversalToken = universalTokenMap.GetMap().GetOk(acr.token)
|
acr.userId, acr.isUniversalToken = universalTokenMap.GetMap().GetOk(acr.token)
|
||||||
if !acr.isUniversalToken {
|
|
||||||
// Fallback: check for a permanent universal token stored in the DB
|
|
||||||
if rec, err := acr.hub.FindFirstRecordByFilter("universal_tokens", "token = {:token}", dbx.Params{"token": acr.token}); err == nil {
|
|
||||||
if userID := rec.GetString("user"); userID != "" {
|
|
||||||
acr.userId = userID
|
|
||||||
acr.isUniversalToken = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find matching fingerprint records for this token
|
// Find matching fingerprint records for this token
|
||||||
fpRecords := getFingerprintRecordsByToken(acr.token, acr.hub)
|
fpRecords := getFingerprintRecordsByToken(acr.token, acr.hub)
|
||||||
@@ -103,7 +92,7 @@ func (acr *agentConnectRequest) agentConnect() (err error) {
|
|||||||
// verifyWsConn verifies the WebSocket connection using the agent's fingerprint and
|
// verifyWsConn verifies the WebSocket connection using the agent's fingerprint and
|
||||||
// SSH key signature, then adds the system to the system manager.
|
// SSH key signature, then adds the system to the system manager.
|
||||||
func (acr *agentConnectRequest) verifyWsConn(conn *gws.Conn, fpRecords []ws.FingerprintRecord) (err error) {
|
func (acr *agentConnectRequest) verifyWsConn(conn *gws.Conn, fpRecords []ws.FingerprintRecord) (err error) {
|
||||||
wsConn := ws.NewWsConnection(conn, acr.agentSemVer)
|
wsConn := ws.NewWsConnection(conn)
|
||||||
|
|
||||||
// must set wsConn in connection store before the read loop
|
// must set wsConn in connection store before the read loop
|
||||||
conn.Session().Store("wsConn", wsConn)
|
conn.Session().Store("wsConn", wsConn)
|
||||||
@@ -122,7 +111,7 @@ func (acr *agentConnectRequest) verifyWsConn(conn *gws.Conn, fpRecords []ws.Fing
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
agentFingerprint, err := wsConn.GetFingerprint(context.Background(), acr.token, signer, acr.isUniversalToken)
|
agentFingerprint, err := wsConn.GetFingerprint(acr.token, signer, acr.isUniversalToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -277,12 +266,9 @@ func (acr *agentConnectRequest) createSystem(agentFingerprint common.Fingerprint
|
|||||||
if agentFingerprint.Port == "" {
|
if agentFingerprint.Port == "" {
|
||||||
agentFingerprint.Port = "45876"
|
agentFingerprint.Port = "45876"
|
||||||
}
|
}
|
||||||
if agentFingerprint.Name == "" {
|
|
||||||
agentFingerprint.Name = agentFingerprint.Hostname
|
|
||||||
}
|
|
||||||
// create new record
|
// create new record
|
||||||
systemRecord := core.NewRecord(systemsCollection)
|
systemRecord := core.NewRecord(systemsCollection)
|
||||||
systemRecord.Set("name", agentFingerprint.Name)
|
systemRecord.Set("name", agentFingerprint.Hostname)
|
||||||
systemRecord.Set("host", remoteAddr)
|
systemRecord.Set("host", remoteAddr)
|
||||||
systemRecord.Set("port", agentFingerprint.Port)
|
systemRecord.Set("port", agentFingerprint.Port)
|
||||||
systemRecord.Set("users", []string{acr.userId})
|
systemRecord.Set("users", []string{acr.userId})
|
||||||
@@ -4,6 +4,9 @@
|
|||||||
package hub
|
package hub
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"beszel/internal/agent"
|
||||||
|
"beszel/internal/common"
|
||||||
|
"beszel/internal/hub/ws"
|
||||||
"crypto/ed25519"
|
"crypto/ed25519"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -14,10 +17,6 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/agent"
|
|
||||||
"github.com/henrygd/beszel/internal/common"
|
|
||||||
"github.com/henrygd/beszel/internal/hub/ws"
|
|
||||||
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
pbtests "github.com/pocketbase/pocketbase/tests"
|
pbtests "github.com/pocketbase/pocketbase/tests"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -1169,106 +1168,6 @@ func TestMultipleSystemsWithSameUniversalToken(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestPermanentUniversalTokenFromDB verifies that a universal token persisted in the DB
|
|
||||||
// (universal_tokens collection) is accepted for agent self-registration even if it is not
|
|
||||||
// present in the in-memory universalTokenMap.
|
|
||||||
func TestPermanentUniversalTokenFromDB(t *testing.T) {
|
|
||||||
// Create hub and test app
|
|
||||||
hub, testApp, err := createTestHub(t)
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer testApp.Cleanup()
|
|
||||||
|
|
||||||
// Get the hub's SSH key
|
|
||||||
hubSigner, err := hub.GetSSHKey("")
|
|
||||||
require.NoError(t, err)
|
|
||||||
goodPubKey := hubSigner.PublicKey()
|
|
||||||
|
|
||||||
// Create test user
|
|
||||||
userRecord, err := createTestUser(testApp)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Create a permanent universal token record in the DB (do NOT add it to universalTokenMap)
|
|
||||||
universalToken := "db-universal-token-123"
|
|
||||||
_, err = createTestRecord(testApp, "universal_tokens", map[string]any{
|
|
||||||
"user": userRecord.Id,
|
|
||||||
"token": universalToken,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Create HTTP server with the actual API route
|
|
||||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.URL.Path == "/api/beszel/agent-connect" {
|
|
||||||
acr := &agentConnectRequest{
|
|
||||||
hub: hub,
|
|
||||||
req: r,
|
|
||||||
res: w,
|
|
||||||
}
|
|
||||||
acr.agentConnect()
|
|
||||||
} else {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
defer ts.Close()
|
|
||||||
|
|
||||||
// Create and configure agent
|
|
||||||
agentDataDir := t.TempDir()
|
|
||||||
err = os.WriteFile(filepath.Join(agentDataDir, "fingerprint"), []byte("db-token-system-fingerprint"), 0644)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
testAgent, err := agent.NewAgent(agentDataDir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Set up environment variables for the agent
|
|
||||||
os.Setenv("BESZEL_AGENT_HUB_URL", ts.URL)
|
|
||||||
os.Setenv("BESZEL_AGENT_TOKEN", universalToken)
|
|
||||||
defer func() {
|
|
||||||
os.Unsetenv("BESZEL_AGENT_HUB_URL")
|
|
||||||
os.Unsetenv("BESZEL_AGENT_TOKEN")
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Start agent in background
|
|
||||||
done := make(chan error, 1)
|
|
||||||
go func() {
|
|
||||||
serverOptions := agent.ServerOptions{
|
|
||||||
Network: "tcp",
|
|
||||||
Addr: "127.0.0.1:46050",
|
|
||||||
Keys: []ssh.PublicKey{goodPubKey},
|
|
||||||
}
|
|
||||||
done <- testAgent.Start(serverOptions)
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Wait for connection result
|
|
||||||
maxWait := 2 * time.Second
|
|
||||||
time.Sleep(20 * time.Millisecond)
|
|
||||||
checkInterval := 20 * time.Millisecond
|
|
||||||
timeout := time.After(maxWait)
|
|
||||||
ticker := time.Tick(checkInterval)
|
|
||||||
|
|
||||||
connectionManager := testAgent.GetConnectionManager()
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-timeout:
|
|
||||||
t.Fatalf("Expected connection to succeed but timed out - agent state: %d", connectionManager.State)
|
|
||||||
case <-ticker:
|
|
||||||
if connectionManager.State == agent.WebSocketConnected {
|
|
||||||
// Success
|
|
||||||
goto verify
|
|
||||||
}
|
|
||||||
case err := <-done:
|
|
||||||
// If Start returns early, treat it as failure
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Agent failed to start/connect: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
verify:
|
|
||||||
// Verify that a system was created for the user (self-registration path)
|
|
||||||
systemsAfter, err := testApp.FindRecordsByFilter("systems", "users ~ {:userId}", "", -1, 0, map[string]any{"userId": userRecord.Id})
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotEmpty(t, systemsAfter, "Expected a system to be created for DB-backed universal token")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestFindOrCreateSystemForToken tests the findOrCreateSystemForToken function
|
// TestFindOrCreateSystemForToken tests the findOrCreateSystemForToken function
|
||||||
func TestFindOrCreateSystemForToken(t *testing.T) {
|
func TestFindOrCreateSystemForToken(t *testing.T) {
|
||||||
hub, testApp, err := createTestHub(t)
|
hub, testApp, err := createTestHub(t)
|
||||||
@@ -2,14 +2,13 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"beszel/internal/entities/system"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"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/spf13/cast"
|
"github.com/spf13/cast"
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user