mirror of
https://github.com/henrygd/beszel.git
synced 2026-02-19 15:36:10 +00:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e3a44e05d | ||
|
|
311095cfdd | ||
|
|
4869c834bb | ||
|
|
e1c1e97f0a | ||
|
|
f6b2824ccc | ||
|
|
f17ffc21b8 | ||
|
|
f792f9b102 | ||
|
|
1def7d8d3a | ||
|
|
ef92b254bf | ||
|
|
10d853c004 | ||
|
|
cdfd116da0 | ||
|
|
283fa9d5c2 | ||
|
|
7d6c0caafc |
1
Makefile
1
Makefile
@@ -51,7 +51,6 @@ clean:
|
||||
lint:
|
||||
golangci-lint run
|
||||
|
||||
test: export GOEXPERIMENT=synctest
|
||||
test:
|
||||
go test -tags=testing ./...
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
@@ -28,6 +29,7 @@ import (
|
||||
// ansiEscapePattern matches ANSI escape sequences (colors, cursor movement, etc.)
|
||||
// This includes CSI sequences like \x1b[...m and simple escapes like \x1b[K
|
||||
var ansiEscapePattern = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b[@-Z\\-_]`)
|
||||
var dockerContainerIDPattern = regexp.MustCompile(`^[a-fA-F0-9]{12,64}$`)
|
||||
|
||||
const (
|
||||
// Docker API timeout in milliseconds
|
||||
@@ -72,6 +74,7 @@ type dockerManager struct {
|
||||
// cacheTimeMs -> DeltaTracker for network bytes sent/received
|
||||
networkSentTrackers map[uint16]*deltatracker.DeltaTracker[string, uint64]
|
||||
networkRecvTrackers map[uint16]*deltatracker.DeltaTracker[string, uint64]
|
||||
retrySleep func(time.Duration)
|
||||
}
|
||||
|
||||
// userAgentRoundTripper is a custom http.RoundTripper that adds a User-Agent header to all requests
|
||||
@@ -565,6 +568,7 @@ func newDockerManager() *dockerManager {
|
||||
lastCpuReadTime: make(map[uint16]map[string]time.Time),
|
||||
networkSentTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
|
||||
networkRecvTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
|
||||
retrySleep: time.Sleep,
|
||||
}
|
||||
|
||||
// If using podman, return client
|
||||
@@ -574,7 +578,7 @@ func newDockerManager() *dockerManager {
|
||||
return manager
|
||||
}
|
||||
|
||||
// this can take up to 5 seconds with retry, so run in goroutine
|
||||
// run version check in goroutine to avoid blocking (server may not be ready and requires retries)
|
||||
go manager.checkDockerVersion()
|
||||
|
||||
// give version check a chance to complete before returning
|
||||
@@ -594,18 +598,18 @@ func (dm *dockerManager) checkDockerVersion() {
|
||||
const versionMaxTries = 2
|
||||
for i := 1; i <= versionMaxTries; i++ {
|
||||
resp, err = dm.client.Get("http://localhost/version")
|
||||
if err == nil {
|
||||
if err == nil && resp.StatusCode == http.StatusOK {
|
||||
break
|
||||
}
|
||||
if resp != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
if i < versionMaxTries {
|
||||
slog.Debug("Failed to get Docker version; retrying", "attempt", i, "error", err)
|
||||
time.Sleep(5 * time.Second)
|
||||
slog.Debug("Failed to get Docker version; retrying", "attempt", i, "err", err, "response", resp)
|
||||
dm.retrySleep(5 * time.Second)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if err != nil || resp.StatusCode != http.StatusOK {
|
||||
return
|
||||
}
|
||||
if err := dm.decode(resp, &versionInfo); err != nil {
|
||||
@@ -647,9 +651,34 @@ func getDockerHost() string {
|
||||
return scheme + socks[0]
|
||||
}
|
||||
|
||||
func validateContainerID(containerID string) error {
|
||||
if !dockerContainerIDPattern.MatchString(containerID) {
|
||||
return fmt.Errorf("invalid container id")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildDockerContainerEndpoint(containerID, action string, query url.Values) (string, error) {
|
||||
if err := validateContainerID(containerID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
u := &url.URL{
|
||||
Scheme: "http",
|
||||
Host: "localhost",
|
||||
Path: fmt.Sprintf("/containers/%s/%s", url.PathEscape(containerID), action),
|
||||
}
|
||||
if len(query) > 0 {
|
||||
u.RawQuery = query.Encode()
|
||||
}
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
// getContainerInfo fetches the inspection data for a container
|
||||
func (dm *dockerManager) getContainerInfo(ctx context.Context, containerID string) ([]byte, error) {
|
||||
endpoint := fmt.Sprintf("http://localhost/containers/%s/json", containerID)
|
||||
endpoint, err := buildDockerContainerEndpoint(containerID, "json", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -680,7 +709,15 @@ func (dm *dockerManager) getContainerInfo(ctx context.Context, containerID strin
|
||||
|
||||
// getLogs fetches the logs for a container
|
||||
func (dm *dockerManager) getLogs(ctx context.Context, containerID string) (string, error) {
|
||||
endpoint := fmt.Sprintf("http://localhost/containers/%s/logs?stdout=1&stderr=1&tail=%d", containerID, dockerLogsTail)
|
||||
query := url.Values{
|
||||
"stdout": []string{"1"},
|
||||
"stderr": []string{"1"},
|
||||
"tail": []string{fmt.Sprintf("%d", dockerLogsTail)},
|
||||
}
|
||||
endpoint, err := buildDockerContainerEndpoint(containerID, "logs", query)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -698,8 +735,17 @@ func (dm *dockerManager) getLogs(ctx context.Context, containerID string) (strin
|
||||
}
|
||||
|
||||
var builder strings.Builder
|
||||
multiplexed := resp.Header.Get("Content-Type") == "application/vnd.docker.multiplexed-stream"
|
||||
if err := decodeDockerLogStream(resp.Body, &builder, multiplexed); err != nil {
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
multiplexed := strings.HasSuffix(contentType, "multiplexed-stream")
|
||||
logReader := io.Reader(resp.Body)
|
||||
if !multiplexed {
|
||||
// Podman may return multiplexed logs without Content-Type. Sniff the first frame header
|
||||
// with a small buffered reader only when the header check fails.
|
||||
bufferedReader := bufio.NewReaderSize(resp.Body, 8)
|
||||
multiplexed = detectDockerMultiplexedStream(bufferedReader)
|
||||
logReader = bufferedReader
|
||||
}
|
||||
if err := decodeDockerLogStream(logReader, &builder, multiplexed); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -711,6 +757,23 @@ func (dm *dockerManager) getLogs(ctx context.Context, containerID string) (strin
|
||||
return logs, nil
|
||||
}
|
||||
|
||||
func detectDockerMultiplexedStream(reader *bufio.Reader) bool {
|
||||
const headerSize = 8
|
||||
header, err := reader.Peek(headerSize)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if header[0] != 0x01 && header[0] != 0x02 {
|
||||
return false
|
||||
}
|
||||
// Docker's stream framing header reserves bytes 1-3 as zero.
|
||||
if header[1] != 0 || header[2] != 0 || header[3] != 0 {
|
||||
return false
|
||||
}
|
||||
frameLen := binary.BigEndian.Uint32(header[4:])
|
||||
return frameLen <= maxLogFrameSize
|
||||
}
|
||||
|
||||
func decodeDockerLogStream(reader io.Reader, builder *strings.Builder, multiplexed bool) error {
|
||||
if !multiplexed {
|
||||
_, err := io.Copy(builder, io.LimitReader(reader, maxTotalLogSize))
|
||||
|
||||
@@ -5,7 +5,14 @@ package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -19,6 +26,37 @@ import (
|
||||
|
||||
var defaultCacheTimeMs = uint16(60_000)
|
||||
|
||||
type recordingRoundTripper struct {
|
||||
statusCode int
|
||||
body string
|
||||
contentType string
|
||||
called bool
|
||||
lastPath string
|
||||
lastQuery map[string]string
|
||||
}
|
||||
|
||||
func (rt *recordingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
rt.called = true
|
||||
rt.lastPath = req.URL.EscapedPath()
|
||||
rt.lastQuery = map[string]string{}
|
||||
for key, values := range req.URL.Query() {
|
||||
if len(values) > 0 {
|
||||
rt.lastQuery[key] = values[0]
|
||||
}
|
||||
}
|
||||
resp := &http.Response{
|
||||
StatusCode: rt.statusCode,
|
||||
Status: "200 OK",
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(rt.body)),
|
||||
Request: req,
|
||||
}
|
||||
if rt.contentType != "" {
|
||||
resp.Header.Set("Content-Type", rt.contentType)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// cycleCpuDeltas cycles the CPU tracking data for a specific cache time interval
|
||||
func (dm *dockerManager) cycleCpuDeltas(cacheTimeMs uint16) {
|
||||
// Clear the CPU tracking maps for this cache time interval
|
||||
@@ -110,6 +148,72 @@ func TestCalculateMemoryUsage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDockerContainerEndpoint(t *testing.T) {
|
||||
t.Run("valid container ID builds escaped endpoint", func(t *testing.T) {
|
||||
endpoint, err := buildDockerContainerEndpoint("0123456789ab", "json", nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "http://localhost/containers/0123456789ab/json", endpoint)
|
||||
})
|
||||
|
||||
t.Run("invalid container ID is rejected", func(t *testing.T) {
|
||||
_, err := buildDockerContainerEndpoint("../../version", "json", nil)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "invalid container id")
|
||||
})
|
||||
}
|
||||
|
||||
func TestContainerDetailsRequestsValidateContainerID(t *testing.T) {
|
||||
rt := &recordingRoundTripper{
|
||||
statusCode: 200,
|
||||
body: `{"Config":{"Env":["SECRET=1"]}}`,
|
||||
}
|
||||
dm := &dockerManager{
|
||||
client: &http.Client{Transport: rt},
|
||||
}
|
||||
|
||||
_, err := dm.getContainerInfo(context.Background(), "../version")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "invalid container id")
|
||||
assert.False(t, rt.called, "request should be rejected before dispatching to Docker API")
|
||||
}
|
||||
|
||||
func TestContainerDetailsRequestsUseExpectedDockerPaths(t *testing.T) {
|
||||
t.Run("container info uses container json endpoint", func(t *testing.T) {
|
||||
rt := &recordingRoundTripper{
|
||||
statusCode: 200,
|
||||
body: `{"Config":{"Env":["SECRET=1"]},"Name":"demo"}`,
|
||||
}
|
||||
dm := &dockerManager{
|
||||
client: &http.Client{Transport: rt},
|
||||
}
|
||||
|
||||
body, err := dm.getContainerInfo(context.Background(), "0123456789ab")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, rt.called)
|
||||
assert.Equal(t, "/containers/0123456789ab/json", rt.lastPath)
|
||||
assert.NotContains(t, string(body), "SECRET=1", "sensitive env vars should be removed")
|
||||
})
|
||||
|
||||
t.Run("container logs uses expected endpoint and query params", func(t *testing.T) {
|
||||
rt := &recordingRoundTripper{
|
||||
statusCode: 200,
|
||||
body: "line1\nline2\n",
|
||||
}
|
||||
dm := &dockerManager{
|
||||
client: &http.Client{Transport: rt},
|
||||
}
|
||||
|
||||
logs, err := dm.getLogs(context.Background(), "abcdef123456")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, rt.called)
|
||||
assert.Equal(t, "/containers/abcdef123456/logs", rt.lastPath)
|
||||
assert.Equal(t, "1", rt.lastQuery["stdout"])
|
||||
assert.Equal(t, "1", rt.lastQuery["stderr"])
|
||||
assert.Equal(t, "200", rt.lastQuery["tail"])
|
||||
assert.Equal(t, "line1\nline2\n", logs)
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateCpuPercentage(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -379,6 +483,117 @@ func TestDockerManagerCreation(t *testing.T) {
|
||||
assert.NotNil(t, dm.networkRecvTrackers)
|
||||
}
|
||||
|
||||
func TestCheckDockerVersion(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
responses []struct {
|
||||
statusCode int
|
||||
body string
|
||||
}
|
||||
expectedGood bool
|
||||
expectedRequests int
|
||||
}{
|
||||
{
|
||||
name: "200 with good version on first try",
|
||||
responses: []struct {
|
||||
statusCode int
|
||||
body string
|
||||
}{
|
||||
{http.StatusOK, `{"Version":"25.0.1"}`},
|
||||
},
|
||||
expectedGood: true,
|
||||
expectedRequests: 1,
|
||||
},
|
||||
{
|
||||
name: "200 with old version on first try",
|
||||
responses: []struct {
|
||||
statusCode int
|
||||
body string
|
||||
}{
|
||||
{http.StatusOK, `{"Version":"24.0.7"}`},
|
||||
},
|
||||
expectedGood: false,
|
||||
expectedRequests: 1,
|
||||
},
|
||||
{
|
||||
name: "non-200 then 200 with good version",
|
||||
responses: []struct {
|
||||
statusCode int
|
||||
body string
|
||||
}{
|
||||
{http.StatusServiceUnavailable, `"not ready"`},
|
||||
{http.StatusOK, `{"Version":"25.1.0"}`},
|
||||
},
|
||||
expectedGood: true,
|
||||
expectedRequests: 2,
|
||||
},
|
||||
{
|
||||
name: "non-200 on all retries",
|
||||
responses: []struct {
|
||||
statusCode int
|
||||
body string
|
||||
}{
|
||||
{http.StatusInternalServerError, `"error"`},
|
||||
{http.StatusUnauthorized, `"error"`},
|
||||
},
|
||||
expectedGood: false,
|
||||
expectedRequests: 2,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
requestCount := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
idx := requestCount
|
||||
requestCount++
|
||||
if idx >= len(tt.responses) {
|
||||
idx = len(tt.responses) - 1
|
||||
}
|
||||
w.WriteHeader(tt.responses[idx].statusCode)
|
||||
fmt.Fprint(w, tt.responses[idx].body)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
dm := &dockerManager{
|
||||
client: &http.Client{
|
||||
Transport: &http.Transport{
|
||||
DialContext: func(_ context.Context, network, _ string) (net.Conn, error) {
|
||||
return net.Dial(network, server.Listener.Addr().String())
|
||||
},
|
||||
},
|
||||
},
|
||||
retrySleep: func(time.Duration) {},
|
||||
}
|
||||
|
||||
dm.checkDockerVersion()
|
||||
|
||||
assert.Equal(t, tt.expectedGood, dm.goodDockerVersion)
|
||||
assert.Equal(t, tt.expectedRequests, requestCount)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("request error on all retries", func(t *testing.T) {
|
||||
requestCount := 0
|
||||
dm := &dockerManager{
|
||||
client: &http.Client{
|
||||
Transport: &http.Transport{
|
||||
DialContext: func(_ context.Context, _, _ string) (net.Conn, error) {
|
||||
requestCount++
|
||||
return nil, errors.New("connection refused")
|
||||
},
|
||||
},
|
||||
},
|
||||
retrySleep: func(time.Duration) {},
|
||||
}
|
||||
|
||||
dm.checkDockerVersion()
|
||||
|
||||
assert.False(t, dm.goodDockerVersion)
|
||||
assert.Equal(t, 2, requestCount)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCycleCpuDeltas(t *testing.T) {
|
||||
dm := &dockerManager{
|
||||
lastCpuContainer: map[uint16]map[string]uint64{
|
||||
@@ -699,6 +914,42 @@ func TestContainerStatsEndToEndWithRealData(t *testing.T) {
|
||||
assert.Equal(t, testTime, testStats.PrevReadTime)
|
||||
}
|
||||
|
||||
func TestGetLogsDetectsMultiplexedWithoutContentType(t *testing.T) {
|
||||
// Docker multiplexed frame: [stream][0,0,0][len(4 bytes BE)][payload]
|
||||
frame := []byte{
|
||||
0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x05,
|
||||
'H', 'e', 'l', 'l', 'o',
|
||||
}
|
||||
rt := &recordingRoundTripper{
|
||||
statusCode: 200,
|
||||
body: string(frame),
|
||||
// Intentionally omit content type to simulate Podman behavior.
|
||||
}
|
||||
dm := &dockerManager{
|
||||
client: &http.Client{Transport: rt},
|
||||
}
|
||||
|
||||
logs, err := dm.getLogs(context.Background(), "abcdef123456")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Hello", logs)
|
||||
}
|
||||
|
||||
func TestGetLogsDoesNotMisclassifyRawStreamAsMultiplexed(t *testing.T) {
|
||||
// Starts with 0x01, but doesn't match Docker frame signature (reserved bytes aren't all zero).
|
||||
raw := []byte{0x01, 0x02, 0x03, 0x04, 'r', 'a', 'w'}
|
||||
rt := &recordingRoundTripper{
|
||||
statusCode: 200,
|
||||
body: string(raw),
|
||||
}
|
||||
dm := &dockerManager{
|
||||
client: &http.Client{Transport: rt},
|
||||
}
|
||||
|
||||
logs, err := dm.getLogs(context.Background(), "abcdef123456")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, raw, []byte(logs))
|
||||
}
|
||||
|
||||
func TestEdgeCasesWithRealData(t *testing.T) {
|
||||
// Test with minimal container stats
|
||||
minimalStats := &container.ApiStats{
|
||||
|
||||
91
agent/gpu.go
91
agent/gpu.go
@@ -9,6 +9,7 @@ import (
|
||||
"maps"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -19,11 +20,13 @@ import (
|
||||
|
||||
const (
|
||||
// Commands
|
||||
nvidiaSmiCmd string = "nvidia-smi"
|
||||
rocmSmiCmd string = "rocm-smi"
|
||||
tegraStatsCmd string = "tegrastats"
|
||||
nvtopCmd string = "nvtop"
|
||||
noGPUFoundMsg string = "no GPU found - see https://beszel.dev/guide/gpu"
|
||||
nvidiaSmiCmd string = "nvidia-smi"
|
||||
rocmSmiCmd string = "rocm-smi"
|
||||
tegraStatsCmd string = "tegrastats"
|
||||
nvtopCmd string = "nvtop"
|
||||
powermetricsCmd string = "powermetrics"
|
||||
macmonCmd string = "macmon"
|
||||
noGPUFoundMsg string = "no GPU found - see https://beszel.dev/guide/gpu"
|
||||
|
||||
// Command retry and timeout constants
|
||||
retryWaitTime time.Duration = 5 * time.Second
|
||||
@@ -82,15 +85,18 @@ var errNoValidData = fmt.Errorf("no valid GPU data found") // Error for missing
|
||||
type collectorSource string
|
||||
|
||||
const (
|
||||
collectorSourceNVTop collectorSource = collectorSource(nvtopCmd)
|
||||
collectorSourceNVML collectorSource = "nvml"
|
||||
collectorSourceNvidiaSMI collectorSource = collectorSource(nvidiaSmiCmd)
|
||||
collectorSourceIntelGpuTop collectorSource = collectorSource(intelGpuStatsCmd)
|
||||
collectorSourceAmdSysfs collectorSource = "amd_sysfs"
|
||||
collectorSourceRocmSMI collectorSource = collectorSource(rocmSmiCmd)
|
||||
collectorGroupNvidia string = "nvidia"
|
||||
collectorGroupIntel string = "intel"
|
||||
collectorGroupAmd string = "amd"
|
||||
collectorSourceNVTop collectorSource = collectorSource(nvtopCmd)
|
||||
collectorSourceNVML collectorSource = "nvml"
|
||||
collectorSourceNvidiaSMI collectorSource = collectorSource(nvidiaSmiCmd)
|
||||
collectorSourceIntelGpuTop collectorSource = collectorSource(intelGpuStatsCmd)
|
||||
collectorSourceAmdSysfs collectorSource = "amd_sysfs"
|
||||
collectorSourceRocmSMI collectorSource = collectorSource(rocmSmiCmd)
|
||||
collectorSourceMacmon collectorSource = collectorSource(macmonCmd)
|
||||
collectorSourcePowermetrics collectorSource = collectorSource(powermetricsCmd)
|
||||
collectorGroupNvidia string = "nvidia"
|
||||
collectorGroupIntel string = "intel"
|
||||
collectorGroupAmd string = "amd"
|
||||
collectorGroupApple string = "apple"
|
||||
)
|
||||
|
||||
func isValidCollectorSource(source collectorSource) bool {
|
||||
@@ -100,7 +106,9 @@ func isValidCollectorSource(source collectorSource) bool {
|
||||
collectorSourceNvidiaSMI,
|
||||
collectorSourceIntelGpuTop,
|
||||
collectorSourceAmdSysfs,
|
||||
collectorSourceRocmSMI:
|
||||
collectorSourceRocmSMI,
|
||||
collectorSourceMacmon,
|
||||
collectorSourcePowermetrics:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -108,12 +116,14 @@ func isValidCollectorSource(source collectorSource) bool {
|
||||
|
||||
// gpuCapabilities describes detected GPU tooling and sysfs support on the host.
|
||||
type gpuCapabilities struct {
|
||||
hasNvidiaSmi bool
|
||||
hasRocmSmi bool
|
||||
hasAmdSysfs bool
|
||||
hasTegrastats bool
|
||||
hasIntelGpuTop bool
|
||||
hasNvtop bool
|
||||
hasNvidiaSmi bool
|
||||
hasRocmSmi bool
|
||||
hasAmdSysfs bool
|
||||
hasTegrastats bool
|
||||
hasIntelGpuTop bool
|
||||
hasNvtop bool
|
||||
hasMacmon bool
|
||||
hasPowermetrics bool
|
||||
}
|
||||
|
||||
type collectorDefinition struct {
|
||||
@@ -449,11 +459,19 @@ func (gm *GPUManager) discoverGpuCapabilities() gpuCapabilities {
|
||||
if _, err := exec.LookPath(nvtopCmd); err == nil {
|
||||
caps.hasNvtop = true
|
||||
}
|
||||
if runtime.GOOS == "darwin" {
|
||||
if _, err := exec.LookPath(macmonCmd); err == nil {
|
||||
caps.hasMacmon = true
|
||||
}
|
||||
if _, err := exec.LookPath(powermetricsCmd); err == nil {
|
||||
caps.hasPowermetrics = true
|
||||
}
|
||||
}
|
||||
return caps
|
||||
}
|
||||
|
||||
func hasAnyGpuCollector(caps gpuCapabilities) bool {
|
||||
return caps.hasNvidiaSmi || caps.hasRocmSmi || caps.hasAmdSysfs || caps.hasTegrastats || caps.hasIntelGpuTop || caps.hasNvtop
|
||||
return caps.hasNvidiaSmi || caps.hasRocmSmi || caps.hasAmdSysfs || caps.hasTegrastats || caps.hasIntelGpuTop || caps.hasNvtop || caps.hasMacmon || caps.hasPowermetrics
|
||||
}
|
||||
|
||||
func (gm *GPUManager) startIntelCollector() {
|
||||
@@ -567,6 +585,22 @@ func (gm *GPUManager) collectorDefinitions(caps gpuCapabilities) map[collectorSo
|
||||
return true
|
||||
},
|
||||
},
|
||||
collectorSourceMacmon: {
|
||||
group: collectorGroupApple,
|
||||
available: caps.hasMacmon,
|
||||
start: func(_ func()) bool {
|
||||
gm.startMacmonCollector()
|
||||
return true
|
||||
},
|
||||
},
|
||||
collectorSourcePowermetrics: {
|
||||
group: collectorGroupApple,
|
||||
available: caps.hasPowermetrics,
|
||||
start: func(_ func()) bool {
|
||||
gm.startPowermetricsCollector()
|
||||
return true
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -674,7 +708,18 @@ func (gm *GPUManager) resolveLegacyCollectorPriority(caps gpuCapabilities) []col
|
||||
priorities = append(priorities, collectorSourceIntelGpuTop)
|
||||
}
|
||||
|
||||
// Keep nvtop as a legacy last resort only when no vendor collector exists.
|
||||
// Apple collectors are currently opt-in only for testing.
|
||||
// Enable them with GPU_COLLECTOR=macmon or GPU_COLLECTOR=powermetrics.
|
||||
// TODO: uncomment below when Apple collectors are confirmed to be working.
|
||||
//
|
||||
// Prefer macmon on macOS (no sudo). Fall back to powermetrics if present.
|
||||
// if caps.hasMacmon {
|
||||
// priorities = append(priorities, collectorSourceMacmon)
|
||||
// } else if caps.hasPowermetrics {
|
||||
// priorities = append(priorities, collectorSourcePowermetrics)
|
||||
// }
|
||||
|
||||
// Keep nvtop as a last resort only when no vendor collector exists.
|
||||
if len(priorities) == 0 && caps.hasNvtop {
|
||||
priorities = append(priorities, collectorSourceNVTop)
|
||||
}
|
||||
|
||||
@@ -103,8 +103,17 @@ func (gm *GPUManager) updateAmdGpuData(cardPath string) bool {
|
||||
|
||||
// Read all sysfs values first (no lock needed - these can be slow)
|
||||
usage, usageErr := readSysfsFloat(filepath.Join(devicePath, "gpu_busy_percent"))
|
||||
memUsed, memUsedErr := readSysfsFloat(filepath.Join(devicePath, "mem_info_vram_used"))
|
||||
memTotal, _ := readSysfsFloat(filepath.Join(devicePath, "mem_info_vram_total"))
|
||||
vramUsed, memUsedErr := readSysfsFloat(filepath.Join(devicePath, "mem_info_vram_used"))
|
||||
vramTotal, _ := readSysfsFloat(filepath.Join(devicePath, "mem_info_vram_total"))
|
||||
memUsed := vramUsed
|
||||
memTotal := vramTotal
|
||||
// if gtt is present, add it to the memory used and total (https://github.com/henrygd/beszel/issues/1569#issuecomment-3837640484)
|
||||
if gttUsed, err := readSysfsFloat(filepath.Join(devicePath, "mem_info_gtt_used")); err == nil && gttUsed > 0 {
|
||||
if gttTotal, err := readSysfsFloat(filepath.Join(devicePath, "mem_info_gtt_total")); err == nil {
|
||||
memUsed += gttUsed
|
||||
memTotal += gttTotal
|
||||
}
|
||||
}
|
||||
|
||||
var temp, power float64
|
||||
hwmons, _ := filepath.Glob(filepath.Join(devicePath, "hwmon/hwmon*"))
|
||||
|
||||
@@ -119,40 +119,68 @@ func TestAmdgpuNameCacheRoundTrip(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUpdateAmdGpuDataWithFakeSysfs(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cardPath := filepath.Join(dir, "card0")
|
||||
devicePath := filepath.Join(cardPath, "device")
|
||||
hwmonPath := filepath.Join(devicePath, "hwmon", "hwmon0")
|
||||
require.NoError(t, os.MkdirAll(hwmonPath, 0o755))
|
||||
|
||||
write := func(name, content string) {
|
||||
require.NoError(t, os.WriteFile(filepath.Join(devicePath, name), []byte(content), 0o644))
|
||||
tests := []struct {
|
||||
name string
|
||||
writeGTT bool
|
||||
wantMemoryUsed float64
|
||||
wantMemoryTotal float64
|
||||
}{
|
||||
{
|
||||
name: "sums vram and gtt when gtt is present",
|
||||
writeGTT: true,
|
||||
wantMemoryUsed: bytesToMegabytes(1073741824 + 536870912),
|
||||
wantMemoryTotal: bytesToMegabytes(2147483648 + 4294967296),
|
||||
},
|
||||
{
|
||||
name: "falls back to vram when gtt is missing",
|
||||
writeGTT: false,
|
||||
wantMemoryUsed: bytesToMegabytes(1073741824),
|
||||
wantMemoryTotal: bytesToMegabytes(2147483648),
|
||||
},
|
||||
}
|
||||
write("vendor", "0x1002")
|
||||
write("device", "0x1506")
|
||||
write("revision", "0xc1")
|
||||
write("gpu_busy_percent", "25")
|
||||
write("mem_info_vram_used", "1073741824")
|
||||
write("mem_info_vram_total", "2147483648")
|
||||
require.NoError(t, os.WriteFile(filepath.Join(hwmonPath, "temp1_input"), []byte("45000"), 0o644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(hwmonPath, "power1_input"), []byte("20000000"), 0o644))
|
||||
|
||||
// Pre-cache name so getAmdGpuName returns a known value (it uses system amdgpu.ids path)
|
||||
cacheAmdgpuName("1506", "c1", "AMD Radeon 610M Graphics", true)
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cardPath := filepath.Join(dir, "card0")
|
||||
devicePath := filepath.Join(cardPath, "device")
|
||||
hwmonPath := filepath.Join(devicePath, "hwmon", "hwmon0")
|
||||
require.NoError(t, os.MkdirAll(hwmonPath, 0o755))
|
||||
|
||||
gm := &GPUManager{GpuDataMap: make(map[string]*system.GPUData)}
|
||||
ok := gm.updateAmdGpuData(cardPath)
|
||||
require.True(t, ok)
|
||||
write := func(name, content string) {
|
||||
require.NoError(t, os.WriteFile(filepath.Join(devicePath, name), []byte(content), 0o644))
|
||||
}
|
||||
write("vendor", "0x1002")
|
||||
write("device", "0x1506")
|
||||
write("revision", "0xc1")
|
||||
write("gpu_busy_percent", "25")
|
||||
write("mem_info_vram_used", "1073741824")
|
||||
write("mem_info_vram_total", "2147483648")
|
||||
if tt.writeGTT {
|
||||
write("mem_info_gtt_used", "536870912")
|
||||
write("mem_info_gtt_total", "4294967296")
|
||||
}
|
||||
require.NoError(t, os.WriteFile(filepath.Join(hwmonPath, "temp1_input"), []byte("45000"), 0o644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(hwmonPath, "power1_input"), []byte("20000000"), 0o644))
|
||||
|
||||
gpu, ok := gm.GpuDataMap["card0"]
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "AMD Radeon 610M", gpu.Name)
|
||||
assert.Equal(t, 25.0, gpu.Usage)
|
||||
assert.Equal(t, bytesToMegabytes(1073741824), gpu.MemoryUsed)
|
||||
assert.Equal(t, bytesToMegabytes(2147483648), gpu.MemoryTotal)
|
||||
assert.Equal(t, 45.0, gpu.Temperature)
|
||||
assert.Equal(t, 20.0, gpu.Power)
|
||||
assert.Equal(t, 1.0, gpu.Count)
|
||||
// Pre-cache name so getAmdGpuName returns a known value (it uses system amdgpu.ids path)
|
||||
cacheAmdgpuName("1506", "c1", "AMD Radeon 610M Graphics", true)
|
||||
|
||||
gm := &GPUManager{GpuDataMap: make(map[string]*system.GPUData)}
|
||||
ok := gm.updateAmdGpuData(cardPath)
|
||||
require.True(t, ok)
|
||||
|
||||
gpu, ok := gm.GpuDataMap["card0"]
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "AMD Radeon 610M", gpu.Name)
|
||||
assert.Equal(t, 25.0, gpu.Usage)
|
||||
assert.Equal(t, tt.wantMemoryUsed, gpu.MemoryUsed)
|
||||
assert.Equal(t, tt.wantMemoryTotal, gpu.MemoryTotal)
|
||||
assert.Equal(t, 45.0, gpu.Temperature)
|
||||
assert.Equal(t, 20.0, gpu.Power)
|
||||
assert.Equal(t, 1.0, gpu.Count)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupAmdgpuNameInFile(t *testing.T) {
|
||||
|
||||
252
agent/gpu_darwin.go
Normal file
252
agent/gpu_darwin.go
Normal file
@@ -0,0 +1,252 @@
|
||||
//go:build darwin
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
)
|
||||
|
||||
const (
|
||||
// powermetricsSampleIntervalMs is the sampling interval passed to powermetrics (-i).
|
||||
powermetricsSampleIntervalMs = 500
|
||||
// powermetricsPollInterval is how often we run powermetrics to collect a new sample.
|
||||
powermetricsPollInterval = 2 * time.Second
|
||||
// macmonIntervalMs is the sampling interval passed to macmon pipe (-i), in milliseconds.
|
||||
macmonIntervalMs = 2500
|
||||
)
|
||||
|
||||
const appleGPUID = "0"
|
||||
|
||||
// startPowermetricsCollector runs powermetrics --samplers gpu_power in a loop and updates
|
||||
// GPU usage and power. Requires root (sudo) on macOS. A single logical GPU is reported as id "0".
|
||||
func (gm *GPUManager) startPowermetricsCollector() {
|
||||
// Ensure single GPU entry for Apple GPU
|
||||
if _, ok := gm.GpuDataMap[appleGPUID]; !ok {
|
||||
gm.GpuDataMap[appleGPUID] = &system.GPUData{Name: "Apple GPU"}
|
||||
}
|
||||
|
||||
go func() {
|
||||
failures := 0
|
||||
for {
|
||||
if err := gm.collectPowermetrics(); err != nil {
|
||||
failures++
|
||||
if failures > maxFailureRetries {
|
||||
slog.Warn("powermetrics GPU collector failed repeatedly, stopping", "err", err)
|
||||
break
|
||||
}
|
||||
slog.Warn("Error collecting macOS GPU data via powermetrics (may require sudo)", "err", err)
|
||||
time.Sleep(retryWaitTime)
|
||||
continue
|
||||
}
|
||||
failures = 0
|
||||
time.Sleep(powermetricsPollInterval)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// collectPowermetrics runs powermetrics once and parses GPU usage and power from its output.
|
||||
func (gm *GPUManager) collectPowermetrics() error {
|
||||
interval := strconv.Itoa(powermetricsSampleIntervalMs)
|
||||
cmd := exec.Command(powermetricsCmd, "--samplers", "gpu_power", "-i", interval, "-n", "1")
|
||||
cmd.Stderr = nil
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !gm.parsePowermetricsData(out) {
|
||||
return errNoValidData
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// parsePowermetricsData parses powermetrics gpu_power output and updates GpuDataMap["0"].
|
||||
// Example output:
|
||||
//
|
||||
// **** GPU usage ****
|
||||
// GPU HW active frequency: 444 MHz
|
||||
// GPU HW active residency: 0.97% (444 MHz: .97% ...
|
||||
// GPU idle residency: 99.03%
|
||||
// GPU Power: 4 mW
|
||||
func (gm *GPUManager) parsePowermetricsData(output []byte) bool {
|
||||
var idleResidency, powerMW float64
|
||||
var gotIdle, gotPower bool
|
||||
|
||||
scanner := bufio.NewScanner(bytes.NewReader(output))
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if strings.HasPrefix(line, "GPU idle residency:") {
|
||||
// "GPU idle residency: 99.03%"
|
||||
fields := strings.Fields(strings.TrimPrefix(line, "GPU idle residency:"))
|
||||
if len(fields) >= 1 {
|
||||
pct := strings.TrimSuffix(fields[0], "%")
|
||||
if v, err := strconv.ParseFloat(pct, 64); err == nil {
|
||||
idleResidency = v
|
||||
gotIdle = true
|
||||
}
|
||||
}
|
||||
} else if strings.HasPrefix(line, "GPU Power:") {
|
||||
// "GPU Power: 4 mW"
|
||||
fields := strings.Fields(strings.TrimPrefix(line, "GPU Power:"))
|
||||
if len(fields) >= 1 {
|
||||
if v, err := strconv.ParseFloat(fields[0], 64); err == nil {
|
||||
powerMW = v
|
||||
gotPower = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return false
|
||||
}
|
||||
if !gotIdle && !gotPower {
|
||||
return false
|
||||
}
|
||||
|
||||
gm.Lock()
|
||||
defer gm.Unlock()
|
||||
|
||||
if _, ok := gm.GpuDataMap[appleGPUID]; !ok {
|
||||
gm.GpuDataMap[appleGPUID] = &system.GPUData{Name: "Apple GPU"}
|
||||
}
|
||||
gpu := gm.GpuDataMap[appleGPUID]
|
||||
|
||||
if gotIdle {
|
||||
// Usage = 100 - idle residency (e.g. 100 - 99.03 = 0.97%)
|
||||
gpu.Usage += 100 - idleResidency
|
||||
}
|
||||
if gotPower {
|
||||
// mW -> W
|
||||
gpu.Power += powerMW / milliwattsInAWatt
|
||||
}
|
||||
gpu.Count++
|
||||
return true
|
||||
}
|
||||
|
||||
// startMacmonCollector runs `macmon pipe` in a loop and parses one JSON object per line.
|
||||
// This collector does not require sudo. A single logical GPU is reported as id "0".
|
||||
func (gm *GPUManager) startMacmonCollector() {
|
||||
if _, ok := gm.GpuDataMap[appleGPUID]; !ok {
|
||||
gm.GpuDataMap[appleGPUID] = &system.GPUData{Name: "Apple GPU"}
|
||||
}
|
||||
|
||||
go func() {
|
||||
failures := 0
|
||||
for {
|
||||
if err := gm.collectMacmonPipe(); err != nil {
|
||||
failures++
|
||||
if failures > maxFailureRetries {
|
||||
slog.Warn("macmon GPU collector failed repeatedly, stopping", "err", err)
|
||||
break
|
||||
}
|
||||
slog.Warn("Error collecting macOS GPU data via macmon", "err", err)
|
||||
time.Sleep(retryWaitTime)
|
||||
continue
|
||||
}
|
||||
failures = 0
|
||||
// `macmon pipe` is long-running; if it returns, wait a bit before restarting.
|
||||
time.Sleep(retryWaitTime)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
type macmonTemp struct {
|
||||
GPUTempAvg float64 `json:"gpu_temp_avg"`
|
||||
}
|
||||
|
||||
type macmonSample struct {
|
||||
GPUPower float64 `json:"gpu_power"` // watts (macmon reports fractional values)
|
||||
GPURAMPower float64 `json:"gpu_ram_power"` // watts
|
||||
GPUUsage []float64 `json:"gpu_usage"` // [freq_mhz, usage] where usage is typically 0..1
|
||||
Temp macmonTemp `json:"temp"`
|
||||
}
|
||||
|
||||
func (gm *GPUManager) collectMacmonPipe() (err error) {
|
||||
cmd := exec.Command(macmonCmd, "pipe", "-i", strconv.Itoa(macmonIntervalMs))
|
||||
// Avoid blocking if macmon writes to stderr.
|
||||
cmd.Stderr = io.Discard
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Ensure we always reap the child to avoid zombies on any return path and
|
||||
// propagate a non-zero exit code if no other error was set.
|
||||
defer func() {
|
||||
_ = stdout.Close()
|
||||
if cmd.ProcessState == nil || !cmd.ProcessState.Exited() {
|
||||
_ = cmd.Process.Kill()
|
||||
}
|
||||
if waitErr := cmd.Wait(); err == nil && waitErr != nil {
|
||||
err = waitErr
|
||||
}
|
||||
}()
|
||||
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
var hadSample bool
|
||||
for scanner.Scan() {
|
||||
line := bytes.TrimSpace(scanner.Bytes())
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
if gm.parseMacmonLine(line) {
|
||||
hadSample = true
|
||||
}
|
||||
}
|
||||
if scanErr := scanner.Err(); scanErr != nil {
|
||||
return scanErr
|
||||
}
|
||||
if !hadSample {
|
||||
return errNoValidData
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseMacmonLine parses a single macmon JSON line and updates Apple GPU metrics.
|
||||
func (gm *GPUManager) parseMacmonLine(line []byte) bool {
|
||||
var sample macmonSample
|
||||
if err := json.Unmarshal(line, &sample); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
usage := 0.0
|
||||
if len(sample.GPUUsage) >= 2 {
|
||||
usage = sample.GPUUsage[1]
|
||||
// Heuristic: macmon typically reports 0..1; convert to percentage.
|
||||
if usage <= 1.0 {
|
||||
usage *= 100
|
||||
}
|
||||
}
|
||||
|
||||
// Consider the line valid if it contains at least one GPU metric.
|
||||
if usage == 0 && sample.GPUPower == 0 && sample.Temp.GPUTempAvg == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
gm.Lock()
|
||||
defer gm.Unlock()
|
||||
|
||||
gpu, ok := gm.GpuDataMap[appleGPUID]
|
||||
if !ok {
|
||||
gpu = &system.GPUData{Name: "Apple GPU"}
|
||||
gm.GpuDataMap[appleGPUID] = gpu
|
||||
}
|
||||
gpu.Temperature = sample.Temp.GPUTempAvg
|
||||
gpu.Usage += usage
|
||||
// macmon reports power in watts; include VRAM power if present.
|
||||
gpu.Power += sample.GPUPower + sample.GPURAMPower
|
||||
gpu.Count++
|
||||
return true
|
||||
}
|
||||
81
agent/gpu_darwin_test.go
Normal file
81
agent/gpu_darwin_test.go
Normal file
@@ -0,0 +1,81 @@
|
||||
//go:build darwin
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParsePowermetricsData(t *testing.T) {
|
||||
input := `
|
||||
Machine model: Mac14,10
|
||||
OS version: 25D125
|
||||
|
||||
*** Sampled system activity (Sat Feb 14 00:42:06 2026 -0500) (503.05ms elapsed) ***
|
||||
|
||||
**** GPU usage ****
|
||||
|
||||
GPU HW active frequency: 444 MHz
|
||||
GPU HW active residency: 0.97% (444 MHz: .97% 612 MHz: 0% 808 MHz: 0% 968 MHz: 0% 1110 MHz: 0% 1236 MHz: 0% 1338 MHz: 0% 1398 MHz: 0%)
|
||||
GPU SW requested state: (P1 : 100% P2 : 0% P3 : 0% P4 : 0% P5 : 0% P6 : 0% P7 : 0% P8 : 0%)
|
||||
GPU idle residency: 99.03%
|
||||
GPU Power: 4 mW
|
||||
`
|
||||
gm := &GPUManager{
|
||||
GpuDataMap: make(map[string]*system.GPUData),
|
||||
}
|
||||
valid := gm.parsePowermetricsData([]byte(input))
|
||||
require.True(t, valid)
|
||||
|
||||
g0, ok := gm.GpuDataMap["0"]
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "Apple GPU", g0.Name)
|
||||
// Usage = 100 - 99.03 = 0.97
|
||||
assert.InDelta(t, 0.97, g0.Usage, 0.01)
|
||||
// 4 mW -> 0.004 W
|
||||
assert.InDelta(t, 0.004, g0.Power, 0.0001)
|
||||
assert.Equal(t, 1.0, g0.Count)
|
||||
}
|
||||
|
||||
func TestParsePowermetricsDataPartial(t *testing.T) {
|
||||
// Only power line (e.g. older macOS or different sampler output)
|
||||
input := `
|
||||
**** GPU usage ****
|
||||
GPU Power: 120 mW
|
||||
`
|
||||
gm := &GPUManager{
|
||||
GpuDataMap: make(map[string]*system.GPUData),
|
||||
}
|
||||
valid := gm.parsePowermetricsData([]byte(input))
|
||||
require.True(t, valid)
|
||||
|
||||
g0, ok := gm.GpuDataMap["0"]
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "Apple GPU", g0.Name)
|
||||
assert.InDelta(t, 0.12, g0.Power, 0.001)
|
||||
assert.Equal(t, 1.0, g0.Count)
|
||||
}
|
||||
|
||||
func TestParseMacmonLine(t *testing.T) {
|
||||
input := `{"all_power":0.6468324661254883,"ane_power":0.0,"cpu_power":0.6359732151031494,"ecpu_usage":[2061,0.1726151406764984],"gpu_power":0.010859241709113121,"gpu_ram_power":0.000965250947047025,"gpu_usage":[503,0.013633215799927711],"memory":{"ram_total":17179869184,"ram_usage":12322914304,"swap_total":0,"swap_usage":0},"pcpu_usage":[1248,0.11792058497667313],"ram_power":0.14885640144348145,"sys_power":10.4955415725708,"temp":{"cpu_temp_avg":23.041261672973633,"gpu_temp_avg":29.44516944885254},"timestamp":"2026-02-17T19:34:27.942556+00:00"}`
|
||||
|
||||
gm := &GPUManager{
|
||||
GpuDataMap: make(map[string]*system.GPUData),
|
||||
}
|
||||
valid := gm.parseMacmonLine([]byte(input))
|
||||
require.True(t, valid)
|
||||
|
||||
g0, ok := gm.GpuDataMap["0"]
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "Apple GPU", g0.Name)
|
||||
// macmon reports usage fraction 0..1; expect percent conversion.
|
||||
assert.InDelta(t, 1.3633, g0.Usage, 0.05)
|
||||
// power includes gpu_power + gpu_ram_power
|
||||
assert.InDelta(t, 0.011824, g0.Power, 0.0005)
|
||||
assert.InDelta(t, 29.445, g0.Temperature, 0.01)
|
||||
assert.Equal(t, 1.0, g0.Count)
|
||||
}
|
||||
9
agent/gpu_darwin_unsupported.go
Normal file
9
agent/gpu_darwin_unsupported.go
Normal file
@@ -0,0 +1,9 @@
|
||||
//go:build !darwin
|
||||
|
||||
package agent
|
||||
|
||||
// startPowermetricsCollector is a no-op on non-darwin platforms; the real implementation is in gpu_darwin.go.
|
||||
func (gm *GPUManager) startPowermetricsCollector() {}
|
||||
|
||||
// startMacmonCollector is a no-op on non-darwin platforms; the real implementation is in gpu_darwin.go.
|
||||
func (gm *GPUManager) startMacmonCollector() {}
|
||||
@@ -37,7 +37,6 @@ func TestHealth(t *testing.T) {
|
||||
})
|
||||
|
||||
// This test uses synctest to simulate time passing.
|
||||
// NOTE: This test requires GOEXPERIMENT=synctest to run.
|
||||
t.Run("check with simulated time", func(t *testing.T) {
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
// Update the file to set the initial timestamp.
|
||||
|
||||
30
go.mod
30
go.mod
@@ -1,6 +1,6 @@
|
||||
module github.com/henrygd/beszel
|
||||
|
||||
go 1.25.7
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
github.com/blang/semver v3.5.1+incompatible
|
||||
@@ -11,17 +11,17 @@ require (
|
||||
github.com/gliderlabs/ssh v0.3.8
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/lxzan/gws v1.8.9
|
||||
github.com/nicholas-fedor/shoutrrr v0.13.1
|
||||
github.com/pocketbase/dbx v1.11.0
|
||||
github.com/pocketbase/pocketbase v0.36.2
|
||||
github.com/nicholas-fedor/shoutrrr v0.13.2
|
||||
github.com/pocketbase/dbx v1.12.0
|
||||
github.com/pocketbase/pocketbase v0.36.4
|
||||
github.com/shirou/gopsutil/v4 v4.26.1
|
||||
github.com/spf13/cast v1.10.0
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/spf13/pflag v1.0.10
|
||||
github.com/stretchr/testify v1.11.1
|
||||
golang.org/x/crypto v0.47.0
|
||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96
|
||||
golang.org/x/sys v0.40.0
|
||||
golang.org/x/crypto v0.48.0
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa
|
||||
golang.org/x/sys v0.41.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
@@ -42,8 +42,8 @@ require (
|
||||
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/klauspost/compress v1.18.3 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect
|
||||
github.com/klauspost/compress v1.18.4 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
@@ -54,15 +54,15 @@ require (
|
||||
github.com/tklauser/numcpus v0.11.0 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
golang.org/x/image v0.35.0 // indirect
|
||||
golang.org/x/net v0.49.0 // indirect
|
||||
golang.org/x/oauth2 v0.34.0 // indirect
|
||||
golang.org/x/image v0.36.0 // indirect
|
||||
golang.org/x/net v0.50.0 // indirect
|
||||
golang.org/x/oauth2 v0.35.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/term v0.39.0 // indirect
|
||||
golang.org/x/text v0.33.0 // indirect
|
||||
golang.org/x/term v0.40.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
howett.net/plist v1.0.1 // indirect
|
||||
modernc.org/libc v1.67.6 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/sqlite v1.44.3 // indirect
|
||||
modernc.org/sqlite v1.45.0 // indirect
|
||||
)
|
||||
|
||||
76
go.sum
76
go.sum
@@ -69,14 +69,14 @@ github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLf
|
||||
github.com/jarcoal/httpmock v1.4.1 h1:0Ju+VCFuARfFlhVXFc2HxlcQkfB+Xq12/EotHko+x2A=
|
||||
github.com/jarcoal/httpmock v1.4.1/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0=
|
||||
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||
github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
|
||||
github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
|
||||
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 h1:PwQumkgq4/acIiZhtifTV5OUqqiP82UAl0h87xj/l9k=
|
||||
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||
github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 h1:PTw+yKnXcOFCR6+8hHTyWBeQ/P4Nb7dd4/0ohEcWQuM=
|
||||
github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||
github.com/lxzan/gws v1.8.9 h1:VU3SGUeWlQrEwfUSfokcZep8mdg/BrUF+y73YYshdBM=
|
||||
github.com/lxzan/gws v1.8.9/go.mod h1:d9yHaR1eDTBHagQC6KY7ycUOaz5KWeqQtP3xu7aMK8Y=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
@@ -85,19 +85,19 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/nicholas-fedor/shoutrrr v0.13.1 h1:llEoHNbnMM4GfQ9+2Ns3n6ssvNfi3NPWluM0AQiicoY=
|
||||
github.com/nicholas-fedor/shoutrrr v0.13.1/go.mod h1:kU4cFJpEAtTzl3iV0l+XUXmM90OlC5T01b7roM4/pYM=
|
||||
github.com/onsi/ginkgo/v2 v2.27.3 h1:ICsZJ8JoYafeXFFlFAG75a7CxMsJHwgKwtO+82SE9L8=
|
||||
github.com/onsi/ginkgo/v2 v2.27.3/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
|
||||
github.com/onsi/gomega v1.38.3 h1:eTX+W6dobAYfFeGC2PV6RwXRu/MyT+cQguijutvkpSM=
|
||||
github.com/onsi/gomega v1.38.3/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4=
|
||||
github.com/nicholas-fedor/shoutrrr v0.13.2 h1:hfsYBIqSFYGg92pZP5CXk/g7/OJIkLYmiUnRl+AD1IA=
|
||||
github.com/nicholas-fedor/shoutrrr v0.13.2/go.mod h1:ZqzV3gY/Wj6AvWs1etlO7+yKbh4iptSbeL8avBpMQbA=
|
||||
github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI=
|
||||
github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE=
|
||||
github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28=
|
||||
github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pocketbase/dbx v1.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU=
|
||||
github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
||||
github.com/pocketbase/pocketbase v0.36.2 h1:mzrxnvXKc3yxKlvZdbwoYXkH8kfIETteD0hWdgj0VI4=
|
||||
github.com/pocketbase/pocketbase v0.36.2/go.mod h1:71vSF8whUDzC8mcLFE10+Qatf9JQdeOGIRWawOuLLKM=
|
||||
github.com/pocketbase/dbx v1.12.0 h1:/oLErM+A0b4xI0PWTGPqSDVjzix48PqI/bng2l0PzoA=
|
||||
github.com/pocketbase/dbx v1.12.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
||||
github.com/pocketbase/pocketbase v0.36.4 h1:zTjRZbp2WfTOJJfb+pFRWa200UaQwxZYt8RzkFMlAZ4=
|
||||
github.com/pocketbase/pocketbase v0.36.4/go.mod h1:9CiezhRudd9FZGa5xZa53QZBTNxc5vvw/FGG+diAECI=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
@@ -129,20 +129,20 @@ github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQ
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
|
||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.35.0 h1:LKjiHdgMtO8z7Fh18nGY6KDcoEtVfsgLDPeLyguqb7I=
|
||||
golang.org/x/image v0.35.0/go.mod h1:MwPLTVgvxSASsxdLzKrl8BRFuyqMyGhLwmC+TO1Sybk=
|
||||
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||
golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
|
||||
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
||||
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
||||
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
@@ -150,20 +150,20 @@ golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
|
||||
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
@@ -195,8 +195,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.44.3 h1:+39JvV/HWMcYslAwRxHb8067w+2zowvFOUrOWIy9PjY=
|
||||
modernc.org/sqlite v1.44.3/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
|
||||
modernc.org/sqlite v1.45.0 h1:r51cSGzKpbptxnby+EIIz5fop4VuE4qFoVEjNvWoObs=
|
||||
modernc.org/sqlite v1.45.0/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
|
||||
@@ -23,6 +23,9 @@ 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
|
||||
|
||||
# AMD GPU name lookup (used by agent on Linux when /usr/share/libdrm/amdgpu.ids is read)
|
||||
COPY --from=builder /app/agent/test-data/amdgpu.ids /usr/share/libdrm/amdgpu.ids
|
||||
|
||||
# Ensure data persistence across container recreations
|
||||
VOLUME ["/var/lib/beszel-agent"]
|
||||
|
||||
|
||||
@@ -20,6 +20,9 @@ RUN rm -rf /tmp/*
|
||||
FROM alpine:3.23
|
||||
COPY --from=builder /agent /agent
|
||||
|
||||
# AMD GPU name lookup (used by agent on Linux when /usr/share/libdrm/amdgpu.ids is read)
|
||||
COPY --from=builder /app/agent/test-data/amdgpu.ids /usr/share/libdrm/amdgpu.ids
|
||||
|
||||
RUN apk add --no-cache smartmontools
|
||||
|
||||
# Ensure data persistence across container recreations
|
||||
|
||||
@@ -37,6 +37,9 @@ RUN apt-get update && apt-get install -y \
|
||||
FROM nvidia/cuda:12.2.2-base-ubuntu22.04
|
||||
COPY --from=builder /agent /agent
|
||||
|
||||
# AMD GPU name lookup (used by agent on hybrid laptops when /usr/share/libdrm/amdgpu.ids is read)
|
||||
COPY --from=builder /app/agent/test-data/amdgpu.ids /usr/share/libdrm/amdgpu.ids
|
||||
|
||||
# Copy smartmontools binaries and config files
|
||||
COPY --from=smartmontools-builder /usr/sbin/smartctl /usr/sbin/smartctl
|
||||
|
||||
|
||||
303
internal/hub/heartbeat/heartbeat.go
Normal file
303
internal/hub/heartbeat/heartbeat.go
Normal file
@@ -0,0 +1,303 @@
|
||||
// Package heartbeat sends periodic outbound pings to an external monitoring
|
||||
// endpoint (e.g. BetterStack, Uptime Kuma, Healthchecks.io) so operators can
|
||||
// monitor Beszel without exposing it to the internet.
|
||||
package heartbeat
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
// Default values for heartbeat configuration.
|
||||
const (
|
||||
defaultInterval = 60 // seconds
|
||||
httpTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
// Payload is the JSON body sent with each heartbeat request.
|
||||
type Payload struct {
|
||||
// Status is "ok" when all non-paused systems are up, "warn" when alerts
|
||||
// are triggered but no systems are down, and "error" when any system is down.
|
||||
Status string `json:"status"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
Msg string `json:"msg"`
|
||||
Systems SystemsSummary `json:"systems"`
|
||||
Down []SystemInfo `json:"down_systems,omitempty"`
|
||||
Alerts []AlertInfo `json:"triggered_alerts,omitempty"`
|
||||
Version string `json:"beszel_version"`
|
||||
}
|
||||
|
||||
// SystemsSummary contains counts of systems by status.
|
||||
type SystemsSummary struct {
|
||||
Total int `json:"total"`
|
||||
Up int `json:"up"`
|
||||
Down int `json:"down"`
|
||||
Paused int `json:"paused"`
|
||||
Pending int `json:"pending"`
|
||||
}
|
||||
|
||||
// SystemInfo identifies a system that is currently down.
|
||||
type SystemInfo struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
Name string `json:"name" db:"name"`
|
||||
Host string `json:"host" db:"host"`
|
||||
}
|
||||
|
||||
// AlertInfo describes a currently triggered alert.
|
||||
type AlertInfo struct {
|
||||
SystemID string `json:"system_id"`
|
||||
SystemName string `json:"system_name"`
|
||||
AlertName string `json:"alert_name"`
|
||||
Threshold float64 `json:"threshold"`
|
||||
}
|
||||
|
||||
// Config holds heartbeat settings read from environment variables.
|
||||
type Config struct {
|
||||
URL string // endpoint to ping
|
||||
Interval int // seconds between pings
|
||||
Method string // HTTP method (GET or POST, default POST)
|
||||
}
|
||||
|
||||
// Heartbeat manages the periodic outbound health check.
|
||||
type Heartbeat struct {
|
||||
app core.App
|
||||
config Config
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// New creates a Heartbeat if configuration is present.
|
||||
// Returns nil if HEARTBEAT_URL is not set (feature disabled).
|
||||
func New(app core.App, getEnv func(string) (string, bool)) *Heartbeat {
|
||||
url, _ := getEnv("HEARTBEAT_URL")
|
||||
url = strings.TrimSpace(url)
|
||||
if app == nil || url == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
interval := defaultInterval
|
||||
if v, ok := getEnv("HEARTBEAT_INTERVAL"); ok {
|
||||
if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 {
|
||||
interval = parsed
|
||||
}
|
||||
}
|
||||
|
||||
method := http.MethodPost
|
||||
if v, ok := getEnv("HEARTBEAT_METHOD"); ok {
|
||||
v = strings.ToUpper(strings.TrimSpace(v))
|
||||
if v == http.MethodGet || v == http.MethodHead {
|
||||
method = v
|
||||
}
|
||||
}
|
||||
|
||||
return &Heartbeat{
|
||||
app: app,
|
||||
config: Config{
|
||||
URL: url,
|
||||
Interval: interval,
|
||||
Method: method,
|
||||
},
|
||||
client: &http.Client{Timeout: httpTimeout},
|
||||
}
|
||||
}
|
||||
|
||||
// Start begins the heartbeat loop. It blocks and should be called in a goroutine.
|
||||
// The loop runs until the provided stop channel is closed.
|
||||
func (hb *Heartbeat) Start(stop <-chan struct{}) {
|
||||
sanitizedURL := sanitizeHeartbeatURL(hb.config.URL)
|
||||
hb.app.Logger().Info("Heartbeat enabled",
|
||||
"url", sanitizedURL,
|
||||
"interval", fmt.Sprintf("%ds", hb.config.Interval),
|
||||
"method", hb.config.Method,
|
||||
)
|
||||
|
||||
// Send an initial heartbeat immediately on startup.
|
||||
hb.send()
|
||||
|
||||
ticker := time.NewTicker(time.Duration(hb.config.Interval) * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
case <-ticker.C:
|
||||
hb.send()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send performs a single heartbeat ping. Exposed for the test-heartbeat API endpoint.
|
||||
func (hb *Heartbeat) Send() error {
|
||||
return hb.send()
|
||||
}
|
||||
|
||||
// GetConfig returns the current heartbeat configuration.
|
||||
func (hb *Heartbeat) GetConfig() Config {
|
||||
return hb.config
|
||||
}
|
||||
|
||||
func (hb *Heartbeat) send() error {
|
||||
var req *http.Request
|
||||
var err error
|
||||
method := normalizeMethod(hb.config.Method)
|
||||
|
||||
if method == http.MethodGet || method == http.MethodHead {
|
||||
req, err = http.NewRequest(method, hb.config.URL, nil)
|
||||
} else {
|
||||
payload, payloadErr := hb.buildPayload()
|
||||
if payloadErr != nil {
|
||||
hb.app.Logger().Error("Heartbeat: failed to build payload", "err", payloadErr)
|
||||
return payloadErr
|
||||
}
|
||||
|
||||
body, jsonErr := json.Marshal(payload)
|
||||
if jsonErr != nil {
|
||||
hb.app.Logger().Error("Heartbeat: failed to marshal payload", "err", jsonErr)
|
||||
return jsonErr
|
||||
}
|
||||
req, err = http.NewRequest(http.MethodPost, hb.config.URL, bytes.NewReader(body))
|
||||
if err == nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
hb.app.Logger().Error("Heartbeat: failed to create request", "err", err)
|
||||
return err
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", "Beszel-Heartbeat")
|
||||
|
||||
resp, err := hb.client.Do(req)
|
||||
if err != nil {
|
||||
hb.app.Logger().Error("Heartbeat: request failed", "url", sanitizeHeartbeatURL(hb.config.URL), "err", err)
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
hb.app.Logger().Warn("Heartbeat: non-success response",
|
||||
"url", sanitizeHeartbeatURL(hb.config.URL),
|
||||
"status", resp.StatusCode,
|
||||
)
|
||||
return fmt.Errorf("heartbeat endpoint returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (hb *Heartbeat) buildPayload() (*Payload, error) {
|
||||
db := hb.app.DB()
|
||||
|
||||
// Count systems by status.
|
||||
var systemCounts []struct {
|
||||
Status string `db:"status"`
|
||||
Count int `db:"cnt"`
|
||||
}
|
||||
err := db.NewQuery("SELECT status, COUNT(*) as cnt FROM systems GROUP BY status").All(&systemCounts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query system counts: %w", err)
|
||||
}
|
||||
|
||||
summary := SystemsSummary{}
|
||||
for _, sc := range systemCounts {
|
||||
switch sc.Status {
|
||||
case "up":
|
||||
summary.Up = sc.Count
|
||||
case "down":
|
||||
summary.Down = sc.Count
|
||||
case "paused":
|
||||
summary.Paused = sc.Count
|
||||
case "pending":
|
||||
summary.Pending = sc.Count
|
||||
}
|
||||
summary.Total += sc.Count
|
||||
}
|
||||
|
||||
// Get names of down systems.
|
||||
var downSystems []SystemInfo
|
||||
if summary.Down > 0 {
|
||||
err = db.NewQuery("SELECT id, name, host FROM systems WHERE status = 'down'").All(&downSystems)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query down systems: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Get triggered alerts with system names.
|
||||
var triggeredAlerts []struct {
|
||||
SystemID string `db:"system"`
|
||||
SystemName string `db:"system_name"`
|
||||
AlertName string `db:"name"`
|
||||
Value float64 `db:"value"`
|
||||
}
|
||||
err = db.NewQuery(`
|
||||
SELECT a.system, s.name as system_name, a.name, a.value
|
||||
FROM alerts a
|
||||
JOIN systems s ON a.system = s.id
|
||||
WHERE a.triggered = true
|
||||
`).All(&triggeredAlerts)
|
||||
if err != nil {
|
||||
// Non-fatal: alerts info is supplementary.
|
||||
triggeredAlerts = nil
|
||||
}
|
||||
|
||||
alerts := make([]AlertInfo, 0, len(triggeredAlerts))
|
||||
for _, ta := range triggeredAlerts {
|
||||
alerts = append(alerts, AlertInfo{
|
||||
SystemID: ta.SystemID,
|
||||
SystemName: ta.SystemName,
|
||||
AlertName: ta.AlertName,
|
||||
Threshold: ta.Value,
|
||||
})
|
||||
}
|
||||
|
||||
// Determine overall status.
|
||||
status := "ok"
|
||||
msg := "All systems operational"
|
||||
if summary.Down > 0 {
|
||||
status = "error"
|
||||
names := make([]string, len(downSystems))
|
||||
for i, ds := range downSystems {
|
||||
names[i] = ds.Name
|
||||
}
|
||||
msg = fmt.Sprintf("%d system(s) down: %s", summary.Down, strings.Join(names, ", "))
|
||||
} else if len(alerts) > 0 {
|
||||
status = "warn"
|
||||
msg = fmt.Sprintf("%d alert(s) triggered", len(alerts))
|
||||
}
|
||||
|
||||
return &Payload{
|
||||
Status: status,
|
||||
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
||||
Msg: msg,
|
||||
Systems: summary,
|
||||
Down: downSystems,
|
||||
Alerts: alerts,
|
||||
Version: beszel.Version,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func normalizeMethod(method string) string {
|
||||
upper := strings.ToUpper(strings.TrimSpace(method))
|
||||
if upper == http.MethodGet || upper == http.MethodHead || upper == http.MethodPost {
|
||||
return upper
|
||||
}
|
||||
return http.MethodPost
|
||||
}
|
||||
|
||||
func sanitizeHeartbeatURL(rawURL string) string {
|
||||
parsed, err := url.Parse(strings.TrimSpace(rawURL))
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return "<invalid-url>"
|
||||
}
|
||||
return parsed.Scheme + "://" + parsed.Host
|
||||
}
|
||||
258
internal/hub/heartbeat/heartbeat_test.go
Normal file
258
internal/hub/heartbeat/heartbeat_test.go
Normal file
@@ -0,0 +1,258 @@
|
||||
//go:build testing
|
||||
// +build testing
|
||||
|
||||
package heartbeat_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/henrygd/beszel/internal/hub/heartbeat"
|
||||
beszeltests "github.com/henrygd/beszel/internal/tests"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
t.Run("returns nil when app is missing", func(t *testing.T) {
|
||||
hb := heartbeat.New(nil, envGetter(map[string]string{
|
||||
"HEARTBEAT_URL": "https://heartbeat.example.com/ping",
|
||||
}))
|
||||
assert.Nil(t, hb)
|
||||
})
|
||||
|
||||
t.Run("returns nil when URL is missing", func(t *testing.T) {
|
||||
app := newTestHub(t)
|
||||
hb := heartbeat.New(app.App, func(string) (string, bool) {
|
||||
return "", false
|
||||
})
|
||||
assert.Nil(t, hb)
|
||||
})
|
||||
|
||||
t.Run("parses and normalizes config values", func(t *testing.T) {
|
||||
app := newTestHub(t)
|
||||
env := map[string]string{
|
||||
"HEARTBEAT_URL": " https://heartbeat.example.com/ping ",
|
||||
"HEARTBEAT_INTERVAL": "90",
|
||||
"HEARTBEAT_METHOD": "head",
|
||||
}
|
||||
getEnv := func(key string) (string, bool) {
|
||||
v, ok := env[key]
|
||||
return v, ok
|
||||
}
|
||||
|
||||
hb := heartbeat.New(app.App, getEnv)
|
||||
require.NotNil(t, hb)
|
||||
cfg := hb.GetConfig()
|
||||
assert.Equal(t, "https://heartbeat.example.com/ping", cfg.URL)
|
||||
assert.Equal(t, 90, cfg.Interval)
|
||||
assert.Equal(t, http.MethodHead, cfg.Method)
|
||||
})
|
||||
}
|
||||
|
||||
func TestSendGETDoesNotRequireAppOrDB(t *testing.T) {
|
||||
app := newTestHub(t)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodGet, r.Method)
|
||||
assert.Equal(t, "Beszel-Heartbeat", r.Header.Get("User-Agent"))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
hb := heartbeat.New(app.App, envGetter(map[string]string{
|
||||
"HEARTBEAT_URL": server.URL,
|
||||
"HEARTBEAT_METHOD": "GET",
|
||||
}))
|
||||
require.NotNil(t, hb)
|
||||
|
||||
require.NoError(t, hb.Send())
|
||||
}
|
||||
|
||||
func TestSendReturnsErrorOnHTTPFailureStatus(t *testing.T) {
|
||||
app := newTestHub(t)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
hb := heartbeat.New(app.App, envGetter(map[string]string{
|
||||
"HEARTBEAT_URL": server.URL,
|
||||
"HEARTBEAT_METHOD": "GET",
|
||||
}))
|
||||
require.NotNil(t, hb)
|
||||
|
||||
err := hb.Send()
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, "heartbeat endpoint returned status 500")
|
||||
}
|
||||
|
||||
func TestSendPOSTBuildsExpectedStatuses(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(t *testing.T, app *beszeltests.TestHub, user *core.Record)
|
||||
expectStatus string
|
||||
expectMsgPart string
|
||||
expectDown int
|
||||
expectAlerts int
|
||||
expectTotal int
|
||||
expectUp int
|
||||
expectPaused int
|
||||
expectPending int
|
||||
expectDownSumm int
|
||||
}{
|
||||
{
|
||||
name: "error when at least one system is down",
|
||||
setup: func(t *testing.T, app *beszeltests.TestHub, user *core.Record) {
|
||||
downSystem := createTestSystem(t, app, user.Id, "db-1", "10.0.0.1", "down")
|
||||
_ = createTestSystem(t, app, user.Id, "web-1", "10.0.0.2", "up")
|
||||
createTriggeredAlert(t, app, user.Id, downSystem.Id, "CPU", 95)
|
||||
},
|
||||
expectStatus: "error",
|
||||
expectMsgPart: "1 system(s) down",
|
||||
expectDown: 1,
|
||||
expectAlerts: 1,
|
||||
expectTotal: 2,
|
||||
expectUp: 1,
|
||||
expectDownSumm: 1,
|
||||
},
|
||||
{
|
||||
name: "warn when only alerts are triggered",
|
||||
setup: func(t *testing.T, app *beszeltests.TestHub, user *core.Record) {
|
||||
system := createTestSystem(t, app, user.Id, "api-1", "10.1.0.1", "up")
|
||||
createTriggeredAlert(t, app, user.Id, system.Id, "CPU", 90)
|
||||
},
|
||||
expectStatus: "warn",
|
||||
expectMsgPart: "1 alert(s) triggered",
|
||||
expectDown: 0,
|
||||
expectAlerts: 1,
|
||||
expectTotal: 1,
|
||||
expectUp: 1,
|
||||
expectDownSumm: 0,
|
||||
},
|
||||
{
|
||||
name: "ok when no down systems and no alerts",
|
||||
setup: func(t *testing.T, app *beszeltests.TestHub, user *core.Record) {
|
||||
_ = createTestSystem(t, app, user.Id, "node-1", "10.2.0.1", "up")
|
||||
_ = createTestSystem(t, app, user.Id, "node-2", "10.2.0.2", "paused")
|
||||
_ = createTestSystem(t, app, user.Id, "node-3", "10.2.0.3", "pending")
|
||||
},
|
||||
expectStatus: "ok",
|
||||
expectMsgPart: "All systems operational",
|
||||
expectDown: 0,
|
||||
expectAlerts: 0,
|
||||
expectTotal: 3,
|
||||
expectUp: 1,
|
||||
expectPaused: 1,
|
||||
expectPending: 1,
|
||||
expectDownSumm: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
app := newTestHub(t)
|
||||
user := createTestUser(t, app)
|
||||
tt.setup(t, app, user)
|
||||
|
||||
type requestCapture struct {
|
||||
method string
|
||||
userAgent string
|
||||
contentType string
|
||||
payload heartbeat.Payload
|
||||
}
|
||||
|
||||
captured := make(chan requestCapture, 1)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer r.Body.Close()
|
||||
body, err := io.ReadAll(r.Body)
|
||||
require.NoError(t, err)
|
||||
|
||||
var payload heartbeat.Payload
|
||||
require.NoError(t, json.Unmarshal(body, &payload))
|
||||
captured <- requestCapture{
|
||||
method: r.Method,
|
||||
userAgent: r.Header.Get("User-Agent"),
|
||||
contentType: r.Header.Get("Content-Type"),
|
||||
payload: payload,
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
hb := heartbeat.New(app.App, envGetter(map[string]string{
|
||||
"HEARTBEAT_URL": server.URL,
|
||||
"HEARTBEAT_METHOD": "POST",
|
||||
}))
|
||||
require.NotNil(t, hb)
|
||||
require.NoError(t, hb.Send())
|
||||
|
||||
req := <-captured
|
||||
assert.Equal(t, http.MethodPost, req.method)
|
||||
assert.Equal(t, "Beszel-Heartbeat", req.userAgent)
|
||||
assert.Equal(t, "application/json", req.contentType)
|
||||
|
||||
assert.Equal(t, tt.expectStatus, req.payload.Status)
|
||||
assert.Contains(t, req.payload.Msg, tt.expectMsgPart)
|
||||
assert.Equal(t, tt.expectDown, len(req.payload.Down))
|
||||
assert.Equal(t, tt.expectAlerts, len(req.payload.Alerts))
|
||||
assert.Equal(t, tt.expectTotal, req.payload.Systems.Total)
|
||||
assert.Equal(t, tt.expectUp, req.payload.Systems.Up)
|
||||
assert.Equal(t, tt.expectDownSumm, req.payload.Systems.Down)
|
||||
assert.Equal(t, tt.expectPaused, req.payload.Systems.Paused)
|
||||
assert.Equal(t, tt.expectPending, req.payload.Systems.Pending)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func newTestHub(t *testing.T) *beszeltests.TestHub {
|
||||
t.Helper()
|
||||
app, err := beszeltests.NewTestHub(t.TempDir())
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(app.Cleanup)
|
||||
return app
|
||||
}
|
||||
|
||||
func createTestUser(t *testing.T, app *beszeltests.TestHub) *core.Record {
|
||||
t.Helper()
|
||||
user, err := beszeltests.CreateUser(app.App, "admin@example.com", "password123")
|
||||
require.NoError(t, err)
|
||||
return user
|
||||
}
|
||||
|
||||
func createTestSystem(t *testing.T, app *beszeltests.TestHub, userID, name, host, status string) *core.Record {
|
||||
t.Helper()
|
||||
system, err := beszeltests.CreateRecord(app.App, "systems", map[string]any{
|
||||
"name": name,
|
||||
"host": host,
|
||||
"port": "45876",
|
||||
"users": []string{userID},
|
||||
"status": status,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return system
|
||||
}
|
||||
|
||||
func createTriggeredAlert(t *testing.T, app *beszeltests.TestHub, userID, systemID, name string, threshold float64) *core.Record {
|
||||
t.Helper()
|
||||
alert, err := beszeltests.CreateRecord(app.App, "alerts", map[string]any{
|
||||
"name": name,
|
||||
"system": systemID,
|
||||
"user": userID,
|
||||
"value": threshold,
|
||||
"min": 0,
|
||||
"triggered": true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return alert
|
||||
}
|
||||
|
||||
func envGetter(values map[string]string) func(string) (string, bool) {
|
||||
return func(key string) (string, bool) {
|
||||
v, ok := values[key]
|
||||
return v, ok
|
||||
}
|
||||
}
|
||||
@@ -9,12 +9,14 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel"
|
||||
"github.com/henrygd/beszel/internal/alerts"
|
||||
"github.com/henrygd/beszel/internal/hub/config"
|
||||
"github.com/henrygd/beszel/internal/hub/heartbeat"
|
||||
"github.com/henrygd/beszel/internal/hub/systems"
|
||||
"github.com/henrygd/beszel/internal/records"
|
||||
"github.com/henrygd/beszel/internal/users"
|
||||
@@ -33,11 +35,15 @@ type Hub struct {
|
||||
um *users.UserManager
|
||||
rm *records.RecordManager
|
||||
sm *systems.SystemManager
|
||||
hb *heartbeat.Heartbeat
|
||||
hbStop chan struct{}
|
||||
pubKey string
|
||||
signer ssh.Signer
|
||||
appURL string
|
||||
}
|
||||
|
||||
var containerIDPattern = regexp.MustCompile(`^[a-fA-F0-9]{12,64}$`)
|
||||
|
||||
// NewHub creates a new Hub instance with default configuration
|
||||
func NewHub(app core.App) *Hub {
|
||||
hub := &Hub{}
|
||||
@@ -48,6 +54,10 @@ func NewHub(app core.App) *Hub {
|
||||
hub.rm = records.NewRecordManager(hub)
|
||||
hub.sm = systems.NewSystemManager(hub)
|
||||
hub.appURL, _ = GetEnv("APP_URL")
|
||||
hub.hb = heartbeat.New(app, GetEnv)
|
||||
if hub.hb != nil {
|
||||
hub.hbStop = make(chan struct{})
|
||||
}
|
||||
return hub
|
||||
}
|
||||
|
||||
@@ -88,6 +98,10 @@ func (h *Hub) StartHub() error {
|
||||
if err := h.sm.Initialize(); err != nil {
|
||||
return err
|
||||
}
|
||||
// start heartbeat if configured
|
||||
if h.hb != nil {
|
||||
go h.hb.Start(h.hbStop)
|
||||
}
|
||||
return e.Next()
|
||||
})
|
||||
|
||||
@@ -287,6 +301,9 @@ func (h *Hub) registerApiRoutes(se *core.ServeEvent) error {
|
||||
})
|
||||
// send test notification
|
||||
apiAuth.POST("/test-notification", h.SendTestNotification)
|
||||
// heartbeat status and test
|
||||
apiAuth.GET("/heartbeat-status", h.getHeartbeatStatus)
|
||||
apiAuth.POST("/test-heartbeat", h.testHeartbeat)
|
||||
// get config.yml content
|
||||
apiAuth.GET("/config-yaml", config.GetYamlConfig)
|
||||
// handle agent websocket connection
|
||||
@@ -403,6 +420,42 @@ func (h *Hub) getUniversalToken(e *core.RequestEvent) error {
|
||||
return e.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// getHeartbeatStatus returns current heartbeat configuration and whether it's enabled
|
||||
func (h *Hub) getHeartbeatStatus(e *core.RequestEvent) error {
|
||||
if e.Auth.GetString("role") != "admin" {
|
||||
return e.ForbiddenError("Requires admin role", nil)
|
||||
}
|
||||
if h.hb == nil {
|
||||
return e.JSON(http.StatusOK, map[string]any{
|
||||
"enabled": false,
|
||||
"msg": "Set HEARTBEAT_URL to enable outbound heartbeat monitoring",
|
||||
})
|
||||
}
|
||||
cfg := h.hb.GetConfig()
|
||||
return e.JSON(http.StatusOK, map[string]any{
|
||||
"enabled": true,
|
||||
"url": cfg.URL,
|
||||
"interval": cfg.Interval,
|
||||
"method": cfg.Method,
|
||||
})
|
||||
}
|
||||
|
||||
// testHeartbeat triggers a single heartbeat ping and returns the result
|
||||
func (h *Hub) testHeartbeat(e *core.RequestEvent) error {
|
||||
if e.Auth.GetString("role") != "admin" {
|
||||
return e.ForbiddenError("Requires admin role", nil)
|
||||
}
|
||||
if h.hb == nil {
|
||||
return e.JSON(http.StatusOK, map[string]any{
|
||||
"err": "Heartbeat not configured. Set HEARTBEAT_URL environment variable.",
|
||||
})
|
||||
}
|
||||
if err := h.hb.Send(); err != nil {
|
||||
return e.JSON(http.StatusOK, map[string]any{"err": err.Error()})
|
||||
}
|
||||
return e.JSON(http.StatusOK, map[string]any{"err": false})
|
||||
}
|
||||
|
||||
// containerRequestHandler handles both container logs and info requests
|
||||
func (h *Hub) containerRequestHandler(e *core.RequestEvent, fetchFunc func(*systems.System, string) (string, error), responseKey string) error {
|
||||
systemID := e.Request.URL.Query().Get("system")
|
||||
@@ -411,6 +464,9 @@ func (h *Hub) containerRequestHandler(e *core.RequestEvent, fetchFunc func(*syst
|
||||
if systemID == "" || containerID == "" {
|
||||
return e.JSON(http.StatusBadRequest, map[string]string{"error": "system and container parameters are required"})
|
||||
}
|
||||
if !containerIDPattern.MatchString(containerID) {
|
||||
return e.JSON(http.StatusBadRequest, map[string]string{"error": "invalid container parameter"})
|
||||
}
|
||||
|
||||
system, err := h.sm.GetSystem(systemID)
|
||||
if err != nil {
|
||||
|
||||
@@ -362,6 +362,58 @@ func TestApiRoutesAuthentication(t *testing.T) {
|
||||
ExpectedContent: []string{"test-system"},
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
{
|
||||
Name: "GET /heartbeat-status - no auth should fail",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/beszel/heartbeat-status",
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{"requires valid"},
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
{
|
||||
Name: "GET /heartbeat-status - with user auth should fail",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/beszel/heartbeat-status",
|
||||
Headers: map[string]string{
|
||||
"Authorization": userToken,
|
||||
},
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{"Requires admin role"},
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
{
|
||||
Name: "GET /heartbeat-status - with admin auth should succeed",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/beszel/heartbeat-status",
|
||||
Headers: map[string]string{
|
||||
"Authorization": adminUserToken,
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{`"enabled":false`},
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
{
|
||||
Name: "POST /test-heartbeat - with user auth should fail",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/beszel/test-heartbeat",
|
||||
Headers: map[string]string{
|
||||
"Authorization": userToken,
|
||||
},
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{"Requires admin role"},
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
{
|
||||
Name: "POST /test-heartbeat - with admin auth should report disabled state",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/beszel/test-heartbeat",
|
||||
Headers: map[string]string{
|
||||
"Authorization": adminUserToken,
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"Heartbeat not configured"},
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
{
|
||||
Name: "GET /universal-token - no auth should fail",
|
||||
Method: http.MethodGet,
|
||||
@@ -493,7 +545,7 @@ func TestApiRoutesAuthentication(t *testing.T) {
|
||||
{
|
||||
Name: "GET /containers/logs - with auth but invalid system should fail",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/beszel/containers/logs?system=invalid-system&container=test-container",
|
||||
URL: "/api/beszel/containers/logs?system=invalid-system&container=0123456789ab",
|
||||
Headers: map[string]string{
|
||||
"Authorization": userToken,
|
||||
},
|
||||
@@ -501,6 +553,39 @@ func TestApiRoutesAuthentication(t *testing.T) {
|
||||
ExpectedContent: []string{"system not found"},
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
{
|
||||
Name: "GET /containers/logs - traversal container should fail validation",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/beszel/containers/logs?system=" + system.Id + "&container=..%2F..%2Fversion",
|
||||
Headers: map[string]string{
|
||||
"Authorization": userToken,
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{"invalid container parameter"},
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
{
|
||||
Name: "GET /containers/info - traversal container should fail validation",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/beszel/containers/info?system=" + system.Id + "&container=../../version?x=",
|
||||
Headers: map[string]string{
|
||||
"Authorization": userToken,
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{"invalid container parameter"},
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
{
|
||||
Name: "GET /containers/info - non-hex container should fail validation",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/beszel/containers/info?system=" + system.Id + "&container=container_name",
|
||||
Headers: map[string]string{
|
||||
"Authorization": userToken,
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{"invalid container parameter"},
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
|
||||
// Auth Optional Routes - Should work without authentication
|
||||
{
|
||||
|
||||
205
internal/site/src/components/routes/settings/heartbeat.tsx
Normal file
205
internal/site/src/components/routes/settings/heartbeat.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { Trans } from "@lingui/react/macro"
|
||||
import { redirectPage } from "@nanostores/router"
|
||||
import clsx from "clsx"
|
||||
import { LoaderCircleIcon, SendIcon } from "lucide-react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { $router } from "@/components/router"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { toast } from "@/components/ui/use-toast"
|
||||
import { isAdmin, pb } from "@/lib/api"
|
||||
|
||||
interface HeartbeatStatus {
|
||||
enabled: boolean
|
||||
url?: string
|
||||
interval?: number
|
||||
method?: string
|
||||
msg?: string
|
||||
}
|
||||
|
||||
export default function HeartbeatSettings() {
|
||||
const [status, setStatus] = useState<HeartbeatStatus | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isTesting, setIsTesting] = useState(false)
|
||||
|
||||
if (!isAdmin()) {
|
||||
redirectPage($router, "settings", { name: "general" })
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatus()
|
||||
}, [])
|
||||
|
||||
async function fetchStatus() {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
const res = await pb.send<HeartbeatStatus>("/api/beszel/heartbeat-status", {})
|
||||
setStatus(res)
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
title: t`Error`,
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
})
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function sendTestHeartbeat() {
|
||||
setIsTesting(true)
|
||||
try {
|
||||
const res = await pb.send<{ err: string | false }>("/api/beszel/test-heartbeat", {
|
||||
method: "POST",
|
||||
})
|
||||
if ("err" in res && !res.err) {
|
||||
toast({
|
||||
title: t`Heartbeat sent successfully`,
|
||||
description: t`Check your monitoring service`,
|
||||
})
|
||||
} else {
|
||||
toast({
|
||||
title: t`Error`,
|
||||
description: (res.err as string) ?? t`Failed to send heartbeat`,
|
||||
variant: "destructive",
|
||||
})
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
title: t`Error`,
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
})
|
||||
} finally {
|
||||
setIsTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const TestIcon = isTesting ? LoaderCircleIcon : SendIcon
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
<h3 className="text-xl font-medium mb-2">
|
||||
<Trans>Heartbeat Monitoring</Trans>
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
<Trans>
|
||||
Send periodic outbound pings to an external monitoring service so you can monitor Beszel without exposing it
|
||||
to the internet.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<Separator className="my-4" />
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2 text-muted-foreground py-4">
|
||||
<LoaderCircleIcon className="h-4 w-4 animate-spin" />
|
||||
<Trans>Loading heartbeat status...</Trans>
|
||||
</div>
|
||||
) : status?.enabled ? (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="success">
|
||||
<Trans>Active</Trans>
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<ConfigItem label={t`Endpoint URL`} value={status.url ?? ""} mono />
|
||||
<ConfigItem label={t`Interval`} value={`${status.interval}s`} />
|
||||
<ConfigItem label={t`HTTP Method`} value={status.method ?? "POST"} />
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<h4 className="text-base font-medium mb-1">
|
||||
<Trans>Test heartbeat</Trans>
|
||||
</h4>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed mb-3">
|
||||
<Trans>Send a single heartbeat ping to verify your endpoint is working.</Trans>
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="flex items-center gap-1.5"
|
||||
onClick={sendTestHeartbeat}
|
||||
disabled={isTesting}
|
||||
>
|
||||
<TestIcon className={clsx("h-4 w-4", isTesting && "animate-spin")} />
|
||||
<Trans>Send test heartbeat</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<h4 className="text-base font-medium mb-2">
|
||||
<Trans>Payload format</Trans>
|
||||
</h4>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed mb-2">
|
||||
<Trans>
|
||||
When using POST, each heartbeat includes a JSON payload with system status summary, list of down
|
||||
systems, and triggered alerts.
|
||||
</Trans>
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
<Trans>
|
||||
The overall status is <code className="bg-muted rounded-sm px-1 text-primary">ok</code> when all systems
|
||||
are up, <code className="bg-muted rounded-sm px-1 text-primary">warn</code> when alerts are triggered,
|
||||
and <code className="bg-muted rounded-sm px-1 text-primary">error</code> when any system is down.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed mb-3">
|
||||
<Trans>Set the following environment variables on your Beszel hub to enable heartbeat monitoring:</Trans>
|
||||
</p>
|
||||
<div className="grid gap-2.5">
|
||||
<EnvVarItem
|
||||
name="HEARTBEAT_URL"
|
||||
description={t`Endpoint URL to ping (required)`}
|
||||
example="https://uptime.betterstack.com/api/v1/heartbeat/xxxx"
|
||||
/>
|
||||
<EnvVarItem name="HEARTBEAT_INTERVAL" description={t`Seconds between pings (default: 60)`} example="60" />
|
||||
<EnvVarItem
|
||||
name="HEARTBEAT_METHOD"
|
||||
description={t`HTTP method: POST, GET, or HEAD (default: POST)`}
|
||||
example="POST"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
<Trans>After setting the environment variables, restart your Beszel hub for changes to take effect.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ConfigItem({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-0.5">{label}</p>
|
||||
<p className={clsx("text-sm text-muted-foreground break-all", mono && "font-mono")}>{value}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EnvVarItem({ name, description, example }: { name: string; description: string; example: string }) {
|
||||
return (
|
||||
<div className="bg-muted/50 rounded-md px-3 py-2 grid gap-1.5">
|
||||
<code className="text-sm font-mono text-primary font-medium leading-tight">{name}</code>
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<Trans>Example:</Trans> <code className="font-mono">{example}</code>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,14 @@ import { t } from "@lingui/core/macro"
|
||||
import { Trans, useLingui } from "@lingui/react/macro"
|
||||
import { useStore } from "@nanostores/react"
|
||||
import { getPagePath, redirectPage } from "@nanostores/router"
|
||||
import { AlertOctagonIcon, BellIcon, FileSlidersIcon, FingerprintIcon, SettingsIcon } from "lucide-react"
|
||||
import {
|
||||
AlertOctagonIcon,
|
||||
BellIcon,
|
||||
FileSlidersIcon,
|
||||
FingerprintIcon,
|
||||
HeartPulseIcon,
|
||||
SettingsIcon,
|
||||
} from "lucide-react"
|
||||
import { lazy, useEffect } from "react"
|
||||
import { $router } from "@/components/router.tsx"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card.tsx"
|
||||
@@ -18,12 +25,14 @@ const notificationsSettingsImport = () => import("./notifications.tsx")
|
||||
const configYamlSettingsImport = () => import("./config-yaml.tsx")
|
||||
const fingerprintsSettingsImport = () => import("./tokens-fingerprints.tsx")
|
||||
const alertsHistoryDataTableSettingsImport = () => import("./alerts-history-data-table.tsx")
|
||||
const heartbeatSettingsImport = () => import("./heartbeat.tsx")
|
||||
|
||||
const GeneralSettings = lazy(generalSettingsImport)
|
||||
const NotificationsSettings = lazy(notificationsSettingsImport)
|
||||
const ConfigYamlSettings = lazy(configYamlSettingsImport)
|
||||
const FingerprintsSettings = lazy(fingerprintsSettingsImport)
|
||||
const AlertsHistoryDataTableSettings = lazy(alertsHistoryDataTableSettingsImport)
|
||||
const HeartbeatSettings = lazy(heartbeatSettingsImport)
|
||||
|
||||
export async function saveSettings(newSettings: Partial<UserSettings>) {
|
||||
try {
|
||||
@@ -88,6 +97,13 @@ export default function SettingsLayout() {
|
||||
admin: true,
|
||||
preload: configYamlSettingsImport,
|
||||
},
|
||||
{
|
||||
title: t`Heartbeat`,
|
||||
href: getPagePath($router, "settings", { name: "heartbeat" }),
|
||||
icon: HeartPulseIcon,
|
||||
admin: true,
|
||||
preload: heartbeatSettingsImport,
|
||||
},
|
||||
]
|
||||
|
||||
const page = useStore($router)
|
||||
@@ -141,5 +157,7 @@ function SettingsContent({ name }: { name: string }) {
|
||||
return <FingerprintsSettings />
|
||||
case "alert-history":
|
||||
return <AlertsHistoryDataTableSettings />
|
||||
case "heartbeat":
|
||||
return <HeartbeatSettings />
|
||||
}
|
||||
}
|
||||
|
||||
@@ -593,7 +593,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
||||
if (showMax) {
|
||||
return data?.stats?.bm?.[0] ?? (data?.stats?.nsm ?? 0) * 1024 * 1024
|
||||
}
|
||||
return data?.stats?.b?.[0] ?? data?.stats?.ns * 1024 * 1024
|
||||
return data?.stats?.b?.[0] ?? (data?.stats?.ns ?? 0) * 1024 * 1024
|
||||
},
|
||||
color: 5,
|
||||
opacity: 0.2,
|
||||
@@ -604,7 +604,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
||||
if (showMax) {
|
||||
return data?.stats?.bm?.[1] ?? (data?.stats?.nrm ?? 0) * 1024 * 1024
|
||||
}
|
||||
return data?.stats?.b?.[1] ?? data?.stats?.nr * 1024 * 1024
|
||||
return data?.stats?.b?.[1] ?? (data?.stats?.nr ?? 0) * 1024 * 1024
|
||||
},
|
||||
color: 2,
|
||||
opacity: 0.2,
|
||||
|
||||
@@ -1,3 +1,29 @@
|
||||
## Unreleased
|
||||
|
||||
- Add outbound heartbeat monitoring to external services (BetterStack, Uptime Kuma, Healthchecks.io, etc.) with system status summary payload. Configured via `BESZEL_HUB_HEARTBEAT_URL`, `BESZEL_HUB_HEARTBEAT_INTERVAL`, and `BESZEL_HUB_HEARTBEAT_METHOD` environment variables.
|
||||
|
||||
- Add GPU monitoring for Apple Silicon. (#1747, #1746)
|
||||
|
||||
- Add `nvtop` integration for expanded GPU compatibility.
|
||||
|
||||
- Add `GPU_COLLECTOR` environment variable to manually specify the GPU collector(s).
|
||||
|
||||
- Add eMMC health monitoring via sysfs. (#1736)
|
||||
|
||||
- Add uptime to systems table. (#1719)
|
||||
|
||||
- Add `DISABLE_SSH` environment variable to disable SSH agent functionality. (#1061)
|
||||
|
||||
- Add `fingerprint` command to the agent. (#1726)
|
||||
|
||||
- Include GTT memory in AMD GPU metrics and improve device name lookup. (#1569)
|
||||
|
||||
- Fix issue where the agent could report incorrect root disk I/O when running in Docker. (#1737)
|
||||
|
||||
- Update Go to 1.26.
|
||||
|
||||
- Add `InstallMethod` parameter to Windows install script.
|
||||
|
||||
## 0.18.3
|
||||
|
||||
- Add experimental sysfs AMD GPU collector. (#737, #1569)
|
||||
|
||||
Reference in New Issue
Block a user