diff --git a/beszel/internal/alerts/alerts.go b/beszel/internal/alerts/alerts.go
index 26cfe5c..b6a0533 100644
--- a/beszel/internal/alerts/alerts.go
+++ b/beszel/internal/alerts/alerts.go
@@ -93,10 +93,18 @@ func NewAlertManager(app hubLike) *AlertManager {
alertQueue: make(chan alertTask),
stopChan: make(chan struct{}),
}
+ am.bindEvents()
go am.startWorker()
return am
}
+// Bind events to the alerts collection lifecycle
+func (am *AlertManager) bindEvents() {
+ am.hub.OnRecordAfterUpdateSuccess("alerts").BindFunc(updateHistoryOnAlertUpdate)
+ am.hub.OnRecordAfterDeleteSuccess("alerts").BindFunc(resolveHistoryOnAlertDelete)
+}
+
+// SendAlert sends an alert to the user
func (am *AlertManager) SendAlert(data AlertMessageData) error {
// get user settings
record, err := am.hub.FindFirstRecordByFilter(
diff --git a/beszel/internal/alerts/alerts_history.go b/beszel/internal/alerts/alerts_history.go
index 9f0aadc..11dfde4 100644
--- a/beszel/internal/alerts/alerts_history.go
+++ b/beszel/internal/alerts/alerts_history.go
@@ -7,90 +7,79 @@ import (
"github.com/pocketbase/pocketbase/core"
)
-func (am *AlertManager) RecordAlertHistory(alert SystemAlertData) {
- // Get alert, user, system, name, value
- alertId := alert.alertRecord.Id
- userId := ""
- if errs := am.hub.ExpandRecord(alert.alertRecord, []string{"user"}, nil); len(errs) == 0 {
- if user := alert.alertRecord.ExpandedOne("user"); user != nil {
- userId = user.Id
- }
- }
- systemId := alert.systemRecord.Id
- name := alert.name
- value := alert.val
- now := time.Now().UTC()
-
- if alert.triggered {
- // Create new alerts_history record
- collection, err := am.hub.FindCollectionByNameOrId("alerts_history")
- if err == nil {
- history := core.NewRecord(collection)
- history.Set("alert", alertId)
- history.Set("user", userId)
- history.Set("system", systemId)
- history.Set("name", name)
- history.Set("value", value)
- history.Set("state", "active")
- history.Set("created_date", now)
- history.Set("solved_date", nil)
- _ = am.hub.Save(history)
- }
- } else {
- // Find latest active alerts_history record for this alert and set to solved
- record, err := am.hub.FindFirstRecordByFilter(
- "alerts_history",
- "alert={:alert} && state='active'",
- dbx.Params{"alert": alertId},
- )
- if err == nil && record != nil {
- record.Set("state", "solved")
- record.Set("solved_date", now)
- _ = am.hub.Save(record)
- }
+// On triggered alert record delete, set matching alert history record to resolved
+func resolveHistoryOnAlertDelete(e *core.RecordEvent) error {
+ if !e.Record.GetBool("triggered") {
+ return e.Next()
}
+ _ = resolveAlertHistoryRecord(e.App, e.Record)
+ return e.Next()
}
-// DeleteOldAlertHistory deletes alerts_history records older than the given retention duration
-func (am *AlertManager) DeleteOldAlertHistory(retention time.Duration) {
- now := time.Now().UTC()
- cutoff := now.Add(-retention)
- _, err := am.hub.DB().NewQuery(
- "DELETE FROM alerts_history WHERE solved_date IS NOT NULL AND solved_date < {:cutoff}",
- ).Bind(dbx.Params{"cutoff": cutoff}).Execute()
+// On alert record update, update alert history record
+func updateHistoryOnAlertUpdate(e *core.RecordEvent) error {
+ original := e.Record.Original()
+ new := e.Record
+
+ originalTriggered := original.GetBool("triggered")
+ newTriggered := new.GetBool("triggered")
+
+ // no need to update alert history if triggered state has not changed
+ if originalTriggered == newTriggered {
+ return e.Next()
+ }
+
+ // if new state is triggered, create new alert history record
+ if newTriggered {
+ _, _ = createAlertHistoryRecord(e.App, new)
+ return e.Next()
+ }
+
+ // if new state is not triggered, check for matching alert history record and set it to resolved
+ _ = resolveAlertHistoryRecord(e.App, new)
+ return e.Next()
+}
+
+// resolveAlertHistoryRecord sets the resolved field to the current time
+func resolveAlertHistoryRecord(app core.App, alertRecord *core.Record) error {
+ alertHistoryRecords, err := app.FindRecordsByFilter(
+ "alerts_history",
+ "alert_id={:alert_id} && resolved=null",
+ "-created",
+ 1,
+ 0,
+ dbx.Params{"alert_id": alertRecord.Id},
+ )
if err != nil {
- am.hub.Logger().Error("failed to delete old alerts_history records", "error", err)
+ return err
}
-}
-
-// Helper to get retention duration from user settings
-func getAlertHistoryRetention(settings map[string]interface{}) time.Duration {
- retStr, _ := settings["alertHistoryRetention"].(string)
- switch retStr {
- case "1m":
- return 30 * 24 * time.Hour
- case "3m":
- return 90 * 24 * time.Hour
- case "6m":
- return 180 * 24 * time.Hour
- case "1y":
- return 365 * 24 * time.Hour
- default:
- return 90 * 24 * time.Hour // default 3 months
+ if len(alertHistoryRecords) == 0 {
+ return nil
}
-}
-
-// CleanUpAllAlertHistory deletes old alerts_history records for each user based on their retention setting
-func (am *AlertManager) CleanUpAllAlertHistory() {
- records, err := am.hub.FindAllRecords("user_settings")
+ alertHistoryRecord := alertHistoryRecords[0] // there should be only one record
+ alertHistoryRecord.Set("resolved", time.Now().UTC())
+ err = app.Save(alertHistoryRecord)
if err != nil {
- return
- }
- for _, record := range records {
- var settings map[string]interface{}
- if err := record.UnmarshalJSONField("settings", &settings); err != nil {
- continue
- }
- am.DeleteOldAlertHistory(getAlertHistoryRetention(settings))
+ app.Logger().Error("Failed to resolve alert history", "err", err)
}
+ return err
+}
+
+// createAlertHistoryRecord creates a new alert history record
+func createAlertHistoryRecord(app core.App, alertRecord *core.Record) (alertHistoryRecord *core.Record, err error) {
+ alertHistoryCollection, err := app.FindCachedCollectionByNameOrId("alerts_history")
+ if err != nil {
+ return nil, err
+ }
+ alertHistoryRecord = core.NewRecord(alertHistoryCollection)
+ alertHistoryRecord.Set("alert_id", alertRecord.Id)
+ alertHistoryRecord.Set("user", alertRecord.GetString("user"))
+ alertHistoryRecord.Set("system", alertRecord.GetString("system"))
+ alertHistoryRecord.Set("name", alertRecord.GetString("name"))
+ alertHistoryRecord.Set("value", alertRecord.GetFloat("value"))
+ err = app.Save(alertHistoryRecord)
+ if err != nil {
+ app.Logger().Error("Failed to save alert history", "err", err)
+ }
+ return alertHistoryRecord, err
}
diff --git a/beszel/internal/alerts/alerts_status.go b/beszel/internal/alerts/alerts_status.go
index 3dcf81a..47e5a77 100644
--- a/beszel/internal/alerts/alerts_status.go
+++ b/beszel/internal/alerts/alerts_status.go
@@ -136,6 +136,14 @@ func (am *AlertManager) handleSystemUp(systemName string, alertRecords []*core.R
// sendStatusAlert sends a status alert ("up" or "down") to the users associated with the alert records.
func (am *AlertManager) sendStatusAlert(alertStatus string, systemName string, alertRecord *core.Record) error {
+ switch alertStatus {
+ case "up":
+ alertRecord.Set("triggered", false)
+ case "down":
+ alertRecord.Set("triggered", true)
+ }
+ am.hub.Save(alertRecord)
+
var emoji string
if alertStatus == "up" {
emoji = "\u2705" // Green checkmark emoji
@@ -146,16 +154,16 @@ func (am *AlertManager) sendStatusAlert(alertStatus string, systemName string, a
title := fmt.Sprintf("Connection to %s is %s %v", systemName, alertStatus, emoji)
message := strings.TrimSuffix(title, emoji)
- if errs := am.hub.ExpandRecord(alertRecord, []string{"user"}, nil); len(errs) > 0 {
- return errs["user"]
- }
- user := alertRecord.ExpandedOne("user")
- if user == nil {
- return nil
- }
+ // if errs := am.hub.ExpandRecord(alertRecord, []string{"user"}, nil); len(errs) > 0 {
+ // return errs["user"]
+ // }
+ // user := alertRecord.ExpandedOne("user")
+ // if user == nil {
+ // return nil
+ // }
return am.SendAlert(AlertMessageData{
- UserID: user.Id,
+ UserID: alertRecord.GetString("user"),
Title: title,
Message: message,
Link: am.hub.MakeLink("system", systemName),
diff --git a/beszel/internal/alerts/alerts_system.go b/beszel/internal/alerts/alerts_system.go
index 4fa1373..7c99a95 100644
--- a/beszel/internal/alerts/alerts_system.go
+++ b/beszel/internal/alerts/alerts_system.go
@@ -15,7 +15,7 @@ import (
func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *system.CombinedData) error {
alertRecords, err := am.hub.FindAllRecords("alerts",
- dbx.NewExp("system={:system}", dbx.Params{"system": systemRecord.Id}),
+ dbx.NewExp("system={:system} AND name!='Status'", dbx.Params{"system": systemRecord.Id}),
)
if err != nil || len(alertRecords) == 0 {
// log.Println("no alerts found for system")
@@ -293,10 +293,6 @@ func (am *AlertManager) sendSystemAlert(alert SystemAlertData) {
// app.Logger().Error("failed to save alert record", "err", err)
return
}
-
- // Create Alert History
- am.RecordAlertHistory(alert)
-
// expand the user relation and send the alert
if errs := am.hub.ExpandRecord(alert.alertRecord, []string{"user"}, nil); len(errs) > 0 {
// app.Logger().Error("failed to expand user relation", "errs", errs)
diff --git a/beszel/internal/hub/hub.go b/beszel/internal/hub/hub.go
index 1683346..492df1c 100644
--- a/beszel/internal/hub/hub.go
+++ b/beszel/internal/hub/hub.go
@@ -215,13 +215,10 @@ func (h *Hub) startServer(se *core.ServeEvent) error {
// registerCronJobs sets up scheduled tasks
func (h *Hub) registerCronJobs(_ *core.ServeEvent) error {
- // delete old records once every hour
+ // delete old system_stats and alerts_history records once every hour
h.Cron().MustAdd("delete old records", "8 * * * *", h.rm.DeleteOldRecords)
// create longer records every 10 minutes
h.Cron().MustAdd("create longer records", "*/10 * * * *", h.rm.CreateLongerRecords)
- // delete old alert history records for each user based on their retention setting
- h.Cron().MustAdd("delete old alerts_history", "5 */1 * * *", h.AlertManager.CleanUpAllAlertHistory)
-
return nil
}
diff --git a/beszel/internal/hub/systems/system_manager.go b/beszel/internal/hub/systems/system_manager.go
index 1ac3431..89b96dc 100644
--- a/beszel/internal/hub/systems/system_manager.go
+++ b/beszel/internal/hub/systems/system_manager.go
@@ -159,8 +159,10 @@ func (sm *SystemManager) onRecordUpdate(e *core.RecordEvent) error {
// - down: Triggers status change alerts
func (sm *SystemManager) onRecordAfterUpdateSuccess(e *core.RecordEvent) error {
newStatus := e.Record.GetString("status")
+ prevStatus := pending
system, ok := sm.systems.GetOk(e.Record.Id)
if ok {
+ prevStatus = system.Status
system.Status = newStatus
}
@@ -182,6 +184,7 @@ func (sm *SystemManager) onRecordAfterUpdateSuccess(e *core.RecordEvent) error {
if err := sm.AddRecord(e.Record, nil); err != nil {
e.App.Logger().Error("Error adding record", "err", err)
}
+ _ = deactivateAlerts(e.App, e.Record.Id)
return e.Next()
}
@@ -190,8 +193,6 @@ func (sm *SystemManager) onRecordAfterUpdateSuccess(e *core.RecordEvent) error {
return sm.AddRecord(e.Record, nil)
}
- prevStatus := system.Status
-
// Trigger system alerts when system comes online
if newStatus == up {
if err := sm.hub.HandleSystemAlerts(e.Record, system.data); err != nil {
diff --git a/beszel/internal/records/records.go b/beszel/internal/records/records.go
index d468881..cfd2e42 100644
--- a/beszel/internal/records/records.go
+++ b/beszel/internal/records/records.go
@@ -366,12 +366,46 @@ func (rm *RecordManager) AverageContainerStats(db dbx.Builder, records RecordIds
return result
}
-// Deletes records older than what is displayed in the UI
+// Delete old records
func (rm *RecordManager) DeleteOldRecords() {
- // Define the collections to process
+ rm.app.RunInTransaction(func(txApp core.App) error {
+ err := deleteOldSystemStats(txApp)
+ if err != nil {
+ return err
+ }
+ err = deleteOldAlertsHistory(txApp, 200, 250)
+ 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"}
- // Define record types and their retention periods
+ // Record types and their retention periods
type RecordDeletionData struct {
recordType string
retention time.Duration
@@ -387,10 +421,9 @@ func (rm *RecordManager) DeleteOldRecords() {
now := time.Now().UTC()
for _, collection := range collections {
- // Build the WHERE clause dynamically
+ // 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
@@ -398,19 +431,15 @@ func (rm *RecordManager) DeleteOldRecords() {
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 the full raw query
+ // Construct and execute the full raw query
rawQuery := fmt.Sprintf("DELETE FROM %s WHERE %s", collection, conditionStr)
-
- // Execute the query with parameters
- if _, err := rm.app.DB().NewQuery(rawQuery).Bind(params).Execute(); err != nil {
- // return fmt.Errorf("failed to delete from %s: %v", collection, err)
- rm.app.Logger().Error("failed to delete", "collection", collection, "error", err)
+ if _, err := app.DB().NewQuery(rawQuery).Bind(params).Execute(); err != nil {
+ return fmt.Errorf("failed to delete from %s: %v", collection, err)
}
}
+ return nil
}
/* Round float to two decimals */
diff --git a/beszel/internal/records/records_test.go b/beszel/internal/records/records_test.go
new file mode 100644
index 0000000..24e91fd
--- /dev/null
+++ b/beszel/internal/records/records_test.go
@@ -0,0 +1,381 @@
+//go:build testing
+// +build testing
+
+package records_test
+
+import (
+ "beszel/internal/records"
+ "beszel/internal/tests"
+ "fmt"
+ "testing"
+ "time"
+
+ "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.TestDeleteOldSystemStats(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.TestDeleteOldAlertsHistory(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.TestDeleteOldAlertsHistory(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.TestDeleteOldAlertsHistory(hub, 10, 20)
+ require.NoError(t, err)
+ })
+}
+
+// TestRecordManagerCreation tests RecordManager creation
+func TestRecordManagerCreation(t *testing.T) {
+ hub, err := tests.NewTestHub(t.TempDir())
+ require.NoError(t, err)
+ defer hub.Cleanup()
+
+ rm := records.NewRecordManager(hub)
+ assert.NotNil(t, rm, "RecordManager should not be nil")
+}
+
+// TestTwoDecimals tests the twoDecimals helper function
+func TestTwoDecimals(t *testing.T) {
+ testCases := []struct {
+ input float64
+ expected float64
+ }{
+ {1.234567, 1.23},
+ {1.235, 1.24}, // Should round up
+ {1.0, 1.0},
+ {0.0, 0.0},
+ {-1.234567, -1.23},
+ {-1.235, -1.23}, // Negative rounding
+ }
+
+ for _, tc := range testCases {
+ result := records.TestTwoDecimals(tc.input)
+ assert.InDelta(t, tc.expected, result, 0.02, "twoDecimals(%f) should equal %f", tc.input, tc.expected)
+ }
+}
diff --git a/beszel/internal/records/records_test_helpers.go b/beszel/internal/records/records_test_helpers.go
new file mode 100644
index 0000000..f4909b6
--- /dev/null
+++ b/beszel/internal/records/records_test_helpers.go
@@ -0,0 +1,23 @@
+//go:build testing
+// +build testing
+
+package records
+
+import (
+ "github.com/pocketbase/pocketbase/core"
+)
+
+// TestDeleteOldSystemStats exposes deleteOldSystemStats for testing
+func TestDeleteOldSystemStats(app core.App) error {
+ return deleteOldSystemStats(app)
+}
+
+// TestDeleteOldAlertsHistory exposes deleteOldAlertsHistory for testing
+func TestDeleteOldAlertsHistory(app core.App, countToKeep, countBeforeDeletion int) error {
+ return deleteOldAlertsHistory(app, countToKeep, countBeforeDeletion)
+}
+
+// TestTwoDecimals exposes twoDecimals for testing
+func TestTwoDecimals(value float64) float64 {
+ return twoDecimals(value)
+}
diff --git a/beszel/migrations/1_collections_snapshot_0_12_0.go b/beszel/migrations/0_collections_snapshot_0_12_0_6.go
similarity index 86%
rename from beszel/migrations/1_collections_snapshot_0_12_0.go
rename to beszel/migrations/0_collections_snapshot_0_12_0_6.go
index 9041296..01cdbff 100644
--- a/beszel/migrations/1_collections_snapshot_0_12_0.go
+++ b/beszel/migrations/0_collections_snapshot_0_12_0_6.go
@@ -140,6 +140,124 @@ func init() {
],
"system": false
},
+ {
+ "id": "pbc_1697146157",
+ "listRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
+ "viewRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
+ "createRule": null,
+ "updateRule": null,
+ "deleteRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
+ "name": "alerts_history",
+ "type": "base",
+ "fields": [
+ {
+ "autogeneratePattern": "[a-z0-9]{15}",
+ "hidden": false,
+ "id": "text3208210256",
+ "max": 15,
+ "min": 15,
+ "name": "id",
+ "pattern": "^[a-z0-9]+$",
+ "presentable": false,
+ "primaryKey": true,
+ "required": true,
+ "system": true,
+ "type": "text"
+ },
+ {
+ "cascadeDelete": true,
+ "collectionId": "_pb_users_auth_",
+ "hidden": false,
+ "id": "relation2375276105",
+ "maxSelect": 1,
+ "minSelect": 0,
+ "name": "user",
+ "presentable": false,
+ "required": true,
+ "system": false,
+ "type": "relation"
+ },
+ {
+ "cascadeDelete": true,
+ "collectionId": "2hz5ncl8tizk5nx",
+ "hidden": false,
+ "id": "relation3377271179",
+ "maxSelect": 1,
+ "minSelect": 0,
+ "name": "system",
+ "presentable": false,
+ "required": true,
+ "system": false,
+ "type": "relation"
+ },
+ {
+ "autogeneratePattern": "",
+ "hidden": false,
+ "id": "text2466471794",
+ "max": 0,
+ "min": 0,
+ "name": "alert_id",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": false,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "autogeneratePattern": "",
+ "hidden": false,
+ "id": "text1579384326",
+ "max": 0,
+ "min": 0,
+ "name": "name",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": true,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "hidden": false,
+ "id": "number494360628",
+ "max": null,
+ "min": null,
+ "name": "value",
+ "onlyInt": false,
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "number"
+ },
+ {
+ "hidden": false,
+ "id": "autodate2990389176",
+ "name": "created",
+ "onCreate": true,
+ "onUpdate": false,
+ "presentable": false,
+ "system": false,
+ "type": "autodate"
+ },
+ {
+ "hidden": false,
+ "id": "date2276568630",
+ "max": "",
+ "min": "",
+ "name": "resolved",
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "date"
+ }
+ ],
+ "indexes": [
+ "CREATE INDEX ` + "`" + `idx_YdGnup5aqB` + "`" + ` ON ` + "`" + `alerts_history` + "`" + ` (` + "`" + `user` + "`" + `)",
+ "CREATE INDEX ` + "`" + `idx_taLet9VdME` + "`" + ` ON ` + "`" + `alerts_history` + "`" + ` (` + "`" + `created` + "`" + `)"
+ ],
+ "system": false
+ },
{
"id": "juohu4jipgc13v7",
"listRule": "@request.auth.id != \"\"",
@@ -757,7 +875,6 @@ func init() {
LEFT JOIN fingerprints f ON s.id = f.system
WHERE f.system IS NULL
`).Column(&systemIds)
-
if err != nil {
return err
}
diff --git a/beszel/migrations/1_create_alerts_history.go b/beszel/migrations/1_create_alerts_history.go
deleted file mode 100644
index 773274d..0000000
--- a/beszel/migrations/1_create_alerts_history.go
+++ /dev/null
@@ -1,74 +0,0 @@
-package migrations
-
-import (
- "github.com/pocketbase/pocketbase/core"
- m "github.com/pocketbase/pocketbase/migrations"
-)
-
-func init() {
- m.Register(func(app core.App) error {
- jsonData := `[
- {
- "name": "alerts_history",
- "type": "base",
- "system": false,
- "listRule": "",
- "deleteRule": "",
- "viewRule": ""
- "fields": [
- {
- "name": "alert",
- "type": "relation",
- "required": true,
- "collectionId": "elngm8x1l60zi2v",
- "cascadeDelete": true,
- "maxSelect": 1
- },
- {
- "name": "user",
- "type": "relation",
- "required": true,
- "collectionId": "_pb_users_auth_",
- "cascadeDelete": true,
- "maxSelect": 1
- },
- {
- "name": "system",
- "type": "relation",
- "required": true,
- "collectionId": "2hz5ncl8tizk5nx",
- "cascadeDelete": true,
- "maxSelect": 1
- },
- {
- "name": "name",
- "type": "text",
- "required": true
- },
- {
- "name": "value",
- "type": "number",
- "required": true
- },
- {
- "name": "state",
- "type": "select",
- "required": true,
- "values": ["active", "solved"]
- },
- {
- "name": "created_date",
- "type": "date",
- "required": true
- },
- {
- "name": "solved_date",
- "type": "date",
- "required": false
- }
- ]
- }
- ]`
- return app.ImportCollectionsByMarshaledJSON([]byte(jsonData), false)
- }, nil)
-}
diff --git a/beszel/site/bun.lockb b/beszel/site/bun.lockb
index e16c071..a6b9843 100755
Binary files a/beszel/site/bun.lockb and b/beszel/site/bun.lockb differ
diff --git a/beszel/site/package-lock.json b/beszel/site/package-lock.json
index 879c8c0..580eeb0 100644
--- a/beszel/site/package-lock.json
+++ b/beszel/site/package-lock.json
@@ -40,7 +40,6 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"recharts": "^2.15.3",
- "sonner": "^2.0.6",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"valibot": "^0.42.1"
@@ -49,6 +48,7 @@
"@lingui/cli": "^5.3.2",
"@lingui/swc-plugin": "^5.5.2",
"@lingui/vite-plugin": "^5.3.2",
+ "@tailwindcss/container-queries": "^0.1.1",
"@types/bun": "^1.2.15",
"@types/react": "^18.3.23",
"@types/react-dom": "^18.3.7",
@@ -2832,6 +2832,16 @@
"@swc/counter": "^0.1.3"
}
},
+ "node_modules/@tailwindcss/container-queries": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/container-queries/-/container-queries-0.1.1.tgz",
+ "integrity": "sha512-p18dswChx6WnTSaJCSGx6lTmrGzNNvm2FtXmiO6AuA1V4U5REyoqwmT6kgAsIMdjo07QdAfYXHJ4hnMtfHzWgA==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "tailwindcss": ">=3.2.0"
+ }
+ },
"node_modules/@tanstack/react-table": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz",
@@ -5484,16 +5494,6 @@
"url": "https://github.com/sponsors/isaacs"
}
},
- "node_modules/sonner": {
- "version": "2.0.6",
- "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.6.tgz",
- "integrity": "sha512-yHFhk8T/DK3YxjFQXIrcHT1rGEeTLliVzWbO0xN8GberVun2RiBnxAjXAYpZrqwEVHBG9asI/Li8TAAhN9m59Q==",
- "license": "MIT",
- "peerDependencies": {
- "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
- "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
- }
- },
"node_modules/source-map": {
"version": "0.8.0-beta.0",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz",
diff --git a/beszel/site/package.json b/beszel/site/package.json
index 3fb76a4..d49c134 100644
--- a/beszel/site/package.json
+++ b/beszel/site/package.json
@@ -43,7 +43,6 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"recharts": "^2.15.3",
- "sonner": "^2.0.6",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"valibot": "^0.42.1"
@@ -52,6 +51,7 @@
"@lingui/cli": "^5.3.2",
"@lingui/swc-plugin": "^5.5.2",
"@lingui/vite-plugin": "^5.3.2",
+ "@tailwindcss/container-queries": "^0.1.1",
"@types/bun": "^1.2.15",
"@types/react": "^18.3.23",
"@types/react-dom": "^18.3.7",
@@ -71,4 +71,4 @@
"optionalDependencies": {
"@esbuild/linux-arm64": "^0.21.5"
}
-}
+}
\ No newline at end of file
diff --git a/beszel/site/src/components/alerts-history-columns.tsx b/beszel/site/src/components/alerts-history-columns.tsx
index 7e59e8a..0203486 100644
--- a/beszel/site/src/components/alerts-history-columns.tsx
+++ b/beszel/site/src/components/alerts-history-columns.tsx
@@ -1,146 +1,164 @@
import { ColumnDef } from "@tanstack/react-table"
import { AlertsHistoryRecord } from "@/types"
import { Button } from "@/components/ui/button"
-import { ArrowUpDown } from "lucide-react"
import { Badge } from "@/components/ui/badge"
+import { alertInfo, formatShortDate, toFixedFloat, formatDuration, cn } from "@/lib/utils"
+import { Trans } from "@lingui/react/macro"
+import { t } from "@lingui/core/macro"
export const alertsHistoryColumns: ColumnDef[] = [
- {
- accessorKey: "system",
- header: ({ column }) => (
-
- ),
- cell: ({ row }) => {row.original.expand?.system?.name || row.original.system},
- enableSorting: true,
- filterFn: (row, _, filterValue) => {
- const display = row.original.expand?.system?.name || row.original.system || ""
- return display.toLowerCase().includes(filterValue.toLowerCase())
- },
- },
- {
- accessorKey: "name",
- header: ({ column }) => (
-
- ),
- cell: ({ row }) => {row.getValue("name")},
- enableSorting: true,
- filterFn: (row, _, filterValue) => {
- const value = row.getValue("name") || ""
- return String(value).toLowerCase().includes(filterValue.toLowerCase())
- },
- },
- {
- accessorKey: "value",
- header: ({ column }) => (
-
- ),
- cell: ({ row }) => {Math.round(Number(row.getValue("value")))},
- enableSorting: true,
- },
- {
- accessorKey: "state",
- header: ({ column }) => (
-
- ),
- cell: ({ row }) => {
- const state = row.getValue("state") as string
- let color = ""
- if (state === "solved") color = "bg-green-100 text-green-800 border-green-200"
- else if (state === "active") color = "bg-yellow-100 text-yellow-800 border-yellow-200"
- return (
-
- {state}
-
- )
- },
- enableSorting: true,
- },
- {
- accessorKey: "create_date",
- header: ({ column }) => (
-
- ),
- cell: ({ row }) => (
-
- {row.original.created_date ? new Date(row.original.created_date).toLocaleString() : ""}
-
- ),
- enableSorting: true,
- },
- {
- accessorKey: "solved_date",
- header: ({ column }) => (
-
- ),
- cell: ({ row }) => (
-
- {row.original.solved_date ? new Date(row.original.solved_date).toLocaleString() : ""}
-
- ),
- enableSorting: true,
- },
- {
- accessorKey: "duration",
- header: ({ column }) => (
-
- ),
- cell: ({ row }) => {
- const created = row.original.created_date ? new Date(row.original.created_date) : null
- const solved = row.original.solved_date ? new Date(row.original.solved_date) : null
- if (!created || !solved) return
- const diffMs = solved.getTime() - created.getTime()
- if (diffMs < 0) return
- const totalSeconds = Math.floor(diffMs / 1000)
- const hours = Math.floor(totalSeconds / 3600)
- const minutes = Math.floor((totalSeconds % 3600) / 60)
- const seconds = totalSeconds % 60
- return (
-
- {[
- hours ? `${hours}h` : null,
- minutes ? `${minutes}m` : null,
- `${seconds}s`
- ].filter(Boolean).join(" ")}
-
- )
- },
- enableSorting: true,
- },
-]
\ No newline at end of file
+ {
+ accessorKey: "system",
+ enableSorting: true,
+ header: ({ column }) => (
+
+ ),
+ cell: ({ row }) => {row.original.expand?.system?.name || row.original.system},
+ filterFn: (row, _, filterValue) => {
+ const display = row.original.expand?.system?.name || row.original.system || ""
+ return display.toLowerCase().includes(filterValue.toLowerCase())
+ },
+ },
+ {
+ // accessorKey: "name",
+ id: "name",
+ accessorFn: (record) => {
+ const name = record.name
+ const info = alertInfo[name]
+ return info?.name().replace("cpu", "CPU") || name
+ },
+ header: ({ column }) => (
+
+ ),
+ cell: ({ getValue, row }) => {
+ let name = getValue() as string
+ const info = alertInfo[row.original.name]
+ const Icon = info?.icon
+
+ return (
+
+ {Icon && }
+ {name}
+
+ )
+ },
+ },
+ {
+ accessorKey: "value",
+ enableSorting: false,
+ header: () => (
+
+ ),
+ cell({ row, getValue }) {
+ const name = row.original.name
+ if (name === "Status") {
+ return {t`Down`}
+ }
+ const value = getValue() as number
+ const unit = alertInfo[name]?.unit
+ return (
+
+ {toFixedFloat(value, value < 10 ? 2 : 1)}
+ {unit}
+
+ )
+ },
+ },
+ {
+ accessorKey: "state",
+ enableSorting: true,
+ sortingFn: (rowA, rowB) => (rowA.original.resolved ? 1 : 0) - (rowB.original.resolved ? 1 : 0),
+ header: ({ column }) => (
+
+ ),
+ cell: ({ row }) => {
+ const resolved = row.original.resolved
+ return (
+
+ {/* {resolved ? : } */}
+ {resolved ? "Resolved" : "Active"}
+
+ )
+ },
+ },
+ {
+ accessorKey: "created",
+ accessorFn: (record) => formatShortDate(record.created),
+ enableSorting: true,
+ invertSorting: true,
+ header: ({ column }) => (
+
+ ),
+ cell: ({ getValue, row }) => (
+
+ {getValue() as string}
+
+ ),
+ },
+ {
+ accessorKey: "resolved",
+ enableSorting: true,
+ invertSorting: true,
+ header: ({ column }) => (
+
+ ),
+ cell: ({ row, getValue }) => {
+ const resolved = getValue() as string | null
+ if (!resolved) {
+ return null
+ }
+ return (
+
+ {formatShortDate(resolved)}
+
+ )
+ },
+ },
+ {
+ accessorKey: "duration",
+ invertSorting: true,
+ enableSorting: true,
+ sortingFn: (rowA, rowB) => {
+ const aCreated = new Date(rowA.original.created)
+ const bCreated = new Date(rowB.original.created)
+ const aResolved = rowA.original.resolved ? new Date(rowA.original.resolved) : null
+ const bResolved = rowB.original.resolved ? new Date(rowB.original.resolved) : null
+ const aDuration = aResolved ? aResolved.getTime() - aCreated.getTime() : null
+ const bDuration = bResolved ? bResolved.getTime() - bCreated.getTime() : null
+ if (!aDuration && bDuration) return -1
+ if (aDuration && !bDuration) return 1
+ return (aDuration || 0) - (bDuration || 0)
+ },
+ header: ({ column }) => (
+
+ ),
+ cell: ({ row }) => {
+ const duration = formatDuration(row.original.created, row.original.resolved)
+ if (!duration) {
+ return null
+ }
+ return {duration}
+ },
+ },
+]
diff --git a/beszel/site/src/components/command-palette.tsx b/beszel/site/src/components/command-palette.tsx
index 5dbea0c..a735a75 100644
--- a/beszel/site/src/components/command-palette.tsx
+++ b/beszel/site/src/components/command-palette.tsx
@@ -69,7 +69,7 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
setOpen(false)
}}
>
-
+
{system.name}
{getHostDisplayValue(system)}
@@ -86,7 +86,7 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
setOpen(false)
}}
>
-
+
Dashboard
@@ -100,7 +100,7 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
setOpen(false)
}}
>
-
+
Settings
@@ -113,7 +113,7 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
setOpen(false)
}}
>
-
+
Notifications
@@ -125,19 +125,31 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
setOpen(false)
}}
>
-
+
Tokens & Fingerprints
{SettingsShortcut}
+ {
+ navigate(getPagePath($router, "settings", { name: "alert-history" }))
+ setOpen(false)
+ }}
+ >
+
+
+ Alert History
+
+ {SettingsShortcut}
+
{
window.location.href = "https://beszel.dev/guide/what-is-beszel"
}}
>
-
+
Documentation
@@ -155,7 +167,7 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
window.open(prependBasePath("/_/"), "_blank")
}}
>
-
+
Users
@@ -167,7 +179,7 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
window.open(prependBasePath("/_/#/logs"), "_blank")
}}
>
-
+
Logs
@@ -179,7 +191,7 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
window.open(prependBasePath("/_/#/settings/backups"), "_blank")
}}
>
-
+
Backups
@@ -192,7 +204,7 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
window.open(prependBasePath("/_/#/settings/mail"), "_blank")
}}
>
-
+
SMTP settings
diff --git a/beszel/site/src/components/routes/home.tsx b/beszel/site/src/components/routes/home.tsx
index a2b7776..d8686f1 100644
--- a/beszel/site/src/components/routes/home.tsx
+++ b/beszel/site/src/components/routes/home.tsx
@@ -110,10 +110,14 @@ const ActiveAlerts = memo(({ activeAlerts }: { activeAlerts: AlertRecord[] }) =>
{alert.sysname} {info.name().toLowerCase().replace("cpu", "CPU")}
-
- Exceeds {alert.value}
- {info.unit} in last
-
+ {alert.name === "Status" ? (
+ Connection is down
+ ) : (
+
+ Exceeds {alert.value}
+ {info.unit} in last
+
+ )}
{
+ return (
+
+
+ Alert History
+
+
+ View your 200 most recent alerts.
+
+
+ )
+})
export default function AlertsHistoryDataTable() {
- const alertsHistory = useStore($alertsHistory)
+ const [data, setData] = useState([])
+ const [sorting, setSorting] = useState([])
+ const [columnFilters, setColumnFilters] = useState([])
+ const [columnVisibility, setColumnVisibility] = useState({})
+ const [rowSelection, setRowSelection] = useState({})
+ const [globalFilter, setGlobalFilter] = useState("")
+ const { toast } = useToast()
+ const [deleteOpen, setDeleteDialogOpen] = useState(false)
- React.useEffect(() => {
- pb.collection("alerts_history")
- .getFullList({
- sort: "-created_date",
- expand: "system,user,alert"
- })
- .then(records => {
- $alertsHistory.set(records)
- })
- }, [])
+ useEffect(() => {
+ let unsubscribe: (() => void) | undefined
+ const pbOptions = {
+ expand: "system",
+ fields: "id,name,value,state,created,resolved,expand.system.name",
+ }
+ // Initial load
+ pb.collection("alerts_history")
+ .getFullList({
+ ...pbOptions,
+ sort: "-created",
+ })
+ .then((records) => setData(records))
- const [sorting, setSorting] = React.useState([])
- const [columnFilters, setColumnFilters] = React.useState([])
- const [columnVisibility, setColumnVisibility] = React.useState({})
- const [rowSelection, setRowSelection] = React.useState({})
- const [combinedFilter, setCombinedFilter] = React.useState("")
- const [globalFilter, setGlobalFilter] = React.useState("")
+ // Subscribe to changes
+ ;(async () => {
+ unsubscribe = await pb.collection("alerts_history").subscribe(
+ "*",
+ (e) => {
+ if (e.action === "create") {
+ setData((current) => [e.record as AlertsHistoryRecord, ...current])
+ }
+ if (e.action === "update") {
+ setData((current) => current.map((r) => (r.id === e.record.id ? (e.record as AlertsHistoryRecord) : r)))
+ }
+ if (e.action === "delete") {
+ setData((current) => current.filter((r) => r.id !== e.record.id))
+ }
+ },
+ pbOptions
+ )
+ })()
+ // Unsubscribe on unmount
+ return () => unsubscribe?.()
+ }, [])
- const table = useReactTable({
- data: alertsHistory,
- columns: [
- {
- id: "select",
- header: ({ table }) => (
- table.toggleAllPageRowsSelected(!!value)}
- aria-label="Select all"
- />
- ),
- cell: ({ row }) => (
- row.toggleSelected(!!value)}
- aria-label="Select row"
- />
- ),
- enableSorting: false,
- enableHiding: false,
- },
- ...alertsHistoryColumns,
- ],
- getCoreRowModel: getCoreRowModel(),
- getPaginationRowModel: getPaginationRowModel(),
- getSortedRowModel: getSortedRowModel(),
- getFilteredRowModel: getFilteredRowModel(),
- onSortingChange: setSorting,
- onColumnFiltersChange: setColumnFilters,
- onColumnVisibilityChange: setColumnVisibility,
- onRowSelectionChange: setRowSelection,
- globalFilterFn: (row, _columnId, filterValue) => {
- const system = row.original.expand?.system?.name || row.original.system || ""
- const name = row.getValue("name") || ""
- const search = String(filterValue).toLowerCase()
- return (
- system.toLowerCase().includes(search) ||
- String(name).toLowerCase().includes(search)
- )
- },
- state: {
- sorting,
- columnFilters,
- columnVisibility,
- rowSelection,
- globalFilter,
- },
- onGlobalFilterChange: setGlobalFilter,
- })
+ const table = useReactTable({
+ data,
+ columns: [
+ {
+ id: "select",
+ header: ({ table }) => (
+ table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ />
+ ),
+ cell: ({ row }) => (
+ row.toggleSelected(!!value)}
+ aria-label="Select row"
+ />
+ ),
+ enableSorting: false,
+ enableHiding: false,
+ },
+ ...alertsHistoryColumns,
+ ],
+ getCoreRowModel: getCoreRowModel(),
+ getPaginationRowModel: getPaginationRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ getFilteredRowModel: getFilteredRowModel(),
+ onSortingChange: setSorting,
+ onColumnFiltersChange: setColumnFilters,
+ onColumnVisibilityChange: setColumnVisibility,
+ onRowSelectionChange: setRowSelection,
+ state: {
+ sorting,
+ columnFilters,
+ columnVisibility,
+ rowSelection,
+ globalFilter,
+ },
+ onGlobalFilterChange: setGlobalFilter,
+ globalFilterFn: (row, _columnId, filterValue) => {
+ const system = row.original.expand?.system?.name ?? ""
+ const name = row.getValue("name") ?? ""
+ const created = row.getValue("created") ?? ""
+ const search = String(filterValue).toLowerCase()
+ return (
+ system.toLowerCase().includes(search) ||
+ (name as string).toLowerCase().includes(search) ||
+ (created as string).toLowerCase().includes(search)
+ )
+ },
+ })
- // Bulk delete handler
- const handleBulkDelete = async () => {
- if (!window.confirm("Are you sure you want to delete the selected records?")) return
- const selectedIds = table.getSelectedRowModel().rows.map(row => row.original.id)
- try {
- await Promise.all(selectedIds.map(id => pb.collection("alerts_history").delete(id)))
- $alertsHistory.set(alertsHistory.filter(r => !selectedIds.includes(r.id)))
- toast.success("Deleted selected records.")
- } catch (e) {
- toast.error("Failed to delete some records.")
- }
- }
+ // Bulk delete handler
+ const handleBulkDelete = async () => {
+ setDeleteDialogOpen(false)
+ const selectedIds = table.getSelectedRowModel().rows.map((row) => row.original.id)
+ try {
+ let batch = pb.createBatch()
+ let inBatch = 0
+ for (const id of selectedIds) {
+ batch.collection("alerts_history").delete(id)
+ inBatch++
+ if (inBatch > 20) {
+ await batch.send()
+ batch = pb.createBatch()
+ inBatch = 0
+ }
+ }
+ inBatch && (await batch.send())
+ table.resetRowSelection()
+ } catch (e) {
+ toast({
+ variant: "destructive",
+ title: t`Error`,
+ description: `Failed to delete records.`,
+ })
+ }
+ }
- // Export to CSV handler
- const handleExportCSV = () => {
- const selectedRows = table.getSelectedRowModel().rows
- if (!selectedRows.length) return
- const headers = ["system", "name", "value", "state", "created_date", "solved_date", "duration"]
- const csvRows = [headers.join(",")]
- for (const row of selectedRows) {
- const r = row.original
- csvRows.push([
- r.expand?.system?.name || r.system,
- r.name,
- r.value,
- r.state,
- r.created_date,
- r.solved_date,
- (() => {
- const created = r.created_date ? new Date(r.created_date) : null
- const solved = r.solved_date ? new Date(r.solved_date) : null
- if (!created || !solved) return ""
- const diffMs = solved.getTime() - created.getTime()
- if (diffMs < 0) return ""
- const totalSeconds = Math.floor(diffMs / 1000)
- const hours = Math.floor(totalSeconds / 3600)
- const minutes = Math.floor((totalSeconds % 3600) / 60)
- const seconds = totalSeconds % 60
- return [
- hours ? `${hours}h` : null,
- minutes ? `${minutes}m` : null,
- `${seconds}s`
- ].filter(Boolean).join(" ")
- })()
- ].map(v => `"${v ?? ""}"`).join(","))
- }
- const blob = new Blob([csvRows.join("\n")], { type: "text/csv" })
- const url = URL.createObjectURL(blob)
- const a = document.createElement("a")
- a.href = url
- a.download = "alerts_history.csv"
- a.click()
- URL.revokeObjectURL(url)
- }
+ // Export to CSV handler
+ const handleExportCSV = () => {
+ const selectedRows = table.getSelectedRowModel().rows
+ if (!selectedRows.length) return
+ const cells: Record string> = {
+ system: (record) => record.expand?.system?.name || record.system,
+ name: (record) => alertInfo[record.name]?.name() || record.name,
+ value: (record) => record.value + (alertInfo[record.name]?.unit ?? ""),
+ state: (record) => (record.resolved ? t`Resolved` : t`Active`),
+ created: (record) => formatShortDate(record.created),
+ resolved: (record) => (record.resolved ? formatShortDate(record.resolved) : ""),
+ duration: (record) => (record.resolved ? formatDuration(record.created, record.resolved) : ""),
+ }
+ const csvRows = [Object.keys(cells).join(",")]
+ for (const row of selectedRows) {
+ const r = row.original
+ csvRows.push(
+ Object.values(cells)
+ .map((val) => val(r))
+ .join(",")
+ )
+ }
+ const blob = new Blob([csvRows.join("\n")], { type: "text/csv" })
+ const url = URL.createObjectURL(blob)
+ const a = document.createElement("a")
+ a.href = url
+ a.download = "alerts_history.csv"
+ a.click()
+ URL.revokeObjectURL(url)
+ }
- return (
-
-
- setGlobalFilter(e.target.value)}
- className="max-w-sm"
- />
- {table.getFilteredSelectedRowModel().rows.length > 0 && (
- <>
-
-
- >
- )}
-
-
-
-
- {table.getHeaderGroups().map(headerGroup => (
-
- {headerGroup.headers.map(header => (
-
- {header.isPlaceholder
- ? null
- : flexRender(header.column.columnDef.header, header.getContext())}
-
- ))}
-
- ))}
-
-
- {table.getRowModel().rows.length ? (
- table.getRowModel().rows.map(row => (
-
- {row.getVisibleCells().map(cell => (
-
- {flexRender(cell.column.columnDef.cell, cell.getContext())}
-
- ))}
-
- ))
- ) : (
-
-
- No results.
-
-
- )}
-
-
-
-
-
- {table.getFilteredSelectedRowModel().rows.length} of {table.getFilteredRowModel().rows.length} row(s) selected.
-
-
-
-
-
-
-
- )
-}
\ No newline at end of file
+ return (
+
+
+
+
+ {table.getFilteredSelectedRowModel().rows.length > 0 && (
+
+
setDeleteDialogOpen(open)}>
+
+
+
+
+
+
+ Are you sure?
+
+
+ This will permanently delete all selected records from the database.
+
+
+
+
+ Cancel
+
+
+ Continue
+
+
+
+
+
+
+ )}
+
setGlobalFilter(e.target.value)}
+ className="px-4 w-full max-w-full @3xl:w-64"
+ />
+
+
+
+
+
+ {table.getHeaderGroups().map((headerGroup) => (
+
+ {headerGroup.headers.map((header) => (
+
+ {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
+
+ ))}
+
+ ))}
+
+
+ {table.getRowModel().rows.length ? (
+ table.getRowModel().rows.map((row) => (
+
+ {row.getVisibleCells().map((cell) => (
+
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
+
+ ))}
+
+ ))
+ ) : (
+
+
+ No results.
+
+
+ )}
+
+
+
+
+
+
+ {table.getFilteredSelectedRowModel().rows.length} of {table.getFilteredRowModel().rows.length} row(s)
+ selected.
+
+
+
+
+
+
+
+
+
+ Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/beszel/site/src/components/routes/settings/general.tsx b/beszel/site/src/components/routes/settings/general.tsx
index 0b4296d..c2cfb55 100644
--- a/beszel/site/src/components/routes/settings/general.tsx
+++ b/beszel/site/src/components/routes/settings/general.tsx
@@ -17,16 +17,11 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
const [isLoading, setIsLoading] = useState(false)
const { i18n } = useLingui()
- // Add state for alert history retention
- const [alertHistoryRetention, setAlertHistoryRetention] = useState(userSettings.alertHistoryRetention || "3m")
-
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setIsLoading(true)
const formData = new FormData(e.target as HTMLFormElement)
const data = Object.fromEntries(formData) as Partial
- // Add alertHistoryRetention to data
- data.alertHistoryRetention = alertHistoryRetention
await saveSettings(data)
setIsLoading(false)
}
@@ -187,27 +182,6 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
-
-
-
-
-
-
+
{!isLoading && (
<>
(
-
+