Alert history updates

This commit is contained in:
henrygd
2025-07-21 20:07:52 -04:00
parent 9d7fb8ab80
commit 18d9258907
29 changed files with 1301 additions and 643 deletions

View File

@@ -93,10 +93,18 @@ func NewAlertManager(app hubLike) *AlertManager {
alertQueue: make(chan alertTask), alertQueue: make(chan alertTask),
stopChan: make(chan struct{}), stopChan: make(chan struct{}),
} }
am.bindEvents()
go am.startWorker() go am.startWorker()
return am 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 { func (am *AlertManager) SendAlert(data AlertMessageData) error {
// get user settings // get user settings
record, err := am.hub.FindFirstRecordByFilter( record, err := am.hub.FindFirstRecordByFilter(

View File

@@ -7,90 +7,79 @@ import (
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
) )
func (am *AlertManager) RecordAlertHistory(alert SystemAlertData) { // On triggered alert record delete, set matching alert history record to resolved
// Get alert, user, system, name, value func resolveHistoryOnAlertDelete(e *core.RecordEvent) error {
alertId := alert.alertRecord.Id if !e.Record.GetBool("triggered") {
userId := "" return e.Next()
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)
}
} }
_ = resolveAlertHistoryRecord(e.App, e.Record)
return e.Next()
} }
// DeleteOldAlertHistory deletes alerts_history records older than the given retention duration // On alert record update, update alert history record
func (am *AlertManager) DeleteOldAlertHistory(retention time.Duration) { func updateHistoryOnAlertUpdate(e *core.RecordEvent) error {
now := time.Now().UTC() original := e.Record.Original()
cutoff := now.Add(-retention) new := e.Record
_, err := am.hub.DB().NewQuery(
"DELETE FROM alerts_history WHERE solved_date IS NOT NULL AND solved_date < {:cutoff}", originalTriggered := original.GetBool("triggered")
).Bind(dbx.Params{"cutoff": cutoff}).Execute() 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 { if err != nil {
am.hub.Logger().Error("failed to delete old alerts_history records", "error", err) return err
} }
} if len(alertHistoryRecords) == 0 {
return nil
// 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
} }
} alertHistoryRecord := alertHistoryRecords[0] // there should be only one record
alertHistoryRecord.Set("resolved", time.Now().UTC())
// CleanUpAllAlertHistory deletes old alerts_history records for each user based on their retention setting err = app.Save(alertHistoryRecord)
func (am *AlertManager) CleanUpAllAlertHistory() {
records, err := am.hub.FindAllRecords("user_settings")
if err != nil { if err != nil {
return app.Logger().Error("Failed to resolve alert history", "err", err)
}
for _, record := range records {
var settings map[string]interface{}
if err := record.UnmarshalJSONField("settings", &settings); err != nil {
continue
}
am.DeleteOldAlertHistory(getAlertHistoryRetention(settings))
} }
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
} }

View File

