mirror of
https://github.com/henrygd/beszel.git
synced 2026-03-22 05:36:15 +01:00
Compare commits
125 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca58ff66ba | ||
|
|
133d229361 | ||
|
|
960cac4060 | ||
|
|
d83865cb4f | ||
|
|
4b43d68da6 | ||
|
|
c790d76211 | ||
|
|
29b182fd7b | ||
|
|
fc78b959aa | ||
|
|
b8b3604aec | ||
|
|
e45606fdec | ||
|
|
640afd82ad | ||
|
|
d025e51c67 | ||
|
|
f70c30345a | ||
|
|
63bdac83a1 | ||
|
|
65897a8df6 | ||
|
|
0dc9b3e273 | ||
|
|
c1c0d8d672 | ||
|
|
1811ab64be | ||
|
|
5578520054 | ||
|
|
7b128d09ac | ||
|
|
d295507c0b | ||
|
|
79fbbb7ad0 | ||
|
|
e7325b23c4 | ||
|
|
c5eba6547a | ||
|
|
82e7c04b25 | ||
|
|
a9ce16cfdd | ||
|
|
2af8b6057f | ||
|
|
3fae4360a8 | ||
|
|
10073d85e1 | ||
|
|
e240ced018 | ||
|
|
ae1e17f5ed | ||
|
|
3abb7c213b | ||
|
|
240e75f025 | ||
|
|
ea984844ff | ||
|
|
0d157b5857 | ||
|
|
d0b6e725c8 | ||
|
|
ffe7f8547a | ||
|
|
37817b0f15 | ||
|
|
a66ac418ae | ||
|
|
2ee2f53267 | ||
|
|
e5c766c00b | ||
|
|
da43ba10e1 | ||
|
|
fca13004bd | ||
|
|
376a86829c | ||
|
|
ef48613f3f | ||
|
|
49976c6f61 | ||
|
|
d68f1f0985 | ||
|
|
273a090200 | ||
|
|
59057a2ba4 | ||
|
|
1b9e781d45 | ||
|
|
4e0ca7c2ba | ||
|
|
a9e7bcd37f | ||
|
|
4635f24fb2 | ||
|
|
3e73399b87 | ||
|
|
e149366451 | ||
|
|
8da1ded73e | ||
|
|
efa37b2312 | ||
|
|
bcdb4c92b5 | ||
|
|
a7d07310b6 | ||
|
|
8db87e5497 | ||
|
|
e601a0d564 | ||
|
|
07491108cd | ||
|
|
42ab17de1f | ||
|
|
2d14174f61 | ||
|
|
a19ccc9263 | ||
|
|
956880aa59 | ||
|
|
b2b54db409 | ||
|
|
32d5188eef | ||
|
|
46dab7f531 | ||
|
|
c898a9ebbc | ||
|
|
8a13b05c20 | ||
|
|
86ea23fe39 | ||
|
|
a284dd74dd | ||
|
|
6a0075291c | ||
|
|
f542bc70a1 | ||
|
|
270e59d9ea | ||
|
|
0d97a604f8 | ||
|
|
f6078fc232 | ||
|
|
6f5d95031c | ||
|
|
4e26defdca | ||
|
|
cda8fa7efd | ||
|
|
fd050f2a8f | ||
|
|
e53d41dcec | ||
|
|
a1eb15dabb | ||
|
|
eb4bdafbea | ||
|
|
fea2330534 | ||
|
|
5e37469ea9 | ||
|
|
e027479bb1 | ||
|
|
1597e869c1 | ||
|
|
862399d8ec | ||
|
|
f6f85f8f9d | ||
|
|
e22d7ca801 | ||
|
|
c382c1d5f6 | ||
|
|
f7618ed6b0 | ||
|
|
d1295b7c50 | ||
|
|
a162a54a58 | ||
|
|
794db0ac6a | ||
|
|
e9fb9b856f | ||
|
|
66bca11d36 | ||
|
|
86e87f0d47 | ||
|
|
fadfc5d81d | ||
|
|
fc39ff1e4d | ||
|
|
82ccfc66e0 | ||
|
|
890bad1c39 | ||
|
|
9c458885f1 | ||
|
|
d2aed0dc72 | ||
|
|
3dbcb5d7da | ||
|
|
57a1a8b39e | ||
|
|
ab81c04569 | ||
|
|
0c32be3bea | ||
|
|
81d43fbf6e | ||
|
|
96f441de40 | ||
|
|
0e95caaee9 | ||
|
|
7697a12b42 | ||
|
|
94245a9ba4 | ||
|
|
b084814aea | ||
|
|
cce74246ee | ||
|
|
a3420b8c67 | ||
|
|
e1bb17ee9e | ||
|
|
52983f60b7 | ||
|
|
1f053fd85d | ||
|
|
a989d121d3 | ||
|
|
50d2406423 | ||
|
|
059d2d0a5b | ||
|
|
621bef30b5 |
48
.dockerignore
Normal file
48
.dockerignore
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# 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
|
||||||
50
.github/workflows/docker-images.yml
vendored
50
.github/workflows/docker-images.yml
vendored
@@ -13,44 +13,60 @@ jobs:
|
|||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- image: henrygd/beszel
|
- image: henrygd/beszel
|
||||||
context: ./beszel
|
context: ./
|
||||||
dockerfile: ./beszel/dockerfile_hub
|
dockerfile: ./internal/dockerfile_hub
|
||||||
registry: docker.io
|
registry: docker.io
|
||||||
username_secret: DOCKERHUB_USERNAME
|
username_secret: DOCKERHUB_USERNAME
|
||||||
password_secret: DOCKERHUB_TOKEN
|
password_secret: DOCKERHUB_TOKEN
|
||||||
|
|
||||||
- image: henrygd/beszel-agent
|
- image: henrygd/beszel-agent
|
||||||
context: ./beszel
|
context: ./
|
||||||
dockerfile: ./beszel/dockerfile_agent
|
dockerfile: ./internal/dockerfile_agent
|
||||||
registry: docker.io
|
registry: docker.io
|
||||||
username_secret: DOCKERHUB_USERNAME
|
username_secret: DOCKERHUB_USERNAME
|
||||||
password_secret: DOCKERHUB_TOKEN
|
password_secret: DOCKERHUB_TOKEN
|
||||||
|
|
||||||
- image: henrygd/beszel-agent-nvidia
|
- image: henrygd/beszel-agent-nvidia
|
||||||
context: ./beszel
|
context: ./
|
||||||
dockerfile: ./beszel/dockerfile_agent_nvidia
|
dockerfile: ./internal/dockerfile_agent_nvidia
|
||||||
|
platforms: linux/amd64
|
||||||
|
registry: docker.io
|
||||||
|
username_secret: DOCKERHUB_USERNAME
|
||||||
|
password_secret: DOCKERHUB_TOKEN
|
||||||
|
|
||||||
|
- image: henrygd/beszel-agent-intel
|
||||||
|
context: ./
|
||||||
|
dockerfile: ./internal/dockerfile_agent_intel
|
||||||
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
|
||||||
|
|
||||||
- image: ghcr.io/${{ github.repository }}/beszel
|
- image: ghcr.io/${{ github.repository }}/beszel
|
||||||
context: ./beszel
|
context: ./
|
||||||
dockerfile: ./beszel/dockerfile_hub
|
dockerfile: ./internal/dockerfile_hub
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password_secret: GITHUB_TOKEN
|
password_secret: GITHUB_TOKEN
|
||||||
|
|
||||||
- image: ghcr.io/${{ github.repository }}/beszel-agent
|
- image: ghcr.io/${{ github.repository }}/beszel-agent
|
||||||
context: ./beszel
|
context: ./
|
||||||
dockerfile: ./beszel/dockerfile_agent
|
dockerfile: ./internal/dockerfile_agent
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password_secret: GITHUB_TOKEN
|
password_secret: GITHUB_TOKEN
|
||||||
|
|
||||||
- image: ghcr.io/${{ github.repository }}/beszel-agent-nvidia
|
- image: ghcr.io/${{ github.repository }}/beszel-agent-nvidia
|
||||||
context: ./beszel
|
context: ./
|
||||||
dockerfile: ./beszel/dockerfile_agent_nvidia
|
dockerfile: ./internal/dockerfile_agent_nvidia
|
||||||
|
platforms: linux/amd64
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password_secret: GITHUB_TOKEN
|
||||||
|
|
||||||
|
- image: ghcr.io/${{ github.repository }}/beszel-agent-intel
|
||||||
|
context: ./
|
||||||
|
dockerfile: ./internal/dockerfile_agent_intel
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
@@ -68,10 +84,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 ./beszel/site
|
run: bun install --no-save --cwd ./internal/site
|
||||||
|
|
||||||
- name: Build site
|
- name: Build site
|
||||||
run: bun run --cwd ./beszel/site build
|
run: bun run --cwd ./internal/site build
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v3
|
||||||
@@ -93,7 +109,9 @@ jobs:
|
|||||||
|
|
||||||
# https://github.com/docker/login-action
|
# https://github.com/docker/login-action
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
if: github.event_name != 'pull_request'
|
env:
|
||||||
|
password_secret_exists: ${{ secrets[matrix.password_secret] != '' && 'true' || 'false' }}
|
||||||
|
if: github.event_name != 'pull_request' && env.password_secret_exists == 'true'
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ matrix.username || secrets[matrix.username_secret] }}
|
username: ${{ matrix.username || secrets[matrix.username_secret] }}
|
||||||
@@ -108,6 +126,6 @@ jobs:
|
|||||||
context: "${{ matrix.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' }}
|
push: ${{ github.ref_type == 'tag' && secrets[matrix.password_secret] != '' }}
|
||||||
tags: ${{ steps.metadata.outputs.tags }}
|
tags: ${{ steps.metadata.outputs.tags }}
|
||||||
labels: ${{ steps.metadata.outputs.labels }}
|
labels: ${{ steps.metadata.outputs.labels }}
|
||||||
|
|||||||
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 ./beszel/site
|
run: bun install --no-save --cwd ./internal/site
|
||||||
|
|
||||||
- name: Build site
|
- name: Build site
|
||||||
run: bun run --cwd ./beszel/site build
|
run: bun run --cwd ./internal/site build
|
||||||
|
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
@@ -38,16 +38,17 @@ jobs:
|
|||||||
|
|
||||||
- name: Build .NET LHM executable for Windows sensors
|
- name: Build .NET LHM executable for Windows sensors
|
||||||
run: |
|
run: |
|
||||||
dotnet build -c Release ./beszel/internal/agent/lhm/beszel_lhm.csproj
|
dotnet build -c Release ./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: ./beszel
|
workdir: ./
|
||||||
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: Analysis
|
name: VulnCheck
|
||||||
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.24.x
|
go-version: 1.25.x
|
||||||
cached: false
|
# cached: false
|
||||||
- name: Get official govulncheck
|
- name: Get official govulncheck
|
||||||
run: go install golang.org/x/vuln/cmd/govulncheck@latest
|
run: go install golang.org/x/vuln/cmd/govulncheck@latest
|
||||||
shell: bash
|
shell: bash
|
||||||
- name: Run govulncheck
|
- name: Run govulncheck
|
||||||
run: govulncheck -C ./beszel -show verbose ./...
|
run: govulncheck -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
|
||||||
beszel/cmd/hub/hub
|
internal/cmd/hub/hub
|
||||||
beszel/cmd/agent/agent
|
internal/cmd/agent/agent
|
||||||
node_modules
|
node_modules
|
||||||
beszel/build
|
build
|
||||||
*timestamp*
|
*timestamp*
|
||||||
.swc
|
.swc
|
||||||
beszel/site/src/locales/**/*.ts
|
internal/site/src/locales/**/*.ts
|
||||||
*.bak
|
*.bak
|
||||||
__debug_*
|
__debug_*
|
||||||
beszel/internal/agent/lhm/obj
|
agent/lhm/obj
|
||||||
beszel/internal/agent/lhm/bin
|
agent/lhm/bin
|
||||||
dockerfile_agent_dev
|
dockerfile_agent_dev
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ before:
|
|||||||
builds:
|
builds:
|
||||||
- id: beszel
|
- id: beszel
|
||||||
binary: beszel
|
binary: beszel
|
||||||
main: cmd/hub/hub.go
|
main: internal/cmd/hub/hub.go
|
||||||
env:
|
env:
|
||||||
- CGO_ENABLED=0
|
- CGO_ENABLED=0
|
||||||
goos:
|
goos:
|
||||||
@@ -22,7 +22,7 @@ builds:
|
|||||||
|
|
||||||
- id: beszel-agent
|
- id: beszel-agent
|
||||||
binary: beszel-agent
|
binary: beszel-agent
|
||||||
main: cmd/agent/agent.go
|
main: internal/cmd/agent/agent.go
|
||||||
env:
|
env:
|
||||||
- CGO_ENABLED=0
|
- CGO_ENABLED=0
|
||||||
goos:
|
goos:
|
||||||
@@ -38,12 +38,25 @@ 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
|
||||||
@@ -54,7 +67,7 @@ builds:
|
|||||||
archives:
|
archives:
|
||||||
- id: beszel-agent
|
- id: beszel-agent
|
||||||
formats: [tar.gz]
|
formats: [tar.gz]
|
||||||
builds:
|
ids:
|
||||||
- beszel-agent
|
- beszel-agent
|
||||||
name_template: >-
|
name_template: >-
|
||||||
{{ .Binary }}_
|
{{ .Binary }}_
|
||||||
@@ -66,7 +79,7 @@ archives:
|
|||||||
|
|
||||||
- id: beszel
|
- id: beszel
|
||||||
formats: [tar.gz]
|
formats: [tar.gz]
|
||||||
builds:
|
ids:
|
||||||
- beszel
|
- beszel
|
||||||
name_template: >-
|
name_template: >-
|
||||||
{{ .Binary }}_
|
{{ .Binary }}_
|
||||||
@@ -85,33 +98,33 @@ nfpms:
|
|||||||
API access.
|
API access.
|
||||||
maintainer: henrygd <hank@henrygd.me>
|
maintainer: henrygd <hank@henrygd.me>
|
||||||
section: net
|
section: net
|
||||||
builds:
|
ids:
|
||||||
- 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]
|
||||||
@@ -122,6 +135,7 @@ 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:
|
||||||
@@ -155,7 +169,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: auto
|
skip_upload: '{{ if eq (tolower .Env.IS_FORK) "true" }}true{{ else }}auto{{ end }}'
|
||||||
extra_install: |
|
extra_install: |
|
||||||
(bin/"beszel-agent-launcher").write <<~EOS
|
(bin/"beszel-agent-launcher").write <<~EOS
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
@@ -187,7 +201,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: auto
|
skip_upload: '{{ if eq (tolower .Env.IS_FORK) "true" }}true{{ else }}auto{{ end }}'
|
||||||
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
|
||||||
102
Makefile
Normal file
102
Makefile
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
# 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
|
||||||
|
.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
|
||||||
|
|
||||||
|
# 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" ./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
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
// Package agent handles the agent's SSH server and system stats collection.
|
// 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
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"beszel"
|
|
||||||
"beszel/internal/entities/system"
|
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -14,6 +15,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gliderlabs/ssh"
|
"github.com/gliderlabs/ssh"
|
||||||
|
"github.com/henrygd/beszel"
|
||||||
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
"github.com/shirou/gopsutil/v4/host"
|
"github.com/shirou/gopsutil/v4/host"
|
||||||
gossh "golang.org/x/crypto/ssh"
|
gossh "golang.org/x/crypto/ssh"
|
||||||
)
|
)
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"beszel/internal/entities/system"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Not thread safe since we only access from gatherStats which is already locked
|
// Not thread safe since we only access from gatherStats which is already locked
|
||||||
@@ -4,11 +4,12 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"beszel/internal/entities/system"
|
|
||||||
"testing"
|
"testing"
|
||||||
"testing/synctest"
|
"testing/synctest"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
@@ -20,9 +20,8 @@ func HasReadableBattery() bool {
|
|||||||
}
|
}
|
||||||
haveCheckedBattery = true
|
haveCheckedBattery = true
|
||||||
bat, err := battery.Get(0)
|
bat, err := battery.Get(0)
|
||||||
if err == nil && bat != nil {
|
systemHasBattery = err == nil && bat != nil && bat.Design != 0 && bat.Full != 0
|
||||||
systemHasBattery = true
|
if !systemHasBattery {
|
||||||
} else {
|
|
||||||
slog.Debug("No battery found", "err", err)
|
slog.Debug("No battery found", "err", err)
|
||||||
}
|
}
|
||||||
return systemHasBattery
|
return systemHasBattery
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"beszel"
|
|
||||||
"beszel/internal/common"
|
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -15,6 +13,9 @@ 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"
|
||||||
@@ -84,7 +85,7 @@ func getToken() (string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return string(tokenBytes), nil
|
return strings.TrimSpace(string(tokenBytes)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// getOptions returns the WebSocket client options, creating them if necessary.
|
// getOptions returns the WebSocket client options, creating them if necessary.
|
||||||
@@ -4,8 +4,6 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"beszel"
|
|
||||||
"beszel/internal/common"
|
|
||||||
"crypto/ed25519"
|
"crypto/ed25519"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
@@ -13,6 +11,10 @@ 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"
|
||||||
@@ -535,4 +537,25 @@ 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,26 +1,29 @@
|
|||||||
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.
|
||||||
@@ -143,15 +146,18 @@ 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
|
||||||
81
agent/deltatracker/deltatracker.go
Normal file
81
agent/deltatracker/deltatracker.go
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
217
agent/deltatracker/deltatracker_test.go
Normal file
217
agent/deltatracker/deltatracker_test.go
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"beszel/internal/entities/system"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -9,6 +8,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
|
||||||
"github.com/shirou/gopsutil/v4/disk"
|
"github.com/shirou/gopsutil/v4/disk"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"beszel/internal/entities/container"
|
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -15,6 +14,8 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/entities/container"
|
||||||
|
|
||||||
"github.com/blang/semver"
|
"github.com/blang/semver"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"beszel/internal/entities/system"
|
|
||||||
"bufio"
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -13,6 +12,8 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
|
||||||
"golang.org/x/exp/slog"
|
"golang.org/x/exp/slog"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -26,13 +27,10 @@ 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
|
||||||
@@ -41,10 +39,11 @@ 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
|
||||||
GpuDataMap map[string]*system.GPUData
|
intelGpuStats bool
|
||||||
|
GpuDataMap map[string]*system.GPUData
|
||||||
}
|
}
|
||||||
|
|
||||||
// RocmSmiJson represents the JSON structure of rocm-smi output
|
// RocmSmiJson represents the JSON structure of rocm-smi output
|
||||||
@@ -65,6 +64,7 @@ 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
|
||||||
@@ -98,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, cmdBufferSize)
|
c.buf = make([]byte, 0, c.bufSize)
|
||||||
}
|
}
|
||||||
scanner.Buffer(c.buf, bufio.MaxScanTokenSize)
|
scanner.Buffer(c.buf, bufio.MaxScanTokenSize)
|
||||||
|
|
||||||
@@ -243,20 +243,32 @@ func (gm *GPUManager) GetCurrentData() map[string]system.GPUData {
|
|||||||
// copy / reset the data
|
// 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 := *gpu
|
|
||||||
|
|
||||||
gpuAvg.Temperature = twoDecimals(gpu.Temperature)
|
|
||||||
gpuAvg.MemoryUsed = twoDecimals(gpu.MemoryUsed)
|
|
||||||
gpuAvg.MemoryTotal = twoDecimals(gpu.MemoryTotal)
|
|
||||||
|
|
||||||
// avoid division by zero
|
// avoid division by zero
|
||||||
if gpu.Count > 0 {
|
count := max(gpu.Count, 1)
|
||||||
gpuAvg.Usage = twoDecimals(gpu.Usage / gpu.Count)
|
|
||||||
gpuAvg.Power = twoDecimals(gpu.Power / gpu.Count)
|
// average the data
|
||||||
|
gpuAvg := *gpu
|
||||||
|
gpuAvg.Temperature = twoDecimals(gpu.Temperature)
|
||||||
|
gpuAvg.Power = twoDecimals(gpu.Power / count)
|
||||||
|
|
||||||
|
// intel gpu stats doesn't provide usage, memory used, or memory total
|
||||||
|
if gpu.Engines != nil {
|
||||||
|
maxEngineUsage := 0.0
|
||||||
|
for name, engine := range gpu.Engines {
|
||||||
|
gpuAvg.Engines[name] = twoDecimals(engine / count)
|
||||||
|
maxEngineUsage = max(maxEngineUsage, engine/count)
|
||||||
|
}
|
||||||
|
gpuAvg.PowerPkg = twoDecimals(gpu.PowerPkg / count)
|
||||||
|
gpuAvg.Usage = twoDecimals(maxEngineUsage)
|
||||||
|
} else {
|
||||||
|
gpuAvg.Usage = twoDecimals(gpu.Usage / count)
|
||||||
|
gpuAvg.MemoryUsed = twoDecimals(gpu.MemoryUsed)
|
||||||
|
gpuAvg.MemoryTotal = twoDecimals(gpu.MemoryTotal)
|
||||||
}
|
}
|
||||||
|
|
||||||
// reset accumulators in the original
|
// reset accumulators in the original gpu data for next collection
|
||||||
gpu.Usage, gpu.Power, gpu.Count = 0, 0, 0
|
gpu.Usage, gpu.Power, gpu.PowerPkg, gpu.Count = gpuAvg.Usage, gpuAvg.Power, gpuAvg.PowerPkg, 1
|
||||||
|
gpu.Engines = gpuAvg.Engines
|
||||||
|
|
||||||
// append id to the name if there are multiple GPUs with the same name
|
// append id to the name if there are multiple GPUs with the same name
|
||||||
if nameCounts[gpu.Name] > 1 {
|
if nameCounts[gpu.Name] > 1 {
|
||||||
@@ -283,18 +295,37 @@ func (gm *GPUManager) detectGPUs() error {
|
|||||||
gm.tegrastats = true
|
gm.tegrastats = true
|
||||||
gm.nvidiaSmi = false
|
gm.nvidiaSmi = false
|
||||||
}
|
}
|
||||||
if gm.nvidiaSmi || gm.rocmSmi || gm.tegrastats {
|
if _, err := exec.LookPath(intelGpuStatsCmd); err == nil {
|
||||||
|
gm.intelGpuStats = true
|
||||||
|
}
|
||||||
|
if gm.nvidiaSmi || gm.rocmSmi || gm.tegrastats || gm.intelGpuStats {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return fmt.Errorf("no GPU found - install nvidia-smi, rocm-smi, or tegrastats")
|
return fmt.Errorf("no GPU found - install nvidia-smi, rocm-smi, tegrastats, or intel_gpu_top")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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,
|
||||||
@@ -328,6 +359,9 @@ 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
|
||||||
@@ -343,6 +377,9 @@ 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
|
||||||
}
|
}
|
||||||
199
agent/gpu_intel.go
Normal file
199
agent/gpu_intel.go
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
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) {
|
||||||
|
cmd := exec.Command(intelGpuStatsCmd, "-s", intelGpuStatsInterval, "-l")
|
||||||
|
// 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' })
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -4,12 +4,13 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"beszel/internal/entities/system"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
@@ -378,12 +379,12 @@ func TestGetCurrentData(t *testing.T) {
|
|||||||
assert.InDelta(t, 60.0, result["1"].Power, 0.01)
|
assert.InDelta(t, 60.0, result["1"].Power, 0.01)
|
||||||
|
|
||||||
// Verify that accumulators in the original map are reset
|
// 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.EqualValues(t, float64(1), 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.EqualValues(t, float64(50.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(100.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(1), 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(30), 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")
|
assert.Equal(t, float64(60), gm.GpuDataMap["1"].Power, "GPU 1 Power should be reset")
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("handles zero count without panicking", func(t *testing.T) {
|
t.Run("handles zero count without panicking", func(t *testing.T) {
|
||||||
@@ -408,7 +409,7 @@ func TestGetCurrentData(t *testing.T) {
|
|||||||
assert.Equal(t, 0.0, result["0"].Power)
|
assert.Equal(t, 0.0, result["0"].Power)
|
||||||
|
|
||||||
// Verify reset count
|
// Verify reset count
|
||||||
assert.Equal(t, float64(0), gm.GpuDataMap["0"].Count)
|
assert.EqualValues(t, 1, gm.GpuDataMap["0"].Count)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -755,11 +756,11 @@ func TestAccumulation(t *testing.T) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
assert.InDelta(t, expected.temperature, gpu.Temperature, 0.01, "Temperature should match")
|
assert.EqualValues(t, expected.temperature, gpu.Temperature, "Temperature should match")
|
||||||
assert.InDelta(t, expected.memoryUsed, gpu.MemoryUsed, 0.01, "Memory used should match")
|
assert.EqualValues(t, expected.memoryUsed, gpu.MemoryUsed, "Memory used should match")
|
||||||
assert.InDelta(t, expected.memoryTotal, gpu.MemoryTotal, 0.01, "Memory total should match")
|
assert.EqualValues(t, expected.memoryTotal, gpu.MemoryTotal, "Memory total should match")
|
||||||
assert.InDelta(t, expected.usage, gpu.Usage, 0.01, "Usage should match")
|
assert.EqualValues(t, expected.usage, gpu.Usage, "Usage should match")
|
||||||
assert.InDelta(t, expected.power, gpu.Power, 0.01, "Power should match")
|
assert.EqualValues(t, expected.power, gpu.Power, "Power should match")
|
||||||
assert.Equal(t, expected.count, gpu.Count, "Count should match")
|
assert.Equal(t, expected.count, gpu.Count, "Count should match")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -772,22 +773,320 @@ func TestAccumulation(t *testing.T) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
assert.InDelta(t, expected.temperature, gpu.Temperature, 0.01, "Temperature in GetCurrentData should match")
|
assert.EqualValues(t, expected.temperature, gpu.Temperature, "Temperature in GetCurrentData should match")
|
||||||
assert.InDelta(t, expected.avgUsage, gpu.Usage, 0.01, "Average usage in GetCurrentData should match")
|
assert.EqualValues(t, expected.avgUsage, gpu.Usage, "Average usage in GetCurrentData should match")
|
||||||
assert.InDelta(t, expected.avgPower, gpu.Power, 0.01, "Average power in GetCurrentData should match")
|
assert.EqualValues(t, expected.avgPower, gpu.Power, "Average power in GetCurrentData should match")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify that accumulators in the original map are reset
|
// Verify that accumulators in the original map are reset
|
||||||
for id := range tt.expectedValues {
|
for id, expected := range tt.expectedValues {
|
||||||
gpu, exists := gm.GpuDataMap[id]
|
gpu, exists := gm.GpuDataMap[id]
|
||||||
assert.True(t, exists, "GPU with ID %s should still exist after GetCurrentData", id)
|
assert.True(t, exists, "GPU with ID %s should still exist after GetCurrentData", id)
|
||||||
if !exists {
|
if !exists {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
assert.Equal(t, float64(0), gpu.Count, "Count should be reset for GPU ID %s", id)
|
assert.EqualValues(t, 1, 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.EqualValues(t, expected.avgUsage, 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)
|
assert.EqualValues(t, expected.avgPower, gpu.Power, "Power should be reset for GPU ID %s", id)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestIntelUpdateFromStats(t *testing.T) {
|
||||||
|
gm := &GPUManager{
|
||||||
|
GpuDataMap: make(map[string]*system.GPUData),
|
||||||
|
}
|
||||||
|
|
||||||
|
// First sample with power and two engines
|
||||||
|
sample1 := intelGpuStats{
|
||||||
|
PowerGPU: 10.5,
|
||||||
|
Engines: map[string]float64{
|
||||||
|
"Render/3D": 20.0,
|
||||||
|
"Video": 5.0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
ok := gm.updateIntelFromStats(&sample1)
|
||||||
|
assert.True(t, ok)
|
||||||
|
|
||||||
|
gpu := gm.GpuDataMap["0"]
|
||||||
|
require.NotNil(t, gpu)
|
||||||
|
assert.Equal(t, "GPU", gpu.Name)
|
||||||
|
assert.EqualValues(t, 10.5, gpu.Power)
|
||||||
|
assert.EqualValues(t, 20.0, gpu.Engines["Render/3D"])
|
||||||
|
assert.EqualValues(t, 5.0, gpu.Engines["Video"])
|
||||||
|
assert.Equal(t, float64(1), gpu.Count)
|
||||||
|
|
||||||
|
// Second sample with zero power (should not add) and additional engine busy
|
||||||
|
sample2 := intelGpuStats{
|
||||||
|
PowerGPU: 0.0,
|
||||||
|
Engines: map[string]float64{
|
||||||
|
"Render/3D": 10.0,
|
||||||
|
"Video": 2.5,
|
||||||
|
"Blitter": 1.0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
// zero power should not increment power accumulator
|
||||||
|
|
||||||
|
ok = gm.updateIntelFromStats(&sample2)
|
||||||
|
assert.True(t, ok)
|
||||||
|
|
||||||
|
gpu = gm.GpuDataMap["0"]
|
||||||
|
require.NotNil(t, gpu)
|
||||||
|
assert.EqualValues(t, 10.5, gpu.Power)
|
||||||
|
assert.EqualValues(t, 30.0, gpu.Engines["Render/3D"]) // 20 + 10
|
||||||
|
assert.EqualValues(t, 7.5, gpu.Engines["Video"]) // 5 + 2.5
|
||||||
|
assert.EqualValues(t, 1.0, gpu.Engines["Blitter"])
|
||||||
|
assert.Equal(t, float64(2), gpu.Count)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntelCollectorStreaming(t *testing.T) {
|
||||||
|
// Save and override PATH
|
||||||
|
origPath := os.Getenv("PATH")
|
||||||
|
defer os.Setenv("PATH", origPath)
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
os.Setenv("PATH", dir)
|
||||||
|
|
||||||
|
// Create a fake intel_gpu_top that prints -l format with four samples (first will be skipped) and exits
|
||||||
|
scriptPath := filepath.Join(dir, "intel_gpu_top")
|
||||||
|
script := `#!/bin/sh
|
||||||
|
echo "Freq MHz IRQ RC6 Power W IMC MiB/s RCS BCS VCS"
|
||||||
|
echo " req act /s % gpu pkg rd wr % se wa % se wa % se wa"
|
||||||
|
echo "373 373 224 45 1.50 4.13 2554 714 12.34 0 0 0.00 0 0 5.00 0 0"
|
||||||
|
echo "226 223 338 58 2.00 2.69 1820 965 0.00 0 0 0.00 0 0 0.00 0 0"
|
||||||
|
echo "189 187 412 67 1.80 2.45 1950 823 8.50 2 1 15.00 1 0 22.00 0 1"
|
||||||
|
echo "298 295 278 51 2.20 3.12 1675 942 5.75 1 2 9.50 3 1 12.00 1 0"`
|
||||||
|
if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gm := &GPUManager{
|
||||||
|
GpuDataMap: make(map[string]*system.GPUData),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run the collector once; it should read four samples but skip the first and return
|
||||||
|
if err := gm.collectIntelStats(); err != nil {
|
||||||
|
t.Fatalf("collectIntelStats error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gpu := gm.GpuDataMap["0"]
|
||||||
|
require.NotNil(t, gpu)
|
||||||
|
// Power should be sum of samples 2-4 (first is skipped): 2.0 + 1.8 + 2.2 = 6.0
|
||||||
|
assert.EqualValues(t, 6.0, gpu.Power)
|
||||||
|
assert.InDelta(t, 8.26, gpu.PowerPkg, 0.01) // Allow small floating point differences
|
||||||
|
// Engines aggregated from samples 2-4
|
||||||
|
assert.EqualValues(t, 14.25, gpu.Engines["Render/3D"]) // 0.00 + 8.50 + 5.75
|
||||||
|
assert.EqualValues(t, 34.0, gpu.Engines["Video"]) // 0.00 + 22.00 + 12.00
|
||||||
|
assert.EqualValues(t, 24.5, gpu.Engines["Blitter"]) // 0.00 + 15.00 + 9.50
|
||||||
|
// Count should be 3 samples (first is skipped)
|
||||||
|
assert.Equal(t, float64(3), gpu.Count)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseIntelHeaders(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
header1 string
|
||||||
|
header2 string
|
||||||
|
wantEngineNames []string
|
||||||
|
wantFriendlyNames []string
|
||||||
|
wantPowerIndex int
|
||||||
|
wantPreEngineCols int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "basic headers with RCS BCS VCS",
|
||||||
|
header1: "Freq MHz IRQ RC6 Power W IMC MiB/s RCS BCS VCS",
|
||||||
|
header2: " req act /s % gpu pkg rd wr % se wa % se wa % se wa",
|
||||||
|
wantEngineNames: []string{"RCS", "BCS", "VCS"},
|
||||||
|
wantFriendlyNames: []string{"Render/3D", "Blitter", "Video"},
|
||||||
|
wantPowerIndex: 4, // "gpu" is at index 4
|
||||||
|
wantPreEngineCols: 8, // 17 total cols - 3*3 = 8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "headers with only RCS",
|
||||||
|
header1: "Freq MHz IRQ RC6 Power W IMC MiB/s RCS",
|
||||||
|
header2: " req act /s % gpu pkg rd wr % se wa",
|
||||||
|
wantEngineNames: []string{"RCS"},
|
||||||
|
wantFriendlyNames: []string{"Render/3D"},
|
||||||
|
wantPowerIndex: 4,
|
||||||
|
wantPreEngineCols: 8, // 11 total - 3*1 = 8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "headers with VECS and CCS",
|
||||||
|
header1: "Freq MHz IRQ RC6 Power W IMC MiB/s VECS CCS",
|
||||||
|
header2: " req act /s % gpu pkg rd wr % se wa % se wa",
|
||||||
|
wantEngineNames: []string{"VECS", "CCS"},
|
||||||
|
wantFriendlyNames: []string{"VideoEnhance", "Compute"},
|
||||||
|
wantPowerIndex: 4,
|
||||||
|
wantPreEngineCols: 8, // 14 total - 3*2 = 8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no engines",
|
||||||
|
header1: "Freq MHz IRQ RC6 Power W IMC MiB/s",
|
||||||
|
header2: " req act /s % gpu pkg rd wr",
|
||||||
|
wantEngineNames: nil, // no engines found, slices remain nil
|
||||||
|
wantFriendlyNames: nil,
|
||||||
|
wantPowerIndex: -1, // no engines, so no search
|
||||||
|
wantPreEngineCols: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "power index not found",
|
||||||
|
header1: "Freq MHz IRQ RC6 Power W IMC MiB/s RCS",
|
||||||
|
header2: " req act /s % pkg cpu rd wr % se wa", // no "gpu"
|
||||||
|
wantEngineNames: []string{"RCS"},
|
||||||
|
wantFriendlyNames: []string{"Render/3D"},
|
||||||
|
wantPowerIndex: -1, // "gpu" not found
|
||||||
|
wantPreEngineCols: 8, // 11 total - 3*1 = 8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty headers",
|
||||||
|
header1: "",
|
||||||
|
header2: "",
|
||||||
|
wantEngineNames: nil, // empty input, slices remain nil
|
||||||
|
wantFriendlyNames: nil,
|
||||||
|
wantPowerIndex: -1,
|
||||||
|
wantPreEngineCols: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
gm := &GPUManager{}
|
||||||
|
engineNames, friendlyNames, powerIndex, preEngineCols := gm.parseIntelHeaders(tt.header1, tt.header2)
|
||||||
|
|
||||||
|
assert.Equal(t, tt.wantEngineNames, engineNames)
|
||||||
|
assert.Equal(t, tt.wantFriendlyNames, friendlyNames)
|
||||||
|
assert.Equal(t, tt.wantPowerIndex, powerIndex)
|
||||||
|
assert.Equal(t, tt.wantPreEngineCols, preEngineCols)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseIntelData(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
line string
|
||||||
|
engineNames []string
|
||||||
|
friendlyNames []string
|
||||||
|
powerIndex int
|
||||||
|
preEngineCols int
|
||||||
|
wantPowerGPU float64
|
||||||
|
wantEngines map[string]float64
|
||||||
|
wantErr error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "basic data with power and engines",
|
||||||
|
line: "373 373 224 45 1.50 4.13 2554 714 12.34 0 0 0.00 0 0 5.00 0 0",
|
||||||
|
engineNames: []string{"RCS", "BCS", "VCS"},
|
||||||
|
friendlyNames: []string{"Render/3D", "Blitter", "Video"},
|
||||||
|
powerIndex: 4,
|
||||||
|
preEngineCols: 8,
|
||||||
|
wantPowerGPU: 1.50,
|
||||||
|
wantEngines: map[string]float64{
|
||||||
|
"Render/3D": 12.34,
|
||||||
|
"Blitter": 0.00,
|
||||||
|
"Video": 5.00,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "data with zero power",
|
||||||
|
line: "226 223 338 58 0.00 2.69 1820 965 0.00 0 0 0.00 0 0 0.00 0 0",
|
||||||
|
engineNames: []string{"RCS", "BCS", "VCS"},
|
||||||
|
friendlyNames: []string{"Render/3D", "Blitter", "Video"},
|
||||||
|
powerIndex: 4,
|
||||||
|
preEngineCols: 8,
|
||||||
|
wantPowerGPU: 0.00,
|
||||||
|
wantEngines: map[string]float64{
|
||||||
|
"Render/3D": 0.00,
|
||||||
|
"Blitter": 0.00,
|
||||||
|
"Video": 0.00,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "data with no power index",
|
||||||
|
line: "373 373 224 45 1.50 4.13 2554 714 12.34 0 0 0.00 0 0 5.00 0 0",
|
||||||
|
engineNames: []string{"RCS", "BCS", "VCS"},
|
||||||
|
friendlyNames: []string{"Render/3D", "Blitter", "Video"},
|
||||||
|
powerIndex: -1,
|
||||||
|
preEngineCols: 8,
|
||||||
|
wantPowerGPU: 0.0, // no power parsed
|
||||||
|
wantEngines: map[string]float64{
|
||||||
|
"Render/3D": 12.34,
|
||||||
|
"Blitter": 0.00,
|
||||||
|
"Video": 5.00,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "data with insufficient columns",
|
||||||
|
line: "373 373 224 45 1.50", // too few columns
|
||||||
|
engineNames: []string{"RCS", "BCS", "VCS"},
|
||||||
|
friendlyNames: []string{"Render/3D", "Blitter", "Video"},
|
||||||
|
powerIndex: 4,
|
||||||
|
preEngineCols: 8,
|
||||||
|
wantPowerGPU: 0.0,
|
||||||
|
wantEngines: nil, // empty sample returned
|
||||||
|
wantErr: errNoValidData,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty line",
|
||||||
|
line: "",
|
||||||
|
engineNames: []string{"RCS"},
|
||||||
|
friendlyNames: []string{"Render/3D"},
|
||||||
|
powerIndex: 4,
|
||||||
|
preEngineCols: 8,
|
||||||
|
wantPowerGPU: 0.0,
|
||||||
|
wantEngines: nil,
|
||||||
|
wantErr: errNoValidData,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "data with invalid power value",
|
||||||
|
line: "373 373 224 45 N/A 4.13 2554 714 12.34 0 0 0.00 0 0 5.00 0 0",
|
||||||
|
engineNames: []string{"RCS", "BCS", "VCS"},
|
||||||
|
friendlyNames: []string{"Render/3D", "Blitter", "Video"},
|
||||||
|
powerIndex: 4,
|
||||||
|
preEngineCols: 8,
|
||||||
|
wantPowerGPU: 0.0, // N/A can't be parsed
|
||||||
|
wantEngines: map[string]float64{
|
||||||
|
"Render/3D": 12.34,
|
||||||
|
"Blitter": 0.00,
|
||||||
|
"Video": 5.00,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "data with invalid engine value",
|
||||||
|
line: "373 373 224 45 1.50 4.13 2554 714 N/A 0 0 0.00 0 0 5.00 0 0",
|
||||||
|
engineNames: []string{"RCS", "BCS", "VCS"},
|
||||||
|
friendlyNames: []string{"Render/3D", "Blitter", "Video"},
|
||||||
|
powerIndex: 4,
|
||||||
|
preEngineCols: 8,
|
||||||
|
wantPowerGPU: 1.50,
|
||||||
|
wantEngines: map[string]float64{
|
||||||
|
"Render/3D": 0.0, // N/A becomes 0
|
||||||
|
"Blitter": 0.00,
|
||||||
|
"Video": 5.00,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "data with no engines",
|
||||||
|
line: "373 373 224 45 1.50 4.13 2554 714",
|
||||||
|
engineNames: []string{},
|
||||||
|
friendlyNames: []string{},
|
||||||
|
powerIndex: 4,
|
||||||
|
preEngineCols: 8,
|
||||||
|
wantPowerGPU: 1.50,
|
||||||
|
wantEngines: nil,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
gm := &GPUManager{}
|
||||||
|
sample, err := gm.parseIntelData(tt.line, tt.engineNames, tt.friendlyNames, tt.powerIndex, tt.preEngineCols)
|
||||||
|
assert.Equal(t, tt.wantErr, err)
|
||||||
|
|
||||||
|
assert.Equal(t, tt.wantPowerGPU, sample.PowerGPU)
|
||||||
|
assert.Equal(t, tt.wantEngines, sample.Engines)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
200
agent/network.go
Normal file
200
agent/network.go
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
var netInterfaceDeltaTracker = deltatracker.NewDeltaTracker[string, uint64]()
|
||||||
|
|
||||||
|
// 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(systemStats *system.Stats) {
|
||||||
|
// 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 systemStats.NetworkInterfaces == nil {
|
||||||
|
systemStats.NetworkInterfaces = make(map[string][4]uint64, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
netInterfaceDeltaTracker.Cycle()
|
||||||
|
// 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
|
||||||
|
|
||||||
|
// track deltas for each network interface
|
||||||
|
var upDelta, downDelta uint64
|
||||||
|
upKey, downKey := fmt.Sprintf("%sup", v.Name), fmt.Sprintf("%sdown", v.Name)
|
||||||
|
netInterfaceDeltaTracker.Set(upKey, v.BytesSent)
|
||||||
|
netInterfaceDeltaTracker.Set(downKey, v.BytesRecv)
|
||||||
|
if msElapsed > 0 {
|
||||||
|
upDelta = netInterfaceDeltaTracker.Delta(upKey) * 1000 / msElapsed
|
||||||
|
downDelta = netInterfaceDeltaTracker.Delta(downKey) * 1000 / msElapsed
|
||||||
|
}
|
||||||
|
// add interface to systemStats
|
||||||
|
systemStats.NetworkInterfaces[v.Name] = [4]uint64{upDelta, downDelta, v.BytesSent, 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
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)
|
||||||
|
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"),
|
||||||
|
strings.HasPrefix(v.Name, "cali"),
|
||||||
|
v.BytesRecv == 0,
|
||||||
|
v.BytesSent == 0:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
259
agent/network_test.go
Normal file
259
agent/network_test.go
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
//go:build testing
|
||||||
|
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"beszel/internal/entities/system"
|
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -11,6 +10,8 @@ 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,12 +4,13 @@
|
|||||||
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,9 +46,10 @@ 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)")
|
var errNoSensors = errors.New("no sensors found (try running as admin with LHM=true)")
|
||||||
|
|
||||||
// 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) {
|
||||||
@@ -139,7 +140,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 lhm.stoppedNoSensors {
|
if !useLHM || 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)
|
||||||
}
|
}
|
||||||
@@ -222,6 +223,10 @@ 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,9 +1,6 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"beszel"
|
|
||||||
"beszel/internal/common"
|
|
||||||
"beszel/internal/entities/system"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -14,6 +11,10 @@ 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"
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"beszel/internal/entities/container"
|
|
||||||
"beszel/internal/entities/system"
|
|
||||||
"context"
|
"context"
|
||||||
"crypto/ed25519"
|
"crypto/ed25519"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -15,6 +13,9 @@ 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"
|
||||||
@@ -1,9 +1,6 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"beszel"
|
|
||||||
"beszel/internal/agent/battery"
|
|
||||||
"beszel/internal/entities/system"
|
|
||||||
"bufio"
|
"bufio"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -12,12 +9,15 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel"
|
||||||
|
"github.com/henrygd/beszel/agent/battery"
|
||||||
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
|
||||||
"github.com/shirou/gopsutil/v4/cpu"
|
"github.com/shirou/gopsutil/v4/cpu"
|
||||||
"github.com/shirou/gopsutil/v4/disk"
|
"github.com/shirou/gopsutil/v4/disk"
|
||||||
"github.com/shirou/gopsutil/v4/host"
|
"github.com/shirou/gopsutil/v4/host"
|
||||||
"github.com/shirou/gopsutil/v4/load"
|
"github.com/shirou/gopsutil/v4/load"
|
||||||
"github.com/shirou/gopsutil/v4/mem"
|
"github.com/shirou/gopsutil/v4/mem"
|
||||||
psutilNet "github.com/shirou/gopsutil/v4/net"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Sets initial / non-changing values about the host system
|
// Sets initial / non-changing values about the host system
|
||||||
@@ -31,7 +31,7 @@ func (a *Agent) initializeSystemInfo() {
|
|||||||
a.systemInfo.KernelVersion = version
|
a.systemInfo.KernelVersion = version
|
||||||
a.systemInfo.Os = system.Darwin
|
a.systemInfo.Os = system.Darwin
|
||||||
} else if strings.Contains(platform, "indows") {
|
} else if strings.Contains(platform, "indows") {
|
||||||
a.systemInfo.KernelVersion = strings.Replace(platform, "Microsoft ", "", 1) + " " + version
|
a.systemInfo.KernelVersion = fmt.Sprintf("%s %s", strings.Replace(platform, "Microsoft ", "", 1), version)
|
||||||
a.systemInfo.Os = system.Windows
|
a.systemInfo.Os = system.Windows
|
||||||
} else if platform == "freebsd" {
|
} else if platform == "freebsd" {
|
||||||
a.systemInfo.Os = system.Freebsd
|
a.systemInfo.Os = system.Freebsd
|
||||||
@@ -69,7 +69,7 @@ func (a *Agent) initializeSystemInfo() {
|
|||||||
|
|
||||||
// Returns current info, stats about the host system
|
// Returns current info, stats about the host system
|
||||||
func (a *Agent) getSystemStats() system.Stats {
|
func (a *Agent) getSystemStats() system.Stats {
|
||||||
systemStats := system.Stats{}
|
var systemStats system.Stats
|
||||||
|
|
||||||
// battery
|
// battery
|
||||||
if battery.HasReadableBattery() {
|
if battery.HasReadableBattery() {
|
||||||
@@ -100,14 +100,22 @@ func (a *Agent) getSystemStats() system.Stats {
|
|||||||
systemStats.Swap = bytesToGigabytes(v.SwapTotal)
|
systemStats.Swap = bytesToGigabytes(v.SwapTotal)
|
||||||
systemStats.SwapUsed = bytesToGigabytes(v.SwapTotal - v.SwapFree - v.SwapCached)
|
systemStats.SwapUsed = bytesToGigabytes(v.SwapTotal - v.SwapFree - v.SwapCached)
|
||||||
// cache + buffers value for default mem calculation
|
// cache + buffers value for default mem calculation
|
||||||
cacheBuff := v.Total - v.Free - v.Used
|
// note: gopsutil automatically adds SReclaimable to v.Cached
|
||||||
// htop memory calculation overrides
|
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" {
|
if a.memCalc == "htop" {
|
||||||
// note: gopsutil automatically adds SReclaimable to v.Cached
|
// cacheBuff = v.Cached + v.Buffers - v.Shared
|
||||||
cacheBuff = v.Cached + v.Buffers - v.Shared
|
|
||||||
v.Used = v.Total - (v.Free + cacheBuff)
|
v.Used = v.Total - (v.Free + cacheBuff)
|
||||||
v.UsedPercent = float64(v.Used) / float64(v.Total) * 100.0
|
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
|
// subtract ZFS ARC size from used memory and add as its own category
|
||||||
if a.zfs {
|
if a.zfs {
|
||||||
if arcSize, _ := getARCSize(); arcSize > 0 && arcSize < v.Used {
|
if arcSize, _ := getARCSize(); arcSize > 0 && arcSize < v.Used {
|
||||||
@@ -172,55 +180,7 @@ func (a *Agent) getSystemStats() system.Stats {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// network stats
|
// network stats
|
||||||
if len(a.netInterfaces) == 0 {
|
a.updateNetworkStats(&systemStats)
|
||||||
// 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
|
// temperatures
|
||||||
// TODO: maybe refactor to methods on systemStats
|
// TODO: maybe refactor to methods on systemStats
|
||||||
@@ -260,6 +220,7 @@ func (a *Agent) getSystemStats() system.Stats {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// update base system info
|
// update base system info
|
||||||
|
a.systemInfo.ConnectionType = a.connectionManager.ConnectionType
|
||||||
a.systemInfo.Cpu = systemStats.Cpu
|
a.systemInfo.Cpu = systemStats.Cpu
|
||||||
a.systemInfo.LoadAvg = systemStats.LoadAvg
|
a.systemInfo.LoadAvg = systemStats.LoadAvg
|
||||||
// TODO: remove these in future release in favor of load avg array
|
// TODO: remove these in future release in favor of load avg array
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"beszel/internal/ghupdate"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/ghupdate"
|
||||||
)
|
)
|
||||||
|
|
||||||
// restarter knows how to restart the beszel-agent service.
|
// restarter knows how to restart the beszel-agent service.
|
||||||
@@ -28,11 +30,11 @@ func (s *systemdRestarter) Restart() error {
|
|||||||
type openRCRestarter struct{ cmd string }
|
type openRCRestarter struct{ cmd string }
|
||||||
|
|
||||||
func (o *openRCRestarter) Restart() error {
|
func (o *openRCRestarter) Restart() error {
|
||||||
if err := exec.Command(o.cmd, "status", "beszel-agent").Run(); err != nil {
|
if err := exec.Command(o.cmd, "beszel-agent", "status").Run(); err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
ghupdate.ColorPrint(ghupdate.ColorYellow, "Restarting beszel-agent via OpenRC…")
|
ghupdate.ColorPrint(ghupdate.ColorYellow, "Restarting beszel-agent via OpenRC…")
|
||||||
return exec.Command(o.cmd, "restart", "beszel-agent").Run()
|
return exec.Command(o.cmd, "beszel-agent", "restart").Run()
|
||||||
}
|
}
|
||||||
|
|
||||||
type openWRTRestarter struct{ cmd string }
|
type openWRTRestarter struct{ cmd string }
|
||||||
@@ -45,6 +47,16 @@ func (w *openWRTRestarter) Restart() error {
|
|||||||
return exec.Command(w.cmd, "restart", "beszel-agent").Run()
|
return exec.Command(w.cmd, "restart", "beszel-agent").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 {
|
func detectRestarter() restarter {
|
||||||
if path, err := exec.LookPath("systemctl"); err == nil {
|
if path, err := exec.LookPath("systemctl"); err == nil {
|
||||||
return &systemdRestarter{cmd: path}
|
return &systemdRestarter{cmd: path}
|
||||||
@@ -53,6 +65,9 @@ func detectRestarter() restarter {
|
|||||||
return &openRCRestarter{cmd: path}
|
return &openRCRestarter{cmd: path}
|
||||||
}
|
}
|
||||||
if path, err := exec.LookPath("service"); err == nil {
|
if path, err := exec.LookPath("service"); err == nil {
|
||||||
|
if runtime.GOOS == "freebsd" {
|
||||||
|
return &freeBSDRestarter{cmd: path}
|
||||||
|
}
|
||||||
return &openWRTRestarter{cmd: path}
|
return &openWRTRestarter{cmd: path}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -60,7 +75,7 @@ func detectRestarter() restarter {
|
|||||||
|
|
||||||
// Update checks GitHub for a newer release of beszel-agent, applies it,
|
// Update checks GitHub for a newer release of beszel-agent, applies it,
|
||||||
// fixes SELinux context if needed, and restarts the service.
|
// fixes SELinux context if needed, and restarts the service.
|
||||||
func Update() error {
|
func Update(useMirror bool) error {
|
||||||
exePath, _ := os.Executable()
|
exePath, _ := os.Executable()
|
||||||
|
|
||||||
dataDir, err := getDataDir()
|
dataDir, err := getDataDir()
|
||||||
@@ -70,6 +85,7 @@ func Update() error {
|
|||||||
updated, err := ghupdate.Update(ghupdate.Config{
|
updated, err := ghupdate.Update(ghupdate.Config{
|
||||||
ArchiveExecutable: "beszel-agent",
|
ArchiveExecutable: "beszel-agent",
|
||||||
DataDir: dataDir,
|
DataDir: dataDir,
|
||||||
|
UseMirror: useMirror,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
@@ -99,6 +115,8 @@ func Update() error {
|
|||||||
if err := r.Restart(); err != nil {
|
if err := r.Restart(); err != nil {
|
||||||
ghupdate.ColorPrintf(ghupdate.ColorYellow, "Warning: failed to restart service: %v", err)
|
ghupdate.ColorPrintf(ghupdate.ColorYellow, "Warning: failed to restart service: %v", err)
|
||||||
ghupdate.ColorPrint(ghupdate.ColorYellow, "Please restart the service manually.")
|
ghupdate.ColorPrint(ghupdate.ColorYellow, "Please restart the service manually.")
|
||||||
|
} else {
|
||||||
|
ghupdate.ColorPrint(ghupdate.ColorGreen, "Service restarted successfully")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
ghupdate.ColorPrint(ghupdate.ColorYellow, "No supported init system detected; please restart manually if needed.")
|
ghupdate.ColorPrint(ghupdate.ColorYellow, "No supported init system detected; please restart manually if needed.")
|
||||||
15
beszel.go
Normal file
15
beszel.go
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
// 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.12.12"
|
||||||
|
// 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")
|
||||||
@@ -1,98 +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 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
|
|
||||||
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:
|
|
||||||
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:
|
|
||||||
@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,67 +0,0 @@
|
|||||||
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,368 +0,0 @@
|
|||||||
//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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
//go:build testing
|
|
||||||
// +build testing
|
|
||||||
|
|
||||||
package records
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestDeleteOldSystemStats exposes deleteOldSystemStats for testing
|
|
||||||
func TestDeleteOldSystemStats(app core.App) error {
|
|
||||||
return deleteOldSystemStats(app)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestDeleteOldAlertsHistory exposes deleteOldAlertsHistory for testing
|
|
||||||
func TestDeleteOldAlertsHistory(app core.App, countToKeep, countBeforeDeletion int) error {
|
|
||||||
return deleteOldAlertsHistory(app, countToKeep, countBeforeDeletion)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestTwoDecimals exposes twoDecimals for testing
|
|
||||||
func TestTwoDecimals(value float64) float64 {
|
|
||||||
return twoDecimals(value)
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
package migrations
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
|
||||||
m "github.com/pocketbase/pocketbase/migrations"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
TempAdminEmail = "_@b.b"
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
m.Register(func(app core.App) error {
|
|
||||||
// initial settings
|
|
||||||
settings := app.Settings()
|
|
||||||
settings.Meta.AppName = "Beszel"
|
|
||||||
settings.Meta.HideControls = true
|
|
||||||
settings.Logs.MinLevel = 4
|
|
||||||
if err := app.Save(settings); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
// create superuser
|
|
||||||
collection, _ := app.FindCollectionByNameOrId(core.CollectionNameSuperusers)
|
|
||||||
user := core.NewRecord(collection)
|
|
||||||
user.SetEmail(TempAdminEmail)
|
|
||||||
user.SetRandomPassword()
|
|
||||||
return app.Save(user)
|
|
||||||
}, nil)
|
|
||||||
}
|
|
||||||
Binary file not shown.
@@ -1,105 +0,0 @@
|
|||||||
import { CartesianGrid, Line, LineChart, YAxis } from "recharts"
|
|
||||||
|
|
||||||
import {
|
|
||||||
ChartContainer,
|
|
||||||
ChartLegend,
|
|
||||||
ChartLegendContent,
|
|
||||||
ChartTooltip,
|
|
||||||
ChartTooltipContent,
|
|
||||||
xAxis,
|
|
||||||
} from "@/components/ui/chart"
|
|
||||||
import { useYAxisWidth, cn, formatShortDate, toFixedFloat, decimalString, chartMargin } from "@/lib/utils"
|
|
||||||
import { ChartData } from "@/types"
|
|
||||||
import { memo, useMemo } from "react"
|
|
||||||
|
|
||||||
export default memo(function GpuPowerChart({ chartData }: { chartData: ChartData }) {
|
|
||||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
|
||||||
|
|
||||||
if (chartData.systemStats.length === 0) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Format temperature data for chart and assign colors */
|
|
||||||
const newChartData = useMemo(() => {
|
|
||||||
const newChartData = { data: [], colors: {} } as {
|
|
||||||
data: Record<string, number | string>[]
|
|
||||||
colors: Record<string, string>
|
|
||||||
}
|
|
||||||
const powerSums = {} as Record<string, number>
|
|
||||||
for (let data of chartData.systemStats) {
|
|
||||||
let newData = { created: data.created } as Record<string, number | string>
|
|
||||||
|
|
||||||
for (let gpu of Object.values(data.stats?.g ?? {})) {
|
|
||||||
if (gpu.p) {
|
|
||||||
const name = gpu.n
|
|
||||||
newData[name] = gpu.p
|
|
||||||
powerSums[name] = (powerSums[name] ?? 0) + newData[name]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
newChartData.data.push(newData)
|
|
||||||
}
|
|
||||||
const keys = Object.keys(powerSums).sort((a, b) => powerSums[b] - powerSums[a])
|
|
||||||
for (let key of keys) {
|
|
||||||
newChartData.colors[key] = `hsl(${((keys.indexOf(key) * 360) / keys.length) % 360}, 60%, 55%)`
|
|
||||||
}
|
|
||||||
return newChartData
|
|
||||||
}, [chartData])
|
|
||||||
|
|
||||||
const colors = Object.keys(newChartData.colors)
|
|
||||||
|
|
||||||
// console.log('rendered at', new Date())
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<ChartContainer
|
|
||||||
className={cn("h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity", {
|
|
||||||
"opacity-100": yAxisWidth,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<LineChart accessibilityLayer data={newChartData.data} margin={chartMargin}>
|
|
||||||
<CartesianGrid vertical={false} />
|
|
||||||
<YAxis
|
|
||||||
direction="ltr"
|
|
||||||
orientation={chartData.orientation}
|
|
||||||
className="tracking-tighter"
|
|
||||||
domain={[0, "auto"]}
|
|
||||||
width={yAxisWidth}
|
|
||||||
tickFormatter={(value) => {
|
|
||||||
const val = toFixedFloat(value, 2)
|
|
||||||
return updateYAxisWidth(val + "W")
|
|
||||||
}}
|
|
||||||
tickLine={false}
|
|
||||||
axisLine={false}
|
|
||||||
/>
|
|
||||||
{xAxis(chartData)}
|
|
||||||
<ChartTooltip
|
|
||||||
animationEasing="ease-out"
|
|
||||||
animationDuration={150}
|
|
||||||
// @ts-ignore
|
|
||||||
itemSorter={(a, b) => b.value - a.value}
|
|
||||||
content={
|
|
||||||
<ChartTooltipContent
|
|
||||||
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
|
||||||
contentFormatter={(item) => decimalString(item.value) + "W"}
|
|
||||||
// indicator="line"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{colors.map((key) => (
|
|
||||||
<Line
|
|
||||||
key={key}
|
|
||||||
dataKey={key}
|
|
||||||
name={key}
|
|
||||||
type="monotoneX"
|
|
||||||
dot={false}
|
|
||||||
strokeWidth={1.5}
|
|
||||||
stroke={newChartData.colors[key]}
|
|
||||||
isAnimationActive={false}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{colors.length > 1 && <ChartLegend content={<ChartLegendContent />} />}
|
|
||||||
</LineChart>
|
|
||||||
</ChartContainer>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import { DialogTitle, type DialogProps } from "@radix-ui/react-dialog"
|
|
||||||
import { Command as CommandPrimitive } from "cmdk"
|
|
||||||
import { Search } from "lucide-react"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { Dialog, DialogContent } from "@/components/ui/dialog"
|
|
||||||
|
|
||||||
const Command = React.forwardRef<
|
|
||||||
React.ElementRef<typeof CommandPrimitive>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<CommandPrimitive
|
|
||||||
ref={ref}
|
|
||||||
className={cn("flex h-full w-full flex-col overflow-hidden bg-card", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
Command.displayName = CommandPrimitive.displayName
|
|
||||||
|
|
||||||
interface CommandDialogProps extends DialogProps {}
|
|
||||||
|
|
||||||
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
|
|
||||||
return (
|
|
||||||
<Dialog {...props}>
|
|
||||||
<DialogContent className="overflow-hidden p-0 shadow-lg">
|
|
||||||
<div className="sr-only">
|
|
||||||
<DialogTitle>Command</DialogTitle>
|
|
||||||
</div>
|
|
||||||
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
|
||||||
{children}
|
|
||||||
</Command>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const CommandInput = React.forwardRef<
|
|
||||||
React.ElementRef<typeof CommandPrimitive.Input>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
|
||||||
<Search className="me-2 h-4 w-4 shrink-0 opacity-50" />
|
|
||||||
<CommandPrimitive.Input
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-hidden placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
|
|
||||||
CommandInput.displayName = CommandPrimitive.Input.displayName
|
|
||||||
|
|
||||||
const CommandList = React.forwardRef<
|
|
||||||
React.ElementRef<typeof CommandPrimitive.List>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<CommandPrimitive.List
|
|
||||||
ref={ref}
|
|
||||||
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
|
|
||||||
CommandList.displayName = CommandPrimitive.List.displayName
|
|
||||||
|
|
||||||
const CommandEmpty = React.forwardRef<
|
|
||||||
React.ElementRef<typeof CommandPrimitive.Empty>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
|
||||||
>((props, ref) => <CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} />)
|
|
||||||
|
|
||||||
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
|
|
||||||
|
|
||||||
const CommandGroup = React.forwardRef<
|
|
||||||
React.ElementRef<typeof CommandPrimitive.Group>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<CommandPrimitive.Group
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
|
|
||||||
CommandGroup.displayName = CommandPrimitive.Group.displayName
|
|
||||||
|
|
||||||
const CommandSeparator = React.forwardRef<
|
|
||||||
React.ElementRef<typeof CommandPrimitive.Separator>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<CommandPrimitive.Separator ref={ref} className={cn("-mx-1 h-px bg-border", className)} {...props} />
|
|
||||||
))
|
|
||||||
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
|
|
||||||
|
|
||||||
const CommandItem = React.forwardRef<
|
|
||||||
React.ElementRef<typeof CommandPrimitive.Item>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<CommandPrimitive.Item
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"relative flex cursor-default opacity-70 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-hidden aria-selected:bg-accent/60 aria-selected:opacity-90 data-[disabled='true']:pointer-events-none data-[disabled='true']:opacity-50",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
|
|
||||||
CommandItem.displayName = CommandPrimitive.Item.displayName
|
|
||||||
|
|
||||||
const CommandShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
|
|
||||||
return <span className={cn("ms-auto text-xs tracking-wide text-muted-foreground", className)} {...props} />
|
|
||||||
}
|
|
||||||
CommandShortcut.displayName = "CommandShortcut"
|
|
||||||
|
|
||||||
export {
|
|
||||||
Command,
|
|
||||||
CommandDialog,
|
|
||||||
CommandInput,
|
|
||||||
CommandList,
|
|
||||||
CommandEmpty,
|
|
||||||
CommandGroup,
|
|
||||||
CommandItem,
|
|
||||||
CommandShortcut,
|
|
||||||
CommandSeparator,
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import { Toast, ToastClose, ToastDescription, ToastProvider, ToastTitle, ToastViewport } from "@/components/ui/toast"
|
|
||||||
import { useToast } from "@/components/ui/use-toast"
|
|
||||||
|
|
||||||
export function Toaster() {
|
|
||||||
const { toasts } = useToast()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ToastProvider>
|
|
||||||
{toasts.map(function ({ id, title, description, action, ...props }) {
|
|
||||||
return (
|
|
||||||
<Toast key={id} {...props}>
|
|
||||||
<div className="grid gap-1">
|
|
||||||
{title && <ToastTitle>{title}</ToastTitle>}
|
|
||||||
{description && <ToastDescription>{description}</ToastDescription>}
|
|
||||||
</div>
|
|
||||||
{action}
|
|
||||||
<ToastClose />
|
|
||||||
</Toast>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
<ToastViewport />
|
|
||||||
</ToastProvider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
package beszel
|
|
||||||
|
|
||||||
import "github.com/blang/semver"
|
|
||||||
|
|
||||||
const (
|
|
||||||
Version = "0.12.5"
|
|
||||||
AppName = "beszel"
|
|
||||||
)
|
|
||||||
|
|
||||||
var MinVersionCbor = semver.MustParse("0.12.0")
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
module beszel
|
module github.com/henrygd/beszel
|
||||||
|
|
||||||
go 1.24.4
|
go 1.25.1
|
||||||
|
|
||||||
// lock shoutrrr to specific version to allow review before updating
|
// lock shoutrrr to specific version to allow review before updating
|
||||||
replace github.com/nicholas-fedor/shoutrrr => github.com/nicholas-fedor/shoutrrr v0.8.8
|
replace github.com/nicholas-fedor/shoutrrr => github.com/nicholas-fedor/shoutrrr v0.9.1
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/blang/semver v3.5.1+incompatible
|
github.com/blang/semver v3.5.1+incompatible
|
||||||
@@ -12,15 +12,16 @@ require (
|
|||||||
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.8.17
|
github.com/nicholas-fedor/shoutrrr v0.9.1
|
||||||
github.com/pocketbase/dbx v1.11.0
|
github.com/pocketbase/dbx v1.11.0
|
||||||
github.com/pocketbase/pocketbase v0.29.3
|
github.com/pocketbase/pocketbase v0.30.0
|
||||||
github.com/shirou/gopsutil/v4 v4.25.6
|
github.com/shirou/gopsutil/v4 v4.25.8
|
||||||
github.com/spf13/cast v1.9.2
|
github.com/spf13/cast v1.10.0
|
||||||
github.com/spf13/cobra v1.9.1
|
github.com/spf13/cobra v1.10.1
|
||||||
github.com/stretchr/testify v1.11.0
|
github.com/spf13/pflag v1.0.10
|
||||||
golang.org/x/crypto v0.41.0
|
github.com/stretchr/testify v1.11.1
|
||||||
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b
|
golang.org/x/crypto v0.42.0
|
||||||
|
golang.org/x/exp v0.0.0-20250911091902-df9299821621
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -32,9 +33,9 @@ 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/ebitengine/purego v0.9.0 // indirect
|
||||||
github.com/fatih/color v1.18.0 // indirect
|
github.com/fatih/color v1.18.0 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.10 // 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
|
||||||
@@ -42,24 +43,23 @@ require (
|
|||||||
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
|
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/klauspost/compress v1.18.0 // indirect
|
github.com/klauspost/compress v1.18.0 // indirect
|
||||||
github.com/lufia/plan9stats v0.0.0-20250821153705-5981dea3221d // indirect
|
github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54 // 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 v0.1.9 // 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/spf13/pflag v1.0.7 // indirect
|
|
||||||
github.com/tklauser/go-sysconf v0.3.15 // indirect
|
github.com/tklauser/go-sysconf v0.3.15 // indirect
|
||||||
github.com/tklauser/numcpus v0.10.0 // indirect
|
github.com/tklauser/numcpus v0.10.0 // indirect
|
||||||
github.com/x448/float16 v0.8.4 // indirect
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
golang.org/x/image v0.30.0 // indirect
|
golang.org/x/image v0.31.0 // indirect
|
||||||
golang.org/x/net v0.43.0 // indirect
|
golang.org/x/net v0.44.0 // indirect
|
||||||
golang.org/x/oauth2 v0.30.0 // indirect
|
golang.org/x/oauth2 v0.31.0 // indirect
|
||||||
golang.org/x/sync v0.16.0 // indirect
|
golang.org/x/sync v0.17.0 // indirect
|
||||||
golang.org/x/sys v0.35.0 // indirect
|
golang.org/x/sys v0.36.0 // indirect
|
||||||
golang.org/x/text v0.28.0 // indirect
|
golang.org/x/text v0.29.0 // indirect
|
||||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect
|
||||||
howett.net/plist v1.0.1 // indirect
|
howett.net/plist v1.0.1 // indirect
|
||||||
modernc.org/libc v1.66.3 // indirect
|
modernc.org/libc v1.66.3 // indirect
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||||
|
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
|
||||||
|
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
|
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
|
||||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
|
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
|
||||||
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=
|
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=
|
||||||
@@ -21,22 +23,22 @@ github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCO
|
|||||||
github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c=
|
github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw=
|
github.com/ebitengine/purego v0.9.0 h1:mh0zpKBIXDceC63hpvPuGLiJ8ZAa3DfrFTudmfi8A4k=
|
||||||
github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
github.com/ebitengine/purego v0.9.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
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/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
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/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.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
|
github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||||
github.com/ganigeorgiev/fexpr v0.5.0 h1:XA9JxtTE/Xm+g/JFI6RfZEHSiQlk+1glLvRK1Lpv/Tk=
|
github.com/ganigeorgiev/fexpr v0.5.0 h1:XA9JxtTE/Xm+g/JFI6RfZEHSiQlk+1glLvRK1Lpv/Tk=
|
||||||
github.com/ganigeorgiev/fexpr v0.5.0/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
|
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 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
||||||
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
||||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||||
@@ -52,14 +54,14 @@ github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArs
|
|||||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8=
|
github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6 h1:EEHtgt9IwisQ2AZ4pIsMjahcegHh6rmhqxzIRQIyepY=
|
||||||
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
github.com/jarcoal/httpmock v1.4.0 h1:BvhqnH0JAYbNudL2GMJKgOHe2CtKlzJ/5rWKyp+hc2k=
|
github.com/jarcoal/httpmock v1.4.1 h1:0Ju+VCFuARfFlhVXFc2HxlcQkfB+Xq12/EotHko+x2A=
|
||||||
github.com/jarcoal/httpmock v1.4.0/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0=
|
github.com/jarcoal/httpmock v1.4.1/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0=
|
||||||
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
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/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||||
@@ -67,8 +69,8 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
|||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/lufia/plan9stats v0.0.0-20250821153705-5981dea3221d h1:vFzYZc8yji+9DmNRhpEbs8VBK4CgV/DPfGzeVJSSp/8=
|
github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54 h1:mFWunSatvkQQDhpdyuFAYwyAan3hzCuma+Pz8sqvOfg=
|
||||||
github.com/lufia/plan9stats v0.0.0-20250821153705-5981dea3221d/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||||
github.com/lxzan/gws v1.8.9 h1:VU3SGUeWlQrEwfUSfokcZep8mdg/BrUF+y73YYshdBM=
|
github.com/lxzan/gws v1.8.9 h1:VU3SGUeWlQrEwfUSfokcZep8mdg/BrUF+y73YYshdBM=
|
||||||
github.com/lxzan/gws v1.8.9/go.mod h1:d9yHaR1eDTBHagQC6KY7ycUOaz5KWeqQtP3xu7aMK8Y=
|
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 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
@@ -77,19 +79,19 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
|
|||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
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 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
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.9.1 h1:SEBhM6P1favzILO0f55CY3P9JwvM9RZ7B1ZMCl+Injs=
|
||||||
github.com/nicholas-fedor/shoutrrr v0.8.8/go.mod h1:T30Y+eoZFEjDk4HtOItcHQioZSOe3Z6a6aNfSz6jc5c=
|
github.com/nicholas-fedor/shoutrrr v0.9.1/go.mod h1:khue5m8LYyMzdPWuJxDTJeT89l9gjwjA+a+r0e8qxxk=
|
||||||
github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus=
|
github.com/onsi/ginkgo/v2 v2.25.3 h1:Ty8+Yi/ayDAGtk4XxmmfUy4GabvM+MegeB4cDLRi6nw=
|
||||||
github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8=
|
github.com/onsi/ginkgo/v2 v2.25.3/go.mod h1:43uiyQC4Ed2tkOzLsEYm7hnrb7UJTWHYNsuy3bG/snE=
|
||||||
github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y=
|
github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
|
||||||
github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0=
|
github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/pocketbase/dbx v1.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU=
|
github.com/pocketbase/dbx v1.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU=
|
||||||
github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
||||||
github.com/pocketbase/pocketbase v0.29.3 h1:Mj8o5awsbVJIdIoTuQNhfC2oL/c4aImQ3RyfFZlzFVg=
|
github.com/pocketbase/pocketbase v0.30.0 h1:7v9O3hBYyHyptnnFjdP8tEJIuyHEfjhG6PC4gjf5eoE=
|
||||||
github.com/pocketbase/pocketbase v0.29.3/go.mod h1:oGpT67LObxCFK4V2fSL7J9YnPbBnnshOpJ5v3zcneww=
|
github.com/pocketbase/pocketbase v0.30.0/go.mod h1:gZIwampw4VqMcEdGHwBZgSa54xWIDgVJb4uINUMXLmA=
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
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/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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
@@ -97,19 +99,19 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qq
|
|||||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/shirou/gopsutil/v4 v4.25.6 h1:kLysI2JsKorfaFPcYmcJqbzROzsBWEOAtw6A7dIfqXs=
|
github.com/shirou/gopsutil/v4 v4.25.8 h1:NnAsw9lN7587WHxjJA9ryDnqhJpFH6A+wagYWTOH970=
|
||||||
github.com/shirou/gopsutil/v4 v4.25.6/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c=
|
github.com/shirou/gopsutil/v4 v4.25.8/go.mod h1:q9QdMmfAOVIw7a+eF86P7ISEU6ka+NLgkUxlopV4RwI=
|
||||||
github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE=
|
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||||
github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||||
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
|
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
|
||||||
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
|
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
|
||||||
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M=
|
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||||
github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
github.com/stretchr/testify v1.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
|
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/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 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
|
||||||
@@ -120,42 +122,44 @@ github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo
|
|||||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||||
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
|
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
|
||||||
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
|
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
|
||||||
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
|
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
|
||||||
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b h1:DXr+pvt3nC887026GRP39Ej11UATqWDmWuS99x26cD0=
|
golang.org/x/exp v0.0.0-20250911091902-df9299821621 h1:2id6c1/gto0kaHYyrixvknJ8tUK/Qs5IsmBtrc+FtgU=
|
||||||
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4=
|
golang.org/x/exp v0.0.0-20250911091902-df9299821621/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk=
|
||||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||||
golang.org/x/image v0.30.0 h1:jD5RhkmVAnjqaCUXfbGBrn3lpxbknfN9w2UhHHU+5B4=
|
golang.org/x/image v0.31.0 h1:mLChjE2MV6g1S7oqbXC0/UcKijjm5fnJLUYKIYrLESA=
|
||||||
golang.org/x/image v0.30.0/go.mod h1:SAEUTxCCMWSrJcCy/4HwavEsfZZJlYxeHLc6tTiAe/c=
|
golang.org/x/image v0.31.0/go.mod h1:R9ec5Lcp96v9FTF+ajwaH3uGxPH4fKfHHAVbUILxghA=
|
||||||
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
|
golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
|
||||||
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
|
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
|
||||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||||
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
|
golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
|
||||||
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
|
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
|
||||||
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
golang.org/x/oauth2 v0.31.0 h1:8Fq0yVZLh4j4YA47vHKFTa9Ew5XIrCP8LC6UeNZnLxo=
|
||||||
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
golang.org/x/oauth2 v0.31.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
|
golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ=
|
||||||
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
|
golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||||
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
|
||||||
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
|
||||||
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A=
|
||||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
|
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/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
@@ -165,18 +169,20 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM=
|
howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM=
|
||||||
howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
|
howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
|
||||||
modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM=
|
modernc.org/cc/v4 v4.26.4 h1:jPhG8oNjtTYuP2FA4YefTJ/wioNUGALmGuEWt7SUR6s=
|
||||||
modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
modernc.org/cc/v4 v4.26.4/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
|
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
|
||||||
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
|
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
|
||||||
modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM=
|
modernc.org/fileutil v1.3.28 h1:Vp156KUA2nPu9F1NEv036x9UGOjg2qsi5QlWTjZmtMk=
|
||||||
modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
modernc.org/fileutil v1.3.28/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
|
modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
|
||||||
modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
|
modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
|
||||||
|
modernc.org/libc v1.66.9 h1:YkHp7E1EWrN2iyNav7JE/nHasmshPvlGkon1VxGqOw0=
|
||||||
|
modernc.org/libc v1.66.9/go.mod h1:aVdcY7udcawRqauu0HukYYxtBSizV+R80n/6aQe9D5k=
|
||||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
4
i18n.yml
4
i18n.yml
@@ -1,3 +1,3 @@
|
|||||||
files:
|
files:
|
||||||
- source: /beszel/site/src/locales/en/en.po
|
- source: /internal/site/src/locales/en/
|
||||||
translation: /beszel/site/src/locales/%two_letters_code%/%two_letters_code%.po
|
translation: /internal/site/src/locales/%two_letters_code%/%two_letters_code%.po
|
||||||
|
|||||||
@@ -87,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),
|
alertQueue: make(chan alertTask, 5),
|
||||||
stopChan: make(chan struct{}),
|
stopChan: make(chan struct{}),
|
||||||
}
|
}
|
||||||
am.bindEvents()
|
am.bindEvents()
|
||||||
@@ -42,21 +42,10 @@ 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 {
|
||||||
alertHistoryRecords, err := app.FindRecordsByFilter(
|
alertHistoryRecord, err := app.FindFirstRecordByFilter("alerts_history", "alert_id={:alert_id} && resolved=null", dbx.Params{"alert_id": alertRecordID})
|
||||||
"alerts_history",
|
if err != nil || alertHistoryRecord == nil {
|
||||||
"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,7 +25,12 @@ 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() {
|
||||||
tick := time.Tick(15 * time.Second)
|
processPendingAlerts := time.Tick(15 * time.Second)
|
||||||
|
|
||||||
|
// check for status alerts that are not resolved when system comes up
|
||||||
|
// (can be removed if we figure out core bug in #1052)
|
||||||
|
checkStatusAlerts := time.Tick(561 * time.Second)
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-am.stopChan:
|
case <-am.stopChan:
|
||||||
@@ -41,7 +46,9 @@ func (am *AlertManager) startWorker() {
|
|||||||
case "cancel":
|
case "cancel":
|
||||||
am.pendingAlerts.Delete(task.alertRecord.Id)
|
am.pendingAlerts.Delete(task.alertRecord.Id)
|
||||||
}
|
}
|
||||||
case <-tick:
|
case <-checkStatusAlerts:
|
||||||
|
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 {
|
||||||
@@ -170,3 +177,35 @@ func (am *AlertManager) sendStatusAlert(alertStatus string, systemName string, a
|
|||||||
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,12 +1,13 @@
|
|||||||
package alerts
|
package alerts
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"beszel/internal/entities/system"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"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"
|
||||||
680
internal/alerts/alerts_test.go
Normal file
680
internal/alerts/alerts_test.go
Normal file
@@ -0,0 +1,680 @@
|
|||||||
|
//go:build testing
|
||||||
|
// +build testing
|
||||||
|
|
||||||
|
package alerts_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"testing/synctest"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/alerts"
|
||||||
|
beszelTests "github.com/henrygd/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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatusAlerts(t *testing.T) {
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
hub, user := beszelTests.GetHubWithUser(t)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
systems, err := beszelTests.CreateSystems(hub, 4, user.Id, "paused")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
var alerts []*core.Record
|
||||||
|
for i, system := range systems {
|
||||||
|
alert, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{
|
||||||
|
"name": "Status",
|
||||||
|
"system": system.Id,
|
||||||
|
"user": user.Id,
|
||||||
|
"min": i + 1,
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
alerts = append(alerts, alert)
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
|
||||||
|
for _, alert := range alerts {
|
||||||
|
assert.False(t, alert.GetBool("triggered"), "Alert should not be triggered immediately")
|
||||||
|
}
|
||||||
|
if hub.TestMailer.TotalSend() != 0 {
|
||||||
|
assert.Zero(t, hub.TestMailer.TotalSend(), "Expected 0 messages, got %d", hub.TestMailer.TotalSend())
|
||||||
|
}
|
||||||
|
for _, system := range systems {
|
||||||
|
assert.EqualValues(t, "paused", system.GetString("status"), "System should be paused")
|
||||||
|
}
|
||||||
|
for _, system := range systems {
|
||||||
|
system.Set("status", "up")
|
||||||
|
err = hub.SaveNoValidate(system)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
assert.EqualValues(t, 0, hub.GetPendingAlertsCount(), "should have 0 alerts in the pendingAlerts map")
|
||||||
|
for _, system := range systems {
|
||||||
|
system.Set("status", "down")
|
||||||
|
err = hub.SaveNoValidate(system)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
// after 30 seconds, should have 4 alerts in the pendingAlerts map, no triggered alerts
|
||||||
|
time.Sleep(time.Second * 30)
|
||||||
|
assert.EqualValues(t, 4, hub.GetPendingAlertsCount(), "should have 4 alerts in the pendingAlerts map")
|
||||||
|
triggeredCount, err := hub.CountRecords("alerts", dbx.HashExp{"triggered": true})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.EqualValues(t, 0, triggeredCount, "should have 0 alert triggered")
|
||||||
|
assert.EqualValues(t, 0, hub.TestMailer.TotalSend(), "should have 0 messages sent")
|
||||||
|
// after 1:30 seconds, should have 1 triggered alert and 3 pending alerts
|
||||||
|
time.Sleep(time.Second * 60)
|
||||||
|
assert.EqualValues(t, 3, hub.GetPendingAlertsCount(), "should have 3 alerts in the pendingAlerts map")
|
||||||
|
triggeredCount, err = hub.CountRecords("alerts", dbx.HashExp{"triggered": true})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.EqualValues(t, 1, triggeredCount, "should have 1 alert triggered")
|
||||||
|
assert.EqualValues(t, 1, hub.TestMailer.TotalSend(), "should have 1 messages sent")
|
||||||
|
// after 2:30 seconds, should have 2 triggered alerts and 2 pending alerts
|
||||||
|
time.Sleep(time.Second * 60)
|
||||||
|
assert.EqualValues(t, 2, hub.GetPendingAlertsCount(), "should have 2 alerts in the pendingAlerts map")
|
||||||
|
triggeredCount, err = hub.CountRecords("alerts", dbx.HashExp{"triggered": true})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.EqualValues(t, 2, triggeredCount, "should have 2 alert triggered")
|
||||||
|
assert.EqualValues(t, 2, hub.TestMailer.TotalSend(), "should have 2 messages sent")
|
||||||
|
// now we will bring the remaning systems back up
|
||||||
|
for _, system := range systems {
|
||||||
|
system.Set("status", "up")
|
||||||
|
err = hub.SaveNoValidate(system)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
// should have 0 alerts in the pendingAlerts map and 0 alerts triggered
|
||||||
|
assert.EqualValues(t, 0, hub.GetPendingAlertsCount(), "should have 0 alerts in the pendingAlerts map")
|
||||||
|
triggeredCount, err = hub.CountRecords("alerts", dbx.HashExp{"triggered": true})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Zero(t, triggeredCount, "should have 0 alert triggered")
|
||||||
|
// 4 messages sent, 2 down alerts and 2 up alerts for first 2 systems
|
||||||
|
assert.EqualValues(t, 4, hub.TestMailer.TotalSend(), "should have 4 messages sent")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertsHistory(t *testing.T) {
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
hub, user := beszelTests.GetHubWithUser(t)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
// Create systems and alerts
|
||||||
|
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
system := systems[0]
|
||||||
|
|
||||||
|
alert, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{
|
||||||
|
"name": "Status",
|
||||||
|
"system": system.Id,
|
||||||
|
"user": user.Id,
|
||||||
|
"min": 1,
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Initially, no alert history records should exist
|
||||||
|
initialHistoryCount, err := hub.CountRecords("alerts_history", nil)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Zero(t, initialHistoryCount, "Should have 0 alert history records initially")
|
||||||
|
|
||||||
|
// Set system to up initially
|
||||||
|
system.Set("status", "up")
|
||||||
|
err = hub.SaveNoValidate(system)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
|
||||||
|
// Set system to down to trigger alert
|
||||||
|
system.Set("status", "down")
|
||||||
|
err = hub.SaveNoValidate(system)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Wait for alert to trigger (after the downtime delay)
|
||||||
|
// With 1 minute delay, we need to wait at least 1 minute + some buffer
|
||||||
|
time.Sleep(time.Second * 75)
|
||||||
|
|
||||||
|
// Check that alert is triggered
|
||||||
|
triggeredCount, err := hub.CountRecords("alerts", dbx.HashExp{"triggered": true, "id": alert.Id})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.EqualValues(t, 1, triggeredCount, "Alert should be triggered")
|
||||||
|
|
||||||
|
// Check that alert history record was created
|
||||||
|
historyCount, err := hub.CountRecords("alerts_history", dbx.HashExp{"alert_id": alert.Id})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.EqualValues(t, 1, historyCount, "Should have 1 alert history record for triggered alert")
|
||||||
|
|
||||||
|
// Get the alert history record and verify it's not resolved immediately
|
||||||
|
historyRecord, err := hub.FindFirstRecordByFilter("alerts_history", "alert_id={:alert_id}", dbx.Params{"alert_id": alert.Id})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, historyRecord, "Alert history record should exist")
|
||||||
|
assert.Equal(t, alert.Id, historyRecord.GetString("alert_id"), "Alert history should reference correct alert")
|
||||||
|
assert.Equal(t, system.Id, historyRecord.GetString("system"), "Alert history should reference correct system")
|
||||||
|
assert.Equal(t, "Status", historyRecord.GetString("name"), "Alert history should have correct name")
|
||||||
|
|
||||||
|
// The alert history might be resolved immediately in some cases, so let's check the alert's triggered status
|
||||||
|
alertRecord, err := hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": alert.Id})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.True(t, alertRecord.GetBool("triggered"), "Alert should still be triggered when checking history")
|
||||||
|
|
||||||
|
// Now resolve the alert by setting system back to up
|
||||||
|
system.Set("status", "up")
|
||||||
|
err = hub.SaveNoValidate(system)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
|
||||||
|
// Check that alert is no longer triggered
|
||||||
|
triggeredCount, err = hub.CountRecords("alerts", dbx.HashExp{"triggered": true, "id": alert.Id})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Zero(t, triggeredCount, "Alert should not be triggered after system is back up")
|
||||||
|
|
||||||
|
// Check that alert history record is now resolved
|
||||||
|
historyRecord, err = hub.FindFirstRecordByFilter("alerts_history", "alert_id={:alert_id}", dbx.Params{"alert_id": alert.Id})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, historyRecord, "Alert history record should still exist")
|
||||||
|
assert.NotNil(t, historyRecord.Get("resolved"), "Alert history should be resolved")
|
||||||
|
|
||||||
|
// Test deleting a triggered alert resolves its history
|
||||||
|
// Create another system and alert
|
||||||
|
systems2, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
system2 := systems2[0]
|
||||||
|
system2.Set("name", "test-system-2") // Rename for clarity
|
||||||
|
err = hub.SaveNoValidate(system2)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
alert2, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{
|
||||||
|
"name": "Status",
|
||||||
|
"system": system2.Id,
|
||||||
|
"user": user.Id,
|
||||||
|
"min": 1,
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Set system2 to down to trigger alert
|
||||||
|
system2.Set("status", "down")
|
||||||
|
err = hub.SaveNoValidate(system2)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Wait for alert to trigger
|
||||||
|
time.Sleep(time.Second * 75)
|
||||||
|
|
||||||
|
// Verify alert is triggered and history record exists
|
||||||
|
triggeredCount, err = hub.CountRecords("alerts", dbx.HashExp{"triggered": true, "id": alert2.Id})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.EqualValues(t, 1, triggeredCount, "Second alert should be triggered")
|
||||||
|
|
||||||
|
historyCount, err = hub.CountRecords("alerts_history", dbx.HashExp{"alert_id": alert2.Id})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.EqualValues(t, 1, historyCount, "Should have 1 alert history record for second alert")
|
||||||
|
|
||||||
|
// Delete the triggered alert
|
||||||
|
err = hub.Delete(alert2)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Check that alert history record is resolved after deletion
|
||||||
|
historyRecord2, err := hub.FindFirstRecordByFilter("alerts_history", "alert_id={:alert_id}", dbx.Params{"alert_id": alert2.Id})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, historyRecord2, "Alert history record should still exist after alert deletion")
|
||||||
|
assert.NotNil(t, historyRecord2.Get("resolved"), "Alert history should be resolved after alert deletion")
|
||||||
|
|
||||||
|
// Verify total history count is correct (2 records total)
|
||||||
|
totalHistoryCount, err := hub.CountRecords("alerts_history", nil)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.EqualValues(t, 2, totalHistoryCount, "Should have 2 total alert history records")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
func TestResolveStatusAlerts(t *testing.T) {
|
||||||
|
hub, user := beszelTests.GetHubWithUser(t)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
// Create a systemUp
|
||||||
|
systemUp, err := beszelTests.CreateRecord(hub, "systems", map[string]any{
|
||||||
|
"name": "test-system",
|
||||||
|
"users": []string{user.Id},
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
"status": "up",
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
systemDown, err := beszelTests.CreateRecord(hub, "systems", map[string]any{
|
||||||
|
"name": "test-system-2",
|
||||||
|
"users": []string{user.Id},
|
||||||
|
"host": "127.0.0.2",
|
||||||
|
"status": "up",
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Create a status alertUp for the system
|
||||||
|
alertUp, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{
|
||||||
|
"name": "Status",
|
||||||
|
"system": systemUp.Id,
|
||||||
|
"user": user.Id,
|
||||||
|
"min": 1,
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
alertDown, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{
|
||||||
|
"name": "Status",
|
||||||
|
"system": systemDown.Id,
|
||||||
|
"user": user.Id,
|
||||||
|
"min": 1,
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify alert is not triggered initially
|
||||||
|
assert.False(t, alertUp.GetBool("triggered"), "Alert should not be triggered initially")
|
||||||
|
|
||||||
|
// Set the system to 'up' (this should not trigger the alert)
|
||||||
|
systemUp.Set("status", "up")
|
||||||
|
err = hub.SaveNoValidate(systemUp)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
systemDown.Set("status", "down")
|
||||||
|
err = hub.SaveNoValidate(systemDown)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Wait a moment for any processing
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
|
||||||
|
// Verify alertUp is still not triggered after setting system to up
|
||||||
|
alertUp, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": alertUp.Id})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.False(t, alertUp.GetBool("triggered"), "Alert should not be triggered when system is up")
|
||||||
|
|
||||||
|
// Manually set both alerts triggered to true
|
||||||
|
alertUp.Set("triggered", true)
|
||||||
|
err = hub.SaveNoValidate(alertUp)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
alertDown.Set("triggered", true)
|
||||||
|
err = hub.SaveNoValidate(alertDown)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify we have exactly one alert with triggered true
|
||||||
|
triggeredCount, err := hub.CountRecords("alerts", dbx.HashExp{"triggered": true})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.EqualValues(t, 2, triggeredCount, "Should have exactly two alerts with triggered true")
|
||||||
|
|
||||||
|
// Verify the specific alertUp is triggered
|
||||||
|
alertUp, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": alertUp.Id})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.True(t, alertUp.GetBool("triggered"), "Alert should be triggered")
|
||||||
|
|
||||||
|
// Verify we have two unresolved alert history records
|
||||||
|
alertHistoryCount, err := hub.CountRecords("alerts_history", dbx.HashExp{"resolved": ""})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.EqualValues(t, 2, alertHistoryCount, "Should have exactly two unresolved alert history records")
|
||||||
|
|
||||||
|
err = alerts.ResolveStatusAlerts(hub)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify alertUp is not triggered after resolving
|
||||||
|
alertUp, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": alertUp.Id})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.False(t, alertUp.GetBool("triggered"), "Alert should not be triggered after resolving")
|
||||||
|
// Verify alertDown is still triggered
|
||||||
|
alertDown, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": alertDown.Id})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.True(t, alertDown.GetBool("triggered"), "Alert should still be triggered after resolving")
|
||||||
|
|
||||||
|
// Verify we have one unresolved alert history record
|
||||||
|
alertHistoryCount, err = hub.CountRecords("alerts_history", dbx.HashExp{"resolved": ""})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.EqualValues(t, 1, alertHistoryCount, "Should have exactly one unresolved alert history record")
|
||||||
|
|
||||||
|
}
|
||||||
62
internal/alerts/alerts_test_helpers.go
Normal file
62
internal/alerts/alerts_test_helpers.go
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
//go:build testing
|
||||||
|
// +build testing
|
||||||
|
|
||||||
|
package alerts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (am *AlertManager) GetAlertManager() *AlertManager {
|
||||||
|
return am
|
||||||
|
}
|
||||||
|
|
||||||
|
func (am *AlertManager) GetPendingAlerts() *sync.Map {
|
||||||
|
return &am.pendingAlerts
|
||||||
|
}
|
||||||
|
|
||||||
|
func (am *AlertManager) GetPendingAlertsCount() int {
|
||||||
|
count := 0
|
||||||
|
am.pendingAlerts.Range(func(key, value any) bool {
|
||||||
|
count++
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProcessPendingAlerts manually processes all expired alerts (for testing)
|
||||||
|
func (am *AlertManager) ProcessPendingAlerts() ([]*core.Record, error) {
|
||||||
|
now := time.Now()
|
||||||
|
var lastErr error
|
||||||
|
var processedAlerts []*core.Record
|
||||||
|
am.pendingAlerts.Range(func(key, value any) bool {
|
||||||
|
info := value.(*alertInfo)
|
||||||
|
if now.After(info.expireTime) {
|
||||||
|
// Downtime delay has passed, process alert
|
||||||
|
if err := am.sendStatusAlert("down", info.systemName, info.alertRecord); err != nil {
|
||||||
|
lastErr = err
|
||||||
|
}
|
||||||
|
processedAlerts = append(processedAlerts, info.alertRecord)
|
||||||
|
am.pendingAlerts.Delete(key)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return processedAlerts, lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForceExpirePendingAlerts sets all pending alerts to expire immediately (for testing)
|
||||||
|
func (am *AlertManager) ForceExpirePendingAlerts() {
|
||||||
|
now := time.Now()
|
||||||
|
am.pendingAlerts.Range(func(key, value any) bool {
|
||||||
|
info := value.(*alertInfo)
|
||||||
|
info.expireTime = now.Add(-time.Second) // Set to 1 second ago
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResolveStatusAlerts(app core.App) error {
|
||||||
|
return resolveStatusAlerts(app)
|
||||||
|
}
|
||||||
@@ -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,43 +17,24 @@ 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.
|
||||||
|
// TODO: add hubURL and token
|
||||||
|
// hubURL string // hubURL is the URL of the hub to use.
|
||||||
|
// 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 {
|
||||||
@@ -63,7 +44,57 @@ func (opts *cmdOptions) parse() bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
flag.Parse()
|
// pflag.CommandLine.ParseErrorsWhitelist.UnknownFlags = true
|
||||||
|
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, "hub-url", "u", "", "URL of the hub to use")
|
||||||
|
// 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"}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
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"
|
||||||
@@ -245,7 +246,7 @@ func TestParseFlags(t *testing.T) {
|
|||||||
oldArgs := os.Args
|
oldArgs := os.Args
|
||||||
defer func() {
|
defer func() {
|
||||||
os.Args = oldArgs
|
os.Args = oldArgs
|
||||||
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
|
pflag.CommandLine = pflag.NewFlagSet(os.Args[0], pflag.ExitOnError)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
@@ -269,6 +270,22 @@ 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"},
|
||||||
@@ -277,6 +294,22 @@ 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"},
|
||||||
@@ -290,12 +323,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
|
||||||
flag.CommandLine = flag.NewFlagSet(tt.args[0], flag.ExitOnError)
|
pflag.CommandLine = pflag.NewFlagSet(tt.args[0], pflag.ExitOnError)
|
||||||
os.Args = tt.args
|
os.Args = tt.args
|
||||||
|
|
||||||
var opts cmdOptions
|
var opts cmdOptions
|
||||||
opts.parse()
|
opts.parse()
|
||||||
flag.Parse()
|
pflag.Parse()
|
||||||
|
|
||||||
assert.Equal(t, tt.expected, opts)
|
assert.Equal(t, tt.expected, opts)
|
||||||
})
|
})
|
||||||
@@ -1,15 +1,16 @@
|
|||||||
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"
|
||||||
@@ -45,11 +46,13 @@ 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
|
||||||
baseApp.RootCmd.AddCommand(&cobra.Command{
|
updateCmd := &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 cmd ./cmd
|
# Copy source files
|
||||||
COPY internal ./internal
|
COPY . ./
|
||||||
|
|
||||||
# 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 ./cmd/agent
|
RUN CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /agent ./internal/cmd/agent
|
||||||
|
|
||||||
RUN rm -rf /tmp/*
|
RUN rm -rf /tmp/*
|
||||||
|
|
||||||
25
internal/dockerfile_agent_intel
Normal file
25
internal/dockerfile_agent_intel
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
FROM --platform=$BUILDPLATFORM golang:alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY ../go.mod ../go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
# Copy source files
|
||||||
|
COPY . ./
|
||||||
|
|
||||||
|
# Build
|
||||||
|
ARG TARGETOS TARGETARCH
|
||||||
|
RUN CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /agent ./internal/cmd/agent
|
||||||
|
|
||||||
|
# --------------------------
|
||||||
|
# Final image
|
||||||
|
# Note: must cap_add: [CAP_PERFMON] and mount /dev/dri/ as volume
|
||||||
|
# --------------------------
|
||||||
|
FROM alpine:edge
|
||||||
|
|
||||||
|
COPY --from=builder /agent /agent
|
||||||
|
|
||||||
|
RUN apk add --no-cache -X https://dl-cdn.alpinelinux.org/alpine/edge/testing igt-gpu-tools
|
||||||
|
|
||||||
|
ENTRYPOINT ["/agent"]
|
||||||
@@ -2,20 +2,26 @@ FROM --platform=$BUILDPLATFORM golang:alpine AS builder
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY go.mod go.sum ./
|
|
||||||
# RUN go mod download
|
COPY ../go.mod ../go.sum ./
|
||||||
COPY *.go ./
|
RUN go mod download
|
||||||
COPY cmd ./cmd
|
|
||||||
COPY internal ./internal
|
# Copy source files
|
||||||
|
COPY . ./
|
||||||
|
|
||||||
# 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 ./cmd/agent
|
RUN CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /agent ./internal/cmd/agent
|
||||||
|
|
||||||
|
RUN rm -rf /tmp/*
|
||||||
|
|
||||||
# --------------------------
|
# --------------------------
|
||||||
# Final image: GPU-enabled agent with nvidia-smi
|
# Final image: GPU-enabled agent with nvidia-smi
|
||||||
# --------------------------
|
# --------------------------
|
||||||
FROM nvidia/cuda:12.9.1-base-ubuntu22.04
|
FROM nvidia/cuda:12.2.2-base-ubuntu22.04
|
||||||
COPY --from=builder /agent /agent
|
COPY --from=builder /agent /agent
|
||||||
|
|
||||||
|
# this is so we don't need to create the /tmp directory in the scratch container
|
||||||
|
COPY --from=builder /tmp /tmp
|
||||||
|
|
||||||
ENTRYPOINT ["/agent"]
|
ENTRYPOINT ["/agent"]
|
||||||
@@ -3,16 +3,11 @@ 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 *.go ./
|
COPY . ./
|
||||||
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 \
|
||||||
@@ -22,7 +17,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 ./cmd/hub
|
RUN CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /beszel ./internal/cmd/hub
|
||||||
|
|
||||||
# ? -------------------------
|
# ? -------------------------
|
||||||
FROM scratch
|
FROM scratch
|
||||||
@@ -3,8 +3,9 @@ package system
|
|||||||
// TODO: this is confusing, make common package with common/types common/helpers etc
|
// TODO: this is confusing, make common package with common/types common/helpers etc
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"beszel/internal/entities/container"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/entities/container"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Stats struct {
|
type Stats struct {
|
||||||
@@ -37,18 +38,22 @@ type Stats struct {
|
|||||||
Bandwidth [2]uint64 `json:"b,omitzero" cbor:"26,keyasint,omitzero"` // [sent bytes, recv bytes]
|
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]
|
MaxBandwidth [2]uint64 `json:"bm,omitzero" cbor:"27,keyasint,omitzero"` // [sent bytes, recv bytes]
|
||||||
// TODO: remove other load fields in future release in favor of load avg array
|
// TODO: remove other load fields in future release in favor of load avg array
|
||||||
LoadAvg [3]float64 `json:"la,omitempty" cbor:"28,keyasint"`
|
LoadAvg [3]float64 `json:"la,omitempty" cbor:"28,keyasint"`
|
||||||
Battery [2]uint8 `json:"bat,omitzero" cbor:"29,keyasint,omitzero"` // [percent, charge state, current]
|
Battery [2]uint8 `json:"bat,omitzero" cbor:"29,keyasint,omitzero"` // [percent, charge state, current]
|
||||||
|
MaxMem float64 `json:"mm,omitempty" cbor:"30,keyasint,omitempty"`
|
||||||
|
NetworkInterfaces map[string][4]uint64 `json:"ni,omitempty" cbor:"31,keyasint,omitempty"` // [upload bytes, download bytes, total upload, total download]
|
||||||
}
|
}
|
||||||
|
|
||||||
type GPUData struct {
|
type GPUData struct {
|
||||||
Name string `json:"n" cbor:"0,keyasint"`
|
Name string `json:"n" cbor:"0,keyasint"`
|
||||||
Temperature float64 `json:"-"`
|
Temperature float64 `json:"-"`
|
||||||
MemoryUsed float64 `json:"mu,omitempty" cbor:"1,keyasint,omitempty"`
|
MemoryUsed float64 `json:"mu,omitempty,omitzero" cbor:"1,keyasint,omitempty,omitzero"`
|
||||||
MemoryTotal float64 `json:"mt,omitempty" cbor:"2,keyasint,omitempty"`
|
MemoryTotal float64 `json:"mt,omitempty,omitzero" cbor:"2,keyasint,omitempty,omitzero"`
|
||||||
Usage float64 `json:"u" cbor:"3,keyasint"`
|
Usage float64 `json:"u" cbor:"3,keyasint,omitempty"`
|
||||||
Power float64 `json:"p,omitempty" cbor:"4,keyasint,omitempty"`
|
Power float64 `json:"p,omitempty" cbor:"4,keyasint,omitempty"`
|
||||||
Count float64 `json:"-"`
|
Count float64 `json:"-"`
|
||||||
|
Engines map[string]float64 `json:"e,omitempty" cbor:"5,keyasint,omitempty"`
|
||||||
|
PowerPkg float64 `json:"pp,omitempty" cbor:"6,keyasint,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type FsStats struct {
|
type FsStats struct {
|
||||||
@@ -81,6 +86,14 @@ const (
|
|||||||
Freebsd
|
Freebsd
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type ConnectionType = uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
ConnectionTypeNone ConnectionType = iota
|
||||||
|
ConnectionTypeSSH
|
||||||
|
ConnectionTypeWebSocket
|
||||||
|
)
|
||||||
|
|
||||||
type Info struct {
|
type Info struct {
|
||||||
Hostname string `json:"h" cbor:"0,keyasint"`
|
Hostname string `json:"h" cbor:"0,keyasint"`
|
||||||
KernelVersion string `json:"k,omitempty" cbor:"1,keyasint,omitempty"`
|
KernelVersion string `json:"k,omitempty" cbor:"1,keyasint,omitempty"`
|
||||||
@@ -102,7 +115,8 @@ type Info struct {
|
|||||||
LoadAvg15 float64 `json:"l15,omitempty" cbor:"17,keyasint,omitempty"`
|
LoadAvg15 float64 `json:"l15,omitempty" cbor:"17,keyasint,omitempty"`
|
||||||
BandwidthBytes uint64 `json:"bb" cbor:"18,keyasint"`
|
BandwidthBytes uint64 `json:"bb" cbor:"18,keyasint"`
|
||||||
// TODO: remove load fields in future release in favor of load avg array
|
// TODO: remove load fields in future release in favor of load avg array
|
||||||
LoadAvg [3]float64 `json:"la,omitempty" cbor:"19,keyasint"`
|
LoadAvg [3]float64 `json:"la,omitempty" cbor:"19,keyasint"`
|
||||||
|
ConnectionType ConnectionType `json:"ct,omitempty" cbor:"20,keyasint,omitempty,omitzero"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Final data structure to return to the hub
|
// Final data structure to return to the hub
|
||||||
@@ -4,7 +4,6 @@
|
|||||||
package ghupdate
|
package ghupdate
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"beszel"
|
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -16,6 +15,8 @@ import (
|
|||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel"
|
||||||
|
|
||||||
"github.com/blang/semver"
|
"github.com/blang/semver"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -23,7 +24,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
colorReset = "\033[0m"
|
colorReset = "\033[0m"
|
||||||
ColorYellow = "\033[33m"
|
ColorYellow = "\033[33m"
|
||||||
colorGreen = "\033[32m"
|
ColorGreen = "\033[32m"
|
||||||
colorCyan = "\033[36m"
|
colorCyan = "\033[36m"
|
||||||
colorGray = "\033[90m"
|
colorGray = "\033[90m"
|
||||||
)
|
)
|
||||||
@@ -64,6 +65,10 @@ type Config struct {
|
|||||||
|
|
||||||
// The data directory to use when fetching and downloading the latest release.
|
// The data directory to use when fetching and downloading the latest release.
|
||||||
DataDir string
|
DataDir string
|
||||||
|
|
||||||
|
// UseMirror specifies whether to use the beszel.dev mirror instead of GitHub API.
|
||||||
|
// When false (default), always uses api.github.com. When true, uses gh.beszel.dev.
|
||||||
|
UseMirror bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type updater struct {
|
type updater struct {
|
||||||
@@ -106,21 +111,19 @@ func (p *updater) update() (updated bool, err error) {
|
|||||||
var latest *release
|
var latest *release
|
||||||
var useMirror bool
|
var useMirror bool
|
||||||
|
|
||||||
|
// Determine the API endpoint based on UseMirror flag
|
||||||
|
apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", p.config.Owner, p.config.Repo)
|
||||||
|
if p.config.UseMirror {
|
||||||
|
useMirror = true
|
||||||
|
apiURL = fmt.Sprintf("https://gh.beszel.dev/repos/%s/%s/releases/latest?api=true", p.config.Owner, p.config.Repo)
|
||||||
|
ColorPrint(ColorYellow, "Using mirror for update.")
|
||||||
|
}
|
||||||
|
|
||||||
latest, err = fetchLatestRelease(
|
latest, err = fetchLatestRelease(
|
||||||
p.config.Context,
|
p.config.Context,
|
||||||
p.config.HttpClient,
|
p.config.HttpClient,
|
||||||
fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", p.config.Owner, p.config.Repo),
|
apiURL,
|
||||||
)
|
)
|
||||||
// if the first fetch fails, try the beszel.dev API (fallback for China)
|
|
||||||
if err != nil {
|
|
||||||
ColorPrint(ColorYellow, "Failed to fetch release. Trying beszel.dev mirror...")
|
|
||||||
useMirror = true
|
|
||||||
latest, err = fetchLatestRelease(
|
|
||||||
p.config.Context,
|
|
||||||
p.config.HttpClient,
|
|
||||||
fmt.Sprintf("https://gh.beszel.dev/repos/%s/%s/releases/latest?api=true", p.config.Owner, p.config.Repo),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
@@ -129,7 +132,7 @@ func (p *updater) update() (updated bool, err error) {
|
|||||||
newVersion := semver.MustParse(strings.TrimPrefix(latest.Tag, "v"))
|
newVersion := semver.MustParse(strings.TrimPrefix(latest.Tag, "v"))
|
||||||
|
|
||||||
if newVersion.LTE(currentVersion) {
|
if newVersion.LTE(currentVersion) {
|
||||||
ColorPrintf(colorGreen, "You already have the latest version %s.", p.currentVersion)
|
ColorPrintf(ColorGreen, "You already have the latest version %s.", p.currentVersion)
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,14 +212,11 @@ func (p *updater) update() (updated bool, err error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ColorPrint(colorGray, "---")
|
ColorPrint(colorGray, "---")
|
||||||
ColorPrint(colorGreen, "Update completed successfully! You can start the executable as usual.")
|
ColorPrint(ColorGreen, "Update completed successfully!")
|
||||||
|
|
||||||
// print the release notes
|
// print the release notes
|
||||||
if latest.Body != "" {
|
if latest.Body != "" {
|
||||||
fmt.Print("\n")
|
fmt.Print("\n")
|
||||||
ColorPrintf(colorCyan, "Here is a list with some of the %s changes:", latest.Tag)
|
|
||||||
// remove the update command note to avoid "stuttering"
|
|
||||||
// (@todo consider moving to a config option)
|
|
||||||
releaseNotes := strings.TrimSpace(strings.Replace(latest.Body, "> _To update the prebuilt executable you can run `./"+p.config.ArchiveExecutable+" update`._", "", 1))
|
releaseNotes := strings.TrimSpace(strings.Replace(latest.Body, "> _To update the prebuilt executable you can run `./"+p.config.ArchiveExecutable+" update`._", "", 1))
|
||||||
ColorPrint(colorCyan, releaseNotes)
|
ColorPrint(colorCyan, releaseNotes)
|
||||||
fmt.Print("\n")
|
fmt.Print("\n")
|
||||||
@@ -1,9 +1,6 @@
|
|||||||
package hub
|
package hub
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"beszel/internal/common"
|
|
||||||
"beszel/internal/hub/expirymap"
|
|
||||||
"beszel/internal/hub/ws"
|
|
||||||
"errors"
|
"errors"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -11,6 +8,10 @@ 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"
|
||||||
@@ -4,9 +4,6 @@
|
|||||||
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"
|
||||||
@@ -17,6 +14,10 @@ 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"
|
||||||
@@ -2,13 +2,14 @@
|
|||||||
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"
|
||||||
@@ -4,12 +4,14 @@
|
|||||||
package config_test
|
package config_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"beszel/internal/hub/config"
|
|
||||||
"beszel/internal/tests"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/tests"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/hub/config"
|
||||||
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -2,25 +2,23 @@
|
|||||||
package hub
|
package hub
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"beszel"
|
|
||||||
"beszel/internal/alerts"
|
|
||||||
"beszel/internal/hub/config"
|
|
||||||
"beszel/internal/hub/systems"
|
|
||||||
"beszel/internal/records"
|
|
||||||
"beszel/internal/users"
|
|
||||||
"beszel/site"
|
|
||||||
"crypto/ed25519"
|
"crypto/ed25519"
|
||||||
"encoding/pem"
|
"encoding/pem"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httputil"
|
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel"
|
||||||
|
"github.com/henrygd/beszel/internal/alerts"
|
||||||
|
"github.com/henrygd/beszel/internal/hub/config"
|
||||||
|
"github.com/henrygd/beszel/internal/hub/systems"
|
||||||
|
"github.com/henrygd/beszel/internal/records"
|
||||||
|
"github.com/henrygd/beszel/internal/users"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/pocketbase/pocketbase"
|
"github.com/pocketbase/pocketbase"
|
||||||
"github.com/pocketbase/pocketbase/apis"
|
"github.com/pocketbase/pocketbase/apis"
|
||||||
@@ -71,6 +69,8 @@ func (h *Hub) StartHub() error {
|
|||||||
if err := config.SyncSystems(e); err != nil {
|
if err := config.SyncSystems(e); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// register middlewares
|
||||||
|
h.registerMiddlewares(e)
|
||||||
// register api routes
|
// register api routes
|
||||||
if err := h.registerApiRoutes(e); err != nil {
|
if err := h.registerApiRoutes(e); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -164,55 +164,6 @@ func (h *Hub) initialize(e *core.ServeEvent) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// startServer sets up the server for Beszel
|
|
||||||
func (h *Hub) startServer(se *core.ServeEvent) error {
|
|
||||||
// TODO: exclude dev server from production binary
|
|
||||||
switch h.IsDev() {
|
|
||||||
case true:
|
|
||||||
proxy := httputil.NewSingleHostReverseProxy(&url.URL{
|
|
||||||
Scheme: "http",
|
|
||||||
Host: "localhost:5173",
|
|
||||||
})
|
|
||||||
se.Router.GET("/{path...}", func(e *core.RequestEvent) error {
|
|
||||||
proxy.ServeHTTP(e.Response, e.Request)
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
default:
|
|
||||||
// parse app url
|
|
||||||
parsedURL, err := url.Parse(h.appURL)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
// fix base paths in html if using subpath
|
|
||||||
basePath := strings.TrimSuffix(parsedURL.Path, "/") + "/"
|
|
||||||
indexFile, _ := fs.ReadFile(site.DistDirFS, "index.html")
|
|
||||||
indexContent := strings.ReplaceAll(string(indexFile), "./", basePath)
|
|
||||||
indexContent = strings.Replace(indexContent, "{{V}}", beszel.Version, 1)
|
|
||||||
indexContent = strings.Replace(indexContent, "{{HUB_URL}}", h.appURL, 1)
|
|
||||||
// set up static asset serving
|
|
||||||
staticPaths := [2]string{"/static/", "/assets/"}
|
|
||||||
serveStatic := apis.Static(site.DistDirFS, false)
|
|
||||||
// get CSP configuration
|
|
||||||
csp, cspExists := GetEnv("CSP")
|
|
||||||
// add route
|
|
||||||
se.Router.GET("/{path...}", func(e *core.RequestEvent) error {
|
|
||||||
// serve static assets if path is in staticPaths
|
|
||||||
for i := range staticPaths {
|
|
||||||
if strings.Contains(e.Request.URL.Path, staticPaths[i]) {
|
|
||||||
e.Response.Header().Set("Cache-Control", "public, max-age=2592000")
|
|
||||||
return serveStatic(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if cspExists {
|
|
||||||
e.Response.Header().Del("X-Frame-Options")
|
|
||||||
e.Response.Header().Set("Content-Security-Policy", csp)
|
|
||||||
}
|
|
||||||
return e.HTML(http.StatusOK, indexContent)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// registerCronJobs sets up scheduled tasks
|
// registerCronJobs sets up scheduled tasks
|
||||||
func (h *Hub) registerCronJobs(_ *core.ServeEvent) error {
|
func (h *Hub) registerCronJobs(_ *core.ServeEvent) error {
|
||||||
// delete old system_stats and alerts_history records once every hour
|
// delete old system_stats and alerts_history records once every hour
|
||||||
@@ -222,6 +173,37 @@ func (h *Hub) registerCronJobs(_ *core.ServeEvent) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// custom middlewares
|
||||||
|
func (h *Hub) registerMiddlewares(se *core.ServeEvent) {
|
||||||
|
// authorizes request with user matching the provided email
|
||||||
|
authorizeRequestWithEmail := func(e *core.RequestEvent, email string) (err error) {
|
||||||
|
if e.Auth != nil || email == "" {
|
||||||
|
return e.Next()
|
||||||
|
}
|
||||||
|
isAuthRefresh := e.Request.URL.Path == "/api/collections/users/auth-refresh" && e.Request.Method == http.MethodPost
|
||||||
|
e.Auth, err = e.App.FindFirstRecordByData("users", "email", email)
|
||||||
|
if err != nil || !isAuthRefresh {
|
||||||
|
return e.Next()
|
||||||
|
}
|
||||||
|
// auth refresh endpoint, make sure token is set in header
|
||||||
|
token, _ := e.Auth.NewAuthToken()
|
||||||
|
e.Request.Header.Set("Authorization", token)
|
||||||
|
return e.Next()
|
||||||
|
}
|
||||||
|
// authenticate with trusted header
|
||||||
|
if autoLogin, _ := GetEnv("AUTO_LOGIN"); autoLogin != "" {
|
||||||
|
se.Router.BindFunc(func(e *core.RequestEvent) error {
|
||||||
|
return authorizeRequestWithEmail(e, autoLogin)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// authenticate with trusted header
|
||||||
|
if trustedHeader, _ := GetEnv("TRUSTED_AUTH_HEADER"); trustedHeader != "" {
|
||||||
|
se.Router.BindFunc(func(e *core.RequestEvent) error {
|
||||||
|
return authorizeRequestWithEmail(e, e.Request.Header.Get(trustedHeader))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// custom api routes
|
// custom api routes
|
||||||
func (h *Hub) registerApiRoutes(se *core.ServeEvent) error {
|
func (h *Hub) registerApiRoutes(se *core.ServeEvent) error {
|
||||||
// auth protected routes
|
// auth protected routes
|
||||||
@@ -4,9 +4,6 @@
|
|||||||
package hub_test
|
package hub_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
beszelTests "beszel/internal/tests"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"bytes"
|
"bytes"
|
||||||
"crypto/ed25519"
|
"crypto/ed25519"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -16,6 +13,10 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/migrations"
|
||||||
|
beszelTests "github.com/henrygd/beszel/internal/tests"
|
||||||
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
pbTests "github.com/pocketbase/pocketbase/tests"
|
pbTests "github.com/pocketbase/pocketbase/tests"
|
||||||
@@ -534,6 +535,115 @@ func TestApiRoutesAuthentication(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFirstUserCreation(t *testing.T) {
|
||||||
|
t.Run("CreateUserEndpoint available when no users exist", func(t *testing.T) {
|
||||||
|
hub, _ := beszelTests.NewTestHub(t.TempDir())
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
hub.StartHub()
|
||||||
|
|
||||||
|
testAppFactoryExisting := func(t testing.TB) *pbTests.TestApp {
|
||||||
|
return hub.TestApp
|
||||||
|
}
|
||||||
|
|
||||||
|
scenarios := []beszelTests.ApiScenario{
|
||||||
|
{
|
||||||
|
Name: "POST /create-user - should be available when no users exist",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/create-user",
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"email": "firstuser@example.com",
|
||||||
|
"password": "password123",
|
||||||
|
}),
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"User created"},
|
||||||
|
TestAppFactory: testAppFactoryExisting,
|
||||||
|
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||||
|
userCount, err := hub.CountRecords("users")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Zero(t, userCount, "Should start with no users")
|
||||||
|
superusers, err := hub.FindAllRecords(core.CollectionNameSuperusers)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.EqualValues(t, 1, len(superusers), "Should start with one temporary superuser")
|
||||||
|
require.EqualValues(t, migrations.TempAdminEmail, superusers[0].GetString("email"), "Should have created one temporary superuser")
|
||||||
|
},
|
||||||
|
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||||
|
userCount, err := hub.CountRecords("users")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.EqualValues(t, 1, userCount, "Should have created one user")
|
||||||
|
superusers, err := hub.FindAllRecords(core.CollectionNameSuperusers)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.EqualValues(t, 1, len(superusers), "Should have created one superuser")
|
||||||
|
require.EqualValues(t, "firstuser@example.com", superusers[0].GetString("email"), "Should have created one superuser")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "POST /create-user - should not be available when users exist",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/create-user",
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"email": "firstuser@example.com",
|
||||||
|
"password": "password123",
|
||||||
|
}),
|
||||||
|
ExpectedStatus: 404,
|
||||||
|
ExpectedContent: []string{"wasn't found"},
|
||||||
|
TestAppFactory: testAppFactoryExisting,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
scenario.Test(t)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("CreateUserEndpoint not available when USER_EMAIL, USER_PASSWORD are set", func(t *testing.T) {
|
||||||
|
os.Setenv("BESZEL_HUB_USER_EMAIL", "me@example.com")
|
||||||
|
os.Setenv("BESZEL_HUB_USER_PASSWORD", "password123")
|
||||||
|
defer os.Unsetenv("BESZEL_HUB_USER_EMAIL")
|
||||||
|
defer os.Unsetenv("BESZEL_HUB_USER_PASSWORD")
|
||||||
|
|
||||||
|
hub, _ := beszelTests.NewTestHub(t.TempDir())
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
hub.StartHub()
|
||||||
|
|
||||||
|
testAppFactory := func(t testing.TB) *pbTests.TestApp {
|
||||||
|
return hub.TestApp
|
||||||
|
}
|
||||||
|
|
||||||
|
scenario := beszelTests.ApiScenario{
|
||||||
|
Name: "POST /create-user - should not be available when USER_EMAIL, USER_PASSWORD are set",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/create-user",
|
||||||
|
ExpectedStatus: 404,
|
||||||
|
ExpectedContent: []string{"wasn't found"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||||
|
users, err := hub.FindAllRecords("users")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.EqualValues(t, 1, len(users), "Should start with one user")
|
||||||
|
require.EqualValues(t, "me@example.com", users[0].GetString("email"), "Should have created one user")
|
||||||
|
superusers, err := hub.FindAllRecords(core.CollectionNameSuperusers)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.EqualValues(t, 1, len(superusers), "Should start with one superuser")
|
||||||
|
require.EqualValues(t, "me@example.com", superusers[0].GetString("email"), "Should have created one superuser")
|
||||||
|
},
|
||||||
|
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||||
|
users, err := hub.FindAllRecords("users")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.EqualValues(t, 1, len(users), "Should still have one user")
|
||||||
|
require.EqualValues(t, "me@example.com", users[0].GetString("email"), "Should have created one user")
|
||||||
|
superusers, err := hub.FindAllRecords(core.CollectionNameSuperusers)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.EqualValues(t, 1, len(superusers), "Should still have one superuser")
|
||||||
|
require.EqualValues(t, "me@example.com", superusers[0].GetString("email"), "Should have created one superuser")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
scenario.Test(t)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestCreateUserEndpointAvailability(t *testing.T) {
|
func TestCreateUserEndpointAvailability(t *testing.T) {
|
||||||
t.Run("CreateUserEndpoint available when no users exist", func(t *testing.T) {
|
t.Run("CreateUserEndpoint available when no users exist", func(t *testing.T) {
|
||||||
hub, _ := beszelTests.NewTestHub(t.TempDir())
|
hub, _ := beszelTests.NewTestHub(t.TempDir())
|
||||||
@@ -601,3 +711,117 @@ func TestCreateUserEndpointAvailability(t *testing.T) {
|
|||||||
scenario.Test(t)
|
scenario.Test(t)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAutoLoginMiddleware(t *testing.T) {
|
||||||
|
var hubs []*beszelTests.TestHub
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
defer os.Unsetenv("AUTO_LOGIN")
|
||||||
|
for _, hub := range hubs {
|
||||||
|
hub.Cleanup()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
os.Setenv("AUTO_LOGIN", "user@test.com")
|
||||||
|
|
||||||
|
testAppFactory := func(t testing.TB) *pbTests.TestApp {
|
||||||
|
hub, _ := beszelTests.NewTestHub(t.TempDir())
|
||||||
|
hubs = append(hubs, hub)
|
||||||
|
hub.StartHub()
|
||||||
|
return hub.TestApp
|
||||||
|
}
|
||||||
|
|
||||||
|
scenarios := []beszelTests.ApiScenario{
|
||||||
|
{
|
||||||
|
Name: "GET /getkey - without auto login should fail",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
URL: "/api/beszel/getkey",
|
||||||
|
ExpectedStatus: 401,
|
||||||
|
ExpectedContent: []string{"requires valid"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "GET /getkey - with auto login should fail if no matching user",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
URL: "/api/beszel/getkey",
|
||||||
|
ExpectedStatus: 401,
|
||||||
|
ExpectedContent: []string{"requires valid"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "GET /getkey - with auto login should succeed",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
URL: "/api/beszel/getkey",
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"\"key\":", "\"v\":"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||||
|
beszelTests.CreateUser(app, "user@test.com", "password123")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
scenario.Test(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTrustedHeaderMiddleware(t *testing.T) {
|
||||||
|
var hubs []*beszelTests.TestHub
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
defer os.Unsetenv("TRUSTED_AUTH_HEADER")
|
||||||
|
for _, hub := range hubs {
|
||||||
|
hub.Cleanup()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
os.Setenv("TRUSTED_AUTH_HEADER", "X-Beszel-Trusted")
|
||||||
|
|
||||||
|
testAppFactory := func(t testing.TB) *pbTests.TestApp {
|
||||||
|
hub, _ := beszelTests.NewTestHub(t.TempDir())
|
||||||
|
hubs = append(hubs, hub)
|
||||||
|
hub.StartHub()
|
||||||
|
return hub.TestApp
|
||||||
|
}
|
||||||
|
|
||||||
|
scenarios := []beszelTests.ApiScenario{
|
||||||
|
{
|
||||||
|
Name: "GET /getkey - without trusted header should fail",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
URL: "/api/beszel/getkey",
|
||||||
|
ExpectedStatus: 401,
|
||||||
|
ExpectedContent: []string{"requires valid"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "GET /getkey - with trusted header should fail if no matching user",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
URL: "/api/beszel/getkey",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"X-Beszel-Trusted": "user@test.com",
|
||||||
|
},
|
||||||
|
ExpectedStatus: 401,
|
||||||
|
ExpectedContent: []string{"requires valid"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "GET /getkey - with trusted header should succeed",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
URL: "/api/beszel/getkey",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"X-Beszel-Trusted": "user@test.com",
|
||||||
|
},
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"\"key\":", "\"v\":"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||||
|
beszelTests.CreateUser(app, "user@test.com", "password123")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
scenario.Test(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
package hub
|
package hub
|
||||||
|
|
||||||
import "beszel/internal/hub/systems"
|
import "github.com/henrygd/beszel/internal/hub/systems"
|
||||||
|
|
||||||
// TESTING ONLY: GetSystemManager returns the system manager
|
// TESTING ONLY: GetSystemManager returns the system manager
|
||||||
func (h *Hub) GetSystemManager() *systems.SystemManager {
|
func (h *Hub) GetSystemManager() *systems.SystemManager {
|
||||||
82
internal/hub/server_development.go
Normal file
82
internal/hub/server_development.go
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
//go:build development
|
||||||
|
|
||||||
|
package hub
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httputil"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel"
|
||||||
|
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/osutils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Wraps http.RoundTripper to modify dev proxy HTML responses
|
||||||
|
type responseModifier struct {
|
||||||
|
transport http.RoundTripper
|
||||||
|
hub *Hub
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rm *responseModifier) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
resp, err := rm.transport.RoundTrip(req)
|
||||||
|
if err != nil {
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
// Only modify HTML responses
|
||||||
|
contentType := resp.Header.Get("Content-Type")
|
||||||
|
if !strings.Contains(contentType, "text/html") {
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
// Create a new response with the modified body
|
||||||
|
modifiedBody := rm.modifyHTML(string(body))
|
||||||
|
resp.Body = io.NopCloser(strings.NewReader(modifiedBody))
|
||||||
|
resp.ContentLength = int64(len(modifiedBody))
|
||||||
|
resp.Header.Set("Content-Length", fmt.Sprintf("%d", len(modifiedBody)))
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rm *responseModifier) modifyHTML(html string) string {
|
||||||
|
parsedURL, err := url.Parse(rm.hub.appURL)
|
||||||
|
if err != nil {
|
||||||
|
return html
|
||||||
|
}
|
||||||
|
// fix base paths in html if using subpath
|
||||||
|
basePath := strings.TrimSuffix(parsedURL.Path, "/") + "/"
|
||||||
|
html = strings.ReplaceAll(html, "./", basePath)
|
||||||
|
html = strings.Replace(html, "{{V}}", beszel.Version, 1)
|
||||||
|
html = strings.Replace(html, "{{HUB_URL}}", rm.hub.appURL, 1)
|
||||||
|
return html
|
||||||
|
}
|
||||||
|
|
||||||
|
// startServer sets up the development server for Beszel
|
||||||
|
func (h *Hub) startServer(se *core.ServeEvent) error {
|
||||||
|
slog.Info("starting server", "appURL", h.appURL)
|
||||||
|
proxy := httputil.NewSingleHostReverseProxy(&url.URL{
|
||||||
|
Scheme: "http",
|
||||||
|
Host: "localhost:5173",
|
||||||
|
})
|
||||||
|
|
||||||
|
proxy.Transport = &responseModifier{
|
||||||
|
transport: http.DefaultTransport,
|
||||||
|
hub: h,
|
||||||
|
}
|
||||||
|
|
||||||
|
se.Router.GET("/{path...}", func(e *core.RequestEvent) error {
|
||||||
|
proxy.ServeHTTP(e.Response, e.Request)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
_ = osutils.LaunchURL(h.appURL)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
52
internal/hub/server_production.go
Normal file
52
internal/hub/server_production.go
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
//go:build !development
|
||||||
|
|
||||||
|
package hub
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel"
|
||||||
|
"github.com/henrygd/beszel/internal/site"
|
||||||
|
|
||||||
|
"github.com/pocketbase/pocketbase/apis"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
// startServer sets up the production server for Beszel
|
||||||
|
func (h *Hub) startServer(se *core.ServeEvent) error {
|
||||||
|
// parse app url
|
||||||
|
parsedURL, err := url.Parse(h.appURL)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// fix base paths in html if using subpath
|
||||||
|
basePath := strings.TrimSuffix(parsedURL.Path, "/") + "/"
|
||||||
|
indexFile, _ := fs.ReadFile(site.DistDirFS, "index.html")
|
||||||
|
html := strings.ReplaceAll(string(indexFile), "./", basePath)
|
||||||
|
html = strings.Replace(html, "{{V}}", beszel.Version, 1)
|
||||||
|
html = strings.Replace(html, "{{HUB_URL}}", h.appURL, 1)
|
||||||
|
// set up static asset serving
|
||||||
|
staticPaths := [2]string{"/static/", "/assets/"}
|
||||||
|
serveStatic := apis.Static(site.DistDirFS, false)
|
||||||
|
// get CSP configuration
|
||||||
|
csp, cspExists := GetEnv("CSP")
|
||||||
|
// add route
|
||||||
|
se.Router.GET("/{path...}", func(e *core.RequestEvent) error {
|
||||||
|
// serve static assets if path is in staticPaths
|
||||||
|
for i := range staticPaths {
|
||||||
|
if strings.Contains(e.Request.URL.Path, staticPaths[i]) {
|
||||||
|
e.Response.Header().Set("Cache-Control", "public, max-age=2592000")
|
||||||
|
return serveStatic(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cspExists {
|
||||||
|
e.Response.Header().Del("X-Frame-Options")
|
||||||
|
e.Response.Header().Set("Content-Security-Policy", csp)
|
||||||
|
}
|
||||||
|
return e.HTML(http.StatusOK, html)
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -1,9 +1,6 @@
|
|||||||
package systems
|
package systems
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"beszel"
|
|
||||||
"beszel/internal/entities/system"
|
|
||||||
"beszel/internal/hub/ws"
|
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -13,6 +10,12 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/hub/ws"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel"
|
||||||
|
|
||||||
"github.com/blang/semver"
|
"github.com/blang/semver"
|
||||||
"github.com/fxamacker/cbor/v2"
|
"github.com/fxamacker/cbor/v2"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
@@ -1,14 +1,18 @@
|
|||||||
package systems
|
package systems
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"beszel"
|
|
||||||
"beszel/internal/common"
|
|
||||||
"beszel/internal/entities/system"
|
|
||||||
"beszel/internal/hub/ws"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/hub/ws"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/common"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel"
|
||||||
|
|
||||||
"github.com/blang/semver"
|
"github.com/blang/semver"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
"github.com/pocketbase/pocketbase/tools/store"
|
"github.com/pocketbase/pocketbase/tools/store"
|
||||||
@@ -30,10 +34,8 @@ const (
|
|||||||
sessionTimeout = 4 * time.Second
|
sessionTimeout = 4 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
// errSystemExists is returned when attempting to add a system that already exists
|
||||||
// errSystemExists is returned when attempting to add a system that already exists
|
var errSystemExists = errors.New("system exists")
|
||||||
errSystemExists = errors.New("system exists")
|
|
||||||
)
|
|
||||||
|
|
||||||
// SystemManager manages a collection of monitored systems and their connections.
|
// SystemManager manages a collection of monitored systems and their connections.
|
||||||
// It handles system lifecycle, status updates, and maintains both SSH and WebSocket connections.
|
// It handles system lifecycle, status updates, and maintains both SSH and WebSocket connections.
|
||||||
@@ -4,16 +4,17 @@
|
|||||||
package systems_test
|
package systems_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"beszel/internal/entities/container"
|
|
||||||
"beszel/internal/entities/system"
|
|
||||||
"beszel/internal/hub/systems"
|
|
||||||
"beszel/internal/tests"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"testing/synctest"
|
"testing/synctest"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/entities/container"
|
||||||
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
"github.com/henrygd/beszel/internal/hub/systems"
|
||||||
|
"github.com/henrygd/beszel/internal/tests"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
@@ -4,9 +4,10 @@
|
|||||||
package systems
|
package systems
|
||||||
|
|
||||||
import (
|
import (
|
||||||
entities "beszel/internal/entities/system"
|
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
entities "github.com/henrygd/beszel/internal/entities/system"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TESTING ONLY: GetSystemCount returns the number of systems in the store
|
// TESTING ONLY: GetSystemCount returns the number of systems in the store
|
||||||
@@ -100,3 +101,10 @@ func (sm *SystemManager) SetSystemStatusInDB(systemID string, status string) boo
|
|||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TESTING ONLY: RemoveAllSystems removes all systems from the store
|
||||||
|
func (sm *SystemManager) RemoveAllSystems() {
|
||||||
|
for _, system := range sm.systems.GetAll() {
|
||||||
|
sm.RemoveSystem(system.Id)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +1,17 @@
|
|||||||
package hub
|
package hub
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"beszel/internal/ghupdate"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/ghupdate"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Update updates beszel to the latest version
|
// Update updates beszel to the latest version
|
||||||
func Update(_ *cobra.Command, _ []string) {
|
func Update(cmd *cobra.Command, _ []string) {
|
||||||
dataDir := os.TempDir()
|
dataDir := os.TempDir()
|
||||||
|
|
||||||
// set dataDir to ./beszel_data if it exists
|
// set dataDir to ./beszel_data if it exists
|
||||||
@@ -19,9 +19,19 @@ func Update(_ *cobra.Command, _ []string) {
|
|||||||
dataDir = "./beszel_data"
|
dataDir = "./beszel_data"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if china-mirrors flag is set
|
||||||
|
useMirror, _ := cmd.Flags().GetBool("china-mirrors")
|
||||||
|
|
||||||
|
// Get the executable path before update
|
||||||
|
exePath, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
updated, err := ghupdate.Update(ghupdate.Config{
|
updated, err := ghupdate.Update(ghupdate.Config{
|
||||||
ArchiveExecutable: "beszel",
|
ArchiveExecutable: "beszel",
|
||||||
DataDir: dataDir,
|
DataDir: dataDir,
|
||||||
|
UseMirror: useMirror,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
@@ -30,6 +40,11 @@ func Update(_ *cobra.Command, _ []string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// make sure the file is executable
|
||||||
|
if err := os.Chmod(exePath, 0755); err != nil {
|
||||||
|
fmt.Printf("Warning: failed to set executable permissions: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Try to restart the service if it's running
|
// Try to restart the service if it's running
|
||||||
restartService()
|
restartService()
|
||||||
}
|
}
|
||||||
@@ -41,13 +56,13 @@ func restartService() {
|
|||||||
// Check if beszel service exists and is active
|
// Check if beszel service exists and is active
|
||||||
cmd := exec.Command("systemctl", "is-active", "beszel.service")
|
cmd := exec.Command("systemctl", "is-active", "beszel.service")
|
||||||
if err := cmd.Run(); err == nil {
|
if err := cmd.Run(); err == nil {
|
||||||
fmt.Println("Restarting beszel service...")
|
ghupdate.ColorPrint(ghupdate.ColorYellow, "Restarting beszel service...")
|
||||||
restartCmd := exec.Command("systemctl", "restart", "beszel.service")
|
restartCmd := exec.Command("systemctl", "restart", "beszel.service")
|
||||||
if err := restartCmd.Run(); err != nil {
|
if err := restartCmd.Run(); err != nil {
|
||||||
fmt.Printf("Warning: Failed to restart service: %v\n", err)
|
ghupdate.ColorPrintf(ghupdate.ColorYellow, "Warning: Failed to restart service: %v\n", err)
|
||||||
fmt.Println("Please restart the service manually: sudo systemctl restart beszel")
|
ghupdate.ColorPrint(ghupdate.ColorYellow, "Please restart the service manually: sudo systemctl restart beszel")
|
||||||
} else {
|
} else {
|
||||||
fmt.Println("Service restarted successfully")
|
ghupdate.ColorPrint(ghupdate.ColorGreen, "Service restarted successfully")
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -57,17 +72,17 @@ func restartService() {
|
|||||||
if _, err := exec.LookPath("rc-service"); err == nil {
|
if _, err := exec.LookPath("rc-service"); err == nil {
|
||||||
cmd := exec.Command("rc-service", "beszel", "status")
|
cmd := exec.Command("rc-service", "beszel", "status")
|
||||||
if err := cmd.Run(); err == nil {
|
if err := cmd.Run(); err == nil {
|
||||||
fmt.Println("Restarting beszel service...")
|
ghupdate.ColorPrint(ghupdate.ColorYellow, "Restarting beszel service...")
|
||||||
restartCmd := exec.Command("rc-service", "beszel", "restart")
|
restartCmd := exec.Command("rc-service", "beszel", "restart")
|
||||||
if err := restartCmd.Run(); err != nil {
|
if err := restartCmd.Run(); err != nil {
|
||||||
fmt.Printf("Warning: Failed to restart service: %v\n", err)
|
ghupdate.ColorPrintf(ghupdate.ColorYellow, "Warning: Failed to restart service: %v\n", err)
|
||||||
fmt.Println("Please restart the service manually: sudo rc-service beszel restart")
|
ghupdate.ColorPrint(ghupdate.ColorYellow, "Please restart the service manually: sudo rc-service beszel restart")
|
||||||
} else {
|
} else {
|
||||||
fmt.Println("Service restarted successfully")
|
ghupdate.ColorPrint(ghupdate.ColorGreen, "Service restarted successfully")
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("Note: Service restart not attempted. If running as a service, restart manually.")
|
ghupdate.ColorPrint(ghupdate.ColorYellow, "Service restart not attempted. If running as a service, restart manually.")
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
package ws
|
package ws
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"beszel/internal/common"
|
|
||||||
"beszel/internal/entities/system"
|
|
||||||
"errors"
|
"errors"
|
||||||
"time"
|
"time"
|
||||||
"weak"
|
"weak"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
|
||||||
|
"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"
|
||||||
@@ -4,11 +4,12 @@
|
|||||||
package ws
|
package ws
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"beszel/internal/common"
|
|
||||||
"crypto/ed25519"
|
"crypto/ed25519"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"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"
|
||||||
50
internal/migrations/1758738789_fix_cached_mem.go
Normal file
50
internal/migrations/1758738789_fix_cached_mem.go
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
m "github.com/pocketbase/pocketbase/migrations"
|
||||||
|
)
|
||||||
|
|
||||||
|
// This can be deleted after Nov 2025 or so
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
m.Register(func(app core.App) error {
|
||||||
|
app.RunInTransaction(func(txApp core.App) error {
|
||||||
|
var systemIds []string
|
||||||
|
txApp.DB().NewQuery("SELECT id FROM systems").Column(&systemIds)
|
||||||
|
|
||||||
|
for _, systemId := range systemIds {
|
||||||
|
var statRecordIds []string
|
||||||
|
txApp.DB().NewQuery("SELECT id FROM system_stats WHERE system = {:system} AND created > {:created}").Bind(map[string]any{"system": systemId, "created": "2025-09-21"}).Column(&statRecordIds)
|
||||||
|
|
||||||
|
for _, statRecordId := range statRecordIds {
|
||||||
|
statRecord, err := txApp.FindRecordById("system_stats", statRecordId)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var systemStats system.Stats
|
||||||
|
err = statRecord.UnmarshalJSONField("stats", &systemStats)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// if mem buff cache is less than total mem, we don't need to fix it
|
||||||
|
if systemStats.MemBuffCache < systemStats.Mem {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
systemStats.MemBuffCache = 0
|
||||||
|
statRecord.Set("stats", systemStats)
|
||||||
|
err = txApp.SaveNoValidate(statRecord)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}, func(app core.App) error {
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
71
internal/migrations/initial-settings.go
Normal file
71
internal/migrations/initial-settings.go
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
m "github.com/pocketbase/pocketbase/migrations"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
TempAdminEmail = "_@b.b"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
m.Register(func(app core.App) error {
|
||||||
|
// initial settings
|
||||||
|
settings := app.Settings()
|
||||||
|
settings.Meta.AppName = "Beszel"
|
||||||
|
settings.Meta.HideControls = true
|
||||||
|
settings.Logs.MinLevel = 4
|
||||||
|
if err := app.Save(settings); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// create superuser
|
||||||
|
superuserCollection, _ := app.FindCollectionByNameOrId(core.CollectionNameSuperusers)
|
||||||
|
superUser := core.NewRecord(superuserCollection)
|
||||||
|
|
||||||
|
// set email
|
||||||
|
email, _ := GetEnv("USER_EMAIL")
|
||||||
|
password, _ := GetEnv("USER_PASSWORD")
|
||||||
|
didProvideUserDetails := email != "" && password != ""
|
||||||
|
|
||||||
|
// set superuser email
|
||||||
|
if email == "" {
|
||||||
|
email = TempAdminEmail
|
||||||
|
}
|
||||||
|
superUser.SetEmail(email)
|
||||||
|
|
||||||
|
// set superuser password
|
||||||
|
if password != "" {
|
||||||
|
superUser.SetPassword(password)
|
||||||
|
} else {
|
||||||
|
superUser.SetRandomPassword()
|
||||||
|
}
|
||||||
|
|
||||||
|
// if user details are provided, we create a regular user as well
|
||||||
|
if didProvideUserDetails {
|
||||||
|
usersCollection, _ := app.FindCollectionByNameOrId("users")
|
||||||
|
user := core.NewRecord(usersCollection)
|
||||||
|
user.SetEmail(email)
|
||||||
|
user.SetPassword(password)
|
||||||
|
user.SetVerified(true)
|
||||||
|
err := app.Save(user)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return app.Save(superUser)
|
||||||
|
}, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetEnv retrieves an environment variable with a "BESZEL_HUB_" prefix, or falls back to the unprefixed key.
|
||||||
|
func GetEnv(key string) (value string, exists bool) {
|
||||||
|
if value, exists = os.LookupEnv("BESZEL_HUB_" + key); exists {
|
||||||
|
return value, exists
|
||||||
|
}
|
||||||
|
// Fallback to the old unprefixed key
|
||||||
|
return os.LookupEnv(key)
|
||||||
|
}
|
||||||
@@ -2,8 +2,6 @@
|
|||||||
package records
|
package records
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"beszel/internal/entities/container"
|
|
||||||
"beszel/internal/entities/system"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
@@ -11,6 +9,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/entities/container"
|
||||||
|
"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"
|
||||||
)
|
)
|
||||||
@@ -39,12 +40,14 @@ type StatsRecord struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// global variables for reusing allocations
|
// global variables for reusing allocations
|
||||||
var statsRecord StatsRecord
|
var (
|
||||||
var containerStats []container.Stats
|
statsRecord StatsRecord
|
||||||
var sumStats system.Stats
|
containerStats []container.Stats
|
||||||
var tempStats system.Stats
|
sumStats system.Stats
|
||||||
var queryParams = make(dbx.Params, 1)
|
tempStats system.Stats
|
||||||
var containerSums = make(map[string]*container.Stats)
|
queryParams = make(dbx.Params, 1)
|
||||||
|
containerSums = make(map[string]*container.Stats)
|
||||||
|
)
|
||||||
|
|
||||||
// Create longer records by averaging shorter records
|
// Create longer records by averaging shorter records
|
||||||
func (rm *RecordManager) CreateLongerRecords() {
|
func (rm *RecordManager) CreateLongerRecords() {
|
||||||
@@ -214,6 +217,7 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
|
|||||||
sum.Battery[1] = stats.Battery[1]
|
sum.Battery[1] = stats.Battery[1]
|
||||||
// Set peak values
|
// Set peak values
|
||||||
sum.MaxCpu = max(sum.MaxCpu, stats.MaxCpu, stats.Cpu)
|
sum.MaxCpu = max(sum.MaxCpu, stats.MaxCpu, stats.Cpu)
|
||||||
|
sum.MaxMem = max(sum.MaxMem, stats.MaxMem, stats.MemUsed)
|
||||||
sum.MaxNetworkSent = max(sum.MaxNetworkSent, stats.MaxNetworkSent, stats.NetworkSent)
|
sum.MaxNetworkSent = max(sum.MaxNetworkSent, stats.MaxNetworkSent, stats.NetworkSent)
|
||||||
sum.MaxNetworkRecv = max(sum.MaxNetworkRecv, stats.MaxNetworkRecv, stats.NetworkRecv)
|
sum.MaxNetworkRecv = max(sum.MaxNetworkRecv, stats.MaxNetworkRecv, stats.NetworkRecv)
|
||||||
sum.MaxDiskReadPs = max(sum.MaxDiskReadPs, stats.MaxDiskReadPs, stats.DiskReadPs)
|
sum.MaxDiskReadPs = max(sum.MaxDiskReadPs, stats.MaxDiskReadPs, stats.DiskReadPs)
|
||||||
@@ -221,6 +225,19 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
|
|||||||
sum.MaxBandwidth[0] = max(sum.MaxBandwidth[0], stats.MaxBandwidth[0], stats.Bandwidth[0])
|
sum.MaxBandwidth[0] = max(sum.MaxBandwidth[0], stats.MaxBandwidth[0], stats.Bandwidth[0])
|
||||||
sum.MaxBandwidth[1] = max(sum.MaxBandwidth[1], stats.MaxBandwidth[1], stats.Bandwidth[1])
|
sum.MaxBandwidth[1] = max(sum.MaxBandwidth[1], stats.MaxBandwidth[1], stats.Bandwidth[1])
|
||||||
|
|
||||||
|
// Accumulate network interfaces
|
||||||
|
if sum.NetworkInterfaces == nil {
|
||||||
|
sum.NetworkInterfaces = make(map[string][4]uint64, len(stats.NetworkInterfaces))
|
||||||
|
}
|
||||||
|
for key, value := range stats.NetworkInterfaces {
|
||||||
|
sum.NetworkInterfaces[key] = [4]uint64{
|
||||||
|
sum.NetworkInterfaces[key][0] + value[0],
|
||||||
|
sum.NetworkInterfaces[key][1] + value[1],
|
||||||
|
max(sum.NetworkInterfaces[key][2], value[2]),
|
||||||
|
max(sum.NetworkInterfaces[key][3], value[3]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Accumulate temperatures
|
// Accumulate temperatures
|
||||||
if stats.Temperatures != nil {
|
if stats.Temperatures != nil {
|
||||||
if sum.Temperatures == nil {
|
if sum.Temperatures == nil {
|
||||||
@@ -267,6 +284,16 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
|
|||||||
gpu.Usage += value.Usage
|
gpu.Usage += value.Usage
|
||||||
gpu.Power += value.Power
|
gpu.Power += value.Power
|
||||||
gpu.Count += value.Count
|
gpu.Count += value.Count
|
||||||
|
|
||||||
|
if value.Engines != nil {
|
||||||
|
if gpu.Engines == nil {
|
||||||
|
gpu.Engines = make(map[string]float64, len(value.Engines))
|
||||||
|
}
|
||||||
|
for engineKey, engineValue := range value.Engines {
|
||||||
|
gpu.Engines[engineKey] += engineValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
sum.GPUData[id] = gpu
|
sum.GPUData[id] = gpu
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -295,6 +322,19 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
|
|||||||
sum.Bandwidth[0] = sum.Bandwidth[0] / uint64(count)
|
sum.Bandwidth[0] = sum.Bandwidth[0] / uint64(count)
|
||||||
sum.Bandwidth[1] = sum.Bandwidth[1] / uint64(count)
|
sum.Bandwidth[1] = sum.Bandwidth[1] / uint64(count)
|
||||||
sum.Battery[0] = uint8(batterySum / int(count))
|
sum.Battery[0] = uint8(batterySum / int(count))
|
||||||
|
|
||||||
|
// Average network interfaces
|
||||||
|
if sum.NetworkInterfaces != nil {
|
||||||
|
for key := range sum.NetworkInterfaces {
|
||||||
|
sum.NetworkInterfaces[key] = [4]uint64{
|
||||||
|
sum.NetworkInterfaces[key][0] / uint64(count),
|
||||||
|
sum.NetworkInterfaces[key][1] / uint64(count),
|
||||||
|
sum.NetworkInterfaces[key][2],
|
||||||
|
sum.NetworkInterfaces[key][3],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Average temperatures
|
// Average temperatures
|
||||||
if sum.Temperatures != nil && tempCount > 0 {
|
if sum.Temperatures != nil && tempCount > 0 {
|
||||||
for key := range sum.Temperatures {
|
for key := range sum.Temperatures {
|
||||||
@@ -323,6 +363,13 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
|
|||||||
gpu.Usage = twoDecimals(gpu.Usage / count)
|
gpu.Usage = twoDecimals(gpu.Usage / count)
|
||||||
gpu.Power = twoDecimals(gpu.Power / count)
|
gpu.Power = twoDecimals(gpu.Power / count)
|
||||||
gpu.Count = twoDecimals(gpu.Count / count)
|
gpu.Count = twoDecimals(gpu.Count / count)
|
||||||
|
|
||||||
|
if gpu.Engines != nil {
|
||||||
|
for engineKey := range gpu.Engines {
|
||||||
|
gpu.Engines[engineKey] = twoDecimals(gpu.Engines[engineKey] / count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
sum.GPUData[id] = gpu
|
sum.GPUData[id] = gpu
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,12 +4,13 @@
|
|||||||
package records_test
|
package records_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"beszel/internal/records"
|
|
||||||
"beszel/internal/tests"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/records"
|
||||||
|
"github.com/henrygd/beszel/internal/tests"
|
||||||
|
|
||||||
"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"
|
||||||
@@ -174,7 +175,7 @@ func TestDeleteOldSystemStats(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Run deletion
|
// Run deletion
|
||||||
err = records.TestDeleteOldSystemStats(hub)
|
err = records.DeleteOldSystemStats(hub)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Verify results
|
// Verify results
|
||||||
@@ -267,7 +268,7 @@ func TestDeleteOldAlertsHistory(t *testing.T) {
|
|||||||
assert.Equal(t, int64(tc.alertCount), countBefore, "Initial count should match")
|
assert.Equal(t, int64(tc.alertCount), countBefore, "Initial count should match")
|
||||||
|
|
||||||
// Run deletion
|
// Run deletion
|
||||||
err = records.TestDeleteOldAlertsHistory(hub, tc.countToKeep, tc.countBeforeDeletion)
|
err = records.DeleteOldAlertsHistory(hub, tc.countToKeep, tc.countBeforeDeletion)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Count after deletion
|
// Count after deletion
|
||||||
@@ -331,7 +332,7 @@ func TestDeleteOldAlertsHistoryEdgeCases(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Should not error and should not delete anything
|
// Should not error and should not delete anything
|
||||||
err = records.TestDeleteOldAlertsHistory(hub, 10, 20)
|
err = records.DeleteOldAlertsHistory(hub, 10, 20)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
count, err := hub.CountRecords("alerts_history")
|
count, err := hub.CountRecords("alerts_history")
|
||||||
@@ -345,7 +346,7 @@ func TestDeleteOldAlertsHistoryEdgeCases(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Should not error with empty table
|
// Should not error with empty table
|
||||||
err = records.TestDeleteOldAlertsHistory(hub, 10, 20)
|
err = records.DeleteOldAlertsHistory(hub, 10, 20)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -375,7 +376,7 @@ func TestTwoDecimals(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range testCases {
|
for _, tc := range testCases {
|
||||||
result := records.TestTwoDecimals(tc.input)
|
result := records.TwoDecimals(tc.input)
|
||||||
assert.InDelta(t, tc.expected, result, 0.02, "twoDecimals(%f) should equal %f", tc.input, tc.expected)
|
assert.InDelta(t, tc.expected, result, 0.02, "twoDecimals(%f) should equal %f", tc.input, tc.expected)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user