Compare commits

...

9 Commits

Author SHA1 Message Date
henrygd
53a7e06dcf upgrade go dependencies 2025-11-30 13:55:25 -05:00
henrygd
11edabd09f add alerts for smart failures 2025-11-30 13:52:31 -05:00
henrygd
41a3d9359f add dedicated S.M.A.R.T. page with persistent device storage
- Add /smart route to view SMART data across all systems
- Store SMART devices in new smart_devices collection
- Auto-fetch SMART data when system first comes online
- Add refresh/delete actions per device with realtime updates
- Add navbar and command palette entries
2025-11-30 13:32:37 -05:00
henrygd
5dfc5f247f update extra disk indicators in systems table to display max 3 states 2025-11-30 12:32:16 -05:00
henrygd
9804c8a31a update translation strings 2025-11-29 20:29:07 -05:00
Pavel Pikta
4d05bfdff0 feat: add crossorigin attribute to manifest link (#1457)
Signed-off-by: Pavel Pikta <pavel_pikta@epam.com>
2025-11-26 19:41:54 -05:00
henrygd
0388401a9e change layout of extra disks in all systems table (#1409) 2025-11-25 16:23:48 -05:00
henrygd
162c548010 quiet hours refactoring: change 'future' to 'inactive' 2025-11-24 19:12:35 -05:00
henrygd
888b4a57e5 add quiet hours to silence alerts during specific time periods (#265) 2025-11-24 17:35:28 -05:00
31 changed files with 2720 additions and 636 deletions

10
go.mod
View File

@@ -10,16 +10,16 @@ require (
github.com/gliderlabs/ssh v0.3.8 github.com/gliderlabs/ssh v0.3.8
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/lxzan/gws v1.8.9 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/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/shirou/gopsutil/v4 v4.25.10
github.com/spf13/cast v1.10.0 github.com/spf13/cast v1.10.0
github.com/spf13/cobra v1.10.1 github.com/spf13/cobra v1.10.1
github.com/spf13/pflag v1.0.10 github.com/spf13/pflag v1.0.10
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
golang.org/x/crypto v0.44.0 golang.org/x/crypto v0.45.0
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
@@ -64,5 +64,5 @@ require (
modernc.org/libc v1.66.10 // indirect modernc.org/libc v1.66.10 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // 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
View File

@@ -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/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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 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-20251114195745-4902fdda35c8 h1:3DsUAV+VNEQa2CUVLxCY3f87278uWfIDhJnbdvDjvmE=
github.com/google/pprof v0.0.0-20251007162407-5df77e3f7d1d/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U= 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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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= 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/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 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= 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.1 h1:8NjY+I3K7cGHy89ncnaPGUA0ex44XbYK3SAFJX9YMI8=
github.com/nicholas-fedor/shoutrrr v0.12.0/go.mod h1:WYiRalR4C43Qmd2zhPWGIFIxu633NB1hDM6Ap/DQcsA= 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 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns=
github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= 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= 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/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 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU=
github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs= 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.34.0 h1:5W80PrGvkRYIMAIK90F7w031/hXgZVz1KSuCJqSpgJo=
github.com/pocketbase/pocketbase v0.33.0/go.mod h1:9BEs+CRV7CrS+X5LfBh4bdJQsbzQAIklft3ovGe/c5A= 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 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= 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= 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 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= 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.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU= golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0= golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39 h1:DHNhtq3sNNzrvduZZIiFyXWOL9IWaDPHqTnLJp+rCBY=
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0= 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.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 h1:LXRZRnv1+zGd5XBUVRFmYEphyyKJjQjCRiOuAP3sZfQ=
golang.org/x/image v0.33.0/go.mod h1:DD3OsTYT9chzuzTQt+zMcOlBHgfoKQb1gry8p76Y1sc= 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/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A= modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I= modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
modernc.org/libc v1.67.0 h1:QzL4IrKab2OFmxA3/vRYl0tLXrIamwrhD6CKD4WBVjQ= modernc.org/libc v1.67.1 h1:bFaqOaa5/zbWYJo8aW0tXPX21hXsngG2M7mckCnFSVk=
modernc.org/libc v1.67.0/go.mod h1:QvvnnJ5P7aitu0ReNpVIEyesuhmDLQ8kaEoyMjIFZJA= 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 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= 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/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= 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.1 h1:VfuXcxcUWWKRBuP8+BR9L7VnmusMgBNNnBYGEe9w/iY=
modernc.org/sqlite v1.40.0/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE= 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 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

View File

@@ -28,6 +28,7 @@ type AlertManager struct {
type AlertMessageData struct { type AlertMessageData struct {
UserID string UserID string
SystemID string
Title string Title string
Message string Message string
Link string Link string
@@ -103,10 +104,84 @@ func NewAlertManager(app hubLike) *AlertManager {
func (am *AlertManager) bindEvents() { func (am *AlertManager) bindEvents() {
am.hub.OnRecordAfterUpdateSuccess("alerts").BindFunc(updateHistoryOnAlertUpdate) am.hub.OnRecordAfterUpdateSuccess("alerts").BindFunc(updateHistoryOnAlertUpdate)
am.hub.OnRecordAfterDeleteSuccess("alerts").BindFunc(resolveHistoryOnAlertDelete) 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 // SendAlert sends an alert to the user
func (am *AlertManager) SendAlert(data AlertMessageData) error { 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 // get user settings
record, err := am.hub.FindFirstRecordByFilter( record, err := am.hub.FindFirstRecordByFilter(
"user_settings", "user={:user}", "user_settings", "user={:user}",

View 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")
}

View 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()
}

View 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")
}

View File

@@ -166,6 +166,7 @@ func (am *AlertManager) sendStatusAlert(alertStatus string, systemName string, a
return am.SendAlert(AlertMessageData{ return am.SendAlert(AlertMessageData{
UserID: alertRecord.GetString("user"), UserID: alertRecord.GetString("user"),
SystemID: systemID,
Title: title, Title: title,
Message: message, Message: message,
Link: am.hub.MakeLink("system", systemID), Link: am.hub.MakeLink("system", systemID),

View File

@@ -309,6 +309,7 @@ func (am *AlertManager) sendSystemAlert(alert SystemAlertData) {
} }
am.SendAlert(AlertMessageData{ am.SendAlert(AlertMessageData{
UserID: alert.alertRecord.GetString("user"), UserID: alert.alertRecord.GetString("user"),
SystemID: alert.systemRecord.Id,
Title: subject, Title: subject,
Message: body, Message: body,
Link: am.hub.MakeLink("system", alert.systemRecord.Id), Link: am.hub.MakeLink("system", alert.systemRecord.Id),

View File

@@ -268,8 +268,8 @@ func (h *Hub) registerApiRoutes(se *core.ServeEvent) error {
// update / delete user alerts // update / delete user alerts
apiAuth.POST("/user-alerts", alerts.UpsertUserAlerts) apiAuth.POST("/user-alerts", alerts.UpsertUserAlerts)
apiAuth.DELETE("/user-alerts", alerts.DeleteUserAlerts) apiAuth.DELETE("/user-alerts", alerts.DeleteUserAlerts)
// get SMART data // refresh SMART devices for a system
apiAuth.GET("/smart", h.getSmartData) apiAuth.POST("/smart/refresh", h.refreshSmartData)
// get systemd service details // get systemd service details
apiAuth.GET("/systemd/info", h.getSystemdInfo) apiAuth.GET("/systemd/info", h.getSystemdInfo)
// /containers routes // /containers routes
@@ -365,22 +365,25 @@ func (h *Hub) getSystemdInfo(e *core.RequestEvent) error {
return e.JSON(http.StatusOK, map[string]any{"details": details}) return e.JSON(http.StatusOK, map[string]any{"details": details})
} }
// getSmartData handles GET /api/beszel/smart requests // refreshSmartData handles POST /api/beszel/smart/refresh requests
func (h *Hub) getSmartData(e *core.RequestEvent) error { // 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") systemID := e.Request.URL.Query().Get("system")
if systemID == "" { if systemID == "" {
return e.JSON(http.StatusBadRequest, map[string]string{"error": "system parameter is required"}) return e.JSON(http.StatusBadRequest, map[string]string{"error": "system parameter is required"})
} }
system, err := h.sm.GetSystem(systemID) system, err := h.sm.GetSystem(systemID)
if err != nil { if err != nil {
return e.JSON(http.StatusNotFound, map[string]string{"error": "system not found"}) return e.JSON(http.StatusNotFound, map[string]string{"error": "system not found"})
} }
data, err := system.FetchSmartDataFromAgent()
if err != nil { // Fetch and save SMART devices
return e.JSON(http.StatusNotFound, map[string]string{"error": err.Error()}) 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 // generates key pair if it doesn't exist and returns signer

View File

@@ -9,6 +9,7 @@ import (
"math/rand" "math/rand"
"net" "net"
"strings" "strings"
"sync"
"time" "time"
"github.com/henrygd/beszel/internal/common" "github.com/henrygd/beszel/internal/common"
@@ -40,6 +41,7 @@ type System struct {
WsConn *ws.WsConn // Handler for agent WebSocket connection WsConn *ws.WsConn // Handler for agent WebSocket connection
agentVersion semver.Version // Agent version agentVersion semver.Version // Agent version
updateTicker *time.Ticker // Ticker for updating the system 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 { func (sm *SystemManager) NewSystem(systemId string) *System {
@@ -191,6 +193,13 @@ func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error
return nil 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 return systemRecord, err
} }
@@ -208,7 +217,7 @@ func createSystemdStatsRecords(app core.App, data []*systemd.Service, systemId s
for i, service := range data { for i, service := range data {
suffix := fmt.Sprintf("%d", i) 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)) 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["name"+suffix] = service.Name
params["state"+suffix] = service.State params["state"+suffix] = service.State
params["sub"+suffix] = service.Sub params["sub"+suffix] = service.Sub
@@ -225,13 +234,6 @@ func createSystemdStatsRecords(app core.App, data []*systemd.Service, systemId s
return err 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 // createContainerRecords creates container records
func createContainerRecords(app core.App, data []*container.Stats, systemId string) error { func createContainerRecords(app core.App, data []*container.Stats, systemId string) error {
if len(data) == 0 { if len(data) == 0 {
@@ -435,43 +437,12 @@ func (sys *System) FetchSystemdInfoFromAgent(serviceName string) (systemd.Servic
return result, err return result, err
} }
// FetchSmartDataFromAgent fetches SMART data from the agent func makeStableHashId(strings ...string) string {
func (sys *System) FetchSmartDataFromAgent() (map[string]any, error) { hash := fnv.New32a()
// fetch via websocket for _, str := range strings {
if sys.WsConn != nil && sys.WsConn.IsConnected() { hash.Write([]byte(str))
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return sys.WsConn.RequestSmartData(ctx)
} }
// fetch via SSH return fmt.Sprintf("%x", hash.Sum32())
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
} }
// fetchDataViaSSH handles fetching data using SSH. // fetchDataViaSSH handles fetching data using SSH.

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

View File

@@ -14,9 +14,9 @@ func TestGetSystemdServiceId(t *testing.T) {
serviceName := "nginx.service" serviceName := "nginx.service"
// Call multiple times and ensure same result // Call multiple times and ensure same result
id1 := getSystemdServiceId(systemId, serviceName) id1 := makeStableHashId(systemId, serviceName)
id2 := getSystemdServiceId(systemId, serviceName) id2 := makeStableHashId(systemId, serviceName)
id3 := getSystemdServiceId(systemId, serviceName) id3 := makeStableHashId(systemId, serviceName)
assert.Equal(t, id1, id2) assert.Equal(t, id1, id2)
assert.Equal(t, id2, id3) assert.Equal(t, id2, id3)
@@ -29,10 +29,10 @@ func TestGetSystemdServiceId(t *testing.T) {
serviceName1 := "nginx.service" serviceName1 := "nginx.service"
serviceName2 := "apache.service" serviceName2 := "apache.service"
id1 := getSystemdServiceId(systemId1, serviceName1) id1 := makeStableHashId(systemId1, serviceName1)
id2 := getSystemdServiceId(systemId2, serviceName1) id2 := makeStableHashId(systemId2, serviceName1)
id3 := getSystemdServiceId(systemId1, serviceName2) id3 := makeStableHashId(systemId1, serviceName2)
id4 := getSystemdServiceId(systemId2, serviceName2) id4 := makeStableHashId(systemId2, serviceName2)
// All IDs should be different // All IDs should be different
assert.NotEqual(t, id1, id2) assert.NotEqual(t, id1, id2)
@@ -56,14 +56,14 @@ func TestGetSystemdServiceId(t *testing.T) {
} }
for _, tc := range testCases { for _, tc := range testCases {
id := getSystemdServiceId(tc.systemId, tc.serviceName) id := makeStableHashId(tc.systemId, tc.serviceName)
// FNV-32 produces 8 hex characters // 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) 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) { t.Run("hexadecimal output", func(t *testing.T) {
id := getSystemdServiceId("test-system", "test-service") id := makeStableHashId("test-system", "test-service")
assert.NotEmpty(t, id) assert.NotEmpty(t, id)
// Should only contain hexadecimal characters // Should only contain hexadecimal characters

View File

@@ -6,6 +6,7 @@ import (
"github.com/fxamacker/cbor/v2" "github.com/fxamacker/cbor/v2"
"github.com/henrygd/beszel/internal/common" "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/system"
"github.com/henrygd/beszel/internal/entities/systemd" "github.com/henrygd/beszel/internal/entities/systemd"
"github.com/lxzan/gws" "github.com/lxzan/gws"
@@ -155,7 +156,7 @@ func (h *systemdInfoHandler) Handle(agentResponse common.AgentResponse) error {
//////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////
// RequestSmartData requests SMART data via WebSocket. // 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() { if !ws.IsConnected() {
return nil, gws.ErrConnClosed return nil, gws.ErrConnClosed
} }
@@ -163,7 +164,7 @@ func (ws *WsConn) RequestSmartData(ctx context.Context) (map[string]any, error)
if err != nil { if err != nil {
return nil, err return nil, err
} }
var result map[string]any var result map[string]smart.SmartData
handler := ResponseHandler(&smartDataHandler{result: &result}) handler := ResponseHandler(&smartDataHandler{result: &result})
if err := ws.handleAgentRequest(req, handler); err != nil { if err := ws.handleAgentRequest(req, handler); err != nil {
return nil, err 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 // smartDataHandler parses SMART data map from AgentResponse
type smartDataHandler struct { type smartDataHandler struct {
BaseHandler BaseHandler
result *map[string]any result *map[string]smart.SmartData
} }
func (h *smartDataHandler) Handle(agentResponse common.AgentResponse) error { func (h *smartDataHandler) Handle(agentResponse common.AgentResponse) error {
if agentResponse.SmartData == nil { if agentResponse.SmartData == nil {
return errors.New("no SMART data in response") return errors.New("no SMART data in response")
} }
// convert to map[string]any for transport convenience in hub layer *h.result = agentResponse.SmartData
out := make(map[string]any, len(agentResponse.SmartData))
for k, v := range agentResponse.SmartData {
out[k] = v
}
*h.result = out
return nil return nil
} }

View File

@@ -1150,6 +1150,294 @@ func init() {
"type": "base", "type": "base",
"updateRule": null, "updateRule": null,
"viewRule": 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"
} }
]` ]`

View File

@@ -498,6 +498,10 @@ func (rm *RecordManager) DeleteOldRecords() {
if err != nil { if err != nil {
return err return err
} }
err = deleteOldQuietHours(txApp)
if err != nil {
return err
}
return nil return nil
}) })
} }
@@ -591,6 +595,17 @@ func deleteOldContainerRecords(app core.App) error {
return nil 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 */ /* Round float to two decimals */
func twoDecimals(value float64) float64 { func twoDecimals(value float64) float64 {
return math.Round(value*100) / 100 return math.Round(value*100) / 100

View File

@@ -17,6 +17,9 @@
"enabled": true, "enabled": true,
"rules": { "rules": {
"recommended": true, "recommended": true,
"a11y": {
"useButtonType": "off"
},
"complexity": { "complexity": {
"noUselessStringConcat": "error", "noUselessStringConcat": "error",
"noUselessUndefinedInitialization": "error", "noUselessUndefinedInitialization": "error",
@@ -30,13 +33,17 @@
"noUnusedFunctionParameters": "error", "noUnusedFunctionParameters": "error",
"noUnusedPrivateClassMembers": "error", "noUnusedPrivateClassMembers": "error",
"useExhaustiveDependencies": { "useExhaustiveDependencies": {
"level": "error", "level": "warn",
"options": { "options": {
"reportUnnecessaryDependencies": false "reportUnnecessaryDependencies": false
} }
}, },
"useUniqueElementIds": "off",
"noUnusedVariables": "error" "noUnusedVariables": "error"
}, },
"security": {
"noDangerouslySetInnerHtml": "warn"
},
"style": { "style": {
"noParameterProperties": "error", "noParameterProperties": "error",
"noYodaExpression": "error", "noYodaExpression": "error",
@@ -47,7 +54,8 @@
}, },
"suspicious": { "suspicious": {
"useAwait": "error", "useAwait": "error",
"noEvolvingTypes": "error" "noEvolvingTypes": "error",
"noArrayIndexKey": "off"
} }
} }
}, },

View File

@@ -2,7 +2,7 @@
<html lang="en" dir="ltr"> <html lang="en" dir="ltr">
<head> <head>
<meta charset="UTF-8" /> <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" /> <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="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" /> <meta name="robots" content="noindex, nofollow" />

View File

@@ -8,10 +8,11 @@ import {
ContainerIcon, ContainerIcon,
DatabaseBackupIcon, DatabaseBackupIcon,
FingerprintIcon, FingerprintIcon,
LayoutDashboard, HardDriveIcon,
LogsIcon, LogsIcon,
MailIcon, MailIcon,
Server, Server,
ServerIcon,
SettingsIcon, SettingsIcon,
UsersIcon, UsersIcon,
} from "lucide-react" } from "lucide-react"
@@ -81,15 +82,15 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
)} )}
<CommandGroup heading={t`Pages / Settings`}> <CommandGroup heading={t`Pages / Settings`}>
<CommandItem <CommandItem
keywords={["home", t`All Systems`]} keywords={["home"]}
onSelect={() => { onSelect={() => {
navigate(basePath) navigate(basePath)
setOpen(false) setOpen(false)
}} }}
> >
<LayoutDashboard className="me-2 size-4" /> <ServerIcon className="me-2 size-4" />
<span> <span>
<Trans>Dashboard</Trans> <Trans>All Systems</Trans>
</span> </span>
<CommandShortcut> <CommandShortcut>
<Trans>Page</Trans> <Trans>Page</Trans>
@@ -109,6 +110,18 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
<Trans>Page</Trans> <Trans>Page</Trans>
</CommandShortcut> </CommandShortcut>
</CommandItem> </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 <CommandItem
onSelect={() => { onSelect={() => {
navigate(getPagePath($router, "settings", { name: "general" })) navigate(getPagePath($router, "settings", { name: "general" }))

View File

@@ -26,7 +26,7 @@ import { Sheet, SheetTitle, SheetHeader, SheetContent, SheetDescription } from "
import { Dialog, DialogContent, DialogTitle } from "../ui/dialog" import { Dialog, DialogContent, DialogTitle } from "../ui/dialog"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { $allSystemsById } from "@/lib/stores" 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 { Separator } from "../ui/separator"
import { $router, Link } from "../router" import { $router, Link } from "../router"
import { listenKeys } from "nanostores" import { listenKeys } from "nanostores"
@@ -36,7 +36,7 @@ const syntaxTheme = "github-dark-dimmed"
export default function ContainersTable({ systemId }: { systemId?: string }) { export default function ContainersTable({ systemId }: { systemId?: string }) {
const loadTime = Date.now() const loadTime = Date.now()
const [data, setData] = useState<ContainerRecord[]>([]) const [data, setData] = useState<ContainerRecord[] | undefined>(undefined)
const [sorting, setSorting] = useBrowserStorage<SortingState>( const [sorting, setSorting] = useBrowserStorage<SortingState>(
`sort-c-${systemId ? 1 : 0}`, `sort-c-${systemId ? 1 : 0}`,
[{ id: systemId ? "name" : "system", desc: false }], [{ id: systemId ? "name" : "system", desc: false }],
@@ -54,7 +54,10 @@ export default function ContainersTable({ systemId }: { systemId?: string }) {
fields: "id,name,image,cpu,memory,net,health,status,system,updated", fields: "id,name,image,cpu,memory,net,health,status,system,updated",
filter: systemId ? pb.filter("system={:system}", { system: systemId }) : undefined, filter: systemId ? pb.filter("system={:system}", { system: systemId }) : undefined,
}) })
.then(({ items }) => items.length && setData((curItems) => { .then(
({ items }) =>
items.length &&
setData((curItems) => {
const lastUpdated = Math.max(items[0].updated, items.at(-1)?.updated ?? 0) const lastUpdated = Math.max(items[0].updated, items.at(-1)?.updated ?? 0)
const containerIds = new Set() const containerIds = new Set()
const newItems = [] const newItems = []
@@ -64,13 +67,14 @@ export default function ContainersTable({ systemId }: { systemId?: string }) {
newItems.push(item) newItems.push(item)
} }
} }
for (const item of curItems) { for (const item of curItems ?? []) {
if (!containerIds.has(item.id) && lastUpdated - item.updated < 70_000) { if (!containerIds.has(item.id) && lastUpdated - item.updated < 70_000) {
newItems.push(item) newItems.push(item)
} }
} }
return newItems return newItems
})) })
)
} }
// initial load // initial load
@@ -93,7 +97,7 @@ export default function ContainersTable({ systemId }: { systemId?: string }) {
}, []) }, [])
const table = useReactTable({ const table = useReactTable({
data, data: data ?? [],
columns: containerChartCols.filter((col) => (systemId ? col.id !== "system" : true)), columns: containerChartCols.filter((col) => (systemId ? col.id !== "system" : true)),
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(), getSortedRowModel: getSortedRowModel(),
@@ -159,7 +163,7 @@ export default function ContainersTable({ systemId }: { systemId?: string }) {
type="button" type="button"
variant="ghost" variant="ghost"
size="icon" 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" className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 text-muted-foreground"
onClick={() => setGlobalFilter("")} onClick={() => setGlobalFilter("")}
> >
@@ -170,7 +174,7 @@ export default function ContainersTable({ systemId }: { systemId?: string }) {
</div> </div>
</CardHeader> </CardHeader>
<div className="rounded-md"> <div className="rounded-md">
<AllContainersTable table={table} rows={rows} colLength={visibleColumns.length} /> <AllContainersTable table={table} rows={rows} colLength={visibleColumns.length} data={data} />
</div> </div>
</Card> </Card>
) )
@@ -180,10 +184,12 @@ const AllContainersTable = memo(function AllContainersTable({
table, table,
rows, rows,
colLength, colLength,
data,
}: { }: {
table: TableType<ContainerRecord> table: TableType<ContainerRecord>
rows: Row<ContainerRecord>[] rows: Row<ContainerRecord>[]
colLength: number colLength: number
data: ContainerRecord[] | undefined
}) { }) {
// The virtualizer will need a reference to the scrollable container element // The virtualizer will need a reference to the scrollable container element
const scrollRef = useRef<HTMLDivElement>(null) const scrollRef = useRef<HTMLDivElement>(null)
@@ -227,7 +233,11 @@ const AllContainersTable = memo(function AllContainersTable({
) : ( ) : (
<TableRow> <TableRow>
<TableCell colSpan={colLength} className="h-37 text-center pointer-events-none"> <TableCell colSpan={colLength} className="h-37 text-center pointer-events-none">
{data ? (
<Trans>No results.</Trans> <Trans>No results.</Trans>
) : (
<LoaderCircleIcon className="animate-spin size-10 opacity-60 mx-auto" />
)}
</TableCell> </TableCell>
</TableRow> </TableRow>
)} )}
@@ -266,7 +276,7 @@ async function getInfoHtml(container: ContainerRecord): Promise<string> {
]) ])
try { try {
info = JSON.stringify(JSON.parse(info), null, 2) info = JSON.stringify(JSON.parse(info), null, 2)
} catch (_) { } } catch (_) {}
return info ? highlighter.codeToHtml(info, { lang: "json", theme: syntaxTheme }) : t`No results.` return info ? highlighter.codeToHtml(info, { lang: "json", theme: syntaxTheme }) : t`No results.`
} catch (error) { } catch (error) {
console.error(error) console.error(error)
@@ -323,7 +333,7 @@ function ContainerSheet({
setLogsDisplay("") setLogsDisplay("")
setInfoDisplay("") setInfoDisplay("")
if (!container) return if (!container) return
; (async () => { ;(async () => {
const [logsHtml, infoHtml] = await Promise.all([getLogsHtml(container), getInfoHtml(container)]) const [logsHtml, infoHtml] = await Promise.all([getLogsHtml(container), getInfoHtml(container)])
setLogsDisplay(logsHtml) setLogsDisplay(logsHtml)
setInfoDisplay(infoHtml) setInfoDisplay(infoHtml)
@@ -505,9 +515,7 @@ function LogsFullscreenDialog({
</div> </div>
</div> </div>
<button <button
onClick={() => { onClick={onRefresh}
void onRefresh()
}}
className="absolute top-3 right-11 opacity-60 hover:opacity-100 p-1" className="absolute top-3 right-11 opacity-60 hover:opacity-100 p-1"
disabled={isRefreshing} disabled={isRefreshing}
title={t`Refresh`} title={t`Refresh`}

View File

@@ -3,6 +3,7 @@ import { getPagePath } from "@nanostores/router"
import { import {
ContainerIcon, ContainerIcon,
DatabaseBackupIcon, DatabaseBackupIcon,
HardDriveIcon,
LogOutIcon, LogOutIcon,
LogsIcon, LogsIcon,
SearchIcon, SearchIcon,
@@ -29,6 +30,7 @@ import { LangToggle } from "./lang-toggle"
import { Logo } from "./logo" import { Logo } from "./logo"
import { ModeToggle } from "./mode-toggle" import { ModeToggle } from "./mode-toggle"
import { $router, basePath, Link, prependBasePath } from "./router" import { $router, basePath, Link, prependBasePath } from "./router"
import { t } from "@lingui/core/macro"
const CommandPalette = lazy(() => import("./command-palette")) 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} /> <ContainerIcon className="h-[1.2rem] w-[1.2rem]" strokeWidth={1.5} />
</Link> </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 /> <LangToggle />
<ModeToggle /> <ModeToggle />
<Link <Link

View File

@@ -3,6 +3,7 @@ import { createRouter } from "@nanostores/router"
const routes = { const routes = {
home: "/", home: "/",
containers: "/containers", containers: "/containers",
smart: "/smart",
system: `/system/:id`, system: `/system/:id`,
settings: `/settings/:name?`, settings: `/settings/:name?`,
forgot_password: `/forgot-password`, forgot_password: `/forgot-password`,

View File

@@ -21,7 +21,7 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const { i18n } = useLingui() const { i18n } = useLingui()
const currentUserSettings = useStore($userSettings) const currentUserSettings = useStore($userSettings)
const layoutWidth = currentUserSettings.layoutWidth ?? 1480 const layoutWidth = currentUserSettings.layoutWidth ?? 1500
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) { async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault() e.preventDefault()

View File

@@ -14,6 +14,7 @@ import { toast } from "@/components/ui/use-toast"
import { isAdmin, pb } from "@/lib/api" import { isAdmin, pb } from "@/lib/api"
import type { UserSettings } from "@/types" import type { UserSettings } from "@/types"
import { saveSettings } from "./layout" import { saveSettings } from "./layout"
import { QuietHours } from "./quiet-hours"
interface ShoutrrrUrlCardProps { interface ShoutrrrUrlCardProps {
url: string url: string
@@ -120,6 +121,7 @@ const SettingsNotificationsPage = ({ userSettings }: { userSettings: UserSetting
</div> </div>
<Separator /> <Separator />
<div className="space-y-3"> <div className="space-y-3">
<div className="grid grid-cols-1 sm:flex items-center justify-between gap-4">
<div> <div>
<h3 className="mb-1 text-lg font-medium"> <h3 className="mb-1 text-lg font-medium">
<Trans>Webhook / Push notifications</Trans> <Trans>Webhook / Push notifications</Trans>
@@ -134,6 +136,18 @@ const SettingsNotificationsPage = ({ userSettings }: { userSettings: UserSetting
</Trans> </Trans>
</p> </p>
</div> </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 && ( {webhooks.length > 0 && (
<div className="grid gap-2.5" id="webhooks"> <div className="grid gap-2.5" id="webhooks">
{webhooks.map((webhook, index) => ( {webhooks.map((webhook, index) => (
@@ -146,16 +160,10 @@ const SettingsNotificationsPage = ({ userSettings }: { userSettings: UserSetting
))} ))}
</div> </div>
)} )}
<Button </div>
type="button" <Separator />
variant="outline" <div className="space-y-3">
size="sm" <QuietHours />
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> </div>
<Separator /> <Separator />
<Button <Button
@@ -194,7 +202,7 @@ const ShoutrrrUrlCard = ({ url, onUrlChange, onRemove }: ShoutrrrUrlCardProps) =
} }
return ( 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"> <div className="flex items-center gap-1">
<Input <Input
type="url" type="url"

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

View 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 />
</>
)
}

View File

@@ -1,37 +1,64 @@
import * as React from "react"
import { t } from "@lingui/core/macro" import { t } from "@lingui/core/macro"
import { import {
ColumnDef, type ColumnDef,
ColumnFiltersState, type ColumnFiltersState,
Column, type Column,
type SortingState,
flexRender, flexRender,
getCoreRowModel, getCoreRowModel,
getFilteredRowModel, getFilteredRowModel,
getSortedRowModel, getSortedRowModel,
SortingState,
useReactTable, useReactTable,
} from "@tanstack/react-table" } from "@tanstack/react-table"
import { Activity, Box, Clock, HardDrive, HashIcon, CpuIcon, BinaryIcon, RotateCwIcon, LoaderCircleIcon, CheckCircle2Icon, XCircleIcon, ArrowLeftRightIcon } from "lucide-react" import {
Activity,
Box,
Clock,
HardDrive,
BinaryIcon,
RotateCwIcon,
LoaderCircleIcon,
CheckCircle2Icon,
XCircleIcon,
ArrowLeftRightIcon,
MoreHorizontalIcon,
RefreshCwIcon,
ServerIcon,
Trash2Icon,
XIcon,
} from "lucide-react"
import { Card, CardHeader, CardTitle, CardDescription } from "@/components/ui/card" import { Card, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet" import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { pb } from "@/lib/api" import { pb } from "@/lib/api"
import { SmartData, SmartAttribute } from "@/types" import type { SmartDeviceRecord, SmartAttribute } from "@/types"
import { formatBytes, toFixedFloat, formatTemperature, cn, secondsToString } from "@/lib/utils" import {
formatBytes,
toFixedFloat,
formatTemperature,
cn,
secondsToString,
hourWithSeconds,
formatShortDate,
} from "@/lib/utils"
import { Trans } from "@lingui/react/macro" import { Trans } from "@lingui/react/macro"
import { useStore } from "@nanostores/react"
import { $allSystemsById } from "@/lib/stores"
import { ThermometerIcon } from "@/components/ui/icons" import { ThermometerIcon } from "@/components/ui/icons"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { Separator } from "@/components/ui/separator" import { Separator } from "@/components/ui/separator"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { useCallback, useMemo, useEffect, useState } from "react"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
// Column definition for S.M.A.R.T. attributes table // Column definition for S.M.A.R.T. attributes table
export const smartColumns: ColumnDef<SmartAttribute>[] = [ export const smartColumns: ColumnDef<SmartAttribute>[] = [
@@ -66,19 +93,19 @@ export const smartColumns: ColumnDef<SmartAttribute>[] = [
}, },
] ]
export type DiskInfo = { export type DiskInfo = {
id: string
system: string
device: string device: string
model: string model: string
serialNumber: string
firmwareVersion: string
capacity: string capacity: string
status: string status: string
temperature: number temperature: number
deviceType: string deviceType: string
powerOnHours?: number powerOnHours?: number
powerCycles?: number powerCycles?: number
attributes?: SmartAttribute[]
updated: string
} }
// Function to format capacity display // Function to format capacity display
@@ -87,38 +114,51 @@ function formatCapacity(bytes: number): string {
return `${toFixedFloat(value, value >= 10 ? 1 : 2)} ${unit}` return `${toFixedFloat(value, value >= 10 ? 1 : 2)} ${unit}`
} }
// Function to convert SmartData to DiskInfo // Function to convert SmartDeviceRecord to DiskInfo
function convertSmartDataToDiskInfo(smartDataRecord: Record<string, SmartData>): DiskInfo[] { function convertSmartDeviceRecordToDiskInfo(records: SmartDeviceRecord[]): DiskInfo[] {
const unknown = "Unknown" const unknown = "Unknown"
return Object.entries(smartDataRecord).map(([key, smartData]) => ({ return records.map((record) => ({
device: smartData.dn || key, id: record.id,
model: smartData.mn || unknown, system: record.system,
serialNumber: smartData.sn || unknown, device: record.name || unknown,
firmwareVersion: smartData.fv || unknown, model: record.model || unknown,
capacity: smartData.c ? formatCapacity(smartData.c) : unknown, serialNumber: record.serial || unknown,
status: smartData.s || unknown, firmwareVersion: record.firmware || unknown,
temperature: smartData.t || 0, capacity: record.capacity ? formatCapacity(record.capacity) : unknown,
deviceType: smartData.dt || unknown, status: record.state || unknown,
// These fields need to be extracted from SmartAttribute if available temperature: record.temp || 0,
powerOnHours: smartData.a?.find(attr => { deviceType: record.type || unknown,
const name = attr.n.toLowerCase(); attributes: record.attributes,
return name.includes("poweronhours") || name.includes("power_on_hours"); updated: record.updated,
})?.rv, powerOnHours: record.hours,
powerCycles: smartData.a?.find(attr => { powerCycles: record.cycles,
const name = attr.n.toLowerCase();
return (name.includes("power") && name.includes("cycle")) || name.includes("startstopcycles");
})?.rv,
})) }))
} }
const SMART_DEVICE_FIELDS = "id,system,name,model,state,capacity,temp,type,hours,cycles,updated"
export const columns: ColumnDef<DiskInfo>[] = [ export const columns: ColumnDef<DiskInfo>[] = [
{
id: "system",
accessorFn: (record) => record.system,
sortingFn: (a, b) => {
const allSystems = $allSystemsById.get()
const systemNameA = allSystems[a.original.system]?.name ?? ""
const systemNameB = allSystems[b.original.system]?.name ?? ""
return systemNameA.localeCompare(systemNameB)
},
header: ({ column }) => <HeaderButton column={column} name={t`System`} Icon={ServerIcon} />,
cell: ({ getValue }) => {
const allSystems = useStore($allSystemsById)
return <span className="ms-1.5 xl:w-30 block truncate">{allSystems[getValue() as string]?.name ?? ""}</span>
},
},
{ {
accessorKey: "device", accessorKey: "device",
sortingFn: (a, b) => a.original.device.localeCompare(b.original.device), sortingFn: (a, b) => a.original.device.localeCompare(b.original.device),
header: ({ column }) => <HeaderButton column={column} name={t`Device`} Icon={HardDrive} />, header: ({ column }) => <HeaderButton column={column} name={t`Device`} Icon={HardDrive} />,
cell: ({ row }) => ( cell: ({ row }) => (
<div className="font-medium max-w-50 truncate ms-1.5" title={row.getValue("device")}> <div className="font-medium max-w-40 truncate ms-1.5" title={row.getValue("device")}>
{row.getValue("device")} {row.getValue("device")}
</div> </div>
), ),
@@ -128,7 +168,7 @@ export const columns: ColumnDef<DiskInfo>[] = [
sortingFn: (a, b) => a.original.model.localeCompare(b.original.model), sortingFn: (a, b) => a.original.model.localeCompare(b.original.model),
header: ({ column }) => <HeaderButton column={column} name={t`Model`} Icon={Box} />, header: ({ column }) => <HeaderButton column={column} name={t`Model`} Icon={Box} />,
cell: ({ row }) => ( cell: ({ row }) => (
<div className="max-w-50 truncate ms-1.5" title={row.getValue("model")}> <div className="max-w-48 truncate ms-1.5" title={row.getValue("model")}>
{row.getValue("model")} {row.getValue("model")}
</div> </div>
), ),
@@ -136,18 +176,7 @@ export const columns: ColumnDef<DiskInfo>[] = [
{ {
accessorKey: "capacity", accessorKey: "capacity",
header: ({ column }) => <HeaderButton column={column} name={t`Capacity`} Icon={BinaryIcon} />, header: ({ column }) => <HeaderButton column={column} name={t`Capacity`} Icon={BinaryIcon} />,
cell: ({ getValue }) => ( cell: ({ getValue }) => <span className="ms-1.5">{getValue() as string}</span>,
<span className="ms-1.5">{getValue() as string}</span>
),
},
{
accessorKey: "temperature",
invertSorting: true,
header: ({ column }) => <HeaderButton column={column} name={t`Temp`} Icon={ThermometerIcon} />,
cell: ({ getValue }) => {
const { value, unit } = formatTemperature(getValue() as number)
return <span className="ms-1.5">{`${value} ${unit}`}</span>
},
}, },
{ {
accessorKey: "status", accessorKey: "status",
@@ -156,11 +185,7 @@ export const columns: ColumnDef<DiskInfo>[] = [
const status = getValue() as string const status = getValue() as string
return ( return (
<div className="ms-1.5"> <div className="ms-1.5">
<Badge <Badge variant={status === "PASSED" ? "success" : status === "FAILED" ? "danger" : "warning"}>{status}</Badge>
variant={status === "PASSED" ? "success" : status === "FAILED" ? "danger" : "warning"}
>
{status}
</Badge>
</div> </div>
) )
}, },
@@ -180,15 +205,13 @@ export const columns: ColumnDef<DiskInfo>[] = [
{ {
accessorKey: "powerOnHours", accessorKey: "powerOnHours",
invertSorting: true, invertSorting: true,
header: ({ column }) => <HeaderButton column={column} name={t({ message: "Power On", comment: "Power On Time" })} Icon={Clock} />, header: ({ column }) => (
<HeaderButton column={column} name={t({ message: "Power On", comment: "Power On Time" })} Icon={Clock} />
),
cell: ({ getValue }) => { cell: ({ getValue }) => {
const hours = (getValue() ?? 0) as number const hours = (getValue() ?? 0) as number
if (!hours && hours !== 0) { if (!hours && hours !== 0) {
return ( return <div className="text-sm text-muted-foreground ms-1.5">N/A</div>
<div className="text-sm text-muted-foreground ms-1.5">
N/A
</div>
)
} }
const seconds = hours * 3600 const seconds = hours * 3600
return ( return (
@@ -202,34 +225,50 @@ export const columns: ColumnDef<DiskInfo>[] = [
{ {
accessorKey: "powerCycles", accessorKey: "powerCycles",
invertSorting: true, invertSorting: true,
header: ({ column }) => <HeaderButton column={column} name={t({ message: "Cycles", comment: "Power Cycles" })} Icon={RotateCwIcon} />, header: ({ column }) => (
<HeaderButton column={column} name={t({ message: "Cycles", comment: "Power Cycles" })} Icon={RotateCwIcon} />
),
cell: ({ getValue }) => { cell: ({ getValue }) => {
const cycles = getValue() as number | undefined const cycles = getValue() as number | undefined
if (!cycles && cycles !== 0) { if (!cycles && cycles !== 0) {
return ( return <div className="text-muted-foreground ms-1.5">N/A</div>
<div className="text-muted-foreground ms-1.5">
N/A
</div>
)
} }
return <span className="ms-1.5">{cycles}</span> return <span className="ms-1.5">{cycles}</span>
}, },
}, },
{ {
accessorKey: "serialNumber", accessorKey: "temperature",
sortingFn: (a, b) => a.original.serialNumber.localeCompare(b.original.serialNumber), invertSorting: true,
header: ({ column }) => <HeaderButton column={column} name={t`Serial Number`} Icon={HashIcon} />, header: ({ column }) => <HeaderButton column={column} name={t`Temp`} Icon={ThermometerIcon} />,
cell: ({ getValue }) => ( cell: ({ getValue }) => {
<span className="ms-1.5">{getValue() as string}</span> const { value, unit } = formatTemperature(getValue() as number)
), return <span className="ms-1.5">{`${value} ${unit}`}</span>
}, },
},
// {
// accessorKey: "serialNumber",
// sortingFn: (a, b) => a.original.serialNumber.localeCompare(b.original.serialNumber),
// header: ({ column }) => <HeaderButton column={column} name={t`Serial Number`} Icon={HashIcon} />,
// cell: ({ getValue }) => <span className="ms-1.5">{getValue() as string}</span>,
// },
// {
// accessorKey: "firmwareVersion",
// sortingFn: (a, b) => a.original.firmwareVersion.localeCompare(b.original.firmwareVersion),
// header: ({ column }) => <HeaderButton column={column} name={t`Firmware`} Icon={CpuIcon} />,
// cell: ({ getValue }) => <span className="ms-1.5">{getValue() as string}</span>,
// },
{ {
accessorKey: "firmwareVersion", id: "updated",
sortingFn: (a, b) => a.original.firmwareVersion.localeCompare(b.original.firmwareVersion), invertSorting: true,
header: ({ column }) => <HeaderButton column={column} name={t`Firmware`} Icon={CpuIcon} />, accessorFn: (record) => record.updated,
cell: ({ getValue }) => ( header: ({ column }) => <HeaderButton column={column} name={t`Updated`} Icon={Clock} />,
<span className="ms-1.5">{getValue() as string}</span> cell: ({ getValue }) => {
), const timestamp = getValue() as string
// if today, use hourWithSeconds, otherwise use formatShortDate
const formatter =
new Date(timestamp).toDateString() === new Date().toDateString() ? hourWithSeconds : formatShortDate
return <span className="ms-1.5 tabular-nums">{formatter(timestamp)}</span>
},
}, },
] ]
@@ -237,7 +276,10 @@ function HeaderButton({ column, name, Icon }: { column: Column<DiskInfo>; name:
const isSorted = column.getIsSorted() const isSorted = column.getIsSorted()
return ( return (
<Button <Button
className={cn("h-9 px-3 flex items-center gap-2 duration-50", isSorted && "bg-accent/70 light:bg-accent text-accent-foreground/90")} className={cn(
"h-9 px-3 flex items-center gap-2 duration-50",
isSorted && "bg-accent/70 light:bg-accent text-accent-foreground/90"
)}
variant="ghost" variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
> >
@@ -247,39 +289,193 @@ function HeaderButton({ column, name, Icon }: { column: Column<DiskInfo>; name:
) )
} }
export default function DisksTable({ systemId }: { systemId: string }) { export default function DisksTable({ systemId }: { systemId?: string }) {
const [sorting, setSorting] = React.useState<SortingState>([{ id: "device", desc: false }]) const [sorting, setSorting] = useState<SortingState>([{ id: systemId ? "device" : "system", desc: false }])
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([]) const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
const [rowSelection, setRowSelection] = React.useState({}) const [rowSelection, setRowSelection] = useState({})
const [smartData, setSmartData] = React.useState<Record<string, SmartData> | undefined>(undefined) const [smartDevices, setSmartDevices] = useState<SmartDeviceRecord[] | undefined>(undefined)
const [activeDisk, setActiveDisk] = React.useState<DiskInfo | null>(null) const [activeDiskId, setActiveDiskId] = useState<string | null>(null)
const [sheetOpen, setSheetOpen] = React.useState(false) const [sheetOpen, setSheetOpen] = useState(false)
const [rowActionState, setRowActionState] = useState<{ type: "refresh" | "delete"; id: string } | null>(null)
const [globalFilter, setGlobalFilter] = useState("")
const openSheet = (disk: DiskInfo) => { const openSheet = (disk: DiskInfo) => {
setActiveDisk(disk) setActiveDiskId(disk.id)
setSheetOpen(true) setSheetOpen(true)
} }
// Fetch smart data when component mounts or systemId changes // Fetch smart devices from collection (without attributes to save bandwidth)
React.useEffect(() => { const fetchSmartDevices = useCallback(() => {
if (systemId) { pb.collection<SmartDeviceRecord>("smart_devices")
pb.send<Record<string, SmartData>>("/api/beszel/smart", { query: { system: systemId } }) .getFullList({
.then((data) => { filter: systemId ? pb.filter("system = {:system}", { system: systemId }) : undefined,
setSmartData(data) fields: SMART_DEVICE_FIELDS,
}) })
.catch(() => setSmartData({})) .then((records) => {
setSmartDevices(records)
})
.catch(() => setSmartDevices([]))
}, [systemId])
// Fetch smart devices when component mounts or systemId changes
useEffect(() => {
fetchSmartDevices()
}, [fetchSmartDevices])
// Subscribe to live updates so rows add/remove without manual refresh/filtering
useEffect(() => {
let unsubscribe: (() => void) | undefined
const pbOptions = systemId
? { fields: SMART_DEVICE_FIELDS, filter: pb.filter("system = {:system}", { system: systemId }) }
: { fields: SMART_DEVICE_FIELDS }
;(async () => {
try {
unsubscribe = await pb.collection("smart_devices").subscribe(
"*",
(event) => {
const record = event.record as SmartDeviceRecord
setSmartDevices((currentDevices) => {
const devices = currentDevices ?? []
const matchesSystemScope = !systemId || record.system === systemId
if (event.action === "delete") {
return devices.filter((device) => device.id !== record.id)
}
if (!matchesSystemScope) {
// Record moved out of scope; ensure it disappears locally.
return devices.filter((device) => device.id !== record.id)
}
const existingIndex = devices.findIndex((device) => device.id === record.id)
if (existingIndex === -1) {
return [record, ...devices]
}
const next = [...devices]
next[existingIndex] = record
return next
})
},
pbOptions
)
} catch (error) {
console.error("Failed to subscribe to SMART device updates:", error)
}
})()
return () => {
unsubscribe?.()
} }
}, [systemId]) }, [systemId])
// Convert SmartData to DiskInfo, if no data use empty array const handleRowRefresh = useCallback(
const diskData = React.useMemo(() => { async (disk: DiskInfo) => {
return smartData ? convertSmartDataToDiskInfo(smartData) : [] if (!disk.system) return
}, [smartData]) setRowActionState({ type: "refresh", id: disk.id })
try {
await pb.send("/api/beszel/smart/refresh", {
method: "POST",
query: { system: disk.system },
})
} catch (error) {
console.error("Failed to refresh SMART device:", error)
} finally {
setRowActionState((state) => (state?.id === disk.id ? null : state))
}
},
[fetchSmartDevices]
)
const handleDeleteDevice = useCallback(async (disk: DiskInfo) => {
setRowActionState({ type: "delete", id: disk.id })
try {
await pb.collection("smart_devices").delete(disk.id)
// setSmartDevices((current) => current?.filter((device) => device.id !== disk.id))
} catch (error) {
console.error("Failed to delete SMART device:", error)
} finally {
setRowActionState((state) => (state?.id === disk.id ? null : state))
}
}, [])
const actionColumn = useMemo<ColumnDef<DiskInfo>>(
() => ({
id: "actions",
enableSorting: false,
header: () => (
<span className="sr-only">
<Trans>Actions</Trans>
</span>
),
cell: ({ row }) => {
const disk = row.original
const isRowRefreshing = rowActionState?.id === disk.id && rowActionState.type === "refresh"
const isRowDeleting = rowActionState?.id === disk.id && rowActionState.type === "delete"
return (
<div className="flex justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-8"
onClick={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
>
<span className="sr-only">
<Trans>Open menu</Trans>
</span>
<MoreHorizontalIcon className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" onClick={(event) => event.stopPropagation()}>
<DropdownMenuItem
onClick={(event) => {
event.stopPropagation()
handleRowRefresh(disk)
}}
disabled={isRowRefreshing || isRowDeleting}
>
<RefreshCwIcon className={cn("me-2.5 size-4", isRowRefreshing && "animate-spin")} />
<Trans>Refresh</Trans>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={(event) => {
event.stopPropagation()
handleDeleteDevice(disk)
}}
disabled={isRowDeleting}
>
<Trash2Icon className="me-2.5 size-4" />
<Trans>Delete</Trans>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
},
}),
[handleRowRefresh, handleDeleteDevice, rowActionState]
)
// Filter columns based on whether systemId is provided
const tableColumns = useMemo(() => {
const baseColumns = systemId ? columns.filter((col) => col.id !== "system") : columns
return [...baseColumns, actionColumn]
}, [systemId, actionColumn])
// Convert SmartDeviceRecord to DiskInfo
const diskData = useMemo(() => {
return smartDevices ? convertSmartDeviceRecordToDiskInfo(smartDevices) : []
}, [smartDevices])
const table = useReactTable({ const table = useReactTable({
data: diskData, data: diskData,
columns: columns, columns: tableColumns,
onSortingChange: setSorting, onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters, onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
@@ -290,10 +486,26 @@ export default function DisksTable({ systemId }: { systemId: string }) {
sorting, sorting,
columnFilters, columnFilters,
rowSelection, rowSelection,
globalFilter,
},
onGlobalFilterChange: setGlobalFilter,
globalFilterFn: (row, _columnId, filterValue) => {
const disk = row.original
const systemName = $allSystemsById.get()[disk.system]?.name ?? ""
const device = disk.device ?? ""
const model = disk.model ?? ""
const status = disk.status ?? ""
const type = disk.deviceType ?? ""
const searchString = `${systemName} ${device} ${model} ${status} ${type}`.toLowerCase()
return (filterValue as string)
.toLowerCase()
.split(" ")
.every((term) => searchString.includes(term))
}, },
}) })
if (!diskData.length && !columnFilters.length) { // Hide the table on system pages if there's no data, but always show on global page
if (systemId && !diskData.length && !columnFilters.length) {
return null return null
} }
@@ -303,21 +515,31 @@ export default function DisksTable({ systemId }: { systemId: string }) {
<CardHeader className="p-0 mb-4"> <CardHeader className="p-0 mb-4">
<div className="grid md:flex gap-5 w-full items-end"> <div className="grid md:flex gap-5 w-full items-end">
<div className="px-2 sm:px-1"> <div className="px-2 sm:px-1">
<CardTitle className="mb-2"> <CardTitle className="mb-2">S.M.A.R.T.</CardTitle>
S.M.A.R.T.
</CardTitle>
<CardDescription className="flex"> <CardDescription className="flex">
<Trans>Click on a device to view more information.</Trans> <Trans>Click on a device to view more information.</Trans>
</CardDescription> </CardDescription>
</div> </div>
<div className="relative ms-auto w-full max-w-full md:w-64">
<Input <Input
placeholder={t`Filter...`} placeholder={t`Filter...`}
value={(table.getColumn("device")?.getFilterValue() as string) ?? ""} value={globalFilter}
onChange={(event) => onChange={(event) => setGlobalFilter(event.target.value)}
table.getColumn("device")?.setFilterValue(event.target.value) className="px-4 w-full max-w-full md:w-64"
}
className="ms-auto px-4 w-full max-w-full md:w-64"
/> />
{globalFilter && (
<Button
type="button"
variant="ghost"
size="icon"
aria-label={t`Clear`}
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 text-muted-foreground"
onClick={() => setGlobalFilter("")}
>
<XIcon className="h-4 w-4" />
</Button>
)}
</div>
</div> </div>
</CardHeader> </CardHeader>
<div className="rounded-md border text-nowrap"> <div className="rounded-md border text-nowrap">
@@ -328,12 +550,7 @@ export default function DisksTable({ systemId }: { systemId: string }) {
{headerGroup.headers.map((header) => { {headerGroup.headers.map((header) => {
return ( return (
<TableHead key={header.id} className="px-2"> <TableHead key={header.id} className="px-2">
{header.isPlaceholder {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead> </TableHead>
) )
})} })}
@@ -351,22 +568,19 @@ export default function DisksTable({ systemId }: { systemId: string }) {
> >
{row.getVisibleCells().map((cell) => ( {row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className="md:ps-5"> <TableCell key={cell.id} className="md:ps-5">
{flexRender( {flexRender(cell.column.columnDef.cell, cell.getContext())}
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell> </TableCell>
))} ))}
</TableRow> </TableRow>
)) ))
) : ( ) : (
<TableRow> <TableRow>
<TableCell <TableCell colSpan={tableColumns.length} className="h-24 text-center">
colSpan={columns.length} {smartDevices ? (
className="h-24 text-center" t`No results.`
> ) : (
{smartData ? t`No results.` : <LoaderCircleIcon className="animate-spin size-10 opacity-60 mx-auto" />} <LoaderCircleIcon className="animate-spin size-10 opacity-60 mx-auto" />
)}
</TableCell> </TableCell>
</TableRow> </TableRow>
)} )}
@@ -374,28 +588,53 @@ export default function DisksTable({ systemId }: { systemId: string }) {
</Table> </Table>
</div> </div>
</Card> </Card>
<DiskSheet disk={activeDisk} smartData={smartData?.[activeDisk?.serialNumber ?? ""]} open={sheetOpen} onOpenChange={setSheetOpen} /> <DiskSheet diskId={activeDiskId} open={sheetOpen} onOpenChange={setSheetOpen} />
</div> </div>
) )
} }
function DiskSheet({ disk, smartData, open, onOpenChange }: { disk: DiskInfo | null; smartData?: SmartData; open: boolean; onOpenChange: (open: boolean) => void }) { function DiskSheet({
if (!disk) return null diskId,
open,
onOpenChange,
}: {
diskId: string | null
open: boolean
onOpenChange: (open: boolean) => void
}) {
const [disk, setDisk] = useState<SmartDeviceRecord | null>(null)
const [isLoading, setIsLoading] = useState(false)
const smartAttributes = smartData?.a || [] // Fetch full device record (including attributes) when sheet opens
useEffect(() => {
if (!diskId) {
setDisk(null)
return
}
// Only fetch when opening, not when closing (keeps data visible during close animation)
if (!open) return
setIsLoading(true)
pb.collection<SmartDeviceRecord>("smart_devices")
.getOne(diskId)
.then(setDisk)
.catch(() => setDisk(null))
.finally(() => setIsLoading(false))
}, [open, diskId])
const smartAttributes = disk?.attributes || []
// Find all attributes where when failed is not empty // Find all attributes where when failed is not empty
const failedAttributes = smartAttributes.filter(attr => attr.wf && attr.wf.trim() !== '') const failedAttributes = smartAttributes.filter((attr) => attr.wf && attr.wf.trim() !== "")
// Filter columns to only show those that have values in at least one row // Filter columns to only show those that have values in at least one row
const visibleColumns = React.useMemo(() => { const visibleColumns = useMemo(() => {
return smartColumns.filter(column => { return smartColumns.filter((column) => {
const accessorKey = (column as any).accessorKey as keyof SmartAttribute const accessorKey = "accessorKey" in column ? (column.accessorKey as keyof SmartAttribute | undefined) : undefined
if (!accessorKey) { if (!accessorKey) {
return true return true
} }
// Check if any row has a non-empty value for this column // Check if any row has a non-empty value for this column
return smartAttributes.some(attr => { return smartAttributes.some((attr) => {
return attr[accessorKey] !== undefined return attr[accessorKey] !== undefined
}) })
}) })
@@ -407,27 +646,60 @@ function DiskSheet({ disk, smartData, open, onOpenChange }: { disk: DiskInfo | n
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
}) })
const unknown = "Unknown"
const deviceName = disk?.name || unknown
const model = disk?.model || unknown
const capacity = disk?.capacity ? formatCapacity(disk.capacity) : unknown
const serialNumber = disk?.serial || unknown
const firmwareVersion = disk?.firmware || unknown
const status = disk?.state || unknown
return ( return (
<Sheet open={open} onOpenChange={onOpenChange}> <Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-full sm:max-w-220 gap-0"> <SheetContent className="w-full sm:max-w-220 gap-0">
<SheetHeader className="mb-0 border-b"> <SheetHeader className="mb-0 border-b">
<SheetTitle><Trans>S.M.A.R.T. Details</Trans> - {disk.device}</SheetTitle> <SheetTitle>
<Trans>S.M.A.R.T. Details</Trans> - {deviceName}
</SheetTitle>
<SheetDescription className="flex flex-wrap items-center gap-x-2 gap-y-1"> <SheetDescription className="flex flex-wrap items-center gap-x-2 gap-y-1">
{disk.model} <Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" /> {model}
{disk.serialNumber} <Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
{capacity}
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
<Tooltip>
<TooltipTrigger asChild>
<span>{serialNumber}</span>
</TooltipTrigger>
<TooltipContent>
<Trans>Serial Number</Trans>
</TooltipContent>
</Tooltip>
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
<Tooltip>
<TooltipTrigger asChild>
<span>{firmwareVersion}</span>
</TooltipTrigger>
<TooltipContent>
<Trans>Firmware Version</Trans>
</TooltipContent>
</Tooltip>
</SheetDescription> </SheetDescription>
</SheetHeader> </SheetHeader>
<div className="flex-1 overflow-auto p-4 flex flex-col gap-4"> <div className="flex-1 overflow-auto p-4 flex flex-col gap-4">
<Alert className="pb-3"> {isLoading ? (
{smartData?.s === "PASSED" ? ( <div className="flex justify-center py-8">
<CheckCircle2Icon className="size-4" /> <LoaderCircleIcon className="animate-spin size-10 opacity-60" />
</div>
) : ( ) : (
<XCircleIcon className="size-4" /> <>
)} <Alert className="pb-3">
<AlertTitle><Trans>S.M.A.R.T. Self-Test</Trans>: {smartData?.s}</AlertTitle> {status === "PASSED" ? <CheckCircle2Icon className="size-4" /> : <XCircleIcon className="size-4" />}
<AlertTitle>
<Trans>S.M.A.R.T. Self-Test</Trans>: {status}
</AlertTitle>
{failedAttributes.length > 0 && ( {failedAttributes.length > 0 && (
<AlertDescription> <AlertDescription>
<Trans>Failed Attributes:</Trans> {failedAttributes.map(attr => attr.n).join(", ")} <Trans>Failed Attributes:</Trans> {failedAttributes.map((attr) => attr.n).join(", ")}
</AlertDescription> </AlertDescription>
)} )}
</Alert> </Alert>
@@ -441,10 +713,7 @@ function DiskSheet({ disk, smartData, open, onOpenChange }: { disk: DiskInfo | n
<TableHead key={header.id}> <TableHead key={header.id}>
{header.isPlaceholder {header.isPlaceholder
? null ? null
: flexRender( : flexRender(header.column.columnDef.header, header.getContext())}
header.column.columnDef.header,
header.getContext()
)}
</TableHead> </TableHead>
))} ))}
</TableRow> </TableRow>
@@ -453,23 +722,17 @@ function DiskSheet({ disk, smartData, open, onOpenChange }: { disk: DiskInfo | n
<TableBody> <TableBody>
{table.getRowModel().rows.map((row) => { {table.getRowModel().rows.map((row) => {
// Check if the attribute is failed // Check if the attribute is failed
const isFailedAttribute = row.original.wf && row.original.wf.trim() !== ''; const isFailedAttribute = row.original.wf && row.original.wf.trim() !== ""
return ( return (
<TableRow <TableRow key={row.id} className={isFailedAttribute ? "text-red-600 dark:text-red-400" : ""}>
key={row.id}
className={isFailedAttribute ? "text-red-600 dark:text-red-400" : ""}
>
{row.getVisibleCells().map((cell) => ( {row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}> <TableCell key={cell.id}>
{flexRender( {flexRender(cell.column.columnDef.cell, cell.getContext())}
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell> </TableCell>
))} ))}
</TableRow> </TableRow>
); )
})} })}
</TableBody> </TableBody>
</Table> </Table>
@@ -479,6 +742,8 @@ function DiskSheet({ disk, smartData, open, onOpenChange }: { disk: DiskInfo | n
<Trans>No S.M.A.R.T. attributes available for this device.</Trans> <Trans>No S.M.A.R.T. attributes available for this device.</Trans>
</div> </div>
)} )}
</>
)}
</div> </div>
</SheetContent> </SheetContent>
</Sheet> </Sheet>

View File

@@ -1,3 +1,4 @@
/** biome-ignore-all lint/correctness/useHookAtTopLevel: <explanation> */
import { t } from "@lingui/core/macro" import { t } from "@lingui/core/macro"
import { Trans, useLingui } from "@lingui/react/macro" import { Trans, useLingui } from "@lingui/react/macro"
import { useStore } from "@nanostores/react" 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", id: "net",
name: () => t`Net`, name: () => t`Net`,
size: 0, 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) const { value, unit } = formatBytes((info.getValue() || 0) as number, true, userSettings.unitNet, false)
return ( return (
<span className="tabular-nums whitespace-nowrap"> <span className="tabular-nums whitespace-nowrap">
{decimalString(value , value >= 100 ? 1 : 2)} {unit} {decimalString(value, value >= 100 ? 1 : 2)} {unit}
</span> </span>
) )
}, },
@@ -291,7 +292,10 @@ export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<Syste
[STATUS_COLORS[SystemStatus.Up]]: numFailed === 0, [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> </span>
) )
}, },
@@ -395,7 +399,6 @@ function DiskCellWithMultiple(info: CellContext<SystemRecord, unknown>) {
const { info: sysInfo, status, id } = info.row.original const { info: sysInfo, status, id } = info.row.original
const extraFs = Object.entries(sysInfo.efs ?? {}) const extraFs = Object.entries(sysInfo.efs ?? {})
// No extra disks - show basic meter
if (extraFs.length === 0) { if (extraFs.length === 0) {
return TableCellWithMeter(info) return TableCellWithMeter(info)
} }
@@ -405,10 +408,9 @@ function DiskCellWithMultiple(info: CellContext<SystemRecord, unknown>) {
// sort extra disks by percentage descending // sort extra disks by percentage descending
extraFs.sort((a, b) => b[1] - a[1]) extraFs.sort((a, b) => b[1] - a[1])
function getMeterClass(pct: number) { function getIndicatorColor(pct: number) {
const threshold = getMeterState(pct) const threshold = getMeterState(pct)
return cn( return (
"h-full",
(status !== SystemStatus.Up && STATUS_COLORS.paused) || (status !== SystemStatus.Up && STATUS_COLORS.paused) ||
(threshold === MeterState.Good && STATUS_COLORS.up) || (threshold === MeterState.Good && STATUS_COLORS.up) ||
(threshold === MeterState.Warn && STATUS_COLORS.pending) || (threshold === MeterState.Warn && STATUS_COLORS.pending) ||
@@ -416,28 +418,50 @@ function DiskCellWithMultiple(info: CellContext<SystemRecord, unknown>) {
) )
} }
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 ( return (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <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"> <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="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 */} {/* Root disk */}
<span className={getMeterClass(rootDiskPct)} style={{ width: `${rootDiskPct}%` }}></span> <span
{/* Extra disks */} className={cn("absolute inset-0", getMeterClass(rootDiskPct))}
{extraFs.map(([_name, pct], index) => ( style={{ width: `${rootDiskPct}%` }}
<span key={index} className={getMeterClass(pct)} style={{ width: `${pct}%` }}></span> ></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> </span>
</div> </div>
</Link> </Link>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right" className="max-w-xs pb-2"> <TooltipContent side="right" className="max-w-xs pb-2">
<div className="grid gap-1.5"> <div className="grid gap-1">
<div className="grid gap-0.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="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"> <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="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="flex-1 min-w-12 grid bg-muted h-2.5 rounded-sm overflow-hidden">
@@ -448,7 +472,9 @@ function DiskCellWithMultiple(info: CellContext<SystemRecord, unknown>) {
{extraFs.map(([name, pct]) => { {extraFs.map(([name, pct]) => {
return ( return (
<div key={name} className="grid gap-0.5"> <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="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"> <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="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="flex-1 min-w-12 grid bg-muted h-2.5 rounded-sm overflow-hidden">

View File

@@ -158,7 +158,7 @@ export default function SystemsTable() {
type="button" type="button"
variant="ghost" variant="ghost"
size="icon" 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" className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 text-muted-foreground"
onClick={() => setFilter("")} onClick={() => setFilter("")}
> >

View File

@@ -34,7 +34,7 @@
--table-header: hsl(225, 6%, 97%); --table-header: hsl(225, 6%, 97%);
--chart-saturation: 65%; --chart-saturation: 65%;
--chart-lightness: 50%; --chart-lightness: 50%;
--container: 1480px; --container: 1500px;
} }
.dark { .dark {
@@ -117,7 +117,6 @@
} }
@layer utilities { @layer utilities {
/* Fonts */ /* Fonts */
@supports (font-variation-settings: normal) { @supports (font-variation-settings: normal) {
:root { :root {

View File

@@ -20,6 +20,7 @@ import * as systemsManager from "@/lib/systemsManager.ts"
const LoginPage = lazy(() => import("@/components/login/login.tsx")) const LoginPage = lazy(() => import("@/components/login/login.tsx"))
const Home = lazy(() => import("@/components/routes/home.tsx")) const Home = lazy(() => import("@/components/routes/home.tsx"))
const Containers = lazy(() => import("@/components/routes/containers.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 SystemDetail = lazy(() => import("@/components/routes/system.tsx"))
const CopyToClipboardDialog = lazy(() => import("@/components/copy-to-clipboard.tsx")) const CopyToClipboardDialog = lazy(() => import("@/components/copy-to-clipboard.tsx"))
@@ -62,6 +63,8 @@ const App = memo(() => {
return <SystemDetail id={page.params.id} /> return <SystemDetail id={page.params.id} />
} else if (page.route === "containers") { } else if (page.route === "containers") {
return <Containers /> return <Containers />
} else if (page.route === "smart") {
return <Smart />
} else if (page.route === "settings") { } else if (page.route === "settings") {
return <Settings /> return <Settings />
} }
@@ -97,7 +100,7 @@ const Layout = () => {
<LoginPage /> <LoginPage />
</Suspense> </Suspense>
) : ( ) : (
<div style={{"--container": `${userSettings.layoutWidth ?? 1480}px`} as React.CSSProperties}> <div style={{ "--container": `${userSettings.layoutWidth ?? 1500}px` } as React.CSSProperties}>
<div className="container"> <div className="container">
<Navbar /> <Navbar />
</div> </div>

View File

@@ -244,6 +244,20 @@ export interface AlertsHistoryRecord extends RecordModel {
resolved?: string | null 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 { export interface ContainerRecord extends RecordModel {
id: string id: string
system: string system: string
@@ -363,6 +377,23 @@ export interface SmartAttribute {
wf?: string 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 { export interface SystemdRecord extends RecordModel {
system: string system: string
name: string name: string