mirror of
https://github.com/henrygd/beszel.git
synced 2026-03-22 21:46:18 +01:00
Compare commits
86 Commits
updater-up
...
split-inte
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb26877720 | ||
|
|
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 | ||
|
|
5f4d3dc730 | ||
|
|
8fa9aece63 | ||
|
|
2f1a022e2a | ||
|
|
4815cd29bc | ||
|
|
e49bfaf5d7 | ||
|
|
b13915b76f | ||
|
|
e2a57dc43b | ||
|
|
7222224b40 | ||
|
|
02ff475b84 | ||
|
|
09cd8d0db9 | ||
|
|
36f1a0c53b | ||
|
|
0b0e94e045 | ||
|
|
20ca6edf81 | ||
|
|
1990f8c6df |
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
|
||||||
34
.github/workflows/docker-images.yml
vendored
34
.github/workflows/docker-images.yml
vendored
@@ -13,44 +13,44 @@ 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
|
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
|
platforms: linux/amd64
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
@@ -68,10 +68,10 @@ jobs:
|
|||||||
uses: oven-sh/setup-bun@v2
|
uses: oven-sh/setup-bun@v2
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --no-save --cwd ./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 +93,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 +110,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
|
||||||
|
|||||||
13
.gitignore
vendored
13
.gitignore
vendored
@@ -8,15 +8,16 @@ 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
|
||||||
|
.vite
|
||||||
@@ -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,29 +15,30 @@ 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"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Agent struct {
|
type Agent struct {
|
||||||
sync.Mutex // Used to lock agent while collecting data
|
sync.Mutex // Used to lock agent while collecting data
|
||||||
debug bool // true if LOG_LEVEL is set to debug
|
debug bool // true if LOG_LEVEL is set to debug
|
||||||
zfs bool // true if system has arcstats
|
zfs bool // true if system has arcstats
|
||||||
memCalc string // Memory calculation formula
|
memCalc string // Memory calculation formula
|
||||||
fsNames []string // List of filesystem device names being monitored
|
fsNames []string // List of filesystem device names being monitored
|
||||||
fsStats map[string]*system.FsStats // Keeps track of disk stats for each filesystem
|
fsStats map[string]*system.FsStats // Keeps track of disk stats for each filesystem
|
||||||
netInterfaces map[string]struct{} // Stores all valid network interfaces
|
netInterfaces map[string]struct{} // Stores all valid network interfaces
|
||||||
netIoStats system.NetIoStats // Keeps track of bandwidth usage
|
netIoStats map[string]system.NetIoStats // Keeps track of per-interface bandwidth usage
|
||||||
dockerManager *dockerManager // Manages Docker API requests
|
dockerManager *dockerManager // Manages Docker API requests
|
||||||
sensorConfig *SensorConfig // Sensors config
|
sensorConfig *SensorConfig // Sensors config
|
||||||
systemInfo system.Info // Host system info
|
systemInfo system.Info // Host system info
|
||||||
gpuManager *GPUManager // Manages GPU data
|
gpuManager *GPUManager // Manages GPU data
|
||||||
cache *SessionCache // Cache for system stats based on primary session ID
|
cache *SessionCache // Cache for system stats based on primary session ID
|
||||||
connectionManager *ConnectionManager // Channel to signal connection events
|
connectionManager *ConnectionManager // Channel to signal connection events
|
||||||
server *ssh.Server // SSH server
|
server *ssh.Server // SSH server
|
||||||
dataDir string // Directory for persisting data
|
dataDir string // Directory for persisting data
|
||||||
keys []gossh.PublicKey // SSH public keys
|
keys []gossh.PublicKey // SSH public keys
|
||||||
hasBattery bool // true if agent has access to battery stats
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAgent creates a new agent with the given data directory for persisting data.
|
// NewAgent creates a new agent with the given data directory for persisting data.
|
||||||
@@ -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,17 +4,18 @@
|
|||||||
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"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSessionCache_GetSet(t *testing.T) {
|
func TestSessionCache_GetSet(t *testing.T) {
|
||||||
synctest.Run(func() {
|
synctest.Test(t, func(t *testing.T) {
|
||||||
cache := NewSessionCache(69 * time.Second)
|
cache := NewSessionCache(69 * time.Second)
|
||||||
|
|
||||||
testData := &system.CombinedData{
|
testData := &system.CombinedData{
|
||||||
53
agent/battery/battery.go
Normal file
53
agent/battery/battery.go
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
//go:build !freebsd
|
||||||
|
|
||||||
|
// Package battery provides functions to check if the system has a battery and to get the battery stats.
|
||||||
|
package battery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"github.com/distatus/battery"
|
||||||
|
)
|
||||||
|
|
||||||
|
var systemHasBattery = false
|
||||||
|
var haveCheckedBattery = false
|
||||||
|
|
||||||
|
// HasReadableBattery checks if the system has a battery and returns true if it does.
|
||||||
|
func HasReadableBattery() bool {
|
||||||
|
if haveCheckedBattery {
|
||||||
|
return systemHasBattery
|
||||||
|
}
|
||||||
|
haveCheckedBattery = true
|
||||||
|
bat, err := battery.Get(0)
|
||||||
|
if err == nil && bat != nil {
|
||||||
|
systemHasBattery = true
|
||||||
|
} else {
|
||||||
|
slog.Debug("No battery found", "err", err)
|
||||||
|
}
|
||||||
|
return systemHasBattery
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBatteryStats returns the current battery percent and charge state
|
||||||
|
func GetBatteryStats() (batteryPercent uint8, batteryState uint8, err error) {
|
||||||
|
if !systemHasBattery {
|
||||||
|
return batteryPercent, batteryState, errors.ErrUnsupported
|
||||||
|
}
|
||||||
|
batteries, err := battery.GetAll()
|
||||||
|
if err != nil || len(batteries) == 0 {
|
||||||
|
return batteryPercent, batteryState, err
|
||||||
|
}
|
||||||
|
totalCapacity := float64(0)
|
||||||
|
totalCharge := float64(0)
|
||||||
|
for _, bat := range batteries {
|
||||||
|
if bat.Design != 0 {
|
||||||
|
totalCapacity += bat.Design
|
||||||
|
} else {
|
||||||
|
totalCapacity += bat.Full
|
||||||
|
}
|
||||||
|
totalCharge += bat.Current
|
||||||
|
}
|
||||||
|
batteryPercent = uint8(totalCharge / totalCapacity * 100)
|
||||||
|
batteryState = uint8(batteries[0].State.Raw)
|
||||||
|
return batteryPercent, batteryState, nil
|
||||||
|
}
|
||||||
13
agent/battery/battery_freebsd.go
Normal file
13
agent/battery/battery_freebsd.go
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
//go:build freebsd
|
||||||
|
|
||||||
|
package battery
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
func HasReadableBattery() bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetBatteryStats() (uint8, uint8, error) {
|
||||||
|
return 0, 0, errors.ErrUnsupported
|
||||||
|
}
|
||||||
@@ -1,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,13 +1,14 @@
|
|||||||
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"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ConnectionManager manages the connection state and events for the agent.
|
// ConnectionManager manages the connection state and events for the agent.
|
||||||
@@ -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"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -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"
|
||||||
)
|
)
|
||||||
@@ -39,7 +39,7 @@ func TestHealth(t *testing.T) {
|
|||||||
// This test uses synctest to simulate time passing.
|
// This test uses synctest to simulate time passing.
|
||||||
// NOTE: This test requires GOEXPERIMENT=synctest to run.
|
// NOTE: This test requires GOEXPERIMENT=synctest to run.
|
||||||
t.Run("check with simulated time", func(t *testing.T) {
|
t.Run("check with simulated time", func(t *testing.T) {
|
||||||
synctest.Run(func() {
|
synctest.Test(t, func(t *testing.T) {
|
||||||
// Update the file to set the initial timestamp.
|
// Update the file to set the initial timestamp.
|
||||||
require.NoError(t, Update(), "Update() failed inside synctest")
|
require.NoError(t, Update(), "Update() failed inside synctest")
|
||||||
|
|
||||||
@@ -5,12 +5,16 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
|
||||||
psutilNet "github.com/shirou/gopsutil/v4/net"
|
psutilNet "github.com/shirou/gopsutil/v4/net"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (a *Agent) initializeNetIoStats() {
|
func (a *Agent) initializeNetIoStats() {
|
||||||
// reset valid network interfaces
|
// reset valid network interfaces
|
||||||
a.netInterfaces = make(map[string]struct{}, 0)
|
a.netInterfaces = make(map[string]struct{}, 0)
|
||||||
|
// reset network I/O stats per interface
|
||||||
|
a.netIoStats = make(map[string]system.NetIoStats, 0)
|
||||||
|
|
||||||
// map of network interface names passed in via NICS env var
|
// map of network interface names passed in via NICS env var
|
||||||
var nicsMap map[string]struct{}
|
var nicsMap map[string]struct{}
|
||||||
@@ -22,13 +26,10 @@ func (a *Agent) initializeNetIoStats() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// reset network I/O stats
|
|
||||||
a.netIoStats.BytesSent = 0
|
|
||||||
a.netIoStats.BytesRecv = 0
|
|
||||||
|
|
||||||
// get intial network I/O stats
|
// get intial network I/O stats
|
||||||
if netIO, err := psutilNet.IOCounters(true); err == nil {
|
if netIO, err := psutilNet.IOCounters(true); err == nil {
|
||||||
a.netIoStats.Time = time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
for _, v := range netIO {
|
for _, v := range netIO {
|
||||||
switch {
|
switch {
|
||||||
// skip if nics exists and the interface is not in the list
|
// skip if nics exists and the interface is not in the list
|
||||||
@@ -43,10 +44,15 @@ func (a *Agent) initializeNetIoStats() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
slog.Info("Detected network interface", "name", v.Name, "sent", v.BytesSent, "recv", v.BytesRecv)
|
slog.Info("Detected network interface", "name", v.Name, "sent", v.BytesSent, "recv", v.BytesRecv)
|
||||||
a.netIoStats.BytesSent += v.BytesSent
|
|
||||||
a.netIoStats.BytesRecv += v.BytesRecv
|
|
||||||
// store as a valid network interface
|
// store as a valid network interface
|
||||||
a.netInterfaces[v.Name] = struct{}{}
|
a.netInterfaces[v.Name] = struct{}{}
|
||||||
|
// initialize per-interface stats
|
||||||
|
a.netIoStats[v.Name] = system.NetIoStats{
|
||||||
|
BytesRecv: v.BytesRecv,
|
||||||
|
BytesSent: v.BytesSent,
|
||||||
|
Time: now,
|
||||||
|
Name: v.Name,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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,8 +1,6 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"beszel"
|
|
||||||
"beszel/internal/entities/system"
|
|
||||||
"bufio"
|
"bufio"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -11,6 +9,10 @@ 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"
|
||||||
@@ -64,13 +66,6 @@ func (a *Agent) initializeSystemInfo() {
|
|||||||
} else {
|
} else {
|
||||||
a.zfs = true
|
a.zfs = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// battery
|
|
||||||
if _, _, err := getBatteryStats(); err != nil {
|
|
||||||
slog.Debug("No battery detected", "err", err)
|
|
||||||
} else {
|
|
||||||
a.hasBattery = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns current info, stats about the host system
|
// Returns current info, stats about the host system
|
||||||
@@ -78,8 +73,8 @@ func (a *Agent) getSystemStats() system.Stats {
|
|||||||
systemStats := system.Stats{}
|
systemStats := system.Stats{}
|
||||||
|
|
||||||
// battery
|
// battery
|
||||||
if a.hasBattery {
|
if battery.HasReadableBattery() {
|
||||||
systemStats.Battery[0], systemStats.Battery[1], _ = getBatteryStats()
|
systemStats.Battery[0], systemStats.Battery[1], _ = battery.GetBatteryStats()
|
||||||
}
|
}
|
||||||
|
|
||||||
// cpu percent
|
// cpu percent
|
||||||
@@ -181,53 +176,85 @@ func (a *Agent) getSystemStats() system.Stats {
|
|||||||
if len(a.netInterfaces) == 0 {
|
if len(a.netInterfaces) == 0 {
|
||||||
// if no network interfaces, initialize again
|
// if no network interfaces, initialize again
|
||||||
// this is a fix if agent started before network is online (#466)
|
// 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()
|
a.initializeNetIoStats()
|
||||||
}
|
}
|
||||||
if netIO, err := psutilNet.IOCounters(true); err == nil {
|
if netIO, err := psutilNet.IOCounters(true); err == nil {
|
||||||
msElapsed := uint64(time.Since(a.netIoStats.Time).Milliseconds())
|
now := time.Now()
|
||||||
a.netIoStats.Time = time.Now()
|
|
||||||
totalBytesSent := uint64(0)
|
// pre-allocate maps with known capacity
|
||||||
totalBytesRecv := uint64(0)
|
interfaceCount := len(a.netInterfaces)
|
||||||
// sum all bytes sent and received
|
if systemStats.NetworkInterfaces == nil || len(systemStats.NetworkInterfaces) != interfaceCount {
|
||||||
|
systemStats.NetworkInterfaces = make(map[string]system.NetworkInterfaceStats, interfaceCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalSent, totalRecv float64
|
||||||
|
|
||||||
|
// single pass through interfaces
|
||||||
for _, v := range netIO {
|
for _, v := range netIO {
|
||||||
// skip if not in valid network interfaces list
|
// skip if not in valid network interfaces list
|
||||||
if _, exists := a.netInterfaces[v.Name]; !exists {
|
if _, exists := a.netInterfaces[v.Name]; !exists {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
totalBytesSent += v.BytesSent
|
|
||||||
totalBytesRecv += v.BytesRecv
|
// get previous stats for this interface
|
||||||
}
|
prevStats, exists := a.netIoStats[v.Name]
|
||||||
// add to systemStats
|
var networkSentPs, networkRecvPs float64
|
||||||
var bytesSentPerSecond, bytesRecvPerSecond uint64
|
|
||||||
if msElapsed > 0 {
|
if exists {
|
||||||
bytesSentPerSecond = (totalBytesSent - a.netIoStats.BytesSent) * 1000 / msElapsed
|
secondsElapsed := time.Since(prevStats.Time).Seconds()
|
||||||
bytesRecvPerSecond = (totalBytesRecv - a.netIoStats.BytesRecv) * 1000 / msElapsed
|
if secondsElapsed > 0 {
|
||||||
}
|
// direct calculation to MB/s, avoiding intermediate bytes/sec
|
||||||
networkSentPs := bytesToMegabytes(float64(bytesSentPerSecond))
|
networkSentPs = bytesToMegabytes(float64(v.BytesSent-prevStats.BytesSent) / secondsElapsed)
|
||||||
networkRecvPs := bytesToMegabytes(float64(bytesRecvPerSecond))
|
networkRecvPs = bytesToMegabytes(float64(v.BytesRecv-prevStats.BytesRecv) / secondsElapsed)
|
||||||
// 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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// accumulate totals
|
||||||
|
totalSent += networkSentPs
|
||||||
|
totalRecv += networkRecvPs
|
||||||
|
|
||||||
|
// store per-interface stats
|
||||||
|
systemStats.NetworkInterfaces[v.Name] = system.NetworkInterfaceStats{
|
||||||
|
NetworkSent: networkSentPs,
|
||||||
|
NetworkRecv: networkRecvPs,
|
||||||
|
TotalBytesSent: v.BytesSent,
|
||||||
|
TotalBytesRecv: v.BytesRecv,
|
||||||
|
}
|
||||||
|
|
||||||
|
// update previous stats (reuse existing struct if possible)
|
||||||
|
if prevStats.Name == v.Name {
|
||||||
|
prevStats.BytesRecv = v.BytesRecv
|
||||||
|
prevStats.BytesSent = v.BytesSent
|
||||||
|
prevStats.PacketsSent = v.PacketsSent
|
||||||
|
prevStats.PacketsRecv = v.PacketsRecv
|
||||||
|
prevStats.Time = now
|
||||||
|
a.netIoStats[v.Name] = prevStats
|
||||||
|
} else {
|
||||||
|
a.netIoStats[v.Name] = system.NetIoStats{
|
||||||
|
BytesRecv: v.BytesRecv,
|
||||||
|
BytesSent: v.BytesSent,
|
||||||
|
PacketsSent: v.PacketsSent,
|
||||||
|
PacketsRecv: v.PacketsRecv,
|
||||||
|
Time: now,
|
||||||
|
Name: v.Name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// add check for issue (#150) where sent is a massive number
|
||||||
|
if totalSent > 10_000 || totalRecv > 10_000 {
|
||||||
|
slog.Warn("Invalid net stats. Resetting.", "sent", totalSent, "recv", totalRecv)
|
||||||
// reset network I/O stats
|
// reset network I/O stats
|
||||||
a.initializeNetIoStats()
|
a.initializeNetIoStats()
|
||||||
} else {
|
} else {
|
||||||
systemStats.NetworkSent = networkSentPs
|
systemStats.NetworkSent = totalSent
|
||||||
systemStats.NetworkRecv = networkRecvPs
|
systemStats.NetworkRecv = totalRecv
|
||||||
systemStats.Bandwidth[0], systemStats.Bandwidth[1] = bytesSentPerSecond, bytesRecvPerSecond
|
|
||||||
// update netIoStats
|
|
||||||
a.netIoStats.BytesSent = totalBytesSent
|
|
||||||
a.netIoStats.BytesRecv = totalBytesRecv
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// connection counts
|
||||||
|
a.updateConnectionCounts(&systemStats)
|
||||||
|
|
||||||
// temperatures
|
// temperatures
|
||||||
// TODO: maybe refactor to methods on systemStats
|
// TODO: maybe refactor to methods on systemStats
|
||||||
a.updateTemperatures(&systemStats)
|
a.updateTemperatures(&systemStats)
|
||||||
@@ -275,14 +302,109 @@ func (a *Agent) getSystemStats() system.Stats {
|
|||||||
a.systemInfo.MemPct = systemStats.MemPct
|
a.systemInfo.MemPct = systemStats.MemPct
|
||||||
a.systemInfo.DiskPct = systemStats.DiskPct
|
a.systemInfo.DiskPct = systemStats.DiskPct
|
||||||
a.systemInfo.Uptime, _ = host.Uptime()
|
a.systemInfo.Uptime, _ = host.Uptime()
|
||||||
// TODO: in future release, remove MB bandwidth values in favor of bytes
|
|
||||||
a.systemInfo.Bandwidth = twoDecimals(systemStats.NetworkSent + systemStats.NetworkRecv)
|
// Sum all per-interface network sent/recv and assign to systemInfo
|
||||||
a.systemInfo.BandwidthBytes = systemStats.Bandwidth[0] + systemStats.Bandwidth[1]
|
var totalSent, totalRecv float64
|
||||||
|
for _, iface := range systemStats.NetworkInterfaces {
|
||||||
|
totalSent += iface.NetworkSent
|
||||||
|
totalRecv += iface.NetworkRecv
|
||||||
|
}
|
||||||
|
a.systemInfo.NetworkSent = twoDecimals(totalSent)
|
||||||
|
a.systemInfo.NetworkRecv = twoDecimals(totalRecv)
|
||||||
slog.Debug("sysinfo", "data", a.systemInfo)
|
slog.Debug("sysinfo", "data", a.systemInfo)
|
||||||
|
|
||||||
return systemStats
|
return systemStats
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *Agent) updateConnectionCounts(systemStats *system.Stats) {
|
||||||
|
// Get IPv4 connections
|
||||||
|
connectionsIPv4, err := psutilNet.Connections("inet")
|
||||||
|
if err != nil {
|
||||||
|
slog.Debug("Failed to get IPv4 connection stats", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get IPv6 connections
|
||||||
|
connectionsIPv6, err := psutilNet.Connections("inet6")
|
||||||
|
if err != nil {
|
||||||
|
slog.Debug("Failed to get IPv6 connection stats", "err", err)
|
||||||
|
// Continue with IPv4 only if IPv6 fails
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize Nets map if needed
|
||||||
|
if systemStats.Nets == nil {
|
||||||
|
systemStats.Nets = make(map[string]float64)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count IPv4 connection states
|
||||||
|
connStatsIPv4 := map[string]int{
|
||||||
|
"established": 0,
|
||||||
|
"listen": 0,
|
||||||
|
"time_wait": 0,
|
||||||
|
"close_wait": 0,
|
||||||
|
"syn_recv": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, conn := range connectionsIPv4 {
|
||||||
|
// Only count TCP connections (Type 1 = SOCK_STREAM)
|
||||||
|
if conn.Type == 1 {
|
||||||
|
switch strings.ToUpper(conn.Status) {
|
||||||
|
case "ESTABLISHED":
|
||||||
|
connStatsIPv4["established"]++
|
||||||
|
case "LISTEN":
|
||||||
|
connStatsIPv4["listen"]++
|
||||||
|
case "TIME_WAIT":
|
||||||
|
connStatsIPv4["time_wait"]++
|
||||||
|
case "CLOSE_WAIT":
|
||||||
|
connStatsIPv4["close_wait"]++
|
||||||
|
case "SYN_RECV":
|
||||||
|
connStatsIPv4["syn_recv"]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count IPv6 connection states
|
||||||
|
connStatsIPv6 := map[string]int{
|
||||||
|
"established": 0,
|
||||||
|
"listen": 0,
|
||||||
|
"time_wait": 0,
|
||||||
|
"close_wait": 0,
|
||||||
|
"syn_recv": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, conn := range connectionsIPv6 {
|
||||||
|
// Only count TCP connections (Type 1 = SOCK_STREAM)
|
||||||
|
if conn.Type == 1 {
|
||||||
|
switch strings.ToUpper(conn.Status) {
|
||||||
|
case "ESTABLISHED":
|
||||||
|
connStatsIPv6["established"]++
|
||||||
|
case "LISTEN":
|
||||||
|
connStatsIPv6["listen"]++
|
||||||
|
case "TIME_WAIT":
|
||||||
|
connStatsIPv6["time_wait"]++
|
||||||
|
case "CLOSE_WAIT":
|
||||||
|
connStatsIPv6["close_wait"]++
|
||||||
|
case "SYN_RECV":
|
||||||
|
connStatsIPv6["syn_recv"]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add IPv4 connection counts to Nets
|
||||||
|
systemStats.Nets["conn_established"] = float64(connStatsIPv4["established"])
|
||||||
|
systemStats.Nets["conn_listen"] = float64(connStatsIPv4["listen"])
|
||||||
|
systemStats.Nets["conn_timewait"] = float64(connStatsIPv4["time_wait"])
|
||||||
|
systemStats.Nets["conn_closewait"] = float64(connStatsIPv4["close_wait"])
|
||||||
|
systemStats.Nets["conn_synrecv"] = float64(connStatsIPv4["syn_recv"])
|
||||||
|
|
||||||
|
// Add IPv6 connection counts to Nets
|
||||||
|
systemStats.Nets["conn6_established"] = float64(connStatsIPv6["established"])
|
||||||
|
systemStats.Nets["conn6_listen"] = float64(connStatsIPv6["listen"])
|
||||||
|
systemStats.Nets["conn6_timewait"] = float64(connStatsIPv6["time_wait"])
|
||||||
|
systemStats.Nets["conn6_closewait"] = float64(connStatsIPv6["close_wait"])
|
||||||
|
systemStats.Nets["conn6_synrecv"] = float64(connStatsIPv6["syn_recv"])
|
||||||
|
}
|
||||||
|
|
||||||
// Returns the size of the ZFS ARC memory cache in bytes
|
// Returns the size of the ZFS ARC memory cache in bytes
|
||||||
func getARCSize() (uint64, error) {
|
func getARCSize() (uint64, error) {
|
||||||
file, err := os.Open("/proc/spl/kstat/zfs/arcstats")
|
file, err := os.Open("/proc/spl/kstat/zfs/arcstats")
|
||||||
@@ -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.
|
||||||
@@ -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.7"
|
||||||
|
// 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,24 +0,0 @@
|
|||||||
package agent
|
|
||||||
|
|
||||||
import "github.com/distatus/battery"
|
|
||||||
|
|
||||||
// getBatteryStats returns the current battery percent and charge state
|
|
||||||
func getBatteryStats() (batteryPercent uint8, batteryState uint8, err error) {
|
|
||||||
batteries, err := battery.GetAll()
|
|
||||||
if err != nil {
|
|
||||||
return batteryPercent, batteryState, err
|
|
||||||
}
|
|
||||||
totalCapacity := float64(0)
|
|
||||||
totalCharge := float64(0)
|
|
||||||
for _, bat := range batteries {
|
|
||||||
if bat.Design != 0 {
|
|
||||||
totalCapacity += bat.Design
|
|
||||||
} else {
|
|
||||||
totalCapacity += bat.Full
|
|
||||||
}
|
|
||||||
totalCharge += bat.Current
|
|
||||||
}
|
|
||||||
batteryPercent = uint8(totalCharge / totalCapacity * 100)
|
|
||||||
batteryState = uint8(batteries[0].State.Raw)
|
|
||||||
return batteryPercent, batteryState, nil
|
|
||||||
}
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
package system
|
|
||||||
|
|
||||||
// TODO: this is confusing, make common package with common/types common/helpers etc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"beszel/internal/entities/container"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Stats struct {
|
|
||||||
Cpu float64 `json:"cpu" cbor:"0,keyasint"`
|
|
||||||
MaxCpu float64 `json:"cpum,omitempty" cbor:"1,keyasint,omitempty"`
|
|
||||||
Mem float64 `json:"m" cbor:"2,keyasint"`
|
|
||||||
MemUsed float64 `json:"mu" cbor:"3,keyasint"`
|
|
||||||
MemPct float64 `json:"mp" cbor:"4,keyasint"`
|
|
||||||
MemBuffCache float64 `json:"mb" cbor:"5,keyasint"`
|
|
||||||
MemZfsArc float64 `json:"mz,omitempty" cbor:"6,keyasint,omitempty"` // ZFS ARC memory
|
|
||||||
Swap float64 `json:"s,omitempty" cbor:"7,keyasint,omitempty"`
|
|
||||||
SwapUsed float64 `json:"su,omitempty" cbor:"8,keyasint,omitempty"`
|
|
||||||
DiskTotal float64 `json:"d" cbor:"9,keyasint"`
|
|
||||||
DiskUsed float64 `json:"du" cbor:"10,keyasint"`
|
|
||||||
DiskPct float64 `json:"dp" cbor:"11,keyasint"`
|
|
||||||
DiskReadPs float64 `json:"dr" cbor:"12,keyasint"`
|
|
||||||
DiskWritePs float64 `json:"dw" cbor:"13,keyasint"`
|
|
||||||
MaxDiskReadPs float64 `json:"drm,omitempty" cbor:"14,keyasint,omitempty"`
|
|
||||||
MaxDiskWritePs float64 `json:"dwm,omitempty" cbor:"15,keyasint,omitempty"`
|
|
||||||
NetworkSent float64 `json:"ns" cbor:"16,keyasint"`
|
|
||||||
NetworkRecv float64 `json:"nr" cbor:"17,keyasint"`
|
|
||||||
MaxNetworkSent float64 `json:"nsm,omitempty" cbor:"18,keyasint,omitempty"`
|
|
||||||
MaxNetworkRecv float64 `json:"nrm,omitempty" cbor:"19,keyasint,omitempty"`
|
|
||||||
Temperatures map[string]float64 `json:"t,omitempty" cbor:"20,keyasint,omitempty"`
|
|
||||||
ExtraFs map[string]*FsStats `json:"efs,omitempty" cbor:"21,keyasint,omitempty"`
|
|
||||||
GPUData map[string]GPUData `json:"g,omitempty" cbor:"22,keyasint,omitempty"`
|
|
||||||
LoadAvg1 float64 `json:"l1,omitempty" cbor:"23,keyasint,omitempty"`
|
|
||||||
LoadAvg5 float64 `json:"l5,omitempty" cbor:"24,keyasint,omitempty"`
|
|
||||||
LoadAvg15 float64 `json:"l15,omitempty" cbor:"25,keyasint,omitempty"`
|
|
||||||
Bandwidth [2]uint64 `json:"b,omitzero" cbor:"26,keyasint,omitzero"` // [sent bytes, recv bytes]
|
|
||||||
MaxBandwidth [2]uint64 `json:"bm,omitzero" cbor:"27,keyasint,omitzero"` // [sent bytes, recv bytes]
|
|
||||||
// TODO: remove other load fields in future release in favor of load avg array
|
|
||||||
LoadAvg [3]float64 `json:"la,omitempty" cbor:"28,keyasint"`
|
|
||||||
Battery [2]uint8 `json:"bat,omitzero" cbor:"29,keyasint,omitzero"` // [percent, charge state, current]
|
|
||||||
}
|
|
||||||
|
|
||||||
type GPUData struct {
|
|
||||||
Name string `json:"n" cbor:"0,keyasint"`
|
|
||||||
Temperature float64 `json:"-"`
|
|
||||||
MemoryUsed float64 `json:"mu,omitempty" cbor:"1,keyasint,omitempty"`
|
|
||||||
MemoryTotal float64 `json:"mt,omitempty" cbor:"2,keyasint,omitempty"`
|
|
||||||
Usage float64 `json:"u" cbor:"3,keyasint"`
|
|
||||||
Power float64 `json:"p,omitempty" cbor:"4,keyasint,omitempty"`
|
|
||||||
Count float64 `json:"-"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type FsStats struct {
|
|
||||||
Time time.Time `json:"-"`
|
|
||||||
Root bool `json:"-"`
|
|
||||||
Mountpoint string `json:"-"`
|
|
||||||
DiskTotal float64 `json:"d" cbor:"0,keyasint"`
|
|
||||||
DiskUsed float64 `json:"du" cbor:"1,keyasint"`
|
|
||||||
TotalRead uint64 `json:"-"`
|
|
||||||
TotalWrite uint64 `json:"-"`
|
|
||||||
DiskReadPs float64 `json:"r" cbor:"2,keyasint"`
|
|
||||||
DiskWritePs float64 `json:"w" cbor:"3,keyasint"`
|
|
||||||
MaxDiskReadPS float64 `json:"rm,omitempty" cbor:"4,keyasint,omitempty"`
|
|
||||||
MaxDiskWritePS float64 `json:"wm,omitempty" cbor:"5,keyasint,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type NetIoStats struct {
|
|
||||||
BytesRecv uint64
|
|
||||||
BytesSent uint64
|
|
||||||
Time time.Time
|
|
||||||
Name string
|
|
||||||
}
|
|
||||||
|
|
||||||
type Os = uint8
|
|
||||||
|
|
||||||
const (
|
|
||||||
Linux Os = iota
|
|
||||||
Darwin
|
|
||||||
Windows
|
|
||||||
Freebsd
|
|
||||||
)
|
|
||||||
|
|
||||||
type Info struct {
|
|
||||||
Hostname string `json:"h" cbor:"0,keyasint"`
|
|
||||||
KernelVersion string `json:"k,omitempty" cbor:"1,keyasint,omitempty"`
|
|
||||||
Cores int `json:"c" cbor:"2,keyasint"`
|
|
||||||
Threads int `json:"t,omitempty" cbor:"3,keyasint,omitempty"`
|
|
||||||
CpuModel string `json:"m" cbor:"4,keyasint"`
|
|
||||||
Uptime uint64 `json:"u" cbor:"5,keyasint"`
|
|
||||||
Cpu float64 `json:"cpu" cbor:"6,keyasint"`
|
|
||||||
MemPct float64 `json:"mp" cbor:"7,keyasint"`
|
|
||||||
DiskPct float64 `json:"dp" cbor:"8,keyasint"`
|
|
||||||
Bandwidth float64 `json:"b" cbor:"9,keyasint"`
|
|
||||||
AgentVersion string `json:"v" cbor:"10,keyasint"`
|
|
||||||
Podman bool `json:"p,omitempty" cbor:"11,keyasint,omitempty"`
|
|
||||||
GpuPct float64 `json:"g,omitempty" cbor:"12,keyasint,omitempty"`
|
|
||||||
DashboardTemp float64 `json:"dt,omitempty" cbor:"13,keyasint,omitempty"`
|
|
||||||
Os Os `json:"os" cbor:"14,keyasint"`
|
|
||||||
LoadAvg1 float64 `json:"l1,omitempty" cbor:"15,keyasint,omitempty"`
|
|
||||||
LoadAvg5 float64 `json:"l5,omitempty" cbor:"16,keyasint,omitempty"`
|
|
||||||
LoadAvg15 float64 `json:"l15,omitempty" cbor:"17,keyasint,omitempty"`
|
|
||||||
BandwidthBytes uint64 `json:"bb" cbor:"18,keyasint"`
|
|
||||||
// TODO: remove load fields in future release in favor of load avg array
|
|
||||||
LoadAvg [3]float64 `json:"la,omitempty" cbor:"19,keyasint"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Final data structure to return to the hub
|
|
||||||
type CombinedData struct {
|
|
||||||
Stats Stats `json:"stats" cbor:"0,keyasint"`
|
|
||||||
Info Info `json:"info" cbor:"1,keyasint"`
|
|
||||||
Containers []*container.Stats `json:"container" cbor:"2,keyasint"`
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
package ghupdate
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
func TestReleaseFindAssetBySuffix(t *testing.T) {
|
|
||||||
r := release{
|
|
||||||
Assets: []*releaseAsset{
|
|
||||||
{Name: "test1.zip", Id: 1},
|
|
||||||
{Name: "test2.zip", Id: 2},
|
|
||||||
{Name: "test22.zip", Id: 22},
|
|
||||||
{Name: "test3.zip", Id: 3},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
asset, err := r.findAssetBySuffix("2.zip")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Expected nil, got err: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if asset.Id != 2 {
|
|
||||||
t.Fatalf("Expected asset with id %d, got %v", 2, asset)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,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,28 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const TooltipProvider = TooltipPrimitive.Provider
|
|
||||||
|
|
||||||
const Tooltip = TooltipPrimitive.Root
|
|
||||||
|
|
||||||
const TooltipTrigger = TooltipPrimitive.Trigger
|
|
||||||
|
|
||||||
const TooltipContent = React.forwardRef<
|
|
||||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
|
||||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
|
||||||
<TooltipPrimitive.Content
|
|
||||||
ref={ref}
|
|
||||||
sideOffset={sideOffset}
|
|
||||||
className={cn(
|
|
||||||
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
|
||||||
|
|
||||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
package beszel
|
|
||||||
|
|
||||||
import "github.com/blang/semver"
|
|
||||||
|
|
||||||
const (
|
|
||||||
Version = "0.12.3"
|
|
||||||
AppName = "beszel"
|
|
||||||
)
|
|
||||||
|
|
||||||
var MinVersionCbor = semver.MustParse("0.12.0")
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
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.8.8
|
||||||
@@ -15,9 +15,10 @@ require (
|
|||||||
github.com/nicholas-fedor/shoutrrr v0.8.17
|
github.com/nicholas-fedor/shoutrrr v0.8.17
|
||||||
github.com/pocketbase/dbx v1.11.0
|
github.com/pocketbase/dbx v1.11.0
|
||||||
github.com/pocketbase/pocketbase v0.29.3
|
github.com/pocketbase/pocketbase v0.29.3
|
||||||
github.com/shirou/gopsutil/v4 v4.25.7
|
github.com/shirou/gopsutil/v4 v4.25.6
|
||||||
github.com/spf13/cast v1.9.2
|
github.com/spf13/cast v1.9.2
|
||||||
github.com/spf13/cobra v1.9.1
|
github.com/spf13/cobra v1.9.1
|
||||||
|
github.com/spf13/pflag v1.0.7
|
||||||
github.com/stretchr/testify v1.11.0
|
github.com/stretchr/testify v1.11.0
|
||||||
golang.org/x/crypto v0.41.0
|
golang.org/x/crypto v0.41.0
|
||||||
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b
|
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b
|
||||||
@@ -49,7 +50,6 @@ require (
|
|||||||
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
|
||||||
@@ -97,8 +97,8 @@ 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.7 h1:bNb2JuqKuAu3tRlPv5piSmBZyMfecwQ+t/ILq+1JqVM=
|
github.com/shirou/gopsutil/v4 v4.25.6 h1:kLysI2JsKorfaFPcYmcJqbzROzsBWEOAtw6A7dIfqXs=
|
||||||
github.com/shirou/gopsutil/v4 v4.25.7/go.mod h1:XV/egmwJtd3ZQjBpJVY5kndsiOO4IRqy9TQnmm6VP7U=
|
github.com/shirou/gopsutil/v4 v4.25.6/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c=
|
||||||
github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE=
|
github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE=
|
||||||
github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||||
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
|
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
|
||||||
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 {
|
||||||
@@ -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"
|
||||||
@@ -37,7 +38,7 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
|
|||||||
case "Memory":
|
case "Memory":
|
||||||
val = data.Info.MemPct
|
val = data.Info.MemPct
|
||||||
case "Bandwidth":
|
case "Bandwidth":
|
||||||
val = data.Info.Bandwidth
|
val = data.Info.NetworkSent + data.Info.NetworkRecv
|
||||||
unit = " MB/s"
|
unit = " MB/s"
|
||||||
case "Disk":
|
case "Disk":
|
||||||
maxUsedPct := data.Info.DiskPct
|
maxUsedPct := data.Info.DiskPct
|
||||||
@@ -10,8 +10,10 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"testing/synctest"
|
||||||
|
"time"
|
||||||
|
|
||||||
beszelTests "beszel/internal/tests"
|
beszelTests "github.com/henrygd/beszel/internal/tests"
|
||||||
|
|
||||||
"github.com/pocketbase/dbx"
|
"github.com/pocketbase/dbx"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
@@ -63,14 +65,14 @@ func TestUserAlertsApi(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
scenarios := []beszelTests.ApiScenario{
|
scenarios := []beszelTests.ApiScenario{
|
||||||
{
|
// {
|
||||||
Name: "GET not implemented - returns index",
|
// Name: "GET not implemented - returns index",
|
||||||
Method: http.MethodGet,
|
// Method: http.MethodGet,
|
||||||
URL: "/api/beszel/user-alerts",
|
// URL: "/api/beszel/user-alerts",
|
||||||
ExpectedStatus: 200,
|
// ExpectedStatus: 200,
|
||||||
ExpectedContent: []string{"<html ", "globalThis.BESZEL"},
|
// ExpectedContent: []string{"<html ", "globalThis.BESZEL"},
|
||||||
TestAppFactory: testAppFactory,
|
// TestAppFactory: testAppFactory,
|
||||||
},
|
// },
|
||||||
{
|
{
|
||||||
Name: "POST no auth",
|
Name: "POST no auth",
|
||||||
Method: http.MethodPost,
|
Method: http.MethodPost,
|
||||||
@@ -366,3 +368,237 @@ func TestUserAlertsApi(t *testing.T) {
|
|||||||
scenario.Test(t)
|
scenario.Test(t)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getHubWithUser(t *testing.T) (*beszelTests.TestHub, *core.Record) {
|
||||||
|
hub, err := beszelTests.NewTestHub(t.TempDir())
|
||||||
|
assert.NoError(t, err)
|
||||||
|
hub.StartHub()
|
||||||
|
|
||||||
|
// Manually initialize the system manager to bind event hooks
|
||||||
|
err = hub.GetSystemManager().Initialize()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Create a test user
|
||||||
|
user, err := beszelTests.CreateUser(hub, "test@example.com", "password")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Create user settings for the test user (required for alert notifications)
|
||||||
|
userSettingsData := map[string]any{
|
||||||
|
"user": user.Id,
|
||||||
|
"settings": `{"emails":[test@example.com],"webhooks":[]}`,
|
||||||
|
}
|
||||||
|
_, err = beszelTests.CreateRecord(hub, "user_settings", userSettingsData)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
return hub, user
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatusAlerts(t *testing.T) {
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
hub, user := 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 := 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")
|
||||||
|
})
|
||||||
|
}
|
||||||
55
internal/alerts/alerts_test_helpers.go
Normal file
55
internal/alerts/alerts_test_helpers.go
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
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
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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/*
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@ RUN CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-
|
|||||||
# --------------------------
|
# --------------------------
|
||||||
# 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
|
||||||
|
|
||||||
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
|
||||||
124
internal/entities/system/system.go
Normal file
124
internal/entities/system/system.go
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
package system
|
||||||
|
|
||||||
|
// TODO: this is confusing, make common package with common/types common/helpers etc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/entities/container"
|
||||||
|
)
|
||||||
|
|
||||||
|
type NetworkInterfaceStats struct {
|
||||||
|
NetworkSent float64 `json:"ns"`
|
||||||
|
NetworkRecv float64 `json:"nr"`
|
||||||
|
MaxNetworkSent float64 `json:"nsm,omitempty"`
|
||||||
|
MaxNetworkRecv float64 `json:"nrm,omitempty"`
|
||||||
|
TotalBytesSent uint64 `json:"tbs,omitempty"` // Total bytes sent since boot
|
||||||
|
TotalBytesRecv uint64 `json:"tbr,omitempty"` // Total bytes received since boot
|
||||||
|
}
|
||||||
|
|
||||||
|
type Stats struct {
|
||||||
|
Cpu float64 `json:"cpu" cbor:"0,keyasint"`
|
||||||
|
MaxCpu float64 `json:"cpum,omitempty" cbor:"1,keyasint,omitempty"`
|
||||||
|
Mem float64 `json:"m" cbor:"2,keyasint"`
|
||||||
|
MemUsed float64 `json:"mu" cbor:"3,keyasint"`
|
||||||
|
MemPct float64 `json:"mp" cbor:"4,keyasint"`
|
||||||
|
MemBuffCache float64 `json:"mb" cbor:"5,keyasint"`
|
||||||
|
MemZfsArc float64 `json:"mz,omitempty" cbor:"6,keyasint,omitempty"` // ZFS ARC memory
|
||||||
|
Swap float64 `json:"s,omitempty" cbor:"7,keyasint,omitempty"`
|
||||||
|
SwapUsed float64 `json:"su,omitempty" cbor:"8,keyasint,omitempty"`
|
||||||
|
DiskTotal float64 `json:"d" cbor:"9,keyasint"`
|
||||||
|
DiskUsed float64 `json:"du" cbor:"10,keyasint"`
|
||||||
|
DiskPct float64 `json:"dp" cbor:"11,keyasint"`
|
||||||
|
DiskReadPs float64 `json:"dr" cbor:"12,keyasint"`
|
||||||
|
DiskWritePs float64 `json:"dw" cbor:"13,keyasint"`
|
||||||
|
MaxDiskReadPs float64 `json:"drm,omitempty" cbor:"14,keyasint,omitempty"`
|
||||||
|
MaxDiskWritePs float64 `json:"dwm,omitempty" cbor:"15,keyasint,omitempty"`
|
||||||
|
NetworkInterfaces map[string]NetworkInterfaceStats `json:"ni" cbor:"16,omitempty"` // Per-interface network stats
|
||||||
|
NetworkSent float64 `json:"ns" cbor:"17,keyasint"` // Total network sent (MB/s)
|
||||||
|
NetworkRecv float64 `json:"nr" cbor:"18,keyasint"` // Total network recv (MB/s)
|
||||||
|
MaxNetworkSent float64 `json:"nsm,omitempty" cbor:"19,keyasint,omitempty"`
|
||||||
|
MaxNetworkRecv float64 `json:"nrm,omitempty" cbor:"20,keyasint,omitempty"`
|
||||||
|
Temperatures map[string]float64 `json:"t,omitempty" cbor:"21,keyasint,omitempty"`
|
||||||
|
ExtraFs map[string]*FsStats `json:"efs,omitempty" cbor:"22,keyasint,omitempty"`
|
||||||
|
GPUData map[string]GPUData `json:"g,omitempty" cbor:"23,keyasint,omitempty"`
|
||||||
|
LoadAvg1 float64 `json:"l1,omitempty" cbor:"24,keyasint,omitempty"`
|
||||||
|
LoadAvg5 float64 `json:"l5,omitempty" cbor:"25,keyasint,omitempty"`
|
||||||
|
LoadAvg15 float64 `json:"l15,omitempty" cbor:"26,keyasint,omitempty"`
|
||||||
|
LoadAvg [3]float64 `json:"la,omitempty" cbor:"27,keyasint"` // [1min, 5min, 15min]
|
||||||
|
Battery [2]uint8 `json:"bat,omitzero" cbor:"28,keyasint,omitzero"` // [percent, charge state]
|
||||||
|
MaxMem float64 `json:"mm,omitempty" cbor:"29,keyasint,omitempty"`
|
||||||
|
Nets map[string]float64 `json:"nets,omitempty" cbor:"30,keyasint,omitempty"` // Network connection statistics
|
||||||
|
}
|
||||||
|
|
||||||
|
type GPUData struct {
|
||||||
|
Name string `json:"n" cbor:"0,keyasint"`
|
||||||
|
Temperature float64 `json:"-"`
|
||||||
|
MemoryUsed float64 `json:"mu,omitempty" cbor:"1,keyasint,omitempty"`
|
||||||
|
MemoryTotal float64 `json:"mt,omitempty" cbor:"2,keyasint,omitempty"`
|
||||||
|
Usage float64 `json:"u" cbor:"3,keyasint"`
|
||||||
|
Power float64 `json:"p,omitempty" cbor:"4,keyasint,omitempty"`
|
||||||
|
Count float64 `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FsStats struct {
|
||||||
|
Time time.Time `json:"-"`
|
||||||
|
Root bool `json:"-"`
|
||||||
|
Mountpoint string `json:"-"`
|
||||||
|
DiskTotal float64 `json:"d" cbor:"0,keyasint"`
|
||||||
|
DiskUsed float64 `json:"du" cbor:"1,keyasint"`
|
||||||
|
TotalRead uint64 `json:"-"`
|
||||||
|
TotalWrite uint64 `json:"-"`
|
||||||
|
DiskReadPs float64 `json:"r" cbor:"2,keyasint"`
|
||||||
|
DiskWritePs float64 `json:"w" cbor:"3,keyasint"`
|
||||||
|
MaxDiskReadPS float64 `json:"rm,omitempty" cbor:"4,keyasint,omitempty"`
|
||||||
|
MaxDiskWritePS float64 `json:"wm,omitempty" cbor:"5,keyasint,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type NetIoStats struct {
|
||||||
|
BytesRecv uint64
|
||||||
|
BytesSent uint64
|
||||||
|
PacketsSent uint64
|
||||||
|
PacketsRecv uint64
|
||||||
|
Time time.Time
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Os = uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
Linux Os = iota
|
||||||
|
Darwin
|
||||||
|
Windows
|
||||||
|
Freebsd
|
||||||
|
)
|
||||||
|
|
||||||
|
type Info struct {
|
||||||
|
Hostname string `json:"h" cbor:"0,keyasint"`
|
||||||
|
KernelVersion string `json:"k,omitempty" cbor:"1,keyasint,omitempty"`
|
||||||
|
Cores int `json:"c" cbor:"2,keyasint"`
|
||||||
|
Threads int `json:"t,omitempty" cbor:"3,keyasint,omitempty"`
|
||||||
|
CpuModel string `json:"m" cbor:"4,keyasint"`
|
||||||
|
Uptime uint64 `json:"u" cbor:"5,keyasint"`
|
||||||
|
Cpu float64 `json:"cpu" cbor:"6,keyasint"`
|
||||||
|
MemPct float64 `json:"mp" cbor:"7,keyasint"`
|
||||||
|
DiskPct float64 `json:"dp" cbor:"8,keyasint"`
|
||||||
|
NetworkSent float64 `json:"ns" cbor:"9,keyasint"` // Per-interface total (MB/s)
|
||||||
|
NetworkRecv float64 `json:"nr" cbor:"10,keyasint"` // Per-interface total (MB/s)
|
||||||
|
AgentVersion string `json:"v" cbor:"11,keyasint"`
|
||||||
|
Podman bool `json:"p,omitempty" cbor:"12,keyasint,omitempty"`
|
||||||
|
GpuPct float64 `json:"g,omitempty" cbor:"13,keyasint,omitempty"`
|
||||||
|
DashboardTemp float64 `json:"dt,omitempty" cbor:"14,keyasint,omitempty"`
|
||||||
|
Os Os `json:"os" cbor:"15,keyasint"`
|
||||||
|
LoadAvg1 float64 `json:"l1,omitempty" cbor:"16,keyasint,omitempty"`
|
||||||
|
LoadAvg5 float64 `json:"l5,omitempty" cbor:"17,keyasint,omitempty"`
|
||||||
|
LoadAvg15 float64 `json:"l15,omitempty" cbor:"18,keyasint,omitempty"`
|
||||||
|
LoadAvg [3]float64 `json:"la,omitempty" cbor:"19,keyasint"` // [1min, 5min, 15min]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final data structure to return to the hub
|
||||||
|
type CombinedData struct {
|
||||||
|
Stats Stats `json:"stats" cbor:"0,keyasint"`
|
||||||
|
Info Info `json:"info" cbor:"1,keyasint"`
|
||||||
|
Containers []*container.Stats `json:"container" cbor:"2,keyasint"`
|
||||||
|
}
|
||||||
140
internal/ghupdate/extract.go
Normal file
140
internal/ghupdate/extract.go
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
package ghupdate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"archive/zip"
|
||||||
|
"compress/gzip"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// extract extracts an archive file to the destination directory.
|
||||||
|
// Supports .zip and .tar.gz files based on the file extension.
|
||||||
|
func extract(srcPath, destDir string) error {
|
||||||
|
if strings.HasSuffix(srcPath, ".tar.gz") {
|
||||||
|
return extractTarGz(srcPath, destDir)
|
||||||
|
}
|
||||||
|
// Default to zip extraction
|
||||||
|
return extractZip(srcPath, destDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractTarGz extracts a tar.gz archive to the destination directory.
|
||||||
|
func extractTarGz(srcPath, destDir string) error {
|
||||||
|
src, err := os.Open(srcPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer src.Close()
|
||||||
|
|
||||||
|
gz, err := gzip.NewReader(src)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer gz.Close()
|
||||||
|
|
||||||
|
tr := tar.NewReader(gz)
|
||||||
|
|
||||||
|
for {
|
||||||
|
header, err := tr.Next()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if header.Typeflag == tar.TypeDir {
|
||||||
|
if err := os.MkdirAll(filepath.Join(destDir, header.Name), 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Dir(filepath.Join(destDir, header.Name)), 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
outFile, err := os.Create(filepath.Join(destDir, header.Name))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := io.Copy(outFile, tr); err != nil {
|
||||||
|
outFile.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
outFile.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractZip extracts the zip archive at "src" to "dest".
|
||||||
|
//
|
||||||
|
// Note that only dirs and regular files will be extracted.
|
||||||
|
// Symbolic links, named pipes, sockets, or any other irregular files
|
||||||
|
// are skipped because they come with too many edge cases and ambiguities.
|
||||||
|
func extractZip(src, dest string) error {
|
||||||
|
zr, err := zip.OpenReader(src)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer zr.Close()
|
||||||
|
|
||||||
|
// normalize dest path to check later for Zip Slip
|
||||||
|
dest = filepath.Clean(dest) + string(os.PathSeparator)
|
||||||
|
|
||||||
|
for _, f := range zr.File {
|
||||||
|
err := extractFile(f, dest)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractFile extracts the provided zipFile into "basePath/zipFileName" path,
|
||||||
|
// creating all the necessary path directories.
|
||||||
|
func extractFile(zipFile *zip.File, basePath string) error {
|
||||||
|
path := filepath.Join(basePath, zipFile.Name)
|
||||||
|
|
||||||
|
// check for Zip Slip
|
||||||
|
if !strings.HasPrefix(path, basePath) {
|
||||||
|
return fmt.Errorf("invalid file path: %s", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
r, err := zipFile.Open()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer r.Close()
|
||||||
|
|
||||||
|
// allow only dirs or regular files
|
||||||
|
if zipFile.FileInfo().IsDir() {
|
||||||
|
if err := os.MkdirAll(path, os.ModePerm); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else if zipFile.FileInfo().Mode().IsRegular() {
|
||||||
|
// ensure that the file path directories are created
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), os.ModePerm); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, zipFile.Mode())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
_, err = io.Copy(f, r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -4,9 +4,6 @@
|
|||||||
package ghupdate
|
package ghupdate
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"archive/tar"
|
|
||||||
"beszel"
|
|
||||||
"compress/gzip"
|
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -18,16 +15,16 @@ import (
|
|||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel"
|
||||||
|
|
||||||
"github.com/blang/semver"
|
"github.com/blang/semver"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
|
||||||
"github.com/pocketbase/pocketbase/tools/archive"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Minimal color functions using ANSI escape codes
|
// Minimal color functions using ANSI escape codes
|
||||||
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"
|
||||||
)
|
)
|
||||||
@@ -68,10 +65,19 @@ 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 {
|
||||||
|
config Config
|
||||||
|
currentVersion string
|
||||||
}
|
}
|
||||||
|
|
||||||
func Update(config Config) (updated bool, err error) {
|
func Update(config Config) (updated bool, err error) {
|
||||||
p := &plugin{
|
p := &updater{
|
||||||
currentVersion: beszel.Version,
|
currentVersion: beszel.Version,
|
||||||
config: config,
|
config: config,
|
||||||
}
|
}
|
||||||
@@ -79,12 +85,7 @@ func Update(config Config) (updated bool, err error) {
|
|||||||
return p.update()
|
return p.update()
|
||||||
}
|
}
|
||||||
|
|
||||||
type plugin struct {
|
func (p *updater) update() (updated bool, err error) {
|
||||||
config Config
|
|
||||||
currentVersion string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *plugin) update() (updated bool, err error) {
|
|
||||||
ColorPrint(ColorYellow, "Fetching release information...")
|
ColorPrint(ColorYellow, "Fetching release information...")
|
||||||
|
|
||||||
if p.config.DataDir == "" {
|
if p.config.DataDir == "" {
|
||||||
@@ -108,20 +109,21 @@ func (p *plugin) update() (updated bool, err error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var latest *release
|
var latest *release
|
||||||
|
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...")
|
|
||||||
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
|
||||||
}
|
}
|
||||||
@@ -130,7 +132,7 @@ func (p *plugin) 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,14 +142,14 @@ func (p *plugin) update() (updated bool, err error) {
|
|||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
releaseDir := filepath.Join(p.config.DataDir, core.LocalTempDirName)
|
releaseDir := filepath.Join(p.config.DataDir, ".beszel_update")
|
||||||
defer os.RemoveAll(releaseDir)
|
defer os.RemoveAll(releaseDir)
|
||||||
|
|
||||||
ColorPrintf(ColorYellow, "Downloading %s...", asset.Name)
|
ColorPrintf(ColorYellow, "Downloading %s...", asset.Name)
|
||||||
|
|
||||||
// download the release asset
|
// download the release asset
|
||||||
assetPath := filepath.Join(releaseDir, asset.Name)
|
assetPath := filepath.Join(releaseDir, asset.Name)
|
||||||
if err := downloadFile(p.config.Context, p.config.HttpClient, asset.DownloadUrl, assetPath); err != nil {
|
if err := downloadFile(p.config.Context, p.config.HttpClient, asset.DownloadUrl, assetPath, useMirror); err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,15 +158,9 @@ func (p *plugin) update() (updated bool, err error) {
|
|||||||
extractDir := filepath.Join(releaseDir, "extracted_"+asset.Name)
|
extractDir := filepath.Join(releaseDir, "extracted_"+asset.Name)
|
||||||
defer os.RemoveAll(extractDir)
|
defer os.RemoveAll(extractDir)
|
||||||
|
|
||||||
// Extract based on file extension
|
// Extract the archive (automatically detects format)
|
||||||
if strings.HasSuffix(asset.Name, ".tar.gz") {
|
if err := extract(assetPath, extractDir); err != nil {
|
||||||
if err := extractTarGz(assetPath, extractDir); err != nil {
|
return false, err
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if err := archive.Extract(assetPath, extractDir); err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ColorPrint(ColorYellow, "Replacing the executable...")
|
ColorPrint(ColorYellow, "Replacing the executable...")
|
||||||
@@ -216,14 +212,11 @@ func (p *plugin) 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")
|
||||||
@@ -275,7 +268,11 @@ func downloadFile(
|
|||||||
client HttpClient,
|
client HttpClient,
|
||||||
url string,
|
url string,
|
||||||
destPath string,
|
destPath string,
|
||||||
|
useMirror bool,
|
||||||
) error {
|
) error {
|
||||||
|
if useMirror {
|
||||||
|
url = strings.Replace(url, "github.com", "gh.beszel.dev", 1)
|
||||||
|
}
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -350,52 +347,3 @@ func archiveSuffix(binaryName, goos, goarch string) string {
|
|||||||
}
|
}
|
||||||
return fmt.Sprintf("%s_%s_%s.tar.gz", binaryName, goos, goarch)
|
return fmt.Sprintf("%s_%s_%s.tar.gz", binaryName, goos, goarch)
|
||||||
}
|
}
|
||||||
|
|
||||||
func extractTarGz(srcPath, destDir string) error {
|
|
||||||
src, err := os.Open(srcPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer src.Close()
|
|
||||||
|
|
||||||
gz, err := gzip.NewReader(src)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer gz.Close()
|
|
||||||
|
|
||||||
tr := tar.NewReader(gz)
|
|
||||||
|
|
||||||
for {
|
|
||||||
header, err := tr.Next()
|
|
||||||
if err == io.EOF {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if header.Typeflag == tar.TypeDir {
|
|
||||||
if err := os.MkdirAll(filepath.Join(destDir, header.Name), 0755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.MkdirAll(filepath.Dir(filepath.Join(destDir, header.Name)), 0755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
outFile, err := os.Create(filepath.Join(destDir, header.Name))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer outFile.Close()
|
|
||||||
|
|
||||||
if _, err := io.Copy(outFile, tr); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
45
internal/ghupdate/ghupdate_test.go
Normal file
45
internal/ghupdate/ghupdate_test.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
package ghupdate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestReleaseFindAssetBySuffix(t *testing.T) {
|
||||||
|
r := release{
|
||||||
|
Assets: []*releaseAsset{
|
||||||
|
{Name: "test1.zip", Id: 1},
|
||||||
|
{Name: "test2.zip", Id: 2},
|
||||||
|
{Name: "test22.zip", Id: 22},
|
||||||
|
{Name: "test3.zip", Id: 3},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
asset, err := r.findAssetBySuffix("2.zip")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Expected nil, got err: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if asset.Id != 2 {
|
||||||
|
t.Fatalf("Expected asset with id %d, got %v", 2, asset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractFailure(t *testing.T) {
|
||||||
|
testDir := t.TempDir()
|
||||||
|
|
||||||
|
// Test with missing zip file
|
||||||
|
missingZipPath := filepath.Join(testDir, "missing_test.zip")
|
||||||
|
extractedPath := filepath.Join(testDir, "zip_extract")
|
||||||
|
|
||||||
|
if err := extract(missingZipPath, extractedPath); err == nil {
|
||||||
|
t.Fatal("Expected Extract to fail due to missing zip file")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test with missing tar.gz file
|
||||||
|
missingTarPath := filepath.Join(testDir, "missing_test.tar.gz")
|
||||||
|
|
||||||
|
if err := extract(missingTarPath, extractedPath); err == nil {
|
||||||
|
t.Fatal("Expected Extract to fail due to missing tar.gz file")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
)
|
)
|
||||||
@@ -29,7 +30,7 @@ func TestSystemManagerNew(t *testing.T) {
|
|||||||
user, err := tests.CreateUser(hub, "test@test.com", "testtesttest")
|
user, err := tests.CreateUser(hub, "test@test.com", "testtesttest")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
synctest.Run(func() {
|
synctest.Test(t, func(t *testing.T) {
|
||||||
sm.Initialize()
|
sm.Initialize()
|
||||||
|
|
||||||
record, err := tests.CreateRecord(hub, "systems", map[string]any{
|
record, err := tests.CreateRecord(hub, "systems", map[string]any{
|
||||||
@@ -110,9 +111,11 @@ func TestSystemManagerNew(t *testing.T) {
|
|||||||
err = hub.Delete(record)
|
err = hub.Delete(record)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.False(t, sm.HasSystem(record.Id), "System should not exist in the store after deletion")
|
assert.False(t, sm.HasSystem(record.Id), "System should not exist in the store after deletion")
|
||||||
|
})
|
||||||
|
|
||||||
testOld(t, hub)
|
testOld(t, hub)
|
||||||
|
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
time.Sleep(time.Second)
|
time.Sleep(time.Second)
|
||||||
synctest.Wait()
|
synctest.Wait()
|
||||||
|
|
||||||
@@ -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,13 @@ func Update(_ *cobra.Command, _ []string) {
|
|||||||
dataDir = "./beszel_data"
|
dataDir = "./beszel_data"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if china-mirrors flag is set
|
||||||
|
useMirror, _ := cmd.Flags().GetBool("china-mirrors")
|
||||||
|
|
||||||
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 +34,14 @@ func Update(_ *cobra.Command, _ []string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// make sure the file is executable
|
||||||
|
exePath, err := os.Executable()
|
||||||
|
if err == nil {
|
||||||
|
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 +53,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 +69,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"
|
||||||
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() {
|
||||||
@@ -203,23 +206,58 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
|
|||||||
sum.DiskPct += stats.DiskPct
|
sum.DiskPct += stats.DiskPct
|
||||||
sum.DiskReadPs += stats.DiskReadPs
|
sum.DiskReadPs += stats.DiskReadPs
|
||||||
sum.DiskWritePs += stats.DiskWritePs
|
sum.DiskWritePs += stats.DiskWritePs
|
||||||
|
sum.LoadAvg1 += stats.LoadAvg1
|
||||||
|
sum.LoadAvg5 += stats.LoadAvg5
|
||||||
|
sum.LoadAvg15 += stats.LoadAvg15
|
||||||
sum.NetworkSent += stats.NetworkSent
|
sum.NetworkSent += stats.NetworkSent
|
||||||
sum.NetworkRecv += stats.NetworkRecv
|
sum.NetworkRecv += stats.NetworkRecv
|
||||||
sum.LoadAvg[0] += stats.LoadAvg[0]
|
sum.LoadAvg[0] += stats.LoadAvg[0]
|
||||||
sum.LoadAvg[1] += stats.LoadAvg[1]
|
sum.LoadAvg[1] += stats.LoadAvg[1]
|
||||||
sum.LoadAvg[2] += stats.LoadAvg[2]
|
sum.LoadAvg[2] += stats.LoadAvg[2]
|
||||||
sum.Bandwidth[0] += stats.Bandwidth[0]
|
|
||||||
sum.Bandwidth[1] += stats.Bandwidth[1]
|
|
||||||
batterySum += int(stats.Battery[0])
|
batterySum += int(stats.Battery[0])
|
||||||
sum.Battery[1] = stats.Battery[1]
|
sum.Battery[1] = stats.Battery[1]
|
||||||
|
|
||||||
|
if stats.NetworkInterfaces != nil {
|
||||||
|
if sum.NetworkInterfaces == nil {
|
||||||
|
sum.NetworkInterfaces = make(map[string]system.NetworkInterfaceStats, len(stats.NetworkInterfaces))
|
||||||
|
}
|
||||||
|
for key, value := range stats.NetworkInterfaces {
|
||||||
|
if _, ok := sum.NetworkInterfaces[key]; !ok {
|
||||||
|
sum.NetworkInterfaces[key] = system.NetworkInterfaceStats{}
|
||||||
|
}
|
||||||
|
ni := sum.NetworkInterfaces[key]
|
||||||
|
ni.NetworkSent += value.NetworkSent
|
||||||
|
ni.NetworkRecv += value.NetworkRecv
|
||||||
|
ni.MaxNetworkSent += value.MaxNetworkSent
|
||||||
|
ni.MaxNetworkRecv += value.MaxNetworkRecv
|
||||||
|
// For cumulative totals, use the maximum value (most recent)
|
||||||
|
if value.TotalBytesSent > ni.TotalBytesSent {
|
||||||
|
ni.TotalBytesSent = value.TotalBytesSent
|
||||||
|
}
|
||||||
|
if value.TotalBytesRecv > ni.TotalBytesRecv {
|
||||||
|
ni.TotalBytesRecv = value.TotalBytesRecv
|
||||||
|
}
|
||||||
|
sum.NetworkInterfaces[key] = ni
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle network connection stats - use the latest values (most recent sample)
|
||||||
|
if stats.Nets != nil {
|
||||||
|
if sum.Nets == nil {
|
||||||
|
sum.Nets = make(map[string]float64)
|
||||||
|
}
|
||||||
|
for key, value := range stats.Nets {
|
||||||
|
sum.Nets[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 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)
|
||||||
sum.MaxDiskWritePs = max(sum.MaxDiskWritePs, stats.MaxDiskWritePs, stats.DiskWritePs)
|
sum.MaxDiskWritePs = max(sum.MaxDiskWritePs, stats.MaxDiskWritePs, stats.DiskWritePs)
|
||||||
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])
|
|
||||||
|
|
||||||
// Accumulate temperatures
|
// Accumulate temperatures
|
||||||
if stats.Temperatures != nil {
|
if stats.Temperatures != nil {
|
||||||
@@ -287,14 +325,26 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
|
|||||||
sum.DiskPct = twoDecimals(sum.DiskPct / count)
|
sum.DiskPct = twoDecimals(sum.DiskPct / count)
|
||||||
sum.DiskReadPs = twoDecimals(sum.DiskReadPs / count)
|
sum.DiskReadPs = twoDecimals(sum.DiskReadPs / count)
|
||||||
sum.DiskWritePs = twoDecimals(sum.DiskWritePs / count)
|
sum.DiskWritePs = twoDecimals(sum.DiskWritePs / count)
|
||||||
|
sum.LoadAvg1 = twoDecimals(sum.LoadAvg1 / count)
|
||||||
|
sum.LoadAvg5 = twoDecimals(sum.LoadAvg5 / count)
|
||||||
|
sum.LoadAvg15 = twoDecimals(sum.LoadAvg15 / count)
|
||||||
sum.NetworkSent = twoDecimals(sum.NetworkSent / count)
|
sum.NetworkSent = twoDecimals(sum.NetworkSent / count)
|
||||||
sum.NetworkRecv = twoDecimals(sum.NetworkRecv / count)
|
sum.NetworkRecv = twoDecimals(sum.NetworkRecv / count)
|
||||||
sum.LoadAvg[0] = twoDecimals(sum.LoadAvg[0] / count)
|
sum.LoadAvg[0] = twoDecimals(sum.LoadAvg[0] / count)
|
||||||
sum.LoadAvg[1] = twoDecimals(sum.LoadAvg[1] / count)
|
sum.LoadAvg[1] = twoDecimals(sum.LoadAvg[1] / count)
|
||||||
sum.LoadAvg[2] = twoDecimals(sum.LoadAvg[2] / count)
|
sum.LoadAvg[2] = twoDecimals(sum.LoadAvg[2] / count)
|
||||||
sum.Bandwidth[0] = sum.Bandwidth[0] / uint64(count)
|
|
||||||
sum.Bandwidth[1] = sum.Bandwidth[1] / uint64(count)
|
|
||||||
sum.Battery[0] = uint8(batterySum / int(count))
|
sum.Battery[0] = uint8(batterySum / int(count))
|
||||||
|
|
||||||
|
if sum.NetworkInterfaces != nil {
|
||||||
|
for key := range sum.NetworkInterfaces {
|
||||||
|
ni := sum.NetworkInterfaces[key]
|
||||||
|
ni.NetworkSent = twoDecimals(ni.NetworkSent / count)
|
||||||
|
ni.NetworkRecv = twoDecimals(ni.NetworkRecv / count)
|
||||||
|
ni.MaxNetworkSent = twoDecimals(max(ni.MaxNetworkSent, ni.NetworkSent))
|
||||||
|
ni.MaxNetworkRecv = twoDecimals(max(ni.MaxNetworkRecv, ni.NetworkRecv))
|
||||||
|
sum.NetworkInterfaces[key] = ni
|
||||||
|
}
|
||||||
|
}
|
||||||
// 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 {
|
||||||
@@ -359,19 +409,15 @@ func (rm *RecordManager) AverageContainerStats(db dbx.Builder, records RecordIds
|
|||||||
}
|
}
|
||||||
sums[stat.Name].Cpu += stat.Cpu
|
sums[stat.Name].Cpu += stat.Cpu
|
||||||
sums[stat.Name].Mem += stat.Mem
|
sums[stat.Name].Mem += stat.Mem
|
||||||
sums[stat.Name].NetworkSent += stat.NetworkSent
|
|
||||||
sums[stat.Name].NetworkRecv += stat.NetworkRecv
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
result := make([]container.Stats, 0, len(sums))
|
result := make([]container.Stats, 0, len(sums))
|
||||||
for _, value := range sums {
|
for _, value := range sums {
|
||||||
result = append(result, container.Stats{
|
result = append(result, container.Stats{
|
||||||
Name: value.Name,
|
Name: value.Name,
|
||||||
Cpu: twoDecimals(value.Cpu / count),
|
Cpu: twoDecimals(value.Cpu / count),
|
||||||
Mem: twoDecimals(value.Mem / count),
|
Mem: twoDecimals(value.Mem / count),
|
||||||
NetworkSent: twoDecimals(value.NetworkSent / count),
|
|
||||||
NetworkRecv: twoDecimals(value.NetworkRecv / count),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
@@ -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"
|
||||||
41
internal/site/biome.json
Normal file
41
internal/site/biome.json
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://biomejs.dev/schemas/2.2.3/schema.json",
|
||||||
|
"vcs": {
|
||||||
|
"enabled": false,
|
||||||
|
"clientKind": "git",
|
||||||
|
"useIgnoreFile": false
|
||||||
|
},
|
||||||
|
"files": {
|
||||||
|
"ignoreUnknown": false
|
||||||
|
},
|
||||||
|
"formatter": {
|
||||||
|
"enabled": true,
|
||||||
|
"indentStyle": "tab",
|
||||||
|
"indentWidth": 2,
|
||||||
|
"lineWidth": 120
|
||||||
|
},
|
||||||
|
"linter": {
|
||||||
|
"enabled": true,
|
||||||
|
"rules": {
|
||||||
|
"recommended": true,
|
||||||
|
"correctness": {
|
||||||
|
"useUniqueElementIds": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"javascript": {
|
||||||
|
"formatter": {
|
||||||
|
"quoteStyle": "double",
|
||||||
|
"semicolons": "asNeeded",
|
||||||
|
"trailingCommas": "es5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"assist": {
|
||||||
|
"enabled": true,
|
||||||
|
"actions": {
|
||||||
|
"source": {
|
||||||
|
"organizeImports": "on"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
internal/site/bun.lockb
Executable file
BIN
internal/site/bun.lockb
Executable file
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user