mirror of
https://github.com/henrygd/beszel.git
synced 2026-04-25 05:51:49 +02:00
Compare commits
5 Commits
svenvg93-f
...
8a2bee11d4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a2bee11d4 | ||
|
|
485f7d16ff | ||
|
|
46fdc94cb8 | ||
|
|
261f7fb76c | ||
|
|
18d9258907 |
@@ -7,20 +7,20 @@ replace github.com/nicholas-fedor/shoutrrr => github.com/nicholas-fedor/shoutrrr
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/blang/semver v3.5.1+incompatible
|
github.com/blang/semver v3.5.1+incompatible
|
||||||
github.com/fxamacker/cbor/v2 v2.8.0
|
github.com/fxamacker/cbor/v2 v2.9.0
|
||||||
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.8.15
|
github.com/nicholas-fedor/shoutrrr v0.8.15
|
||||||
github.com/pocketbase/dbx v1.11.0
|
github.com/pocketbase/dbx v1.11.0
|
||||||
github.com/pocketbase/pocketbase v0.28.4
|
github.com/pocketbase/pocketbase v0.29.0
|
||||||
github.com/rhysd/go-github-selfupdate v1.2.3
|
github.com/rhysd/go-github-selfupdate v1.2.3
|
||||||
github.com/shirou/gopsutil/v4 v4.25.6
|
github.com/shirou/gopsutil/v4 v4.25.6
|
||||||
github.com/spf13/cast v1.9.2
|
github.com/spf13/cast v1.9.2
|
||||||
github.com/spf13/cobra v1.9.1
|
github.com/spf13/cobra v1.9.1
|
||||||
github.com/stretchr/testify v1.10.0
|
github.com/stretchr/testify v1.10.0
|
||||||
golang.org/x/crypto v0.39.0
|
golang.org/x/crypto v0.40.0
|
||||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b
|
golang.org/x/exp v0.0.0-20250718183923-645b1fa84792
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ require (
|
|||||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||||
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect
|
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect
|
||||||
github.com/go-sql-driver/mysql v1.9.1 // indirect
|
github.com/go-sql-driver/mysql v1.9.1 // indirect
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
|
github.com/golang-jwt/jwt/v5 v5.2.3 // indirect
|
||||||
github.com/google/go-github/v30 v30.1.0 // indirect
|
github.com/google/go-github/v30 v30.1.0 // indirect
|
||||||
github.com/google/go-querystring v1.1.0 // indirect
|
github.com/google/go-querystring v1.1.0 // indirect
|
||||||
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf // indirect
|
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf // indirect
|
||||||
@@ -52,19 +52,19 @@ require (
|
|||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/spf13/pflag v1.0.6 // indirect
|
github.com/spf13/pflag v1.0.7 // indirect
|
||||||
github.com/tcnksm/go-gitconfig v0.1.2 // indirect
|
github.com/tcnksm/go-gitconfig v0.1.2 // indirect
|
||||||
github.com/tklauser/go-sysconf v0.3.15 // indirect
|
github.com/tklauser/go-sysconf v0.3.15 // indirect
|
||||||
github.com/tklauser/numcpus v0.10.0 // indirect
|
github.com/tklauser/numcpus v0.10.0 // indirect
|
||||||
github.com/ulikunitz/xz v0.5.12 // indirect
|
github.com/ulikunitz/xz v0.5.12 // indirect
|
||||||
github.com/x448/float16 v0.8.4 // indirect
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
golang.org/x/image v0.28.0 // indirect
|
golang.org/x/image v0.29.0 // indirect
|
||||||
golang.org/x/net v0.41.0 // indirect
|
golang.org/x/net v0.42.0 // indirect
|
||||||
golang.org/x/oauth2 v0.30.0 // indirect
|
golang.org/x/oauth2 v0.30.0 // indirect
|
||||||
golang.org/x/sync v0.15.0 // indirect
|
golang.org/x/sync v0.16.0 // indirect
|
||||||
golang.org/x/sys v0.33.0 // indirect
|
golang.org/x/sys v0.34.0 // indirect
|
||||||
golang.org/x/text v0.26.0 // indirect
|
golang.org/x/text v0.27.0 // indirect
|
||||||
modernc.org/libc v1.65.10 // indirect
|
modernc.org/libc v1.65.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
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/
|
|||||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||||
github.com/fxamacker/cbor/v2 v2.8.0 h1:fFtUGXUzXPHTIUdne5+zzMPTfffl3RD5qYnkY40vtxU=
|
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||||
github.com/fxamacker/cbor/v2 v2.8.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
|
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
|
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
|
||||||
github.com/ganigeorgiev/fexpr v0.5.0 h1:XA9JxtTE/Xm+g/JFI6RfZEHSiQlk+1glLvRK1Lpv/Tk=
|
github.com/ganigeorgiev/fexpr v0.5.0 h1:XA9JxtTE/Xm+g/JFI6RfZEHSiQlk+1glLvRK1Lpv/Tk=
|
||||||
@@ -46,8 +46,8 @@ github.com/go-sql-driver/mysql v1.9.1 h1:FrjNGn/BsJQjVRuSa8CBrM5BWA9BWoXXat3KrtS
|
|||||||
github.com/go-sql-driver/mysql v1.9.1/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
github.com/go-sql-driver/mysql v1.9.1/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||||
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||||
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0=
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
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/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
@@ -103,8 +103,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.28.4 h1:RmhWXDcfKrFM9/W0G0Zrlv4eKBM8/s/v4SQKytjgD20=
|
github.com/pocketbase/pocketbase v0.29.0 h1:oL6qvkU2QSybClVtQdaq9Z1F3Wk59iKYCfIaf1R8KUs=
|
||||||
github.com/pocketbase/pocketbase v0.28.4/go.mod h1:jSuN93vE/oeJVOz2D2ZxcYyr2bYNmDOMCUkM+JhyJQ0=
|
github.com/pocketbase/pocketbase v0.29.0/go.mod h1:SqyH7o/3e+/uLySATlJqxH4S8gyU6R0adG56ZSV1vuU=
|
||||||
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=
|
||||||
@@ -120,8 +120,9 @@ github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE=
|
|||||||
github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||||
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
|
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
|
||||||
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
|
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
|
||||||
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
|
||||||
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M=
|
||||||
|
github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
@@ -143,29 +144,29 @@ go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
|
|||||||
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
|
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
|
||||||
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.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
|
golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
|
||||||
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
|
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
|
||||||
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
|
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
|
||||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
golang.org/x/exp v0.0.0-20250718183923-645b1fa84792 h1:R9PFI6EUdfVKgwKjZef7QIwGcBKu86OEFpJ9nUEP2l4=
|
||||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
golang.org/x/exp v0.0.0-20250718183923-645b1fa84792/go.mod h1:A+z0yzpGtvnG90cToK5n2tu8UJVP2XUATh+r+sfOOOc=
|
||||||
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.28.0 h1:gdem5JW1OLS4FbkWgLO+7ZeFzYtL3xClb97GaUzYMFE=
|
golang.org/x/image v0.29.0 h1:HcdsyR4Gsuys/Axh0rDEmlBmB68rW1U9BUdB3UVHsas=
|
||||||
golang.org/x/image v0.28.0/go.mod h1:GUJYXtnGKEUgggyzh+Vxt+AviiCcyiwpsl8iQ8MvwGY=
|
golang.org/x/image v0.29.0/go.mod h1:RVJROnf3SLK8d26OW91j4FrIHGbsJ8QnbEocVTOWQDA=
|
||||||
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
|
golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg=
|
||||||
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ=
|
||||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||||
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
|
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
|
||||||
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
|
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
|
||||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||||
golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||||
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||||
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
||||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
|
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||||
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
@@ -173,19 +174,19 @@ golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7w
|
|||||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
|
||||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
||||||
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
|
golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg=
|
||||||
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
|
golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||||
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
|
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
|
||||||
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
|
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0=
|
||||||
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||||
google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||||
@@ -201,16 +202,20 @@ gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
|||||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s=
|
modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM=
|
||||||
modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
|
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
|
||||||
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
|
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
|
||||||
modernc.org/fileutil v1.3.3 h1:3qaU+7f7xxTUmvU1pJTZiDLAIoJVdUSSauJNHg9yXoA=
|
modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM=
|
||||||
modernc.org/fileutil v1.3.3/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
modernc.org/libc v1.65.10 h1:ZwEk8+jhW7qBjHIT+wd0d9VjitRyQef9BnzlzGwMODc=
|
modernc.org/libc v1.65.10 h1:ZwEk8+jhW7qBjHIT+wd0d9VjitRyQef9BnzlzGwMODc=
|
||||||
modernc.org/libc v1.65.10/go.mod h1:StFvYpx7i/mXtBAfVOjaU0PWZOvIRoZSgXhrwXzr8Po=
|
modernc.org/libc v1.65.10/go.mod h1:StFvYpx7i/mXtBAfVOjaU0PWZOvIRoZSgXhrwXzr8Po=
|
||||||
|
modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
|
||||||
|
modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
|
||||||
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=
|
||||||
|
|||||||
391
beszel/internal/agent/client_test.go
Normal file
391
beszel/internal/agent/client_test.go
Normal file
@@ -0,0 +1,391 @@
|
|||||||
|
//go:build testing
|
||||||
|
// +build testing
|
||||||
|
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"beszel"
|
||||||
|
"beszel/internal/common"
|
||||||
|
"crypto/ed25519"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/fxamacker/cbor/v2"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestNewWebSocketClient tests WebSocket client creation
|
||||||
|
func TestNewWebSocketClient(t *testing.T) {
|
||||||
|
agent := createTestAgent(t)
|
||||||
|
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
hubURL string
|
||||||
|
token string
|
||||||
|
expectError bool
|
||||||
|
errorMsg string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid configuration",
|
||||||
|
hubURL: "http://localhost:8080",
|
||||||
|
token: "test-token-123",
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid https URL",
|
||||||
|
hubURL: "https://hub.example.com",
|
||||||
|
token: "secure-token",
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing hub URL",
|
||||||
|
hubURL: "",
|
||||||
|
token: "test-token",
|
||||||
|
expectError: true,
|
||||||
|
errorMsg: "HUB_URL environment variable not set",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid URL",
|
||||||
|
hubURL: "ht\ttp://invalid",
|
||||||
|
token: "test-token",
|
||||||
|
expectError: true,
|
||||||
|
errorMsg: "invalid hub URL",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing token",
|
||||||
|
hubURL: "http://localhost:8080",
|
||||||
|
token: "",
|
||||||
|
expectError: true,
|
||||||
|
errorMsg: "TOKEN environment variable not set",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
// Set up environment
|
||||||
|
if tc.hubURL != "" {
|
||||||
|
os.Setenv("BESZEL_AGENT_HUB_URL", tc.hubURL)
|
||||||
|
} else {
|
||||||
|
os.Unsetenv("BESZEL_AGENT_HUB_URL")
|
||||||
|
}
|
||||||
|
if tc.token != "" {
|
||||||
|
os.Setenv("BESZEL_AGENT_TOKEN", tc.token)
|
||||||
|
} else {
|
||||||
|
os.Unsetenv("BESZEL_AGENT_TOKEN")
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
os.Unsetenv("BESZEL_AGENT_HUB_URL")
|
||||||
|
os.Unsetenv("BESZEL_AGENT_TOKEN")
|
||||||
|
}()
|
||||||
|
|
||||||
|
client, err := newWebSocketClient(agent)
|
||||||
|
|
||||||
|
if tc.expectError {
|
||||||
|
assert.Error(t, err)
|
||||||
|
if err != nil && tc.errorMsg != "" {
|
||||||
|
assert.Contains(t, err.Error(), tc.errorMsg)
|
||||||
|
}
|
||||||
|
assert.Nil(t, client)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotNil(t, client)
|
||||||
|
assert.Equal(t, agent, client.agent)
|
||||||
|
assert.Equal(t, tc.token, client.token)
|
||||||
|
assert.Equal(t, tc.hubURL, client.hubURL.String())
|
||||||
|
assert.NotEmpty(t, client.fingerprint)
|
||||||
|
assert.NotNil(t, client.hubRequest)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebSocketClient_GetOptions tests WebSocket client options configuration
|
||||||
|
func TestWebSocketClient_GetOptions(t *testing.T) {
|
||||||
|
agent := createTestAgent(t)
|
||||||
|
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
inputURL string
|
||||||
|
expectedScheme string
|
||||||
|
expectedPath string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "http to ws conversion",
|
||||||
|
inputURL: "http://localhost:8080",
|
||||||
|
expectedScheme: "ws",
|
||||||
|
expectedPath: "/api/beszel/agent-connect",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "https to wss conversion",
|
||||||
|
inputURL: "https://hub.example.com",
|
||||||
|
expectedScheme: "wss",
|
||||||
|
expectedPath: "/api/beszel/agent-connect",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "existing path preservation",
|
||||||
|
inputURL: "http://localhost:8080/custom/path",
|
||||||
|
expectedScheme: "ws",
|
||||||
|
expectedPath: "/custom/path/api/beszel/agent-connect",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
// Set up environment
|
||||||
|
os.Setenv("BESZEL_AGENT_HUB_URL", tc.inputURL)
|
||||||
|
os.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
||||||
|
defer func() {
|
||||||
|
os.Unsetenv("BESZEL_AGENT_HUB_URL")
|
||||||
|
os.Unsetenv("BESZEL_AGENT_TOKEN")
|
||||||
|
}()
|
||||||
|
|
||||||
|
client, err := newWebSocketClient(agent)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
options := client.getOptions()
|
||||||
|
|
||||||
|
// Parse the WebSocket URL
|
||||||
|
wsURL, err := url.Parse(options.Addr)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, tc.expectedScheme, wsURL.Scheme)
|
||||||
|
assert.Equal(t, tc.expectedPath, wsURL.Path)
|
||||||
|
|
||||||
|
// Check headers
|
||||||
|
assert.Equal(t, "test-token", options.RequestHeader.Get("X-Token"))
|
||||||
|
assert.Equal(t, beszel.Version, options.RequestHeader.Get("X-Beszel"))
|
||||||
|
assert.Contains(t, options.RequestHeader.Get("User-Agent"), "Mozilla/5.0")
|
||||||
|
|
||||||
|
// Test options caching
|
||||||
|
options2 := client.getOptions()
|
||||||
|
assert.Same(t, options, options2, "Options should be cached")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebSocketClient_VerifySignature tests signature verification
|
||||||
|
func TestWebSocketClient_VerifySignature(t *testing.T) {
|
||||||
|
agent := createTestAgent(t)
|
||||||
|
|
||||||
|
// Generate test key pairs
|
||||||
|
_, goodPrivKey, err := ed25519.GenerateKey(nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
goodPubKey, err := ssh.NewPublicKey(goodPrivKey.Public().(ed25519.PublicKey))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, badPrivKey, err := ed25519.GenerateKey(nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
badPubKey, err := ssh.NewPublicKey(badPrivKey.Public().(ed25519.PublicKey))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Set up environment
|
||||||
|
os.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080")
|
||||||
|
os.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
||||||
|
defer func() {
|
||||||
|
os.Unsetenv("BESZEL_AGENT_HUB_URL")
|
||||||
|
os.Unsetenv("BESZEL_AGENT_TOKEN")
|
||||||
|
}()
|
||||||
|
|
||||||
|
client, err := newWebSocketClient(agent)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
keys []ssh.PublicKey
|
||||||
|
token string
|
||||||
|
signWith ed25519.PrivateKey
|
||||||
|
expectError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid signature with correct key",
|
||||||
|
keys: []ssh.PublicKey{goodPubKey},
|
||||||
|
token: "test-token",
|
||||||
|
signWith: goodPrivKey,
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid signature with wrong key",
|
||||||
|
keys: []ssh.PublicKey{goodPubKey},
|
||||||
|
token: "test-token",
|
||||||
|
signWith: badPrivKey,
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid signature with multiple keys",
|
||||||
|
keys: []ssh.PublicKey{badPubKey, goodPubKey},
|
||||||
|
token: "test-token",
|
||||||
|
signWith: goodPrivKey,
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no valid keys",
|
||||||
|
keys: []ssh.PublicKey{badPubKey},
|
||||||
|
token: "test-token",
|
||||||
|
signWith: goodPrivKey,
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
// Set up agent with test keys
|
||||||
|
agent.keys = tc.keys
|
||||||
|
client.token = tc.token
|
||||||
|
|
||||||
|
// Create signature
|
||||||
|
signature := ed25519.Sign(tc.signWith, []byte(tc.token))
|
||||||
|
|
||||||
|
err := client.verifySignature(signature)
|
||||||
|
|
||||||
|
if tc.expectError {
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "invalid signature")
|
||||||
|
} else {
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebSocketClient_HandleHubRequest tests hub request routing (basic verification logic)
|
||||||
|
func TestWebSocketClient_HandleHubRequest(t *testing.T) {
|
||||||
|
agent := createTestAgent(t)
|
||||||
|
|
||||||
|
// Set up environment
|
||||||
|
os.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080")
|
||||||
|
os.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
||||||
|
defer func() {
|
||||||
|
os.Unsetenv("BESZEL_AGENT_HUB_URL")
|
||||||
|
os.Unsetenv("BESZEL_AGENT_TOKEN")
|
||||||
|
}()
|
||||||
|
|
||||||
|
client, err := newWebSocketClient(agent)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
action common.WebSocketAction
|
||||||
|
hubVerified bool
|
||||||
|
expectError bool
|
||||||
|
errorMsg string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "CheckFingerprint without verification",
|
||||||
|
action: common.CheckFingerprint,
|
||||||
|
hubVerified: false,
|
||||||
|
expectError: false, // CheckFingerprint is allowed without verification
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "GetData without verification",
|
||||||
|
action: common.GetData,
|
||||||
|
hubVerified: false,
|
||||||
|
expectError: true,
|
||||||
|
errorMsg: "hub not verified",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
client.hubVerified = tc.hubVerified
|
||||||
|
|
||||||
|
// Create minimal request
|
||||||
|
hubRequest := &common.HubRequest[cbor.RawMessage]{
|
||||||
|
Action: tc.action,
|
||||||
|
Data: cbor.RawMessage{},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := client.handleHubRequest(hubRequest)
|
||||||
|
|
||||||
|
if tc.expectError {
|
||||||
|
assert.Error(t, err)
|
||||||
|
if tc.errorMsg != "" {
|
||||||
|
assert.Contains(t, err.Error(), tc.errorMsg)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For CheckFingerprint, we expect a decode error since we're not providing valid data,
|
||||||
|
// but it shouldn't be the "hub not verified" error
|
||||||
|
if err != nil && tc.errorMsg != "" {
|
||||||
|
assert.NotContains(t, err.Error(), tc.errorMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebSocketClient_GetUserAgent tests user agent generation
|
||||||
|
func TestGetUserAgent(t *testing.T) {
|
||||||
|
// Run multiple times to check both variants
|
||||||
|
userAgents := make(map[string]bool)
|
||||||
|
|
||||||
|
for range 20 {
|
||||||
|
ua := getUserAgent()
|
||||||
|
userAgents[ua] = true
|
||||||
|
|
||||||
|
// Check that it's a valid Mozilla user agent
|
||||||
|
assert.Contains(t, ua, "Mozilla/5.0")
|
||||||
|
assert.Contains(t, ua, "AppleWebKit/537.36")
|
||||||
|
assert.Contains(t, ua, "Chrome/124.0.0.0")
|
||||||
|
assert.Contains(t, ua, "Safari/537.36")
|
||||||
|
|
||||||
|
// Should contain either Windows or Mac
|
||||||
|
isWindows := strings.Contains(ua, "Windows NT 11.0")
|
||||||
|
isMac := strings.Contains(ua, "Macintosh; Intel Mac OS X 14_0_0")
|
||||||
|
assert.True(t, isWindows || isMac, "User agent should contain either Windows or Mac identifier")
|
||||||
|
}
|
||||||
|
|
||||||
|
// With enough iterations, we should see both variants
|
||||||
|
// though this might occasionally fail
|
||||||
|
if len(userAgents) == 1 {
|
||||||
|
t.Log("Note: Only one user agent variant was generated in this test run")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebSocketClient_Close tests connection closing
|
||||||
|
func TestWebSocketClient_Close(t *testing.T) {
|
||||||
|
agent := createTestAgent(t)
|
||||||
|
|
||||||
|
// Set up environment
|
||||||
|
os.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080")
|
||||||
|
os.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
||||||
|
defer func() {
|
||||||
|
os.Unsetenv("BESZEL_AGENT_HUB_URL")
|
||||||
|
os.Unsetenv("BESZEL_AGENT_TOKEN")
|
||||||
|
}()
|
||||||
|
|
||||||
|
client, err := newWebSocketClient(agent)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Test closing with nil connection (should not panic)
|
||||||
|
assert.NotPanics(t, func() {
|
||||||
|
client.Close()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebSocketClient_ConnectRateLimit tests connection rate limiting
|
||||||
|
func TestWebSocketClient_ConnectRateLimit(t *testing.T) {
|
||||||
|
agent := createTestAgent(t)
|
||||||
|
|
||||||
|
// Set up environment
|
||||||
|
os.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080")
|
||||||
|
os.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
||||||
|
defer func() {
|
||||||
|
os.Unsetenv("BESZEL_AGENT_HUB_URL")
|
||||||
|
os.Unsetenv("BESZEL_AGENT_TOKEN")
|
||||||
|
}()
|
||||||
|
|
||||||
|
client, err := newWebSocketClient(agent)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Set recent connection attempt
|
||||||
|
client.lastConnectAttempt = time.Now()
|
||||||
|
|
||||||
|
// Test that connection fails quickly due to rate limiting
|
||||||
|
// This won't actually connect but should fail fast
|
||||||
|
err = client.Connect()
|
||||||
|
assert.Error(t, err, "Connection should fail but not hang")
|
||||||
|
}
|
||||||
@@ -174,24 +174,27 @@ func (a *Agent) getSystemStats() system.Stats {
|
|||||||
a.initializeNetIoStats()
|
a.initializeNetIoStats()
|
||||||
}
|
}
|
||||||
if netIO, err := psutilNet.IOCounters(true); err == nil {
|
if netIO, err := psutilNet.IOCounters(true); err == nil {
|
||||||
secondsElapsed := time.Since(a.netIoStats.Time).Seconds()
|
msElapsed := uint64(time.Since(a.netIoStats.Time).Milliseconds())
|
||||||
a.netIoStats.Time = time.Now()
|
a.netIoStats.Time = time.Now()
|
||||||
bytesSent := uint64(0)
|
totalBytesSent := uint64(0)
|
||||||
bytesRecv := uint64(0)
|
totalBytesRecv := uint64(0)
|
||||||
// sum all bytes sent and received
|
// sum all bytes sent and received
|
||||||
for _, v := range netIO {
|
for _, v := range netIO {
|
||||||
// skip if not in valid network interfaces list
|
// skip if not in valid network interfaces list
|
||||||
if _, exists := a.netInterfaces[v.Name]; !exists {
|
if _, exists := a.netInterfaces[v.Name]; !exists {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
bytesSent += v.BytesSent
|
totalBytesSent += v.BytesSent
|
||||||
bytesRecv += v.BytesRecv
|
totalBytesRecv += v.BytesRecv
|
||||||
}
|
}
|
||||||
// add to systemStats
|
// add to systemStats
|
||||||
sentPerSecond := float64(bytesSent-a.netIoStats.BytesSent) / secondsElapsed
|
var bytesSentPerSecond, bytesRecvPerSecond uint64
|
||||||
recvPerSecond := float64(bytesRecv-a.netIoStats.BytesRecv) / secondsElapsed
|
if msElapsed > 0 {
|
||||||
networkSentPs := bytesToMegabytes(sentPerSecond)
|
bytesSentPerSecond = (totalBytesSent - a.netIoStats.BytesSent) * 1000 / msElapsed
|
||||||
networkRecvPs := bytesToMegabytes(recvPerSecond)
|
bytesRecvPerSecond = (totalBytesRecv - a.netIoStats.BytesRecv) * 1000 / msElapsed
|
||||||
|
}
|
||||||
|
networkSentPs := bytesToMegabytes(float64(bytesSentPerSecond))
|
||||||
|
networkRecvPs := bytesToMegabytes(float64(bytesRecvPerSecond))
|
||||||
// add check for issue (#150) where sent is a massive number
|
// add check for issue (#150) where sent is a massive number
|
||||||
if networkSentPs > 10_000 || networkRecvPs > 10_000 {
|
if networkSentPs > 10_000 || networkRecvPs > 10_000 {
|
||||||
slog.Warn("Invalid net stats. Resetting.", "sent", networkSentPs, "recv", networkRecvPs)
|
slog.Warn("Invalid net stats. Resetting.", "sent", networkSentPs, "recv", networkRecvPs)
|
||||||
@@ -206,9 +209,10 @@ func (a *Agent) getSystemStats() system.Stats {
|
|||||||
} else {
|
} else {
|
||||||
systemStats.NetworkSent = networkSentPs
|
systemStats.NetworkSent = networkSentPs
|
||||||
systemStats.NetworkRecv = networkRecvPs
|
systemStats.NetworkRecv = networkRecvPs
|
||||||
|
systemStats.Bandwidth[0], systemStats.Bandwidth[1] = bytesSentPerSecond, bytesRecvPerSecond
|
||||||
// update netIoStats
|
// update netIoStats
|
||||||
a.netIoStats.BytesSent = bytesSent
|
a.netIoStats.BytesSent = totalBytesSent
|
||||||
a.netIoStats.BytesRecv = bytesRecv
|
a.netIoStats.BytesRecv = totalBytesRecv
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,7 +261,9 @@ func (a *Agent) getSystemStats() system.Stats {
|
|||||||
a.systemInfo.MemPct = systemStats.MemPct
|
a.systemInfo.MemPct = systemStats.MemPct
|
||||||
a.systemInfo.DiskPct = systemStats.DiskPct
|
a.systemInfo.DiskPct = systemStats.DiskPct
|
||||||
a.systemInfo.Uptime, _ = host.Uptime()
|
a.systemInfo.Uptime, _ = host.Uptime()
|
||||||
|
// TODO: in future release, remove MB bandwidth values in favor of bytes
|
||||||
a.systemInfo.Bandwidth = twoDecimals(systemStats.NetworkSent + systemStats.NetworkRecv)
|
a.systemInfo.Bandwidth = twoDecimals(systemStats.NetworkSent + systemStats.NetworkRecv)
|
||||||
|
a.systemInfo.BandwidthBytes = systemStats.Bandwidth[0] + systemStats.Bandwidth[1]
|
||||||
slog.Debug("sysinfo", "data", a.systemInfo)
|
slog.Debug("sysinfo", "data", a.systemInfo)
|
||||||
|
|
||||||
return systemStats
|
return systemStats
|
||||||
|
|||||||
@@ -93,10 +93,18 @@ func NewAlertManager(app hubLike) *AlertManager {
|
|||||||
alertQueue: make(chan alertTask),
|
alertQueue: make(chan alertTask),
|
||||||
stopChan: make(chan struct{}),
|
stopChan: make(chan struct{}),
|
||||||
}
|
}
|
||||||
|
am.bindEvents()
|
||||||
go am.startWorker()
|
go am.startWorker()
|
||||||
return am
|
return am
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bind events to the alerts collection lifecycle
|
||||||
|
func (am *AlertManager) bindEvents() {
|
||||||
|
am.hub.OnRecordAfterUpdateSuccess("alerts").BindFunc(updateHistoryOnAlertUpdate)
|
||||||
|
am.hub.OnRecordAfterDeleteSuccess("alerts").BindFunc(resolveHistoryOnAlertDelete)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendAlert sends an alert to the user
|
||||||
func (am *AlertManager) SendAlert(data AlertMessageData) error {
|
func (am *AlertManager) SendAlert(data AlertMessageData) error {
|
||||||
// get user settings
|
// get user settings
|
||||||
record, err := am.hub.FindFirstRecordByFilter(
|
record, err := am.hub.FindFirstRecordByFilter(
|
||||||
|
|||||||
@@ -7,90 +7,79 @@ import (
|
|||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (am *AlertManager) RecordAlertHistory(alert SystemAlertData) {
|
// On triggered alert record delete, set matching alert history record to resolved
|
||||||
// Get alert, user, system, name, value
|
func resolveHistoryOnAlertDelete(e *core.RecordEvent) error {
|
||||||
alertId := alert.alertRecord.Id
|
if !e.Record.GetBool("triggered") {
|
||||||
userId := ""
|
return e.Next()
|
||||||
if errs := am.hub.ExpandRecord(alert.alertRecord, []string{"user"}, nil); len(errs) == 0 {
|
|
||||||
if user := alert.alertRecord.ExpandedOne("user"); user != nil {
|
|
||||||
userId = user.Id
|
|
||||||
}
|
}
|
||||||
}
|
_ = resolveAlertHistoryRecord(e.App, e.Record)
|
||||||
systemId := alert.systemRecord.Id
|
return e.Next()
|
||||||
name := alert.name
|
}
|
||||||
value := alert.val
|
|
||||||
now := time.Now().UTC()
|
|
||||||
|
|
||||||
if alert.triggered {
|
// On alert record update, update alert history record
|
||||||
// Create new alerts_history record
|
func updateHistoryOnAlertUpdate(e *core.RecordEvent) error {
|
||||||
collection, err := am.hub.FindCollectionByNameOrId("alerts_history")
|
original := e.Record.Original()
|
||||||
if err == nil {
|
new := e.Record
|
||||||
history := core.NewRecord(collection)
|
|
||||||
history.Set("alert", alertId)
|
originalTriggered := original.GetBool("triggered")
|
||||||
history.Set("user", userId)
|
newTriggered := new.GetBool("triggered")
|
||||||
history.Set("system", systemId)
|
|
||||||
history.Set("name", name)
|
// no need to update alert history if triggered state has not changed
|
||||||
history.Set("value", value)
|
if originalTriggered == newTriggered {
|
||||||
history.Set("state", "active")
|
return e.Next()
|
||||||
history.Set("created_date", now)
|
|
||||||
history.Set("solved_date", nil)
|
|
||||||
_ = am.hub.Save(history)
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// Find latest active alerts_history record for this alert and set to solved
|
// if new state is triggered, create new alert history record
|
||||||
record, err := am.hub.FindFirstRecordByFilter(
|
if newTriggered {
|
||||||
|
_, _ = createAlertHistoryRecord(e.App, new)
|
||||||
|
return e.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
// if new state is not triggered, check for matching alert history record and set it to resolved
|
||||||
|
_ = resolveAlertHistoryRecord(e.App, new)
|
||||||
|
return e.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveAlertHistoryRecord sets the resolved field to the current time
|
||||||
|
func resolveAlertHistoryRecord(app core.App, alertRecord *core.Record) error {
|
||||||
|
alertHistoryRecords, err := app.FindRecordsByFilter(
|
||||||
"alerts_history",
|
"alerts_history",
|
||||||
"alert={:alert} && state='active'",
|
"alert_id={:alert_id} && resolved=null",
|
||||||
dbx.Params{"alert": alertId},
|
"-created",
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
dbx.Params{"alert_id": alertRecord.Id},
|
||||||
)
|
)
|
||||||
if err == nil && record != nil {
|
|
||||||
record.Set("state", "solved")
|
|
||||||
record.Set("solved_date", now)
|
|
||||||
_ = am.hub.Save(record)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteOldAlertHistory deletes alerts_history records older than the given retention duration
|
|
||||||
func (am *AlertManager) DeleteOldAlertHistory(retention time.Duration) {
|
|
||||||
now := time.Now().UTC()
|
|
||||||
cutoff := now.Add(-retention)
|
|
||||||
_, err := am.hub.DB().NewQuery(
|
|
||||||
"DELETE FROM alerts_history WHERE solved_date IS NOT NULL AND solved_date < {:cutoff}",
|
|
||||||
).Bind(dbx.Params{"cutoff": cutoff}).Execute()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
am.hub.Logger().Error("failed to delete old alerts_history records", "error", err)
|
return err
|
||||||
}
|
}
|
||||||
}
|
if len(alertHistoryRecords) == 0 {
|
||||||
|
return nil
|
||||||
// Helper to get retention duration from user settings
|
|
||||||
func getAlertHistoryRetention(settings map[string]interface{}) time.Duration {
|
|
||||||
retStr, _ := settings["alertHistoryRetention"].(string)
|
|
||||||
switch retStr {
|
|
||||||
case "1m":
|
|
||||||
return 30 * 24 * time.Hour
|
|
||||||
case "3m":
|
|
||||||
return 90 * 24 * time.Hour
|
|
||||||
case "6m":
|
|
||||||
return 180 * 24 * time.Hour
|
|
||||||
case "1y":
|
|
||||||
return 365 * 24 * time.Hour
|
|
||||||
default:
|
|
||||||
return 90 * 24 * time.Hour // default 3 months
|
|
||||||
}
|
}
|
||||||
}
|
alertHistoryRecord := alertHistoryRecords[0] // there should be only one record
|
||||||
|
alertHistoryRecord.Set("resolved", time.Now().UTC())
|
||||||
// CleanUpAllAlertHistory deletes old alerts_history records for each user based on their retention setting
|
err = app.Save(alertHistoryRecord)
|
||||||
func (am *AlertManager) CleanUpAllAlertHistory() {
|
|
||||||
records, err := am.hub.FindAllRecords("user_settings")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
app.Logger().Error("Failed to resolve alert history", "err", err)
|
||||||
}
|
|
||||||
for _, record := range records {
|
|
||||||
var settings map[string]interface{}
|
|
||||||
if err := record.UnmarshalJSONField("settings", &settings); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
am.DeleteOldAlertHistory(getAlertHistoryRetention(settings))
|
|
||||||
}
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// createAlertHistoryRecord creates a new alert history record
|
||||||
|
func createAlertHistoryRecord(app core.App, alertRecord *core.Record) (alertHistoryRecord *core.Record, err error) {
|
||||||
|
alertHistoryCollection, err := app.FindCachedCollectionByNameOrId("alerts_history")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
alertHistoryRecord = core.NewRecord(alertHistoryCollection)
|
||||||
|
alertHistoryRecord.Set("alert_id", alertRecord.Id)
|
||||||
|
alertHistoryRecord.Set("user", alertRecord.GetString("user"))
|
||||||
|
alertHistoryRecord.Set("system", alertRecord.GetString("system"))
|
||||||
|
alertHistoryRecord.Set("name", alertRecord.GetString("name"))
|
||||||
|
alertHistoryRecord.Set("value", alertRecord.GetFloat("value"))
|
||||||
|
err = app.Save(alertHistoryRecord)
|
||||||
|
if err != nil {
|
||||||
|
app.Logger().Error("Failed to save alert history", "err", err)
|
||||||
|
}
|
||||||
|
return alertHistoryRecord, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,6 +136,14 @@ func (am *AlertManager) handleSystemUp(systemName string, alertRecords []*core.R
|
|||||||
|
|
||||||
// sendStatusAlert sends a status alert ("up" or "down") to the users associated with the alert records.
|
// sendStatusAlert sends a status alert ("up" or "down") to the users associated with the alert records.
|
||||||
func (am *AlertManager) sendStatusAlert(alertStatus string, systemName string, alertRecord *core.Record) error {
|
func (am *AlertManager) sendStatusAlert(alertStatus string, systemName string, alertRecord *core.Record) error {
|
||||||
|
switch alertStatus {
|
||||||
|
case "up":
|
||||||
|
alertRecord.Set("triggered", false)
|
||||||
|
case "down":
|
||||||
|
alertRecord.Set("triggered", true)
|
||||||
|
}
|
||||||
|
am.hub.Save(alertRecord)
|
||||||
|
|
||||||
var emoji string
|
var emoji string
|
||||||
if alertStatus == "up" {
|
if alertStatus == "up" {
|
||||||
emoji = "\u2705" // Green checkmark emoji
|
emoji = "\u2705" // Green checkmark emoji
|
||||||
@@ -146,16 +154,16 @@ func (am *AlertManager) sendStatusAlert(alertStatus string, systemName string, a
|
|||||||
title := fmt.Sprintf("Connection to %s is %s %v", systemName, alertStatus, emoji)
|
title := fmt.Sprintf("Connection to %s is %s %v", systemName, alertStatus, emoji)
|
||||||
message := strings.TrimSuffix(title, emoji)
|
message := strings.TrimSuffix(title, emoji)
|
||||||
|
|
||||||
if errs := am.hub.ExpandRecord(alertRecord, []string{"user"}, nil); len(errs) > 0 {
|
// if errs := am.hub.ExpandRecord(alertRecord, []string{"user"}, nil); len(errs) > 0 {
|
||||||
return errs["user"]
|
// return errs["user"]
|
||||||
}
|
// }
|
||||||
user := alertRecord.ExpandedOne("user")
|
// user := alertRecord.ExpandedOne("user")
|
||||||
if user == nil {
|
// if user == nil {
|
||||||
return nil
|
// return nil
|
||||||
}
|
// }
|
||||||
|
|
||||||
return am.SendAlert(AlertMessageData{
|
return am.SendAlert(AlertMessageData{
|
||||||
UserID: user.Id,
|
UserID: alertRecord.GetString("user"),
|
||||||
Title: title,
|
Title: title,
|
||||||
Message: message,
|
Message: message,
|
||||||
Link: am.hub.MakeLink("system", systemName),
|
Link: am.hub.MakeLink("system", systemName),
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import (
|
|||||||
|
|
||||||
func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *system.CombinedData) error {
|
func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *system.CombinedData) error {
|
||||||
alertRecords, err := am.hub.FindAllRecords("alerts",
|
alertRecords, err := am.hub.FindAllRecords("alerts",
|
||||||
dbx.NewExp("system={:system}", dbx.Params{"system": systemRecord.Id}),
|
dbx.NewExp("system={:system} AND name!='Status'", dbx.Params{"system": systemRecord.Id}),
|
||||||
)
|
)
|
||||||
if err != nil || len(alertRecords) == 0 {
|
if err != nil || len(alertRecords) == 0 {
|
||||||
// log.Println("no alerts found for system")
|
// log.Println("no alerts found for system")
|
||||||
@@ -293,10 +293,6 @@ func (am *AlertManager) sendSystemAlert(alert SystemAlertData) {
|
|||||||
// app.Logger().Error("failed to save alert record", "err", err)
|
// app.Logger().Error("failed to save alert record", "err", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create Alert History
|
|
||||||
am.RecordAlertHistory(alert)
|
|
||||||
|
|
||||||
// expand the user relation and send the alert
|
// expand the user relation and send the alert
|
||||||
if errs := am.hub.ExpandRecord(alert.alertRecord, []string{"user"}, nil); len(errs) > 0 {
|
if errs := am.hub.ExpandRecord(alert.alertRecord, []string{"user"}, nil); len(errs) > 0 {
|
||||||
// app.Logger().Error("failed to expand user relation", "errs", errs)
|
// app.Logger().Error("failed to expand user relation", "errs", errs)
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ type Stats struct {
|
|||||||
LoadAvg1 float64 `json:"l1,omitempty" cbor:"23,keyasint,omitempty,omitzero"`
|
LoadAvg1 float64 `json:"l1,omitempty" cbor:"23,keyasint,omitempty,omitzero"`
|
||||||
LoadAvg5 float64 `json:"l5,omitempty" cbor:"24,keyasint,omitempty,omitzero"`
|
LoadAvg5 float64 `json:"l5,omitempty" cbor:"24,keyasint,omitempty,omitzero"`
|
||||||
LoadAvg15 float64 `json:"l15,omitempty" cbor:"25,keyasint,omitempty,omitzero"`
|
LoadAvg15 float64 `json:"l15,omitempty" cbor:"25,keyasint,omitempty,omitzero"`
|
||||||
|
Bandwidth [2]uint64 `json:"b,omitzero" cbor:"26,keyasint,omitzero"` // [sent bytes, recv bytes]
|
||||||
|
MaxBandwidth [2]uint64 `json:"bm,omitzero" cbor:"27,keyasint,omitzero"` // [sent bytes, recv bytes]
|
||||||
}
|
}
|
||||||
|
|
||||||
type GPUData struct {
|
type GPUData struct {
|
||||||
@@ -95,6 +97,7 @@ type Info struct {
|
|||||||
LoadAvg1 float64 `json:"l1,omitempty" cbor:"15,keyasint,omitempty"`
|
LoadAvg1 float64 `json:"l1,omitempty" cbor:"15,keyasint,omitempty"`
|
||||||
LoadAvg5 float64 `json:"l5,omitempty" cbor:"16,keyasint,omitempty"`
|
LoadAvg5 float64 `json:"l5,omitempty" cbor:"16,keyasint,omitempty"`
|
||||||
LoadAvg15 float64 `json:"l15,omitempty" cbor:"17,keyasint,omitempty"`
|
LoadAvg15 float64 `json:"l15,omitempty" cbor:"17,keyasint,omitempty"`
|
||||||
|
BandwidthBytes uint64 `json:"bb" cbor:"18,keyasint"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Final data structure to return to the hub
|
// Final data structure to return to the hub
|
||||||
|
|||||||
@@ -215,13 +215,10 @@ func (h *Hub) startServer(se *core.ServeEvent) error {
|
|||||||
|
|
||||||
// registerCronJobs sets up scheduled tasks
|
// registerCronJobs sets up scheduled tasks
|
||||||
func (h *Hub) registerCronJobs(_ *core.ServeEvent) error {
|
func (h *Hub) registerCronJobs(_ *core.ServeEvent) error {
|
||||||
// delete old records once every hour
|
// delete old system_stats and alerts_history records once every hour
|
||||||
h.Cron().MustAdd("delete old records", "8 * * * *", h.rm.DeleteOldRecords)
|
h.Cron().MustAdd("delete old records", "8 * * * *", h.rm.DeleteOldRecords)
|
||||||
// create longer records every 10 minutes
|
// create longer records every 10 minutes
|
||||||
h.Cron().MustAdd("create longer records", "*/10 * * * *", h.rm.CreateLongerRecords)
|
h.Cron().MustAdd("create longer records", "*/10 * * * *", h.rm.CreateLongerRecords)
|
||||||
// delete old alert history records for each user based on their retention setting
|
|
||||||
h.Cron().MustAdd("delete old alerts_history", "5 */1 * * *", h.AlertManager.CleanUpAllAlertHistory)
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -159,8 +159,10 @@ func (sm *SystemManager) onRecordUpdate(e *core.RecordEvent) error {
|
|||||||
// - down: Triggers status change alerts
|
// - down: Triggers status change alerts
|
||||||
func (sm *SystemManager) onRecordAfterUpdateSuccess(e *core.RecordEvent) error {
|
func (sm *SystemManager) onRecordAfterUpdateSuccess(e *core.RecordEvent) error {
|
||||||
newStatus := e.Record.GetString("status")
|
newStatus := e.Record.GetString("status")
|
||||||
|
prevStatus := pending
|
||||||
system, ok := sm.systems.GetOk(e.Record.Id)
|
system, ok := sm.systems.GetOk(e.Record.Id)
|
||||||
if ok {
|
if ok {
|
||||||
|
prevStatus = system.Status
|
||||||
system.Status = newStatus
|
system.Status = newStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,6 +184,7 @@ func (sm *SystemManager) onRecordAfterUpdateSuccess(e *core.RecordEvent) error {
|
|||||||
if err := sm.AddRecord(e.Record, nil); err != nil {
|
if err := sm.AddRecord(e.Record, nil); err != nil {
|
||||||
e.App.Logger().Error("Error adding record", "err", err)
|
e.App.Logger().Error("Error adding record", "err", err)
|
||||||
}
|
}
|
||||||
|
_ = deactivateAlerts(e.App, e.Record.Id)
|
||||||
return e.Next()
|
return e.Next()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,8 +193,6 @@ func (sm *SystemManager) onRecordAfterUpdateSuccess(e *core.RecordEvent) error {
|
|||||||
return sm.AddRecord(e.Record, nil)
|
return sm.AddRecord(e.Record, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
prevStatus := system.Status
|
|
||||||
|
|
||||||
// Trigger system alerts when system comes online
|
// Trigger system alerts when system comes online
|
||||||
if newStatus == up {
|
if newStatus == up {
|
||||||
if err := sm.hub.HandleSystemAlerts(e.Record, system.data); err != nil {
|
if err := sm.hub.HandleSystemAlerts(e.Record, system.data); err != nil {
|
||||||
|
|||||||
@@ -114,6 +114,9 @@ func (ws *WsConn) Ping() error {
|
|||||||
|
|
||||||
// sendMessage encodes data to CBOR and sends it as a binary message to the agent.
|
// sendMessage encodes data to CBOR and sends it as a binary message to the agent.
|
||||||
func (ws *WsConn) sendMessage(data common.HubRequest[any]) error {
|
func (ws *WsConn) sendMessage(data common.HubRequest[any]) error {
|
||||||
|
if ws.conn == nil {
|
||||||
|
return gws.ErrConnClosed
|
||||||
|
}
|
||||||
bytes, err := cbor.Marshal(data)
|
bytes, err := cbor.Marshal(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
221
beszel/internal/hub/ws/ws_test.go
Normal file
221
beszel/internal/hub/ws/ws_test.go
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
//go:build testing
|
||||||
|
// +build testing
|
||||||
|
|
||||||
|
package ws
|
||||||
|
|
||||||
|
import (
|
||||||
|
"beszel/internal/common"
|
||||||
|
"crypto/ed25519"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/fxamacker/cbor/v2"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestGetUpgrader tests the singleton upgrader
|
||||||
|
func TestGetUpgrader(t *testing.T) {
|
||||||
|
// Reset the global upgrader to test singleton behavior
|
||||||
|
upgrader = nil
|
||||||
|
|
||||||
|
// First call should create the upgrader
|
||||||
|
upgrader1 := GetUpgrader()
|
||||||
|
assert.NotNil(t, upgrader1, "Upgrader should not be nil")
|
||||||
|
|
||||||
|
// Second call should return the same instance
|
||||||
|
upgrader2 := GetUpgrader()
|
||||||
|
assert.Same(t, upgrader1, upgrader2, "Should return the same upgrader instance")
|
||||||
|
|
||||||
|
// Verify it's properly configured
|
||||||
|
assert.NotNil(t, upgrader1, "Upgrader should be configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewWsConnection tests WebSocket connection creation
|
||||||
|
func TestNewWsConnection(t *testing.T) {
|
||||||
|
// We can't easily mock gws.Conn, so we'll pass nil and test the structure
|
||||||
|
wsConn := NewWsConnection(nil)
|
||||||
|
|
||||||
|
assert.NotNil(t, wsConn, "WebSocket connection should not be nil")
|
||||||
|
assert.Nil(t, wsConn.conn, "Connection should be nil as passed")
|
||||||
|
assert.NotNil(t, wsConn.responseChan, "Response channel should be initialized")
|
||||||
|
assert.NotNil(t, wsConn.DownChan, "Down channel should be initialized")
|
||||||
|
assert.Equal(t, 1, cap(wsConn.responseChan), "Response channel should have capacity of 1")
|
||||||
|
assert.Equal(t, 1, cap(wsConn.DownChan), "Down channel should have capacity of 1")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWsConn_IsConnected tests the connection status check
|
||||||
|
func TestWsConn_IsConnected(t *testing.T) {
|
||||||
|
// Test with nil connection
|
||||||
|
wsConn := NewWsConnection(nil)
|
||||||
|
assert.False(t, wsConn.IsConnected(), "Should not be connected when conn is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWsConn_Close tests the connection closing with nil connection
|
||||||
|
func TestWsConn_Close(t *testing.T) {
|
||||||
|
wsConn := NewWsConnection(nil)
|
||||||
|
|
||||||
|
// Should handle nil connection gracefully
|
||||||
|
assert.NotPanics(t, func() {
|
||||||
|
wsConn.Close([]byte("test message"))
|
||||||
|
}, "Should not panic when closing nil connection")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWsConn_SendMessage_CBOR tests CBOR encoding in sendMessage
|
||||||
|
func TestWsConn_SendMessage_CBOR(t *testing.T) {
|
||||||
|
wsConn := NewWsConnection(nil)
|
||||||
|
|
||||||
|
testData := common.HubRequest[any]{
|
||||||
|
Action: common.GetData,
|
||||||
|
Data: "test data",
|
||||||
|
}
|
||||||
|
|
||||||
|
// This will fail because conn is nil, but we can test the CBOR encoding logic
|
||||||
|
// by checking that the function properly encodes to CBOR before failing
|
||||||
|
err := wsConn.sendMessage(testData)
|
||||||
|
assert.Error(t, err, "Should error with nil connection")
|
||||||
|
|
||||||
|
// Test CBOR encoding separately
|
||||||
|
bytes, err := cbor.Marshal(testData)
|
||||||
|
assert.NoError(t, err, "Should encode to CBOR successfully")
|
||||||
|
|
||||||
|
// Verify we can decode it back
|
||||||
|
var decodedData common.HubRequest[any]
|
||||||
|
err = cbor.Unmarshal(bytes, &decodedData)
|
||||||
|
assert.NoError(t, err, "Should decode from CBOR successfully")
|
||||||
|
assert.Equal(t, testData.Action, decodedData.Action, "Action should match")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWsConn_GetFingerprint_SignatureGeneration tests signature creation logic
|
||||||
|
func TestWsConn_GetFingerprint_SignatureGeneration(t *testing.T) {
|
||||||
|
// Generate test key pair
|
||||||
|
_, privKey, err := ed25519.GenerateKey(nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
signer, err := ssh.NewSignerFromKey(privKey)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
token := "test-token"
|
||||||
|
|
||||||
|
// This will timeout since conn is nil, but we can verify the signature logic
|
||||||
|
// We can't test the full flow, but we can test that the signature is created properly
|
||||||
|
challenge := []byte(token)
|
||||||
|
signature, err := signer.Sign(nil, challenge)
|
||||||
|
assert.NoError(t, err, "Should create signature successfully")
|
||||||
|
assert.NotEmpty(t, signature.Blob, "Signature blob should not be empty")
|
||||||
|
assert.Equal(t, signer.PublicKey().Type(), signature.Format, "Signature format should match key type")
|
||||||
|
|
||||||
|
// Test the fingerprint request structure
|
||||||
|
fpRequest := common.FingerprintRequest{
|
||||||
|
Signature: signature.Blob,
|
||||||
|
NeedSysInfo: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test CBOR encoding of fingerprint request
|
||||||
|
fpData, err := cbor.Marshal(fpRequest)
|
||||||
|
assert.NoError(t, err, "Should encode fingerprint request to CBOR")
|
||||||
|
|
||||||
|
var decodedFpRequest common.FingerprintRequest
|
||||||
|
err = cbor.Unmarshal(fpData, &decodedFpRequest)
|
||||||
|
assert.NoError(t, err, "Should decode fingerprint request from CBOR")
|
||||||
|
assert.Equal(t, fpRequest.Signature, decodedFpRequest.Signature, "Signature should match")
|
||||||
|
assert.Equal(t, fpRequest.NeedSysInfo, decodedFpRequest.NeedSysInfo, "NeedSysInfo should match")
|
||||||
|
|
||||||
|
// Test the full hub request structure
|
||||||
|
hubRequest := common.HubRequest[any]{
|
||||||
|
Action: common.CheckFingerprint,
|
||||||
|
Data: fpRequest,
|
||||||
|
}
|
||||||
|
|
||||||
|
hubData, err := cbor.Marshal(hubRequest)
|
||||||
|
assert.NoError(t, err, "Should encode hub request to CBOR")
|
||||||
|
|
||||||
|
var decodedHubRequest common.HubRequest[cbor.RawMessage]
|
||||||
|
err = cbor.Unmarshal(hubData, &decodedHubRequest)
|
||||||
|
assert.NoError(t, err, "Should decode hub request from CBOR")
|
||||||
|
assert.Equal(t, common.CheckFingerprint, decodedHubRequest.Action, "Action should be CheckFingerprint")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWsConn_RequestSystemData_RequestFormat tests system data request format
|
||||||
|
func TestWsConn_RequestSystemData_RequestFormat(t *testing.T) {
|
||||||
|
// Test the request format that would be sent
|
||||||
|
request := common.HubRequest[any]{
|
||||||
|
Action: common.GetData,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test CBOR encoding
|
||||||
|
data, err := cbor.Marshal(request)
|
||||||
|
assert.NoError(t, err, "Should encode request to CBOR")
|
||||||
|
|
||||||
|
// Test decoding
|
||||||
|
var decodedRequest common.HubRequest[any]
|
||||||
|
err = cbor.Unmarshal(data, &decodedRequest)
|
||||||
|
assert.NoError(t, err, "Should decode request from CBOR")
|
||||||
|
assert.Equal(t, common.GetData, decodedRequest.Action, "Should have GetData action")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFingerprintRecord tests the FingerprintRecord struct
|
||||||
|
func TestFingerprintRecord(t *testing.T) {
|
||||||
|
record := FingerprintRecord{
|
||||||
|
Id: "test-id",
|
||||||
|
SystemId: "system-123",
|
||||||
|
Fingerprint: "test-fingerprint",
|
||||||
|
Token: "test-token",
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, "test-id", record.Id)
|
||||||
|
assert.Equal(t, "system-123", record.SystemId)
|
||||||
|
assert.Equal(t, "test-fingerprint", record.Fingerprint)
|
||||||
|
assert.Equal(t, "test-token", record.Token)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDeadlineConstant tests that the deadline constant is reasonable
|
||||||
|
func TestDeadlineConstant(t *testing.T) {
|
||||||
|
assert.Equal(t, 70*time.Second, deadline, "Deadline should be 70 seconds")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCommonActions tests that the common actions are properly defined
|
||||||
|
func TestCommonActions(t *testing.T) {
|
||||||
|
// Test that the actions we use exist and have expected values
|
||||||
|
assert.Equal(t, common.WebSocketAction(0), common.GetData, "GetData should be action 0")
|
||||||
|
assert.Equal(t, common.WebSocketAction(1), common.CheckFingerprint, "CheckFingerprint should be action 1")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandler tests that we can create a Handler
|
||||||
|
func TestHandler(t *testing.T) {
|
||||||
|
handler := &Handler{}
|
||||||
|
assert.NotNil(t, handler, "Handler should be created successfully")
|
||||||
|
|
||||||
|
// The Handler embeds gws.BuiltinEventHandler, so it should have the embedded type
|
||||||
|
assert.NotNil(t, handler.BuiltinEventHandler, "Should have embedded BuiltinEventHandler")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWsConnChannelBehavior tests channel behavior without WebSocket connections
|
||||||
|
func TestWsConnChannelBehavior(t *testing.T) {
|
||||||
|
wsConn := NewWsConnection(nil)
|
||||||
|
|
||||||
|
// Test that channels are properly initialized and can be used
|
||||||
|
select {
|
||||||
|
case wsConn.DownChan <- struct{}{}:
|
||||||
|
// Should be able to write to channel
|
||||||
|
default:
|
||||||
|
t.Error("Should be able to write to DownChan")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test reading from DownChan
|
||||||
|
select {
|
||||||
|
case <-wsConn.DownChan:
|
||||||
|
// Should be able to read from channel
|
||||||
|
case <-time.After(10 * time.Millisecond):
|
||||||
|
t.Error("Should be able to read from DownChan")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response channel should be empty initially
|
||||||
|
select {
|
||||||
|
case <-wsConn.responseChan:
|
||||||
|
t.Error("Response channel should be empty initially")
|
||||||
|
default:
|
||||||
|
// Expected - channel should be empty
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -206,12 +206,16 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
|
|||||||
sum.LoadAvg1 += stats.LoadAvg1
|
sum.LoadAvg1 += stats.LoadAvg1
|
||||||
sum.LoadAvg5 += stats.LoadAvg5
|
sum.LoadAvg5 += stats.LoadAvg5
|
||||||
sum.LoadAvg15 += stats.LoadAvg15
|
sum.LoadAvg15 += stats.LoadAvg15
|
||||||
|
sum.Bandwidth[0] += stats.Bandwidth[0]
|
||||||
|
sum.Bandwidth[1] += stats.Bandwidth[1]
|
||||||
// Set peak values
|
// Set peak values
|
||||||
sum.MaxCpu = max(sum.MaxCpu, stats.MaxCpu, stats.Cpu)
|
sum.MaxCpu = max(sum.MaxCpu, stats.MaxCpu, stats.Cpu)
|
||||||
sum.MaxNetworkSent = max(sum.MaxNetworkSent, stats.MaxNetworkSent, stats.NetworkSent)
|
sum.MaxNetworkSent = max(sum.MaxNetworkSent, stats.MaxNetworkSent, stats.NetworkSent)
|
||||||
sum.MaxNetworkRecv = max(sum.MaxNetworkRecv, stats.MaxNetworkRecv, stats.NetworkRecv)
|
sum.MaxNetworkRecv = max(sum.MaxNetworkRecv, stats.MaxNetworkRecv, stats.NetworkRecv)
|
||||||
sum.MaxDiskReadPs = max(sum.MaxDiskReadPs, stats.MaxDiskReadPs, stats.DiskReadPs)
|
sum.MaxDiskReadPs = max(sum.MaxDiskReadPs, stats.MaxDiskReadPs, stats.DiskReadPs)
|
||||||
sum.MaxDiskWritePs = max(sum.MaxDiskWritePs, stats.MaxDiskWritePs, stats.DiskWritePs)
|
sum.MaxDiskWritePs = max(sum.MaxDiskWritePs, stats.MaxDiskWritePs, stats.DiskWritePs)
|
||||||
|
sum.MaxBandwidth[0] = max(sum.MaxBandwidth[0], stats.MaxBandwidth[0], stats.Bandwidth[0])
|
||||||
|
sum.MaxBandwidth[1] = max(sum.MaxBandwidth[1], stats.MaxBandwidth[1], stats.Bandwidth[1])
|
||||||
|
|
||||||
// Accumulate temperatures
|
// Accumulate temperatures
|
||||||
if stats.Temperatures != nil {
|
if stats.Temperatures != nil {
|
||||||
@@ -284,6 +288,8 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
|
|||||||
sum.LoadAvg1 = twoDecimals(sum.LoadAvg1 / count)
|
sum.LoadAvg1 = twoDecimals(sum.LoadAvg1 / count)
|
||||||
sum.LoadAvg5 = twoDecimals(sum.LoadAvg5 / count)
|
sum.LoadAvg5 = twoDecimals(sum.LoadAvg5 / count)
|
||||||
sum.LoadAvg15 = twoDecimals(sum.LoadAvg15 / count)
|
sum.LoadAvg15 = twoDecimals(sum.LoadAvg15 / count)
|
||||||
|
sum.Bandwidth[0] = sum.Bandwidth[0] / uint64(count)
|
||||||
|
sum.Bandwidth[1] = sum.Bandwidth[1] / uint64(count)
|
||||||
// Average temperatures
|
// Average temperatures
|
||||||
if sum.Temperatures != nil && tempCount > 0 {
|
if sum.Temperatures != nil && tempCount > 0 {
|
||||||
for key := range sum.Temperatures {
|
for key := range sum.Temperatures {
|
||||||
@@ -366,12 +372,46 @@ func (rm *RecordManager) AverageContainerStats(db dbx.Builder, records RecordIds
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deletes records older than what is displayed in the UI
|
// Delete old records
|
||||||
func (rm *RecordManager) DeleteOldRecords() {
|
func (rm *RecordManager) DeleteOldRecords() {
|
||||||
// Define the collections to process
|
rm.app.RunInTransaction(func(txApp core.App) error {
|
||||||
|
err := deleteOldSystemStats(txApp)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = deleteOldAlertsHistory(txApp, 200, 250)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete old alerts history records
|
||||||
|
func deleteOldAlertsHistory(app core.App, countToKeep, countBeforeDeletion int) error {
|
||||||
|
db := app.DB()
|
||||||
|
var users []struct {
|
||||||
|
Id string `db:"user"`
|
||||||
|
}
|
||||||
|
err := db.NewQuery("SELECT user, COUNT(*) as count FROM alerts_history GROUP BY user HAVING count > {:countBeforeDeletion}").Bind(dbx.Params{"countBeforeDeletion": countBeforeDeletion}).All(&users)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, user := range users {
|
||||||
|
_, err = db.NewQuery("DELETE FROM alerts_history WHERE user = {:user} AND id NOT IN (SELECT id FROM alerts_history WHERE user = {:user} ORDER BY created DESC LIMIT {:countToKeep})").Bind(dbx.Params{"user": user.Id, "countToKeep": countToKeep}).Execute()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deletes system_stats records older than what is displayed in the UI
|
||||||
|
func deleteOldSystemStats(app core.App) error {
|
||||||
|
// Collections to process
|
||||||
collections := [2]string{"system_stats", "container_stats"}
|
collections := [2]string{"system_stats", "container_stats"}
|
||||||
|
|
||||||
// Define record types and their retention periods
|
// Record types and their retention periods
|
||||||
type RecordDeletionData struct {
|
type RecordDeletionData struct {
|
||||||
recordType string
|
recordType string
|
||||||
retention time.Duration
|
retention time.Duration
|
||||||
@@ -387,10 +427,9 @@ func (rm *RecordManager) DeleteOldRecords() {
|
|||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
|
|
||||||
for _, collection := range collections {
|
for _, collection := range collections {
|
||||||
// Build the WHERE clause dynamically
|
// Build the WHERE clause
|
||||||
var conditionParts []string
|
var conditionParts []string
|
||||||
var params dbx.Params = make(map[string]any)
|
var params dbx.Params = make(map[string]any)
|
||||||
|
|
||||||
for i := range recordData {
|
for i := range recordData {
|
||||||
rd := recordData[i]
|
rd := recordData[i]
|
||||||
// Create parameterized condition for this record type
|
// Create parameterized condition for this record type
|
||||||
@@ -398,19 +437,15 @@ func (rm *RecordManager) DeleteOldRecords() {
|
|||||||
conditionParts = append(conditionParts, fmt.Sprintf("(type = '%s' AND created < {:%s})", rd.recordType, dateParam))
|
conditionParts = append(conditionParts, fmt.Sprintf("(type = '%s' AND created < {:%s})", rd.recordType, dateParam))
|
||||||
params[dateParam] = now.Add(-rd.retention)
|
params[dateParam] = now.Add(-rd.retention)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Combine conditions with OR
|
// Combine conditions with OR
|
||||||
conditionStr := strings.Join(conditionParts, " OR ")
|
conditionStr := strings.Join(conditionParts, " OR ")
|
||||||
|
// Construct and execute the full raw query
|
||||||
// Construct the full raw query
|
|
||||||
rawQuery := fmt.Sprintf("DELETE FROM %s WHERE %s", collection, conditionStr)
|
rawQuery := fmt.Sprintf("DELETE FROM %s WHERE %s", collection, conditionStr)
|
||||||
|
if _, err := app.DB().NewQuery(rawQuery).Bind(params).Execute(); err != nil {
|
||||||
// Execute the query with parameters
|
return fmt.Errorf("failed to delete from %s: %v", collection, err)
|
||||||
if _, err := rm.app.DB().NewQuery(rawQuery).Bind(params).Execute(); err != nil {
|
|
||||||
// return fmt.Errorf("failed to delete from %s: %v", collection, err)
|
|
||||||
rm.app.Logger().Error("failed to delete", "collection", collection, "error", err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Round float to two decimals */
|
/* Round float to two decimals */
|
||||||
|
|||||||
381
beszel/internal/records/records_test.go
Normal file
381
beszel/internal/records/records_test.go
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
//go:build testing
|
||||||
|
// +build testing
|
||||||
|
|
||||||
|
package records_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"beszel/internal/records"
|
||||||
|
"beszel/internal/tests"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pocketbase/dbx"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/types"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestDeleteOldRecords tests the main DeleteOldRecords function
|
||||||
|
func TestDeleteOldRecords(t *testing.T) {
|
||||||
|
hub, err := tests.NewTestHub(t.TempDir())
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
rm := records.NewRecordManager(hub)
|
||||||
|
|
||||||
|
// Create test user for alerts history
|
||||||
|
user, err := tests.CreateUser(hub, "test@example.com", "testtesttest")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Create test system
|
||||||
|
system, err := tests.CreateRecord(hub, "systems", map[string]any{
|
||||||
|
"name": "test-system",
|
||||||
|
"host": "localhost",
|
||||||
|
"port": "45876",
|
||||||
|
"status": "up",
|
||||||
|
"users": []string{user.Id},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
// Create old system_stats records that should be deleted
|
||||||
|
var record *core.Record
|
||||||
|
record, err = tests.CreateRecord(hub, "system_stats", map[string]any{
|
||||||
|
"system": system.Id,
|
||||||
|
"type": "1m",
|
||||||
|
"stats": `{"cpu": 50.0, "mem": 1024}`,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
// created is autodate field, so we need to set it manually
|
||||||
|
record.SetRaw("created", now.UTC().Add(-2*time.Hour).Format(types.DefaultDateLayout))
|
||||||
|
err = hub.SaveNoValidate(record)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, record)
|
||||||
|
require.InDelta(t, record.GetDateTime("created").Time().UTC().Unix(), now.UTC().Add(-2*time.Hour).Unix(), 1)
|
||||||
|
require.Equal(t, record.Get("system"), system.Id)
|
||||||
|
require.Equal(t, record.Get("type"), "1m")
|
||||||
|
|
||||||
|
// Create recent system_stats record that should be kept
|
||||||
|
_, err = tests.CreateRecord(hub, "system_stats", map[string]any{
|
||||||
|
"system": system.Id,
|
||||||
|
"type": "1m",
|
||||||
|
"stats": `{"cpu": 30.0, "mem": 512}`,
|
||||||
|
"created": now.Add(-30 * time.Minute), // 30 minutes old, should be kept
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Create many alerts history records to trigger deletion
|
||||||
|
for i := range 260 { // More than countBeforeDeletion (250)
|
||||||
|
_, err = tests.CreateRecord(hub, "alerts_history", map[string]any{
|
||||||
|
"user": user.Id,
|
||||||
|
"name": "CPU",
|
||||||
|
"value": i + 1,
|
||||||
|
"system": system.Id,
|
||||||
|
"created": now.Add(-time.Duration(i) * time.Minute),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count records before deletion
|
||||||
|
systemStatsCountBefore, err := hub.CountRecords("system_stats")
|
||||||
|
require.NoError(t, err)
|
||||||
|
alertsCountBefore, err := hub.CountRecords("alerts_history")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Run deletion
|
||||||
|
rm.DeleteOldRecords()
|
||||||
|
|
||||||
|
// Count records after deletion
|
||||||
|
systemStatsCountAfter, err := hub.CountRecords("system_stats")
|
||||||
|
require.NoError(t, err)
|
||||||
|
alertsCountAfter, err := hub.CountRecords("alerts_history")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify old system stats were deleted
|
||||||
|
assert.Less(t, systemStatsCountAfter, systemStatsCountBefore, "Old system stats should be deleted")
|
||||||
|
|
||||||
|
// Verify alerts history was trimmed
|
||||||
|
assert.Less(t, alertsCountAfter, alertsCountBefore, "Excessive alerts history should be deleted")
|
||||||
|
assert.Equal(t, alertsCountAfter, int64(200), "Alerts count should be equal to countToKeep (200)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDeleteOldSystemStats tests the deleteOldSystemStats function
|
||||||
|
func TestDeleteOldSystemStats(t *testing.T) {
|
||||||
|
hub, err := tests.NewTestHub(t.TempDir())
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
// Create test system
|
||||||
|
user, err := tests.CreateUser(hub, "test@example.com", "testtesttest")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
system, err := tests.CreateRecord(hub, "systems", map[string]any{
|
||||||
|
"name": "test-system",
|
||||||
|
"host": "localhost",
|
||||||
|
"port": "45876",
|
||||||
|
"status": "up",
|
||||||
|
"users": []string{user.Id},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
// Test data for different record types and their retention periods
|
||||||
|
testCases := []struct {
|
||||||
|
recordType string
|
||||||
|
retention time.Duration
|
||||||
|
shouldBeKept bool
|
||||||
|
ageFromNow time.Duration
|
||||||
|
description string
|
||||||
|
}{
|
||||||
|
{"1m", time.Hour, true, 30 * time.Minute, "1m record within 1 hour should be kept"},
|
||||||
|
{"1m", time.Hour, false, 2 * time.Hour, "1m record older than 1 hour should be deleted"},
|
||||||
|
{"10m", 12 * time.Hour, true, 6 * time.Hour, "10m record within 12 hours should be kept"},
|
||||||
|
{"10m", 12 * time.Hour, false, 24 * time.Hour, "10m record older than 12 hours should be deleted"},
|
||||||
|
{"20m", 24 * time.Hour, true, 12 * time.Hour, "20m record within 24 hours should be kept"},
|
||||||
|
{"20m", 24 * time.Hour, false, 48 * time.Hour, "20m record older than 24 hours should be deleted"},
|
||||||
|
{"120m", 7 * 24 * time.Hour, true, 3 * 24 * time.Hour, "120m record within 7 days should be kept"},
|
||||||
|
{"120m", 7 * 24 * time.Hour, false, 10 * 24 * time.Hour, "120m record older than 7 days should be deleted"},
|
||||||
|
{"480m", 30 * 24 * time.Hour, true, 15 * 24 * time.Hour, "480m record within 30 days should be kept"},
|
||||||
|
{"480m", 30 * 24 * time.Hour, false, 45 * 24 * time.Hour, "480m record older than 30 days should be deleted"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create test records for both system_stats and container_stats
|
||||||
|
collections := []string{"system_stats", "container_stats"}
|
||||||
|
recordIds := make(map[string][]string)
|
||||||
|
|
||||||
|
for _, collection := range collections {
|
||||||
|
recordIds[collection] = make([]string, 0)
|
||||||
|
|
||||||
|
for i, tc := range testCases {
|
||||||
|
recordTime := now.Add(-tc.ageFromNow)
|
||||||
|
|
||||||
|
var stats string
|
||||||
|
if collection == "system_stats" {
|
||||||
|
stats = fmt.Sprintf(`{"cpu": %d.0, "mem": %d}`, i*10, i*100)
|
||||||
|
} else {
|
||||||
|
stats = fmt.Sprintf(`[{"name": "container%d", "cpu": %d.0, "mem": %d}]`, i, i*5, i*50)
|
||||||
|
}
|
||||||
|
|
||||||
|
record, err := tests.CreateRecord(hub, collection, map[string]any{
|
||||||
|
"system": system.Id,
|
||||||
|
"type": tc.recordType,
|
||||||
|
"stats": stats,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
record.SetRaw("created", recordTime.Format(types.DefaultDateLayout))
|
||||||
|
err = hub.SaveNoValidate(record)
|
||||||
|
require.NoError(t, err)
|
||||||
|
recordIds[collection] = append(recordIds[collection], record.Id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run deletion
|
||||||
|
err = records.TestDeleteOldSystemStats(hub)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify results
|
||||||
|
for _, collection := range collections {
|
||||||
|
for i, tc := range testCases {
|
||||||
|
recordId := recordIds[collection][i]
|
||||||
|
|
||||||
|
// Try to find the record
|
||||||
|
_, err := hub.FindRecordById(collection, recordId)
|
||||||
|
|
||||||
|
if tc.shouldBeKept {
|
||||||
|
assert.NoError(t, err, "Record should exist: %s", tc.description)
|
||||||
|
} else {
|
||||||
|
assert.Error(t, err, "Record should be deleted: %s", tc.description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDeleteOldAlertsHistory tests the deleteOldAlertsHistory function
|
||||||
|
func TestDeleteOldAlertsHistory(t *testing.T) {
|
||||||
|
hub, err := tests.NewTestHub(t.TempDir())
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
// Create test users
|
||||||
|
user1, err := tests.CreateUser(hub, "user1@example.com", "testtesttest")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
user2, err := tests.CreateUser(hub, "user2@example.com", "testtesttest")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
system, err := tests.CreateRecord(hub, "systems", map[string]any{
|
||||||
|
"name": "test-system",
|
||||||
|
"host": "localhost",
|
||||||
|
"port": "45876",
|
||||||
|
"status": "up",
|
||||||
|
"users": []string{user1.Id, user2.Id},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
user *core.Record
|
||||||
|
alertCount int
|
||||||
|
countToKeep int
|
||||||
|
countBeforeDeletion int
|
||||||
|
expectedAfterDeletion int
|
||||||
|
description string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "User with few alerts (below threshold)",
|
||||||
|
user: user1,
|
||||||
|
alertCount: 100,
|
||||||
|
countToKeep: 50,
|
||||||
|
countBeforeDeletion: 150,
|
||||||
|
expectedAfterDeletion: 100, // No deletion because below threshold
|
||||||
|
description: "User with alerts below countBeforeDeletion should not have any deleted",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "User with many alerts (above threshold)",
|
||||||
|
user: user2,
|
||||||
|
alertCount: 300,
|
||||||
|
countToKeep: 100,
|
||||||
|
countBeforeDeletion: 200,
|
||||||
|
expectedAfterDeletion: 100, // Should be trimmed to countToKeep
|
||||||
|
description: "User with alerts above countBeforeDeletion should be trimmed to countToKeep",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
// Create alerts for this user
|
||||||
|
for i := 0; i < tc.alertCount; i++ {
|
||||||
|
_, err := tests.CreateRecord(hub, "alerts_history", map[string]any{
|
||||||
|
"user": tc.user.Id,
|
||||||
|
"name": "CPU",
|
||||||
|
"value": i + 1,
|
||||||
|
"system": system.Id,
|
||||||
|
"created": now.Add(-time.Duration(i) * time.Minute),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count before deletion
|
||||||
|
countBefore, err := hub.CountRecords("alerts_history",
|
||||||
|
dbx.NewExp("user = {:user}", dbx.Params{"user": tc.user.Id}))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, int64(tc.alertCount), countBefore, "Initial count should match")
|
||||||
|
|
||||||
|
// Run deletion
|
||||||
|
err = records.TestDeleteOldAlertsHistory(hub, tc.countToKeep, tc.countBeforeDeletion)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Count after deletion
|
||||||
|
countAfter, err := hub.CountRecords("alerts_history",
|
||||||
|
dbx.NewExp("user = {:user}", dbx.Params{"user": tc.user.Id}))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, int64(tc.expectedAfterDeletion), countAfter, tc.description)
|
||||||
|
|
||||||
|
// If deletion occurred, verify the most recent records were kept
|
||||||
|
if tc.expectedAfterDeletion < tc.alertCount {
|
||||||
|
records, err := hub.FindRecordsByFilter("alerts_history",
|
||||||
|
"user = {:user}",
|
||||||
|
"-created", // Order by created DESC
|
||||||
|
tc.countToKeep,
|
||||||
|
0,
|
||||||
|
map[string]any{"user": tc.user.Id})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, records, tc.expectedAfterDeletion, "Should have exactly countToKeep records")
|
||||||
|
|
||||||
|
// Verify records are in descending order by created time
|
||||||
|
for i := 1; i < len(records); i++ {
|
||||||
|
prev := records[i-1].GetDateTime("created").Time()
|
||||||
|
curr := records[i].GetDateTime("created").Time()
|
||||||
|
assert.True(t, prev.After(curr) || prev.Equal(curr),
|
||||||
|
"Records should be ordered by created time (newest first)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDeleteOldAlertsHistoryEdgeCases tests edge cases for alerts history deletion
|
||||||
|
func TestDeleteOldAlertsHistoryEdgeCases(t *testing.T) {
|
||||||
|
hub, err := tests.NewTestHub(t.TempDir())
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
t.Run("No users with excessive alerts", func(t *testing.T) {
|
||||||
|
// Create user with few alerts
|
||||||
|
user, err := tests.CreateUser(hub, "few@example.com", "testtesttest")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
system, err := tests.CreateRecord(hub, "systems", map[string]any{
|
||||||
|
"name": "test-system",
|
||||||
|
"host": "localhost",
|
||||||
|
"port": "45876",
|
||||||
|
"status": "up",
|
||||||
|
"users": []string{user.Id},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create only 5 alerts (well below threshold)
|
||||||
|
for i := range 5 {
|
||||||
|
_, err := tests.CreateRecord(hub, "alerts_history", map[string]any{
|
||||||
|
"user": user.Id,
|
||||||
|
"name": "CPU",
|
||||||
|
"value": i + 1,
|
||||||
|
"system": system.Id,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should not error and should not delete anything
|
||||||
|
err = records.TestDeleteOldAlertsHistory(hub, 10, 20)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
count, err := hub.CountRecords("alerts_history")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, int64(5), count, "All alerts should remain")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Empty alerts_history table", func(t *testing.T) {
|
||||||
|
// Clear any existing alerts
|
||||||
|
_, err := hub.DB().NewQuery("DELETE FROM alerts_history").Execute()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Should not error with empty table
|
||||||
|
err = records.TestDeleteOldAlertsHistory(hub, 10, 20)
|
||||||
|
require.NoError(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRecordManagerCreation tests RecordManager creation
|
||||||
|
func TestRecordManagerCreation(t *testing.T) {
|
||||||
|
hub, err := tests.NewTestHub(t.TempDir())
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
rm := records.NewRecordManager(hub)
|
||||||
|
assert.NotNil(t, rm, "RecordManager should not be nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTwoDecimals tests the twoDecimals helper function
|
||||||
|
func TestTwoDecimals(t *testing.T) {
|
||||||
|
testCases := []struct {
|
||||||
|
input float64
|
||||||
|
expected float64
|
||||||
|
}{
|
||||||
|
{1.234567, 1.23},
|
||||||
|
{1.235, 1.24}, // Should round up
|
||||||
|
{1.0, 1.0},
|
||||||
|
{0.0, 0.0},
|
||||||
|
{-1.234567, -1.23},
|
||||||
|
{-1.235, -1.23}, // Negative rounding
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
result := records.TestTwoDecimals(tc.input)
|
||||||
|
assert.InDelta(t, tc.expected, result, 0.02, "twoDecimals(%f) should equal %f", tc.input, tc.expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
23
beszel/internal/records/records_test_helpers.go
Normal file
23
beszel/internal/records/records_test_helpers.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
//go:build testing
|
||||||
|
// +build testing
|
||||||
|
|
||||||
|
package records
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestDeleteOldSystemStats exposes deleteOldSystemStats for testing
|
||||||
|
func TestDeleteOldSystemStats(app core.App) error {
|
||||||
|
return deleteOldSystemStats(app)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDeleteOldAlertsHistory exposes deleteOldAlertsHistory for testing
|
||||||
|
func TestDeleteOldAlertsHistory(app core.App, countToKeep, countBeforeDeletion int) error {
|
||||||
|
return deleteOldAlertsHistory(app, countToKeep, countBeforeDeletion)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTwoDecimals exposes twoDecimals for testing
|
||||||
|
func TestTwoDecimals(value float64) float64 {
|
||||||
|
return twoDecimals(value)
|
||||||
|
}
|
||||||
@@ -140,6 +140,124 @@ func init() {
|
|||||||
],
|
],
|
||||||
"system": false
|
"system": false
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "pbc_1697146157",
|
||||||
|
"listRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
|
||||||
|
"viewRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
|
||||||
|
"createRule": null,
|
||||||
|
"updateRule": null,
|
||||||
|
"deleteRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
|
||||||
|
"name": "alerts_history",
|
||||||
|
"type": "base",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "[a-z0-9]{15}",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text3208210256",
|
||||||
|
"max": 15,
|
||||||
|
"min": 15,
|
||||||
|
"name": "id",
|
||||||
|
"pattern": "^[a-z0-9]+$",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": true,
|
||||||
|
"required": true,
|
||||||
|
"system": true,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cascadeDelete": true,
|
||||||
|
"collectionId": "_pb_users_auth_",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "relation2375276105",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"minSelect": 0,
|
||||||
|
"name": "user",
|
||||||
|
"presentable": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "relation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cascadeDelete": true,
|
||||||
|
"collectionId": "2hz5ncl8tizk5nx",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "relation3377271179",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"minSelect": 0,
|
||||||
|
"name": "system",
|
||||||
|
"presentable": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "relation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text2466471794",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "alert_id",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text1579384326",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "name",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "number494360628",
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"name": "value",
|
||||||
|
"onlyInt": false,
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate2990389176",
|
||||||
|
"name": "created",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": false,
|
||||||
|
"presentable": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "autodate"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "date2276568630",
|
||||||
|
"max": "",
|
||||||
|
"min": "",
|
||||||
|
"name": "resolved",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "date"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"indexes": [
|
||||||
|
"CREATE INDEX ` + "`" + `idx_YdGnup5aqB` + "`" + ` ON ` + "`" + `alerts_history` + "`" + ` (` + "`" + `user` + "`" + `)",
|
||||||
|
"CREATE INDEX ` + "`" + `idx_taLet9VdME` + "`" + ` ON ` + "`" + `alerts_history` + "`" + ` (` + "`" + `created` + "`" + `)"
|
||||||
|
],
|
||||||
|
"system": false
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "juohu4jipgc13v7",
|
"id": "juohu4jipgc13v7",
|
||||||
"listRule": "@request.auth.id != \"\"",
|
"listRule": "@request.auth.id != \"\"",
|
||||||
@@ -757,7 +875,6 @@ func init() {
|
|||||||
LEFT JOIN fingerprints f ON s.id = f.system
|
LEFT JOIN fingerprints f ON s.id = f.system
|
||||||
WHERE f.system IS NULL
|
WHERE f.system IS NULL
|
||||||
`).Column(&systemIds)
|
`).Column(&systemIds)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
package migrations
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
|
||||||
m "github.com/pocketbase/pocketbase/migrations"
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
m.Register(func(app core.App) error {
|
|
||||||
jsonData := `[
|
|
||||||
{
|
|
||||||
"name": "alerts_history",
|
|
||||||
"type": "base",
|
|
||||||
"system": false,
|
|
||||||
"listRule": "",
|
|
||||||
"deleteRule": "",
|
|
||||||
"viewRule": ""
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"name": "alert",
|
|
||||||
"type": "relation",
|
|
||||||
"required": true,
|
|
||||||
"collectionId": "elngm8x1l60zi2v",
|
|
||||||
"cascadeDelete": true,
|
|
||||||
"maxSelect": 1
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "user",
|
|
||||||
"type": "relation",
|
|
||||||
"required": true,
|
|
||||||
"collectionId": "_pb_users_auth_",
|
|
||||||
"cascadeDelete": true,
|
|
||||||
"maxSelect": 1
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "system",
|
|
||||||
"type": "relation",
|
|
||||||
"required": true,
|
|
||||||
"collectionId": "2hz5ncl8tizk5nx",
|
|
||||||
"cascadeDelete": true,
|
|
||||||
"maxSelect": 1
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "name",
|
|
||||||
"type": "text",
|
|
||||||
"required": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "value",
|
|
||||||
"type": "number",
|
|
||||||
"required": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "state",
|
|
||||||
"type": "select",
|
|
||||||
"required": true,
|
|
||||||
"values": ["active", "solved"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "created_date",
|
|
||||||
"type": "date",
|
|
||||||
"required": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "solved_date",
|
|
||||||
"type": "date",
|
|
||||||
"required": false
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]`
|
|
||||||
return app.ImportCollectionsByMarshaledJSON([]byte(jsonData), false)
|
|
||||||
}, nil)
|
|
||||||
}
|
|
||||||
Binary file not shown.
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="manifest" href="./static/manifest.json" />
|
<link rel="manifest" href="./static/manifest.json" />
|
||||||
<link rel="icon" type="image/svg+xml" href="./static/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="./static/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0,maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||||
<title>Beszel</title>
|
<title>Beszel</title>
|
||||||
<script>
|
<script>
|
||||||
globalThis.BESZEL = {
|
globalThis.BESZEL = {
|
||||||
|
|||||||
22
beszel/site/package-lock.json
generated
22
beszel/site/package-lock.json
generated
@@ -40,7 +40,6 @@
|
|||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"recharts": "^2.15.3",
|
"recharts": "^2.15.3",
|
||||||
"sonner": "^2.0.6",
|
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"valibot": "^0.42.1"
|
"valibot": "^0.42.1"
|
||||||
@@ -49,6 +48,7 @@
|
|||||||
"@lingui/cli": "^5.3.2",
|
"@lingui/cli": "^5.3.2",
|
||||||
"@lingui/swc-plugin": "^5.5.2",
|
"@lingui/swc-plugin": "^5.5.2",
|
||||||
"@lingui/vite-plugin": "^5.3.2",
|
"@lingui/vite-plugin": "^5.3.2",
|
||||||
|
"@tailwindcss/container-queries": "^0.1.1",
|
||||||
"@types/bun": "^1.2.15",
|
"@types/bun": "^1.2.15",
|
||||||
"@types/react": "^18.3.23",
|
"@types/react": "^18.3.23",
|
||||||
"@types/react-dom": "^18.3.7",
|
"@types/react-dom": "^18.3.7",
|
||||||
@@ -2832,6 +2832,16 @@
|
|||||||
"@swc/counter": "^0.1.3"
|
"@swc/counter": "^0.1.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tailwindcss/container-queries": {
|
||||||
|
"version": "0.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/container-queries/-/container-queries-0.1.1.tgz",
|
||||||
|
"integrity": "sha512-p18dswChx6WnTSaJCSGx6lTmrGzNNvm2FtXmiO6AuA1V4U5REyoqwmT6kgAsIMdjo07QdAfYXHJ4hnMtfHzWgA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"tailwindcss": ">=3.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tanstack/react-table": {
|
"node_modules/@tanstack/react-table": {
|
||||||
"version": "8.21.3",
|
"version": "8.21.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz",
|
||||||
@@ -5484,16 +5494,6 @@
|
|||||||
"url": "https://github.com/sponsors/isaacs"
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/sonner": {
|
|
||||||
"version": "2.0.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.6.tgz",
|
|
||||||
"integrity": "sha512-yHFhk8T/DK3YxjFQXIrcHT1rGEeTLliVzWbO0xN8GberVun2RiBnxAjXAYpZrqwEVHBG9asI/Li8TAAhN9m59Q==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
|
|
||||||
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/source-map": {
|
"node_modules/source-map": {
|
||||||
"version": "0.8.0-beta.0",
|
"version": "0.8.0-beta.0",
|
||||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz",
|
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz",
|
||||||
|
|||||||
@@ -43,7 +43,6 @@
|
|||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"recharts": "^2.15.3",
|
"recharts": "^2.15.3",
|
||||||
"sonner": "^2.0.6",
|
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"valibot": "^0.42.1"
|
"valibot": "^0.42.1"
|
||||||
@@ -52,6 +51,7 @@
|
|||||||
"@lingui/cli": "^5.3.2",
|
"@lingui/cli": "^5.3.2",
|
||||||
"@lingui/swc-plugin": "^5.5.2",
|
"@lingui/swc-plugin": "^5.5.2",
|
||||||
"@lingui/vite-plugin": "^5.3.2",
|
"@lingui/vite-plugin": "^5.3.2",
|
||||||
|
"@tailwindcss/container-queries": "^0.1.1",
|
||||||
"@types/bun": "^1.2.15",
|
"@types/bun": "^1.2.15",
|
||||||
"@types/react": "^18.3.23",
|
"@types/react": "^18.3.23",
|
||||||
"@types/react-dom": "^18.3.7",
|
"@types/react-dom": "^18.3.7",
|
||||||
|
|||||||
@@ -1,146 +1,164 @@
|
|||||||
import { ColumnDef } from "@tanstack/react-table"
|
import { ColumnDef } from "@tanstack/react-table"
|
||||||
import { AlertsHistoryRecord } from "@/types"
|
import { AlertsHistoryRecord } from "@/types"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { ArrowUpDown } from "lucide-react"
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { alertInfo, formatShortDate, toFixedFloat, formatDuration, cn } from "@/lib/utils"
|
||||||
|
import { Trans } from "@lingui/react/macro"
|
||||||
|
import { t } from "@lingui/core/macro"
|
||||||
|
|
||||||
export const alertsHistoryColumns: ColumnDef<AlertsHistoryRecord>[] = [
|
export const alertsHistoryColumns: ColumnDef<AlertsHistoryRecord>[] = [
|
||||||
{
|
{
|
||||||
accessorKey: "system",
|
accessorKey: "system",
|
||||||
|
enableSorting: true,
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<Button
|
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}>
|
||||||
variant="ghost"
|
<Trans>System</Trans>
|
||||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
|
||||||
>
|
|
||||||
System <ArrowUpDown className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
cell: ({ row }) => <span className="text-center block">{row.original.expand?.system?.name || row.original.system}</span>,
|
cell: ({ row }) => <span className="ps-2">{row.original.expand?.system?.name || row.original.system}</span>,
|
||||||
enableSorting: true,
|
|
||||||
filterFn: (row, _, filterValue) => {
|
filterFn: (row, _, filterValue) => {
|
||||||
const display = row.original.expand?.system?.name || row.original.system || ""
|
const display = row.original.expand?.system?.name || row.original.system || ""
|
||||||
return display.toLowerCase().includes(filterValue.toLowerCase())
|
return display.toLowerCase().includes(filterValue.toLowerCase())
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "name",
|
// accessorKey: "name",
|
||||||
|
id: "name",
|
||||||
|
accessorFn: (record) => {
|
||||||
|
const name = record.name
|
||||||
|
const info = alertInfo[name]
|
||||||
|
return info?.name().replace("cpu", "CPU") || name
|
||||||
|
},
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<Button
|
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}>
|
||||||
variant="ghost"
|
<Trans>Name</Trans>
|
||||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
|
||||||
>
|
|
||||||
Name <ArrowUpDown className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
cell: ({ row }) => <span className="text-center block">{row.getValue("name")}</span>,
|
cell: ({ getValue, row }) => {
|
||||||
enableSorting: true,
|
let name = getValue() as string
|
||||||
filterFn: (row, _, filterValue) => {
|
const info = alertInfo[row.original.name]
|
||||||
const value = row.getValue("name") || ""
|
const Icon = info?.icon
|
||||||
return String(value).toLowerCase().includes(filterValue.toLowerCase())
|
|
||||||
|
return (
|
||||||
|
<span className="flex items-center gap-2 ps-1 min-w-40">
|
||||||
|
{Icon && <Icon className="size-3.5" />}
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "value",
|
accessorKey: "value",
|
||||||
header: ({ column }) => (
|
enableSorting: false,
|
||||||
<Button
|
header: () => (
|
||||||
variant="ghost"
|
<Button variant="ghost">
|
||||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
<Trans>Value</Trans>
|
||||||
className="text-right w-full justify-end"
|
|
||||||
>
|
|
||||||
Value <ArrowUpDown className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
cell: ({ row }) => <span className="text-center block">{Math.round(Number(row.getValue("value")))}</span>,
|
cell({ row, getValue }) {
|
||||||
enableSorting: true,
|
const name = row.original.name
|
||||||
|
if (name === "Status") {
|
||||||
|
return <span className="ps-2">{t`Down`}</span>
|
||||||
|
}
|
||||||
|
const value = getValue() as number
|
||||||
|
const unit = alertInfo[name]?.unit
|
||||||
|
return (
|
||||||
|
<span className="tabular-nums ps-2.5">
|
||||||
|
{toFixedFloat(value, value < 10 ? 2 : 1)}
|
||||||
|
{unit}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "state",
|
accessorKey: "state",
|
||||||
|
enableSorting: true,
|
||||||
|
sortingFn: (rowA, rowB) => (rowA.original.resolved ? 1 : 0) - (rowB.original.resolved ? 1 : 0),
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<Button
|
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}>
|
||||||
variant="ghost"
|
<Trans>State</Trans>
|
||||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
|
||||||
className="text-center w-full justify-start"
|
|
||||||
>
|
|
||||||
State <ArrowUpDown className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const state = row.getValue("state") as string
|
const resolved = row.original.resolved
|
||||||
let color = ""
|
|
||||||
if (state === "solved") color = "bg-green-100 text-green-800 border-green-200"
|
|
||||||
else if (state === "active") color = "bg-yellow-100 text-yellow-800 border-yellow-200"
|
|
||||||
return (
|
return (
|
||||||
<span className="text-center block">
|
<Badge
|
||||||
<Badge className={`capitalize ${color}`}>{state}</Badge>
|
className={cn(
|
||||||
|
"capitalize pointer-events-none",
|
||||||
|
resolved
|
||||||
|
? "bg-green-100 text-green-800 border-green-200 dark:opacity-80"
|
||||||
|
: "bg-yellow-100 text-yellow-800 border-yellow-200"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* {resolved ? <CircleCheckIcon className="size-3 me-0.5" /> : <CircleAlertIcon className="size-3 me-0.5" />} */}
|
||||||
|
<Trans>{resolved ? "Resolved" : "Active"}</Trans>
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "created",
|
||||||
|
accessorFn: (record) => formatShortDate(record.created),
|
||||||
|
enableSorting: true,
|
||||||
|
invertSorting: true,
|
||||||
|
header: ({ column }) => (
|
||||||
|
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}>
|
||||||
|
<Trans>Created</Trans>
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
cell: ({ getValue, row }) => (
|
||||||
|
<span className="ps-1 tabular-nums tracking-tight" title={`${row.original.created} UTC`}>
|
||||||
|
{getValue() as string}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "resolved",
|
||||||
|
enableSorting: true,
|
||||||
|
invertSorting: true,
|
||||||
|
header: ({ column }) => (
|
||||||
|
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}>
|
||||||
|
<Trans>Resolved</Trans>
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
cell: ({ row, getValue }) => {
|
||||||
|
const resolved = getValue() as string | null
|
||||||
|
if (!resolved) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="ps-1 tabular-nums tracking-tight" title={`${row.original.resolved} UTC`}>
|
||||||
|
{formatShortDate(resolved)}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
enableSorting: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "create_date",
|
|
||||||
header: ({ column }) => (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
|
||||||
>
|
|
||||||
Created <ArrowUpDown className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
),
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<span className="text-center block">
|
|
||||||
{row.original.created_date ? new Date(row.original.created_date).toLocaleString() : ""}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
enableSorting: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "solved_date",
|
|
||||||
header: ({ column }) => (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
|
||||||
>
|
|
||||||
Solved <ArrowUpDown className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
),
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<span className="text-center block">
|
|
||||||
{row.original.solved_date ? new Date(row.original.solved_date).toLocaleString() : ""}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
enableSorting: true,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "duration",
|
accessorKey: "duration",
|
||||||
|
invertSorting: true,
|
||||||
|
enableSorting: true,
|
||||||
|
sortingFn: (rowA, rowB) => {
|
||||||
|
const aCreated = new Date(rowA.original.created)
|
||||||
|
const bCreated = new Date(rowB.original.created)
|
||||||
|
const aResolved = rowA.original.resolved ? new Date(rowA.original.resolved) : null
|
||||||
|
const bResolved = rowB.original.resolved ? new Date(rowB.original.resolved) : null
|
||||||
|
const aDuration = aResolved ? aResolved.getTime() - aCreated.getTime() : null
|
||||||
|
const bDuration = bResolved ? bResolved.getTime() - bCreated.getTime() : null
|
||||||
|
if (!aDuration && bDuration) return -1
|
||||||
|
if (aDuration && !bDuration) return 1
|
||||||
|
return (aDuration || 0) - (bDuration || 0)
|
||||||
|
},
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<Button
|
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}>
|
||||||
variant="ghost"
|
<Trans>Duration</Trans>
|
||||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
|
||||||
>
|
|
||||||
Duration <ArrowUpDown className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const created = row.original.created_date ? new Date(row.original.created_date) : null
|
const duration = formatDuration(row.original.created, row.original.resolved)
|
||||||
const solved = row.original.solved_date ? new Date(row.original.solved_date) : null
|
if (!duration) {
|
||||||
if (!created || !solved) return <span className="text-center block"></span>
|
return null
|
||||||
const diffMs = solved.getTime() - created.getTime()
|
}
|
||||||
if (diffMs < 0) return <span className="text-center block"></span>
|
return <span className="ps-2">{duration}</span>
|
||||||
const totalSeconds = Math.floor(diffMs / 1000)
|
|
||||||
const hours = Math.floor(totalSeconds / 3600)
|
|
||||||
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
|
||||||
const seconds = totalSeconds % 60
|
|
||||||
return (
|
|
||||||
<span className="text-center block">
|
|
||||||
{[
|
|
||||||
hours ? `${hours}h` : null,
|
|
||||||
minutes ? `${minutes}m` : null,
|
|
||||||
`${seconds}s`
|
|
||||||
].filter(Boolean).join(" ")}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
enableSorting: true,
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -1,79 +1,42 @@
|
|||||||
import { t } from "@lingui/core/macro"
|
|
||||||
|
|
||||||
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
|
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
|
||||||
import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
|
import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
|
||||||
import { useYAxisWidth, cn, formatShortDate, chartMargin } from "@/lib/utils"
|
import { useYAxisWidth, cn, formatShortDate, chartMargin } from "@/lib/utils"
|
||||||
// import Spinner from '../spinner'
|
import { ChartData, SystemStatsRecord } from "@/types"
|
||||||
import { ChartData } from "@/types"
|
import { useMemo } from "react"
|
||||||
import { memo, useMemo } from "react"
|
|
||||||
import { useLingui } from "@lingui/react/macro"
|
|
||||||
|
|
||||||
/** [label, key, color, opacity] */
|
export type DataPoint = {
|
||||||
type DataKeys = [string, string, number, number]
|
label: string
|
||||||
|
dataKey: (data: SystemStatsRecord) => number | undefined
|
||||||
const getNestedValue = (path: string, max = false, data: any): number | null => {
|
color: string
|
||||||
// fallback value (obj?.stats?.cpum ? 0 : null) should only come into play when viewing
|
opacity: number
|
||||||
// a max value which doesn't exist, or the value was zero and omitted from the stats object.
|
|
||||||
// so we check if cpum is present. if so, return 0 to make sure the zero value is displayed.
|
|
||||||
// if not, return null - there is no max data so do not display anything.
|
|
||||||
return `stats.${path}${max ? "m" : ""}`
|
|
||||||
.split(".")
|
|
||||||
.reduce((acc: any, key: string) => acc?.[key] ?? (data.stats?.cpum ? 0 : null), data)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default memo(function AreaChartDefault({
|
export default function AreaChartDefault({
|
||||||
maxToggled = false,
|
|
||||||
chartName,
|
|
||||||
chartData,
|
chartData,
|
||||||
max,
|
max,
|
||||||
|
maxToggled,
|
||||||
tickFormatter,
|
tickFormatter,
|
||||||
contentFormatter,
|
contentFormatter,
|
||||||
}: {
|
dataPoints,
|
||||||
maxToggled?: boolean
|
}: // logRender = false,
|
||||||
chartName: string
|
{
|
||||||
chartData: ChartData
|
chartData: ChartData
|
||||||
max?: number
|
max?: number
|
||||||
tickFormatter: (value: number) => string
|
maxToggled?: boolean
|
||||||
contentFormatter: ({ value }: { value: number }) => string
|
tickFormatter: (value: number, index: number) => string
|
||||||
|
contentFormatter: ({ value, payload }: { value: number; payload: SystemStatsRecord }) => string
|
||||||
|
dataPoints?: DataPoint[]
|
||||||
|
// logRender?: boolean
|
||||||
}) {
|
}) {
|
||||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||||
const { i18n } = useLingui()
|
|
||||||
|
|
||||||
const { chartTime } = chartData
|
|
||||||
|
|
||||||
const showMax = chartTime !== "1h" && maxToggled
|
|
||||||
|
|
||||||
const dataKeys: DataKeys[] = useMemo(() => {
|
|
||||||
// [label, key, color, opacity]
|
|
||||||
if (chartName === "CPU Usage") {
|
|
||||||
return [[t`CPU Usage`, "cpu", 1, 0.4]]
|
|
||||||
} else if (chartName === "dio") {
|
|
||||||
return [
|
|
||||||
[t({ message: "Write", comment: "Disk write" }), "dw", 3, 0.3],
|
|
||||||
[t({ message: "Read", comment: "Disk read" }), "dr", 1, 0.3],
|
|
||||||
]
|
|
||||||
} else if (chartName === "bw") {
|
|
||||||
return [
|
|
||||||
[t({ message: "Sent", comment: "Network bytes sent (upload)" }), "ns", 5, 0.2],
|
|
||||||
[t({ message: "Received", comment: "Network bytes received (download)" }), "nr", 2, 0.2],
|
|
||||||
]
|
|
||||||
} else if (chartName.startsWith("efs")) {
|
|
||||||
return [
|
|
||||||
[t`Write`, `${chartName}.w`, 3, 0.3],
|
|
||||||
[t`Read`, `${chartName}.r`, 1, 0.3],
|
|
||||||
]
|
|
||||||
} else if (chartName.startsWith("g.")) {
|
|
||||||
return [chartName.includes("mu") ? [t`Used`, chartName, 2, 0.25] : [t`Usage`, chartName, 1, 0.4]]
|
|
||||||
}
|
|
||||||
return []
|
|
||||||
}, [chartName, i18n.locale])
|
|
||||||
|
|
||||||
// console.log('Rendered at', new Date())
|
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
if (chartData.systemStats.length === 0) {
|
if (chartData.systemStats.length === 0) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
// if (logRender) {
|
||||||
|
// console.log("Rendered at", new Date())
|
||||||
|
// }
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<ChartContainer
|
<ChartContainer
|
||||||
@@ -89,7 +52,7 @@ export default memo(function AreaChartDefault({
|
|||||||
className="tracking-tighter"
|
className="tracking-tighter"
|
||||||
width={yAxisWidth}
|
width={yAxisWidth}
|
||||||
domain={[0, max ?? "auto"]}
|
domain={[0, max ?? "auto"]}
|
||||||
tickFormatter={(value) => updateYAxisWidth(tickFormatter(value))}
|
tickFormatter={(value, index) => updateYAxisWidth(tickFormatter(value, index))}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
/>
|
/>
|
||||||
@@ -101,20 +64,19 @@ export default memo(function AreaChartDefault({
|
|||||||
<ChartTooltipContent
|
<ChartTooltipContent
|
||||||
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
||||||
contentFormatter={contentFormatter}
|
contentFormatter={contentFormatter}
|
||||||
// indicator="line"
|
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{dataKeys.map((key, i) => {
|
{dataPoints?.map((dataPoint, i) => {
|
||||||
const color = `hsl(var(--chart-${key[2]}))`
|
const color = `hsl(var(--chart-${dataPoint.color}))`
|
||||||
return (
|
return (
|
||||||
<Area
|
<Area
|
||||||
key={i}
|
key={i}
|
||||||
dataKey={getNestedValue.bind(null, key[1], showMax)}
|
dataKey={dataPoint.dataKey}
|
||||||
name={key[0]}
|
name={dataPoint.label}
|
||||||
type="monotoneX"
|
type="monotoneX"
|
||||||
fill={color}
|
fill={color}
|
||||||
fillOpacity={key[3]}
|
fillOpacity={dataPoint.opacity}
|
||||||
stroke={color}
|
stroke={color}
|
||||||
isAnimationActive={false}
|
isAnimationActive={false}
|
||||||
/>
|
/>
|
||||||
@@ -125,4 +87,5 @@ export default memo(function AreaChartDefault({
|
|||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})
|
}, [chartData.systemStats.length, yAxisWidth, maxToggled])
|
||||||
|
}
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
|
|||||||
setOpen(false)
|
setOpen(false)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Server className="me-2 h-4 w-4" />
|
<Server className="me-2 size-4" />
|
||||||
<span>{system.name}</span>
|
<span>{system.name}</span>
|
||||||
<CommandShortcut>{getHostDisplayValue(system)}</CommandShortcut>
|
<CommandShortcut>{getHostDisplayValue(system)}</CommandShortcut>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
@@ -86,7 +86,7 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
|
|||||||
setOpen(false)
|
setOpen(false)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<LayoutDashboard className="me-2 h-4 w-4" />
|
<LayoutDashboard className="me-2 size-4" />
|
||||||
<span>
|
<span>
|
||||||
<Trans>Dashboard</Trans>
|
<Trans>Dashboard</Trans>
|
||||||
</span>
|
</span>
|
||||||
@@ -100,7 +100,7 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
|
|||||||
setOpen(false)
|
setOpen(false)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SettingsIcon className="me-2 h-4 w-4" />
|
<SettingsIcon className="me-2 size-4" />
|
||||||
<span>
|
<span>
|
||||||
<Trans>Settings</Trans>
|
<Trans>Settings</Trans>
|
||||||
</span>
|
</span>
|
||||||
@@ -113,7 +113,7 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
|
|||||||
setOpen(false)
|
setOpen(false)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MailIcon className="me-2 h-4 w-4" />
|
<MailIcon className="me-2 size-4" />
|
||||||
<span>
|
<span>
|
||||||
<Trans>Notifications</Trans>
|
<Trans>Notifications</Trans>
|
||||||
</span>
|
</span>
|
||||||
@@ -125,19 +125,31 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
|
|||||||
setOpen(false)
|
setOpen(false)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<FingerprintIcon className="me-2 h-4 w-4" />
|
<FingerprintIcon className="me-2 size-4" />
|
||||||
<span>
|
<span>
|
||||||
<Trans>Tokens & Fingerprints</Trans>
|
<Trans>Tokens & Fingerprints</Trans>
|
||||||
</span>
|
</span>
|
||||||
{SettingsShortcut}
|
{SettingsShortcut}
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
|
<CommandItem
|
||||||
|
onSelect={() => {
|
||||||
|
navigate(getPagePath($router, "settings", { name: "alert-history" }))
|
||||||
|
setOpen(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LogsIcon className="me-2 size-4" />
|
||||||
|
<span>
|
||||||
|
<Trans>Alert History</Trans>
|
||||||
|
</span>
|
||||||
|
{SettingsShortcut}
|
||||||
|
</CommandItem>
|
||||||
<CommandItem
|
<CommandItem
|
||||||
keywords={["help", "oauth", "oidc"]}
|
keywords={["help", "oauth", "oidc"]}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
window.location.href = "https://beszel.dev/guide/what-is-beszel"
|
window.location.href = "https://beszel.dev/guide/what-is-beszel"
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<BookIcon className="me-2 h-4 w-4" />
|
<BookIcon className="me-2 size-4" />
|
||||||
<span>
|
<span>
|
||||||
<Trans>Documentation</Trans>
|
<Trans>Documentation</Trans>
|
||||||
</span>
|
</span>
|
||||||
@@ -155,7 +167,7 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
|
|||||||
window.open(prependBasePath("/_/"), "_blank")
|
window.open(prependBasePath("/_/"), "_blank")
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<UsersIcon className="me-2 h-4 w-4" />
|
<UsersIcon className="me-2 size-4" />
|
||||||
<span>
|
<span>
|
||||||
<Trans>Users</Trans>
|
<Trans>Users</Trans>
|
||||||
</span>
|
</span>
|
||||||
@@ -167,7 +179,7 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
|
|||||||
window.open(prependBasePath("/_/#/logs"), "_blank")
|
window.open(prependBasePath("/_/#/logs"), "_blank")
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<LogsIcon className="me-2 h-4 w-4" />
|
<LogsIcon className="me-2 size-4" />
|
||||||
<span>
|
<span>
|
||||||
<Trans>Logs</Trans>
|
<Trans>Logs</Trans>
|
||||||
</span>
|
</span>
|
||||||
@@ -179,7 +191,7 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
|
|||||||
window.open(prependBasePath("/_/#/settings/backups"), "_blank")
|
window.open(prependBasePath("/_/#/settings/backups"), "_blank")
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DatabaseBackupIcon className="me-2 h-4 w-4" />
|
<DatabaseBackupIcon className="me-2 size-4" />
|
||||||
<span>
|
<span>
|
||||||
<Trans>Backups</Trans>
|
<Trans>Backups</Trans>
|
||||||
</span>
|
</span>
|
||||||
@@ -192,7 +204,7 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
|
|||||||
window.open(prependBasePath("/_/#/settings/mail"), "_blank")
|
window.open(prependBasePath("/_/#/settings/mail"), "_blank")
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MailIcon className="me-2 h-4 w-4" />
|
<MailIcon className="me-2 size-4" />
|
||||||
<span>
|
<span>
|
||||||
<Trans>SMTP settings</Trans>
|
<Trans>SMTP settings</Trans>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -110,10 +110,14 @@ const ActiveAlerts = memo(({ activeAlerts }: { activeAlerts: AlertRecord[] }) =>
|
|||||||
{alert.sysname} {info.name().toLowerCase().replace("cpu", "CPU")}
|
{alert.sysname} {info.name().toLowerCase().replace("cpu", "CPU")}
|
||||||
</AlertTitle>
|
</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
|
{alert.name === "Status" ? (
|
||||||
|
<Trans>Connection is down</Trans>
|
||||||
|
) : (
|
||||||
<Trans>
|
<Trans>
|
||||||
Exceeds {alert.value}
|
Exceeds {alert.value}
|
||||||
{info.unit} in last <Plural value={alert.min} one="# minute" other="# minutes" />
|
{info.unit} in last <Plural value={alert.min} one="# minute" other="# minutes" />
|
||||||
</Trans>
|
</Trans>
|
||||||
|
)}
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
<Link
|
<Link
|
||||||
href={getPagePath($router, "system", { name: alert.sysname! })}
|
href={getPagePath($router, "system", { name: alert.sysname! })}
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
"use client"
|
import { pb } from "@/lib/stores"
|
||||||
|
import { alertInfo, cn, formatDuration, formatShortDate } from "@/lib/utils"
|
||||||
import * as React from "react"
|
|
||||||
import { useStore } from "@nanostores/react"
|
|
||||||
import { $alertsHistory, pb } from "@/lib/stores"
|
|
||||||
import { AlertsHistoryRecord } from "@/types"
|
import { AlertsHistoryRecord } from "@/types"
|
||||||
import {
|
import {
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
@@ -16,52 +13,112 @@ import {
|
|||||||
VisibilityState,
|
VisibilityState,
|
||||||
} from "@tanstack/react-table"
|
} from "@tanstack/react-table"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { alertsHistoryColumns } from "../../alerts-history-columns"
|
import { alertsHistoryColumns } from "../../alerts-history-columns"
|
||||||
import { Checkbox } from "@/components/ui/checkbox"
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
import { toast } from "sonner"
|
import { memo, useEffect, useState } from "react"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select"
|
||||||
|
import {
|
||||||
|
ChevronLeftIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
ChevronsLeftIcon,
|
||||||
|
ChevronsRightIcon,
|
||||||
|
DownloadIcon,
|
||||||
|
Trash2Icon,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { Trans } from "@lingui/react/macro"
|
||||||
|
import { t } from "@lingui/core/macro"
|
||||||
|
import { useToast } from "@/components/ui/use-toast"
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from "@/components/ui/alert-dialog"
|
||||||
|
|
||||||
|
const SectionIntro = memo(() => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-medium mb-2">
|
||||||
|
<Trans>Alert History</Trans>
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||||
|
<Trans>View your 200 most recent alerts.</Trans>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
export default function AlertsHistoryDataTable() {
|
export default function AlertsHistoryDataTable() {
|
||||||
const alertsHistory = useStore($alertsHistory)
|
const [data, setData] = useState<AlertsHistoryRecord[]>([])
|
||||||
|
const [sorting, setSorting] = useState<SortingState>([])
|
||||||
|
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
||||||
|
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
|
||||||
|
const [rowSelection, setRowSelection] = useState({})
|
||||||
|
const [globalFilter, setGlobalFilter] = useState("")
|
||||||
|
const { toast } = useToast()
|
||||||
|
const [deleteOpen, setDeleteDialogOpen] = useState(false)
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
|
let unsubscribe: (() => void) | undefined
|
||||||
|
const pbOptions = {
|
||||||
|
expand: "system",
|
||||||
|
fields: "id,name,value,state,created,resolved,expand.system.name",
|
||||||
|
}
|
||||||
|
// Initial load
|
||||||
pb.collection<AlertsHistoryRecord>("alerts_history")
|
pb.collection<AlertsHistoryRecord>("alerts_history")
|
||||||
.getFullList({
|
.getFullList({
|
||||||
sort: "-created_date",
|
...pbOptions,
|
||||||
expand: "system,user,alert"
|
sort: "-created",
|
||||||
})
|
|
||||||
.then(records => {
|
|
||||||
$alertsHistory.set(records)
|
|
||||||
})
|
})
|
||||||
|
.then((records) => setData(records))
|
||||||
|
|
||||||
|
// Subscribe to changes
|
||||||
|
;(async () => {
|
||||||
|
unsubscribe = await pb.collection("alerts_history").subscribe(
|
||||||
|
"*",
|
||||||
|
(e) => {
|
||||||
|
if (e.action === "create") {
|
||||||
|
setData((current) => [e.record as AlertsHistoryRecord, ...current])
|
||||||
|
}
|
||||||
|
if (e.action === "update") {
|
||||||
|
setData((current) => current.map((r) => (r.id === e.record.id ? (e.record as AlertsHistoryRecord) : r)))
|
||||||
|
}
|
||||||
|
if (e.action === "delete") {
|
||||||
|
setData((current) => current.filter((r) => r.id !== e.record.id))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
pbOptions
|
||||||
|
)
|
||||||
|
})()
|
||||||
|
// Unsubscribe on unmount
|
||||||
|
return () => unsubscribe?.()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const [sorting, setSorting] = React.useState<SortingState>([])
|
|
||||||
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([])
|
|
||||||
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({})
|
|
||||||
const [rowSelection, setRowSelection] = React.useState({})
|
|
||||||
const [combinedFilter, setCombinedFilter] = React.useState("")
|
|
||||||
const [globalFilter, setGlobalFilter] = React.useState("")
|
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: alertsHistory,
|
data,
|
||||||
columns: [
|
columns: [
|
||||||
{
|
{
|
||||||
id: "select",
|
id: "select",
|
||||||
header: ({ table }) => (
|
header: ({ table }) => (
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={
|
className="ms-2"
|
||||||
table.getIsAllPageRowsSelected() ||
|
checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")}
|
||||||
(table.getIsSomePageRowsSelected() && "indeterminate")
|
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||||
}
|
|
||||||
onCheckedChange={value => table.toggleAllPageRowsSelected(!!value)}
|
|
||||||
aria-label="Select all"
|
aria-label="Select all"
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={row.getIsSelected()}
|
checked={row.getIsSelected()}
|
||||||
onCheckedChange={value => row.toggleSelected(!!value)}
|
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||||
aria-label="Select row"
|
aria-label="Select row"
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
@@ -78,15 +135,6 @@ export default function AlertsHistoryDataTable() {
|
|||||||
onColumnFiltersChange: setColumnFilters,
|
onColumnFiltersChange: setColumnFilters,
|
||||||
onColumnVisibilityChange: setColumnVisibility,
|
onColumnVisibilityChange: setColumnVisibility,
|
||||||
onRowSelectionChange: setRowSelection,
|
onRowSelectionChange: setRowSelection,
|
||||||
globalFilterFn: (row, _columnId, filterValue) => {
|
|
||||||
const system = row.original.expand?.system?.name || row.original.system || ""
|
|
||||||
const name = row.getValue("name") || ""
|
|
||||||
const search = String(filterValue).toLowerCase()
|
|
||||||
return (
|
|
||||||
system.toLowerCase().includes(search) ||
|
|
||||||
String(name).toLowerCase().includes(search)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
state: {
|
state: {
|
||||||
sorting,
|
sorting,
|
||||||
columnFilters,
|
columnFilters,
|
||||||
@@ -95,18 +143,43 @@ export default function AlertsHistoryDataTable() {
|
|||||||
globalFilter,
|
globalFilter,
|
||||||
},
|
},
|
||||||
onGlobalFilterChange: setGlobalFilter,
|
onGlobalFilterChange: setGlobalFilter,
|
||||||
|
globalFilterFn: (row, _columnId, filterValue) => {
|
||||||
|
const system = row.original.expand?.system?.name ?? ""
|
||||||
|
const name = row.getValue("name") ?? ""
|
||||||
|
const created = row.getValue("created") ?? ""
|
||||||
|
const search = String(filterValue).toLowerCase()
|
||||||
|
return (
|
||||||
|
system.toLowerCase().includes(search) ||
|
||||||
|
(name as string).toLowerCase().includes(search) ||
|
||||||
|
(created as string).toLowerCase().includes(search)
|
||||||
|
)
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Bulk delete handler
|
// Bulk delete handler
|
||||||
const handleBulkDelete = async () => {
|
const handleBulkDelete = async () => {
|
||||||
if (!window.confirm("Are you sure you want to delete the selected records?")) return
|
setDeleteDialogOpen(false)
|
||||||
const selectedIds = table.getSelectedRowModel().rows.map(row => row.original.id)
|
const selectedIds = table.getSelectedRowModel().rows.map((row) => row.original.id)
|
||||||
try {
|
try {
|
||||||
await Promise.all(selectedIds.map(id => pb.collection("alerts_history").delete(id)))
|
let batch = pb.createBatch()
|
||||||
$alertsHistory.set(alertsHistory.filter(r => !selectedIds.includes(r.id)))
|
let inBatch = 0
|
||||||
toast.success("Deleted selected records.")
|
for (const id of selectedIds) {
|
||||||
|
batch.collection("alerts_history").delete(id)
|
||||||
|
inBatch++
|
||||||
|
if (inBatch > 20) {
|
||||||
|
await batch.send()
|
||||||
|
batch = pb.createBatch()
|
||||||
|
inBatch = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
inBatch && (await batch.send())
|
||||||
|
table.resetRowSelection()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error("Failed to delete some records.")
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: t`Error`,
|
||||||
|
description: `Failed to delete records.`,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,34 +187,23 @@ export default function AlertsHistoryDataTable() {
|
|||||||
const handleExportCSV = () => {
|
const handleExportCSV = () => {
|
||||||
const selectedRows = table.getSelectedRowModel().rows
|
const selectedRows = table.getSelectedRowModel().rows
|
||||||
if (!selectedRows.length) return
|
if (!selectedRows.length) return
|
||||||
const headers = ["system", "name", "value", "state", "created_date", "solved_date", "duration"]
|
const cells: Record<string, (record: AlertsHistoryRecord) => string> = {
|
||||||
const csvRows = [headers.join(",")]
|
system: (record) => record.expand?.system?.name || record.system,
|
||||||
|
name: (record) => alertInfo[record.name]?.name() || record.name,
|
||||||
|
value: (record) => record.value + (alertInfo[record.name]?.unit ?? ""),
|
||||||
|
state: (record) => (record.resolved ? t`Resolved` : t`Active`),
|
||||||
|
created: (record) => formatShortDate(record.created),
|
||||||
|
resolved: (record) => (record.resolved ? formatShortDate(record.resolved) : ""),
|
||||||
|
duration: (record) => (record.resolved ? formatDuration(record.created, record.resolved) : ""),
|
||||||
|
}
|
||||||
|
const csvRows = [Object.keys(cells).join(",")]
|
||||||
for (const row of selectedRows) {
|
for (const row of selectedRows) {
|
||||||
const r = row.original
|
const r = row.original
|
||||||
csvRows.push([
|
csvRows.push(
|
||||||
r.expand?.system?.name || r.system,
|
Object.values(cells)
|
||||||
r.name,
|
.map((val) => val(r))
|
||||||
r.value,
|
.join(",")
|
||||||
r.state,
|
)
|
||||||
r.created_date,
|
|
||||||
r.solved_date,
|
|
||||||
(() => {
|
|
||||||
const created = r.created_date ? new Date(r.created_date) : null
|
|
||||||
const solved = r.solved_date ? new Date(r.solved_date) : null
|
|
||||||
if (!created || !solved) return ""
|
|
||||||
const diffMs = solved.getTime() - created.getTime()
|
|
||||||
if (diffMs < 0) return ""
|
|
||||||
const totalSeconds = Math.floor(diffMs / 1000)
|
|
||||||
const hours = Math.floor(totalSeconds / 3600)
|
|
||||||
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
|
||||||
const seconds = totalSeconds % 60
|
|
||||||
return [
|
|
||||||
hours ? `${hours}h` : null,
|
|
||||||
minutes ? `${minutes}m` : null,
|
|
||||||
`${seconds}s`
|
|
||||||
].filter(Boolean).join(" ")
|
|
||||||
})()
|
|
||||||
].map(v => `"${v ?? ""}"`).join(","))
|
|
||||||
}
|
}
|
||||||
const blob = new Blob([csvRows.join("\n")], { type: "text/csv" })
|
const blob = new Blob([csvRows.join("\n")], { type: "text/csv" })
|
||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
@@ -153,43 +215,67 @@ export default function AlertsHistoryDataTable() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="@container w-full">
|
||||||
<div className="flex items-center py-4 gap-4">
|
<div className="@3xl:flex items-end mb-4 gap-4">
|
||||||
<Input
|
<SectionIntro />
|
||||||
placeholder="Filter system or name..."
|
<div className="flex items-center gap-2 ms-auto mt-3 @3xl:mt-0">
|
||||||
value={globalFilter}
|
|
||||||
onChange={e => setGlobalFilter(e.target.value)}
|
|
||||||
className="max-w-sm"
|
|
||||||
/>
|
|
||||||
{table.getFilteredSelectedRowModel().rows.length > 0 && (
|
{table.getFilteredSelectedRowModel().rows.length > 0 && (
|
||||||
<>
|
<div className="fixed bottom-0 left-0 w-full p-4 grid grid-cols-2 items-center gap-4 z-50 backdrop-blur-md shrink-0 @lg:static @lg:p-0 @lg:w-auto @lg:gap-3">
|
||||||
<Button
|
<AlertDialog open={deleteOpen} onOpenChange={(open) => setDeleteDialogOpen(open)}>
|
||||||
variant="destructive"
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant="destructive" className="h-9 shrink-0">
|
||||||
|
<Trash2Icon className="size-4 shrink-0" />
|
||||||
|
<span className="ms-1">
|
||||||
|
<Trans>Delete</Trans>
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
<Trans>Are you sure?</Trans>
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
<Trans>This will permanently delete all selected records from the database.</Trans>
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
className={cn(buttonVariants({ variant: "destructive" }))}
|
||||||
onClick={handleBulkDelete}
|
onClick={handleBulkDelete}
|
||||||
size="sm"
|
|
||||||
>
|
>
|
||||||
Delete Selected
|
<Trans>Continue</Trans>
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
<Button variant="outline" className="h-10" onClick={handleExportCSV}>
|
||||||
|
<DownloadIcon className="size-4" />
|
||||||
|
<span className="ms-1">
|
||||||
|
<Trans>Export</Trans>
|
||||||
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleExportCSV}
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
Export Selected
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-md border overflow-x-auto">
|
)}
|
||||||
|
<Input
|
||||||
|
placeholder={t`Filter...`}
|
||||||
|
value={globalFilter}
|
||||||
|
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||||
|
className="px-4 w-full max-w-full @3xl:w-64"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border overflow-x-auto whitespace-nowrap">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
{table.getHeaderGroups().map(headerGroup => (
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
<TableRow key={headerGroup.id}>
|
<TableRow key={headerGroup.id}>
|
||||||
{headerGroup.headers.map(header => (
|
{headerGroup.headers.map((header) => (
|
||||||
<TableHead key={header.id}>
|
<TableHead className="px-2" key={header.id}>
|
||||||
{header.isPlaceholder
|
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
? null
|
|
||||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
|
||||||
</TableHead>
|
</TableHead>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -197,13 +283,10 @@ export default function AlertsHistoryDataTable() {
|
|||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{table.getRowModel().rows.length ? (
|
{table.getRowModel().rows.length ? (
|
||||||
table.getRowModel().rows.map(row => (
|
table.getRowModel().rows.map((row) => (
|
||||||
<TableRow
|
<TableRow key={row.id} data-state={row.getIsSelected() && "selected"}>
|
||||||
key={row.id}
|
{row.getVisibleCells().map((cell) => (
|
||||||
data-state={row.getIsSelected() && "selected"}
|
<TableCell key={cell.id} className="py-3">
|
||||||
>
|
|
||||||
{row.getVisibleCells().map(cell => (
|
|
||||||
<TableCell key={cell.id}>
|
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
))}
|
))}
|
||||||
@@ -212,34 +295,89 @@ export default function AlertsHistoryDataTable() {
|
|||||||
) : (
|
) : (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={table.getAllColumns().length} className="h-24 text-center">
|
<TableCell colSpan={table.getAllColumns().length} className="h-24 text-center">
|
||||||
No results.
|
<Trans>No results.</Trans>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-end space-x-2 py-4">
|
<div className="flex items-center justify-between ps-1 tabular-nums">
|
||||||
<div className="text-muted-foreground flex-1 text-sm">
|
<div className="text-muted-foreground hidden flex-1 text-sm lg:flex">
|
||||||
{table.getFilteredSelectedRowModel().rows.length} of {table.getFilteredRowModel().rows.length} row(s) selected.
|
<Trans>
|
||||||
|
{table.getFilteredSelectedRowModel().rows.length} of {table.getFilteredRowModel().rows.length} row(s)
|
||||||
|
selected.
|
||||||
|
</Trans>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-x-2">
|
<div className="flex w-full items-center gap-8 lg:w-fit my-3">
|
||||||
|
<div className="hidden items-center gap-2 lg:flex">
|
||||||
|
<Label htmlFor="rows-per-page" className="text-sm font-medium">
|
||||||
|
<Trans>Rows per page</Trans>
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={`${table.getState().pagination.pageSize}`}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
table.setPageSize(Number(value))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[4.8em]" id="rows-per-page">
|
||||||
|
<SelectValue placeholder={table.getState().pagination.pageSize} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent side="top">
|
||||||
|
{[10, 20, 50, 100, 200].map((pageSize) => (
|
||||||
|
<SelectItem key={pageSize} value={`${pageSize}`}>
|
||||||
|
{pageSize}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-fit items-center justify-center text-sm font-medium">
|
||||||
|
<Trans>
|
||||||
|
Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
|
||||||
|
</Trans>
|
||||||
|
</div>
|
||||||
|
<div className="ms-auto flex items-center gap-2 lg:ms-0">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
className="hidden size-9 p-0 lg:flex"
|
||||||
|
onClick={() => table.setPageIndex(0)}
|
||||||
|
disabled={!table.getCanPreviousPage()}
|
||||||
|
>
|
||||||
|
<span className="sr-only">Go to first page</span>
|
||||||
|
<ChevronsLeftIcon className="size-5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="size-9"
|
||||||
|
size="icon"
|
||||||
onClick={() => table.previousPage()}
|
onClick={() => table.previousPage()}
|
||||||
disabled={!table.getCanPreviousPage()}
|
disabled={!table.getCanPreviousPage()}
|
||||||
>
|
>
|
||||||
Previous
|
<span className="sr-only">Go to previous page</span>
|
||||||
|
<ChevronLeftIcon className="size-5" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
className="size-9"
|
||||||
|
size="icon"
|
||||||
onClick={() => table.nextPage()}
|
onClick={() => table.nextPage()}
|
||||||
disabled={!table.getCanNextPage()}
|
disabled={!table.getCanNextPage()}
|
||||||
>
|
>
|
||||||
Next
|
<span className="sr-only">Go to next page</span>
|
||||||
|
<ChevronRightIcon className="size-5" />
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="hidden size-9 lg:flex"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||||
|
disabled={!table.getCanNextPage()}
|
||||||
|
>
|
||||||
|
<span className="sr-only">Go to last page</span>
|
||||||
|
<ChevronsRightIcon className="size-5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,16 +17,11 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
|
|||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const { i18n } = useLingui()
|
const { i18n } = useLingui()
|
||||||
|
|
||||||
// Add state for alert history retention
|
|
||||||
const [alertHistoryRetention, setAlertHistoryRetention] = useState(userSettings.alertHistoryRetention || "3m")
|
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
const formData = new FormData(e.target as HTMLFormElement)
|
const formData = new FormData(e.target as HTMLFormElement)
|
||||||
const data = Object.fromEntries(formData) as Partial<UserSettings>
|
const data = Object.fromEntries(formData) as Partial<UserSettings>
|
||||||
// Add alertHistoryRetention to data
|
|
||||||
data.alertHistoryRetention = alertHistoryRetention
|
|
||||||
await saveSettings(data)
|
await saveSettings(data)
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
@@ -187,27 +182,6 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
<div>
|
|
||||||
<Label htmlFor="alertHistoryRetention">
|
|
||||||
<Trans>Alert History Retention</Trans>
|
|
||||||
</Label>
|
|
||||||
<Select
|
|
||||||
name="alertHistoryRetention"
|
|
||||||
value={alertHistoryRetention}
|
|
||||||
onValueChange={setAlertHistoryRetention}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-64 mt-1">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="1m">1 month</SelectItem>
|
|
||||||
<SelectItem value="3m">3 months</SelectItem>
|
|
||||||
<SelectItem value="6m">6 months</SelectItem>
|
|
||||||
<SelectItem value="1y">1 year</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<Separator />
|
|
||||||
<Button type="submit" className="flex items-center gap-1.5 disabled:opacity-100" disabled={isLoading}>
|
<Button type="submit" className="flex items-center gap-1.5 disabled:opacity-100" disabled={isLoading}>
|
||||||
{isLoading ? <LoaderCircleIcon className="h-4 w-4 animate-spin" /> : <SaveIcon className="h-4 w-4" />}
|
{isLoading ? <LoaderCircleIcon className="h-4 w-4 animate-spin" /> : <SaveIcon className="h-4 w-4" />}
|
||||||
<Trans>Save Settings</Trans>
|
<Trans>Save Settings</Trans>
|
||||||
|
|||||||
@@ -66,17 +66,17 @@ export default function SettingsLayout() {
|
|||||||
icon: FingerprintIcon,
|
icon: FingerprintIcon,
|
||||||
noReadOnly: true,
|
noReadOnly: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: t`Alert History`,
|
||||||
|
href: getPagePath($router, "settings", { name: "alert-history" }),
|
||||||
|
icon: LogsIcon,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: t`YAML Config`,
|
title: t`YAML Config`,
|
||||||
href: getPagePath($router, "settings", { name: "config" }),
|
href: getPagePath($router, "settings", { name: "config" }),
|
||||||
icon: FileSlidersIcon,
|
icon: FileSlidersIcon,
|
||||||
admin: true,
|
admin: true,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: t`Alerts History`,
|
|
||||||
href: getPagePath($router, "settings", { name: "alerts-history" }),
|
|
||||||
icon: LogsIcon,
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
const page = useStore($router)
|
const page = useStore($router)
|
||||||
@@ -127,7 +127,7 @@ function SettingsContent({ name }: { name: string }) {
|
|||||||
return <ConfigYaml />
|
return <ConfigYaml />
|
||||||
case "tokens":
|
case "tokens":
|
||||||
return <Fingerprints />
|
return <Fingerprints />
|
||||||
case "alerts-history":
|
case "alert-history":
|
||||||
return <AlertsHistoryDataTable />
|
return <AlertsHistoryDataTable />
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export function SidebarNav({ className, items, ...props }: SidebarNavProps) {
|
|||||||
return (
|
return (
|
||||||
<SelectItem key={item.href} value={item.href}>
|
<SelectItem key={item.href} value={item.href}>
|
||||||
<span className="flex items-center gap-2 truncate">
|
<span className="flex items-center gap-2 truncate">
|
||||||
{item.icon && <item.icon className="h-4 w-4" />}
|
{item.icon && <item.icon className="size-4" />}
|
||||||
<span className="truncate">{item.title}</span>
|
<span className="truncate">{item.title}</span>
|
||||||
</span>
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
@@ -45,7 +45,7 @@ export function SidebarNav({ className, items, ...props }: SidebarNavProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Desktop View */}
|
{/* Desktop View */}
|
||||||
<nav className={cn("hidden md:grid gap-1", className)} {...props}>
|
<nav className={cn("hidden md:grid gap-1 sticky top-6", className)} {...props}>
|
||||||
{items.map((item) => {
|
{items.map((item) => {
|
||||||
if ((item.admin && !isAdmin()) || (item.noReadOnly && isReadOnlyUser())) {
|
if ((item.admin && !isAdmin()) || (item.noReadOnly && isReadOnlyUser())) {
|
||||||
return null
|
return null
|
||||||
@@ -60,7 +60,7 @@ export function SidebarNav({ className, items, ...props }: SidebarNavProps) {
|
|||||||
page?.path === item.href ? "bg-muted hover:bg-muted" : "hover:bg-muted/50"
|
page?.path === item.href ? "bg-muted hover:bg-muted" : "hover:bg-muted/50"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{item.icon && <item.icon className="h-4 w-4 shrink-0" />}
|
{item.icon && <item.icon className="size-4 shrink-0" />}
|
||||||
<span className="truncate">{item.title}</span>
|
<span className="truncate">{item.title}</span>
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ const SectionUniversalToken = memo(() => {
|
|||||||
or on hub restart.
|
or on hub restart.
|
||||||
</Trans>
|
</Trans>
|
||||||
</p>
|
</p>
|
||||||
<div className="min-h-16 overflow-auto max-w-full inline-flex items-center gap-5 mt-3 border py-2 pl-5 pr-4 rounded-md">
|
<div className="min-h-16 overflow-auto max-w-full inline-flex items-center gap-5 mt-3 border py-2 ps-5 pe-4 rounded-md">
|
||||||
{!isLoading && (
|
{!isLoading && (
|
||||||
<>
|
<>
|
||||||
<Switch
|
<Switch
|
||||||
|
|||||||
@@ -371,6 +371,7 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
|
|
||||||
// select field for switching between avg and max values
|
// select field for switching between avg and max values
|
||||||
const maxValSelect = isLongerChart ? <SelectAvgMax max={maxValues} /> : null
|
const maxValSelect = isLongerChart ? <SelectAvgMax max={maxValues} /> : null
|
||||||
|
const showMax = chartTime !== "1h" && maxValues
|
||||||
|
|
||||||
// if no data, show empty message
|
// if no data, show empty message
|
||||||
const dataEmpty = !chartLoading && chartData.systemStats.length === 0
|
const dataEmpty = !chartLoading && chartData.systemStats.length === 0
|
||||||
@@ -477,8 +478,15 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
>
|
>
|
||||||
<AreaChartDefault
|
<AreaChartDefault
|
||||||
chartData={chartData}
|
chartData={chartData}
|
||||||
chartName="CPU Usage"
|
|
||||||
maxToggled={maxValues}
|
maxToggled={maxValues}
|
||||||
|
dataPoints={[
|
||||||
|
{
|
||||||
|
label: t`CPU Usage`,
|
||||||
|
dataKey: ({ stats }) => (showMax ? stats?.cpum : stats?.cpu),
|
||||||
|
color: "1",
|
||||||
|
opacity: 0.4,
|
||||||
|
},
|
||||||
|
]}
|
||||||
tickFormatter={(val) => toFixedFloat(val, 2) + "%"}
|
tickFormatter={(val) => toFixedFloat(val, 2) + "%"}
|
||||||
contentFormatter={({ value }) => decimalString(value) + "%"}
|
contentFormatter={({ value }) => decimalString(value) + "%"}
|
||||||
/>
|
/>
|
||||||
@@ -530,8 +538,21 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
>
|
>
|
||||||
<AreaChartDefault
|
<AreaChartDefault
|
||||||
chartData={chartData}
|
chartData={chartData}
|
||||||
chartName="dio"
|
|
||||||
maxToggled={maxValues}
|
maxToggled={maxValues}
|
||||||
|
dataPoints={[
|
||||||
|
{
|
||||||
|
label: t({ message: "Write", comment: "Disk write" }),
|
||||||
|
dataKey: ({ stats }) => (showMax ? stats?.dwm : stats?.dw),
|
||||||
|
color: "3",
|
||||||
|
opacity: 0.3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t({ message: "Read", comment: "Disk read" }),
|
||||||
|
dataKey: ({ stats }) => (showMax ? stats?.drm : stats?.dr),
|
||||||
|
color: "1",
|
||||||
|
opacity: 0.3,
|
||||||
|
},
|
||||||
|
]}
|
||||||
tickFormatter={(val) => {
|
tickFormatter={(val) => {
|
||||||
const { value, unit } = formatBytes(val, true, userSettings.unitDisk, true)
|
const { value, unit } = formatBytes(val, true, userSettings.unitDisk, true)
|
||||||
return toFixedFloat(value, value >= 10 ? 0 : 1) + " " + unit
|
return toFixedFloat(value, value >= 10 ? 0 : 1) + " " + unit
|
||||||
@@ -552,15 +573,39 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
>
|
>
|
||||||
<AreaChartDefault
|
<AreaChartDefault
|
||||||
chartData={chartData}
|
chartData={chartData}
|
||||||
chartName="bw"
|
|
||||||
maxToggled={maxValues}
|
maxToggled={maxValues}
|
||||||
|
dataPoints={[
|
||||||
|
{
|
||||||
|
label: t`Sent`,
|
||||||
|
// use bytes if available, otherwise multiply old MB (can remove in future)
|
||||||
|
dataKey(data) {
|
||||||
|
if (showMax) {
|
||||||
|
return data?.stats?.bm?.[0] ?? (data?.stats?.nsm ?? 0) * 1024 * 1024
|
||||||
|
}
|
||||||
|
return data?.stats?.b?.[0] ?? data?.stats?.ns * 1024 * 1024
|
||||||
|
},
|
||||||
|
color: "5",
|
||||||
|
opacity: 0.2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t`Received`,
|
||||||
|
dataKey(data) {
|
||||||
|
if (showMax) {
|
||||||
|
return data?.stats?.bm?.[1] ?? (data?.stats?.nrm ?? 0) * 1024 * 1024
|
||||||
|
}
|
||||||
|
return data?.stats?.b?.[1] ?? data?.stats?.nr * 1024 * 1024
|
||||||
|
},
|
||||||
|
color: "2",
|
||||||
|
opacity: 0.2,
|
||||||
|
},
|
||||||
|
]}
|
||||||
tickFormatter={(val) => {
|
tickFormatter={(val) => {
|
||||||
let { value, unit } = formatBytes(val, true, userSettings.unitNet, true)
|
let { value, unit } = formatBytes(val, true, userSettings.unitNet, false)
|
||||||
return toFixedFloat(value, value >= 10 ? 0 : 1) + " " + unit
|
return toFixedFloat(value, value >= 10 ? 0 : 1) + " " + unit
|
||||||
}}
|
}}
|
||||||
contentFormatter={({ value }) => {
|
contentFormatter={(data) => {
|
||||||
const { value: convertedValue, unit } = formatBytes(value, true, userSettings.unitNet, true)
|
const { value, unit } = formatBytes(data.value, true, userSettings.unitNet, false)
|
||||||
return decimalString(convertedValue, convertedValue >= 100 ? 1 : 2) + " " + unit
|
return decimalString(value, value >= 100 ? 1 : 2) + " " + unit
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
@@ -649,7 +694,14 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
>
|
>
|
||||||
<AreaChartDefault
|
<AreaChartDefault
|
||||||
chartData={chartData}
|
chartData={chartData}
|
||||||
chartName={`g.${id}.u`}
|
dataPoints={[
|
||||||
|
{
|
||||||
|
label: t`Usage`,
|
||||||
|
dataKey: ({ stats }) => stats?.g?.[id]?.u ?? 0,
|
||||||
|
color: "1",
|
||||||
|
opacity: 0.35,
|
||||||
|
},
|
||||||
|
]}
|
||||||
tickFormatter={(val) => toFixedFloat(val, 2) + "%"}
|
tickFormatter={(val) => toFixedFloat(val, 2) + "%"}
|
||||||
contentFormatter={({ value }) => decimalString(value) + "%"}
|
contentFormatter={({ value }) => decimalString(value) + "%"}
|
||||||
/>
|
/>
|
||||||
@@ -662,7 +714,14 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
>
|
>
|
||||||
<AreaChartDefault
|
<AreaChartDefault
|
||||||
chartData={chartData}
|
chartData={chartData}
|
||||||
chartName={`g.${id}.mu`}
|
dataPoints={[
|
||||||
|
{
|
||||||
|
label: t`Usage`,
|
||||||
|
dataKey: ({ stats }) => stats?.g?.[id]?.mu ?? 0,
|
||||||
|
color: "2",
|
||||||
|
opacity: 0.25,
|
||||||
|
},
|
||||||
|
]}
|
||||||
max={gpu.mt}
|
max={gpu.mt}
|
||||||
tickFormatter={(val) => {
|
tickFormatter={(val) => {
|
||||||
const { value, unit } = formatBytes(val, false, Unit.Bytes, true)
|
const { value, unit } = formatBytes(val, false, Unit.Bytes, true)
|
||||||
@@ -707,7 +766,20 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
>
|
>
|
||||||
<AreaChartDefault
|
<AreaChartDefault
|
||||||
chartData={chartData}
|
chartData={chartData}
|
||||||
chartName={`efs.${extraFsName}`}
|
dataPoints={[
|
||||||
|
{
|
||||||
|
label: t`Write`,
|
||||||
|
dataKey: ({ stats }) => stats?.efs?.[extraFsName]?.[showMax ? "wm" : "w"] ?? 0,
|
||||||
|
color: "3",
|
||||||
|
opacity: 0.3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t`Read`,
|
||||||
|
dataKey: ({ stats }) => stats?.efs?.[extraFsName]?.[showMax ? "rm" : "r"] ?? 0,
|
||||||
|
color: "1",
|
||||||
|
opacity: 0.3,
|
||||||
|
},
|
||||||
|
]}
|
||||||
maxToggled={maxValues}
|
maxToggled={maxValues}
|
||||||
tickFormatter={(val) => {
|
tickFormatter={(val) => {
|
||||||
const { value, unit } = formatBytes(val, true, userSettings.unitDisk, true)
|
const { value, unit } = formatBytes(val, true, userSettings.unitDisk, true)
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ export default function SystemsTable() {
|
|||||||
invertSorting: false,
|
invertSorting: false,
|
||||||
Icon: ServerIcon,
|
Icon: ServerIcon,
|
||||||
cell: (info) => (
|
cell: (info) => (
|
||||||
<span className="flex gap-0.5 items-center text-base md:pe-5">
|
<span className="flex gap-0.5 items-center text-base md:ps-1 md:pe-5">
|
||||||
<IndicatorDot system={info.row.original} />
|
<IndicatorDot system={info.row.original} />
|
||||||
<Button
|
<Button
|
||||||
data-nolink
|
data-nolink
|
||||||
@@ -233,7 +233,7 @@ export default function SystemsTable() {
|
|||||||
header: sortableHeader,
|
header: sortableHeader,
|
||||||
cell(info: CellContext<SystemRecord, unknown>) {
|
cell(info: CellContext<SystemRecord, unknown>) {
|
||||||
const { info: sysInfo, status } = info.row.original
|
const { info: sysInfo, status } = info.row.original
|
||||||
if (sysInfo.l1 == undefined) {
|
if (sysInfo.l1 === undefined) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,13 +245,13 @@ export default function SystemsTable() {
|
|||||||
const normalized = max / cpuThreads
|
const normalized = max / cpuThreads
|
||||||
if (status !== "up") return "bg-primary/30"
|
if (status !== "up") return "bg-primary/30"
|
||||||
if (normalized < 0.7) return "bg-green-500"
|
if (normalized < 0.7) return "bg-green-500"
|
||||||
if (normalized < 1.0) return "bg-yellow-500"
|
if (normalized < 1) return "bg-yellow-500"
|
||||||
return "bg-red-600"
|
return "bg-red-600"
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 w-full tabular-nums tracking-tight">
|
<div className="flex items-center gap-[.35em] w-full tabular-nums tracking-tight">
|
||||||
<span className={cn("inline-block size-2 rounded-full", getDotColor())} />
|
<span className={cn("inline-block size-2 rounded-full me-0.5", getDotColor())} />
|
||||||
{loadAverages.map((la, i) => (
|
{loadAverages.map((la, i) => (
|
||||||
<span key={i}>{decimalString(la, la >= 10 ? 1 : 2)}</span>
|
<span key={i}>{decimalString(la, la >= 10 ? 1 : 2)}</span>
|
||||||
))}
|
))}
|
||||||
@@ -260,19 +260,19 @@ export default function SystemsTable() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorFn: (originalRow) => originalRow.info.b || 0,
|
accessorFn: ({ info }) => info.bb || (info.b || 0) * 1024 * 1024,
|
||||||
id: "net",
|
id: "net",
|
||||||
name: () => t`Net`,
|
name: () => t`Net`,
|
||||||
size: 0,
|
size: 0,
|
||||||
Icon: EthernetIcon,
|
Icon: EthernetIcon,
|
||||||
header: sortableHeader,
|
header: sortableHeader,
|
||||||
cell(info) {
|
cell(info) {
|
||||||
if (info.row.original.status !== "up") {
|
const sys = info.row.original
|
||||||
|
if (sys.status === "paused") {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
const val = info.getValue() as number
|
|
||||||
const userSettings = useStore($userSettings)
|
const userSettings = useStore($userSettings)
|
||||||
const { value, unit } = formatBytes(val, true, userSettings.unitNet, true)
|
const { value, unit } = formatBytes(info.getValue() as number, true, userSettings.unitNet, false)
|
||||||
return (
|
return (
|
||||||
<span className="tabular-nums whitespace-nowrap">
|
<span className="tabular-nums whitespace-nowrap">
|
||||||
{decimalString(value, value >= 100 ? 1 : 2)} {unit}
|
{decimalString(value, value >= 100 ? 1 : 2)} {unit}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||||
import { Check } from "lucide-react"
|
import { Check } from "lucide-react"
|
||||||
@@ -11,13 +13,14 @@ const Checkbox = React.forwardRef<
|
|||||||
<CheckboxPrimitive.Root
|
<CheckboxPrimitive.Root
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"peer h-4 w-4 shrink-0 rounded-[.3em] border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
"peer size-4 flex items-center justify-center shrink-0 rounded-[.3em] border border-input ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||||
|
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<CheckboxPrimitive.Indicator className={cn("flex items-center justify-center text-current")}>
|
<CheckboxPrimitive.Indicator className={cn("flex items-center justify-center text-current")}>
|
||||||
<Check className="h-4 w-4" />
|
<Check className="size-4" />
|
||||||
</CheckboxPrimitive.Indicator>
|
</CheckboxPrimitive.Indicator>
|
||||||
</CheckboxPrimitive.Root>
|
</CheckboxPrimitive.Root>
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTML
|
|||||||
<tr
|
<tr
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-b border-border/60 hover:bg-muted/40 dark:hover:bg-muted/20 data-[state=selected]:bg-muted",
|
"border-b border-border/60 hover:bg-muted/40 dark:hover:bg-muted/20 data-[state=selected]:!bg-muted",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -15,9 +15,6 @@ export const $systems = atom([] as SystemRecord[])
|
|||||||
/** List of alert records */
|
/** List of alert records */
|
||||||
export const $alerts = atom([] as AlertRecord[])
|
export const $alerts = atom([] as AlertRecord[])
|
||||||
|
|
||||||
/** List of alerts history records */
|
|
||||||
export const $alertsHistory = atom([] as AlertsHistoryRecord[])
|
|
||||||
|
|
||||||
/** SSH public key */
|
/** SSH public key */
|
||||||
export const $publicKey = atom("")
|
export const $publicKey = atom("")
|
||||||
|
|
||||||
|
|||||||
@@ -367,6 +367,7 @@ export async function updateUserSettings() {
|
|||||||
|
|
||||||
export const chartMargin = { top: 12 }
|
export const chartMargin = { top: 12 }
|
||||||
|
|
||||||
|
/** Alert info for each alert type */
|
||||||
export const alertInfo: Record<string, AlertInfo> = {
|
export const alertInfo: Record<string, AlertInfo> = {
|
||||||
Status: {
|
Status: {
|
||||||
name: () => t`Status`,
|
name: () => t`Status`,
|
||||||
@@ -455,3 +456,42 @@ export const getHubURL = () => BESZEL?.HUB_URL || window.location.origin
|
|||||||
|
|
||||||
/** Map of system IDs to their corresponding tokens (used to avoid fetching in add-system dialog) */
|
/** Map of system IDs to their corresponding tokens (used to avoid fetching in add-system dialog) */
|
||||||
export const tokenMap = new Map<SystemRecord["id"], FingerprintRecord["token"]>()
|
export const tokenMap = new Map<SystemRecord["id"], FingerprintRecord["token"]>()
|
||||||
|
|
||||||
|
/** Calculate duration between two dates and format as human-readable string */
|
||||||
|
export function formatDuration(
|
||||||
|
createdDate: string | null | undefined,
|
||||||
|
resolvedDate: string | null | undefined
|
||||||
|
): string {
|
||||||
|
const created = createdDate ? new Date(createdDate) : null
|
||||||
|
const resolved = resolvedDate ? new Date(resolvedDate) : null
|
||||||
|
|
||||||
|
if (!created || !resolved) return ""
|
||||||
|
|
||||||
|
const diffMs = resolved.getTime() - created.getTime()
|
||||||
|
if (diffMs < 0) return ""
|
||||||
|
|
||||||
|
const totalSeconds = Math.floor(diffMs / 1000)
|
||||||
|
let hours = Math.floor(totalSeconds / 3600)
|
||||||
|
let minutes = Math.floor((totalSeconds % 3600) / 60)
|
||||||
|
let seconds = totalSeconds % 60
|
||||||
|
|
||||||
|
// if seconds are close to 60, round up to next minute
|
||||||
|
// if minutes are close to 60, round up to next hour
|
||||||
|
if (seconds >= 58) {
|
||||||
|
minutes += 1
|
||||||
|
seconds = 0
|
||||||
|
}
|
||||||
|
if (minutes >= 60) {
|
||||||
|
hours += 1
|
||||||
|
minutes = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// For durations over 1 hour, omit seconds for cleaner display
|
||||||
|
if (hours > 0) {
|
||||||
|
return [hours ? `${hours}h` : null, minutes ? `${minutes}m` : null].filter(Boolean).join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
return [hours ? `${hours}h` : null, minutes ? `${minutes}m` : null, seconds ? `${seconds}s` : null]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")
|
||||||
|
}
|
||||||
|
|||||||
24
beszel/site/src/types.d.ts
vendored
24
beszel/site/src/types.d.ts
vendored
@@ -60,6 +60,8 @@ export interface SystemInfo {
|
|||||||
dp: number
|
dp: number
|
||||||
/** bandwidth (mb) */
|
/** bandwidth (mb) */
|
||||||
b: number
|
b: number
|
||||||
|
/** bandwidth bytes */
|
||||||
|
bb?: number
|
||||||
/** agent version */
|
/** agent version */
|
||||||
v: string
|
v: string
|
||||||
/** system is using podman */
|
/** system is using podman */
|
||||||
@@ -115,10 +117,14 @@ export interface SystemStats {
|
|||||||
ns: number
|
ns: number
|
||||||
/** network received (mb) */
|
/** network received (mb) */
|
||||||
nr: number
|
nr: number
|
||||||
|
/** bandwidth bytes [sent, recv] */
|
||||||
|
b?: [number, number]
|
||||||
/** max network sent (mb) */
|
/** max network sent (mb) */
|
||||||
nsm?: number
|
nsm?: number
|
||||||
/** max network received (mb) */
|
/** max network received (mb) */
|
||||||
nrm?: number
|
nrm?: number
|
||||||
|
/** max network sent (bytes) */
|
||||||
|
bm?: [number, number]
|
||||||
/** temperatures */
|
/** temperatures */
|
||||||
t?: Record<string, number>
|
t?: Record<string, number>
|
||||||
/** extra filesystems */
|
/** extra filesystems */
|
||||||
@@ -190,14 +196,13 @@ export interface AlertRecord extends RecordModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface AlertsHistoryRecord extends RecordModel {
|
export interface AlertsHistoryRecord extends RecordModel {
|
||||||
alert: string;
|
alert: string
|
||||||
user: string;
|
user: string
|
||||||
system: string;
|
system: string
|
||||||
name: string;
|
name: string
|
||||||
value: number;
|
val: number
|
||||||
state: "active" | "solved";
|
created: string
|
||||||
created_date: string;
|
resolved?: string | null
|
||||||
solved_date?: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ChartTimes = "1h" | "12h" | "24h" | "1w" | "30d"
|
export type ChartTimes = "1h" | "12h" | "24h" | "1w" | "30d"
|
||||||
@@ -221,9 +226,6 @@ export interface UserSettings {
|
|||||||
unitTemp?: Unit
|
unitTemp?: Unit
|
||||||
unitNet?: Unit
|
unitNet?: Unit
|
||||||
unitDisk?: Unit
|
unitDisk?: Unit
|
||||||
|
|
||||||
// New field for alert history retention (e.g., '1m', '3m', '6m', '1y')
|
|
||||||
alertHistoryRetention?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type ChartDataContainer = {
|
type ChartDataContainer = {
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
|
require("@tailwindcss/container-queries"),
|
||||||
require("tailwindcss-animate"),
|
require("tailwindcss-animate"),
|
||||||
require("tailwindcss-rtl"),
|
require("tailwindcss-rtl"),
|
||||||
function ({ addVariant }) {
|
function ({ addVariant }) {
|
||||||
|
|||||||
Reference in New Issue
Block a user