@@ -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. // 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 { 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 var emoji string
if alertStatus == "up" { if alertStatus == "up" {
emoji = "\u2705" // Green checkmark emoji 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) title := fmt.Sprintf("Connection to %s is %s %v", systemName, alertStatus, emoji)
message := strings.TrimSuffix(title, emoji) message := strings.TrimSuffix(title, emoji)
if errs := am.hub.ExpandRecord(alertRecord, []string{"user"}, nil); len(errs) > 0 { // if errs := am.hub.ExpandRecord(alertRecord, []string{"user"}, nil); len(errs) > 0 {
return errs["user"] // return errs["user"]
} // }
user := alertRecord.ExpandedOne("user") // user := alertRecord.ExpandedOne("user")
if user == nil { // if user == nil {
return nil // return nil
} // }
return am.SendAlert(AlertMessageData{ return am.SendAlert(AlertMessageData{
UserID: user.Id, UserID: alertRecord.GetString("user"),
Title: title, Title: title,
Message: message, Message: message,
Link: am.hub.MakeLink("system", systemName), Link: am.hub.MakeLink("system", systemName),

View File

@@ -15,7 +15,7 @@ import (
func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *system.CombinedData) error { func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *system.CombinedData) error {
alertRecords, err := am.hub.FindAllRecords("alerts", 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 { if err != nil || len(alertRecords) == 0 {
// log.Println("no alerts found for system") // 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) // app.Logger().Error("failed to save alert record", "err", err)
return return
} }
// Create Alert History
am.RecordAlertHistory(alert)
// expand the user relation and send the alert // expand the user relation and send the alert
if errs := am.hub.ExpandRecord(alert.alertRecord, []string{"user"}, nil); len(errs) > 0 { if errs := am.hub.ExpandRecord(alert.alertRecord, []string{"user"}, nil); len(errs) > 0 {
// app.Logger().Error("failed to expand user relation", "errs", errs) // app.Logger().Error("failed to expand user relation", "errs", errs)

View File

@@ -215,13 +215,10 @@ func (h *Hub) startServer(se *core.ServeEvent) error {
// registerCronJobs sets up scheduled tasks // registerCronJobs sets up scheduled tasks
func (h *Hub) registerCronJobs(_ *core.ServeEvent) error { 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) h.Cron().MustAdd("delete old records", "8 * * * *", h.rm.DeleteOldRecords)
// create longer records every 10 minutes // create longer records every 10 minutes
h.Cron().MustAdd("create longer records", "*/10 * * * *", h.rm.CreateLongerRecords) 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 return nil
} }

View File

@@ -159,8 +159,10 @@ func (sm *SystemManager) onRecordUpdate(e *core.RecordEvent) error {
// - down: Triggers status change alerts // - down: Triggers status change alerts
func (sm *SystemManager) onRecordAfterUpdateSuccess(e *core.RecordEvent) error { func (sm *SystemManager) onRecordAfterUpdateSuccess(e *core.RecordEvent) error {
newStatus := e.Record.GetString("status") newStatus := e.Record.GetString("status")
prevStatus := pending
system, ok := sm.systems.GetOk(e.Record.Id) system, ok := sm.systems.GetOk(e.Record.Id)
if ok { if ok {
prevStatus = system.Status
system.Status = newStatus system.Status = newStatus
} }
@@ -182,6 +184,7 @@ func (sm *SystemManager) onRecordAfterUpdateSuccess(e *core.RecordEvent) error {
if err := sm.AddRecord(e.Record, nil); err != nil { if err := sm.AddRecord(e.Record, nil); err != nil {
e.App.Logger().Error("Error adding record", "err", err) e.App.Logger().Error("Error adding record", "err", err)
} }
_ = deactivateAlerts(e.App, e.Record.Id)
return e.Next() return e.Next()
} }
@@ -190,8 +193,6 @@ func (sm *SystemManager) onRecordAfterUpdateSuccess(e *core.RecordEvent) error {
return sm.AddRecord(e.Record, nil) return sm.AddRecord(e.Record, nil)
} }
prevStatus := system.Status
// Trigger system alerts when system comes online // Trigger system alerts when system comes online
if newStatus == up { if newStatus == up {
if err := sm.hub.HandleSystemAlerts(e.Record, system.data); err != nil { if err := sm.hub.HandleSystemAlerts(e.Record, system.data); err != nil {

View File

@@ -366,12 +366,46 @@ func (rm *RecordManager) AverageContainerStats(db dbx.Builder, records RecordIds
return result return result
} }
// Deletes records older than what is displayed in the UI // Delete old records
func (rm *RecordManager) DeleteOldRecords() { 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"} collections := [2]string{"system_stats", "container_stats"}
// Define record types and their retention periods // Record types and their retention periods
type RecordDeletionData struct { type RecordDeletionData struct {
recordType string recordType string
retention time.Duration retention time.Duration
@@ -387,10 +421,9 @@ func (rm *RecordManager) DeleteOldRecords() {
now := time.Now().UTC() now := time.Now().UTC()
for _, collection := range collections { for _, collection := range collections {
// Build the WHERE clause dynamically // Build the WHERE clause
var conditionParts []string var conditionParts []string
var params dbx.Params = make(map[string]any) var params dbx.Params = make(map[string]any)
for i := range recordData { for i := range recordData {
rd := recordData[i] rd := recordData[i]
// Create parameterized condition for this record type // 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)) conditionParts = append(conditionParts, fmt.Sprintf("(type = '%s' AND created < {:%s})", rd.recordType, dateParam))
params[dateParam] = now.Add(-rd.retention) params[dateParam] = now.Add(-rd.retention)
} }
// Combine conditions with OR // Combine conditions with OR
conditionStr := strings.Join(conditionParts, " OR ") conditionStr := strings.Join(conditionParts, " OR ")
// Construct and execute the full raw query
// Construct the full raw query
rawQuery := fmt.Sprintf("DELETE FROM %s WHERE %s", collection, conditionStr) rawQuery := fmt.Sprintf("DELETE FROM %s WHERE %s", collection, conditionStr)
if _, err := app.DB().NewQuery(rawQuery).Bind(params).Execute(); err != nil {
// Execute the query with parameters return fmt.Errorf("failed to delete from %s: %v", collection, err)
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)
} }
} }
return nil
} }
/* Round float to two decimals */ /* Round float to two decimals */

View File

@@ -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)
}
}

View File

@@ -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)
}

View File

@@ -140,6 +140,124 @@ func init() {
], ],
"system": false "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", "id": "juohu4jipgc13v7",
"listRule": "@request.auth.id != \"\"", "listRule": "@request.auth.id != \"\"",
@@ -757,7 +875,6 @@ func init() {
LEFT JOIN fingerprints f ON s.id = f.system LEFT JOIN fingerprints f ON s.id = f.system
WHERE f.system IS NULL WHERE f.system IS NULL
`).Column(&systemIds) `).Column(&systemIds)
if err != nil { if err != nil {
return err return err
} }

View File

@@ -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)
}

Binary file not shown.

View File

@@ -40,7 +40,6 @@
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"recharts": "^2.15.3", "recharts": "^2.15.3",
"sonner": "^2.0.6",
"tailwind-merge": "^2.6.0", "tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"valibot": "^0.42.1" "valibot": "^0.42.1"
@@ -49,6 +48,7 @@
"@lingui/cli": "^5.3.2", "@lingui/cli": "^5.3.2",
"@lingui/swc-plugin": "^5.5.2", "@lingui/swc-plugin": "^5.5.2",
"@lingui/vite-plugin": "^5.3.2", "@lingui/vite-plugin": "^5.3.2",
"@tailwindcss/container-queries": "^0.1.1",
"@types/bun": "^1.2.15", "@types/bun": "^1.2.15",
"@types/react": "^18.3.23", "@types/react": "^18.3.23",
"@types/react-dom": "^18.3.7", "@types/react-dom": "^18.3.7",
@@ -2832,6 +2832,16 @@
"@swc/counter": "^0.1.3" "@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": { "node_modules/@tanstack/react-table": {
"version": "8.21.3", "version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz",
@@ -5484,16 +5494,6 @@
"url": "https://github.com/sponsors/isaacs" "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": { "node_modules/source-map": {
"version": "0.8.0-beta.0", "version": "0.8.0-beta.0",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz",

View File

@@ -43,7 +43,6 @@
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"recharts": "^2.15.3", "recharts": "^2.15.3",
"sonner": "^2.0.6",
"tailwind-merge": "^2.6.0", "tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"valibot": "^0.42.1" "valibot": "^0.42.1"
@@ -52,6 +51,7 @@
"@lingui/cli": "^5.3.2", "@lingui/cli": "^5.3.2",
"@lingui/swc-plugin": "^5.5.2", "@lingui/swc-plugin": "^5.5.2",
"@lingui/vite-plugin": "^5.3.2", "@lingui/vite-plugin": "^5.3.2",
"@tailwindcss/container-queries": "^0.1.1",
"@types/bun": "^1.2.15", "@types/bun": "^1.2.15",
"@types/react": "^18.3.23", "@types/react": "^18.3.23",
"@types/react-dom": "^18.3.7", "@types/react-dom": "^18.3.7",
@@ -71,4 +71,4 @@
"optionalDependencies": { "optionalDependencies": {
"@esbuild/linux-arm64": "^0.21.5" "@esbuild/linux-arm64": "^0.21.5"
} }
} }

View File

@@ -1,146 +1,164 @@
import { ColumnDef } from "@tanstack/react-table" import { ColumnDef } from "@tanstack/react-table"
import { AlertsHistoryRecord } from "@/types" import { AlertsHistoryRecord } from "@/types"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { ArrowUpDown } from "lucide-react"
import { Badge } from "@/components/ui/badge" 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<AlertsHistoryRecord>[] = [ export const alertsHistoryColumns: ColumnDef<AlertsHistoryRecord>[] = [
{ {
accessorKey: "system", accessorKey: "system",
header: ({ column }) => ( enableSorting: true,
<Button header: ({ column }) => (
variant="ghost" <Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}>
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} <Trans>System</Trans>
> </Button>
System <ArrowUpDown className="ml-2 h-4 w-4" /> ),
</Button> cell: ({ row }) => <span className="ps-2">{row.original.expand?.system?.name || row.original.system}</span>,
), filterFn: (row, _, filterValue) => {
cell: ({ row }) => <span className="text-center block">{row.original.expand?.system?.name || row.original.system}</span>, const display = row.original.expand?.system?.name || row.original.system || ""
enableSorting: true, return display.toLowerCase().includes(filterValue.toLowerCase())
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) => {
accessorKey: "name", const name = record.name
header: ({ column }) => ( const info = alertInfo[name]
<Button return info?.name().replace("cpu", "CPU") || name
variant="ghost" },
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} header: ({ column }) => (
> <Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}>
Name <ArrowUpDown className="ml-2 h-4 w-4" /> <Trans>Name</Trans>
</Button> </Button>
), ),
cell: ({ row }) => <span className="text-center block">{row.getValue("name")}</span>, cell: ({ getValue, row }) => {
enableSorting: true, let name = getValue() as string
filterFn: (row, _, filterValue) => { const info = alertInfo[row.original.name]
const value = row.getValue("name") || "" const Icon = info?.icon
return String(value).toLowerCase().includes(filterValue.toLowerCase())
}, return (
}, <span className="flex items-center gap-2 ps-1 min-w-40">
{ {Icon && <Icon className="size-3.5" />}
accessorKey: "value", {name}
header: ({ column }) => ( </span>
<Button )
variant="ghost" },
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} },
className="text-right w-full justify-end" {
> accessorKey: "value",
Value <ArrowUpDown className="ml-2 h-4 w-4" /> enableSorting: false,
</Button> header: () => (
), <Button variant="ghost">
cell: ({ row }) => <span className="text-center block">{Math.round(Number(row.getValue("value")))}</span>, <Trans>Value</Trans>
enableSorting: true, </Button>
}, ),
{ cell({ row, getValue }) {
accessorKey: "state", const name = row.original.name
header: ({ column }) => ( if (name === "Status") {
<Button return <span className="ps-2">{t`Down`}</span>
variant="ghost" }
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} const value = getValue() as number
className="text-center w-full justify-start" const unit = alertInfo[name]?.unit
> return (
State <ArrowUpDown className="ml-2 h-4 w-4" /> <span className="tabular-nums ps-2.5">
</Button> {toFixedFloat(value, value < 10 ? 2 : 1)}
), {unit}
cell: ({ row }) => { </span>
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 ( accessorKey: "state",
<span className="text-center block"> enableSorting: true,
<Badge className={`capitalize ${color}`}>{state}</Badge> sortingFn: (rowA, rowB) => (rowA.original.resolved ? 1 : 0) - (rowB.original.resolved ? 1 : 0),
</span> header: ({ column }) => (
) <Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}>
}, <Trans>State</Trans>
enableSorting: true, </Button>
}, ),
{ cell: ({ row }) => {
accessorKey: "create_date", const resolved = row.original.resolved
header: ({ column }) => ( return (
<Button <Badge
variant="ghost" className={cn(
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} "capitalize pointer-events-none",
> resolved
Created <ArrowUpDown className="ml-2 h-4 w-4" /> ? "bg-green-100 text-green-800 border-green-200 dark:opacity-80"
</Button> : "bg-yellow-100 text-yellow-800 border-yellow-200"
), )}
cell: ({ row }) => ( >
<span className="text-center block"> {/* {resolved ? <CircleCheckIcon className="size-3 me-0.5" /> : <CircleAlertIcon className="size-3 me-0.5" />} */}
{row.original.created_date ? new Date(row.original.created_date).toLocaleString() : ""} <Trans>{resolved ? "Resolved" : "Active"}</Trans>
</span> </Badge>
), )
enableSorting: true, },
}, },
{ {
accessorKey: "solved_date", accessorKey: "created",
header: ({ column }) => ( accessorFn: (record) => formatShortDate(record.created),
<Button enableSorting: true,
variant="ghost" invertSorting: true,
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} header: ({ column }) => (
> <Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}>
Solved <ArrowUpDown className="ml-2 h-4 w-4" /> <Trans>Created</Trans>
</Button> </Button>
), ),
cell: ({ row }) => ( cell: ({ getValue, row }) => (
<span className="text-center block"> <span className="ps-1 tabular-nums tracking-tight" title={`${row.original.created} UTC`}>
{row.original.solved_date ? new Date(row.original.solved_date).toLocaleString() : ""} {getValue() as string}
</span> </span>
), ),
enableSorting: true, },
}, {
{ accessorKey: "resolved",
accessorKey: "duration", enableSorting: true,
header: ({ column }) => ( invertSorting: true,
<Button header: ({ column }) => (
variant="ghost" <Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}>
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} <Trans>Resolved</Trans>
> </Button>
Duration <ArrowUpDown className="ml-2 h-4 w-4" /> ),
</Button> cell: ({ row, getValue }) => {
), const resolved = getValue() as string | null
cell: ({ row }) => { if (!resolved) {
const created = row.original.created_date ? new Date(row.original.created_date) : null return null
const solved = row.original.solved_date ? new Date(row.original.solved_date) : null }
if (!created || !solved) return <span className="text-center block"></span> return (
const diffMs = solved.getTime() - created.getTime() <span className="ps-1 tabular-nums tracking-tight" title={`${row.original.resolved} UTC`}>
if (diffMs < 0) return <span className="text-center block"></span> {formatShortDate(resolved)}
const totalSeconds = Math.floor(diffMs / 1000) </span>
const hours = Math.floor(totalSeconds / 3600) )
const minutes = Math.floor((totalSeconds % 3600) / 60) },
const seconds = totalSeconds % 60 },
return ( {
<span className="text-center block"> accessorKey: "duration",
{[ invertSorting: true,
hours ? `${hours}h` : null, enableSorting: true,
minutes ? `${minutes}m` : null, sortingFn: (rowA, rowB) => {
`${seconds}s` const aCreated = new Date(rowA.original.created)
].filter(Boolean).join(" ")} const bCreated = new Date(rowB.original.created)
</span> 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
enableSorting: true, 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 }) => (
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}>
<Trans>Duration</Trans>
</Button>
),
cell: ({ row }) => {
const duration = formatDuration(row.original.created, row.original.resolved)
if (!duration) {
return null
}
return <span className="ps-2">{duration}</span>
},
},
]

