mirror of
https://github.com/henrygd/beszel.git
synced 2026-03-22 05:36:15 +01:00
Compare commits
1 Commits
intel-gpu
...
ssr-system
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
605e3f7e9d |
@@ -1,48 +0,0 @@
|
|||||||
# Node.js dependencies
|
|
||||||
node_modules
|
|
||||||
internalsite/node_modules
|
|
||||||
|
|
||||||
# Go build artifacts and binaries
|
|
||||||
build
|
|
||||||
dist
|
|
||||||
*.exe
|
|
||||||
beszel-agent
|
|
||||||
beszel_data*
|
|
||||||
pb_data
|
|
||||||
data
|
|
||||||
temp
|
|
||||||
|
|
||||||
# Development and IDE files
|
|
||||||
.vscode
|
|
||||||
.idea*
|
|
||||||
*.swc
|
|
||||||
__debug_*
|
|
||||||
|
|
||||||
# Git and version control
|
|
||||||
.git
|
|
||||||
.gitignore
|
|
||||||
|
|
||||||
# Documentation and supplemental files
|
|
||||||
*.md
|
|
||||||
supplemental
|
|
||||||
freebsd-port
|
|
||||||
|
|
||||||
# Test files (exclude from production builds)
|
|
||||||
*_test.go
|
|
||||||
coverage
|
|
||||||
|
|
||||||
# Docker files
|
|
||||||
dockerfile_*
|
|
||||||
|
|
||||||
# Temporary files
|
|
||||||
*.tmp
|
|
||||||
*.bak
|
|
||||||
*.log
|
|
||||||
|
|
||||||
# OS specific files
|
|
||||||
.DS_Store
|
|
||||||
Thumbs.db
|
|
||||||
|
|
||||||
# .NET build artifacts
|
|
||||||
agent/lhm/obj
|
|
||||||
agent/lhm/bin
|
|
||||||
32
.github/workflows/docker-images.yml
vendored
32
.github/workflows/docker-images.yml
vendored
@@ -13,44 +13,44 @@ jobs:
|
|||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- image: henrygd/beszel
|
- image: henrygd/beszel
|
||||||
context: ./
|
context: ./beszel
|
||||||
dockerfile: ./internal/dockerfile_hub
|
dockerfile: ./beszel/dockerfile_hub
|
||||||
registry: docker.io
|
registry: docker.io
|
||||||
username_secret: DOCKERHUB_USERNAME
|
username_secret: DOCKERHUB_USERNAME
|
||||||
password_secret: DOCKERHUB_TOKEN
|
password_secret: DOCKERHUB_TOKEN
|
||||||
|
|
||||||
- image: henrygd/beszel-agent
|
- image: henrygd/beszel-agent
|
||||||
context: ./
|
context: ./beszel
|
||||||
dockerfile: ./internal/dockerfile_agent
|
dockerfile: ./beszel/dockerfile_agent
|
||||||
registry: docker.io
|
registry: docker.io
|
||||||
username_secret: DOCKERHUB_USERNAME
|
username_secret: DOCKERHUB_USERNAME
|
||||||
password_secret: DOCKERHUB_TOKEN
|
password_secret: DOCKERHUB_TOKEN
|
||||||
|
|
||||||
- image: henrygd/beszel-agent-nvidia
|
- image: henrygd/beszel-agent-nvidia
|
||||||
context: ./
|
context: ./beszel
|
||||||
dockerfile: ./internal/dockerfile_agent_nvidia
|
dockerfile: ./beszel/dockerfile_agent_nvidia
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
registry: docker.io
|
registry: docker.io
|
||||||
username_secret: DOCKERHUB_USERNAME
|
username_secret: DOCKERHUB_USERNAME
|
||||||
password_secret: DOCKERHUB_TOKEN
|
password_secret: DOCKERHUB_TOKEN
|
||||||
|
|
||||||
- image: ghcr.io/${{ github.repository }}/beszel
|
- image: ghcr.io/${{ github.repository }}/beszel
|
||||||
context: ./
|
context: ./beszel
|
||||||
dockerfile: ./internal/dockerfile_hub
|
dockerfile: ./beszel/dockerfile_hub
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password_secret: GITHUB_TOKEN
|
password_secret: GITHUB_TOKEN
|
||||||
|
|
||||||
- image: ghcr.io/${{ github.repository }}/beszel-agent
|
- image: ghcr.io/${{ github.repository }}/beszel-agent
|
||||||
context: ./
|
context: ./beszel
|
||||||
dockerfile: ./internal/dockerfile_agent
|
dockerfile: ./beszel/dockerfile_agent
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password_secret: GITHUB_TOKEN
|
password_secret: GITHUB_TOKEN
|
||||||
|
|
||||||
- image: ghcr.io/${{ github.repository }}/beszel-agent-nvidia
|
- image: ghcr.io/${{ github.repository }}/beszel-agent-nvidia
|
||||||
context: ./
|
context: ./beszel
|
||||||
dockerfile: ./internal/dockerfile_agent_nvidia
|
dockerfile: ./beszel/dockerfile_agent_nvidia
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
@@ -68,10 +68,10 @@ jobs:
|
|||||||
uses: oven-sh/setup-bun@v2
|
uses: oven-sh/setup-bun@v2
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --no-save --cwd ./internal/site
|
run: bun install --no-save --cwd ./beszel/site
|
||||||
|
|
||||||
- name: Build site
|
- name: Build site
|
||||||
run: bun run --cwd ./internal/site build
|
run: bun run --cwd ./beszel/site build
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v3
|
||||||
@@ -93,8 +93,8 @@ jobs:
|
|||||||
|
|
||||||
# https://github.com/docker/login-action
|
# https://github.com/docker/login-action
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
env:
|
env:
|
||||||
password_secret_exists: ${{ secrets[matrix.password_secret] != '' && 'true' || 'false' }}
|
password_secret_exists: ${{ secrets[matrix.password_secret] != '' && 'true' || 'false' }}
|
||||||
if: github.event_name != 'pull_request' && env.password_secret_exists == 'true'
|
if: github.event_name != 'pull_request' && env.password_secret_exists == 'true'
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
|
|||||||
8
.github/workflows/release.yml
vendored
8
.github/workflows/release.yml
vendored
@@ -21,10 +21,10 @@ jobs:
|
|||||||
uses: oven-sh/setup-bun@v2
|
uses: oven-sh/setup-bun@v2
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --no-save --cwd ./internal/site
|
run: bun install --no-save --cwd ./beszel/site
|
||||||
|
|
||||||
- name: Build site
|
- name: Build site
|
||||||
run: bun run --cwd ./internal/site build
|
run: bun run --cwd ./beszel/site build
|
||||||
|
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
@@ -38,13 +38,13 @@ jobs:
|
|||||||
|
|
||||||
- name: Build .NET LHM executable for Windows sensors
|
- name: Build .NET LHM executable for Windows sensors
|
||||||
run: |
|
run: |
|
||||||
dotnet build -c Release ./agent/lhm/beszel_lhm.csproj
|
dotnet build -c Release ./beszel/internal/agent/lhm/beszel_lhm.csproj
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
- name: GoReleaser beszel
|
- name: GoReleaser beszel
|
||||||
uses: goreleaser/goreleaser-action@v6
|
uses: goreleaser/goreleaser-action@v6
|
||||||
with:
|
with:
|
||||||
workdir: ./
|
workdir: ./beszel
|
||||||
distribution: goreleaser
|
distribution: goreleaser
|
||||||
version: latest
|
version: latest
|
||||||
args: release --clean
|
args: release --clean
|
||||||
|
|||||||
8
.github/workflows/vulncheck.yml
vendored
8
.github/workflows/vulncheck.yml
vendored
@@ -15,7 +15,7 @@ permissions:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
vulncheck:
|
vulncheck:
|
||||||
name: VulnCheck
|
name: Analysis
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Check out code into the Go module directory
|
- name: Check out code into the Go module directory
|
||||||
@@ -23,11 +23,11 @@ jobs:
|
|||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: 1.25.x
|
go-version: 1.24.x
|
||||||
# cached: false
|
cached: false
|
||||||
- name: Get official govulncheck
|
- name: Get official govulncheck
|
||||||
run: go install golang.org/x/vuln/cmd/govulncheck@latest
|
run: go install golang.org/x/vuln/cmd/govulncheck@latest
|
||||||
shell: bash
|
shell: bash
|
||||||
- name: Run govulncheck
|
- name: Run govulncheck
|
||||||
run: govulncheck -show verbose ./...
|
run: govulncheck -C ./beszel -show verbose ./...
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|||||||
12
.gitignore
vendored
12
.gitignore
vendored
@@ -8,15 +8,15 @@ beszel_data
|
|||||||
beszel_data*
|
beszel_data*
|
||||||
dist
|
dist
|
||||||
*.exe
|
*.exe
|
||||||
internal/cmd/hub/hub
|
beszel/cmd/hub/hub
|
||||||
internal/cmd/agent/agent
|
beszel/cmd/agent/agent
|
||||||
node_modules
|
node_modules
|
||||||
build
|
beszel/build
|
||||||
*timestamp*
|
*timestamp*
|
||||||
.swc
|
.swc
|
||||||
internal/site/src/locales/**/*.ts
|
beszel/site/src/locales/**/*.ts
|
||||||
*.bak
|
*.bak
|
||||||
__debug_*
|
__debug_*
|
||||||
agent/lhm/obj
|
beszel/internal/agent/lhm/obj
|
||||||
agent/lhm/bin
|
beszel/internal/agent/lhm/bin
|
||||||
dockerfile_agent_dev
|
dockerfile_agent_dev
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
// Package deltatracker provides a tracker for calculating differences in numeric values over time.
|
|
||||||
package deltatracker
|
|
||||||
|
|
||||||
import (
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"golang.org/x/exp/constraints"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Numeric is a constraint that permits any integer or floating-point type.
|
|
||||||
type Numeric interface {
|
|
||||||
constraints.Integer | constraints.Float
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeltaTracker is a generic, thread-safe tracker for calculating differences
|
|
||||||
// in numeric values over time.
|
|
||||||
// K is the key type (e.g., int, string).
|
|
||||||
// V is the value type (e.g., int, int64, float32, float64).
|
|
||||||
type DeltaTracker[K comparable, V Numeric] struct {
|
|
||||||
sync.RWMutex
|
|
||||||
current map[K]V
|
|
||||||
previous map[K]V
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewDeltaTracker creates a new generic tracker.
|
|
||||||
func NewDeltaTracker[K comparable, V Numeric]() *DeltaTracker[K, V] {
|
|
||||||
return &DeltaTracker[K, V]{
|
|
||||||
current: make(map[K]V),
|
|
||||||
previous: make(map[K]V),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set records the current value for a given ID.
|
|
||||||
func (t *DeltaTracker[K, V]) Set(id K, value V) {
|
|
||||||
t.Lock()
|
|
||||||
defer t.Unlock()
|
|
||||||
t.current[id] = value
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deltas returns a map of all calculated deltas for the current interval.
|
|
||||||
func (t *DeltaTracker[K, V]) Deltas() map[K]V {
|
|
||||||
t.RLock()
|
|
||||||
defer t.RUnlock()
|
|
||||||
|
|
||||||
deltas := make(map[K]V)
|
|
||||||
for id, currentVal := range t.current {
|
|
||||||
if previousVal, ok := t.previous[id]; ok {
|
|
||||||
deltas[id] = currentVal - previousVal
|
|
||||||
} else {
|
|
||||||
deltas[id] = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return deltas
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delta returns the delta for a single key.
|
|
||||||
// Returns 0 if the key doesn't exist or has no previous value.
|
|
||||||
func (t *DeltaTracker[K, V]) Delta(id K) V {
|
|
||||||
t.RLock()
|
|
||||||
defer t.RUnlock()
|
|
||||||
|
|
||||||
currentVal, currentOk := t.current[id]
|
|
||||||
if !currentOk {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
previousVal, previousOk := t.previous[id]
|
|
||||||
if !previousOk {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
return currentVal - previousVal
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cycle prepares the tracker for the next interval.
|
|
||||||
func (t *DeltaTracker[K, V]) Cycle() {
|
|
||||||
t.Lock()
|
|
||||||
defer t.Unlock()
|
|
||||||
t.previous = t.current
|
|
||||||
t.current = make(map[K]V)
|
|
||||||
}
|
|
||||||
@@ -1,217 +0,0 @@
|
|||||||
package deltatracker
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
func ExampleDeltaTracker() {
|
|
||||||
tracker := NewDeltaTracker[string, int]()
|
|
||||||
tracker.Set("key1", 10)
|
|
||||||
tracker.Set("key2", 20)
|
|
||||||
tracker.Cycle()
|
|
||||||
tracker.Set("key1", 15)
|
|
||||||
tracker.Set("key2", 30)
|
|
||||||
fmt.Println(tracker.Delta("key1"))
|
|
||||||
fmt.Println(tracker.Delta("key2"))
|
|
||||||
fmt.Println(tracker.Deltas())
|
|
||||||
// Output: 5
|
|
||||||
// 10
|
|
||||||
// map[key1:5 key2:10]
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewDeltaTracker(t *testing.T) {
|
|
||||||
tracker := NewDeltaTracker[string, int]()
|
|
||||||
assert.NotNil(t, tracker)
|
|
||||||
assert.Empty(t, tracker.current)
|
|
||||||
assert.Empty(t, tracker.previous)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSet(t *testing.T) {
|
|
||||||
tracker := NewDeltaTracker[string, int]()
|
|
||||||
tracker.Set("key1", 10)
|
|
||||||
|
|
||||||
tracker.RLock()
|
|
||||||
defer tracker.RUnlock()
|
|
||||||
|
|
||||||
assert.Equal(t, 10, tracker.current["key1"])
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDeltas(t *testing.T) {
|
|
||||||
tracker := NewDeltaTracker[string, int]()
|
|
||||||
|
|
||||||
// Test with no previous values
|
|
||||||
tracker.Set("key1", 10)
|
|
||||||
tracker.Set("key2", 20)
|
|
||||||
|
|
||||||
deltas := tracker.Deltas()
|
|
||||||
assert.Equal(t, 0, deltas["key1"])
|
|
||||||
assert.Equal(t, 0, deltas["key2"])
|
|
||||||
|
|
||||||
// Cycle to move current to previous
|
|
||||||
tracker.Cycle()
|
|
||||||
|
|
||||||
// Set new values and check deltas
|
|
||||||
tracker.Set("key1", 15) // Delta should be 5 (15-10)
|
|
||||||
tracker.Set("key2", 25) // Delta should be 5 (25-20)
|
|
||||||
tracker.Set("key3", 30) // New key, delta should be 0
|
|
||||||
|
|
||||||
deltas = tracker.Deltas()
|
|
||||||
assert.Equal(t, 5, deltas["key1"])
|
|
||||||
assert.Equal(t, 5, deltas["key2"])
|
|
||||||
assert.Equal(t, 0, deltas["key3"])
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCycle(t *testing.T) {
|
|
||||||
tracker := NewDeltaTracker[string, int]()
|
|
||||||
|
|
||||||
tracker.Set("key1", 10)
|
|
||||||
tracker.Set("key2", 20)
|
|
||||||
|
|
||||||
// Verify current has values
|
|
||||||
tracker.RLock()
|
|
||||||
assert.Equal(t, 10, tracker.current["key1"])
|
|
||||||
assert.Equal(t, 20, tracker.current["key2"])
|
|
||||||
assert.Empty(t, tracker.previous)
|
|
||||||
tracker.RUnlock()
|
|
||||||
|
|
||||||
tracker.Cycle()
|
|
||||||
|
|
||||||
// After cycle, previous should have the old current values
|
|
||||||
// and current should be empty
|
|
||||||
tracker.RLock()
|
|
||||||
assert.Empty(t, tracker.current)
|
|
||||||
assert.Equal(t, 10, tracker.previous["key1"])
|
|
||||||
assert.Equal(t, 20, tracker.previous["key2"])
|
|
||||||
tracker.RUnlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCompleteWorkflow(t *testing.T) {
|
|
||||||
tracker := NewDeltaTracker[string, int]()
|
|
||||||
|
|
||||||
// First interval
|
|
||||||
tracker.Set("server1", 100)
|
|
||||||
tracker.Set("server2", 200)
|
|
||||||
|
|
||||||
// Get deltas for first interval (should be zero)
|
|
||||||
firstDeltas := tracker.Deltas()
|
|
||||||
assert.Equal(t, 0, firstDeltas["server1"])
|
|
||||||
assert.Equal(t, 0, firstDeltas["server2"])
|
|
||||||
|
|
||||||
// Cycle to next interval
|
|
||||||
tracker.Cycle()
|
|
||||||
|
|
||||||
// Second interval
|
|
||||||
tracker.Set("server1", 150) // Delta: 50
|
|
||||||
tracker.Set("server2", 180) // Delta: -20
|
|
||||||
tracker.Set("server3", 300) // New server, delta: 300
|
|
||||||
|
|
||||||
secondDeltas := tracker.Deltas()
|
|
||||||
assert.Equal(t, 50, secondDeltas["server1"])
|
|
||||||
assert.Equal(t, -20, secondDeltas["server2"])
|
|
||||||
assert.Equal(t, 0, secondDeltas["server3"])
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDeltaTrackerWithDifferentTypes(t *testing.T) {
|
|
||||||
// Test with int64
|
|
||||||
intTracker := NewDeltaTracker[string, int64]()
|
|
||||||
intTracker.Set("pid1", 1000)
|
|
||||||
intTracker.Cycle()
|
|
||||||
intTracker.Set("pid1", 1200)
|
|
||||||
intDeltas := intTracker.Deltas()
|
|
||||||
assert.Equal(t, int64(200), intDeltas["pid1"])
|
|
||||||
|
|
||||||
// Test with float64
|
|
||||||
floatTracker := NewDeltaTracker[string, float64]()
|
|
||||||
floatTracker.Set("cpu1", 1.5)
|
|
||||||
floatTracker.Cycle()
|
|
||||||
floatTracker.Set("cpu1", 2.7)
|
|
||||||
floatDeltas := floatTracker.Deltas()
|
|
||||||
assert.InDelta(t, 1.2, floatDeltas["cpu1"], 0.0001)
|
|
||||||
|
|
||||||
// Test with int keys
|
|
||||||
pidTracker := NewDeltaTracker[int, int64]()
|
|
||||||
pidTracker.Set(101, 20000)
|
|
||||||
pidTracker.Cycle()
|
|
||||||
pidTracker.Set(101, 22500)
|
|
||||||
pidDeltas := pidTracker.Deltas()
|
|
||||||
assert.Equal(t, int64(2500), pidDeltas[101])
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDelta(t *testing.T) {
|
|
||||||
tracker := NewDeltaTracker[string, int]()
|
|
||||||
|
|
||||||
// Test getting delta for non-existent key
|
|
||||||
result := tracker.Delta("nonexistent")
|
|
||||||
assert.Equal(t, 0, result)
|
|
||||||
|
|
||||||
// Test getting delta for key with no previous value
|
|
||||||
tracker.Set("key1", 10)
|
|
||||||
result = tracker.Delta("key1")
|
|
||||||
assert.Equal(t, 0, result)
|
|
||||||
|
|
||||||
// Cycle to move current to previous
|
|
||||||
tracker.Cycle()
|
|
||||||
|
|
||||||
// Test getting delta for key with previous value
|
|
||||||
tracker.Set("key1", 15)
|
|
||||||
result = tracker.Delta("key1")
|
|
||||||
assert.Equal(t, 5, result)
|
|
||||||
|
|
||||||
// Test getting delta for key that exists in previous but not current
|
|
||||||
result = tracker.Delta("key1")
|
|
||||||
assert.Equal(t, 5, result) // Should still return 5
|
|
||||||
|
|
||||||
// Test getting delta for key that exists in current but not previous
|
|
||||||
tracker.Set("key2", 20)
|
|
||||||
result = tracker.Delta("key2")
|
|
||||||
assert.Equal(t, 0, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDeltaWithDifferentTypes(t *testing.T) {
|
|
||||||
// Test with int64
|
|
||||||
intTracker := NewDeltaTracker[string, int64]()
|
|
||||||
intTracker.Set("pid1", 1000)
|
|
||||||
intTracker.Cycle()
|
|
||||||
intTracker.Set("pid1", 1200)
|
|
||||||
result := intTracker.Delta("pid1")
|
|
||||||
assert.Equal(t, int64(200), result)
|
|
||||||
|
|
||||||
// Test with float64
|
|
||||||
floatTracker := NewDeltaTracker[string, float64]()
|
|
||||||
floatTracker.Set("cpu1", 1.5)
|
|
||||||
floatTracker.Cycle()
|
|
||||||
floatTracker.Set("cpu1", 2.7)
|
|
||||||
floatResult := floatTracker.Delta("cpu1")
|
|
||||||
assert.InDelta(t, 1.2, floatResult, 0.0001)
|
|
||||||
|
|
||||||
// Test with int keys
|
|
||||||
pidTracker := NewDeltaTracker[int, int64]()
|
|
||||||
pidTracker.Set(101, 20000)
|
|
||||||
pidTracker.Cycle()
|
|
||||||
pidTracker.Set(101, 22500)
|
|
||||||
pidResult := pidTracker.Delta(101)
|
|
||||||
assert.Equal(t, int64(2500), pidResult)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDeltaConcurrentAccess(t *testing.T) {
|
|
||||||
tracker := NewDeltaTracker[string, int]()
|
|
||||||
|
|
||||||
// Set initial values
|
|
||||||
tracker.Set("key1", 10)
|
|
||||||
tracker.Set("key2", 20)
|
|
||||||
tracker.Cycle()
|
|
||||||
|
|
||||||
// Set new values
|
|
||||||
tracker.Set("key1", 15)
|
|
||||||
tracker.Set("key2", 25)
|
|
||||||
|
|
||||||
// Test concurrent access safety
|
|
||||||
result1 := tracker.Delta("key1")
|
|
||||||
result2 := tracker.Delta("key2")
|
|
||||||
|
|
||||||
assert.Equal(t, 5, result1)
|
|
||||||
assert.Equal(t, 5, result2)
|
|
||||||
}
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"os/exec"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
intelGpuStatsCmd string = "intel_gpu_top"
|
|
||||||
intelGpuStatsInterval string = "3300" // in milliseconds
|
|
||||||
)
|
|
||||||
|
|
||||||
type intelGpuStats struct {
|
|
||||||
Power struct {
|
|
||||||
GPU float64 `json:"GPU"`
|
|
||||||
} `json:"power"`
|
|
||||||
Engines map[string]struct {
|
|
||||||
Busy float64 `json:"busy"`
|
|
||||||
} `json:"engines"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateIntelFromStats updates aggregated GPU data from a single intelGpuStats sample
|
|
||||||
func (gm *GPUManager) updateIntelFromStats(sample *intelGpuStats) bool {
|
|
||||||
gm.Lock()
|
|
||||||
defer gm.Unlock()
|
|
||||||
|
|
||||||
// only one gpu for now - cmd doesn't provide all by default
|
|
||||||
gpuData, ok := gm.GpuDataMap["0"]
|
|
||||||
if !ok {
|
|
||||||
gpuData = &system.GPUData{Name: "GPU", Engines: make(map[string]float64)}
|
|
||||||
gm.GpuDataMap["0"] = gpuData
|
|
||||||
}
|
|
||||||
|
|
||||||
if sample.Power.GPU > 0 {
|
|
||||||
gpuData.Power += sample.Power.GPU
|
|
||||||
}
|
|
||||||
|
|
||||||
if gpuData.Engines == nil {
|
|
||||||
gpuData.Engines = make(map[string]float64, len(sample.Engines))
|
|
||||||
}
|
|
||||||
for name, engine := range sample.Engines {
|
|
||||||
gpuData.Engines[name] += engine.Busy
|
|
||||||
}
|
|
||||||
|
|
||||||
gpuData.Count++
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// collectIntelStats executes intel_gpu_top in JSON mode and stream-decodes the array of samples
|
|
||||||
func (gm *GPUManager) collectIntelStats() error {
|
|
||||||
cmd := exec.Command(intelGpuStatsCmd, "-s", intelGpuStatsInterval, "-J")
|
|
||||||
stdout, err := cmd.StdoutPipe()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := cmd.Start(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
dec := json.NewDecoder(stdout)
|
|
||||||
|
|
||||||
// Expect a JSON array stream: [ { ... }, { ... }, ... ]
|
|
||||||
tok, err := dec.Token()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if delim, ok := tok.(json.Delim); !ok || delim != '[' {
|
|
||||||
return fmt.Errorf("unexpected JSON start token: %v", tok)
|
|
||||||
}
|
|
||||||
|
|
||||||
var sample intelGpuStats
|
|
||||||
for {
|
|
||||||
if dec.More() {
|
|
||||||
// Clear the engines map before decoding
|
|
||||||
if sample.Engines != nil {
|
|
||||||
for k := range sample.Engines {
|
|
||||||
delete(sample.Engines, k)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := dec.Decode(&sample); err != nil {
|
|
||||||
return fmt.Errorf("decode intel gpu: %w", err)
|
|
||||||
}
|
|
||||||
gm.updateIntelFromStats(&sample)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// Attempt to read closing bracket (will only be present when process exits)
|
|
||||||
tok, err = dec.Token()
|
|
||||||
if err != nil {
|
|
||||||
// When the process is still running, decoder will block in More/Decode; any error here is terminal
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if delim, ok := tok.(json.Delim); ok && delim == ']' {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return cmd.Wait()
|
|
||||||
}
|
|
||||||
144
agent/network.go
144
agent/network.go
@@ -1,144 +0,0 @@
|
|||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/agent/deltatracker"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
|
||||||
psutilNet "github.com/shirou/gopsutil/v4/net"
|
|
||||||
)
|
|
||||||
|
|
||||||
var netInterfaceDeltaTracker = deltatracker.NewDeltaTracker[string, uint64]()
|
|
||||||
|
|
||||||
func (a *Agent) updateNetworkStats(systemStats *system.Stats) {
|
|
||||||
// network stats
|
|
||||||
if len(a.netInterfaces) == 0 {
|
|
||||||
// if no network interfaces, initialize again
|
|
||||||
// this is a fix if agent started before network is online (#466)
|
|
||||||
// maybe refactor this in the future to not cache interface names at all so we
|
|
||||||
// don't miss an interface that's been added after agent started in any circumstance
|
|
||||||
a.initializeNetIoStats()
|
|
||||||
}
|
|
||||||
|
|
||||||
if systemStats.NetworkInterfaces == nil {
|
|
||||||
systemStats.NetworkInterfaces = make(map[string][4]uint64, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
if netIO, err := psutilNet.IOCounters(true); err == nil {
|
|
||||||
msElapsed := uint64(time.Since(a.netIoStats.Time).Milliseconds())
|
|
||||||
a.netIoStats.Time = time.Now()
|
|
||||||
totalBytesSent := uint64(0)
|
|
||||||
totalBytesRecv := uint64(0)
|
|
||||||
netInterfaceDeltaTracker.Cycle()
|
|
||||||
// sum all bytes sent and received
|
|
||||||
for _, v := range netIO {
|
|
||||||
// skip if not in valid network interfaces list
|
|
||||||
if _, exists := a.netInterfaces[v.Name]; !exists {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
totalBytesSent += v.BytesSent
|
|
||||||
totalBytesRecv += v.BytesRecv
|
|
||||||
|
|
||||||
// track deltas for each network interface
|
|
||||||
var upDelta, downDelta uint64
|
|
||||||
upKey, downKey := fmt.Sprintf("%sup", v.Name), fmt.Sprintf("%sdown", v.Name)
|
|
||||||
netInterfaceDeltaTracker.Set(upKey, v.BytesSent)
|
|
||||||
netInterfaceDeltaTracker.Set(downKey, v.BytesRecv)
|
|
||||||
if msElapsed > 0 {
|
|
||||||
upDelta = netInterfaceDeltaTracker.Delta(upKey) * 1000 / msElapsed
|
|
||||||
downDelta = netInterfaceDeltaTracker.Delta(downKey) * 1000 / msElapsed
|
|
||||||
}
|
|
||||||
// add interface to systemStats
|
|
||||||
systemStats.NetworkInterfaces[v.Name] = [4]uint64{upDelta, downDelta, v.BytesSent, v.BytesRecv}
|
|
||||||
}
|
|
||||||
|
|
||||||
// add to systemStats
|
|
||||||
var bytesSentPerSecond, bytesRecvPerSecond uint64
|
|
||||||
if msElapsed > 0 {
|
|
||||||
bytesSentPerSecond = (totalBytesSent - a.netIoStats.BytesSent) * 1000 / msElapsed
|
|
||||||
bytesRecvPerSecond = (totalBytesRecv - a.netIoStats.BytesRecv) * 1000 / msElapsed
|
|
||||||
}
|
|
||||||
networkSentPs := bytesToMegabytes(float64(bytesSentPerSecond))
|
|
||||||
networkRecvPs := bytesToMegabytes(float64(bytesRecvPerSecond))
|
|
||||||
// add check for issue (#150) where sent is a massive number
|
|
||||||
if networkSentPs > 10_000 || networkRecvPs > 10_000 {
|
|
||||||
slog.Warn("Invalid net stats. Resetting.", "sent", networkSentPs, "recv", networkRecvPs)
|
|
||||||
for _, v := range netIO {
|
|
||||||
if _, exists := a.netInterfaces[v.Name]; !exists {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
slog.Info(v.Name, "recv", v.BytesRecv, "sent", v.BytesSent)
|
|
||||||
}
|
|
||||||
// reset network I/O stats
|
|
||||||
a.initializeNetIoStats()
|
|
||||||
} else {
|
|
||||||
systemStats.NetworkSent = networkSentPs
|
|
||||||
systemStats.NetworkRecv = networkRecvPs
|
|
||||||
systemStats.Bandwidth[0], systemStats.Bandwidth[1] = bytesSentPerSecond, bytesRecvPerSecond
|
|
||||||
// update netIoStats
|
|
||||||
a.netIoStats.BytesSent = totalBytesSent
|
|
||||||
a.netIoStats.BytesRecv = totalBytesRecv
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *Agent) initializeNetIoStats() {
|
|
||||||
// reset valid network interfaces
|
|
||||||
a.netInterfaces = make(map[string]struct{}, 0)
|
|
||||||
|
|
||||||
// map of network interface names passed in via NICS env var
|
|
||||||
var nicsMap map[string]struct{}
|
|
||||||
nics, nicsEnvExists := GetEnv("NICS")
|
|
||||||
if nicsEnvExists {
|
|
||||||
nicsMap = make(map[string]struct{}, 0)
|
|
||||||
for nic := range strings.SplitSeq(nics, ",") {
|
|
||||||
nicsMap[nic] = struct{}{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// reset network I/O stats
|
|
||||||
a.netIoStats.BytesSent = 0
|
|
||||||
a.netIoStats.BytesRecv = 0
|
|
||||||
|
|
||||||
// get intial network I/O stats
|
|
||||||
if netIO, err := psutilNet.IOCounters(true); err == nil {
|
|
||||||
a.netIoStats.Time = time.Now()
|
|
||||||
for _, v := range netIO {
|
|
||||||
switch {
|
|
||||||
// skip if nics exists and the interface is not in the list
|
|
||||||
case nicsEnvExists:
|
|
||||||
if _, nameInNics := nicsMap[v.Name]; !nameInNics {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// otherwise run the interface name through the skipNetworkInterface function
|
|
||||||
default:
|
|
||||||
if a.skipNetworkInterface(v) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
slog.Info("Detected network interface", "name", v.Name, "sent", v.BytesSent, "recv", v.BytesRecv)
|
|
||||||
a.netIoStats.BytesSent += v.BytesSent
|
|
||||||
a.netIoStats.BytesRecv += v.BytesRecv
|
|
||||||
// store as a valid network interface
|
|
||||||
a.netInterfaces[v.Name] = struct{}{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *Agent) skipNetworkInterface(v psutilNet.IOCountersStat) bool {
|
|
||||||
switch {
|
|
||||||
case strings.HasPrefix(v.Name, "lo"),
|
|
||||||
strings.HasPrefix(v.Name, "docker"),
|
|
||||||
strings.HasPrefix(v.Name, "br-"),
|
|
||||||
strings.HasPrefix(v.Name, "veth"),
|
|
||||||
strings.HasPrefix(v.Name, "bond"),
|
|
||||||
v.BytesRecv == 0,
|
|
||||||
v.BytesSent == 0:
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
15
beszel.go
15
beszel.go
@@ -1,15 +0,0 @@
|
|||||||
// Package beszel provides core application constants and version information
|
|
||||||
// which are used throughout the application.
|
|
||||||
package beszel
|
|
||||||
|
|
||||||
import "github.com/blang/semver"
|
|
||||||
|
|
||||||
const (
|
|
||||||
// Version is the current version of the application.
|
|
||||||
Version = "0.12.9"
|
|
||||||
// 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")
|
|
||||||
@@ -9,7 +9,7 @@ before:
|
|||||||
builds:
|
builds:
|
||||||
- id: beszel
|
- id: beszel
|
||||||
binary: beszel
|
binary: beszel
|
||||||
main: internal/cmd/hub/hub.go
|
main: cmd/hub/hub.go
|
||||||
env:
|
env:
|
||||||
- CGO_ENABLED=0
|
- CGO_ENABLED=0
|
||||||
goos:
|
goos:
|
||||||
@@ -22,7 +22,7 @@ builds:
|
|||||||
|
|
||||||
- id: beszel-agent
|
- id: beszel-agent
|
||||||
binary: beszel-agent
|
binary: beszel-agent
|
||||||
main: internal/cmd/agent/agent.go
|
main: cmd/agent/agent.go
|
||||||
env:
|
env:
|
||||||
- CGO_ENABLED=0
|
- CGO_ENABLED=0
|
||||||
goos:
|
goos:
|
||||||
@@ -103,28 +103,28 @@ nfpms:
|
|||||||
formats:
|
formats:
|
||||||
- deb
|
- deb
|
||||||
contents:
|
contents:
|
||||||
- src: ./supplemental/debian/beszel-agent.service
|
- src: ../supplemental/debian/beszel-agent.service
|
||||||
dst: lib/systemd/system/beszel-agent.service
|
dst: lib/systemd/system/beszel-agent.service
|
||||||
packager: deb
|
packager: deb
|
||||||
- src: ./supplemental/debian/copyright
|
- src: ../supplemental/debian/copyright
|
||||||
dst: usr/share/doc/beszel-agent/copyright
|
dst: usr/share/doc/beszel-agent/copyright
|
||||||
packager: deb
|
packager: deb
|
||||||
- src: ./supplemental/debian/lintian-overrides
|
- src: ../supplemental/debian/lintian-overrides
|
||||||
dst: usr/share/lintian/overrides/beszel-agent
|
dst: usr/share/lintian/overrides/beszel-agent
|
||||||
packager: deb
|
packager: deb
|
||||||
scripts:
|
scripts:
|
||||||
postinstall: ./supplemental/debian/postinstall.sh
|
postinstall: ../supplemental/debian/postinstall.sh
|
||||||
preremove: ./supplemental/debian/prerm.sh
|
preremove: ../supplemental/debian/prerm.sh
|
||||||
postremove: ./supplemental/debian/postrm.sh
|
postremove: ../supplemental/debian/postrm.sh
|
||||||
deb:
|
deb:
|
||||||
predepends:
|
predepends:
|
||||||
- adduser
|
- adduser
|
||||||
- debconf
|
- debconf
|
||||||
scripts:
|
scripts:
|
||||||
templates: ./supplemental/debian/templates
|
templates: ../supplemental/debian/templates
|
||||||
# Currently broken due to a bug in goreleaser
|
# Currently broken due to a bug in goreleaser
|
||||||
# https://github.com/goreleaser/goreleaser/issues/5487
|
# https://github.com/goreleaser/goreleaser/issues/5487
|
||||||
#config: ./supplemental/debian/config.sh
|
#config: ../supplemental/debian/config.sh
|
||||||
|
|
||||||
scoops:
|
scoops:
|
||||||
- ids: [beszel-agent]
|
- ids: [beszel-agent]
|
||||||
@@ -135,7 +135,7 @@ scoops:
|
|||||||
homepage: "https://beszel.dev"
|
homepage: "https://beszel.dev"
|
||||||
description: "Agent for Beszel, a lightweight server monitoring platform."
|
description: "Agent for Beszel, a lightweight server monitoring platform."
|
||||||
license: MIT
|
license: MIT
|
||||||
skip_upload: '{{ if eq (tolower .Env.IS_FORK) "true" }}true{{ else }}auto{{ end }}'
|
skip_upload: "{{ if .Env.IS_FORK }}true{{ else }}auto{{ end }}"
|
||||||
|
|
||||||
# # Needs choco installed, so doesn't build on linux / default gh workflow :(
|
# # Needs choco installed, so doesn't build on linux / default gh workflow :(
|
||||||
# chocolateys:
|
# chocolateys:
|
||||||
@@ -169,7 +169,7 @@ brews:
|
|||||||
homepage: "https://beszel.dev"
|
homepage: "https://beszel.dev"
|
||||||
description: "Agent for Beszel, a lightweight server monitoring platform."
|
description: "Agent for Beszel, a lightweight server monitoring platform."
|
||||||
license: MIT
|
license: MIT
|
||||||
skip_upload: '{{ if eq (tolower .Env.IS_FORK) "true" }}true{{ else }}auto{{ end }}'
|
skip_upload: "{{ if .Env.IS_FORK }}true{{ else }}auto{{ end }}"
|
||||||
extra_install: |
|
extra_install: |
|
||||||
(bin/"beszel-agent-launcher").write <<~EOS
|
(bin/"beszel-agent-launcher").write <<~EOS
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
@@ -201,7 +201,7 @@ winget:
|
|||||||
release_notes_url: "https://github.com/henrygd/beszel/releases/tag/v{{ .Version }}"
|
release_notes_url: "https://github.com/henrygd/beszel/releases/tag/v{{ .Version }}"
|
||||||
publisher_support_url: "https://github.com/henrygd/beszel/issues"
|
publisher_support_url: "https://github.com/henrygd/beszel/issues"
|
||||||
short_description: "Agent for Beszel, a lightweight server monitoring platform."
|
short_description: "Agent for Beszel, a lightweight server monitoring platform."
|
||||||
skip_upload: '{{ if eq (tolower .Env.IS_FORK) "true" }}true{{ else }}auto{{ end }}'
|
skip_upload: "{{ if .Env.IS_FORK }}true{{ else }}auto{{ end }}"
|
||||||
description: |
|
description: |
|
||||||
Beszel is a lightweight server monitoring platform that includes Docker
|
Beszel is a lightweight server monitoring platform that includes Docker
|
||||||
statistics, historical data, and alert functions. It has a friendly web
|
statistics, historical data, and alert functions. It has a friendly web
|
||||||
@@ -26,11 +26,11 @@ tidy:
|
|||||||
|
|
||||||
build-web-ui:
|
build-web-ui:
|
||||||
@if command -v bun >/dev/null 2>&1; then \
|
@if command -v bun >/dev/null 2>&1; then \
|
||||||
bun install --cwd ./internal/site && \
|
bun install --cwd ./site && \
|
||||||
bun run --cwd ./internal/site build; \
|
bun run --cwd ./site build; \
|
||||||
else \
|
else \
|
||||||
npm install --prefix ./internal/site && \
|
npm install --prefix ./site && \
|
||||||
npm run --prefix ./internal/site build; \
|
npm run --prefix ./site build; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Conditional .NET build - only for Windows
|
# Conditional .NET build - only for Windows
|
||||||
@@ -38,8 +38,8 @@ build-dotnet-conditional:
|
|||||||
@if [ "$(OS)" = "windows" ]; then \
|
@if [ "$(OS)" = "windows" ]; then \
|
||||||
echo "Building .NET executable for Windows..."; \
|
echo "Building .NET executable for Windows..."; \
|
||||||
if command -v dotnet >/dev/null 2>&1; then \
|
if command -v dotnet >/dev/null 2>&1; then \
|
||||||
rm -rf ./agent/lhm/bin; \
|
rm -rf ./internal/agent/lhm/bin; \
|
||||||
dotnet build -c Release ./agent/lhm/beszel_lhm.csproj; \
|
dotnet build -c Release ./internal/agent/lhm/beszel_lhm.csproj; \
|
||||||
else \
|
else \
|
||||||
echo "Error: dotnet not found. Install .NET SDK to build Windows agent."; \
|
echo "Error: dotnet not found. Install .NET SDK to build Windows agent."; \
|
||||||
exit 1; \
|
exit 1; \
|
||||||
@@ -48,51 +48,51 @@ build-dotnet-conditional:
|
|||||||
|
|
||||||
# Update build-agent to include conditional .NET build
|
# Update build-agent to include conditional .NET build
|
||||||
build-agent: tidy build-dotnet-conditional
|
build-agent: tidy build-dotnet-conditional
|
||||||
GOOS=$(OS) GOARCH=$(ARCH) go build -o ./build/beszel-agent_$(OS)_$(ARCH)$(EXE_EXT) -ldflags "-w -s" ./internal/cmd/agent
|
GOOS=$(OS) GOARCH=$(ARCH) go build -o ./build/beszel-agent_$(OS)_$(ARCH)$(EXE_EXT) -ldflags "-w -s" beszel/cmd/agent
|
||||||
|
|
||||||
build-hub: tidy $(if $(filter false,$(SKIP_WEB)),build-web-ui)
|
build-hub: tidy $(if $(filter false,$(SKIP_WEB)),build-web-ui)
|
||||||
GOOS=$(OS) GOARCH=$(ARCH) go build -o ./build/beszel_$(OS)_$(ARCH)$(EXE_EXT) -ldflags "-w -s" ./internal/cmd/hub
|
GOOS=$(OS) GOARCH=$(ARCH) go build -o ./build/beszel_$(OS)_$(ARCH)$(EXE_EXT) -ldflags "-w -s" beszel/cmd/hub
|
||||||
|
|
||||||
build-hub-dev: tidy
|
build-hub-dev: tidy
|
||||||
mkdir -p ./internal/site/dist && touch ./internal/site/dist/index.html
|
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" ./internal/cmd/hub
|
GOOS=$(OS) GOARCH=$(ARCH) go build -tags development -o ./build/beszel-dev_$(OS)_$(ARCH)$(EXE_EXT) -ldflags "-w -s" beszel/cmd/hub
|
||||||
|
|
||||||
build: build-agent build-hub
|
build: build-agent build-hub
|
||||||
|
|
||||||
generate-locales:
|
generate-locales:
|
||||||
@if [ ! -f ./internal/site/src/locales/en/en.ts ]; then \
|
@if [ ! -f ./site/src/locales/en/en.ts ]; then \
|
||||||
echo "Generating locales..."; \
|
echo "Generating locales..."; \
|
||||||
command -v bun >/dev/null 2>&1 && cd ./internal/site && bun install && bun run sync || cd ./internal/site && npm install && npm run sync; \
|
command -v bun >/dev/null 2>&1 && cd ./site && bun install && bun run sync || cd ./site && npm install && npm run sync; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
dev-server: generate-locales
|
dev-server: generate-locales
|
||||||
cd ./internal/site
|
cd ./site
|
||||||
@if command -v bun >/dev/null 2>&1; then \
|
@if command -v bun >/dev/null 2>&1; then \
|
||||||
cd ./internal/site && bun run dev --host 0.0.0.0; \
|
cd ./site && bun run dev --host 0.0.0.0; \
|
||||||
else \
|
else \
|
||||||
cd ./internal/site && npm run dev --host 0.0.0.0; \
|
cd ./site && npm run dev --host 0.0.0.0; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
dev-hub: export ENV=dev
|
dev-hub: export ENV=dev
|
||||||
dev-hub:
|
dev-hub:
|
||||||
mkdir -p ./internal/site/dist && touch ./internal/site/dist/index.html
|
mkdir -p ./site/dist && touch ./site/dist/index.html
|
||||||
@if command -v entr >/dev/null 2>&1; then \
|
@if command -v entr >/dev/null 2>&1; then \
|
||||||
find ./internal -type f -name '*.go' | entr -r -s "cd ./internal/cmd/hub && go run -tags development . serve --http 0.0.0.0:8090"; \
|
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"; \
|
||||||
else \
|
else \
|
||||||
cd ./internal/cmd/hub && go run -tags development . serve --http 0.0.0.0:8090; \
|
cd ./cmd/hub && go run -tags development . serve --http 0.0.0.0:8090; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
dev-agent:
|
dev-agent:
|
||||||
@if command -v entr >/dev/null 2>&1; then \
|
@if command -v entr >/dev/null 2>&1; then \
|
||||||
find ./internal/cmd/agent/*.go ./agent/*.go | entr -r go run github.com/henrygd/beszel/internal/cmd/agent; \
|
find ./cmd/agent/*.go ./internal/agent/*.go | entr -r go run beszel/cmd/agent; \
|
||||||
else \
|
else \
|
||||||
go run github.com/henrygd/beszel/internal/cmd/agent; \
|
go run beszel/cmd/agent; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
build-dotnet:
|
build-dotnet:
|
||||||
@if command -v dotnet >/dev/null 2>&1; then \
|
@if command -v dotnet >/dev/null 2>&1; then \
|
||||||
rm -rf ./agent/lhm/bin; \
|
rm -rf ./internal/agent/lhm/bin; \
|
||||||
dotnet build -c Release ./agent/lhm/beszel_lhm.csproj; \
|
dotnet build -c Release ./internal/agent/lhm/beszel_lhm.csproj; \
|
||||||
else \
|
else \
|
||||||
echo "dotnet not found"; \
|
echo "dotnet not found"; \
|
||||||
fi
|
fi
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"beszel"
|
||||||
|
"beszel/internal/agent"
|
||||||
|
"beszel/internal/agent/health"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/henrygd/beszel"
|
|
||||||
"github.com/henrygd/beszel/agent"
|
|
||||||
"github.com/henrygd/beszel/agent/health"
|
|
||||||
"github.com/spf13/pflag"
|
"github.com/spf13/pflag"
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
)
|
)
|
||||||
@@ -1,13 +1,12 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"beszel/internal/agent"
|
||||||
"crypto/ed25519"
|
"crypto/ed25519"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/agent"
|
|
||||||
|
|
||||||
"github.com/spf13/pflag"
|
"github.com/spf13/pflag"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -1,16 +1,15 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"beszel"
|
||||||
|
"beszel/internal/hub"
|
||||||
|
_ "beszel/migrations"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel"
|
|
||||||
"github.com/henrygd/beszel/internal/hub"
|
|
||||||
_ "github.com/henrygd/beszel/internal/migrations"
|
|
||||||
|
|
||||||
"github.com/pocketbase/pocketbase"
|
"github.com/pocketbase/pocketbase"
|
||||||
"github.com/pocketbase/pocketbase/plugins/migratecmd"
|
"github.com/pocketbase/pocketbase/plugins/migratecmd"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
@@ -2,15 +2,15 @@ FROM --platform=$BUILDPLATFORM golang:alpine AS builder
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY ../go.mod ../go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
# RUN go mod download
|
||||||
|
COPY *.go ./
|
||||||
# Copy source files
|
COPY cmd ./cmd
|
||||||
COPY . ./
|
COPY internal ./internal
|
||||||
|
|
||||||
# Build
|
# Build
|
||||||
ARG TARGETOS TARGETARCH
|
ARG TARGETOS TARGETARCH
|
||||||
RUN CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /agent ./internal/cmd/agent
|
RUN CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /agent ./cmd/agent
|
||||||
|
|
||||||
RUN rm -rf /tmp/*
|
RUN rm -rf /tmp/*
|
||||||
|
|
||||||
@@ -2,18 +2,15 @@ FROM --platform=$BUILDPLATFORM golang:alpine AS builder
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY go.mod go.sum ./
|
||||||
COPY ../go.mod ../go.sum ./
|
# RUN go mod download
|
||||||
RUN go mod download
|
COPY *.go ./
|
||||||
|
COPY cmd ./cmd
|
||||||
# Copy source files
|
COPY internal ./internal
|
||||||
COPY . ./
|
|
||||||
|
|
||||||
# Build
|
# Build
|
||||||
ARG TARGETOS TARGETARCH
|
ARG TARGETOS TARGETARCH
|
||||||
RUN CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /agent ./internal/cmd/agent
|
RUN CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /agent ./cmd/agent
|
||||||
|
|
||||||
RUN rm -rf /tmp/*
|
|
||||||
|
|
||||||
# --------------------------
|
# --------------------------
|
||||||
# Final image: GPU-enabled agent with nvidia-smi
|
# Final image: GPU-enabled agent with nvidia-smi
|
||||||
@@ -21,7 +18,4 @@ RUN rm -rf /tmp/*
|
|||||||
FROM nvidia/cuda:12.2.2-base-ubuntu22.04
|
FROM nvidia/cuda:12.2.2-base-ubuntu22.04
|
||||||
COPY --from=builder /agent /agent
|
COPY --from=builder /agent /agent
|
||||||
|
|
||||||
# this is so we don't need to create the /tmp directory in the scratch container
|
|
||||||
COPY --from=builder /tmp /tmp
|
|
||||||
|
|
||||||
ENTRYPOINT ["/agent"]
|
ENTRYPOINT ["/agent"]
|
||||||
@@ -3,11 +3,16 @@ FROM --platform=$BUILDPLATFORM golang:alpine AS builder
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Download Go modules
|
# Download Go modules
|
||||||
COPY ../go.mod ../go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
# Copy source files
|
# Copy source files
|
||||||
COPY . ./
|
COPY *.go ./
|
||||||
|
COPY cmd ./cmd
|
||||||
|
COPY internal ./internal
|
||||||
|
COPY migrations ./migrations
|
||||||
|
COPY site/dist ./site/dist
|
||||||
|
COPY site/*.go ./site
|
||||||
|
|
||||||
RUN apk add --no-cache \
|
RUN apk add --no-cache \
|
||||||
unzip \
|
unzip \
|
||||||
@@ -17,7 +22,7 @@ RUN update-ca-certificates
|
|||||||
|
|
||||||
# Build
|
# Build
|
||||||
ARG TARGETOS TARGETARCH
|
ARG TARGETOS TARGETARCH
|
||||||
RUN CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /beszel ./internal/cmd/hub
|
RUN CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /beszel ./cmd/hub
|
||||||
|
|
||||||
# ? -------------------------
|
# ? -------------------------
|
||||||
FROM scratch
|
FROM scratch
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
module github.com/henrygd/beszel
|
module beszel
|
||||||
|
|
||||||
go 1.25.1
|
go 1.24.4
|
||||||
|
|
||||||
// lock shoutrrr to specific version to allow review before updating
|
// lock shoutrrr to specific version to allow review before updating
|
||||||
replace github.com/nicholas-fedor/shoutrrr => github.com/nicholas-fedor/shoutrrr v0.8.8
|
replace github.com/nicholas-fedor/shoutrrr => github.com/nicholas-fedor/shoutrrr v0.8.8
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
// Package agent implements the Beszel monitoring agent that collects and serves system metrics.
|
// Package agent handles the agent's SSH server and system stats collection.
|
||||||
//
|
|
||||||
// The agent runs on monitored systems and communicates collected data
|
|
||||||
// to the Beszel hub for centralized monitoring and alerting.
|
|
||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"beszel"
|
||||||
|
"beszel/internal/entities/system"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -15,8 +14,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gliderlabs/ssh"
|
"github.com/gliderlabs/ssh"
|
||||||
"github.com/henrygd/beszel"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
|
||||||
"github.com/shirou/gopsutil/v4/host"
|
"github.com/shirou/gopsutil/v4/host"
|
||||||
gossh "golang.org/x/crypto/ssh"
|
gossh "golang.org/x/crypto/ssh"
|
||||||
)
|
)
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"beszel/internal/entities/system"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Not thread safe since we only access from gatherStats which is already locked
|
// Not thread safe since we only access from gatherStats which is already locked
|
||||||
@@ -4,12 +4,11 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"beszel/internal/entities/system"
|
||||||
"testing"
|
"testing"
|
||||||
"testing/synctest"
|
"testing/synctest"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
@@ -20,8 +20,9 @@ func HasReadableBattery() bool {
|
|||||||
}
|
}
|
||||||
haveCheckedBattery = true
|
haveCheckedBattery = true
|
||||||
bat, err := battery.Get(0)
|
bat, err := battery.Get(0)
|
||||||
systemHasBattery = err == nil && bat != nil && bat.Design != 0 && bat.Full != 0
|
if err == nil && bat != nil {
|
||||||
if !systemHasBattery {
|
systemHasBattery = true
|
||||||
|
} else {
|
||||||
slog.Debug("No battery found", "err", err)
|
slog.Debug("No battery found", "err", err)
|
||||||
}
|
}
|
||||||
return systemHasBattery
|
return systemHasBattery
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"beszel"
|
||||||
|
"beszel/internal/common"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -13,9 +15,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel"
|
|
||||||
"github.com/henrygd/beszel/internal/common"
|
|
||||||
|
|
||||||
"github.com/fxamacker/cbor/v2"
|
"github.com/fxamacker/cbor/v2"
|
||||||
"github.com/lxzan/gws"
|
"github.com/lxzan/gws"
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
@@ -85,7 +84,7 @@ func getToken() (string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return strings.TrimSpace(string(tokenBytes)), nil
|
return string(tokenBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// getOptions returns the WebSocket client options, creating them if necessary.
|
// getOptions returns the WebSocket client options, creating them if necessary.
|
||||||
@@ -4,6 +4,8 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"beszel"
|
||||||
|
"beszel/internal/common"
|
||||||
"crypto/ed25519"
|
"crypto/ed25519"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
@@ -11,10 +13,6 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/common"
|
|
||||||
|
|
||||||
"github.com/fxamacker/cbor/v2"
|
"github.com/fxamacker/cbor/v2"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -537,25 +535,4 @@ func TestGetToken(t *testing.T) {
|
|||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, "", token, "Empty file should return empty string")
|
assert.Equal(t, "", token, "Empty file should return empty string")
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("strips whitespace from TOKEN_FILE", func(t *testing.T) {
|
|
||||||
unsetEnvVars()
|
|
||||||
|
|
||||||
tokenWithWhitespace := " test-token-with-whitespace \n\t"
|
|
||||||
expectedToken := "test-token-with-whitespace"
|
|
||||||
tokenFile, err := os.CreateTemp("", "token-test-*.txt")
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer os.Remove(tokenFile.Name())
|
|
||||||
|
|
||||||
_, err = tokenFile.WriteString(tokenWithWhitespace)
|
|
||||||
require.NoError(t, err)
|
|
||||||
tokenFile.Close()
|
|
||||||
|
|
||||||
os.Setenv("TOKEN_FILE", tokenFile.Name())
|
|
||||||
defer os.Unsetenv("TOKEN_FILE")
|
|
||||||
|
|
||||||
token, err := getToken()
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Equal(t, expectedToken, token, "Whitespace should be stripped from token file content")
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
@@ -1,29 +1,26 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"beszel/internal/agent/health"
|
||||||
"errors"
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/agent/health"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// ConnectionManager manages the connection state and events for the agent.
|
// ConnectionManager manages the connection state and events for the agent.
|
||||||
// It handles both WebSocket and SSH connections, automatically switching between
|
// It handles both WebSocket and SSH connections, automatically switching between
|
||||||
// them based on availability and managing reconnection attempts.
|
// them based on availability and managing reconnection attempts.
|
||||||
type ConnectionManager struct {
|
type ConnectionManager struct {
|
||||||
agent *Agent // Reference to the parent agent
|
agent *Agent // Reference to the parent agent
|
||||||
State ConnectionState // Current connection state
|
State ConnectionState // Current connection state
|
||||||
eventChan chan ConnectionEvent // Channel for connection events
|
eventChan chan ConnectionEvent // Channel for connection events
|
||||||
wsClient *WebSocketClient // WebSocket client for hub communication
|
wsClient *WebSocketClient // WebSocket client for hub communication
|
||||||
serverOptions ServerOptions // Configuration for SSH server
|
serverOptions ServerOptions // Configuration for SSH server
|
||||||
wsTicker *time.Ticker // Ticker for WebSocket connection attempts
|
wsTicker *time.Ticker // Ticker for WebSocket connection attempts
|
||||||
isConnecting bool // Prevents multiple simultaneous reconnection attempts
|
isConnecting bool // Prevents multiple simultaneous reconnection attempts
|
||||||
ConnectionType system.ConnectionType
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConnectionState represents the current connection state of the agent.
|
// ConnectionState represents the current connection state of the agent.
|
||||||
@@ -146,18 +143,15 @@ func (c *ConnectionManager) handleStateChange(newState ConnectionState) {
|
|||||||
switch newState {
|
switch newState {
|
||||||
case WebSocketConnected:
|
case WebSocketConnected:
|
||||||
slog.Info("WebSocket connected", "host", c.wsClient.hubURL.Host)
|
slog.Info("WebSocket connected", "host", c.wsClient.hubURL.Host)
|
||||||
c.ConnectionType = system.ConnectionTypeWebSocket
|
|
||||||
c.stopWsTicker()
|
c.stopWsTicker()
|
||||||
_ = c.agent.StopServer()
|
_ = c.agent.StopServer()
|
||||||
c.isConnecting = false
|
c.isConnecting = false
|
||||||
case SSHConnected:
|
case SSHConnected:
|
||||||
// stop new ws connection attempts
|
// stop new ws connection attempts
|
||||||
slog.Info("SSH connection established")
|
slog.Info("SSH connection established")
|
||||||
c.ConnectionType = system.ConnectionTypeSSH
|
|
||||||
c.stopWsTicker()
|
c.stopWsTicker()
|
||||||
c.isConnecting = false
|
c.isConnecting = false
|
||||||
case Disconnected:
|
case Disconnected:
|
||||||
c.ConnectionType = system.ConnectionTypeNone
|
|
||||||
if c.isConnecting {
|
if c.isConnecting {
|
||||||
// Already handling reconnection, avoid duplicate attempts
|
// Already handling reconnection, avoid duplicate attempts
|
||||||
return
|
return
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"beszel/internal/entities/system"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -8,8 +9,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
|
||||||
|
|
||||||
"github.com/shirou/gopsutil/v4/disk"
|
"github.com/shirou/gopsutil/v4/disk"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"beszel/internal/entities/container"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -14,8 +15,6 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/container"
|
|
||||||
|
|
||||||
"github.com/blang/semver"
|
"github.com/blang/semver"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"beszel/internal/entities/system"
|
||||||
"bufio"
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -12,8 +13,6 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
|
||||||
|
|
||||||
"golang.org/x/exp/slog"
|
"golang.org/x/exp/slog"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -27,10 +26,13 @@ const (
|
|||||||
nvidiaSmiInterval string = "4" // in seconds
|
nvidiaSmiInterval string = "4" // in seconds
|
||||||
tegraStatsInterval string = "3700" // in milliseconds
|
tegraStatsInterval string = "3700" // in milliseconds
|
||||||
rocmSmiInterval time.Duration = 4300 * time.Millisecond
|
rocmSmiInterval time.Duration = 4300 * time.Millisecond
|
||||||
|
|
||||||
// Command retry and timeout constants
|
// Command retry and timeout constants
|
||||||
retryWaitTime time.Duration = 5 * time.Second
|
retryWaitTime time.Duration = 5 * time.Second
|
||||||
maxFailureRetries int = 5
|
maxFailureRetries int = 5
|
||||||
|
|
||||||
|
cmdBufferSize uint16 = 10 * 1024
|
||||||
|
|
||||||
// Unit Conversions
|
// Unit Conversions
|
||||||
mebibytesInAMegabyte float64 = 1.024 // nvidia-smi reports memory in MiB
|
mebibytesInAMegabyte float64 = 1.024 // nvidia-smi reports memory in MiB
|
||||||
milliwattsInAWatt float64 = 1000.0 // tegrastats reports power in mW
|
milliwattsInAWatt float64 = 1000.0 // tegrastats reports power in mW
|
||||||
@@ -39,11 +41,10 @@ const (
|
|||||||
// GPUManager manages data collection for GPUs (either Nvidia or AMD)
|
// GPUManager manages data collection for GPUs (either Nvidia or AMD)
|
||||||
type GPUManager struct {
|
type GPUManager struct {
|
||||||
sync.Mutex
|
sync.Mutex
|
||||||
nvidiaSmi bool
|
nvidiaSmi bool
|
||||||
rocmSmi bool
|
rocmSmi bool
|
||||||
tegrastats bool
|
tegrastats bool
|
||||||
intelGpuStats bool
|
GpuDataMap map[string]*system.GPUData
|
||||||
GpuDataMap map[string]*system.GPUData
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// RocmSmiJson represents the JSON structure of rocm-smi output
|
// RocmSmiJson represents the JSON structure of rocm-smi output
|
||||||
@@ -64,7 +65,6 @@ type gpuCollector struct {
|
|||||||
cmdArgs []string
|
cmdArgs []string
|
||||||
parse func([]byte) bool // returns true if valid data was found
|
parse func([]byte) bool // returns true if valid data was found
|
||||||
buf []byte
|
buf []byte
|
||||||
bufSize uint16
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var errNoValidData = fmt.Errorf("no valid GPU data found") // Error for missing data
|
var errNoValidData = fmt.Errorf("no valid GPU data found") // Error for missing data
|
||||||
@@ -98,7 +98,7 @@ func (c *gpuCollector) collect() error {
|
|||||||
|
|
||||||
scanner := bufio.NewScanner(stdout)
|
scanner := bufio.NewScanner(stdout)
|
||||||
if c.buf == nil {
|
if c.buf == nil {
|
||||||
c.buf = make([]byte, 0, c.bufSize)
|
c.buf = make([]byte, 0, cmdBufferSize)
|
||||||
}
|
}
|
||||||
scanner.Buffer(c.buf, bufio.MaxScanTokenSize)
|
scanner.Buffer(c.buf, bufio.MaxScanTokenSize)
|
||||||
|
|
||||||
@@ -243,31 +243,20 @@ func (gm *GPUManager) GetCurrentData() map[string]system.GPUData {
|
|||||||
// copy / reset the data
|
// copy / reset the data
|
||||||
gpuData := make(map[string]system.GPUData, len(gm.GpuDataMap))
|
gpuData := make(map[string]system.GPUData, len(gm.GpuDataMap))
|
||||||
for id, gpu := range gm.GpuDataMap {
|
for id, gpu := range gm.GpuDataMap {
|
||||||
// avoid division by zero
|
|
||||||
count := max(gpu.Count, 1)
|
|
||||||
|
|
||||||
// average the data
|
|
||||||
gpuAvg := *gpu
|
gpuAvg := *gpu
|
||||||
gpuAvg.Temperature = twoDecimals(gpu.Temperature)
|
|
||||||
gpuAvg.Power = twoDecimals(gpu.Power / count)
|
|
||||||
|
|
||||||
// intel gpu stats doesn't provide usage, memory used, or memory total
|
gpuAvg.Temperature = twoDecimals(gpu.Temperature)
|
||||||
if gm.intelGpuStats {
|
gpuAvg.MemoryUsed = twoDecimals(gpu.MemoryUsed)
|
||||||
maxEngineUsage := 0.0
|
gpuAvg.MemoryTotal = twoDecimals(gpu.MemoryTotal)
|
||||||
for name, engine := range gpu.Engines {
|
|
||||||
gpuAvg.Engines[name] = twoDecimals(engine / count)
|
// avoid division by zero
|
||||||
maxEngineUsage = max(maxEngineUsage, engine/count)
|
if gpu.Count > 0 {
|
||||||
}
|
gpuAvg.Usage = twoDecimals(gpu.Usage / gpu.Count)
|
||||||
gpuAvg.Usage = twoDecimals(maxEngineUsage)
|
gpuAvg.Power = twoDecimals(gpu.Power / gpu.Count)
|
||||||
} else {
|
|
||||||
gpuAvg.Usage = twoDecimals(gpu.Usage / count)
|
|
||||||
gpuAvg.MemoryUsed = twoDecimals(gpu.MemoryUsed)
|
|
||||||
gpuAvg.MemoryTotal = twoDecimals(gpu.MemoryTotal)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// reset accumulators in the original gpu data for next collection
|
// reset accumulators in the original
|
||||||
gpu.Usage, gpu.Power, gpu.Count = gpuAvg.Usage, gpuAvg.Power, 1
|
gpu.Usage, gpu.Power, gpu.Count = 0, 0, 0
|
||||||
gpu.Engines = gpuAvg.Engines
|
|
||||||
|
|
||||||
// append id to the name if there are multiple GPUs with the same name
|
// append id to the name if there are multiple GPUs with the same name
|
||||||
if nameCounts[gpu.Name] > 1 {
|
if nameCounts[gpu.Name] > 1 {
|
||||||
@@ -294,37 +283,18 @@ func (gm *GPUManager) detectGPUs() error {
|
|||||||
gm.tegrastats = true
|
gm.tegrastats = true
|
||||||
gm.nvidiaSmi = false
|
gm.nvidiaSmi = false
|
||||||
}
|
}
|
||||||
if _, err := exec.LookPath(intelGpuStatsCmd); err == nil {
|
if gm.nvidiaSmi || gm.rocmSmi || gm.tegrastats {
|
||||||
gm.intelGpuStats = true
|
|
||||||
}
|
|
||||||
if gm.nvidiaSmi || gm.rocmSmi || gm.tegrastats || gm.intelGpuStats {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return fmt.Errorf("no GPU found - install nvidia-smi, rocm-smi, tegrastats, or intel_gpu_top")
|
return fmt.Errorf("no GPU found - install nvidia-smi, rocm-smi, or tegrastats")
|
||||||
}
|
}
|
||||||
|
|
||||||
// startCollector starts the appropriate GPU data collector based on the command
|
// startCollector starts the appropriate GPU data collector based on the command
|
||||||
func (gm *GPUManager) startCollector(command string) {
|
func (gm *GPUManager) startCollector(command string) {
|
||||||
collector := gpuCollector{
|
collector := gpuCollector{
|
||||||
name: command,
|
name: command,
|
||||||
bufSize: 10 * 1024,
|
|
||||||
}
|
}
|
||||||
switch command {
|
switch command {
|
||||||
case intelGpuStatsCmd:
|
|
||||||
go func() {
|
|
||||||
failures := 0
|
|
||||||
for {
|
|
||||||
if err := gm.collectIntelStats(); err != nil {
|
|
||||||
failures++
|
|
||||||
if failures > maxFailureRetries {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
slog.Warn("Error collecting Intel GPU data; see https://beszel.dev/guide/gpu", "err", err)
|
|
||||||
time.Sleep(retryWaitTime)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
case nvidiaSmiCmd:
|
case nvidiaSmiCmd:
|
||||||
collector.cmdArgs = []string{
|
collector.cmdArgs = []string{
|
||||||
"-l", nvidiaSmiInterval,
|
"-l", nvidiaSmiInterval,
|
||||||
@@ -373,9 +343,6 @@ func NewGPUManager() (*GPUManager, error) {
|
|||||||
if gm.tegrastats {
|
if gm.tegrastats {
|
||||||
gm.startCollector(tegraStatsCmd)
|
gm.startCollector(tegraStatsCmd)
|
||||||
}
|
}
|
||||||
if gm.intelGpuStats {
|
|
||||||
gm.startCollector(intelGpuStatsCmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &gm, nil
|
return &gm, nil
|
||||||
}
|
}
|
||||||
@@ -4,13 +4,12 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"beszel/internal/entities/system"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
@@ -792,96 +791,3 @@ func TestAccumulation(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestIntelUpdateFromStats(t *testing.T) {
|
|
||||||
gm := &GPUManager{
|
|
||||||
GpuDataMap: make(map[string]*system.GPUData),
|
|
||||||
}
|
|
||||||
|
|
||||||
// First sample with power and two engines
|
|
||||||
sample1 := intelGpuStats{
|
|
||||||
Engines: map[string]struct {
|
|
||||||
Busy float64 `json:"busy"`
|
|
||||||
}{
|
|
||||||
"Render/3D": {Busy: 20.0},
|
|
||||||
"Video": {Busy: 5.0},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
sample1.Power.GPU = 10.5
|
|
||||||
|
|
||||||
ok := gm.updateIntelFromStats(&sample1)
|
|
||||||
assert.True(t, ok)
|
|
||||||
|
|
||||||
gpu := gm.GpuDataMap["0"]
|
|
||||||
require.NotNil(t, gpu)
|
|
||||||
assert.Equal(t, "GPU", gpu.Name)
|
|
||||||
assert.InDelta(t, 10.5, gpu.Power, 0.001)
|
|
||||||
assert.InDelta(t, 20.0, gpu.Engines["Render/3D"], 0.001)
|
|
||||||
assert.InDelta(t, 5.0, gpu.Engines["Video"], 0.001)
|
|
||||||
assert.Equal(t, float64(1), gpu.Count)
|
|
||||||
|
|
||||||
// Second sample with zero power (should not add) and additional engine busy
|
|
||||||
sample2 := intelGpuStats{
|
|
||||||
Engines: map[string]struct {
|
|
||||||
Busy float64 `json:"busy"`
|
|
||||||
}{
|
|
||||||
"Render/3D": {Busy: 10.0},
|
|
||||||
"Video": {Busy: 2.5},
|
|
||||||
"Blitter": {Busy: 1.0},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
// zero power should not increment power accumulator
|
|
||||||
sample2.Power.GPU = 0.0
|
|
||||||
|
|
||||||
ok = gm.updateIntelFromStats(&sample2)
|
|
||||||
assert.True(t, ok)
|
|
||||||
|
|
||||||
gpu = gm.GpuDataMap["0"]
|
|
||||||
require.NotNil(t, gpu)
|
|
||||||
assert.InDelta(t, 10.5, gpu.Power, 0.001)
|
|
||||||
assert.InDelta(t, 30.0, gpu.Engines["Render/3D"], 0.001) // 20 + 10
|
|
||||||
assert.InDelta(t, 7.5, gpu.Engines["Video"], 0.001) // 5 + 2.5
|
|
||||||
assert.InDelta(t, 1.0, gpu.Engines["Blitter"], 0.001)
|
|
||||||
assert.Equal(t, float64(2), gpu.Count)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIntelCollectorStreaming(t *testing.T) {
|
|
||||||
// Save and override PATH
|
|
||||||
origPath := os.Getenv("PATH")
|
|
||||||
defer os.Setenv("PATH", origPath)
|
|
||||||
|
|
||||||
dir := t.TempDir()
|
|
||||||
os.Setenv("PATH", dir)
|
|
||||||
|
|
||||||
// Create a fake intel_gpu_top that prints a JSON array with two samples and exits
|
|
||||||
scriptPath := filepath.Join(dir, "intel_gpu_top")
|
|
||||||
script := `#!/bin/sh
|
|
||||||
# Ignore args -s and -J
|
|
||||||
# Emit a JSON array with two objects, separated by a comma, then exit
|
|
||||||
(echo '['; \
|
|
||||||
echo '{"power":{"GPU":1.5},"engines":{"Render/3D":{"busy":12.34}}},'; \
|
|
||||||
echo '{"power":{"GPU":2.0},"engines":{"Video":{"busy":5}}}'; \
|
|
||||||
echo ']')`
|
|
||||||
if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
gm := &GPUManager{
|
|
||||||
GpuDataMap: make(map[string]*system.GPUData),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run the collector once; it should read two samples and return
|
|
||||||
if err := gm.collectIntelStats(); err != nil {
|
|
||||||
t.Fatalf("collectIntelStats error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
gpu := gm.GpuDataMap["0"]
|
|
||||||
require.NotNil(t, gpu)
|
|
||||||
// Power should be sum of non-zero samples: 1.5 + 2.0 = 3.5
|
|
||||||
assert.InDelta(t, 3.5, gpu.Power, 0.001)
|
|
||||||
// Engines aggregated
|
|
||||||
assert.InDelta(t, 12.34, gpu.Engines["Render/3D"], 0.001)
|
|
||||||
assert.InDelta(t, 5.0, gpu.Engines["Video"], 0.001)
|
|
||||||
// Count should be 2 samples
|
|
||||||
assert.Equal(t, float64(2), gpu.Count)
|
|
||||||
}
|
|
||||||
67
beszel/internal/agent/network.go
Normal file
67
beszel/internal/agent/network.go
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
psutilNet "github.com/shirou/gopsutil/v4/net"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a *Agent) initializeNetIoStats() {
|
||||||
|
// reset valid network interfaces
|
||||||
|
a.netInterfaces = make(map[string]struct{}, 0)
|
||||||
|
|
||||||
|
// map of network interface names passed in via NICS env var
|
||||||
|
var nicsMap map[string]struct{}
|
||||||
|
nics, nicsEnvExists := GetEnv("NICS")
|
||||||
|
if nicsEnvExists {
|
||||||
|
nicsMap = make(map[string]struct{}, 0)
|
||||||
|
for nic := range strings.SplitSeq(nics, ",") {
|
||||||
|
nicsMap[nic] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// reset network I/O stats
|
||||||
|
a.netIoStats.BytesSent = 0
|
||||||
|
a.netIoStats.BytesRecv = 0
|
||||||
|
|
||||||
|
// get intial network I/O stats
|
||||||
|
if netIO, err := psutilNet.IOCounters(true); err == nil {
|
||||||
|
a.netIoStats.Time = time.Now()
|
||||||
|
for _, v := range netIO {
|
||||||
|
switch {
|
||||||
|
// skip if nics exists and the interface is not in the list
|
||||||
|
case nicsEnvExists:
|
||||||
|
if _, nameInNics := nicsMap[v.Name]; !nameInNics {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// otherwise run the interface name through the skipNetworkInterface function
|
||||||
|
default:
|
||||||
|
if a.skipNetworkInterface(v) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slog.Info("Detected network interface", "name", v.Name, "sent", v.BytesSent, "recv", v.BytesRecv)
|
||||||
|
a.netIoStats.BytesSent += v.BytesSent
|
||||||
|
a.netIoStats.BytesRecv += v.BytesRecv
|
||||||
|
// store as a valid network interface
|
||||||
|
a.netInterfaces[v.Name] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Agent) skipNetworkInterface(v psutilNet.IOCountersStat) bool {
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(v.Name, "lo"),
|
||||||
|
strings.HasPrefix(v.Name, "docker"),
|
||||||
|
strings.HasPrefix(v.Name, "br-"),
|
||||||
|
strings.HasPrefix(v.Name, "veth"),
|
||||||
|
strings.HasPrefix(v.Name, "bond"),
|
||||||
|
v.BytesRecv == 0,
|
||||||
|
v.BytesSent == 0:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"beszel/internal/entities/system"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -10,8 +11,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
|
||||||
|
|
||||||
"github.com/shirou/gopsutil/v4/common"
|
"github.com/shirou/gopsutil/v4/common"
|
||||||
"github.com/shirou/gopsutil/v4/sensors"
|
"github.com/shirou/gopsutil/v4/sensors"
|
||||||
)
|
)
|
||||||
@@ -4,13 +4,12 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"beszel/internal/entities/system"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
|
||||||
|
|
||||||
"github.com/shirou/gopsutil/v4/common"
|
"github.com/shirou/gopsutil/v4/common"
|
||||||
"github.com/shirou/gopsutil/v4/sensors"
|
"github.com/shirou/gopsutil/v4/sensors"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -46,10 +46,9 @@ var lhmFs embed.FS
|
|||||||
var (
|
var (
|
||||||
beszelLhm *lhmProcess
|
beszelLhm *lhmProcess
|
||||||
beszelLhmOnce sync.Once
|
beszelLhmOnce sync.Once
|
||||||
useLHM = os.Getenv("LHM") == "true"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var errNoSensors = errors.New("no sensors found (try running as admin with LHM=true)")
|
var errNoSensors = errors.New("no sensors found (try running as admin)")
|
||||||
|
|
||||||
// newlhmProcess copies the embedded LHM executable to a temporary directory and starts it.
|
// newlhmProcess copies the embedded LHM executable to a temporary directory and starts it.
|
||||||
func newlhmProcess() (*lhmProcess, error) {
|
func newlhmProcess() (*lhmProcess, error) {
|
||||||
@@ -140,7 +139,7 @@ func (lhm *lhmProcess) cleanupProcess() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (lhm *lhmProcess) getTemps(ctx context.Context) (temps []sensors.TemperatureStat, err error) {
|
func (lhm *lhmProcess) getTemps(ctx context.Context) (temps []sensors.TemperatureStat, err error) {
|
||||||
if !useLHM || lhm.stoppedNoSensors {
|
if lhm.stoppedNoSensors {
|
||||||
// Fall back to gopsutil if we can't get sensors from LHM
|
// Fall back to gopsutil if we can't get sensors from LHM
|
||||||
return sensors.TemperaturesWithContext(ctx)
|
return sensors.TemperaturesWithContext(ctx)
|
||||||
}
|
}
|
||||||
@@ -223,10 +222,6 @@ func getSensorTemps(ctx context.Context) (temps []sensors.TemperatureStat, err e
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
if !useLHM {
|
|
||||||
return sensors.TemperaturesWithContext(ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize process once
|
// Initialize process once
|
||||||
beszelLhmOnce.Do(func() {
|
beszelLhmOnce.Do(func() {
|
||||||
beszelLhm, err = newlhmProcess()
|
beszelLhm, err = newlhmProcess()
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"beszel"
|
||||||
|
"beszel/internal/common"
|
||||||
|
"beszel/internal/entities/system"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -11,10 +14,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel"
|
|
||||||
"github.com/henrygd/beszel/internal/common"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
|
||||||
|
|
||||||
"github.com/blang/semver"
|
"github.com/blang/semver"
|
||||||
"github.com/fxamacker/cbor/v2"
|
"github.com/fxamacker/cbor/v2"
|
||||||
"github.com/gliderlabs/ssh"
|
"github.com/gliderlabs/ssh"
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"beszel/internal/entities/container"
|
||||||
|
"beszel/internal/entities/system"
|
||||||
"context"
|
"context"
|
||||||
"crypto/ed25519"
|
"crypto/ed25519"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -13,9 +15,6 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/container"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
|
||||||
|
|
||||||
"github.com/blang/semver"
|
"github.com/blang/semver"
|
||||||
"github.com/fxamacker/cbor/v2"
|
"github.com/fxamacker/cbor/v2"
|
||||||
"github.com/gliderlabs/ssh"
|
"github.com/gliderlabs/ssh"
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"beszel"
|
||||||
|
"beszel/internal/agent/battery"
|
||||||
|
"beszel/internal/entities/system"
|
||||||
"bufio"
|
"bufio"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -9,15 +12,12 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel"
|
|
||||||
"github.com/henrygd/beszel/agent/battery"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
|
||||||
|
|
||||||
"github.com/shirou/gopsutil/v4/cpu"
|
"github.com/shirou/gopsutil/v4/cpu"
|
||||||
"github.com/shirou/gopsutil/v4/disk"
|
"github.com/shirou/gopsutil/v4/disk"
|
||||||
"github.com/shirou/gopsutil/v4/host"
|
"github.com/shirou/gopsutil/v4/host"
|
||||||
"github.com/shirou/gopsutil/v4/load"
|
"github.com/shirou/gopsutil/v4/load"
|
||||||
"github.com/shirou/gopsutil/v4/mem"
|
"github.com/shirou/gopsutil/v4/mem"
|
||||||
|
psutilNet "github.com/shirou/gopsutil/v4/net"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Sets initial / non-changing values about the host system
|
// Sets initial / non-changing values about the host system
|
||||||
@@ -31,7 +31,7 @@ func (a *Agent) initializeSystemInfo() {
|
|||||||
a.systemInfo.KernelVersion = version
|
a.systemInfo.KernelVersion = version
|
||||||
a.systemInfo.Os = system.Darwin
|
a.systemInfo.Os = system.Darwin
|
||||||
} else if strings.Contains(platform, "indows") {
|
} else if strings.Contains(platform, "indows") {
|
||||||
a.systemInfo.KernelVersion = fmt.Sprintf("%s %s", strings.Replace(platform, "Microsoft ", "", 1), version)
|
a.systemInfo.KernelVersion = strings.Replace(platform, "Microsoft ", "", 1) + " " + version
|
||||||
a.systemInfo.Os = system.Windows
|
a.systemInfo.Os = system.Windows
|
||||||
} else if platform == "freebsd" {
|
} else if platform == "freebsd" {
|
||||||
a.systemInfo.Os = system.Freebsd
|
a.systemInfo.Os = system.Freebsd
|
||||||
@@ -69,7 +69,7 @@ func (a *Agent) initializeSystemInfo() {
|
|||||||
|
|
||||||
// Returns current info, stats about the host system
|
// Returns current info, stats about the host system
|
||||||
func (a *Agent) getSystemStats() system.Stats {
|
func (a *Agent) getSystemStats() system.Stats {
|
||||||
var systemStats system.Stats
|
systemStats := system.Stats{}
|
||||||
|
|
||||||
// battery
|
// battery
|
||||||
if battery.HasReadableBattery() {
|
if battery.HasReadableBattery() {
|
||||||
@@ -172,7 +172,55 @@ func (a *Agent) getSystemStats() system.Stats {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// network stats
|
// network stats
|
||||||
a.updateNetworkStats(&systemStats)
|
if len(a.netInterfaces) == 0 {
|
||||||
|
// if no network interfaces, initialize again
|
||||||
|
// this is a fix if agent started before network is online (#466)
|
||||||
|
// maybe refactor this in the future to not cache interface names at all so we
|
||||||
|
// don't miss an interface that's been added after agent started in any circumstance
|
||||||
|
a.initializeNetIoStats()
|
||||||
|
}
|
||||||
|
if netIO, err := psutilNet.IOCounters(true); err == nil {
|
||||||
|
msElapsed := uint64(time.Since(a.netIoStats.Time).Milliseconds())
|
||||||
|
a.netIoStats.Time = time.Now()
|
||||||
|
totalBytesSent := uint64(0)
|
||||||
|
totalBytesRecv := uint64(0)
|
||||||
|
// sum all bytes sent and received
|
||||||
|
for _, v := range netIO {
|
||||||
|
// skip if not in valid network interfaces list
|
||||||
|
if _, exists := a.netInterfaces[v.Name]; !exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
totalBytesSent += v.BytesSent
|
||||||
|
totalBytesRecv += v.BytesRecv
|
||||||
|
}
|
||||||
|
// add to systemStats
|
||||||
|
var bytesSentPerSecond, bytesRecvPerSecond uint64
|
||||||
|
if msElapsed > 0 {
|
||||||
|
bytesSentPerSecond = (totalBytesSent - a.netIoStats.BytesSent) * 1000 / msElapsed
|
||||||
|
bytesRecvPerSecond = (totalBytesRecv - a.netIoStats.BytesRecv) * 1000 / msElapsed
|
||||||
|
}
|
||||||
|
networkSentPs := bytesToMegabytes(float64(bytesSentPerSecond))
|
||||||
|
networkRecvPs := bytesToMegabytes(float64(bytesRecvPerSecond))
|
||||||
|
// add check for issue (#150) where sent is a massive number
|
||||||
|
if networkSentPs > 10_000 || networkRecvPs > 10_000 {
|
||||||
|
slog.Warn("Invalid net stats. Resetting.", "sent", networkSentPs, "recv", networkRecvPs)
|
||||||
|
for _, v := range netIO {
|
||||||
|
if _, exists := a.netInterfaces[v.Name]; !exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
slog.Info(v.Name, "recv", v.BytesRecv, "sent", v.BytesSent)
|
||||||
|
}
|
||||||
|
// reset network I/O stats
|
||||||
|
a.initializeNetIoStats()
|
||||||
|
} else {
|
||||||
|
systemStats.NetworkSent = networkSentPs
|
||||||
|
systemStats.NetworkRecv = networkRecvPs
|
||||||
|
systemStats.Bandwidth[0], systemStats.Bandwidth[1] = bytesSentPerSecond, bytesRecvPerSecond
|
||||||
|
// update netIoStats
|
||||||
|
a.netIoStats.BytesSent = totalBytesSent
|
||||||
|
a.netIoStats.BytesRecv = totalBytesRecv
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// temperatures
|
// temperatures
|
||||||
// TODO: maybe refactor to methods on systemStats
|
// TODO: maybe refactor to methods on systemStats
|
||||||
@@ -212,7 +260,6 @@ func (a *Agent) getSystemStats() system.Stats {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// update base system info
|
// update base system info
|
||||||
a.systemInfo.ConnectionType = a.connectionManager.ConnectionType
|
|
||||||
a.systemInfo.Cpu = systemStats.Cpu
|
a.systemInfo.Cpu = systemStats.Cpu
|
||||||
a.systemInfo.LoadAvg = systemStats.LoadAvg
|
a.systemInfo.LoadAvg = systemStats.LoadAvg
|
||||||
// TODO: remove these in future release in favor of load avg array
|
// TODO: remove these in future release in favor of load avg array
|
||||||
@@ -1,14 +1,12 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"beszel/internal/ghupdate"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"runtime"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/ghupdate"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// restarter knows how to restart the beszel-agent service.
|
// restarter knows how to restart the beszel-agent service.
|
||||||
@@ -47,16 +45,6 @@ func (w *openWRTRestarter) Restart() error {
|
|||||||
return exec.Command(w.cmd, "restart", "beszel-agent").Run()
|
return exec.Command(w.cmd, "restart", "beszel-agent").Run()
|
||||||
}
|
}
|
||||||
|
|
||||||
type freeBSDRestarter struct{ cmd string }
|
|
||||||
|
|
||||||
func (f *freeBSDRestarter) Restart() error {
|
|
||||||
if err := exec.Command(f.cmd, "beszel-agent", "status").Run(); err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
ghupdate.ColorPrint(ghupdate.ColorYellow, "Restarting beszel-agent via FreeBSD rc…")
|
|
||||||
return exec.Command(f.cmd, "beszel-agent", "restart").Run()
|
|
||||||
}
|
|
||||||
|
|
||||||
func detectRestarter() restarter {
|
func detectRestarter() restarter {
|
||||||
if path, err := exec.LookPath("systemctl"); err == nil {
|
if path, err := exec.LookPath("systemctl"); err == nil {
|
||||||
return &systemdRestarter{cmd: path}
|
return &systemdRestarter{cmd: path}
|
||||||
@@ -65,9 +53,6 @@ func detectRestarter() restarter {
|
|||||||
return &openRCRestarter{cmd: path}
|
return &openRCRestarter{cmd: path}
|
||||||
}
|
}
|
||||||
if path, err := exec.LookPath("service"); err == nil {
|
if path, err := exec.LookPath("service"); err == nil {
|
||||||
if runtime.GOOS == "freebsd" {
|
|
||||||
return &freeBSDRestarter{cmd: path}
|
|
||||||
}
|
|
||||||
return &openWRTRestarter{cmd: path}
|
return &openWRTRestarter{cmd: path}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -87,7 +87,7 @@ var supportsTitle = map[string]struct{}{
|
|||||||
func NewAlertManager(app hubLike) *AlertManager {
|
func NewAlertManager(app hubLike) *AlertManager {
|
||||||
am := &AlertManager{
|
am := &AlertManager{
|
||||||
hub: app,
|
hub: app,
|
||||||
alertQueue: make(chan alertTask, 5),
|
alertQueue: make(chan alertTask),
|
||||||
stopChan: make(chan struct{}),
|
stopChan: make(chan struct{}),
|
||||||
}
|
}
|
||||||
am.bindEvents()
|
am.bindEvents()
|
||||||
@@ -42,10 +42,21 @@ func updateHistoryOnAlertUpdate(e *core.RecordEvent) error {
|
|||||||
|
|
||||||
// resolveAlertHistoryRecord sets the resolved field to the current time
|
// resolveAlertHistoryRecord sets the resolved field to the current time
|
||||||
func resolveAlertHistoryRecord(app core.App, alertRecordID string) error {
|
func resolveAlertHistoryRecord(app core.App, alertRecordID string) error {
|
||||||
alertHistoryRecord, err := app.FindFirstRecordByFilter("alerts_history", "alert_id={:alert_id} && resolved=null", dbx.Params{"alert_id": alertRecordID})
|
alertHistoryRecords, err := app.FindRecordsByFilter(
|
||||||
if err != nil || alertHistoryRecord == nil {
|
"alerts_history",
|
||||||
|
"alert_id={:alert_id} && resolved=null",
|
||||||
|
"-created",
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
dbx.Params{"alert_id": alertRecordID},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if len(alertHistoryRecords) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
alertHistoryRecord := alertHistoryRecords[0] // there should be only one record
|
||||||
alertHistoryRecord.Set("resolved", time.Now().UTC())
|
alertHistoryRecord.Set("resolved", time.Now().UTC())
|
||||||
err = app.Save(alertHistoryRecord)
|
err = app.Save(alertHistoryRecord)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -25,12 +25,7 @@ type alertInfo struct {
|
|||||||
// startWorker is a long-running goroutine that processes alert tasks
|
// startWorker is a long-running goroutine that processes alert tasks
|
||||||
// every x seconds. It must be running to process status alerts.
|
// every x seconds. It must be running to process status alerts.
|
||||||
func (am *AlertManager) startWorker() {
|
func (am *AlertManager) startWorker() {
|
||||||
processPendingAlerts := time.Tick(15 * time.Second)
|
tick := time.Tick(15 * time.Second)
|
||||||
|
|
||||||
// check for status alerts that are not resolved when system comes up
|
|
||||||
// (can be removed if we figure out core bug in #1052)
|
|
||||||
checkStatusAlerts := time.Tick(561 * time.Second)
|
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-am.stopChan:
|
case <-am.stopChan:
|
||||||
@@ -46,9 +41,7 @@ func (am *AlertManager) startWorker() {
|
|||||||
case "cancel":
|
case "cancel":
|
||||||
am.pendingAlerts.Delete(task.alertRecord.Id)
|
am.pendingAlerts.Delete(task.alertRecord.Id)
|
||||||
}
|
}
|
||||||
case <-checkStatusAlerts:
|
case <-tick:
|
||||||
resolveStatusAlerts(am.hub)
|
|
||||||
case <-processPendingAlerts:
|
|
||||||
// Check for expired alerts every tick
|
// Check for expired alerts every tick
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
for key, value := range am.pendingAlerts.Range {
|
for key, value := range am.pendingAlerts.Range {
|
||||||
@@ -177,35 +170,3 @@ func (am *AlertManager) sendStatusAlert(alertStatus string, systemName string, a
|
|||||||
LinkText: "View " + systemName,
|
LinkText: "View " + systemName,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveStatusAlerts resolves any status alerts that weren't resolved
|
|
||||||
// when system came up (https://github.com/henrygd/beszel/issues/1052)
|
|
||||||
func resolveStatusAlerts(app core.App) error {
|
|
||||||
db := app.DB()
|
|
||||||
// Find all active status alerts where the system is actually up
|
|
||||||
var alertIds []string
|
|
||||||
err := db.NewQuery(`
|
|
||||||
SELECT a.id
|
|
||||||
FROM alerts a
|
|
||||||
JOIN systems s ON a.system = s.id
|
|
||||||
WHERE a.name = 'Status'
|
|
||||||
AND a.triggered = true
|
|
||||||
AND s.status = 'up'
|
|
||||||
`).Column(&alertIds)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
// resolve all matching alert records
|
|
||||||
for _, alertId := range alertIds {
|
|
||||||
alert, err := app.FindRecordById("alerts", alertId)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
alert.Set("triggered", false)
|
|
||||||
err = app.Save(alert)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,12 @@
|
|||||||
package alerts
|
package alerts
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"beszel/internal/entities/system"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
|
||||||
|
|
||||||
"github.com/pocketbase/dbx"
|
"github.com/pocketbase/dbx"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
"github.com/pocketbase/pocketbase/tools/types"
|
"github.com/pocketbase/pocketbase/tools/types"
|
||||||
368
beszel/internal/alerts/alerts_test.go
Normal file
368
beszel/internal/alerts/alerts_test.go
Normal file
@@ -0,0 +1,368 @@
|
|||||||
|
//go:build testing
|
||||||
|
// +build testing
|
||||||
|
|
||||||
|
package alerts_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
beszelTests "beszel/internal/tests"
|
||||||
|
|
||||||
|
"github.com/pocketbase/dbx"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
pbTests "github.com/pocketbase/pocketbase/tests"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// marshal to json and return an io.Reader (for use in ApiScenario.Body)
|
||||||
|
func jsonReader(v any) io.Reader {
|
||||||
|
data, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return bytes.NewReader(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserAlertsApi(t *testing.T) {
|
||||||
|
hub, _ := beszelTests.NewTestHub(t.TempDir())
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
hub.StartHub()
|
||||||
|
|
||||||
|
user1, _ := beszelTests.CreateUser(hub, "alertstest@example.com", "password")
|
||||||
|
user1Token, _ := user1.NewAuthToken()
|
||||||
|
|
||||||
|
user2, _ := beszelTests.CreateUser(hub, "alertstest2@example.com", "password")
|
||||||
|
user2Token, _ := user2.NewAuthToken()
|
||||||
|
|
||||||
|
system1, _ := beszelTests.CreateRecord(hub, "systems", map[string]any{
|
||||||
|
"name": "system1",
|
||||||
|
"users": []string{user1.Id},
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
})
|
||||||
|
|
||||||
|
system2, _ := beszelTests.CreateRecord(hub, "systems", map[string]any{
|
||||||
|
"name": "system2",
|
||||||
|
"users": []string{user1.Id, user2.Id},
|
||||||
|
"host": "127.0.0.2",
|
||||||
|
})
|
||||||
|
|
||||||
|
userRecords, _ := hub.CountRecords("users")
|
||||||
|
assert.EqualValues(t, 2, userRecords, "all users should be created")
|
||||||
|
|
||||||
|
systemRecords, _ := hub.CountRecords("systems")
|
||||||
|
assert.EqualValues(t, 2, systemRecords, "all systems should be created")
|
||||||
|
|
||||||
|
testAppFactory := func(t testing.TB) *pbTests.TestApp {
|
||||||
|
return hub.TestApp
|
||||||
|
}
|
||||||
|
|
||||||
|
scenarios := []beszelTests.ApiScenario{
|
||||||
|
{
|
||||||
|
Name: "GET not implemented - returns index",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"<html ", "globalThis.BESZEL"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "POST no auth",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
ExpectedStatus: 401,
|
||||||
|
ExpectedContent: []string{"requires valid"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "POST no body",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": user1Token,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 400,
|
||||||
|
ExpectedContent: []string{"Bad data"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "POST bad data",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": user1Token,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 400,
|
||||||
|
ExpectedContent: []string{"Bad data"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"invalidField": "this should cause validation error",
|
||||||
|
"threshold": "not a number",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "POST malformed JSON",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": user1Token,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 400,
|
||||||
|
ExpectedContent: []string{"Bad data"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: strings.NewReader(`{"alertType": "cpu", "threshold": 80, "enabled": true,}`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "POST valid alert data multiple systems",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": user1Token,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"\"success\":true"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"value": 69,
|
||||||
|
"min": 9,
|
||||||
|
"systems": []string{system1.Id, system2.Id},
|
||||||
|
"overwrite": false,
|
||||||
|
}),
|
||||||
|
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||||
|
// check total alerts
|
||||||
|
alerts, _ := app.CountRecords("alerts")
|
||||||
|
assert.EqualValues(t, 2, alerts, "should have 2 alerts")
|
||||||
|
// check alert has correct values
|
||||||
|
matchingAlerts, _ := app.CountRecords("alerts", dbx.HashExp{"name": "CPU", "user": user1.Id, "system": system1.Id, "value": 69, "min": 9})
|
||||||
|
assert.EqualValues(t, 1, matchingAlerts, "should have 1 alert")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "POST valid alert data single system",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": user1Token,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"\"success\":true"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"name": "Memory",
|
||||||
|
"systems": []string{system1.Id},
|
||||||
|
"value": 90,
|
||||||
|
"min": 10,
|
||||||
|
}),
|
||||||
|
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||||
|
user1Alerts, _ := app.CountRecords("alerts", dbx.HashExp{"user": user1.Id})
|
||||||
|
assert.EqualValues(t, 3, user1Alerts, "should have 3 alerts")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Overwrite: false, should not overwrite existing alert",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": user1Token,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"\"success\":true"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"value": 45,
|
||||||
|
"min": 5,
|
||||||
|
"systems": []string{system1.Id},
|
||||||
|
"overwrite": false,
|
||||||
|
}),
|
||||||
|
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||||
|
beszelTests.ClearCollection(t, app, "alerts")
|
||||||
|
beszelTests.CreateRecord(app, "alerts", map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"system": system1.Id,
|
||||||
|
"user": user1.Id,
|
||||||
|
"value": 80,
|
||||||
|
"min": 10,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||||
|
alerts, _ := app.CountRecords("alerts")
|
||||||
|
assert.EqualValues(t, 1, alerts, "should have 1 alert")
|
||||||
|
alert, _ := app.FindFirstRecordByFilter("alerts", "name = 'CPU' && user = {:user}", dbx.Params{"user": user1.Id})
|
||||||
|
assert.EqualValues(t, 80, alert.Get("value"), "should have 80 as value")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Overwrite: true, should overwrite existing alert",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": user2Token,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"\"success\":true"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"value": 45,
|
||||||
|
"min": 5,
|
||||||
|
"systems": []string{system2.Id},
|
||||||
|
"overwrite": true,
|
||||||
|
}),
|
||||||
|
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||||
|
beszelTests.ClearCollection(t, app, "alerts")
|
||||||
|
beszelTests.CreateRecord(app, "alerts", map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"system": system2.Id,
|
||||||
|
"user": user2.Id,
|
||||||
|
"value": 80,
|
||||||
|
"min": 10,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||||
|
alerts, _ := app.CountRecords("alerts")
|
||||||
|
assert.EqualValues(t, 1, alerts, "should have 1 alert")
|
||||||
|
alert, _ := app.FindFirstRecordByFilter("alerts", "name = 'CPU' && user = {:user}", dbx.Params{"user": user2.Id})
|
||||||
|
assert.EqualValues(t, 45, alert.Get("value"), "should have 45 as value")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "DELETE no auth",
|
||||||
|
Method: http.MethodDelete,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
ExpectedStatus: 401,
|
||||||
|
ExpectedContent: []string{"requires valid"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"systems": []string{system1.Id},
|
||||||
|
}),
|
||||||
|
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||||
|
beszelTests.ClearCollection(t, app, "alerts")
|
||||||
|
beszelTests.CreateRecord(app, "alerts", map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"system": system1.Id,
|
||||||
|
"user": user1.Id,
|
||||||
|
"value": 80,
|
||||||
|
"min": 10,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||||
|
alerts, _ := app.CountRecords("alerts")
|
||||||
|
assert.EqualValues(t, 1, alerts, "should have 1 alert")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "DELETE alert",
|
||||||
|
Method: http.MethodDelete,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": user1Token,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"\"count\":1", "\"success\":true"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"systems": []string{system1.Id},
|
||||||
|
}),
|
||||||
|
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||||
|
beszelTests.ClearCollection(t, app, "alerts")
|
||||||
|
beszelTests.CreateRecord(app, "alerts", map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"system": system1.Id,
|
||||||
|
"user": user1.Id,
|
||||||
|
"value": 80,
|
||||||
|
"min": 10,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||||
|
alerts, _ := app.CountRecords("alerts")
|
||||||
|
assert.Zero(t, alerts, "should have 0 alerts")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "DELETE alert multiple systems",
|
||||||
|
Method: http.MethodDelete,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": user1Token,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"\"count\":2", "\"success\":true"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"name": "Memory",
|
||||||
|
"systems": []string{system1.Id, system2.Id},
|
||||||
|
}),
|
||||||
|
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||||
|
beszelTests.ClearCollection(t, app, "alerts")
|
||||||
|
for _, systemId := range []string{system1.Id, system2.Id} {
|
||||||
|
_, err := beszelTests.CreateRecord(app, "alerts", map[string]any{
|
||||||
|
"name": "Memory",
|
||||||
|
"system": systemId,
|
||||||
|
"user": user1.Id,
|
||||||
|
"value": 90,
|
||||||
|
"min": 10,
|
||||||
|
})
|
||||||
|
assert.NoError(t, err, "should create alert")
|
||||||
|
}
|
||||||
|
alerts, _ := app.CountRecords("alerts")
|
||||||
|
assert.EqualValues(t, 2, alerts, "should have 2 alerts")
|
||||||
|
},
|
||||||
|
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||||
|
alerts, _ := app.CountRecords("alerts")
|
||||||
|
assert.Zero(t, alerts, "should have 0 alerts")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "User 2 should not be able to delete alert of user 1",
|
||||||
|
Method: http.MethodDelete,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": user2Token,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"\"count\":1", "\"success\":true"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"systems": []string{system2.Id},
|
||||||
|
}),
|
||||||
|
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||||
|
beszelTests.ClearCollection(t, app, "alerts")
|
||||||
|
for _, user := range []string{user1.Id, user2.Id} {
|
||||||
|
beszelTests.CreateRecord(app, "alerts", map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"system": system2.Id,
|
||||||
|
"user": user,
|
||||||
|
"value": 80,
|
||||||
|
"min": 10,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
alerts, _ := app.CountRecords("alerts")
|
||||||
|
assert.EqualValues(t, 2, alerts, "should have 2 alerts")
|
||||||
|
user1AlertCount, _ := app.CountRecords("alerts", dbx.HashExp{"user": user1.Id})
|
||||||
|
assert.EqualValues(t, 1, user1AlertCount, "should have 1 alert")
|
||||||
|
user2AlertCount, _ := app.CountRecords("alerts", dbx.HashExp{"user": user2.Id})
|
||||||
|
assert.EqualValues(t, 1, user2AlertCount, "should have 1 alert")
|
||||||
|
},
|
||||||
|
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||||
|
user1AlertCount, _ := app.CountRecords("alerts", dbx.HashExp{"user": user1.Id})
|
||||||
|
assert.EqualValues(t, 1, user1AlertCount, "should have 1 alert")
|
||||||
|
user2AlertCount, _ := app.CountRecords("alerts", dbx.HashExp{"user": user2.Id})
|
||||||
|
assert.Zero(t, user2AlertCount, "should have 0 alerts")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
scenario.Test(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,9 +3,8 @@ package system
|
|||||||
// TODO: this is confusing, make common package with common/types common/helpers etc
|
// TODO: this is confusing, make common package with common/types common/helpers etc
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"beszel/internal/entities/container"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/container"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Stats struct {
|
type Stats struct {
|
||||||
@@ -38,21 +37,19 @@ type Stats struct {
|
|||||||
Bandwidth [2]uint64 `json:"b,omitzero" cbor:"26,keyasint,omitzero"` // [sent bytes, recv bytes]
|
Bandwidth [2]uint64 `json:"b,omitzero" cbor:"26,keyasint,omitzero"` // [sent bytes, recv bytes]
|
||||||
MaxBandwidth [2]uint64 `json:"bm,omitzero" cbor:"27,keyasint,omitzero"` // [sent bytes, recv bytes]
|
MaxBandwidth [2]uint64 `json:"bm,omitzero" cbor:"27,keyasint,omitzero"` // [sent bytes, recv bytes]
|
||||||
// TODO: remove other load fields in future release in favor of load avg array
|
// TODO: remove other load fields in future release in favor of load avg array
|
||||||
LoadAvg [3]float64 `json:"la,omitempty" cbor:"28,keyasint"`
|
LoadAvg [3]float64 `json:"la,omitempty" cbor:"28,keyasint"`
|
||||||
Battery [2]uint8 `json:"bat,omitzero" cbor:"29,keyasint,omitzero"` // [percent, charge state, current]
|
Battery [2]uint8 `json:"bat,omitzero" cbor:"29,keyasint,omitzero"` // [percent, charge state, current]
|
||||||
MaxMem float64 `json:"mm,omitempty" cbor:"30,keyasint,omitempty"`
|
MaxMem float64 `json:"mm,omitempty" cbor:"30,keyasint,omitempty"`
|
||||||
NetworkInterfaces map[string][4]uint64 `json:"ni,omitempty" cbor:"31,keyasint,omitempty"` // [upload bytes, download bytes, total upload, total download]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type GPUData struct {
|
type GPUData struct {
|
||||||
Name string `json:"n" cbor:"0,keyasint"`
|
Name string `json:"n" cbor:"0,keyasint"`
|
||||||
Temperature float64 `json:"-"`
|
Temperature float64 `json:"-"`
|
||||||
MemoryUsed float64 `json:"mu,omitempty,omitzero" cbor:"1,keyasint,omitempty,omitzero"`
|
MemoryUsed float64 `json:"mu,omitempty" cbor:"1,keyasint,omitempty"`
|
||||||
MemoryTotal float64 `json:"mt,omitempty,omitzero" cbor:"2,keyasint,omitempty,omitzero"`
|
MemoryTotal float64 `json:"mt,omitempty" cbor:"2,keyasint,omitempty"`
|
||||||
Usage float64 `json:"u" cbor:"3,keyasint,omitempty"`
|
Usage float64 `json:"u" cbor:"3,keyasint"`
|
||||||
Power float64 `json:"p,omitempty" cbor:"4,keyasint,omitempty"`
|
Power float64 `json:"p,omitempty" cbor:"4,keyasint,omitempty"`
|
||||||
Count float64 `json:"-"`
|
Count float64 `json:"-"`
|
||||||
Engines map[string]float64 `json:"e,omitempty" cbor:"5,keyasint,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type FsStats struct {
|
type FsStats struct {
|
||||||
@@ -85,14 +82,6 @@ const (
|
|||||||
Freebsd
|
Freebsd
|
||||||
)
|
)
|
||||||
|
|
||||||
type ConnectionType = uint8
|
|
||||||
|
|
||||||
const (
|
|
||||||
ConnectionTypeNone ConnectionType = iota
|
|
||||||
ConnectionTypeSSH
|
|
||||||
ConnectionTypeWebSocket
|
|
||||||
)
|
|
||||||
|
|
||||||
type Info struct {
|
type Info struct {
|
||||||
Hostname string `json:"h" cbor:"0,keyasint"`
|
Hostname string `json:"h" cbor:"0,keyasint"`
|
||||||
KernelVersion string `json:"k,omitempty" cbor:"1,keyasint,omitempty"`
|
KernelVersion string `json:"k,omitempty" cbor:"1,keyasint,omitempty"`
|
||||||
@@ -114,8 +103,7 @@ type Info struct {
|
|||||||
LoadAvg15 float64 `json:"l15,omitempty" cbor:"17,keyasint,omitempty"`
|
LoadAvg15 float64 `json:"l15,omitempty" cbor:"17,keyasint,omitempty"`
|
||||||
BandwidthBytes uint64 `json:"bb" cbor:"18,keyasint"`
|
BandwidthBytes uint64 `json:"bb" cbor:"18,keyasint"`
|
||||||
// TODO: remove load fields in future release in favor of load avg array
|
// TODO: remove load fields in future release in favor of load avg array
|
||||||
LoadAvg [3]float64 `json:"la,omitempty" cbor:"19,keyasint"`
|
LoadAvg [3]float64 `json:"la,omitempty" cbor:"19,keyasint"`
|
||||||
ConnectionType ConnectionType `json:"ct,omitempty" cbor:"20,keyasint,omitempty,omitzero"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Final data structure to return to the hub
|
// Final data structure to return to the hub
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
package ghupdate
|
package ghupdate
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"beszel"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -15,8 +16,6 @@ import (
|
|||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/henrygd/beszel"
|
|
||||||
|
|
||||||
"github.com/blang/semver"
|
"github.com/blang/semver"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
package hub
|
package hub
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"beszel/internal/common"
|
||||||
|
"beszel/internal/hub/expirymap"
|
||||||
|
"beszel/internal/hub/ws"
|
||||||
"errors"
|
"errors"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -8,10 +11,6 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/common"
|
|
||||||
"github.com/henrygd/beszel/internal/hub/expirymap"
|
|
||||||
"github.com/henrygd/beszel/internal/hub/ws"
|
|
||||||
|
|
||||||
"github.com/blang/semver"
|
"github.com/blang/semver"
|
||||||
"github.com/lxzan/gws"
|
"github.com/lxzan/gws"
|
||||||
"github.com/pocketbase/dbx"
|
"github.com/pocketbase/dbx"
|
||||||
@@ -4,6 +4,9 @@
|
|||||||
package hub
|
package hub
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"beszel/internal/agent"
|
||||||
|
"beszel/internal/common"
|
||||||
|
"beszel/internal/hub/ws"
|
||||||
"crypto/ed25519"
|
"crypto/ed25519"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -14,10 +17,6 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/agent"
|
|
||||||
"github.com/henrygd/beszel/internal/common"
|
|
||||||
"github.com/henrygd/beszel/internal/hub/ws"
|
|
||||||
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
pbtests "github.com/pocketbase/pocketbase/tests"
|
pbtests "github.com/pocketbase/pocketbase/tests"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -2,14 +2,13 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"beszel/internal/entities/system"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
|
||||||
|
|
||||||
"github.com/pocketbase/dbx"
|
"github.com/pocketbase/dbx"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
"github.com/spf13/cast"
|
"github.com/spf13/cast"
|
||||||
@@ -4,14 +4,12 @@
|
|||||||
package config_test
|
package config_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"beszel/internal/hub/config"
|
||||||
|
"beszel/internal/tests"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/tests"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/hub/config"
|
|
||||||
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -2,6 +2,12 @@
|
|||||||
package hub
|
package hub
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"beszel"
|
||||||
|
"beszel/internal/alerts"
|
||||||
|
"beszel/internal/hub/config"
|
||||||
|
"beszel/internal/hub/systems"
|
||||||
|
"beszel/internal/records"
|
||||||
|
"beszel/internal/users"
|
||||||
"crypto/ed25519"
|
"crypto/ed25519"
|
||||||
"encoding/pem"
|
"encoding/pem"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -12,14 +18,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel"
|
|
||||||
"github.com/henrygd/beszel/internal/alerts"
|
|
||||||
"github.com/henrygd/beszel/internal/hub/config"
|
|
||||||
"github.com/henrygd/beszel/internal/hub/systems"
|
|
||||||
"github.com/henrygd/beszel/internal/records"
|
|
||||||
"github.com/henrygd/beszel/internal/users"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/pocketbase/dbx"
|
||||||
"github.com/pocketbase/pocketbase"
|
"github.com/pocketbase/pocketbase"
|
||||||
"github.com/pocketbase/pocketbase/apis"
|
"github.com/pocketbase/pocketbase/apis"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
@@ -69,8 +69,6 @@ func (h *Hub) StartHub() error {
|
|||||||
if err := config.SyncSystems(e); err != nil {
|
if err := config.SyncSystems(e); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// register middlewares
|
|
||||||
h.registerMiddlewares(e)
|
|
||||||
// register api routes
|
// register api routes
|
||||||
if err := h.registerApiRoutes(e); err != nil {
|
if err := h.registerApiRoutes(e); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -115,6 +113,8 @@ func (h *Hub) initialize(e *core.ServeEvent) error {
|
|||||||
// set URL if BASE_URL env is set
|
// set URL if BASE_URL env is set
|
||||||
if h.appURL != "" {
|
if h.appURL != "" {
|
||||||
settings.Meta.AppURL = h.appURL
|
settings.Meta.AppURL = h.appURL
|
||||||
|
} else {
|
||||||
|
h.appURL = settings.Meta.AppURL
|
||||||
}
|
}
|
||||||
if err := e.App.Save(settings); err != nil {
|
if err := e.App.Save(settings); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -173,37 +173,6 @@ func (h *Hub) registerCronJobs(_ *core.ServeEvent) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// custom middlewares
|
|
||||||
func (h *Hub) registerMiddlewares(se *core.ServeEvent) {
|
|
||||||
// authorizes request with user matching the provided email
|
|
||||||
authorizeRequestWithEmail := func(e *core.RequestEvent, email string) (err error) {
|
|
||||||
if e.Auth != nil || email == "" {
|
|
||||||
return e.Next()
|
|
||||||
}
|
|
||||||
isAuthRefresh := e.Request.URL.Path == "/api/collections/users/auth-refresh" && e.Request.Method == http.MethodPost
|
|
||||||
e.Auth, err = e.App.FindFirstRecordByData("users", "email", email)
|
|
||||||
if err != nil || !isAuthRefresh {
|
|
||||||
return e.Next()
|
|
||||||
}
|
|
||||||
// auth refresh endpoint, make sure token is set in header
|
|
||||||
token, _ := e.Auth.NewAuthToken()
|
|
||||||
e.Request.Header.Set("Authorization", token)
|
|
||||||
return e.Next()
|
|
||||||
}
|
|
||||||
// authenticate with trusted header
|
|
||||||
if autoLogin, _ := GetEnv("AUTO_LOGIN"); autoLogin != "" {
|
|
||||||
se.Router.BindFunc(func(e *core.RequestEvent) error {
|
|
||||||
return authorizeRequestWithEmail(e, autoLogin)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
// authenticate with trusted header
|
|
||||||
if trustedHeader, _ := GetEnv("TRUSTED_AUTH_HEADER"); trustedHeader != "" {
|
|
||||||
se.Router.BindFunc(func(e *core.RequestEvent) error {
|
|
||||||
return authorizeRequestWithEmail(e, e.Request.Header.Get(trustedHeader))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// custom api routes
|
// custom api routes
|
||||||
func (h *Hub) registerApiRoutes(se *core.ServeEvent) error {
|
func (h *Hub) registerApiRoutes(se *core.ServeEvent) error {
|
||||||
// auth protected routes
|
// auth protected routes
|
||||||
@@ -331,3 +300,30 @@ func (h *Hub) MakeLink(parts ...string) string {
|
|||||||
}
|
}
|
||||||
return base
|
return base
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SystemInfo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Id string `json:"id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Port uint16 `json:"port"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
Info string `json:"info"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Hub) getUserSystemsFromRequest(req *http.Request) ([]SystemInfo, error) {
|
||||||
|
systems := []SystemInfo{}
|
||||||
|
token, err := req.Cookie("beszauth")
|
||||||
|
if err != nil {
|
||||||
|
return systems, err
|
||||||
|
}
|
||||||
|
if token.Value != "" {
|
||||||
|
user, err := h.FindAuthRecordByToken(token.Value)
|
||||||
|
if err != nil {
|
||||||
|
return systems, err
|
||||||
|
}
|
||||||
|
h.DB().NewQuery("SELECT s.id, s.info, s.status, s.name, s.port, s.host FROM systems s JOIN json_each(s.users) AS je WHERE je.value = {:user_id}").Bind(dbx.Params{
|
||||||
|
"user_id": user.Id,
|
||||||
|
}).All(&systems)
|
||||||
|
}
|
||||||
|
return systems, err
|
||||||
|
}
|
||||||
@@ -4,6 +4,9 @@
|
|||||||
package hub_test
|
package hub_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
beszelTests "beszel/internal/tests"
|
||||||
|
"testing"
|
||||||
|
|
||||||
"bytes"
|
"bytes"
|
||||||
"crypto/ed25519"
|
"crypto/ed25519"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -13,10 +16,6 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/migrations"
|
|
||||||
beszelTests "github.com/henrygd/beszel/internal/tests"
|
|
||||||
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
pbTests "github.com/pocketbase/pocketbase/tests"
|
pbTests "github.com/pocketbase/pocketbase/tests"
|
||||||
@@ -535,115 +534,6 @@ func TestApiRoutesAuthentication(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFirstUserCreation(t *testing.T) {
|
|
||||||
t.Run("CreateUserEndpoint available when no users exist", func(t *testing.T) {
|
|
||||||
hub, _ := beszelTests.NewTestHub(t.TempDir())
|
|
||||||
defer hub.Cleanup()
|
|
||||||
|
|
||||||
hub.StartHub()
|
|
||||||
|
|
||||||
testAppFactoryExisting := func(t testing.TB) *pbTests.TestApp {
|
|
||||||
return hub.TestApp
|
|
||||||
}
|
|
||||||
|
|
||||||
scenarios := []beszelTests.ApiScenario{
|
|
||||||
{
|
|
||||||
Name: "POST /create-user - should be available when no users exist",
|
|
||||||
Method: http.MethodPost,
|
|
||||||
URL: "/api/beszel/create-user",
|
|
||||||
Body: jsonReader(map[string]any{
|
|
||||||
"email": "firstuser@example.com",
|
|
||||||
"password": "password123",
|
|
||||||
}),
|
|
||||||
ExpectedStatus: 200,
|
|
||||||
ExpectedContent: []string{"User created"},
|
|
||||||
TestAppFactory: testAppFactoryExisting,
|
|
||||||
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
|
||||||
userCount, err := hub.CountRecords("users")
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Zero(t, userCount, "Should start with no users")
|
|
||||||
superusers, err := hub.FindAllRecords(core.CollectionNameSuperusers)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.EqualValues(t, 1, len(superusers), "Should start with one temporary superuser")
|
|
||||||
require.EqualValues(t, migrations.TempAdminEmail, superusers[0].GetString("email"), "Should have created one temporary superuser")
|
|
||||||
},
|
|
||||||
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
|
||||||
userCount, err := hub.CountRecords("users")
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.EqualValues(t, 1, userCount, "Should have created one user")
|
|
||||||
superusers, err := hub.FindAllRecords(core.CollectionNameSuperusers)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.EqualValues(t, 1, len(superusers), "Should have created one superuser")
|
|
||||||
require.EqualValues(t, "firstuser@example.com", superusers[0].GetString("email"), "Should have created one superuser")
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "POST /create-user - should not be available when users exist",
|
|
||||||
Method: http.MethodPost,
|
|
||||||
URL: "/api/beszel/create-user",
|
|
||||||
Body: jsonReader(map[string]any{
|
|
||||||
"email": "firstuser@example.com",
|
|
||||||
"password": "password123",
|
|
||||||
}),
|
|
||||||
ExpectedStatus: 404,
|
|
||||||
ExpectedContent: []string{"wasn't found"},
|
|
||||||
TestAppFactory: testAppFactoryExisting,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, scenario := range scenarios {
|
|
||||||
scenario.Test(t)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("CreateUserEndpoint not available when USER_EMAIL, USER_PASSWORD are set", func(t *testing.T) {
|
|
||||||
os.Setenv("BESZEL_HUB_USER_EMAIL", "me@example.com")
|
|
||||||
os.Setenv("BESZEL_HUB_USER_PASSWORD", "password123")
|
|
||||||
defer os.Unsetenv("BESZEL_HUB_USER_EMAIL")
|
|
||||||
defer os.Unsetenv("BESZEL_HUB_USER_PASSWORD")
|
|
||||||
|
|
||||||
hub, _ := beszelTests.NewTestHub(t.TempDir())
|
|
||||||
defer hub.Cleanup()
|
|
||||||
|
|
||||||
hub.StartHub()
|
|
||||||
|
|
||||||
testAppFactory := func(t testing.TB) *pbTests.TestApp {
|
|
||||||
return hub.TestApp
|
|
||||||
}
|
|
||||||
|
|
||||||
scenario := beszelTests.ApiScenario{
|
|
||||||
Name: "POST /create-user - should not be available when USER_EMAIL, USER_PASSWORD are set",
|
|
||||||
Method: http.MethodPost,
|
|
||||||
URL: "/api/beszel/create-user",
|
|
||||||
ExpectedStatus: 404,
|
|
||||||
ExpectedContent: []string{"wasn't found"},
|
|
||||||
TestAppFactory: testAppFactory,
|
|
||||||
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
|
||||||
users, err := hub.FindAllRecords("users")
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.EqualValues(t, 1, len(users), "Should start with one user")
|
|
||||||
require.EqualValues(t, "me@example.com", users[0].GetString("email"), "Should have created one user")
|
|
||||||
superusers, err := hub.FindAllRecords(core.CollectionNameSuperusers)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.EqualValues(t, 1, len(superusers), "Should start with one superuser")
|
|
||||||
require.EqualValues(t, "me@example.com", superusers[0].GetString("email"), "Should have created one superuser")
|
|
||||||
},
|
|
||||||
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
|
||||||
users, err := hub.FindAllRecords("users")
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.EqualValues(t, 1, len(users), "Should still have one user")
|
|
||||||
require.EqualValues(t, "me@example.com", users[0].GetString("email"), "Should have created one user")
|
|
||||||
superusers, err := hub.FindAllRecords(core.CollectionNameSuperusers)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.EqualValues(t, 1, len(superusers), "Should still have one superuser")
|
|
||||||
require.EqualValues(t, "me@example.com", superusers[0].GetString("email"), "Should have created one superuser")
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
scenario.Test(t)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCreateUserEndpointAvailability(t *testing.T) {
|
func TestCreateUserEndpointAvailability(t *testing.T) {
|
||||||
t.Run("CreateUserEndpoint available when no users exist", func(t *testing.T) {
|
t.Run("CreateUserEndpoint available when no users exist", func(t *testing.T) {
|
||||||
hub, _ := beszelTests.NewTestHub(t.TempDir())
|
hub, _ := beszelTests.NewTestHub(t.TempDir())
|
||||||
@@ -711,117 +601,3 @@ func TestCreateUserEndpointAvailability(t *testing.T) {
|
|||||||
scenario.Test(t)
|
scenario.Test(t)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAutoLoginMiddleware(t *testing.T) {
|
|
||||||
var hubs []*beszelTests.TestHub
|
|
||||||
|
|
||||||
defer func() {
|
|
||||||
defer os.Unsetenv("AUTO_LOGIN")
|
|
||||||
for _, hub := range hubs {
|
|
||||||
hub.Cleanup()
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
os.Setenv("AUTO_LOGIN", "user@test.com")
|
|
||||||
|
|
||||||
testAppFactory := func(t testing.TB) *pbTests.TestApp {
|
|
||||||
hub, _ := beszelTests.NewTestHub(t.TempDir())
|
|
||||||
hubs = append(hubs, hub)
|
|
||||||
hub.StartHub()
|
|
||||||
return hub.TestApp
|
|
||||||
}
|
|
||||||
|
|
||||||
scenarios := []beszelTests.ApiScenario{
|
|
||||||
{
|
|
||||||
Name: "GET /getkey - without auto login should fail",
|
|
||||||
Method: http.MethodGet,
|
|
||||||
URL: "/api/beszel/getkey",
|
|
||||||
ExpectedStatus: 401,
|
|
||||||
ExpectedContent: []string{"requires valid"},
|
|
||||||
TestAppFactory: testAppFactory,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "GET /getkey - with auto login should fail if no matching user",
|
|
||||||
Method: http.MethodGet,
|
|
||||||
URL: "/api/beszel/getkey",
|
|
||||||
ExpectedStatus: 401,
|
|
||||||
ExpectedContent: []string{"requires valid"},
|
|
||||||
TestAppFactory: testAppFactory,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "GET /getkey - with auto login should succeed",
|
|
||||||
Method: http.MethodGet,
|
|
||||||
URL: "/api/beszel/getkey",
|
|
||||||
ExpectedStatus: 200,
|
|
||||||
ExpectedContent: []string{"\"key\":", "\"v\":"},
|
|
||||||
TestAppFactory: testAppFactory,
|
|
||||||
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
|
||||||
beszelTests.CreateUser(app, "user@test.com", "password123")
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, scenario := range scenarios {
|
|
||||||
scenario.Test(t)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTrustedHeaderMiddleware(t *testing.T) {
|
|
||||||
var hubs []*beszelTests.TestHub
|
|
||||||
|
|
||||||
defer func() {
|
|
||||||
defer os.Unsetenv("TRUSTED_AUTH_HEADER")
|
|
||||||
for _, hub := range hubs {
|
|
||||||
hub.Cleanup()
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
os.Setenv("TRUSTED_AUTH_HEADER", "X-Beszel-Trusted")
|
|
||||||
|
|
||||||
testAppFactory := func(t testing.TB) *pbTests.TestApp {
|
|
||||||
hub, _ := beszelTests.NewTestHub(t.TempDir())
|
|
||||||
hubs = append(hubs, hub)
|
|
||||||
hub.StartHub()
|
|
||||||
return hub.TestApp
|
|
||||||
}
|
|
||||||
|
|
||||||
scenarios := []beszelTests.ApiScenario{
|
|
||||||
{
|
|
||||||
Name: "GET /getkey - without trusted header should fail",
|
|
||||||
Method: http.MethodGet,
|
|
||||||
URL: "/api/beszel/getkey",
|
|
||||||
ExpectedStatus: 401,
|
|
||||||
ExpectedContent: []string{"requires valid"},
|
|
||||||
TestAppFactory: testAppFactory,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "GET /getkey - with trusted header should fail if no matching user",
|
|
||||||
Method: http.MethodGet,
|
|
||||||
URL: "/api/beszel/getkey",
|
|
||||||
Headers: map[string]string{
|
|
||||||
"X-Beszel-Trusted": "user@test.com",
|
|
||||||
},
|
|
||||||
ExpectedStatus: 401,
|
|
||||||
ExpectedContent: []string{"requires valid"},
|
|
||||||
TestAppFactory: testAppFactory,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "GET /getkey - with trusted header should succeed",
|
|
||||||
Method: http.MethodGet,
|
|
||||||
URL: "/api/beszel/getkey",
|
|
||||||
Headers: map[string]string{
|
|
||||||
"X-Beszel-Trusted": "user@test.com",
|
|
||||||
},
|
|
||||||
ExpectedStatus: 200,
|
|
||||||
ExpectedContent: []string{"\"key\":", "\"v\":"},
|
|
||||||
TestAppFactory: testAppFactory,
|
|
||||||
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
|
||||||
beszelTests.CreateUser(app, "user@test.com", "password123")
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, scenario := range scenarios {
|
|
||||||
scenario.Test(t)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
package hub
|
package hub
|
||||||
|
|
||||||
import "github.com/henrygd/beszel/internal/hub/systems"
|
import "beszel/internal/hub/systems"
|
||||||
|
|
||||||
// TESTING ONLY: GetSystemManager returns the system manager
|
// TESTING ONLY: GetSystemManager returns the system manager
|
||||||
func (h *Hub) GetSystemManager() *systems.SystemManager {
|
func (h *Hub) GetSystemManager() *systems.SystemManager {
|
||||||
@@ -3,6 +3,8 @@
|
|||||||
package hub
|
package hub
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"beszel"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -11,35 +13,39 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/henrygd/beszel"
|
|
||||||
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
"github.com/pocketbase/pocketbase/tools/osutils"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Wraps http.RoundTripper to modify dev proxy HTML responses
|
// responseModifier wraps an http.RoundTripper to modify HTML responses
|
||||||
type responseModifier struct {
|
type responseModifier struct {
|
||||||
transport http.RoundTripper
|
transport http.RoundTripper
|
||||||
hub *Hub
|
hub *Hub
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RoundTrip implements http.RoundTripper interface with response modification
|
||||||
func (rm *responseModifier) RoundTrip(req *http.Request) (*http.Response, error) {
|
func (rm *responseModifier) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
resp, err := rm.transport.RoundTrip(req)
|
resp, err := rm.transport.RoundTrip(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return resp, err
|
return resp, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only modify HTML responses
|
// Only modify HTML responses
|
||||||
contentType := resp.Header.Get("Content-Type")
|
contentType := resp.Header.Get("Content-Type")
|
||||||
if !strings.Contains(contentType, "text/html") {
|
if !strings.Contains(contentType, "text/html") {
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Read the response body
|
||||||
body, err := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return resp, err
|
return resp, err
|
||||||
}
|
}
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
|
|
||||||
|
// Modify the HTML content here
|
||||||
|
modifiedBody := rm.modifyHTML(string(body), req)
|
||||||
|
|
||||||
// Create a new response with the modified body
|
// Create a new response with the modified body
|
||||||
modifiedBody := rm.modifyHTML(string(body))
|
|
||||||
resp.Body = io.NopCloser(strings.NewReader(modifiedBody))
|
resp.Body = io.NopCloser(strings.NewReader(modifiedBody))
|
||||||
resp.ContentLength = int64(len(modifiedBody))
|
resp.ContentLength = int64(len(modifiedBody))
|
||||||
resp.Header.Set("Content-Length", fmt.Sprintf("%d", len(modifiedBody)))
|
resp.Header.Set("Content-Length", fmt.Sprintf("%d", len(modifiedBody)))
|
||||||
@@ -47,7 +53,8 @@ func (rm *responseModifier) RoundTrip(req *http.Request) (*http.Response, error)
|
|||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rm *responseModifier) modifyHTML(html string) string {
|
// modifyHTML applies modifications to HTML content
|
||||||
|
func (rm *responseModifier) modifyHTML(html string, req *http.Request) string {
|
||||||
parsedURL, err := url.Parse(rm.hub.appURL)
|
parsedURL, err := url.Parse(rm.hub.appURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return html
|
return html
|
||||||
@@ -56,7 +63,19 @@ func (rm *responseModifier) modifyHTML(html string) string {
|
|||||||
basePath := strings.TrimSuffix(parsedURL.Path, "/") + "/"
|
basePath := strings.TrimSuffix(parsedURL.Path, "/") + "/"
|
||||||
html = strings.ReplaceAll(html, "./", basePath)
|
html = strings.ReplaceAll(html, "./", basePath)
|
||||||
html = strings.Replace(html, "{{V}}", beszel.Version, 1)
|
html = strings.Replace(html, "{{V}}", beszel.Version, 1)
|
||||||
|
slog.Info("modifying HTML", "appURL", rm.hub.appURL)
|
||||||
html = strings.Replace(html, "{{HUB_URL}}", rm.hub.appURL, 1)
|
html = strings.Replace(html, "{{HUB_URL}}", rm.hub.appURL, 1)
|
||||||
|
|
||||||
|
systems, err := rm.hub.getUserSystemsFromRequest(req)
|
||||||
|
if err != nil {
|
||||||
|
return html
|
||||||
|
}
|
||||||
|
systemsJson, err := json.Marshal(systems)
|
||||||
|
if err != nil {
|
||||||
|
return html
|
||||||
|
}
|
||||||
|
html = strings.Replace(html, "'{SYSTEMS}'", string(systemsJson), 1)
|
||||||
|
|
||||||
return html
|
return html
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,6 +87,7 @@ func (h *Hub) startServer(se *core.ServeEvent) error {
|
|||||||
Host: "localhost:5173",
|
Host: "localhost:5173",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Set up custom transport with response modification
|
||||||
proxy.Transport = &responseModifier{
|
proxy.Transport = &responseModifier{
|
||||||
transport: http.DefaultTransport,
|
transport: http.DefaultTransport,
|
||||||
hub: h,
|
hub: h,
|
||||||
@@ -77,6 +97,5 @@ func (h *Hub) startServer(se *core.ServeEvent) error {
|
|||||||
proxy.ServeHTTP(e.Response, e.Request)
|
proxy.ServeHTTP(e.Response, e.Request)
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
_ = osutils.LaunchURL(h.appURL)
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -3,14 +3,15 @@
|
|||||||
package hub
|
package hub
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"beszel"
|
||||||
|
"beszel/site"
|
||||||
|
"encoding/json"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/henrygd/beszel"
|
|
||||||
"github.com/henrygd/beszel/internal/site"
|
|
||||||
|
|
||||||
"github.com/pocketbase/pocketbase/apis"
|
"github.com/pocketbase/pocketbase/apis"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
)
|
)
|
||||||
@@ -46,6 +47,15 @@ func (h *Hub) startServer(se *core.ServeEvent) error {
|
|||||||
e.Response.Header().Del("X-Frame-Options")
|
e.Response.Header().Del("X-Frame-Options")
|
||||||
e.Response.Header().Set("Content-Security-Policy", csp)
|
e.Response.Header().Set("Content-Security-Policy", csp)
|
||||||
}
|
}
|
||||||
|
systems, err := h.getUserSystemsFromRequest(e.Request)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("error getting user systems", "error", err)
|
||||||
|
}
|
||||||
|
systemsJson, err := json.Marshal(systems)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("error marshalling user systems", "error", err)
|
||||||
|
}
|
||||||
|
html = strings.Replace(html, "'{SYSTEMS}'", string(systemsJson), 1)
|
||||||
return e.HTML(http.StatusOK, html)
|
return e.HTML(http.StatusOK, html)
|
||||||
})
|
})
|
||||||
return nil
|
return nil
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
package systems
|
package systems
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"beszel"
|
||||||
|
"beszel/internal/entities/system"
|
||||||
|
"beszel/internal/hub/ws"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -10,12 +13,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/hub/ws"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel"
|
|
||||||
|
|
||||||
"github.com/blang/semver"
|
"github.com/blang/semver"
|
||||||
"github.com/fxamacker/cbor/v2"
|
"github.com/fxamacker/cbor/v2"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
@@ -1,18 +1,14 @@
|
|||||||
package systems
|
package systems
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"beszel"
|
||||||
|
"beszel/internal/common"
|
||||||
|
"beszel/internal/entities/system"
|
||||||
|
"beszel/internal/hub/ws"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/hub/ws"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/common"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel"
|
|
||||||
|
|
||||||
"github.com/blang/semver"
|
"github.com/blang/semver"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
"github.com/pocketbase/pocketbase/tools/store"
|
"github.com/pocketbase/pocketbase/tools/store"
|
||||||
@@ -34,8 +30,10 @@ const (
|
|||||||
sessionTimeout = 4 * time.Second
|
sessionTimeout = 4 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
// errSystemExists is returned when attempting to add a system that already exists
|
var (
|
||||||
var errSystemExists = errors.New("system exists")
|
// errSystemExists is returned when attempting to add a system that already exists
|
||||||
|
errSystemExists = errors.New("system exists")
|
||||||
|
)
|
||||||
|
|
||||||
// SystemManager manages a collection of monitored systems and their connections.
|
// SystemManager manages a collection of monitored systems and their connections.
|
||||||
// It handles system lifecycle, status updates, and maintains both SSH and WebSocket connections.
|
// It handles system lifecycle, status updates, and maintains both SSH and WebSocket connections.
|
||||||
@@ -4,17 +4,16 @@
|
|||||||
package systems_test
|
package systems_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"beszel/internal/entities/container"
|
||||||
|
"beszel/internal/entities/system"
|
||||||
|
"beszel/internal/hub/systems"
|
||||||
|
"beszel/internal/tests"
|
||||||
"fmt"
|
"fmt"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"testing/synctest"
|
"testing/synctest"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/container"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
|
||||||
"github.com/henrygd/beszel/internal/hub/systems"
|
|
||||||
"github.com/henrygd/beszel/internal/tests"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
@@ -4,10 +4,9 @@
|
|||||||
package systems
|
package systems
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
entities "beszel/internal/entities/system"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
entities "github.com/henrygd/beszel/internal/entities/system"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// TESTING ONLY: GetSystemCount returns the number of systems in the store
|
// TESTING ONLY: GetSystemCount returns the number of systems in the store
|
||||||
@@ -101,10 +100,3 @@ func (sm *SystemManager) SetSystemStatusInDB(systemID string, status string) boo
|
|||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// TESTING ONLY: RemoveAllSystems removes all systems from the store
|
|
||||||
func (sm *SystemManager) RemoveAllSystems() {
|
|
||||||
for _, system := range sm.systems.GetAll() {
|
|
||||||
sm.RemoveSystem(system.Id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
package hub
|
package hub
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"beszel/internal/ghupdate"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/ghupdate"
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1,14 +1,12 @@
|
|||||||
package ws
|
package ws
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"beszel/internal/common"
|
||||||
|
"beszel/internal/entities/system"
|
||||||
"errors"
|
"errors"
|
||||||
"time"
|
"time"
|
||||||
"weak"
|
"weak"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/common"
|
|
||||||
|
|
||||||
"github.com/fxamacker/cbor/v2"
|
"github.com/fxamacker/cbor/v2"
|
||||||
"github.com/lxzan/gws"
|
"github.com/lxzan/gws"
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
@@ -4,12 +4,11 @@
|
|||||||
package ws
|
package ws
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"beszel/internal/common"
|
||||||
"crypto/ed25519"
|
"crypto/ed25519"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/common"
|
|
||||||
|
|
||||||
"github.com/fxamacker/cbor/v2"
|
"github.com/fxamacker/cbor/v2"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
package records
|
package records
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"beszel/internal/entities/container"
|
||||||
|
"beszel/internal/entities/system"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
@@ -9,9 +11,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/container"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
|
||||||
|
|
||||||
"github.com/pocketbase/dbx"
|
"github.com/pocketbase/dbx"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
)
|
)
|
||||||
@@ -40,14 +39,12 @@ type StatsRecord struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// global variables for reusing allocations
|
// global variables for reusing allocations
|
||||||
var (
|
var statsRecord StatsRecord
|
||||||
statsRecord StatsRecord
|
var containerStats []container.Stats
|
||||||
containerStats []container.Stats
|
var sumStats system.Stats
|
||||||
sumStats system.Stats
|
var tempStats system.Stats
|
||||||
tempStats system.Stats
|
var queryParams = make(dbx.Params, 1)
|
||||||
queryParams = make(dbx.Params, 1)
|
var containerSums = make(map[string]*container.Stats)
|
||||||
containerSums = make(map[string]*container.Stats)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Create longer records by averaging shorter records
|
// Create longer records by averaging shorter records
|
||||||
func (rm *RecordManager) CreateLongerRecords() {
|
func (rm *RecordManager) CreateLongerRecords() {
|
||||||
@@ -225,19 +222,6 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
|
|||||||
sum.MaxBandwidth[0] = max(sum.MaxBandwidth[0], stats.MaxBandwidth[0], stats.Bandwidth[0])
|
sum.MaxBandwidth[0] = max(sum.MaxBandwidth[0], stats.MaxBandwidth[0], stats.Bandwidth[0])
|
||||||
sum.MaxBandwidth[1] = max(sum.MaxBandwidth[1], stats.MaxBandwidth[1], stats.Bandwidth[1])
|
sum.MaxBandwidth[1] = max(sum.MaxBandwidth[1], stats.MaxBandwidth[1], stats.Bandwidth[1])
|
||||||
|
|
||||||
// Accumulate network interfaces
|
|
||||||
if sum.NetworkInterfaces == nil {
|
|
||||||
sum.NetworkInterfaces = make(map[string][4]uint64, len(stats.NetworkInterfaces))
|
|
||||||
}
|
|
||||||
for key, value := range stats.NetworkInterfaces {
|
|
||||||
sum.NetworkInterfaces[key] = [4]uint64{
|
|
||||||
sum.NetworkInterfaces[key][0] + value[0],
|
|
||||||
sum.NetworkInterfaces[key][1] + value[1],
|
|
||||||
max(sum.NetworkInterfaces[key][2], value[2]),
|
|
||||||
max(sum.NetworkInterfaces[key][3], value[3]),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Accumulate temperatures
|
// Accumulate temperatures
|
||||||
if stats.Temperatures != nil {
|
if stats.Temperatures != nil {
|
||||||
if sum.Temperatures == nil {
|
if sum.Temperatures == nil {
|
||||||
@@ -284,16 +268,6 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
|
|||||||
gpu.Usage += value.Usage
|
gpu.Usage += value.Usage
|
||||||
gpu.Power += value.Power
|
gpu.Power += value.Power
|
||||||
gpu.Count += value.Count
|
gpu.Count += value.Count
|
||||||
|
|
||||||
if value.Engines != nil {
|
|
||||||
if gpu.Engines == nil {
|
|
||||||
gpu.Engines = make(map[string]float64, len(value.Engines))
|
|
||||||
}
|
|
||||||
for engineKey, engineValue := range value.Engines {
|
|
||||||
gpu.Engines[engineKey] += engineValue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sum.GPUData[id] = gpu
|
sum.GPUData[id] = gpu
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -322,19 +296,6 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
|
|||||||
sum.Bandwidth[0] = sum.Bandwidth[0] / uint64(count)
|
sum.Bandwidth[0] = sum.Bandwidth[0] / uint64(count)
|
||||||
sum.Bandwidth[1] = sum.Bandwidth[1] / uint64(count)
|
sum.Bandwidth[1] = sum.Bandwidth[1] / uint64(count)
|
||||||
sum.Battery[0] = uint8(batterySum / int(count))
|
sum.Battery[0] = uint8(batterySum / int(count))
|
||||||
|
|
||||||
// Average network interfaces
|
|
||||||
if sum.NetworkInterfaces != nil {
|
|
||||||
for key := range sum.NetworkInterfaces {
|
|
||||||
sum.NetworkInterfaces[key] = [4]uint64{
|
|
||||||
sum.NetworkInterfaces[key][0] / uint64(count),
|
|
||||||
sum.NetworkInterfaces[key][1] / uint64(count),
|
|
||||||
sum.NetworkInterfaces[key][2],
|
|
||||||
sum.NetworkInterfaces[key][3],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Average temperatures
|
// Average temperatures
|
||||||
if sum.Temperatures != nil && tempCount > 0 {
|
if sum.Temperatures != nil && tempCount > 0 {
|
||||||
for key := range sum.Temperatures {
|
for key := range sum.Temperatures {
|
||||||
@@ -363,13 +324,6 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
|
|||||||
gpu.Usage = twoDecimals(gpu.Usage / count)
|
gpu.Usage = twoDecimals(gpu.Usage / count)
|
||||||
gpu.Power = twoDecimals(gpu.Power / count)
|
gpu.Power = twoDecimals(gpu.Power / count)
|
||||||
gpu.Count = twoDecimals(gpu.Count / count)
|
gpu.Count = twoDecimals(gpu.Count / count)
|
||||||
|
|
||||||
if gpu.Engines != nil {
|
|
||||||
for engineKey := range gpu.Engines {
|
|
||||||
gpu.Engines[engineKey] = twoDecimals(gpu.Engines[engineKey] / count)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sum.GPUData[id] = gpu
|
sum.GPUData[id] = gpu
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,13 +4,12 @@
|
|||||||
package records_test
|
package records_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"beszel/internal/records"
|
||||||
|
"beszel/internal/tests"
|
||||||
"fmt"
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/records"
|
|
||||||
"github.com/henrygd/beszel/internal/tests"
|
|
||||||
|
|
||||||
"github.com/pocketbase/dbx"
|
"github.com/pocketbase/dbx"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
"github.com/pocketbase/pocketbase/tools/types"
|
"github.com/pocketbase/pocketbase/tools/types"
|
||||||
@@ -175,7 +174,7 @@ func TestDeleteOldSystemStats(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Run deletion
|
// Run deletion
|
||||||
err = records.DeleteOldSystemStats(hub)
|
err = records.TestDeleteOldSystemStats(hub)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Verify results
|
// Verify results
|
||||||
@@ -268,7 +267,7 @@ func TestDeleteOldAlertsHistory(t *testing.T) {
|
|||||||
assert.Equal(t, int64(tc.alertCount), countBefore, "Initial count should match")
|
assert.Equal(t, int64(tc.alertCount), countBefore, "Initial count should match")
|
||||||
|
|
||||||
// Run deletion
|
// Run deletion
|
||||||
err = records.DeleteOldAlertsHistory(hub, tc.countToKeep, tc.countBeforeDeletion)
|
err = records.TestDeleteOldAlertsHistory(hub, tc.countToKeep, tc.countBeforeDeletion)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Count after deletion
|
// Count after deletion
|
||||||
@@ -332,7 +331,7 @@ func TestDeleteOldAlertsHistoryEdgeCases(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Should not error and should not delete anything
|
// Should not error and should not delete anything
|
||||||
err = records.DeleteOldAlertsHistory(hub, 10, 20)
|
err = records.TestDeleteOldAlertsHistory(hub, 10, 20)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
count, err := hub.CountRecords("alerts_history")
|
count, err := hub.CountRecords("alerts_history")
|
||||||
@@ -346,7 +345,7 @@ func TestDeleteOldAlertsHistoryEdgeCases(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Should not error with empty table
|
// Should not error with empty table
|
||||||
err = records.DeleteOldAlertsHistory(hub, 10, 20)
|
err = records.TestDeleteOldAlertsHistory(hub, 10, 20)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -376,7 +375,7 @@ func TestTwoDecimals(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range testCases {
|
for _, tc := range testCases {
|
||||||
result := records.TwoDecimals(tc.input)
|
result := records.TestTwoDecimals(tc.input)
|
||||||
assert.InDelta(t, tc.expected, result, 0.02, "twoDecimals(%f) should equal %f", tc.input, tc.expected)
|
assert.InDelta(t, tc.expected, result, 0.02, "twoDecimals(%f) should equal %f", tc.input, tc.expected)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
23
beszel/internal/records/records_test_helpers.go
Normal file
23
beszel/internal/records/records_test_helpers.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
//go:build testing
|
||||||
|
// +build testing
|
||||||
|
|
||||||
|
package records
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestDeleteOldSystemStats exposes deleteOldSystemStats for testing
|
||||||
|
func TestDeleteOldSystemStats(app core.App) error {
|
||||||
|
return deleteOldSystemStats(app)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDeleteOldAlertsHistory exposes deleteOldAlertsHistory for testing
|
||||||
|
func TestDeleteOldAlertsHistory(app core.App, countToKeep, countBeforeDeletion int) error {
|
||||||
|
return deleteOldAlertsHistory(app, countToKeep, countBeforeDeletion)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTwoDecimals exposes twoDecimals for testing
|
||||||
|
func TestTwoDecimals(value float64) float64 {
|
||||||
|
return twoDecimals(value)
|
||||||
|
}
|
||||||
@@ -5,11 +5,10 @@
|
|||||||
package tests
|
package tests
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"beszel/internal/hub"
|
||||||
"fmt"
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/hub"
|
|
||||||
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
"github.com/pocketbase/pocketbase/tests"
|
"github.com/pocketbase/pocketbase/tests"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -97,56 +96,3 @@ func ClearCollection(t testing.TB, app core.App, collectionName string) error {
|
|||||||
assert.EqualValues(t, recordCount, 0, "should have 0 records after clearing")
|
assert.EqualValues(t, recordCount, 0, "should have 0 records after clearing")
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *TestHub) Cleanup() {
|
|
||||||
h.GetAlertManager().StopWorker()
|
|
||||||
h.GetSystemManager().RemoveAllSystems()
|
|
||||||
h.TestApp.Cleanup()
|
|
||||||
}
|
|
||||||
|
|
||||||
func CreateSystems(app core.App, count int, userId string, status string) ([]*core.Record, error) {
|
|
||||||
systems := make([]*core.Record, 0, count)
|
|
||||||
for i := range count {
|
|
||||||
system, err := CreateRecord(app, "systems", map[string]any{
|
|
||||||
"name": fmt.Sprintf("test-system-%d", i),
|
|
||||||
"host": fmt.Sprintf("127.0.0.%d", i),
|
|
||||||
"port": "33914",
|
|
||||||
"users": []string{userId},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
system.Set("status", status)
|
|
||||||
err = app.SaveNoValidate(system)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
systems = append(systems, system)
|
|
||||||
}
|
|
||||||
return systems, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetHubWithUser creates a test hub with a test user and user settings
|
|
||||||
func GetHubWithUser(t *testing.T) (*TestHub, *core.Record) {
|
|
||||||
hub, err := 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 := 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 = CreateRecord(hub, "user_settings", userSettingsData)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
return hub, user
|
|
||||||
}
|
|
||||||
@@ -2,11 +2,10 @@
|
|||||||
package users
|
package users
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"beszel/migrations"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/migrations"
|
|
||||||
|
|
||||||
"github.com/pocketbase/dbx"
|
"github.com/pocketbase/dbx"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
)
|
)
|
||||||
29
beszel/migrations/initial-settings.go
Normal file
29
beszel/migrations/initial-settings.go
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
BIN
beszel/site/bun.lockb
Executable file
BIN
beszel/site/bun.lockb
Executable file
Binary file not shown.
@@ -10,7 +10,8 @@
|
|||||||
globalThis.BESZEL = {
|
globalThis.BESZEL = {
|
||||||
BASE_PATH: "%BASE_URL%",
|
BASE_PATH: "%BASE_URL%",
|
||||||
HUB_VERSION: "{{V}}",
|
HUB_VERSION: "{{V}}",
|
||||||
HUB_URL: "{{HUB_URL}}"
|
HUB_URL: "{{HUB_URL}}",
|
||||||
|
SYSTEMS: '{SYSTEMS}'
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "beszel",
|
"name": "beszel",
|
||||||
"version": "0.12.9",
|
"version": "0.12.6",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "beszel",
|
"name": "beszel",
|
||||||
"version": "0.12.9",
|
"version": "0.12.6",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@henrygd/queue": "^1.0.7",
|
"@henrygd/queue": "^1.0.7",
|
||||||
"@henrygd/semaphore": "^0.0.2",
|
"@henrygd/semaphore": "^0.0.2",
|
||||||
@@ -35,7 +35,6 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"d3-time": "^3.1.0",
|
"d3-time": "^3.1.0",
|
||||||
"input-otp": "^1.4.2",
|
|
||||||
"lucide-react": "^0.452.0",
|
"lucide-react": "^0.452.0",
|
||||||
"nanostores": "^0.11.4",
|
"nanostores": "^0.11.4",
|
||||||
"pocketbase": "^0.26.2",
|
"pocketbase": "^0.26.2",
|
||||||
@@ -46,7 +45,6 @@
|
|||||||
"valibot": "^0.42.1"
|
"valibot": "^0.42.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.2.3",
|
|
||||||
"@lingui/cli": "^5.4.1",
|
"@lingui/cli": "^5.4.1",
|
||||||
"@lingui/swc-plugin": "^5.6.1",
|
"@lingui/swc-plugin": "^5.6.1",
|
||||||
"@lingui/vite-plugin": "^5.4.1",
|
"@lingui/vite-plugin": "^5.4.1",
|
||||||
@@ -331,169 +329,6 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/biome": {
|
|
||||||
"version": "2.2.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.2.3.tgz",
|
|
||||||
"integrity": "sha512-9w0uMTvPrIdvUrxazZ42Ib7t8Y2yoGLKLdNne93RLICmaHw7mcLv4PPb5LvZLJF3141gQHiCColOh/v6VWlWmg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT OR Apache-2.0",
|
|
||||||
"bin": {
|
|
||||||
"biome": "bin/biome"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14.21.3"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/biome"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@biomejs/cli-darwin-arm64": "2.2.3",
|
|
||||||
"@biomejs/cli-darwin-x64": "2.2.3",
|
|
||||||
"@biomejs/cli-linux-arm64": "2.2.3",
|
|
||||||
"@biomejs/cli-linux-arm64-musl": "2.2.3",
|
|
||||||
"@biomejs/cli-linux-x64": "2.2.3",
|
|
||||||
"@biomejs/cli-linux-x64-musl": "2.2.3",
|
|
||||||
"@biomejs/cli-win32-arm64": "2.2.3",
|
|
||||||
"@biomejs/cli-win32-x64": "2.2.3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@biomejs/cli-darwin-arm64": {
|
|
||||||
"version": "2.2.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.2.3.tgz",
|
|
||||||
"integrity": "sha512-OrqQVBpadB5eqzinXN4+Q6honBz+tTlKVCsbEuEpljK8ASSItzIRZUA02mTikl3H/1nO2BMPFiJ0nkEZNy3B1w==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT OR Apache-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14.21.3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@biomejs/cli-darwin-x64": {
|
|
||||||
"version": "2.2.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.2.3.tgz",
|
|
||||||
"integrity": "sha512-OCdBpb1TmyfsTgBAM1kPMXyYKTohQ48WpiN9tkt9xvU6gKVKHY4oVwteBebiOqyfyzCNaSiuKIPjmHjUZ2ZNMg==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT OR Apache-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14.21.3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@biomejs/cli-linux-arm64": {
|
|
||||||
"version": "2.2.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.2.3.tgz",
|
|
||||||
"integrity": "sha512-g/Uta2DqYpECxG+vUmTAmUKlVhnGEcY7DXWgKP8ruLRa8Si1QHsWknPY3B/wCo0KgYiFIOAZ9hjsHfNb9L85+g==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT OR Apache-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14.21.3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@biomejs/cli-linux-arm64-musl": {
|
|
||||||
"version": "2.2.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.2.3.tgz",
|
|
||||||
"integrity": "sha512-q3w9jJ6JFPZPeqyvwwPeaiS/6NEszZ+pXKF+IczNo8Xj6fsii45a4gEEicKyKIytalV+s829ACZujQlXAiVLBQ==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT OR Apache-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14.21.3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@biomejs/cli-linux-x64": {
|
|
||||||
"version": "2.2.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.2.3.tgz",
|
|
||||||
"integrity": "sha512-LEtyYL1fJsvw35CxrbQ0gZoxOG3oZsAjzfRdvRBRHxOpQ91Q5doRVjvWW/wepgSdgk5hlaNzfeqpyGmfSD0Eyw==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT OR Apache-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14.21.3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@biomejs/cli-linux-x64-musl": {
|
|
||||||
"version": "2.2.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.2.3.tgz",
|
|
||||||
"integrity": "sha512-y76Dn4vkP1sMRGPFlNc+OTETBhGPJ90jY3il6jAfur8XWrYBQV3swZ1Jo0R2g+JpOeeoA0cOwM7mJG6svDz79w==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT OR Apache-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14.21.3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@biomejs/cli-win32-arm64": {
|
|
||||||
"version": "2.2.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.2.3.tgz",
|
|
||||||
"integrity": "sha512-Ms9zFYzjcJK7LV+AOMYnjN3pV3xL8Prxf9aWdDVL74onLn5kcvZ1ZMQswE5XHtnd/r/0bnUd928Rpbs14BzVmA==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT OR Apache-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14.21.3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@biomejs/cli-win32-x64": {
|
|
||||||
"version": "2.2.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.2.3.tgz",
|
|
||||||
"integrity": "sha512-gvCpewE7mBwBIpqk1YrUqNR4mCiyJm6UI3YWQQXkedSSEwzRdodRpaKhbdbHw1/hmTWOVXQ+Eih5Qctf4TCVOQ==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT OR Apache-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14.21.3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.25.6",
|
"version": "0.25.6",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.6.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.6.tgz",
|
||||||
@@ -4438,16 +4273,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/input-otp": {
|
|
||||||
"version": "1.4.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/input-otp/-/input-otp-1.4.2.tgz",
|
|
||||||
"integrity": "sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc",
|
|
||||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/internmap": {
|
"node_modules/internmap": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||||
@@ -5927,14 +5752,14 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.15",
|
"version": "0.2.14",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz",
|
||||||
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
|
"integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.4.4",
|
||||||
"picomatch": "^4.0.3"
|
"picomatch": "^4.0.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12.0.0"
|
"node": ">=12.0.0"
|
||||||
@@ -6121,9 +5946,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "7.1.5",
|
"version": "7.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.3.tgz",
|
||||||
"integrity": "sha512-4cKBO9wR75r0BeIWWWId9XK9Lj6La5X846Zw9dFfzMRw38IlTk2iCcUt6hsyiDRcPidc55ZParFYDXi0nXOeLQ==",
|
"integrity": "sha512-OOUi5zjkDxYrKhTV3V7iKsoS37VUM7v40+HuwEmcrsf11Cdx9y3DIr2Px6liIcZFwt3XSRpQvFpL3WVy7ApkGw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -6132,7 +5957,7 @@
|
|||||||
"picomatch": "^4.0.3",
|
"picomatch": "^4.0.3",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"rollup": "^4.43.0",
|
"rollup": "^4.43.0",
|
||||||
"tinyglobby": "^0.2.15"
|
"tinyglobby": "^0.2.14"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"vite": "bin/vite.js"
|
"vite": "bin/vite.js"
|
||||||
@@ -1,18 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "beszel",
|
"name": "beszel",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.12.9",
|
"version": "0.12.6",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host",
|
"dev": "vite --host",
|
||||||
"build": "lingui extract --overwrite && lingui compile && vite build",
|
"build": "lingui extract --overwrite && lingui compile && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"sync": "lingui extract --overwrite && lingui compile",
|
"sync": "lingui extract --overwrite && lingui compile",
|
||||||
"sync_and_purge": "lingui extract --overwrite --clean && lingui compile",
|
"sync_and_purge": "lingui extract --overwrite --clean && lingui compile"
|
||||||
"format": "biome format --write .",
|
|
||||||
"lint": "biome lint .",
|
|
||||||
"check": "biome check .",
|
|
||||||
"check:fix": "biome check --fix ."
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@henrygd/queue": "^1.0.7",
|
"@henrygd/queue": "^1.0.7",
|
||||||
@@ -42,7 +38,6 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"d3-time": "^3.1.0",
|
"d3-time": "^3.1.0",
|
||||||
"input-otp": "^1.4.2",
|
|
||||||
"lucide-react": "^0.452.0",
|
"lucide-react": "^0.452.0",
|
||||||
"nanostores": "^0.11.4",
|
"nanostores": "^0.11.4",
|
||||||
"pocketbase": "^0.26.2",
|
"pocketbase": "^0.26.2",
|
||||||
@@ -53,7 +48,6 @@
|
|||||||
"valibot": "^0.42.1"
|
"valibot": "^0.42.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.2.3",
|
|
||||||
"@lingui/cli": "^5.4.1",
|
"@lingui/cli": "^5.4.1",
|
||||||
"@lingui/swc-plugin": "^5.6.1",
|
"@lingui/swc-plugin": "^5.6.1",
|
||||||
"@lingui/vite-plugin": "^5.4.1",
|
"@lingui/vite-plugin": "^5.4.1",
|
||||||
@@ -76,4 +70,4 @@
|
|||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@esbuild/linux-arm64": "^0.21.5"
|
"@esbuild/linux-arm64": "^0.21.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user