mirror of
https://github.com/henrygd/beszel.git
synced 2026-09-21 08:57:48 +02:00
feat: add network monitors (ICMP/TCP/HTTP/DNS) (#2266)
Co-authored-by: xiaomiku01 <xiaomiku01@outlook.com> Co-authored-by: henrygd <hank@henrygd.me>
This commit is contained in:
226
internal/records/monitor_averaging_test.go
Normal file
226
internal/records/monitor_averaging_test.go
Normal file
@@ -0,0 +1,226 @@
|
||||
//go:build testing
|
||||
|
||||
package records_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
monitorEntity "github.com/henrygd/beszel/internal/entities/monitor"
|
||||
"github.com/henrygd/beszel/internal/records"
|
||||
"github.com/henrygd/beszel/internal/tests"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAverageMonitorStats(t *testing.T) {
|
||||
hub, err := tests.NewTestHub(t.TempDir())
|
||||
require.NoError(t, err)
|
||||
defer hub.Cleanup()
|
||||
|
||||
collection, err := hub.FindCachedCollectionByNameOrId("network_monitor_stats")
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, collection.Fields.GetByName("res_avg"))
|
||||
assert.Nil(t, collection.Fields.GetByName("loss"))
|
||||
|
||||
rm := records.NewRecordManager(hub)
|
||||
user, err := tests.CreateUser(hub, "monitor-avg@example.com", "testtesttest")
|
||||
require.NoError(t, err)
|
||||
sys, err := tests.CreateRecord(hub, "systems", map[string]any{
|
||||
"name": "monitor-avg-system",
|
||||
"host": "localhost",
|
||||
"port": "45876",
|
||||
"status": "up",
|
||||
"users": []string{user.Id},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
monitor, err := tests.CreateRecord(hub, "network_monitors", map[string]any{
|
||||
"system": sys.Id,
|
||||
"name": "cloudflare",
|
||||
"target": "1.1.1.1",
|
||||
"protocol": "icmp",
|
||||
"interval": 30,
|
||||
"enabled": true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
created := time.Now().UnixMilli()
|
||||
// Unequal probe counts must weight both latency and loss.
|
||||
recordA, err := tests.CreateRecord(hub, "network_monitor_stats", map[string]any{
|
||||
"system": sys.Id,
|
||||
"monitor": monitor.Id,
|
||||
"type": "1m",
|
||||
"created": created,
|
||||
"res_min": 5,
|
||||
"res_max": 20,
|
||||
"total_count": 6, "success_count": 6, "res_sum": 60,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
recordB, err := tests.CreateRecord(hub, "network_monitor_stats", map[string]any{
|
||||
"system": sys.Id,
|
||||
"monitor": monitor.Id,
|
||||
"type": "1m",
|
||||
"created": created,
|
||||
"res_min": 10,
|
||||
"res_max": 60,
|
||||
"total_count": 1, "success_count": 1, "res_sum": 22,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
result, count, err := rm.AverageMonitorStats(hub.DB(), monitor.Id, "1m", created-1)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 2, count)
|
||||
assert.Equal(t, monitorEntity.Stats{ResAvg: 11.71, ResMin: 5, ResMax: 60, TotalCount: 7, SuccessCount: 7, ResponseSum: 82}, result)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name, monitor, recordType string
|
||||
after int64
|
||||
}{
|
||||
{"other monitor", "missing", "1m", created - 1},
|
||||
{"other type", monitor.Id, "10m", created - 1},
|
||||
{"exclusive cutoff", monitor.Id, "1m", created},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
stats, count, err := rm.AverageMonitorStats(hub.DB(), tc.monitor, tc.recordType, tc.after)
|
||||
require.NoError(t, err)
|
||||
assert.Zero(t, count)
|
||||
assert.Equal(t, monitorEntity.Stats{}, stats)
|
||||
})
|
||||
}
|
||||
|
||||
// A failure-only bucket counts toward loss but must not lower latency.
|
||||
recordB.Set("res_min", 0)
|
||||
recordB.Set("res_max", 0)
|
||||
recordB.Set("success_count", 0)
|
||||
recordB.Set("res_sum", 0)
|
||||
require.NoError(t, hub.Save(recordB))
|
||||
result, count, err = rm.AverageMonitorStats(hub.DB(), monitor.Id, "1m", created-1)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 2, count)
|
||||
assert.Equal(t, monitorEntity.Stats{ResAvg: 10, ResMin: 5, ResMax: 20, Loss: 14.29, TotalCount: 7, SuccessCount: 6, ResponseSum: 60}, result)
|
||||
// Sparse monitor records must propagate through every rollup level.
|
||||
rm.CreateLongerRecords()
|
||||
for _, recordType := range []string{"10m", "20m", "120m", "480m"} {
|
||||
rollups, err := hub.FindAllRecords("network_monitor_stats", dbx.HashExp{"monitor": monitor.Id, "type": recordType})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, rollups, 1, recordType)
|
||||
assert.Equal(t, 5.0, rollups[0].GetFloat("res_min"))
|
||||
assert.Equal(t, 20.0, rollups[0].GetFloat("res_max"))
|
||||
assert.Equal(t, 7, rollups[0].GetInt("total_count"))
|
||||
assert.Equal(t, 6, rollups[0].GetInt("success_count"))
|
||||
assert.Equal(t, 60, rollups[0].GetInt("res_sum"))
|
||||
// A sibling with a different number of probes must retain its actual
|
||||
// weight when the next tier combines their underlying counts.
|
||||
_, err = tests.CreateRecord(hub, "network_monitor_stats", map[string]any{
|
||||
"system": sys.Id, "monitor": monitor.Id, "type": recordType, "created": created,
|
||||
"res_min": 100, "res_max": 100,
|
||||
"total_count": 3, "success_count": 1, "res_sum": 100,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
merged, count, err := rm.AverageMonitorStats(hub.DB(), monitor.Id, recordType, created-1)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 2, count)
|
||||
assert.Equal(t, monitorEntity.Stats{
|
||||
ResAvg: 22.86, ResMin: 5, ResMax: 100, Loss: 30,
|
||||
TotalCount: 10, SuccessCount: 7, ResponseSum: 160,
|
||||
}, merged)
|
||||
}
|
||||
// All failures produce zero latency, while a genuine zero-microsecond
|
||||
// success remains a valid minimum (it must not be filtered out as a sentinel).
|
||||
recordA.Set("success_count", 0)
|
||||
recordA.Set("res_sum", 0)
|
||||
require.NoError(t, hub.Save(recordA))
|
||||
result, _, err = rm.AverageMonitorStats(hub.DB(), monitor.Id, "1m", created-1)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, monitorEntity.Stats{TotalCount: 7, Loss: 100}, result)
|
||||
recordA.Set("success_count", 1)
|
||||
recordA.Set("res_min", 0)
|
||||
recordA.Set("res_max", 0)
|
||||
require.NoError(t, hub.Save(recordA))
|
||||
result, _, err = rm.AverageMonitorStats(hub.DB(), monitor.Id, "1m", created-1)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, monitorEntity.Stats{TotalCount: 7, SuccessCount: 1, Loss: 85.71}, result)
|
||||
}
|
||||
|
||||
func TestSparseMonitorRollups(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
interval int
|
||||
samples int
|
||||
disabled bool
|
||||
}{
|
||||
{"five minute interval", 300, 2, false},
|
||||
{"ten minute interval", 600, 1, false},
|
||||
{"fifteen minute interval", 900, 1, false},
|
||||
{"empty window", 900, 0, false},
|
||||
{"disabled with pending history", 300, 2, true},
|
||||
{"disabled empty window", 900, 0, true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
hub, err := tests.NewTestHub(t.TempDir())
|
||||
require.NoError(t, err)
|
||||
defer hub.Cleanup()
|
||||
user, err := tests.CreateUser(hub, "sparse-monitor@example.com", "testtesttest")
|
||||
require.NoError(t, err)
|
||||
sys, err := tests.CreateRecord(hub, "systems", map[string]any{
|
||||
"name": "sparse-monitor-system", "host": "localhost", "port": "45876", "status": "up",
|
||||
"users": []string{user.Id},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
monitor, err := tests.CreateRecord(hub, "network_monitors", map[string]any{
|
||||
"system": sys.Id, "target": "1.1.1.1", "protocol": "icmp",
|
||||
"interval": tc.interval, "enabled": true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
now := time.Now()
|
||||
for i := range tc.samples {
|
||||
_, err := tests.CreateRecord(hub, "network_monitor_stats", map[string]any{
|
||||
"system": sys.Id, "monitor": monitor.Id, "type": "1m",
|
||||
"created": now.Add(-time.Minute - time.Duration(i*tc.interval)*time.Second).UnixMilli(),
|
||||
"res_min": 8, "res_max": 20,
|
||||
"total_count": 4, "success_count": 3, "res_sum": 36,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
// Other collections must still reject fewer than nine minute records.
|
||||
for _, collection := range []string{"system_stats", "container_stats"} {
|
||||
stats := `{"cpu":10}`
|
||||
if collection == "container_stats" {
|
||||
stats = `[{"name":"test","cpu":10}]`
|
||||
}
|
||||
for range 8 {
|
||||
_, err := tests.CreateRecord(hub, collection, map[string]any{
|
||||
"system": sys.Id, "type": "1m", "stats": stats,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
}
|
||||
if tc.disabled {
|
||||
monitor.Set("enabled", false)
|
||||
require.NoError(t, hub.Save(monitor))
|
||||
}
|
||||
records.NewRecordManager(hub).CreateLongerRecords()
|
||||
for _, recordType := range []string{"10m", "20m", "120m", "480m"} {
|
||||
rollups, err := hub.FindAllRecords("network_monitor_stats", dbx.HashExp{"monitor": monitor.Id, "type": recordType})
|
||||
require.NoError(t, err)
|
||||
if tc.samples == 0 {
|
||||
assert.Empty(t, rollups, recordType)
|
||||
} else {
|
||||
require.Len(t, rollups, 1, recordType)
|
||||
assert.Equal(t, 8.0, rollups[0].GetFloat("res_min"))
|
||||
assert.Equal(t, 20.0, rollups[0].GetFloat("res_max"))
|
||||
assert.Equal(t, 4*tc.samples, rollups[0].GetInt("total_count"))
|
||||
assert.Equal(t, 3*tc.samples, rollups[0].GetInt("success_count"))
|
||||
assert.Equal(t, 36*tc.samples, rollups[0].GetInt("res_sum"))
|
||||
}
|
||||
for _, collection := range []string{"system_stats", "container_stats"} {
|
||||
count, err := hub.CountRecords(collection, dbx.HashExp{"system": sys.Id, "type": recordType})
|
||||
require.NoError(t, err)
|
||||
assert.Zero(t, count, "%s %s", collection, recordType)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -3,15 +3,16 @@ package records
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/internal/entities/container"
|
||||
"github.com/henrygd/beszel/internal/entities/monitor"
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
type RecordManager struct {
|
||||
@@ -39,7 +40,7 @@ type StatsRecord struct {
|
||||
|
||||
// Create longer records by averaging shorter records
|
||||
func (rm *RecordManager) CreateLongerRecords() {
|
||||
// start := time.Now()
|
||||
now := time.Now().UTC()
|
||||
longerRecordData := []LongerRecordData{
|
||||
{
|
||||
shorterType: "1m",
|
||||
@@ -69,23 +70,28 @@ func (rm *RecordManager) CreateLongerRecords() {
|
||||
}
|
||||
// wrap the operations in a transaction
|
||||
// Pocketbase cron does not handle errors, log them here.
|
||||
rm.app.RunInTransaction(func(txApp core.App) error {
|
||||
err := rm.app.RunInTransaction(func(txApp core.App) error {
|
||||
var err error
|
||||
|
||||
collections := [2]*core.Collection{}
|
||||
collections[0], err = txApp.FindCachedCollectionByNameOrId("system_stats")
|
||||
if err != nil {
|
||||
slog.Error("Error finding cached collection using system stats:", "err", err)
|
||||
return err
|
||||
}
|
||||
collections[1], err = txApp.FindCachedCollectionByNameOrId("container_stats")
|
||||
if err != nil {
|
||||
slog.Error("Error finding cached collection using container stats:", "err", err)
|
||||
return err
|
||||
}
|
||||
monitorStatsColl, err := txApp.FindCachedCollectionByNameOrId("network_monitor_stats")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var systems RecordIds
|
||||
db := txApp.DB()
|
||||
|
||||
db.NewQuery("SELECT id FROM systems WHERE status='up'").All(&systems)
|
||||
if err := db.NewQuery("SELECT id FROM systems WHERE status='up'").All(&systems); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// loop through all active systems, time periods, and collections
|
||||
for _, system := range systems {
|
||||
@@ -94,44 +100,52 @@ func (rm *RecordManager) CreateLongerRecords() {
|
||||
recordData := longerRecordData[i]
|
||||
// log.Println("processing longer record type", recordData.longerType)
|
||||
// add one minute padding for longer records because they are created slightly later than the job start time
|
||||
longerRecordPeriod := time.Now().UTC().Add(recordData.longerTimeDuration + time.Minute)
|
||||
longerRecordPeriod := now.Add(recordData.longerTimeDuration + time.Minute)
|
||||
// shorter records are created independently of longer records, so we shouldn't need to add padding
|
||||
shorterRecordPeriod := time.Now().UTC().Add(recordData.longerTimeDuration)
|
||||
// loop through both collections
|
||||
shorterRecordPeriod := now.Add(recordData.longerTimeDuration)
|
||||
for _, collection := range collections {
|
||||
// check creation time of last longer record if not 10m, since 10m is created every run
|
||||
if recordData.longerType != "10m" {
|
||||
count, err := txApp.CountRecords(
|
||||
collection.Id,
|
||||
dbx.NewExp(
|
||||
"system = {:system} AND type = {:type} AND created > {:created}",
|
||||
dbx.Params{"type": recordData.longerType, "system": system.Id, "created": longerRecordPeriod},
|
||||
),
|
||||
)
|
||||
count, err := txApp.CountRecords(collection.Id, dbx.NewExp(
|
||||
"system = {:system} AND type = {:type} AND created > {:created}",
|
||||
dbx.Params{
|
||||
"type": recordData.longerType,
|
||||
"system": system.Id,
|
||||
"created": longerRecordPeriod.Format(types.DefaultDateLayout),
|
||||
},
|
||||
))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// continue if longer record exists
|
||||
if err != nil || count > 0 {
|
||||
if count > 0 {
|
||||
continue
|
||||
}
|
||||
}
|
||||
// get shorter records from the past x minutes
|
||||
var recordIds RecordIds
|
||||
|
||||
err := txApp.DB().
|
||||
params := dbx.Params{
|
||||
"type": recordData.shorterType,
|
||||
"system": system.Id,
|
||||
"created": shorterRecordPeriod.Format(types.DefaultDateLayout),
|
||||
}
|
||||
|
||||
err := db.
|
||||
Select("id").
|
||||
From(collection.Name).
|
||||
AndWhere(dbx.NewExp(
|
||||
Where(dbx.NewExp(
|
||||
"system={:system} AND type={:type} AND created > {:created}",
|
||||
dbx.Params{
|
||||
"type": recordData.shorterType,
|
||||
"system": system.Id,
|
||||
"created": shorterRecordPeriod,
|
||||
},
|
||||
params,
|
||||
)).
|
||||
OrderBy("created").
|
||||
All(&recordIds)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// continue if not enough shorter records
|
||||
if err != nil || len(recordIds) < recordData.minShorterRecords {
|
||||
if len(recordIds) < recordData.minShorterRecords {
|
||||
continue
|
||||
}
|
||||
// average the shorter records and create longer record
|
||||
@@ -142,20 +156,88 @@ func (rm *RecordManager) CreateLongerRecords() {
|
||||
case "system_stats":
|
||||
longerRecord.Set("stats", rm.AverageSystemStats(db, recordIds))
|
||||
case "container_stats":
|
||||
|
||||
longerRecord.Set("stats", rm.AverageContainerStats(db, recordIds))
|
||||
}
|
||||
if err := txApp.SaveNoValidate(longerRecord); err != nil {
|
||||
slog.Error("failed to save longer record", "err", err)
|
||||
txApp.Logger().Error("failed to save longer record", "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// network_monitor_stats is aggregated per monitor (not per system)
|
||||
var monitors []struct {
|
||||
Id string `db:"id"`
|
||||
System string `db:"system"`
|
||||
}
|
||||
// Disabled monitors still have history that must advance through retention tiers.
|
||||
if err := db.NewQuery("SELECT id, system FROM network_monitors").All(&monitors); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, monitorRec := range monitors {
|
||||
for i := range longerRecordData {
|
||||
recordData := longerRecordData[i]
|
||||
longerRecordPeriod := now.Add(recordData.longerTimeDuration + time.Minute)
|
||||
shorterRecordPeriod := now.Add(recordData.longerTimeDuration)
|
||||
|
||||
if recordData.longerType != "10m" {
|
||||
count, err := txApp.CountRecords(monitorStatsColl.Id, dbx.NewExp(
|
||||
"monitor={:monitor} AND type={:type} AND created>{:created}",
|
||||
dbx.Params{
|
||||
"monitor": monitorRec.Id,
|
||||
"type": recordData.longerType,
|
||||
"created": longerRecordPeriod.UnixMilli(),
|
||||
},
|
||||
))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
stats, count, err := rm.AverageMonitorStats(db, monitorRec.Id, recordData.shorterType, shorterRecordPeriod.UnixMilli())
|
||||
if err != nil {
|
||||
txApp.Logger().Error("failed to average monitor stats", "monitor", monitorRec.Id, "err", err)
|
||||
continue
|
||||
}
|
||||
// Monitor intervals can exceed the aggregation window, so average
|
||||
// any available records at every level and skip only empty windows.
|
||||
if count == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
longerRecord := core.NewRecord(monitorStatsColl)
|
||||
longerRecord.Set("system", monitorRec.System)
|
||||
longerRecord.Set("monitor", monitorRec.Id)
|
||||
longerRecord.Set("type", recordData.longerType)
|
||||
longerRecord.Set("created", now.UnixMilli())
|
||||
longerRecord.Set("res_min", stats.ResMin)
|
||||
longerRecord.Set("res_max", stats.ResMax)
|
||||
longerRecord.Set("total_count", stats.TotalCount)
|
||||
longerRecord.Set("success_count", stats.SuccessCount)
|
||||
longerRecord.Set("res_sum", stats.ResponseSum)
|
||||
if err := txApp.SaveNoValidate(longerRecord); err != nil {
|
||||
txApp.Logger().Error("failed to save monitor longer record", "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
rm.app.Logger().Error("failed to create longer records", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// log.Println("finished creating longer records", "time (ms)", time.Since(start).Milliseconds())
|
||||
func getCreatedTimeField(collectionName string, period time.Time) any {
|
||||
// network_monitor_stats stores created as unix timestamp in ms, not as a date string
|
||||
if collectionName == "network_monitor_stats" {
|
||||
return period.UnixMilli()
|
||||
}
|
||||
return period.Format(types.DefaultDateLayout)
|
||||
}
|
||||
|
||||
// Calculate the average stats of a list of system_stats records without reflect
|
||||
@@ -596,6 +678,36 @@ func AverageContainerStatsSlice(records [][]container.Stats) []container.Stats {
|
||||
return result
|
||||
}
|
||||
|
||||
// AverageMonitorStats merges probe counts and response sums, preserving their
|
||||
// weights through every retention tier. Failed probes do not contribute latency.
|
||||
func (rm *RecordManager) AverageMonitorStats(db dbx.Builder, monitorID, recordType string, createdAfter int64) (monitor.Stats, int, error) {
|
||||
var result struct {
|
||||
monitor.Stats
|
||||
Count int `db:"count"`
|
||||
}
|
||||
err := db.Select(
|
||||
"COUNT(*) AS count",
|
||||
"COALESCE(SUM(total_count), 0) AS total_count",
|
||||
"COALESCE(SUM(success_count), 0) AS success_count",
|
||||
"COALESCE(SUM(res_sum), 0) AS res_sum",
|
||||
"COALESCE(MIN(CASE WHEN success_count > 0 THEN res_min END), 0) AS res_min",
|
||||
"COALESCE(MAX(CASE WHEN success_count > 0 THEN res_max END), 0) AS res_max",
|
||||
).From("network_monitor_stats").Where(dbx.NewExp(
|
||||
"monitor={:monitor} AND type={:type} AND created>{:created}",
|
||||
dbx.Params{"monitor": monitorID, "type": recordType, "created": createdAfter},
|
||||
)).One(&result)
|
||||
if err != nil {
|
||||
return monitor.Stats{}, 0, err
|
||||
}
|
||||
if result.SuccessCount > 0 {
|
||||
result.ResAvg = twoDecimals(float64(result.ResponseSum) / float64(result.SuccessCount))
|
||||
}
|
||||
if result.TotalCount > 0 {
|
||||
result.Loss = twoDecimals(float64(result.TotalCount-result.SuccessCount) * 100 / float64(result.TotalCount))
|
||||
}
|
||||
return result.Stats, result.Count, nil
|
||||
}
|
||||
|
||||
/* Round float to two decimals */
|
||||
func twoDecimals(value float64) float64 {
|
||||
return math.Round(value*100) / 100
|
||||
|
||||
@@ -3,7 +3,6 @@ package records
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
@@ -60,7 +59,7 @@ func deleteOldAlertsHistory(app core.App, countToKeep, countBeforeDeletion int)
|
||||
// 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"}
|
||||
collections := [3]string{"system_stats", "container_stats", "network_monitor_stats"}
|
||||
|
||||
// Record types and their retention periods
|
||||
type RecordDeletionData struct {
|
||||
@@ -76,24 +75,17 @@ func deleteOldSystemStats(app core.App) error {
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
db := app.DB()
|
||||
|
||||
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)
|
||||
query := db.Delete(collection, dbx.NewExp("type={:type} AND created<{:created}"))
|
||||
for _, rd := range recordData {
|
||||
if _, err := query.Bind(dbx.Params{
|
||||
"type": rd.recordType,
|
||||
"created": getCreatedTimeField(collection, now.Add(-rd.retention)),
|
||||
}).Execute(); err != nil {
|
||||
return fmt.Errorf("failed to delete from %s: %v", collection, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
86
internal/records/records_rollup_test.go
Normal file
86
internal/records/records_rollup_test.go
Normal file
@@ -0,0 +1,86 @@
|
||||
//go:build testing
|
||||
|
||||
package records_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/internal/records"
|
||||
"github.com/henrygd/beszel/internal/tests"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestLongerRecordsPreventDuplicates(t *testing.T) {
|
||||
for _, collection := range []string{"system_stats", "container_stats", "network_monitor_stats"} {
|
||||
for _, tier := range []struct {
|
||||
shorter, longer string
|
||||
count int
|
||||
}{
|
||||
{"10m", "20m", 2},
|
||||
{"20m", "120m", 6},
|
||||
{"120m", "480m", 4},
|
||||
} {
|
||||
t.Run(collection+"/"+tier.longer, func(t *testing.T) {
|
||||
hub, err := tests.NewTestHub(t.TempDir())
|
||||
require.NoError(t, err)
|
||||
defer hub.Cleanup()
|
||||
|
||||
user, err := tests.CreateUser(hub, "rollup@example.com", "testtesttest")
|
||||
require.NoError(t, err)
|
||||
sys, err := tests.CreateRecord(hub, "systems", map[string]any{
|
||||
"name": "rollup-system", "host": "localhost", "port": "45876",
|
||||
"status": "up", "users": []string{user.Id},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
created := time.Now().UTC().Add(-time.Minute)
|
||||
data := map[string]any{
|
||||
"system": sys.Id, "type": tier.shorter,
|
||||
"created": created.Format(types.DefaultDateLayout),
|
||||
}
|
||||
filter := dbx.HashExp{"system": sys.Id, "type": tier.longer}
|
||||
switch collection {
|
||||
case "system_stats":
|
||||
data["stats"] = `{"cpu":10}`
|
||||
case "container_stats":
|
||||
data["stats"] = `[{"name":"test","cpu":10}]`
|
||||
case "network_monitor_stats":
|
||||
monitor, err := tests.CreateRecord(hub, "network_monitors", map[string]any{
|
||||
"system": sys.Id, "target": "1.1.1.1", "protocol": "icmp",
|
||||
"interval": 30, "enabled": true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
data["monitor"] = monitor.Id
|
||||
data["created"] = created.UnixMilli()
|
||||
data["total_count"] = 1
|
||||
data["success_count"] = 1
|
||||
data["res_sum"] = 10
|
||||
data["res_min"] = 10
|
||||
data["res_max"] = 10
|
||||
filter["monitor"] = monitor.Id
|
||||
}
|
||||
for range tier.count {
|
||||
_, err := tests.CreateRecord(hub, collection, data)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
rm := records.NewRecordManager(hub)
|
||||
rm.CreateLongerRecords()
|
||||
first, err := hub.FindAllRecords(collection, filter)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, first, 1)
|
||||
|
||||
// The shorter records remain eligible, but the existing longer
|
||||
// record must prevent another rollup on a subsequent invocation.
|
||||
rm.CreateLongerRecords()
|
||||
second, err := hub.FindAllRecords(collection, filter)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, second, 1)
|
||||
require.Equal(t, first[0].Id, second[0].Id)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user