View File

@@ -69,7 +69,7 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
setOpen(false) setOpen(false)
}} }}
> >
<Server className="me-2 h-4 w-4" /> <Server className="me-2 size-4" />
<span>{system.name}</span> <span>{system.name}</span>
<CommandShortcut>{getHostDisplayValue(system)}</CommandShortcut> <CommandShortcut>{getHostDisplayValue(system)}</CommandShortcut>
</CommandItem> </CommandItem>
@@ -86,7 +86,7 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
setOpen(false) setOpen(false)
}} }}
> >
<LayoutDashboard className="me-2 h-4 w-4" /> <LayoutDashboard className="me-2 size-4" />
<span> <span>
<Trans>Dashboard</Trans> <Trans>Dashboard</Trans>
</span> </span>
@@ -100,7 +100,7 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
setOpen(false) setOpen(false)
}} }}
> >
<SettingsIcon className="me-2 h-4 w-4" /> <SettingsIcon className="me-2 size-4" />
<span> <span>
<Trans>Settings</Trans> <Trans>Settings</Trans>
</span> </span>
@@ -113,7 +113,7 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
setOpen(false) setOpen(false)
}} }}
> >
<MailIcon className="me-2 h-4 w-4" /> <MailIcon className="me-2 size-4" />
<span> <span>
<Trans>Notifications</Trans> <Trans>Notifications</Trans>
</span> </span>
@@ -125,19 +125,31 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
setOpen(false) setOpen(false)
}} }}
> >
<FingerprintIcon className="me-2 h-4 w-4" /> <FingerprintIcon className="me-2 size-4" />
<span> <span>
<Trans>Tokens & Fingerprints</Trans> <Trans>Tokens & Fingerprints</Trans>
</span> </span>
{SettingsShortcut} {SettingsShortcut}
</CommandItem> </CommandItem>
<CommandItem
onSelect={() => {
navigate(getPagePath($router, "settings", { name: "alert-history" }))
setOpen(false)
}}
>
<LogsIcon className="me-2 size-4" />
<span>
<Trans>Alert History</Trans>
</span>
{SettingsShortcut}
</CommandItem>
<CommandItem <CommandItem
keywords={["help", "oauth", "oidc"]} keywords={["help", "oauth", "oidc"]}
onSelect={() => { onSelect={() => {
window.location.href = "https://beszel.dev/guide/what-is-beszel" window.location.href = "https://beszel.dev/guide/what-is-beszel"
}} }}
> >
<BookIcon className="me-2 h-4 w-4" /> <BookIcon className="me-2 size-4" />
<span> <span>
<Trans>Documentation</Trans> <Trans>Documentation</Trans>
</span> </span>
@@ -155,7 +167,7 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
window.open(prependBasePath("/_/"), "_blank") window.open(prependBasePath("/_/"), "_blank")
}} }}
> >
<UsersIcon className="me-2 h-4 w-4" /> <UsersIcon className="me-2 size-4" />
<span> <span>
<Trans>Users</Trans> <Trans>Users</Trans>
</span> </span>
@@ -167,7 +179,7 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
window.open(prependBasePath("/_/#/logs"), "_blank") window.open(prependBasePath("/_/#/logs"), "_blank")
}} }}
> >
<LogsIcon className="me-2 h-4 w-4" /> <LogsIcon className="me-2 size-4" />
<span> <span>
<Trans>Logs</Trans> <Trans>Logs</Trans>
</span> </span>
@@ -179,7 +191,7 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
window.open(prependBasePath("/_/#/settings/backups"), "_blank") window.open(prependBasePath("/_/#/settings/backups"), "_blank")
}} }}
> >
<DatabaseBackupIcon className="me-2 h-4 w-4" /> <DatabaseBackupIcon className="me-2 size-4" />
<span> <span>
<Trans>Backups</Trans> <Trans>Backups</Trans>
</span> </span>
@@ -192,7 +204,7 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
window.open(prependBasePath("/_/#/settings/mail"), "_blank") window.open(prependBasePath("/_/#/settings/mail"), "_blank")
}} }}
> >
<MailIcon className="me-2 h-4 w-4" /> <MailIcon className="me-2 size-4" />
<span> <span>
<Trans>SMTP settings</Trans> <Trans>SMTP settings</Trans>
</span> </span>

