From 18d9258907f3957a808ec593538024ed8ee4d173 Mon Sep 17 00:00:00 2001 From: henrygd Date: Mon, 21 Jul 2025 20:07:52 -0400 Subject: [PATCH] Alert history updates --- beszel/internal/alerts/alerts.go | 8 + beszel/internal/alerts/alerts_history.go | 145 ++--- beszel/internal/alerts/alerts_status.go | 24 +- beszel/internal/alerts/alerts_system.go | 6 +- beszel/internal/hub/hub.go | 5 +- beszel/internal/hub/systems/system_manager.go | 5 +- beszel/internal/records/records.go | 55 +- beszel/internal/records/records_test.go | 381 +++++++++++ .../internal/records/records_test_helpers.go | 23 + ....go => 0_collections_snapshot_0_12_0_6.go} | 119 +++- beszel/migrations/1_create_alerts_history.go | 74 --- beszel/site/bun.lockb | Bin 207778 -> 208207 bytes beszel/site/package-lock.json | 22 +- beszel/site/package.json | 4 +- .../src/components/alerts-history-columns.tsx | 298 +++++---- .../site/src/components/command-palette.tsx | 32 +- beszel/site/src/components/routes/home.tsx | 12 +- .../settings/alerts-history-data-table.tsx | 602 +++++++++++------- .../components/routes/settings/general.tsx | 26 - .../src/components/routes/settings/layout.tsx | 12 +- .../routes/settings/sidebar-nav.tsx | 6 +- .../routes/settings/tokens-fingerprints.tsx | 2 +- .../systems-table/systems-table.tsx | 12 +- beszel/site/src/components/ui/checkbox.tsx | 7 +- beszel/site/src/components/ui/table.tsx | 2 +- beszel/site/src/lib/stores.ts | 3 - beszel/site/src/lib/utils.ts | 40 ++ beszel/site/src/types.d.ts | 18 +- beszel/site/tailwind.config.js | 1 + 29 files changed, 1301 insertions(+), 643 deletions(-) create mode 100644 beszel/internal/records/records_test.go create mode 100644 beszel/internal/records/records_test_helpers.go rename beszel/migrations/{1_collections_snapshot_0_12_0.go => 0_collections_snapshot_0_12_0_6.go} (86%) delete mode 100644 beszel/migrations/1_create_alerts_history.go 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 e16c071d3cf6e0c6c295c76cc1be076b4a5151e5..a6b98431fdb1d9f20b6544b6fd25a12c75d1b044 100755 GIT binary patch delta 34034 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 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 -
- - -
-