Compare commits

...

19 Commits

Author SHA1 Message Date
henrygd
c1c1cd1bcb ui: fix temperature chart filtering 2026-04-20 20:45:16 -04:00
Yvan Wang
cd9ea51039 fix(hub): use rfcEmail validator to allow IDN/Punycode email addresses (#1935)
valibot's email() action uses a domain-label regex that disallows the
-- sequence, so Punycode ACE labels like xn--mnchen-3ya.de (the ASCII
form of münchen.de) are incorrectly rejected.

Switching to rfcEmail() applies the RFC 5321 domain-label pattern,
which allows hyphens within labels and therefore accepts both standard
and internationalized domain names.
2026-04-18 12:23:14 -04:00
Uğur Tafralı
a71617e058 feat(agent): Add EXIT_ON_DNS_ERROR environment variable (#1929)
Co-authored-by: henrygd <hank@henrygd.me>
2026-04-17 19:26:11 -04:00
henrygd
e5507fa106 refactor(agent): clean up records package and add tests 2026-04-15 16:24:40 -04:00
Lars Lehtonen
a024c3cfd0 fix(cron): log unhandled records cleanup errors (#1909) 2026-04-15 15:33:55 -04:00
henrygd
07466804e7 ui: allow filtering systems by host and agent version (#163) 2026-04-14 16:27:38 -04:00
henrygd
981c788d6f agent: make sure prefixed ALL_PROXY env var works (#1919) 2026-04-14 14:46:43 -04:00
Rafael Marmelo
f5576759de agent: Allow agent to connect to hub via SOCKS5 proxy 2026-04-14 14:46:43 -04:00
Sven van Ginkel
be0b708064 feat(hub): add OAUTH_DISABLE_POPUP env var (#1900)
Co-authored-by: henrygd <hank@henrygd.me>
2026-04-13 20:00:05 -04:00
amarildo
ab3a3de46c deps: update shoutrrr to 0.14.3, fixing some matrix issues (#1906) 2026-04-10 18:35:20 -04:00
Lars Lehtonen
1556e53926 fix(agent): dropped linux battery error (#1908) 2026-04-10 18:33:42 -04:00
henrygd
e3ade3aeb8 hub: optimize System.HasUser check 2026-04-10 18:32:37 -04:00
henrygd
b013f06956 rm deprecated tsconfig baseUrl property 2026-04-10 18:29:20 -04:00
FlintyLemming
3793b27958 fix(agent): use nvme_total_capacity fallback for NVMe disk size (#1899)
Some enterprise NVMe drives (e.g. Dell Ent NVMe CM7 U.2) report capacity
via nvme_total_capacity instead of user_capacity.bytes in smartctl output.
The NVMe SMART parser now falls back to nvme_total_capacity when
user_capacity.bytes is zero.
2026-04-09 15:50:59 -04:00
y0tka
5b02158228 feat(ui): add theme state that follows system/browser theme (#1903) 2026-04-09 15:48:44 -04:00
henrygd
0ae8c42ae0 fix(hub): System.HasUser - return true if SHARE_ALL_SYSTEMS=true (#1891)
- move hub's GetEnv function to new utils package to more easily share
across different hub packages
- change System.HasUser to take core.Record instead of user ID string
- add tests
2026-04-08 20:13:39 -04:00
henrygd
ea80f3c5a2 fix(agent): add safety check for read returning negative bytes (#1799) 2026-04-07 18:41:22 -04:00
henrygd
c3dffff5e4 hub: prevent non-admin users from sending test alerts to internal hosts 2026-04-07 16:08:28 -04:00
henrygd
06fdd0e7a8 refactor(hub,alerts): move api functions/tests to alerts_api.go 2026-04-07 14:47:08 -04:00
41 changed files with 2540 additions and 1233 deletions

View File

@@ -82,6 +82,9 @@ func GetBatteryStats() (batteryPercent uint8, batteryState uint8, err error) {
return batteryPercent, batteryState, errors.ErrUnsupported
}
paths, err := getBatteryPaths()
if err != nil {
return batteryPercent, batteryState, err
}
if len(paths) == 0 {
return batteryPercent, batteryState, errors.New("no batteries")
}

View File

@@ -20,6 +20,7 @@ import (
"github.com/fxamacker/cbor/v2"
"github.com/lxzan/gws"
"golang.org/x/crypto/ssh"
"golang.org/x/net/proxy"
)
const (
@@ -104,6 +105,11 @@ func (client *WebSocketClient) getOptions() *gws.ClientOption {
}
client.hubURL.Path = path.Join(client.hubURL.Path, "api/beszel/agent-connect")
// make sure BESZEL_AGENT_ALL_PROXY works (GWS only checks ALL_PROXY)
if val := os.Getenv("BESZEL_AGENT_ALL_PROXY"); val != "" {
os.Setenv("ALL_PROXY", val)
}
client.options = &gws.ClientOption{
Addr: client.hubURL.String(),
TlsConfig: &tls.Config{InsecureSkipVerify: true},
@@ -112,6 +118,9 @@ func (client *WebSocketClient) getOptions() *gws.ClientOption {
"X-Token": []string{client.token},
"X-Beszel": []string{beszel.Version},
},
NewDialer: func() (gws.Dialer, error) {
return proxy.FromEnvironment(), nil
},
}
return client.options
}

View File

@@ -4,11 +4,15 @@ import (
"context"
"errors"
"log/slog"
"net"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/henrygd/beszel/agent/health"
"github.com/henrygd/beszel/agent/utils"
"github.com/henrygd/beszel/internal/entities/system"
)
@@ -111,13 +115,36 @@ func (c *ConnectionManager) Start(serverOptions ServerOptions) error {
_ = health.Update()
case <-sigCtx.Done():
slog.Info("Shutting down", "cause", context.Cause(sigCtx))
_ = c.agent.StopServer()
c.closeWebSocket()
return health.CleanUp()
return c.stop()
}
}
}
// stop does not stop the connection manager itself, just any active connections. The manager will attempt to reconnect after stopping, so this should only be called immediately before shutting down the entire agent.
//
// If we need or want to expose a graceful Stop method in the future, do something like this to actually stop the manager:
//
// func (c *ConnectionManager) Start(serverOptions ServerOptions) error {
// ctx, cancel := context.WithCancel(context.Background())
// c.cancel = cancel
//
// for {
// select {
// case <-ctx.Done():
// return c.stop()
// }
// }
// }
//
// func (c *ConnectionManager) Stop() {
// c.cancel()
// }
func (c *ConnectionManager) stop() error {
_ = c.agent.StopServer()
c.closeWebSocket()
return health.CleanUp()
}
// handleEvent processes connection events and updates the connection state accordingly.
func (c *ConnectionManager) handleEvent(event ConnectionEvent) {
switch event {
@@ -185,9 +212,16 @@ func (c *ConnectionManager) connect() {
// Try WebSocket first, if it fails, start SSH server
err := c.startWebSocketConnection()
if err != nil && c.State == Disconnected {
c.startSSHServer()
c.startWsTicker()
if err != nil {
if shouldExitOnErr(err) {
time.Sleep(2 * time.Second) // prevent tight restart loop
_ = c.stop()
os.Exit(1)
}
if c.State == Disconnected {
c.startSSHServer()
c.startWsTicker()
}
}
}
@@ -224,3 +258,14 @@ func (c *ConnectionManager) closeWebSocket() {
c.wsClient.Close()
}
}
// shouldExitOnErr checks if the error is a DNS resolution failure and if the
// EXIT_ON_DNS_ERROR env var is set. https://github.com/henrygd/beszel/issues/1924.
func shouldExitOnErr(err error) bool {
if val, _ := utils.GetEnv("EXIT_ON_DNS_ERROR"); val == "true" {
if opErr, ok := errors.AsType[*net.OpError](err); ok {
return strings.Contains(opErr.Err.Error(), "lookup")
}
}
return false
}

View File

@@ -4,6 +4,7 @@ package agent
import (
"crypto/ed25519"
"errors"
"fmt"
"net"
"net/url"
@@ -298,3 +299,65 @@ func TestConnectionManager_ConnectFlow(t *testing.T) {
cm.connect()
}, "Connect should not panic without WebSocket client")
}
func TestShouldExitOnErr(t *testing.T) {
createDialErr := func(msg string) error {
return &net.OpError{
Op: "dial",
Net: "tcp",
Err: errors.New(msg),
}
}
tests := []struct {
name string
err error
envValue string
expected bool
}{
{
name: "no env var",
err: createDialErr("lookup lkahsdfasdf: no such host"),
envValue: "",
expected: false,
},
{
name: "env var false",
err: createDialErr("lookup lkahsdfasdf: no such host"),
envValue: "false",
expected: false,
},
{
name: "env var true, matching error",
err: createDialErr("lookup lkahsdfasdf: no such host"),
envValue: "true",
expected: true,
},
{
name: "env var true, matching error with extra context",
err: createDialErr("lookup beszel.server.lan on [::1]:53: read udp [::1]:44557->[::1]:53: read: connection refused"),
envValue: "true",
expected: true,
},
{
name: "env var true, non-matching error",
err: errors.New("connection refused"),
envValue: "true",
expected: false,
},
{
name: "env var true, dial but not lookup",
err: createDialErr("connection timeout"),
envValue: "true",
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Setenv("EXIT_ON_DNS_ERROR", tt.envValue)
result := shouldExitOnErr(tt.err)
assert.Equal(t, tt.expected, result)
})
}
}

View File

@@ -156,6 +156,7 @@ func (gm *GPUManager) updateAmdGpuData(cardPath string) bool {
func readSysfsFloat(path string) (float64, error) {
val, err := utils.ReadStringFileLimited(path, 64)
if err != nil {
slog.Debug("Failed to read sysfs value", "path", path, "error", err)
return 0, err
}
return strconv.ParseFloat(val, 64)

View File

@@ -1118,6 +1118,9 @@ func (sm *SmartManager) parseSmartForNvme(output []byte) (bool, int) {
smartData.SerialNumber = data.SerialNumber
smartData.FirmwareVersion = data.FirmwareVersion
smartData.Capacity = data.UserCapacity.Bytes
if smartData.Capacity == 0 {
smartData.Capacity = data.NVMeTotalCapacity
}
if smartData.Capacity == 0 && (runtime.GOOS == "darwin" || sm.darwinNvmeProvider != nil) {
smartData.Capacity = sm.lookupDarwinNvmeCapacity(data.SerialNumber)
}

View File

@@ -1,6 +1,8 @@
// Package utils provides utility functions for the agent.
package utils
import (
"fmt"
"io"
"math"
"os"
@@ -68,6 +70,9 @@ func ReadStringFileLimited(path string, maxSize int) (string, error) {
if err != nil && err != io.EOF {
return "", err
}
if n < 0 {
return "", fmt.Errorf("%s returned negative bytes: %d", path, n)
}
return strings.TrimSpace(string(buf[:n])), nil
}

2
go.mod
View File

@@ -10,7 +10,7 @@ require (
github.com/gliderlabs/ssh v0.3.8
github.com/google/uuid v1.6.0
github.com/lxzan/gws v1.9.1
github.com/nicholas-fedor/shoutrrr v0.14.1
github.com/nicholas-fedor/shoutrrr v0.14.3
github.com/pocketbase/dbx v1.12.0
github.com/pocketbase/pocketbase v0.36.8
github.com/shirou/gopsutil/v4 v4.26.3

4
go.sum
View File

@@ -85,8 +85,8 @@ 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.14.1 h1:6sx4cJNfNuUtD6ygGlB0dqcCQ+abfsUh+b+6jgujf6A=
github.com/nicholas-fedor/shoutrrr v0.14.1/go.mod h1:U7IywBkLpBV7rgn8iLbQ9/LklJG1gm24bFv5cXXsDKs=
github.com/nicholas-fedor/shoutrrr v0.14.3 h1:aBX2iw9a7jl5wfHd3bi9LnS5ucoYIy6KcLH9XVF+gig=
github.com/nicholas-fedor/shoutrrr v0.14.3/go.mod h1:U7IywBkLpBV7rgn8iLbQ9/LklJG1gm24bFv5cXXsDKs=
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=

View File

@@ -302,21 +302,6 @@ func (am *AlertManager) SendShoutrrrAlert(notificationUrl, title, message, link,
return nil
}
func (am *AlertManager) SendTestNotification(e *core.RequestEvent) error {
var data struct {
URL string `json:"url"`
}
err := e.BindBody(&data)
if err != nil || data.URL == "" {
return e.BadRequestError("URL is required", err)
}
err = am.SendShoutrrrAlert(data.URL, "Test Alert", "This is a notification from Beszel.", am.hub.Settings().Meta.AppURL, "View Beszel")
if err != nil {
return e.JSON(200, map[string]string{"err": err.Error()})
}
return e.JSON(200, map[string]bool{"err": false})
}
// setAlertTriggered updates the "triggered" status of an alert record in the database
func (am *AlertManager) setAlertTriggered(alert CachedAlertData, triggered bool) error {
alertRecord, err := am.hub.FindRecordById("alerts", alert.Id)

View File

@@ -3,7 +3,11 @@ package alerts
import (
"database/sql"
"errors"
"net"
"net/http"
"net/url"
"slices"
"strings"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
@@ -117,3 +121,72 @@ func DeleteUserAlerts(e *core.RequestEvent) error {
return e.JSON(http.StatusOK, map[string]any{"success": true, "count": numDeleted})
}
// SendTestNotification handles API request to send a test notification to a specified Shoutrrr URL
func (am *AlertManager) SendTestNotification(e *core.RequestEvent) error {
var data struct {
URL string `json:"url"`
}
err := e.BindBody(&data)
if err != nil || data.URL == "" {
return e.BadRequestError("URL is required", err)
}
// Only allow admins to send test notifications to internal URLs
if !e.Auth.IsSuperuser() && e.Auth.GetString("role") != "admin" {
internalURL, err := isInternalURL(data.URL)
if err != nil {
return e.BadRequestError(err.Error(), nil)
}
if internalURL {
return e.ForbiddenError("Only admins can send to internal destinations", nil)
}
}
err = am.SendShoutrrrAlert(data.URL, "Test Alert", "This is a notification from Beszel.", am.hub.Settings().Meta.AppURL, "View Beszel")
if err != nil {
return e.JSON(200, map[string]string{"err": err.Error()})
}
return e.JSON(200, map[string]bool{"err": false})
}
// isInternalURL checks if the given shoutrrr URL points to an internal destination (localhost or private IP)
func isInternalURL(rawURL string) (bool, error) {
parsedURL, err := url.Parse(rawURL)
if err != nil {
return false, err
}
host := parsedURL.Hostname()
if host == "" {
return false, nil
}
if strings.EqualFold(host, "localhost") {
return true, nil
}
if ip := net.ParseIP(host); ip != nil {
return isInternalIP(ip), nil
}
// Some Shoutrrr URLs use the host position for service identifiers rather than a
// network hostname (for example, discord://token@webhookid). Restrict DNS lookups
// to names that look like actual hostnames so valid service URLs keep working.
if !strings.Contains(host, ".") {
return false, nil
}
ips, err := net.LookupIP(host)
if err != nil {
return false, nil
}
if slices.ContainsFunc(ips, isInternalIP) {
return true, nil
}
return false, nil
}
func isInternalIP(ip net.IP) bool {
return ip.IsPrivate() || ip.IsLoopback() || ip.IsUnspecified()
}

View File

@@ -0,0 +1,501 @@
//go:build testing
package alerts_test
import (
"bytes"
"encoding/json"
"io"
"net/http"
"strings"
"testing"
"github.com/henrygd/beszel/internal/alerts"
beszelTests "github.com/henrygd/beszel/internal/tests"
pbTests "github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/stretchr/testify/assert"
)
// marshal to json and return an io.Reader (for use in ApiScenario.Body)
func jsonReader(v any) io.Reader {
data, err := json.Marshal(v)
if err != nil {
panic(err)
}
return bytes.NewReader(data)
}
func TestIsInternalURL(t *testing.T) {
testCases := []struct {
name string
url string
internal bool
}{
{name: "loopback ipv4", url: "generic://127.0.0.1", internal: true},
{name: "localhost hostname", url: "generic://localhost", internal: true},
{name: "localhost hostname", url: "generic+http://localhost/api/v1/postStuff", internal: true},
{name: "localhost hostname", url: "generic+http://127.0.0.1:8080/api/v1/postStuff", internal: true},
{name: "localhost hostname", url: "generic+https://beszel.dev/api/v1/postStuff", internal: false},
{name: "public ipv4", url: "generic://8.8.8.8", internal: false},
{name: "token style service url", url: "discord://abc123@123456789", internal: false},
{name: "single label service url", url: "slack://token@team/channel", internal: false},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
internal, err := alerts.IsInternalURL(testCase.url)
assert.NoError(t, err)
assert.Equal(t, testCase.internal, internal)
})
}
}
func TestUserAlertsApi(t *testing.T) {
hub, _ := beszelTests.NewTestHub(t.TempDir())
defer hub.Cleanup()
hub.StartHub()
user1, _ := beszelTests.CreateUser(hub, "alertstest@example.com", "password")
user1Token, _ := user1.NewAuthToken()
user2, _ := beszelTests.CreateUser(hub, "alertstest2@example.com", "password")
user2Token, _ := user2.NewAuthToken()
system1, _ := beszelTests.CreateRecord(hub, "systems", map[string]any{
"name": "system1",
"users": []string{user1.Id},
"host": "127.0.0.1",
})
system2, _ := beszelTests.CreateRecord(hub, "systems", map[string]any{
"name": "system2",
"users": []string{user1.Id, user2.Id},
"host": "127.0.0.2",
})
userRecords, _ := hub.CountRecords("users")
assert.EqualValues(t, 2, userRecords, "all users should be created")
systemRecords, _ := hub.CountRecords("systems")
assert.EqualValues(t, 2, systemRecords, "all systems should be created")
testAppFactory := func(t testing.TB) *pbTests.TestApp {
return hub.TestApp
}
scenarios := []beszelTests.ApiScenario{
// {
// Name: "GET not implemented - returns index",
// Method: http.MethodGet,
// URL: "/api/beszel/user-alerts",
// ExpectedStatus: 200,
// ExpectedContent: []string{"<html ", "globalThis.BESZEL"},
// TestAppFactory: testAppFactory,
// },
{
Name: "POST no auth",
Method: http.MethodPost,
URL: "/api/beszel/user-alerts",
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
},
{
Name: "POST no body",
Method: http.MethodPost,
URL: "/api/beszel/user-alerts",
Headers: map[string]string{
"Authorization": user1Token,
},
ExpectedStatus: 400,
ExpectedContent: []string{"Bad data"},
TestAppFactory: testAppFactory,
},
{
Name: "POST bad data",
Method: http.MethodPost,
URL: "/api/beszel/user-alerts",
Headers: map[string]string{
"Authorization": user1Token,
},
ExpectedStatus: 400,
ExpectedContent: []string{"Bad data"},
TestAppFactory: testAppFactory,
Body: jsonReader(map[string]any{
"invalidField": "this should cause validation error",
"threshold": "not a number",
}),
},
{
Name: "POST malformed JSON",
Method: http.MethodPost,
URL: "/api/beszel/user-alerts",
Headers: map[string]string{
"Authorization": user1Token,
},
ExpectedStatus: 400,
ExpectedContent: []string{"Bad data"},
TestAppFactory: testAppFactory,
Body: strings.NewReader(`{"alertType": "cpu", "threshold": 80, "enabled": true,}`),
},
{
Name: "POST valid alert data multiple systems",
Method: http.MethodPost,
URL: "/api/beszel/user-alerts",
Headers: map[string]string{
"Authorization": user1Token,
},
ExpectedStatus: 200,
ExpectedContent: []string{"\"success\":true"},
TestAppFactory: testAppFactory,
Body: jsonReader(map[string]any{
"name": "CPU",
"value": 69,
"min": 9,
"systems": []string{system1.Id, system2.Id},
"overwrite": false,
}),
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
// check total alerts
alerts, _ := app.CountRecords("alerts")
assert.EqualValues(t, 2, alerts, "should have 2 alerts")
// check alert has correct values
matchingAlerts, _ := app.CountRecords("alerts", dbx.HashExp{"name": "CPU", "user": user1.Id, "system": system1.Id, "value": 69, "min": 9})
assert.EqualValues(t, 1, matchingAlerts, "should have 1 alert")
},
},
{
Name: "POST valid alert data single system",
Method: http.MethodPost,
URL: "/api/beszel/user-alerts",
Headers: map[string]string{
"Authorization": user1Token,
},
ExpectedStatus: 200,
ExpectedContent: []string{"\"success\":true"},
TestAppFactory: testAppFactory,
Body: jsonReader(map[string]any{
"name": "Memory",
"systems": []string{system1.Id},
"value": 90,
"min": 10,
}),
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
user1Alerts, _ := app.CountRecords("alerts", dbx.HashExp{"user": user1.Id})
assert.EqualValues(t, 3, user1Alerts, "should have 3 alerts")
},
},
{
Name: "Overwrite: false, should not overwrite existing alert",
Method: http.MethodPost,
URL: "/api/beszel/user-alerts",
Headers: map[string]string{
"Authorization": user1Token,
},
ExpectedStatus: 200,
ExpectedContent: []string{"\"success\":true"},
TestAppFactory: testAppFactory,
Body: jsonReader(map[string]any{
"name": "CPU",
"value": 45,
"min": 5,
"systems": []string{system1.Id},
"overwrite": false,
}),
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
beszelTests.ClearCollection(t, app, "alerts")
beszelTests.CreateRecord(app, "alerts", map[string]any{
"name": "CPU",
"system": system1.Id,
"user": user1.Id,
"value": 80,
"min": 10,
})
},
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
alerts, _ := app.CountRecords("alerts")
assert.EqualValues(t, 1, alerts, "should have 1 alert")
alert, _ := app.FindFirstRecordByFilter("alerts", "name = 'CPU' && user = {:user}", dbx.Params{"user": user1.Id})
assert.EqualValues(t, 80, alert.Get("value"), "should have 80 as value")
},
},
{
Name: "Overwrite: true, should overwrite existing alert",
Method: http.MethodPost,
URL: "/api/beszel/user-alerts",
Headers: map[string]string{
"Authorization": user2Token,
},
ExpectedStatus: 200,
ExpectedContent: []string{"\"success\":true"},
TestAppFactory: testAppFactory,
Body: jsonReader(map[string]any{
"name": "CPU",
"value": 45,
"min": 5,
"systems": []string{system2.Id},
"overwrite": true,
}),
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
beszelTests.ClearCollection(t, app, "alerts")
beszelTests.CreateRecord(app, "alerts", map[string]any{
"name": "CPU",
"system": system2.Id,
"user": user2.Id,
"value": 80,
"min": 10,
})
},
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
alerts, _ := app.CountRecords("alerts")
assert.EqualValues(t, 1, alerts, "should have 1 alert")
alert, _ := app.FindFirstRecordByFilter("alerts", "name = 'CPU' && user = {:user}", dbx.Params{"user": user2.Id})
assert.EqualValues(t, 45, alert.Get("value"), "should have 45 as value")
},
},
{
Name: "DELETE no auth",
Method: http.MethodDelete,
URL: "/api/beszel/user-alerts",
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
Body: jsonReader(map[string]any{
"name": "CPU",
"systems": []string{system1.Id},
}),
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
beszelTests.ClearCollection(t, app, "alerts")
beszelTests.CreateRecord(app, "alerts", map[string]any{
"name": "CPU",
"system": system1.Id,
"user": user1.Id,
"value": 80,
"min": 10,
})
},
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
alerts, _ := app.CountRecords("alerts")
assert.EqualValues(t, 1, alerts, "should have 1 alert")
},
},
{
Name: "DELETE alert",
Method: http.MethodDelete,
URL: "/api/beszel/user-alerts",
Headers: map[string]string{
"Authorization": user1Token,
},
ExpectedStatus: 200,
ExpectedContent: []string{"\"count\":1", "\"success\":true"},
TestAppFactory: testAppFactory,
Body: jsonReader(map[string]any{
"name": "CPU",
"systems": []string{system1.Id},
}),
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
beszelTests.ClearCollection(t, app, "alerts")
beszelTests.CreateRecord(app, "alerts", map[string]any{
"name": "CPU",
"system": system1.Id,
"user": user1.Id,
"value": 80,
"min": 10,
})
},
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
alerts, _ := app.CountRecords("alerts")
assert.Zero(t, alerts, "should have 0 alerts")
},
},
{
Name: "DELETE alert multiple systems",
Method: http.MethodDelete,
URL: "/api/beszel/user-alerts",
Headers: map[string]string{
"Authorization": user1Token,
},
ExpectedStatus: 200,
ExpectedContent: []string{"\"count\":2", "\"success\":true"},
TestAppFactory: testAppFactory,
Body: jsonReader(map[string]any{
"name": "Memory",
"systems": []string{system1.Id, system2.Id},
}),
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
beszelTests.ClearCollection(t, app, "alerts")
for _, systemId := range []string{system1.Id, system2.Id} {
_, err := beszelTests.CreateRecord(app, "alerts", map[string]any{
"name": "Memory",
"system": systemId,
"user": user1.Id,
"value": 90,
"min": 10,
})
assert.NoError(t, err, "should create alert")
}
alerts, _ := app.CountRecords("alerts")
assert.EqualValues(t, 2, alerts, "should have 2 alerts")
},
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
alerts, _ := app.CountRecords("alerts")
assert.Zero(t, alerts, "should have 0 alerts")
},
},
{
Name: "User 2 should not be able to delete alert of user 1",
Method: http.MethodDelete,
URL: "/api/beszel/user-alerts",
Headers: map[string]string{
"Authorization": user2Token,
},
ExpectedStatus: 200,
ExpectedContent: []string{"\"count\":1", "\"success\":true"},
TestAppFactory: testAppFactory,
Body: jsonReader(map[string]any{
"name": "CPU",
"systems": []string{system2.Id},
}),
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
beszelTests.ClearCollection(t, app, "alerts")
for _, user := range []string{user1.Id, user2.Id} {
beszelTests.CreateRecord(app, "alerts", map[string]any{
"name": "CPU",
"system": system2.Id,
"user": user,
"value": 80,
"min": 10,
})
}
alerts, _ := app.CountRecords("alerts")
assert.EqualValues(t, 2, alerts, "should have 2 alerts")
user1AlertCount, _ := app.CountRecords("alerts", dbx.HashExp{"user": user1.Id})
assert.EqualValues(t, 1, user1AlertCount, "should have 1 alert")
user2AlertCount, _ := app.CountRecords("alerts", dbx.HashExp{"user": user2.Id})
assert.EqualValues(t, 1, user2AlertCount, "should have 1 alert")
},
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
user1AlertCount, _ := app.CountRecords("alerts", dbx.HashExp{"user": user1.Id})
assert.EqualValues(t, 1, user1AlertCount, "should have 1 alert")
user2AlertCount, _ := app.CountRecords("alerts", dbx.HashExp{"user": user2.Id})
assert.Zero(t, user2AlertCount, "should have 0 alerts")
},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
func TestSendTestNotification(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup()
userToken, err := user.NewAuthToken()
adminUser, err := beszelTests.CreateUserWithRole(hub, "admin@example.com", "password123", "admin")
assert.NoError(t, err, "Failed to create admin user")
adminUserToken, err := adminUser.NewAuthToken()
superuser, err := beszelTests.CreateSuperuser(hub, "superuser@example.com", "password123")
assert.NoError(t, err, "Failed to create superuser")
superuserToken, err := superuser.NewAuthToken()
assert.NoError(t, err, "Failed to create superuser auth token")
testAppFactory := func(t testing.TB) *pbTests.TestApp {
return hub.TestApp
}
scenarios := []beszelTests.ApiScenario{
{
Name: "POST /test-notification - no auth should fail",
Method: http.MethodPost,
URL: "/api/beszel/test-notification",
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
Body: jsonReader(map[string]any{
"url": "generic://127.0.0.1",
}),
},
{
Name: "POST /test-notification - with external auth should succeed",
Method: http.MethodPost,
URL: "/api/beszel/test-notification",
TestAppFactory: testAppFactory,
Headers: map[string]string{
"Authorization": userToken,
},
Body: jsonReader(map[string]any{
"url": "generic://8.8.8.8",
}),
ExpectedStatus: 200,
ExpectedContent: []string{"\"err\":"},
},
{
Name: "POST /test-notification - local url with user auth should fail",
Method: http.MethodPost,
URL: "/api/beszel/test-notification",
TestAppFactory: testAppFactory,
Headers: map[string]string{
"Authorization": userToken,
},
Body: jsonReader(map[string]any{
"url": "generic://localhost:8010",
}),
ExpectedStatus: 403,
ExpectedContent: []string{"Only admins"},
},
{
Name: "POST /test-notification - internal url with user auth should fail",
Method: http.MethodPost,
URL: "/api/beszel/test-notification",
TestAppFactory: testAppFactory,
Headers: map[string]string{
"Authorization": userToken,
},
Body: jsonReader(map[string]any{
"url": "generic+http://192.168.0.5",
}),
ExpectedStatus: 403,
ExpectedContent: []string{"Only admins"},
},
{
Name: "POST /test-notification - internal url with admin auth should succeed",
Method: http.MethodPost,
URL: "/api/beszel/test-notification",
TestAppFactory: testAppFactory,
Headers: map[string]string{
"Authorization": adminUserToken,
},
Body: jsonReader(map[string]any{
"url": "generic://127.0.0.1",
}),
ExpectedStatus: 200,
ExpectedContent: []string{"\"err\":"},
},
{
Name: "POST /test-notification - internal url with superuser auth should succeed",
Method: http.MethodPost,
URL: "/api/beszel/test-notification",
TestAppFactory: testAppFactory,
Headers: map[string]string{
"Authorization": superuserToken,
},
Body: jsonReader(map[string]any{
"url": "generic://127.0.0.1",
}),
ExpectedStatus: 200,
ExpectedContent: []string{"\"err\":"},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}

View File

@@ -3,11 +3,6 @@
package alerts_test
import (
"bytes"
"encoding/json"
"io"
"net/http"
"strings"
"testing"
"testing/synctest"
"time"
@@ -16,359 +11,9 @@ import (
"github.com/henrygd/beszel/internal/alerts"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
pbTests "github.com/pocketbase/pocketbase/tests"
"github.com/stretchr/testify/assert"
)
// marshal to json and return an io.Reader (for use in ApiScenario.Body)
func jsonReader(v any) io.Reader {
data, err := json.Marshal(v)
if err != nil {
panic(err)
}
return bytes.NewReader(data)
}
func TestUserAlertsApi(t *testing.T) {
hub, _ := beszelTests.NewTestHub(t.TempDir())
defer hub.Cleanup()
hub.StartHub()
user1, _ := beszelTests.CreateUser(hub, "alertstest@example.com", "password")
user1Token, _ := user1.NewAuthToken()
user2, _ := beszelTests.CreateUser(hub, "alertstest2@example.com", "password")
user2Token, _ := user2.NewAuthToken()
system1, _ := beszelTests.CreateRecord(hub, "systems", map[string]any{
"name": "system1",
"users": []string{user1.Id},
"host": "127.0.0.1",
})
system2, _ := beszelTests.CreateRecord(hub, "systems", map[string]any{
"name": "system2",
"users": []string{user1.Id, user2.Id},
"host": "127.0.0.2",
})
userRecords, _ := hub.CountRecords("users")
assert.EqualValues(t, 2, userRecords, "all users should be created")
systemRecords, _ := hub.CountRecords("systems")
assert.EqualValues(t, 2, systemRecords, "all systems should be created")
testAppFactory := func(t testing.TB) *pbTests.TestApp {
return hub.TestApp
}
scenarios := []beszelTests.ApiScenario{
// {
// Name: "GET not implemented - returns index",
// Method: http.MethodGet,
// URL: "/api/beszel/user-alerts",
// ExpectedStatus: 200,
// ExpectedContent: []string{"<html ", "globalThis.BESZEL"},
// TestAppFactory: testAppFactory,
// },
{
Name: "POST no auth",
Method: http.MethodPost,
URL: "/api/beszel/user-alerts",
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
},
{
Name: "POST no body",
Method: http.MethodPost,
URL: "/api/beszel/user-alerts",
Headers: map[string]string{
"Authorization": user1Token,
},
ExpectedStatus: 400,
ExpectedContent: []string{"Bad data"},
TestAppFactory: testAppFactory,
},
{
Name: "POST bad data",
Method: http.MethodPost,
URL: "/api/beszel/user-alerts",
Headers: map[string]string{
"Authorization": user1Token,
},
ExpectedStatus: 400,
ExpectedContent: []string{"Bad data"},
TestAppFactory: testAppFactory,
Body: jsonReader(map[string]any{
"invalidField": "this should cause validation error",
"threshold": "not a number",
}),
},
{
Name: "POST malformed JSON",
Method: http.MethodPost,
URL: "/api/beszel/user-alerts",
Headers: map[string]string{
"Authorization": user1Token,
},
ExpectedStatus: 400,
ExpectedContent: []string{"Bad data"},
TestAppFactory: testAppFactory,
Body: strings.NewReader(`{"alertType": "cpu", "threshold": 80, "enabled": true,}`),
},
{
Name: "POST valid alert data multiple systems",
Method: http.MethodPost,
URL: "/api/beszel/user-alerts",
Headers: map[string]string{
"Authorization": user1Token,
},
ExpectedStatus: 200,
ExpectedContent: []string{"\"success\":true"},
TestAppFactory: testAppFactory,
Body: jsonReader(map[string]any{
"name": "CPU",
"value": 69,
"min": 9,
"systems": []string{system1.Id, system2.Id},
"overwrite": false,
}),
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
// check total alerts
alerts, _ := app.CountRecords("alerts")
assert.EqualValues(t, 2, alerts, "should have 2 alerts")
// check alert has correct values
matchingAlerts, _ := app.CountRecords("alerts", dbx.HashExp{"name": "CPU", "user": user1.Id, "system": system1.Id, "value": 69, "min": 9})
assert.EqualValues(t, 1, matchingAlerts, "should have 1 alert")
},
},
{
Name: "POST valid alert data single system",
Method: http.MethodPost,
URL: "/api/beszel/user-alerts",
Headers: map[string]string{
"Authorization": user1Token,
},
ExpectedStatus: 200,
ExpectedContent: []string{"\"success\":true"},
TestAppFactory: testAppFactory,
Body: jsonReader(map[string]any{
"name": "Memory",
"systems": []string{system1.Id},
"value": 90,
"min": 10,
}),
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
user1Alerts, _ := app.CountRecords("alerts", dbx.HashExp{"user": user1.Id})
assert.EqualValues(t, 3, user1Alerts, "should have 3 alerts")
},
},
{
Name: "Overwrite: false, should not overwrite existing alert",
Method: http.MethodPost,
URL: "/api/beszel/user-alerts",
Headers: map[string]string{
"Authorization": user1Token,
},
ExpectedStatus: 200,
ExpectedContent: []string{"\"success\":true"},
TestAppFactory: testAppFactory,
Body: jsonReader(map[string]any{
"name": "CPU",
"value": 45,
"min": 5,
"systems": []string{system1.Id},
"overwrite": false,
}),
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
beszelTests.ClearCollection(t, app, "alerts")
beszelTests.CreateRecord(app, "alerts", map[string]any{
"name": "CPU",
"system": system1.Id,
"user": user1.Id,
"value": 80,
"min": 10,
})
},
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
alerts, _ := app.CountRecords("alerts")
assert.EqualValues(t, 1, alerts, "should have 1 alert")
alert, _ := app.FindFirstRecordByFilter("alerts", "name = 'CPU' && user = {:user}", dbx.Params{"user": user1.Id})
assert.EqualValues(t, 80, alert.Get("value"), "should have 80 as value")
},
},
{
Name: "Overwrite: true, should overwrite existing alert",
Method: http.MethodPost,
URL: "/api/beszel/user-alerts",
Headers: map[string]string{
"Authorization": user2Token,
},
ExpectedStatus: 200,
ExpectedContent: []string{"\"success\":true"},
TestAppFactory: testAppFactory,
Body: jsonReader(map[string]any{
"name": "CPU",
"value": 45,
"min": 5,
"systems": []string{system2.Id},
"overwrite": true,
}),
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
beszelTests.ClearCollection(t, app, "alerts")
beszelTests.CreateRecord(app, "alerts", map[string]any{
"name": "CPU",
"system": system2.Id,
"user": user2.Id,
"value": 80,
"min": 10,
})
},
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
alerts, _ := app.CountRecords("alerts")
assert.EqualValues(t, 1, alerts, "should have 1 alert")
alert, _ := app.FindFirstRecordByFilter("alerts", "name = 'CPU' && user = {:user}", dbx.Params{"user": user2.Id})
assert.EqualValues(t, 45, alert.Get("value"), "should have 45 as value")
},
},
{
Name: "DELETE no auth",
Method: http.MethodDelete,
URL: "/api/beszel/user-alerts",
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
Body: jsonReader(map[string]any{
"name": "CPU",
"systems": []string{system1.Id},
}),
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
beszelTests.ClearCollection(t, app, "alerts")
beszelTests.CreateRecord(app, "alerts", map[string]any{
"name": "CPU",
"system": system1.Id,
"user": user1.Id,
"value": 80,
"min": 10,
})
},
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
alerts, _ := app.CountRecords("alerts")
assert.EqualValues(t, 1, alerts, "should have 1 alert")
},
},
{
Name: "DELETE alert",
Method: http.MethodDelete,
URL: "/api/beszel/user-alerts",
Headers: map[string]string{
"Authorization": user1Token,
},
ExpectedStatus: 200,
ExpectedContent: []string{"\"count\":1", "\"success\":true"},
TestAppFactory: testAppFactory,
Body: jsonReader(map[string]any{
"name": "CPU",
"systems": []string{system1.Id},
}),
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
beszelTests.ClearCollection(t, app, "alerts")
beszelTests.CreateRecord(app, "alerts", map[string]any{
"name": "CPU",
"system": system1.Id,
"user": user1.Id,
"value": 80,
"min": 10,
})
},
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
alerts, _ := app.CountRecords("alerts")
assert.Zero(t, alerts, "should have 0 alerts")
},
},
{
Name: "DELETE alert multiple systems",
Method: http.MethodDelete,
URL: "/api/beszel/user-alerts",
Headers: map[string]string{
"Authorization": user1Token,
},
ExpectedStatus: 200,
ExpectedContent: []string{"\"count\":2", "\"success\":true"},
TestAppFactory: testAppFactory,
Body: jsonReader(map[string]any{
"name": "Memory",
"systems": []string{system1.Id, system2.Id},
}),
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
beszelTests.ClearCollection(t, app, "alerts")
for _, systemId := range []string{system1.Id, system2.Id} {
_, err := beszelTests.CreateRecord(app, "alerts", map[string]any{
"name": "Memory",
"system": systemId,
"user": user1.Id,
"value": 90,
"min": 10,
})
assert.NoError(t, err, "should create alert")
}
alerts, _ := app.CountRecords("alerts")
assert.EqualValues(t, 2, alerts, "should have 2 alerts")
},
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
alerts, _ := app.CountRecords("alerts")
assert.Zero(t, alerts, "should have 0 alerts")
},
},
{
Name: "User 2 should not be able to delete alert of user 1",
Method: http.MethodDelete,
URL: "/api/beszel/user-alerts",
Headers: map[string]string{
"Authorization": user2Token,
},
ExpectedStatus: 200,
ExpectedContent: []string{"\"count\":1", "\"success\":true"},
TestAppFactory: testAppFactory,
Body: jsonReader(map[string]any{
"name": "CPU",
"systems": []string{system2.Id},
}),
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
beszelTests.ClearCollection(t, app, "alerts")
for _, user := range []string{user1.Id, user2.Id} {
beszelTests.CreateRecord(app, "alerts", map[string]any{
"name": "CPU",
"system": system2.Id,
"user": user,
"value": 80,
"min": 10,
})
}
alerts, _ := app.CountRecords("alerts")
assert.EqualValues(t, 2, alerts, "should have 2 alerts")
user1AlertCount, _ := app.CountRecords("alerts", dbx.HashExp{"user": user1.Id})
assert.EqualValues(t, 1, user1AlertCount, "should have 1 alert")
user2AlertCount, _ := app.CountRecords("alerts", dbx.HashExp{"user": user2.Id})
assert.EqualValues(t, 1, user2AlertCount, "should have 1 alert")
},
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
user1AlertCount, _ := app.CountRecords("alerts", dbx.HashExp{"user": user1.Id})
assert.EqualValues(t, 1, user1AlertCount, "should have 1 alert")
user2AlertCount, _ := app.CountRecords("alerts", dbx.HashExp{"user": user2.Id})
assert.Zero(t, user2AlertCount, "should have 0 alerts")
},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
func TestAlertsHistory(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)

