From 888b4a57e5289b5795c397d21118bb45cd1b114c Mon Sep 17 00:00:00 2001 From: henrygd Date: Mon, 24 Nov 2025 17:35:28 -0500 Subject: [PATCH] add quiet hours to silence alerts during specific time periods (#265) --- internal/alerts/alerts.go | 74 +++ internal/alerts/alerts_quiet_hours_test.go | 426 ++++++++++++++ internal/alerts/alerts_status.go | 1 + internal/alerts/alerts_system.go | 1 + ..._0.go => 0_collections_snapshot_0_16_2.go} | 93 +++ internal/records/records.go | 15 + internal/site/biome.json | 3 +- .../routes/settings/notifications.tsx | 56 +- .../routes/settings/quiet-hours.tsx | 530 ++++++++++++++++++ .../systems-table/systems-table-columns.tsx | 64 +-- internal/site/src/types.d.ts | 14 + 11 files changed, 1220 insertions(+), 57 deletions(-) create mode 100644 internal/alerts/alerts_quiet_hours_test.go rename internal/migrations/{0_collections_snapshot_0_16_0.go => 0_collections_snapshot_0_16_2.go} (91%) create mode 100644 internal/site/src/components/routes/settings/quiet-hours.tsx diff --git a/internal/alerts/alerts.go b/internal/alerts/alerts.go index 82d2c180..11ad1f93 100644 --- a/internal/alerts/alerts.go +++ b/internal/alerts/alerts.go @@ -28,6 +28,7 @@ type AlertManager struct { type AlertMessageData struct { UserID string + SystemID string Title string Message string Link string @@ -105,8 +106,81 @@ func (am *AlertManager) bindEvents() { am.hub.OnRecordAfterDeleteSuccess("alerts").BindFunc(resolveHistoryOnAlertDelete) } +// IsNotificationSilenced checks if a notification should be silenced based on configured quiet hours +func (am *AlertManager) IsNotificationSilenced(userID, systemID string) bool { + // Query for quiet hours windows that match this user and system + // Include both global windows (system is null/empty) and system-specific windows + var filter string + var params dbx.Params + + if systemID == "" { + // If no systemID provided, only check global windows + filter = "user={:user} AND system=''" + params = dbx.Params{"user": userID} + } else { + // Check both global and system-specific windows + filter = "user={:user} AND (system='' OR system={:system})" + params = dbx.Params{ + "user": userID, + "system": systemID, + } + } + + quietHourWindows, err := am.hub.FindAllRecords("quiet_hours", dbx.NewExp(filter, params)) + if err != nil || len(quietHourWindows) == 0 { + return false + } + + now := time.Now().UTC() + + for _, window := range quietHourWindows { + windowType := window.GetString("type") + start := window.GetDateTime("start").Time() + end := window.GetDateTime("end").Time() + + if windowType == "daily" { + // For daily recurring windows, extract just the time portion and compare + // The start/end are stored as full datetime but we only care about HH:MM + startHour, startMin, _ := start.Clock() + endHour, endMin, _ := end.Clock() + nowHour, nowMin, _ := now.Clock() + + // Convert to minutes since midnight for easier comparison + startMinutes := startHour*60 + startMin + endMinutes := endHour*60 + endMin + nowMinutes := nowHour*60 + nowMin + + // Handle case where window crosses midnight + if endMinutes < startMinutes { + // Window crosses midnight (e.g., 23:00 - 01:00) + if nowMinutes >= startMinutes || nowMinutes < endMinutes { + return true + } + } else { + // Normal case (e.g., 09:00 - 17:00) + if nowMinutes >= startMinutes && nowMinutes < endMinutes { + return true + } + } + } else { + // One-time window: check if current time is within the date range + if (now.After(start) || now.Equal(start)) && now.Before(end) { + return true + } + } + } + + return false +} + // SendAlert sends an alert to the user func (am *AlertManager) SendAlert(data AlertMessageData) error { + // Check if alert is silenced + if am.IsNotificationSilenced(data.UserID, data.SystemID) { + am.hub.Logger().Info("Notification silenced", "user", data.UserID, "system", data.SystemID, "title", data.Title) + return nil + } + // get user settings record, err := am.hub.FindFirstRecordByFilter( "user_settings", "user={:user}", diff --git a/internal/alerts/alerts_quiet_hours_test.go b/internal/alerts/alerts_quiet_hours_test.go new file mode 100644 index 00000000..4f4217d4 --- /dev/null +++ b/internal/alerts/alerts_quiet_hours_test.go @@ -0,0 +1,426 @@ +//go:build testing +// +build testing + +package alerts_test + +import ( + "testing" + "testing/synctest" + "time" + + "github.com/henrygd/beszel/internal/alerts" + beszelTests "github.com/henrygd/beszel/internal/tests" + + "github.com/pocketbase/dbx" + "github.com/stretchr/testify/assert" +) + +func TestAlertSilencedOneTime(t *testing.T) { + hub, user := beszelTests.GetHubWithUser(t) + defer hub.Cleanup() + + // Create a system + systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up") + assert.NoError(t, err) + system := systems[0] + + // Create an alert + alert, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{ + "name": "CPU", + "system": system.Id, + "user": user.Id, + "value": 80, + "min": 1, + }) + assert.NoError(t, err) + + // Create a one-time quiet hours window (current time - 1 hour to current time + 1 hour) + now := time.Now().UTC() + startTime := now.Add(-1 * time.Hour) + endTime := now.Add(1 * time.Hour) + + _, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{ + "user": user.Id, + "system": system.Id, + "type": "one-time", + "start": startTime, + "end": endTime, + }) + assert.NoError(t, err) + + // Get alert manager + am := alerts.NewAlertManager(hub) + defer am.StopWorker() + + // Test that alert is silenced + silenced := am.IsNotificationSilenced(user.Id, system.Id) + assert.True(t, silenced, "Alert should be silenced during active one-time window") + + // Create a window that has already ended + pastStart := now.Add(-3 * time.Hour) + pastEnd := now.Add(-2 * time.Hour) + + _, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{ + "user": user.Id, + "system": system.Id, + "type": "one-time", + "start": pastStart, + "end": pastEnd, + }) + assert.NoError(t, err) + + // Should still be silenced because of the first window + silenced = am.IsNotificationSilenced(user.Id, system.Id) + assert.True(t, silenced, "Alert should still be silenced (past window doesn't affect active window)") + + // Clear all windows and create a future window + _, err = hub.DB().NewQuery("DELETE FROM quiet_hours").Execute() + assert.NoError(t, err) + + futureStart := now.Add(2 * time.Hour) + futureEnd := now.Add(3 * time.Hour) + + _, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{ + "user": user.Id, + "system": system.Id, + "type": "one-time", + "start": futureStart, + "end": futureEnd, + }) + assert.NoError(t, err) + + // Alert should NOT be silenced (window hasn't started yet) + silenced = am.IsNotificationSilenced(user.Id, system.Id) + assert.False(t, silenced, "Alert should not be silenced (window hasn't started)") + + _ = alert +} + +func TestAlertSilencedDaily(t *testing.T) { + hub, user := beszelTests.GetHubWithUser(t) + defer hub.Cleanup() + + // Create a system + systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up") + assert.NoError(t, err) + system := systems[0] + + // Get alert manager + am := alerts.NewAlertManager(hub) + defer am.StopWorker() + + // Get current hour and create a window that includes current time + now := time.Now().UTC() + currentHour := now.Hour() + currentMin := now.Minute() + + // Create a window from 1 hour ago to 1 hour from now + startHour := (currentHour - 1 + 24) % 24 + endHour := (currentHour + 1) % 24 + + // Create times with just the hours/minutes we want (date doesn't matter for daily) + startTime := time.Date(2000, 1, 1, startHour, currentMin, 0, 0, time.UTC) + endTime := time.Date(2000, 1, 1, endHour, currentMin, 0, 0, time.UTC) + + _, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{ + "user": user.Id, + "system": system.Id, + "type": "daily", + "start": startTime, + "end": endTime, + }) + assert.NoError(t, err) + + // Alert should be silenced (current time is within the daily window) + silenced := am.IsNotificationSilenced(user.Id, system.Id) + assert.True(t, silenced, "Alert should be silenced during active daily window") + + // Clear windows and create one that doesn't include current time + _, err = hub.DB().NewQuery("DELETE FROM quiet_hours").Execute() + assert.NoError(t, err) + + // Create a window from 6-12 hours from now + futureStartHour := (currentHour + 6) % 24 + futureEndHour := (currentHour + 12) % 24 + + startTime = time.Date(2000, 1, 1, futureStartHour, 0, 0, 0, time.UTC) + endTime = time.Date(2000, 1, 1, futureEndHour, 0, 0, 0, time.UTC) + + _, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{ + "user": user.Id, + "system": system.Id, + "type": "daily", + "start": startTime, + "end": endTime, + }) + assert.NoError(t, err) + + // Alert should NOT be silenced + silenced = am.IsNotificationSilenced(user.Id, system.Id) + assert.False(t, silenced, "Alert should not be silenced (outside daily window)") +} + +func TestAlertSilencedDailyMidnightCrossing(t *testing.T) { + hub, user := beszelTests.GetHubWithUser(t) + defer hub.Cleanup() + + // Create a system + systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up") + assert.NoError(t, err) + system := systems[0] + + // Get alert manager + am := alerts.NewAlertManager(hub) + defer am.StopWorker() + + // Create a window that crosses midnight: 22:00 - 02:00 + startTime := time.Date(2000, 1, 1, 22, 0, 0, 0, time.UTC) + endTime := time.Date(2000, 1, 1, 2, 0, 0, 0, time.UTC) + + _, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{ + "user": user.Id, + "system": system.Id, + "type": "daily", + "start": startTime, + "end": endTime, + }) + assert.NoError(t, err) + + // Test with a time at 23:00 (should be silenced) + // We can't control the actual current time, but we can verify the logic + // by checking if the window was created correctly + windows, err := hub.FindAllRecords("quiet_hours", dbx.HashExp{ + "user": user.Id, + "system": system.Id, + }) + assert.NoError(t, err) + assert.Len(t, windows, 1, "Should have created 1 window") + + window := windows[0] + assert.Equal(t, "daily", window.GetString("type")) + assert.Equal(t, 22, window.GetDateTime("start").Time().Hour()) + assert.Equal(t, 2, window.GetDateTime("end").Time().Hour()) +} + +func TestAlertSilencedGlobal(t *testing.T) { + hub, user := beszelTests.GetHubWithUser(t) + defer hub.Cleanup() + + // Create multiple systems + systems, err := beszelTests.CreateSystems(hub, 3, user.Id, "up") + assert.NoError(t, err) + + // Get alert manager + am := alerts.NewAlertManager(hub) + defer am.StopWorker() + + // Create a global quiet hours window (no system specified) + now := time.Now().UTC() + startTime := now.Add(-1 * time.Hour) + endTime := now.Add(1 * time.Hour) + + _, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{ + "user": user.Id, + "type": "one-time", + "start": startTime, + "end": endTime, + // system field is empty/null for global windows + }) + assert.NoError(t, err) + + // All systems should be silenced + for _, system := range systems { + silenced := am.IsNotificationSilenced(user.Id, system.Id) + assert.True(t, silenced, "Alert should be silenced for system %s (global window)", system.Id) + } + + // Even with a systemID that doesn't exist, should be silenced + silenced := am.IsNotificationSilenced(user.Id, "nonexistent-system") + assert.True(t, silenced, "Alert should be silenced for any system (global window)") +} + +func TestAlertSilencedSystemSpecific(t *testing.T) { + hub, user := beszelTests.GetHubWithUser(t) + defer hub.Cleanup() + + // Create multiple systems + systems, err := beszelTests.CreateSystems(hub, 2, user.Id, "up") + assert.NoError(t, err) + system1 := systems[0] + system2 := systems[1] + + // Get alert manager + am := alerts.NewAlertManager(hub) + defer am.StopWorker() + + // Create a system-specific quiet hours window for system1 only + now := time.Now().UTC() + startTime := now.Add(-1 * time.Hour) + endTime := now.Add(1 * time.Hour) + + _, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{ + "user": user.Id, + "system": system1.Id, + "type": "one-time", + "start": startTime, + "end": endTime, + }) + assert.NoError(t, err) + + // System1 should be silenced + silenced := am.IsNotificationSilenced(user.Id, system1.Id) + assert.True(t, silenced, "Alert should be silenced for system1") + + // System2 should NOT be silenced + silenced = am.IsNotificationSilenced(user.Id, system2.Id) + assert.False(t, silenced, "Alert should not be silenced for system2") +} + +func TestAlertSilencedMultiUser(t *testing.T) { + hub, _ := beszelTests.GetHubWithUser(t) + defer hub.Cleanup() + + // Create two users + user1, err := beszelTests.CreateUser(hub, "user1@example.com", "password") + assert.NoError(t, err) + + user2, err := beszelTests.CreateUser(hub, "user2@example.com", "password") + assert.NoError(t, err) + + // Create a system accessible to both users + system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{ + "name": "shared-system", + "users": []string{user1.Id, user2.Id}, + "host": "127.0.0.1", + }) + assert.NoError(t, err) + + // Get alert manager + am := alerts.NewAlertManager(hub) + defer am.StopWorker() + + // Create a quiet hours window for user1 only + now := time.Now().UTC() + startTime := now.Add(-1 * time.Hour) + endTime := now.Add(1 * time.Hour) + + _, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{ + "user": user1.Id, + "system": system.Id, + "type": "one-time", + "start": startTime, + "end": endTime, + }) + assert.NoError(t, err) + + // User1 should be silenced + silenced := am.IsNotificationSilenced(user1.Id, system.Id) + assert.True(t, silenced, "Alert should be silenced for user1") + + // User2 should NOT be silenced + silenced = am.IsNotificationSilenced(user2.Id, system.Id) + assert.False(t, silenced, "Alert should not be silenced for user2") +} + +func TestAlertSilencedWithActualAlert(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + hub, user := beszelTests.GetHubWithUser(t) + defer hub.Cleanup() + + // Create a system + systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up") + assert.NoError(t, err) + system := systems[0] + + // Create a status alert + _, err = beszelTests.CreateRecord(hub, "alerts", map[string]any{ + "name": "Status", + "system": system.Id, + "user": user.Id, + "min": 1, + }) + assert.NoError(t, err) + + // Create user settings with email + userSettings, err := hub.FindFirstRecordByFilter("user_settings", "user={:user}", dbx.Params{"user": user.Id}) + if err != nil || userSettings == nil { + userSettings, err = beszelTests.CreateRecord(hub, "user_settings", map[string]any{ + "user": user.Id, + "settings": map[string]any{ + "emails": []string{"test@example.com"}, + }, + }) + assert.NoError(t, err) + } + + // Create a quiet hours window + now := time.Now().UTC() + startTime := now.Add(-1 * time.Hour) + endTime := now.Add(1 * time.Hour) + + _, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{ + "user": user.Id, + "system": system.Id, + "type": "one-time", + "start": startTime, + "end": endTime, + }) + assert.NoError(t, err) + + // Get initial email count + initialEmailCount := hub.TestMailer.TotalSend() + + // Trigger an alert by setting system to down + system.Set("status", "down") + err = hub.SaveNoValidate(system) + assert.NoError(t, err) + + // Wait for the alert to be processed (1 minute + buffer) + time.Sleep(time.Second * 75) + synctest.Wait() + + // Check that no email was sent (because alert is silenced) + finalEmailCount := hub.TestMailer.TotalSend() + assert.Equal(t, initialEmailCount, finalEmailCount, "No emails should be sent when alert is silenced") + + // Clear quiet hours windows + _, err = hub.DB().NewQuery("DELETE FROM quiet_hours").Execute() + assert.NoError(t, err) + + // Reset system to up, then down again + system.Set("status", "up") + err = hub.SaveNoValidate(system) + assert.NoError(t, err) + time.Sleep(100 * time.Millisecond) + + system.Set("status", "down") + err = hub.SaveNoValidate(system) + assert.NoError(t, err) + + // Wait for the alert to be processed + time.Sleep(time.Second * 75) + synctest.Wait() + + // Now an email should be sent + newEmailCount := hub.TestMailer.TotalSend() + assert.Greater(t, newEmailCount, finalEmailCount, "Email should be sent when not silenced") + }) +} + +func TestAlertSilencedNoWindows(t *testing.T) { + hub, user := beszelTests.GetHubWithUser(t) + defer hub.Cleanup() + + // Create a system + systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up") + assert.NoError(t, err) + system := systems[0] + + // Get alert manager + am := alerts.NewAlertManager(hub) + defer am.StopWorker() + + // Without any quiet hours windows, alert should NOT be silenced + silenced := am.IsNotificationSilenced(user.Id, system.Id) + assert.False(t, silenced, "Alert should not be silenced when no windows exist") +} diff --git a/internal/alerts/alerts_status.go b/internal/alerts/alerts_status.go index 3455b525..920b8bf8 100644 --- a/internal/alerts/alerts_status.go +++ b/internal/alerts/alerts_status.go @@ -166,6 +166,7 @@ func (am *AlertManager) sendStatusAlert(alertStatus string, systemName string, a return am.SendAlert(AlertMessageData{ UserID: alertRecord.GetString("user"), + SystemID: systemID, Title: title, Message: message, Link: am.hub.MakeLink("system", systemID), diff --git a/internal/alerts/alerts_system.go b/internal/alerts/alerts_system.go index 7fe5bc2e..c082178b 100644 --- a/internal/alerts/alerts_system.go +++ b/internal/alerts/alerts_system.go @@ -309,6 +309,7 @@ func (am *AlertManager) sendSystemAlert(alert SystemAlertData) { } am.SendAlert(AlertMessageData{ UserID: alert.alertRecord.GetString("user"), + SystemID: alert.systemRecord.Id, Title: subject, Message: body, Link: am.hub.MakeLink("system", alert.systemRecord.Id), diff --git a/internal/migrations/0_collections_snapshot_0_16_0.go b/internal/migrations/0_collections_snapshot_0_16_2.go similarity index 91% rename from internal/migrations/0_collections_snapshot_0_16_0.go rename to internal/migrations/0_collections_snapshot_0_16_2.go index 56a07c50..cd703df3 100644 --- a/internal/migrations/0_collections_snapshot_0_16_0.go +++ b/internal/migrations/0_collections_snapshot_0_16_2.go @@ -1150,6 +1150,99 @@ func init() { "type": "base", "updateRule": null, "viewRule": null + }, + { + "createRule": "@request.auth.id != \"\" && user.id = @request.auth.id", + "deleteRule": "@request.auth.id != \"\" && user.id = @request.auth.id", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{10}", + "hidden": false, + "id": "text3208210256", + "max": 10, + "min": 10, + "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": false, + "system": false, + "type": "relation" + }, + { + "cascadeDelete": true, + "collectionId": "2hz5ncl8tizk5nx", + "hidden": false, + "id": "relation3377271179", + "maxSelect": 1, + "minSelect": 0, + "name": "system", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "hidden": false, + "id": "select2844932856", + "maxSelect": 1, + "name": "type", + "presentable": false, + "required": true, + "system": false, + "type": "select", + "values": [ + "one-time", + "daily" + ] + }, + { + "hidden": false, + "id": "date2675529103", + "max": "", + "min": "", + "name": "start", + "presentable": false, + "required": true, + "system": false, + "type": "date" + }, + { + "hidden": false, + "id": "date16528305", + "max": "", + "min": "", + "name": "end", + "presentable": false, + "required": true, + "system": false, + "type": "date" + } + ], + "id": "pbc_451525641", + "indexes": [ + "CREATE INDEX ` + "`" + `idx_q0iKnRP9v8` + "`" + ` ON ` + "`" + `quiet_hours` + "`" + ` (\n ` + "`" + `user` + "`" + `,\n ` + "`" + `system` + "`" + `\n)", + "CREATE INDEX ` + "`" + `idx_6T7ljT7FJd` + "`" + ` ON ` + "`" + `quiet_hours` + "`" + ` (\n ` + "`" + `type` + "`" + `,\n ` + "`" + `end` + "`" + `\n)" + ], + "listRule": "@request.auth.id != \"\" && user.id = @request.auth.id", + "name": "quiet_hours", + "system": false, + "type": "base", + "updateRule": "@request.auth.id != \"\" && user.id = @request.auth.id", + "viewRule": "@request.auth.id != \"\" && user.id = @request.auth.id" } ]` diff --git a/internal/records/records.go b/internal/records/records.go index 1b2c42e6..eaf4ef7f 100644 --- a/internal/records/records.go +++ b/internal/records/records.go @@ -498,6 +498,10 @@ func (rm *RecordManager) DeleteOldRecords() { if err != nil { return err } + err = deleteOldQuietHours(txApp) + if err != nil { + return err + } return nil }) } @@ -591,6 +595,17 @@ func deleteOldContainerRecords(app core.App) error { return nil } +// Deletes old quiet hours records where end date has passed +func deleteOldQuietHours(app core.App) error { + now := time.Now().UTC() + _, err := app.DB().NewQuery("DELETE FROM quiet_hours WHERE type = 'one-time' AND end < {:now}").Bind(dbx.Params{"now": now}).Execute() + if err != nil { + return err + } + + return nil +} + /* Round float to two decimals */ func twoDecimals(value float64) float64 { return math.Round(value*100) / 100 diff --git a/internal/site/biome.json b/internal/site/biome.json index a2da8da2..7990ae84 100644 --- a/internal/site/biome.json +++ b/internal/site/biome.json @@ -30,11 +30,12 @@ "noUnusedFunctionParameters": "error", "noUnusedPrivateClassMembers": "error", "useExhaustiveDependencies": { - "level": "error", + "level": "warn", "options": { "reportUnnecessaryDependencies": false } }, + "useUniqueElementIds": "off", "noUnusedVariables": "error" }, "style": { diff --git a/internal/site/src/components/routes/settings/notifications.tsx b/internal/site/src/components/routes/settings/notifications.tsx index e9ec35f3..8f570719 100644 --- a/internal/site/src/components/routes/settings/notifications.tsx +++ b/internal/site/src/components/routes/settings/notifications.tsx @@ -14,6 +14,7 @@ import { toast } from "@/components/ui/use-toast" import { isAdmin, pb } from "@/lib/api" import type { UserSettings } from "@/types" import { saveSettings } from "./layout" +import { QuietHours } from "./quiet-hours" interface ShoutrrrUrlCardProps { url: string @@ -120,19 +121,32 @@ const SettingsNotificationsPage = ({ userSettings }: { userSettings: UserSetting
-
-

- Webhook / Push notifications -

-

- - Beszel uses{" "} - - Shoutrrr - {" "} - to integrate with popular notification services. - -

+
+
+

+ Webhook / Push notifications +

+

+ + Beszel uses{" "} + + Shoutrrr + {" "} + to integrate with popular notification services. + +

+
+
{webhooks.length > 0 && (
@@ -146,16 +160,10 @@ const SettingsNotificationsPage = ({ userSettings }: { userSettings: UserSetting ))}
)} - +
+ +
+
+ + + +
+ {data.length > 0 && ( +
+ + + + + + + System + + + + + + Type + + + + + + State + + + + + + Schedule + + + + Actions + + + + + {data.map((record) => ( + + + {record.system ? record.expand?.system?.name || record.system : All Systems} + + + {record.type === "daily" ? Daily : One-time} + + + {(() => { + const state = getWindowState(record) + const stateConfig = { + active: { label: Active, variant: "success" as const }, + past: { label: Past, variant: "danger" as const }, + future: { label: Future, variant: "default" as const }, + } + const config = stateConfig[state] + return {config.label} + })()} + + {formatDateTime(record)} + + + + + + + openEditDialog(record)}> + + Edit + + handleDelete(record.id)}> + + Delete + + + + + + ))} + +
+
+ )} + + ) +} + +// Helper function to format Date as datetime-local string (YYYY-MM-DDTHH:mm) in local time +function formatDateTimeLocal(date: Date): string { + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, "0") + const day = String(date.getDate()).padStart(2, "0") + const hours = String(date.getHours()).padStart(2, "0") + const minutes = String(date.getMinutes()).padStart(2, "0") + return `${year}-${month}-${day}T${hours}:${minutes}` +} + +function QuietHoursDialog({ + editingRecord, + systems, + onClose, + toast, +}: { + editingRecord: QuietHoursRecord | null + systems: SystemRecord[] + onClose: () => void + toast: any +}) { + const [selectedSystem, setSelectedSystem] = useState(editingRecord?.system || "") + const [isGlobal, setIsGlobal] = useState(!editingRecord?.system) + const [windowType, setWindowType] = useState<"one-time" | "daily">(editingRecord?.type || "one-time") + const [startDateTime, setStartDateTime] = useState("") + const [endDateTime, setEndDateTime] = useState("") + const [startTime, setStartTime] = useState("") + const [endTime, setEndTime] = useState("") + + useEffect(() => { + if (editingRecord) { + setSelectedSystem(editingRecord.system || "") + setIsGlobal(!editingRecord.system) + setWindowType(editingRecord.type) + if (editingRecord.type === "daily") { + // Extract time from datetime + const start = new Date(editingRecord.start) + const end = editingRecord.end ? new Date(editingRecord.end) : null + setStartTime(start.toTimeString().slice(0, 5)) + setEndTime(end ? end.toTimeString().slice(0, 5) : "") + } else { + // For one-time, format as datetime-local (local time, not UTC) + const startDate = new Date(editingRecord.start) + const endDate = editingRecord.end ? new Date(editingRecord.end) : null + + setStartDateTime(formatDateTimeLocal(startDate)) + setEndDateTime(endDate ? formatDateTimeLocal(endDate) : "") + } + } else { + // Reset form with default dates: today at 12pm and 1pm + const today = new Date() + const noon = new Date(today) + noon.setHours(12, 0, 0, 0) + const onePm = new Date(today) + onePm.setHours(13, 0, 0, 0) + + setSelectedSystem("") + setIsGlobal(true) + setWindowType("one-time") + setStartDateTime(formatDateTimeLocal(noon)) + setEndDateTime(formatDateTimeLocal(onePm)) + setStartTime("12:00") + setEndTime("13:00") + } + }, [editingRecord]) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + + try { + let startValue: string + let endValue: string | undefined + + if (windowType === "daily") { + // For daily windows, convert local time to UTC + // Create a date with the time in local timezone, then convert to UTC + const startDate = new Date(`2000-01-01T${startTime}:00`) + startValue = startDate.toISOString() + + if (endTime) { + const endDate = new Date(`2000-01-01T${endTime}:00`) + endValue = endDate.toISOString() + } + } else { + // For one-time windows, use the datetime values + startValue = new Date(startDateTime).toISOString() + endValue = endDateTime ? new Date(endDateTime).toISOString() : undefined + } + + const data = { + user: pb.authStore.record?.id, + system: isGlobal ? undefined : selectedSystem, + type: windowType, + start: startValue, + end: endValue, + } + + if (editingRecord) { + await pb.collection("quiet_hours").update(editingRecord.id, data) + toast({ + title: t`Updated`, + description: t`Quiet hours have been updated.`, + }) + } else { + await pb.collection("quiet_hours").create(data) + toast({ + title: t`Created`, + description: t`Quiet hours have been created.`, + }) + } + + onClose() + } catch (e) { + toast({ + variant: "destructive", + title: t`Error`, + description: t`Failed to save quiet hours.`, + }) + } + } + + return ( + + + {editingRecord ? Edit Quiet Hours : Add Quiet Hours} + + Configure quiet hours where notifications will not be sent. + + +
+ setIsGlobal(value === "global")}> + + + All Systems + + + Specific System + + + + +
+ + + {/* Hidden input for native form validation */} + {}} + required={!isGlobal} + /> +
+
+
+ +
+ + +
+ + {windowType === "one-time" ? ( + <> +
+ + setStartDateTime(e.target.value)} + min={formatDateTimeLocal(new Date(new Date().setHours(0, 0, 0, 0)))} + required + className="tabular-nums tracking-tighter" + /> +
+
+ + setEndDateTime(e.target.value)} + min={startDateTime || formatDateTimeLocal(new Date())} + required + className="tabular-nums tracking-tighter" + /> +
+ + ) : ( +
+
+ + setStartTime(e.target.value)} + required + /> +
+
+ + setEndTime(e.target.value)} + required + /> +
+
+ )} + + + + + +
+
+ ) +} diff --git a/internal/site/src/components/systems-table/systems-table-columns.tsx b/internal/site/src/components/systems-table/systems-table-columns.tsx index 45e9d1c1..0c83f5ac 100644 --- a/internal/site/src/components/systems-table/systems-table-columns.tsx +++ b/internal/site/src/components/systems-table/systems-table-columns.tsx @@ -233,7 +233,7 @@ export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef - {decimalString(value , value >= 100 ? 1 : 2)} {unit} + {decimalString(value, value >= 100 ? 1 : 2)} {unit} ) }, @@ -377,9 +377,9 @@ function TableCellWithMeter(info: CellContext) { const meterClass = cn( "h-full", (info.row.original.status !== SystemStatus.Up && STATUS_COLORS.paused) || - (threshold === MeterState.Good && STATUS_COLORS.up) || - (threshold === MeterState.Warn && STATUS_COLORS.pending) || - STATUS_COLORS.down + (threshold === MeterState.Good && STATUS_COLORS.up) || + (threshold === MeterState.Warn && STATUS_COLORS.pending) || + STATUS_COLORS.down ) return (
@@ -401,18 +401,18 @@ function DiskCellWithMultiple(info: CellContext) { } const rootDiskPct = sysInfo.dp - + // sort extra disks by percentage descending extraFs.sort((a, b) => b[1] - a[1]) - + function getMeterClass(pct: number) { const threshold = getMeterState(pct) return cn( "h-full", (status !== SystemStatus.Up && STATUS_COLORS.paused) || - (threshold === MeterState.Good && STATUS_COLORS.up) || - (threshold === MeterState.Warn && STATUS_COLORS.pending) || - STATUS_COLORS.down + (threshold === MeterState.Good && STATUS_COLORS.up) || + (threshold === MeterState.Warn && STATUS_COLORS.pending) || + STATUS_COLORS.down ) } @@ -435,30 +435,30 @@ function DiskCellWithMultiple(info: CellContext) { -
-
-
Root
-
- {decimalString(rootDiskPct, rootDiskPct >= 10 ? 1 : 2)}% - - - -
+
+
+
Root
+
+ {decimalString(rootDiskPct, rootDiskPct >= 10 ? 1 : 2)}% + + +
- {extraFs.map(([name, pct]) => { - return ( -
-
{name}
-
- {decimalString(pct, pct >= 10 ? 1 : 2)}% - - - -
-
- ) - })}
+ {extraFs.map(([name, pct]) => { + return ( +
+
{name}
+
+ {decimalString(pct, pct >= 10 ? 1 : 2)}% + + + +
+
+ ) + })} +
) @@ -469,7 +469,7 @@ export function IndicatorDot({ system, className }: { system: SystemRecord; clas return ( ) } diff --git a/internal/site/src/types.d.ts b/internal/site/src/types.d.ts index 3454ee49..c191ae92 100644 --- a/internal/site/src/types.d.ts +++ b/internal/site/src/types.d.ts @@ -244,6 +244,20 @@ export interface AlertsHistoryRecord extends RecordModel { resolved?: string | null } +export interface QuietHoursRecord extends RecordModel { + id: string + user: string + system: string + type: "one-time" | "daily" + start: string + end: string + expand?: { + system?: { + name: string + } + } +} + export interface ContainerRecord extends RecordModel { id: string system: string