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 -
- - -
-