View File

@@ -110,10 +110,14 @@ const ActiveAlerts = memo(({ activeAlerts }: { activeAlerts: AlertRecord[] }) =>
{alert.sysname} {info.name().toLowerCase().replace("cpu", "CPU")} {alert.sysname} {info.name().toLowerCase().replace("cpu", "CPU")}
</AlertTitle> </AlertTitle>
<AlertDescription> <AlertDescription>
<Trans> {alert.name === "Status" ? (
Exceeds {alert.value} <Trans>Connection is down</Trans>
{info.unit} in last <Plural value={alert.min} one="# minute" other="# minutes" /> ) : (
</Trans> <Trans>
Exceeds {alert.value}
{info.unit} in last <Plural value={alert.min} one="# minute" other="# minutes" />
</Trans>
)}
</AlertDescription> </AlertDescription>
<Link <Link
href={getPagePath($router, "system", { name: alert.sysname! })} href={getPagePath($router, "system", { name: alert.sysname! })}

View File

@@ -1,247 +1,385 @@
"use client" import { pb } from "@/lib/stores"
import { alertInfo, cn, formatDuration, formatShortDate } from "@/lib/utils"
import * as React from "react"
import { useStore } from "@nanostores/react"
import { $alertsHistory, pb } from "@/lib/stores"
import { AlertsHistoryRecord } from "@/types" import { AlertsHistoryRecord } from "@/types"
import { import {
getCoreRowModel, getCoreRowModel,
getPaginationRowModel, getPaginationRowModel,
getSortedRowModel, getSortedRowModel,
getFilteredRowModel, getFilteredRowModel,
useReactTable, useReactTable,
flexRender, flexRender,
ColumnFiltersState, ColumnFiltersState,
SortingState, SortingState,
VisibilityState, VisibilityState,
} from "@tanstack/react-table" } from "@tanstack/react-table"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Button } from "@/components/ui/button" import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { alertsHistoryColumns } from "../../alerts-history-columns" import { alertsHistoryColumns } from "../../alerts-history-columns"
import { Checkbox } from "@/components/ui/checkbox" import { Checkbox } from "@/components/ui/checkbox"
import { toast } from "sonner" import { memo, useEffect, useState } from "react"
import { Label } from "@/components/ui/label"
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select"
import {
ChevronLeftIcon,
ChevronRightIcon,
ChevronsLeftIcon,
ChevronsRightIcon,
DownloadIcon,
Trash2Icon,
} from "lucide-react"
import { Trans } from "@lingui/react/macro"
import { t } from "@lingui/core/macro"
import { useToast } from "@/components/ui/use-toast"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
const SectionIntro = memo(() => {
return (
<div>
<h3 className="text-xl font-medium mb-2">
<Trans>Alert History</Trans>
</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
<Trans>View your 200 most recent alerts.</Trans>
</p>
</div>
)
})
export default function AlertsHistoryDataTable() { export default function AlertsHistoryDataTable() {
const alertsHistory = useStore($alertsHistory) const [data, setData] = useState<AlertsHistoryRecord[]>([])
const [sorting, setSorting] = useState<SortingState>([])
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
const [rowSelection, setRowSelection] = useState({})
const [globalFilter, setGlobalFilter] = useState("")
const { toast } = useToast()
const [deleteOpen, setDeleteDialogOpen] = useState(false)
React.useEffect(() => { useEffect(() => {
pb.collection<AlertsHistoryRecord>("alerts_history") let unsubscribe: (() => void) | undefined
.getFullList({ const pbOptions = {
sort: "-created_date", expand: "system",
expand: "system,user,alert" fields: "id,name,value,state,created,resolved,expand.system.name",
}) }
.then(records => { // Initial load
$alertsHistory.set(records) pb.collection<AlertsHistoryRecord>("alerts_history")
}) .getFullList({
}, []) ...pbOptions,
sort: "-created",
})
.then((records) => setData(records))
const [sorting, setSorting] = React.useState<SortingState>([]) // Subscribe to changes
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([]) ;(async () => {
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({}) unsubscribe = await pb.collection("alerts_history").subscribe(
const [rowSelection, setRowSelection] = React.useState({}) "*",
const [combinedFilter, setCombinedFilter] = React.useState("") (e) => {
const [globalFilter, setGlobalFilter] = React.useState("") 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({ const table = useReactTable({
data: alertsHistory, data,
columns: [ columns: [
{ {
id: "select", id: "select",
header: ({ table }) => ( header: ({ table }) => (
<Checkbox <Checkbox
checked={ className="ms-2"
table.getIsAllPageRowsSelected() || checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")}
(table.getIsSomePageRowsSelected() && "indeterminate") onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
} aria-label="Select all"
onCheckedChange={value => table.toggleAllPageRowsSelected(!!value)} />
aria-label="Select all" ),
/> cell: ({ row }) => (
), <Checkbox
cell: ({ row }) => ( checked={row.getIsSelected()}
<Checkbox onCheckedChange={(value) => row.toggleSelected(!!value)}
checked={row.getIsSelected()} aria-label="Select row"
onCheckedChange={value => row.toggleSelected(!!value)} />
aria-label="Select row" ),
/> enableSorting: false,
), enableHiding: false,
enableSorting: false, },
enableHiding: false, ...alertsHistoryColumns,
}, ],
...alertsHistoryColumns, getCoreRowModel: getCoreRowModel(),
], getPaginationRowModel: getPaginationRowModel(),
getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(), getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(), onSortingChange: setSorting,
getFilteredRowModel: getFilteredRowModel(), onColumnFiltersChange: setColumnFilters,
onSortingChange: setSorting, onColumnVisibilityChange: setColumnVisibility,
onColumnFiltersChange: setColumnFilters, onRowSelectionChange: setRowSelection,
onColumnVisibilityChange: setColumnVisibility, state: {
onRowSelectionChange: setRowSelection, sorting,
globalFilterFn: (row, _columnId, filterValue) => { columnFilters,
const system = row.original.expand?.system?.name || row.original.system || "" columnVisibility,
const name = row.getValue("name") || "" rowSelection,
const search = String(filterValue).toLowerCase() globalFilter,
return ( },
system.toLowerCase().includes(search) || onGlobalFilterChange: setGlobalFilter,
String(name).toLowerCase().includes(search) globalFilterFn: (row, _columnId, filterValue) => {
) const system = row.original.expand?.system?.name ?? ""
}, const name = row.getValue("name") ?? ""
state: { const created = row.getValue("created") ?? ""
sorting, const search = String(filterValue).toLowerCase()
columnFilters, return (
columnVisibility, system.toLowerCase().includes(search) ||
rowSelection, (name as string).toLowerCase().includes(search) ||
globalFilter, (created as string).toLowerCase().includes(search)
}, )
onGlobalFilterChange: setGlobalFilter, },
}) })
// Bulk delete handler // Bulk delete handler
const handleBulkDelete = async () => { const handleBulkDelete = async () => {
if (!window.confirm("Are you sure you want to delete the selected records?")) return setDeleteDialogOpen(false)
const selectedIds = table.getSelectedRowModel().rows.map(row => row.original.id) const selectedIds = table.getSelectedRowModel().rows.map((row) => row.original.id)
try { try {
await Promise.all(selectedIds.map(id => pb.collection("alerts_history").delete(id))) let batch = pb.createBatch()
$alertsHistory.set(alertsHistory.filter(r => !selectedIds.includes(r.id))) let inBatch = 0
toast.success("Deleted selected records.") for (const id of selectedIds) {
} catch (e) { batch.collection("alerts_history").delete(id)
toast.error("Failed to delete some records.") 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 // Export to CSV handler
const handleExportCSV = () => { const handleExportCSV = () => {
const selectedRows = table.getSelectedRowModel().rows const selectedRows = table.getSelectedRowModel().rows
if (!selectedRows.length) return if (!selectedRows.length) return
const headers = ["system", "name", "value", "state", "created_date", "solved_date", "duration"] const cells: Record<string, (record: AlertsHistoryRecord) => string> = {
const csvRows = [headers.join(",")] system: (record) => record.expand?.system?.name || record.system,
for (const row of selectedRows) { name: (record) => alertInfo[record.name]?.name() || record.name,
const r = row.original value: (record) => record.value + (alertInfo[record.name]?.unit ?? ""),
csvRows.push([ state: (record) => (record.resolved ? t`Resolved` : t`Active`),
r.expand?.system?.name || r.system, created: (record) => formatShortDate(record.created),
r.name, resolved: (record) => (record.resolved ? formatShortDate(record.resolved) : ""),
r.value, duration: (record) => (record.resolved ? formatDuration(record.created, record.resolved) : ""),
r.state, }
r.created_date, const csvRows = [Object.keys(cells).join(",")]
r.solved_date, for (const row of selectedRows) {
(() => { const r = row.original
const created = r.created_date ? new Date(r.created_date) : null csvRows.push(
const solved = r.solved_date ? new Date(r.solved_date) : null Object.values(cells)
if (!created || !solved) return "" .map((val) => val(r))
const diffMs = solved.getTime() - created.getTime() .join(",")
if (diffMs < 0) return "" )
const totalSeconds = Math.floor(diffMs / 1000) }
const hours = Math.floor(totalSeconds / 3600) const blob = new Blob([csvRows.join("\n")], { type: "text/csv" })
const minutes = Math.floor((totalSeconds % 3600) / 60) const url = URL.createObjectURL(blob)
const seconds = totalSeconds % 60 const a = document.createElement("a")
return [ a.href = url
hours ? `${hours}h` : null, a.download = "alerts_history.csv"
minutes ? `${minutes}m` : null, a.click()
`${seconds}s` URL.revokeObjectURL(url)
].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)
}
return ( return (
<div className="w-full"> <div className="@container w-full">
<div className="flex items-center py-4 gap-4"> <div className="@3xl:flex items-end mb-4 gap-4">
<Input <SectionIntro />
placeholder="Filter system or name..." <div className="flex items-center gap-2 ms-auto mt-3 @3xl:mt-0">
value={globalFilter} {table.getFilteredSelectedRowModel().rows.length > 0 && (
onChange={e => setGlobalFilter(e.target.value)} <div className="fixed bottom-0 left-0 w-full p-4 grid grid-cols-2 items-center gap-4 z-50 backdrop-blur-md shrink-0 @lg:static @lg:p-0 @lg:w-auto @lg:gap-3">
className="max-w-sm" <AlertDialog open={deleteOpen} onOpenChange={(open) => setDeleteDialogOpen(open)}>
/> <AlertDialogTrigger asChild>
{table.getFilteredSelectedRowModel().rows.length > 0 && ( <Button variant="destructive" className="h-9 shrink-0">
<> <Trash2Icon className="size-4 shrink-0" />
<Button <span className="ms-1">
variant="destructive" <Trans>Delete</Trans>
onClick={handleBulkDelete} </span>
size="sm" </Button>
> </AlertDialogTrigger>
Delete Selected <AlertDialogContent>
</Button> <AlertDialogHeader>
<Button <AlertDialogTitle>
variant="outline" <Trans>Are you sure?</Trans>
onClick={handleExportCSV} </AlertDialogTitle>
size="sm" <AlertDialogDescription>
> <Trans>This will permanently delete all selected records from the database.</Trans>
Export Selected </AlertDialogDescription>
</Button> </AlertDialogHeader>
</> <AlertDialogFooter>
)} <AlertDialogCancel>
</div> <Trans>Cancel</Trans>
<div className="rounded-md border overflow-x-auto"> </AlertDialogCancel>
<Table> <AlertDialogAction
<TableHeader> className={cn(buttonVariants({ variant: "destructive" }))}
{table.getHeaderGroups().map(headerGroup => ( onClick={handleBulkDelete}
<TableRow key={headerGroup.id}> >
{headerGroup.headers.map(header => ( <Trans>Continue</Trans>
<TableHead key={header.id}> </AlertDialogAction>
{header.isPlaceholder </AlertDialogFooter>
? null </AlertDialogContent>
: flexRender(header.column.columnDef.header, header.getContext())} </AlertDialog>
</TableHead> <Button variant="outline" className="h-10" onClick={handleExportCSV}>
))} <DownloadIcon className="size-4" />
</TableRow> <span className="ms-1">
))} <Trans>Export</Trans>
</TableHeader> </span>
<TableBody> </Button>
{table.getRowModel().rows.length ? ( </div>
table.getRowModel().rows.map(row => ( )}
<TableRow <Input
key={row.id} placeholder={t`Filter...`}
data-state={row.getIsSelected() && "selected"} value={globalFilter}
> onChange={(e) => setGlobalFilter(e.target.value)}
{row.getVisibleCells().map(cell => ( className="px-4 w-full max-w-full @3xl:w-64"
<TableCell key={cell.id}> />
{flexRender(cell.column.columnDef.cell, cell.getContext())} </div>
</TableCell> </div>
))} <div className="rounded-md border overflow-x-auto whitespace-nowrap">
</TableRow> <Table>
)) <TableHeader>
) : ( {table.getHeaderGroups().map((headerGroup) => (
<TableRow> <TableRow key={headerGroup.id}>
<TableCell colSpan={table.getAllColumns().length} className="h-24 text-center"> {headerGroup.headers.map((header) => (
No results. <TableHead className="px-2" key={header.id}>
</TableCell> {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</TableRow> </TableHead>
)} ))}
</TableBody> </TableRow>
</Table> ))}
</div> </TableHeader>
<div className="flex items-center justify-end space-x-2 py-4"> <TableBody>
<div className="text-muted-foreground flex-1 text-sm"> {table.getRowModel().rows.length ? (
{table.getFilteredSelectedRowModel().rows.length} of {table.getFilteredRowModel().rows.length} row(s) selected. table.getRowModel().rows.map((row) => (
</div> <TableRow key={row.id} data-state={row.getIsSelected() && "selected"}>
<div className="space-x-2"> {row.getVisibleCells().map((cell) => (
<Button <TableCell key={cell.id} className="py-3">
variant="outline" {flexRender(cell.column.columnDef.cell, cell.getContext())}
size="sm" </TableCell>
onClick={() => table.previousPage()} ))}
disabled={!table.getCanPreviousPage()} </TableRow>
> ))
Previous ) : (
</Button> <TableRow>
<Button <TableCell colSpan={table.getAllColumns().length} className="h-24 text-center">
variant="outline" <Trans>No results.</Trans>
size="sm" </TableCell>
onClick={() => table.nextPage()} </TableRow>
disabled={!table.getCanNextPage()} )}
> </TableBody>
Next </Table>
</Button> </div>
</div> <div className="flex items-center justify-between ps-1 tabular-nums">
</div> <div className="text-muted-foreground hidden flex-1 text-sm lg:flex">
</div> <Trans>
) {table.getFilteredSelectedRowModel().rows.length} of {table.getFilteredRowModel().rows.length} row(s)
} selected.
</Trans>
</div>
<div className="flex w-full items-center gap-8 lg:w-fit my-3">
<div className="hidden items-center gap-2 lg:flex">
<Label htmlFor="rows-per-page" className="text-sm font-medium">
<Trans>Rows per page</Trans>
</Label>
<Select
value={`${table.getState().pagination.pageSize}`}
onValueChange={(value) => {
table.setPageSize(Number(value))
}}
>
<SelectTrigger className="w-[4.8em]" id="rows-per-page">
<SelectValue placeholder={table.getState().pagination.pageSize} />
</SelectTrigger>
<SelectContent side="top">
{[10, 20, 50, 100, 200].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex w-fit items-center justify-center text-sm font-medium">
<Trans>
Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
</Trans>
</div>
<div className="ms-auto flex items-center gap-2 lg:ms-0">
<Button
variant="outline"
className="hidden size-9 p-0 lg:flex"
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to first page</span>
<ChevronsLeftIcon className="size-5" />
</Button>
<Button
variant="outline"
className="size-9"
size="icon"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to previous page</span>
<ChevronLeftIcon className="size-5" />
</Button>
<Button
variant="outline"
className="size-9"
size="icon"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to next page</span>
<ChevronRightIcon className="size-5" />
</Button>
<Button
variant="outline"
className="hidden size-9 lg:flex"
size="icon"
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to last page</span>
<ChevronsRightIcon className="size-5" />
</Button>
</div>
</div>
</div>
</div>
)
}

View File

@@ -17,16 +17,11 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const { i18n } = useLingui() const { i18n } = useLingui()
// Add state for alert history retention
const [alertHistoryRetention, setAlertHistoryRetention] = useState(userSettings.alertHistoryRetention || "3m")
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) { async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault() e.preventDefault()
setIsLoading(true) setIsLoading(true)
const formData = new FormData(e.target as HTMLFormElement) const formData = new FormData(e.target as HTMLFormElement)
const data = Object.fromEntries(formData) as Partial<UserSettings> const data = Object.fromEntries(formData) as Partial<UserSettings>
// Add alertHistoryRetention to data
data.alertHistoryRetention = alertHistoryRetention
await saveSettings(data) await saveSettings(data)
setIsLoading(false) setIsLoading(false)
} }
@@ -187,27 +182,6 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
</div> </div>
</div> </div>
<Separator /> <Separator />
<div>
<Label htmlFor="alertHistoryRetention">
<Trans>Alert History Retention</Trans>
</Label>
<Select
name="alertHistoryRetention"
value={alertHistoryRetention}
onValueChange={setAlertHistoryRetention}
>
<SelectTrigger className="w-64 mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1m">1 month</SelectItem>
<SelectItem value="3m">3 months</SelectItem>
<SelectItem value="6m">6 months</SelectItem>
<SelectItem value="1y">1 year</SelectItem>
</SelectContent>
</Select>
</div>
<Separator />
<Button type="submit" className="flex items-center gap-1.5 disabled:opacity-100" disabled={isLoading}> <Button type="submit" className="flex items-center gap-1.5 disabled:opacity-100" disabled={isLoading}>
{isLoading ? <LoaderCircleIcon className="h-4 w-4 animate-spin" /> : <SaveIcon className="h-4 w-4" />} {isLoading ? <LoaderCircleIcon className="h-4 w-4 animate-spin" /> : <SaveIcon className="h-4 w-4" />}
<Trans>Save Settings</Trans> <Trans>Save Settings</Trans>

View File

@@ -66,17 +66,17 @@ export default function SettingsLayout() {
icon: FingerprintIcon, icon: FingerprintIcon,
noReadOnly: true, noReadOnly: true,
}, },
{
title: t`Alert History`,
href: getPagePath($router, "settings", { name: "alert-history" }),
icon: LogsIcon,
},
{ {
title: t`YAML Config`, title: t`YAML Config`,
href: getPagePath($router, "settings", { name: "config" }), href: getPagePath($router, "settings", { name: "config" }),
icon: FileSlidersIcon, icon: FileSlidersIcon,
admin: true, admin: true,
}, },
{
title: t`Alerts History`,
href: getPagePath($router, "settings", { name: "alerts-history" }),
icon: LogsIcon,
},
] ]
const page = useStore($router) const page = useStore($router)
@@ -127,7 +127,7 @@ function SettingsContent({ name }: { name: string }) {
return <ConfigYaml /> return <ConfigYaml />
case "tokens": case "tokens":
return <Fingerprints /> return <Fingerprints />
case "alerts-history": case "alert-history":
return <AlertsHistoryDataTable /> return <AlertsHistoryDataTable />
} }
} }

View File

@@ -33,7 +33,7 @@ export function SidebarNav({ className, items, ...props }: SidebarNavProps) {
return ( return (
<SelectItem key={item.href} value={item.href}> <SelectItem key={item.href} value={item.href}>
<span className="flex items-center gap-2 truncate"> <span className="flex items-center gap-2 truncate">
{item.icon && <item.icon className="h-4 w-4" />} {item.icon && <item.icon className="size-4" />}
<span className="truncate">{item.title}</span> <span className="truncate">{item.title}</span>
</span> </span>
</SelectItem> </SelectItem>
@@ -45,7 +45,7 @@ export function SidebarNav({ className, items, ...props }: SidebarNavProps) {
</div> </div>
{/* Desktop View */} {/* Desktop View */}
<nav className={cn("hidden md:grid gap-1", className)} {...props}> <nav className={cn("hidden md:grid gap-1 sticky top-6", className)} {...props}>
{items.map((item) => { {items.map((item) => {
if ((item.admin && !isAdmin()) || (item.noReadOnly && isReadOnlyUser())) { if ((item.admin && !isAdmin()) || (item.noReadOnly && isReadOnlyUser())) {
return null return null
@@ -60,7 +60,7 @@ export function SidebarNav({ className, items, ...props }: SidebarNavProps) {
page?.path === item.href ? "bg-muted hover:bg-muted" : "hover:bg-muted/50" page?.path === item.href ? "bg-muted hover:bg-muted" : "hover:bg-muted/50"
)} )}
> >
{item.icon && <item.icon className="h-4 w-4 shrink-0" />} {item.icon && <item.icon className="size-4 shrink-0" />}
<span className="truncate">{item.title}</span> <span className="truncate">{item.title}</span>
</Link> </Link>
) )

View File

@@ -159,7 +159,7 @@ const SectionUniversalToken = memo(() => {
or on hub restart. or on hub restart.
</Trans> </Trans>
</p> </p>
<div className="min-h-16 overflow-auto max-w-full inline-flex items-center gap-5 mt-3 border py-2 pl-5 pr-4 rounded-md"> <div className="min-h-16 overflow-auto max-w-full inline-flex items-center gap-5 mt-3 border py-2 ps-5 pe-4 rounded-md">
{!isLoading && ( {!isLoading && (
<> <>
<Switch <Switch

View File

@@ -173,7 +173,7 @@ export default function SystemsTable() {
invertSorting: false, invertSorting: false,
Icon: ServerIcon, Icon: ServerIcon,
cell: (info) => ( cell: (info) => (
<span className="flex gap-0.5 items-center text-base md:pe-5"> <span className="flex gap-0.5 items-center text-base md:ps-1 md:pe-5">
<IndicatorDot system={info.row.original} /> <IndicatorDot system={info.row.original} />
<Button <Button
data-nolink data-nolink
@@ -233,7 +233,7 @@ export default function SystemsTable() {
header: sortableHeader, header: sortableHeader,
cell(info: CellContext<SystemRecord, unknown>) { cell(info: CellContext<SystemRecord, unknown>) {
const { info: sysInfo, status } = info.row.original const { info: sysInfo, status } = info.row.original
if (sysInfo.l1 == undefined) { if (sysInfo.l1 === undefined) {
return null return null
} }
@@ -245,13 +245,13 @@ export default function SystemsTable() {
const normalized = max / cpuThreads const normalized = max / cpuThreads
if (status !== "up") return "bg-primary/30" if (status !== "up") return "bg-primary/30"
if (normalized < 0.7) return "bg-green-500" if (normalized < 0.7) return "bg-green-500"
if (normalized < 1.0) return "bg-yellow-500" if (normalized < 1) return "bg-yellow-500"
return "bg-red-600" return "bg-red-600"
} }
return ( return (
<div className="flex items-center gap-2 w-full tabular-nums tracking-tight"> <div className="flex items-center gap-[.35em] w-full tabular-nums tracking-tight">
<span className={cn("inline-block size-2 rounded-full", getDotColor())} /> <span className={cn("inline-block size-2 rounded-full me-0.5", getDotColor())} />
{loadAverages.map((la, i) => ( {loadAverages.map((la, i) => (
<span key={i}>{decimalString(la, la >= 10 ? 1 : 2)}</span> <span key={i}>{decimalString(la, la >= 10 ? 1 : 2)}</span>
))} ))}
@@ -267,7 +267,7 @@ export default function SystemsTable() {
Icon: EthernetIcon, Icon: EthernetIcon,
header: sortableHeader, header: sortableHeader,
cell(info) { cell(info) {
if (info.row.original.status !== "up") { if (info.row.original.status === "paused") {
return null return null
} }
const val = info.getValue() as number const val = info.getValue() as number

View File

@@ -1,3 +1,5 @@
"use client"
import * as React from "react" import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox" import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react" import { Check } from "lucide-react"
@@ -11,13 +13,14 @@ const Checkbox = React.forwardRef<
<CheckboxPrimitive.Root <CheckboxPrimitive.Root
ref={ref} ref={ref}
className={cn( className={cn(
"peer h-4 w-4 shrink-0 rounded-[.3em] border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground", "peer size-4 flex items-center justify-center shrink-0 rounded-[.3em] border border-input ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className className
)} )}
{...props} {...props}
> >
<CheckboxPrimitive.Indicator className={cn("flex items-center justify-center text-current")}> <CheckboxPrimitive.Indicator className={cn("flex items-center justify-center text-current")}>
<Check className="h-4 w-4" /> <Check className="size-4" />
</CheckboxPrimitive.Indicator> </CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root> </CheckboxPrimitive.Root>
)) ))

View File

@@ -37,7 +37,7 @@ const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTML
<tr <tr
ref={ref} ref={ref}
className={cn( className={cn(
"border-b border-border/60 hover:bg-muted/40 dark:hover:bg-muted/20 data-[state=selected]:bg-muted", "border-b border-border/60 hover:bg-muted/40 dark:hover:bg-muted/20 data-[state=selected]:!bg-muted",
className className
)} )}
{...props} {...props}

View File

@@ -15,9 +15,6 @@ export const $systems = atom([] as SystemRecord[])
/** List of alert records */ /** List of alert records */
export const $alerts = atom([] as AlertRecord[]) export const $alerts = atom([] as AlertRecord[])
/** List of alerts history records */
export const $alertsHistory = atom([] as AlertsHistoryRecord[])
/** SSH public key */ /** SSH public key */
export const $publicKey = atom("") export const $publicKey = atom("")

View File

@@ -367,6 +367,7 @@ export async function updateUserSettings() {
export const chartMargin = { top: 12 } export const chartMargin = { top: 12 }
/** Alert info for each alert type */
export const alertInfo: Record<string, AlertInfo> = { export const alertInfo: Record<string, AlertInfo> = {
Status: { Status: {
name: () => t`Status`, name: () => t`Status`,
@@ -455,3 +456,42 @@ export const getHubURL = () => BESZEL?.HUB_URL || window.location.origin
/** Map of system IDs to their corresponding tokens (used to avoid fetching in add-system dialog) */ /** Map of system IDs to their corresponding tokens (used to avoid fetching in add-system dialog) */
export const tokenMap = new Map<SystemRecord["id"], FingerprintRecord["token"]>() export const tokenMap = new Map<SystemRecord["id"], FingerprintRecord["token"]>()
/** Calculate duration between two dates and format as human-readable string */
export function formatDuration(
createdDate: string | null | undefined,
resolvedDate: string | null | undefined
): string {
const created = createdDate ? new Date(createdDate) : null
const resolved = resolvedDate ? new Date(resolvedDate) : null
if (!created || !resolved) return ""
const diffMs = resolved.getTime() - created.getTime()
if (diffMs < 0) return ""
const totalSeconds = Math.floor(diffMs / 1000)
let hours = Math.floor(totalSeconds / 3600)
let minutes = Math.floor((totalSeconds % 3600) / 60)
let seconds = totalSeconds % 60
// if seconds are close to 60, round up to next minute
// if minutes are close to 60, round up to next hour
if (seconds >= 58) {
minutes += 1
seconds = 0
}
if (minutes >= 60) {
hours += 1
minutes = 0
}
// For durations over 1 hour, omit seconds for cleaner display
if (hours > 0) {
return [hours ? `${hours}h` : null, minutes ? `${minutes}m` : null].filter(Boolean).join(" ")
}
return [hours ? `${hours}h` : null, minutes ? `${minutes}m` : null, seconds ? `${seconds}s` : null]
.filter(Boolean)
.join(" ")
}

View File

@@ -190,14 +190,13 @@ export interface AlertRecord extends RecordModel {
} }
export interface AlertsHistoryRecord extends RecordModel { export interface AlertsHistoryRecord extends RecordModel {
alert: string; alert: string
user: string; user: string
system: string; system: string
name: string; name: string
value: number; val: number
state: "active" | "solved"; created: string
created_date: string; resolved?: string | null
solved_date?: string | null;
} }
export type ChartTimes = "1h" | "12h" | "24h" | "1w" | "30d" export type ChartTimes = "1h" | "12h" | "24h" | "1w" | "30d"
@@ -221,9 +220,6 @@ export interface UserSettings {
unitTemp?: Unit unitTemp?: Unit
unitNet?: Unit unitNet?: Unit
unitDisk?: Unit unitDisk?: Unit
// New field for alert history retention (e.g., '1m', '3m', '6m', '1y')
alertHistoryRetention?: string
} }
type ChartDataContainer = { type ChartDataContainer = {

View File

@@ -91,6 +91,7 @@ module.exports = {
}, },
}, },
plugins: [ plugins: [
require("@tailwindcss/container-queries"),
require("tailwindcss-animate"), require("tailwindcss-animate"),
require("tailwindcss-rtl"), require("tailwindcss-rtl"),
function ({ addVariant }) { function ({ addVariant }) {