mirror of
https://github.com/henrygd/beszel.git
synced 2026-03-22 21:46:18 +01:00
Compare commits
70 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22e9ede766 | ||
|
|
9ab359d3cf | ||
|
|
5447ccad47 | ||
|
|
3e51d79c37 | ||
|
|
0996d60224 | ||
|
|
7a5ec067f5 | ||
|
|
98563d643d | ||
|
|
268e364bd4 | ||
|
|
dd84a9fd35 | ||
|
|
2f4e537f72 | ||
|
|
9637363cf3 | ||
|
|
73d0dd25ec | ||
|
|
2ecf5572ba | ||
|
|
5e97167ee0 | ||
|
|
1a4862ecd9 | ||
|
|
6235d15fa2 | ||
|
|
4694642674 | ||
|
|
56c0b86025 | ||
|
|
82e3f3c7c1 | ||
|
|
38a9c535b8 | ||
|
|
34c83e7c17 | ||
|
|
fe5732d75a | ||
|
|
cc32b50d82 | ||
|
|
764e043e83 | ||
|
|
cec9339f6d | ||
|
|
f96f04f876 | ||
|
|
06b1c2200b | ||
|
|
e88e2bf3dc | ||
|
|
8621a45383 | ||
|
|
f2ddee9216 | ||
|
|
f350d61ee2 | ||
|
|
2d670c585d | ||
|
|
55d1c00903 | ||
|
|
78a9086b55 | ||
|
|
4ee169fea5 | ||
|
|
a286bed54c | ||
|
|
314cee081a | ||
|
|
e287124632 | ||
|
|
9cccefd3fa | ||
|
|
ec95f63806 | ||
|
|
812fe20df7 | ||
|
|
ddfcbc546b | ||
|
|
c74d5496af | ||
|
|
060846d70a | ||
|
|
e03e2b8d67 | ||
|
|
c46879694d | ||
|
|
61a68e5be1 | ||
|
|
bd43a2a2c2 | ||
|
|
3aeca6af2f | ||
|
|
3e95269a7c | ||
|
|
53b02dd55f | ||
|
|
43ba9d5c6a | ||
|
|
1cb4a711c3 | ||
|
|
aef99c3bd9 | ||
|
|
138cbc13d6 | ||
|
|
62d5ae8236 | ||
|
|
8ce605d65e | ||
|
|
c8743201a2 | ||
|
|
f16e22e521 | ||
|
|
9710d0d2f1 | ||
|
|
2889d151ea | ||
|
|
ce6e887d1b | ||
|
|
b4cf5bb1c0 | ||
|
|
9bc7773607 | ||
|
|
3362a3d1cf | ||
|
|
3b13fadde2 | ||
|
|
99d79f7d2d | ||
|
|
1fb23ff673 | ||
|
|
29529d1a84 | ||
|
|
9f84629b92 |
9
SECURITY.md
Normal file
9
SECURITY.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Security Policy
|
||||||
|
|
||||||
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
|
If you find a vulnerability in the latest version, please email me directly at hank@henrygd.me, or [submit a private advisory](https://github.com/henrygd/beszel/security/advisories/new).
|
||||||
|
|
||||||
|
If you submit an advisory, open an empty issue as well to let me know that you did (or email me), as I'm not sure if I get notifications for that.
|
||||||
|
|
||||||
|
If the issue is low severity (use best judgement) you may open an issue for it instead of contacting me directly.
|
||||||
@@ -38,5 +38,5 @@ func main() {
|
|||||||
addr = portEnvVar
|
addr = portEnvVar
|
||||||
}
|
}
|
||||||
|
|
||||||
agent.NewAgent(pubKey, addr).Run()
|
agent.NewAgent().Run(pubKey, addr)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,40 +4,42 @@ go 1.22.4
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/blang/semver v3.5.1+incompatible
|
github.com/blang/semver v3.5.1+incompatible
|
||||||
|
github.com/containrrr/shoutrrr v0.8.0
|
||||||
github.com/gliderlabs/ssh v0.3.7
|
github.com/gliderlabs/ssh v0.3.7
|
||||||
github.com/goccy/go-json v0.10.3
|
github.com/goccy/go-json v0.10.3
|
||||||
github.com/labstack/echo/v5 v5.0.0-20230722203903-ec5b858dab61
|
github.com/labstack/echo/v5 v5.0.0-20230722203903-ec5b858dab61
|
||||||
github.com/pocketbase/dbx v1.10.1
|
github.com/pocketbase/dbx v1.10.1
|
||||||
github.com/pocketbase/pocketbase v0.22.20
|
github.com/pocketbase/pocketbase v0.22.21
|
||||||
github.com/rhysd/go-github-selfupdate v1.2.3
|
github.com/rhysd/go-github-selfupdate v1.2.3
|
||||||
github.com/shirou/gopsutil/v4 v4.24.8
|
github.com/shirou/gopsutil/v4 v4.24.8
|
||||||
github.com/spf13/cobra v1.8.1
|
github.com/spf13/cobra v1.8.1
|
||||||
golang.org/x/crypto v0.26.0
|
golang.org/x/crypto v0.27.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/AlecAivazis/survey/v2 v2.3.7 // indirect
|
github.com/AlecAivazis/survey/v2 v2.3.7 // indirect
|
||||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
|
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
|
||||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
|
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2 v1.30.5 // indirect
|
github.com/aws/aws-sdk-go-v2 v1.31.0 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.4 // indirect
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.5 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.27.32 // indirect
|
github.com/aws/aws-sdk-go-v2/config v1.27.39 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.31 // indirect
|
github.com/aws/aws-sdk-go-v2/credentials v1.17.37 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.13 // indirect
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.14 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.17 // indirect
|
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.25 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.17 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.18 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.17 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.18 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.17 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.18 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.4 // indirect
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.5 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.19 // indirect
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.20 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.19 // indirect
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.20 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.17 // indirect
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.18 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.61.1 // indirect
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.63.3 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/sso v1.22.6 // indirect
|
github.com/aws/aws-sdk-go-v2/service/sso v1.23.3 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.6 // indirect
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.27.3 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/sts v1.30.6 // indirect
|
github.com/aws/aws-sdk-go-v2/service/sts v1.31.3 // indirect
|
||||||
github.com/aws/smithy-go v1.20.4 // indirect
|
github.com/aws/smithy-go v1.21.0 // indirect
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
github.com/disintegration/imaging v1.6.2 // indirect
|
github.com/disintegration/imaging v1.6.2 // indirect
|
||||||
github.com/domodwyer/mailyak/v3 v3.6.2 // indirect
|
github.com/domodwyer/mailyak/v3 v3.6.2 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
@@ -56,12 +58,13 @@ require (
|
|||||||
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf // indirect
|
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
||||||
github.com/lufia/plan9stats v0.0.0-20240819163618-b1d8f4d146e7 // indirect
|
github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
github.com/mattn/go-sqlite3 v1.14.23 // indirect
|
||||||
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
|
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
|
||||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
github.com/ncruces/go-strftime v0.1.9 // 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/shoenig/go-m1cpu v0.1.6 // indirect
|
github.com/shoenig/go-m1cpu v0.1.6 // indirect
|
||||||
@@ -76,24 +79,25 @@ require (
|
|||||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
go.opencensus.io v0.24.0 // indirect
|
go.opencensus.io v0.24.0 // indirect
|
||||||
gocloud.dev v0.39.0 // indirect
|
gocloud.dev v0.39.0 // indirect
|
||||||
golang.org/x/image v0.19.0 // indirect
|
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect
|
||||||
golang.org/x/net v0.28.0 // indirect
|
golang.org/x/image v0.20.0 // indirect
|
||||||
golang.org/x/oauth2 v0.22.0 // indirect
|
golang.org/x/net v0.29.0 // indirect
|
||||||
|
golang.org/x/oauth2 v0.23.0 // indirect
|
||||||
golang.org/x/sync v0.8.0 // indirect
|
golang.org/x/sync v0.8.0 // indirect
|
||||||
golang.org/x/sys v0.24.0 // indirect
|
golang.org/x/sys v0.25.0 // indirect
|
||||||
golang.org/x/term v0.23.0 // indirect
|
golang.org/x/term v0.24.0 // indirect
|
||||||
golang.org/x/text v0.17.0 // indirect
|
golang.org/x/text v0.18.0 // indirect
|
||||||
golang.org/x/time v0.6.0 // indirect
|
golang.org/x/time v0.6.0 // indirect
|
||||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
|
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
|
||||||
google.golang.org/api v0.196.0 // indirect
|
google.golang.org/api v0.199.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20240924160255-9d4c2d233b61 // indirect
|
||||||
google.golang.org/grpc v1.66.0 // indirect
|
google.golang.org/grpc v1.67.0 // indirect
|
||||||
google.golang.org/protobuf v1.34.2 // indirect
|
google.golang.org/protobuf v1.34.2 // indirect
|
||||||
modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a // indirect
|
modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a // indirect
|
||||||
modernc.org/libc v1.60.1 // indirect
|
modernc.org/libc v1.61.0 // indirect
|
||||||
modernc.org/mathutil v1.6.0 // indirect
|
modernc.org/mathutil v1.6.0 // indirect
|
||||||
modernc.org/memory v1.8.0 // indirect
|
modernc.org/memory v1.8.0 // indirect
|
||||||
modernc.org/sqlite v1.32.0 // indirect
|
modernc.org/sqlite v1.33.1 // indirect
|
||||||
modernc.org/strutil v1.2.0 // indirect
|
modernc.org/strutil v1.2.0 // indirect
|
||||||
modernc.org/token v1.1.0 // indirect
|
modernc.org/token v1.1.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
178
beszel/go.sum
178
beszel/go.sum
@@ -1,12 +1,13 @@
|
|||||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||||
cloud.google.com/go v0.115.1 h1:Jo0SM9cQnSkYfp44+v+NQXHpcHqlnRJk2qxh6yvxxxQ=
|
cloud.google.com/go v0.115.1 h1:Jo0SM9cQnSkYfp44+v+NQXHpcHqlnRJk2qxh6yvxxxQ=
|
||||||
cloud.google.com/go v0.115.1/go.mod h1:DuujITeaufu3gL68/lOFIirVNJwQeyf5UXyi+Wbgknc=
|
cloud.google.com/go v0.115.1/go.mod h1:DuujITeaufu3gL68/lOFIirVNJwQeyf5UXyi+Wbgknc=
|
||||||
cloud.google.com/go/auth v0.9.3 h1:VOEUIAADkkLtyfr3BLa3R8Ed/j6w1jTBmARx+wb5w5U=
|
cloud.google.com/go/auth v0.9.5 h1:4CTn43Eynw40aFVr3GpPqsQponx2jv0BQpjvajsbbzw=
|
||||||
cloud.google.com/go/auth v0.9.3/go.mod h1:7z6VY+7h3KUdRov5F1i8NDP5ZzWKYmEPO842BgCsmTk=
|
cloud.google.com/go/auth v0.9.5/go.mod h1:Xo0n7n66eHyOWWCnitop6870Ilwo3PiZyodVkkH1xWM=
|
||||||
cloud.google.com/go/auth/oauth2adapt v0.2.4 h1:0GWE/FUsXhf6C+jAkWgYm7X9tK8cuEIfy19DBn6B6bY=
|
cloud.google.com/go/auth/oauth2adapt v0.2.4 h1:0GWE/FUsXhf6C+jAkWgYm7X9tK8cuEIfy19DBn6B6bY=
|
||||||
cloud.google.com/go/auth/oauth2adapt v0.2.4/go.mod h1:jC/jOpwFP6JBxhB3P5Rr0a9HLMC/Pe3eaL4NmdvqPtc=
|
cloud.google.com/go/auth/oauth2adapt v0.2.4/go.mod h1:jC/jOpwFP6JBxhB3P5Rr0a9HLMC/Pe3eaL4NmdvqPtc=
|
||||||
cloud.google.com/go/compute/metadata v0.5.0 h1:Zr0eK8JbFv6+Wi4ilXAR8FJ3wyNdpxHKJNPos6LTZOY=
|
cloud.google.com/go/compute v1.14.0 h1:hfm2+FfxVmnRlh6LpB7cg1ZNU+5edAHmW679JePztk0=
|
||||||
cloud.google.com/go/compute/metadata v0.5.0/go.mod h1:aHnloV2TPI38yx4s9+wAZhHykWvVCfu7hQbF+9CWoiY=
|
cloud.google.com/go/compute/metadata v0.5.2 h1:UxK4uu/Tn+I3p2dYWTfiX4wva7aYlKixAHn3fyqngqo=
|
||||||
|
cloud.google.com/go/compute/metadata v0.5.2/go.mod h1:C66sj2AluDcIqakBq/M8lw8/ybHgOZqin2obFxa/E5k=
|
||||||
cloud.google.com/go/iam v1.1.13 h1:7zWBXG9ERbMLrzQBRhFliAV+kjcRToDTgQT3CTwYyv4=
|
cloud.google.com/go/iam v1.1.13 h1:7zWBXG9ERbMLrzQBRhFliAV+kjcRToDTgQT3CTwYyv4=
|
||||||
cloud.google.com/go/iam v1.1.13/go.mod h1:K8mY0uSXwEXS30KrnVb+j54LB/ntfZu1dr+4zFMNbus=
|
cloud.google.com/go/iam v1.1.13/go.mod h1:K8mY0uSXwEXS30KrnVb+j54LB/ntfZu1dr+4zFMNbus=
|
||||||
cloud.google.com/go/storage v1.43.0 h1:CcxnSohZwizt4LCzQHWvBf1/kvtHUn7gk9QERXPyXFs=
|
cloud.google.com/go/storage v1.43.0 h1:CcxnSohZwizt4LCzQHWvBf1/kvtHUn7gk9QERXPyXFs=
|
||||||
@@ -25,55 +26,58 @@ github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3d
|
|||||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
|
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
|
||||||
github.com/aws/aws-sdk-go v1.55.5 h1:KKUZBfBoyqy5d3swXyiC7Q76ic40rYcbqH7qjh59kzU=
|
github.com/aws/aws-sdk-go v1.55.5 h1:KKUZBfBoyqy5d3swXyiC7Q76ic40rYcbqH7qjh59kzU=
|
||||||
github.com/aws/aws-sdk-go v1.55.5/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
|
github.com/aws/aws-sdk-go v1.55.5/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
|
||||||
github.com/aws/aws-sdk-go-v2 v1.30.5 h1:mWSRTwQAb0aLE17dSzztCVJWI9+cRMgqebndjwDyK0g=
|
github.com/aws/aws-sdk-go-v2 v1.31.0 h1:3V05LbxTSItI5kUqNwhJrrrY1BAXxXt0sN0l72QmG5U=
|
||||||
github.com/aws/aws-sdk-go-v2 v1.30.5/go.mod h1:CT+ZPWXbYrci8chcARI3OmI/qgd+f6WtuLOoaIA8PR0=
|
github.com/aws/aws-sdk-go-v2 v1.31.0/go.mod h1:ztolYtaEUtdpf9Wftr31CJfLVjOnD/CVRkKOOYgF8hA=
|
||||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.4 h1:70PVAiL15/aBMh5LThwgXdSQorVr91L127ttckI9QQU=
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.5 h1:xDAuZTn4IMm8o1LnBZvmrL8JA1io4o3YWNXgohbf20g=
|
||||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.4/go.mod h1:/MQxMqci8tlqDH+pjmoLu1i0tbWCUP1hhyMRuFxpQCw=
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.5/go.mod h1:wYSv6iDS621sEFLfKvpPE2ugjTuGlAG7iROg0hLOkfc=
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.27.32 h1:jnAMVTJTpAQlePCUUlnXnllHEMGVWmvUJOiGjgtS9S0=
|
github.com/aws/aws-sdk-go-v2/config v1.27.39 h1:FCylu78eTGzW1ynHcongXK9YHtoXD5AiiUqq3YfJYjU=
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.27.32/go.mod h1:JibtzKJoXT0M/MhoYL6qfCk7nm/MppwukDFZtdgVRoY=
|
github.com/aws/aws-sdk-go-v2/config v1.27.39/go.mod h1:wczj2hbyskP4LjMKBEZwPRO1shXY+GsQleab+ZXT2ik=
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.31 h1:jtyfcOfgoqWA2hW/E8sFbwdfgwD3APnF9CLCKE8dTyw=
|
github.com/aws/aws-sdk-go-v2/credentials v1.17.37 h1:G2aOH01yW8X373JK419THj5QVqu9vKEwxSEsGxihoW0=
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.31/go.mod h1:RSgY5lfCfw+FoyKWtOpLolPlfQVdDBQWTUniAaE+NKY=
|
github.com/aws/aws-sdk-go-v2/credentials v1.17.37/go.mod h1:0ecCjlb7htYCptRD45lXJ6aJDQac6D2NlKGpZqyTG6A=
|
||||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.13 h1:pfQ2sqNpMVK6xz2RbqLEL0GH87JOwSxPV2rzm8Zsb74=
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.14 h1:C/d03NAmh8C4BZXhuRNboF/DqhBkBCeDiJDcaqIT5pA=
|
||||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.13/go.mod h1:NG7RXPUlqfsCLLFfi0+IpKN4sCB9D9fw/qTaSB+xRoU=
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.14/go.mod h1:7I0Ju7p9mCIdlrfS+JCgqcYD0VXz/N4yozsox+0o078=
|
||||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.17 h1:QbV9wh6vtB3UAZvdfktPj8jT+w6yIrKYd4PngLWDmCE=
|
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.25 h1:HkpHeZMM39sGtMHVYG1buAg93vhj5d7F81y6G0OAbGc=
|
||||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.17/go.mod h1:0trBfk2z3LEozr2WZz7IxcRJWl2jv0Ro7JpByqh3coQ=
|
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.25/go.mod h1:j3Vz04ZjaWA6kygOsZRpmWe4CyGqfqq2u3unDTU0QGA=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.17 h1:pI7Bzt0BJtYA0N/JEC6B8fJ4RBrEMi1LBrkMdFYNSnQ=
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.18 h1:kYQ3H1u0ANr9KEKlGs/jTLrBFPo8P8NaH/w7A01NeeM=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.17/go.mod h1:Dh5zzJYMtxfIjYW+/evjQ8uj2OyR/ve2KROHGHlSFqE=
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.18/go.mod h1:r506HmK5JDUh9+Mw4CfGJGSSoqIiLCndAuqXuhbv67Y=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.17 h1:Mqr/V5gvrhA2gvgnF42Zh5iMiQNcOYthFYwCyrnuWlc=
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.18 h1:Z7IdFUONvTcvS7YuhtVxN99v2cCoHRXOS4mTr0B/pUc=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.17/go.mod h1:aLJpZlCmjE+V+KtN1q1uyZkfnUWpQGpbsn89XPKyzfU=
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.18/go.mod h1:DkKMmksZVVyat+Y+r1dEOgJEfUeA7UngIHWeKsi0yNc=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 h1:VaRN3TlFdd6KxX1x3ILT5ynH6HvKgqdiXoTxAF4HQcQ=
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 h1:VaRN3TlFdd6KxX1x3ILT5ynH6HvKgqdiXoTxAF4HQcQ=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc=
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.17 h1:Roo69qTpfu8OlJ2Tb7pAYVuF0CpuUMB0IYWwYP/4DZM=
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.18 h1:OWYvKL53l1rbsUmW7bQyJVsYU/Ii3bbAAQIIFNbM0Tk=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.17/go.mod h1:NcWPxQzGM1USQggaTVwz6VpqMZPX1CvDJLDh6jnOCa4=
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.18/go.mod h1:CUx0G1v3wG6l01tUB+j7Y8kclA8NSqK4ef0YG79a4cg=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.4 h1:KypMCbLPPHEmf9DgMGw51jMj77VfGPAN2Kv4cfhlfgI=
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.5 h1:QFASJGfT8wMXtuP3D5CRmMjARHv9ZmzFUMJznHDOY3w=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.4/go.mod h1:Vz1JQXliGcQktFTN/LN6uGppAIRoLBR2bMvIMP0gOjc=
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.5/go.mod h1:QdZ3OmoIjSX+8D1OPAzPxDfjXASbBMDsz9qvtyIhtik=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.19 h1:FLMkfEiRjhgeDTCjjLoc3URo/TBkgeQbocA78lfkzSI=
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.20 h1:rTWjG6AvWekO2B1LHeM3ktU7MqyX9rzWQ7hgzneZW7E=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.19/go.mod h1:Vx+GucNSsdhaxs3aZIKfSUjKVGsxN25nX2SRcdhuw08=
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.20/go.mod h1:RGW2DDpVc8hu6Y6yG8G5CHVmVOAn1oV8rNKOHRJyswg=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.19 h1:rfprUlsdzgl7ZL2KlXiUAoJnI/VxfHCvDFr2QDFj6u4=
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.20 h1:Xbwbmk44URTiHNx6PNo0ujDE6ERlsCKJD3u1zfnzAPg=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.19/go.mod h1:SCWkEdRq8/7EK60NcvvQ6NXKuTcchAD4ROAsC37VEZE=
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.20/go.mod h1:oAfOFzUB14ltPZj1rWwRc3d/6OgD76R8KlvU3EqM9Fg=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.17 h1:u+EfGmksnJc/x5tq3A+OD7LrMbSSR/5TrKLvkdy/fhY=
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.18 h1:eb+tFOIl9ZsUe2259/BKPeniKuz4/02zZFH/i4Nf8Rg=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.17/go.mod h1:VaMx6302JHax2vHJWgRo+5n9zvbacs3bLU/23DNQrTY=
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.18/go.mod h1:GVCC2IJNJTmdlyEsSmofEy7EfJncP7DNnXDzRjJ5Keg=
|
||||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.61.1 h1:6ZRIbdMbN83W2/EIAU5z8FQZpmuULsBojTaok+uBEIg=
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.63.3 h1:3zt8qqznMuAZWDTDpcwv9Xr11M/lVj2FsRR7oYBt0OA=
|
||||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.61.1/go.mod h1:5FmD/Dqq57gP+XwaUnd5WFPipAuzrf0HmupX27Gvjvc=
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.63.3/go.mod h1:NLTqRLe3pUNu3nTEHI6XlHLKYmc8fbHUdMxAB6+s41Q=
|
||||||
github.com/aws/aws-sdk-go-v2/service/sso v1.22.6 h1:o++HUDXlbrTl4PSal3YHtdErQxB8mDGAtkKNXBWPfIU=
|
github.com/aws/aws-sdk-go-v2/service/sso v1.23.3 h1:rs4JCczF805+FDv2tRhZ1NU0RB2H6ryAvsWPanAr72Y=
|
||||||
github.com/aws/aws-sdk-go-v2/service/sso v1.22.6/go.mod h1:eEygMHnTKH/3kNp9Jr1n3PdejuSNcgwLe1dWgQtO0VQ=
|
github.com/aws/aws-sdk-go-v2/service/sso v1.23.3/go.mod h1:XRlMvmad0ZNL+75C5FYdMvbbLkd6qiqz6foR1nA1PXY=
|
||||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.6 h1:yCHcQCOwTfIsc8DoEhM3qXPxD+j8CbI6t1K3dNzsWV0=
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.27.3 h1:S7EPdMVZod8BGKQQPTBK+FcX9g7bKR7c4+HxWqHP7Vg=
|
||||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.6/go.mod h1:bCbAxKDqNvkHxRaIMnyVPXPo+OaPRwvmgzMxbz1VKSA=
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.27.3/go.mod h1:FnvDM4sfa+isJ3kDXIzAB9GAwVSzFzSy97uZ3IsHo4E=
|
||||||
github.com/aws/aws-sdk-go-v2/service/sts v1.30.6 h1:TrQadF7GcqvQ63kgwEcjlrVc2Fa0wpgLT0xtc73uAd8=
|
github.com/aws/aws-sdk-go-v2/service/sts v1.31.3 h1:VzudTFrDCIDakXtemR7l6Qzt2+JYsVqo2MxBPt5k8T8=
|
||||||
github.com/aws/aws-sdk-go-v2/service/sts v1.30.6/go.mod h1:NXi1dIAGteSaRLqYgarlhP/Ij0cFT+qmCwiJqWh/U5o=
|
github.com/aws/aws-sdk-go-v2/service/sts v1.31.3/go.mod h1:yMWe0F+XG0DkRZK5ODZhG7BEFYhLXi2dqGsv6tX0cgI=
|
||||||
github.com/aws/smithy-go v1.20.4 h1:2HK1zBdPgRbjFOHlfeQZfpC4r72MOb9bZkiFwggKO+4=
|
github.com/aws/smithy-go v1.21.0 h1:H7L8dtDRk0P1Qm6y0ji7MCYMQObJ5R9CRpyPhRUkLYA=
|
||||||
github.com/aws/smithy-go v1.20.4/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg=
|
github.com/aws/smithy-go v1.21.0/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg=
|
||||||
github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ=
|
github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ=
|
||||||
github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
|
github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
|
||||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||||
|
github.com/containrrr/shoutrrr v0.8.0 h1:mfG2ATzIS7NR2Ec6XL+xyoHzN97H8WPjir8aYzJUSec=
|
||||||
|
github.com/containrrr/shoutrrr v0.8.0/go.mod h1:ioyQAyu1LJY6sILuNyKaQaw+9Ttik5QePU8atnAdO2o=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||||
github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI=
|
github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI=
|
||||||
github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
|
github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||||
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||||
github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8=
|
github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8=
|
||||||
@@ -109,6 +113,8 @@ github.com/go-ozzo/ozzo-validation/v4 v4.3.0/go.mod h1:2NKgrcHl3z6cJs+3Oo940FPRi
|
|||||||
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
|
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
|
||||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||||
|
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=
|
||||||
|
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls=
|
||||||
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
|
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
|
||||||
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=
|
github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=
|
||||||
@@ -153,8 +159,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
|||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/google/wire v0.6.0 h1:HBkoIh4BdSxoyo9PveV8giw7ZsaBOvzWKfcg/6MrVwI=
|
github.com/google/wire v0.6.0 h1:HBkoIh4BdSxoyo9PveV8giw7ZsaBOvzWKfcg/6MrVwI=
|
||||||
github.com/google/wire v0.6.0/go.mod h1:F4QhpQ9EDIdJ1Mbop/NZBRB+5yrR6qg3BnctaoUk6NA=
|
github.com/google/wire v0.6.0/go.mod h1:F4QhpQ9EDIdJ1Mbop/NZBRB+5yrR6qg3BnctaoUk6NA=
|
||||||
github.com/googleapis/enterprise-certificate-proxy v0.3.3 h1:QRje2j5GZimBzlbhGA2V2QlGNgL8G6e+wGo/+/2bWI0=
|
github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw=
|
||||||
github.com/googleapis/enterprise-certificate-proxy v0.3.3/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA=
|
github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA=
|
||||||
github.com/googleapis/gax-go/v2 v2.13.0 h1:yitjD5f7jQHhyDsnhKEBU52NdvvdSeGzlAnDPT0hH1s=
|
github.com/googleapis/gax-go/v2 v2.13.0 h1:yitjD5f7jQHhyDsnhKEBU52NdvvdSeGzlAnDPT0hH1s=
|
||||||
github.com/googleapis/gax-go/v2 v2.13.0/go.mod h1:Z/fvTZXF8/uw7Xu5GuslPw+bplx6SS338j1Is2S+B7A=
|
github.com/googleapis/gax-go/v2 v2.13.0/go.mod h1:Z/fvTZXF8/uw7Xu5GuslPw+bplx6SS338j1Is2S+B7A=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
@@ -166,6 +172,8 @@ github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf h1:WfD7V
|
|||||||
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf/go.mod h1:hyb9oH7vZsitZCiBt0ZvifOrB+qc8PS5IiilCIb87rg=
|
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf/go.mod h1:hyb9oH7vZsitZCiBt0ZvifOrB+qc8PS5IiilCIb87rg=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
|
github.com/jarcoal/httpmock v1.3.0 h1:2RJ8GP0IIaWwcC9Fp2BmVi8Kog3v2Hn7VXM3fTd+nuc=
|
||||||
|
github.com/jarcoal/httpmock v1.3.0/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg=
|
||||||
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
|
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
|
||||||
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
|
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
|
||||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
|
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
|
||||||
@@ -179,8 +187,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
|||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/labstack/echo/v5 v5.0.0-20230722203903-ec5b858dab61 h1:FwuzbVh87iLiUQj1+uQUsuw9x5t9m5n5g7rG7o4svW4=
|
github.com/labstack/echo/v5 v5.0.0-20230722203903-ec5b858dab61 h1:FwuzbVh87iLiUQj1+uQUsuw9x5t9m5n5g7rG7o4svW4=
|
||||||
github.com/labstack/echo/v5 v5.0.0-20230722203903-ec5b858dab61/go.mod h1:paQfF1YtHe+GrGg5fOgjsjoCX/UKDr9bc1DoWpZfns8=
|
github.com/labstack/echo/v5 v5.0.0-20230722203903-ec5b858dab61/go.mod h1:paQfF1YtHe+GrGg5fOgjsjoCX/UKDr9bc1DoWpZfns8=
|
||||||
github.com/lufia/plan9stats v0.0.0-20240819163618-b1d8f4d146e7 h1:5RK988zAqB3/AN3opGfRpoQgAVqr6/A5+qRTi67VUZY=
|
github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 h1:7UMa6KCCMjZEMDtTVdcGu0B1GmmC7QJKiCCjyTAWQy0=
|
||||||
github.com/lufia/plan9stats v0.0.0-20240819163618-b1d8f4d146e7/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k=
|
github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k=
|
||||||
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
|
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
|
||||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||||
@@ -188,22 +196,27 @@ github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hd
|
|||||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
github.com/mattn/go-sqlite3 v1.14.23 h1:gbShiuAP1W5j9UOksQ06aiiqPMxYecovVGwmTxWtuw0=
|
||||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
github.com/mattn/go-sqlite3 v1.14.23/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
|
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
|
||||||
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI=
|
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI=
|
||||||
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
|
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
|
||||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/onsi/ginkgo v1.6.0 h1:Ix8l273rp3QzYgXSR+c8d1fTG7UPgYkOSELPhiY/YGw=
|
||||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||||
github.com/onsi/gomega v1.4.2 h1:3mYCb7aPxS/RU7TI1y4rkEn1oKmPRjNJLNEXgw7MH2I=
|
github.com/onsi/ginkgo/v2 v2.9.2 h1:BA2GMJOtfGAfagzYtrAlufIP0lq6QERkFmHLMLPwFSU=
|
||||||
|
github.com/onsi/ginkgo/v2 v2.9.2/go.mod h1:WHcJJG2dIlcCqVfBAwUCrJxSPFb6v4azBwgxeMeDuts=
|
||||||
github.com/onsi/gomega v1.4.2/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
github.com/onsi/gomega v1.4.2/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE=
|
||||||
|
github.com/onsi/gomega v1.27.6/go.mod h1:PIQNjfQwkP3aQAH7lf7j87O/5FiNr+ZR8+ipb+qQlhg=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/pocketbase/dbx v1.10.1 h1:cw+vsyfCJD8YObOVeqb93YErnlxwYMkNZ4rwN0G0AaA=
|
github.com/pocketbase/dbx v1.10.1 h1:cw+vsyfCJD8YObOVeqb93YErnlxwYMkNZ4rwN0G0AaA=
|
||||||
github.com/pocketbase/dbx v1.10.1/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
github.com/pocketbase/dbx v1.10.1/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
||||||
github.com/pocketbase/pocketbase v0.22.20 h1:yUkhO5bTPWlzD4ZK6EQlS4R3AcHKDlBD+DxxU2BR83I=
|
github.com/pocketbase/pocketbase v0.22.21 h1:DGPCxn6co8VuTV0mton4NFO/ON49XiFMszRr+Mysy48=
|
||||||
github.com/pocketbase/pocketbase v0.22.20/go.mod h1:Cw5E4uoGhKItBIE2lJL3NfmiUr9Syk2xaNJ2G7Dssow=
|
github.com/pocketbase/pocketbase v0.22.21/go.mod h1:Cw5E4uoGhKItBIE2lJL3NfmiUr9Syk2xaNJ2G7Dssow=
|
||||||
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/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||||
@@ -270,18 +283,20 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
|
|||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
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.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
|
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
|
||||||
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
|
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
|
||||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
|
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk=
|
||||||
|
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY=
|
||||||
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.19.0 h1:D9FX4QWkLfkeqaC62SonffIIuYdOk/UE2XKUBgRIBIQ=
|
golang.org/x/image v0.20.0 h1:7cVCUjQwfL18gyBJOmYvptfSHS8Fb3YUDtfLIZ7Nbpw=
|
||||||
golang.org/x/image v0.19.0/go.mod h1:y0zrRqlQRWQ5PXaYCOMLTW2fpsxZ8Qh9I/ohnInJEys=
|
golang.org/x/image v0.20.0/go.mod h1:0a88To4CYVBAHp5FXJm8o7QbUl37Vd85ply1vyD8auM=
|
||||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8=
|
golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0=
|
||||||
golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
|
||||||
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-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/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=
|
||||||
@@ -293,12 +308,12 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL
|
|||||||
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE=
|
golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
|
||||||
golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg=
|
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
|
||||||
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.22.0 h1:BzDx2FehcG7jJwgWLELCdmLuxk2i+x9UDpSiss2u0ZA=
|
golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs=
|
||||||
golang.org/x/oauth2 v0.22.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||||
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.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
@@ -321,21 +336,21 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc
|
|||||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
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.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg=
|
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
|
||||||
golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
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.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/term v0.23.0 h1:F6D4vR+EHoL9/sWAWgAR1H2DcHr4PareCbAaCo1RpuU=
|
golang.org/x/term v0.24.0 h1:Mh5cbb+Zk2hqqXNO7S1iTjEphVL+jb8ZWaqh/g+JWkM=
|
||||||
golang.org/x/term v0.23.0/go.mod h1:DgV24QBUrK6jhZXl+20l6UWznPlwAHm1Q1mGHtydmSk=
|
golang.org/x/term v0.24.0/go.mod h1:lOBK/LVxemqiMij05LGJ0tzNr8xlmwBRJ81PX6wVLH8=
|
||||||
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.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
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.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc=
|
golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
|
||||||
golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
||||||
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
|
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
|
||||||
golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||||
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=
|
||||||
@@ -345,14 +360,14 @@ golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3
|
|||||||
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg=
|
golang.org/x/tools v0.25.0 h1:oFU9pkj/iJgs+0DT+VMHrx+oBKs/LJMV+Uvg78sl+fE=
|
||||||
golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI=
|
golang.org/x/tools v0.25.0/go.mod h1:/vtpO8WL1N9cQC3FN5zPqb//fRXskFHbLKk4OW1Q7rg=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
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=
|
||||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
|
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
|
||||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
|
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
|
||||||
google.golang.org/api v0.196.0 h1:k/RafYqebaIJBO3+SMnfEGtFVlvp5vSgqTUF54UN/zg=
|
google.golang.org/api v0.199.0 h1:aWUXClp+VFJmqE0JPvpZOK3LDQMyFKYIow4etYd9qxs=
|
||||||
google.golang.org/api v0.196.0/go.mod h1:g9IL21uGkYgvQ5BZg6BAtoGJQIm8r6EgaAbpNey5wBE=
|
google.golang.org/api v0.199.0/go.mod h1:ohG4qSztDJmZdjK/Ar6MhbAmb/Rpi4JHOqagsh90K28=
|
||||||
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=
|
||||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||||
@@ -362,17 +377,17 @@ google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98
|
|||||||
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
||||||
google.golang.org/genproto v0.0.0-20240903143218-8af14fe29dc1 h1:BulPr26Jqjnd4eYDVe+YvyR7Yc2vJGkO5/0UxD0/jZU=
|
google.golang.org/genproto v0.0.0-20240903143218-8af14fe29dc1 h1:BulPr26Jqjnd4eYDVe+YvyR7Yc2vJGkO5/0UxD0/jZU=
|
||||||
google.golang.org/genproto v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:hL97c3SYopEHblzpxRL4lSs523++l8DYxGM1FQiYmb4=
|
google.golang.org/genproto v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:hL97c3SYopEHblzpxRL4lSs523++l8DYxGM1FQiYmb4=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20240812133136-8ffd90a71988 h1:+/tmTy5zAieooKIXfzDm9KiA3Bv6JBwriRN9LY+yayk=
|
google.golang.org/genproto/googleapis/api v0.0.0-20240814211410-ddb44dafa142 h1:wKguEg1hsxI2/L3hUYrpo1RVi48K+uTyzKqprwLXsb8=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20240812133136-8ffd90a71988/go.mod h1:4+X6GvPs+25wZKbQq9qyAXrwIRExv7w0Ea6MgZLZiDM=
|
google.golang.org/genproto/googleapis/api v0.0.0-20240814211410-ddb44dafa142/go.mod h1:d6be+8HhtEtucleCbxpPW9PA9XwISACu8nvpPqF0BVo=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 h1:pPJltXNxVzT4pK9yD8vR9X75DaWYYmLGMsEvBfFQZzQ=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20240924160255-9d4c2d233b61 h1:N9BgCIAUvn/M+p4NJccWPWb3BWh88+zyL0ll9HgbEeM=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20240924160255-9d4c2d233b61/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
|
||||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||||
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||||
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||||
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
|
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
|
||||||
google.golang.org/grpc v1.66.0 h1:DibZuoBznOxbDQxRINckZcUvnCEvrW9pcWIE2yF9r1c=
|
google.golang.org/grpc v1.67.0 h1:IdH9y6PF5MPSdAntIcpjQ+tXO41pcQsfZV2RxtQgVcw=
|
||||||
google.golang.org/grpc v1.66.0/go.mod h1:s3/l6xSSCURdVfAnL+TqCNMyTDAGN6+lZeVxnZR128Y=
|
google.golang.org/grpc v1.67.0/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA=
|
||||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||||
@@ -389,7 +404,6 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8
|
|||||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
|
|
||||||
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.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
@@ -406,8 +420,8 @@ modernc.org/gc/v2 v2.5.0 h1:bJ9ChznK1L1mUtAQtxi0wi5AtAs5jQuw4PrPHO5pb6M=
|
|||||||
modernc.org/gc/v2 v2.5.0/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
|
modernc.org/gc/v2 v2.5.0/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
|
||||||
modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a h1:CfbpOLEo2IwNzJdMvE8aiRbPMxoTpgAJeyePh0SmO8M=
|
modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a h1:CfbpOLEo2IwNzJdMvE8aiRbPMxoTpgAJeyePh0SmO8M=
|
||||||
modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
|
modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
|
||||||
modernc.org/libc v1.60.1 h1:at373l8IFRTkJIkAU85BIuUoBM4T1b51ds0E1ovPG2s=
|
modernc.org/libc v1.61.0 h1:eGFcvWpqlnoGwzZeZe3PWJkkKbM/3SUGyk1DVZQ0TpE=
|
||||||
modernc.org/libc v1.60.1/go.mod h1:xJuobKuNxKH3RUatS7GjR+suWj+5c2K7bi4m/S5arOY=
|
modernc.org/libc v1.61.0/go.mod h1:DvxVX89wtGTu+r72MLGhygpfi3aUGgZRdAYGCAVVud0=
|
||||||
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
||||||
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
||||||
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
|
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
|
||||||
@@ -416,8 +430,8 @@ modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
|
|||||||
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||||
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
|
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
|
||||||
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
|
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
|
||||||
modernc.org/sqlite v1.32.0 h1:6BM4uGza7bWypsw4fdLRsLxut6bHe4c58VeqjRgST8s=
|
modernc.org/sqlite v1.33.1 h1:trb6Z3YYoeM9eDL1O8do81kP+0ejv+YzgyFo+Gwy0nM=
|
||||||
modernc.org/sqlite v1.32.0/go.mod h1:UqoylwmTb9F+IqXERT8bW9zzOWN8qwAIcLdzeBZs4hA=
|
modernc.org/sqlite v1.33.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k=
|
||||||
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
|
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
|
||||||
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
|
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
|
||||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
|||||||
@@ -1,386 +1,89 @@
|
|||||||
|
// Package agent handles the agent's SSH server and system stats collection.
|
||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"beszel"
|
|
||||||
"beszel/internal/entities/container"
|
"beszel/internal/entities/container"
|
||||||
"beszel/internal/entities/system"
|
"beszel/internal/entities/system"
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"log/slog"
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"math"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/shirou/gopsutil/v4/cpu"
|
"github.com/shirou/gopsutil/v4/common"
|
||||||
"github.com/shirou/gopsutil/v4/disk"
|
|
||||||
"github.com/shirou/gopsutil/v4/host"
|
|
||||||
"github.com/shirou/gopsutil/v4/mem"
|
|
||||||
"github.com/shirou/gopsutil/v4/sensors"
|
|
||||||
|
|
||||||
sshServer "github.com/gliderlabs/ssh"
|
|
||||||
psutilNet "github.com/shirou/gopsutil/v4/net"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Agent struct {
|
type Agent struct {
|
||||||
addr string
|
hostname string // Hostname of the system
|
||||||
pubKey []byte
|
kernelVersion string // Kernel version of the system
|
||||||
sem chan struct{}
|
cpuModel string // CPU model of the system
|
||||||
containerStatsMap map[string]*container.PrevContainerStats
|
cores int // Number of cores of the system
|
||||||
containerStatsMutex *sync.Mutex
|
threads int // Number of threads of the system
|
||||||
fsNames []string
|
debug bool // true if LOG_LEVEL is set to debug
|
||||||
fsStats map[string]*system.FsStats
|
fsNames []string // List of filesystem device names being monitored
|
||||||
netInterfaces map[string]struct{}
|
fsStats map[string]*system.FsStats // Keeps track of disk stats for each filesystem
|
||||||
netIoStats *system.NetIoStats
|
netInterfaces map[string]struct{} // Stores all valid network interfaces
|
||||||
dockerClient *http.Client
|
netIoStats system.NetIoStats // Keeps track of bandwidth usage
|
||||||
bufferPool *sync.Pool
|
containerStatsMap map[string]*container.Stats // Keeps track of container stats
|
||||||
|
containerStatsMutex sync.RWMutex // Mutex to prevent concurrent access to prevContainerStatsMap
|
||||||
|
dockerClient *http.Client // HTTP client to query docker api
|
||||||
|
apiContainerList *[]container.ApiInfo // List of containers from docker host
|
||||||
|
sensorsContext context.Context // Sensors context to override sys location
|
||||||
|
sensorsWhitelist map[string]struct{} // List of sensors to monitor
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAgent(pubKey []byte, addr string) *Agent {
|
func NewAgent() *Agent {
|
||||||
return &Agent{
|
return &Agent{
|
||||||
addr: addr,
|
containerStatsMap: make(map[string]*container.Stats),
|
||||||
pubKey: pubKey,
|
containerStatsMutex: sync.RWMutex{},
|
||||||
sem: make(chan struct{}, 15),
|
netIoStats: system.NetIoStats{},
|
||||||
containerStatsMap: make(map[string]*container.PrevContainerStats),
|
|
||||||
containerStatsMutex: &sync.Mutex{},
|
|
||||||
netIoStats: &system.NetIoStats{},
|
|
||||||
dockerClient: newDockerClient(),
|
dockerClient: newDockerClient(),
|
||||||
bufferPool: &sync.Pool{
|
sensorsContext: context.Background(),
|
||||||
New: func() interface{} {
|
|
||||||
return new(bytes.Buffer)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Agent) acquireSemaphore() {
|
func (a *Agent) Run(pubKey []byte, addr string) {
|
||||||
a.sem <- struct{}{}
|
// Set up slog with a log level determined by the LOG_LEVEL env var
|
||||||
}
|
if logLevelStr, exists := os.LookupEnv("LOG_LEVEL"); exists {
|
||||||
|
switch strings.ToLower(logLevelStr) {
|
||||||
func (a *Agent) releaseSemaphore() {
|
case "debug":
|
||||||
<-a.sem
|
a.debug = true
|
||||||
}
|
slog.SetLogLoggerLevel(slog.LevelDebug)
|
||||||
|
case "warn":
|
||||||
func (a *Agent) getSystemStats() (system.Info, system.Stats) {
|
slog.SetLogLoggerLevel(slog.LevelWarn)
|
||||||
systemStats := system.Stats{}
|
case "error":
|
||||||
|
slog.SetLogLoggerLevel(slog.LevelError)
|
||||||
// cpu percent
|
|
||||||
cpuPct, err := cpu.Percent(0, false)
|
|
||||||
if err != nil {
|
|
||||||
log.Println("Error getting cpu percent:", err)
|
|
||||||
} else if len(cpuPct) > 0 {
|
|
||||||
systemStats.Cpu = twoDecimals(cpuPct[0])
|
|
||||||
}
|
|
||||||
|
|
||||||
// memory
|
|
||||||
if v, err := mem.VirtualMemory(); err == nil {
|
|
||||||
systemStats.Mem = bytesToGigabytes(v.Total)
|
|
||||||
systemStats.MemUsed = bytesToGigabytes(v.Used)
|
|
||||||
systemStats.MemBuffCache = bytesToGigabytes(v.Total - v.Free - v.Used)
|
|
||||||
systemStats.MemPct = twoDecimals(v.UsedPercent)
|
|
||||||
systemStats.Swap = bytesToGigabytes(v.SwapTotal)
|
|
||||||
systemStats.SwapUsed = bytesToGigabytes(v.SwapTotal - v.SwapFree)
|
|
||||||
}
|
|
||||||
|
|
||||||
// disk usage
|
|
||||||
for _, stats := range a.fsStats {
|
|
||||||
// log.Println("Reading filesystem:", fs.Mountpoint)
|
|
||||||
if d, err := disk.Usage(stats.Mountpoint); err == nil {
|
|
||||||
stats.DiskTotal = bytesToGigabytes(d.Total)
|
|
||||||
stats.DiskUsed = bytesToGigabytes(d.Used)
|
|
||||||
if stats.Root {
|
|
||||||
systemStats.DiskTotal = bytesToGigabytes(d.Total)
|
|
||||||
systemStats.DiskUsed = bytesToGigabytes(d.Used)
|
|
||||||
systemStats.DiskPct = twoDecimals(d.UsedPercent)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// reset stats if error (likely unmounted)
|
|
||||||
log.Printf("Error reading %s: %+v\n", stats.Mountpoint, err)
|
|
||||||
stats.DiskTotal = 0
|
|
||||||
stats.DiskUsed = 0
|
|
||||||
stats.TotalRead = 0
|
|
||||||
stats.TotalWrite = 0
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// disk i/o
|
// Set sensors context (allows overriding sys location for sensors)
|
||||||
if ioCounters, err := disk.IOCounters(a.fsNames...); err == nil {
|
if sysSensors, exists := os.LookupEnv("SYS_SENSORS"); exists {
|
||||||
for _, d := range ioCounters {
|
slog.Info("SYS_SENSORS", "path", sysSensors)
|
||||||
stats := a.fsStats[d.Name]
|
a.sensorsContext = context.WithValue(a.sensorsContext,
|
||||||
if stats == nil {
|
common.EnvKey, common.EnvMap{common.HostSysEnvKey: sysSensors},
|
||||||
continue
|
)
|
||||||
}
|
}
|
||||||
secondsElapsed := time.Since(stats.Time).Seconds()
|
|
||||||
readPerSecond := float64(d.ReadBytes-stats.TotalRead) / secondsElapsed
|
// Set sensors whitelist
|
||||||
writePerSecond := float64(d.WriteBytes-stats.TotalWrite) / secondsElapsed
|
if sensors, exists := os.LookupEnv("SENSORS"); exists {
|
||||||
stats.Time = time.Now()
|
a.sensorsWhitelist = make(map[string]struct{})
|
||||||
stats.DiskReadPs = bytesToMegabytes(readPerSecond)
|
for _, sensor := range strings.Split(sensors, ",") {
|
||||||
stats.DiskWritePs = bytesToMegabytes(writePerSecond)
|
a.sensorsWhitelist[sensor] = struct{}{}
|
||||||
stats.TotalRead = d.ReadBytes
|
|
||||||
stats.TotalWrite = d.WriteBytes
|
|
||||||
// if root filesystem, update system stats
|
|
||||||
if stats.Root {
|
|
||||||
systemStats.DiskReadPs = stats.DiskReadPs
|
|
||||||
systemStats.DiskWritePs = stats.DiskWritePs
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// network stats
|
a.initializeSystemInfo()
|
||||||
if netIO, err := psutilNet.IOCounters(true); err == nil {
|
a.initializeDiskInfo()
|
||||||
secondsElapsed := time.Since(a.netIoStats.Time).Seconds()
|
a.initializeNetIoStats()
|
||||||
a.netIoStats.Time = time.Now()
|
|
||||||
bytesSent := uint64(0)
|
|
||||||
bytesRecv := uint64(0)
|
|
||||||
// sum all bytes sent and received
|
|
||||||
for _, v := range netIO {
|
|
||||||
// skip if not in valid network interfaces list
|
|
||||||
if _, exists := a.netInterfaces[v.Name]; !exists {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// log.Printf("%+v: %+v recv, %+v sent\n", v.Name, v.BytesRecv, v.BytesSent)
|
|
||||||
bytesSent += v.BytesSent
|
|
||||||
bytesRecv += v.BytesRecv
|
|
||||||
}
|
|
||||||
// add to systemStats
|
|
||||||
sentPerSecond := float64(bytesSent-a.netIoStats.BytesSent) / secondsElapsed
|
|
||||||
recvPerSecond := float64(bytesRecv-a.netIoStats.BytesRecv) / secondsElapsed
|
|
||||||
networkSentPs := bytesToMegabytes(sentPerSecond)
|
|
||||||
networkRecvPs := bytesToMegabytes(recvPerSecond)
|
|
||||||
// add check for issue (#150) where sent is a massive number
|
|
||||||
if networkSentPs > 10_000 || networkRecvPs > 10_000 {
|
|
||||||
log.Printf("Warning: network sent/recv is %.2f/%.2f MB/s. Resetting stats.\n", networkSentPs, networkRecvPs)
|
|
||||||
for _, v := range netIO {
|
|
||||||
if _, exists := a.netInterfaces[v.Name]; !exists {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
log.Printf("%+s: %v recv, %v sent\n", v.Name, v.BytesRecv, v.BytesSent)
|
|
||||||
}
|
|
||||||
// reset network I/O stats
|
|
||||||
a.initializeNetIoStats()
|
|
||||||
} else {
|
|
||||||
systemStats.NetworkSent = networkSentPs
|
|
||||||
systemStats.NetworkRecv = networkRecvPs
|
|
||||||
// update netIoStats
|
|
||||||
a.netIoStats.BytesSent = bytesSent
|
|
||||||
a.netIoStats.BytesRecv = bytesRecv
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// temperatures
|
a.startServer(pubKey, addr)
|
||||||
if temps, err := sensors.SensorsTemperatures(); err == nil {
|
|
||||||
systemStats.Temperatures = make(map[string]float64)
|
|
||||||
// log.Printf("Temperatures: %+v\n", temps)
|
|
||||||
for i, temp := range temps {
|
|
||||||
if _, ok := systemStats.Temperatures[temp.SensorKey]; ok {
|
|
||||||
// if key already exists, append int to key
|
|
||||||
systemStats.Temperatures[temp.SensorKey+"_"+strconv.Itoa(i)] = twoDecimals(temp.Temperature)
|
|
||||||
} else {
|
|
||||||
systemStats.Temperatures[temp.SensorKey] = twoDecimals(temp.Temperature)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// log.Printf("Temperature map: %+v\n", systemStats.Temperatures)
|
|
||||||
}
|
|
||||||
|
|
||||||
systemInfo := system.Info{
|
|
||||||
Cpu: systemStats.Cpu,
|
|
||||||
MemPct: systemStats.MemPct,
|
|
||||||
DiskPct: systemStats.DiskPct,
|
|
||||||
AgentVersion: beszel.Version,
|
|
||||||
}
|
|
||||||
|
|
||||||
// add host info
|
|
||||||
if info, err := host.Info(); err == nil {
|
|
||||||
systemInfo.Uptime = info.Uptime
|
|
||||||
// systemInfo.Os = info.OS
|
|
||||||
}
|
|
||||||
// add cpu stats
|
|
||||||
if info, err := cpu.Info(); err == nil && len(info) > 0 {
|
|
||||||
systemInfo.CpuModel = info[0].ModelName
|
|
||||||
}
|
|
||||||
if cores, err := cpu.Counts(false); err == nil {
|
|
||||||
systemInfo.Cores = cores
|
|
||||||
}
|
|
||||||
if threads, err := cpu.Counts(true); err == nil {
|
|
||||||
systemInfo.Threads = threads
|
|
||||||
}
|
|
||||||
|
|
||||||
return systemInfo, systemStats
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *Agent) getDockerStats() ([]container.Stats, error) {
|
|
||||||
resp, err := a.dockerClient.Get("http://localhost/containers/json")
|
|
||||||
if err != nil {
|
|
||||||
a.closeIdleConnections(err)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
var containers []container.ApiInfo
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&containers); err != nil {
|
|
||||||
log.Printf("Error decoding containers: %+v\n", err)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
containerStats := make([]container.Stats, 0, len(containers))
|
|
||||||
containerStatsMutex := sync.Mutex{}
|
|
||||||
|
|
||||||
// store valid ids to clean up old container ids from map
|
|
||||||
validIds := make(map[string]struct{}, len(containers))
|
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
|
|
||||||
for _, ctr := range containers {
|
|
||||||
ctr.IdShort = ctr.Id[:12]
|
|
||||||
validIds[ctr.IdShort] = struct{}{}
|
|
||||||
// check if container is less than 1 minute old (possible restart)
|
|
||||||
// note: can't use Created field because it's not updated on restart
|
|
||||||
if strings.Contains(ctr.Status, "second") {
|
|
||||||
// if so, remove old container data
|
|
||||||
a.deleteContainerStatsSync(ctr.IdShort)
|
|
||||||
}
|
|
||||||
wg.Add(1)
|
|
||||||
a.acquireSemaphore()
|
|
||||||
go func() {
|
|
||||||
defer a.releaseSemaphore()
|
|
||||||
defer wg.Done()
|
|
||||||
cstats, err := a.getContainerStats(ctr)
|
|
||||||
if err != nil {
|
|
||||||
// close idle connections if error is a network timeout
|
|
||||||
isTimeout := a.closeIdleConnections(err)
|
|
||||||
// delete container from map if not a timeout
|
|
||||||
if !isTimeout {
|
|
||||||
a.deleteContainerStatsSync(ctr.IdShort)
|
|
||||||
}
|
|
||||||
// retry once
|
|
||||||
cstats, err = a.getContainerStats(ctr)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Error getting container stats: %+v\n", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
containerStatsMutex.Lock()
|
|
||||||
defer containerStatsMutex.Unlock()
|
|
||||||
containerStats = append(containerStats, cstats)
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
wg.Wait()
|
|
||||||
|
|
||||||
for id := range a.containerStatsMap {
|
|
||||||
if _, exists := validIds[id]; !exists {
|
|
||||||
// log.Printf("Removing container cpu map entry: %+v\n", id)
|
|
||||||
delete(a.containerStatsMap, id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return containerStats, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *Agent) getContainerStats(ctr container.ApiInfo) (container.Stats, error) {
|
|
||||||
cStats := container.Stats{}
|
|
||||||
|
|
||||||
resp, err := a.dockerClient.Get("http://localhost/containers/" + ctr.IdShort + "/stats?stream=0&one-shot=1")
|
|
||||||
if err != nil {
|
|
||||||
return cStats, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
// use a pooled buffer to store the response body
|
|
||||||
buf := a.bufferPool.Get().(*bytes.Buffer)
|
|
||||||
defer a.bufferPool.Put(buf)
|
|
||||||
buf.Reset()
|
|
||||||
_, err = io.Copy(buf, resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return cStats, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// unmarshal the json data from the buffer
|
|
||||||
var statsJson container.ApiStats
|
|
||||||
if err := json.Unmarshal(buf.Bytes(), &statsJson); err != nil {
|
|
||||||
return cStats, err
|
|
||||||
}
|
|
||||||
|
|
||||||
name := ctr.Names[0][1:]
|
|
||||||
|
|
||||||
// check if container has valid data, otherwise may be in restart loop (#103)
|
|
||||||
if statsJson.MemoryStats.Usage == 0 {
|
|
||||||
return cStats, fmt.Errorf("%s - invalid data", name)
|
|
||||||
}
|
|
||||||
|
|
||||||
// memory (https://docs.docker.com/reference/cli/docker/container/stats/)
|
|
||||||
memCache := statsJson.MemoryStats.Stats["inactive_file"]
|
|
||||||
if memCache == 0 {
|
|
||||||
memCache = statsJson.MemoryStats.Stats["cache"]
|
|
||||||
}
|
|
||||||
usedMemory := statsJson.MemoryStats.Usage - memCache
|
|
||||||
|
|
||||||
a.containerStatsMutex.Lock()
|
|
||||||
defer a.containerStatsMutex.Unlock()
|
|
||||||
|
|
||||||
// add empty values if they doesn't exist in map
|
|
||||||
stats, initialized := a.containerStatsMap[ctr.IdShort]
|
|
||||||
if !initialized {
|
|
||||||
stats = &container.PrevContainerStats{}
|
|
||||||
a.containerStatsMap[ctr.IdShort] = stats
|
|
||||||
}
|
|
||||||
|
|
||||||
// cpu
|
|
||||||
cpuDelta := statsJson.CPUStats.CPUUsage.TotalUsage - stats.Cpu[0]
|
|
||||||
systemDelta := statsJson.CPUStats.SystemUsage - stats.Cpu[1]
|
|
||||||
cpuPct := float64(cpuDelta) / float64(systemDelta) * 100
|
|
||||||
if cpuPct > 100 {
|
|
||||||
return cStats, fmt.Errorf("%s cpu pct greater than 100: %+v", name, cpuPct)
|
|
||||||
}
|
|
||||||
stats.Cpu = [2]uint64{statsJson.CPUStats.CPUUsage.TotalUsage, statsJson.CPUStats.SystemUsage}
|
|
||||||
|
|
||||||
// network
|
|
||||||
var total_sent, total_recv uint64
|
|
||||||
for _, v := range statsJson.Networks {
|
|
||||||
total_sent += v.TxBytes
|
|
||||||
total_recv += v.RxBytes
|
|
||||||
}
|
|
||||||
var sent_delta, recv_delta float64
|
|
||||||
// prevent first run from sending all prev sent/recv bytes
|
|
||||||
if initialized {
|
|
||||||
secondsElapsed := time.Since(stats.Net.Time).Seconds()
|
|
||||||
sent_delta = float64(total_sent-stats.Net.Sent) / secondsElapsed
|
|
||||||
recv_delta = float64(total_recv-stats.Net.Recv) / secondsElapsed
|
|
||||||
// log.Printf("sent delta: %+v, recv delta: %+v\n", sent_delta, recv_delta)
|
|
||||||
}
|
|
||||||
stats.Net.Sent = total_sent
|
|
||||||
stats.Net.Recv = total_recv
|
|
||||||
stats.Net.Time = time.Now()
|
|
||||||
|
|
||||||
// cStats := a.containerStatsPool.Get().(*container.Stats)
|
|
||||||
cStats.Name = name
|
|
||||||
cStats.Cpu = twoDecimals(cpuPct)
|
|
||||||
cStats.Mem = bytesToMegabytes(float64(usedMemory))
|
|
||||||
cStats.NetworkSent = bytesToMegabytes(sent_delta)
|
|
||||||
cStats.NetworkRecv = bytesToMegabytes(recv_delta)
|
|
||||||
|
|
||||||
return cStats, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// delete container stats from map using mutex
|
|
||||||
func (a *Agent) deleteContainerStatsSync(id string) {
|
|
||||||
a.containerStatsMutex.Lock()
|
|
||||||
defer a.containerStatsMutex.Unlock()
|
|
||||||
delete(a.containerStatsMap, id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Agent) gatherStats() system.CombinedData {
|
func (a *Agent) gatherStats() system.CombinedData {
|
||||||
systemInfo, systemStats := a.getSystemStats()
|
systemInfo, SystemStats := a.getSystemStats()
|
||||||
systemData := system.CombinedData{
|
systemData := system.CombinedData{
|
||||||
Stats: systemStats,
|
Stats: SystemStats,
|
||||||
Info: systemInfo,
|
Info: systemInfo,
|
||||||
}
|
}
|
||||||
// add docker stats
|
// add docker stats
|
||||||
@@ -394,268 +97,5 @@ func (a *Agent) gatherStats() system.CombinedData {
|
|||||||
systemData.Stats.ExtraFs[name] = stats
|
systemData.Stats.ExtraFs[name] = stats
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// log.Printf("%+v\n", systemData)
|
|
||||||
return systemData
|
return systemData
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Agent) startServer() {
|
|
||||||
sshServer.Handle(a.handleSession)
|
|
||||||
|
|
||||||
log.Printf("Starting SSH server on %s", a.addr)
|
|
||||||
if err := sshServer.ListenAndServe(a.addr, nil, sshServer.NoPty(),
|
|
||||||
sshServer.PublicKeyAuth(func(ctx sshServer.Context, key sshServer.PublicKey) bool {
|
|
||||||
allowed, _, _, _, _ := sshServer.ParseAuthorizedKey(a.pubKey)
|
|
||||||
return sshServer.KeysEqual(key, allowed)
|
|
||||||
}),
|
|
||||||
); err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *Agent) handleSession(s sshServer.Session) {
|
|
||||||
stats := a.gatherStats()
|
|
||||||
encoder := json.NewEncoder(s)
|
|
||||||
if err := encoder.Encode(stats); err != nil {
|
|
||||||
log.Println("Error encoding stats:", err.Error())
|
|
||||||
s.Exit(1)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.Exit(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *Agent) Run() {
|
|
||||||
a.fsStats = make(map[string]*system.FsStats)
|
|
||||||
|
|
||||||
filesystem, fsEnvVarExists := os.LookupEnv("FILESYSTEM")
|
|
||||||
if fsEnvVarExists {
|
|
||||||
a.fsStats[filesystem] = &system.FsStats{Root: true, Mountpoint: "/"}
|
|
||||||
}
|
|
||||||
|
|
||||||
if extraFilesystems, exists := os.LookupEnv("EXTRA_FILESYSTEMS"); exists {
|
|
||||||
// parse comma separated list of filesystems
|
|
||||||
for _, filesystem := range strings.Split(extraFilesystems, ",") {
|
|
||||||
a.fsStats[filesystem] = &system.FsStats{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
a.initializeDiskInfo(fsEnvVarExists)
|
|
||||||
a.initializeDiskIoStats()
|
|
||||||
a.initializeNetIoStats()
|
|
||||||
|
|
||||||
// log.Printf("Filesystems: %+v\n", a.fsStats)
|
|
||||||
a.startServer()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sets up the filesystems to monitor for disk usage and I/O.
|
|
||||||
func (a *Agent) initializeDiskInfo(fsEnvVarExists bool) error {
|
|
||||||
partitions, err := disk.Partitions(false)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// log.Printf("Partitions: %+v\n", partitions)
|
|
||||||
for _, v := range partitions {
|
|
||||||
// binary - use root mountpoint if not already set by env var
|
|
||||||
if !fsEnvVarExists && v.Mountpoint == "/" {
|
|
||||||
a.fsStats[v.Device] = &system.FsStats{Root: true, Mountpoint: "/"}
|
|
||||||
}
|
|
||||||
// docker - use /etc/hosts device as root if not mapped
|
|
||||||
if !fsEnvVarExists && v.Mountpoint == "/etc/hosts" && strings.HasPrefix(v.Device, "/dev") && !strings.Contains(v.Device, "mapper") {
|
|
||||||
a.fsStats[v.Device] = &system.FsStats{Root: true, Mountpoint: "/"}
|
|
||||||
}
|
|
||||||
// check if device is in /extra-filesystem
|
|
||||||
if strings.HasPrefix(v.Mountpoint, "/extra-filesystem") {
|
|
||||||
// todo: may be able to tweak this to be able to mount custom root at /extra-filesystems/root
|
|
||||||
a.fsStats[v.Device] = &system.FsStats{Mountpoint: v.Mountpoint}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// set mountpoints for extra filesystems if passed in via env var
|
|
||||||
for name, stats := range a.fsStats {
|
|
||||||
if strings.HasSuffix(v.Device, name) {
|
|
||||||
stats.Mountpoint = v.Mountpoint
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// remove extra filesystems that don't have a mountpoint
|
|
||||||
hasRoot := false
|
|
||||||
for name, stats := range a.fsStats {
|
|
||||||
if stats.Root {
|
|
||||||
hasRoot = true
|
|
||||||
log.Println("Detected root fs:", name)
|
|
||||||
}
|
|
||||||
if stats.Mountpoint == "" {
|
|
||||||
log.Printf("Ignoring %s. No mountpoint found.\n", name)
|
|
||||||
delete(a.fsStats, name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// if no root filesystem set, use most read device in /proc/diskstats
|
|
||||||
if !hasRoot {
|
|
||||||
rootDevice := findMaxReadsDevice()
|
|
||||||
log.Printf("Detected root fs: %+s\n", rootDevice)
|
|
||||||
a.fsStats[rootDevice] = &system.FsStats{Root: true, Mountpoint: "/"}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sets start values for disk I/O stats.
|
|
||||||
func (a *Agent) initializeDiskIoStats() {
|
|
||||||
// create slice of fs names to pass to disk.IOCounters later
|
|
||||||
a.fsNames = make([]string, 0, len(a.fsStats))
|
|
||||||
|
|
||||||
for name, stats := range a.fsStats {
|
|
||||||
if io, err := disk.IOCounters(name); err == nil {
|
|
||||||
for _, d := range io {
|
|
||||||
// add name to slice
|
|
||||||
a.fsNames = append(a.fsNames, d.Name)
|
|
||||||
// normalize name with io counters
|
|
||||||
if name != d.Name {
|
|
||||||
// log.Println("Normalizing disk I/O stats:", name, d.Name)
|
|
||||||
a.fsStats[d.Name] = stats
|
|
||||||
delete(a.fsStats, name)
|
|
||||||
}
|
|
||||||
stats.Time = time.Now()
|
|
||||||
stats.TotalRead = d.ReadBytes
|
|
||||||
stats.TotalWrite = d.WriteBytes
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *Agent) initializeNetIoStats() {
|
|
||||||
// reset valid network interfaces
|
|
||||||
a.netInterfaces = make(map[string]struct{}, 0)
|
|
||||||
// reset network I/O stats
|
|
||||||
a.netIoStats.BytesSent = 0
|
|
||||||
a.netIoStats.BytesRecv = 0
|
|
||||||
// get intial network I/O stats
|
|
||||||
if netIO, err := psutilNet.IOCounters(true); err == nil {
|
|
||||||
a.netIoStats.Time = time.Now()
|
|
||||||
for _, v := range netIO {
|
|
||||||
if skipNetworkInterface(v) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
log.Printf("Detected network interface: %+v (%+v recv, %+v sent)\n", v.Name, v.BytesRecv, v.BytesSent)
|
|
||||||
a.netIoStats.BytesSent += v.BytesSent
|
|
||||||
a.netIoStats.BytesRecv += v.BytesRecv
|
|
||||||
// store as a valid network interface
|
|
||||||
a.netInterfaces[v.Name] = struct{}{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func bytesToMegabytes(b float64) float64 {
|
|
||||||
return twoDecimals(b / 1048576)
|
|
||||||
}
|
|
||||||
|
|
||||||
func bytesToGigabytes(b uint64) float64 {
|
|
||||||
return twoDecimals(float64(b) / 1073741824)
|
|
||||||
}
|
|
||||||
|
|
||||||
func twoDecimals(value float64) float64 {
|
|
||||||
return math.Round(value*100) / 100
|
|
||||||
}
|
|
||||||
|
|
||||||
func skipNetworkInterface(v psutilNet.IOCountersStat) bool {
|
|
||||||
switch {
|
|
||||||
case strings.HasPrefix(v.Name, "lo"),
|
|
||||||
strings.HasPrefix(v.Name, "docker"),
|
|
||||||
strings.HasPrefix(v.Name, "br-"),
|
|
||||||
strings.HasPrefix(v.Name, "veth"),
|
|
||||||
v.BytesRecv == 0,
|
|
||||||
v.BytesSent == 0:
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func newDockerClient() *http.Client {
|
|
||||||
dockerHost := "unix:///var/run/docker.sock"
|
|
||||||
if dockerHostEnv, exists := os.LookupEnv("DOCKER_HOST"); exists {
|
|
||||||
dockerHost = dockerHostEnv
|
|
||||||
}
|
|
||||||
|
|
||||||
parsedURL, err := url.Parse(dockerHost)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal("Error parsing DOCKER_HOST: " + err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
transport := &http.Transport{
|
|
||||||
ForceAttemptHTTP2: false,
|
|
||||||
IdleConnTimeout: 90 * time.Second,
|
|
||||||
DisableCompression: true,
|
|
||||||
MaxConnsPerHost: 20,
|
|
||||||
MaxIdleConnsPerHost: 20,
|
|
||||||
DisableKeepAlives: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
switch parsedURL.Scheme {
|
|
||||||
case "unix":
|
|
||||||
transport.DialContext = func(ctx context.Context, proto, addr string) (net.Conn, error) {
|
|
||||||
return (&net.Dialer{}).DialContext(ctx, "unix", parsedURL.Path)
|
|
||||||
}
|
|
||||||
case "tcp", "http", "https":
|
|
||||||
log.Println("Using DOCKER_HOST: " + dockerHost)
|
|
||||||
transport.DialContext = func(ctx context.Context, proto, addr string) (net.Conn, error) {
|
|
||||||
return (&net.Dialer{}).DialContext(ctx, "tcp", parsedURL.Host)
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
log.Fatal("Unsupported DOCKER_HOST: " + parsedURL.Scheme)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &http.Client{
|
|
||||||
Timeout: time.Second,
|
|
||||||
Transport: transport,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// closes idle connections on timeouts to prevent reuse of stale connections
|
|
||||||
func (a *Agent) closeIdleConnections(err error) (isTimeout bool) {
|
|
||||||
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
|
|
||||||
log.Printf("Closing idle connections. Error: %+v\n", err)
|
|
||||||
a.dockerClient.Transport.(*http.Transport).CloseIdleConnections()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Returns the device with the most reads in /proc/diskstats
|
|
||||||
// (fallback in case the root device is not supplied or detected)
|
|
||||||
func findMaxReadsDevice() string {
|
|
||||||
content, err := os.ReadFile("/proc/diskstats")
|
|
||||||
if err != nil {
|
|
||||||
return "/"
|
|
||||||
}
|
|
||||||
|
|
||||||
lines := strings.Split(string(content), "\n")
|
|
||||||
var maxReadsSectors int64
|
|
||||||
var maxReadsDevice string
|
|
||||||
|
|
||||||
for _, line := range lines {
|
|
||||||
fields := strings.Fields(line)
|
|
||||||
if len(fields) < 7 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
deviceName := fields[2]
|
|
||||||
readsSectors, err := strconv.ParseInt(fields[5], 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if readsSectors > maxReadsSectors {
|
|
||||||
maxReadsSectors = readsSectors
|
|
||||||
maxReadsDevice = deviceName
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if maxReadsDevice == "" {
|
|
||||||
return "/"
|
|
||||||
}
|
|
||||||
|
|
||||||
return maxReadsDevice
|
|
||||||
}
|
|
||||||
|
|||||||
169
beszel/internal/agent/disk.go
Normal file
169
beszel/internal/agent/disk.go
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"beszel/internal/entities/system"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/shirou/gopsutil/v4/disk"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sets up the filesystems to monitor for disk usage and I/O.
|
||||||
|
func (a *Agent) initializeDiskInfo() {
|
||||||
|
filesystem := os.Getenv("FILESYSTEM")
|
||||||
|
efPath := "/extra-filesystems"
|
||||||
|
hasRoot := false
|
||||||
|
|
||||||
|
// Create map for disk stats
|
||||||
|
a.fsStats = make(map[string]*system.FsStats)
|
||||||
|
|
||||||
|
partitions, err := disk.Partitions(false)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Error getting disk partitions", "err", err)
|
||||||
|
}
|
||||||
|
slog.Debug("Disk", "partitions", partitions)
|
||||||
|
|
||||||
|
// ioContext := context.WithValue(a.sensorsContext,
|
||||||
|
// common.EnvKey, common.EnvMap{common.HostProcEnvKey: "/tmp/testproc"},
|
||||||
|
// )
|
||||||
|
// diskIoCounters, err := disk.IOCountersWithContext(ioContext)
|
||||||
|
|
||||||
|
diskIoCounters, err := disk.IOCounters()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Error getting diskstats", "err", err)
|
||||||
|
}
|
||||||
|
slog.Debug("Disk I/O", "diskstats", diskIoCounters)
|
||||||
|
|
||||||
|
// Helper function to add a filesystem to fsStats if it doesn't exist
|
||||||
|
addFsStat := func(device, mountpoint string, root bool) {
|
||||||
|
key := filepath.Base(device)
|
||||||
|
if _, exists := a.fsStats[key]; !exists {
|
||||||
|
if root {
|
||||||
|
slog.Info("Detected root device", "name", key)
|
||||||
|
// check if root device is in /proc/diskstats, use fallback if not
|
||||||
|
if _, exists := diskIoCounters[key]; !exists {
|
||||||
|
slog.Warn("Device not found in diskstats", "name", key)
|
||||||
|
key = findFallbackIoDevice(filesystem, diskIoCounters)
|
||||||
|
slog.Info("Using I/O fallback", "name", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.fsStats[key] = &system.FsStats{Root: root, Mountpoint: mountpoint}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use FILESYSTEM env var to find root filesystem
|
||||||
|
if filesystem != "" {
|
||||||
|
for _, p := range partitions {
|
||||||
|
if strings.HasSuffix(p.Device, filesystem) || p.Mountpoint == filesystem {
|
||||||
|
addFsStat(p.Device, p.Mountpoint, true)
|
||||||
|
hasRoot = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasRoot {
|
||||||
|
slog.Warn("Partition details not found", "filesystem", filesystem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add EXTRA_FILESYSTEMS env var values to fsStats
|
||||||
|
if extraFilesystems, exists := os.LookupEnv("EXTRA_FILESYSTEMS"); exists {
|
||||||
|
for _, fs := range strings.Split(extraFilesystems, ",") {
|
||||||
|
found := false
|
||||||
|
for _, p := range partitions {
|
||||||
|
if strings.HasSuffix(p.Device, fs) || p.Mountpoint == fs {
|
||||||
|
addFsStat(p.Device, p.Mountpoint, false)
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// if not in partitions, test if we can get disk usage
|
||||||
|
if !found {
|
||||||
|
if _, err := disk.Usage(fs); err == nil {
|
||||||
|
addFsStat(filepath.Base(fs), fs, false)
|
||||||
|
} else {
|
||||||
|
slog.Error("Invalid filesystem", "name", fs, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process partitions for various mount points
|
||||||
|
for _, p := range partitions {
|
||||||
|
// fmt.Println(p.Device, p.Mountpoint)
|
||||||
|
// Binary root fallback or docker root fallback
|
||||||
|
if !hasRoot && (p.Mountpoint == "/" || (p.Mountpoint == "/etc/hosts" && strings.HasPrefix(p.Device, "/dev") && !strings.Contains(p.Device, "mapper"))) {
|
||||||
|
addFsStat(p.Device, "/", true)
|
||||||
|
hasRoot = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if device is in /extra-filesystems
|
||||||
|
if strings.HasPrefix(p.Mountpoint, efPath) {
|
||||||
|
addFsStat(p.Device, p.Mountpoint, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check all folders in /extra-filesystems and add them if not already present
|
||||||
|
if folders, err := os.ReadDir(efPath); err == nil {
|
||||||
|
existingMountpoints := make(map[string]bool)
|
||||||
|
for _, stats := range a.fsStats {
|
||||||
|
existingMountpoints[stats.Mountpoint] = true
|
||||||
|
}
|
||||||
|
for _, folder := range folders {
|
||||||
|
if folder.IsDir() {
|
||||||
|
mountpoint := filepath.Join(efPath, folder.Name())
|
||||||
|
slog.Debug("/extra-filesystems", "mountpoint", mountpoint)
|
||||||
|
if !existingMountpoints[mountpoint] {
|
||||||
|
a.fsStats[folder.Name()] = &system.FsStats{Mountpoint: mountpoint}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no root filesystem set, use fallback
|
||||||
|
if !hasRoot {
|
||||||
|
rootDevice := findFallbackIoDevice(filepath.Base(filesystem), diskIoCounters)
|
||||||
|
slog.Info("Root disk", "mountpoint", "/", "io", rootDevice)
|
||||||
|
a.fsStats[rootDevice] = &system.FsStats{Root: true, Mountpoint: "/"}
|
||||||
|
}
|
||||||
|
|
||||||
|
a.initializeDiskIoStats(diskIoCounters)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the device with the most reads in /proc/diskstats,
|
||||||
|
// or the device specified by the filesystem argument if it exists
|
||||||
|
func findFallbackIoDevice(filesystem string, diskIoCounters map[string]disk.IOCountersStat) string {
|
||||||
|
var maxReadBytes uint64
|
||||||
|
maxReadDevice := "/"
|
||||||
|
for _, d := range diskIoCounters {
|
||||||
|
if d.Name == filesystem {
|
||||||
|
return d.Name
|
||||||
|
}
|
||||||
|
if d.ReadBytes > maxReadBytes {
|
||||||
|
maxReadBytes = d.ReadBytes
|
||||||
|
maxReadDevice = d.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return maxReadDevice
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sets start values for disk I/O stats.
|
||||||
|
func (a *Agent) initializeDiskIoStats(diskIoCounters map[string]disk.IOCountersStat) {
|
||||||
|
for device, stats := range a.fsStats {
|
||||||
|
// skip if not in diskIoCounters
|
||||||
|
d, exists := diskIoCounters[device]
|
||||||
|
if !exists {
|
||||||
|
slog.Warn("Device not found in diskstats", "name", device)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// populate initial values
|
||||||
|
stats.Time = time.Now()
|
||||||
|
stats.TotalRead = d.ReadBytes
|
||||||
|
stats.TotalWrite = d.WriteBytes
|
||||||
|
// add to list of valid io device names
|
||||||
|
a.fsNames = append(a.fsNames, device)
|
||||||
|
}
|
||||||
|
}
|
||||||
211
beszel/internal/agent/docker.go
Normal file
211
beszel/internal/agent/docker.go
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"beszel/internal/entities/container"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Returns stats for all running containers
|
||||||
|
func (a *Agent) getDockerStats() ([]*container.Stats, error) {
|
||||||
|
resp, err := a.dockerClient.Get("http://localhost/containers/json")
|
||||||
|
if err != nil {
|
||||||
|
a.closeIdleConnections(err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&a.apiContainerList); err != nil {
|
||||||
|
slog.Error("Error decoding containers", "err", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
containersLength := len(*a.apiContainerList)
|
||||||
|
containerStats := make([]*container.Stats, containersLength)
|
||||||
|
|
||||||
|
// store valid ids to clean up old container ids from map
|
||||||
|
validIds := make(map[string]struct{}, containersLength)
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
for i, ctr := range *a.apiContainerList {
|
||||||
|
ctr.IdShort = ctr.Id[:12]
|
||||||
|
validIds[ctr.IdShort] = struct{}{}
|
||||||
|
// check if container is less than 1 minute old (possible restart)
|
||||||
|
// note: can't use Created field because it's not updated on restart
|
||||||
|
if strings.Contains(ctr.Status, "second") {
|
||||||
|
// if so, remove old container data
|
||||||
|
a.deleteContainerStatsSync(ctr.IdShort)
|
||||||
|
}
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
stats, err := a.getContainerStats(ctr)
|
||||||
|
if err != nil {
|
||||||
|
// close idle connections if error is a network timeout
|
||||||
|
isTimeout := a.closeIdleConnections(err)
|
||||||
|
// delete container from map if not a timeout
|
||||||
|
if !isTimeout {
|
||||||
|
a.deleteContainerStatsSync(ctr.IdShort)
|
||||||
|
}
|
||||||
|
// retry once
|
||||||
|
stats, err = a.getContainerStats(ctr)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Error getting container stats", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
containerStats[i] = stats
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
// remove old / invalid container stats
|
||||||
|
for id := range a.containerStatsMap {
|
||||||
|
if _, exists := validIds[id]; !exists {
|
||||||
|
delete(a.containerStatsMap, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return containerStats, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns stats for individual container
|
||||||
|
func (a *Agent) getContainerStats(ctr container.ApiInfo) (*container.Stats, error) {
|
||||||
|
name := ctr.Names[0][1:]
|
||||||
|
|
||||||
|
resp, err := a.dockerClient.Get("http://localhost/containers/" + ctr.IdShort + "/stats?stream=0&one-shot=1")
|
||||||
|
if err != nil {
|
||||||
|
return &container.Stats{Name: name}, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
a.containerStatsMutex.Lock()
|
||||||
|
defer a.containerStatsMutex.Unlock()
|
||||||
|
|
||||||
|
// add empty values if they doesn't exist in map
|
||||||
|
stats, initialized := a.containerStatsMap[ctr.IdShort]
|
||||||
|
if !initialized {
|
||||||
|
stats = &container.Stats{Name: name}
|
||||||
|
a.containerStatsMap[ctr.IdShort] = stats
|
||||||
|
}
|
||||||
|
|
||||||
|
// reset current stats
|
||||||
|
stats.Cpu = 0
|
||||||
|
stats.Mem = 0
|
||||||
|
stats.NetworkSent = 0
|
||||||
|
stats.NetworkRecv = 0
|
||||||
|
|
||||||
|
// docker host container stats response
|
||||||
|
var res container.ApiStats
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
|
||||||
|
return stats, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if container has valid data, otherwise may be in restart loop (#103)
|
||||||
|
if res.MemoryStats.Usage == 0 {
|
||||||
|
return stats, fmt.Errorf("%s - no memory stats - see https://github.com/henrygd/beszel/issues/144", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// memory (https://docs.docker.com/reference/cli/docker/container/stats/)
|
||||||
|
memCache := res.MemoryStats.Stats.InactiveFile
|
||||||
|
if memCache == 0 {
|
||||||
|
memCache = res.MemoryStats.Stats.Cache
|
||||||
|
}
|
||||||
|
usedMemory := res.MemoryStats.Usage - memCache
|
||||||
|
|
||||||
|
// cpu
|
||||||
|
cpuDelta := res.CPUStats.CPUUsage.TotalUsage - stats.PrevCpu[0]
|
||||||
|
systemDelta := res.CPUStats.SystemUsage - stats.PrevCpu[1]
|
||||||
|
cpuPct := float64(cpuDelta) / float64(systemDelta) * 100
|
||||||
|
if cpuPct > 100 {
|
||||||
|
return stats, fmt.Errorf("%s cpu pct greater than 100: %+v", name, cpuPct)
|
||||||
|
}
|
||||||
|
stats.PrevCpu = [2]uint64{res.CPUStats.CPUUsage.TotalUsage, res.CPUStats.SystemUsage}
|
||||||
|
|
||||||
|
// network
|
||||||
|
var total_sent, total_recv uint64
|
||||||
|
for _, v := range res.Networks {
|
||||||
|
total_sent += v.TxBytes
|
||||||
|
total_recv += v.RxBytes
|
||||||
|
}
|
||||||
|
var sent_delta, recv_delta float64
|
||||||
|
// prevent first run from sending all prev sent/recv bytes
|
||||||
|
if initialized {
|
||||||
|
secondsElapsed := time.Since(stats.PrevNet.Time).Seconds()
|
||||||
|
sent_delta = float64(total_sent-stats.PrevNet.Sent) / secondsElapsed
|
||||||
|
recv_delta = float64(total_recv-stats.PrevNet.Recv) / secondsElapsed
|
||||||
|
}
|
||||||
|
stats.PrevNet.Sent = total_sent
|
||||||
|
stats.PrevNet.Recv = total_recv
|
||||||
|
stats.PrevNet.Time = time.Now()
|
||||||
|
|
||||||
|
stats.Cpu = twoDecimals(cpuPct)
|
||||||
|
stats.Mem = bytesToMegabytes(float64(usedMemory))
|
||||||
|
stats.NetworkSent = bytesToMegabytes(sent_delta)
|
||||||
|
stats.NetworkRecv = bytesToMegabytes(recv_delta)
|
||||||
|
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Creates a new http client for docker api
|
||||||
|
func newDockerClient() *http.Client {
|
||||||
|
dockerHost := "unix:///var/run/docker.sock"
|
||||||
|
if dockerHostEnv, exists := os.LookupEnv("DOCKER_HOST"); exists {
|
||||||
|
slog.Info("DOCKER_HOST", "host", dockerHostEnv)
|
||||||
|
dockerHost = dockerHostEnv
|
||||||
|
}
|
||||||
|
|
||||||
|
parsedURL, err := url.Parse(dockerHost)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Error parsing DOCKER_HOST", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
transport := &http.Transport{
|
||||||
|
ForceAttemptHTTP2: false,
|
||||||
|
IdleConnTimeout: 90 * time.Second,
|
||||||
|
DisableCompression: true,
|
||||||
|
MaxConnsPerHost: 10,
|
||||||
|
MaxIdleConnsPerHost: 10,
|
||||||
|
DisableKeepAlives: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
switch parsedURL.Scheme {
|
||||||
|
case "unix":
|
||||||
|
transport.DialContext = func(ctx context.Context, proto, addr string) (net.Conn, error) {
|
||||||
|
return (&net.Dialer{}).DialContext(ctx, "unix", parsedURL.Path)
|
||||||
|
}
|
||||||
|
case "tcp", "http", "https":
|
||||||
|
transport.DialContext = func(ctx context.Context, proto, addr string) (net.Conn, error) {
|
||||||
|
return (&net.Dialer{}).DialContext(ctx, "tcp", parsedURL.Host)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
slog.Error("Invalid DOCKER_HOST", "scheme", parsedURL.Scheme)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &http.Client{
|
||||||
|
Timeout: time.Second,
|
||||||
|
Transport: transport,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Closes idle connections on timeouts to prevent reuse of stale connections
|
||||||
|
func (a *Agent) closeIdleConnections(err error) (isTimeout bool) {
|
||||||
|
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
|
||||||
|
slog.Warn("Closing idle connections", "err", err)
|
||||||
|
a.dockerClient.Transport.(*http.Transport).CloseIdleConnections()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
67
beszel/internal/agent/network.go
Normal file
67
beszel/internal/agent/network.go
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
psutilNet "github.com/shirou/gopsutil/v4/net"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a *Agent) initializeNetIoStats() {
|
||||||
|
// reset valid network interfaces
|
||||||
|
a.netInterfaces = make(map[string]struct{}, 0)
|
||||||
|
|
||||||
|
// map of network interface names passed in via NICS env var
|
||||||
|
var nicsMap map[string]struct{}
|
||||||
|
nics, nicsEnvExists := os.LookupEnv("NICS")
|
||||||
|
if nicsEnvExists {
|
||||||
|
nicsMap = make(map[string]struct{}, 0)
|
||||||
|
for _, nic := range strings.Split(nics, ",") {
|
||||||
|
nicsMap[nic] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// reset network I/O stats
|
||||||
|
a.netIoStats.BytesSent = 0
|
||||||
|
a.netIoStats.BytesRecv = 0
|
||||||
|
|
||||||
|
// get intial network I/O stats
|
||||||
|
if netIO, err := psutilNet.IOCounters(true); err == nil {
|
||||||
|
a.netIoStats.Time = time.Now()
|
||||||
|
for _, v := range netIO {
|
||||||
|
switch {
|
||||||
|
// skip if nics exists and the interface is not in the list
|
||||||
|
case nicsEnvExists:
|
||||||
|
if _, nameInNics := nicsMap[v.Name]; !nameInNics {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// otherwise run the interface name through the skipNetworkInterface function
|
||||||
|
default:
|
||||||
|
if a.skipNetworkInterface(v) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slog.Info("Detected network interface", "name", v.Name, "sent", v.BytesSent, "recv", v.BytesRecv)
|
||||||
|
a.netIoStats.BytesSent += v.BytesSent
|
||||||
|
a.netIoStats.BytesRecv += v.BytesRecv
|
||||||
|
// store as a valid network interface
|
||||||
|
a.netInterfaces[v.Name] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Agent) skipNetworkInterface(v psutilNet.IOCountersStat) bool {
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(v.Name, "lo"),
|
||||||
|
strings.HasPrefix(v.Name, "docker"),
|
||||||
|
strings.HasPrefix(v.Name, "br-"),
|
||||||
|
strings.HasPrefix(v.Name, "veth"),
|
||||||
|
v.BytesRecv == 0,
|
||||||
|
v.BytesSent == 0:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
35
beszel/internal/agent/server.go
Normal file
35
beszel/internal/agent/server.go
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
sshServer "github.com/gliderlabs/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a *Agent) startServer(pubKey []byte, addr string) {
|
||||||
|
sshServer.Handle(a.handleSession)
|
||||||
|
|
||||||
|
slog.Info("Starting SSH server", "address", addr)
|
||||||
|
if err := sshServer.ListenAndServe(addr, nil, sshServer.NoPty(),
|
||||||
|
sshServer.PublicKeyAuth(func(ctx sshServer.Context, key sshServer.PublicKey) bool {
|
||||||
|
allowed, _, _, _, _ := sshServer.ParseAuthorizedKey(pubKey)
|
||||||
|
return sshServer.KeysEqual(key, allowed)
|
||||||
|
}),
|
||||||
|
); err != nil {
|
||||||
|
slog.Error("Error starting SSH server", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Agent) handleSession(s sshServer.Session) {
|
||||||
|
stats := a.gatherStats()
|
||||||
|
slog.Debug("Sending stats", "data", stats)
|
||||||
|
if err := json.NewEncoder(s).Encode(stats); err != nil {
|
||||||
|
slog.Error("Error encoding stats", "err", err)
|
||||||
|
s.Exit(1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.Exit(0)
|
||||||
|
}
|
||||||
191
beszel/internal/agent/system.go
Normal file
191
beszel/internal/agent/system.go
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"beszel"
|
||||||
|
"beszel/internal/entities/system"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/shirou/gopsutil/v4/cpu"
|
||||||
|
"github.com/shirou/gopsutil/v4/disk"
|
||||||
|
"github.com/shirou/gopsutil/v4/host"
|
||||||
|
"github.com/shirou/gopsutil/v4/mem"
|
||||||
|
psutilNet "github.com/shirou/gopsutil/v4/net"
|
||||||
|
"github.com/shirou/gopsutil/v4/sensors"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sets initial / non-changing values about the host system
|
||||||
|
func (a *Agent) initializeSystemInfo() {
|
||||||
|
a.kernelVersion, _ = host.KernelVersion()
|
||||||
|
a.hostname, _ = os.Hostname()
|
||||||
|
|
||||||
|
// add cpu stats
|
||||||
|
if info, err := cpu.Info(); err == nil && len(info) > 0 {
|
||||||
|
a.cpuModel = info[0].ModelName
|
||||||
|
}
|
||||||
|
a.cores, _ = cpu.Counts(false)
|
||||||
|
if threads, err := cpu.Counts(true); err == nil {
|
||||||
|
if threads > 0 && threads < a.cores {
|
||||||
|
// in lxc logical cores reflects container limits, so use that as cores if lower
|
||||||
|
a.cores = threads
|
||||||
|
} else {
|
||||||
|
a.threads = threads
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns current info, stats about the host system
|
||||||
|
func (a *Agent) getSystemStats() (system.Info, system.Stats) {
|
||||||
|
systemStats := system.Stats{}
|
||||||
|
|
||||||
|
// cpu percent
|
||||||
|
cpuPct, err := cpu.Percent(0, false)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Error getting cpu percent", "err", err)
|
||||||
|
} else if len(cpuPct) > 0 {
|
||||||
|
systemStats.Cpu = twoDecimals(cpuPct[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// memory
|
||||||
|
if v, err := mem.VirtualMemory(); err == nil {
|
||||||
|
systemStats.Mem = bytesToGigabytes(v.Total)
|
||||||
|
systemStats.MemUsed = bytesToGigabytes(v.Used)
|
||||||
|
systemStats.MemBuffCache = bytesToGigabytes(v.Total - v.Free - v.Used)
|
||||||
|
systemStats.MemPct = twoDecimals(v.UsedPercent)
|
||||||
|
systemStats.Swap = bytesToGigabytes(v.SwapTotal)
|
||||||
|
systemStats.SwapUsed = bytesToGigabytes(v.SwapTotal - v.SwapFree)
|
||||||
|
}
|
||||||
|
|
||||||
|
// disk usage
|
||||||
|
for _, stats := range a.fsStats {
|
||||||
|
if d, err := disk.Usage(stats.Mountpoint); err == nil {
|
||||||
|
stats.DiskTotal = bytesToGigabytes(d.Total)
|
||||||
|
stats.DiskUsed = bytesToGigabytes(d.Used)
|
||||||
|
if stats.Root {
|
||||||
|
systemStats.DiskTotal = bytesToGigabytes(d.Total)
|
||||||
|
systemStats.DiskUsed = bytesToGigabytes(d.Used)
|
||||||
|
systemStats.DiskPct = twoDecimals(d.UsedPercent)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// reset stats if error (likely unmounted)
|
||||||
|
slog.Error("Error getting disk stats", "name", stats.Mountpoint, "err", err)
|
||||||
|
stats.DiskTotal = 0
|
||||||
|
stats.DiskUsed = 0
|
||||||
|
stats.TotalRead = 0
|
||||||
|
stats.TotalWrite = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// disk i/o
|
||||||
|
if ioCounters, err := disk.IOCounters(a.fsNames...); err == nil {
|
||||||
|
for _, d := range ioCounters {
|
||||||
|
stats := a.fsStats[d.Name]
|
||||||
|
if stats == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
secondsElapsed := time.Since(stats.Time).Seconds()
|
||||||
|
readPerSecond := float64(d.ReadBytes-stats.TotalRead) / secondsElapsed
|
||||||
|
writePerSecond := float64(d.WriteBytes-stats.TotalWrite) / secondsElapsed
|
||||||
|
stats.Time = time.Now()
|
||||||
|
stats.DiskReadPs = bytesToMegabytes(readPerSecond)
|
||||||
|
stats.DiskWritePs = bytesToMegabytes(writePerSecond)
|
||||||
|
stats.TotalRead = d.ReadBytes
|
||||||
|
stats.TotalWrite = d.WriteBytes
|
||||||
|
// if root filesystem, update system stats
|
||||||
|
if stats.Root {
|
||||||
|
systemStats.DiskReadPs = stats.DiskReadPs
|
||||||
|
systemStats.DiskWritePs = stats.DiskWritePs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// network stats
|
||||||
|
if netIO, err := psutilNet.IOCounters(true); err == nil {
|
||||||
|
secondsElapsed := time.Since(a.netIoStats.Time).Seconds()
|
||||||
|
a.netIoStats.Time = time.Now()
|
||||||
|
bytesSent := uint64(0)
|
||||||
|
bytesRecv := uint64(0)
|
||||||
|
// sum all bytes sent and received
|
||||||
|
for _, v := range netIO {
|
||||||
|
// skip if not in valid network interfaces list
|
||||||
|
if _, exists := a.netInterfaces[v.Name]; !exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
bytesSent += v.BytesSent
|
||||||
|
bytesRecv += v.BytesRecv
|
||||||
|
}
|
||||||
|
// add to systemStats
|
||||||
|
sentPerSecond := float64(bytesSent-a.netIoStats.BytesSent) / secondsElapsed
|
||||||
|
recvPerSecond := float64(bytesRecv-a.netIoStats.BytesRecv) / secondsElapsed
|
||||||
|
networkSentPs := bytesToMegabytes(sentPerSecond)
|
||||||
|
networkRecvPs := bytesToMegabytes(recvPerSecond)
|
||||||
|
// add check for issue (#150) where sent is a massive number
|
||||||
|
if networkSentPs > 10_000 || networkRecvPs > 10_000 {
|
||||||
|
slog.Warn("Invalid network stats. Resetting.", "sent", networkSentPs, "recv", networkRecvPs)
|
||||||
|
for _, v := range netIO {
|
||||||
|
if _, exists := a.netInterfaces[v.Name]; !exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
slog.Info(v.Name, "recv", v.BytesRecv, "sent", v.BytesSent)
|
||||||
|
}
|
||||||
|
// reset network I/O stats
|
||||||
|
a.initializeNetIoStats()
|
||||||
|
} else {
|
||||||
|
systemStats.NetworkSent = networkSentPs
|
||||||
|
systemStats.NetworkRecv = networkRecvPs
|
||||||
|
// update netIoStats
|
||||||
|
a.netIoStats.BytesSent = bytesSent
|
||||||
|
a.netIoStats.BytesRecv = bytesRecv
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// temperatures
|
||||||
|
temps, err := sensors.TemperaturesWithContext(a.sensorsContext)
|
||||||
|
if err != nil && a.debug {
|
||||||
|
err.(*sensors.Warnings).Verbose = true
|
||||||
|
slog.Debug("Sensor error", "errs", err)
|
||||||
|
}
|
||||||
|
if len(temps) > 0 {
|
||||||
|
slog.Debug("Temperatures", "data", temps)
|
||||||
|
systemStats.Temperatures = make(map[string]float64, len(temps))
|
||||||
|
for i, sensor := range temps {
|
||||||
|
// skip if temperature is 0
|
||||||
|
if sensor.Temperature == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := systemStats.Temperatures[sensor.SensorKey]; ok {
|
||||||
|
// if key already exists, append int to key
|
||||||
|
systemStats.Temperatures[sensor.SensorKey+"_"+strconv.Itoa(i)] = twoDecimals(sensor.Temperature)
|
||||||
|
} else {
|
||||||
|
systemStats.Temperatures[sensor.SensorKey] = twoDecimals(sensor.Temperature)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// remove sensors from systemStats if whitelist exists and sensor is not in whitelist
|
||||||
|
// (do this here instead of in initial loop so we have correct keys if int was appended)
|
||||||
|
if a.sensorsWhitelist != nil {
|
||||||
|
for key := range systemStats.Temperatures {
|
||||||
|
if _, nameInWhitelist := a.sensorsWhitelist[key]; !nameInWhitelist {
|
||||||
|
delete(systemStats.Temperatures, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
systemInfo := system.Info{
|
||||||
|
Cpu: systemStats.Cpu,
|
||||||
|
MemPct: systemStats.MemPct,
|
||||||
|
DiskPct: systemStats.DiskPct,
|
||||||
|
AgentVersion: beszel.Version,
|
||||||
|
Hostname: a.hostname,
|
||||||
|
KernelVersion: a.kernelVersion,
|
||||||
|
CpuModel: a.cpuModel,
|
||||||
|
Cores: a.cores,
|
||||||
|
Threads: a.threads,
|
||||||
|
}
|
||||||
|
|
||||||
|
systemInfo.Uptime, _ = host.Uptime()
|
||||||
|
|
||||||
|
return systemInfo, systemStats
|
||||||
|
}
|
||||||
22
beszel/internal/agent/utils.go
Normal file
22
beszel/internal/agent/utils.go
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import "math"
|
||||||
|
|
||||||
|
// delete container stats from map using mutex
|
||||||
|
func (a *Agent) deleteContainerStatsSync(id string) {
|
||||||
|
a.containerStatsMutex.Lock()
|
||||||
|
defer a.containerStatsMutex.Unlock()
|
||||||
|
delete(a.containerStatsMap, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func bytesToMegabytes(b float64) float64 {
|
||||||
|
return twoDecimals(b / 1048576)
|
||||||
|
}
|
||||||
|
|
||||||
|
func bytesToGigabytes(b uint64) float64 {
|
||||||
|
return twoDecimals(float64(b) / 1073741824)
|
||||||
|
}
|
||||||
|
|
||||||
|
func twoDecimals(value float64) float64 {
|
||||||
|
return math.Round(value*100) / 100
|
||||||
|
}
|
||||||
@@ -5,9 +5,13 @@ import (
|
|||||||
"beszel/internal/entities/system"
|
"beszel/internal/entities/system"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/mail"
|
"net/mail"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
"github.com/containrrr/shoutrrr"
|
||||||
|
"github.com/labstack/echo/v5"
|
||||||
"github.com/pocketbase/dbx"
|
"github.com/pocketbase/dbx"
|
||||||
"github.com/pocketbase/pocketbase"
|
"github.com/pocketbase/pocketbase"
|
||||||
|
"github.com/pocketbase/pocketbase/apis"
|
||||||
"github.com/pocketbase/pocketbase/models"
|
"github.com/pocketbase/pocketbase/models"
|
||||||
"github.com/pocketbase/pocketbase/tools/mailer"
|
"github.com/pocketbase/pocketbase/tools/mailer"
|
||||||
)
|
)
|
||||||
@@ -16,67 +20,66 @@ type AlertManager struct {
|
|||||||
app *pocketbase.PocketBase
|
app *pocketbase.PocketBase
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AlertData struct {
|
||||||
|
UserID string
|
||||||
|
Title string
|
||||||
|
Message string
|
||||||
|
Link string
|
||||||
|
LinkText string
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserNotificationSettings struct {
|
||||||
|
Emails []string `json:"emails"`
|
||||||
|
Webhooks []string `json:"webhooks"`
|
||||||
|
}
|
||||||
|
|
||||||
func NewAlertManager(app *pocketbase.PocketBase) *AlertManager {
|
func NewAlertManager(app *pocketbase.PocketBase) *AlertManager {
|
||||||
return &AlertManager{
|
return &AlertManager{
|
||||||
app: app,
|
app: app,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (am *AlertManager) HandleSystemAlerts(newStatus string, newRecord *models.Record, oldRecord *models.Record) {
|
func (am *AlertManager) HandleSystemInfoAlerts(systemRecord *models.Record, systemInfo system.Info) {
|
||||||
alertRecords, err := am.app.Dao().FindRecordsByExpr("alerts",
|
alertRecords, err := am.app.Dao().FindRecordsByExpr("alerts",
|
||||||
dbx.NewExp("system = {:system}", dbx.Params{"system": oldRecord.GetId()}),
|
dbx.NewExp("system={:system}", dbx.Params{"system": systemRecord.GetId()}),
|
||||||
)
|
)
|
||||||
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")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// log.Println("found alerts", len(alertRecords))
|
// log.Println("found alerts", len(alertRecords))
|
||||||
var systemInfo *system.Info
|
|
||||||
for _, alertRecord := range alertRecords {
|
for _, alertRecord := range alertRecords {
|
||||||
name := alertRecord.GetString("name")
|
name := alertRecord.GetString("name")
|
||||||
switch name {
|
switch name {
|
||||||
case "Status":
|
|
||||||
am.handleStatusAlerts(newStatus, oldRecord, alertRecord)
|
|
||||||
case "CPU", "Memory", "Disk":
|
case "CPU", "Memory", "Disk":
|
||||||
if newStatus != "up" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if systemInfo == nil {
|
|
||||||
systemInfo = getSystemInfo(newRecord)
|
|
||||||
}
|
|
||||||
if name == "CPU" {
|
if name == "CPU" {
|
||||||
am.handleSlidingValueAlert(newRecord, alertRecord, name, systemInfo.Cpu)
|
am.handleSlidingValueAlert(systemRecord, alertRecord, name, systemInfo.Cpu)
|
||||||
} else if name == "Memory" {
|
} else if name == "Memory" {
|
||||||
am.handleSlidingValueAlert(newRecord, alertRecord, name, systemInfo.MemPct)
|
am.handleSlidingValueAlert(systemRecord, alertRecord, name, systemInfo.MemPct)
|
||||||
} else if name == "Disk" {
|
} else if name == "Disk" {
|
||||||
am.handleSlidingValueAlert(newRecord, alertRecord, name, systemInfo.DiskPct)
|
am.handleSlidingValueAlert(systemRecord, alertRecord, name, systemInfo.DiskPct)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func getSystemInfo(record *models.Record) *system.Info {
|
func (am *AlertManager) handleSlidingValueAlert(systemRecord *models.Record, alertRecord *models.Record, name string, curValue float64) {
|
||||||
var SystemInfo system.Info
|
|
||||||
record.UnmarshalJSONField("info", &SystemInfo)
|
|
||||||
return &SystemInfo
|
|
||||||
}
|
|
||||||
|
|
||||||
func (am *AlertManager) handleSlidingValueAlert(newRecord *models.Record, alertRecord *models.Record, name string, curValue float64) {
|
|
||||||
triggered := alertRecord.GetBool("triggered")
|
triggered := alertRecord.GetBool("triggered")
|
||||||
threshold := alertRecord.GetFloat("value")
|
threshold := alertRecord.GetFloat("value")
|
||||||
// fmt.Println(name, curValue, "threshold", threshold, "triggered", triggered)
|
// fmt.Println(name, curValue, "threshold", threshold, "triggered", triggered)
|
||||||
var subject string
|
var subject string
|
||||||
var body string
|
var body string
|
||||||
|
var systemName string
|
||||||
if !triggered && curValue > threshold {
|
if !triggered && curValue > threshold {
|
||||||
alertRecord.Set("triggered", true)
|
alertRecord.Set("triggered", true)
|
||||||
systemName := newRecord.GetString("name")
|
systemName = systemRecord.GetString("name")
|
||||||
subject = fmt.Sprintf("%s usage above threshold on %s", name, systemName)
|
subject = fmt.Sprintf("%s usage above threshold on %s", name, systemName)
|
||||||
body = fmt.Sprintf("%s usage on %s is %.1f%%.\n\n%s\n\n- Beszel", name, systemName, curValue, am.app.Settings().Meta.AppUrl+"/system/"+systemName)
|
body = fmt.Sprintf("%s usage on %s is %.1f%%.", name, systemName, curValue)
|
||||||
} else if triggered && curValue <= threshold {
|
} else if triggered && curValue <= threshold {
|
||||||
alertRecord.Set("triggered", false)
|
alertRecord.Set("triggered", false)
|
||||||
systemName := newRecord.GetString("name")
|
systemName = systemRecord.GetString("name")
|
||||||
subject = fmt.Sprintf("%s usage below threshold on %s", name, systemName)
|
subject = fmt.Sprintf("%s usage below threshold on %s", name, systemName)
|
||||||
body = fmt.Sprintf("%s usage on %s is below threshold at %.1f%%.\n\n%s\n\n- Beszel", name, systemName, curValue, am.app.Settings().Meta.AppUrl+"/system/"+systemName)
|
body = fmt.Sprintf("%s usage on %s is below threshold at %.1f%%.", name, systemName, curValue)
|
||||||
} else {
|
} else {
|
||||||
// fmt.Println(name, "not triggered")
|
// fmt.Println(name, "not triggered")
|
||||||
return
|
return
|
||||||
@@ -91,61 +94,195 @@ func (am *AlertManager) handleSlidingValueAlert(newRecord *models.Record, alertR
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if user := alertRecord.ExpandedOne("user"); user != nil {
|
if user := alertRecord.ExpandedOne("user"); user != nil {
|
||||||
am.sendAlert(&mailer.Message{
|
am.sendAlert(AlertData{
|
||||||
To: []mail.Address{{Address: user.GetString("email")}},
|
UserID: user.GetId(),
|
||||||
Subject: subject,
|
Title: subject,
|
||||||
Text: body,
|
Message: body,
|
||||||
|
Link: am.app.Settings().Meta.AppUrl + "/system/" + url.QueryEscape(systemName),
|
||||||
|
LinkText: "View " + systemName,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (am *AlertManager) handleStatusAlerts(newStatus string, oldRecord *models.Record, alertRecord *models.Record) error {
|
func (am *AlertManager) HandleStatusAlerts(newStatus string, oldSystemRecord *models.Record) error {
|
||||||
var alertStatus string
|
var alertStatus string
|
||||||
switch newStatus {
|
switch newStatus {
|
||||||
case "up":
|
case "up":
|
||||||
if oldRecord.GetString("status") == "down" {
|
if oldSystemRecord.GetString("status") == "down" {
|
||||||
alertStatus = "up"
|
alertStatus = "up"
|
||||||
}
|
}
|
||||||
case "down":
|
case "down":
|
||||||
if oldRecord.GetString("status") == "up" {
|
if oldSystemRecord.GetString("status") == "up" {
|
||||||
alertStatus = "down"
|
alertStatus = "down"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if alertStatus == "" {
|
if alertStatus == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
// expand the user relation
|
// check if use
|
||||||
if errs := am.app.Dao().ExpandRecord(alertRecord, []string{"user"}, nil); len(errs) > 0 {
|
alertRecords, err := am.app.Dao().FindRecordsByExpr("alerts",
|
||||||
return fmt.Errorf("failed to expand: %v", errs)
|
dbx.HashExp{
|
||||||
}
|
"system": oldSystemRecord.GetId(),
|
||||||
user := alertRecord.ExpandedOne("user")
|
"name": "Status",
|
||||||
if user == nil {
|
},
|
||||||
|
)
|
||||||
|
if err != nil || len(alertRecords) == 0 {
|
||||||
|
// log.Println("no alerts found for system")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
emoji := "\U0001F534"
|
for _, alertRecord := range alertRecords {
|
||||||
if alertStatus == "up" {
|
// expand the user relation
|
||||||
emoji = "\u2705"
|
if errs := am.app.Dao().ExpandRecord(alertRecord, []string{"user"}, nil); len(errs) > 0 {
|
||||||
|
return fmt.Errorf("failed to expand: %v", errs)
|
||||||
|
}
|
||||||
|
user := alertRecord.ExpandedOne("user")
|
||||||
|
if user == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
emoji := "\U0001F534"
|
||||||
|
if alertStatus == "up" {
|
||||||
|
emoji = "\u2705"
|
||||||
|
}
|
||||||
|
// send alert
|
||||||
|
systemName := oldSystemRecord.GetString("name")
|
||||||
|
am.sendAlert(AlertData{
|
||||||
|
UserID: user.GetId(),
|
||||||
|
Title: fmt.Sprintf("Connection to %s is %s %v", systemName, alertStatus, emoji),
|
||||||
|
Message: fmt.Sprintf("Connection to %s is %s", systemName, alertStatus),
|
||||||
|
Link: am.app.Settings().Meta.AppUrl + "/system/" + url.QueryEscape(systemName),
|
||||||
|
LinkText: "View " + systemName,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
// send alert
|
|
||||||
systemName := oldRecord.GetString("name")
|
|
||||||
am.sendAlert(&mailer.Message{
|
|
||||||
To: []mail.Address{{Address: user.GetString("email")}},
|
|
||||||
Subject: fmt.Sprintf("Connection to %s is %s %v", systemName, alertStatus, emoji),
|
|
||||||
Text: fmt.Sprintf("Connection to %s is %s\n\n- Beszel", systemName, alertStatus),
|
|
||||||
})
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (am *AlertManager) sendAlert(message *mailer.Message) {
|
func (am *AlertManager) sendAlert(data AlertData) {
|
||||||
// fmt.Println("sending alert", "to", message.To, "subj", message.Subject, "body", message.Text)
|
// get user settings
|
||||||
message.From = mail.Address{
|
record, err := am.app.Dao().FindFirstRecordByFilter(
|
||||||
Address: am.app.Settings().Meta.SenderAddress,
|
"user_settings", "user={:user}",
|
||||||
Name: am.app.Settings().Meta.SenderName,
|
dbx.Params{"user": data.UserID},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
am.app.Logger().Error("Failed to get user settings", "err", err.Error())
|
||||||
|
return
|
||||||
}
|
}
|
||||||
if err := am.app.NewMailClient().Send(message); err != nil {
|
// unmarshal user settings
|
||||||
|
userAlertSettings := UserNotificationSettings{
|
||||||
|
Emails: []string{},
|
||||||
|
Webhooks: []string{},
|
||||||
|
}
|
||||||
|
if err := record.UnmarshalJSONField("settings", &userAlertSettings); err != nil {
|
||||||
|
am.app.Logger().Error("Failed to unmarshal user settings", "err", err.Error())
|
||||||
|
}
|
||||||
|
// send alerts via webhooks
|
||||||
|
for _, webhook := range userAlertSettings.Webhooks {
|
||||||
|
if err := am.SendShoutrrrAlert(webhook, data.Title, data.Message, data.Link, data.LinkText); err != nil {
|
||||||
|
am.app.Logger().Error("Failed to send shoutrrr alert", "err", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// send alerts via email
|
||||||
|
if len(userAlertSettings.Emails) == 0 {
|
||||||
|
// log.Println("No email addresses found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
addresses := []mail.Address{}
|
||||||
|
for _, email := range userAlertSettings.Emails {
|
||||||
|
addresses = append(addresses, mail.Address{Address: email})
|
||||||
|
}
|
||||||
|
message := mailer.Message{
|
||||||
|
To: addresses,
|
||||||
|
Subject: data.Title,
|
||||||
|
Text: data.Message + fmt.Sprintf("\n\n%s", data.Link),
|
||||||
|
From: mail.Address{
|
||||||
|
Address: am.app.Settings().Meta.SenderAddress,
|
||||||
|
Name: am.app.Settings().Meta.SenderName,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := am.app.NewMailClient().Send(&message); err != nil {
|
||||||
am.app.Logger().Error("Failed to send alert: ", "err", err.Error())
|
am.app.Logger().Error("Failed to send alert: ", "err", err.Error())
|
||||||
} else {
|
} else {
|
||||||
am.app.Logger().Info("Sent alert", "to", message.To, "subj", message.Subject)
|
am.app.Logger().Info("Sent email alert", "to", message.To, "subj", message.Subject)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SendShoutrrrAlert sends an alert via a Shoutrrr URL
|
||||||
|
func (am *AlertManager) SendShoutrrrAlert(notificationUrl, title, message, link, linkText string) error {
|
||||||
|
// services that support title param
|
||||||
|
supportsTitle := []string{"bark", "discord", "gotify", "ifttt", "join", "matrix", "ntfy", "opsgenie", "pushbullet", "pushover", "slack", "teams", "telegram", "zulip"}
|
||||||
|
|
||||||
|
// Parse the URL
|
||||||
|
parsedURL, err := url.Parse(notificationUrl)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error parsing URL: %v", err)
|
||||||
|
}
|
||||||
|
scheme := parsedURL.Scheme
|
||||||
|
queryParams := parsedURL.Query()
|
||||||
|
|
||||||
|
// Add title
|
||||||
|
if sliceContains(supportsTitle, scheme) {
|
||||||
|
queryParams.Add("title", title)
|
||||||
|
} else if scheme == "mattermost" {
|
||||||
|
// use markdown title for mattermost
|
||||||
|
message = "##### " + title + "\n\n" + message
|
||||||
|
} else if scheme == "generic" && queryParams.Has("template") {
|
||||||
|
// add title as property if using generic with template json
|
||||||
|
titleKey := queryParams.Get("titlekey")
|
||||||
|
if titleKey == "" {
|
||||||
|
titleKey = "title"
|
||||||
|
}
|
||||||
|
queryParams.Add("$"+titleKey, title)
|
||||||
|
} else {
|
||||||
|
// otherwise just add title to message
|
||||||
|
message = title + "\n\n" + message
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add link
|
||||||
|
if scheme == "ntfy" {
|
||||||
|
// if ntfy, add link to actions
|
||||||
|
queryParams.Add("Actions", fmt.Sprintf("view, %s, %s", linkText, link))
|
||||||
|
} else {
|
||||||
|
// else add link directly to the message
|
||||||
|
message += "\n\n" + link
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encode the modified query parameters back into the URL
|
||||||
|
parsedURL.RawQuery = queryParams.Encode()
|
||||||
|
// log.Println("URL after modification:", parsedURL.String())
|
||||||
|
|
||||||
|
err = shoutrrr.Send(parsedURL.String(), message)
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
am.app.Logger().Info("Sent shoutrrr alert", "title", title)
|
||||||
|
} else {
|
||||||
|
am.app.Logger().Error("Error sending shoutrrr alert", "err", err.Error())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contains checks if a string is present in a slice of strings
|
||||||
|
func sliceContains(slice []string, item string) bool {
|
||||||
|
for _, v := range slice {
|
||||||
|
if v == item {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (am *AlertManager) SendTestNotification(c echo.Context) error {
|
||||||
|
requestData := apis.RequestInfo(c)
|
||||||
|
if requestData.AuthRecord == nil {
|
||||||
|
return apis.NewForbiddenError("Forbidden", nil)
|
||||||
|
}
|
||||||
|
url := c.QueryParam("url")
|
||||||
|
// log.Println("url", url)
|
||||||
|
if url == "" {
|
||||||
|
return c.JSON(200, map[string]string{"err": "URL is required"})
|
||||||
|
}
|
||||||
|
err := am.SendShoutrrrAlert(url, "Test Alert", "This is a notification from Beszel.", am.app.Settings().Meta.AppUrl, "View Beszel")
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(200, map[string]string{"err": err.Error()})
|
||||||
|
}
|
||||||
|
return c.JSON(200, map[string]bool{"err": false})
|
||||||
|
}
|
||||||
|
|||||||
@@ -85,15 +85,13 @@ type CPUUsage struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type MemoryStats struct {
|
type MemoryStats struct {
|
||||||
|
|
||||||
// current res_counter usage for memory
|
// current res_counter usage for memory
|
||||||
Usage uint64 `json:"usage,omitempty"`
|
Usage uint64 `json:"usage,omitempty"`
|
||||||
Cache uint64 `json:"cache,omitempty"`
|
// all the stats exported via memory.stat.
|
||||||
|
Stats MemoryStatsStats `json:"stats,omitempty"`
|
||||||
// maximum usage ever recorded.
|
// maximum usage ever recorded.
|
||||||
// MaxUsage uint64 `json:"max_usage,omitempty"`
|
// MaxUsage uint64 `json:"max_usage,omitempty"`
|
||||||
// TODO(vishh): Export these as stronger types.
|
// TODO(vishh): Export these as stronger types.
|
||||||
// all the stats exported via memory.stat.
|
|
||||||
Stats map[string]uint64 `json:"stats,omitempty"`
|
|
||||||
// number of times memory usage hits limits.
|
// number of times memory usage hits limits.
|
||||||
// Failcnt uint64 `json:"failcnt,omitempty"`
|
// Failcnt uint64 `json:"failcnt,omitempty"`
|
||||||
// Limit uint64 `json:"limit,omitempty"`
|
// Limit uint64 `json:"limit,omitempty"`
|
||||||
@@ -106,6 +104,11 @@ type MemoryStats struct {
|
|||||||
// PrivateWorkingSet uint64 `json:"privateworkingset,omitempty"`
|
// PrivateWorkingSet uint64 `json:"privateworkingset,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MemoryStatsStats struct {
|
||||||
|
Cache uint64 `json:"cache,omitempty"`
|
||||||
|
InactiveFile uint64 `json:"inactive_file,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type NetworkStats struct {
|
type NetworkStats struct {
|
||||||
// Bytes received. Windows and Linux.
|
// Bytes received. Windows and Linux.
|
||||||
RxBytes uint64 `json:"rx_bytes"`
|
RxBytes uint64 `json:"rx_bytes"`
|
||||||
@@ -113,21 +116,19 @@ type NetworkStats struct {
|
|||||||
TxBytes uint64 `json:"tx_bytes"`
|
TxBytes uint64 `json:"tx_bytes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Container stats to return to the hub
|
type prevNetStats struct {
|
||||||
type Stats struct {
|
Sent uint64
|
||||||
Name string `json:"n"`
|
Recv uint64
|
||||||
Cpu float64 `json:"c"`
|
Time time.Time
|
||||||
Mem float64 `json:"m"`
|
|
||||||
NetworkSent float64 `json:"ns"`
|
|
||||||
NetworkRecv float64 `json:"nr"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keeps track of container stats from previous run
|
// Docker container stats
|
||||||
type PrevContainerStats struct {
|
type Stats struct {
|
||||||
Cpu [2]uint64
|
Name string `json:"n"`
|
||||||
Net struct {
|
Cpu float64 `json:"c"`
|
||||||
Sent uint64
|
Mem float64 `json:"m"`
|
||||||
Recv uint64
|
NetworkSent float64 `json:"ns"`
|
||||||
Time time.Time
|
NetworkRecv float64 `json:"nr"`
|
||||||
}
|
PrevCpu [2]uint64 `json:"-"`
|
||||||
|
PrevNet prevNetStats `json:"-"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ type Stats struct {
|
|||||||
|
|
||||||
type FsStats struct {
|
type FsStats struct {
|
||||||
Time time.Time `json:"-"`
|
Time time.Time `json:"-"`
|
||||||
Device string `json:"-"`
|
|
||||||
Root bool `json:"-"`
|
Root bool `json:"-"`
|
||||||
Mountpoint string `json:"-"`
|
Mountpoint string `json:"-"`
|
||||||
DiskTotal float64 `json:"d"`
|
DiskTotal float64 `json:"d"`
|
||||||
@@ -45,9 +44,11 @@ type NetIoStats struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Info struct {
|
type Info struct {
|
||||||
Cores int `json:"c"`
|
Hostname string `json:"h"`
|
||||||
Threads int `json:"t"`
|
KernelVersion string `json:"k,omitempty"`
|
||||||
CpuModel string `json:"m"`
|
Cores int `json:"c"`
|
||||||
|
Threads int `json:"t,omitempty"`
|
||||||
|
CpuModel string `json:"m"`
|
||||||
// Os string `json:"o"`
|
// Os string `json:"o"`
|
||||||
Uptime uint64 `json:"u"`
|
Uptime uint64 `json:"u"`
|
||||||
Cpu float64 `json:"cpu"`
|
Cpu float64 `json:"cpu"`
|
||||||
@@ -58,7 +59,7 @@ type Info struct {
|
|||||||
|
|
||||||
// Final data structure to return to the hub
|
// Final data structure to return to the hub
|
||||||
type CombinedData struct {
|
type CombinedData struct {
|
||||||
Stats Stats `json:"stats"`
|
Stats Stats `json:"stats"`
|
||||||
Info Info `json:"info"`
|
Info Info `json:"info"`
|
||||||
Containers []container.Stats `json:"container"`
|
Containers []*container.Stats `json:"container"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Package hub handles updating systems and serving the web UI.
|
||||||
package hub
|
package hub
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -5,7 +6,9 @@ import (
|
|||||||
"beszel/internal/alerts"
|
"beszel/internal/alerts"
|
||||||
"beszel/internal/entities/system"
|
"beszel/internal/entities/system"
|
||||||
"beszel/internal/records"
|
"beszel/internal/records"
|
||||||
|
"beszel/internal/users"
|
||||||
"beszel/site"
|
"beszel/site"
|
||||||
|
|
||||||
"context"
|
"context"
|
||||||
"crypto/ed25519"
|
"crypto/ed25519"
|
||||||
"encoding/pem"
|
"encoding/pem"
|
||||||
@@ -20,7 +23,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/goccy/go-json"
|
"github.com/goccy/go-json"
|
||||||
|
|
||||||
"github.com/labstack/echo/v5"
|
"github.com/labstack/echo/v5"
|
||||||
"github.com/pocketbase/pocketbase"
|
"github.com/pocketbase/pocketbase"
|
||||||
"github.com/pocketbase/pocketbase/apis"
|
"github.com/pocketbase/pocketbase/apis"
|
||||||
@@ -37,6 +39,9 @@ type Hub struct {
|
|||||||
systemConnections map[string]*ssh.Client
|
systemConnections map[string]*ssh.Client
|
||||||
sshClientConfig *ssh.ClientConfig
|
sshClientConfig *ssh.ClientConfig
|
||||||
pubKey string
|
pubKey string
|
||||||
|
am *alerts.AlertManager
|
||||||
|
um *users.UserManager
|
||||||
|
rm *records.RecordManager
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHub(app *pocketbase.PocketBase) *Hub {
|
func NewHub(app *pocketbase.PocketBase) *Hub {
|
||||||
@@ -44,12 +49,16 @@ func NewHub(app *pocketbase.PocketBase) *Hub {
|
|||||||
app: app,
|
app: app,
|
||||||
connectionLock: &sync.Mutex{},
|
connectionLock: &sync.Mutex{},
|
||||||
systemConnections: make(map[string]*ssh.Client),
|
systemConnections: make(map[string]*ssh.Client),
|
||||||
|
am: alerts.NewAlertManager(app),
|
||||||
|
um: users.NewUserManager(app),
|
||||||
|
rm: records.NewRecordManager(app),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Hub) Run() {
|
func (h *Hub) Run() {
|
||||||
var rm *records.RecordManager
|
// rm := records.NewRecordManager(h.app)
|
||||||
var am *alerts.AlertManager
|
// am := alerts.NewAlertManager(h.app)
|
||||||
|
// um := users.NewUserManager(h.app)
|
||||||
|
|
||||||
// loosely check if it was executed using "go run"
|
// loosely check if it was executed using "go run"
|
||||||
isGoRun := strings.HasPrefix(os.Args[0], os.TempDir())
|
isGoRun := strings.HasPrefix(os.Args[0], os.TempDir())
|
||||||
@@ -63,9 +72,6 @@ func (h *Hub) Run() {
|
|||||||
|
|
||||||
// initial setup
|
// initial setup
|
||||||
h.app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
|
h.app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
|
||||||
// set up record manager and alert manager
|
|
||||||
rm = records.NewRecordManager(h.app)
|
|
||||||
am = alerts.NewAlertManager(h.app)
|
|
||||||
// create ssh client config
|
// create ssh client config
|
||||||
err := h.createSSHClientConfig()
|
err := h.createSSHClientConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -90,7 +96,7 @@ func (h *Hub) Run() {
|
|||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
// serve site
|
// serve web ui
|
||||||
h.app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
|
h.app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
|
||||||
switch isGoRun {
|
switch isGoRun {
|
||||||
case true:
|
case true:
|
||||||
@@ -98,12 +104,17 @@ func (h *Hub) Run() {
|
|||||||
Scheme: "http",
|
Scheme: "http",
|
||||||
Host: "localhost:5173",
|
Host: "localhost:5173",
|
||||||
})
|
})
|
||||||
e.Router.GET("/static/*", apis.StaticDirectoryHandler(os.DirFS("../../site/public/static"), false))
|
|
||||||
e.Router.Any("/*", echo.WrapHandler(proxy))
|
e.Router.Any("/*", echo.WrapHandler(proxy))
|
||||||
// e.Router.Any("/", echo.WrapHandler(proxy))
|
|
||||||
default:
|
default:
|
||||||
e.Router.GET("/static/*", apis.StaticDirectoryHandler(site.Static, false))
|
csp, cspExists := os.LookupEnv("CSP")
|
||||||
e.Router.Any("/*", apis.StaticDirectoryHandler(site.Dist, true))
|
e.Router.Any("/*", func(c echo.Context) error {
|
||||||
|
if cspExists {
|
||||||
|
c.Response().Header().Del("X-Frame-Options")
|
||||||
|
c.Response().Header().Set("Content-Security-Policy", csp)
|
||||||
|
}
|
||||||
|
indexFallback := !strings.HasPrefix(c.Request().URL.Path, "/static/")
|
||||||
|
return apis.StaticDirectoryHandler(site.Dist, indexFallback)(c)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -115,9 +126,9 @@ func (h *Hub) Run() {
|
|||||||
// set up cron jobs
|
// set up cron jobs
|
||||||
scheduler := cron.New()
|
scheduler := cron.New()
|
||||||
// delete old records once every hour
|
// delete old records once every hour
|
||||||
scheduler.MustAdd("delete old records", "8 * * * *", rm.DeleteOldRecords)
|
scheduler.MustAdd("delete old records", "8 * * * *", h.rm.DeleteOldRecords)
|
||||||
// create longer records every 10 minutes
|
// create longer records every 10 minutes
|
||||||
scheduler.MustAdd("create longer records", "*/10 * * * *", rm.CreateLongerRecords)
|
scheduler.MustAdd("create longer records", "*/10 * * * *", h.rm.CreateLongerRecords)
|
||||||
scheduler.Start()
|
scheduler.Start()
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -140,15 +151,8 @@ func (h *Hub) Run() {
|
|||||||
}
|
}
|
||||||
return c.JSON(http.StatusOK, map[string]bool{"firstRun": adminNum == 0})
|
return c.JSON(http.StatusOK, map[string]bool{"firstRun": adminNum == 0})
|
||||||
})
|
})
|
||||||
return nil
|
// send test notification
|
||||||
})
|
e.Router.GET("/api/beszel/send-test-notification", h.am.SendTestNotification)
|
||||||
|
|
||||||
// user creation - set default role to user if unset
|
|
||||||
h.app.OnModelBeforeCreate("users").Add(func(e *core.ModelEvent) error {
|
|
||||||
user := e.Model.(*models.Record)
|
|
||||||
if user.GetString("role") == "" {
|
|
||||||
user.Set("role", "user")
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -166,6 +170,10 @@ func (h *Hub) Run() {
|
|||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// handle default values for user / user_settings creation
|
||||||
|
h.app.OnModelBeforeCreate("users").Add(h.um.InitializeUserRole)
|
||||||
|
h.app.OnModelBeforeCreate("user_settings").Add(h.um.InitializeUserSettings)
|
||||||
|
|
||||||
// do things after a systems record is updated
|
// do things after a systems record is updated
|
||||||
h.app.OnModelAfterUpdate("systems").Add(func(e *core.ModelEvent) error {
|
h.app.OnModelAfterUpdate("systems").Add(func(e *core.ModelEvent) error {
|
||||||
newRecord := e.Model.(*models.Record)
|
newRecord := e.Model.(*models.Record)
|
||||||
@@ -180,10 +188,11 @@ func (h *Hub) Run() {
|
|||||||
// if system is set to pending (unpause), try to connect immediately
|
// if system is set to pending (unpause), try to connect immediately
|
||||||
if newStatus == "pending" {
|
if newStatus == "pending" {
|
||||||
go h.updateSystem(newRecord)
|
go h.updateSystem(newRecord)
|
||||||
|
} else {
|
||||||
|
h.am.HandleStatusAlerts(newStatus, oldRecord)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// alerts
|
|
||||||
am.HandleSystemAlerts(newStatus, newRecord, oldRecord)
|
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -259,7 +268,7 @@ func (h *Hub) updateSystem(record *models.Record) {
|
|||||||
}
|
}
|
||||||
// get system stats from agent
|
// get system stats from agent
|
||||||
var systemData system.CombinedData
|
var systemData system.CombinedData
|
||||||
if err := requestJsonFromAgent(client, &systemData); err != nil {
|
if err := h.requestJsonFromAgent(client, &systemData); err != nil {
|
||||||
if err.Error() == "bad client" {
|
if err.Error() == "bad client" {
|
||||||
// if previous connection was closed, try again
|
// if previous connection was closed, try again
|
||||||
h.app.Logger().Error("Existing SSH connection closed. Retrying...", "host", record.GetString("host"), "port", record.GetString("port"))
|
h.app.Logger().Error("Existing SSH connection closed. Retrying...", "host", record.GetString("host"), "port", record.GetString("port"))
|
||||||
@@ -297,6 +306,8 @@ func (h *Hub) updateSystem(record *models.Record) {
|
|||||||
h.app.Logger().Error("Failed to save record: ", "err", err.Error())
|
h.app.Logger().Error("Failed to save record: ", "err", err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// system info alerts (todo: temp alerts, extra fs alerts)
|
||||||
|
h.am.HandleSystemInfoAlerts(record, systemData.Info)
|
||||||
}
|
}
|
||||||
|
|
||||||
// set system to specified status and save record
|
// set system to specified status and save record
|
||||||
@@ -352,7 +363,8 @@ func (h *Hub) createSSHClientConfig() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func requestJsonFromAgent(client *ssh.Client, systemData *system.CombinedData) error {
|
// Fetches system stats from the agent and decodes the json data into the provided struct
|
||||||
|
func (h *Hub) requestJsonFromAgent(client *ssh.Client, systemData *system.CombinedData) error {
|
||||||
session, err := newSessionWithTimeout(client, 5*time.Second)
|
session, err := newSessionWithTimeout(client, 5*time.Second)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("bad client")
|
return fmt.Errorf("bad client")
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/pocketbase/pocketbase"
|
"github.com/pocketbase/pocketbase"
|
||||||
"github.com/pocketbase/pocketbase/daos"
|
"github.com/pocketbase/pocketbase/daos"
|
||||||
"github.com/pocketbase/pocketbase/models"
|
"github.com/pocketbase/pocketbase/models"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
type RecordManager struct {
|
type RecordManager struct {
|
||||||
@@ -19,10 +20,10 @@ type RecordManager struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type LongerRecordData struct {
|
type LongerRecordData struct {
|
||||||
shorterType string
|
shorterType string
|
||||||
longerType string
|
longerType string
|
||||||
longerTimeDuration time.Duration
|
longerTimeDuration time.Duration
|
||||||
expectedShorterRecords int
|
minShorterRecords int
|
||||||
}
|
}
|
||||||
|
|
||||||
type RecordDeletionData struct {
|
type RecordDeletionData struct {
|
||||||
@@ -39,28 +40,29 @@ func (rm *RecordManager) CreateLongerRecords() {
|
|||||||
// start := time.Now()
|
// start := time.Now()
|
||||||
recordData := []LongerRecordData{
|
recordData := []LongerRecordData{
|
||||||
{
|
{
|
||||||
shorterType: "1m",
|
shorterType: "1m",
|
||||||
expectedShorterRecords: 10,
|
// change to 9 from 10 to allow edge case timing or short pauses
|
||||||
longerType: "10m",
|
minShorterRecords: 9,
|
||||||
longerTimeDuration: -10 * time.Minute,
|
longerType: "10m",
|
||||||
|
longerTimeDuration: -10 * time.Minute,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
shorterType: "10m",
|
shorterType: "10m",
|
||||||
expectedShorterRecords: 2,
|
minShorterRecords: 2,
|
||||||
longerType: "20m",
|
longerType: "20m",
|
||||||
longerTimeDuration: -20 * time.Minute,
|
longerTimeDuration: -20 * time.Minute,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
shorterType: "20m",
|
shorterType: "20m",
|
||||||
expectedShorterRecords: 6,
|
minShorterRecords: 6,
|
||||||
longerType: "120m",
|
longerType: "120m",
|
||||||
longerTimeDuration: -120 * time.Minute,
|
longerTimeDuration: -120 * time.Minute,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
shorterType: "120m",
|
shorterType: "120m",
|
||||||
expectedShorterRecords: 4,
|
minShorterRecords: 4,
|
||||||
longerType: "480m",
|
longerType: "480m",
|
||||||
longerTimeDuration: -480 * time.Minute,
|
longerTimeDuration: -480 * time.Minute,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
// wrap the operations in a transaction
|
// wrap the operations in a transaction
|
||||||
@@ -111,7 +113,7 @@ func (rm *RecordManager) CreateLongerRecords() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// continue if not enough shorter records
|
// continue if not enough shorter records
|
||||||
if err != nil || len(allShorterRecords) < recordData.expectedShorterRecords {
|
if err != nil || len(allShorterRecords) < recordData.minShorterRecords {
|
||||||
// log.Println("not enough shorter records. continue.", len(allShorterRecords), recordData.expectedShorterRecords)
|
// log.Println("not enough shorter records. continue.", len(allShorterRecords), recordData.expectedShorterRecords)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -142,9 +144,10 @@ func (rm *RecordManager) CreateLongerRecords() {
|
|||||||
|
|
||||||
// Calculate the average stats of a list of system_stats records without reflect
|
// Calculate the average stats of a list of system_stats records without reflect
|
||||||
func (rm *RecordManager) AverageSystemStats(records []*models.Record) system.Stats {
|
func (rm *RecordManager) AverageSystemStats(records []*models.Record) system.Stats {
|
||||||
var sum system.Stats
|
sum := system.Stats{
|
||||||
sum.Temperatures = make(map[string]float64)
|
Temperatures: make(map[string]float64),
|
||||||
sum.ExtraFs = make(map[string]*system.FsStats)
|
ExtraFs: make(map[string]*system.FsStats),
|
||||||
|
}
|
||||||
|
|
||||||
count := float64(len(records))
|
count := float64(len(records))
|
||||||
// use different counter for temps in case some records don't have them
|
// use different counter for temps in case some records don't have them
|
||||||
@@ -231,7 +234,7 @@ func (rm *RecordManager) AverageSystemStats(records []*models.Record) system.Sta
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Calculate the average stats of a list of container_stats records
|
// Calculate the average stats of a list of container_stats records
|
||||||
func (rm *RecordManager) AverageContainerStats(records []*models.Record) (stats []container.Stats) {
|
func (rm *RecordManager) AverageContainerStats(records []*models.Record) []container.Stats {
|
||||||
sums := make(map[string]*container.Stats)
|
sums := make(map[string]*container.Stats)
|
||||||
count := float64(len(records))
|
count := float64(len(records))
|
||||||
|
|
||||||
@@ -240,7 +243,7 @@ func (rm *RecordManager) AverageContainerStats(records []*models.Record) (stats
|
|||||||
record.UnmarshalJSONField("stats", &containerStats)
|
record.UnmarshalJSONField("stats", &containerStats)
|
||||||
for _, stat := range containerStats {
|
for _, stat := range containerStats {
|
||||||
if _, ok := sums[stat.Name]; !ok {
|
if _, ok := sums[stat.Name]; !ok {
|
||||||
sums[stat.Name] = &container.Stats{Name: stat.Name, Cpu: 0, Mem: 0}
|
sums[stat.Name] = &container.Stats{Name: stat.Name}
|
||||||
}
|
}
|
||||||
sums[stat.Name].Cpu += stat.Cpu
|
sums[stat.Name].Cpu += stat.Cpu
|
||||||
sums[stat.Name].Mem += stat.Mem
|
sums[stat.Name].Mem += stat.Mem
|
||||||
@@ -249,8 +252,9 @@ func (rm *RecordManager) AverageContainerStats(records []*models.Record) (stats
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
result := make([]container.Stats, 0, len(sums))
|
||||||
for _, value := range sums {
|
for _, value := range sums {
|
||||||
stats = append(stats, container.Stats{
|
result = append(result, container.Stats{
|
||||||
Name: value.Name,
|
Name: value.Name,
|
||||||
Cpu: twoDecimals(value.Cpu / count),
|
Cpu: twoDecimals(value.Cpu / count),
|
||||||
Mem: twoDecimals(value.Mem / count),
|
Mem: twoDecimals(value.Mem / count),
|
||||||
@@ -258,11 +262,11 @@ func (rm *RecordManager) AverageContainerStats(records []*models.Record) (stats
|
|||||||
NetworkRecv: twoDecimals(value.NetworkRecv / count),
|
NetworkRecv: twoDecimals(value.NetworkRecv / count),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return stats
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deletes records older than what is displayed in the UI
|
||||||
func (rm *RecordManager) DeleteOldRecords() {
|
func (rm *RecordManager) DeleteOldRecords() {
|
||||||
// start := time.Now()
|
|
||||||
collections := []string{"system_stats", "container_stats"}
|
collections := []string{"system_stats", "container_stats"}
|
||||||
recordData := []RecordDeletionData{
|
recordData := []RecordDeletionData{
|
||||||
{
|
{
|
||||||
@@ -286,29 +290,17 @@ func (rm *RecordManager) DeleteOldRecords() {
|
|||||||
retention: 30 * 24 * time.Hour,
|
retention: 30 * 24 * time.Hour,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
rm.app.Dao().RunInTransaction(func(txDao *daos.Dao) error {
|
db := rm.app.Dao().NonconcurrentDB()
|
||||||
for _, recordData := range recordData {
|
for _, recordData := range recordData {
|
||||||
exp := dbx.NewExp(
|
for _, collectionSlug := range collections {
|
||||||
"type = {:type} AND created < {:created}",
|
formattedDate := time.Now().UTC().Add(-recordData.retention).Format(types.DefaultDateLayout)
|
||||||
dbx.Params{"type": recordData.recordType, "created": time.Now().UTC().Add(-recordData.retention)},
|
expr := dbx.NewExp("[[created]] < {:date} AND [[type]] = {:type}", dbx.Params{"date": formattedDate, "type": recordData.recordType})
|
||||||
)
|
_, err := db.Delete(collectionSlug, expr).Execute()
|
||||||
for _, collectionSlug := range collections {
|
if err != nil {
|
||||||
collectionRecords, err := txDao.FindRecordsByExpr(collectionSlug, exp)
|
rm.app.Logger().Error("Failed to delete records", "err", err.Error())
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
for _, record := range collectionRecords {
|
|
||||||
err := txDao.DeleteRecord(record)
|
|
||||||
if err != nil {
|
|
||||||
rm.app.Logger().Error("Failed to delete records", "err", err.Error())
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
}
|
||||||
})
|
|
||||||
// log.Println("finished deleting old records", "time (ms)", time.Since(start).Milliseconds())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Round float to two decimals */
|
/* Round float to two decimals */
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Package update handles updating beszel and beszel-agent.
|
||||||
package update
|
package update
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
65
beszel/internal/users/users.go
Normal file
65
beszel/internal/users/users.go
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
// Package users handles user-related custom functionality.
|
||||||
|
package users
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/pocketbase/pocketbase"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
"github.com/pocketbase/pocketbase/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UserManager struct {
|
||||||
|
app *pocketbase.PocketBase
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserSettings struct {
|
||||||
|
ChartTime string `json:"chartTime"`
|
||||||
|
NotificationEmails []string `json:"emails"`
|
||||||
|
NotificationWebhooks []string `json:"webhooks"`
|
||||||
|
// Language string `json:"lang"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUserManager(app *pocketbase.PocketBase) *UserManager {
|
||||||
|
return &UserManager{
|
||||||
|
app: app,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (um *UserManager) InitializeUserRole(e *core.ModelEvent) error {
|
||||||
|
user := e.Model.(*models.Record)
|
||||||
|
if user.GetString("role") == "" {
|
||||||
|
user.Set("role", "user")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (um *UserManager) InitializeUserSettings(e *core.ModelEvent) error {
|
||||||
|
record := e.Model.(*models.Record)
|
||||||
|
// intialize settings with defaults
|
||||||
|
settings := UserSettings{
|
||||||
|
// Language: "en",
|
||||||
|
ChartTime: "1h",
|
||||||
|
NotificationEmails: []string{},
|
||||||
|
NotificationWebhooks: []string{},
|
||||||
|
}
|
||||||
|
record.UnmarshalJSONField("settings", &settings)
|
||||||
|
if len(settings.NotificationEmails) == 0 {
|
||||||
|
// get user email from auth record
|
||||||
|
if errs := um.app.Dao().ExpandRecord(record, []string{"user"}, nil); len(errs) == 0 {
|
||||||
|
// app.Logger().Error("failed to expand user relation", "errs", errs)
|
||||||
|
if user := record.ExpandedOne("user"); user != nil {
|
||||||
|
settings.NotificationEmails = []string{user.GetString("email")}
|
||||||
|
} else {
|
||||||
|
log.Println("Failed to get user email from auth record")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.Println("failed to expand user relation", "errs", errs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// if len(settings.NotificationWebhooks) == 0 {
|
||||||
|
// settings.NotificationWebhooks = []string{""}
|
||||||
|
// }
|
||||||
|
record.Set("settings", settings)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ func init() {
|
|||||||
{
|
{
|
||||||
"id": "2hz5ncl8tizk5nx",
|
"id": "2hz5ncl8tizk5nx",
|
||||||
"created": "2024-07-07 16:08:20.979Z",
|
"created": "2024-07-07 16:08:20.979Z",
|
||||||
"updated": "2024-07-28 17:00:47.996Z",
|
"updated": "2024-07-28 17:14:24.492Z",
|
||||||
"name": "systems",
|
"name": "systems",
|
||||||
"type": "base",
|
"type": "base",
|
||||||
"system": false,
|
"system": false,
|
||||||
@@ -120,7 +120,7 @@ func init() {
|
|||||||
{
|
{
|
||||||
"id": "ej9oowivz8b2mht",
|
"id": "ej9oowivz8b2mht",
|
||||||
"created": "2024-07-07 16:09:09.179Z",
|
"created": "2024-07-07 16:09:09.179Z",
|
||||||
"updated": "2024-07-22 20:13:31.324Z",
|
"updated": "2024-07-28 17:14:24.492Z",
|
||||||
"name": "system_stats",
|
"name": "system_stats",
|
||||||
"type": "base",
|
"type": "base",
|
||||||
"system": false,
|
"system": false,
|
||||||
@@ -186,7 +186,7 @@ func init() {
|
|||||||
{
|
{
|
||||||
"id": "juohu4jipgc13v7",
|
"id": "juohu4jipgc13v7",
|
||||||
"created": "2024-07-07 16:09:57.976Z",
|
"created": "2024-07-07 16:09:57.976Z",
|
||||||
"updated": "2024-07-22 20:13:31.324Z",
|
"updated": "2024-07-28 17:14:24.492Z",
|
||||||
"name": "container_stats",
|
"name": "container_stats",
|
||||||
"type": "base",
|
"type": "base",
|
||||||
"system": false,
|
"system": false,
|
||||||
@@ -250,7 +250,7 @@ func init() {
|
|||||||
{
|
{
|
||||||
"id": "_pb_users_auth_",
|
"id": "_pb_users_auth_",
|
||||||
"created": "2024-07-14 16:25:18.226Z",
|
"created": "2024-07-14 16:25:18.226Z",
|
||||||
"updated": "2024-07-28 17:02:08.311Z",
|
"updated": "2024-09-12 23:19:36.280Z",
|
||||||
"name": "users",
|
"name": "users",
|
||||||
"type": "auth",
|
"type": "auth",
|
||||||
"system": false,
|
"system": false,
|
||||||
@@ -316,7 +316,7 @@ func init() {
|
|||||||
{
|
{
|
||||||
"id": "elngm8x1l60zi2v",
|
"id": "elngm8x1l60zi2v",
|
||||||
"created": "2024-07-15 01:16:04.044Z",
|
"created": "2024-07-15 01:16:04.044Z",
|
||||||
"updated": "2024-07-22 20:13:31.324Z",
|
"updated": "2024-07-28 17:14:24.492Z",
|
||||||
"name": "alerts",
|
"name": "alerts",
|
||||||
"type": "base",
|
"type": "base",
|
||||||
"system": false,
|
"system": false,
|
||||||
@@ -403,6 +403,53 @@ func init() {
|
|||||||
"updateRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
|
"updateRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
|
||||||
"deleteRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
|
"deleteRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
|
||||||
"options": {}
|
"options": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "4afacsdnlu8q8r2",
|
||||||
|
"created": "2024-09-12 17:42:55.324Z",
|
||||||
|
"updated": "2024-09-12 21:19:59.114Z",
|
||||||
|
"name": "user_settings",
|
||||||
|
"type": "base",
|
||||||
|
"system": false,
|
||||||
|
"schema": [
|
||||||
|
{
|
||||||
|
"system": false,
|
||||||
|
"id": "d5vztyxa",
|
||||||
|
"name": "user",
|
||||||
|
"type": "relation",
|
||||||
|
"required": true,
|
||||||
|
"presentable": false,
|
||||||
|
"unique": false,
|
||||||
|
"options": {
|
||||||
|
"collectionId": "_pb_users_auth_",
|
||||||
|
"cascadeDelete": false,
|
||||||
|
"minSelect": null,
|
||||||
|
"maxSelect": 1,
|
||||||
|
"displayFields": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"system": false,
|
||||||
|
"id": "xcx4qgqq",
|
||||||
|
"name": "settings",
|
||||||
|
"type": "json",
|
||||||
|
"required": false,
|
||||||
|
"presentable": false,
|
||||||
|
"unique": false,
|
||||||
|
"options": {
|
||||||
|
"maxSize": 2000000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"indexes": [
|
||||||
|
"CREATE UNIQUE INDEX ` + "`" + `idx_30Lwgf2` + "`" + ` ON ` + "`" + `user_settings` + "`" + ` (` + "`" + `user` + "`" + `)"
|
||||||
|
],
|
||||||
|
"listRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
|
||||||
|
"viewRule": null,
|
||||||
|
"createRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
|
||||||
|
"updateRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
|
||||||
|
"deleteRule": null,
|
||||||
|
"options": {}
|
||||||
}
|
}
|
||||||
]`
|
]`
|
||||||
|
|
||||||
@@ -11,5 +11,3 @@ import (
|
|||||||
var assets embed.FS
|
var assets embed.FS
|
||||||
|
|
||||||
var Dist = echo.MustSubFS(assets, "dist")
|
var Dist = echo.MustSubFS(assets, "dist")
|
||||||
|
|
||||||
var Static = echo.MustSubFS(assets, "dist/static")
|
|
||||||
|
|||||||
@@ -2,18 +2,17 @@ import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
|
|||||||
|
|
||||||
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'
|
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'
|
||||||
import {
|
import {
|
||||||
|
useYAxisWidth,
|
||||||
chartTimeData,
|
chartTimeData,
|
||||||
cn,
|
cn,
|
||||||
formatShortDate,
|
formatShortDate,
|
||||||
toFixedWithoutTrailingZeros,
|
toFixedWithoutTrailingZeros,
|
||||||
twoDecimalString,
|
twoDecimalString,
|
||||||
useYaxisWidth,
|
|
||||||
} from '@/lib/utils'
|
} from '@/lib/utils'
|
||||||
// import Spinner from '../spinner'
|
// import Spinner from '../spinner'
|
||||||
import { useStore } from '@nanostores/react'
|
import { useStore } from '@nanostores/react'
|
||||||
import { $chartTime } from '@/lib/stores'
|
import { $chartTime } from '@/lib/stores'
|
||||||
import { SystemStatsRecord } from '@/types'
|
import { SystemStatsRecord } from '@/types'
|
||||||
import { useMemo, useRef } from 'react'
|
|
||||||
|
|
||||||
export default function BandwidthChart({
|
export default function BandwidthChart({
|
||||||
ticks,
|
ticks,
|
||||||
@@ -22,19 +21,16 @@ export default function BandwidthChart({
|
|||||||
ticks: number[]
|
ticks: number[]
|
||||||
systemData: SystemStatsRecord[]
|
systemData: SystemStatsRecord[]
|
||||||
}) {
|
}) {
|
||||||
const chartRef = useRef<HTMLDivElement>(null)
|
|
||||||
const yAxisWidth = useYaxisWidth(chartRef)
|
|
||||||
const chartTime = useStore($chartTime)
|
const chartTime = useStore($chartTime)
|
||||||
|
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||||
const yAxisSet = useMemo(() => yAxisWidth !== 180, [yAxisWidth])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={chartRef}>
|
<div>
|
||||||
{/* {!yAxisSet && <Spinner />} */}
|
{/* {!yAxisSet && <Spinner />} */}
|
||||||
<ChartContainer
|
<ChartContainer
|
||||||
config={{}}
|
config={{}}
|
||||||
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
|
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
|
||||||
'opacity-100': yAxisSet,
|
'opacity-100': yAxisWidth,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<AreaChart
|
<AreaChart
|
||||||
@@ -52,10 +48,13 @@ export default function BandwidthChart({
|
|||||||
className="tracking-tighter"
|
className="tracking-tighter"
|
||||||
width={yAxisWidth}
|
width={yAxisWidth}
|
||||||
// domain={[0, (max: number) => (max <= 0.4 ? 0.4 : Math.ceil(max))]}
|
// domain={[0, (max: number) => (max <= 0.4 ? 0.4 : Math.ceil(max))]}
|
||||||
tickFormatter={(value) => toFixedWithoutTrailingZeros(value, 2)}
|
tickFormatter={(value) => {
|
||||||
|
const val = toFixedWithoutTrailingZeros(value, 2) + ' MB/s'
|
||||||
|
return updateYAxisWidth(val)
|
||||||
|
}}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
unit={' MB/s'}
|
// unit={' MB/s'}
|
||||||
/>
|
/>
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="created"
|
dataKey="created"
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import {
|
|||||||
ChartTooltip,
|
ChartTooltip,
|
||||||
ChartTooltipContent,
|
ChartTooltipContent,
|
||||||
} from '@/components/ui/chart'
|
} from '@/components/ui/chart'
|
||||||
import { useMemo, useRef } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { chartTimeData, cn, formatShortDate, twoDecimalString, useYaxisWidth } from '@/lib/utils'
|
import { useYAxisWidth, chartTimeData, cn, formatShortDate, twoDecimalString } from '@/lib/utils'
|
||||||
// import Spinner from '../spinner'
|
// import Spinner from '../spinner'
|
||||||
import { useStore } from '@nanostores/react'
|
import { useStore } from '@nanostores/react'
|
||||||
import { $chartTime, $containerFilter } from '@/lib/stores'
|
import { $chartTime, $containerFilter } from '@/lib/stores'
|
||||||
@@ -18,12 +18,9 @@ export default function ContainerCpuChart({
|
|||||||
chartData: Record<string, number | string>[]
|
chartData: Record<string, number | string>[]
|
||||||
ticks: number[]
|
ticks: number[]
|
||||||
}) {
|
}) {
|
||||||
const chartRef = useRef<HTMLDivElement>(null)
|
|
||||||
const yAxisWidth = useYaxisWidth(chartRef)
|
|
||||||
const chartTime = useStore($chartTime)
|
const chartTime = useStore($chartTime)
|
||||||
const filter = useStore($containerFilter)
|
const filter = useStore($containerFilter)
|
||||||
|
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||||
const yAxisSet = useMemo(() => yAxisWidth !== 180, [yAxisWidth])
|
|
||||||
|
|
||||||
const chartConfig = useMemo(() => {
|
const chartConfig = useMemo(() => {
|
||||||
let config = {} as Record<
|
let config = {} as Record<
|
||||||
@@ -65,12 +62,12 @@ export default function ContainerCpuChart({
|
|||||||
// }
|
// }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={chartRef}>
|
<div>
|
||||||
{/* {!yAxisSet && <Spinner />} */}
|
{/* {!yAxisSet && <Spinner />} */}
|
||||||
<ChartContainer
|
<ChartContainer
|
||||||
config={{}}
|
config={{}}
|
||||||
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
|
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
|
||||||
'opacity-100': yAxisSet,
|
'opacity-100': yAxisWidth,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<AreaChart
|
<AreaChart
|
||||||
@@ -89,8 +86,10 @@ export default function ContainerCpuChart({
|
|||||||
width={yAxisWidth}
|
width={yAxisWidth}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
unit={'%'}
|
tickFormatter={(x) => {
|
||||||
tickFormatter={(x) => (x % 1 === 0 ? x : x.toFixed(1))}
|
const val = (x % 1 === 0 ? x : x.toFixed(1)) + '%'
|
||||||
|
return updateYAxisWidth(val)
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="time"
|
dataKey="time"
|
||||||
|
|||||||
@@ -5,14 +5,14 @@ import {
|
|||||||
ChartTooltip,
|
ChartTooltip,
|
||||||
ChartTooltipContent,
|
ChartTooltipContent,
|
||||||
} from '@/components/ui/chart'
|
} from '@/components/ui/chart'
|
||||||
import { useMemo, useRef } from 'react'
|
import { useMemo } from 'react'
|
||||||
import {
|
import {
|
||||||
|
useYAxisWidth,
|
||||||
chartTimeData,
|
chartTimeData,
|
||||||
cn,
|
cn,
|
||||||
formatShortDate,
|
formatShortDate,
|
||||||
toFixedWithoutTrailingZeros,
|
toFixedWithoutTrailingZeros,
|
||||||
twoDecimalString,
|
twoDecimalString,
|
||||||
useYaxisWidth,
|
|
||||||
} from '@/lib/utils'
|
} from '@/lib/utils'
|
||||||
// import Spinner from '../spinner'
|
// import Spinner from '../spinner'
|
||||||
import { useStore } from '@nanostores/react'
|
import { useStore } from '@nanostores/react'
|
||||||
@@ -26,11 +26,8 @@ export default function ContainerMemChart({
|
|||||||
ticks: number[]
|
ticks: number[]
|
||||||
}) {
|
}) {
|
||||||
const chartTime = useStore($chartTime)
|
const chartTime = useStore($chartTime)
|
||||||
const chartRef = useRef<HTMLDivElement>(null)
|
|
||||||
const yAxisWidth = useYaxisWidth(chartRef)
|
|
||||||
const filter = useStore($containerFilter)
|
const filter = useStore($containerFilter)
|
||||||
|
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||||
const yAxisSet = useMemo(() => yAxisWidth !== 180, [yAxisWidth])
|
|
||||||
|
|
||||||
const chartConfig = useMemo(() => {
|
const chartConfig = useMemo(() => {
|
||||||
let config = {} as Record<
|
let config = {} as Record<
|
||||||
@@ -72,12 +69,12 @@ export default function ContainerMemChart({
|
|||||||
// }
|
// }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={chartRef}>
|
<div>
|
||||||
{/* {!yAxisSet && <Spinner />} */}
|
{/* {!yAxisSet && <Spinner />} */}
|
||||||
<ChartContainer
|
<ChartContainer
|
||||||
config={{}}
|
config={{}}
|
||||||
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
|
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
|
||||||
'opacity-100': yAxisSet,
|
'opacity-100': yAxisWidth,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<AreaChart
|
<AreaChart
|
||||||
@@ -94,9 +91,11 @@ export default function ContainerMemChart({
|
|||||||
// domain={[0, (max: number) => Math.ceil(max)]}
|
// domain={[0, (max: number) => Math.ceil(max)]}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
unit={' GB'}
|
|
||||||
width={yAxisWidth}
|
width={yAxisWidth}
|
||||||
tickFormatter={(value) => toFixedWithoutTrailingZeros(value / 1024, 2)}
|
tickFormatter={(value) => {
|
||||||
|
const val = toFixedWithoutTrailingZeros(value / 1024, 2) + ' GB'
|
||||||
|
return updateYAxisWidth(val)
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="time"
|
dataKey="time"
|
||||||
|
|||||||
@@ -5,14 +5,14 @@ import {
|
|||||||
ChartTooltip,
|
ChartTooltip,
|
||||||
ChartTooltipContent,
|
ChartTooltipContent,
|
||||||
} from '@/components/ui/chart'
|
} from '@/components/ui/chart'
|
||||||
import { useMemo, useRef } from 'react'
|
import { useMemo } from 'react'
|
||||||
import {
|
import {
|
||||||
|
useYAxisWidth,
|
||||||
chartTimeData,
|
chartTimeData,
|
||||||
cn,
|
cn,
|
||||||
formatShortDate,
|
formatShortDate,
|
||||||
toFixedWithoutTrailingZeros,
|
toFixedWithoutTrailingZeros,
|
||||||
twoDecimalString,
|
twoDecimalString,
|
||||||
useYaxisWidth,
|
|
||||||
} from '@/lib/utils'
|
} from '@/lib/utils'
|
||||||
// import Spinner from '../spinner'
|
// import Spinner from '../spinner'
|
||||||
import { useStore } from '@nanostores/react'
|
import { useStore } from '@nanostores/react'
|
||||||
@@ -27,11 +27,8 @@ export default function ContainerCpuChart({
|
|||||||
ticks: number[]
|
ticks: number[]
|
||||||
}) {
|
}) {
|
||||||
const chartTime = useStore($chartTime)
|
const chartTime = useStore($chartTime)
|
||||||
const chartRef = useRef<HTMLDivElement>(null)
|
|
||||||
const yAxisWidth = useYaxisWidth(chartRef)
|
|
||||||
const filter = useStore($containerFilter)
|
const filter = useStore($containerFilter)
|
||||||
|
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||||
const yAxisSet = useMemo(() => yAxisWidth !== 180, [yAxisWidth])
|
|
||||||
|
|
||||||
const chartConfig = useMemo(() => {
|
const chartConfig = useMemo(() => {
|
||||||
let config = {} as Record<
|
let config = {} as Record<
|
||||||
@@ -72,12 +69,12 @@ export default function ContainerCpuChart({
|
|||||||
// }
|
// }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={chartRef}>
|
<div>
|
||||||
{/* {!yAxisSet && <Spinner />} */}
|
{/* {!yAxisSet && <Spinner />} */}
|
||||||
<ChartContainer
|
<ChartContainer
|
||||||
config={{}}
|
config={{}}
|
||||||
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
|
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
|
||||||
'opacity-100': yAxisSet,
|
'opacity-100': yAxisWidth,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<AreaChart
|
<AreaChart
|
||||||
@@ -95,8 +92,10 @@ export default function ContainerCpuChart({
|
|||||||
width={yAxisWidth}
|
width={yAxisWidth}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
unit={' MB/s'}
|
tickFormatter={(value) => {
|
||||||
tickFormatter={(value) => toFixedWithoutTrailingZeros(value, 2)}
|
const val = toFixedWithoutTrailingZeros(value, 2) + ' MB/s'
|
||||||
|
return updateYAxisWidth(val)
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="time"
|
dataKey="time"
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
|
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
|
||||||
|
|
||||||
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'
|
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'
|
||||||
import { chartTimeData, cn, formatShortDate, twoDecimalString, useYaxisWidth } from '@/lib/utils'
|
import { useYAxisWidth, chartTimeData, cn, formatShortDate, twoDecimalString } from '@/lib/utils'
|
||||||
// import Spinner from '../spinner'
|
// import Spinner from '../spinner'
|
||||||
import { useStore } from '@nanostores/react'
|
import { useStore } from '@nanostores/react'
|
||||||
import { $chartTime } from '@/lib/stores'
|
import { $chartTime } from '@/lib/stores'
|
||||||
import { SystemStatsRecord } from '@/types'
|
import { SystemStatsRecord } from '@/types'
|
||||||
import { useMemo, useRef } from 'react'
|
|
||||||
|
|
||||||
export default function CpuChart({
|
export default function CpuChart({
|
||||||
ticks,
|
ticks,
|
||||||
@@ -16,17 +15,14 @@ export default function CpuChart({
|
|||||||
systemData: SystemStatsRecord[]
|
systemData: SystemStatsRecord[]
|
||||||
}) {
|
}) {
|
||||||
const chartTime = useStore($chartTime)
|
const chartTime = useStore($chartTime)
|
||||||
const chartRef = useRef<HTMLDivElement>(null)
|
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||||
const yAxisWidth = useYaxisWidth(chartRef)
|
|
||||||
|
|
||||||
const yAxisSet = useMemo(() => yAxisWidth !== 180, [yAxisWidth])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={chartRef}>
|
<div>
|
||||||
<ChartContainer
|
<ChartContainer
|
||||||
config={{}}
|
config={{}}
|
||||||
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
|
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
|
||||||
'opacity-100': yAxisSet,
|
'opacity-100': yAxisWidth,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<AreaChart
|
<AreaChart
|
||||||
@@ -42,7 +38,7 @@ export default function CpuChart({
|
|||||||
width={yAxisWidth}
|
width={yAxisWidth}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
unit={'%'}
|
tickFormatter={(value) => updateYAxisWidth(value + '%')}
|
||||||
/>
|
/>
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="created"
|
dataKey="created"
|
||||||
|
|||||||
@@ -1,8 +1,17 @@
|
|||||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
|
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
|
||||||
|
|
||||||
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'
|
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'
|
||||||
import { chartTimeData, cn, formatShortDate, twoDecimalString, useYaxisWidth } from '@/lib/utils'
|
import {
|
||||||
import { useMemo, useRef } from 'react'
|
useYAxisWidth,
|
||||||
|
chartTimeData,
|
||||||
|
cn,
|
||||||
|
formatShortDate,
|
||||||
|
twoDecimalString,
|
||||||
|
toFixedFloat,
|
||||||
|
getSizeVal,
|
||||||
|
getSizeUnit,
|
||||||
|
} from '@/lib/utils'
|
||||||
|
// import { useMemo } from 'react'
|
||||||
// import Spinner from '../spinner'
|
// import Spinner from '../spinner'
|
||||||
import { useStore } from '@nanostores/react'
|
import { useStore } from '@nanostores/react'
|
||||||
import { $chartTime } from '@/lib/stores'
|
import { $chartTime } from '@/lib/stores'
|
||||||
@@ -11,27 +20,24 @@ import { SystemStatsRecord } from '@/types'
|
|||||||
export default function DiskChart({
|
export default function DiskChart({
|
||||||
ticks,
|
ticks,
|
||||||
systemData,
|
systemData,
|
||||||
|
dataKey,
|
||||||
|
diskSize,
|
||||||
}: {
|
}: {
|
||||||
ticks: number[]
|
ticks: number[]
|
||||||
systemData: SystemStatsRecord[]
|
systemData: SystemStatsRecord[]
|
||||||
|
dataKey: string
|
||||||
|
diskSize: number
|
||||||
}) {
|
}) {
|
||||||
const chartTime = useStore($chartTime)
|
const chartTime = useStore($chartTime)
|
||||||
const chartRef = useRef<HTMLDivElement>(null)
|
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||||
const yAxisWidth = useYaxisWidth(chartRef)
|
|
||||||
|
|
||||||
const yAxisSet = useMemo(() => yAxisWidth !== 180, [yAxisWidth])
|
|
||||||
|
|
||||||
const diskSize = useMemo(() => {
|
|
||||||
return Math.round(systemData.at(-1)?.stats.d ?? NaN)
|
|
||||||
}, [systemData])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={chartRef}>
|
<div>
|
||||||
{/* {!yAxisSet && <Spinner />} */}
|
{/* {!yAxisSet && <Spinner />} */}
|
||||||
<ChartContainer
|
<ChartContainer
|
||||||
config={{}}
|
config={{}}
|
||||||
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
|
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
|
||||||
'opacity-100': yAxisSet,
|
'opacity-100': yAxisWidth,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<AreaChart
|
<AreaChart
|
||||||
@@ -53,7 +59,9 @@ export default function DiskChart({
|
|||||||
minTickGap={6}
|
minTickGap={6}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
unit={' GB'}
|
tickFormatter={(value) =>
|
||||||
|
updateYAxisWidth(toFixedFloat(getSizeVal(value), 2) + getSizeUnit(value))
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="created"
|
dataKey="created"
|
||||||
@@ -72,13 +80,15 @@ export default function DiskChart({
|
|||||||
content={
|
content={
|
||||||
<ChartTooltipContent
|
<ChartTooltipContent
|
||||||
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
||||||
contentFormatter={(item) => twoDecimalString(item.value) + ' GB'}
|
contentFormatter={({ value }) =>
|
||||||
|
twoDecimalString(getSizeVal(value)) + getSizeUnit(value)
|
||||||
|
}
|
||||||
indicator="line"
|
indicator="line"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Area
|
<Area
|
||||||
dataKey="stats.du"
|
dataKey={dataKey}
|
||||||
name="Disk Usage"
|
name="Disk Usage"
|
||||||
type="monotoneX"
|
type="monotoneX"
|
||||||
fill="hsl(var(--chart-4))"
|
fill="hsl(var(--chart-4))"
|
||||||
|
|||||||
@@ -2,43 +2,37 @@ import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
|
|||||||
|
|
||||||
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'
|
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'
|
||||||
import {
|
import {
|
||||||
|
useYAxisWidth,
|
||||||
chartTimeData,
|
chartTimeData,
|
||||||
cn,
|
cn,
|
||||||
formatShortDate,
|
formatShortDate,
|
||||||
toFixedWithoutTrailingZeros,
|
toFixedWithoutTrailingZeros,
|
||||||
twoDecimalString,
|
twoDecimalString,
|
||||||
useYaxisWidth,
|
|
||||||
} from '@/lib/utils'
|
} from '@/lib/utils'
|
||||||
// import Spinner from '../spinner'
|
// import Spinner from '../spinner'
|
||||||
import { useStore } from '@nanostores/react'
|
import { useStore } from '@nanostores/react'
|
||||||
import { $chartTime } from '@/lib/stores'
|
import { $chartTime } from '@/lib/stores'
|
||||||
import { SystemStatsRecord } from '@/types'
|
import { SystemStatsRecord } from '@/types'
|
||||||
import { useMemo, useRef } from 'react'
|
|
||||||
|
|
||||||
export default function DiskIoChart({
|
export default function DiskIoChart({
|
||||||
ticks,
|
ticks,
|
||||||
systemData,
|
systemData,
|
||||||
|
dataKeys,
|
||||||
}: {
|
}: {
|
||||||
ticks: number[]
|
ticks: number[]
|
||||||
systemData: SystemStatsRecord[]
|
systemData: SystemStatsRecord[]
|
||||||
|
dataKeys: string[]
|
||||||
}) {
|
}) {
|
||||||
const chartTime = useStore($chartTime)
|
const chartTime = useStore($chartTime)
|
||||||
const chartRef = useRef<HTMLDivElement>(null)
|
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||||
const yAxisWidth = useYaxisWidth(chartRef)
|
|
||||||
|
|
||||||
const yAxisSet = useMemo(() => yAxisWidth !== 180, [yAxisWidth])
|
|
||||||
|
|
||||||
// if (!systemData.length || !ticks.length) {
|
|
||||||
// return <Spinner />
|
|
||||||
// }
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={chartRef}>
|
<div>
|
||||||
{/* {!yAxisSet && <Spinner />} */}
|
{/* {!yAxisSet && <Spinner />} */}
|
||||||
<ChartContainer
|
<ChartContainer
|
||||||
config={{}}
|
config={{}}
|
||||||
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
|
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
|
||||||
'opacity-100': yAxisSet,
|
'opacity-100': yAxisWidth,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<AreaChart
|
<AreaChart
|
||||||
@@ -56,10 +50,12 @@ export default function DiskIoChart({
|
|||||||
className="tracking-tighter"
|
className="tracking-tighter"
|
||||||
width={yAxisWidth}
|
width={yAxisWidth}
|
||||||
// domain={[0, (max: number) => (max <= 0.4 ? 0.4 : Math.ceil(max))]}
|
// domain={[0, (max: number) => (max <= 0.4 ? 0.4 : Math.ceil(max))]}
|
||||||
tickFormatter={(value) => toFixedWithoutTrailingZeros(value, 2)}
|
tickFormatter={(value) => {
|
||||||
|
const val = toFixedWithoutTrailingZeros(value, 2) + ' MB/s'
|
||||||
|
return updateYAxisWidth(val)
|
||||||
|
}}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
unit={' MB/s'}
|
|
||||||
/>
|
/>
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="created"
|
dataKey="created"
|
||||||
@@ -83,26 +79,22 @@ export default function DiskIoChart({
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Area
|
{dataKeys.map((dataKey, i) => {
|
||||||
dataKey="stats.dw"
|
const action = i ? 'Read' : 'Write'
|
||||||
name="Write"
|
const color = i ? 'hsl(var(--chart-1))' : 'hsl(var(--chart-3))'
|
||||||
type="monotoneX"
|
return (
|
||||||
fill="hsl(var(--chart-3))"
|
<Area
|
||||||
fillOpacity={0.3}
|
key={i}
|
||||||
stroke="hsl(var(--chart-3))"
|
dataKey={dataKey}
|
||||||
// animationDuration={1200}
|
name={action}
|
||||||
isAnimationActive={false}
|
type="monotoneX"
|
||||||
/>
|
fill={color}
|
||||||
<Area
|
fillOpacity={0.3}
|
||||||
dataKey="stats.dr"
|
stroke={color}
|
||||||
name="Read"
|
isAnimationActive={false}
|
||||||
type="monotoneX"
|
/>
|
||||||
fill="hsl(var(--chart-1))"
|
)
|
||||||
fillOpacity={0.3}
|
})}
|
||||||
stroke="hsl(var(--chart-1))"
|
|
||||||
// animationDuration={1200}
|
|
||||||
isAnimationActive={false}
|
|
||||||
/>
|
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,92 +0,0 @@
|
|||||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
|
|
||||||
|
|
||||||
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'
|
|
||||||
import { chartTimeData, cn, formatShortDate, twoDecimalString, useYaxisWidth } from '@/lib/utils'
|
|
||||||
import { useMemo, useRef } from 'react'
|
|
||||||
// import Spinner from '../spinner'
|
|
||||||
import { useStore } from '@nanostores/react'
|
|
||||||
import { $chartTime } from '@/lib/stores'
|
|
||||||
import { SystemStatsRecord } from '@/types'
|
|
||||||
|
|
||||||
export default function ExFsDiskChart({
|
|
||||||
ticks,
|
|
||||||
systemData,
|
|
||||||
fs,
|
|
||||||
}: {
|
|
||||||
ticks: number[]
|
|
||||||
systemData: SystemStatsRecord[]
|
|
||||||
fs: string
|
|
||||||
}) {
|
|
||||||
const chartTime = useStore($chartTime)
|
|
||||||
const chartRef = useRef<HTMLDivElement>(null)
|
|
||||||
const yAxisWidth = useYaxisWidth(chartRef)
|
|
||||||
|
|
||||||
const yAxisSet = useMemo(() => yAxisWidth !== 180, [yAxisWidth])
|
|
||||||
|
|
||||||
const diskSize = useMemo(() => {
|
|
||||||
const size = systemData.at(-1)?.stats.efs?.[fs].d ?? 0
|
|
||||||
return size > 10 ? Math.round(size) : size
|
|
||||||
}, [systemData])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={chartRef}>
|
|
||||||
<ChartContainer
|
|
||||||
config={{}}
|
|
||||||
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
|
|
||||||
'opacity-100': yAxisSet,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<AreaChart
|
|
||||||
accessibilityLayer
|
|
||||||
data={systemData}
|
|
||||||
margin={{
|
|
||||||
top: 10,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CartesianGrid vertical={false} />
|
|
||||||
<YAxis
|
|
||||||
className="tracking-tighter"
|
|
||||||
width={yAxisWidth}
|
|
||||||
domain={[0, diskSize]}
|
|
||||||
tickCount={9}
|
|
||||||
minTickGap={6}
|
|
||||||
tickLine={false}
|
|
||||||
axisLine={false}
|
|
||||||
unit={' GB'}
|
|
||||||
/>
|
|
||||||
<XAxis
|
|
||||||
dataKey="created"
|
|
||||||
domain={[ticks[0], ticks.at(-1)!]}
|
|
||||||
ticks={ticks}
|
|
||||||
type="number"
|
|
||||||
scale={'time'}
|
|
||||||
minTickGap={35}
|
|
||||||
tickMargin={8}
|
|
||||||
axisLine={false}
|
|
||||||
tickFormatter={chartTimeData[chartTime].format}
|
|
||||||
/>
|
|
||||||
<ChartTooltip
|
|
||||||
animationEasing="ease-out"
|
|
||||||
animationDuration={150}
|
|
||||||
content={
|
|
||||||
<ChartTooltipContent
|
|
||||||
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
|
||||||
contentFormatter={(item) => twoDecimalString(item.value) + ' GB'}
|
|
||||||
indicator="line"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Area
|
|
||||||
dataKey={`stats.efs.${fs}.du`}
|
|
||||||
name="Disk Usage"
|
|
||||||
type="monotoneX"
|
|
||||||
fill="hsl(var(--chart-4))"
|
|
||||||
fillOpacity={0.4}
|
|
||||||
stroke="hsl(var(--chart-4))"
|
|
||||||
isAnimationActive={false}
|
|
||||||
/>
|
|
||||||
</AreaChart>
|
|
||||||
</ChartContainer>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
|
|
||||||
|
|
||||||
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'
|
|
||||||
import {
|
|
||||||
chartTimeData,
|
|
||||||
cn,
|
|
||||||
formatShortDate,
|
|
||||||
toFixedWithoutTrailingZeros,
|
|
||||||
twoDecimalString,
|
|
||||||
useYaxisWidth,
|
|
||||||
} from '@/lib/utils'
|
|
||||||
// import Spinner from '../spinner'
|
|
||||||
import { useStore } from '@nanostores/react'
|
|
||||||
import { $chartTime } from '@/lib/stores'
|
|
||||||
import { SystemStatsRecord } from '@/types'
|
|
||||||
import { useMemo, useRef } from 'react'
|
|
||||||
|
|
||||||
export default function ExFsDiskIoChart({
|
|
||||||
ticks,
|
|
||||||
systemData,
|
|
||||||
fs,
|
|
||||||
}: {
|
|
||||||
ticks: number[]
|
|
||||||
systemData: SystemStatsRecord[]
|
|
||||||
fs: string
|
|
||||||
}) {
|
|
||||||
const chartTime = useStore($chartTime)
|
|
||||||
const chartRef = useRef<HTMLDivElement>(null)
|
|
||||||
const yAxisWidth = useYaxisWidth(chartRef)
|
|
||||||
|
|
||||||
const yAxisSet = useMemo(() => yAxisWidth !== 180, [yAxisWidth])
|
|
||||||
|
|
||||||
// if (!systemData.length || !ticks.length) {
|
|
||||||
// return <Spinner />
|
|
||||||
// }
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={chartRef}>
|
|
||||||
{/* {!yAxisSet && <Spinner />} */}
|
|
||||||
<ChartContainer
|
|
||||||
config={{}}
|
|
||||||
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
|
|
||||||
'opacity-100': yAxisSet,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<AreaChart
|
|
||||||
accessibilityLayer
|
|
||||||
data={systemData}
|
|
||||||
margin={{
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
top: 10,
|
|
||||||
bottom: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CartesianGrid vertical={false} />
|
|
||||||
<YAxis
|
|
||||||
className="tracking-tighter"
|
|
||||||
width={yAxisWidth}
|
|
||||||
// domain={[0, (max: number) => (max <= 0.4 ? 0.4 : Math.ceil(max))]}
|
|
||||||
tickFormatter={(value) => toFixedWithoutTrailingZeros(value, 2)}
|
|
||||||
tickLine={false}
|
|
||||||
axisLine={false}
|
|
||||||
unit={' MB/s'}
|
|
||||||
/>
|
|
||||||
<XAxis
|
|
||||||
dataKey="created"
|
|
||||||
domain={[ticks[0], ticks.at(-1)!]}
|
|
||||||
ticks={ticks}
|
|
||||||
type="number"
|
|
||||||
scale={'time'}
|
|
||||||
minTickGap={35}
|
|
||||||
tickMargin={8}
|
|
||||||
axisLine={false}
|
|
||||||
tickFormatter={chartTimeData[chartTime].format}
|
|
||||||
/>
|
|
||||||
<ChartTooltip
|
|
||||||
animationEasing="ease-out"
|
|
||||||
animationDuration={150}
|
|
||||||
content={
|
|
||||||
<ChartTooltipContent
|
|
||||||
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
|
||||||
contentFormatter={(item) => twoDecimalString(item.value) + ' MB/s'}
|
|
||||||
indicator="line"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Area
|
|
||||||
dataKey={`stats.efs.${fs}.w`}
|
|
||||||
name="Write"
|
|
||||||
type="monotoneX"
|
|
||||||
fill="hsl(var(--chart-3))"
|
|
||||||
fillOpacity={0.3}
|
|
||||||
stroke="hsl(var(--chart-3))"
|
|
||||||
// animationDuration={1200}
|
|
||||||
isAnimationActive={false}
|
|
||||||
/>
|
|
||||||
<Area
|
|
||||||
dataKey={`stats.efs.${fs}.r`}
|
|
||||||
name="Read"
|
|
||||||
type="monotoneX"
|
|
||||||
fill="hsl(var(--chart-1))"
|
|
||||||
fillOpacity={0.3}
|
|
||||||
stroke="hsl(var(--chart-1))"
|
|
||||||
// animationDuration={1200}
|
|
||||||
isAnimationActive={false}
|
|
||||||
/>
|
|
||||||
</AreaChart>
|
|
||||||
</ChartContainer>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -2,14 +2,14 @@ import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
|
|||||||
|
|
||||||
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'
|
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'
|
||||||
import {
|
import {
|
||||||
|
useYAxisWidth,
|
||||||
chartTimeData,
|
chartTimeData,
|
||||||
cn,
|
cn,
|
||||||
formatShortDate,
|
formatShortDate,
|
||||||
toFixedFloat,
|
toFixedFloat,
|
||||||
twoDecimalString,
|
twoDecimalString,
|
||||||
useYaxisWidth,
|
|
||||||
} from '@/lib/utils'
|
} from '@/lib/utils'
|
||||||
import { useMemo, useRef } from 'react'
|
import { useMemo } from 'react'
|
||||||
// import Spinner from '../spinner'
|
// import Spinner from '../spinner'
|
||||||
import { useStore } from '@nanostores/react'
|
import { useStore } from '@nanostores/react'
|
||||||
import { $chartTime } from '@/lib/stores'
|
import { $chartTime } from '@/lib/stores'
|
||||||
@@ -22,23 +22,20 @@ export default function MemChart({
|
|||||||
ticks: number[]
|
ticks: number[]
|
||||||
systemData: SystemStatsRecord[]
|
systemData: SystemStatsRecord[]
|
||||||
}) {
|
}) {
|
||||||
const chartRef = useRef<HTMLDivElement>(null)
|
|
||||||
const yAxisWidth = useYaxisWidth(chartRef)
|
|
||||||
const chartTime = useStore($chartTime)
|
const chartTime = useStore($chartTime)
|
||||||
|
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||||
const yAxisSet = useMemo(() => yAxisWidth !== 180, [yAxisWidth])
|
|
||||||
|
|
||||||
const totalMem = useMemo(() => {
|
const totalMem = useMemo(() => {
|
||||||
return toFixedFloat(systemData.at(-1)?.stats.m ?? 0, 1)
|
return toFixedFloat(systemData.at(-1)?.stats.m ?? 0, 1)
|
||||||
}, [systemData])
|
}, [systemData])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={chartRef}>
|
<div>
|
||||||
{/* {!yAxisSet && <Spinner />} */}
|
{/* {!yAxisSet && <Spinner />} */}
|
||||||
<ChartContainer
|
<ChartContainer
|
||||||
config={{}}
|
config={{}}
|
||||||
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
|
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
|
||||||
'opacity-100': yAxisSet,
|
'opacity-100': yAxisWidth,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<AreaChart
|
<AreaChart
|
||||||
@@ -58,7 +55,10 @@ export default function MemChart({
|
|||||||
width={yAxisWidth}
|
width={yAxisWidth}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
unit={' GB'}
|
tickFormatter={(value) => {
|
||||||
|
const val = toFixedFloat(value, 1)
|
||||||
|
return updateYAxisWidth(val + ' GB')
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<XAxis
|
<XAxis
|
||||||
@@ -100,10 +100,10 @@ export default function MemChart({
|
|||||||
dataKey="stats.mb"
|
dataKey="stats.mb"
|
||||||
name="Cache / Buffers"
|
name="Cache / Buffers"
|
||||||
type="monotoneX"
|
type="monotoneX"
|
||||||
fill="hsl(var(--chart-2))"
|
fill="hsla(160 60% 45% / 0.5)"
|
||||||
fillOpacity={0.2}
|
fillOpacity={0.4}
|
||||||
strokeOpacity={0.3}
|
// strokeOpacity={1}
|
||||||
stroke="hsl(var(--chart-2))"
|
stroke="hsla(160 60% 45% / 0.5)"
|
||||||
stackId="1"
|
stackId="1"
|
||||||
isAnimationActive={false}
|
isAnimationActive={false}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -2,18 +2,17 @@ import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
|
|||||||
|
|
||||||
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'
|
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'
|
||||||
import {
|
import {
|
||||||
|
useYAxisWidth,
|
||||||
chartTimeData,
|
chartTimeData,
|
||||||
cn,
|
cn,
|
||||||
formatShortDate,
|
formatShortDate,
|
||||||
toFixedWithoutTrailingZeros,
|
toFixedWithoutTrailingZeros,
|
||||||
twoDecimalString,
|
twoDecimalString,
|
||||||
useYaxisWidth,
|
|
||||||
} from '@/lib/utils'
|
} from '@/lib/utils'
|
||||||
// import Spinner from '../spinner'
|
// import Spinner from '../spinner'
|
||||||
import { useStore } from '@nanostores/react'
|
import { useStore } from '@nanostores/react'
|
||||||
import { $chartTime } from '@/lib/stores'
|
import { $chartTime } from '@/lib/stores'
|
||||||
import { SystemStatsRecord } from '@/types'
|
import { SystemStatsRecord } from '@/types'
|
||||||
import { useMemo, useRef } from 'react'
|
|
||||||
|
|
||||||
export default function SwapChart({
|
export default function SwapChart({
|
||||||
ticks,
|
ticks,
|
||||||
@@ -23,17 +22,14 @@ export default function SwapChart({
|
|||||||
systemData: SystemStatsRecord[]
|
systemData: SystemStatsRecord[]
|
||||||
}) {
|
}) {
|
||||||
const chartTime = useStore($chartTime)
|
const chartTime = useStore($chartTime)
|
||||||
const chartRef = useRef<HTMLDivElement>(null)
|
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||||
const yAxisWidth = useYaxisWidth(chartRef)
|
|
||||||
|
|
||||||
const yAxisSet = useMemo(() => yAxisWidth !== 180, [yAxisWidth])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={chartRef}>
|
<div>
|
||||||
<ChartContainer
|
<ChartContainer
|
||||||
config={{}}
|
config={{}}
|
||||||
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
|
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
|
||||||
'opacity-100': yAxisSet,
|
'opacity-100': yAxisWidth,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<AreaChart accessibilityLayer data={systemData} margin={{ top: 10 }}>
|
<AreaChart accessibilityLayer data={systemData} margin={{ top: 10 }}>
|
||||||
@@ -44,7 +40,7 @@ export default function SwapChart({
|
|||||||
width={yAxisWidth}
|
width={yAxisWidth}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
unit={' GB'}
|
tickFormatter={(value) => updateYAxisWidth(value + ' GB')}
|
||||||
/>
|
/>
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="created"
|
dataKey="created"
|
||||||
|
|||||||
@@ -8,17 +8,17 @@ import {
|
|||||||
ChartTooltipContent,
|
ChartTooltipContent,
|
||||||
} from '@/components/ui/chart'
|
} from '@/components/ui/chart'
|
||||||
import {
|
import {
|
||||||
|
useYAxisWidth,
|
||||||
chartTimeData,
|
chartTimeData,
|
||||||
cn,
|
cn,
|
||||||
formatShortDate,
|
formatShortDate,
|
||||||
toFixedWithoutTrailingZeros,
|
toFixedWithoutTrailingZeros,
|
||||||
twoDecimalString,
|
twoDecimalString,
|
||||||
useYaxisWidth,
|
|
||||||
} from '@/lib/utils'
|
} from '@/lib/utils'
|
||||||
import { useStore } from '@nanostores/react'
|
import { useStore } from '@nanostores/react'
|
||||||
import { $chartTime } from '@/lib/stores'
|
import { $chartTime } from '@/lib/stores'
|
||||||
import { SystemStatsRecord } from '@/types'
|
import { SystemStatsRecord } from '@/types'
|
||||||
import { useMemo, useRef } from 'react'
|
import { useMemo } from 'react'
|
||||||
|
|
||||||
export default function TemperatureChart({
|
export default function TemperatureChart({
|
||||||
ticks,
|
ticks,
|
||||||
@@ -27,9 +27,8 @@ export default function TemperatureChart({
|
|||||||
ticks: number[]
|
ticks: number[]
|
||||||
systemData: SystemStatsRecord[]
|
systemData: SystemStatsRecord[]
|
||||||
}) {
|
}) {
|
||||||
const chartRef = useRef<HTMLDivElement>(null)
|
|
||||||
const yAxisWidth = useYaxisWidth(chartRef)
|
|
||||||
const chartTime = useStore($chartTime)
|
const chartTime = useStore($chartTime)
|
||||||
|
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||||
|
|
||||||
/** Format temperature data for chart and assign colors */
|
/** Format temperature data for chart and assign colors */
|
||||||
const newChartData = useMemo(() => {
|
const newChartData = useMemo(() => {
|
||||||
@@ -55,15 +54,15 @@ export default function TemperatureChart({
|
|||||||
return chartData
|
return chartData
|
||||||
}, [systemData])
|
}, [systemData])
|
||||||
|
|
||||||
const yAxisSet = useMemo(() => yAxisWidth !== 180, [yAxisWidth])
|
const colors = Object.keys(newChartData.colors)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={chartRef}>
|
<div>
|
||||||
{/* {!yAxisSet && <Spinner />} */}
|
{/* {!yAxisSet && <Spinner />} */}
|
||||||
<ChartContainer
|
<ChartContainer
|
||||||
config={{}}
|
config={{}}
|
||||||
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
|
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
|
||||||
'opacity-100': yAxisSet,
|
'opacity-100': yAxisWidth,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<LineChart
|
<LineChart
|
||||||
@@ -79,11 +78,14 @@ export default function TemperatureChart({
|
|||||||
<CartesianGrid vertical={false} />
|
<CartesianGrid vertical={false} />
|
||||||
<YAxis
|
<YAxis
|
||||||
className="tracking-tighter"
|
className="tracking-tighter"
|
||||||
|
domain={[0, 'auto']}
|
||||||
width={yAxisWidth}
|
width={yAxisWidth}
|
||||||
tickFormatter={(value) => toFixedWithoutTrailingZeros(value, 2)}
|
tickFormatter={(value) => {
|
||||||
|
const val = toFixedWithoutTrailingZeros(value, 2)
|
||||||
|
return updateYAxisWidth(val + ' °C')
|
||||||
|
}}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
unit={' °C'}
|
|
||||||
/>
|
/>
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="created"
|
dataKey="created"
|
||||||
@@ -109,7 +111,7 @@ export default function TemperatureChart({
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{Object.keys(newChartData.colors).map((key) => (
|
{colors.map((key) => (
|
||||||
<Line
|
<Line
|
||||||
key={key}
|
key={key}
|
||||||
dataKey={key}
|
dataKey={key}
|
||||||
@@ -121,7 +123,7 @@ export default function TemperatureChart({
|
|||||||
isAnimationActive={false}
|
isAnimationActive={false}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<ChartLegend content={<ChartLegendContent />} />
|
{colors.length < 12 && <ChartLegend content={<ChartLegendContent />} />}
|
||||||
</LineChart>
|
</LineChart>
|
||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
LogsIcon,
|
LogsIcon,
|
||||||
MailIcon,
|
MailIcon,
|
||||||
Server,
|
Server,
|
||||||
|
SettingsIcon,
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
@@ -46,33 +47,9 @@ export default function CommandPalette() {
|
|||||||
<CommandInput placeholder="Search for systems or settings..." />
|
<CommandInput placeholder="Search for systems or settings..." />
|
||||||
<CommandList>
|
<CommandList>
|
||||||
<CommandEmpty>No results found.</CommandEmpty>
|
<CommandEmpty>No results found.</CommandEmpty>
|
||||||
<CommandGroup heading="Suggestions">
|
|
||||||
<CommandItem
|
|
||||||
keywords={['home']}
|
|
||||||
onSelect={() => {
|
|
||||||
navigate('/')
|
|
||||||
setOpen((open) => !open)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<LayoutDashboard className="mr-2 h-4 w-4" />
|
|
||||||
<span>Dashboard</span>
|
|
||||||
<CommandShortcut>Page</CommandShortcut>
|
|
||||||
</CommandItem>
|
|
||||||
<CommandItem
|
|
||||||
keywords={['github']}
|
|
||||||
onSelect={() => {
|
|
||||||
window.location.href = 'https://github.com/henrygd/beszel/blob/main/readme.md'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Github className="mr-2 h-4 w-4" />
|
|
||||||
<span>Documentation</span>
|
|
||||||
<CommandShortcut>GitHub</CommandShortcut>
|
|
||||||
</CommandItem>
|
|
||||||
</CommandGroup>
|
|
||||||
{systems.length > 0 && (
|
{systems.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<CommandSeparator />
|
<CommandGroup>
|
||||||
<CommandGroup heading="Systems">
|
|
||||||
{systems.map((system) => (
|
{systems.map((system) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={system.id}
|
key={system.id}
|
||||||
@@ -87,11 +64,56 @@ export default function CommandPalette() {
|
|||||||
</CommandItem>
|
</CommandItem>
|
||||||
))}
|
))}
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
|
<CommandSeparator className="mb-1.5" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
<CommandGroup heading="Pages / Settings">
|
||||||
|
<CommandItem
|
||||||
|
keywords={['home']}
|
||||||
|
onSelect={() => {
|
||||||
|
navigate('/')
|
||||||
|
setOpen((open) => !open)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LayoutDashboard className="mr-2 h-4 w-4" />
|
||||||
|
<span>Dashboard</span>
|
||||||
|
<CommandShortcut>Page</CommandShortcut>
|
||||||
|
</CommandItem>
|
||||||
|
<CommandItem
|
||||||
|
onSelect={() => {
|
||||||
|
navigate('/settings/general')
|
||||||
|
setOpen((open) => !open)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SettingsIcon className="mr-2 h-4 w-4" />
|
||||||
|
<span>Settings</span>
|
||||||
|
<CommandShortcut>Settings</CommandShortcut>
|
||||||
|
</CommandItem>
|
||||||
|
<CommandItem
|
||||||
|
keywords={['alerts']}
|
||||||
|
onSelect={() => {
|
||||||
|
navigate('/settings/notifications')
|
||||||
|
setOpen((open) => !open)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MailIcon className="mr-2 h-4 w-4" />
|
||||||
|
<span>Notification settings</span>
|
||||||
|
<CommandShortcut>Settings</CommandShortcut>
|
||||||
|
</CommandItem>
|
||||||
|
<CommandItem
|
||||||
|
keywords={['github']}
|
||||||
|
onSelect={() => {
|
||||||
|
window.location.href = 'https://github.com/henrygd/beszel/blob/main/readme.md'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Github className="mr-2 h-4 w-4" />
|
||||||
|
<span>Documentation</span>
|
||||||
|
<CommandShortcut>GitHub</CommandShortcut>
|
||||||
|
</CommandItem>
|
||||||
|
</CommandGroup>
|
||||||
{isAdmin() && (
|
{isAdmin() && (
|
||||||
<>
|
<>
|
||||||
<CommandSeparator />
|
<CommandSeparator className="mb-1.5" />
|
||||||
<CommandGroup heading="Admin">
|
<CommandGroup heading="Admin">
|
||||||
<CommandItem
|
<CommandItem
|
||||||
keywords={['pocketbase']}
|
keywords={['pocketbase']}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ export const $router = createRouter(
|
|||||||
{
|
{
|
||||||
home: '/',
|
home: '/',
|
||||||
server: '/system/:name',
|
server: '/system/:name',
|
||||||
'forgot-password': '/forgot-password',
|
settings: '/settings/:name?',
|
||||||
},
|
},
|
||||||
{ links: false }
|
{ links: false }
|
||||||
)
|
)
|
||||||
@@ -16,7 +16,7 @@ export const navigate = (urlString: string) => {
|
|||||||
|
|
||||||
function onClick(e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) {
|
function onClick(e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
$router.open(new URL((e.target as HTMLAnchorElement).href).pathname)
|
$router.open(new URL((e.currentTarget as HTMLAnchorElement).href).pathname)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Link = (props: React.AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
export const Link = (props: React.AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
||||||
|
|||||||
110
beszel/site/src/components/routes/settings/general.tsx
Normal file
110
beszel/site/src/components/routes/settings/general.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { chartTimeData } from '@/lib/utils'
|
||||||
|
import { Separator } from '@/components/ui/separator'
|
||||||
|
import { LoaderCircleIcon, SaveIcon } from 'lucide-react'
|
||||||
|
import { UserSettings } from '@/types'
|
||||||
|
import { saveSettings } from './layout'
|
||||||
|
import { useState } from 'react'
|
||||||
|
// import { Input } from '@/components/ui/input'
|
||||||
|
|
||||||
|
export default function SettingsProfilePage({ userSettings }: { userSettings: UserSettings }) {
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault()
|
||||||
|
setIsLoading(true)
|
||||||
|
const formData = new FormData(e.target as HTMLFormElement)
|
||||||
|
const data = Object.fromEntries(formData) as Partial<UserSettings>
|
||||||
|
await saveSettings(data)
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-medium mb-2">General</h3>
|
||||||
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||||
|
Change general application options.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Separator className="my-4" />
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
|
{/* <Separator />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="mb-4">
|
||||||
|
<h3 className="mb-1 text-lg font-medium">Language</h3>
|
||||||
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||||
|
Internationalization will be added in a future release. Please see the{' '}
|
||||||
|
<a href="#" className="link" target="_blank">
|
||||||
|
discussion on GitHub
|
||||||
|
</a>{' '}
|
||||||
|
for more details.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Label className="block" htmlFor="lang">
|
||||||
|
Preferred language
|
||||||
|
</Label>
|
||||||
|
<Select defaultValue="en">
|
||||||
|
<SelectTrigger id="lang">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="en">English</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div> */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="mb-4">
|
||||||
|
<h3 className="mb-1 text-lg font-medium">Chart options</h3>
|
||||||
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||||
|
Adjust display options for charts.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Label className="block" htmlFor="chartTime">
|
||||||
|
Default time period
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
name="chartTime"
|
||||||
|
key={userSettings.chartTime}
|
||||||
|
defaultValue={userSettings.chartTime}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="chartTime">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{Object.entries(chartTimeData).map(([value, { label }]) => (
|
||||||
|
<SelectItem key={label} value={value}>
|
||||||
|
{label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-[0.8rem] text-muted-foreground">
|
||||||
|
Sets the default time range for charts when a system is viewed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<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" />
|
||||||
|
)}
|
||||||
|
Save settings
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
98
beszel/site/src/components/routes/settings/layout.tsx
Normal file
98
beszel/site/src/components/routes/settings/layout.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { Separator } from '../../ui/separator'
|
||||||
|
import { SidebarNav } from './sidebar-nav.tsx'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card.tsx'
|
||||||
|
import { useStore } from '@nanostores/react'
|
||||||
|
import { $router } from '@/components/router.tsx'
|
||||||
|
import { redirectPage } from '@nanostores/router'
|
||||||
|
import { BellIcon, SettingsIcon } from 'lucide-react'
|
||||||
|
import { $userSettings, pb } from '@/lib/stores.ts'
|
||||||
|
import { toast } from '@/components/ui/use-toast.ts'
|
||||||
|
import { UserSettings } from '@/types.js'
|
||||||
|
import General from './general.tsx'
|
||||||
|
import Notifications from './notifications.tsx'
|
||||||
|
|
||||||
|
const sidebarNavItems = [
|
||||||
|
{
|
||||||
|
title: 'General',
|
||||||
|
href: '/settings/general',
|
||||||
|
icon: SettingsIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Notifications',
|
||||||
|
href: '/settings/notifications',
|
||||||
|
icon: BellIcon,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export async function saveSettings(newSettings: Partial<UserSettings>) {
|
||||||
|
try {
|
||||||
|
// get fresh copy of settings
|
||||||
|
const req = await pb.collection('user_settings').getFirstListItem('', {
|
||||||
|
fields: 'id,settings',
|
||||||
|
})
|
||||||
|
// update user settings
|
||||||
|
const updatedSettings = await pb.collection('user_settings').update(req.id, {
|
||||||
|
settings: {
|
||||||
|
...req.settings,
|
||||||
|
...newSettings,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
$userSettings.set(updatedSettings.settings)
|
||||||
|
toast({
|
||||||
|
title: 'Settings saved',
|
||||||
|
description: 'Your user settings have been updated.',
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
// console.error('update settings', e)
|
||||||
|
toast({
|
||||||
|
title: 'Failed to save settings',
|
||||||
|
description: 'Check logs for more details.',
|
||||||
|
variant: 'destructive',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SettingsLayout() {
|
||||||
|
const page = useStore($router)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = 'Settings / Beszel'
|
||||||
|
// redirect to account page if no page is specified
|
||||||
|
if (page?.path === '/settings') {
|
||||||
|
redirectPage($router, 'settings', { name: 'general' })
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="pt-5 px-4 pb-8 sm:pt-6 sm:px-7">
|
||||||
|
<CardHeader className="p-0">
|
||||||
|
<CardTitle className="mb-1">Settings</CardTitle>
|
||||||
|
<CardDescription>Manage display and notification preferences.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<Separator className="hidden md:block my-5" />
|
||||||
|
<div className="flex flex-col gap-3.5 md:flex-row md:gap-5 lg:gap-10">
|
||||||
|
<aside className="md:w-48 w-full">
|
||||||
|
<SidebarNav items={sidebarNavItems} />
|
||||||
|
</aside>
|
||||||
|
<div className="flex-1">
|
||||||
|
{/* @ts-ignore */}
|
||||||
|
<SettingsContent name={page?.params?.name ?? 'general'} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SettingsContent({ name }: { name: string }) {
|
||||||
|
const userSettings = useStore($userSettings)
|
||||||
|
|
||||||
|
switch (name) {
|
||||||
|
case 'general':
|
||||||
|
return <General userSettings={userSettings} />
|
||||||
|
case 'notifications':
|
||||||
|
return <Notifications userSettings={userSettings} />
|
||||||
|
}
|
||||||
|
}
|
||||||
233
beszel/site/src/components/routes/settings/notifications.tsx
Normal file
233
beszel/site/src/components/routes/settings/notifications.tsx
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { pb } from '@/lib/stores'
|
||||||
|
import { Separator } from '@/components/ui/separator'
|
||||||
|
import { Card } from '@/components/ui/card'
|
||||||
|
import { BellIcon, LoaderCircleIcon, PlusIcon, SaveIcon, Trash2Icon } from 'lucide-react'
|
||||||
|
import { ChangeEventHandler, useEffect, useState } from 'react'
|
||||||
|
import { toast } from '@/components/ui/use-toast'
|
||||||
|
import { InputTags } from '@/components/ui/input-tags'
|
||||||
|
import { UserSettings } from '@/types'
|
||||||
|
import { saveSettings } from './layout'
|
||||||
|
import * as v from 'valibot'
|
||||||
|
import { isAdmin } from '@/lib/utils'
|
||||||
|
|
||||||
|
interface ShoutrrrUrlCardProps {
|
||||||
|
url: string
|
||||||
|
onUrlChange: ChangeEventHandler<HTMLInputElement>
|
||||||
|
onRemove: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const NotificationSchema = v.object({
|
||||||
|
emails: v.array(v.pipe(v.string(), v.email())),
|
||||||
|
webhooks: v.array(v.pipe(v.string(), v.url())),
|
||||||
|
})
|
||||||
|
|
||||||
|
const SettingsNotificationsPage = ({ userSettings }: { userSettings: UserSettings }) => {
|
||||||
|
const [webhooks, setWebhooks] = useState(userSettings.webhooks ?? [])
|
||||||
|
const [emails, setEmails] = useState<string[]>(userSettings.emails ?? [])
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
|
||||||
|
// update values when userSettings changes
|
||||||
|
useEffect(() => {
|
||||||
|
setWebhooks(userSettings.webhooks ?? [])
|
||||||
|
setEmails(userSettings.emails ?? [])
|
||||||
|
}, [userSettings])
|
||||||
|
|
||||||
|
function addWebhook() {
|
||||||
|
setWebhooks([...webhooks, ''])
|
||||||
|
// focus on the new input
|
||||||
|
queueMicrotask(() => {
|
||||||
|
const inputs = document.querySelectorAll('#webhooks input') as NodeListOf<HTMLInputElement>
|
||||||
|
inputs[inputs.length - 1]?.focus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const removeWebhook = (index: number) => setWebhooks(webhooks.filter((_, i) => i !== index))
|
||||||
|
|
||||||
|
function updateWebhook(index: number, value: string) {
|
||||||
|
const newWebhooks = [...webhooks]
|
||||||
|
newWebhooks[index] = value
|
||||||
|
setWebhooks(newWebhooks)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateSettings() {
|
||||||
|
setIsLoading(true)
|
||||||
|
try {
|
||||||
|
const parsedData = v.parse(NotificationSchema, { emails, webhooks })
|
||||||
|
await saveSettings(parsedData)
|
||||||
|
} catch (e: any) {
|
||||||
|
toast({
|
||||||
|
title: 'Failed to save settings',
|
||||||
|
description: e.message,
|
||||||
|
variant: 'destructive',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-medium mb-2">Notifications</h3>
|
||||||
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||||
|
Configure how you receive alert notifications.
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1.5 leading-relaxed">
|
||||||
|
Looking instead for where to create alerts? Click the bell{' '}
|
||||||
|
<BellIcon className="inline h-4 w-4" /> icons in the systems table.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Separator className="my-4" />
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="mb-4">
|
||||||
|
<h3 className="mb-1 text-lg font-medium">Email notifications</h3>
|
||||||
|
{isAdmin() && (
|
||||||
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||||
|
Please{' '}
|
||||||
|
<a href="/_/#/settings/mail" className="link" target="_blank">
|
||||||
|
configure an SMTP server
|
||||||
|
</a>{' '}
|
||||||
|
to ensure alerts are delivered.{' '}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Label className="block" htmlFor="email">
|
||||||
|
To email(s)
|
||||||
|
</Label>
|
||||||
|
<InputTags
|
||||||
|
value={emails}
|
||||||
|
onChange={setEmails}
|
||||||
|
placeholder="Enter email address..."
|
||||||
|
className="w-full"
|
||||||
|
type="email"
|
||||||
|
id="email"
|
||||||
|
/>
|
||||||
|
<p className="text-[0.8rem] text-muted-foreground">
|
||||||
|
Save address using enter key or comma. Leave blank to disable email notifications.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="mb-1 text-lg font-medium">Webhook / Push notifications</h3>
|
||||||
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||||
|
Beszel uses{' '}
|
||||||
|
<a
|
||||||
|
href="https://containrrr.dev/shoutrrr/services/overview/"
|
||||||
|
target="_blank"
|
||||||
|
className="link"
|
||||||
|
>
|
||||||
|
Shoutrrr
|
||||||
|
</a>{' '}
|
||||||
|
to integrate with popular notification services.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{webhooks.length > 0 && (
|
||||||
|
<div className="grid gap-2.5" id="webhooks">
|
||||||
|
{webhooks.map((webhook, index) => (
|
||||||
|
<ShoutrrrUrlCard
|
||||||
|
key={index}
|
||||||
|
url={webhook}
|
||||||
|
onUrlChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
|
updateWebhook(index, e.target.value)
|
||||||
|
}
|
||||||
|
onRemove={() => removeWebhook(index)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="mt-2 flex items-center gap-1"
|
||||||
|
onClick={addWebhook}
|
||||||
|
>
|
||||||
|
<PlusIcon className="h-4 w-4 -ml-0.5" />
|
||||||
|
Add URL
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-1.5 disabled:opacity-100"
|
||||||
|
onClick={updateSettings}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<LoaderCircleIcon className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<SaveIcon className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Save settings
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ShoutrrrUrlCard = ({ url, onUrlChange, onRemove }: ShoutrrrUrlCardProps) => {
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
|
||||||
|
const sendTestNotification = async () => {
|
||||||
|
setIsLoading(true)
|
||||||
|
const res = await pb.send('/api/beszel/send-test-notification', { url })
|
||||||
|
if ('err' in res && !res.err) {
|
||||||
|
toast({
|
||||||
|
title: 'Test notification sent',
|
||||||
|
description: 'Check your notification service',
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: res.err ?? 'Failed to send test notification',
|
||||||
|
variant: 'destructive',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-muted/30 p-2 md:p-3">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Input
|
||||||
|
type="url"
|
||||||
|
className="light:bg-card"
|
||||||
|
required
|
||||||
|
placeholder="generic://webhook.site/xxxxxx"
|
||||||
|
value={url}
|
||||||
|
onChange={onUrlChange}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="w-20 md:w-28"
|
||||||
|
disabled={isLoading || url === ''}
|
||||||
|
onClick={sendTestNotification}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<LoaderCircleIcon className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<span>
|
||||||
|
Test <span className="hidden md:inline">URL</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="shrink-0"
|
||||||
|
aria-label="Delete"
|
||||||
|
onClick={onRemove}
|
||||||
|
>
|
||||||
|
<Trash2Icon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SettingsNotificationsPage
|
||||||
68
beszel/site/src/components/routes/settings/sidebar-nav.tsx
Normal file
68
beszel/site/src/components/routes/settings/sidebar-nav.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { buttonVariants } from '../../ui/button'
|
||||||
|
import { $router, Link, navigate } from '../../router'
|
||||||
|
import { useStore } from '@nanostores/react'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { Separator } from '@/components/ui/separator'
|
||||||
|
|
||||||
|
interface SidebarNavProps extends React.HTMLAttributes<HTMLElement> {
|
||||||
|
items: {
|
||||||
|
href: string
|
||||||
|
title: string
|
||||||
|
icon?: React.FC<React.SVGProps<SVGSVGElement>>
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SidebarNav({ className, items, ...props }: SidebarNavProps) {
|
||||||
|
const page = useStore($router)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Mobile View */}
|
||||||
|
<div className="md:hidden">
|
||||||
|
<Select onValueChange={(value: string) => navigate(value)} value={page?.path}>
|
||||||
|
<SelectTrigger className="w-full my-3.5">
|
||||||
|
<SelectValue placeholder="Select a page" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{items.map((item) => (
|
||||||
|
<SelectItem key={item.href} value={item.href}>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
{item.icon && <item.icon className="h-4 w-4" />}
|
||||||
|
{item.title}
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Separator />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop View */}
|
||||||
|
<nav className={cn('hidden md:grid gap-1', className)} {...props}>
|
||||||
|
{items.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({ variant: 'ghost' }),
|
||||||
|
'flex items-center gap-3',
|
||||||
|
page?.path === item.href ? 'bg-muted hover:bg-muted' : 'hover:bg-muted/50',
|
||||||
|
'justify-start'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.icon && <item.icon className="h-4 w-4" />}
|
||||||
|
{item.title}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,17 +1,10 @@
|
|||||||
import { $systems, pb, $chartTime, $containerFilter } from '@/lib/stores'
|
import { $systems, pb, $chartTime, $containerFilter, $userSettings } from '@/lib/stores'
|
||||||
import { ContainerStatsRecord, SystemRecord, SystemStatsRecord } from '@/types'
|
import { ContainerStatsRecord, SystemRecord, SystemStatsRecord } from '@/types'
|
||||||
import { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '../ui/card'
|
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '../ui/card'
|
||||||
import { useStore } from '@nanostores/react'
|
import { useStore } from '@nanostores/react'
|
||||||
import Spinner from '../spinner'
|
import Spinner from '../spinner'
|
||||||
import {
|
import { ClockArrowUp, CpuIcon, GlobeIcon, LayoutGridIcon, MonitorIcon, XIcon } from 'lucide-react'
|
||||||
ClockArrowUp,
|
|
||||||
CpuIcon,
|
|
||||||
GlobeIcon,
|
|
||||||
LayoutGridIcon,
|
|
||||||
StretchHorizontalIcon,
|
|
||||||
XIcon,
|
|
||||||
} from 'lucide-react'
|
|
||||||
import ChartTimeSelect from '../charts/chart-time-select'
|
import ChartTimeSelect from '../charts/chart-time-select'
|
||||||
import {
|
import {
|
||||||
chartTimeData,
|
chartTimeData,
|
||||||
@@ -25,6 +18,7 @@ import { scaleTime } from 'd3-scale'
|
|||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip'
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip'
|
||||||
import { Button, buttonVariants } from '../ui/button'
|
import { Button, buttonVariants } from '../ui/button'
|
||||||
import { Input } from '../ui/input'
|
import { Input } from '../ui/input'
|
||||||
|
import { Rows, TuxIcon } from '../ui/icons'
|
||||||
|
|
||||||
const CpuChart = lazy(() => import('../charts/cpu-chart'))
|
const CpuChart = lazy(() => import('../charts/cpu-chart'))
|
||||||
const ContainerCpuChart = lazy(() => import('../charts/container-cpu-chart'))
|
const ContainerCpuChart = lazy(() => import('../charts/container-cpu-chart'))
|
||||||
@@ -36,8 +30,6 @@ const BandwidthChart = lazy(() => import('../charts/bandwidth-chart'))
|
|||||||
const ContainerNetChart = lazy(() => import('../charts/container-net-chart'))
|
const ContainerNetChart = lazy(() => import('../charts/container-net-chart'))
|
||||||
const SwapChart = lazy(() => import('../charts/swap-chart'))
|
const SwapChart = lazy(() => import('../charts/swap-chart'))
|
||||||
const TemperatureChart = lazy(() => import('../charts/temperature-chart'))
|
const TemperatureChart = lazy(() => import('../charts/temperature-chart'))
|
||||||
const ExFsDiskChart = lazy(() => import('../charts/extra-fs-disk-chart'))
|
|
||||||
const ExFsDiskIoChart = lazy(() => import('../charts/extra-fs-disk-io-chart'))
|
|
||||||
|
|
||||||
export default function SystemDetail({ name }: { name: string }) {
|
export default function SystemDetail({ name }: { name: string }) {
|
||||||
const systems = useStore($systems)
|
const systems = useStore($systems)
|
||||||
@@ -46,7 +38,6 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
const [ticks, setTicks] = useState([] as number[])
|
const [ticks, setTicks] = useState([] as number[])
|
||||||
const [system, setSystem] = useState({} as SystemRecord)
|
const [system, setSystem] = useState({} as SystemRecord)
|
||||||
const [systemStats, setSystemStats] = useState([] as SystemStatsRecord[])
|
const [systemStats, setSystemStats] = useState([] as SystemStatsRecord[])
|
||||||
const [hasDockerStats, setHasDocker] = useState(false)
|
|
||||||
const netCardRef = useRef<HTMLDivElement>(null)
|
const netCardRef = useRef<HTMLDivElement>(null)
|
||||||
const [dockerCpuChartData, setDockerCpuChartData] = useState<Record<string, number | string>[]>(
|
const [dockerCpuChartData, setDockerCpuChartData] = useState<Record<string, number | string>[]>(
|
||||||
[]
|
[]
|
||||||
@@ -57,14 +48,15 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
const [dockerNetChartData, setDockerNetChartData] = useState<Record<string, number | number[]>[]>(
|
const [dockerNetChartData, setDockerNetChartData] = useState<Record<string, number | number[]>[]>(
|
||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
|
const hasDockerStats = dockerCpuChartData.length > 0
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = `${name} / Beszel`
|
document.title = `${name} / Beszel`
|
||||||
return () => {
|
return () => {
|
||||||
resetCharts()
|
resetCharts()
|
||||||
$chartTime.set('1h')
|
$chartTime.set($userSettings.get().chartTime)
|
||||||
$containerFilter.set('')
|
$containerFilter.set('')
|
||||||
setHasDocker(false)
|
// setHasDocker(false)
|
||||||
}
|
}
|
||||||
}, [name])
|
}, [name])
|
||||||
|
|
||||||
@@ -148,7 +140,6 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
const expectedInterval = chartTimeData[chartTime].expectedInterval
|
const expectedInterval = chartTimeData[chartTime].expectedInterval
|
||||||
if (containerStats.status === 'fulfilled' && containerStats.value.length) {
|
if (containerStats.status === 'fulfilled' && containerStats.value.length) {
|
||||||
makeContainerData(addEmptyValues(containerStats.value, expectedInterval))
|
makeContainerData(addEmptyValues(containerStats.value, expectedInterval))
|
||||||
setHasDocker(true)
|
|
||||||
}
|
}
|
||||||
if (systemStats.status === 'fulfilled') {
|
if (systemStats.status === 'fulfilled') {
|
||||||
setSystemStats(addEmptyValues(systemStats.value, expectedInterval))
|
setSystemStats(addEmptyValues(systemStats.value, expectedInterval))
|
||||||
@@ -198,13 +189,40 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
setDockerNetChartData(dockerNetData)
|
setDockerNetChartData(dockerNetData)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const uptime = useMemo(() => {
|
// values for system info bar
|
||||||
let uptime = system.info?.u || 0
|
const systemInfo = useMemo(() => {
|
||||||
if (uptime < 172800) {
|
if (!system.info) {
|
||||||
return `${Math.trunc(uptime / 3600)} hours`
|
return []
|
||||||
}
|
}
|
||||||
return `${Math.trunc(system.info?.u / 86400)} days`
|
let uptime: number | string = system.info.u
|
||||||
}, [system.info?.u])
|
if (system.info.u < 172800) {
|
||||||
|
uptime = `${Math.trunc(uptime / 3600)} hours`
|
||||||
|
} else {
|
||||||
|
uptime = `${Math.trunc(system.info?.u / 86400)} days`
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{ value: system.host, Icon: GlobeIcon },
|
||||||
|
{
|
||||||
|
value: system.info.h,
|
||||||
|
Icon: MonitorIcon,
|
||||||
|
label: 'Hostname',
|
||||||
|
// hide if hostname is same as host or name
|
||||||
|
hide: system.info.h === system.host || system.info.h === system.name,
|
||||||
|
},
|
||||||
|
{ value: uptime, Icon: ClockArrowUp, label: 'Uptime' },
|
||||||
|
{ value: system.info.k, Icon: TuxIcon, label: 'Kernel' },
|
||||||
|
{
|
||||||
|
value: `${system.info.m} (${system.info.c}c${system.info.t ? `/${system.info.t}t` : ''})`,
|
||||||
|
Icon: CpuIcon,
|
||||||
|
hide: !system.info.m,
|
||||||
|
},
|
||||||
|
] as {
|
||||||
|
value: string | number | undefined
|
||||||
|
label?: string
|
||||||
|
Icon: any
|
||||||
|
hide?: boolean
|
||||||
|
}[]
|
||||||
|
}, [system.info])
|
||||||
|
|
||||||
/** Space for tooltip if more than 12 containers */
|
/** Space for tooltip if more than 12 containers */
|
||||||
const bottomSpacing = useMemo(() => {
|
const bottomSpacing = useMemo(() => {
|
||||||
@@ -251,32 +269,31 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
</span>
|
</span>
|
||||||
{system.status}
|
{system.status}
|
||||||
</div>
|
</div>
|
||||||
<Separator orientation="vertical" className="h-4 bg-primary/30" />
|
{systemInfo.map(({ value, label, Icon, hide }, i) => {
|
||||||
<div className="flex gap-1.5 items-center">
|
if (hide || !value) {
|
||||||
<GlobeIcon className="h-4 w-4" /> {system.host}
|
return null
|
||||||
</div>
|
}
|
||||||
{system.info?.u && (
|
const content = (
|
||||||
<TooltipProvider>
|
<div className="flex gap-1.5 items-center">
|
||||||
<Tooltip>
|
<Icon className="h-4 w-4" /> {value}
|
||||||
<Separator orientation="vertical" className="h-4 bg-primary/30" />
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<div className="flex gap-1.5">
|
|
||||||
<ClockArrowUp className="h-4 w-4 mt-[1px]" /> {uptime}
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>Uptime</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
)}
|
|
||||||
{system.info?.m && (
|
|
||||||
<>
|
|
||||||
<Separator orientation="vertical" className="h-4 bg-primary/30" />
|
|
||||||
<div className="flex gap-1.5">
|
|
||||||
<CpuIcon className="h-4 w-4 mt-[1px]" />
|
|
||||||
{system.info.m} ({system.info.c}c / {system.info.t}t)
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
)
|
||||||
)}
|
return (
|
||||||
|
<div key={i} className="contents">
|
||||||
|
<Separator orientation="vertical" className="h-4 bg-primary/30" />
|
||||||
|
{label ? (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip delayDuration={150}>
|
||||||
|
<TooltipTrigger asChild>{content}</TooltipTrigger>
|
||||||
|
<TooltipContent>{label}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
) : (
|
||||||
|
content
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="lg:ml-auto flex items-center gap-2 max-sm:-mb-1">
|
<div className="lg:ml-auto flex items-center gap-2 max-sm:-mb-1">
|
||||||
@@ -295,7 +312,7 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
{grid ? (
|
{grid ? (
|
||||||
<LayoutGridIcon className="h-[1.2rem] w-[1.2rem] opacity-85" />
|
<LayoutGridIcon className="h-[1.2rem] w-[1.2rem] opacity-85" />
|
||||||
) : (
|
) : (
|
||||||
<StretchHorizontalIcon className="h-[1.2rem] w-[1.2rem] opacity-85" />
|
<Rows className="h-[1.3rem] w-[1.3rem] opacity-85" />
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
@@ -346,18 +363,21 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
</ChartCard>
|
</ChartCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(systemStats.at(-1)?.stats.s ?? 0) > 0 && (
|
|
||||||
<ChartCard grid={grid} title="Swap Usage" description="Swap space used by the system">
|
|
||||||
<SwapChart ticks={ticks} systemData={systemStats} />
|
|
||||||
</ChartCard>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ChartCard grid={grid} title="Disk Space" description="Usage of root partition">
|
<ChartCard grid={grid} title="Disk Space" description="Usage of root partition">
|
||||||
<DiskChart ticks={ticks} systemData={systemStats} />
|
<DiskChart
|
||||||
|
ticks={ticks}
|
||||||
|
systemData={systemStats}
|
||||||
|
dataKey="stats.du"
|
||||||
|
diskSize={Math.round(systemStats.at(-1)?.stats.d ?? NaN)}
|
||||||
|
/>
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
|
|
||||||
<ChartCard grid={grid} title="Disk I/O" description="Throughput of root filesystem">
|
<ChartCard grid={grid} title="Disk I/O" description="Throughput of root filesystem">
|
||||||
<DiskIoChart ticks={ticks} systemData={systemStats} />
|
<DiskIoChart
|
||||||
|
ticks={ticks}
|
||||||
|
systemData={systemStats}
|
||||||
|
dataKeys={['stats.dw', 'stats.dr']}
|
||||||
|
/>
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
|
|
||||||
<ChartCard
|
<ChartCard
|
||||||
@@ -385,6 +405,12 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{(systemStats.at(-1)?.stats.su ?? 0) > 0 && (
|
||||||
|
<ChartCard grid={grid} title="Swap Usage" description="Swap space used by the system">
|
||||||
|
<SwapChart ticks={ticks} systemData={systemStats} />
|
||||||
|
</ChartCard>
|
||||||
|
)}
|
||||||
|
|
||||||
{systemStats.at(-1)?.stats.t && (
|
{systemStats.at(-1)?.stats.t && (
|
||||||
<ChartCard grid={grid} title="Temperature" description="Temperatures of system sensors">
|
<ChartCard grid={grid} title="Temperature" description="Temperatures of system sensors">
|
||||||
<TemperatureChart ticks={ticks} systemData={systemStats} />
|
<TemperatureChart ticks={ticks} systemData={systemStats} />
|
||||||
@@ -399,18 +425,27 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
return (
|
return (
|
||||||
<div key={extraFsName} className="contents">
|
<div key={extraFsName} className="contents">
|
||||||
<ChartCard
|
<ChartCard
|
||||||
grid={true}
|
grid={grid}
|
||||||
title={`${extraFsName} Usage`}
|
title={`${extraFsName} Usage`}
|
||||||
description={`Disk usage of ${extraFsName}`}
|
description={`Disk usage of ${extraFsName}`}
|
||||||
>
|
>
|
||||||
<ExFsDiskChart ticks={ticks} systemData={systemStats} fs={extraFsName} />
|
<DiskChart
|
||||||
|
ticks={ticks}
|
||||||
|
systemData={systemStats}
|
||||||
|
dataKey={`stats.efs.${extraFsName}.du`}
|
||||||
|
diskSize={Math.round(systemStats.at(-1)?.stats.efs?.[extraFsName].d ?? NaN)}
|
||||||
|
/>
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
<ChartCard
|
<ChartCard
|
||||||
grid={true}
|
grid={grid}
|
||||||
title={`${extraFsName} I/O`}
|
title={`${extraFsName} I/O`}
|
||||||
description={`Throughput of of ${extraFsName}`}
|
description={`Throughput of ${extraFsName}`}
|
||||||
>
|
>
|
||||||
<ExFsDiskIoChart ticks={ticks} systemData={systemStats} fs={extraFsName} />
|
<DiskIoChart
|
||||||
|
ticks={ticks}
|
||||||
|
systemData={systemStats}
|
||||||
|
dataKeys={[`stats.efs.${extraFsName}.w`, `stats.efs.${extraFsName}.r`]}
|
||||||
|
/>
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -324,7 +324,7 @@ export default function SystemsTable({ filter }: { filter?: string }) {
|
|||||||
? 'auto'
|
? 'auto'
|
||||||
: cell.column.getSize(),
|
: cell.column.getSize(),
|
||||||
}}
|
}}
|
||||||
className={'overflow-hidden relative py-2.5'}
|
className={cn('overflow-hidden relative', data.length > 10 ? 'py-2' : 'py-2.5')}
|
||||||
>
|
>
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
@@ -9,12 +9,13 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
import { BellIcon } from 'lucide-react'
|
import { BellIcon } from 'lucide-react'
|
||||||
import { cn, isAdmin } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Switch } from '@/components/ui/switch'
|
import { Switch } from '@/components/ui/switch'
|
||||||
import { AlertRecord, SystemRecord } from '@/types'
|
import { AlertRecord, SystemRecord } from '@/types'
|
||||||
import { lazy, Suspense, useMemo, useState } from 'react'
|
import { lazy, Suspense, useMemo, useState } from 'react'
|
||||||
import { toast } from './ui/use-toast'
|
import { toast } from './ui/use-toast'
|
||||||
|
import { Link } from './router'
|
||||||
|
|
||||||
const Slider = lazy(() => import('./ui/slider'))
|
const Slider = lazy(() => import('./ui/slider'))
|
||||||
|
|
||||||
@@ -49,20 +50,13 @@ export default function AlertsButton({ system }: { system: SystemRecord }) {
|
|||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="max-h-full overflow-auto">
|
<DialogContent className="max-h-full overflow-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="mb-1">Alerts for {system.name}</DialogTitle>
|
<DialogTitle className="text-xl">{system.name} alerts</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription className="mb-1">
|
||||||
{isAdmin() && (
|
See{' '}
|
||||||
<span>
|
<Link href="/settings/notifications" className="link">
|
||||||
Please{' '}
|
notification settings
|
||||||
<a
|
</Link>{' '}
|
||||||
href="/_/#/settings/mail"
|
to configure how you receive alerts.
|
||||||
className="font-medium text-primary opacity-80 hover:opacity-100 duration-100"
|
|
||||||
>
|
|
||||||
configure an SMTP server
|
|
||||||
</a>{' '}
|
|
||||||
to ensure alerts are delivered.{' '}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="grid gap-3">
|
<div className="grid gap-3">
|
||||||
@@ -86,7 +80,7 @@ export default function AlertsButton({ system }: { system: SystemRecord }) {
|
|||||||
alerts={systemAlerts}
|
alerts={systemAlerts}
|
||||||
name="Disk"
|
name="Disk"
|
||||||
title="Disk Usage"
|
title="Disk Usage"
|
||||||
description="Triggers when disk usage exceeds a threshold."
|
description="Triggers when root usage exceeds a threshold."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
36
beszel/site/src/components/ui/badge.tsx
Normal file
36
beszel/site/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||||
|
secondary:
|
||||||
|
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
destructive:
|
||||||
|
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||||
|
outline: "text-foreground",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface BadgeProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
25
beszel/site/src/components/ui/icons.tsx
Normal file
25
beszel/site/src/components/ui/icons.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { SVGProps } from 'react'
|
||||||
|
|
||||||
|
// linux-logo-bold from https://github.com/phosphor-icons/core (MIT license)
|
||||||
|
export function TuxIcon(props: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 256 256" {...props}>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M231 217a12 12 0 0 1-16-2c-2-1-35-44-35-127a52 52 0 1 0-104 0c0 83-33 126-35 127a12 12 0 0 1-18-14c0-1 29-39 29-113a76 76 0 1 1 152 0c0 74 29 112 29 113a12 12 0 0 1-2 16m-127-97a16 16 0 1 0-16-16 16 16 0 0 0 16 16m64-16a16 16 0 1 0-16 16 16 16 0 0 0 16-16m-73 51 28 12a12 12 0 0 0 10 0l28-12a12 12 0 0 0-10-22l-23 10-23-10a12 12 0 0 0-10 22m33 29a57 57 0 0 0-39 15 12 12 0 0 0 17 18 33 33 0 0 1 44 0 12 12 0 1 0 17-18 57 57 0 0 0-39-15"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MingCute Apache License 2.0 https://github.com/Richard9394/MingCute
|
||||||
|
export function Rows(props: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" {...props}>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M5 3a2 2 0 0 0-2 2v4a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2zm0 2h14v4H5zm0 8a2 2 0 0 0-2 2v4a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-4a2 2 0 0 0-2-2zm0 2h14v4H5z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
81
beszel/site/src/components/ui/input-tags.tsx
Normal file
81
beszel/site/src/components/ui/input-tags.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { XIcon } from 'lucide-react'
|
||||||
|
import { type InputProps } from './input'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
type InputTagsProps = Omit<InputProps, 'value' | 'onChange'> & {
|
||||||
|
value: string[]
|
||||||
|
onChange: React.Dispatch<React.SetStateAction<string[]>>
|
||||||
|
}
|
||||||
|
|
||||||
|
const InputTags = React.forwardRef<HTMLInputElement, InputTagsProps>(
|
||||||
|
({ className, value, onChange, ...props }, ref) => {
|
||||||
|
const [pendingDataPoint, setPendingDataPoint] = React.useState('')
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (pendingDataPoint.includes(',')) {
|
||||||
|
const newDataPoints = new Set([
|
||||||
|
...value,
|
||||||
|
...pendingDataPoint.split(',').map((chunk) => chunk.trim()),
|
||||||
|
])
|
||||||
|
onChange(Array.from(newDataPoints))
|
||||||
|
setPendingDataPoint('')
|
||||||
|
}
|
||||||
|
}, [pendingDataPoint, onChange, value])
|
||||||
|
|
||||||
|
const addPendingDataPoint = () => {
|
||||||
|
if (pendingDataPoint) {
|
||||||
|
const newDataPoints = new Set([...value, pendingDataPoint])
|
||||||
|
onChange(Array.from(newDataPoints))
|
||||||
|
setPendingDataPoint('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'bg-background min-h-10 flex w-full flex-wrap gap-2 rounded-md border border-input px-3 py-2 text-sm placeholder:text-muted-foreground has-[:focus-visible]:outline-none ring-offset-background has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring has-[:focus-visible]:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{value.map((item) => (
|
||||||
|
<Badge key={item}>
|
||||||
|
{item}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="ml-2 h-3 w-3"
|
||||||
|
onClick={() => {
|
||||||
|
onChange(value.filter((i) => i !== item))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<XIcon className="w-3" />
|
||||||
|
</Button>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
<input
|
||||||
|
className="flex-1 outline-none bg-background placeholder:text-muted-foreground"
|
||||||
|
value={pendingDataPoint}
|
||||||
|
onChange={(e) => setPendingDataPoint(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ',') {
|
||||||
|
e.preventDefault()
|
||||||
|
addPendingDataPoint()
|
||||||
|
} else if (e.key === 'Backspace' && pendingDataPoint.length === 0 && value.length > 0) {
|
||||||
|
e.preventDefault()
|
||||||
|
onChange(value.slice(0, -1))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
InputTags.displayName = 'InputTags'
|
||||||
|
|
||||||
|
export { InputTags }
|
||||||
@@ -44,7 +44,7 @@ const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTML
|
|||||||
<tr
|
<tr
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'border-b transition-colors hover:bg-muted/40 dark:hover:bg-muted/30 data-[state=selected]:bg-muted',
|
'border-b hover:bg-muted/40 dark:hover:bg-muted/30 data-[state=selected]:bg-muted',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -78,6 +78,9 @@
|
|||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
|
.link {
|
||||||
|
@apply text-primary font-medium underline-offset-4 hover:underline;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.recharts-tooltip-wrapper {
|
.recharts-tooltip-wrapper {
|
||||||
@@ -85,5 +88,5 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.recharts-yAxis {
|
.recharts-yAxis {
|
||||||
font-variant-numeric: tabular-nums;
|
@apply tabular-nums;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import PocketBase from 'pocketbase'
|
import PocketBase from 'pocketbase'
|
||||||
import { atom, WritableAtom } from 'nanostores'
|
import { atom, map, WritableAtom } from 'nanostores'
|
||||||
import { AlertRecord, ChartTimes, SystemRecord } from '@/types'
|
import { AlertRecord, ChartTimes, SystemRecord, UserSettings } from '@/types'
|
||||||
|
|
||||||
/** PocketBase JS Client */
|
/** PocketBase JS Client */
|
||||||
export const pb = new PocketBase('/')
|
export const pb = new PocketBase('/')
|
||||||
@@ -23,6 +23,17 @@ export const $hubVersion = atom('')
|
|||||||
/** Chart time period */
|
/** Chart time period */
|
||||||
export const $chartTime = atom('1h') as WritableAtom<ChartTimes>
|
export const $chartTime = atom('1h') as WritableAtom<ChartTimes>
|
||||||
|
|
||||||
|
/** User settings */
|
||||||
|
export const $userSettings = map<UserSettings>({
|
||||||
|
chartTime: '1h',
|
||||||
|
emails: [pb.authStore.model?.email || ''],
|
||||||
|
})
|
||||||
|
// update local storage on change
|
||||||
|
$userSettings.subscribe((value) => {
|
||||||
|
// console.log('user settings changed', value)
|
||||||
|
$chartTime.set(value.chartTime)
|
||||||
|
})
|
||||||
|
|
||||||
/** Container chart filter */
|
/** Container chart filter */
|
||||||
export const $containerFilter = atom('')
|
export const $containerFilter = atom('')
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { toast } from '@/components/ui/use-toast'
|
import { toast } from '@/components/ui/use-toast'
|
||||||
import { type ClassValue, clsx } from 'clsx'
|
import { type ClassValue, clsx } from 'clsx'
|
||||||
import { twMerge } from 'tailwind-merge'
|
import { twMerge } from 'tailwind-merge'
|
||||||
import { $alerts, $copyContent, $systems, pb } from './stores'
|
import { $alerts, $copyContent, $systems, $userSettings, pb } from './stores'
|
||||||
import { AlertRecord, ChartTimeData, ChartTimes, SystemRecord } from '@/types'
|
import { AlertRecord, ChartTimeData, ChartTimes, SystemRecord } from '@/types'
|
||||||
import { RecordModel, RecordSubscription } from 'pocketbase'
|
import { RecordModel, RecordSubscription } from 'pocketbase'
|
||||||
import { WritableAtom } from 'nanostores'
|
import { WritableAtom } from 'nanostores'
|
||||||
@@ -40,18 +40,14 @@ const verifyAuth = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const updateSystemList = async () => {
|
export const updateSystemList = async () => {
|
||||||
// try {
|
const records = await pb
|
||||||
const records = await pb.collection<SystemRecord>('systems').getFullList({ sort: '+name' })
|
.collection<SystemRecord>('systems')
|
||||||
|
.getFullList({ sort: '+name', fields: 'id,name,host,info,status' })
|
||||||
if (records.length) {
|
if (records.length) {
|
||||||
$systems.set(records)
|
$systems.set(records)
|
||||||
} else {
|
} else {
|
||||||
verifyAuth()
|
verifyAuth()
|
||||||
}
|
}
|
||||||
// }
|
|
||||||
// catch (e) {
|
|
||||||
// console.log('verifying auth error', e)
|
|
||||||
// verifyAuth()
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const updateAlerts = () => {
|
export const updateAlerts = () => {
|
||||||
@@ -182,7 +178,7 @@ export const chartTimeData: ChartTimeData = {
|
|||||||
expectedInterval: 60_000 * 120,
|
expectedInterval: 60_000 * 120,
|
||||||
label: '1 week',
|
label: '1 week',
|
||||||
ticks: 7,
|
ticks: 7,
|
||||||
format: (timestamp: string) => formatShortDate(timestamp),
|
format: (timestamp: string) => formatDay(timestamp),
|
||||||
getOffset: (endTime: Date) => timeDay.offset(endTime, -7),
|
getOffset: (endTime: Date) => timeDay.offset(endTime, -7),
|
||||||
},
|
},
|
||||||
'30d': {
|
'30d': {
|
||||||
@@ -195,22 +191,27 @@ export const chartTimeData: ChartTimeData = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Hacky solution to set the correct width of the yAxis in recharts */
|
/** Sets the correct width of the y axis in recharts based on the longest label */
|
||||||
export function useYaxisWidth(chartRef: React.RefObject<HTMLDivElement>) {
|
export function useYAxisWidth() {
|
||||||
const [yAxisWidth, setYAxisWidth] = useState(180)
|
const [yAxisWidth, setYAxisWidth] = useState(0)
|
||||||
useEffect(() => {
|
let maxChars = 0
|
||||||
let interval = setInterval(() => {
|
let timeout: Timer
|
||||||
// console.log('chartRef', chartRef.current)
|
function updateYAxisWidth(str: string) {
|
||||||
const yAxisElement = chartRef?.current?.querySelector('.yAxis')
|
if (str.length > maxChars) {
|
||||||
if (yAxisElement) {
|
maxChars = str.length
|
||||||
// console.log('yAxisElement', yAxisElement)
|
const div = document.createElement('div')
|
||||||
clearInterval(interval)
|
div.className = 'text-xs tabular-nums tracking-tighter table sr-only'
|
||||||
setYAxisWidth(yAxisElement.getBoundingClientRect().width + 24)
|
div.innerHTML = str
|
||||||
}
|
clearTimeout(timeout)
|
||||||
}, 16)
|
timeout = setTimeout(() => {
|
||||||
return () => clearInterval(interval)
|
document.body.appendChild(div)
|
||||||
}, [])
|
setYAxisWidth(div.offsetWidth + 24)
|
||||||
return yAxisWidth
|
document.body.removeChild(div)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return str
|
||||||
|
}
|
||||||
|
return { yAxisWidth, updateYAxisWidth }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useClampedIsInViewport(options: HookOptions): [boolean | null, CallbackRef] {
|
export function useClampedIsInViewport(options: HookOptions): [boolean | null, CallbackRef] {
|
||||||
@@ -270,3 +271,36 @@ export const useLocalStorage = (key: string, defaultValue: any) => {
|
|||||||
|
|
||||||
return [value, setValue]
|
return [value, setValue]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateUserSettings() {
|
||||||
|
try {
|
||||||
|
const req = await pb.collection('user_settings').getFirstListItem('', { fields: 'settings' })
|
||||||
|
$userSettings.set(req.settings)
|
||||||
|
return
|
||||||
|
} catch (e) {
|
||||||
|
console.log('get settings', e)
|
||||||
|
}
|
||||||
|
// create user settings if error fetching existing
|
||||||
|
try {
|
||||||
|
const createdSettings = await pb
|
||||||
|
.collection('user_settings')
|
||||||
|
.create({ user: pb.authStore.model!.id })
|
||||||
|
$userSettings.set(createdSettings.settings)
|
||||||
|
} catch (e) {
|
||||||
|
console.log('create settings', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the unit of size (TB or GB) for a given size in gigabytes
|
||||||
|
* @param n size in gigabytes
|
||||||
|
* @returns unit of size (TB or GB)
|
||||||
|
*/
|
||||||
|
export const getSizeUnit = (n: number) => (n >= 1_000 ? ' TB' : ' GB')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the value of number in gigabytes if less than 1000, otherwise in terabytes
|
||||||
|
* @param n size in gigabytes
|
||||||
|
* @returns value in GB if less than 1000, otherwise value in TB
|
||||||
|
*/
|
||||||
|
export const getSizeVal = (n: number) => (n >= 1_000 ? n / 1_000 : n)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import './index.css'
|
import './index.css'
|
||||||
import React, { Suspense, lazy, useEffect } from 'react'
|
import { Suspense, lazy, useEffect, StrictMode } from 'react'
|
||||||
import ReactDOM from 'react-dom/client'
|
import ReactDOM from 'react-dom/client'
|
||||||
import Home from './components/routes/home.tsx'
|
import Home from './components/routes/home.tsx'
|
||||||
import { ThemeProvider } from './components/theme-provider.tsx'
|
import { ThemeProvider } from './components/theme-provider.tsx'
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
import { ModeToggle } from './components/mode-toggle.tsx'
|
import { ModeToggle } from './components/mode-toggle.tsx'
|
||||||
import {
|
import {
|
||||||
cn,
|
cn,
|
||||||
|
updateUserSettings,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
isReadOnlyUser,
|
isReadOnlyUser,
|
||||||
updateAlerts,
|
updateAlerts,
|
||||||
@@ -27,6 +28,7 @@ import {
|
|||||||
LogOutIcon,
|
LogOutIcon,
|
||||||
LogsIcon,
|
LogsIcon,
|
||||||
ServerIcon,
|
ServerIcon,
|
||||||
|
SettingsIcon,
|
||||||
UserIcon,
|
UserIcon,
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
@@ -42,7 +44,7 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
DropdownMenuLabel,
|
DropdownMenuLabel,
|
||||||
} from './components/ui/dropdown-menu.tsx'
|
} from './components/ui/dropdown-menu.tsx'
|
||||||
import { $router, Link, navigate } from './components/router.tsx'
|
import { $router, Link } from './components/router.tsx'
|
||||||
import SystemDetail from './components/routes/system.tsx'
|
import SystemDetail from './components/routes/system.tsx'
|
||||||
import { AddSystemButton } from './components/add-system.tsx'
|
import { AddSystemButton } from './components/add-system.tsx'
|
||||||
|
|
||||||
@@ -50,6 +52,7 @@ import { AddSystemButton } from './components/add-system.tsx'
|
|||||||
const CommandPalette = lazy(() => import('./components/command-palette.tsx'))
|
const CommandPalette = lazy(() => import('./components/command-palette.tsx'))
|
||||||
const LoginPage = lazy(() => import('./components/login/login.tsx'))
|
const LoginPage = lazy(() => import('./components/login/login.tsx'))
|
||||||
const CopyToClipboardDialog = lazy(() => import('./components/copy-to-clipboard.tsx'))
|
const CopyToClipboardDialog = lazy(() => import('./components/copy-to-clipboard.tsx'))
|
||||||
|
const Settings = lazy(() => import('./components/routes/settings/layout.tsx'))
|
||||||
|
|
||||||
const App = () => {
|
const App = () => {
|
||||||
const page = useStore($router)
|
const page = useStore($router)
|
||||||
@@ -66,9 +69,10 @@ const App = () => {
|
|||||||
$publicKey.set(data.key)
|
$publicKey.set(data.key)
|
||||||
$hubVersion.set(data.v)
|
$hubVersion.set(data.v)
|
||||||
})
|
})
|
||||||
// get servers / alerts
|
// get servers / alerts / settings
|
||||||
updateSystemList()
|
updateSystemList()
|
||||||
updateAlerts()
|
updateAlerts()
|
||||||
|
updateUserSettings()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// update favicon
|
// update favicon
|
||||||
@@ -99,6 +103,12 @@ const App = () => {
|
|||||||
return <Home />
|
return <Home />
|
||||||
} else if (page.route === 'server') {
|
} else if (page.route === 'server') {
|
||||||
return <SystemDetail name={page.params.name} />
|
return <SystemDetail name={page.params.name} />
|
||||||
|
} else if (page.route === 'settings') {
|
||||||
|
return (
|
||||||
|
<Suspense>
|
||||||
|
<Settings />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,20 +128,19 @@ const Layout = () => {
|
|||||||
<>
|
<>
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<div className="flex items-center h-14 md:h-16 bg-card px-4 pr-3 sm:px-6 border bt-0 rounded-md my-4">
|
<div className="flex items-center h-14 md:h-16 bg-card px-4 pr-3 sm:px-6 border bt-0 rounded-md my-4">
|
||||||
<Link
|
<Link href="/" aria-label="Home" className={'p-2 pl-0'}>
|
||||||
href="/"
|
|
||||||
aria-label="Home"
|
|
||||||
className={'p-2 pl-0'}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
navigate('/')
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Logo className="h-[1.15em] fill-foreground" />
|
<Logo className="h-[1.15em] fill-foreground" />
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className={'flex ml-auto items-center'}>
|
<div className={'flex ml-auto items-center'}>
|
||||||
<ModeToggle />
|
<ModeToggle />
|
||||||
|
<Link
|
||||||
|
href="/settings/general"
|
||||||
|
aria-label="Settings"
|
||||||
|
className={cn('', buttonVariants({ variant: 'ghost', size: 'icon' }))}
|
||||||
|
>
|
||||||
|
<SettingsIcon className="h-[1.2rem] w-[1.2rem]" />
|
||||||
|
</Link>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<button
|
<button
|
||||||
@@ -209,10 +218,10 @@ const Layout = () => {
|
|||||||
ReactDOM.createRoot(document.getElementById('app')!).render(
|
ReactDOM.createRoot(document.getElementById('app')!).render(
|
||||||
// strict mode in dev mounts / unmounts components twice
|
// strict mode in dev mounts / unmounts components twice
|
||||||
// and breaks the clipboard dialog
|
// and breaks the clipboard dialog
|
||||||
//<React.StrictMode>
|
//<StrictMode>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<Layout />
|
<Layout />
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
//</React.StrictMode>
|
//</StrictMode>
|
||||||
)
|
)
|
||||||
|
|||||||
13
beszel/site/src/types.d.ts
vendored
13
beszel/site/src/types.d.ts
vendored
@@ -10,10 +10,14 @@ export interface SystemRecord extends RecordModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SystemInfo {
|
export interface SystemInfo {
|
||||||
|
/** hostname */
|
||||||
|
h: string
|
||||||
|
/** kernel **/
|
||||||
|
k?: string
|
||||||
/** cpu percent */
|
/** cpu percent */
|
||||||
cpu: number
|
cpu: number
|
||||||
/** cpu threads */
|
/** cpu threads */
|
||||||
t: number
|
t?: number
|
||||||
/** cpu cores */
|
/** cpu cores */
|
||||||
c: number
|
c: number
|
||||||
/** cpu model */
|
/** cpu model */
|
||||||
@@ -118,3 +122,10 @@ export interface ChartTimeData {
|
|||||||
getOffset: (endTime: Date) => Date
|
getOffset: (endTime: Date) => Date
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type UserSettings = {
|
||||||
|
// lang?: string
|
||||||
|
chartTime: ChartTimes
|
||||||
|
emails?: string[]
|
||||||
|
webhooks?: string[]
|
||||||
|
}
|
||||||
|
|||||||
@@ -94,5 +94,10 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [require('tailwindcss-animate')],
|
plugins: [
|
||||||
|
require('tailwindcss-animate'),
|
||||||
|
function ({ addVariant }) {
|
||||||
|
addVariant('light', '.light &')
|
||||||
|
},
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package beszel
|
package beszel
|
||||||
|
|
||||||
const (
|
const (
|
||||||
Version = "0.3.0"
|
Version = "0.5.0"
|
||||||
AppName = "beszel"
|
AppName = "beszel"
|
||||||
)
|
)
|
||||||
|
|||||||
120
readme.md
120
readme.md
@@ -9,43 +9,43 @@ A lightweight server resource monitoring hub with historical data, docker stats,
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Lightweight**: Much smaller and less demanding than leading solutions.
|
- **Lightweight**: Smaller and less resource-intensive than leading solutions.
|
||||||
- **Docker stats**: CPU and memory usage history for each container.
|
- **Simple**: Easy setup, no need for public internet exposure.
|
||||||
- **Alerts**: Configurable alerts for CPU, memory, and disk usage, and system status.
|
- **Docker stats**: Tracks CPU, memory, and network usage history for each container.
|
||||||
- **Multi-user**: Each user has their own systems. Admins can share systems across users.
|
- **Alerts**: Configurable alerts for CPU, memory, disk usage, and system status.
|
||||||
- **Simple**: Easy setup and doesn't require anything to be publicly available online.
|
- **Multi-user**: Each user manages their own systems. Admins can share systems across users.
|
||||||
- **OAuth / OIDC**: Supports many OAuth2 providers. Password auth can be disabled.
|
- **OAuth / OIDC**: Supports multiple OAuth2 providers. Password authentication can be disabled.
|
||||||
- **Automatic backups**: Save and restore your data to / from disk or S3-compatible storage.
|
- **Automatic backups**: Save and restore data from disk or S3-compatible storage.
|
||||||
- **REST API**: Use your metrics in your own scripts and applications.
|
- **REST API**: Use or update your data in your own scripts and applications.
|
||||||
|
|
||||||
## Introduction
|
## Introduction
|
||||||
|
|
||||||
Beszel has two components: the hub and the agent.
|
Beszel consists of two main components: the hub and the agent.
|
||||||
|
|
||||||
The hub is a web application that provides a dashboard to view and manage your connected systems. It's built on top of [PocketBase](https://pocketbase.io/).
|
- **Hub:** A web application that provides a dashboard for viewing and managing connected systems. Built on [PocketBase](https://pocketbase.io/).
|
||||||
|
|
||||||
The agent runs on each system you want to monitor. It creates a minimal SSH server through which it communicates system metrics to the hub.
|
- **Agent:** Runs on each system you want to monitor, creating a minimal SSH server to communicate system metrics to the hub.
|
||||||
|
|
||||||
## Getting started
|
## Getting started
|
||||||
|
|
||||||
If not using docker, ignore 4-5 and run the agent using the binary instead.
|
If not using docker, skip steps 4-5 and run the agent using the binary.
|
||||||
|
|
||||||
1. Start the hub (see [installation](#installation)).
|
1. Start the hub (see [installation](#installation)).
|
||||||
2. Open http://localhost:8090 and create an admin user.
|
2. Open <http://localhost:8090> and create an admin user.
|
||||||
3. Click "Add system." Enter the name and host of the system you want to monitor.
|
3. Click "Add system." Enter the name and host of the system you want to monitor.
|
||||||
4. Click "Copy docker compose" to copy the agent's docker-compose.yml file to your clipboard.
|
4. Click "Copy docker compose" to copy the agent's docker-compose.yml file to your clipboard.
|
||||||
5. On the agent system, create the compose file and run `docker compose up` to start the agent.
|
5. On the agent system, create the compose file and run `docker compose up` to start the agent.
|
||||||
6. Back in the hub, click the "Add system" button in the dialog to finish adding the system.
|
6. Back in the hub, click the "Add system" button in the dialog to finish adding the system.
|
||||||
|
|
||||||
If all goes well, you should see the system flip to green. If it goes red, check the Logs page, and see [troubleshooting tips](#faq--troubleshooting).
|
If all goes well, the system should flip to green. If it turns red, check the Logs page and refer to [troubleshooting tips](#faq--troubleshooting).
|
||||||
|
|
||||||
### Tutoriel en français
|
### Tutoriel en français
|
||||||
|
|
||||||
Pour le tutoriel en français, consultez https://belginux.com/installer-beszel-avec-docker/
|
Pour le tutoriel en français, consultez <https://belginux.com/installer-beszel-avec-docker/>
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
You may install the hub and agent as single binaries, or by using Docker.
|
You can install the hub and agent as single binaries or using Docker.
|
||||||
|
|
||||||
### Docker
|
### Docker
|
||||||
|
|
||||||
@@ -53,9 +53,9 @@ You may install the hub and agent as single binaries, or by using Docker.
|
|||||||
|
|
||||||
**Agent**: The hub provides compose content for the agent, but you can also reference the example [docker-compose.yml](/supplemental/docker/agent/docker-compose.yml) file.
|
**Agent**: The hub provides compose content for the agent, but you can also reference the example [docker-compose.yml](/supplemental/docker/agent/docker-compose.yml) file.
|
||||||
|
|
||||||
The agent uses the host network mode so it can access network interface stats. This automatically exposes the port, so change the port using an environment variable if you need to.
|
The agent uses host network mode to access network interface stats, which automatically exposes the port. Change the port using an environment variable if needed.
|
||||||
|
|
||||||
If you don't need network stats, remove that line from the compose file and map the port manually.
|
If you don't require network stats, remove that line from the compose file and map the port manually.
|
||||||
|
|
||||||
> **Note**: If disk I/O stats are missing or incorrect, try using the `FILESYSTEM` environment variable ([instructions here](#finding-the-correct-filesystem)). Check agent logs to see the current device being used.
|
> **Note**: If disk I/O stats are missing or incorrect, try using the `FILESYSTEM` environment variable ([instructions here](#finding-the-correct-filesystem)). Check agent logs to see the current device being used.
|
||||||
|
|
||||||
@@ -94,13 +94,14 @@ PORT=45876 KEY="{PASTE_YOUR_KEY}" ./beszel-agent
|
|||||||
|
|
||||||
Use `./beszel update` and `./beszel-agent update` to update to the latest version.
|
Use `./beszel update` and `./beszel-agent update` to update to the latest version.
|
||||||
|
|
||||||
## Environment Variables
|
## Environment variables
|
||||||
|
|
||||||
### Hub
|
### Hub
|
||||||
|
|
||||||
| Name | Default | Description |
|
| Name | Default | Description |
|
||||||
| ----------------------- | ------- | -------------------------------- |
|
| ----------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `DISABLE_PASSWORD_AUTH` | false | Disables password authentication |
|
| `CSP` | unset | Adds a [Content-Security-Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy) header with this value. |
|
||||||
|
| `DISABLE_PASSWORD_AUTH` | false | Disables password authentication. |
|
||||||
|
|
||||||
### Agent
|
### Agent
|
||||||
|
|
||||||
@@ -108,9 +109,14 @@ Use `./beszel update` and `./beszel-agent update` to update to the latest versio
|
|||||||
| ------------------- | ------- | ---------------------------------------------------------------------------------------- |
|
| ------------------- | ------- | ---------------------------------------------------------------------------------------- |
|
||||||
| `DOCKER_HOST` | unset | Overrides the docker host (docker.sock) if using a proxy.[^socket] |
|
| `DOCKER_HOST` | unset | Overrides the docker host (docker.sock) if using a proxy.[^socket] |
|
||||||
| `EXTRA_FILESYSTEMS` | unset | See [Monitoring additional disks / partitions](#monitoring-additional-disks--partitions) |
|
| `EXTRA_FILESYSTEMS` | unset | See [Monitoring additional disks / partitions](#monitoring-additional-disks--partitions) |
|
||||||
| `FILESYSTEM` | unset | Device or partition to use for root disk I/O stats. |
|
| `FILESYSTEM` | unset | Device, partition, or mount point to use for root disk stats. |
|
||||||
| `KEY` | unset | Public SSH key to use for authentication. Provided in hub. |
|
| `KEY` | unset | Public SSH key to use for authentication. Provided in hub. |
|
||||||
|
| `LOG_LEVEL` | info | Logging level. Valid values: "debug", "info", "warn", "error". |
|
||||||
|
| `NICS` | unset | Whitelist of network interfaces to monitor for bandwidth chart. |
|
||||||
| `PORT` | 45876 | Port or address:port to listen on. |
|
| `PORT` | 45876 | Port or address:port to listen on. |
|
||||||
|
| `SENSORS` | unset | Whitelist of temperature sensors to monitor. |
|
||||||
|
|
||||||
|
<!-- | `SYS_SENSORS` | unset | Overrides the sys location for sensors. | -->
|
||||||
|
|
||||||
[^socket]: Beszel only needs access to read container information. For [linuxserver/docker-socket-proxy](https://github.com/linuxserver/docker-socket-proxy) you would set `CONTAINERS=1`.
|
[^socket]: Beszel only needs access to read container information. For [linuxserver/docker-socket-proxy](https://github.com/linuxserver/docker-socket-proxy) you would set `CONTAINERS=1`.
|
||||||
|
|
||||||
@@ -145,20 +151,20 @@ Visit the "Auth providers" page to enable your provider. The redirect / callback
|
|||||||
- Twitter
|
- Twitter
|
||||||
- VK
|
- VK
|
||||||
- Yandex
|
- Yandex
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
## Monitoring additional disks / partitions
|
## Monitoring additional disks, partitions, or remote mounts
|
||||||
|
|
||||||
> [!NOTE]
|
The method for adding additional disks differs depending on your deployment method.
|
||||||
> This feature is new and has been tested on a limited number of systems. Please report any issues.
|
|
||||||
|
|
||||||
You can configure the agent to monitor the usage and I/O of more than one disk or partition. The approach differs depending on the deployment method.
|
|
||||||
|
|
||||||
Use `lsblk` to find the names and mount points of your partitions. If you have trouble, check the agent logs.
|
Use `lsblk` to find the names and mount points of your partitions. If you have trouble, check the agent logs.
|
||||||
|
|
||||||
|
> Note: The charts will use the name of the device or partition if available, and fall back to the folder name. You will not get I/O stats for network mounted drives.
|
||||||
|
|
||||||
### Docker
|
### Docker
|
||||||
|
|
||||||
Mount a folder from the partition's filesystem in the container's `/extra-filesystems` directory, like the example below. The charts will use the name of the device or partition, not the name of the folder.
|
Mount a folder from the target filesystem in the container's `/extra-filesystems` directory. For example:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
volumes:
|
volumes:
|
||||||
@@ -168,10 +174,10 @@ volumes:
|
|||||||
|
|
||||||
### Binary
|
### Binary
|
||||||
|
|
||||||
Set the `EXTRA_FILESYSTEMS` environment variable to a comma-separated list of devices or partitions to monitor. For example:
|
Set the `EXTRA_FILESYSTEMS` environment variable to a comma-separated list of devices, partitions, or mount points to monitor. For example:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
EXTRA_FILESYSTEMS="sdb,sdc1,mmcblk0"
|
EXTRA_FILESYSTEMS="sdb,sdc1,mmcblk0,/mnt/network-share"
|
||||||
```
|
```
|
||||||
|
|
||||||
## REST API
|
## REST API
|
||||||
@@ -180,29 +186,27 @@ Because Beszel is built on PocketBase, you can use the PocketBase [web APIs](htt
|
|||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
The hub and agent communicate over SSH, so they don't need to be exposed to the internet. And the connection won't break if you put your own auth gateway, such as Authelia, in front of the hub.
|
The hub and agent communicate over SSH, so they don't need to be exposed to the internet. Even if you place an external auth gateway, such as Authelia, in front of the hub, it won't disrupt or break the connection between the hub and agent.
|
||||||
|
|
||||||
When the hub is started for the first time, it generates an ED25519 key pair.
|
When the hub is started for the first time, it generates an ED25519 key pair.
|
||||||
|
|
||||||
The agent's SSH server is configured to accept connections only using this key. It does not provide a pseudo-terminal or accept input, so it's not possible to execute commands on the agent even if your private key is compromised.
|
The agent's SSH server is configured to accept connections using this key only. It does not provide a pseudo-terminal or accept input, so it's impossible to execute commands on the agent even if your private key is compromised.
|
||||||
|
|
||||||
## User roles
|
## User roles
|
||||||
|
|
||||||
### Admin
|
### Admin
|
||||||
|
|
||||||
Assumed to have an admin account in PocketBase, so links to backups, SMTP settings, etc., are shown in the hub.
|
Admins have access to additional links in the hub, such as backups, SMTP settings, etc. The first user created is automatically an admin and can log into PocketBase.
|
||||||
|
|
||||||
The first user created automatically becomes an admin and can log into PocketBase.
|
Changing a user's role does not create a PocketBase admin account for them. To do that, go to Settings > Admins in PocketBase and add them manually.
|
||||||
|
|
||||||
Please note that changing a user's role will not create a PocketBase admin account for them. If you want to do that, go to Settings > Admins in PocketBase and add them there.
|
|
||||||
|
|
||||||
### User
|
### User
|
||||||
|
|
||||||
Can create their own systems and alerts. Links to PocketBase settings are not shown in the hub.
|
Users can create their own systems and alerts. Links to PocketBase settings are not shown in the hub.
|
||||||
|
|
||||||
### Read only
|
### Read only
|
||||||
|
|
||||||
Cannot create systems, but can view any system that has been shared with them by an admin. Can create alerts.
|
Read-only users cannot create systems but can view any system shared with them by an admin and create alerts.
|
||||||
|
|
||||||
## FAQ / Troubleshooting
|
## FAQ / Troubleshooting
|
||||||
|
|
||||||
@@ -210,44 +214,44 @@ Cannot create systems, but can view any system that has been shared with them by
|
|||||||
|
|
||||||
Assuming the agent is running, the connection is probably being blocked by a firewall. You have two options:
|
Assuming the agent is running, the connection is probably being blocked by a firewall. You have two options:
|
||||||
|
|
||||||
1. Add an inbound rule to the agent system's firewall(s) to allow TCP connections to the port. Check any active firewalls, like iptables, and in your cloud provider account if applicable.
|
1. Add an inbound rule to the agent system's firewall(s) to allow TCP connections to the port. Check any active firewalls, like iptables, and your cloud provider's firewall settings if applicable.
|
||||||
2. Alternatively, software like [Cloudflare Tunnel](https://www.cloudflare.com/products/tunnel/), [WireGuard](https://www.wireguard.com/), or [Tailscale](https://tailscale.com/) can be used to securely bypass your firewall.
|
2. Alternatively, use software like [Cloudflare Tunnel](https://www.cloudflare.com/products/tunnel/), [WireGuard](https://www.wireguard.com/), or [Tailscale](https://tailscale.com/) to securely bypass your firewall.
|
||||||
|
|
||||||
Connectivity can be tested by running `telnet <agent-ip> <port>`.
|
You can test connectivity by running telnet `<agent-ip> <port>`.
|
||||||
|
|
||||||
### Connecting the hub and agent on the same system using Docker
|
### Connecting the hub and agent on the same system using Docker
|
||||||
|
|
||||||
If using host network mode for the agent but not the hub, you can add your system using the hostname `host.docker.internal`, which resolves to the internal IP address used by the host. See [example docker-compose.yml](/supplemental/docker/same-system/docker-compose.yml).
|
If using host network mode for the agent but not the hub, add your system using the hostname `host.docker.internal`, which resolves to the internal IP address used by the host. See the [example docker-compose.yml](/supplemental/docker/same-system/docker-compose.yml).
|
||||||
|
|
||||||
If using host network for both, you can use `localhost` as the hostname.
|
If using host network mode for both, you can use `localhost` as the hostname.
|
||||||
|
|
||||||
Otherwise you can use the agent's `container_name` as the hostname if both are in the same docker network.
|
Otherwise, use the agent's `container_name` as the hostname if both are in the same Docker network.
|
||||||
|
|
||||||
### Finding the correct filesystem
|
### Finding the correct filesystem
|
||||||
|
|
||||||
The filesystem / device / partition to use for disk I/O stats is specified in the `FILESYSTEM` environment variable.
|
Specify the filesystem/device/partition for root disk stats using the `FILESYSTEM` environment variable.
|
||||||
|
|
||||||
If it's not set, the agent will try to find the partition mounted on `/` and use that. This doesn't seem to work in a container, so it's recommended to set this value. One of the following methods should work (you usually want the option mounted on `/`):
|
If not set, the agent will try to find the partition mounted on `/` and use that. This may not work correctly in a container, so it's recommended to set this value. Use one of the following methods to find the correct filesystem:
|
||||||
|
|
||||||
- Run `lsblk` and choose an option under "NAME"
|
- Run `lsblk` and choose an option under "NAME."
|
||||||
- Run `df -h` and choose an option under "Filesystem"
|
- Run `df -h` and choose an option under "Filesystem."
|
||||||
- Run `sudo fdisk -l` and choose an option under "Device"
|
- Run `sudo fdisk -l` and choose an option under "Device."
|
||||||
|
|
||||||
### Docker container charts are empty or missing
|
### Docker container charts are empty or missing
|
||||||
|
|
||||||
If container charts show empty data, or don't show up at all, you may need to enable cgroup memory accounting. To verify, run `docker stats`. If that also shows zero memory usage, follow this guide to fix:
|
If container charts show empty data or don't appear at all, you may need to enable cgroup memory accounting. To verify, run `docker stats`. If that shows zero memory usage, follow this guide to fix the issue:
|
||||||
|
|
||||||
https://akashrajpurohit.com/blog/resolving-missing-memory-stats-in-docker-stats-on-raspberry-pi/
|
<https://akashrajpurohit.com/blog/resolving-missing-memory-stats-in-docker-stats-on-raspberry-pi/>
|
||||||
|
|
||||||
### Docker containers are not populating reliably
|
### Docker Containers Are Not Populating Reliably
|
||||||
|
|
||||||
Try upgrading your docker version on the agent system. I had this issue on a machine running version 24. It was fixed by upgrading to version 27.
|
Try upgrading your Docker version on the agent system. This issue was observed on a machine running version 24 and was resolved by upgrading to version 27.
|
||||||
|
|
||||||
### Month / week records are not populating reliably
|
### Month / week records are not populating reliably
|
||||||
|
|
||||||
Records for longer time periods are made by averaging stats from the shorter time periods. They require the agent to be running uninterrupted for long enough to get a full set of data.
|
Records for longer time periods are created by averaging stats from shorter periods. The agent must run uninterrupted for a full set of data to populate these records.
|
||||||
|
|
||||||
If you pause / unpause the agent for longer than one minute, the data will be incomplete and the timing for the current interval will reset.
|
Pausing/unpausing the agent for longer than one minute will result in incomplete data, resetting the timing for the current interval.
|
||||||
|
|
||||||
## Compiling
|
## Compiling
|
||||||
|
|
||||||
@@ -294,3 +298,7 @@ GOOS=freebsd GOARCH=arm64 CGO_ENABLED=0 go build -ldflags "-w -s" .
|
|||||||
```
|
```
|
||||||
|
|
||||||
You can see a list of valid options by running `go tool dist list`.
|
You can see a list of valid options by running `go tool dist list`.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Beszel is licensed under the MIT License. See the [LICENSE](LICENSE) file for more details.
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ There are two scripts, one for the hub and one for the agent. You can run either
|
|||||||
|
|
||||||
The install script creates a dedicated user for the service (`beszel`), downloads the latest release, and installs the service.
|
The install script creates a dedicated user for the service (`beszel`), downloads the latest release, and installs the service.
|
||||||
|
|
||||||
|
If you need to edit the service -- for instance, to change an environment variable -- you can edit the file(s) in `/etc/systemd/system/`. Then reload the systemd daemon and restart the service.
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> You need system administrator privileges to run the install script. If you encounter a problem, please [open an issue](https://github.com/henrygd/beszel/issues/new).
|
> You need system administrator privileges to run the install script. If you encounter a problem, please [open an issue](https://github.com/henrygd/beszel/issues/new).
|
||||||
|
|
||||||
@@ -71,6 +73,8 @@ sudo /opt/beszel-agent/beszel-agent update && sudo systemctl restart beszel-agen
|
|||||||
|
|
||||||
## Manual install
|
## Manual install
|
||||||
|
|
||||||
|
### Hub
|
||||||
|
|
||||||
1. Create the system service at `/etc/systemd/system/beszel.service`
|
1. Create the system service at `/etc/systemd/system/beszel.service`
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -97,9 +101,7 @@ sudo systemctl enable beszel.service
|
|||||||
sudo systemctl start beszel.service
|
sudo systemctl start beszel.service
|
||||||
```
|
```
|
||||||
|
|
||||||
## Run the agent as a system service (Linux)
|
### Agent
|
||||||
|
|
||||||
This runs the agent in the background continuously using systemd.
|
|
||||||
|
|
||||||
1. Create the system service at `/etc/systemd/system/beszel-agent.service`
|
1. Create the system service at `/etc/systemd/system/beszel-agent.service`
|
||||||
|
|
||||||
@@ -112,6 +114,7 @@ After=network.target
|
|||||||
# update the values in curly braces below (remove the braces)
|
# update the values in curly braces below (remove the braces)
|
||||||
Environment="PORT={PASTE_YOUR_PORT_HERE}"
|
Environment="PORT={PASTE_YOUR_PORT_HERE}"
|
||||||
Environment="KEY={PASTE_YOUR_KEY_HERE}"
|
Environment="KEY={PASTE_YOUR_KEY_HERE}"
|
||||||
|
# Environment="EXTRA_FILESYSTEMS={sdb}"
|
||||||
ExecStart={/path/to/directory}/beszel-agent
|
ExecStart={/path/to/directory}/beszel-agent
|
||||||
User={YOUR_USERNAME}
|
User={YOUR_USERNAME}
|
||||||
Restart=always
|
Restart=always
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ After=network.target
|
|||||||
[Service]
|
[Service]
|
||||||
Environment="PORT=$PORT"
|
Environment="PORT=$PORT"
|
||||||
Environment="KEY=$KEY"
|
Environment="KEY=$KEY"
|
||||||
|
# Environment="EXTRA_FILESYSTEMS=sdb"
|
||||||
ExecStart=/opt/beszel-agent/beszel-agent
|
ExecStart=/opt/beszel-agent/beszel-agent
|
||||||
User=beszel
|
User=beszel
|
||||||
Restart=always
|
Restart=always
|
||||||
|
|||||||
Reference in New Issue
Block a user