mirror of
https://github.com/henrygd/beszel.git
synced 2026-03-23 14:06:18 +01:00
Compare commits
9 Commits
quiet-hour
...
53a7e06dcf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53a7e06dcf | ||
|
|
11edabd09f | ||
|
|
41a3d9359f | ||
|
|
5dfc5f247f | ||
|
|
9804c8a31a | ||
|
|
4d05bfdff0 | ||
|
|
0388401a9e | ||
|
|
162c548010 | ||
|
|
888b4a57e5 |
10
go.mod
10
go.mod
@@ -10,16 +10,16 @@ require (
|
||||
github.com/gliderlabs/ssh v0.3.8
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/lxzan/gws v1.8.9
|
||||
github.com/nicholas-fedor/shoutrrr v0.12.0
|
||||
github.com/nicholas-fedor/shoutrrr v0.12.1
|
||||
github.com/pocketbase/dbx v1.11.0
|
||||
github.com/pocketbase/pocketbase v0.33.0
|
||||
github.com/pocketbase/pocketbase v0.34.0
|
||||
github.com/shirou/gopsutil/v4 v4.25.10
|
||||
github.com/spf13/cast v1.10.0
|
||||
github.com/spf13/cobra v1.10.1
|
||||
github.com/spf13/pflag v1.0.10
|
||||
github.com/stretchr/testify v1.11.1
|
||||
golang.org/x/crypto v0.44.0
|
||||
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6
|
||||
golang.org/x/crypto v0.45.0
|
||||
golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
@@ -64,5 +64,5 @@ require (
|
||||
modernc.org/libc v1.66.10 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/sqlite v1.40.0 // indirect
|
||||
modernc.org/sqlite v1.40.1 // indirect
|
||||
)
|
||||
|
||||
28
go.sum
28
go.sum
@@ -58,8 +58,8 @@ github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArs
|
||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/pprof v0.0.0-20251007162407-5df77e3f7d1d h1:KJIErDwbSHjnp/SGzE5ed8Aol7JsKiI5X7yWKAtzhM0=
|
||||
github.com/google/pprof v0.0.0-20251007162407-5df77e3f7d1d/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U=
|
||||
github.com/google/pprof v0.0.0-20251114195745-4902fdda35c8 h1:3DsUAV+VNEQa2CUVLxCY3f87278uWfIDhJnbdvDjvmE=
|
||||
github.com/google/pprof v0.0.0-20251114195745-4902fdda35c8/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
@@ -85,8 +85,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/nicholas-fedor/shoutrrr v0.12.0 h1:8mwJdfU+uBEybSymwQJMGl/grG7lvVUKbVSNxn3XvUI=
|
||||
github.com/nicholas-fedor/shoutrrr v0.12.0/go.mod h1:WYiRalR4C43Qmd2zhPWGIFIxu633NB1hDM6Ap/DQcsA=
|
||||
github.com/nicholas-fedor/shoutrrr v0.12.1 h1:8NjY+I3K7cGHy89ncnaPGUA0ex44XbYK3SAFJX9YMI8=
|
||||
github.com/nicholas-fedor/shoutrrr v0.12.1/go.mod h1:64qWuPpvTUv9ZppEoR6OdroiFmgf9w11YSaR0h9KZGg=
|
||||
github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns=
|
||||
github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
|
||||
github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
|
||||
@@ -96,8 +96,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pocketbase/dbx v1.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU=
|
||||
github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
||||
github.com/pocketbase/pocketbase v0.33.0 h1:v2EfiY3hxigzRJ/BwFuwVn0vUv7d2QQoD5zUFPaKR9o=
|
||||
github.com/pocketbase/pocketbase v0.33.0/go.mod h1:9BEs+CRV7CrS+X5LfBh4bdJQsbzQAIklft3ovGe/c5A=
|
||||
github.com/pocketbase/pocketbase v0.34.0 h1:5W80PrGvkRYIMAIK90F7w031/hXgZVz1KSuCJqSpgJo=
|
||||
github.com/pocketbase/pocketbase v0.34.0/go.mod h1:K/9z/Zb9PR9yW2Qyoc73jHV/EKT8cMTk9bQWyrzYlvI=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
@@ -129,10 +129,10 @@ github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQ
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU=
|
||||
golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc=
|
||||
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0=
|
||||
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
|
||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||
golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39 h1:DHNhtq3sNNzrvduZZIiFyXWOL9IWaDPHqTnLJp+rCBY=
|
||||
golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.33.0 h1:LXRZRnv1+zGd5XBUVRFmYEphyyKJjQjCRiOuAP3sZfQ=
|
||||
golang.org/x/image v0.33.0/go.mod h1:DD3OsTYT9chzuzTQt+zMcOlBHgfoKQb1gry8p76Y1sc=
|
||||
@@ -187,8 +187,8 @@ modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
|
||||
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
|
||||
modernc.org/libc v1.67.0 h1:QzL4IrKab2OFmxA3/vRYl0tLXrIamwrhD6CKD4WBVjQ=
|
||||
modernc.org/libc v1.67.0/go.mod h1:QvvnnJ5P7aitu0ReNpVIEyesuhmDLQ8kaEoyMjIFZJA=
|
||||
modernc.org/libc v1.67.1 h1:bFaqOaa5/zbWYJo8aW0tXPX21hXsngG2M7mckCnFSVk=
|
||||
modernc.org/libc v1.67.1/go.mod h1:QvvnnJ5P7aitu0ReNpVIEyesuhmDLQ8kaEoyMjIFZJA=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
@@ -197,8 +197,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.40.0 h1:bNWEDlYhNPAUdUdBzjAvn8icAs/2gaKlj4vM+tQ6KdQ=
|
||||
modernc.org/sqlite v1.40.0/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
|
||||
modernc.org/sqlite v1.40.1 h1:VfuXcxcUWWKRBuP8+BR9L7VnmusMgBNNnBYGEe9w/iY=
|
||||
modernc.org/sqlite v1.40.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
|
||||
@@ -28,6 +28,7 @@ type AlertManager struct {
|
||||
|
||||
type AlertMessageData struct {
|
||||
UserID string
|
||||
SystemID string
|
||||
Title string
|
||||
Message string
|
||||
Link string
|
||||
@@ -103,10 +104,84 @@ func NewAlertManager(app hubLike) *AlertManager {
|
||||
func (am *AlertManager) bindEvents() {
|
||||
am.hub.OnRecordAfterUpdateSuccess("alerts").BindFunc(updateHistoryOnAlertUpdate)
|
||||
am.hub.OnRecordAfterDeleteSuccess("alerts").BindFunc(resolveHistoryOnAlertDelete)
|
||||
am.hub.OnRecordAfterUpdateSuccess("smart_devices").BindFunc(am.handleSmartDeviceAlert)
|
||||
}
|
||||
|
||||
// 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}",
|
||||
|
||||
426
internal/alerts/alerts_quiet_hours_test.go
Normal file
426
internal/alerts/alerts_quiet_hours_test.go
Normal file
@@ -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")
|
||||
}
|
||||
67
internal/alerts/alerts_smart.go
Normal file
67
internal/alerts/alerts_smart.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package alerts
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
// handleSmartDeviceAlert sends alerts when a SMART device state changes from PASSED to FAILED.
|
||||
// This is automatic and does not require user opt-in.
|
||||
func (am *AlertManager) handleSmartDeviceAlert(e *core.RecordEvent) error {
|
||||
oldState := e.Record.Original().GetString("state")
|
||||
newState := e.Record.GetString("state")
|
||||
|
||||
// Only alert when transitioning from PASSED to FAILED
|
||||
if oldState != "PASSED" || newState != "FAILED" {
|
||||
return e.Next()
|
||||
}
|
||||
|
||||
systemID := e.Record.GetString("system")
|
||||
if systemID == "" {
|
||||
return e.Next()
|
||||
}
|
||||
|
||||
// Fetch the system record to get the name and users
|
||||
systemRecord, err := e.App.FindRecordById("systems", systemID)
|
||||
if err != nil {
|
||||
e.App.Logger().Error("Failed to find system for SMART alert", "err", err, "systemID", systemID)
|
||||
return e.Next()
|
||||
}
|
||||
|
||||
systemName := systemRecord.GetString("name")
|
||||
deviceName := e.Record.GetString("name")
|
||||
model := e.Record.GetString("model")
|
||||
|
||||
// Build alert message
|
||||
title := fmt.Sprintf("SMART failure on %s: %s \U0001F534", systemName, deviceName)
|
||||
var message string
|
||||
if model != "" {
|
||||
message = fmt.Sprintf("Disk %s (%s) SMART status changed to FAILED", deviceName, model)
|
||||
} else {
|
||||
message = fmt.Sprintf("Disk %s SMART status changed to FAILED", deviceName)
|
||||
}
|
||||
|
||||
// Get users associated with the system
|
||||
userIDs := systemRecord.GetStringSlice("users")
|
||||
if len(userIDs) == 0 {
|
||||
return e.Next()
|
||||
}
|
||||
|
||||
// Send alert to each user
|
||||
for _, userID := range userIDs {
|
||||
if err := am.SendAlert(AlertMessageData{
|
||||
UserID: userID,
|
||||
SystemID: systemID,
|
||||
Title: title,
|
||||
Message: message,
|
||||
Link: am.hub.MakeLink("system", systemID),
|
||||
LinkText: "View " + systemName,
|
||||
}); err != nil {
|
||||
e.App.Logger().Error("Failed to send SMART alert", "err", err, "userID", userID)
|
||||
}
|
||||
}
|
||||
|
||||
return e.Next()
|
||||
}
|
||||
|
||||
196
internal/alerts/alerts_smart_test.go
Normal file
196
internal/alerts/alerts_smart_test.go
Normal file
@@ -0,0 +1,196 @@
|
||||
//go:build testing
|
||||
// +build testing
|
||||
|
||||
package alerts_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
beszelTests "github.com/henrygd/beszel/internal/tests"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestSmartDeviceAlert(t *testing.T) {
|
||||
hub, user := beszelTests.GetHubWithUser(t)
|
||||
defer hub.Cleanup()
|
||||
|
||||
// Create a system for the user
|
||||
system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{
|
||||
"name": "test-system",
|
||||
"users": []string{user.Id},
|
||||
"host": "127.0.0.1",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Create a smart_device with state PASSED
|
||||
smartDevice, err := beszelTests.CreateRecord(hub, "smart_devices", map[string]any{
|
||||
"system": system.Id,
|
||||
"name": "/dev/sda",
|
||||
"model": "Samsung SSD 970 EVO",
|
||||
"state": "PASSED",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify no emails sent initially
|
||||
assert.Zero(t, hub.TestMailer.TotalSend(), "should have 0 emails sent initially")
|
||||
|
||||
// Re-fetch the record so PocketBase can properly track original values
|
||||
smartDevice, err = hub.FindRecordById("smart_devices", smartDevice.Id)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Update the smart device state to FAILED
|
||||
smartDevice.Set("state", "FAILED")
|
||||
err = hub.Save(smartDevice)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Wait for the alert to be processed
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
// Verify that an email was sent
|
||||
assert.EqualValues(t, 1, hub.TestMailer.TotalSend(), "should have 1 email sent after state changed to FAILED")
|
||||
|
||||
// Check the email content
|
||||
lastMessage := hub.TestMailer.LastMessage()
|
||||
assert.Contains(t, lastMessage.Subject, "SMART failure on test-system")
|
||||
assert.Contains(t, lastMessage.Subject, "/dev/sda")
|
||||
assert.Contains(t, lastMessage.Text, "Samsung SSD 970 EVO")
|
||||
assert.Contains(t, lastMessage.Text, "FAILED")
|
||||
}
|
||||
|
||||
func TestSmartDeviceAlertNoAlertOnNonPassedToFailed(t *testing.T) {
|
||||
hub, user := beszelTests.GetHubWithUser(t)
|
||||
defer hub.Cleanup()
|
||||
|
||||
// Create a system for the user
|
||||
system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{
|
||||
"name": "test-system",
|
||||
"users": []string{user.Id},
|
||||
"host": "127.0.0.1",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Create a smart_device with state UNKNOWN
|
||||
smartDevice, err := beszelTests.CreateRecord(hub, "smart_devices", map[string]any{
|
||||
"system": system.Id,
|
||||
"name": "/dev/sda",
|
||||
"model": "Samsung SSD 970 EVO",
|
||||
"state": "UNKNOWN",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Re-fetch the record so PocketBase can properly track original values
|
||||
smartDevice, err = hub.FindRecordById("smart_devices", smartDevice.Id)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Update the state from UNKNOWN to FAILED - should NOT trigger alert
|
||||
smartDevice.Set("state", "FAILED")
|
||||
err = hub.Save(smartDevice)
|
||||
assert.NoError(t, err)
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
// Verify no email was sent (only PASSED -> FAILED triggers alert)
|
||||
assert.Zero(t, hub.TestMailer.TotalSend(), "should have 0 emails when changing from UNKNOWN to FAILED")
|
||||
|
||||
// Re-fetch the record again
|
||||
smartDevice, err = hub.FindRecordById("smart_devices", smartDevice.Id)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Update state from FAILED to PASSED - should NOT trigger alert
|
||||
smartDevice.Set("state", "PASSED")
|
||||
err = hub.Save(smartDevice)
|
||||
assert.NoError(t, err)
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
// Verify no email was sent
|
||||
assert.Zero(t, hub.TestMailer.TotalSend(), "should have 0 emails when changing from FAILED to PASSED")
|
||||
}
|
||||
|
||||
func TestSmartDeviceAlertMultipleUsers(t *testing.T) {
|
||||
hub, user1 := beszelTests.GetHubWithUser(t)
|
||||
defer hub.Cleanup()
|
||||
|
||||
// Create a second user
|
||||
user2, err := beszelTests.CreateUser(hub, "test2@example.com", "password")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Create user settings for the second user
|
||||
_, err = beszelTests.CreateRecord(hub, "user_settings", map[string]any{
|
||||
"user": user2.Id,
|
||||
"settings": `{"emails":["test2@example.com"],"webhooks":[]}`,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Create a system with 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)
|
||||
|
||||
// Create a smart_device with state PASSED
|
||||
smartDevice, err := beszelTests.CreateRecord(hub, "smart_devices", map[string]any{
|
||||
"system": system.Id,
|
||||
"name": "/dev/nvme0n1",
|
||||
"model": "WD Black SN850",
|
||||
"state": "PASSED",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Re-fetch the record so PocketBase can properly track original values
|
||||
smartDevice, err = hub.FindRecordById("smart_devices", smartDevice.Id)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Update the smart device state to FAILED
|
||||
smartDevice.Set("state", "FAILED")
|
||||
err = hub.Save(smartDevice)
|
||||
assert.NoError(t, err)
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
// Verify that two emails were sent (one for each user)
|
||||
assert.EqualValues(t, 2, hub.TestMailer.TotalSend(), "should have 2 emails sent for 2 users")
|
||||
}
|
||||
|
||||
func TestSmartDeviceAlertWithoutModel(t *testing.T) {
|
||||
hub, user := beszelTests.GetHubWithUser(t)
|
||||
defer hub.Cleanup()
|
||||
|
||||
// Create a system for the user
|
||||
system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{
|
||||
"name": "test-system",
|
||||
"users": []string{user.Id},
|
||||
"host": "127.0.0.1",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Create a smart_device with state PASSED but no model
|
||||
smartDevice, err := beszelTests.CreateRecord(hub, "smart_devices", map[string]any{
|
||||
"system": system.Id,
|
||||
"name": "/dev/sdb",
|
||||
"state": "PASSED",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Re-fetch the record so PocketBase can properly track original values
|
||||
smartDevice, err = hub.FindRecordById("smart_devices", smartDevice.Id)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Update the smart device state to FAILED
|
||||
smartDevice.Set("state", "FAILED")
|
||||
err = hub.Save(smartDevice)
|
||||
assert.NoError(t, err)
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
// Verify that an email was sent
|
||||
assert.EqualValues(t, 1, hub.TestMailer.TotalSend(), "should have 1 email sent")
|
||||
|
||||
// Check that the email doesn't have empty parentheses for missing model
|
||||
lastMessage := hub.TestMailer.LastMessage()
|
||||
assert.NotContains(t, lastMessage.Text, "()", "should not have empty parentheses for missing model")
|
||||
assert.Contains(t, lastMessage.Text, "/dev/sdb")
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -268,8 +268,8 @@ func (h *Hub) registerApiRoutes(se *core.ServeEvent) error {
|
||||
// update / delete user alerts
|
||||
apiAuth.POST("/user-alerts", alerts.UpsertUserAlerts)
|
||||
apiAuth.DELETE("/user-alerts", alerts.DeleteUserAlerts)
|
||||
// get SMART data
|
||||
apiAuth.GET("/smart", h.getSmartData)
|
||||
// refresh SMART devices for a system
|
||||
apiAuth.POST("/smart/refresh", h.refreshSmartData)
|
||||
// get systemd service details
|
||||
apiAuth.GET("/systemd/info", h.getSystemdInfo)
|
||||
// /containers routes
|
||||
@@ -365,22 +365,25 @@ func (h *Hub) getSystemdInfo(e *core.RequestEvent) error {
|
||||
return e.JSON(http.StatusOK, map[string]any{"details": details})
|
||||
}
|
||||
|
||||
// getSmartData handles GET /api/beszel/smart requests
|
||||
func (h *Hub) getSmartData(e *core.RequestEvent) error {
|
||||
// refreshSmartData handles POST /api/beszel/smart/refresh requests
|
||||
// Fetches fresh SMART data from the agent and updates the collection
|
||||
func (h *Hub) refreshSmartData(e *core.RequestEvent) error {
|
||||
systemID := e.Request.URL.Query().Get("system")
|
||||
if systemID == "" {
|
||||
return e.JSON(http.StatusBadRequest, map[string]string{"error": "system parameter is required"})
|
||||
}
|
||||
|
||||
system, err := h.sm.GetSystem(systemID)
|
||||
if err != nil {
|
||||
return e.JSON(http.StatusNotFound, map[string]string{"error": "system not found"})
|
||||
}
|
||||
data, err := system.FetchSmartDataFromAgent()
|
||||
if err != nil {
|
||||
return e.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
||||
|
||||
// Fetch and save SMART devices
|
||||
if err := system.FetchAndSaveSmartDevices(); err != nil {
|
||||
return e.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
e.Response.Header().Set("Cache-Control", "public, max-age=60")
|
||||
return e.JSON(http.StatusOK, data)
|
||||
|
||||
return e.JSON(http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// generates key pair if it doesn't exist and returns signer
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"math/rand"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/internal/common"
|
||||
@@ -40,6 +41,7 @@ type System struct {
|
||||
WsConn *ws.WsConn // Handler for agent WebSocket connection
|
||||
agentVersion semver.Version // Agent version
|
||||
updateTicker *time.Ticker // Ticker for updating the system
|
||||
smartOnce sync.Once // Once for fetching and saving smart devices
|
||||
}
|
||||
|
||||
func (sm *SystemManager) NewSystem(systemId string) *System {
|
||||
@@ -191,6 +193,13 @@ func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error
|
||||
return nil
|
||||
})
|
||||
|
||||
// Fetch and save SMART devices when system first comes online
|
||||
if err == nil {
|
||||
sys.smartOnce.Do(func() {
|
||||
go sys.FetchAndSaveSmartDevices()
|
||||
})
|
||||
}
|
||||
|
||||
return systemRecord, err
|
||||
}
|
||||
|
||||
@@ -208,7 +217,7 @@ func createSystemdStatsRecords(app core.App, data []*systemd.Service, systemId s
|
||||
for i, service := range data {
|
||||
suffix := fmt.Sprintf("%d", i)
|
||||
valueStrings = append(valueStrings, fmt.Sprintf("({:id%[1]s}, {:system}, {:name%[1]s}, {:state%[1]s}, {:sub%[1]s}, {:cpu%[1]s}, {:cpuPeak%[1]s}, {:memory%[1]s}, {:memPeak%[1]s}, {:updated})", suffix))
|
||||
params["id"+suffix] = getSystemdServiceId(systemId, service.Name)
|
||||
params["id"+suffix] = makeStableHashId(systemId, service.Name)
|
||||
params["name"+suffix] = service.Name
|
||||
params["state"+suffix] = service.State
|
||||
params["sub"+suffix] = service.Sub
|
||||
@@ -225,13 +234,6 @@ func createSystemdStatsRecords(app core.App, data []*systemd.Service, systemId s
|
||||
return err
|
||||
}
|
||||
|
||||
// getSystemdServiceId generates a deterministic unique id for a systemd service
|
||||
func getSystemdServiceId(systemId string, serviceName string) string {
|
||||
hash := fnv.New32a()
|
||||
hash.Write([]byte(systemId + serviceName))
|
||||
return fmt.Sprintf("%x", hash.Sum32())
|
||||
}
|
||||
|
||||
// createContainerRecords creates container records
|
||||
func createContainerRecords(app core.App, data []*container.Stats, systemId string) error {
|
||||
if len(data) == 0 {
|
||||
@@ -435,43 +437,12 @@ func (sys *System) FetchSystemdInfoFromAgent(serviceName string) (systemd.Servic
|
||||
return result, err
|
||||
}
|
||||
|
||||
// FetchSmartDataFromAgent fetches SMART data from the agent
|
||||
func (sys *System) FetchSmartDataFromAgent() (map[string]any, error) {
|
||||
// fetch via websocket
|
||||
if sys.WsConn != nil && sys.WsConn.IsConnected() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
return sys.WsConn.RequestSmartData(ctx)
|
||||
func makeStableHashId(strings ...string) string {
|
||||
hash := fnv.New32a()
|
||||
for _, str := range strings {
|
||||
hash.Write([]byte(str))
|
||||
}
|
||||
// fetch via SSH
|
||||
var result map[string]any
|
||||
err := sys.runSSHOperation(5*time.Second, 1, func(session *ssh.Session) (bool, error) {
|
||||
stdout, err := session.StdoutPipe()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
stdin, stdinErr := session.StdinPipe()
|
||||
if stdinErr != nil {
|
||||
return false, stdinErr
|
||||
}
|
||||
if err := session.Shell(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
req := common.HubRequest[any]{Action: common.GetSmartData}
|
||||
_ = cbor.NewEncoder(stdin).Encode(req)
|
||||
_ = stdin.Close()
|
||||
var resp common.AgentResponse
|
||||
if err := cbor.NewDecoder(stdout).Decode(&resp); err != nil {
|
||||
return false, err
|
||||
}
|
||||
// Convert to generic map for JSON response
|
||||
result = make(map[string]any, len(resp.SmartData))
|
||||
for k, v := range resp.SmartData {
|
||||
result[k] = v
|
||||
}
|
||||
return false, nil
|
||||
})
|
||||
return result, err
|
||||
return fmt.Sprintf("%x", hash.Sum32())
|
||||
}
|
||||
|
||||
// fetchDataViaSSH handles fetching data using SSH.
|
||||
|
||||
132
internal/hub/systems/system_smart.go
Normal file
132
internal/hub/systems/system_smart.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package systems
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fxamacker/cbor/v2"
|
||||
"github.com/henrygd/beszel/internal/common"
|
||||
"github.com/henrygd/beszel/internal/entities/smart"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// FetchSmartDataFromAgent fetches SMART data from the agent
|
||||
func (sys *System) FetchSmartDataFromAgent() (map[string]smart.SmartData, error) {
|
||||
// fetch via websocket
|
||||
if sys.WsConn != nil && sys.WsConn.IsConnected() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
return sys.WsConn.RequestSmartData(ctx)
|
||||
}
|
||||
// fetch via SSH
|
||||
var result map[string]smart.SmartData
|
||||
err := sys.runSSHOperation(5*time.Second, 1, func(session *ssh.Session) (bool, error) {
|
||||
stdout, err := session.StdoutPipe()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
stdin, stdinErr := session.StdinPipe()
|
||||
if stdinErr != nil {
|
||||
return false, stdinErr
|
||||
}
|
||||
if err := session.Shell(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
req := common.HubRequest[any]{Action: common.GetSmartData}
|
||||
_ = cbor.NewEncoder(stdin).Encode(req)
|
||||
_ = stdin.Close()
|
||||
var resp common.AgentResponse
|
||||
if err := cbor.NewDecoder(stdout).Decode(&resp); err != nil {
|
||||
return false, err
|
||||
}
|
||||
result = resp.SmartData
|
||||
return false, nil
|
||||
})
|
||||
return result, err
|
||||
}
|
||||
|
||||
// FetchAndSaveSmartDevices fetches SMART data from the agent and saves it to the database
|
||||
func (sys *System) FetchAndSaveSmartDevices() error {
|
||||
smartData, err := sys.FetchSmartDataFromAgent()
|
||||
if err != nil || len(smartData) == 0 {
|
||||
return err
|
||||
}
|
||||
return sys.saveSmartDevices(smartData)
|
||||
}
|
||||
|
||||
// saveSmartDevices saves SMART device data to the smart_devices collection
|
||||
func (sys *System) saveSmartDevices(smartData map[string]smart.SmartData) error {
|
||||
if len(smartData) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
hub := sys.manager.hub
|
||||
collection, err := hub.FindCachedCollectionByNameOrId("smart_devices")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for deviceKey, device := range smartData {
|
||||
if err := sys.upsertSmartDeviceRecord(collection, deviceKey, device); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (sys *System) upsertSmartDeviceRecord(collection *core.Collection, deviceKey string, device smart.SmartData) error {
|
||||
hub := sys.manager.hub
|
||||
recordID := makeStableHashId(sys.Id, deviceKey)
|
||||
|
||||
record, err := hub.FindRecordById(collection, recordID)
|
||||
if err != nil {
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
record = core.NewRecord(collection)
|
||||
record.Set("id", recordID)
|
||||
}
|
||||
|
||||
name := device.DiskName
|
||||
if name == "" {
|
||||
name = deviceKey
|
||||
}
|
||||
|
||||
powerOnHours, powerCycles := extractPowerMetrics(device.Attributes)
|
||||
record.Set("system", sys.Id)
|
||||
record.Set("name", name)
|
||||
record.Set("model", device.ModelName)
|
||||
record.Set("state", device.SmartStatus)
|
||||
record.Set("capacity", device.Capacity)
|
||||
record.Set("temp", device.Temperature)
|
||||
record.Set("firmware", device.FirmwareVersion)
|
||||
record.Set("serial", device.SerialNumber)
|
||||
record.Set("type", device.DiskType)
|
||||
record.Set("hours", powerOnHours)
|
||||
record.Set("cycles", powerCycles)
|
||||
record.Set("attributes", device.Attributes)
|
||||
|
||||
return hub.SaveNoValidate(record)
|
||||
}
|
||||
|
||||
// extractPowerMetrics extracts power on hours and power cycles from SMART attributes
|
||||
func extractPowerMetrics(attributes []*smart.SmartAttribute) (powerOnHours, powerCycles uint64) {
|
||||
for _, attr := range attributes {
|
||||
nameLower := strings.ToLower(attr.Name)
|
||||
if powerOnHours == 0 && (strings.Contains(nameLower, "poweronhours") || strings.Contains(nameLower, "power_on_hours")) {
|
||||
powerOnHours = attr.RawValue
|
||||
}
|
||||
if powerCycles == 0 && ((strings.Contains(nameLower, "power") && strings.Contains(nameLower, "cycle")) || strings.Contains(nameLower, "startstopcycles")) {
|
||||
powerCycles = attr.RawValue
|
||||
}
|
||||
if powerOnHours > 0 && powerCycles > 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -14,9 +14,9 @@ func TestGetSystemdServiceId(t *testing.T) {
|
||||
serviceName := "nginx.service"
|
||||
|
||||
// Call multiple times and ensure same result
|
||||
id1 := getSystemdServiceId(systemId, serviceName)
|
||||
id2 := getSystemdServiceId(systemId, serviceName)
|
||||
id3 := getSystemdServiceId(systemId, serviceName)
|
||||
id1 := makeStableHashId(systemId, serviceName)
|
||||
id2 := makeStableHashId(systemId, serviceName)
|
||||
id3 := makeStableHashId(systemId, serviceName)
|
||||
|
||||
assert.Equal(t, id1, id2)
|
||||
assert.Equal(t, id2, id3)
|
||||
@@ -29,10 +29,10 @@ func TestGetSystemdServiceId(t *testing.T) {
|
||||
serviceName1 := "nginx.service"
|
||||
serviceName2 := "apache.service"
|
||||
|
||||
id1 := getSystemdServiceId(systemId1, serviceName1)
|
||||
id2 := getSystemdServiceId(systemId2, serviceName1)
|
||||
id3 := getSystemdServiceId(systemId1, serviceName2)
|
||||
id4 := getSystemdServiceId(systemId2, serviceName2)
|
||||
id1 := makeStableHashId(systemId1, serviceName1)
|
||||
id2 := makeStableHashId(systemId2, serviceName1)
|
||||
id3 := makeStableHashId(systemId1, serviceName2)
|
||||
id4 := makeStableHashId(systemId2, serviceName2)
|
||||
|
||||
// All IDs should be different
|
||||
assert.NotEqual(t, id1, id2)
|
||||
@@ -56,14 +56,14 @@ func TestGetSystemdServiceId(t *testing.T) {
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
id := getSystemdServiceId(tc.systemId, tc.serviceName)
|
||||
id := makeStableHashId(tc.systemId, tc.serviceName)
|
||||
// FNV-32 produces 8 hex characters
|
||||
assert.Len(t, id, 8, "ID should be 8 characters for systemId='%s', serviceName='%s'", tc.systemId, tc.serviceName)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("hexadecimal output", func(t *testing.T) {
|
||||
id := getSystemdServiceId("test-system", "test-service")
|
||||
id := makeStableHashId("test-system", "test-service")
|
||||
assert.NotEmpty(t, id)
|
||||
|
||||
// Should only contain hexadecimal characters
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"github.com/fxamacker/cbor/v2"
|
||||
"github.com/henrygd/beszel/internal/common"
|
||||
"github.com/henrygd/beszel/internal/entities/smart"
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
"github.com/henrygd/beszel/internal/entities/systemd"
|
||||
"github.com/lxzan/gws"
|
||||
@@ -155,7 +156,7 @@ func (h *systemdInfoHandler) Handle(agentResponse common.AgentResponse) error {
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// RequestSmartData requests SMART data via WebSocket.
|
||||
func (ws *WsConn) RequestSmartData(ctx context.Context) (map[string]any, error) {
|
||||
func (ws *WsConn) RequestSmartData(ctx context.Context) (map[string]smart.SmartData, error) {
|
||||
if !ws.IsConnected() {
|
||||
return nil, gws.ErrConnClosed
|
||||
}
|
||||
@@ -163,7 +164,7 @@ func (ws *WsConn) RequestSmartData(ctx context.Context) (map[string]any, error)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result map[string]any
|
||||
var result map[string]smart.SmartData
|
||||
handler := ResponseHandler(&smartDataHandler{result: &result})
|
||||
if err := ws.handleAgentRequest(req, handler); err != nil {
|
||||
return nil, err
|
||||
@@ -174,19 +175,14 @@ func (ws *WsConn) RequestSmartData(ctx context.Context) (map[string]any, error)
|
||||
// smartDataHandler parses SMART data map from AgentResponse
|
||||
type smartDataHandler struct {
|
||||
BaseHandler
|
||||
result *map[string]any
|
||||
result *map[string]smart.SmartData
|
||||
}
|
||||
|
||||
func (h *smartDataHandler) Handle(agentResponse common.AgentResponse) error {
|
||||
if agentResponse.SmartData == nil {
|
||||
return errors.New("no SMART data in response")
|
||||
}
|
||||
// convert to map[string]any for transport convenience in hub layer
|
||||
out := make(map[string]any, len(agentResponse.SmartData))
|
||||
for k, v := range agentResponse.SmartData {
|
||||
out[k] = v
|
||||
}
|
||||
*h.result = out
|
||||
*h.result = agentResponse.SmartData
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1150,6 +1150,294 @@ 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"
|
||||
},
|
||||
{
|
||||
"createRule": null,
|
||||
"deleteRule": "@request.auth.id != \"\" && system.users.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": "2hz5ncl8tizk5nx",
|
||||
"hidden": false,
|
||||
"id": "relation3377271179",
|
||||
"maxSelect": 1,
|
||||
"minSelect": 0,
|
||||
"name": "system",
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"hidden": false,
|
||||
"id": "text1579384326",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "name",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"hidden": false,
|
||||
"id": "text3616895705",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "model",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"hidden": false,
|
||||
"id": "text2744374011",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "state",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "number3051925876",
|
||||
"max": null,
|
||||
"min": null,
|
||||
"name": "capacity",
|
||||
"onlyInt": false,
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "number190023114",
|
||||
"max": null,
|
||||
"min": null,
|
||||
"name": "temp",
|
||||
"onlyInt": false,
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"hidden": false,
|
||||
"id": "text3589068740",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "firmware",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"hidden": false,
|
||||
"id": "text3547646428",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "serial",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"hidden": false,
|
||||
"id": "text2363381545",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "type",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "number1234567890",
|
||||
"max": null,
|
||||
"min": null,
|
||||
"name": "hours",
|
||||
"onlyInt": true,
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "number0987654321",
|
||||
"max": null,
|
||||
"min": null,
|
||||
"name": "cycles",
|
||||
"onlyInt": true,
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "json832282224",
|
||||
"maxSize": 0,
|
||||
"name": "attributes",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "json"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "autodate3332085495",
|
||||
"name": "updated",
|
||||
"onCreate": true,
|
||||
"onUpdate": true,
|
||||
"presentable": false,
|
||||
"system": false,
|
||||
"type": "autodate"
|
||||
}
|
||||
],
|
||||
"id": "pbc_2571630677",
|
||||
"indexes": [
|
||||
"CREATE INDEX ` + "`" + `idx_DZ9yhvgl44` + "`" + ` ON ` + "`" + `smart_devices` + "`" + ` (` + "`" + `system` + "`" + `)"
|
||||
],
|
||||
"listRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id",
|
||||
"name": "smart_devices",
|
||||
"system": false,
|
||||
"type": "base",
|
||||
"updateRule": null,
|
||||
"viewRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id"
|
||||
}
|
||||
]`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"a11y": {
|
||||
"useButtonType": "off"
|
||||
},
|
||||
"complexity": {
|
||||
"noUselessStringConcat": "error",
|
||||
"noUselessUndefinedInitialization": "error",
|
||||
@@ -30,13 +33,17 @@
|
||||
"noUnusedFunctionParameters": "error",
|
||||
"noUnusedPrivateClassMembers": "error",
|
||||
"useExhaustiveDependencies": {
|
||||
"level": "error",
|
||||
"level": "warn",
|
||||
"options": {
|
||||
"reportUnnecessaryDependencies": false
|
||||
}
|
||||
},
|
||||
"useUniqueElementIds": "off",
|
||||
"noUnusedVariables": "error"
|
||||
},
|
||||
"security": {
|
||||
"noDangerouslySetInnerHtml": "warn"
|
||||
},
|
||||
"style": {
|
||||
"noParameterProperties": "error",
|
||||
"noYodaExpression": "error",
|
||||
@@ -47,7 +54,8 @@
|
||||
},
|
||||
"suspicious": {
|
||||
"useAwait": "error",
|
||||
"noEvolvingTypes": "error"
|
||||
"noEvolvingTypes": "error",
|
||||
"noArrayIndexKey": "off"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="en" dir="ltr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="manifest" href="./static/manifest.json" />
|
||||
<link rel="manifest" href="./static/manifest.json" crossorigin="use-credentials" />
|
||||
<link rel="icon" type="image/svg+xml" href="./static/icon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0,maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
|
||||
@@ -8,10 +8,11 @@ import {
|
||||
ContainerIcon,
|
||||
DatabaseBackupIcon,
|
||||
FingerprintIcon,
|
||||
LayoutDashboard,
|
||||
HardDriveIcon,
|
||||
LogsIcon,
|
||||
MailIcon,
|
||||
Server,
|
||||
ServerIcon,
|
||||
SettingsIcon,
|
||||
UsersIcon,
|
||||
} from "lucide-react"
|
||||
@@ -81,15 +82,15 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
|
||||
)}
|
||||
<CommandGroup heading={t`Pages / Settings`}>
|
||||
<CommandItem
|
||||
keywords={["home", t`All Systems`]}
|
||||
keywords={["home"]}
|
||||
onSelect={() => {
|
||||
navigate(basePath)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<LayoutDashboard className="me-2 size-4" />
|
||||
<ServerIcon className="me-2 size-4" />
|
||||
<span>
|
||||
<Trans>Dashboard</Trans>
|
||||
<Trans>All Systems</Trans>
|
||||
</span>
|
||||
<CommandShortcut>
|
||||
<Trans>Page</Trans>
|
||||
@@ -109,6 +110,18 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
|
||||
<Trans>Page</Trans>
|
||||
</CommandShortcut>
|
||||
</CommandItem>
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
navigate(getPagePath($router, "smart"))
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<HardDriveIcon className="me-2 size-4" />
|
||||
<span>S.M.A.R.T.</span>
|
||||
<CommandShortcut>
|
||||
<Trans>Page</Trans>
|
||||
</CommandShortcut>
|
||||
</CommandItem>
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
navigate(getPagePath($router, "settings", { name: "general" }))
|
||||
|
||||
@@ -26,7 +26,7 @@ import { Sheet, SheetTitle, SheetHeader, SheetContent, SheetDescription } from "
|
||||
import { Dialog, DialogContent, DialogTitle } from "../ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { $allSystemsById } from "@/lib/stores"
|
||||
import { MaximizeIcon, RefreshCwIcon, XIcon } from "lucide-react"
|
||||
import { LoaderCircleIcon, MaximizeIcon, RefreshCwIcon, XIcon } from "lucide-react"
|
||||
import { Separator } from "../ui/separator"
|
||||
import { $router, Link } from "../router"
|
||||
import { listenKeys } from "nanostores"
|
||||
@@ -36,7 +36,7 @@ const syntaxTheme = "github-dark-dimmed"
|
||||
|
||||
export default function ContainersTable({ systemId }: { systemId?: string }) {
|
||||
const loadTime = Date.now()
|
||||
const [data, setData] = useState<ContainerRecord[]>([])
|
||||
const [data, setData] = useState<ContainerRecord[] | undefined>(undefined)
|
||||
const [sorting, setSorting] = useBrowserStorage<SortingState>(
|
||||
`sort-c-${systemId ? 1 : 0}`,
|
||||
[{ id: systemId ? "name" : "system", desc: false }],
|
||||
@@ -54,23 +54,27 @@ export default function ContainersTable({ systemId }: { systemId?: string }) {
|
||||
fields: "id,name,image,cpu,memory,net,health,status,system,updated",
|
||||
filter: systemId ? pb.filter("system={:system}", { system: systemId }) : undefined,
|
||||
})
|
||||
.then(({ items }) => items.length && setData((curItems) => {
|
||||
const lastUpdated = Math.max(items[0].updated, items.at(-1)?.updated ?? 0)
|
||||
const containerIds = new Set()
|
||||
const newItems = []
|
||||
for (const item of items) {
|
||||
if (Math.abs(lastUpdated - item.updated) < 70_000) {
|
||||
containerIds.add(item.id)
|
||||
newItems.push(item)
|
||||
}
|
||||
}
|
||||
for (const item of curItems) {
|
||||
if (!containerIds.has(item.id) && lastUpdated - item.updated < 70_000) {
|
||||
newItems.push(item)
|
||||
}
|
||||
}
|
||||
return newItems
|
||||
}))
|
||||
.then(
|
||||
({ items }) =>
|
||||
items.length &&
|
||||
setData((curItems) => {
|
||||
const lastUpdated = Math.max(items[0].updated, items.at(-1)?.updated ?? 0)
|
||||
const containerIds = new Set()
|
||||
const newItems = []
|
||||
for (const item of items) {
|
||||
if (Math.abs(lastUpdated - item.updated) < 70_000) {
|
||||
containerIds.add(item.id)
|
||||
newItems.push(item)
|
||||
}
|
||||
}
|
||||
for (const item of curItems ?? []) {
|
||||
if (!containerIds.has(item.id) && lastUpdated - item.updated < 70_000) {
|
||||
newItems.push(item)
|
||||
}
|
||||
}
|
||||
return newItems
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// initial load
|
||||
@@ -93,7 +97,7 @@ export default function ContainersTable({ systemId }: { systemId?: string }) {
|
||||
}, [])
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
data: data ?? [],
|
||||
columns: containerChartCols.filter((col) => (systemId ? col.id !== "system" : true)),
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
@@ -159,7 +163,7 @@ export default function ContainersTable({ systemId }: { systemId?: string }) {
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t`Clear filter`}
|
||||
aria-label={t`Clear`}
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 text-muted-foreground"
|
||||
onClick={() => setGlobalFilter("")}
|
||||
>
|
||||
@@ -170,7 +174,7 @@ export default function ContainersTable({ systemId }: { systemId?: string }) {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<div className="rounded-md">
|
||||
<AllContainersTable table={table} rows={rows} colLength={visibleColumns.length} />
|
||||
<AllContainersTable table={table} rows={rows} colLength={visibleColumns.length} data={data} />
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
@@ -180,10 +184,12 @@ const AllContainersTable = memo(function AllContainersTable({
|
||||
table,
|
||||
rows,
|
||||
colLength,
|
||||
data,
|
||||
}: {
|
||||
table: TableType<ContainerRecord>
|
||||
rows: Row<ContainerRecord>[]
|
||||
colLength: number
|
||||
data: ContainerRecord[] | undefined
|
||||
}) {
|
||||
// The virtualizer will need a reference to the scrollable container element
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
@@ -227,7 +233,11 @@ const AllContainersTable = memo(function AllContainersTable({
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={colLength} className="h-37 text-center pointer-events-none">
|
||||
<Trans>No results.</Trans>
|
||||
{data ? (
|
||||
<Trans>No results.</Trans>
|
||||
) : (
|
||||
<LoaderCircleIcon className="animate-spin size-10 opacity-60 mx-auto" />
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
@@ -266,7 +276,7 @@ async function getInfoHtml(container: ContainerRecord): Promise<string> {
|
||||
])
|
||||
try {
|
||||
info = JSON.stringify(JSON.parse(info), null, 2)
|
||||
} catch (_) { }
|
||||
} catch (_) {}
|
||||
return info ? highlighter.codeToHtml(info, { lang: "json", theme: syntaxTheme }) : t`No results.`
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
@@ -323,12 +333,12 @@ function ContainerSheet({
|
||||
setLogsDisplay("")
|
||||
setInfoDisplay("")
|
||||
if (!container) return
|
||||
; (async () => {
|
||||
const [logsHtml, infoHtml] = await Promise.all([getLogsHtml(container), getInfoHtml(container)])
|
||||
setLogsDisplay(logsHtml)
|
||||
setInfoDisplay(infoHtml)
|
||||
setTimeout(scrollLogsToBottom, 20)
|
||||
})()
|
||||
;(async () => {
|
||||
const [logsHtml, infoHtml] = await Promise.all([getLogsHtml(container), getInfoHtml(container)])
|
||||
setLogsDisplay(logsHtml)
|
||||
setInfoDisplay(infoHtml)
|
||||
setTimeout(scrollLogsToBottom, 20)
|
||||
})()
|
||||
}, [container])
|
||||
|
||||
return (
|
||||
@@ -505,9 +515,7 @@ function LogsFullscreenDialog({
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
void onRefresh()
|
||||
}}
|
||||
onClick={onRefresh}
|
||||
className="absolute top-3 right-11 opacity-60 hover:opacity-100 p-1"
|
||||
disabled={isRefreshing}
|
||||
title={t`Refresh`}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { getPagePath } from "@nanostores/router"
|
||||
import {
|
||||
ContainerIcon,
|
||||
DatabaseBackupIcon,
|
||||
HardDriveIcon,
|
||||
LogOutIcon,
|
||||
LogsIcon,
|
||||
SearchIcon,
|
||||
@@ -29,6 +30,7 @@ import { LangToggle } from "./lang-toggle"
|
||||
import { Logo } from "./logo"
|
||||
import { ModeToggle } from "./mode-toggle"
|
||||
import { $router, basePath, Link, prependBasePath } from "./router"
|
||||
import { t } from "@lingui/core/macro"
|
||||
|
||||
const CommandPalette = lazy(() => import("./command-palette"))
|
||||
|
||||
@@ -55,6 +57,13 @@ export default function Navbar() {
|
||||
>
|
||||
<ContainerIcon className="h-[1.2rem] w-[1.2rem]" strokeWidth={1.5} />
|
||||
</Link>
|
||||
<Link
|
||||
href={getPagePath($router, "smart")}
|
||||
className={cn("hidden md:grid", buttonVariants({ variant: "ghost", size: "icon" }))}
|
||||
aria-label="S.M.A.R.T."
|
||||
>
|
||||
<HardDriveIcon className="h-[1.2rem] w-[1.2rem]" strokeWidth={1.5} />
|
||||
</Link>
|
||||
<LangToggle />
|
||||
<ModeToggle />
|
||||
<Link
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createRouter } from "@nanostores/router"
|
||||
const routes = {
|
||||
home: "/",
|
||||
containers: "/containers",
|
||||
smart: "/smart",
|
||||
system: `/system/:id`,
|
||||
settings: `/settings/:name?`,
|
||||
forgot_password: `/forgot-password`,
|
||||
|
||||
@@ -21,7 +21,7 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const { i18n } = useLingui()
|
||||
const currentUserSettings = useStore($userSettings)
|
||||
const layoutWidth = currentUserSettings.layoutWidth ?? 1480
|
||||
const layoutWidth = currentUserSettings.layoutWidth ?? 1500
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
|
||||
@@ -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
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h3 className="mb-1 text-lg font-medium">
|
||||
<Trans>Webhook / Push notifications</Trans>
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
<Trans>
|
||||
Beszel uses{" "}
|
||||
<a href="https://beszel.dev/guide/notifications" target="_blank" className="link" rel="noopener">
|
||||
Shoutrrr
|
||||
</a>{" "}
|
||||
to integrate with popular notification services.
|
||||
</Trans>
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="mb-1 text-lg font-medium">
|
||||
<Trans>Webhook / Push notifications</Trans>
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
<Trans>
|
||||
Beszel uses{" "}
|
||||
<a href="https://beszel.dev/guide/notifications" target="_blank" className="link" rel="noopener">
|
||||
Shoutrrr
|
||||
</a>{" "}
|
||||
to integrate with popular notification services.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-10 shrink-0"
|
||||
onClick={addWebhook}
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
<span className="ms-1">
|
||||
<Trans>Add URL</Trans>
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
{webhooks.length > 0 && (
|
||||
<div className="grid gap-2.5" id="webhooks">
|
||||
@@ -146,16 +160,10 @@ const SettingsNotificationsPage = ({ userSettings }: { userSettings: UserSetting
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-2 flex items-center gap-1"
|
||||
onClick={addWebhook}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4 -ms-0.5" />
|
||||
<Trans>Add URL</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="space-y-3">
|
||||
<QuietHours />
|
||||
</div>
|
||||
<Separator />
|
||||
<Button
|
||||
@@ -194,7 +202,7 @@ const ShoutrrrUrlCard = ({ url, onUrlChange, onRemove }: ShoutrrrUrlCardProps) =
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="bg-muted/40 p-2 md:p-3">
|
||||
<Card className="bg-table-header p-2 md:p-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
type="url"
|
||||
|
||||
522
internal/site/src/components/routes/settings/quiet-hours.tsx
Normal file
522
internal/site/src/components/routes/settings/quiet-hours.tsx
Normal file
@@ -0,0 +1,522 @@
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { Trans } from "@lingui/react/macro"
|
||||
import { useStore } from "@nanostores/react"
|
||||
import {
|
||||
MoreHorizontalIcon,
|
||||
PlusIcon,
|
||||
Trash2Icon,
|
||||
ServerIcon,
|
||||
ClockIcon,
|
||||
CalendarIcon,
|
||||
ActivityIcon,
|
||||
PenSquareIcon,
|
||||
} from "lucide-react"
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
import { pb } from "@/lib/api"
|
||||
import { $systems } from "@/lib/stores"
|
||||
import { formatShortDate } from "@/lib/utils"
|
||||
import type { QuietHoursRecord, SystemRecord } from "@/types"
|
||||
|
||||
export function QuietHours() {
|
||||
const [data, setData] = useState<QuietHoursRecord[]>([])
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [editingRecord, setEditingRecord] = useState<QuietHoursRecord | null>(null)
|
||||
const { toast } = useToast()
|
||||
const systems = useStore($systems)
|
||||
|
||||
useEffect(() => {
|
||||
let unsubscribe: (() => void) | undefined
|
||||
const pbOptions = {
|
||||
expand: "system",
|
||||
fields: "id,user,system,type,start,end,expand.system.name",
|
||||
}
|
||||
// Initial load
|
||||
pb.collection<QuietHoursRecord>("quiet_hours")
|
||||
.getList(0, 200, {
|
||||
...pbOptions,
|
||||
sort: "system",
|
||||
})
|
||||
.then(({ items }) => setData(items))
|
||||
|
||||
// Subscribe to changes
|
||||
;(async () => {
|
||||
unsubscribe = await pb.collection("quiet_hours").subscribe(
|
||||
"*",
|
||||
(e) => {
|
||||
if (e.action === "create") {
|
||||
setData((current) => [e.record as QuietHoursRecord, ...current])
|
||||
}
|
||||
if (e.action === "update") {
|
||||
setData((current) => current.map((r) => (r.id === e.record.id ? (e.record as QuietHoursRecord) : r)))
|
||||
}
|
||||
if (e.action === "delete") {
|
||||
setData((current) => current.filter((r) => r.id !== e.record.id))
|
||||
}
|
||||
},
|
||||
pbOptions
|
||||
)
|
||||
})()
|
||||
// Unsubscribe on unmount
|
||||
return () => unsubscribe?.()
|
||||
}, [])
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await pb.collection("quiet_hours").delete(id)
|
||||
} catch (e: unknown) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t`Error`,
|
||||
description: (e as Error).message || "Failed to delete quiet hours.",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const openEditDialog = (record: QuietHoursRecord) => {
|
||||
setEditingRecord(record)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const closeDialog = () => {
|
||||
setDialogOpen(false)
|
||||
setEditingRecord(null)
|
||||
}
|
||||
|
||||
const formatDateTime = (record: QuietHoursRecord) => {
|
||||
if (record.type === "daily") {
|
||||
// For daily windows, show only time
|
||||
const startTime = new Date(record.start).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })
|
||||
const endTime = new Date(record.end).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })
|
||||
return `${startTime} - ${endTime}`
|
||||
}
|
||||
// For one-time windows, show full date and time
|
||||
const start = formatShortDate(record.start)
|
||||
const end = formatShortDate(record.end)
|
||||
return `${start} - ${end}`
|
||||
}
|
||||
|
||||
const getWindowState = (record: QuietHoursRecord): "active" | "past" | "inactive" => {
|
||||
const now = new Date()
|
||||
|
||||
if (record.type === "daily") {
|
||||
// For daily windows, check if current time is within the window
|
||||
const startDate = new Date(record.start)
|
||||
const endDate = new Date(record.end)
|
||||
|
||||
// Get current time in local timezone
|
||||
const currentMinutes = now.getHours() * 60 + now.getMinutes()
|
||||
const startMinutes = startDate.getUTCHours() * 60 + startDate.getUTCMinutes()
|
||||
const endMinutes = endDate.getUTCHours() * 60 + endDate.getUTCMinutes()
|
||||
|
||||
// Convert UTC to local time offset
|
||||
const offset = now.getTimezoneOffset()
|
||||
const localStartMinutes = (startMinutes - offset + 1440) % 1440
|
||||
const localEndMinutes = (endMinutes - offset + 1440) % 1440
|
||||
|
||||
// Handle cases where window spans midnight
|
||||
if (localStartMinutes <= localEndMinutes) {
|
||||
return currentMinutes >= localStartMinutes && currentMinutes < localEndMinutes ? "active" : "inactive"
|
||||
} else {
|
||||
return currentMinutes >= localStartMinutes || currentMinutes < localEndMinutes ? "active" : "inactive"
|
||||
}
|
||||
} else {
|
||||
// For one-time windows
|
||||
const startDate = new Date(record.start)
|
||||
const endDate = new Date(record.end)
|
||||
|
||||
if (now >= startDate && now < endDate) {
|
||||
return "active"
|
||||
} else if (now >= endDate) {
|
||||
return "past"
|
||||
} else {
|
||||
return "inactive"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-1 sm:flex items-center justify-between gap-4 mb-3">
|
||||
<div>
|
||||
<h3 className="mb-1 text-lg font-medium">
|
||||
<Trans>Quiet hours</Trans>
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
<Trans>
|
||||
Schedule quiet hours where notifications will not be sent, such as during maintenance periods.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" className="h-10 shrink-0" onClick={() => setEditingRecord(null)}>
|
||||
<PlusIcon className="size-4" />
|
||||
<span className="ms-1">
|
||||
<Trans>Add Quiet Hours</Trans>
|
||||
</span>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<QuietHoursDialog editingRecord={editingRecord} systems={systems} onClose={closeDialog} toast={toast} />
|
||||
</Dialog>
|
||||
</div>
|
||||
{data.length > 0 && (
|
||||
<div className="rounded-md border overflow-x-auto whitespace-nowrap">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-border/50">
|
||||
<TableHead className="px-4">
|
||||
<span className="flex items-center gap-2">
|
||||
<ServerIcon className="size-4" />
|
||||
<Trans>System</Trans>
|
||||
</span>
|
||||
</TableHead>
|
||||
<TableHead className="px-4">
|
||||
<span className="flex items-center gap-2">
|
||||
<ClockIcon className="size-4" />
|
||||
<Trans>Type</Trans>
|
||||
</span>
|
||||
</TableHead>
|
||||
<TableHead className="px-4">
|
||||
<span className="flex items-center gap-2">
|
||||
<CalendarIcon className="size-4" />
|
||||
<Trans>Schedule</Trans>
|
||||
</span>
|
||||
</TableHead>
|
||||
<TableHead className="px-4">
|
||||
<span className="flex items-center gap-2">
|
||||
<ActivityIcon className="size-4" />
|
||||
<Trans>State</Trans>
|
||||
</span>
|
||||
</TableHead>
|
||||
<TableHead className="px-4 text-right sr-only">
|
||||
<Trans>Actions</Trans>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.map((record) => (
|
||||
<TableRow key={record.id}>
|
||||
<TableCell className="px-4 py-3">
|
||||
{record.system ? record.expand?.system?.name || record.system : <Trans>All Systems</Trans>}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3">
|
||||
{record.type === "daily" ? <Trans>Daily</Trans> : <Trans>One-time</Trans>}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3">{formatDateTime(record)}</TableCell>
|
||||
<TableCell className="px-4 py-3">
|
||||
{(() => {
|
||||
const state = getWindowState(record)
|
||||
const stateConfig = {
|
||||
active: { label: <Trans>Active</Trans>, variant: "success" as const },
|
||||
past: { label: <Trans>Past</Trans>, variant: "danger" as const },
|
||||
inactive: { label: <Trans>Inactive</Trans>, variant: "default" as const },
|
||||
}
|
||||
const config = stateConfig[state]
|
||||
return <Badge variant={config.variant}>{config.label}</Badge>
|
||||
})()}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3 text-right">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="size-8">
|
||||
<span className="sr-only">
|
||||
<Trans>Open menu</Trans>
|
||||
</span>
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => openEditDialog(record)}>
|
||||
<PenSquareIcon className="me-2.5 size-4" />
|
||||
<Trans>Edit</Trans>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleDelete(record.id)}>
|
||||
<Trash2Icon className="me-2.5 size-4" />
|
||||
<Trans>Delete</Trans>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// 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: ReturnType<typeof useToast>["toast"]
|
||||
}) {
|
||||
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)
|
||||
} else {
|
||||
await pb.collection("quiet_hours").create(data)
|
||||
}
|
||||
|
||||
onClose()
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t`Error`,
|
||||
description: t`Failed to save settings`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingRecord ? <Trans>Edit Quiet Hours</Trans> : <Trans>Add Quiet Hours</Trans>}</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>Configure quiet hours where notifications will not be sent.</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<Tabs value={isGlobal ? "global" : "system"} onValueChange={(value) => setIsGlobal(value === "global")}>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="global">
|
||||
<Trans>Global</Trans>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="system">
|
||||
<Trans>System</Trans>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="system" className="mt-4 space-y-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="system">
|
||||
<Trans>System</Trans>
|
||||
</Label>
|
||||
<Select value={selectedSystem} onValueChange={setSelectedSystem}>
|
||||
<SelectTrigger id="system">
|
||||
<SelectValue placeholder={t`Select a system`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{systems.map((system) => (
|
||||
<SelectItem key={system.id} value={system.id}>
|
||||
{system.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{/* Hidden input for native form validation */}
|
||||
<input
|
||||
className="sr-only"
|
||||
type="text"
|
||||
tabIndex={-1}
|
||||
autoComplete="off"
|
||||
value={selectedSystem}
|
||||
onChange={() => {}}
|
||||
required={!isGlobal}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="type">
|
||||
<Trans>Type</Trans>
|
||||
</Label>
|
||||
<Select value={windowType} onValueChange={(value: "one-time" | "daily") => setWindowType(value)}>
|
||||
<SelectTrigger id="type">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="one-time">
|
||||
<Trans>One-time</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem value="daily">
|
||||
<Trans>Daily</Trans>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{windowType === "one-time" ? (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="start-datetime">
|
||||
<Trans>Start Time</Trans>
|
||||
</Label>
|
||||
<Input
|
||||
id="start-datetime"
|
||||
type="datetime-local"
|
||||
value={startDateTime}
|
||||
onChange={(e) => setStartDateTime(e.target.value)}
|
||||
min={formatDateTimeLocal(new Date(new Date().setHours(0, 0, 0, 0)))}
|
||||
required
|
||||
className="tabular-nums tracking-tighter"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="end-datetime">
|
||||
<Trans>End Time</Trans>
|
||||
</Label>
|
||||
<Input
|
||||
id="end-datetime"
|
||||
type="datetime-local"
|
||||
value={endDateTime}
|
||||
onChange={(e) => setEndDateTime(e.target.value)}
|
||||
min={startDateTime || formatDateTimeLocal(new Date())}
|
||||
required
|
||||
className="tabular-nums tracking-tighter"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="grid gap-2 grid-cols-2">
|
||||
<div>
|
||||
<Label htmlFor="start-time">
|
||||
<Trans>Start Time</Trans>
|
||||
</Label>
|
||||
<Input
|
||||
className="tabular-nums tracking-tighter"
|
||||
id="start-time"
|
||||
type="time"
|
||||
value={startTime}
|
||||
onChange={(e) => setStartTime(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="end-time">
|
||||
<Trans>End Time</Trans>
|
||||
</Label>
|
||||
<Input
|
||||
className="tabular-nums tracking-tighter"
|
||||
id="end-time"
|
||||
type="time"
|
||||
value={endTime}
|
||||
onChange={(e) => setEndTime(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<Button type="submit">{editingRecord ? <Trans>Update</Trans> : <Trans>Create</Trans>}</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
)
|
||||
}
|
||||
20
internal/site/src/components/routes/smart.tsx
Normal file
20
internal/site/src/components/routes/smart.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { useEffect } from "react"
|
||||
import SmartTable from "@/components/routes/system/smart-table"
|
||||
import { ActiveAlerts } from "@/components/active-alerts"
|
||||
import { FooterRepoLink } from "@/components/footer-repo-link"
|
||||
|
||||
export default function Smart() {
|
||||
useEffect(() => {
|
||||
document.title = `S.M.A.R.T. / Beszel`
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid gap-4">
|
||||
<ActiveAlerts />
|
||||
<SmartTable />
|
||||
</div>
|
||||
<FooterRepoLink />
|
||||
</>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,4 @@
|
||||
/** biome-ignore-all lint/correctness/useHookAtTopLevel: <explanation> */
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { Trans, useLingui } from "@lingui/react/macro"
|
||||
import { useStore } from "@nanostores/react"
|
||||
@@ -218,7 +219,7 @@ export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<Syste
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorFn: ({ info }) => (info.bb || (info.b || 0) * 1024 * 1024) || undefined,
|
||||
accessorFn: ({ info }) => info.bb || (info.b || 0) * 1024 * 1024 || undefined,
|
||||
id: "net",
|
||||
name: () => t`Net`,
|
||||
size: 0,
|
||||
@@ -233,7 +234,7 @@ export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<Syste
|
||||
const { value, unit } = formatBytes((info.getValue() || 0) as number, true, userSettings.unitNet, false)
|
||||
return (
|
||||
<span className="tabular-nums whitespace-nowrap">
|
||||
{decimalString(value , value >= 100 ? 1 : 2)} {unit}
|
||||
{decimalString(value, value >= 100 ? 1 : 2)} {unit}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
@@ -291,7 +292,10 @@ export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<Syste
|
||||
[STATUS_COLORS[SystemStatus.Up]]: numFailed === 0,
|
||||
})}
|
||||
/>
|
||||
{totalCount} <span className="text-muted-foreground text-sm -ms-0.5">({t`Failed`.toLowerCase()}: {numFailed})</span>
|
||||
{totalCount}{" "}
|
||||
<span className="text-muted-foreground text-sm -ms-0.5">
|
||||
({t`Failed`.toLowerCase()}: {numFailed})
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
},
|
||||
@@ -395,70 +399,92 @@ function DiskCellWithMultiple(info: CellContext<SystemRecord, unknown>) {
|
||||
const { info: sysInfo, status, id } = info.row.original
|
||||
const extraFs = Object.entries(sysInfo.efs ?? {})
|
||||
|
||||
// No extra disks - show basic meter
|
||||
if (extraFs.length === 0) {
|
||||
return TableCellWithMeter(info)
|
||||
}
|
||||
|
||||
const rootDiskPct = sysInfo.dp
|
||||
|
||||
|
||||
// sort extra disks by percentage descending
|
||||
extraFs.sort((a, b) => b[1] - a[1])
|
||||
|
||||
function getMeterClass(pct: number) {
|
||||
|
||||
function getIndicatorColor(pct: number) {
|
||||
const threshold = getMeterState(pct)
|
||||
return cn(
|
||||
"h-full",
|
||||
return (
|
||||
(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
|
||||
)
|
||||
}
|
||||
|
||||
function getMeterClass(pct: number) {
|
||||
return cn("h-full", getIndicatorColor(pct))
|
||||
}
|
||||
|
||||
// Extra disk indicators (max 3 dots - one per state if any disk exists in range)
|
||||
const stateColors = [STATUS_COLORS.up, STATUS_COLORS.pending, STATUS_COLORS.down]
|
||||
const extraDiskIndicators =
|
||||
status !== SystemStatus.Up
|
||||
? []
|
||||
: [...new Set(extraFs.map(([, pct]) => getMeterState(pct)))].sort().map((state) => stateColors[state])
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link href={getPagePath($router, "system", { id })} tabIndex={-1} className="flex flex-col gap-0.5 w-full relative z-10">
|
||||
<Link
|
||||
href={getPagePath($router, "system", { id })}
|
||||
tabIndex={-1}
|
||||
className="flex flex-col gap-0.5 w-full relative z-10"
|
||||
>
|
||||
<div className="flex gap-2 items-center tabular-nums tracking-tight">
|
||||
<span className="min-w-8 shrink-0">{decimalString(rootDiskPct, rootDiskPct >= 10 ? 1 : 2)}%</span>
|
||||
<span className="flex-1 min-w-8 grid bg-muted h-[1em] rounded-sm overflow-hidden">
|
||||
<span className="flex-1 min-w-8 flex items-center gap-0.5 px-1 justify-end bg-muted h-[1em] rounded-sm overflow-hidden relative">
|
||||
{/* Root disk */}
|
||||
<span className={getMeterClass(rootDiskPct)} style={{ width: `${rootDiskPct}%` }}></span>
|
||||
{/* Extra disks */}
|
||||
{extraFs.map(([_name, pct], index) => (
|
||||
<span key={index} className={getMeterClass(pct)} style={{ width: `${pct}%` }}></span>
|
||||
<span
|
||||
className={cn("absolute inset-0", getMeterClass(rootDiskPct))}
|
||||
style={{ width: `${rootDiskPct}%` }}
|
||||
></span>
|
||||
{/* Extra disk indicators */}
|
||||
{extraDiskIndicators.map((color) => (
|
||||
<span
|
||||
key={color}
|
||||
className={cn("size-1.5 rounded-full shrink-0 outline-[0.5px] outline-muted", color)}
|
||||
/>
|
||||
))}
|
||||
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="max-w-xs pb-2">
|
||||
<div className="grid gap-1.5">
|
||||
<div className="grid gap-0.5">
|
||||
<div className="text-[0.65rem] text-muted-foreground uppercase tracking-wide tabular-nums"><Trans context="Root disk label">Root</Trans></div>
|
||||
<div className="flex gap-2 items-center tabular-nums text-xs">
|
||||
<span className="min-w-7">{decimalString(rootDiskPct, rootDiskPct >= 10 ? 1 : 2)}%</span>
|
||||
<span className="flex-1 min-w-12 grid bg-muted h-2.5 rounded-sm overflow-hidden">
|
||||
<span className={getMeterClass(rootDiskPct)} style={{ width: `${rootDiskPct}%` }}></span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid gap-1">
|
||||
<div className="grid gap-0.5">
|
||||
<div className="text-[0.65rem] text-muted-foreground uppercase tracking-wide tabular-nums">
|
||||
<Trans context="Root disk label">Root</Trans>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center tabular-nums text-xs">
|
||||
<span className="min-w-7">{decimalString(rootDiskPct, rootDiskPct >= 10 ? 1 : 2)}%</span>
|
||||
<span className="flex-1 min-w-12 grid bg-muted h-2.5 rounded-sm overflow-hidden">
|
||||
<span className={getMeterClass(rootDiskPct)} style={{ width: `${rootDiskPct}%` }}></span>
|
||||
</span>
|
||||
</div>
|
||||
{extraFs.map(([name, pct]) => {
|
||||
return (
|
||||
<div key={name} className="grid gap-0.5">
|
||||
<div className="text-[0.65rem] max-w-40 text-muted-foreground uppercase tracking-wide truncate">{name}</div>
|
||||
<div className="flex gap-2 items-center tabular-nums text-xs">
|
||||
<span className="min-w-7">{decimalString(pct, pct >= 10 ? 1 : 2)}%</span>
|
||||
<span className="flex-1 min-w-12 grid bg-muted h-2.5 rounded-sm overflow-hidden">
|
||||
<span className={getMeterClass(pct)} style={{ width: `${pct}%` }}></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{extraFs.map(([name, pct]) => {
|
||||
return (
|
||||
<div key={name} className="grid gap-0.5">
|
||||
<div className="text-[0.65rem] max-w-40 text-muted-foreground uppercase tracking-wide truncate">
|
||||
{name}
|
||||
</div>
|
||||
<div className="flex gap-2 items-center tabular-nums text-xs">
|
||||
<span className="min-w-7">{decimalString(pct, pct >= 10 ? 1 : 2)}%</span>
|
||||
<span className="flex-1 min-w-12 grid bg-muted h-2.5 rounded-sm overflow-hidden">
|
||||
<span className={getMeterClass(pct)} style={{ width: `${pct}%` }}></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
|
||||
@@ -158,7 +158,7 @@ export default function SystemsTable() {
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Clear filter"
|
||||
aria-label={t`Clear`}
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 text-muted-foreground"
|
||||
onClick={() => setFilter("")}
|
||||
>
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
--table-header: hsl(225, 6%, 97%);
|
||||
--chart-saturation: 65%;
|
||||
--chart-lightness: 50%;
|
||||
--container: 1480px;
|
||||
--container: 1500px;
|
||||
}
|
||||
|
||||
.dark {
|
||||
@@ -117,7 +117,6 @@
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
|
||||
/* Fonts */
|
||||
@supports (font-variation-settings: normal) {
|
||||
:root {
|
||||
|
||||
@@ -20,6 +20,7 @@ import * as systemsManager from "@/lib/systemsManager.ts"
|
||||
const LoginPage = lazy(() => import("@/components/login/login.tsx"))
|
||||
const Home = lazy(() => import("@/components/routes/home.tsx"))
|
||||
const Containers = lazy(() => import("@/components/routes/containers.tsx"))
|
||||
const Smart = lazy(() => import("@/components/routes/smart.tsx"))
|
||||
const SystemDetail = lazy(() => import("@/components/routes/system.tsx"))
|
||||
const CopyToClipboardDialog = lazy(() => import("@/components/copy-to-clipboard.tsx"))
|
||||
|
||||
@@ -62,6 +63,8 @@ const App = memo(() => {
|
||||
return <SystemDetail id={page.params.id} />
|
||||
} else if (page.route === "containers") {
|
||||
return <Containers />
|
||||
} else if (page.route === "smart") {
|
||||
return <Smart />
|
||||
} else if (page.route === "settings") {
|
||||
return <Settings />
|
||||
}
|
||||
@@ -97,7 +100,7 @@ const Layout = () => {
|
||||
<LoginPage />
|
||||
</Suspense>
|
||||
) : (
|
||||
<div style={{"--container": `${userSettings.layoutWidth ?? 1480}px`} as React.CSSProperties}>
|
||||
<div style={{ "--container": `${userSettings.layoutWidth ?? 1500}px` } as React.CSSProperties}>
|
||||
<div className="container">
|
||||
<Navbar />
|
||||
</div>
|
||||
|
||||
31
internal/site/src/types.d.ts
vendored
31
internal/site/src/types.d.ts
vendored
@@ -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
|
||||
@@ -363,6 +377,23 @@ export interface SmartAttribute {
|
||||
wf?: string
|
||||
}
|
||||
|
||||
export interface SmartDeviceRecord extends RecordModel {
|
||||
id: string
|
||||
system: string
|
||||
name: string
|
||||
model: string
|
||||
state: string
|
||||
capacity: number
|
||||
temp: number
|
||||
firmware: string
|
||||
serial: string
|
||||
type: string
|
||||
hours: number
|
||||
cycles: number
|
||||
attributes: SmartAttribute[]
|
||||
updated: string
|
||||
}
|
||||
|
||||
export interface SystemdRecord extends RecordModel {
|
||||
system: string
|
||||
name: string
|
||||
|
||||
Reference in New Issue
Block a user