mirror of
https://github.com/henrygd/beszel.git
synced 2025-12-28 14:00:20 +00:00
Compare commits
47 Commits
ssr-system
...
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 |
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
|
||||
32
.github/workflows/docker-images.yml
vendored
32
.github/workflows/docker-images.yml
vendored
@@ -13,44 +13,44 @@ jobs:
|
||||
matrix:
|
||||
include:
|
||||
- image: henrygd/beszel
|
||||
context: ./beszel
|
||||
dockerfile: ./beszel/dockerfile_hub
|
||||
context: ./
|
||||
dockerfile: ./internal/dockerfile_hub
|
||||
registry: docker.io
|
||||
username_secret: DOCKERHUB_USERNAME
|
||||
password_secret: DOCKERHUB_TOKEN
|
||||
|
||||
- image: henrygd/beszel-agent
|
||||
context: ./beszel
|
||||
dockerfile: ./beszel/dockerfile_agent
|
||||
context: ./
|
||||
dockerfile: ./internal/dockerfile_agent
|
||||
registry: docker.io
|
||||
username_secret: DOCKERHUB_USERNAME
|
||||
password_secret: DOCKERHUB_TOKEN
|
||||
|
||||
- image: henrygd/beszel-agent-nvidia
|
||||
context: ./beszel
|
||||
dockerfile: ./beszel/dockerfile_agent_nvidia
|
||||
context: ./
|
||||
dockerfile: ./internal/dockerfile_agent_nvidia
|
||||
platforms: linux/amd64
|
||||
registry: docker.io
|
||||
username_secret: DOCKERHUB_USERNAME
|
||||
password_secret: DOCKERHUB_TOKEN
|
||||
|
||||
- image: ghcr.io/${{ github.repository }}/beszel
|
||||
context: ./beszel
|
||||
dockerfile: ./beszel/dockerfile_hub
|
||||
context: ./
|
||||
dockerfile: ./internal/dockerfile_hub
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password_secret: GITHUB_TOKEN
|
||||
|
||||
- image: ghcr.io/${{ github.repository }}/beszel-agent
|
||||
context: ./beszel
|
||||
dockerfile: ./beszel/dockerfile_agent
|
||||
context: ./
|
||||
dockerfile: ./internal/dockerfile_agent
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password_secret: GITHUB_TOKEN
|
||||
|
||||
- image: ghcr.io/${{ github.repository }}/beszel-agent-nvidia
|
||||
context: ./beszel
|
||||
dockerfile: ./beszel/dockerfile_agent_nvidia
|
||||
context: ./
|
||||
dockerfile: ./internal/dockerfile_agent_nvidia
|
||||
platforms: linux/amd64
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -68,10 +68,10 @@ jobs:
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --no-save --cwd ./beszel/site
|
||||
run: bun install --no-save --cwd ./internal/site
|
||||
|
||||
- name: Build site
|
||||
run: bun run --cwd ./beszel/site build
|
||||
run: bun run --cwd ./internal/site build
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
@@ -93,8 +93,8 @@ jobs:
|
||||
|
||||
# https://github.com/docker/login-action
|
||||
- name: Login to Docker Hub
|
||||
env:
|
||||
password_secret_exists: ${{ secrets[matrix.password_secret] != '' && 'true' || 'false' }}
|
||||
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
|
||||
with:
|
||||
|
||||
8
.github/workflows/release.yml
vendored
8
.github/workflows/release.yml
vendored
@@ -21,10 +21,10 @@ jobs:
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --no-save --cwd ./beszel/site
|
||||
run: bun install --no-save --cwd ./internal/site
|
||||
|
||||
- name: Build site
|
||||
run: bun run --cwd ./beszel/site build
|
||||
run: bun run --cwd ./internal/site build
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
@@ -38,13 +38,13 @@ jobs:
|
||||
|
||||
- name: Build .NET LHM executable for Windows sensors
|
||||
run: |
|
||||
dotnet build -c Release ./beszel/internal/agent/lhm/beszel_lhm.csproj
|
||||
dotnet build -c Release ./agent/lhm/beszel_lhm.csproj
|
||||
shell: bash
|
||||
|
||||
- name: GoReleaser beszel
|
||||
uses: goreleaser/goreleaser-action@v6
|
||||
with:
|
||||
workdir: ./beszel
|
||||
workdir: ./
|
||||
distribution: goreleaser
|
||||
version: latest
|
||||
args: release --clean
|
||||
|
||||
8
.github/workflows/vulncheck.yml
vendored
8
.github/workflows/vulncheck.yml
vendored
@@ -15,7 +15,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
vulncheck:
|
||||
name: Analysis
|
||||
name: VulnCheck
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out code into the Go module directory
|
||||
@@ -23,11 +23,11 @@ jobs:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: 1.24.x
|
||||
cached: false
|
||||
go-version: 1.25.x
|
||||
# cached: false
|
||||
- name: Get official govulncheck
|
||||
run: go install golang.org/x/vuln/cmd/govulncheck@latest
|
||||
shell: bash
|
||||
- name: Run govulncheck
|
||||
run: govulncheck -C ./beszel -show verbose ./...
|
||||
run: govulncheck -show verbose ./...
|
||||
shell: bash
|
||||
|
||||
13
.gitignore
vendored
13
.gitignore
vendored
@@ -8,15 +8,16 @@ beszel_data
|
||||
beszel_data*
|
||||
dist
|
||||
*.exe
|
||||
beszel/cmd/hub/hub
|
||||
beszel/cmd/agent/agent
|
||||
internal/cmd/hub/hub
|
||||
internal/cmd/agent/agent
|
||||
node_modules
|
||||
beszel/build
|
||||
build
|
||||
*timestamp*
|
||||
.swc
|
||||
beszel/site/src/locales/**/*.ts
|
||||
internal/site/src/locales/**/*.ts
|
||||
*.bak
|
||||
__debug_*
|
||||
beszel/internal/agent/lhm/obj
|
||||
beszel/internal/agent/lhm/bin
|
||||
agent/lhm/obj
|
||||
agent/lhm/bin
|
||||
dockerfile_agent_dev
|
||||
.vite
|
||||
@@ -9,7 +9,7 @@ before:
|
||||
builds:
|
||||
- id: beszel
|
||||
binary: beszel
|
||||
main: cmd/hub/hub.go
|
||||
main: internal/cmd/hub/hub.go
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
goos:
|
||||
@@ -22,7 +22,7 @@ builds:
|
||||
|
||||
- id: beszel-agent
|
||||
binary: beszel-agent
|
||||
main: cmd/agent/agent.go
|
||||
main: internal/cmd/agent/agent.go
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
goos:
|
||||
@@ -103,28 +103,28 @@ nfpms:
|
||||
formats:
|
||||
- deb
|
||||
contents:
|
||||
- src: ../supplemental/debian/beszel-agent.service
|
||||
- src: ./supplemental/debian/beszel-agent.service
|
||||
dst: lib/systemd/system/beszel-agent.service
|
||||
packager: deb
|
||||
- src: ../supplemental/debian/copyright
|
||||
- src: ./supplemental/debian/copyright
|
||||
dst: usr/share/doc/beszel-agent/copyright
|
||||
packager: deb
|
||||
- src: ../supplemental/debian/lintian-overrides
|
||||
- src: ./supplemental/debian/lintian-overrides
|
||||
dst: usr/share/lintian/overrides/beszel-agent
|
||||
packager: deb
|
||||
scripts:
|
||||
postinstall: ../supplemental/debian/postinstall.sh
|
||||
preremove: ../supplemental/debian/prerm.sh
|
||||
postremove: ../supplemental/debian/postrm.sh
|
||||
postinstall: ./supplemental/debian/postinstall.sh
|
||||
preremove: ./supplemental/debian/prerm.sh
|
||||
postremove: ./supplemental/debian/postrm.sh
|
||||
deb:
|
||||
predepends:
|
||||
- adduser
|
||||
- debconf
|
||||
scripts:
|
||||
templates: ../supplemental/debian/templates
|
||||
templates: ./supplemental/debian/templates
|
||||
# Currently broken due to a bug in goreleaser
|
||||
# https://github.com/goreleaser/goreleaser/issues/5487
|
||||
#config: ../supplemental/debian/config.sh
|
||||
#config: ./supplemental/debian/config.sh
|
||||
|
||||
scoops:
|
||||
- ids: [beszel-agent]
|
||||
@@ -135,7 +135,7 @@ scoops:
|
||||
homepage: "https://beszel.dev"
|
||||
description: "Agent for Beszel, a lightweight server monitoring platform."
|
||||
license: MIT
|
||||
skip_upload: "{{ if .Env.IS_FORK }}true{{ else }}auto{{ end }}"
|
||||
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 :(
|
||||
# chocolateys:
|
||||
@@ -169,7 +169,7 @@ brews:
|
||||
homepage: "https://beszel.dev"
|
||||
description: "Agent for Beszel, a lightweight server monitoring platform."
|
||||
license: MIT
|
||||
skip_upload: "{{ if .Env.IS_FORK }}true{{ else }}auto{{ end }}"
|
||||
skip_upload: '{{ if eq (tolower .Env.IS_FORK) "true" }}true{{ else }}auto{{ end }}'
|
||||
extra_install: |
|
||||
(bin/"beszel-agent-launcher").write <<~EOS
|
||||
#!/bin/bash
|
||||
@@ -201,7 +201,7 @@ winget:
|
||||
release_notes_url: "https://github.com/henrygd/beszel/releases/tag/v{{ .Version }}"
|
||||
publisher_support_url: "https://github.com/henrygd/beszel/issues"
|
||||
short_description: "Agent for Beszel, a lightweight server monitoring platform."
|
||||
skip_upload: "{{ if .Env.IS_FORK }}true{{ else }}auto{{ end }}"
|
||||
skip_upload: '{{ if eq (tolower .Env.IS_FORK) "true" }}true{{ else }}auto{{ end }}'
|
||||
description: |
|
||||
Beszel is a lightweight server monitoring platform that includes Docker
|
||||
statistics, historical data, and alert functions. It has a friendly web
|
||||
@@ -26,11 +26,11 @@ tidy:
|
||||
|
||||
build-web-ui:
|
||||
@if command -v bun >/dev/null 2>&1; then \
|
||||
bun install --cwd ./site && \
|
||||
bun run --cwd ./site build; \
|
||||
bun install --cwd ./internal/site && \
|
||||
bun run --cwd ./internal/site build; \
|
||||
else \
|
||||
npm install --prefix ./site && \
|
||||
npm run --prefix ./site build; \
|
||||
npm install --prefix ./internal/site && \
|
||||
npm run --prefix ./internal/site build; \
|
||||
fi
|
||||
|
||||
# Conditional .NET build - only for Windows
|
||||
@@ -38,8 +38,8 @@ 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; \
|
||||
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; \
|
||||
@@ -48,51 +48,51 @@ build-dotnet-conditional:
|
||||
|
||||
# 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
|
||||
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" beszel/cmd/hub
|
||||
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 ./site/dist && touch ./site/dist/index.html
|
||||
GOOS=$(OS) GOARCH=$(ARCH) go build -tags development -o ./build/beszel-dev_$(OS)_$(ARCH)$(EXE_EXT) -ldflags "-w -s" beszel/cmd/hub
|
||||
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 ./site/src/locales/en/en.ts ]; then \
|
||||
@if [ ! -f ./internal/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; \
|
||||
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 ./site
|
||||
cd ./internal/site
|
||||
@if command -v bun >/dev/null 2>&1; then \
|
||||
cd ./site && bun run dev --host 0.0.0.0; \
|
||||
cd ./internal/site && bun run dev --host 0.0.0.0; \
|
||||
else \
|
||||
cd ./site && npm run dev --host 0.0.0.0; \
|
||||
cd ./internal/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
|
||||
mkdir -p ./internal/site/dist && touch ./internal/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 -tags development . serve --http 0.0.0.0:8090"; \
|
||||
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 ./cmd/hub && go run -tags development . serve --http 0.0.0.0:8090; \
|
||||
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 ./cmd/agent/*.go ./internal/agent/*.go | entr -r go run beszel/cmd/agent; \
|
||||
find ./internal/cmd/agent/*.go ./agent/*.go | entr -r go run github.com/henrygd/beszel/internal/cmd/agent; \
|
||||
else \
|
||||
go run beszel/cmd/agent; \
|
||||
go run github.com/henrygd/beszel/internal/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; \
|
||||
rm -rf ./agent/lhm/bin; \
|
||||
dotnet build -c Release ./agent/lhm/beszel_lhm.csproj; \
|
||||
else \
|
||||
echo "dotnet not found"; \
|
||||
fi
|
||||
@@ -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
|
||||
|
||||
import (
|
||||
"beszel"
|
||||
"beszel/internal/entities/system"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"log/slog"
|
||||
@@ -14,28 +15,30 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gliderlabs/ssh"
|
||||
"github.com/henrygd/beszel"
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
"github.com/shirou/gopsutil/v4/host"
|
||||
gossh "golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
type Agent struct {
|
||||
sync.Mutex // Used to lock agent while collecting data
|
||||
debug bool // true if LOG_LEVEL is set to debug
|
||||
zfs bool // true if system has arcstats
|
||||
memCalc string // Memory calculation formula
|
||||
fsNames []string // List of filesystem device names being monitored
|
||||
fsStats map[string]*system.FsStats // Keeps track of disk stats for each filesystem
|
||||
netInterfaces map[string]struct{} // Stores all valid network interfaces
|
||||
netIoStats system.NetIoStats // Keeps track of bandwidth usage
|
||||
dockerManager *dockerManager // Manages Docker API requests
|
||||
sensorConfig *SensorConfig // Sensors config
|
||||
systemInfo system.Info // Host system info
|
||||
gpuManager *GPUManager // Manages GPU data
|
||||
cache *SessionCache // Cache for system stats based on primary session ID
|
||||
connectionManager *ConnectionManager // Channel to signal connection events
|
||||
server *ssh.Server // SSH server
|
||||
dataDir string // Directory for persisting data
|
||||
keys []gossh.PublicKey // SSH public keys
|
||||
sync.Mutex // Used to lock agent while collecting data
|
||||
debug bool // true if LOG_LEVEL is set to debug
|
||||
zfs bool // true if system has arcstats
|
||||
memCalc string // Memory calculation formula
|
||||
fsNames []string // List of filesystem device names being monitored
|
||||
fsStats map[string]*system.FsStats // Keeps track of disk stats for each filesystem
|
||||
netInterfaces map[string]struct{} // Stores all valid network interfaces
|
||||
netIoStats map[string]system.NetIoStats // Keeps track of per-interface bandwidth usage
|
||||
dockerManager *dockerManager // Manages Docker API requests
|
||||
sensorConfig *SensorConfig // Sensors config
|
||||
systemInfo system.Info // Host system info
|
||||
gpuManager *GPUManager // Manages GPU data
|
||||
cache *SessionCache // Cache for system stats based on primary session ID
|
||||
connectionManager *ConnectionManager // Channel to signal connection events
|
||||
server *ssh.Server // SSH server
|
||||
dataDir string // Directory for persisting data
|
||||
keys []gossh.PublicKey // SSH public keys
|
||||
}
|
||||
|
||||
// NewAgent creates a new agent with the given data directory for persisting data.
|
||||
@@ -1,8 +1,9 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"beszel/internal/entities/system"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
)
|
||||
|
||||
// Not thread safe since we only access from gatherStats which is already locked
|
||||
@@ -4,11 +4,12 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"beszel/internal/entities/system"
|
||||
"testing"
|
||||
"testing/synctest"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -1,8 +1,6 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"beszel"
|
||||
"beszel/internal/common"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -15,6 +13,9 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel"
|
||||
"github.com/henrygd/beszel/internal/common"
|
||||
|
||||
"github.com/fxamacker/cbor/v2"
|
||||
"github.com/lxzan/gws"
|
||||
"golang.org/x/crypto/ssh"
|
||||
@@ -84,7 +85,7 @@ func getToken() (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(tokenBytes), nil
|
||||
return strings.TrimSpace(string(tokenBytes)), nil
|
||||
}
|
||||
|
||||
// getOptions returns the WebSocket client options, creating them if necessary.
|
||||
@@ -4,8 +4,6 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"beszel"
|
||||
"beszel/internal/common"
|
||||
"crypto/ed25519"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -13,6 +11,10 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel"
|
||||
|
||||
"github.com/henrygd/beszel/internal/common"
|
||||
|
||||
"github.com/fxamacker/cbor/v2"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -535,4 +537,25 @@ func TestGetToken(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
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
|
||||
|
||||
import (
|
||||
"beszel/internal/agent/health"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/agent/health"
|
||||
)
|
||||
|
||||
// ConnectionManager manages the connection state and events for the agent.
|
||||
@@ -1,7 +1,6 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"beszel/internal/entities/system"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -9,6 +8,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
|
||||
"github.com/shirou/gopsutil/v4/disk"
|
||||
)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"beszel/internal/entities/container"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
@@ -15,6 +14,8 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/internal/entities/container"
|
||||
|
||||
"github.com/blang/semver"
|
||||
)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"beszel/internal/entities/system"
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
@@ -13,6 +12,8 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
|
||||
"golang.org/x/exp/slog"
|
||||
)
|
||||
|
||||
@@ -4,12 +4,13 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"beszel/internal/entities/system"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -5,12 +5,16 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
|
||||
psutilNet "github.com/shirou/gopsutil/v4/net"
|
||||
)
|
||||
|
||||
func (a *Agent) initializeNetIoStats() {
|
||||
// reset valid network interfaces
|
||||
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
|
||||
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
|
||||
if netIO, err := psutilNet.IOCounters(true); err == nil {
|
||||
a.netIoStats.Time = time.Now()
|
||||
now := time.Now()
|
||||
|
||||
for _, v := range netIO {
|
||||
switch {
|
||||
// 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)
|
||||
a.netIoStats.BytesSent += v.BytesSent
|
||||
a.netIoStats.BytesRecv += v.BytesRecv
|
||||
// store as a valid network interface
|
||||
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
|
||||
|
||||
import (
|
||||
"beszel/internal/entities/system"
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
@@ -11,6 +10,8 @@ import (
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
|
||||
"github.com/shirou/gopsutil/v4/common"
|
||||
"github.com/shirou/gopsutil/v4/sensors"
|
||||
)
|
||||
@@ -4,12 +4,13 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"beszel/internal/entities/system"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
|
||||
"github.com/shirou/gopsutil/v4/common"
|
||||
"github.com/shirou/gopsutil/v4/sensors"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -46,9 +46,10 @@ var lhmFs embed.FS
|
||||
var (
|
||||
beszelLhm *lhmProcess
|
||||
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.
|
||||
func newlhmProcess() (*lhmProcess, error) {
|
||||
@@ -139,7 +140,7 @@ func (lhm *lhmProcess) cleanupProcess() {
|
||||
}
|
||||
|
||||
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
|
||||
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
|
||||
beszelLhmOnce.Do(func() {
|
||||
beszelLhm, err = newlhmProcess()
|
||||
@@ -1,9 +1,6 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"beszel"
|
||||
"beszel/internal/common"
|
||||
"beszel/internal/entities/system"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -14,6 +11,10 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel"
|
||||
"github.com/henrygd/beszel/internal/common"
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
|
||||
"github.com/blang/semver"
|
||||
"github.com/fxamacker/cbor/v2"
|
||||
"github.com/gliderlabs/ssh"
|
||||
@@ -1,8 +1,6 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"beszel/internal/entities/container"
|
||||
"beszel/internal/entities/system"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"encoding/json"
|
||||
@@ -15,6 +13,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/internal/entities/container"
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
|
||||
"github.com/blang/semver"
|
||||
"github.com/fxamacker/cbor/v2"
|
||||
"github.com/gliderlabs/ssh"
|
||||
@@ -1,9 +1,6 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"beszel"
|
||||
"beszel/internal/agent/battery"
|
||||
"beszel/internal/entities/system"
|
||||
"bufio"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
@@ -12,6 +9,10 @@ import (
|
||||
"strings"
|
||||
"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/disk"
|
||||
"github.com/shirou/gopsutil/v4/host"
|
||||
@@ -175,53 +176,85 @@ func (a *Agent) getSystemStats() system.Stats {
|
||||
if len(a.netInterfaces) == 0 {
|
||||
// if no network interfaces, initialize again
|
||||
// this is a fix if agent started before network is online (#466)
|
||||
// maybe refactor this in the future to not cache interface names at all so we
|
||||
// don't miss an interface that's been added after agent started in any circumstance
|
||||
a.initializeNetIoStats()
|
||||
}
|
||||
if netIO, err := psutilNet.IOCounters(true); err == nil {
|
||||
msElapsed := uint64(time.Since(a.netIoStats.Time).Milliseconds())
|
||||
a.netIoStats.Time = time.Now()
|
||||
totalBytesSent := uint64(0)
|
||||
totalBytesRecv := uint64(0)
|
||||
// sum all bytes sent and received
|
||||
now := time.Now()
|
||||
|
||||
// pre-allocate maps with known capacity
|
||||
interfaceCount := len(a.netInterfaces)
|
||||
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 {
|
||||
// skip if not in valid network interfaces list
|
||||
if _, exists := a.netInterfaces[v.Name]; !exists {
|
||||
continue
|
||||
}
|
||||
totalBytesSent += v.BytesSent
|
||||
totalBytesRecv += v.BytesRecv
|
||||
}
|
||||
// add to systemStats
|
||||
var bytesSentPerSecond, bytesRecvPerSecond uint64
|
||||
if msElapsed > 0 {
|
||||
bytesSentPerSecond = (totalBytesSent - a.netIoStats.BytesSent) * 1000 / msElapsed
|
||||
bytesRecvPerSecond = (totalBytesRecv - a.netIoStats.BytesRecv) * 1000 / msElapsed
|
||||
}
|
||||
networkSentPs := bytesToMegabytes(float64(bytesSentPerSecond))
|
||||
networkRecvPs := bytesToMegabytes(float64(bytesRecvPerSecond))
|
||||
// add check for issue (#150) where sent is a massive number
|
||||
if networkSentPs > 10_000 || networkRecvPs > 10_000 {
|
||||
slog.Warn("Invalid net stats. Resetting.", "sent", networkSentPs, "recv", networkRecvPs)
|
||||
for _, v := range netIO {
|
||||
if _, exists := a.netInterfaces[v.Name]; !exists {
|
||||
continue
|
||||
|
||||
// get previous stats for this interface
|
||||
prevStats, exists := a.netIoStats[v.Name]
|
||||
var networkSentPs, networkRecvPs float64
|
||||
|
||||
if exists {
|
||||
secondsElapsed := time.Since(prevStats.Time).Seconds()
|
||||
if secondsElapsed > 0 {
|
||||
// direct calculation to MB/s, avoiding intermediate bytes/sec
|
||||
networkSentPs = bytesToMegabytes(float64(v.BytesSent-prevStats.BytesSent) / secondsElapsed)
|
||||
networkRecvPs = bytesToMegabytes(float64(v.BytesRecv-prevStats.BytesRecv) / secondsElapsed)
|
||||
}
|
||||
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
|
||||
a.initializeNetIoStats()
|
||||
} else {
|
||||
systemStats.NetworkSent = networkSentPs
|
||||
systemStats.NetworkRecv = networkRecvPs
|
||||
systemStats.Bandwidth[0], systemStats.Bandwidth[1] = bytesSentPerSecond, bytesRecvPerSecond
|
||||
// update netIoStats
|
||||
a.netIoStats.BytesSent = totalBytesSent
|
||||
a.netIoStats.BytesRecv = totalBytesRecv
|
||||
systemStats.NetworkSent = totalSent
|
||||
systemStats.NetworkRecv = totalRecv
|
||||
}
|
||||
}
|
||||
|
||||
// connection counts
|
||||
a.updateConnectionCounts(&systemStats)
|
||||
|
||||
// temperatures
|
||||
// TODO: maybe refactor to methods on systemStats
|
||||
a.updateTemperatures(&systemStats)
|
||||
@@ -269,14 +302,109 @@ func (a *Agent) getSystemStats() system.Stats {
|
||||
a.systemInfo.MemPct = systemStats.MemPct
|
||||
a.systemInfo.DiskPct = systemStats.DiskPct
|
||||
a.systemInfo.Uptime, _ = host.Uptime()
|
||||
// TODO: in future release, remove MB bandwidth values in favor of bytes
|
||||
a.systemInfo.Bandwidth = twoDecimals(systemStats.NetworkSent + systemStats.NetworkRecv)
|
||||
a.systemInfo.BandwidthBytes = systemStats.Bandwidth[0] + systemStats.Bandwidth[1]
|
||||
|
||||
// Sum all per-interface network sent/recv and assign to systemInfo
|
||||
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)
|
||||
|
||||
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
|
||||
func getARCSize() (uint64, error) {
|
||||
file, err := os.Open("/proc/spl/kstat/zfs/arcstats")
|
||||
@@ -1,12 +1,14 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"beszel/internal/ghupdate"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/henrygd/beszel/internal/ghupdate"
|
||||
)
|
||||
|
||||
// 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()
|
||||
}
|
||||
|
||||
type freeBSDRestarter struct{ cmd string }
|
||||
|
||||
func (f *freeBSDRestarter) Restart() error {
|
||||
if err := exec.Command(f.cmd, "beszel-agent", "status").Run(); err != nil {
|
||||
return nil
|
||||
}
|
||||
ghupdate.ColorPrint(ghupdate.ColorYellow, "Restarting beszel-agent via FreeBSD rc…")
|
||||
return exec.Command(f.cmd, "beszel-agent", "restart").Run()
|
||||
}
|
||||
|
||||
func detectRestarter() restarter {
|
||||
if path, err := exec.LookPath("systemctl"); err == nil {
|
||||
return &systemdRestarter{cmd: path}
|
||||
@@ -53,6 +65,9 @@ func detectRestarter() restarter {
|
||||
return &openRCRestarter{cmd: path}
|
||||
}
|
||||
if path, err := exec.LookPath("service"); err == nil {
|
||||
if runtime.GOOS == "freebsd" {
|
||||
return &freeBSDRestarter{cmd: path}
|
||||
}
|
||||
return &openWRTRestarter{cmd: path}
|
||||
}
|
||||
return nil
|
||||
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,114 +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]
|
||||
MaxMem float64 `json:"mm,omitempty" cbor:"30,keyasint,omitempty"`
|
||||
}
|
||||
|
||||
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 @@
|
||||
//go:build development
|
||||
|
||||
package hub
|
||||
|
||||
import (
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
// startServer sets up the development server for Beszel
|
||||
func (h *Hub) startServer(se *core.ServeEvent) error {
|
||||
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
|
||||
})
|
||||
return nil
|
||||
}
|
||||
@@ -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,24 +0,0 @@
|
||||
import { Toast, ToastClose, ToastDescription, ToastProvider, ToastTitle, ToastViewport } from "@/components/ui/toast"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
|
||||
export function Toaster() {
|
||||
const { toasts } = useToast()
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||
return (
|
||||
<Toast key={id} {...props}>
|
||||
<div className="grid gap-1">
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
{description && <ToastDescription>{description}</ToastDescription>}
|
||||
</div>
|
||||
{action}
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
)
|
||||
})}
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
package beszel
|
||||
|
||||
import "github.com/blang/semver"
|
||||
|
||||
const (
|
||||
Version = "0.12.6"
|
||||
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
|
||||
replace github.com/nicholas-fedor/shoutrrr => github.com/nicholas-fedor/shoutrrr v0.8.8
|
||||
4
i18n.yml
4
i18n.yml
@@ -1,3 +1,3 @@
|
||||
files:
|
||||
- source: /beszel/site/src/locales/en/en.po
|
||||
translation: /beszel/site/src/locales/%two_letters_code%/%two_letters_code%.po
|
||||
- source: /internal/site/src/locales/en/
|
||||
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 {
|
||||
am := &AlertManager{
|
||||
hub: app,
|
||||
alertQueue: make(chan alertTask),
|
||||
alertQueue: make(chan alertTask, 5),
|
||||
stopChan: make(chan struct{}),
|
||||
}
|
||||
am.bindEvents()
|
||||
@@ -42,21 +42,10 @@ func updateHistoryOnAlertUpdate(e *core.RecordEvent) error {
|
||||
|
||||
// resolveAlertHistoryRecord sets the resolved field to the current time
|
||||
func resolveAlertHistoryRecord(app core.App, alertRecordID string) error {
|
||||
alertHistoryRecords, err := app.FindRecordsByFilter(
|
||||
"alerts_history",
|
||||
"alert_id={:alert_id} && resolved=null",
|
||||
"-created",
|
||||
1,
|
||||
0,
|
||||
dbx.Params{"alert_id": alertRecordID},
|
||||
)
|
||||
if err != nil {
|
||||
alertHistoryRecord, err := app.FindFirstRecordByFilter("alerts_history", "alert_id={:alert_id} && resolved=null", dbx.Params{"alert_id": alertRecordID})
|
||||
if err != nil || alertHistoryRecord == nil {
|
||||
return err
|
||||
}
|
||||
if len(alertHistoryRecords) == 0 {
|
||||
return nil
|
||||
}
|
||||
alertHistoryRecord := alertHistoryRecords[0] // there should be only one record
|
||||
alertHistoryRecord.Set("resolved", time.Now().UTC())
|
||||
err = app.Save(alertHistoryRecord)
|
||||
if err != nil {
|
||||
@@ -1,12 +1,13 @@
|
||||
package alerts
|
||||
|
||||
import (
|
||||
"beszel/internal/entities/system"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
@@ -37,7 +38,7 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
|
||||
case "Memory":
|
||||
val = data.Info.MemPct
|
||||
case "Bandwidth":
|
||||
val = data.Info.Bandwidth
|
||||
val = data.Info.NetworkSent + data.Info.NetworkRecv
|
||||
unit = " MB/s"
|
||||
case "Disk":
|
||||
maxUsedPct := data.Info.DiskPct
|
||||
@@ -10,8 +10,10 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/synctest"
|
||||
"time"
|
||||
|
||||
beszelTests "beszel/internal/tests"
|
||||
beszelTests "github.com/henrygd/beszel/internal/tests"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
@@ -63,14 +65,14 @@ func TestUserAlertsApi(t *testing.T) {
|
||||
}
|
||||
|
||||
scenarios := []beszelTests.ApiScenario{
|
||||
{
|
||||
Name: "GET not implemented - returns index",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/beszel/user-alerts",
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"<html ", "globalThis.BESZEL"},
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
// {
|
||||
// Name: "GET not implemented - returns index",
|
||||
// Method: http.MethodGet,
|
||||
// URL: "/api/beszel/user-alerts",
|
||||
// ExpectedStatus: 200,
|
||||
// ExpectedContent: []string{"<html ", "globalThis.BESZEL"},
|
||||
// TestAppFactory: testAppFactory,
|
||||
// },
|
||||
{
|
||||
Name: "POST no auth",
|
||||
Method: http.MethodPost,
|
||||
@@ -366,3 +368,237 @@ func TestUserAlertsApi(t *testing.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,14 +1,14 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"beszel"
|
||||
"beszel/internal/agent"
|
||||
"beszel/internal/agent/health"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"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"
|
||||
)
|
||||
@@ -1,12 +1,13 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"beszel/internal/agent"
|
||||
"crypto/ed25519"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/henrygd/beszel/agent"
|
||||
|
||||
"github.com/spf13/pflag"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -1,15 +1,16 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"beszel"
|
||||
"beszel/internal/hub"
|
||||
_ "beszel/migrations"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"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/plugins/migratecmd"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -2,15 +2,15 @@ FROM --platform=$BUILDPLATFORM golang:alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
# RUN go mod download
|
||||
COPY *.go ./
|
||||
COPY cmd ./cmd
|
||||
COPY internal ./internal
|
||||
COPY ../go.mod ../go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
# Copy source files
|
||||
COPY . ./
|
||||
|
||||
# Build
|
||||
ARG TARGETOS TARGETARCH
|
||||
RUN CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /agent ./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/*
|
||||
|
||||
@@ -3,16 +3,11 @@ FROM --platform=$BUILDPLATFORM golang:alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
# Download Go modules
|
||||
COPY go.mod go.sum ./
|
||||
COPY ../go.mod ../go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
# Copy source files
|
||||
COPY *.go ./
|
||||
COPY cmd ./cmd
|
||||
COPY internal ./internal
|
||||
COPY migrations ./migrations
|
||||
COPY site/dist ./site/dist
|
||||
COPY site/*.go ./site
|
||||
COPY . ./
|
||||
|
||||
RUN apk add --no-cache \
|
||||
unzip \
|
||||
@@ -22,7 +17,7 @@ RUN update-ca-certificates
|
||||
|
||||
# Build
|
||||
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
|
||||
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"`
|
||||
}
|
||||
@@ -4,7 +4,6 @@
|
||||
package ghupdate
|
||||
|
||||
import (
|
||||
"beszel"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -16,6 +15,8 @@ import (
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/henrygd/beszel"
|
||||
|
||||
"github.com/blang/semver"
|
||||
)
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
package hub
|
||||
|
||||
import (
|
||||
"beszel/internal/common"
|
||||
"beszel/internal/hub/expirymap"
|
||||
"beszel/internal/hub/ws"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -11,6 +8,10 @@ import (
|
||||
"sync"
|
||||
"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/lxzan/gws"
|
||||
"github.com/pocketbase/dbx"
|
||||
@@ -4,9 +4,6 @@
|
||||
package hub
|
||||
|
||||
import (
|
||||
"beszel/internal/agent"
|
||||
"beszel/internal/common"
|
||||
"beszel/internal/hub/ws"
|
||||
"crypto/ed25519"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -17,6 +14,10 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/agent"
|
||||
"github.com/henrygd/beszel/internal/common"
|
||||
"github.com/henrygd/beszel/internal/hub/ws"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
pbtests "github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -2,13 +2,14 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"beszel/internal/entities/system"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/spf13/cast"
|
||||
@@ -4,12 +4,14 @@
|
||||
package config_test
|
||||
|
||||
import (
|
||||
"beszel/internal/hub/config"
|
||||
"beszel/internal/tests"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/henrygd/beszel/internal/tests"
|
||||
|
||||
"github.com/henrygd/beszel/internal/hub/config"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -2,12 +2,6 @@
|
||||
package hub
|
||||
|
||||
import (
|
||||
"beszel"
|
||||
"beszel/internal/alerts"
|
||||
"beszel/internal/hub/config"
|
||||
"beszel/internal/hub/systems"
|
||||
"beszel/internal/records"
|
||||
"beszel/internal/users"
|
||||
"crypto/ed25519"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
@@ -18,6 +12,13 @@ import (
|
||||
"strings"
|
||||
"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/pocketbase/pocketbase"
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
@@ -68,6 +69,8 @@ func (h *Hub) StartHub() error {
|
||||
if err := config.SyncSystems(e); err != nil {
|
||||
return err
|
||||
}
|
||||
// register middlewares
|
||||
h.registerMiddlewares(e)
|
||||
// register api routes
|
||||
if err := h.registerApiRoutes(e); err != nil {
|
||||
return err
|
||||
@@ -170,6 +173,37 @@ func (h *Hub) registerCronJobs(_ *core.ServeEvent) error {
|
||||
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
|
||||
func (h *Hub) registerApiRoutes(se *core.ServeEvent) error {
|
||||
// auth protected routes
|
||||
@@ -4,9 +4,6 @@
|
||||
package hub_test
|
||||
|
||||
import (
|
||||
beszelTests "beszel/internal/tests"
|
||||
"testing"
|
||||
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
"encoding/json"
|
||||
@@ -16,6 +13,10 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/henrygd/beszel/internal/migrations"
|
||||
beszelTests "github.com/henrygd/beszel/internal/tests"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
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) {
|
||||
t.Run("CreateUserEndpoint available when no users exist", func(t *testing.T) {
|
||||
hub, _ := beszelTests.NewTestHub(t.TempDir())
|
||||
@@ -601,3 +711,117 @@ func TestCreateUserEndpointAvailability(t *testing.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
|
||||
|
||||
import "beszel/internal/hub/systems"
|
||||
import "github.com/henrygd/beszel/internal/hub/systems"
|
||||
|
||||
// TESTING ONLY: GetSystemManager returns the system manager
|
||||
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
|
||||
}
|
||||
@@ -3,13 +3,14 @@
|
||||
package hub
|
||||
|
||||
import (
|
||||
"beszel"
|
||||
"beszel/site"
|
||||
"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"
|
||||
)
|
||||
@@ -24,9 +25,9 @@ func (h *Hub) startServer(se *core.ServeEvent) error {
|
||||
// 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)
|
||||
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)
|
||||
@@ -45,7 +46,7 @@ func (h *Hub) startServer(se *core.ServeEvent) error {
|
||||
e.Response.Header().Del("X-Frame-Options")
|
||||
e.Response.Header().Set("Content-Security-Policy", csp)
|
||||
}
|
||||
return e.HTML(http.StatusOK, indexContent)
|
||||
return e.HTML(http.StatusOK, html)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
@@ -1,9 +1,6 @@
|
||||
package systems
|
||||
|
||||
import (
|
||||
"beszel"
|
||||
"beszel/internal/entities/system"
|
||||
"beszel/internal/hub/ws"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -13,6 +10,12 @@ import (
|
||||
"strings"
|
||||
"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/fxamacker/cbor/v2"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
@@ -1,14 +1,18 @@
|
||||
package systems
|
||||
|
||||
import (
|
||||
"beszel"
|
||||
"beszel/internal/common"
|
||||
"beszel/internal/entities/system"
|
||||
"beszel/internal/hub/ws"
|
||||
"errors"
|
||||
"fmt"
|
||||
"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/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/store"
|
||||
@@ -30,10 +34,8 @@ const (
|
||||
sessionTimeout = 4 * time.Second
|
||||
)
|
||||
|
||||
var (
|
||||
// errSystemExists is returned when attempting to add a system that already exists
|
||||
errSystemExists = errors.New("system exists")
|
||||
)
|
||||
// errSystemExists is returned when attempting to add a system that already exists
|
||||
var errSystemExists = errors.New("system exists")
|
||||
|
||||
// SystemManager manages a collection of monitored systems and their connections.
|
||||
// It handles system lifecycle, status updates, and maintains both SSH and WebSocket connections.
|
||||
@@ -4,16 +4,17 @@
|
||||
package systems_test
|
||||
|
||||
import (
|
||||
"beszel/internal/entities/container"
|
||||
"beszel/internal/entities/system"
|
||||
"beszel/internal/hub/systems"
|
||||
"beszel/internal/tests"
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
"testing/synctest"
|
||||
"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/require"
|
||||
)
|
||||
@@ -4,9 +4,10 @@
|
||||
package systems
|
||||
|
||||
import (
|
||||
entities "beszel/internal/entities/system"
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
entities "github.com/henrygd/beszel/internal/entities/system"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// TESTING ONLY: RemoveAllSystems removes all systems from the store
|
||||
func (sm *SystemManager) RemoveAllSystems() {
|
||||
for _, system := range sm.systems.GetAll() {
|
||||
sm.RemoveSystem(system.Id)
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
package hub
|
||||
|
||||
import (
|
||||
"beszel/internal/ghupdate"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
|
||||
"github.com/henrygd/beszel/internal/ghupdate"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"beszel/internal/common"
|
||||
"beszel/internal/entities/system"
|
||||
"errors"
|
||||
"time"
|
||||
"weak"
|
||||
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
|
||||
"github.com/henrygd/beszel/internal/common"
|
||||
|
||||
"github.com/fxamacker/cbor/v2"
|
||||
"github.com/lxzan/gws"
|
||||
"golang.org/x/crypto/ssh"
|
||||
@@ -4,11 +4,12 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"beszel/internal/common"
|
||||
"crypto/ed25519"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/internal/common"
|
||||
|
||||
"github.com/fxamacker/cbor/v2"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"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
|
||||
|
||||
import (
|
||||
"beszel/internal/entities/container"
|
||||
"beszel/internal/entities/system"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
@@ -11,6 +9,9 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/internal/entities/container"
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
@@ -39,12 +40,14 @@ type StatsRecord struct {
|
||||
}
|
||||
|
||||
// global variables for reusing allocations
|
||||
var statsRecord StatsRecord
|
||||
var containerStats []container.Stats
|
||||
var sumStats system.Stats
|
||||
var tempStats system.Stats
|
||||
var queryParams = make(dbx.Params, 1)
|
||||
var containerSums = make(map[string]*container.Stats)
|
||||
var (
|
||||
statsRecord StatsRecord
|
||||
containerStats []container.Stats
|
||||
sumStats system.Stats
|
||||
tempStats system.Stats
|
||||
queryParams = make(dbx.Params, 1)
|
||||
containerSums = make(map[string]*container.Stats)
|
||||
)
|
||||
|
||||
// Create longer records by averaging shorter records
|
||||
func (rm *RecordManager) CreateLongerRecords() {
|
||||
@@ -203,15 +206,51 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
|
||||
sum.DiskPct += stats.DiskPct
|
||||
sum.DiskReadPs += stats.DiskReadPs
|
||||
sum.DiskWritePs += stats.DiskWritePs
|
||||
sum.LoadAvg1 += stats.LoadAvg1
|
||||
sum.LoadAvg5 += stats.LoadAvg5
|
||||
sum.LoadAvg15 += stats.LoadAvg15
|
||||
sum.NetworkSent += stats.NetworkSent
|
||||
sum.NetworkRecv += stats.NetworkRecv
|
||||
sum.LoadAvg[0] += stats.LoadAvg[0]
|
||||
sum.LoadAvg[1] += stats.LoadAvg[1]
|
||||
sum.LoadAvg[2] += stats.LoadAvg[2]
|
||||
sum.Bandwidth[0] += stats.Bandwidth[0]
|
||||
sum.Bandwidth[1] += stats.Bandwidth[1]
|
||||
batterySum += int(stats.Battery[0])
|
||||
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
|
||||
sum.MaxCpu = max(sum.MaxCpu, stats.MaxCpu, stats.Cpu)
|
||||
sum.MaxMem = max(sum.MaxMem, stats.MaxMem, stats.MemUsed)
|
||||
@@ -219,8 +258,6 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
|
||||
sum.MaxNetworkRecv = max(sum.MaxNetworkRecv, stats.MaxNetworkRecv, stats.NetworkRecv)
|
||||
sum.MaxDiskReadPs = max(sum.MaxDiskReadPs, stats.MaxDiskReadPs, stats.DiskReadPs)
|
||||
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
|
||||
if stats.Temperatures != nil {
|
||||
@@ -288,14 +325,26 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
|
||||
sum.DiskPct = twoDecimals(sum.DiskPct / count)
|
||||
sum.DiskReadPs = twoDecimals(sum.DiskReadPs / 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.NetworkRecv = twoDecimals(sum.NetworkRecv / count)
|
||||
sum.LoadAvg[0] = twoDecimals(sum.LoadAvg[0] / count)
|
||||
sum.LoadAvg[1] = twoDecimals(sum.LoadAvg[1] / 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))
|
||||
|
||||
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
|
||||
if sum.Temperatures != nil && tempCount > 0 {
|
||||
for key := range sum.Temperatures {
|
||||
@@ -360,19 +409,15 @@ func (rm *RecordManager) AverageContainerStats(db dbx.Builder, records RecordIds
|
||||
}
|
||||
sums[stat.Name].Cpu += stat.Cpu
|
||||
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))
|
||||
for _, value := range sums {
|
||||
result = append(result, container.Stats{
|
||||
Name: value.Name,
|
||||
Cpu: twoDecimals(value.Cpu / count),
|
||||
Mem: twoDecimals(value.Mem / count),
|
||||
NetworkSent: twoDecimals(value.NetworkSent / count),
|
||||
NetworkRecv: twoDecimals(value.NetworkRecv / count),
|
||||
Name: value.Name,
|
||||
Cpu: twoDecimals(value.Cpu / count),
|
||||
Mem: twoDecimals(value.Mem / count),
|
||||
})
|
||||
}
|
||||
return result
|
||||
@@ -4,12 +4,13 @@
|
||||
package records_test
|
||||
|
||||
import (
|
||||
"beszel/internal/records"
|
||||
"beszel/internal/tests"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/internal/records"
|
||||
"github.com/henrygd/beszel/internal/tests"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"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.
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user