mirror of
https://github.com/henrygd/beszel.git
synced 2026-04-18 10:51:50 +02:00
Compare commits
4 Commits
53a7e06dcf
...
quiet-hour
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1275af956b | ||
|
|
bf36015bd9 | ||
|
|
56807dc5e4 | ||
|
|
56a9915b43 |
10
go.mod
10
go.mod
@@ -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.1
|
github.com/nicholas-fedor/shoutrrr v0.12.0
|
||||||
github.com/pocketbase/dbx v1.11.0
|
github.com/pocketbase/dbx v1.11.0
|
||||||
github.com/pocketbase/pocketbase v0.34.0
|
github.com/pocketbase/pocketbase v0.33.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.45.0
|
golang.org/x/crypto v0.44.0
|
||||||
golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39
|
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6
|
||||||
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.1 // indirect
|
modernc.org/sqlite v1.40.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
28
go.sum
28
go.sum
@@ -58,8 +58,8 @@ github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArs
|
|||||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
github.com/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-20251114195745-4902fdda35c8 h1:3DsUAV+VNEQa2CUVLxCY3f87278uWfIDhJnbdvDjvmE=
|
github.com/google/pprof v0.0.0-20251007162407-5df77e3f7d1d h1:KJIErDwbSHjnp/SGzE5ed8Aol7JsKiI5X7yWKAtzhM0=
|
||||||
github.com/google/pprof v0.0.0-20251114195745-4902fdda35c8/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U=
|
github.com/google/pprof v0.0.0-20251007162407-5df77e3f7d1d/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.1 h1:8NjY+I3K7cGHy89ncnaPGUA0ex44XbYK3SAFJX9YMI8=
|
github.com/nicholas-fedor/shoutrrr v0.12.0 h1:8mwJdfU+uBEybSymwQJMGl/grG7lvVUKbVSNxn3XvUI=
|
||||||
github.com/nicholas-fedor/shoutrrr v0.12.1/go.mod h1:64qWuPpvTUv9ZppEoR6OdroiFmgf9w11YSaR0h9KZGg=
|
github.com/nicholas-fedor/shoutrrr v0.12.0/go.mod h1:WYiRalR4C43Qmd2zhPWGIFIxu633NB1hDM6Ap/DQcsA=
|
||||||
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.34.0 h1:5W80PrGvkRYIMAIK90F7w031/hXgZVz1KSuCJqSpgJo=
|
github.com/pocketbase/pocketbase v0.33.0 h1:v2EfiY3hxigzRJ/BwFuwVn0vUv7d2QQoD5zUFPaKR9o=
|
||||||
github.com/pocketbase/pocketbase v0.34.0/go.mod h1:K/9z/Zb9PR9yW2Qyoc73jHV/EKT8cMTk9bQWyrzYlvI=
|
github.com/pocketbase/pocketbase v0.33.0/go.mod h1:9BEs+CRV7CrS+X5LfBh4bdJQsbzQAIklft3ovGe/c5A=
|
||||||
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.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU=
|
||||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc=
|
||||||
golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39 h1:DHNhtq3sNNzrvduZZIiFyXWOL9IWaDPHqTnLJp+rCBY=
|
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0=
|
||||||
golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
|
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/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.1 h1:bFaqOaa5/zbWYJo8aW0tXPX21hXsngG2M7mckCnFSVk=
|
modernc.org/libc v1.67.0 h1:QzL4IrKab2OFmxA3/vRYl0tLXrIamwrhD6CKD4WBVjQ=
|
||||||
modernc.org/libc v1.67.1/go.mod h1:QvvnnJ5P7aitu0ReNpVIEyesuhmDLQ8kaEoyMjIFZJA=
|
modernc.org/libc v1.67.0/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.1 h1:VfuXcxcUWWKRBuP8+BR9L7VnmusMgBNNnBYGEe9w/iY=
|
modernc.org/sqlite v1.40.0 h1:bNWEDlYhNPAUdUdBzjAvn8icAs/2gaKlj4vM+tQ6KdQ=
|
||||||
modernc.org/sqlite v1.40.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
|
modernc.org/sqlite v1.40.0/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=
|
||||||
|
|||||||
@@ -104,7 +104,6 @@ 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
|
// IsNotificationSilenced checks if a notification should be silenced based on configured quiet hours
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
//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")
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
// refresh SMART devices for a system
|
// get SMART data
|
||||||
apiAuth.POST("/smart/refresh", h.refreshSmartData)
|
apiAuth.GET("/smart", h.getSmartData)
|
||||||
// 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,25 +365,22 @@ 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})
|
||||||
}
|
}
|
||||||
|
|
||||||
// refreshSmartData handles POST /api/beszel/smart/refresh requests
|
// getSmartData handles GET /api/beszel/smart requests
|
||||||
// Fetches fresh SMART data from the agent and updates the collection
|
func (h *Hub) getSmartData(e *core.RequestEvent) error {
|
||||||
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()
|
||||||
// Fetch and save SMART devices
|
if err != nil {
|
||||||
if err := system.FetchAndSaveSmartDevices(); err != nil {
|
return e.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
||||||
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, map[string]string{"status": "ok"})
|
return e.JSON(http.StatusOK, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// generates key pair if it doesn't exist and returns signer
|
// generates key pair if it doesn't exist and returns signer
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
"math/rand"
|
"math/rand"
|
||||||
"net"
|
"net"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/common"
|
"github.com/henrygd/beszel/internal/common"
|
||||||
@@ -41,7 +40,6 @@ 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 {
|
||||||
@@ -193,13 +191,6 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,7 +208,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] = makeStableHashId(systemId, service.Name)
|
params["id"+suffix] = getSystemdServiceId(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
|
||||||
@@ -234,6 +225,13 @@ 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 {
|
||||||
@@ -437,12 +435,43 @@ func (sys *System) FetchSystemdInfoFromAgent(serviceName string) (systemd.Servic
|
|||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeStableHashId(strings ...string) string {
|
// FetchSmartDataFromAgent fetches SMART data from the agent
|
||||||
hash := fnv.New32a()
|
func (sys *System) FetchSmartDataFromAgent() (map[string]any, error) {
|
||||||
for _, str := range strings {
|
// fetch via websocket
|
||||||
hash.Write([]byte(str))
|
if sys.WsConn != nil && sys.WsConn.IsConnected() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
return sys.WsConn.RequestSmartData(ctx)
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("%x", hash.Sum32())
|
// fetch via SSH
|
||||||
|
var result map[string]any
|
||||||
|
err := sys.runSSHOperation(5*time.Second, 1, func(session *ssh.Session) (bool, error) {
|
||||||
|
stdout, err := session.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
stdin, stdinErr := session.StdinPipe()
|
||||||
|
if stdinErr != nil {
|
||||||
|
return false, stdinErr
|
||||||
|
}
|
||||||
|
if err := session.Shell(); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
req := common.HubRequest[any]{Action: common.GetSmartData}
|
||||||
|
_ = cbor.NewEncoder(stdin).Encode(req)
|
||||||
|
_ = stdin.Close()
|
||||||
|
var resp common.AgentResponse
|
||||||
|
if err := cbor.NewDecoder(stdout).Decode(&resp); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
// Convert to generic map for JSON response
|
||||||
|
result = make(map[string]any, len(resp.SmartData))
|
||||||
|
for k, v := range resp.SmartData {
|
||||||
|
result[k] = v
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
})
|
||||||
|
return result, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchDataViaSSH handles fetching data using SSH.
|
// fetchDataViaSSH handles fetching data using SSH.
|
||||||
|
|||||||
@@ -1,132 +0,0 @@
|
|||||||
package systems
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"errors"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/fxamacker/cbor/v2"
|
|
||||||
"github.com/henrygd/beszel/internal/common"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/smart"
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
|
||||||
"golang.org/x/crypto/ssh"
|
|
||||||
)
|
|
||||||
|
|
||||||
// FetchSmartDataFromAgent fetches SMART data from the agent
|
|
||||||
func (sys *System) FetchSmartDataFromAgent() (map[string]smart.SmartData, error) {
|
|
||||||
// fetch via websocket
|
|
||||||
if sys.WsConn != nil && sys.WsConn.IsConnected() {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
return sys.WsConn.RequestSmartData(ctx)
|
|
||||||
}
|
|
||||||
// fetch via SSH
|
|
||||||
var result map[string]smart.SmartData
|
|
||||||
err := sys.runSSHOperation(5*time.Second, 1, func(session *ssh.Session) (bool, error) {
|
|
||||||
stdout, err := session.StdoutPipe()
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
stdin, stdinErr := session.StdinPipe()
|
|
||||||
if stdinErr != nil {
|
|
||||||
return false, stdinErr
|
|
||||||
}
|
|
||||||
if err := session.Shell(); err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
req := common.HubRequest[any]{Action: common.GetSmartData}
|
|
||||||
_ = cbor.NewEncoder(stdin).Encode(req)
|
|
||||||
_ = stdin.Close()
|
|
||||||
var resp common.AgentResponse
|
|
||||||
if err := cbor.NewDecoder(stdout).Decode(&resp); err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
result = resp.SmartData
|
|
||||||
return false, nil
|
|
||||||
})
|
|
||||||
return result, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// FetchAndSaveSmartDevices fetches SMART data from the agent and saves it to the database
|
|
||||||
func (sys *System) FetchAndSaveSmartDevices() error {
|
|
||||||
smartData, err := sys.FetchSmartDataFromAgent()
|
|
||||||
if err != nil || len(smartData) == 0 {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return sys.saveSmartDevices(smartData)
|
|
||||||
}
|
|
||||||
|
|
||||||
// saveSmartDevices saves SMART device data to the smart_devices collection
|
|
||||||
func (sys *System) saveSmartDevices(smartData map[string]smart.SmartData) error {
|
|
||||||
if len(smartData) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
hub := sys.manager.hub
|
|
||||||
collection, err := hub.FindCachedCollectionByNameOrId("smart_devices")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
for deviceKey, device := range smartData {
|
|
||||||
if err := sys.upsertSmartDeviceRecord(collection, deviceKey, device); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (sys *System) upsertSmartDeviceRecord(collection *core.Collection, deviceKey string, device smart.SmartData) error {
|
|
||||||
hub := sys.manager.hub
|
|
||||||
recordID := makeStableHashId(sys.Id, deviceKey)
|
|
||||||
|
|
||||||
record, err := hub.FindRecordById(collection, recordID)
|
|
||||||
if err != nil {
|
|
||||||
if !errors.Is(err, sql.ErrNoRows) {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
record = core.NewRecord(collection)
|
|
||||||
record.Set("id", recordID)
|
|
||||||
}
|
|
||||||
|
|
||||||
name := device.DiskName
|
|
||||||
if name == "" {
|
|
||||||
name = deviceKey
|
|
||||||
}
|
|
||||||
|
|
||||||
powerOnHours, powerCycles := extractPowerMetrics(device.Attributes)
|
|
||||||
record.Set("system", sys.Id)
|
|
||||||
record.Set("name", name)
|
|
||||||
record.Set("model", device.ModelName)
|
|
||||||
record.Set("state", device.SmartStatus)
|
|
||||||
record.Set("capacity", device.Capacity)
|
|
||||||
record.Set("temp", device.Temperature)
|
|
||||||
record.Set("firmware", device.FirmwareVersion)
|
|
||||||
record.Set("serial", device.SerialNumber)
|
|
||||||
record.Set("type", device.DiskType)
|
|
||||||
record.Set("hours", powerOnHours)
|
|
||||||
record.Set("cycles", powerCycles)
|
|
||||||
record.Set("attributes", device.Attributes)
|
|
||||||
|
|
||||||
return hub.SaveNoValidate(record)
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractPowerMetrics extracts power on hours and power cycles from SMART attributes
|
|
||||||
func extractPowerMetrics(attributes []*smart.SmartAttribute) (powerOnHours, powerCycles uint64) {
|
|
||||||
for _, attr := range attributes {
|
|
||||||
nameLower := strings.ToLower(attr.Name)
|
|
||||||
if powerOnHours == 0 && (strings.Contains(nameLower, "poweronhours") || strings.Contains(nameLower, "power_on_hours")) {
|
|
||||||
powerOnHours = attr.RawValue
|
|
||||||
}
|
|
||||||
if powerCycles == 0 && ((strings.Contains(nameLower, "power") && strings.Contains(nameLower, "cycle")) || strings.Contains(nameLower, "startstopcycles")) {
|
|
||||||
powerCycles = attr.RawValue
|
|
||||||
}
|
|
||||||
if powerOnHours > 0 && powerCycles > 0 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
@@ -14,9 +14,9 @@ func TestGetSystemdServiceId(t *testing.T) {
|
|||||||
serviceName := "nginx.service"
|
serviceName := "nginx.service"
|
||||||
|
|
||||||
// Call multiple times and ensure same result
|
// Call multiple times and ensure same result
|
||||||
id1 := makeStableHashId(systemId, serviceName)
|
id1 := getSystemdServiceId(systemId, serviceName)
|
||||||
id2 := makeStableHashId(systemId, serviceName)
|
id2 := getSystemdServiceId(systemId, serviceName)
|
||||||
id3 := makeStableHashId(systemId, serviceName)
|
id3 := getSystemdServiceId(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 := makeStableHashId(systemId1, serviceName1)
|
id1 := getSystemdServiceId(systemId1, serviceName1)
|
||||||
id2 := makeStableHashId(systemId2, serviceName1)
|
id2 := getSystemdServiceId(systemId2, serviceName1)
|
||||||
id3 := makeStableHashId(systemId1, serviceName2)
|
id3 := getSystemdServiceId(systemId1, serviceName2)
|
||||||
id4 := makeStableHashId(systemId2, serviceName2)
|
id4 := getSystemdServiceId(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 := makeStableHashId(tc.systemId, tc.serviceName)
|
id := getSystemdServiceId(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 := makeStableHashId("test-system", "test-service")
|
id := getSystemdServiceId("test-system", "test-service")
|
||||||
assert.NotEmpty(t, id)
|
assert.NotEmpty(t, id)
|
||||||
|
|
||||||
// Should only contain hexadecimal characters
|
// Should only contain hexadecimal characters
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ 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"
|
||||||
@@ -156,7 +155,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]smart.SmartData, error) {
|
func (ws *WsConn) RequestSmartData(ctx context.Context) (map[string]any, error) {
|
||||||
if !ws.IsConnected() {
|
if !ws.IsConnected() {
|
||||||
return nil, gws.ErrConnClosed
|
return nil, gws.ErrConnClosed
|
||||||
}
|
}
|
||||||
@@ -164,7 +163,7 @@ func (ws *WsConn) RequestSmartData(ctx context.Context) (map[string]smart.SmartD
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
var result map[string]smart.SmartData
|
var result map[string]any
|
||||||
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
|
||||||
@@ -175,14 +174,19 @@ func (ws *WsConn) RequestSmartData(ctx context.Context) (map[string]smart.SmartD
|
|||||||
// 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]smart.SmartData
|
result *map[string]any
|
||||||
}
|
}
|
||||||
|
|
||||||
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")
|
||||||
}
|
}
|
||||||
*h.result = agentResponse.SmartData
|
// convert to map[string]any for transport convenience in hub layer
|
||||||
|
out := make(map[string]any, len(agentResponse.SmartData))
|
||||||
|
for k, v := range agentResponse.SmartData {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
*h.result = out
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1243,201 +1243,6 @@ func init() {
|
|||||||
"type": "base",
|
"type": "base",
|
||||||
"updateRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
|
"updateRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
|
||||||
"viewRule": "@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"
|
|
||||||
}
|
}
|
||||||
]`
|
]`
|
||||||
|
|
||||||
|
|||||||
@@ -17,9 +17,6 @@
|
|||||||
"enabled": true,
|
"enabled": true,
|
||||||
"rules": {
|
"rules": {
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
"a11y": {
|
|
||||||
"useButtonType": "off"
|
|
||||||
},
|
|
||||||
"complexity": {
|
"complexity": {
|
||||||
"noUselessStringConcat": "error",
|
"noUselessStringConcat": "error",
|
||||||
"noUselessUndefinedInitialization": "error",
|
"noUselessUndefinedInitialization": "error",
|
||||||
@@ -41,9 +38,6 @@
|
|||||||
"useUniqueElementIds": "off",
|
"useUniqueElementIds": "off",
|
||||||
"noUnusedVariables": "error"
|
"noUnusedVariables": "error"
|
||||||
},
|
},
|
||||||
"security": {
|
|
||||||
"noDangerouslySetInnerHtml": "warn"
|
|
||||||
},
|
|
||||||
"style": {
|
"style": {
|
||||||
"noParameterProperties": "error",
|
"noParameterProperties": "error",
|
||||||
"noYodaExpression": "error",
|
"noYodaExpression": "error",
|
||||||
@@ -54,8 +48,7 @@
|
|||||||
},
|
},
|
||||||
"suspicious": {
|
"suspicious": {
|
||||||
"useAwait": "error",
|
"useAwait": "error",
|
||||||
"noEvolvingTypes": "error",
|
"noEvolvingTypes": "error"
|
||||||
"noArrayIndexKey": "off"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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" crossorigin="use-credentials" />
|
<link rel="manifest" href="./static/manifest.json" />
|
||||||
<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" />
|
||||||
|
|||||||
@@ -8,11 +8,10 @@ import {
|
|||||||
ContainerIcon,
|
ContainerIcon,
|
||||||
DatabaseBackupIcon,
|
DatabaseBackupIcon,
|
||||||
FingerprintIcon,
|
FingerprintIcon,
|
||||||
HardDriveIcon,
|
LayoutDashboard,
|
||||||
LogsIcon,
|
LogsIcon,
|
||||||
MailIcon,
|
MailIcon,
|
||||||
Server,
|
Server,
|
||||||
ServerIcon,
|
|
||||||
SettingsIcon,
|
SettingsIcon,
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
@@ -82,15 +81,15 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
|
|||||||
)}
|
)}
|
||||||
<CommandGroup heading={t`Pages / Settings`}>
|
<CommandGroup heading={t`Pages / Settings`}>
|
||||||
<CommandItem
|
<CommandItem
|
||||||
keywords={["home"]}
|
keywords={["home", t`All Systems`]}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
navigate(basePath)
|
navigate(basePath)
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ServerIcon className="me-2 size-4" />
|
<LayoutDashboard className="me-2 size-4" />
|
||||||
<span>
|
<span>
|
||||||
<Trans>All Systems</Trans>
|
<Trans>Dashboard</Trans>
|
||||||
</span>
|
</span>
|
||||||
<CommandShortcut>
|
<CommandShortcut>
|
||||||
<Trans>Page</Trans>
|
<Trans>Page</Trans>
|
||||||
@@ -110,18 +109,6 @@ 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" }))
|
||||||
|
|||||||
@@ -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 { LoaderCircleIcon, MaximizeIcon, RefreshCwIcon, XIcon } from "lucide-react"
|
import { 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[] | undefined>(undefined)
|
const [data, setData] = useState<ContainerRecord[]>([])
|
||||||
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,10 +54,7 @@ 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(
|
.then(({ items }) => items.length && setData((curItems) => {
|
||||||
({ 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 = []
|
||||||
@@ -67,14 +64,13 @@ 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
|
||||||
@@ -97,7 +93,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(),
|
||||||
@@ -163,7 +159,7 @@ export default function ContainersTable({ systemId }: { systemId?: string }) {
|
|||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
aria-label={t`Clear`}
|
aria-label={t`Clear filter`}
|
||||||
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("")}
|
||||||
>
|
>
|
||||||
@@ -174,7 +170,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} data={data} />
|
<AllContainersTable table={table} rows={rows} colLength={visibleColumns.length} />
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
@@ -184,12 +180,10 @@ 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)
|
||||||
@@ -233,11 +227,7 @@ 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>
|
||||||
)}
|
)}
|
||||||
@@ -276,7 +266,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)
|
||||||
@@ -333,7 +323,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)
|
||||||
@@ -515,7 +505,9 @@ function LogsFullscreenDialog({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onRefresh}
|
onClick={() => {
|
||||||
|
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`}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { getPagePath } from "@nanostores/router"
|
|||||||
import {
|
import {
|
||||||
ContainerIcon,
|
ContainerIcon,
|
||||||
DatabaseBackupIcon,
|
DatabaseBackupIcon,
|
||||||
HardDriveIcon,
|
|
||||||
LogOutIcon,
|
LogOutIcon,
|
||||||
LogsIcon,
|
LogsIcon,
|
||||||
SearchIcon,
|
SearchIcon,
|
||||||
@@ -30,7 +29,6 @@ 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"))
|
||||||
|
|
||||||
@@ -57,13 +55,6 @@ 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
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ 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`,
|
||||||
|
|||||||
@@ -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 ?? 1500
|
const layoutWidth = currentUserSettings.layoutWidth ?? 1480
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|||||||
@@ -1,16 +1,7 @@
|
|||||||
import { t } from "@lingui/core/macro"
|
import { t } from "@lingui/core/macro"
|
||||||
import { Trans } from "@lingui/react/macro"
|
import { Trans } from "@lingui/react/macro"
|
||||||
import { useStore } from "@nanostores/react"
|
import { useStore } from "@nanostores/react"
|
||||||
import {
|
import { MoreHorizontalIcon, PlusIcon, Trash2Icon, ServerIcon, ClockIcon, CalendarIcon, ActivityIcon, PenSquareIcon } from "lucide-react"
|
||||||
MoreHorizontalIcon,
|
|
||||||
PlusIcon,
|
|
||||||
Trash2Icon,
|
|
||||||
ServerIcon,
|
|
||||||
ClockIcon,
|
|
||||||
CalendarIcon,
|
|
||||||
ActivityIcon,
|
|
||||||
PenSquareIcon,
|
|
||||||
} from "lucide-react"
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
@@ -24,7 +15,12 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/components/ui/dialog"
|
} from "@/components/ui/dialog"
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
@@ -58,7 +54,7 @@ export function QuietHours() {
|
|||||||
.then(({ items }) => setData(items))
|
.then(({ items }) => setData(items))
|
||||||
|
|
||||||
// Subscribe to changes
|
// Subscribe to changes
|
||||||
;(async () => {
|
; (async () => {
|
||||||
unsubscribe = await pb.collection("quiet_hours").subscribe(
|
unsubscribe = await pb.collection("quiet_hours").subscribe(
|
||||||
"*",
|
"*",
|
||||||
(e) => {
|
(e) => {
|
||||||
@@ -66,7 +62,9 @@ export function QuietHours() {
|
|||||||
setData((current) => [e.record as QuietHoursRecord, ...current])
|
setData((current) => [e.record as QuietHoursRecord, ...current])
|
||||||
}
|
}
|
||||||
if (e.action === "update") {
|
if (e.action === "update") {
|
||||||
setData((current) => current.map((r) => (r.id === e.record.id ? (e.record as QuietHoursRecord) : r)))
|
setData((current) =>
|
||||||
|
current.map((r) => (r.id === e.record.id ? (e.record as QuietHoursRecord) : r))
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (e.action === "delete") {
|
if (e.action === "delete") {
|
||||||
setData((current) => current.filter((r) => r.id !== e.record.id))
|
setData((current) => current.filter((r) => r.id !== e.record.id))
|
||||||
@@ -104,8 +102,8 @@ export function QuietHours() {
|
|||||||
const formatDateTime = (record: QuietHoursRecord) => {
|
const formatDateTime = (record: QuietHoursRecord) => {
|
||||||
if (record.type === "daily") {
|
if (record.type === "daily") {
|
||||||
// For daily windows, show only time
|
// For daily windows, show only time
|
||||||
const startTime = new Date(record.start).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })
|
const startTime = new Date(record.start).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
||||||
const endTime = new Date(record.end).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })
|
const endTime = new Date(record.end).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
||||||
return `${startTime} - ${endTime}`
|
return `${startTime} - ${endTime}`
|
||||||
}
|
}
|
||||||
// For one-time windows, show full date and time
|
// For one-time windows, show full date and time
|
||||||
@@ -114,7 +112,7 @@ export function QuietHours() {
|
|||||||
return `${start} - ${end}`
|
return `${start} - ${end}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const getWindowState = (record: QuietHoursRecord): "active" | "past" | "inactive" => {
|
const getWindowState = (record: QuietHoursRecord): "active" | "past" | "future" => {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
|
|
||||||
if (record.type === "daily") {
|
if (record.type === "daily") {
|
||||||
@@ -134,9 +132,9 @@ export function QuietHours() {
|
|||||||
|
|
||||||
// Handle cases where window spans midnight
|
// Handle cases where window spans midnight
|
||||||
if (localStartMinutes <= localEndMinutes) {
|
if (localStartMinutes <= localEndMinutes) {
|
||||||
return currentMinutes >= localStartMinutes && currentMinutes < localEndMinutes ? "active" : "inactive"
|
return currentMinutes >= localStartMinutes && currentMinutes < localEndMinutes ? "active" : "future"
|
||||||
} else {
|
} else {
|
||||||
return currentMinutes >= localStartMinutes || currentMinutes < localEndMinutes ? "active" : "inactive"
|
return currentMinutes >= localStartMinutes || currentMinutes < localEndMinutes ? "active" : "future"
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// For one-time windows
|
// For one-time windows
|
||||||
@@ -148,7 +146,7 @@ export function QuietHours() {
|
|||||||
} else if (now >= endDate) {
|
} else if (now >= endDate) {
|
||||||
return "past"
|
return "past"
|
||||||
} else {
|
} else {
|
||||||
return "inactive"
|
return "future"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -161,9 +159,7 @@ export function QuietHours() {
|
|||||||
<Trans>Quiet hours</Trans>
|
<Trans>Quiet hours</Trans>
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||||
<Trans>
|
<Trans>Schedule quiet hours where notifications will not be sent, such as during maintenance periods.</Trans>
|
||||||
Schedule quiet hours where notifications will not be sent, such as during maintenance periods.
|
|
||||||
</Trans>
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
@@ -175,7 +171,12 @@ export function QuietHours() {
|
|||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<QuietHoursDialog editingRecord={editingRecord} systems={systems} onClose={closeDialog} toast={toast} />
|
<QuietHoursDialog
|
||||||
|
editingRecord={editingRecord}
|
||||||
|
systems={systems}
|
||||||
|
onClose={closeDialog}
|
||||||
|
toast={toast}
|
||||||
|
/>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
{data.length > 0 && (
|
{data.length > 0 && (
|
||||||
@@ -197,14 +198,14 @@ export function QuietHours() {
|
|||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="px-4">
|
<TableHead className="px-4">
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<CalendarIcon className="size-4" />
|
<ActivityIcon className="size-4" />
|
||||||
<Trans>Schedule</Trans>
|
<Trans>State</Trans>
|
||||||
</span>
|
</span>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="px-4">
|
<TableHead className="px-4">
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<ActivityIcon className="size-4" />
|
<CalendarIcon className="size-4" />
|
||||||
<Trans>State</Trans>
|
<Trans>Schedule</Trans>
|
||||||
</span>
|
</span>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="px-4 text-right sr-only">
|
<TableHead className="px-4 text-right sr-only">
|
||||||
@@ -216,31 +217,33 @@ export function QuietHours() {
|
|||||||
{data.map((record) => (
|
{data.map((record) => (
|
||||||
<TableRow key={record.id}>
|
<TableRow key={record.id}>
|
||||||
<TableCell className="px-4 py-3">
|
<TableCell className="px-4 py-3">
|
||||||
{record.system ? record.expand?.system?.name || record.system : <Trans>All Systems</Trans>}
|
{record.system ? (record.expand?.system?.name || record.system) : <Trans>All Systems</Trans>}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-4 py-3">
|
<TableCell className="px-4 py-3">
|
||||||
{record.type === "daily" ? <Trans>Daily</Trans> : <Trans>One-time</Trans>}
|
{record.type === "daily" ? <Trans>Daily</Trans> : <Trans>One-time</Trans>}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-4 py-3">{formatDateTime(record)}</TableCell>
|
|
||||||
<TableCell className="px-4 py-3">
|
<TableCell className="px-4 py-3">
|
||||||
{(() => {
|
{(() => {
|
||||||
const state = getWindowState(record)
|
const state = getWindowState(record)
|
||||||
const stateConfig = {
|
const stateConfig = {
|
||||||
active: { label: <Trans>Active</Trans>, variant: "success" as const },
|
active: { label: <Trans>Active</Trans>, variant: "success" as const },
|
||||||
past: { label: <Trans>Past</Trans>, variant: "danger" as const },
|
past: { label: <Trans>Past</Trans>, variant: "danger" as const },
|
||||||
inactive: { label: <Trans>Inactive</Trans>, variant: "default" as const },
|
future: { label: <Trans>Future</Trans>, variant: "default" as const },
|
||||||
}
|
}
|
||||||
const config = stateConfig[state]
|
const config = stateConfig[state]
|
||||||
return <Badge variant={config.variant}>{config.label}</Badge>
|
return (
|
||||||
|
<Badge variant={config.variant}>
|
||||||
|
{config.label}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
})()}
|
})()}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell className="px-4 py-3">{formatDateTime(record)}</TableCell>
|
||||||
<TableCell className="px-4 py-3 text-right">
|
<TableCell className="px-4 py-3 text-right">
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" className="size-8">
|
<Button variant="ghost" size="icon" className="size-8">
|
||||||
<span className="sr-only">
|
<span className="sr-only"><Trans>Open menu</Trans></span>
|
||||||
<Trans>Open menu</Trans>
|
|
||||||
</span>
|
|
||||||
<MoreHorizontalIcon className="size-4" />
|
<MoreHorizontalIcon className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
@@ -262,6 +265,8 @@ export function QuietHours() {
|
|||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -269,10 +274,10 @@ export function QuietHours() {
|
|||||||
// Helper function to format Date as datetime-local string (YYYY-MM-DDTHH:mm) in local time
|
// Helper function to format Date as datetime-local string (YYYY-MM-DDTHH:mm) in local time
|
||||||
function formatDateTimeLocal(date: Date): string {
|
function formatDateTimeLocal(date: Date): string {
|
||||||
const year = date.getFullYear()
|
const year = date.getFullYear()
|
||||||
const month = String(date.getMonth() + 1).padStart(2, "0")
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
const day = String(date.getDate()).padStart(2, "0")
|
const day = String(date.getDate()).padStart(2, '0')
|
||||||
const hours = String(date.getHours()).padStart(2, "0")
|
const hours = String(date.getHours()).padStart(2, '0')
|
||||||
const minutes = String(date.getMinutes()).padStart(2, "0")
|
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||||
return `${year}-${month}-${day}T${hours}:${minutes}`
|
return `${year}-${month}-${day}T${hours}:${minutes}`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,7 +290,7 @@ function QuietHoursDialog({
|
|||||||
editingRecord: QuietHoursRecord | null
|
editingRecord: QuietHoursRecord | null
|
||||||
systems: SystemRecord[]
|
systems: SystemRecord[]
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
toast: ReturnType<typeof useToast>["toast"]
|
toast: any
|
||||||
}) {
|
}) {
|
||||||
const [selectedSystem, setSelectedSystem] = useState(editingRecord?.system || "")
|
const [selectedSystem, setSelectedSystem] = useState(editingRecord?.system || "")
|
||||||
const [isGlobal, setIsGlobal] = useState(!editingRecord?.system)
|
const [isGlobal, setIsGlobal] = useState(!editingRecord?.system)
|
||||||
@@ -365,8 +370,16 @@ function QuietHoursDialog({
|
|||||||
|
|
||||||
if (editingRecord) {
|
if (editingRecord) {
|
||||||
await pb.collection("quiet_hours").update(editingRecord.id, data)
|
await pb.collection("quiet_hours").update(editingRecord.id, data)
|
||||||
|
toast({
|
||||||
|
title: t`Updated`,
|
||||||
|
description: t`Quiet hours have been updated.`,
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
await pb.collection("quiet_hours").create(data)
|
await pb.collection("quiet_hours").create(data)
|
||||||
|
toast({
|
||||||
|
title: t`Created`,
|
||||||
|
description: t`Quiet hours have been created.`,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
onClose()
|
onClose()
|
||||||
@@ -374,7 +387,7 @@ function QuietHoursDialog({
|
|||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
title: t`Error`,
|
title: t`Error`,
|
||||||
description: t`Failed to save settings`,
|
description: t`Failed to save quiet hours.`,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -382,7 +395,9 @@ function QuietHoursDialog({
|
|||||||
return (
|
return (
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{editingRecord ? <Trans>Edit Quiet Hours</Trans> : <Trans>Add Quiet Hours</Trans>}</DialogTitle>
|
<DialogTitle>
|
||||||
|
{editingRecord ? <Trans>Edit Quiet Hours</Trans> : <Trans>Add Quiet Hours</Trans>}
|
||||||
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
<Trans>Configure quiet hours where notifications will not be sent.</Trans>
|
<Trans>Configure quiet hours where notifications will not be sent.</Trans>
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
@@ -391,10 +406,10 @@ function QuietHoursDialog({
|
|||||||
<Tabs value={isGlobal ? "global" : "system"} onValueChange={(value) => setIsGlobal(value === "global")}>
|
<Tabs value={isGlobal ? "global" : "system"} onValueChange={(value) => setIsGlobal(value === "global")}>
|
||||||
<TabsList className="grid w-full grid-cols-2">
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
<TabsTrigger value="global">
|
<TabsTrigger value="global">
|
||||||
<Trans>Global</Trans>
|
<Trans>All Systems</Trans>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="system">
|
<TabsTrigger value="system">
|
||||||
<Trans>System</Trans>
|
<Trans>Specific System</Trans>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
@@ -422,7 +437,7 @@ function QuietHoursDialog({
|
|||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
value={selectedSystem}
|
value={selectedSystem}
|
||||||
onChange={() => {}}
|
onChange={() => { }}
|
||||||
required={!isGlobal}
|
required={!isGlobal}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -452,7 +467,7 @@ function QuietHoursDialog({
|
|||||||
<>
|
<>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="start-datetime">
|
<Label htmlFor="start-datetime">
|
||||||
<Trans>Start Time</Trans>
|
<Trans>Start Date & Time</Trans>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="start-datetime"
|
id="start-datetime"
|
||||||
@@ -466,7 +481,7 @@ function QuietHoursDialog({
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="end-datetime">
|
<Label htmlFor="end-datetime">
|
||||||
<Trans>End Time</Trans>
|
<Trans>End Date & Time</Trans>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="end-datetime"
|
id="end-datetime"
|
||||||
@@ -480,13 +495,12 @@ function QuietHoursDialog({
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-2 grid-cols-2">
|
<>
|
||||||
<div>
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="start-time">
|
<Label htmlFor="start-time">
|
||||||
<Trans>Start Time</Trans>
|
<Trans>Start Time</Trans>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
className="tabular-nums tracking-tighter"
|
|
||||||
id="start-time"
|
id="start-time"
|
||||||
type="time"
|
type="time"
|
||||||
value={startTime}
|
value={startTime}
|
||||||
@@ -494,20 +508,13 @@ function QuietHoursDialog({
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="end-time">
|
<Label htmlFor="end-time">
|
||||||
<Trans>End Time</Trans>
|
<Trans>End Time</Trans>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input id="end-time" type="time" value={endTime} onChange={(e) => setEndTime(e.target.value)} required />
|
||||||
className="tabular-nums tracking-tighter"
|
|
||||||
id="end-time"
|
|
||||||
type="time"
|
|
||||||
value={endTime}
|
|
||||||
onChange={(e) => setEndTime(e.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
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 />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,64 +1,37 @@
|
|||||||
|
import * as React from "react"
|
||||||
import { t } from "@lingui/core/macro"
|
import { t } from "@lingui/core/macro"
|
||||||
import {
|
import {
|
||||||
type ColumnDef,
|
ColumnDef,
|
||||||
type ColumnFiltersState,
|
ColumnFiltersState,
|
||||||
type Column,
|
Column,
|
||||||
type SortingState,
|
|
||||||
flexRender,
|
flexRender,
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
getFilteredRowModel,
|
getFilteredRowModel,
|
||||||
getSortedRowModel,
|
getSortedRowModel,
|
||||||
|
SortingState,
|
||||||
useReactTable,
|
useReactTable,
|
||||||
} from "@tanstack/react-table"
|
} from "@tanstack/react-table"
|
||||||
import {
|
import { Activity, Box, Clock, HardDrive, HashIcon, CpuIcon, BinaryIcon, RotateCwIcon, LoaderCircleIcon, CheckCircle2Icon, XCircleIcon, ArrowLeftRightIcon } from "lucide-react"
|
||||||
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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import {
|
||||||
|
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 type { SmartDeviceRecord, SmartAttribute } from "@/types"
|
import { SmartData, SmartAttribute } from "@/types"
|
||||||
import {
|
import { formatBytes, toFixedFloat, formatTemperature, cn, secondsToString } from "@/lib/utils"
|
||||||
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>[] = [
|
||||||
@@ -93,19 +66,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
|
||||||
@@ -114,51 +87,38 @@ function formatCapacity(bytes: number): string {
|
|||||||
return `${toFixedFloat(value, value >= 10 ? 1 : 2)} ${unit}`
|
return `${toFixedFloat(value, value >= 10 ? 1 : 2)} ${unit}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Function to convert SmartDeviceRecord to DiskInfo
|
// Function to convert SmartData to DiskInfo
|
||||||
function convertSmartDeviceRecordToDiskInfo(records: SmartDeviceRecord[]): DiskInfo[] {
|
function convertSmartDataToDiskInfo(smartDataRecord: Record<string, SmartData>): DiskInfo[] {
|
||||||
const unknown = "Unknown"
|
const unknown = "Unknown"
|
||||||
return records.map((record) => ({
|
return Object.entries(smartDataRecord).map(([key, smartData]) => ({
|
||||||
id: record.id,
|
device: smartData.dn || key,
|
||||||
system: record.system,
|
model: smartData.mn || unknown,
|
||||||
device: record.name || unknown,
|
serialNumber: smartData.sn || unknown,
|
||||||
model: record.model || unknown,
|
firmwareVersion: smartData.fv || unknown,
|
||||||
serialNumber: record.serial || unknown,
|
capacity: smartData.c ? formatCapacity(smartData.c) : unknown,
|
||||||
firmwareVersion: record.firmware || unknown,
|
status: smartData.s || unknown,
|
||||||
capacity: record.capacity ? formatCapacity(record.capacity) : unknown,
|
temperature: smartData.t || 0,
|
||||||
status: record.state || unknown,
|
deviceType: smartData.dt || unknown,
|
||||||
temperature: record.temp || 0,
|
// These fields need to be extracted from SmartAttribute if available
|
||||||
deviceType: record.type || unknown,
|
powerOnHours: smartData.a?.find(attr => {
|
||||||
attributes: record.attributes,
|
const name = attr.n.toLowerCase();
|
||||||
updated: record.updated,
|
return name.includes("poweronhours") || name.includes("power_on_hours");
|
||||||
powerOnHours: record.hours,
|
})?.rv,
|
||||||
powerCycles: record.cycles,
|
powerCycles: smartData.a?.find(attr => {
|
||||||
|
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-40 truncate ms-1.5" title={row.getValue("device")}>
|
<div className="font-medium max-w-50 truncate ms-1.5" title={row.getValue("device")}>
|
||||||
{row.getValue("device")}
|
{row.getValue("device")}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -168,7 +128,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-48 truncate ms-1.5" title={row.getValue("model")}>
|
<div className="max-w-50 truncate ms-1.5" title={row.getValue("model")}>
|
||||||
{row.getValue("model")}
|
{row.getValue("model")}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -176,7 +136,18 @@ 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 }) => <span className="ms-1.5">{getValue() as string}</span>,
|
cell: ({ getValue }) => (
|
||||||
|
<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",
|
||||||
@@ -185,7 +156,11 @@ 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 variant={status === "PASSED" ? "success" : status === "FAILED" ? "danger" : "warning"}>{status}</Badge>
|
<Badge
|
||||||
|
variant={status === "PASSED" ? "success" : status === "FAILED" ? "danger" : "warning"}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -205,13 +180,15 @@ export const columns: ColumnDef<DiskInfo>[] = [
|
|||||||
{
|
{
|
||||||
accessorKey: "powerOnHours",
|
accessorKey: "powerOnHours",
|
||||||
invertSorting: true,
|
invertSorting: true,
|
||||||
header: ({ column }) => (
|
header: ({ column }) => <HeaderButton column={column} name={t({ message: "Power On", comment: "Power On Time" })} Icon={Clock} />,
|
||||||
<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 <div className="text-sm text-muted-foreground ms-1.5">N/A</div>
|
return (
|
||||||
|
<div className="text-sm text-muted-foreground ms-1.5">
|
||||||
|
N/A
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
const seconds = hours * 3600
|
const seconds = hours * 3600
|
||||||
return (
|
return (
|
||||||
@@ -225,50 +202,34 @@ export const columns: ColumnDef<DiskInfo>[] = [
|
|||||||
{
|
{
|
||||||
accessorKey: "powerCycles",
|
accessorKey: "powerCycles",
|
||||||
invertSorting: true,
|
invertSorting: true,
|
||||||
header: ({ column }) => (
|
header: ({ column }) => <HeaderButton column={column} name={t({ message: "Cycles", comment: "Power Cycles" })} Icon={RotateCwIcon} />,
|
||||||
<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 <div className="text-muted-foreground ms-1.5">N/A</div>
|
return (
|
||||||
|
<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: "temperature",
|
accessorKey: "serialNumber",
|
||||||
invertSorting: true,
|
sortingFn: (a, b) => a.original.serialNumber.localeCompare(b.original.serialNumber),
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`Temp`} Icon={ThermometerIcon} />,
|
header: ({ column }) => <HeaderButton column={column} name={t`Serial Number`} Icon={HashIcon} />,
|
||||||
cell: ({ getValue }) => {
|
cell: ({ getValue }) => (
|
||||||
const { value, unit } = formatTemperature(getValue() as number)
|
<span className="ms-1.5">{getValue() as string}</span>
|
||||||
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>,
|
|
||||||
// },
|
|
||||||
{
|
{
|
||||||
id: "updated",
|
accessorKey: "firmwareVersion",
|
||||||
invertSorting: true,
|
sortingFn: (a, b) => a.original.firmwareVersion.localeCompare(b.original.firmwareVersion),
|
||||||
accessorFn: (record) => record.updated,
|
header: ({ column }) => <HeaderButton column={column} name={t`Firmware`} Icon={CpuIcon} />,
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`Updated`} Icon={Clock} />,
|
cell: ({ getValue }) => (
|
||||||
cell: ({ getValue }) => {
|
<span className="ms-1.5">{getValue() as string}</span>
|
||||||
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>
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -276,10 +237,7 @@ function HeaderButton({ column, name, Icon }: { column: Column<DiskInfo>; name:
|
|||||||
const isSorted = column.getIsSorted()
|
const isSorted = column.getIsSorted()
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
className={cn(
|
className={cn("h-9 px-3 flex items-center gap-2 duration-50", isSorted && "bg-accent/70 light:bg-accent text-accent-foreground/90")}
|
||||||
"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")}
|
||||||
>
|
>
|
||||||
@@ -289,193 +247,39 @@ 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] = useState<SortingState>([{ id: systemId ? "device" : "system", desc: false }])
|
const [sorting, setSorting] = React.useState<SortingState>([{ id: "device", desc: false }])
|
||||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([])
|
||||||
const [rowSelection, setRowSelection] = useState({})
|
const [rowSelection, setRowSelection] = React.useState({})
|
||||||
const [smartDevices, setSmartDevices] = useState<SmartDeviceRecord[] | undefined>(undefined)
|
const [smartData, setSmartData] = React.useState<Record<string, SmartData> | undefined>(undefined)
|
||||||
const [activeDiskId, setActiveDiskId] = useState<string | null>(null)
|
const [activeDisk, setActiveDisk] = React.useState<DiskInfo | null>(null)
|
||||||
const [sheetOpen, setSheetOpen] = useState(false)
|
const [sheetOpen, setSheetOpen] = React.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) => {
|
||||||
setActiveDiskId(disk.id)
|
setActiveDisk(disk)
|
||||||
setSheetOpen(true)
|
setSheetOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch smart devices from collection (without attributes to save bandwidth)
|
// Fetch smart data when component mounts or systemId changes
|
||||||
const fetchSmartDevices = useCallback(() => {
|
React.useEffect(() => {
|
||||||
pb.collection<SmartDeviceRecord>("smart_devices")
|
if (systemId) {
|
||||||
.getFullList({
|
pb.send<Record<string, SmartData>>("/api/beszel/smart", { query: { system: systemId } })
|
||||||
filter: systemId ? pb.filter("system = {:system}", { system: systemId }) : undefined,
|
.then((data) => {
|
||||||
fields: SMART_DEVICE_FIELDS,
|
setSmartData(data)
|
||||||
})
|
})
|
||||||
.then((records) => {
|
.catch(() => setSmartData({}))
|
||||||
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])
|
||||||
|
|
||||||
const handleRowRefresh = useCallback(
|
// Convert SmartData to DiskInfo, if no data use empty array
|
||||||
async (disk: DiskInfo) => {
|
const diskData = React.useMemo(() => {
|
||||||
if (!disk.system) return
|
return smartData ? convertSmartDataToDiskInfo(smartData) : []
|
||||||
setRowActionState({ type: "refresh", id: disk.id })
|
}, [smartData])
|
||||||
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: tableColumns,
|
columns: columns,
|
||||||
onSortingChange: setSorting,
|
onSortingChange: setSorting,
|
||||||
onColumnFiltersChange: setColumnFilters,
|
onColumnFiltersChange: setColumnFilters,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
@@ -486,26 +290,10 @@ 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))
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Hide the table on system pages if there's no data, but always show on global page
|
if (!diskData.length && !columnFilters.length) {
|
||||||
if (systemId && !diskData.length && !columnFilters.length) {
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -515,31 +303,21 @@ 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">S.M.A.R.T.</CardTitle>
|
<CardTitle className="mb-2">
|
||||||
|
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={globalFilter}
|
value={(table.getColumn("device")?.getFilterValue() as string) ?? ""}
|
||||||
onChange={(event) => setGlobalFilter(event.target.value)}
|
onChange={(event) =>
|
||||||
className="px-4 w-full max-w-full md:w-64"
|
table.getColumn("device")?.setFilterValue(event.target.value)
|
||||||
|
}
|
||||||
|
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">
|
||||||
@@ -550,7 +328,12 @@ 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 ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext()
|
||||||
|
)}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -568,19 +351,22 @@ 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(cell.column.columnDef.cell, cell.getContext())}
|
{flexRender(
|
||||||
|
cell.column.columnDef.cell,
|
||||||
|
cell.getContext()
|
||||||
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={tableColumns.length} className="h-24 text-center">
|
<TableCell
|
||||||
{smartDevices ? (
|
colSpan={columns.length}
|
||||||
t`No results.`
|
className="h-24 text-center"
|
||||||
) : (
|
>
|
||||||
<LoaderCircleIcon className="animate-spin size-10 opacity-60 mx-auto" />
|
{smartData ? t`No results.` : <LoaderCircleIcon className="animate-spin size-10 opacity-60 mx-auto" />}
|
||||||
)}
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
@@ -588,53 +374,28 @@ export default function DisksTable({ systemId }: { systemId?: string }) {
|
|||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
<DiskSheet diskId={activeDiskId} open={sheetOpen} onOpenChange={setSheetOpen} />
|
<DiskSheet disk={activeDisk} smartData={smartData?.[activeDisk?.serialNumber ?? ""]} open={sheetOpen} onOpenChange={setSheetOpen} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function DiskSheet({
|
function DiskSheet({ disk, smartData, open, onOpenChange }: { disk: DiskInfo | null; smartData?: SmartData; open: boolean; onOpenChange: (open: boolean) => void }) {
|
||||||
diskId,
|
if (!disk) return null
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
}: {
|
|
||||||
diskId: string | null
|
|
||||||
open: boolean
|
|
||||||
onOpenChange: (open: boolean) => void
|
|
||||||
}) {
|
|
||||||
const [disk, setDisk] = useState<SmartDeviceRecord | null>(null)
|
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
|
||||||
|
|
||||||
// Fetch full device record (including attributes) when sheet opens
|
const smartAttributes = smartData?.a || []
|
||||||
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 = useMemo(() => {
|
const visibleColumns = React.useMemo(() => {
|
||||||
return smartColumns.filter((column) => {
|
return smartColumns.filter(column => {
|
||||||
const accessorKey = "accessorKey" in column ? (column.accessorKey as keyof SmartAttribute | undefined) : undefined
|
const accessorKey = (column as any).accessorKey as keyof SmartAttribute
|
||||||
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
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -646,60 +407,27 @@ function DiskSheet({
|
|||||||
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>
|
<SheetTitle><Trans>S.M.A.R.T. Details</Trans> - {disk.device}</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">
|
||||||
{model}
|
{disk.model} <Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
|
||||||
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
|
{disk.serialNumber}
|
||||||
{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">
|
||||||
{isLoading ? (
|
|
||||||
<div className="flex justify-center py-8">
|
|
||||||
<LoaderCircleIcon className="animate-spin size-10 opacity-60" />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Alert className="pb-3">
|
<Alert className="pb-3">
|
||||||
{status === "PASSED" ? <CheckCircle2Icon className="size-4" /> : <XCircleIcon className="size-4" />}
|
{smartData?.s === "PASSED" ? (
|
||||||
<AlertTitle>
|
<CheckCircle2Icon className="size-4" />
|
||||||
<Trans>S.M.A.R.T. Self-Test</Trans>: {status}
|
) : (
|
||||||
</AlertTitle>
|
<XCircleIcon className="size-4" />
|
||||||
|
)}
|
||||||
|
<AlertTitle><Trans>S.M.A.R.T. Self-Test</Trans>: {smartData?.s}</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>
|
||||||
@@ -713,7 +441,10 @@ function DiskSheet({
|
|||||||
<TableHead key={header.id}>
|
<TableHead key={header.id}>
|
||||||
{header.isPlaceholder
|
{header.isPlaceholder
|
||||||
? null
|
? null
|
||||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
: flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext()
|
||||||
|
)}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -722,17 +453,23 @@ function DiskSheet({
|
|||||||
<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 key={row.id} className={isFailedAttribute ? "text-red-600 dark:text-red-400" : ""}>
|
<TableRow
|
||||||
|
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(cell.column.columnDef.cell, cell.getContext())}
|
{flexRender(
|
||||||
|
cell.column.columnDef.cell,
|
||||||
|
cell.getContext()
|
||||||
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
@@ -742,8 +479,6 @@ function DiskSheet({
|
|||||||
<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>
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/** 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"
|
||||||
@@ -219,7 +218,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,
|
||||||
@@ -292,10 +291,7 @@ export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<Syste
|
|||||||
[STATUS_COLORS[SystemStatus.Up]]: numFailed === 0,
|
[STATUS_COLORS[SystemStatus.Up]]: numFailed === 0,
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
{totalCount}{" "}
|
{totalCount} <span className="text-muted-foreground text-sm -ms-0.5">({t`Failed`.toLowerCase()}: {numFailed})</span>
|
||||||
<span className="text-muted-foreground text-sm -ms-0.5">
|
|
||||||
({t`Failed`.toLowerCase()}: {numFailed})
|
|
||||||
</span>
|
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -399,6 +395,7 @@ 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)
|
||||||
}
|
}
|
||||||
@@ -408,9 +405,10 @@ 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 getIndicatorColor(pct: number) {
|
function getMeterClass(pct: number) {
|
||||||
const threshold = getMeterState(pct)
|
const threshold = getMeterState(pct)
|
||||||
return (
|
return cn(
|
||||||
|
"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) ||
|
||||||
@@ -418,50 +416,28 @@ 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
|
<Link href={getPagePath($router, "system", { id })} tabIndex={-1} className="flex flex-col gap-0.5 w-full relative z-10">
|
||||||
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 flex items-center gap-0.5 px-1 justify-end bg-muted h-[1em] rounded-sm overflow-hidden relative">
|
<span className="flex-1 min-w-8 grid bg-muted h-[1em] rounded-sm overflow-hidden">
|
||||||
{/* Root disk */}
|
{/* Root disk */}
|
||||||
<span
|
<span className={getMeterClass(rootDiskPct)} style={{ width: `${rootDiskPct}%` }}></span>
|
||||||
className={cn("absolute inset-0", getMeterClass(rootDiskPct))}
|
{/* Extra disks */}
|
||||||
style={{ width: `${rootDiskPct}%` }}
|
{extraFs.map(([_name, pct], index) => (
|
||||||
></span>
|
<span key={index} className={getMeterClass(pct)} style={{ width: `${pct}%` }}></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">
|
<div className="grid gap-1.5">
|
||||||
<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">
|
<div className="text-[0.65rem] text-muted-foreground uppercase tracking-wide tabular-nums"><Trans context="Root disk label">Root</Trans></div>
|
||||||
<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">
|
||||||
@@ -472,9 +448,7 @@ 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">
|
<div className="text-[0.65rem] max-w-40 text-muted-foreground uppercase tracking-wide truncate">{name}</div>
|
||||||
{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">
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ export default function SystemsTable() {
|
|||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
aria-label={t`Clear`}
|
aria-label="Clear filter"
|
||||||
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("")}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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: 1500px;
|
--container: 1480px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
@@ -117,6 +117,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
|
|
||||||
/* Fonts */
|
/* Fonts */
|
||||||
@supports (font-variation-settings: normal) {
|
@supports (font-variation-settings: normal) {
|
||||||
:root {
|
:root {
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ 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"))
|
||||||
|
|
||||||
@@ -63,8 +62,6 @@ 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 />
|
||||||
}
|
}
|
||||||
@@ -100,7 +97,7 @@ const Layout = () => {
|
|||||||
<LoginPage />
|
<LoginPage />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ "--container": `${userSettings.layoutWidth ?? 1500}px` } as React.CSSProperties}>
|
<div style={{"--container": `${userSettings.layoutWidth ?? 1480}px`} as React.CSSProperties}>
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<Navbar />
|
<Navbar />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
17
internal/site/src/types.d.ts
vendored
17
internal/site/src/types.d.ts
vendored
@@ -377,23 +377,6 @@ 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
|
||||||
|
|||||||
Reference in New Issue
Block a user