mirror of
https://github.com/henrygd/beszel.git
synced 2026-08-18 00:07:47 +02:00
Compare commits
10 Commits
07466804e7
...
smart-devi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
214180a431 | ||
|
|
d3a1d61955 | ||
|
|
9708e24fd2 | ||
|
|
17e910a246 | ||
|
|
0e65a2373f | ||
|
|
c1c1cd1bcb | ||
|
|
cd9ea51039 | ||
|
|
a71617e058 | ||
|
|
e5507fa106 | ||
|
|
a024c3cfd0 |
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,14 @@ type deviceKey struct {
|
||||
|
||||
var errNoValidSmartData = fmt.Errorf("no valid SMART data found") // Error for missing data
|
||||
|
||||
// isBridgePassthroughType reports whether the device type uses a USB bridge
|
||||
// passthrough driver that may fail transiently (exit 2) due to wakeup data
|
||||
// or other bridge-specific quirks. A single retry is attempted in CollectSmart.
|
||||
func isBridgePassthroughType(deviceType string) bool {
|
||||
dt := strings.ToLower(deviceType)
|
||||
return strings.HasPrefix(dt, "jms56x") || strings.HasPrefix(dt, "jmb39x")
|
||||
}
|
||||
|
||||
// Refresh updates SMART data for all known devices
|
||||
func (sm *SmartManager) Refresh(forceScan bool) error {
|
||||
sm.refreshMutex.Lock()
|
||||
@@ -368,9 +376,15 @@ func (sm *SmartManager) parseSmartOutput(deviceInfo *DeviceInfo, output []byte)
|
||||
Type string
|
||||
Parse func([]byte) (bool, int)
|
||||
}{
|
||||
{Type: "nvme", Parse: sm.parseSmartForNvme},
|
||||
{Type: "sat", Parse: sm.parseSmartForSata},
|
||||
{Type: "scsi", Parse: sm.parseSmartForScsi},
|
||||
{Type: "nvme", Parse: func(output []byte) (bool, int) {
|
||||
return sm.parseSmartForNvme(output, deviceInfo.Type)
|
||||
}},
|
||||
{Type: "sat", Parse: func(output []byte) (bool, int) {
|
||||
return sm.parseSmartForSata(output, deviceInfo.Type)
|
||||
}},
|
||||
{Type: "scsi", Parse: func(output []byte) (bool, int) {
|
||||
return sm.parseSmartForScsi(output, deviceInfo.Type)
|
||||
}},
|
||||
}
|
||||
|
||||
deviceType := normalizeParserType(deviceInfo.parserType)
|
||||
@@ -495,15 +509,22 @@ func (sm *SmartManager) CollectSmart(deviceInfo *DeviceInfo) error {
|
||||
// Check if device is in standby (exit status 2)
|
||||
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok && exitErr.ExitCode() == 2 {
|
||||
if hasExistingData {
|
||||
// Device is in standby and we have cached data, keep using cache
|
||||
return nil
|
||||
}
|
||||
// No cached data, need to collect initial data by bypassing standby
|
||||
ctx2, cancel2 := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel2()
|
||||
// No cached data, retry without -n standby
|
||||
args = sm.smartctlArgs(deviceInfo, false)
|
||||
cmd = exec.CommandContext(ctx2, sm.smartctlPath, args...)
|
||||
cmd = exec.CommandContext(ctx, sm.smartctlPath, args...)
|
||||
output, err = cmd.CombinedOutput()
|
||||
|
||||
// Bridge passthrough drivers (jms56x, jmb39x) can fail siently due
|
||||
// to wakeup data left by the bridge firmware. A second retry succeeds
|
||||
// after the first attempt has cleared the bridge state.
|
||||
if deviceInfo != nil && isBridgePassthroughType(deviceInfo.Type) {
|
||||
if exitErr2, ok2 := errors.AsType[*exec.ExitError](err); ok2 && exitErr2.ExitCode() == 2 {
|
||||
cmd = exec.CommandContext(ctx, sm.smartctlPath, args...)
|
||||
output, err = cmd.CombinedOutput()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hasValidData := sm.parseSmartOutput(deviceInfo, output)
|
||||
@@ -733,6 +754,7 @@ func mergeDeviceLists(existing, scanned, configured []*DeviceInfo) []*DeviceInfo
|
||||
}
|
||||
if existingDev := deviceIndexByName[configuredDevice.Name]; existingDev != nil {
|
||||
applyConfiguredMetadata(existingDev, configuredDevice)
|
||||
delete(deviceIndexByName, configuredDevice.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -836,9 +858,12 @@ func (sm *SmartManager) isVirtualDeviceFromStrings(fields ...string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// parseSmartForSata parses the output of smartctl --all -j for SATA/ATA devices and updates the SmartDataMap
|
||||
// Returns hasValidData and exitStatus
|
||||
func (sm *SmartManager) parseSmartForSata(output []byte) (bool, int) {
|
||||
// parseSmartForSata parses the output of smartctl --all -j for SATA/ATA devices and updates the SmartDataMap.
|
||||
// configuredType is the user-specified device type (e.g., "sat", "jms56x,0") from SMART_DEVICES.
|
||||
// When non-empty, it overrides the disk type reported by smartctl in SmartData.DiskType so that
|
||||
// updateSmartDevices can match entries by the configured composite (name, type) key.
|
||||
// Returns hasValidData and exitStatus.
|
||||
func (sm *SmartManager) parseSmartForSata(output []byte, configuredType string) (bool, int) {
|
||||
var data smart.SmartInfoForSata
|
||||
|
||||
if err := json.Unmarshal(output, &data); err != nil {
|
||||
@@ -877,6 +902,9 @@ func (sm *SmartManager) parseSmartForSata(output []byte) (bool, int) {
|
||||
smartData.SmartStatus = getSmartStatus(smartData.Temperature, data.SmartStatus.Passed)
|
||||
smartData.DiskName = data.Device.Name
|
||||
smartData.DiskType = data.Device.Type
|
||||
if configuredType != "" {
|
||||
smartData.DiskType = normalizeParserType(configuredType)
|
||||
}
|
||||
|
||||
// get values from ata_device_statistics if necessary
|
||||
var ataDeviceStats smart.AtaDeviceStatistics
|
||||
@@ -950,7 +978,7 @@ func findAtaDeviceStatisticsValue(data *smart.SmartInfoForSata, ataDeviceStats *
|
||||
return nil
|
||||
}
|
||||
|
||||
func (sm *SmartManager) parseSmartForScsi(output []byte) (bool, int) {
|
||||
func (sm *SmartManager) parseSmartForScsi(output []byte, configuredType string) (bool, int) {
|
||||
var data smart.SmartInfoForScsi
|
||||
|
||||
if err := json.Unmarshal(output, &data); err != nil {
|
||||
@@ -985,6 +1013,9 @@ func (sm *SmartManager) parseSmartForScsi(output []byte) (bool, int) {
|
||||
smartData.SmartStatus = getSmartStatus(smartData.Temperature, data.SmartStatus.Passed)
|
||||
smartData.DiskName = data.Device.Name
|
||||
smartData.DiskType = data.Device.Type
|
||||
if configuredType != "" {
|
||||
smartData.DiskType = normalizeParserType(configuredType)
|
||||
}
|
||||
|
||||
attributes := make([]*smart.SmartAttribute, 0, 10)
|
||||
attributes = append(attributes, &smart.SmartAttribute{Name: "PowerOnHours", RawValue: data.PowerOnTime.Hours})
|
||||
@@ -1082,9 +1113,12 @@ func (sm *SmartManager) lookupDarwinNvmeCapacity(serial string) uint64 {
|
||||
return sm.darwinNvmeCapacity[serial]
|
||||
}
|
||||
|
||||
// parseSmartForNvme parses the output of smartctl --all -j /dev/nvmeX and updates the SmartDataMap
|
||||
// Returns hasValidData and exitStatus
|
||||
func (sm *SmartManager) parseSmartForNvme(output []byte) (bool, int) {
|
||||
// parseSmartForNvme parses the output of smartctl --all -j /dev/nvmeX and updates the SmartDataMap.
|
||||
// configuredType is the user-specified device type (e.g., "nvme") from SMART_DEVICES.
|
||||
// When non-empty, it overrides the disk type reported by smartctl in SmartData.DiskType so that
|
||||
// updateSmartDevices can match entries by the configured composite (name, type) key.
|
||||
// Returns hasValidData and exitStatus.
|
||||
func (sm *SmartManager) parseSmartForNvme(output []byte, configuredType string) (bool, int) {
|
||||
data := &smart.SmartInfoForNvme{}
|
||||
|
||||
if err := json.Unmarshal(output, &data); err != nil {
|
||||
@@ -1128,6 +1162,9 @@ func (sm *SmartManager) parseSmartForNvme(output []byte) (bool, int) {
|
||||
smartData.SmartStatus = getSmartStatus(smartData.Temperature, data.SmartStatus.Passed)
|
||||
smartData.DiskName = data.Device.Name
|
||||
smartData.DiskType = data.Device.Type
|
||||
if configuredType != "" {
|
||||
smartData.DiskType = normalizeParserType(configuredType)
|
||||
}
|
||||
|
||||
// nvme attributes does not follow the same format as ata attributes,
|
||||
// so we manually map each field to SmartAttributes
|
||||
|
||||
@@ -24,7 +24,7 @@ func TestParseSmartForScsi(t *testing.T) {
|
||||
SmartDataMap: make(map[string]*smart.SmartData),
|
||||
}
|
||||
|
||||
hasData, exitStatus := sm.parseSmartForScsi(data)
|
||||
hasData, exitStatus := sm.parseSmartForScsi(data, "")
|
||||
if !hasData {
|
||||
t.Fatalf("expected SCSI data to parse successfully")
|
||||
}
|
||||
@@ -69,7 +69,7 @@ func TestParseSmartForSata(t *testing.T) {
|
||||
SmartDataMap: make(map[string]*smart.SmartData),
|
||||
}
|
||||
|
||||
hasData, exitStatus := sm.parseSmartForSata(data)
|
||||
hasData, exitStatus := sm.parseSmartForSata(data, "")
|
||||
require.True(t, hasData)
|
||||
assert.Equal(t, 64, exitStatus)
|
||||
|
||||
@@ -88,6 +88,26 @@ func TestParseSmartForSata(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSmartForSataWithConfiguredType(t *testing.T) {
|
||||
fixturePath := filepath.Join("test-data", "smart", "sda.json")
|
||||
data, err := os.ReadFile(fixturePath)
|
||||
require.NoError(t, err)
|
||||
|
||||
sm := &SmartManager{
|
||||
SmartDataMap: make(map[string]*smart.SmartData),
|
||||
}
|
||||
|
||||
hasData, exitStatus := sm.parseSmartForSata(data, "sat+megaraid")
|
||||
require.True(t, hasData)
|
||||
assert.Equal(t, 64, exitStatus)
|
||||
|
||||
deviceData, ok := sm.SmartDataMap["9C40918040082"]
|
||||
require.True(t, ok, "expected smart data entry for serial 9C40918040082")
|
||||
|
||||
assert.Equal(t, "sat+megaraid", deviceData.DiskType,
|
||||
"DiskType should be overridden by configuredType")
|
||||
}
|
||||
|
||||
func TestParseSmartForSataDeviceStatisticsTemperature(t *testing.T) {
|
||||
jsonPayload := []byte(`{
|
||||
"smartctl": {"exit_status": 0},
|
||||
@@ -112,7 +132,7 @@ func TestParseSmartForSataDeviceStatisticsTemperature(t *testing.T) {
|
||||
}`)
|
||||
|
||||
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
|
||||
hasData, exitStatus := sm.parseSmartForSata(jsonPayload)
|
||||
hasData, exitStatus := sm.parseSmartForSata(jsonPayload, "")
|
||||
require.True(t, hasData)
|
||||
assert.Equal(t, 0, exitStatus)
|
||||
|
||||
@@ -147,7 +167,7 @@ func TestParseSmartForSataAtaDeviceStatistics(t *testing.T) {
|
||||
}`)
|
||||
|
||||
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
|
||||
hasData, exitStatus := sm.parseSmartForSata(jsonPayload)
|
||||
hasData, exitStatus := sm.parseSmartForSata(jsonPayload, "")
|
||||
require.True(t, hasData)
|
||||
assert.Equal(t, 0, exitStatus)
|
||||
|
||||
@@ -184,7 +204,7 @@ func TestParseSmartForSataNegativeDeviceStatistics(t *testing.T) {
|
||||
}`)
|
||||
|
||||
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
|
||||
hasData, exitStatus := sm.parseSmartForSata(jsonPayload)
|
||||
hasData, exitStatus := sm.parseSmartForSata(jsonPayload, "")
|
||||
require.True(t, hasData)
|
||||
assert.Equal(t, 0, exitStatus)
|
||||
|
||||
@@ -223,7 +243,7 @@ func TestParseSmartForSataParentheticalRawValue(t *testing.T) {
|
||||
|
||||
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
|
||||
|
||||
hasData, exitStatus := sm.parseSmartForSata(jsonPayload)
|
||||
hasData, exitStatus := sm.parseSmartForSata(jsonPayload, "")
|
||||
require.True(t, hasData)
|
||||
assert.Equal(t, 0, exitStatus)
|
||||
|
||||
@@ -245,7 +265,7 @@ func TestParseSmartForNvme(t *testing.T) {
|
||||
SmartDataMap: make(map[string]*smart.SmartData),
|
||||
}
|
||||
|
||||
hasData, exitStatus := sm.parseSmartForNvme(data)
|
||||
hasData, exitStatus := sm.parseSmartForNvme(data, "")
|
||||
require.True(t, hasData)
|
||||
assert.Equal(t, 0, exitStatus)
|
||||
|
||||
@@ -1225,7 +1245,7 @@ func TestParseSmartForNvmeAppleSSD(t *testing.T) {
|
||||
darwinNvmeProvider: fakeProvider,
|
||||
}
|
||||
|
||||
hasData, _ := sm.parseSmartForNvme(data)
|
||||
hasData, _ := sm.parseSmartForNvme(data, "")
|
||||
require.True(t, hasData)
|
||||
|
||||
deviceData, ok := sm.SmartDataMap["0ba0147940253c15"]
|
||||
@@ -1237,7 +1257,7 @@ func TestParseSmartForNvmeAppleSSD(t *testing.T) {
|
||||
assert.Equal(t, 1, providerCalls, "system_profiler should be called once")
|
||||
|
||||
// Second parse: provider should NOT be called again (cache hit)
|
||||
_, _ = sm.parseSmartForNvme(data)
|
||||
_, _ = sm.parseSmartForNvme(data, "")
|
||||
assert.Equal(t, 1, providerCalls, "system_profiler should not be called again after caching")
|
||||
}
|
||||
|
||||
|
||||
18
go.mod
18
go.mod
@@ -1,6 +1,6 @@
|
||||
module github.com/henrygd/beszel
|
||||
|
||||
go 1.26.1
|
||||
go 1.26.3
|
||||
|
||||
require (
|
||||
github.com/blang/semver v3.5.1+incompatible
|
||||
@@ -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.3
|
||||
github.com/nicholas-fedor/shoutrrr v0.15.1
|
||||
github.com/pocketbase/dbx v1.12.0
|
||||
github.com/pocketbase/pocketbase v0.36.8
|
||||
github.com/shirou/gopsutil/v4 v4.26.3
|
||||
@@ -18,9 +18,10 @@ require (
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/spf13/pflag v1.0.10
|
||||
github.com/stretchr/testify v1.11.1
|
||||
golang.org/x/crypto v0.49.0
|
||||
golang.org/x/crypto v0.52.0
|
||||
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90
|
||||
golang.org/x/sys v0.42.0
|
||||
golang.org/x/net v0.55.0
|
||||
golang.org/x/sys v0.45.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
howett.net/plist v1.0.1
|
||||
)
|
||||
@@ -46,7 +47,7 @@ require (
|
||||
github.com/klauspost/compress v1.18.5 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20260324052639-156f7da3f749 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-isatty v0.0.22 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||
@@ -55,12 +56,11 @@ require (
|
||||
github.com/tklauser/numcpus v0.11.0 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
golang.org/x/image v0.38.0 // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/image v0.41.0 // indirect
|
||||
golang.org/x/oauth2 v0.36.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/term v0.41.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
golang.org/x/term v0.43.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
modernc.org/libc v1.70.0 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
|
||||
57
go.sum
57
go.sum
@@ -1,7 +1,7 @@
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
|
||||
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||
github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
|
||||
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
|
||||
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=
|
||||
@@ -56,8 +56,8 @@ github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArs
|
||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc h1:VBbFa1lDYWEeV5FZKUiYKYT0VxCp9twUmmaq9eb8sXw=
|
||||
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
|
||||
github.com/google/pprof v0.0.0-20260507013755-92041b743c96 h1:YDDnaZ9afWajDboPMt9Vikqca/yWAX7KAxVzb4lJU1M=
|
||||
github.com/google/pprof v0.0.0-20260507013755-92041b743c96/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
@@ -81,16 +81,16 @@ github.com/lxzan/gws v1.9.1 h1:4lbIp4cW0hOLP3ejFHR/uWRy741AURx7oKkNNi2OT9o=
|
||||
github.com/lxzan/gws v1.9.1/go.mod h1:gXHSCPmTGryWJ4icuqy8Yho32E4YIMHH0fkDRYJRbdc=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
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.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=
|
||||
github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg=
|
||||
github.com/nicholas-fedor/shoutrrr v0.15.1 h1:dfgqpaeyr0CwUhqtwWBHS4girmAvFPOoxroHaVH1q1Y=
|
||||
github.com/nicholas-fedor/shoutrrr v0.15.1/go.mod h1:xrdV1ab2W0/xa5kM6WP9mBuqVuJaDWqUwYvYvVuPTjk=
|
||||
github.com/onsi/ginkgo/v2 v2.29.0 h1:rfh+ZFjgJhYWRoIqVf3Uwx/W20yLrcrE2h2GmYVRaag=
|
||||
github.com/onsi/ginkgo/v2 v2.29.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44=
|
||||
github.com/onsi/gomega v1.41.0 h1:OwKp4pXNgVxf6sCplzYo794OFNuoL2q2SBMU5NSWOjA=
|
||||
github.com/onsi/gomega v1.41.0/go.mod h1:M/Uqpu/8qTjtzCLUA2zJHX9Iilrau25x1PdoSRbWh5A=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
@@ -133,18 +133,18 @@ go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA=
|
||||
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE=
|
||||
golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
|
||||
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
||||
golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
|
||||
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
|
||||
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
@@ -153,18 +153,17 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
820
internal/records/records_averaging_test.go
Normal file
820
internal/records/records_averaging_test.go
Normal 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)
|
||||
}
|
||||
138
internal/records/records_deletion.go
Normal file
138
internal/records/records_deletion.go
Normal 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
|
||||
}
|
||||
428
internal/records/records_deletion_test.go
Normal file
428
internal/records/records_deletion_test.go
Normal 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")
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -17,7 +17,7 @@ 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.`),
|
||||
|
||||
@@ -24,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())),
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
# Specialized images available for Nvidia / Intel GPUs
|
||||
#
|
||||
# Docs: https://beszel.dev/guide/agent-installation
|
||||
# Env vars: https://beszel.dev/guide/environment-variables
|
||||
|
||||
services:
|
||||
beszel-agent:
|
||||
image: 'henrygd/beszel-agent' #Or henrygd/beszel-agent-nvidia
|
||||
container_name: 'beszel-agent'
|
||||
image: henrygd/beszel-agent
|
||||
container_name: beszel-agent
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
# Only when using henrygd/beszel-agent-nvidia
|
||||
# runtime: nvidia
|
||||
volumes:
|
||||
- ./beszel_agent_data:/var/lib/beszel-agent
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
# monitor other disks / partitions by mounting a folder in /extra-filesystems
|
||||
# - /mnt/disk/.beszel:/extra-filesystems/sda1:ro
|
||||
# - /mnt/disk1/.beszel:/extra-filesystems/disk1:ro
|
||||
environment:
|
||||
PORT: 45876
|
||||
KEY: 'ssh-ed25519 YOUR_PUBLIC_KEY'
|
||||
# Only when using henrygd/beszel-agent-nvidia
|
||||
# NVIDIA_VISIBLE_DEVICES: all
|
||||
# NVIDIA_DRIVER_CAPABILITIES: compute,video,utility
|
||||
LISTEN: 45876
|
||||
KEY: "<public key>"
|
||||
HUB_URL: "<hub url>"
|
||||
TOKEN: "<token>"
|
||||
# healthcheck:
|
||||
# test: ['CMD', '/agent', 'health']
|
||||
# interval: 120s
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
# Docs: https://beszel.dev/guide/hub-installation
|
||||
# Env vars: https://beszel.dev/guide/environment-variables
|
||||
|
||||
services:
|
||||
beszel:
|
||||
image: 'henrygd/beszel'
|
||||
container_name: 'beszel'
|
||||
image: "henrygd/beszel"
|
||||
container_name: "beszel"
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- '8090:8090'
|
||||
- "8090:8090"
|
||||
volumes:
|
||||
- ./beszel_data:/beszel_data
|
||||
# healthcheck:
|
||||
# test: ['CMD', '/beszel', 'health', '--url', 'http://localhost:8090']
|
||||
# interval: 120s
|
||||
# start_period: 10s
|
||||
# timeout: 5s
|
||||
|
||||
@@ -1,26 +1,38 @@
|
||||
# Docs: https://beszel.dev/guide/getting-started
|
||||
# Env vars: https://beszel.dev/guide/environment-variables
|
||||
|
||||
services:
|
||||
beszel:
|
||||
image: 'henrygd/beszel'
|
||||
container_name: 'beszel'
|
||||
image: henrygd/beszel:latest
|
||||
container_name: beszel
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
APP_URL: http://localhost:8090
|
||||
ports:
|
||||
- '8090:8090'
|
||||
- 8090:8090
|
||||
volumes:
|
||||
- ./beszel_data:/beszel_data
|
||||
extra_hosts:
|
||||
- 'host.docker.internal:host-gateway'
|
||||
- ./beszel_socket:/beszel_socket
|
||||
# healthcheck:
|
||||
# test: ['CMD', '/beszel', 'health', '--url', 'http://localhost:8090']
|
||||
# interval: 120s
|
||||
# start_period: 10s
|
||||
# timeout: 5s
|
||||
|
||||
beszel-agent:
|
||||
image: 'henrygd/beszel-agent' #Add -nvidia for nvidia gpus
|
||||
container_name: 'beszel-agent'
|
||||
image: henrygd/beszel-agent:latest
|
||||
container_name: beszel-agent
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
# runtime: nvidia # when using beszel-agent-nvidia
|
||||
volumes:
|
||||
- ./beszel_agent_data:/var/lib/beszel-agent
|
||||
- ./beszel_socket:/beszel_socket
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
environment:
|
||||
PORT: 45876
|
||||
KEY: '...'
|
||||
# FILESYSTEM: /dev/sda1 # set to the correct filesystem for disk I/O stats
|
||||
# NVIDIA_VISIBLE_DEVICES: all # when using beszel-agent-nvidia
|
||||
# NVIDIA_DRIVER_CAPABILITIES: utility # when using beszel-agent-nvidia
|
||||
LISTEN: /beszel_socket/beszel.sock
|
||||
HUB_URL: http://localhost:8090
|
||||
TOKEN: <token>
|
||||
KEY: "<key>"
|
||||
# healthcheck:
|
||||
# test: ['CMD', '/agent', 'health']
|
||||
# interval: 120s
|
||||
|
||||
Reference in New Issue
Block a user