View File

@@ -95,3 +95,7 @@ func (am *AlertManager) RestorePendingStatusAlerts() error {
func (am *AlertManager) SetAlertTriggered(alert CachedAlertData, triggered bool) error {
return am.setAlertTriggered(alert, triggered)
}
func IsInternalURL(rawURL string) (bool, error) {
return isInternalURL(rawURL)
}

View File

@@ -195,6 +195,6 @@ func main() {
}
if err := a.Start(serverConfig); err != nil {
log.Fatal("Failed to start server: ", err)
log.Fatal("Failed to start: ", err)
}
}

View File

@@ -494,7 +494,7 @@ type SmartInfoForNvme struct {
FirmwareVersion string `json:"firmware_version"`
// NVMePCIVendor NVMePCIVendor `json:"nvme_pci_vendor"`
// NVMeIEEEOUIIdentifier uint32 `json:"nvme_ieee_oui_identifier"`
// NVMeTotalCapacity uint64 `json:"nvme_total_capacity"`
NVMeTotalCapacity uint64 `json:"nvme_total_capacity"`
// NVMeUnallocatedCapacity uint64 `json:"nvme_unallocated_capacity"`
// NVMeControllerID uint16 `json:"nvme_controller_id"`
// NVMeVersion VersionStringInfo `json:"nvme_version"`

View File

@@ -14,6 +14,7 @@ import (
"github.com/henrygd/beszel/internal/ghupdate"
"github.com/henrygd/beszel/internal/hub/config"
"github.com/henrygd/beszel/internal/hub/systems"
"github.com/henrygd/beszel/internal/hub/utils"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
@@ -70,13 +71,13 @@ func (h *Hub) registerMiddlewares(se *core.ServeEvent) {
return e.Next()
}
// authenticate with trusted header
if autoLogin, _ := GetEnv("AUTO_LOGIN"); autoLogin != "" {
if autoLogin, _ := utils.GetEnv("AUTO_LOGIN"); autoLogin != "" {
se.Router.BindFunc(func(e *core.RequestEvent) error {
return authorizeRequestWithEmail(e, autoLogin)
})
}
// authenticate with trusted header
if trustedHeader, _ := GetEnv("TRUSTED_AUTH_HEADER"); trustedHeader != "" {
if trustedHeader, _ := utils.GetEnv("TRUSTED_AUTH_HEADER"); trustedHeader != "" {
se.Router.BindFunc(func(e *core.RequestEvent) error {
return authorizeRequestWithEmail(e, e.Request.Header.Get(trustedHeader))
})
@@ -104,7 +105,7 @@ func (h *Hub) registerApiRoutes(se *core.ServeEvent) error {
apiAuth.GET("/info", h.getInfo)
apiAuth.GET("/getkey", h.getInfo) // deprecated - keep for compatibility w/ integrations
// check for updates
if optIn, _ := GetEnv("CHECK_UPDATES"); optIn == "true" {
if optIn, _ := utils.GetEnv("CHECK_UPDATES"); optIn == "true" {
var updateInfo UpdateInfo
apiAuth.GET("/update", updateInfo.getUpdate)
}
@@ -127,7 +128,7 @@ func (h *Hub) registerApiRoutes(se *core.ServeEvent) error {
// get systemd service details
apiAuth.GET("/systemd/info", h.getSystemdInfo)
// /containers routes
if enabled, _ := GetEnv("CONTAINER_DETAILS"); enabled != "false" {
if enabled, _ := utils.GetEnv("CONTAINER_DETAILS"); enabled != "false" {
// get container logs
apiAuth.GET("/containers/logs", h.getContainerLogs)
// get container info
@@ -147,7 +148,7 @@ func (h *Hub) getInfo(e *core.RequestEvent) error {
Key: h.pubKey,
Version: beszel.Version,
}
if optIn, _ := GetEnv("CHECK_UPDATES"); optIn == "true" {
if optIn, _ := utils.GetEnv("CHECK_UPDATES"); optIn == "true" {
info.CheckUpdate = true
}
return e.JSON(http.StatusOK, info)
@@ -315,7 +316,7 @@ func (h *Hub) containerRequestHandler(e *core.RequestEvent, fetchFunc func(*syst
}
system, err := h.sm.GetSystem(systemID)
if err != nil || !system.HasUser(e.App, e.Auth.Id) {
if err != nil || !system.HasUser(e.App, e.Auth) {
return e.NotFoundError("", nil)
}
@@ -350,7 +351,7 @@ func (h *Hub) getSystemdInfo(e *core.RequestEvent) error {
return e.BadRequestError("Invalid system or service parameter", nil)
}
system, err := h.sm.GetSystem(systemID)
if err != nil || !system.HasUser(e.App, e.Auth.Id) {
if err != nil || !system.HasUser(e.App, e.Auth) {
return e.NotFoundError("", nil)
}
// verify service exists before fetching details
@@ -378,7 +379,7 @@ func (h *Hub) refreshSmartData(e *core.RequestEvent) error {
}
system, err := h.sm.GetSystem(systemID)
if err != nil || !system.HasUser(e.App, e.Auth.Id) {
if err != nil || !system.HasUser(e.App, e.Auth) {
return e.NotFoundError("", nil)
}

View File

@@ -66,31 +66,6 @@ func TestApiRoutesAuthentication(t *testing.T) {
scenarios := []beszelTests.ApiScenario{
// Auth Protected Routes - Should require authentication
{
Name: "POST /test-notification - no auth should fail",
Method: http.MethodPost,
URL: "/api/beszel/test-notification",
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
Body: jsonReader(map[string]any{
"url": "generic://127.0.0.1",
}),
},
{
Name: "POST /test-notification - with auth should succeed",
Method: http.MethodPost,
URL: "/api/beszel/test-notification",
TestAppFactory: testAppFactory,
Headers: map[string]string{
"Authorization": userToken,
},
Body: jsonReader(map[string]any{
"url": "generic://127.0.0.1",
}),
ExpectedStatus: 200,
ExpectedContent: []string{"sending message"},
},
{
Name: "GET /config-yaml - no auth should fail",
Method: http.MethodGet,
@@ -369,6 +344,23 @@ func TestApiRoutesAuthentication(t *testing.T) {
"Authorization": user2Token,
},
},
{
Name: "GET /containers/info - SHARE_ALL_SYSTEMS allows non-member user",
Method: http.MethodGet,
URL: fmt.Sprintf("/api/beszel/containers/info?system=%s&container=abababababab", system.Id),
ExpectedStatus: 500,
ExpectedContent: []string{"Something went wrong while processing your request."},
TestAppFactory: testAppFactory,
Headers: map[string]string{
"Authorization": user2Token,
},
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
t.Setenv("SHARE_ALL_SYSTEMS", "true")
},
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
t.Setenv("SHARE_ALL_SYSTEMS", "")
},
},
{
Name: "GET /containers/logs - with auth but missing system param should fail",
Method: http.MethodGet,

View File

@@ -1,6 +1,9 @@
package hub
import "github.com/pocketbase/pocketbase/core"
import (
"github.com/henrygd/beszel/internal/hub/utils"
"github.com/pocketbase/pocketbase/core"
)
type collectionRules struct {
list *string
@@ -22,11 +25,11 @@ func setCollectionAuthSettings(app core.App) error {
}
// disable email auth if DISABLE_PASSWORD_AUTH env var is set
disablePasswordAuth, _ := GetEnv("DISABLE_PASSWORD_AUTH")
disablePasswordAuth, _ := utils.GetEnv("DISABLE_PASSWORD_AUTH")
usersCollection.PasswordAuth.Enabled = disablePasswordAuth != "true"
usersCollection.PasswordAuth.IdentityFields = []string{"email"}
// allow oauth user creation if USER_CREATION is set
if userCreation, _ := GetEnv("USER_CREATION"); userCreation == "true" {
if userCreation, _ := utils.GetEnv("USER_CREATION"); userCreation == "true" {
cr := "@request.context = 'oauth2'"
usersCollection.CreateRule = &cr
} else {
@@ -34,7 +37,7 @@ func setCollectionAuthSettings(app core.App) error {
}
// enable mfaOtp mfa if MFA_OTP env var is set
mfaOtp, _ := GetEnv("MFA_OTP")
mfaOtp, _ := utils.GetEnv("MFA_OTP")
usersCollection.OTP.Length = 6
superusersCollection.OTP.Length = 6
usersCollection.OTP.Enabled = mfaOtp == "true"
@@ -50,7 +53,7 @@ func setCollectionAuthSettings(app core.App) error {
// When SHARE_ALL_SYSTEMS is enabled, any authenticated user can read
// system-scoped data. Write rules continue to block readonly users.
shareAllSystems, _ := GetEnv("SHARE_ALL_SYSTEMS")
shareAllSystems, _ := utils.GetEnv("SHARE_ALL_SYSTEMS")
authenticatedRule := "@request.auth.id != \"\""
systemsMemberRule := authenticatedRule + " && users.id ?= @request.auth.id"

View File

@@ -15,6 +15,7 @@ import (
"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/hub/utils"
"github.com/henrygd/beszel/internal/records"
"github.com/henrygd/beszel/internal/users"
@@ -44,7 +45,7 @@ func NewHub(app core.App) *Hub {
hub.um = users.NewUserManager(hub)
hub.rm = records.NewRecordManager(hub)
hub.sm = systems.NewSystemManager(hub)
hub.hb = heartbeat.New(app, GetEnv)
hub.hb = heartbeat.New(app, utils.GetEnv)
if hub.hb != nil {
hub.hbStop = make(chan struct{})
}
@@ -52,15 +53,6 @@ func NewHub(app core.App) *Hub {
return hub
}
// GetEnv retrieves an environment variable with a "BESZEL_HUB_" prefix, or falls back to the unprefixed key.
func GetEnv(key string) (value string, exists bool) {
if value, exists = os.LookupEnv("BESZEL_HUB_" + key); exists {
return value, exists
}
// Fallback to the old unprefixed key
return os.LookupEnv(key)
}
// onAfterBootstrapAndMigrations ensures the provided function runs after the database is set up and migrations are applied.
// This is a workaround for behavior in PocketBase where onBootstrap runs before migrations, forcing use of onServe for this purpose.
// However, PB's tests.TestApp is already bootstrapped, generally doesn't serve, but does handle migrations.
@@ -131,7 +123,7 @@ func (h *Hub) initialize(app core.App) error {
// batch requests (for alerts)
settings.Batch.Enabled = true
// set URL if APP_URL env is set
if appURL, isSet := GetEnv("APP_URL"); isSet {
if appURL, isSet := utils.GetEnv("APP_URL"); isSet {
h.appURL = appURL
settings.Meta.AppURL = appURL
}

42
internal/hub/server.go Normal file
View File

@@ -0,0 +1,42 @@
package hub
import (
"encoding/json"
"net/url"
"strings"
"github.com/henrygd/beszel"
"github.com/henrygd/beszel/internal/hub/utils"
)
// PublicAppInfo defines the structure of the public app information that will be injected into the HTML
type PublicAppInfo struct {
BASE_PATH string
HUB_VERSION string
HUB_URL string
OAUTH_DISABLE_POPUP bool `json:"OAUTH_DISABLE_POPUP,omitempty"`
}
// modifyIndexHTML injects the public app information into the index.html content
func modifyIndexHTML(hub *Hub, html []byte) string {
info := getPublicAppInfo(hub)
content, err := json.Marshal(info)
if err != nil {
return string(html)
}
htmlContent := strings.ReplaceAll(string(html), "./", info.BASE_PATH)
return strings.Replace(htmlContent, "\"{info}\"", string(content), 1)
}
func getPublicAppInfo(hub *Hub) PublicAppInfo {
parsedURL, _ := url.Parse(hub.appURL)
info := PublicAppInfo{
BASE_PATH: strings.TrimSuffix(parsedURL.Path, "/") + "/",
HUB_VERSION: beszel.Version,
HUB_URL: hub.appURL,
}
if val, _ := utils.GetEnv("OAUTH_DISABLE_POPUP"); val == "true" {
info.OAUTH_DISABLE_POPUP = true
}
return info
}

View File

@@ -10,8 +10,6 @@ import (
"net/url"
"strings"
"github.com/henrygd/beszel"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/osutils"
)
@@ -38,7 +36,7 @@ func (rm *responseModifier) RoundTrip(req *http.Request) (*http.Response, error)
}
resp.Body.Close()
// Create a new response with the modified body
modifiedBody := rm.modifyHTML(string(body))
modifiedBody := modifyIndexHTML(rm.hub, body)
resp.Body = io.NopCloser(strings.NewReader(modifiedBody))
resp.ContentLength = int64(len(modifiedBody))
resp.Header.Set("Content-Length", fmt.Sprintf("%d", len(modifiedBody)))
@@ -46,19 +44,6 @@ func (rm *responseModifier) RoundTrip(req *http.Request) (*http.Response, error)
return resp, nil
}
func (rm *responseModifier) modifyHTML(html string) string {
parsedURL, err := url.Parse(rm.hub.appURL)
if err != nil {
return html
}
// fix base paths in html if using subpath
basePath := strings.TrimSuffix(parsedURL.Path, "/") + "/"
html = strings.ReplaceAll(html, "./", basePath)
html = strings.Replace(html, "{{V}}", beszel.Version, 1)
html = strings.Replace(html, "{{HUB_URL}}", rm.hub.appURL, 1)
return html
}
// startServer sets up the development server for Beszel
func (h *Hub) startServer(se *core.ServeEvent) error {
proxy := httputil.NewSingleHostReverseProxy(&url.URL{

View File

@@ -5,10 +5,9 @@ package hub
import (
"io/fs"
"net/http"
"net/url"
"strings"
"github.com/henrygd/beszel"
"github.com/henrygd/beszel/internal/hub/utils"
"github.com/henrygd/beszel/internal/site"
"github.com/pocketbase/pocketbase/apis"
@@ -17,22 +16,13 @@ import (
// startServer sets up the production server for Beszel
func (h *Hub) startServer(se *core.ServeEvent) error {
// parse app url
parsedURL, err := url.Parse(h.appURL)
if err != nil {
return err
}
// fix base paths in html if using subpath
basePath := strings.TrimSuffix(parsedURL.Path, "/") + "/"
indexFile, _ := fs.ReadFile(site.DistDirFS, "index.html")
html := strings.ReplaceAll(string(indexFile), "./", basePath)
html = strings.Replace(html, "{{V}}", beszel.Version, 1)
html = strings.Replace(html, "{{HUB_URL}}", h.appURL, 1)
html := modifyIndexHTML(h, indexFile)
// set up static asset serving
staticPaths := [2]string{"/static/", "/assets/"}
serveStatic := apis.Static(site.DistDirFS, false)
// get CSP configuration
csp, cspExists := GetEnv("CSP")
csp, cspExists := utils.GetEnv("CSP")
// add route
se.Router.GET("/{path...}", func(e *core.RequestEvent) error {
// serve static assets if path is in staticPaths

View File

@@ -8,13 +8,13 @@ import (
"hash/fnv"
"math/rand"
"net"
"slices"
"strings"
"sync/atomic"
"time"
"github.com/henrygd/beszel/internal/common"
"github.com/henrygd/beszel/internal/hub/transport"
"github.com/henrygd/beszel/internal/hub/utils"
"github.com/henrygd/beszel/internal/hub/ws"
"github.com/henrygd/beszel/internal/entities/container"
@@ -353,14 +353,25 @@ func (sys *System) getRecord(app core.App) (*core.Record, error) {
return record, nil
}
// HasUser checks if the given user ID is in the system's users list.
func (sys *System) HasUser(app core.App, userID string) bool {
record, err := sys.getRecord(app)
if err != nil {
// HasUser checks if the given user is in the system's users list.
// Returns true if SHARE_ALL_SYSTEMS is enabled (any authenticated user can access any system).
func (sys *System) HasUser(app core.App, user *core.Record) bool {
if user == nil {
return false
}
users := record.GetStringSlice("users")
return slices.Contains(users, userID)
if v, _ := utils.GetEnv("SHARE_ALL_SYSTEMS"); v == "true" {
return true
}
var recordData = struct {
Users string
}{}
err := app.DB().NewQuery("SELECT users FROM systems WHERE id={:id}").
Bind(dbx.Params{"id": sys.Id}).
One(&recordData)
if err != nil || recordData.Users == "" {
return false
}
return strings.Contains(recordData.Users, user.Id)
}
// setDown marks a system as down in the database.

View File

@@ -421,3 +421,60 @@ func testOld(t *testing.T, hub *tests.TestHub) {
assert.NoError(t, err)
})
}
func TestHasUser(t *testing.T) {
hub, err := tests.NewTestHub(t.TempDir())
require.NoError(t, err)
defer hub.Cleanup()
sm := hub.GetSystemManager()
err = sm.Initialize()
require.NoError(t, err)
user1, err := tests.CreateUser(hub, "user1@test.com", "password123")
require.NoError(t, err)
user2, err := tests.CreateUser(hub, "user2@test.com", "password123")
require.NoError(t, err)
systemRecord, err := tests.CreateRecord(hub, "systems", map[string]any{
"name": "has-user-test",
"host": "127.0.0.1",
"port": "33914",
"users": []string{user1.Id},
})
require.NoError(t, err)
sys, err := sm.GetSystemFromStore(systemRecord.Id)
require.NoError(t, err)
t.Run("user in list returns true", func(t *testing.T) {
assert.True(t, sys.HasUser(hub, user1))
})
t.Run("user not in list returns false", func(t *testing.T) {
assert.False(t, sys.HasUser(hub, user2))
})
t.Run("unknown user ID returns false", func(t *testing.T) {
assert.False(t, sys.HasUser(hub, nil))
})
t.Run("SHARE_ALL_SYSTEMS=true grants access to non-member", func(t *testing.T) {
t.Setenv("SHARE_ALL_SYSTEMS", "true")
assert.True(t, sys.HasUser(hub, user2))
})
t.Run("BESZEL_HUB_SHARE_ALL_SYSTEMS=true grants access to non-member", func(t *testing.T) {
t.Setenv("BESZEL_HUB_SHARE_ALL_SYSTEMS", "true")
assert.True(t, sys.HasUser(hub, user2))
})
t.Run("additional user works", func(t *testing.T) {
assert.False(t, sys.HasUser(hub, user2))
systemRecord.Set("users", []string{user1.Id, user2.Id})
err = hub.Save(systemRecord)
require.NoError(t, err)
assert.True(t, sys.HasUser(hub, user1))
assert.True(t, sys.HasUser(hub, user2))
})
}

View File

@@ -0,0 +1,12 @@
// Package utils provides utility functions for the hub.
package utils
import "os"
// GetEnv retrieves an environment variable with a "BESZEL_HUB_" prefix, or falls back to the unprefixed key.
func GetEnv(key string) (value string, exists bool) {
if value, exists = os.LookupEnv("BESZEL_HUB_" + key); exists {
return value, exists
}
return os.LookupEnv(key)
}

View File

@@ -3,10 +3,8 @@ package records
import (
"encoding/json"
"fmt"
"log"
"math"
"strings"
"time"
"github.com/henrygd/beszel/internal/entities/container"
@@ -39,16 +37,6 @@ type StatsRecord struct {
Stats []byte `db:"stats"`
}
// global variables for reusing allocations
var (
statsRecord StatsRecord
containerStats []container.Stats
sumStats system.Stats
tempStats system.Stats
queryParams = make(dbx.Params, 1)
containerSums = make(map[string]*container.Stats)
)
// Create longer records by averaging shorter records
func (rm *RecordManager) CreateLongerRecords() {
// start := time.Now()
@@ -163,41 +151,47 @@ func (rm *RecordManager) CreateLongerRecords() {
return nil
})
statsRecord.Stats = statsRecord.Stats[:0]
// log.Println("finished creating longer records", "time (ms)", time.Since(start).Milliseconds())
}
// Calculate the average stats of a list of system_stats records without reflect
func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *system.Stats {
// Clear/reset global structs for reuse
sumStats = system.Stats{}
tempStats = system.Stats{}
sum := &sumStats
stats := &tempStats
stats := make([]system.Stats, 0, len(records))
var row StatsRecord
params := make(dbx.Params, 1)
for _, rec := range records {
row.Stats = row.Stats[:0]
params["id"] = rec.Id
db.NewQuery("SELECT stats FROM system_stats WHERE id = {:id}").Bind(params).One(&row)
var s system.Stats
if err := json.Unmarshal(row.Stats, &s); err != nil {
continue
}
stats = append(stats, s)
}
result := AverageSystemStatsSlice(stats)
return &result
}
// AverageSystemStatsSlice computes the average of a slice of system stats.
func AverageSystemStatsSlice(records []system.Stats) system.Stats {
var sum system.Stats
count := float64(len(records))
if count == 0 {
return sum
}
// necessary because uint8 is not big enough for the sum
batterySum := 0
// accumulate per-core usage across records
var cpuCoresSums []uint64
// accumulate cpu breakdown [user, system, iowait, steal, idle]
var cpuBreakdownSums []float64
count := float64(len(records))
tempCount := float64(0)
// Accumulate totals
for _, record := range records {
id := record.Id
// clear global statsRecord for reuse
statsRecord.Stats = statsRecord.Stats[:0]
// reset tempStats each iteration to avoid omitzero fields retaining stale values
*stats = system.Stats{}
queryParams["id"] = id
db.NewQuery("SELECT stats FROM system_stats WHERE id = {:id}").Bind(queryParams).One(&statsRecord)
if err := json.Unmarshal(statsRecord.Stats, stats); err != nil {
continue
}
for i := range records {
stats := &records[i]
sum.Cpu += stats.Cpu
// accumulate cpu time breakdowns if present
@@ -205,8 +199,8 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
if len(cpuBreakdownSums) < len(stats.CpuBreakdown) {
cpuBreakdownSums = append(cpuBreakdownSums, make([]float64, len(stats.CpuBreakdown)-len(cpuBreakdownSums))...)
}
for i, v := range stats.CpuBreakdown {
cpuBreakdownSums[i] += v
for j, v := range stats.CpuBreakdown {
cpuBreakdownSums[j] += v
}
}
sum.Mem += stats.Mem
@@ -242,8 +236,8 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
// extend slices to accommodate core count
cpuCoresSums = append(cpuCoresSums, make([]uint64, len(stats.CpuCoresUsage)-len(cpuCoresSums))...)
}
for i, v := range stats.CpuCoresUsage {
cpuCoresSums[i] += uint64(v)
for j, v := range stats.CpuCoresUsage {
cpuCoresSums[j] += uint64(v)
}
}
// Set peak values
@@ -343,109 +337,107 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
}
}
// Compute averages in place
if count > 0 {
sum.Cpu = twoDecimals(sum.Cpu / count)
sum.Mem = twoDecimals(sum.Mem / count)
sum.MemUsed = twoDecimals(sum.MemUsed / count)
sum.MemPct = twoDecimals(sum.MemPct / count)
sum.MemBuffCache = twoDecimals(sum.MemBuffCache / count)
sum.MemZfsArc = twoDecimals(sum.MemZfsArc / count)
sum.Swap = twoDecimals(sum.Swap / count)
sum.SwapUsed = twoDecimals(sum.SwapUsed / count)
sum.DiskTotal = twoDecimals(sum.DiskTotal / count)
sum.DiskUsed = twoDecimals(sum.DiskUsed / count)
sum.DiskPct = twoDecimals(sum.DiskPct / count)
sum.DiskReadPs = twoDecimals(sum.DiskReadPs / count)
sum.DiskWritePs = twoDecimals(sum.DiskWritePs / count)
sum.DiskIO[0] = sum.DiskIO[0] / uint64(count)
sum.DiskIO[1] = sum.DiskIO[1] / uint64(count)
for i := range sum.DiskIoStats {
sum.DiskIoStats[i] = twoDecimals(sum.DiskIoStats[i] / count)
}
sum.NetworkSent = twoDecimals(sum.NetworkSent / count)
sum.NetworkRecv = twoDecimals(sum.NetworkRecv / count)
sum.LoadAvg[0] = twoDecimals(sum.LoadAvg[0] / count)
sum.LoadAvg[1] = twoDecimals(sum.LoadAvg[1] / count)
sum.LoadAvg[2] = twoDecimals(sum.LoadAvg[2] / count)
sum.Bandwidth[0] = sum.Bandwidth[0] / uint64(count)
sum.Bandwidth[1] = sum.Bandwidth[1] / uint64(count)
sum.Battery[0] = uint8(batterySum / int(count))
// Compute averages
sum.Cpu = twoDecimals(sum.Cpu / count)
sum.Mem = twoDecimals(sum.Mem / count)
sum.MemUsed = twoDecimals(sum.MemUsed / count)
sum.MemPct = twoDecimals(sum.MemPct / count)
sum.MemBuffCache = twoDecimals(sum.MemBuffCache / count)
sum.MemZfsArc = twoDecimals(sum.MemZfsArc / count)
sum.Swap = twoDecimals(sum.Swap / count)
sum.SwapUsed = twoDecimals(sum.SwapUsed / count)
sum.DiskTotal = twoDecimals(sum.DiskTotal / count)
sum.DiskUsed = twoDecimals(sum.DiskUsed / count)
sum.DiskPct = twoDecimals(sum.DiskPct / count)
sum.DiskReadPs = twoDecimals(sum.DiskReadPs / count)
sum.DiskWritePs = twoDecimals(sum.DiskWritePs / count)
sum.DiskIO[0] = sum.DiskIO[0] / uint64(count)
sum.DiskIO[1] = sum.DiskIO[1] / uint64(count)
for i := range sum.DiskIoStats {
sum.DiskIoStats[i] = twoDecimals(sum.DiskIoStats[i] / count)
}
sum.NetworkSent = twoDecimals(sum.NetworkSent / count)
sum.NetworkRecv = twoDecimals(sum.NetworkRecv / count)
sum.LoadAvg[0] = twoDecimals(sum.LoadAvg[0] / count)
sum.LoadAvg[1] = twoDecimals(sum.LoadAvg[1] / count)
sum.LoadAvg[2] = twoDecimals(sum.LoadAvg[2] / count)
sum.Bandwidth[0] = sum.Bandwidth[0] / uint64(count)
sum.Bandwidth[1] = sum.Bandwidth[1] / uint64(count)
sum.Battery[0] = uint8(batterySum / int(count))
// Average network interfaces
if sum.NetworkInterfaces != nil {
for key := range sum.NetworkInterfaces {
sum.NetworkInterfaces[key] = [4]uint64{
sum.NetworkInterfaces[key][0] / uint64(count),
sum.NetworkInterfaces[key][1] / uint64(count),
sum.NetworkInterfaces[key][2],
sum.NetworkInterfaces[key][3],
// Average network interfaces
if sum.NetworkInterfaces != nil {
for key := range sum.NetworkInterfaces {
sum.NetworkInterfaces[key] = [4]uint64{
sum.NetworkInterfaces[key][0] / uint64(count),
sum.NetworkInterfaces[key][1] / uint64(count),
sum.NetworkInterfaces[key][2],
sum.NetworkInterfaces[key][3],
}
}
}
// Average temperatures
if sum.Temperatures != nil && tempCount > 0 {
for key := range sum.Temperatures {
sum.Temperatures[key] = twoDecimals(sum.Temperatures[key] / tempCount)
}
}
// Average extra filesystem stats
if sum.ExtraFs != nil {
for key := range sum.ExtraFs {
fs := sum.ExtraFs[key]
fs.DiskTotal = twoDecimals(fs.DiskTotal / count)
fs.DiskUsed = twoDecimals(fs.DiskUsed / count)
fs.DiskWritePs = twoDecimals(fs.DiskWritePs / count)
fs.DiskReadPs = twoDecimals(fs.DiskReadPs / count)
fs.DiskReadBytes = fs.DiskReadBytes / uint64(count)
fs.DiskWriteBytes = fs.DiskWriteBytes / uint64(count)
for i := range fs.DiskIoStats {
fs.DiskIoStats[i] = twoDecimals(fs.DiskIoStats[i] / count)
}
}
}
// Average GPU data
if sum.GPUData != nil {
for id := range sum.GPUData {
gpu := sum.GPUData[id]
gpu.Temperature = twoDecimals(gpu.Temperature / count)
gpu.MemoryUsed = twoDecimals(gpu.MemoryUsed / count)
gpu.MemoryTotal = twoDecimals(gpu.MemoryTotal / count)
gpu.Usage = twoDecimals(gpu.Usage / count)
gpu.Power = twoDecimals(gpu.Power / count)
gpu.Count = twoDecimals(gpu.Count / count)
if gpu.Engines != nil {
for engineKey := range gpu.Engines {
gpu.Engines[engineKey] = twoDecimals(gpu.Engines[engineKey] / count)
}
}
sum.GPUData[id] = gpu
}
}
// Average temperatures
if sum.Temperatures != nil && tempCount > 0 {
for key := range sum.Temperatures {
sum.Temperatures[key] = twoDecimals(sum.Temperatures[key] / tempCount)
}
// Average per-core usage
if len(cpuCoresSums) > 0 {
avg := make(system.Uint8Slice, len(cpuCoresSums))
for i := range cpuCoresSums {
v := math.Round(float64(cpuCoresSums[i]) / count)
avg[i] = uint8(v)
}
sum.CpuCoresUsage = avg
}
// Average extra filesystem stats
if sum.ExtraFs != nil {
for key := range sum.ExtraFs {
fs := sum.ExtraFs[key]
fs.DiskTotal = twoDecimals(fs.DiskTotal / count)
fs.DiskUsed = twoDecimals(fs.DiskUsed / count)
fs.DiskWritePs = twoDecimals(fs.DiskWritePs / count)
fs.DiskReadPs = twoDecimals(fs.DiskReadPs / count)
fs.DiskReadBytes = fs.DiskReadBytes / uint64(count)
fs.DiskWriteBytes = fs.DiskWriteBytes / uint64(count)
for i := range fs.DiskIoStats {
fs.DiskIoStats[i] = twoDecimals(fs.DiskIoStats[i] / count)
}
}
}
// Average GPU data
if sum.GPUData != nil {
for id := range sum.GPUData {
gpu := sum.GPUData[id]
gpu.Temperature = twoDecimals(gpu.Temperature / count)
gpu.MemoryUsed = twoDecimals(gpu.MemoryUsed / count)
gpu.MemoryTotal = twoDecimals(gpu.MemoryTotal / count)
gpu.Usage = twoDecimals(gpu.Usage / count)
gpu.Power = twoDecimals(gpu.Power / count)
gpu.Count = twoDecimals(gpu.Count / count)
if gpu.Engines != nil {
for engineKey := range gpu.Engines {
gpu.Engines[engineKey] = twoDecimals(gpu.Engines[engineKey] / count)
}
}
sum.GPUData[id] = gpu
}
}
// Average per-core usage
if len(cpuCoresSums) > 0 {
avg := make(system.Uint8Slice, len(cpuCoresSums))
for i := range cpuCoresSums {
v := math.Round(float64(cpuCoresSums[i]) / count)
avg[i] = uint8(v)
}
sum.CpuCoresUsage = avg
}
// Average CPU breakdown
if len(cpuBreakdownSums) > 0 {
avg := make([]float64, len(cpuBreakdownSums))
for i := range cpuBreakdownSums {
avg[i] = twoDecimals(cpuBreakdownSums[i] / count)
}
sum.CpuBreakdown = avg
// Average CPU breakdown
if len(cpuBreakdownSums) > 0 {
avg := make([]float64, len(cpuBreakdownSums))
for i := range cpuBreakdownSums {
avg[i] = twoDecimals(cpuBreakdownSums[i] / count)
}
sum.CpuBreakdown = avg
}
return sum
@@ -453,29 +445,33 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
// Calculate the average stats of a list of container_stats records
func (rm *RecordManager) AverageContainerStats(db dbx.Builder, records RecordIds) []container.Stats {
// Clear global map for reuse
for k := range containerSums {
delete(containerSums, k)
}
sums := containerSums
count := float64(len(records))
for i := range records {
id := records[i].Id
// clear global statsRecord for reuse
statsRecord.Stats = statsRecord.Stats[:0]
// must set to nil (not [:0]) to avoid json.Unmarshal reusing backing array
// which causes omitzero fields to inherit stale values from previous iterations
containerStats = nil
queryParams["id"] = id
db.NewQuery("SELECT stats FROM container_stats WHERE id = {:id}").Bind(queryParams).One(&statsRecord)
if err := json.Unmarshal(statsRecord.Stats, &containerStats); err != nil {
allStats := make([][]container.Stats, 0, len(records))
var row StatsRecord
params := make(dbx.Params, 1)
for _, rec := range records {
row.Stats = row.Stats[:0]
params["id"] = rec.Id
db.NewQuery("SELECT stats FROM container_stats WHERE id = {:id}").Bind(params).One(&row)
var cs []container.Stats
if err := json.Unmarshal(row.Stats, &cs); err != nil {
return []container.Stats{}
}
allStats = append(allStats, cs)
}
return AverageContainerStatsSlice(allStats)
}
// AverageContainerStatsSlice computes the average of container stats across multiple time periods.
func AverageContainerStatsSlice(records [][]container.Stats) []container.Stats {
if len(records) == 0 {
return []container.Stats{}
}
sums := make(map[string]*container.Stats)
count := float64(len(records))
for _, containerStats := range records {
for i := range containerStats {
stat := containerStats[i]
stat := &containerStats[i]
if _, ok := sums[stat.Name]; !ok {
sums[stat.Name] = &container.Stats{Name: stat.Name}
}
@@ -504,133 +500,6 @@ func (rm *RecordManager) AverageContainerStats(db dbx.Builder, records RecordIds
return result
}
// Delete old records
func (rm *RecordManager) DeleteOldRecords() {
rm.app.RunInTransaction(func(txApp core.App) error {
err := deleteOldSystemStats(txApp)
if err != nil {
return err
}
err = deleteOldContainerRecords(txApp)
if err != nil {
return err
}
err = deleteOldSystemdServiceRecords(txApp)
if err != nil {
return err
}
err = deleteOldAlertsHistory(txApp, 200, 250)
if err != nil {
return err
}
err = deleteOldQuietHours(txApp)
if err != nil {
return err
}
return nil
})
}
// Delete old alerts history records
func deleteOldAlertsHistory(app core.App, countToKeep, countBeforeDeletion int) error {
db := app.DB()
var users []struct {
Id string `db:"user"`
}
err := db.NewQuery("SELECT user, COUNT(*) as count FROM alerts_history GROUP BY user HAVING count > {:countBeforeDeletion}").Bind(dbx.Params{"countBeforeDeletion": countBeforeDeletion}).All(&users)
if err != nil {
return err
}
for _, user := range users {
_, err = db.NewQuery("DELETE FROM alerts_history WHERE user = {:user} AND id NOT IN (SELECT id FROM alerts_history WHERE user = {:user} ORDER BY created DESC LIMIT {:countToKeep})").Bind(dbx.Params{"user": user.Id, "countToKeep": countToKeep}).Execute()
if err != nil {
return err
}
}
return nil
}
// Deletes system_stats records older than what is displayed in the UI
func deleteOldSystemStats(app core.App) error {
// Collections to process
collections := [2]string{"system_stats", "container_stats"}
// Record types and their retention periods
type RecordDeletionData struct {
recordType string
retention time.Duration
}
recordData := []RecordDeletionData{
{recordType: "1m", retention: time.Hour}, // 1 hour
{recordType: "10m", retention: 12 * time.Hour}, // 12 hours
{recordType: "20m", retention: 24 * time.Hour}, // 1 day
{recordType: "120m", retention: 7 * 24 * time.Hour}, // 7 days
{recordType: "480m", retention: 30 * 24 * time.Hour}, // 30 days
}
now := time.Now().UTC()
for _, collection := range collections {
// Build the WHERE clause
var conditionParts []string
var params dbx.Params = make(map[string]any)
for i := range recordData {
rd := recordData[i]
// Create parameterized condition for this record type
dateParam := fmt.Sprintf("date%d", i)
conditionParts = append(conditionParts, fmt.Sprintf("(type = '%s' AND created < {:%s})", rd.recordType, dateParam))
params[dateParam] = now.Add(-rd.retention)
}
// Combine conditions with OR
conditionStr := strings.Join(conditionParts, " OR ")
// Construct and execute the full raw query
rawQuery := fmt.Sprintf("DELETE FROM %s WHERE %s", collection, conditionStr)
if _, err := app.DB().NewQuery(rawQuery).Bind(params).Execute(); err != nil {
return fmt.Errorf("failed to delete from %s: %v", collection, err)
}
}
return nil
}
// Deletes systemd service records that haven't been updated in the last 20 minutes
func deleteOldSystemdServiceRecords(app core.App) error {
now := time.Now().UTC()
twentyMinutesAgo := now.Add(-20 * time.Minute)
// Delete systemd service records where updated < twentyMinutesAgo
_, err := app.DB().NewQuery("DELETE FROM systemd_services WHERE updated < {:updated}").Bind(dbx.Params{"updated": twentyMinutesAgo.UnixMilli()}).Execute()
if err != nil {
return fmt.Errorf("failed to delete old systemd service records: %v", err)
}
return nil
}
// Deletes container records that haven't been updated in the last 10 minutes
func deleteOldContainerRecords(app core.App) error {
now := time.Now().UTC()
tenMinutesAgo := now.Add(-10 * time.Minute)
// Delete container records where updated < tenMinutesAgo
_, err := app.DB().NewQuery("DELETE FROM containers WHERE updated < {:updated}").Bind(dbx.Params{"updated": tenMinutesAgo.UnixMilli()}).Execute()
if err != nil {
return fmt.Errorf("failed to delete old container records: %v", err)
}
return nil
}
// Deletes old quiet hours records where end date has passed
func deleteOldQuietHours(app core.App) error {
now := time.Now().UTC()
_, err := app.DB().NewQuery("DELETE FROM quiet_hours WHERE type = 'one-time' AND end < {:now}").Bind(dbx.Params{"now": now}).Execute()
if err != nil {
return err
}
return nil
}
/* Round float to two decimals */
func twoDecimals(value float64) float64 {
return math.Round(value*100) / 100

View File

@@ -0,0 +1,820 @@
//go:build testing
package records_test
import (
"sort"
"testing"
"github.com/henrygd/beszel/internal/entities/container"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/henrygd/beszel/internal/records"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestAverageSystemStatsSlice_Empty(t *testing.T) {
result := records.AverageSystemStatsSlice(nil)
assert.Equal(t, system.Stats{}, result)
result = records.AverageSystemStatsSlice([]system.Stats{})
assert.Equal(t, system.Stats{}, result)
}
func TestAverageSystemStatsSlice_SingleRecord(t *testing.T) {
input := []system.Stats{
{
Cpu: 45.67,
Mem: 16.0,
MemUsed: 8.5,
MemPct: 53.12,
MemBuffCache: 2.0,
Swap: 4.0,
SwapUsed: 1.0,
DiskTotal: 500.0,
DiskUsed: 250.0,
DiskPct: 50.0,
DiskReadPs: 100.5,
DiskWritePs: 200.75,
NetworkSent: 10.5,
NetworkRecv: 20.25,
LoadAvg: [3]float64{1.5, 2.0, 3.5},
Bandwidth: [2]uint64{1000, 2000},
DiskIO: [2]uint64{500, 600},
Battery: [2]uint8{80, 1},
},
}
result := records.AverageSystemStatsSlice(input)
assert.Equal(t, 45.67, result.Cpu)
assert.Equal(t, 16.0, result.Mem)
assert.Equal(t, 8.5, result.MemUsed)
assert.Equal(t, 53.12, result.MemPct)
assert.Equal(t, 2.0, result.MemBuffCache)
assert.Equal(t, 4.0, result.Swap)
assert.Equal(t, 1.0, result.SwapUsed)
assert.Equal(t, 500.0, result.DiskTotal)
assert.Equal(t, 250.0, result.DiskUsed)
assert.Equal(t, 50.0, result.DiskPct)
assert.Equal(t, 100.5, result.DiskReadPs)
assert.Equal(t, 200.75, result.DiskWritePs)
assert.Equal(t, 10.5, result.NetworkSent)
assert.Equal(t, 20.25, result.NetworkRecv)
assert.Equal(t, [3]float64{1.5, 2.0, 3.5}, result.LoadAvg)
assert.Equal(t, [2]uint64{1000, 2000}, result.Bandwidth)
assert.Equal(t, [2]uint64{500, 600}, result.DiskIO)
assert.Equal(t, uint8(80), result.Battery[0])
assert.Equal(t, uint8(1), result.Battery[1])
}
func TestAverageSystemStatsSlice_BasicAveraging(t *testing.T) {
input := []system.Stats{
{
Cpu: 20.0,
Mem: 16.0,
MemUsed: 6.0,
MemPct: 37.5,
MemBuffCache: 1.0,
MemZfsArc: 0.5,
Swap: 4.0,
SwapUsed: 1.0,
DiskTotal: 500.0,
DiskUsed: 200.0,
DiskPct: 40.0,
DiskReadPs: 100.0,
DiskWritePs: 200.0,
NetworkSent: 10.0,
NetworkRecv: 20.0,
LoadAvg: [3]float64{1.0, 2.0, 3.0},
Bandwidth: [2]uint64{1000, 2000},
DiskIO: [2]uint64{400, 600},
Battery: [2]uint8{80, 1},
},
{
Cpu: 40.0,
Mem: 16.0,
MemUsed: 10.0,
MemPct: 62.5,
MemBuffCache: 3.0,
MemZfsArc: 1.5,
Swap: 4.0,
SwapUsed: 3.0,
DiskTotal: 500.0,
DiskUsed: 300.0,
DiskPct: 60.0,
DiskReadPs: 200.0,
DiskWritePs: 400.0,
NetworkSent: 30.0,
NetworkRecv: 40.0,
LoadAvg: [3]float64{3.0, 4.0, 5.0},
Bandwidth: [2]uint64{3000, 4000},
DiskIO: [2]uint64{600, 800},
Battery: [2]uint8{60, 1},
},
}
result := records.AverageSystemStatsSlice(input)
assert.Equal(t, 30.0, result.Cpu)
assert.Equal(t, 16.0, result.Mem)
assert.Equal(t, 8.0, result.MemUsed)
assert.Equal(t, 50.0, result.MemPct)
assert.Equal(t, 2.0, result.MemBuffCache)
assert.Equal(t, 1.0, result.MemZfsArc)
assert.Equal(t, 4.0, result.Swap)
assert.Equal(t, 2.0, result.SwapUsed)
assert.Equal(t, 500.0, result.DiskTotal)
assert.Equal(t, 250.0, result.DiskUsed)
assert.Equal(t, 50.0, result.DiskPct)
assert.Equal(t, 150.0, result.DiskReadPs)
assert.Equal(t, 300.0, result.DiskWritePs)
assert.Equal(t, 20.0, result.NetworkSent)
assert.Equal(t, 30.0, result.NetworkRecv)
assert.Equal(t, [3]float64{2.0, 3.0, 4.0}, result.LoadAvg)
assert.Equal(t, [2]uint64{2000, 3000}, result.Bandwidth)
assert.Equal(t, [2]uint64{500, 700}, result.DiskIO)
assert.Equal(t, uint8(70), result.Battery[0])
assert.Equal(t, uint8(1), result.Battery[1])
}
func TestAverageSystemStatsSlice_PeakValues(t *testing.T) {
input := []system.Stats{
{
Cpu: 20.0,
MaxCpu: 25.0,
MemUsed: 6.0,
MaxMem: 7.0,
NetworkSent: 10.0,
MaxNetworkSent: 15.0,
NetworkRecv: 20.0,
MaxNetworkRecv: 25.0,
DiskReadPs: 100.0,
MaxDiskReadPs: 120.0,
DiskWritePs: 200.0,
MaxDiskWritePs: 220.0,
Bandwidth: [2]uint64{1000, 2000},
MaxBandwidth: [2]uint64{1500, 2500},
DiskIO: [2]uint64{400, 600},
MaxDiskIO: [2]uint64{500, 700},
DiskIoStats: [6]float64{10.0, 20.0, 30.0, 5.0, 8.0, 12.0},
MaxDiskIoStats: [6]float64{15.0, 25.0, 35.0, 6.0, 9.0, 14.0},
},
{
Cpu: 40.0,
MaxCpu: 50.0,
MemUsed: 10.0,
MaxMem: 12.0,
NetworkSent: 30.0,
MaxNetworkSent: 35.0,
NetworkRecv: 40.0,
MaxNetworkRecv: 45.0,
DiskReadPs: 200.0,
MaxDiskReadPs: 210.0,
DiskWritePs: 400.0,
MaxDiskWritePs: 410.0,
Bandwidth: [2]uint64{3000, 4000},
MaxBandwidth: [2]uint64{3500, 4500},
DiskIO: [2]uint64{600, 800},
MaxDiskIO: [2]uint64{650, 850},
DiskIoStats: [6]float64{50.0, 60.0, 70.0, 15.0, 18.0, 22.0},
MaxDiskIoStats: [6]float64{55.0, 65.0, 75.0, 16.0, 19.0, 23.0},
},
}
result := records.AverageSystemStatsSlice(input)
assert.Equal(t, 50.0, result.MaxCpu)
assert.Equal(t, 12.0, result.MaxMem)
assert.Equal(t, 35.0, result.MaxNetworkSent)
assert.Equal(t, 45.0, result.MaxNetworkRecv)
assert.Equal(t, 210.0, result.MaxDiskReadPs)
assert.Equal(t, 410.0, result.MaxDiskWritePs)
assert.Equal(t, [2]uint64{3500, 4500}, result.MaxBandwidth)
assert.Equal(t, [2]uint64{650, 850}, result.MaxDiskIO)
assert.Equal(t, [6]float64{30.0, 40.0, 50.0, 10.0, 13.0, 17.0}, result.DiskIoStats)
assert.Equal(t, [6]float64{55.0, 65.0, 75.0, 16.0, 19.0, 23.0}, result.MaxDiskIoStats)
}
func TestAverageSystemStatsSlice_DiskIoStats(t *testing.T) {
input := []system.Stats{
{
Cpu: 10.0,
DiskIoStats: [6]float64{10.0, 20.0, 30.0, 5.0, 8.0, 12.0},
MaxDiskIoStats: [6]float64{12.0, 22.0, 32.0, 6.0, 9.0, 13.0},
},
{
Cpu: 20.0,
DiskIoStats: [6]float64{30.0, 40.0, 50.0, 15.0, 18.0, 22.0},
MaxDiskIoStats: [6]float64{28.0, 38.0, 48.0, 14.0, 17.0, 21.0},
},
{
Cpu: 30.0,
DiskIoStats: [6]float64{20.0, 30.0, 40.0, 10.0, 12.0, 16.0},
MaxDiskIoStats: [6]float64{25.0, 35.0, 45.0, 11.0, 13.0, 17.0},
},
}
result := records.AverageSystemStatsSlice(input)
// Average: (10+30+20)/3=20, (20+40+30)/3=30, (30+50+40)/3=40, (5+15+10)/3=10, (8+18+12)/3≈12.67, (12+22+16)/3≈16.67
assert.Equal(t, 20.0, result.DiskIoStats[0])
assert.Equal(t, 30.0, result.DiskIoStats[1])
assert.Equal(t, 40.0, result.DiskIoStats[2])
assert.Equal(t, 10.0, result.DiskIoStats[3])
assert.Equal(t, 12.67, result.DiskIoStats[4])
assert.Equal(t, 16.67, result.DiskIoStats[5])
// Max: current DiskIoStats[0] wins for record 2 (30 > MaxDiskIoStats 28)
assert.Equal(t, 30.0, result.MaxDiskIoStats[0])
assert.Equal(t, 40.0, result.MaxDiskIoStats[1])
assert.Equal(t, 50.0, result.MaxDiskIoStats[2])
assert.Equal(t, 15.0, result.MaxDiskIoStats[3])
assert.Equal(t, 18.0, result.MaxDiskIoStats[4])
assert.Equal(t, 22.0, result.MaxDiskIoStats[5])
}
// Tests that current DiskIoStats values are considered when computing MaxDiskIoStats.
func TestAverageSystemStatsSlice_DiskIoStatsPeakFromCurrentValues(t *testing.T) {
input := []system.Stats{
{Cpu: 10.0, DiskIoStats: [6]float64{95.0, 90.0, 85.0, 50.0, 60.0, 80.0}, MaxDiskIoStats: [6]float64{80.0, 80.0, 80.0, 40.0, 50.0, 70.0}},
{Cpu: 20.0, DiskIoStats: [6]float64{10.0, 10.0, 10.0, 5.0, 6.0, 8.0}, MaxDiskIoStats: [6]float64{20.0, 20.0, 20.0, 10.0, 12.0, 16.0}},
}
result := records.AverageSystemStatsSlice(input)
// Current value from first record (95, 90, 85, 50, 60, 80) beats MaxDiskIoStats in both records
assert.Equal(t, 95.0, result.MaxDiskIoStats[0])
assert.Equal(t, 90.0, result.MaxDiskIoStats[1])
assert.Equal(t, 85.0, result.MaxDiskIoStats[2])
assert.Equal(t, 50.0, result.MaxDiskIoStats[3])
assert.Equal(t, 60.0, result.MaxDiskIoStats[4])
assert.Equal(t, 80.0, result.MaxDiskIoStats[5])
}
// Tests that current values are considered when computing peaks
// (i.e., current cpu > MaxCpu should still win).
func TestAverageSystemStatsSlice_PeakFromCurrentValues(t *testing.T) {
input := []system.Stats{
{Cpu: 95.0, MaxCpu: 80.0, MemUsed: 15.0, MaxMem: 10.0},
{Cpu: 10.0, MaxCpu: 20.0, MemUsed: 5.0, MaxMem: 8.0},
}
result := records.AverageSystemStatsSlice(input)
assert.Equal(t, 95.0, result.MaxCpu)
assert.Equal(t, 15.0, result.MaxMem)
}
// Tests that records without temperature data are excluded from the temperature average.
func TestAverageSystemStatsSlice_Temperatures(t *testing.T) {
input := []system.Stats{
{
Cpu: 10.0,
Temperatures: map[string]float64{"cpu": 60.0, "gpu": 70.0},
},
{
Cpu: 20.0,
Temperatures: map[string]float64{"cpu": 80.0, "gpu": 90.0},
},
{
// No temperatures - should not affect temp averaging
Cpu: 30.0,
},
}
result := records.AverageSystemStatsSlice(input)
require.NotNil(t, result.Temperatures)
// Average over 2 records that had temps, not 3
assert.Equal(t, 70.0, result.Temperatures["cpu"])
assert.Equal(t, 80.0, result.Temperatures["gpu"])
}
func TestAverageSystemStatsSlice_NetworkInterfaces(t *testing.T) {
input := []system.Stats{
{
Cpu: 10.0,
NetworkInterfaces: map[string][4]uint64{
"eth0": {100, 200, 150, 250},
"eth1": {50, 60, 70, 80},
},
},
{
Cpu: 20.0,
NetworkInterfaces: map[string][4]uint64{
"eth0": {200, 400, 300, 500},
"eth1": {150, 160, 170, 180},
},
},
}
result := records.AverageSystemStatsSlice(input)
require.NotNil(t, result.NetworkInterfaces)
// [0] and [1] are averaged, [2] and [3] are max
assert.Equal(t, [4]uint64{150, 300, 300, 500}, result.NetworkInterfaces["eth0"])
assert.Equal(t, [4]uint64{100, 110, 170, 180}, result.NetworkInterfaces["eth1"])
}
func TestAverageSystemStatsSlice_ExtraFs(t *testing.T) {
input := []system.Stats{
{
Cpu: 10.0,
ExtraFs: map[string]*system.FsStats{
"/data": {
DiskTotal: 1000.0,
DiskUsed: 400.0,
DiskReadPs: 50.0,
DiskWritePs: 100.0,
MaxDiskReadPS: 60.0,
MaxDiskWritePS: 110.0,
DiskReadBytes: 5000,
DiskWriteBytes: 10000,
MaxDiskReadBytes: 6000,
MaxDiskWriteBytes: 11000,
DiskIoStats: [6]float64{10.0, 20.0, 30.0, 5.0, 8.0, 12.0},
MaxDiskIoStats: [6]float64{12.0, 22.0, 32.0, 6.0, 9.0, 13.0},
},
},
},
{
Cpu: 20.0,
ExtraFs: map[string]*system.FsStats{
"/data": {
DiskTotal: 1000.0,
DiskUsed: 600.0,
DiskReadPs: 150.0,
DiskWritePs: 200.0,
MaxDiskReadPS: 160.0,
MaxDiskWritePS: 210.0,
DiskReadBytes: 15000,
DiskWriteBytes: 20000,
MaxDiskReadBytes: 16000,
MaxDiskWriteBytes: 21000,
DiskIoStats: [6]float64{50.0, 60.0, 70.0, 15.0, 18.0, 22.0},
MaxDiskIoStats: [6]float64{55.0, 65.0, 75.0, 16.0, 19.0, 23.0},
},
},
},
}
result := records.AverageSystemStatsSlice(input)
require.NotNil(t, result.ExtraFs)
require.NotNil(t, result.ExtraFs["/data"])
fs := result.ExtraFs["/data"]
assert.Equal(t, 1000.0, fs.DiskTotal)
assert.Equal(t, 500.0, fs.DiskUsed)
assert.Equal(t, 100.0, fs.DiskReadPs)
assert.Equal(t, 150.0, fs.DiskWritePs)
assert.Equal(t, 160.0, fs.MaxDiskReadPS)
assert.Equal(t, 210.0, fs.MaxDiskWritePS)
assert.Equal(t, uint64(10000), fs.DiskReadBytes)
assert.Equal(t, uint64(15000), fs.DiskWriteBytes)
assert.Equal(t, uint64(16000), fs.MaxDiskReadBytes)
assert.Equal(t, uint64(21000), fs.MaxDiskWriteBytes)
assert.Equal(t, [6]float64{30.0, 40.0, 50.0, 10.0, 13.0, 17.0}, fs.DiskIoStats)
assert.Equal(t, [6]float64{55.0, 65.0, 75.0, 16.0, 19.0, 23.0}, fs.MaxDiskIoStats)
}
// Tests that ExtraFs DiskIoStats peak considers current values, not just previous peaks.
func TestAverageSystemStatsSlice_ExtraFsDiskIoStatsPeakFromCurrentValues(t *testing.T) {
input := []system.Stats{
{
Cpu: 10.0,
ExtraFs: map[string]*system.FsStats{
"/data": {
DiskIoStats: [6]float64{95.0, 90.0, 85.0, 50.0, 60.0, 80.0}, // exceeds MaxDiskIoStats
MaxDiskIoStats: [6]float64{80.0, 80.0, 80.0, 40.0, 50.0, 70.0},
},
},
},
{
Cpu: 20.0,
ExtraFs: map[string]*system.FsStats{
"/data": {
DiskIoStats: [6]float64{10.0, 10.0, 10.0, 5.0, 6.0, 8.0},
MaxDiskIoStats: [6]float64{20.0, 20.0, 20.0, 10.0, 12.0, 16.0},
},
},
},
}
result := records.AverageSystemStatsSlice(input)
fs := result.ExtraFs["/data"]
assert.Equal(t, 95.0, fs.MaxDiskIoStats[0])
assert.Equal(t, 90.0, fs.MaxDiskIoStats[1])
assert.Equal(t, 85.0, fs.MaxDiskIoStats[2])
assert.Equal(t, 50.0, fs.MaxDiskIoStats[3])
assert.Equal(t, 60.0, fs.MaxDiskIoStats[4])
assert.Equal(t, 80.0, fs.MaxDiskIoStats[5])
}
// Tests that extra FS peak values consider current values, not just previous peaks.
func TestAverageSystemStatsSlice_ExtraFsPeaksFromCurrentValues(t *testing.T) {
input := []system.Stats{
{
Cpu: 10.0,
ExtraFs: map[string]*system.FsStats{
"/data": {
DiskReadPs: 500.0, // exceeds MaxDiskReadPS
MaxDiskReadPS: 100.0,
DiskReadBytes: 50000,
MaxDiskReadBytes: 10000,
},
},
},
{
Cpu: 20.0,
ExtraFs: map[string]*system.FsStats{
"/data": {
DiskReadPs: 50.0,
MaxDiskReadPS: 200.0,
DiskReadBytes: 5000,
MaxDiskReadBytes: 20000,
},
},
},
}
result := records.AverageSystemStatsSlice(input)
fs := result.ExtraFs["/data"]
assert.Equal(t, 500.0, fs.MaxDiskReadPS)
assert.Equal(t, uint64(50000), fs.MaxDiskReadBytes)
}
func TestAverageSystemStatsSlice_GPUData(t *testing.T) {
input := []system.Stats{
{
Cpu: 10.0,
GPUData: map[string]system.GPUData{
"gpu0": {
Name: "RTX 4090",
Temperature: 60.0,
MemoryUsed: 4.0,
MemoryTotal: 24.0,
Usage: 30.0,
Power: 200.0,
Count: 1.0,
Engines: map[string]float64{
"3D": 50.0,
"Video": 10.0,
},
},
},
},
{
Cpu: 20.0,
GPUData: map[string]system.GPUData{
"gpu0": {
Name: "RTX 4090",
Temperature: 80.0,
MemoryUsed: 8.0,
MemoryTotal: 24.0,
Usage: 70.0,
Power: 300.0,
Count: 1.0,
Engines: map[string]float64{
"3D": 90.0,
"Video": 30.0,
},
},
},
},
}
result := records.AverageSystemStatsSlice(input)
require.NotNil(t, result.GPUData)
gpu := result.GPUData["gpu0"]
assert.Equal(t, "RTX 4090", gpu.Name)
assert.Equal(t, 70.0, gpu.Temperature)
assert.Equal(t, 6.0, gpu.MemoryUsed)
assert.Equal(t, 24.0, gpu.MemoryTotal)
assert.Equal(t, 50.0, gpu.Usage)
assert.Equal(t, 250.0, gpu.Power)
assert.Equal(t, 1.0, gpu.Count)
require.NotNil(t, gpu.Engines)
assert.Equal(t, 70.0, gpu.Engines["3D"])
assert.Equal(t, 20.0, gpu.Engines["Video"])
}
func TestAverageSystemStatsSlice_MultipleGPUs(t *testing.T) {
input := []system.Stats{
{
Cpu: 10.0,
GPUData: map[string]system.GPUData{
"gpu0": {Name: "GPU A", Usage: 20.0, Temperature: 50.0},
"gpu1": {Name: "GPU B", Usage: 60.0, Temperature: 70.0},
},
},
{
Cpu: 20.0,
GPUData: map[string]system.GPUData{
"gpu0": {Name: "GPU A", Usage: 40.0, Temperature: 60.0},
"gpu1": {Name: "GPU B", Usage: 80.0, Temperature: 80.0},
},
},
}
result := records.AverageSystemStatsSlice(input)
require.NotNil(t, result.GPUData)
assert.Equal(t, 30.0, result.GPUData["gpu0"].Usage)
assert.Equal(t, 55.0, result.GPUData["gpu0"].Temperature)
assert.Equal(t, 70.0, result.GPUData["gpu1"].Usage)
assert.Equal(t, 75.0, result.GPUData["gpu1"].Temperature)
}
func TestAverageSystemStatsSlice_CpuCoresUsage(t *testing.T) {
input := []system.Stats{
{Cpu: 10.0, CpuCoresUsage: system.Uint8Slice{10, 20, 30, 40}},
{Cpu: 20.0, CpuCoresUsage: system.Uint8Slice{30, 40, 50, 60}},
}
result := records.AverageSystemStatsSlice(input)
require.NotNil(t, result.CpuCoresUsage)
assert.Equal(t, system.Uint8Slice{20, 30, 40, 50}, result.CpuCoresUsage)
}
// Tests that per-core usage rounds correctly (e.g., 15.5 -> 16 via math.Round).
func TestAverageSystemStatsSlice_CpuCoresUsageRounding(t *testing.T) {
input := []system.Stats{
{Cpu: 10.0, CpuCoresUsage: system.Uint8Slice{11}},
{Cpu: 20.0, CpuCoresUsage: system.Uint8Slice{20}},
}
result := records.AverageSystemStatsSlice(input)
require.NotNil(t, result.CpuCoresUsage)
// (11+20)/2 = 15.5, rounds to 16
assert.Equal(t, uint8(16), result.CpuCoresUsage[0])
}
func TestAverageSystemStatsSlice_CpuBreakdown(t *testing.T) {
input := []system.Stats{
{Cpu: 10.0, CpuBreakdown: []float64{5.0, 3.0, 1.0, 0.5, 90.5}},
{Cpu: 20.0, CpuBreakdown: []float64{15.0, 7.0, 3.0, 1.5, 73.5}},
}
result := records.AverageSystemStatsSlice(input)
require.NotNil(t, result.CpuBreakdown)
assert.Equal(t, []float64{10.0, 5.0, 2.0, 1.0, 82.0}, result.CpuBreakdown)
}
// Tests that Battery[1] (charge state) uses the last record's value.
func TestAverageSystemStatsSlice_BatteryLastChargeState(t *testing.T) {
input := []system.Stats{
{Cpu: 10.0, Battery: [2]uint8{100, 1}}, // charging
{Cpu: 20.0, Battery: [2]uint8{90, 0}}, // not charging
}
result := records.AverageSystemStatsSlice(input)
assert.Equal(t, uint8(95), result.Battery[0])
assert.Equal(t, uint8(0), result.Battery[1]) // last record's charge state
}
func TestAverageSystemStatsSlice_ThreeRecordsRounding(t *testing.T) {
input := []system.Stats{
{Cpu: 10.0, Mem: 8.0},
{Cpu: 20.0, Mem: 8.0},
{Cpu: 30.0, Mem: 8.0},
}
result := records.AverageSystemStatsSlice(input)
assert.Equal(t, 20.0, result.Cpu)
assert.Equal(t, 8.0, result.Mem)
}
// Tests records where some have optional fields and others don't.
func TestAverageSystemStatsSlice_MixedOptionalFields(t *testing.T) {
input := []system.Stats{
{
Cpu: 10.0,
CpuCoresUsage: system.Uint8Slice{50, 60},
CpuBreakdown: []float64{5.0, 3.0, 1.0, 0.5, 90.5},
GPUData: map[string]system.GPUData{
"gpu0": {Name: "GPU", Usage: 40.0},
},
},
{
Cpu: 20.0,
// No CpuCoresUsage, CpuBreakdown, or GPUData
},
}
result := records.AverageSystemStatsSlice(input)
assert.Equal(t, 15.0, result.Cpu)
// CpuCoresUsage: only 1 record had it, so sum/2
require.NotNil(t, result.CpuCoresUsage)
assert.Equal(t, uint8(25), result.CpuCoresUsage[0])
assert.Equal(t, uint8(30), result.CpuCoresUsage[1])
// CpuBreakdown: only 1 record had it, so sum/2
require.NotNil(t, result.CpuBreakdown)
assert.Equal(t, 2.5, result.CpuBreakdown[0])
// GPUData: only 1 record had it, so sum/2
require.NotNil(t, result.GPUData)
assert.Equal(t, 20.0, result.GPUData["gpu0"].Usage)
}
// Tests with 10 records matching the common real-world case (10 x 1m -> 1 x 10m).
func TestAverageSystemStatsSlice_TenRecords(t *testing.T) {
input := make([]system.Stats, 10)
for i := range input {
input[i] = system.Stats{
Cpu: float64(i * 10), // 0, 10, 20, ..., 90
Mem: 16.0,
MemUsed: float64(4 + i), // 4, 5, 6, ..., 13
MemPct: float64(25 + i), // 25, 26, ..., 34
DiskTotal: 500.0,
DiskUsed: 250.0,
DiskPct: 50.0,
NetworkSent: float64(i),
NetworkRecv: float64(i * 2),
Bandwidth: [2]uint64{uint64(i * 1000), uint64(i * 2000)},
LoadAvg: [3]float64{float64(i), float64(i) * 0.5, float64(i) * 0.25},
}
}
result := records.AverageSystemStatsSlice(input)
assert.Equal(t, 45.0, result.Cpu) // avg of 0..90
assert.Equal(t, 16.0, result.Mem) // constant
assert.Equal(t, 8.5, result.MemUsed) // avg of 4..13
assert.Equal(t, 29.5, result.MemPct) // avg of 25..34
assert.Equal(t, 500.0, result.DiskTotal)
assert.Equal(t, 250.0, result.DiskUsed)
assert.Equal(t, 50.0, result.DiskPct)
assert.Equal(t, 4.5, result.NetworkSent)
assert.Equal(t, 9.0, result.NetworkRecv)
assert.Equal(t, [2]uint64{4500, 9000}, result.Bandwidth)
}
// --- Container Stats Tests ---
func TestAverageContainerStatsSlice_Empty(t *testing.T) {
result := records.AverageContainerStatsSlice(nil)
assert.Equal(t, []container.Stats{}, result)
result = records.AverageContainerStatsSlice([][]container.Stats{})
assert.Equal(t, []container.Stats{}, result)
}
func TestAverageContainerStatsSlice_SingleRecord(t *testing.T) {
input := [][]container.Stats{
{
{Name: "nginx", Cpu: 5.0, Mem: 128.0, Bandwidth: [2]uint64{1000, 2000}},
},
}
result := records.AverageContainerStatsSlice(input)
require.Len(t, result, 1)
assert.Equal(t, "nginx", result[0].Name)
assert.Equal(t, 5.0, result[0].Cpu)
assert.Equal(t, 128.0, result[0].Mem)
assert.Equal(t, [2]uint64{1000, 2000}, result[0].Bandwidth)
}
func TestAverageContainerStatsSlice_BasicAveraging(t *testing.T) {
input := [][]container.Stats{
{
{Name: "nginx", Cpu: 10.0, Mem: 100.0, Bandwidth: [2]uint64{1000, 2000}},
{Name: "redis", Cpu: 5.0, Mem: 64.0, Bandwidth: [2]uint64{500, 1000}},
},
{
{Name: "nginx", Cpu: 20.0, Mem: 200.0, Bandwidth: [2]uint64{3000, 4000}},
{Name: "redis", Cpu: 15.0, Mem: 128.0, Bandwidth: [2]uint64{1500, 2000}},
},
}
result := records.AverageContainerStatsSlice(input)
sort.Slice(result, func(i, j int) bool { return result[i].Name < result[j].Name })
require.Len(t, result, 2)
assert.Equal(t, "nginx", result[0].Name)
assert.Equal(t, 15.0, result[0].Cpu)
assert.Equal(t, 150.0, result[0].Mem)
assert.Equal(t, [2]uint64{2000, 3000}, result[0].Bandwidth)
assert.Equal(t, "redis", result[1].Name)
assert.Equal(t, 10.0, result[1].Cpu)
assert.Equal(t, 96.0, result[1].Mem)
assert.Equal(t, [2]uint64{1000, 1500}, result[1].Bandwidth)
}
// Tests containers that appear in some records but not all.
func TestAverageContainerStatsSlice_ContainerAppearsInSomeRecords(t *testing.T) {
input := [][]container.Stats{
{
{Name: "nginx", Cpu: 10.0, Mem: 100.0},
{Name: "redis", Cpu: 5.0, Mem: 64.0},
},
{
{Name: "nginx", Cpu: 20.0, Mem: 200.0},
// redis not present
},
}
result := records.AverageContainerStatsSlice(input)
sort.Slice(result, func(i, j int) bool { return result[i].Name < result[j].Name })
require.Len(t, result, 2)
assert.Equal(t, "nginx", result[0].Name)
assert.Equal(t, 15.0, result[0].Cpu)
assert.Equal(t, 150.0, result[0].Mem)
// redis: sum / count where count = total records (2), not records containing redis
assert.Equal(t, "redis", result[1].Name)
assert.Equal(t, 2.5, result[1].Cpu)
assert.Equal(t, 32.0, result[1].Mem)
}
// Tests backward compatibility with deprecated NetworkSent/NetworkRecv (MB) when Bandwidth is zero.
func TestAverageContainerStatsSlice_DeprecatedNetworkFields(t *testing.T) {
input := [][]container.Stats{
{
{Name: "nginx", Cpu: 10.0, Mem: 100.0, NetworkSent: 1.0, NetworkRecv: 2.0}, // 1 MB, 2 MB
},
{
{Name: "nginx", Cpu: 20.0, Mem: 200.0, NetworkSent: 3.0, NetworkRecv: 4.0}, // 3 MB, 4 MB
},
}
result := records.AverageContainerStatsSlice(input)
require.Len(t, result, 1)
assert.Equal(t, "nginx", result[0].Name)
// avg sent = (1*1048576 + 3*1048576) / 2 = 2*1048576
assert.Equal(t, uint64(2*1048576), result[0].Bandwidth[0])
// avg recv = (2*1048576 + 4*1048576) / 2 = 3*1048576
assert.Equal(t, uint64(3*1048576), result[0].Bandwidth[1])
}
// Tests that when Bandwidth is set, deprecated NetworkSent/NetworkRecv are ignored.
func TestAverageContainerStatsSlice_MixedBandwidthAndDeprecated(t *testing.T) {
input := [][]container.Stats{
{
{Name: "nginx", Cpu: 10.0, Mem: 100.0, Bandwidth: [2]uint64{5000, 6000}, NetworkSent: 99.0, NetworkRecv: 99.0},
},
{
{Name: "nginx", Cpu: 20.0, Mem: 200.0, Bandwidth: [2]uint64{7000, 8000}},
},
}
result := records.AverageContainerStatsSlice(input)
require.Len(t, result, 1)
assert.Equal(t, uint64(6000), result[0].Bandwidth[0])
assert.Equal(t, uint64(7000), result[0].Bandwidth[1])
}
func TestAverageContainerStatsSlice_ThreeRecords(t *testing.T) {
input := [][]container.Stats{
{{Name: "app", Cpu: 1.0, Mem: 100.0}},
{{Name: "app", Cpu: 2.0, Mem: 200.0}},
{{Name: "app", Cpu: 3.0, Mem: 300.0}},
}
result := records.AverageContainerStatsSlice(input)
require.Len(t, result, 1)
assert.Equal(t, 2.0, result[0].Cpu)
assert.Equal(t, 200.0, result[0].Mem)
}
func TestAverageContainerStatsSlice_ManyContainers(t *testing.T) {
input := [][]container.Stats{
{
{Name: "a", Cpu: 10.0, Mem: 100.0},
{Name: "b", Cpu: 20.0, Mem: 200.0},
{Name: "c", Cpu: 30.0, Mem: 300.0},
{Name: "d", Cpu: 40.0, Mem: 400.0},
},
{
{Name: "a", Cpu: 20.0, Mem: 200.0},
{Name: "b", Cpu: 30.0, Mem: 300.0},
{Name: "c", Cpu: 40.0, Mem: 400.0},
{Name: "d", Cpu: 50.0, Mem: 500.0},
},
}
result := records.AverageContainerStatsSlice(input)
sort.Slice(result, func(i, j int) bool { return result[i].Name < result[j].Name })
require.Len(t, result, 4)
assert.Equal(t, 15.0, result[0].Cpu)
assert.Equal(t, 25.0, result[1].Cpu)
assert.Equal(t, 35.0, result[2].Cpu)
assert.Equal(t, 45.0, result[3].Cpu)
}

View File

@@ -0,0 +1,138 @@
package records
import (
"fmt"
"log/slog"
"strings"
"time"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
)
// Delete old records
func (rm *RecordManager) DeleteOldRecords() {
rm.app.RunInTransaction(func(txApp core.App) error {
err := deleteOldSystemStats(txApp)
if err != nil {
slog.Error("Error deleting old system stats", "err", err)
}
err = deleteOldContainerRecords(txApp)
if err != nil {
slog.Error("Error deleting old container records", "err", err)
}
err = deleteOldSystemdServiceRecords(txApp)
if err != nil {
slog.Error("Error deleting old systemd service records", "err", err)
}
err = deleteOldAlertsHistory(txApp, 200, 250)
if err != nil {
slog.Error("Error deleting old alerts history", "err", err)
}
err = deleteOldQuietHours(txApp)
if err != nil {
slog.Error("Error deleting old quiet hours", "err", err)
}
return nil
})
}
// Delete old alerts history records
func deleteOldAlertsHistory(app core.App, countToKeep, countBeforeDeletion int) error {
db := app.DB()
var users []struct {
Id string `db:"user"`
}
err := db.NewQuery("SELECT user, COUNT(*) as count FROM alerts_history GROUP BY user HAVING count > {:countBeforeDeletion}").Bind(dbx.Params{"countBeforeDeletion": countBeforeDeletion}).All(&users)
if err != nil {
return err
}
for _, user := range users {
_, err = db.NewQuery("DELETE FROM alerts_history WHERE user = {:user} AND id NOT IN (SELECT id FROM alerts_history WHERE user = {:user} ORDER BY created DESC LIMIT {:countToKeep})").Bind(dbx.Params{"user": user.Id, "countToKeep": countToKeep}).Execute()
if err != nil {
return err
}
}
return nil
}
// Deletes system_stats records older than what is displayed in the UI
func deleteOldSystemStats(app core.App) error {
// Collections to process
collections := [2]string{"system_stats", "container_stats"}
// Record types and their retention periods
type RecordDeletionData struct {
recordType string
retention time.Duration
}
recordData := []RecordDeletionData{
{recordType: "1m", retention: time.Hour}, // 1 hour
{recordType: "10m", retention: 12 * time.Hour}, // 12 hours
{recordType: "20m", retention: 24 * time.Hour}, // 1 day
{recordType: "120m", retention: 7 * 24 * time.Hour}, // 7 days
{recordType: "480m", retention: 30 * 24 * time.Hour}, // 30 days
}
now := time.Now().UTC()
for _, collection := range collections {
// Build the WHERE clause
var conditionParts []string
var params dbx.Params = make(map[string]any)
for i := range recordData {
rd := recordData[i]
// Create parameterized condition for this record type
dateParam := fmt.Sprintf("date%d", i)
conditionParts = append(conditionParts, fmt.Sprintf("(type = '%s' AND created < {:%s})", rd.recordType, dateParam))
params[dateParam] = now.Add(-rd.retention)
}
// Combine conditions with OR
conditionStr := strings.Join(conditionParts, " OR ")
// Construct and execute the full raw query
rawQuery := fmt.Sprintf("DELETE FROM %s WHERE %s", collection, conditionStr)
if _, err := app.DB().NewQuery(rawQuery).Bind(params).Execute(); err != nil {
return fmt.Errorf("failed to delete from %s: %v", collection, err)
}
}
return nil
}
// Deletes systemd service records that haven't been updated in the last 20 minutes
func deleteOldSystemdServiceRecords(app core.App) error {
now := time.Now().UTC()
twentyMinutesAgo := now.Add(-20 * time.Minute)
// Delete systemd service records where updated < twentyMinutesAgo
_, err := app.DB().NewQuery("DELETE FROM systemd_services WHERE updated < {:updated}").Bind(dbx.Params{"updated": twentyMinutesAgo.UnixMilli()}).Execute()
if err != nil {
return fmt.Errorf("failed to delete old systemd service records: %v", err)
}
return nil
}
// Deletes container records that haven't been updated in the last 10 minutes
func deleteOldContainerRecords(app core.App) error {
now := time.Now().UTC()
tenMinutesAgo := now.Add(-10 * time.Minute)
// Delete container records where updated < tenMinutesAgo
_, err := app.DB().NewQuery("DELETE FROM containers WHERE updated < {:updated}").Bind(dbx.Params{"updated": tenMinutesAgo.UnixMilli()}).Execute()
if err != nil {
return fmt.Errorf("failed to delete old container records: %v", err)
}
return nil
}
// Deletes old quiet hours records where end date has passed
func deleteOldQuietHours(app core.App) error {
now := time.Now().UTC()
_, err := app.DB().NewQuery("DELETE FROM quiet_hours WHERE type = 'one-time' AND end < {:now}").Bind(dbx.Params{"now": now}).Execute()
if err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,428 @@
//go:build testing
package records_test
import (
"fmt"
"testing"
"time"
"github.com/henrygd/beszel/internal/records"
"github.com/henrygd/beszel/internal/tests"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestDeleteOldRecords tests the main DeleteOldRecords function
func TestDeleteOldRecords(t *testing.T) {
hub, err := tests.NewTestHub(t.TempDir())
require.NoError(t, err)
defer hub.Cleanup()
rm := records.NewRecordManager(hub)
// Create test user for alerts history
user, err := tests.CreateUser(hub, "test@example.com", "testtesttest")
require.NoError(t, err)
// Create test system
system, err := tests.CreateRecord(hub, "systems", map[string]any{
"name": "test-system",
"host": "localhost",
"port": "45876",
"status": "up",
"users": []string{user.Id},
})
require.NoError(t, err)
now := time.Now()
// Create old system_stats records that should be deleted
var record *core.Record
record, err = tests.CreateRecord(hub, "system_stats", map[string]any{
"system": system.Id,
"type": "1m",
"stats": `{"cpu": 50.0, "mem": 1024}`,
})
require.NoError(t, err)
// created is autodate field, so we need to set it manually
record.SetRaw("created", now.UTC().Add(-2*time.Hour).Format(types.DefaultDateLayout))
err = hub.SaveNoValidate(record)
require.NoError(t, err)
require.NotNil(t, record)
require.InDelta(t, record.GetDateTime("created").Time().UTC().Unix(), now.UTC().Add(-2*time.Hour).Unix(), 1)
require.Equal(t, record.Get("system"), system.Id)
require.Equal(t, record.Get("type"), "1m")
// Create recent system_stats record that should be kept
_, err = tests.CreateRecord(hub, "system_stats", map[string]any{
"system": system.Id,
"type": "1m",
"stats": `{"cpu": 30.0, "mem": 512}`,
"created": now.Add(-30 * time.Minute), // 30 minutes old, should be kept
})
require.NoError(t, err)
// Create many alerts history records to trigger deletion
for i := range 260 { // More than countBeforeDeletion (250)
_, err = tests.CreateRecord(hub, "alerts_history", map[string]any{
"user": user.Id,
"name": "CPU",
"value": i + 1,
"system": system.Id,
"created": now.Add(-time.Duration(i) * time.Minute),
})
require.NoError(t, err)
}
// Count records before deletion
systemStatsCountBefore, err := hub.CountRecords("system_stats")
require.NoError(t, err)
alertsCountBefore, err := hub.CountRecords("alerts_history")
require.NoError(t, err)
// Run deletion
rm.DeleteOldRecords()
// Count records after deletion
systemStatsCountAfter, err := hub.CountRecords("system_stats")
require.NoError(t, err)
alertsCountAfter, err := hub.CountRecords("alerts_history")
require.NoError(t, err)
// Verify old system stats were deleted
assert.Less(t, systemStatsCountAfter, systemStatsCountBefore, "Old system stats should be deleted")
// Verify alerts history was trimmed
assert.Less(t, alertsCountAfter, alertsCountBefore, "Excessive alerts history should be deleted")
assert.Equal(t, alertsCountAfter, int64(200), "Alerts count should be equal to countToKeep (200)")
}
// TestDeleteOldSystemStats tests the deleteOldSystemStats function
func TestDeleteOldSystemStats(t *testing.T) {
hub, err := tests.NewTestHub(t.TempDir())
require.NoError(t, err)
defer hub.Cleanup()
// Create test system
user, err := tests.CreateUser(hub, "test@example.com", "testtesttest")
require.NoError(t, err)
system, err := tests.CreateRecord(hub, "systems", map[string]any{
"name": "test-system",
"host": "localhost",
"port": "45876",
"status": "up",
"users": []string{user.Id},
})
require.NoError(t, err)
now := time.Now().UTC()
// Test data for different record types and their retention periods
testCases := []struct {
recordType string
retention time.Duration
shouldBeKept bool
ageFromNow time.Duration
description string
}{
{"1m", time.Hour, true, 30 * time.Minute, "1m record within 1 hour should be kept"},
{"1m", time.Hour, false, 2 * time.Hour, "1m record older than 1 hour should be deleted"},
{"10m", 12 * time.Hour, true, 6 * time.Hour, "10m record within 12 hours should be kept"},
{"10m", 12 * time.Hour, false, 24 * time.Hour, "10m record older than 12 hours should be deleted"},
{"20m", 24 * time.Hour, true, 12 * time.Hour, "20m record within 24 hours should be kept"},
{"20m", 24 * time.Hour, false, 48 * time.Hour, "20m record older than 24 hours should be deleted"},
{"120m", 7 * 24 * time.Hour, true, 3 * 24 * time.Hour, "120m record within 7 days should be kept"},
{"120m", 7 * 24 * time.Hour, false, 10 * 24 * time.Hour, "120m record older than 7 days should be deleted"},
{"480m", 30 * 24 * time.Hour, true, 15 * 24 * time.Hour, "480m record within 30 days should be kept"},
{"480m", 30 * 24 * time.Hour, false, 45 * 24 * time.Hour, "480m record older than 30 days should be deleted"},
}
// Create test records for both system_stats and container_stats
collections := []string{"system_stats", "container_stats"}
recordIds := make(map[string][]string)
for _, collection := range collections {
recordIds[collection] = make([]string, 0)
for i, tc := range testCases {
recordTime := now.Add(-tc.ageFromNow)
var stats string
if collection == "system_stats" {
stats = fmt.Sprintf(`{"cpu": %d.0, "mem": %d}`, i*10, i*100)
} else {
stats = fmt.Sprintf(`[{"name": "container%d", "cpu": %d.0, "mem": %d}]`, i, i*5, i*50)
}
record, err := tests.CreateRecord(hub, collection, map[string]any{
"system": system.Id,
"type": tc.recordType,
"stats": stats,
})
require.NoError(t, err)
record.SetRaw("created", recordTime.Format(types.DefaultDateLayout))
err = hub.SaveNoValidate(record)
require.NoError(t, err)
recordIds[collection] = append(recordIds[collection], record.Id)
}
}
// Run deletion
err = records.DeleteOldSystemStats(hub)
require.NoError(t, err)
// Verify results
for _, collection := range collections {
for i, tc := range testCases {
recordId := recordIds[collection][i]
// Try to find the record
_, err := hub.FindRecordById(collection, recordId)
if tc.shouldBeKept {
assert.NoError(t, err, "Record should exist: %s", tc.description)
} else {
assert.Error(t, err, "Record should be deleted: %s", tc.description)
}
}
}
}
// TestDeleteOldAlertsHistory tests the deleteOldAlertsHistory function
func TestDeleteOldAlertsHistory(t *testing.T) {
hub, err := tests.NewTestHub(t.TempDir())
require.NoError(t, err)
defer hub.Cleanup()
// Create test users
user1, err := tests.CreateUser(hub, "user1@example.com", "testtesttest")
require.NoError(t, err)
user2, err := tests.CreateUser(hub, "user2@example.com", "testtesttest")
require.NoError(t, err)
system, err := tests.CreateRecord(hub, "systems", map[string]any{
"name": "test-system",
"host": "localhost",
"port": "45876",
"status": "up",
"users": []string{user1.Id, user2.Id},
})
require.NoError(t, err)
now := time.Now().UTC()
testCases := []struct {
name string
user *core.Record
alertCount int
countToKeep int
countBeforeDeletion int
expectedAfterDeletion int
description string
}{
{
name: "User with few alerts (below threshold)",
user: user1,
alertCount: 100,
countToKeep: 50,
countBeforeDeletion: 150,
expectedAfterDeletion: 100, // No deletion because below threshold
description: "User with alerts below countBeforeDeletion should not have any deleted",
},
{
name: "User with many alerts (above threshold)",
user: user2,
alertCount: 300,
countToKeep: 100,
countBeforeDeletion: 200,
expectedAfterDeletion: 100, // Should be trimmed to countToKeep
description: "User with alerts above countBeforeDeletion should be trimmed to countToKeep",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Create alerts for this user
for i := 0; i < tc.alertCount; i++ {
_, err := tests.CreateRecord(hub, "alerts_history", map[string]any{
"user": tc.user.Id,
"name": "CPU",
"value": i + 1,
"system": system.Id,
"created": now.Add(-time.Duration(i) * time.Minute),
})
require.NoError(t, err)
}
// Count before deletion
countBefore, err := hub.CountRecords("alerts_history",
dbx.NewExp("user = {:user}", dbx.Params{"user": tc.user.Id}))
require.NoError(t, err)
assert.Equal(t, int64(tc.alertCount), countBefore, "Initial count should match")
// Run deletion
err = records.DeleteOldAlertsHistory(hub, tc.countToKeep, tc.countBeforeDeletion)
require.NoError(t, err)
// Count after deletion
countAfter, err := hub.CountRecords("alerts_history",
dbx.NewExp("user = {:user}", dbx.Params{"user": tc.user.Id}))
require.NoError(t, err)
assert.Equal(t, int64(tc.expectedAfterDeletion), countAfter, tc.description)
// If deletion occurred, verify the most recent records were kept
if tc.expectedAfterDeletion < tc.alertCount {
records, err := hub.FindRecordsByFilter("alerts_history",
"user = {:user}",
"-created", // Order by created DESC
tc.countToKeep,
0,
map[string]any{"user": tc.user.Id})
require.NoError(t, err)
assert.Len(t, records, tc.expectedAfterDeletion, "Should have exactly countToKeep records")
// Verify records are in descending order by created time
for i := 1; i < len(records); i++ {
prev := records[i-1].GetDateTime("created").Time()
curr := records[i].GetDateTime("created").Time()
assert.True(t, prev.After(curr) || prev.Equal(curr),
"Records should be ordered by created time (newest first)")
}
}
})
}
}
// TestDeleteOldAlertsHistoryEdgeCases tests edge cases for alerts history deletion
func TestDeleteOldAlertsHistoryEdgeCases(t *testing.T) {
hub, err := tests.NewTestHub(t.TempDir())
require.NoError(t, err)
defer hub.Cleanup()
t.Run("No users with excessive alerts", func(t *testing.T) {
// Create user with few alerts
user, err := tests.CreateUser(hub, "few@example.com", "testtesttest")
require.NoError(t, err)
system, err := tests.CreateRecord(hub, "systems", map[string]any{
"name": "test-system",
"host": "localhost",
"port": "45876",
"status": "up",
"users": []string{user.Id},
})
// Create only 5 alerts (well below threshold)
for i := range 5 {
_, err := tests.CreateRecord(hub, "alerts_history", map[string]any{
"user": user.Id,
"name": "CPU",
"value": i + 1,
"system": system.Id,
})
require.NoError(t, err)
}
// Should not error and should not delete anything
err = records.DeleteOldAlertsHistory(hub, 10, 20)
require.NoError(t, err)
count, err := hub.CountRecords("alerts_history")
require.NoError(t, err)
assert.Equal(t, int64(5), count, "All alerts should remain")
})
t.Run("Empty alerts_history table", func(t *testing.T) {
// Clear any existing alerts
_, err := hub.DB().NewQuery("DELETE FROM alerts_history").Execute()
require.NoError(t, err)
// Should not error with empty table
err = records.DeleteOldAlertsHistory(hub, 10, 20)
require.NoError(t, err)
})
}
// TestDeleteOldSystemdServiceRecords tests systemd service cleanup via DeleteOldRecords
func TestDeleteOldSystemdServiceRecords(t *testing.T) {
hub, err := tests.NewTestHub(t.TempDir())
require.NoError(t, err)
defer hub.Cleanup()
rm := records.NewRecordManager(hub)
// Create test user and system
user, err := tests.CreateUser(hub, "test@example.com", "testtesttest")
require.NoError(t, err)
system, err := tests.CreateRecord(hub, "systems", map[string]any{
"name": "test-system",
"host": "localhost",
"port": "45876",
"status": "up",
"users": []string{user.Id},
})
require.NoError(t, err)
now := time.Now().UTC()
// Create old systemd service records that should be deleted (older than 20 minutes)
oldRecord, err := tests.CreateRecord(hub, "systemd_services", map[string]any{
"system": system.Id,
"name": "nginx.service",
"state": 0, // Active
"sub": 1, // Running
"cpu": 5.0,
"cpuPeak": 10.0,
"memory": 1024000,
"memPeak": 2048000,
})
require.NoError(t, err)
// Set updated time to 25 minutes ago (should be deleted)
oldRecord.SetRaw("updated", now.Add(-25*time.Minute).UnixMilli())
err = hub.SaveNoValidate(oldRecord)
require.NoError(t, err)
// Create recent systemd service record that should be kept (within 20 minutes)
recentRecord, err := tests.CreateRecord(hub, "systemd_services", map[string]any{
"system": system.Id,
"name": "apache.service",
"state": 1, // Inactive
"sub": 0, // Dead
"cpu": 2.0,
"cpuPeak": 3.0,
"memory": 512000,
"memPeak": 1024000,
})
require.NoError(t, err)
// Set updated time to 10 minutes ago (should be kept)
recentRecord.SetRaw("updated", now.Add(-10*time.Minute).UnixMilli())
err = hub.SaveNoValidate(recentRecord)
require.NoError(t, err)
// Count records before deletion
countBefore, err := hub.CountRecords("systemd_services")
require.NoError(t, err)
assert.Equal(t, int64(2), countBefore, "Should have 2 systemd service records initially")
// Run deletion via RecordManager
rm.DeleteOldRecords()
// Count records after deletion
countAfter, err := hub.CountRecords("systemd_services")
require.NoError(t, err)
assert.Equal(t, int64(1), countAfter, "Should have 1 systemd service record after deletion")
// Verify the correct record was kept
remainingRecords, err := hub.FindRecordsByFilter("systemd_services", "", "", 10, 0, nil)
require.NoError(t, err)
assert.Len(t, remainingRecords, 1, "Should have exactly 1 record remaining")
assert.Equal(t, "apache.service", remainingRecords[0].Get("name"), "The recent record should be kept")
}

View File

@@ -3,430 +3,15 @@
package records_test
import (
"fmt"
"testing"
"time"
"github.com/henrygd/beszel/internal/records"
"github.com/henrygd/beszel/internal/tests"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestDeleteOldRecords tests the main DeleteOldRecords function
func TestDeleteOldRecords(t *testing.T) {
hub, err := tests.NewTestHub(t.TempDir())
require.NoError(t, err)
defer hub.Cleanup()
rm := records.NewRecordManager(hub)
// Create test user for alerts history
user, err := tests.CreateUser(hub, "test@example.com", "testtesttest")
require.NoError(t, err)
// Create test system
system, err := tests.CreateRecord(hub, "systems", map[string]any{
"name": "test-system",
"host": "localhost",
"port": "45876",
"status": "up",
"users": []string{user.Id},
})
require.NoError(t, err)
now := time.Now()
// Create old system_stats records that should be deleted
var record *core.Record
record, err = tests.CreateRecord(hub, "system_stats", map[string]any{
"system": system.Id,
"type": "1m",
"stats": `{"cpu": 50.0, "mem": 1024}`,
})
require.NoError(t, err)
// created is autodate field, so we need to set it manually
record.SetRaw("created", now.UTC().Add(-2*time.Hour).Format(types.DefaultDateLayout))
err = hub.SaveNoValidate(record)
require.NoError(t, err)
require.NotNil(t, record)
require.InDelta(t, record.GetDateTime("created").Time().UTC().Unix(), now.UTC().Add(-2*time.Hour).Unix(), 1)
require.Equal(t, record.Get("system"), system.Id)
require.Equal(t, record.Get("type"), "1m")
// Create recent system_stats record that should be kept
_, err = tests.CreateRecord(hub, "system_stats", map[string]any{
"system": system.Id,
"type": "1m",
"stats": `{"cpu": 30.0, "mem": 512}`,
"created": now.Add(-30 * time.Minute), // 30 minutes old, should be kept
})
require.NoError(t, err)
// Create many alerts history records to trigger deletion
for i := range 260 { // More than countBeforeDeletion (250)
_, err = tests.CreateRecord(hub, "alerts_history", map[string]any{
"user": user.Id,
"name": "CPU",
"value": i + 1,
"system": system.Id,
"created": now.Add(-time.Duration(i) * time.Minute),
})
require.NoError(t, err)
}
// Count records before deletion
systemStatsCountBefore, err := hub.CountRecords("system_stats")
require.NoError(t, err)
alertsCountBefore, err := hub.CountRecords("alerts_history")
require.NoError(t, err)
// Run deletion
rm.DeleteOldRecords()
// Count records after deletion
systemStatsCountAfter, err := hub.CountRecords("system_stats")
require.NoError(t, err)
alertsCountAfter, err := hub.CountRecords("alerts_history")
require.NoError(t, err)
// Verify old system stats were deleted
assert.Less(t, systemStatsCountAfter, systemStatsCountBefore, "Old system stats should be deleted")
// Verify alerts history was trimmed
assert.Less(t, alertsCountAfter, alertsCountBefore, "Excessive alerts history should be deleted")
assert.Equal(t, alertsCountAfter, int64(200), "Alerts count should be equal to countToKeep (200)")
}
// TestDeleteOldSystemStats tests the deleteOldSystemStats function
func TestDeleteOldSystemStats(t *testing.T) {
hub, err := tests.NewTestHub(t.TempDir())
require.NoError(t, err)
defer hub.Cleanup()
// Create test system
user, err := tests.CreateUser(hub, "test@example.com", "testtesttest")
require.NoError(t, err)
system, err := tests.CreateRecord(hub, "systems", map[string]any{
"name": "test-system",
"host": "localhost",
"port": "45876",
"status": "up",
"users": []string{user.Id},
})
require.NoError(t, err)
now := time.Now().UTC()
// Test data for different record types and their retention periods
testCases := []struct {
recordType string
retention time.Duration
shouldBeKept bool
ageFromNow time.Duration
description string
}{
{"1m", time.Hour, true, 30 * time.Minute, "1m record within 1 hour should be kept"},
{"1m", time.Hour, false, 2 * time.Hour, "1m record older than 1 hour should be deleted"},
{"10m", 12 * time.Hour, true, 6 * time.Hour, "10m record within 12 hours should be kept"},
{"10m", 12 * time.Hour, false, 24 * time.Hour, "10m record older than 12 hours should be deleted"},
{"20m", 24 * time.Hour, true, 12 * time.Hour, "20m record within 24 hours should be kept"},
{"20m", 24 * time.Hour, false, 48 * time.Hour, "20m record older than 24 hours should be deleted"},
{"120m", 7 * 24 * time.Hour, true, 3 * 24 * time.Hour, "120m record within 7 days should be kept"},
{"120m", 7 * 24 * time.Hour, false, 10 * 24 * time.Hour, "120m record older than 7 days should be deleted"},
{"480m", 30 * 24 * time.Hour, true, 15 * 24 * time.Hour, "480m record within 30 days should be kept"},
{"480m", 30 * 24 * time.Hour, false, 45 * 24 * time.Hour, "480m record older than 30 days should be deleted"},
}
// Create test records for both system_stats and container_stats
collections := []string{"system_stats", "container_stats"}
recordIds := make(map[string][]string)
for _, collection := range collections {
recordIds[collection] = make([]string, 0)
for i, tc := range testCases {
recordTime := now.Add(-tc.ageFromNow)
var stats string
if collection == "system_stats" {
stats = fmt.Sprintf(`{"cpu": %d.0, "mem": %d}`, i*10, i*100)
} else {
stats = fmt.Sprintf(`[{"name": "container%d", "cpu": %d.0, "mem": %d}]`, i, i*5, i*50)
}
record, err := tests.CreateRecord(hub, collection, map[string]any{
"system": system.Id,
"type": tc.recordType,
"stats": stats,
})
require.NoError(t, err)
record.SetRaw("created", recordTime.Format(types.DefaultDateLayout))
err = hub.SaveNoValidate(record)
require.NoError(t, err)
recordIds[collection] = append(recordIds[collection], record.Id)
}
}
// Run deletion
err = records.DeleteOldSystemStats(hub)
require.NoError(t, err)
// Verify results
for _, collection := range collections {
for i, tc := range testCases {
recordId := recordIds[collection][i]
// Try to find the record
_, err := hub.FindRecordById(collection, recordId)
if tc.shouldBeKept {
assert.NoError(t, err, "Record should exist: %s", tc.description)
} else {
assert.Error(t, err, "Record should be deleted: %s", tc.description)
}
}
}
}
// TestDeleteOldAlertsHistory tests the deleteOldAlertsHistory function
func TestDeleteOldAlertsHistory(t *testing.T) {
hub, err := tests.NewTestHub(t.TempDir())
require.NoError(t, err)
defer hub.Cleanup()
// Create test users
user1, err := tests.CreateUser(hub, "user1@example.com", "testtesttest")
require.NoError(t, err)
user2, err := tests.CreateUser(hub, "user2@example.com", "testtesttest")
require.NoError(t, err)
system, err := tests.CreateRecord(hub, "systems", map[string]any{
"name": "test-system",
"host": "localhost",
"port": "45876",
"status": "up",
"users": []string{user1.Id, user2.Id},
})
require.NoError(t, err)
now := time.Now().UTC()
testCases := []struct {
name string
user *core.Record
alertCount int
countToKeep int
countBeforeDeletion int
expectedAfterDeletion int
description string
}{
{
name: "User with few alerts (below threshold)",
user: user1,
alertCount: 100,
countToKeep: 50,
countBeforeDeletion: 150,
expectedAfterDeletion: 100, // No deletion because below threshold
description: "User with alerts below countBeforeDeletion should not have any deleted",
},
{
name: "User with many alerts (above threshold)",
user: user2,
alertCount: 300,
countToKeep: 100,
countBeforeDeletion: 200,
expectedAfterDeletion: 100, // Should be trimmed to countToKeep
description: "User with alerts above countBeforeDeletion should be trimmed to countToKeep",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Create alerts for this user
for i := 0; i < tc.alertCount; i++ {
_, err := tests.CreateRecord(hub, "alerts_history", map[string]any{
"user": tc.user.Id,
"name": "CPU",
"value": i + 1,
"system": system.Id,
"created": now.Add(-time.Duration(i) * time.Minute),
})
require.NoError(t, err)
}
// Count before deletion
countBefore, err := hub.CountRecords("alerts_history",
dbx.NewExp("user = {:user}", dbx.Params{"user": tc.user.Id}))
require.NoError(t, err)
assert.Equal(t, int64(tc.alertCount), countBefore, "Initial count should match")
// Run deletion
err = records.DeleteOldAlertsHistory(hub, tc.countToKeep, tc.countBeforeDeletion)
require.NoError(t, err)
// Count after deletion
countAfter, err := hub.CountRecords("alerts_history",
dbx.NewExp("user = {:user}", dbx.Params{"user": tc.user.Id}))
require.NoError(t, err)
assert.Equal(t, int64(tc.expectedAfterDeletion), countAfter, tc.description)
// If deletion occurred, verify the most recent records were kept
if tc.expectedAfterDeletion < tc.alertCount {
records, err := hub.FindRecordsByFilter("alerts_history",
"user = {:user}",
"-created", // Order by created DESC
tc.countToKeep,
0,
map[string]any{"user": tc.user.Id})
require.NoError(t, err)
assert.Len(t, records, tc.expectedAfterDeletion, "Should have exactly countToKeep records")
// Verify records are in descending order by created time
for i := 1; i < len(records); i++ {
prev := records[i-1].GetDateTime("created").Time()
curr := records[i].GetDateTime("created").Time()
assert.True(t, prev.After(curr) || prev.Equal(curr),
"Records should be ordered by created time (newest first)")
}
}
})
}
}
// TestDeleteOldAlertsHistoryEdgeCases tests edge cases for alerts history deletion
func TestDeleteOldAlertsHistoryEdgeCases(t *testing.T) {
hub, err := tests.NewTestHub(t.TempDir())
require.NoError(t, err)
defer hub.Cleanup()
t.Run("No users with excessive alerts", func(t *testing.T) {
// Create user with few alerts
user, err := tests.CreateUser(hub, "few@example.com", "testtesttest")
require.NoError(t, err)
system, err := tests.CreateRecord(hub, "systems", map[string]any{
"name": "test-system",
"host": "localhost",
"port": "45876",
"status": "up",
"users": []string{user.Id},
})
// Create only 5 alerts (well below threshold)
for i := range 5 {
_, err := tests.CreateRecord(hub, "alerts_history", map[string]any{
"user": user.Id,
"name": "CPU",
"value": i + 1,
"system": system.Id,
})
require.NoError(t, err)
}
// Should not error and should not delete anything
err = records.DeleteOldAlertsHistory(hub, 10, 20)
require.NoError(t, err)
count, err := hub.CountRecords("alerts_history")
require.NoError(t, err)
assert.Equal(t, int64(5), count, "All alerts should remain")
})
t.Run("Empty alerts_history table", func(t *testing.T) {
// Clear any existing alerts
_, err := hub.DB().NewQuery("DELETE FROM alerts_history").Execute()
require.NoError(t, err)
// Should not error with empty table
err = records.DeleteOldAlertsHistory(hub, 10, 20)
require.NoError(t, err)
})
}
// TestDeleteOldSystemdServiceRecords tests systemd service cleanup via DeleteOldRecords
func TestDeleteOldSystemdServiceRecords(t *testing.T) {
hub, err := tests.NewTestHub(t.TempDir())
require.NoError(t, err)
defer hub.Cleanup()
rm := records.NewRecordManager(hub)
// Create test user and system
user, err := tests.CreateUser(hub, "test@example.com", "testtesttest")
require.NoError(t, err)
system, err := tests.CreateRecord(hub, "systems", map[string]any{
"name": "test-system",
"host": "localhost",
"port": "45876",
"status": "up",
"users": []string{user.Id},
})
require.NoError(t, err)
now := time.Now().UTC()
// Create old systemd service records that should be deleted (older than 20 minutes)
oldRecord, err := tests.CreateRecord(hub, "systemd_services", map[string]any{
"system": system.Id,
"name": "nginx.service",
"state": 0, // Active
"sub": 1, // Running
"cpu": 5.0,
"cpuPeak": 10.0,
"memory": 1024000,
"memPeak": 2048000,
})
require.NoError(t, err)
// Set updated time to 25 minutes ago (should be deleted)
oldRecord.SetRaw("updated", now.Add(-25*time.Minute).UnixMilli())
err = hub.SaveNoValidate(oldRecord)
require.NoError(t, err)
// Create recent systemd service record that should be kept (within 20 minutes)
recentRecord, err := tests.CreateRecord(hub, "systemd_services", map[string]any{
"system": system.Id,
"name": "apache.service",
"state": 1, // Inactive
"sub": 0, // Dead
"cpu": 2.0,
"cpuPeak": 3.0,
"memory": 512000,
"memPeak": 1024000,
})
require.NoError(t, err)
// Set updated time to 10 minutes ago (should be kept)
recentRecord.SetRaw("updated", now.Add(-10*time.Minute).UnixMilli())
err = hub.SaveNoValidate(recentRecord)
require.NoError(t, err)
// Count records before deletion
countBefore, err := hub.CountRecords("systemd_services")
require.NoError(t, err)
assert.Equal(t, int64(2), countBefore, "Should have 2 systemd service records initially")
// Run deletion via RecordManager
rm.DeleteOldRecords()
// Count records after deletion
countAfter, err := hub.CountRecords("systemd_services")
require.NoError(t, err)
assert.Equal(t, int64(1), countAfter, "Should have 1 systemd service record after deletion")
// Verify the correct record was kept
remainingRecords, err := hub.FindRecordsByFilter("systemd_services", "", "", 10, 0, nil)
require.NoError(t, err)
assert.Len(t, remainingRecords, 1, "Should have exactly 1 record remaining")
assert.Equal(t, "apache.service", remainingRecords[0].Get("name"), "The recent record should be kept")
}
// TestRecordManagerCreation tests RecordManager creation
func TestRecordManagerCreation(t *testing.T) {
hub, err := tests.NewTestHub(t.TempDir())

View File

@@ -22,11 +22,7 @@
})();
</script>
<script>
globalThis.BESZEL = {
BASE_PATH: "%BASE_URL%",
HUB_VERSION: "{{V}}",
HUB_URL: "{{HUB_URL}}"
}
globalThis.BESZEL = "{info}"
</script>
</head>
<body>

View File

@@ -104,7 +104,7 @@ export default function LineChartDefault({
isAnimationActive={false}
// stackId={dataPoint.stackId}
order={dataPoint.order || i}
// activeDot={dataPoint.activeDot ?? true}
activeDot={dataPoint.activeDot ?? true}
/>
)
})

View File

@@ -12,12 +12,12 @@ import { Label } from "@/components/ui/label"
import { pb } from "@/lib/api"
import { $authenticated } from "@/lib/stores"
import { cn } from "@/lib/utils"
import { $router, Link, prependBasePath } from "../router"
import { $router, Link, basePath, prependBasePath } from "../router"
import { toast } from "../ui/use-toast"
import { OtpInputForm } from "./otp-forms"
const honeypot = v.literal("")
const emailSchema = v.pipe(v.string(), v.email(t`Invalid email address.`))
const emailSchema = v.pipe(v.string(), v.rfcEmail(t`Invalid email address.`))
const passwordSchema = v.pipe(
v.string(),
v.minLength(8, t`Password must be at least 8 characters.`),
@@ -37,8 +37,7 @@ const RegisterSchema = v.looseObject({
passwordConfirm: passwordSchema,
})
export const showLoginFaliedToast = (description?: string) => {
description ||= t`Please check your credentials and try again`
export const showLoginFaliedToast = (description = t`Please check your credentials and try again`) => {
toast({
title: t`Login attempt failed`,
description,
@@ -130,10 +129,6 @@ export function UserAuthForm({
[isFirstRun]
)
if (!authMethods) {
return null
}
const authProviders = authMethods.oauth2.providers ?? []
const oauthEnabled = authMethods.oauth2.enabled && authProviders.length > 0
const passwordEnabled = authMethods.password.enabled
@@ -142,6 +137,12 @@ export function UserAuthForm({
function loginWithOauth(provider: AuthProviderInfo, forcePopup = false) {
setIsOauthLoading(true)
if (globalThis.BESZEL.OAUTH_DISABLE_POPUP) {
redirectToOauthProvider(provider)
return
}
const oAuthOpts: OAuth2AuthConfig = {
provider: provider.name,
}
@@ -150,10 +151,7 @@ export function UserAuthForm({
const authWindow = window.open()
if (!authWindow) {
setIsOauthLoading(false)
toast({
title: t`Error`,
description: t`Please enable pop-ups for this site`,
})
showLoginFaliedToast(t`Please enable pop-ups for this site`)
return
}
oAuthOpts.urlCallback = (url) => {
@@ -171,16 +169,57 @@ export function UserAuthForm({
})
}
/**
* Redirects the user to the OAuth provider's authentication page in the same window.
* Requires the app's base URL to be registered as a redirect URI with the OAuth provider.
*/
function redirectToOauthProvider(provider: AuthProviderInfo) {
const url = new URL(provider.authURL)
// url.searchParams.set("redirect_uri", `${window.location.origin}${basePath}`)
sessionStorage.setItem("provider", JSON.stringify(provider))
window.location.href = url.toString()
}
useEffect(() => {
// auto login if password disabled and only one auth provider
if (!passwordEnabled && authProviders.length === 1 && !sessionStorage.getItem("lo")) {
// Add a small timeout to ensure browser is ready to handle popups
setTimeout(() => {
loginWithOauth(authProviders[0], true)
}, 300)
// handle redirect-based OAuth callback if we have a code
const params = new URLSearchParams(window.location.search)
const code = params.get("code")
if (code) {
const state = params.get("state")
const provider: AuthProviderInfo = JSON.parse(sessionStorage.getItem("provider") ?? "{}")
if (!state || provider.state !== state) {
showLoginFaliedToast()
} else {
setIsOauthLoading(true)
window.history.replaceState({}, "", window.location.pathname)
pb.collection("users")
.authWithOAuth2Code(provider.name, code, provider.codeVerifier, `${window.location.origin}${basePath}`)
.then(() => $authenticated.set(pb.authStore.isValid))
.catch((e: unknown) => showLoginFaliedToast((e as Error).message))
.finally(() => setIsOauthLoading(false))
}
}
// auto login if password disabled and only one auth provider
if (!code && !passwordEnabled && authProviders.length === 1 && !sessionStorage.getItem("lo")) {
// Add a small timeout to ensure browser is ready to handle popups
setTimeout(() => loginWithOauth(authProviders[0], false), 300)
return
}
// refresh auth if not in above states (required for trusted auth header)
pb.collection("users")
.authRefresh()
.then((res) => {
pb.authStore.save(res.token, res.record)
$authenticated.set(!!pb.authStore.isValid)
})
}, [])
if (!authMethods) {
return null
}
if (otpId && mfaId) {
return <OtpInputForm otpId={otpId} mfaId={mfaId} />
}
@@ -248,7 +287,7 @@ export function UserAuthForm({
)}
<div className="sr-only">
{/* honeypot */}
<label htmlFor="website"></label>
<label htmlFor="website">Website</label>
<input
id="website"
type="text"

View File

@@ -1,28 +1,39 @@
import { t } from "@lingui/core/macro"
import { MoonStarIcon, SunIcon } from "lucide-react"
import { MoonStarIcon, SunIcon, SunMoonIcon } from "lucide-react"
import { useTheme } from "@/components/theme-provider"
import { Button } from "@/components/ui/button"
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"
import { Trans } from "@lingui/react/macro"
import { cn } from "@/lib/utils"
const themes = ["light", "dark", "system"] as const
const icons = [SunIcon, MoonStarIcon, SunMoonIcon] as const
export function ModeToggle() {
const { theme, setTheme } = useTheme()
const currentIndex = themes.indexOf(theme)
const Icon = icons[currentIndex]
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={"ghost"}
size="icon"
aria-label={t`Toggle theme`}
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
aria-label={t`Switch theme`}
onClick={() => setTheme(themes[(currentIndex + 1) % themes.length])}
>
<SunIcon className="h-[1.2rem] w-[1.2rem] transition-all -rotate-90 dark:opacity-0 dark:rotate-0" />
<MoonStarIcon className="absolute h-[1.2rem] w-[1.2rem] transition-all opacity-0 -rotate-90 dark:opacity-100 dark:rotate-0" />
<Icon
className={cn(
"animate-in fade-in spin-in-[-30deg] duration-200",
currentIndex === 2 ? "size-[1.35rem]" : "size-[1.2rem]"
)}
/>
</Button>
</TooltipTrigger>
<TooltipContent>
<Trans>Toggle theme</Trans>
<Trans>Switch theme</Trans>
</TooltipContent>
</Tooltip>
)

View File

@@ -15,6 +15,7 @@ import { isAdmin, pb } from "@/lib/api"
import type { UserSettings } from "@/types"
import { saveSettings } from "./layout"
import { QuietHours } from "./quiet-hours"
import type { ClientResponseError } from "pocketbase"
interface ShoutrrrUrlCardProps {
url: string
@@ -23,7 +24,7 @@ interface ShoutrrrUrlCardProps {
}
const NotificationSchema = v.object({
emails: v.array(v.pipe(v.string(), v.email())),
emails: v.array(v.pipe(v.string(), v.rfcEmail())),
webhooks: v.array(v.pipe(v.string(), v.url())),
})
@@ -59,10 +60,10 @@ const SettingsNotificationsPage = ({ userSettings }: { userSettings: UserSetting
try {
const parsedData = v.parse(NotificationSchema, { emails, webhooks })
await saveSettings(parsedData)
} catch (e: any) {
} catch (e: unknown) {
toast({
title: t`Failed to save settings`,
description: e.message,
description: (e as Error).message,
variant: "destructive",
})
}
@@ -136,12 +137,7 @@ const SettingsNotificationsPage = ({ userSettings }: { userSettings: UserSetting
</Trans>
</p>
</div>
<Button
type="button"
variant="outline"
className="h-10 shrink-0"
onClick={addWebhook}
>
<Button type="button" variant="outline" className="h-10 shrink-0" onClick={addWebhook}>
<PlusIcon className="size-4" />
<span className="ms-1">
<Trans>Add URL</Trans>
@@ -180,25 +176,34 @@ const SettingsNotificationsPage = ({ userSettings }: { userSettings: UserSetting
)
}
function showTestNotificationError(msg: string) {
toast({
title: t`Error`,
description: msg ?? t`Failed to send test notification`,
variant: "destructive",
})
}
const ShoutrrrUrlCard = ({ url, onUrlChange, onRemove }: ShoutrrrUrlCardProps) => {
const [isLoading, setIsLoading] = useState(false)
const sendTestNotification = async () => {
setIsLoading(true)
const res = await pb.send("/api/beszel/test-notification", { method: "POST", body: { url } })
if ("err" in res && !res.err) {
toast({
title: t`Test notification sent`,
description: t`Check your notification service`,
})
} else {
toast({
title: t`Error`,
description: res.err ?? t`Failed to send test notification`,
variant: "destructive",
})
try {
const res = await pb.send("/api/beszel/test-notification", { method: "POST", body: { url } })
if ("err" in res && !res.err) {
toast({
title: t`Test notification sent`,
description: t`Check your notification service`,
})
} else {
showTestNotificationError(res.err)
}
} catch (e: unknown) {
showTestNotificationError((e as ClientResponseError).data?.message)
} finally {
setIsLoading(false)
}
setIsLoading(false)
}
return (

View File

@@ -120,7 +120,8 @@ export function TemperatureChart({
label: key,
dataKey: dataKeys[key],
color: colorMap[key],
opacity: strokeOpacity,
strokeOpacity,
activeDot: !filtered,
}
})
}, [sortedKeys, filter, dataKeys, colorMap])
@@ -134,7 +135,7 @@ export function TemperatureChart({
// label: `Test ${++i}`,
// dataKey: () => 0,
// color: "red",
// opacity: 1,
// strokeOpacity: 1,
// })
// }
// }
@@ -202,6 +203,7 @@ export function TemperatureChart({
return `${decimalString(value)} ${unit}`
}}
dataPoints={dataPoints}
filter={filter}
></LineChartDefault>
</ChartCard>
</div>

View File

@@ -110,20 +110,23 @@ export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<Syste
// match filter value against name or translated status
return (row, _, newFilterInput) => {
const { name, status } = row.original
const sys = row.original
if (sys.host.includes(newFilterInput) || sys.info.v?.includes(newFilterInput)) {
return true
}
if (newFilterInput !== filterInput) {
filterInput = newFilterInput
filterInputLower = newFilterInput.toLowerCase()
}
let nameLower = nameCache.get(name)
let nameLower = nameCache.get(sys.name)
if (nameLower === undefined) {
nameLower = name.toLowerCase()
nameCache.set(name, nameLower)
nameLower = sys.name.toLowerCase()
nameCache.set(sys.name, nameLower)
}
if (nameLower.includes(filterInputLower)) {
return true
}
const statusLower = statusTranslations[status as keyof typeof statusTranslations]
const statusLower = statusTranslations[sys.status as keyof typeof statusTranslations]
return statusLower?.includes(filterInputLower) || false
}
})(),

View File

@@ -94,18 +94,6 @@ const Layout = () => {
document.documentElement.dir = direction
}, [direction])
useEffect(() => {
// refresh auth if not authenticated (required for trusted auth header)
if (!authenticated) {
pb.collection("users")
.authRefresh()
.then((res) => {
pb.authStore.save(res.token, res.record)
$authenticated.set(!!pb.authStore.isValid)
})
}
}, [])
return (
<DirectionProvider dir={direction}>
{!authenticated ? (

View File

@@ -7,6 +7,7 @@ declare global {
BASE_PATH: string
HUB_VERSION: string
HUB_URL: string
OAUTH_DISABLE_POPUP: boolean
}
}

View File

@@ -7,7 +7,7 @@
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"baseUrl": ".",
// "baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},