mirror of
https://github.com/henrygd/beszel.git
synced 2025-11-29 16:33:21 +00:00
Compare commits
94 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 | ||
|
|
d2284c3fed | ||
|
|
eb420bef3a | ||
|
|
9cf6c167b0 | ||
|
|
fbc7f79660 | ||
|
|
37170f2bdb | ||
|
|
af4c05e692 | ||
|
|
202a506485 | ||
|
|
aa3866c8ed | ||
|
|
f9c0d0b89d | ||
|
|
ec5b1a833d | ||
|
|
1cfda8fb9f | ||
|
|
2168db6ebd | ||
|
|
e64ef49e97 | ||
|
|
54e0240dd8 | ||
|
|
05f52ad15a | ||
|
|
8ffb3a0cc8 | ||
|
|
953d7cac1e | ||
|
|
1cfd3cdd30 | ||
|
|
b4a3cb9ce6 | ||
|
|
7a6fbc8346 | ||
|
|
76cffb16de | ||
|
|
13f7d016e6 | ||
|
|
7a8dccfc97 | ||
|
|
68824935e9 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -11,3 +11,4 @@ dist
|
||||
beszel/cmd/hub/hub
|
||||
beszel/cmd/agent/agent
|
||||
node_modules
|
||||
package-lock.json
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
agent.NewAgent(pubKey, addr).Run()
|
||||
agent.NewAgent().Run(pubKey, addr)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ COPY internal ./internal
|
||||
|
||||
# Build
|
||||
ARG TARGETOS TARGETARCH
|
||||
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /agent ./cmd/agent
|
||||
RUN CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /agent ./cmd/agent
|
||||
|
||||
# ? -------------------------
|
||||
FROM scratch
|
||||
|
||||
@@ -22,7 +22,7 @@ RUN update-ca-certificates
|
||||
|
||||
# Build
|
||||
ARG TARGETOS TARGETARCH
|
||||
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /beszel ./cmd/hub
|
||||
RUN CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /beszel ./cmd/hub
|
||||
|
||||
# ? -------------------------
|
||||
FROM scratch
|
||||
|
||||
@@ -4,40 +4,42 @@ go 1.22.4
|
||||
|
||||
require (
|
||||
github.com/blang/semver v3.5.1+incompatible
|
||||
github.com/containrrr/shoutrrr v0.8.0
|
||||
github.com/gliderlabs/ssh v0.3.7
|
||||
github.com/goccy/go-json v0.10.3
|
||||
github.com/labstack/echo/v5 v5.0.0-20230722203903-ec5b858dab61
|
||||
github.com/pocketbase/dbx v1.10.1
|
||||
github.com/pocketbase/pocketbase v0.22.19
|
||||
github.com/pocketbase/pocketbase v0.22.21
|
||||
github.com/rhysd/go-github-selfupdate v1.2.3
|
||||
github.com/shirou/gopsutil/v4 v4.24.7
|
||||
github.com/shirou/gopsutil/v4 v4.24.8
|
||||
github.com/spf13/cobra v1.8.1
|
||||
golang.org/x/crypto v0.26.0
|
||||
golang.org/x/crypto v0.27.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/AlecAivazis/survey/v2 v2.3.7 // indirect
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2 v1.30.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/config v1.27.28 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.28 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.12 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.12 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2 v1.31.0 // 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.39 // 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.14 // 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.18 // 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/v4a v1.3.16 // 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/checksum v1.3.18 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.18 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.60.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.22.5 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.5 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.30.4 // indirect
|
||||
github.com/aws/smithy-go v1.20.4 // 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.5 // 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.20 // 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.63.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.23.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.27.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.31.3 // 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/domodwyer/mailyak/v3 v3.6.2 // 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/mousetrap v1.1.0 // 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-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/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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/shoenig/go-m1cpu v0.1.6 // indirect
|
||||
@@ -76,24 +79,25 @@ require (
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
go.opencensus.io v0.24.0 // indirect
|
||||
gocloud.dev v0.39.0 // indirect
|
||||
golang.org/x/image v0.19.0 // indirect
|
||||
golang.org/x/net v0.28.0 // indirect
|
||||
golang.org/x/oauth2 v0.22.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect
|
||||
golang.org/x/image v0.20.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/sys v0.24.0 // indirect
|
||||
golang.org/x/term v0.23.0 // indirect
|
||||
golang.org/x/text v0.17.0 // indirect
|
||||
golang.org/x/sys v0.25.0 // indirect
|
||||
golang.org/x/term v0.24.0 // indirect
|
||||
golang.org/x/text v0.18.0 // indirect
|
||||
golang.org/x/time v0.6.0 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20240716161551-93cc26a95ae9 // indirect
|
||||
google.golang.org/api v0.194.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240822170219-fc7c04adadcd // indirect
|
||||
google.golang.org/grpc v1.65.0 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
|
||||
google.golang.org/api v0.199.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240924160255-9d4c2d233b61 // indirect
|
||||
google.golang.org/grpc v1.67.0 // indirect
|
||||
google.golang.org/protobuf v1.34.2 // indirect
|
||||
modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a // indirect
|
||||
modernc.org/libc v1.59.9 // indirect
|
||||
modernc.org/libc v1.61.0 // indirect
|
||||
modernc.org/mathutil v1.6.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/token v1.1.0 // indirect
|
||||
)
|
||||
|
||||
218
beszel/go.sum
218
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.115.1 h1:Jo0SM9cQnSkYfp44+v+NQXHpcHqlnRJk2qxh6yvxxxQ=
|
||||
cloud.google.com/go v0.115.1/go.mod h1:DuujITeaufu3gL68/lOFIirVNJwQeyf5UXyi+Wbgknc=
|
||||
cloud.google.com/go/auth v0.9.1 h1:+pMtLEV2k0AXKvs/tGZojuj6QaioxfUjOpMsG5Gtx+w=
|
||||
cloud.google.com/go/auth v0.9.1/go.mod h1:Sw8ocT5mhhXxFklyhT12Eiy0ed6tTrPMCJjSI8KhYLk=
|
||||
cloud.google.com/go/auth v0.9.5 h1:4CTn43Eynw40aFVr3GpPqsQponx2jv0BQpjvajsbbzw=
|
||||
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/go.mod h1:jC/jOpwFP6JBxhB3P5Rr0a9HLMC/Pe3eaL4NmdvqPtc=
|
||||
cloud.google.com/go/compute/metadata v0.5.0 h1:Zr0eK8JbFv6+Wi4ilXAR8FJ3wyNdpxHKJNPos6LTZOY=
|
||||
cloud.google.com/go/compute/metadata v0.5.0/go.mod h1:aHnloV2TPI38yx4s9+wAZhHykWvVCfu7hQbF+9CWoiY=
|
||||
cloud.google.com/go/compute v1.14.0 h1:hfm2+FfxVmnRlh6LpB7cg1ZNU+5edAHmW679JePztk0=
|
||||
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/go.mod h1:K8mY0uSXwEXS30KrnVb+j54LB/ntfZu1dr+4zFMNbus=
|
||||
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/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-v2 v1.30.4 h1:frhcagrVNrzmT95RJImMHgabt99vkXGslubDaDagTk8=
|
||||
github.com/aws/aws-sdk-go-v2 v1.30.4/go.mod h1:CT+ZPWXbYrci8chcARI3OmI/qgd+f6WtuLOoaIA8PR0=
|
||||
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.4/go.mod h1:/MQxMqci8tlqDH+pjmoLu1i0tbWCUP1hhyMRuFxpQCw=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.27.28 h1:OTxWGW/91C61QlneCtnD62NLb4W616/NM1jA8LhJqbg=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.27.28/go.mod h1:uzVRVtJSU5EFv6Fu82AoVFKozJi2ZCY6WRCXj06rbvs=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.28 h1:m8+AHY/ND8CMHJnPoH7PJIRakWGa4gbfbxuY9TGTUXM=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.28/go.mod h1:6TF7dSc78ehD1SL6KpRIPKMA1GyyWflIkjqg+qmf4+c=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.12 h1:yjwoSyDZF8Jth+mUk5lSPJCkMC0lMy6FaCD51jm6ayE=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.12/go.mod h1:fuR57fAgMk7ot3WcNQfb6rSEn+SUffl7ri+aa8uKysI=
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.12 h1:i7cJ1izNlox4ka6cvbHPTztYGtbpW4Je/jyQIKOIU4A=
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.12/go.mod h1:lHnam/4CTEVHaANZD54IrpE80VLK+lUU84WEeJ1FJ8M=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.16 h1:TNyt/+X43KJ9IJJMjKfa3bNTiZbUP7DeCxfbTROESwY=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.16/go.mod h1:2DwJF39FlNAUiX5pAc0UNeiz16lK2t7IaFcm0LFHEgc=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.16 h1:jYfy8UPmd+6kJW5YhY0L1/KftReOGxI/4NtVSTh9O/I=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.16/go.mod h1:7ZfEPZxkW42Afq4uQB8H2E2e6ebh6mXTueEpYzjCzcs=
|
||||
github.com/aws/aws-sdk-go-v2 v1.31.0 h1:3V05LbxTSItI5kUqNwhJrrrY1BAXxXt0sN0l72QmG5U=
|
||||
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.5 h1:xDAuZTn4IMm8o1LnBZvmrL8JA1io4o3YWNXgohbf20g=
|
||||
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.39 h1:FCylu78eTGzW1ynHcongXK9YHtoXD5AiiUqq3YfJYjU=
|
||||
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.37 h1:G2aOH01yW8X373JK419THj5QVqu9vKEwxSEsGxihoW0=
|
||||
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.14 h1:C/d03NAmh8C4BZXhuRNboF/DqhBkBCeDiJDcaqIT5pA=
|
||||
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.25 h1:HkpHeZMM39sGtMHVYG1buAg93vhj5d7F81y6G0OAbGc=
|
||||
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.18 h1:kYQ3H1u0ANr9KEKlGs/jTLrBFPo8P8NaH/w7A01NeeM=
|
||||
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.18 h1:Z7IdFUONvTcvS7YuhtVxN99v2cCoHRXOS4mTr0B/pUc=
|
||||
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/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.16 h1:mimdLQkIX1zr8GIPY1ZtALdBQGxcASiBd2MOp8m/dMc=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.16/go.mod h1:YHk6owoSwrIsok+cAH9PENCOGoH5PU2EllX4vLtSrsY=
|
||||
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.4/go.mod h1:Vz1JQXliGcQktFTN/LN6uGppAIRoLBR2bMvIMP0gOjc=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.18 h1:GckUnpm4EJOAio1c8o25a+b3lVfwVzC9gnSBqiiNmZM=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.18/go.mod h1:Br6+bxfG33Dk3ynmkhsW2Z/t9D4+lRqdLDNCKi85w0U=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.18 h1:tJ5RnkHCiSH0jyd6gROjlJtNwov0eGYNz8s8nFcR0jQ=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.18/go.mod h1:++NHzT+nAF7ZPrHPsA+ENvsXkOO8wEu+C6RXltAG4/c=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.16 h1:jg16PhLPUiHIj8zYIW6bqzeQSuHVEiWnGA0Brz5Xv2I=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.16/go.mod h1:Uyk1zE1VVdsHSU7096h/rwnXDzOzYQVl+FNPhPw7ShY=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.60.0 h1:2QXGJvG19QwqXUvgcdoCOZPyLuvZf8LiXPCN4P53TdI=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.60.0/go.mod h1:BSPI0EfnYUuNHPS0uqIo5VrRwzie+Fp+YhQOUs16sKI=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.22.5 h1:zCsFCKvbj25i7p1u94imVoO447I/sFv8qq+lGJhRN0c=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.22.5/go.mod h1:ZeDX1SnKsVlejeuz41GiajjZpRSWR7/42q/EyA/QEiM=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.5 h1:SKvPgvdvmiTWoi0GAJ7AsJfOz3ngVkD/ERbs5pUnHNI=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.5/go.mod h1:20sz31hv/WsPa3HhU3hfrIet2kxM4Pe0r20eBZ20Tac=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.30.4 h1:iAckBT2OeEK/kBDyN/jDtpEExhjeeA/Im2q4X0rJZT8=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.30.4/go.mod h1:vmSqFK+BVIwVpDAGZB3CoCXHzurt4qBE8lf+I/kRTh0=
|
||||
github.com/aws/smithy-go v1.20.4 h1:2HK1zBdPgRbjFOHlfeQZfpC4r72MOb9bZkiFwggKO+4=
|
||||
github.com/aws/smithy-go v1.20.4/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg=
|
||||
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.18/go.mod h1:CUx0G1v3wG6l01tUB+j7Y8kclA8NSqK4ef0YG79a4cg=
|
||||
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.5/go.mod h1:QdZ3OmoIjSX+8D1OPAzPxDfjXASbBMDsz9qvtyIhtik=
|
||||
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.20/go.mod h1:RGW2DDpVc8hu6Y6yG8G5CHVmVOAn1oV8rNKOHRJyswg=
|
||||
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.20/go.mod h1:oAfOFzUB14ltPZj1rWwRc3d/6OgD76R8KlvU3EqM9Fg=
|
||||
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.18/go.mod h1:GVCC2IJNJTmdlyEsSmofEy7EfJncP7DNnXDzRjJ5Keg=
|
||||
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.63.3/go.mod h1:NLTqRLe3pUNu3nTEHI6XlHLKYmc8fbHUdMxAB6+s41Q=
|
||||
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.23.3/go.mod h1:XRlMvmad0ZNL+75C5FYdMvbbLkd6qiqz6foR1nA1PXY=
|
||||
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.27.3/go.mod h1:FnvDM4sfa+isJ3kDXIzAB9GAwVSzFzSy97uZ3IsHo4E=
|
||||
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.31.3/go.mod h1:yMWe0F+XG0DkRZK5ODZhG7BEFYhLXi2dqGsv6tX0cgI=
|
||||
github.com/aws/smithy-go v1.21.0 h1:H7L8dtDRk0P1Qm6y0ji7MCYMQObJ5R9CRpyPhRUkLYA=
|
||||
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/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
|
||||
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/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/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI=
|
||||
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.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.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/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||
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.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||
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/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=
|
||||
@@ -144,8 +150,8 @@ github.com/google/go-github/v30 v30.1.0/go.mod h1:n8jBpHl45a/rlBUtRJMOG4GhNADUQF
|
||||
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
|
||||
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
|
||||
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
|
||||
github.com/google/pprof v0.0.0-20240625030939-27f56978b8b0 h1:e+8XbKB6IMn8A4OAyZccO4pYfB3s7bt6azNIPE7AnPg=
|
||||
github.com/google/pprof v0.0.0-20240625030939-27f56978b8b0/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo=
|
||||
github.com/google/pprof v0.0.0-20240827171923-fa2c70bbbfe5 h1:5iH8iuqE5apketRbSFBy+X1V0o+l+8NF1avt4HWl7cA=
|
||||
github.com/google/pprof v0.0.0-20240827171923-fa2c70bbbfe5/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
|
||||
github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM=
|
||||
github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA=
|
||||
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
@@ -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/wire v0.6.0 h1:HBkoIh4BdSxoyo9PveV8giw7ZsaBOvzWKfcg/6MrVwI=
|
||||
github.com/google/wire v0.6.0/go.mod h1:F4QhpQ9EDIdJ1Mbop/NZBRB+5yrR6qg3BnctaoUk6NA=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw=
|
||||
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/go.mod h1:Z/fvTZXF8/uw7Xu5GuslPw+bplx6SS338j1Is2S+B7A=
|
||||
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/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
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/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
|
||||
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/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/lufia/plan9stats v0.0.0-20240819163618-b1d8f4d146e7 h1:5RK988zAqB3/AN3opGfRpoQgAVqr6/A5+qRTi67VUZY=
|
||||
github.com/lufia/plan9stats v0.0.0-20240819163618-b1d8f4d146e7/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k=
|
||||
github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 h1:7UMa6KCCMjZEMDtTVdcGu0B1GmmC7QJKiCCjyTAWQy0=
|
||||
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.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
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.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
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.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.23 h1:gbShiuAP1W5j9UOksQ06aiiqPMxYecovVGwmTxWtuw0=
|
||||
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-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI=
|
||||
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/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/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/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.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/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
||||
github.com/pocketbase/pocketbase v0.22.19 h1:Hu9J2nsRQIaw8MiDLzE65xUPyMPjf4DcS2f+QmH1G+c=
|
||||
github.com/pocketbase/pocketbase v0.22.19/go.mod h1:0QFvDOOW7ANId78ChZSagyHbmP6CgMxDQrQFXzeaDpA=
|
||||
github.com/pocketbase/pocketbase v0.22.21 h1:DGPCxn6co8VuTV0mton4NFO/ON49XiFMszRr+Mysy48=
|
||||
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/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
@@ -214,8 +227,8 @@ github.com/rhysd/go-github-selfupdate v1.2.3/go.mod h1:mp/N8zj6jFfBQy/XMYoWsmfzx
|
||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/shirou/gopsutil/v4 v4.24.7 h1:V9UGTK4gQ8HvcnPKf6Zt3XHyQq/peaekfxpJ2HSocJk=
|
||||
github.com/shirou/gopsutil/v4 v4.24.7/go.mod h1:0uW/073rP7FYLOkvxolUQM5rMOLTNmRXnFKafpb71rw=
|
||||
github.com/shirou/gopsutil/v4 v4.24.8 h1:pVQjIenQkIhqO81mwTaXjTzOMT7d3TZkf43PlVFHENI=
|
||||
github.com/shirou/gopsutil/v4 v4.24.8/go.mod h1:wE0OrJtj4dG+hYkxqDH3QiBICdKSf04/npcvLLc/oRg=
|
||||
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
|
||||
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
|
||||
github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=
|
||||
@@ -254,34 +267,36 @@ github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
|
||||
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.53.0 h1:9G6E0TXzGFVfTnawRzrPl83iHOAV7L8NJiR8RSGYV1g=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.53.0/go.mod h1:azvtTADFQJA8mX80jIH/akaE7h+dbm/sVuaHqN13w74=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 h1:4K4tsIXefpVJtvA/8srF4V4y0akAoPHkIslgAkjixJA=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0/go.mod h1:jjdQuTGVsXV4vSs+CJ2qYDeDPf9yIJV23qlIzBm73Vg=
|
||||
go.opentelemetry.io/otel v1.28.0 h1:/SqNcYk+idO0CxKEUOtKQClMK/MimZihKYMruSMViUo=
|
||||
go.opentelemetry.io/otel v1.28.0/go.mod h1:q68ijF8Fc8CnMHKyzqL6akLO46ePnjkgfIMIjUIX9z4=
|
||||
go.opentelemetry.io/otel/metric v1.28.0 h1:f0HGvSl1KRAU1DLgLGFjrwVyismPlnuU6JD6bOeuA5Q=
|
||||
go.opentelemetry.io/otel/metric v1.28.0/go.mod h1:Fb1eVBFZmLVTMb6PPohq3TO9IIhUisDsbJoL/+uQW4s=
|
||||
go.opentelemetry.io/otel/trace v1.28.0 h1:GhQ9cUuQGmNDd5BTCP2dAvv75RdMxEfTmYejp+lkx9g=
|
||||
go.opentelemetry.io/otel/trace v1.28.0/go.mod h1:jPyXzNPg6da9+38HEwElrQiHlVMTnVfM3/yv2OlIHaI=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 h1:r6I7RJCN86bpD/FQwedZ0vSixDpwuWREjW9oRMsmqDc=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0/go.mod h1:B9yO6b04uB80CzjedvewuqDhxJxi11s7/GtiGa8bAjI=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
|
||||
go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
|
||||
go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8=
|
||||
go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc=
|
||||
go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8=
|
||||
go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4=
|
||||
go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ=
|
||||
gocloud.dev v0.39.0 h1:EYABYGhAalPUaMrbSKOr5lejxoxvXj99nE8XFtsDgds=
|
||||
gocloud.dev v0.39.0/go.mod h1:drz+VyYNBvrMTW0KZiBAYEdl8lbNZx+OQ7oQvdrFmSQ=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
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-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
|
||||
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
|
||||
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
|
||||
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-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.19.0 h1:D9FX4QWkLfkeqaC62SonffIIuYdOk/UE2XKUBgRIBIQ=
|
||||
golang.org/x/image v0.19.0/go.mod h1:y0zrRqlQRWQ5PXaYCOMLTW2fpsxZ8Qh9I/ohnInJEys=
|
||||
golang.org/x/image v0.20.0 h1:7cVCUjQwfL18gyBJOmYvptfSHS8Fb3YUDtfLIZ7Nbpw=
|
||||
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-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
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.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8=
|
||||
golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0=
|
||||
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-20180826012351-8a410e7b638d/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-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.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE=
|
||||
golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg=
|
||||
golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
|
||||
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-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.22.0 h1:BzDx2FehcG7jJwgWLELCdmLuxk2i+x9UDpSiss2u0ZA=
|
||||
golang.org/x/oauth2 v0.22.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||
golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs=
|
||||
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-20181108010431-42b317875d0f/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.1.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.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
|
||||
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-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.23.0 h1:F6D4vR+EHoL9/sWAWgAR1H2DcHr4PareCbAaCo1RpuU=
|
||||
golang.org/x/term v0.23.0/go.mod h1:DgV24QBUrK6jhZXl+20l6UWznPlwAHm1Q1mGHtydmSk=
|
||||
golang.org/x/term v0.24.0 h1:Mh5cbb+Zk2hqqXNO7S1iTjEphVL+jb8ZWaqh/g+JWkM=
|
||||
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.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.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.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.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
||||
golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
|
||||
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/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
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-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.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg=
|
||||
golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI=
|
||||
golang.org/x/tools v0.25.0 h1:oFU9pkj/iJgs+0DT+VMHrx+oBKs/LJMV+Uvg78sl+fE=
|
||||
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-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20240716161551-93cc26a95ae9 h1:LLhsEBxRTBLuKlQxFBYUOU8xyFgXv6cOTp2HASDlsDk=
|
||||
golang.org/x/xerrors v0.0.0-20240716161551-93cc26a95ae9/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
|
||||
google.golang.org/api v0.194.0 h1:dztZKG9HgtIpbI35FhfuSNR/zmaMVdxNlntHj1sIS4s=
|
||||
google.golang.org/api v0.194.0/go.mod h1:AgvUFdojGANh3vI+P7EVnxj3AISHllxGCJSFmggmnd0=
|
||||
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=
|
||||
google.golang.org/api v0.199.0 h1:aWUXClp+VFJmqE0JPvpZOK3LDQMyFKYIow4etYd9qxs=
|
||||
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.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
@@ -360,19 +375,19 @@ google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCID
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
||||
google.golang.org/genproto v0.0.0-20240814211410-ddb44dafa142 h1:oLiyxGgE+rt22duwci1+TG7bg2/L1LQsXwfjPlmuJA0=
|
||||
google.golang.org/genproto v0.0.0-20240814211410-ddb44dafa142/go.mod h1:G11eXq53iI5Q+kyNOmCvnzBaxEA2Q/Ik5Tj7nqBE8j4=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20240812133136-8ffd90a71988 h1:+/tmTy5zAieooKIXfzDm9KiA3Bv6JBwriRN9LY+yayk=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20240812133136-8ffd90a71988/go.mod h1:4+X6GvPs+25wZKbQq9qyAXrwIRExv7w0Ea6MgZLZiDM=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240822170219-fc7c04adadcd h1:6TEm2ZxXoQmFWFlt1vNxvVOa1Q0dXFQD1m/rYjXmS0E=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240822170219-fc7c04adadcd/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
|
||||
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/googleapis/api v0.0.0-20240814211410-ddb44dafa142 h1:wKguEg1hsxI2/L3hUYrpo1RVi48K+uTyzKqprwLXsb8=
|
||||
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-20240924160255-9d4c2d233b61 h1:N9BgCIAUvn/M+p4NJccWPWb3BWh88+zyL0ll9HgbEeM=
|
||||
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.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||
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.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
|
||||
google.golang.org/grpc v1.65.0 h1:bs/cUb4lp1G5iImFFd3u5ixQzweKizoZJAwBNLR42lc=
|
||||
google.golang.org/grpc v1.65.0/go.mod h1:WgYC2ypjlB0EiQi6wdKixMqukr6lBc0Vo+oOgjrM5ZQ=
|
||||
google.golang.org/grpc v1.67.0 h1:IdH9y6PF5MPSdAntIcpjQ+tXO41pcQsfZV2RxtQgVcw=
|
||||
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-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
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/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.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
|
||||
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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
@@ -398,16 +412,16 @@ honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWh
|
||||
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
|
||||
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
||||
modernc.org/ccgo/v4 v4.20.7 h1:skrinQsjxWfvj6nbC3ztZPJy+NuwmB3hV9zX/pthNYQ=
|
||||
modernc.org/ccgo/v4 v4.20.7/go.mod h1:UOkI3JSG2zT4E2ioHlncSOZsXbuDCZLvPi3uMlZT5GY=
|
||||
modernc.org/ccgo/v4 v4.21.0 h1:kKPI3dF7RIag8YcToh5ZwDcVMIv6VGa0ED5cvh0LMW4=
|
||||
modernc.org/ccgo/v4 v4.21.0/go.mod h1:h6kt6H/A2+ew/3MW/p6KEoQmrq/i3pr0J/SiwiaF/g0=
|
||||
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
||||
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
||||
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/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/libc v1.59.9 h1:k+nNDDakwipimgmJ1D9H466LhFeSkaPPycAs1OZiDmY=
|
||||
modernc.org/libc v1.59.9/go.mod h1:EY/egGEU7Ju66eU6SBqCNYaFUDuc4npICkMWnU5EE3A=
|
||||
modernc.org/libc v1.61.0 h1:eGFcvWpqlnoGwzZeZe3PWJkkKbM/3SUGyk1DVZQ0TpE=
|
||||
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/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
||||
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/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
|
||||
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
|
||||
modernc.org/sqlite v1.32.0 h1:6BM4uGza7bWypsw4fdLRsLxut6bHe4c58VeqjRgST8s=
|
||||
modernc.org/sqlite v1.32.0/go.mod h1:UqoylwmTb9F+IqXERT8bW9zzOWN8qwAIcLdzeBZs4hA=
|
||||
modernc.org/sqlite v1.33.1 h1:trb6Z3YYoeM9eDL1O8do81kP+0ejv+YzgyFo+Gwy0nM=
|
||||
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/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
|
||||
@@ -1,518 +1,101 @@
|
||||
// Package agent handles the agent's SSH server and system stats collection.
|
||||
package agent
|
||||
|
||||
import (
|
||||
"beszel"
|
||||
"beszel/internal/entities/container"
|
||||
"beszel/internal/entities/system"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"math"
|
||||
"net"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"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"
|
||||
"github.com/shirou/gopsutil/v4/sensors"
|
||||
|
||||
sshServer "github.com/gliderlabs/ssh"
|
||||
psutilNet "github.com/shirou/gopsutil/v4/net"
|
||||
"github.com/shirou/gopsutil/v4/common"
|
||||
)
|
||||
|
||||
type Agent struct {
|
||||
addr string
|
||||
pubKey []byte
|
||||
sem chan struct{}
|
||||
containerStatsMap map[string]*container.PrevContainerStats
|
||||
containerStatsMutex *sync.Mutex
|
||||
diskIoStats *system.DiskIoStats
|
||||
netIoStats *system.NetIoStats
|
||||
dockerClient *http.Client
|
||||
containerStatsPool *sync.Pool
|
||||
bufferPool *sync.Pool
|
||||
hostname string // Hostname of the system
|
||||
kernelVersion string // Kernel version of the system
|
||||
cpuModel string // CPU model of the system
|
||||
cores int // Number of cores of the system
|
||||
threads int // Number of threads of the system
|
||||
debug bool // true if LOG_LEVEL is set to debug
|
||||
fsNames []string // List of filesystem device names being monitored
|
||||
fsStats map[string]*system.FsStats // Keeps track of disk stats for each filesystem
|
||||
netInterfaces map[string]struct{} // Stores all valid network interfaces
|
||||
netIoStats system.NetIoStats // Keeps track of bandwidth usage
|
||||
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{
|
||||
addr: addr,
|
||||
pubKey: pubKey,
|
||||
sem: make(chan struct{}, 15),
|
||||
containerStatsMap: make(map[string]*container.PrevContainerStats),
|
||||
containerStatsMutex: &sync.Mutex{},
|
||||
diskIoStats: &system.DiskIoStats{},
|
||||
netIoStats: &system.NetIoStats{},
|
||||
containerStatsMap: make(map[string]*container.Stats),
|
||||
containerStatsMutex: sync.RWMutex{},
|
||||
netIoStats: system.NetIoStats{},
|
||||
dockerClient: newDockerClient(),
|
||||
containerStatsPool: &sync.Pool{
|
||||
New: func() interface{} {
|
||||
return new(container.Stats)
|
||||
},
|
||||
},
|
||||
bufferPool: &sync.Pool{
|
||||
New: func() interface{} {
|
||||
return new(bytes.Buffer)
|
||||
},
|
||||
},
|
||||
sensorsContext: context.Background(),
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) acquireSemaphore() {
|
||||
a.sem <- struct{}{}
|
||||
}
|
||||
|
||||
func (a *Agent) releaseSemaphore() {
|
||||
<-a.sem
|
||||
}
|
||||
|
||||
func (a *Agent) getSystemStats() (*system.Info, *system.Stats) {
|
||||
systemStats := &system.Stats{}
|
||||
|
||||
// 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
|
||||
if d, err := disk.Usage("/"); err == nil {
|
||||
systemStats.Disk = bytesToGigabytes(d.Total)
|
||||
systemStats.DiskUsed = bytesToGigabytes(d.Used)
|
||||
systemStats.DiskPct = twoDecimals(d.UsedPercent)
|
||||
}
|
||||
|
||||
// disk i/o
|
||||
if io, err := disk.IOCounters(a.diskIoStats.Filesystem); err == nil {
|
||||
for _, d := range io {
|
||||
// add to systemStats
|
||||
secondsElapsed := time.Since(a.diskIoStats.Time).Seconds()
|
||||
readPerSecond := float64(d.ReadBytes-a.diskIoStats.Read) / secondsElapsed
|
||||
systemStats.DiskRead = bytesToMegabytes(readPerSecond)
|
||||
writePerSecond := float64(d.WriteBytes-a.diskIoStats.Write) / secondsElapsed
|
||||
systemStats.DiskWrite = bytesToMegabytes(writePerSecond)
|
||||
// update diskIoStats
|
||||
a.diskIoStats.Time = time.Now()
|
||||
a.diskIoStats.Read = d.ReadBytes
|
||||
a.diskIoStats.Write = d.WriteBytes
|
||||
func (a *Agent) Run(pubKey []byte, addr string) {
|
||||
// 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) {
|
||||
case "debug":
|
||||
a.debug = true
|
||||
slog.SetLogLoggerLevel(slog.LevelDebug)
|
||||
case "warn":
|
||||
slog.SetLogLoggerLevel(slog.LevelWarn)
|
||||
case "error":
|
||||
slog.SetLogLoggerLevel(slog.LevelError)
|
||||
}
|
||||
}
|
||||
|
||||
// network stats
|
||||
if netIO, err := psutilNet.IOCounters(true); err == nil {
|
||||
bytesSent := uint64(0)
|
||||
bytesRecv := uint64(0)
|
||||
for _, v := range netIO {
|
||||
if skipNetworkInterface(&v) {
|
||||
continue
|
||||
}
|
||||
// log.Printf("%+v: %+v recv, %+v sent\n", v.Name, v.BytesRecv, v.BytesSent)
|
||||
bytesSent += v.BytesSent
|
||||
bytesRecv += v.BytesRecv
|
||||
}
|
||||
// add to systemStats
|
||||
secondsElapsed := time.Since(a.netIoStats.Time).Seconds()
|
||||
sentPerSecond := float64(bytesSent-a.netIoStats.BytesSent) / secondsElapsed
|
||||
recvPerSecond := float64(bytesRecv-a.netIoStats.BytesRecv) / secondsElapsed
|
||||
systemStats.NetworkSent = bytesToMegabytes(sentPerSecond)
|
||||
systemStats.NetworkRecv = bytesToMegabytes(recvPerSecond)
|
||||
// update netIoStats
|
||||
a.netIoStats.BytesSent = bytesSent
|
||||
a.netIoStats.BytesRecv = bytesRecv
|
||||
a.netIoStats.Time = time.Now()
|
||||
// Set sensors context (allows overriding sys location for sensors)
|
||||
if sysSensors, exists := os.LookupEnv("SYS_SENSORS"); exists {
|
||||
slog.Info("SYS_SENSORS", "path", sysSensors)
|
||||
a.sensorsContext = context.WithValue(a.sensorsContext,
|
||||
common.EnvKey, common.EnvMap{common.HostSysEnvKey: sysSensors},
|
||||
)
|
||||
}
|
||||
|
||||
// temperatures
|
||||
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))
|
||||
|
||||
// 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)
|
||||
go func() {
|
||||
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
|
||||
}
|
||||
}
|
||||
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)
|
||||
// Set sensors whitelist
|
||||
if sensors, exists := os.LookupEnv("SENSORS"); exists {
|
||||
a.sensorsWhitelist = make(map[string]struct{})
|
||||
for _, sensor := range strings.Split(sensors, ",") {
|
||||
a.sensorsWhitelist[sensor] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
return containerStats, nil
|
||||
a.initializeSystemInfo()
|
||||
a.initializeDiskInfo()
|
||||
a.initializeNetIoStats()
|
||||
|
||||
a.startServer(pubKey, addr)
|
||||
}
|
||||
|
||||
func (a *Agent) getContainerStats(ctr *container.ApiInfo) (*container.Stats, error) {
|
||||
// use semaphore to limit concurrency
|
||||
a.acquireSemaphore()
|
||||
defer a.releaseSemaphore()
|
||||
|
||||
resp, err := a.dockerClient.Get("http://localhost/containers/" + ctr.IdShort + "/stats?stream=0&one-shot=1")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// get a buffer from the pool
|
||||
buf := a.bufferPool.Get().(*bytes.Buffer)
|
||||
defer a.bufferPool.Put(buf)
|
||||
buf.Reset()
|
||||
// read the response body into the buffer
|
||||
_, err = io.Copy(buf, resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// unmarshal the json data from the buffer
|
||||
var statsJson container.ApiStats
|
||||
if err := json.Unmarshal(buf.Bytes(), &statsJson); err != nil {
|
||||
return nil, 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 nil, 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 nil, 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 {
|
||||
systemInfo, systemStats := a.getSystemStats()
|
||||
systemData := &system.CombinedData{
|
||||
Stats: systemStats,
|
||||
func (a *Agent) gatherStats() system.CombinedData {
|
||||
systemInfo, SystemStats := a.getSystemStats()
|
||||
systemData := system.CombinedData{
|
||||
Stats: SystemStats,
|
||||
Info: systemInfo,
|
||||
}
|
||||
// add docker stats
|
||||
if containerStats, err := a.getDockerStats(); err == nil {
|
||||
systemData.Containers = containerStats
|
||||
}
|
||||
// fmt.Printf("%+v\n", systemData)
|
||||
// add extra filesystems
|
||||
systemData.Stats.ExtraFs = make(map[string]*system.FsStats)
|
||||
for name, stats := range a.fsStats {
|
||||
if !stats.Root && stats.DiskTotal > 0 {
|
||||
systemData.Stats.ExtraFs[name] = stats
|
||||
}
|
||||
}
|
||||
return systemData
|
||||
}
|
||||
|
||||
// return container stats to pool
|
||||
func (a *Agent) returnStatsToPool(containerStats []*container.Stats) {
|
||||
for _, stats := range containerStats {
|
||||
a.containerStatsPool.Put(stats)
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
defer a.returnStatsToPool(stats.Containers)
|
||||
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() {
|
||||
if filesystem, exists := os.LookupEnv("FILESYSTEM"); exists {
|
||||
a.diskIoStats.Filesystem = filesystem
|
||||
} else {
|
||||
a.diskIoStats.Filesystem = findDefaultFilesystem()
|
||||
}
|
||||
|
||||
a.initializeDiskIoStats()
|
||||
a.initializeNetIoStats()
|
||||
|
||||
a.startServer()
|
||||
}
|
||||
|
||||
func (a *Agent) initializeDiskIoStats() {
|
||||
if io, err := disk.IOCounters(a.diskIoStats.Filesystem); err == nil {
|
||||
for _, d := range io {
|
||||
a.diskIoStats.Time = time.Now()
|
||||
a.diskIoStats.Read = d.ReadBytes
|
||||
a.diskIoStats.Write = d.WriteBytes
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) initializeNetIoStats() {
|
||||
if netIO, err := psutilNet.IOCounters(true); err == nil {
|
||||
bytesSent := uint64(0)
|
||||
bytesRecv := uint64(0)
|
||||
for _, v := range netIO {
|
||||
if skipNetworkInterface(&v) {
|
||||
continue
|
||||
}
|
||||
log.Printf("Found network interface: %+v (%+v recv, %+v sent)\n", v.Name, v.BytesRecv, v.BytesSent)
|
||||
bytesSent += v.BytesSent
|
||||
bytesRecv += v.BytesRecv
|
||||
}
|
||||
a.netIoStats.BytesSent = bytesSent
|
||||
a.netIoStats.BytesRecv = bytesRecv
|
||||
a.netIoStats.Time = time.Now()
|
||||
}
|
||||
}
|
||||
|
||||
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 findDefaultFilesystem() string {
|
||||
if partitions, err := disk.Partitions(false); err == nil {
|
||||
for _, v := range partitions {
|
||||
if v.Mountpoint == "/" {
|
||||
log.Printf("Using filesystem: %+v\n", v.Device)
|
||||
return v.Device
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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,80 +5,81 @@ import (
|
||||
"beszel/internal/entities/system"
|
||||
"fmt"
|
||||
"net/mail"
|
||||
"net/url"
|
||||
|
||||
"github.com/containrrr/shoutrrr"
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase"
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tools/mailer"
|
||||
)
|
||||
|
||||
type AlertManager struct {
|
||||
app *pocketbase.PocketBase
|
||||
mailClient mailer.Mailer
|
||||
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 {
|
||||
return &AlertManager{
|
||||
app: app,
|
||||
mailClient: app.NewMailClient(),
|
||||
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",
|
||||
dbx.NewExp("system = {:system}", dbx.Params{"system": oldRecord.GetId()}),
|
||||
dbx.NewExp("system={:system}", dbx.Params{"system": systemRecord.GetId()}),
|
||||
)
|
||||
if err != nil || len(alertRecords) == 0 {
|
||||
// log.Println("no alerts found for system")
|
||||
return
|
||||
}
|
||||
// log.Println("found alerts", len(alertRecords))
|
||||
var systemInfo *system.Info
|
||||
for _, alertRecord := range alertRecords {
|
||||
name := alertRecord.GetString("name")
|
||||
switch name {
|
||||
case "Status":
|
||||
am.handleStatusAlerts(newStatus, oldRecord, alertRecord)
|
||||
case "CPU", "Memory", "Disk":
|
||||
if newStatus != "up" {
|
||||
continue
|
||||
}
|
||||
if systemInfo == nil {
|
||||
systemInfo = getSystemInfo(newRecord)
|
||||
}
|
||||
if name == "CPU" {
|
||||
am.handleSlidingValueAlert(newRecord, alertRecord, name, systemInfo.Cpu)
|
||||
am.handleSlidingValueAlert(systemRecord, alertRecord, name, systemInfo.Cpu)
|
||||
} else if name == "Memory" {
|
||||
am.handleSlidingValueAlert(newRecord, alertRecord, name, systemInfo.MemPct)
|
||||
am.handleSlidingValueAlert(systemRecord, alertRecord, name, systemInfo.MemPct)
|
||||
} 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 {
|
||||
var SystemInfo system.Info
|
||||
record.UnmarshalJSONField("info", &SystemInfo)
|
||||
return &SystemInfo
|
||||
}
|
||||
|
||||
func (am *AlertManager) handleSlidingValueAlert(newRecord *models.Record, alertRecord *models.Record, name string, curValue float64) {
|
||||
func (am *AlertManager) handleSlidingValueAlert(systemRecord *models.Record, alertRecord *models.Record, name string, curValue float64) {
|
||||
triggered := alertRecord.GetBool("triggered")
|
||||
threshold := alertRecord.GetFloat("value")
|
||||
// fmt.Println(name, curValue, "threshold", threshold, "triggered", triggered)
|
||||
var subject string
|
||||
var body string
|
||||
var systemName string
|
||||
if !triggered && curValue > threshold {
|
||||
alertRecord.Set("triggered", true)
|
||||
systemName := newRecord.GetString("name")
|
||||
systemName = systemRecord.GetString("name")
|
||||
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 {
|
||||
alertRecord.Set("triggered", false)
|
||||
systemName := newRecord.GetString("name")
|
||||
systemName = systemRecord.GetString("name")
|
||||
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 {
|
||||
// fmt.Println(name, "not triggered")
|
||||
return
|
||||
@@ -93,61 +94,195 @@ func (am *AlertManager) handleSlidingValueAlert(newRecord *models.Record, alertR
|
||||
return
|
||||
}
|
||||
if user := alertRecord.ExpandedOne("user"); user != nil {
|
||||
am.sendAlert(&mailer.Message{
|
||||
To: []mail.Address{{Address: user.GetString("email")}},
|
||||
Subject: subject,
|
||||
Text: body,
|
||||
am.sendAlert(AlertData{
|
||||
UserID: user.GetId(),
|
||||
Title: subject,
|
||||
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
|
||||
switch newStatus {
|
||||
case "up":
|
||||
if oldRecord.GetString("status") == "down" {
|
||||
if oldSystemRecord.GetString("status") == "down" {
|
||||
alertStatus = "up"
|
||||
}
|
||||
case "down":
|
||||
if oldRecord.GetString("status") == "up" {
|
||||
if oldSystemRecord.GetString("status") == "up" {
|
||||
alertStatus = "down"
|
||||
}
|
||||
}
|
||||
if alertStatus == "" {
|
||||
return nil
|
||||
}
|
||||
// expand the user relation
|
||||
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 {
|
||||
// check if use
|
||||
alertRecords, err := am.app.Dao().FindRecordsByExpr("alerts",
|
||||
dbx.HashExp{
|
||||
"system": oldSystemRecord.GetId(),
|
||||
"name": "Status",
|
||||
},
|
||||
)
|
||||
if err != nil || len(alertRecords) == 0 {
|
||||
// log.Println("no alerts found for system")
|
||||
return nil
|
||||
}
|
||||
emoji := "\U0001F534"
|
||||
if alertStatus == "up" {
|
||||
emoji = "\u2705"
|
||||
for _, alertRecord := range alertRecords {
|
||||
// expand the user relation
|
||||
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
|
||||
}
|
||||
|
||||
func (am *AlertManager) sendAlert(message *mailer.Message) {
|
||||
// fmt.Println("sending alert", "to", message.To, "subj", message.Subject, "body", message.Text)
|
||||
message.From = mail.Address{
|
||||
Address: am.app.Settings().Meta.SenderAddress,
|
||||
Name: am.app.Settings().Meta.SenderName,
|
||||
func (am *AlertManager) sendAlert(data AlertData) {
|
||||
// get user settings
|
||||
record, err := am.app.Dao().FindFirstRecordByFilter(
|
||||
"user_settings", "user={:user}",
|
||||
dbx.Params{"user": data.UserID},
|
||||
)
|
||||
if err != nil {
|
||||
am.app.Logger().Error("Failed to get user settings", "err", err.Error())
|
||||
return
|
||||
}
|
||||
if err := am.mailClient.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())
|
||||
} 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 {
|
||||
|
||||
// current res_counter usage for memory
|
||||
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.
|
||||
// MaxUsage uint64 `json:"max_usage,omitempty"`
|
||||
// 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.
|
||||
// Failcnt uint64 `json:"failcnt,omitempty"`
|
||||
// Limit uint64 `json:"limit,omitempty"`
|
||||
@@ -106,6 +104,11 @@ type MemoryStats struct {
|
||||
// PrivateWorkingSet uint64 `json:"privateworkingset,omitempty"`
|
||||
}
|
||||
|
||||
type MemoryStatsStats struct {
|
||||
Cache uint64 `json:"cache,omitempty"`
|
||||
InactiveFile uint64 `json:"inactive_file,omitempty"`
|
||||
}
|
||||
|
||||
type NetworkStats struct {
|
||||
// Bytes received. Windows and Linux.
|
||||
RxBytes uint64 `json:"rx_bytes"`
|
||||
@@ -113,21 +116,19 @@ type NetworkStats struct {
|
||||
TxBytes uint64 `json:"tx_bytes"`
|
||||
}
|
||||
|
||||
// Container stats to return to the hub
|
||||
type Stats struct {
|
||||
Name string `json:"n"`
|
||||
Cpu float64 `json:"c"`
|
||||
Mem float64 `json:"m"`
|
||||
NetworkSent float64 `json:"ns"`
|
||||
NetworkRecv float64 `json:"nr"`
|
||||
type prevNetStats struct {
|
||||
Sent uint64
|
||||
Recv uint64
|
||||
Time time.Time
|
||||
}
|
||||
|
||||
// Keeps track of container stats from previous run
|
||||
type PrevContainerStats struct {
|
||||
Cpu [2]uint64
|
||||
Net struct {
|
||||
Sent uint64
|
||||
Recv uint64
|
||||
Time time.Time
|
||||
}
|
||||
// Docker container stats
|
||||
type Stats struct {
|
||||
Name string `json:"n"`
|
||||
Cpu float64 `json:"c"`
|
||||
Mem float64 `json:"m"`
|
||||
NetworkSent float64 `json:"ns"`
|
||||
NetworkRecv float64 `json:"nr"`
|
||||
PrevCpu [2]uint64 `json:"-"`
|
||||
PrevNet prevNetStats `json:"-"`
|
||||
}
|
||||
|
||||
@@ -6,28 +6,34 @@ import (
|
||||
)
|
||||
|
||||
type Stats struct {
|
||||
Cpu float64 `json:"cpu"`
|
||||
Mem float64 `json:"m"`
|
||||
MemUsed float64 `json:"mu"`
|
||||
MemPct float64 `json:"mp"`
|
||||
MemBuffCache float64 `json:"mb"`
|
||||
Swap float64 `json:"s"`
|
||||
SwapUsed float64 `json:"su"`
|
||||
Disk float64 `json:"d"`
|
||||
DiskUsed float64 `json:"du"`
|
||||
DiskPct float64 `json:"dp"`
|
||||
DiskRead float64 `json:"dr"`
|
||||
DiskWrite float64 `json:"dw"`
|
||||
NetworkSent float64 `json:"ns"`
|
||||
NetworkRecv float64 `json:"nr"`
|
||||
Temperatures map[string]float64 `json:"t,omitempty"`
|
||||
Cpu float64 `json:"cpu"`
|
||||
Mem float64 `json:"m"`
|
||||
MemUsed float64 `json:"mu"`
|
||||
MemPct float64 `json:"mp"`
|
||||
MemBuffCache float64 `json:"mb"`
|
||||
Swap float64 `json:"s,omitempty"`
|
||||
SwapUsed float64 `json:"su,omitempty"`
|
||||
DiskTotal float64 `json:"d"`
|
||||
DiskUsed float64 `json:"du"`
|
||||
DiskPct float64 `json:"dp"`
|
||||
DiskReadPs float64 `json:"dr"`
|
||||
DiskWritePs float64 `json:"dw"`
|
||||
NetworkSent float64 `json:"ns"`
|
||||
NetworkRecv float64 `json:"nr"`
|
||||
Temperatures map[string]float64 `json:"t,omitempty"`
|
||||
ExtraFs map[string]*FsStats `json:"efs,omitempty"`
|
||||
}
|
||||
|
||||
type DiskIoStats struct {
|
||||
Read uint64
|
||||
Write uint64
|
||||
Time time.Time
|
||||
Filesystem string
|
||||
type FsStats struct {
|
||||
Time time.Time `json:"-"`
|
||||
Root bool `json:"-"`
|
||||
Mountpoint string `json:"-"`
|
||||
DiskTotal float64 `json:"d"`
|
||||
DiskUsed float64 `json:"du"`
|
||||
TotalRead uint64 `json:"-"`
|
||||
TotalWrite uint64 `json:"-"`
|
||||
DiskWritePs float64 `json:"w"`
|
||||
DiskReadPs float64 `json:"r"`
|
||||
}
|
||||
|
||||
type NetIoStats struct {
|
||||
@@ -38,9 +44,11 @@ type NetIoStats struct {
|
||||
}
|
||||
|
||||
type Info struct {
|
||||
Cores int `json:"c"`
|
||||
Threads int `json:"t"`
|
||||
CpuModel string `json:"m"`
|
||||
Hostname string `json:"h"`
|
||||
KernelVersion string `json:"k,omitempty"`
|
||||
Cores int `json:"c"`
|
||||
Threads int `json:"t,omitempty"`
|
||||
CpuModel string `json:"m"`
|
||||
// Os string `json:"o"`
|
||||
Uptime uint64 `json:"u"`
|
||||
Cpu float64 `json:"cpu"`
|
||||
@@ -51,7 +59,7 @@ type Info struct {
|
||||
|
||||
// Final data structure to return to the hub
|
||||
type CombinedData struct {
|
||||
Stats *Stats `json:"stats"`
|
||||
Info *Info `json:"info"`
|
||||
Stats Stats `json:"stats"`
|
||||
Info Info `json:"info"`
|
||||
Containers []*container.Stats `json:"container"`
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Package hub handles updating systems and serving the web UI.
|
||||
package hub
|
||||
|
||||
import (
|
||||
@@ -5,7 +6,10 @@ import (
|
||||
"beszel/internal/alerts"
|
||||
"beszel/internal/entities/system"
|
||||
"beszel/internal/records"
|
||||
"beszel/internal/users"
|
||||
"beszel/site"
|
||||
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
@@ -19,7 +23,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/goccy/go-json"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/pocketbase"
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
@@ -36,6 +39,9 @@ type Hub struct {
|
||||
systemConnections map[string]*ssh.Client
|
||||
sshClientConfig *ssh.ClientConfig
|
||||
pubKey string
|
||||
am *alerts.AlertManager
|
||||
um *users.UserManager
|
||||
rm *records.RecordManager
|
||||
}
|
||||
|
||||
func NewHub(app *pocketbase.PocketBase) *Hub {
|
||||
@@ -43,12 +49,16 @@ func NewHub(app *pocketbase.PocketBase) *Hub {
|
||||
app: app,
|
||||
connectionLock: &sync.Mutex{},
|
||||
systemConnections: make(map[string]*ssh.Client),
|
||||
am: alerts.NewAlertManager(app),
|
||||
um: users.NewUserManager(app),
|
||||
rm: records.NewRecordManager(app),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) Run() {
|
||||
var rm *records.RecordManager
|
||||
var am *alerts.AlertManager
|
||||
// rm := records.NewRecordManager(h.app)
|
||||
// am := alerts.NewAlertManager(h.app)
|
||||
// um := users.NewUserManager(h.app)
|
||||
|
||||
// loosely check if it was executed using "go run"
|
||||
isGoRun := strings.HasPrefix(os.Args[0], os.TempDir())
|
||||
@@ -62,9 +72,6 @@ func (h *Hub) Run() {
|
||||
|
||||
// initial setup
|
||||
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
|
||||
err := h.createSSHClientConfig()
|
||||
if err != nil {
|
||||
@@ -89,7 +96,7 @@ func (h *Hub) Run() {
|
||||
return nil
|
||||
})
|
||||
|
||||
// serve site
|
||||
// serve web ui
|
||||
h.app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
|
||||
switch isGoRun {
|
||||
case true:
|
||||
@@ -97,12 +104,17 @@ func (h *Hub) Run() {
|
||||
Scheme: "http",
|
||||
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))
|
||||
default:
|
||||
e.Router.GET("/static/*", apis.StaticDirectoryHandler(site.Static, false))
|
||||
e.Router.Any("/*", apis.StaticDirectoryHandler(site.Dist, true))
|
||||
csp, cspExists := os.LookupEnv("CSP")
|
||||
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
|
||||
})
|
||||
@@ -114,9 +126,9 @@ func (h *Hub) Run() {
|
||||
// set up cron jobs
|
||||
scheduler := cron.New()
|
||||
// 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
|
||||
scheduler.MustAdd("create longer records", "*/10 * * * *", rm.CreateLongerRecords)
|
||||
scheduler.MustAdd("create longer records", "*/10 * * * *", h.rm.CreateLongerRecords)
|
||||
scheduler.Start()
|
||||
return nil
|
||||
})
|
||||
@@ -139,15 +151,8 @@ func (h *Hub) Run() {
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]bool{"firstRun": adminNum == 0})
|
||||
})
|
||||
return nil
|
||||
})
|
||||
|
||||
// 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")
|
||||
}
|
||||
// send test notification
|
||||
e.Router.GET("/api/beszel/send-test-notification", h.am.SendTestNotification)
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -165,6 +170,10 @@ func (h *Hub) Run() {
|
||||
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
|
||||
h.app.OnModelAfterUpdate("systems").Add(func(e *core.ModelEvent) error {
|
||||
newRecord := e.Model.(*models.Record)
|
||||
@@ -179,10 +188,11 @@ func (h *Hub) Run() {
|
||||
// if system is set to pending (unpause), try to connect immediately
|
||||
if newStatus == "pending" {
|
||||
go h.updateSystem(newRecord)
|
||||
} else {
|
||||
h.am.HandleStatusAlerts(newStatus, oldRecord)
|
||||
|
||||
}
|
||||
|
||||
// alerts
|
||||
am.HandleSystemAlerts(newStatus, newRecord, oldRecord)
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -246,8 +256,10 @@ func (h *Hub) updateSystem(record *models.Record) {
|
||||
// create system connection
|
||||
client, err = h.createSystemConnection(record)
|
||||
if err != nil {
|
||||
h.app.Logger().Error("Failed to connect:", "err", err.Error(), "system", record.GetString("host"), "port", record.GetString("port"))
|
||||
h.updateSystemStatus(record, "down")
|
||||
if record.GetString("status") != "down" {
|
||||
h.app.Logger().Error("Failed to connect:", "err", err.Error(), "system", record.GetString("host"), "port", record.GetString("port"))
|
||||
h.updateSystemStatus(record, "down")
|
||||
}
|
||||
return
|
||||
}
|
||||
h.connectionLock.Lock()
|
||||
@@ -256,7 +268,7 @@ func (h *Hub) updateSystem(record *models.Record) {
|
||||
}
|
||||
// get system stats from agent
|
||||
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 previous connection was closed, try again
|
||||
h.app.Logger().Error("Existing SSH connection closed. Retrying...", "host", record.GetString("host"), "port", record.GetString("port"))
|
||||
@@ -294,6 +306,8 @@ func (h *Hub) updateSystem(record *models.Record) {
|
||||
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
|
||||
@@ -349,8 +363,9 @@ func (h *Hub) createSSHClientConfig() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func requestJsonFromAgent(client *ssh.Client, systemData *system.CombinedData) error {
|
||||
session, err := client.NewSession()
|
||||
// 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)
|
||||
if err != nil {
|
||||
return fmt.Errorf("bad client")
|
||||
}
|
||||
@@ -377,6 +392,32 @@ func requestJsonFromAgent(client *ssh.Client, systemData *system.CombinedData) e
|
||||
return nil
|
||||
}
|
||||
|
||||
// Adds timeout to SSH session creation to avoid hanging in case of network issues
|
||||
func newSessionWithTimeout(client *ssh.Client, timeout time.Duration) (*ssh.Session, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
// use goroutine to create the session
|
||||
sessionChan := make(chan *ssh.Session, 1)
|
||||
errChan := make(chan error, 1)
|
||||
go func() {
|
||||
if session, err := client.NewSession(); err != nil {
|
||||
errChan <- err
|
||||
} else {
|
||||
sessionChan <- session
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case session := <-sessionChan:
|
||||
return session, nil
|
||||
case err := <-errChan:
|
||||
return nil, err
|
||||
case <-ctx.Done():
|
||||
return nil, fmt.Errorf("session creation timed out")
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) getSSHKey() ([]byte, error) {
|
||||
dataDir := h.app.DataDir()
|
||||
// check if the key pair already exists
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/pocketbase/pocketbase"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
type RecordManager struct {
|
||||
@@ -19,10 +20,10 @@ type RecordManager struct {
|
||||
}
|
||||
|
||||
type LongerRecordData struct {
|
||||
shorterType string
|
||||
longerType string
|
||||
longerTimeDuration time.Duration
|
||||
expectedShorterRecords int
|
||||
shorterType string
|
||||
longerType string
|
||||
longerTimeDuration time.Duration
|
||||
minShorterRecords int
|
||||
}
|
||||
|
||||
type RecordDeletionData struct {
|
||||
@@ -39,28 +40,29 @@ func (rm *RecordManager) CreateLongerRecords() {
|
||||
// start := time.Now()
|
||||
recordData := []LongerRecordData{
|
||||
{
|
||||
shorterType: "1m",
|
||||
expectedShorterRecords: 10,
|
||||
longerType: "10m",
|
||||
longerTimeDuration: -10 * time.Minute,
|
||||
shorterType: "1m",
|
||||
// change to 9 from 10 to allow edge case timing or short pauses
|
||||
minShorterRecords: 9,
|
||||
longerType: "10m",
|
||||
longerTimeDuration: -10 * time.Minute,
|
||||
},
|
||||
{
|
||||
shorterType: "10m",
|
||||
expectedShorterRecords: 2,
|
||||
longerType: "20m",
|
||||
longerTimeDuration: -20 * time.Minute,
|
||||
shorterType: "10m",
|
||||
minShorterRecords: 2,
|
||||
longerType: "20m",
|
||||
longerTimeDuration: -20 * time.Minute,
|
||||
},
|
||||
{
|
||||
shorterType: "20m",
|
||||
expectedShorterRecords: 6,
|
||||
longerType: "120m",
|
||||
longerTimeDuration: -120 * time.Minute,
|
||||
shorterType: "20m",
|
||||
minShorterRecords: 6,
|
||||
longerType: "120m",
|
||||
longerTimeDuration: -120 * time.Minute,
|
||||
},
|
||||
{
|
||||
shorterType: "120m",
|
||||
expectedShorterRecords: 4,
|
||||
longerType: "480m",
|
||||
longerTimeDuration: -480 * time.Minute,
|
||||
shorterType: "120m",
|
||||
minShorterRecords: 4,
|
||||
longerType: "480m",
|
||||
longerTimeDuration: -480 * time.Minute,
|
||||
},
|
||||
}
|
||||
// wrap the operations in a transaction
|
||||
@@ -111,7 +113,7 @@ func (rm *RecordManager) CreateLongerRecords() {
|
||||
)
|
||||
|
||||
// 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)
|
||||
continue
|
||||
}
|
||||
@@ -140,33 +142,12 @@ func (rm *RecordManager) CreateLongerRecords() {
|
||||
// log.Println("finished creating longer records", "time (ms)", time.Since(start).Milliseconds())
|
||||
}
|
||||
|
||||
// Calculate the average stats of a list of system_stats records with reflect
|
||||
// func (rm *RecordManager) AverageSystemStats(records []*models.Record) system.Stats {
|
||||
// count := float64(len(records))
|
||||
// sum := reflect.New(reflect.TypeOf(system.Stats{})).Elem()
|
||||
|
||||
// var stats system.Stats
|
||||
// for _, record := range records {
|
||||
// record.UnmarshalJSONField("stats", &stats)
|
||||
// statValue := reflect.ValueOf(stats)
|
||||
// for i := 0; i < statValue.NumField(); i++ {
|
||||
// field := sum.Field(i)
|
||||
// field.SetFloat(field.Float() + statValue.Field(i).Float())
|
||||
// }
|
||||
// }
|
||||
|
||||
// average := reflect.New(reflect.TypeOf(system.Stats{})).Elem()
|
||||
// for i := 0; i < sum.NumField(); i++ {
|
||||
// average.Field(i).SetFloat(twoDecimals(sum.Field(i).Float() / count))
|
||||
// }
|
||||
|
||||
// return average.Interface().(system.Stats)
|
||||
// }
|
||||
|
||||
// Calculate the average stats of a list of system_stats records without reflect
|
||||
func (rm *RecordManager) AverageSystemStats(records []*models.Record) system.Stats {
|
||||
var sum system.Stats
|
||||
sum.Temperatures = make(map[string]float64)
|
||||
sum := system.Stats{
|
||||
Temperatures: make(map[string]float64),
|
||||
ExtraFs: make(map[string]*system.FsStats),
|
||||
}
|
||||
|
||||
count := float64(len(records))
|
||||
// use different counter for temps in case some records don't have them
|
||||
@@ -182,13 +163,14 @@ func (rm *RecordManager) AverageSystemStats(records []*models.Record) system.Sta
|
||||
sum.MemBuffCache += stats.MemBuffCache
|
||||
sum.Swap += stats.Swap
|
||||
sum.SwapUsed += stats.SwapUsed
|
||||
sum.Disk += stats.Disk
|
||||
sum.DiskTotal += stats.DiskTotal
|
||||
sum.DiskUsed += stats.DiskUsed
|
||||
sum.DiskPct += stats.DiskPct
|
||||
sum.DiskRead += stats.DiskRead
|
||||
sum.DiskWrite += stats.DiskWrite
|
||||
sum.DiskReadPs += stats.DiskReadPs
|
||||
sum.DiskWritePs += stats.DiskWritePs
|
||||
sum.NetworkSent += stats.NetworkSent
|
||||
sum.NetworkRecv += stats.NetworkRecv
|
||||
// add temps to sum
|
||||
if stats.Temperatures != nil {
|
||||
tempCount++
|
||||
for key, value := range stats.Temperatures {
|
||||
@@ -198,6 +180,18 @@ func (rm *RecordManager) AverageSystemStats(records []*models.Record) system.Sta
|
||||
sum.Temperatures[key] += value
|
||||
}
|
||||
}
|
||||
// add extra fs to sum
|
||||
if stats.ExtraFs != nil {
|
||||
for key, value := range stats.ExtraFs {
|
||||
if _, ok := sum.ExtraFs[key]; !ok {
|
||||
sum.ExtraFs[key] = &system.FsStats{}
|
||||
}
|
||||
sum.ExtraFs[key].DiskTotal += value.DiskTotal
|
||||
sum.ExtraFs[key].DiskUsed += value.DiskUsed
|
||||
sum.ExtraFs[key].DiskWritePs += value.DiskWritePs
|
||||
sum.ExtraFs[key].DiskReadPs += value.DiskReadPs
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stats = system.Stats{
|
||||
@@ -208,11 +202,11 @@ func (rm *RecordManager) AverageSystemStats(records []*models.Record) system.Sta
|
||||
MemBuffCache: twoDecimals(sum.MemBuffCache / count),
|
||||
Swap: twoDecimals(sum.Swap / count),
|
||||
SwapUsed: twoDecimals(sum.SwapUsed / count),
|
||||
Disk: twoDecimals(sum.Disk / count),
|
||||
DiskTotal: twoDecimals(sum.DiskTotal / count),
|
||||
DiskUsed: twoDecimals(sum.DiskUsed / count),
|
||||
DiskPct: twoDecimals(sum.DiskPct / count),
|
||||
DiskRead: twoDecimals(sum.DiskRead / count),
|
||||
DiskWrite: twoDecimals(sum.DiskWrite / count),
|
||||
DiskReadPs: twoDecimals(sum.DiskReadPs / count),
|
||||
DiskWritePs: twoDecimals(sum.DiskWritePs / count),
|
||||
NetworkSent: twoDecimals(sum.NetworkSent / count),
|
||||
NetworkRecv: twoDecimals(sum.NetworkRecv / count),
|
||||
}
|
||||
@@ -224,11 +218,23 @@ func (rm *RecordManager) AverageSystemStats(records []*models.Record) system.Sta
|
||||
}
|
||||
}
|
||||
|
||||
if len(sum.ExtraFs) != 0 {
|
||||
stats.ExtraFs = make(map[string]*system.FsStats)
|
||||
for key, value := range sum.ExtraFs {
|
||||
stats.ExtraFs[key] = &system.FsStats{
|
||||
DiskTotal: twoDecimals(value.DiskTotal / count),
|
||||
DiskUsed: twoDecimals(value.DiskUsed / count),
|
||||
DiskWritePs: twoDecimals(value.DiskWritePs / count),
|
||||
DiskReadPs: twoDecimals(value.DiskReadPs / count),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
// 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)
|
||||
count := float64(len(records))
|
||||
|
||||
@@ -237,7 +243,7 @@ func (rm *RecordManager) AverageContainerStats(records []*models.Record) (stats
|
||||
record.UnmarshalJSONField("stats", &containerStats)
|
||||
for _, stat := range containerStats {
|
||||
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].Mem += stat.Mem
|
||||
@@ -246,8 +252,9 @@ func (rm *RecordManager) AverageContainerStats(records []*models.Record) (stats
|
||||
}
|
||||
}
|
||||
|
||||
result := make([]container.Stats, 0, len(sums))
|
||||
for _, value := range sums {
|
||||
stats = append(stats, container.Stats{
|
||||
result = append(result, container.Stats{
|
||||
Name: value.Name,
|
||||
Cpu: twoDecimals(value.Cpu / count),
|
||||
Mem: twoDecimals(value.Mem / count),
|
||||
@@ -255,11 +262,11 @@ func (rm *RecordManager) AverageContainerStats(records []*models.Record) (stats
|
||||
NetworkRecv: twoDecimals(value.NetworkRecv / count),
|
||||
})
|
||||
}
|
||||
return stats
|
||||
return result
|
||||
}
|
||||
|
||||
// Deletes records older than what is displayed in the UI
|
||||
func (rm *RecordManager) DeleteOldRecords() {
|
||||
// start := time.Now()
|
||||
collections := []string{"system_stats", "container_stats"}
|
||||
recordData := []RecordDeletionData{
|
||||
{
|
||||
@@ -283,29 +290,17 @@ func (rm *RecordManager) DeleteOldRecords() {
|
||||
retention: 30 * 24 * time.Hour,
|
||||
},
|
||||
}
|
||||
rm.app.Dao().RunInTransaction(func(txDao *daos.Dao) error {
|
||||
for _, recordData := range recordData {
|
||||
exp := dbx.NewExp(
|
||||
"type = {:type} AND created < {:created}",
|
||||
dbx.Params{"type": recordData.recordType, "created": time.Now().UTC().Add(-recordData.retention)},
|
||||
)
|
||||
for _, collectionSlug := range collections {
|
||||
collectionRecords, err := txDao.FindRecordsByExpr(collectionSlug, exp)
|
||||
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
|
||||
}
|
||||
}
|
||||
db := rm.app.Dao().NonconcurrentDB()
|
||||
for _, recordData := range recordData {
|
||||
for _, collectionSlug := range collections {
|
||||
formattedDate := time.Now().UTC().Add(-recordData.retention).Format(types.DefaultDateLayout)
|
||||
expr := dbx.NewExp("[[created]] < {:date} AND [[type]] = {:type}", dbx.Params{"date": formattedDate, "type": recordData.recordType})
|
||||
_, err := db.Delete(collectionSlug, expr).Execute()
|
||||
if err != nil {
|
||||
rm.app.Logger().Error("Failed to delete records", "err", err.Error())
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
// log.Println("finished deleting old records", "time (ms)", time.Since(start).Milliseconds())
|
||||
}
|
||||
}
|
||||
|
||||
/* Round float to two decimals */
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Package update handles updating beszel and beszel-agent.
|
||||
package update
|
||||
|
||||
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",
|
||||
"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",
|
||||
"type": "base",
|
||||
"system": false,
|
||||
@@ -120,7 +120,7 @@ func init() {
|
||||
{
|
||||
"id": "ej9oowivz8b2mht",
|
||||
"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",
|
||||
"type": "base",
|
||||
"system": false,
|
||||
@@ -186,7 +186,7 @@ func init() {
|
||||
{
|
||||
"id": "juohu4jipgc13v7",
|
||||
"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",
|
||||
"type": "base",
|
||||
"system": false,
|
||||
@@ -250,7 +250,7 @@ func init() {
|
||||
{
|
||||
"id": "_pb_users_auth_",
|
||||
"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",
|
||||
"type": "auth",
|
||||
"system": false,
|
||||
@@ -316,7 +316,7 @@ func init() {
|
||||
{
|
||||
"id": "elngm8x1l60zi2v",
|
||||
"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",
|
||||
"type": "base",
|
||||
"system": false,
|
||||
@@ -403,6 +403,53 @@ func init() {
|
||||
"updateRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
|
||||
"deleteRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
|
||||
"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": {}
|
||||
}
|
||||
]`
|
||||
|
||||
Binary file not shown.
@@ -11,5 +11,3 @@ import (
|
||||
var assets embed.FS
|
||||
|
||||
var Dist = echo.MustSubFS(assets, "dist")
|
||||
|
||||
var Static = echo.MustSubFS(assets, "dist/static")
|
||||
|
||||
@@ -5,12 +5,6 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Beszel</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
4597
beszel/site/package-lock.json
generated
4597
beszel/site/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -22,7 +22,7 @@
|
||||
"@radix-ui/react-switch": "^1.1.0",
|
||||
"@radix-ui/react-toast": "^1.2.1",
|
||||
"@radix-ui/react-tooltip": "^1.1.2",
|
||||
"@tanstack/react-table": "^8.20.1",
|
||||
"@tanstack/react-table": "^8.20.5",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -31,23 +31,23 @@
|
||||
"d3-time": "^3.1.0",
|
||||
"lucide-react": "^0.407.0",
|
||||
"nanostores": "^0.10.3",
|
||||
"pocketbase": "^0.21.4",
|
||||
"pocketbase": "^0.21.5",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"recharts": "^2.13.0-alpha.4",
|
||||
"tailwind-merge": "^2.4.0",
|
||||
"tailwind-merge": "^2.5.2",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"use-is-in-viewport": "^1.0.9",
|
||||
"valibot": "^0.36.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.1.6",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/bun": "^1.1.8",
|
||||
"@types/react": "^18.3.5",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.40",
|
||||
"tailwindcss": "^3.4.7",
|
||||
"postcss": "^8.4.44",
|
||||
"tailwindcss": "^3.4.10",
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^5.3.5"
|
||||
"vite": "^5.4.2"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
beszel/site/public/static/InterVariable.woff2
Normal file
BIN
beszel/site/public/static/InterVariable.woff2
Normal file
Binary file not shown.
@@ -33,10 +33,12 @@ export function AddSystemButton({ className }: { className?: string }) {
|
||||
network_mode: host
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
# monitor other disks / partitions by mounting a folder in /extra-filesystems
|
||||
# - /mnt/disk1/.beszel:/extra-filesystems/disk1:ro
|
||||
environment:
|
||||
PORT: ${port}
|
||||
KEY: "${publicKey}"
|
||||
# FILESYSTEM: /dev/sda1 # set to the correct filesystem for disk I/O stats`)
|
||||
# FILESYSTEM: /dev/sda1 # override the root partition / device for disk I/O stats`)
|
||||
}
|
||||
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
|
||||
@@ -2,17 +2,17 @@ import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
|
||||
|
||||
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'
|
||||
import {
|
||||
useYAxisWidth,
|
||||
chartTimeData,
|
||||
cn,
|
||||
formatShortDate,
|
||||
toFixedWithoutTrailingZeros,
|
||||
useYaxisWidth,
|
||||
twoDecimalString,
|
||||
} 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 BandwidthChart({
|
||||
ticks,
|
||||
@@ -21,19 +21,16 @@ export default function BandwidthChart({
|
||||
ticks: number[]
|
||||
systemData: SystemStatsRecord[]
|
||||
}) {
|
||||
const chartRef = useRef<HTMLDivElement>(null)
|
||||
const yAxisWidth = useYaxisWidth(chartRef)
|
||||
const chartTime = useStore($chartTime)
|
||||
|
||||
const yAxisSet = useMemo(() => yAxisWidth !== 180, [yAxisWidth])
|
||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||
|
||||
return (
|
||||
<div ref={chartRef}>
|
||||
<div>
|
||||
{/* {!yAxisSet && <Spinner />} */}
|
||||
<ChartContainer
|
||||
config={{}}
|
||||
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
|
||||
'opacity-100': yAxisSet,
|
||||
'opacity-100': yAxisWidth,
|
||||
})}
|
||||
>
|
||||
<AreaChart
|
||||
@@ -51,10 +48,13 @@ export default function BandwidthChart({
|
||||
className="tracking-tighter"
|
||||
width={yAxisWidth}
|
||||
// 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}
|
||||
axisLine={false}
|
||||
unit={' MB/s'}
|
||||
// unit={' MB/s'}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="created"
|
||||
@@ -72,8 +72,8 @@ export default function BandwidthChart({
|
||||
animationDuration={150}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
unit=" MB/s"
|
||||
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
||||
contentFormatter={(item) => twoDecimalString(item.value) + ' MB/s'}
|
||||
indicator="line"
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ export default function ChartTimeSelect({ className }: { className?: string }) {
|
||||
onValueChange={(value: ChartTimes) => $chartTime.set(value)}
|
||||
>
|
||||
<SelectTrigger className={cn(className, 'relative pl-10 pr-5')}>
|
||||
<HistoryIcon className="h-4 w-4 absolute left-4 top-1/2 -translate-y-1/2 opacity-80" />
|
||||
<HistoryIcon className="h-4 w-4 absolute left-4 top-1/2 -translate-y-1/2 opacity-85" />
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
||||
@@ -5,11 +5,11 @@ import {
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from '@/components/ui/chart'
|
||||
import { useMemo, useRef } from 'react'
|
||||
import { chartTimeData, cn, formatShortDate, useYaxisWidth } from '@/lib/utils'
|
||||
import { useMemo } from 'react'
|
||||
import { useYAxisWidth, chartTimeData, cn, formatShortDate, twoDecimalString } from '@/lib/utils'
|
||||
// import Spinner from '../spinner'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { $chartTime } from '@/lib/stores'
|
||||
import { $chartTime, $containerFilter } from '@/lib/stores'
|
||||
|
||||
export default function ContainerCpuChart({
|
||||
chartData,
|
||||
@@ -18,11 +18,9 @@ export default function ContainerCpuChart({
|
||||
chartData: Record<string, number | string>[]
|
||||
ticks: number[]
|
||||
}) {
|
||||
const chartRef = useRef<HTMLDivElement>(null)
|
||||
const yAxisWidth = useYaxisWidth(chartRef)
|
||||
const chartTime = useStore($chartTime)
|
||||
|
||||
const yAxisSet = useMemo(() => yAxisWidth !== 180, [yAxisWidth])
|
||||
const filter = useStore($containerFilter)
|
||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||
|
||||
const chartConfig = useMemo(() => {
|
||||
let config = {} as Record<
|
||||
@@ -64,12 +62,12 @@ export default function ContainerCpuChart({
|
||||
// }
|
||||
|
||||
return (
|
||||
<div ref={chartRef}>
|
||||
<div>
|
||||
{/* {!yAxisSet && <Spinner />} */}
|
||||
<ChartContainer
|
||||
config={{}}
|
||||
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
|
||||
'opacity-100': yAxisSet,
|
||||
'opacity-100': yAxisWidth,
|
||||
})}
|
||||
>
|
||||
<AreaChart
|
||||
@@ -88,8 +86,10 @@ export default function ContainerCpuChart({
|
||||
width={yAxisWidth}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
unit={'%'}
|
||||
tickFormatter={(x) => (x % 1 === 0 ? x : x.toFixed(1))}
|
||||
tickFormatter={(x) => {
|
||||
const val = (x % 1 === 0 ? x : x.toFixed(1)) + '%'
|
||||
return updateYAxisWidth(val)
|
||||
}}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
@@ -109,23 +109,33 @@ export default function ContainerCpuChart({
|
||||
labelFormatter={(_, data) => formatShortDate(data[0].payload.time)}
|
||||
// @ts-ignore
|
||||
itemSorter={(a, b) => b.value - a.value}
|
||||
content={<ChartTooltipContent unit="%" indicator="line" />}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
filter={filter}
|
||||
contentFormatter={(item) => twoDecimalString(item.value) + '%'}
|
||||
indicator="line"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{Object.keys(chartConfig).map((key) => (
|
||||
<Area
|
||||
key={key}
|
||||
// isAnimationActive={chartData.length < 20}
|
||||
isAnimationActive={false}
|
||||
// animateNewValues={false}
|
||||
// animationDuration={1200}
|
||||
dataKey={key}
|
||||
type="monotoneX"
|
||||
fill={chartConfig[key].color}
|
||||
fillOpacity={0.4}
|
||||
stroke={chartConfig[key].color}
|
||||
stackId="a"
|
||||
/>
|
||||
))}
|
||||
{Object.keys(chartConfig).map((key) => {
|
||||
const filtered = filter && !key.includes(filter)
|
||||
let fillOpacity = filtered ? 0.05 : 0.4
|
||||
let strokeOpacity = filtered ? 0.1 : 1
|
||||
return (
|
||||
<Area
|
||||
key={key}
|
||||
isAnimationActive={false}
|
||||
dataKey={key}
|
||||
type="monotoneX"
|
||||
fill={chartConfig[key].color}
|
||||
fillOpacity={fillOpacity}
|
||||
stroke={chartConfig[key].color}
|
||||
strokeOpacity={strokeOpacity}
|
||||
activeDot={{ opacity: filtered ? 0 : 1 }}
|
||||
stackId="a"
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
|
||||
@@ -5,17 +5,18 @@ import {
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from '@/components/ui/chart'
|
||||
import { useMemo, useRef } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import {
|
||||
useYAxisWidth,
|
||||
chartTimeData,
|
||||
cn,
|
||||
formatShortDate,
|
||||
toFixedWithoutTrailingZeros,
|
||||
useYaxisWidth,
|
||||
twoDecimalString,
|
||||
} from '@/lib/utils'
|
||||
// import Spinner from '../spinner'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { $chartTime } from '@/lib/stores'
|
||||
import { $chartTime, $containerFilter } from '@/lib/stores'
|
||||
|
||||
export default function ContainerMemChart({
|
||||
chartData,
|
||||
@@ -25,10 +26,8 @@ export default function ContainerMemChart({
|
||||
ticks: number[]
|
||||
}) {
|
||||
const chartTime = useStore($chartTime)
|
||||
const chartRef = useRef<HTMLDivElement>(null)
|
||||
const yAxisWidth = useYaxisWidth(chartRef)
|
||||
|
||||
const yAxisSet = useMemo(() => yAxisWidth !== 180, [yAxisWidth])
|
||||
const filter = useStore($containerFilter)
|
||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||
|
||||
const chartConfig = useMemo(() => {
|
||||
let config = {} as Record<
|
||||
@@ -70,12 +69,12 @@ export default function ContainerMemChart({
|
||||
// }
|
||||
|
||||
return (
|
||||
<div ref={chartRef}>
|
||||
<div>
|
||||
{/* {!yAxisSet && <Spinner />} */}
|
||||
<ChartContainer
|
||||
config={{}}
|
||||
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
|
||||
'opacity-100': yAxisSet,
|
||||
'opacity-100': yAxisWidth,
|
||||
})}
|
||||
>
|
||||
<AreaChart
|
||||
@@ -92,9 +91,11 @@ export default function ContainerMemChart({
|
||||
// domain={[0, (max: number) => Math.ceil(max)]}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
unit={' GB'}
|
||||
width={yAxisWidth}
|
||||
tickFormatter={(value) => toFixedWithoutTrailingZeros(value / 1024, 2)}
|
||||
tickFormatter={(value) => {
|
||||
const val = toFixedWithoutTrailingZeros(value / 1024, 2) + ' GB'
|
||||
return updateYAxisWidth(val)
|
||||
}}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
@@ -114,21 +115,33 @@ export default function ContainerMemChart({
|
||||
labelFormatter={(_, data) => formatShortDate(data[0].payload.time)}
|
||||
// @ts-ignore
|
||||
itemSorter={(a, b) => b.value - a.value}
|
||||
content={<ChartTooltipContent unit=" MB" indicator="line" />}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
filter={filter}
|
||||
contentFormatter={(item) => twoDecimalString(item.value) + ' MB'}
|
||||
indicator="line"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{Object.keys(chartConfig).map((key) => (
|
||||
<Area
|
||||
key={key}
|
||||
// animationDuration={1200}
|
||||
isAnimationActive={false}
|
||||
dataKey={key}
|
||||
type="monotoneX"
|
||||
fill={chartConfig[key].color}
|
||||
fillOpacity={0.4}
|
||||
stroke={chartConfig[key].color}
|
||||
stackId="a"
|
||||
/>
|
||||
))}
|
||||
{Object.keys(chartConfig).map((key) => {
|
||||
const filtered = filter && !key.includes(filter)
|
||||
let fillOpacity = filtered ? 0.05 : 0.4
|
||||
let strokeOpacity = filtered ? 0.1 : 1
|
||||
return (
|
||||
<Area
|
||||
key={key}
|
||||
isAnimationActive={false}
|
||||
dataKey={key}
|
||||
type="monotoneX"
|
||||
fill={chartConfig[key].color}
|
||||
strokeOpacity={strokeOpacity}
|
||||
fillOpacity={fillOpacity}
|
||||
stroke={chartConfig[key].color}
|
||||
activeDot={filtered ? false : {}}
|
||||
stackId="a"
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
|
||||
@@ -5,17 +5,18 @@ import {
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from '@/components/ui/chart'
|
||||
import { useMemo, useRef } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import {
|
||||
useYAxisWidth,
|
||||
chartTimeData,
|
||||
cn,
|
||||
formatShortDate,
|
||||
toFixedWithoutTrailingZeros,
|
||||
useYaxisWidth,
|
||||
twoDecimalString,
|
||||
} from '@/lib/utils'
|
||||
// import Spinner from '../spinner'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { $chartTime } from '@/lib/stores'
|
||||
import { $chartTime, $containerFilter } from '@/lib/stores'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
|
||||
export default function ContainerCpuChart({
|
||||
@@ -26,10 +27,8 @@ export default function ContainerCpuChart({
|
||||
ticks: number[]
|
||||
}) {
|
||||
const chartTime = useStore($chartTime)
|
||||
const chartRef = useRef<HTMLDivElement>(null)
|
||||
const yAxisWidth = useYaxisWidth(chartRef)
|
||||
|
||||
const yAxisSet = useMemo(() => yAxisWidth !== 180, [yAxisWidth])
|
||||
const filter = useStore($containerFilter)
|
||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||
|
||||
const chartConfig = useMemo(() => {
|
||||
let config = {} as Record<
|
||||
@@ -70,12 +69,12 @@ export default function ContainerCpuChart({
|
||||
// }
|
||||
|
||||
return (
|
||||
<div ref={chartRef}>
|
||||
<div>
|
||||
{/* {!yAxisSet && <Spinner />} */}
|
||||
<ChartContainer
|
||||
config={{}}
|
||||
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
|
||||
'opacity-100': yAxisSet,
|
||||
'opacity-100': yAxisWidth,
|
||||
})}
|
||||
>
|
||||
<AreaChart
|
||||
@@ -93,8 +92,10 @@ export default function ContainerCpuChart({
|
||||
width={yAxisWidth}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
unit={' MB/s'}
|
||||
tickFormatter={(value) => toFixedWithoutTrailingZeros(value, 2)}
|
||||
tickFormatter={(value) => {
|
||||
const val = toFixedWithoutTrailingZeros(value, 2) + ' MB/s'
|
||||
return updateYAxisWidth(val)
|
||||
}}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
@@ -116,6 +117,7 @@ export default function ContainerCpuChart({
|
||||
itemSorter={(a, b) => b.value - a.value}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
filter={filter}
|
||||
indicator="line"
|
||||
contentFormatter={(item, key) => {
|
||||
try {
|
||||
@@ -123,10 +125,10 @@ export default function ContainerCpuChart({
|
||||
const received = item?.payload?.[key][1] ?? 0
|
||||
return (
|
||||
<span className="flex">
|
||||
{received.toLocaleString()} MB/s
|
||||
{twoDecimalString(received)} MB/s
|
||||
<span className="opacity-70 ml-0.5"> rx </span>
|
||||
<Separator orientation="vertical" className="h-3 mx-1.5 bg-primary/40" />
|
||||
{sent.toLocaleString()} MB/s<span className="opacity-70 ml-0.5"> tx</span>
|
||||
{twoDecimalString(sent)} MB/s<span className="opacity-70 ml-0.5"> tx</span>
|
||||
</span>
|
||||
)
|
||||
} catch (e) {
|
||||
@@ -136,20 +138,27 @@ export default function ContainerCpuChart({
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{Object.keys(chartConfig).map((key) => (
|
||||
<Area
|
||||
key={key}
|
||||
name={key}
|
||||
// animationDuration={1200}
|
||||
isAnimationActive={false}
|
||||
dataKey={(data) => data?.[key]?.[2] ?? 0}
|
||||
type="monotoneX"
|
||||
fill={chartConfig[key].color}
|
||||
fillOpacity={0.4}
|
||||
stroke={chartConfig[key].color}
|
||||
stackId="a"
|
||||
/>
|
||||
))}
|
||||
{Object.keys(chartConfig).map((key) => {
|
||||
const filtered = filter && !key.includes(filter)
|
||||
let fillOpacity = filtered ? 0.05 : 0.4
|
||||
let strokeOpacity = filtered ? 0.1 : 1
|
||||
return (
|
||||
<Area
|
||||
key={key}
|
||||
name={key}
|
||||
// animationDuration={1200}
|
||||
isAnimationActive={false}
|
||||
dataKey={(data) => data?.[key]?.[2] ?? 0}
|
||||
type="monotoneX"
|
||||
fill={chartConfig[key].color}
|
||||
fillOpacity={fillOpacity}
|
||||
stroke={chartConfig[key].color}
|
||||
strokeOpacity={strokeOpacity}
|
||||
activeDot={{ opacity: filtered ? 0 : 1 }}
|
||||
stackId="a"
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
|
||||
|
||||
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'
|
||||
import { chartTimeData, cn, formatShortDate, useYaxisWidth } from '@/lib/utils'
|
||||
import { useYAxisWidth, chartTimeData, cn, formatShortDate, twoDecimalString } 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 CpuChart({
|
||||
ticks,
|
||||
@@ -16,17 +15,14 @@ export default function CpuChart({
|
||||
systemData: SystemStatsRecord[]
|
||||
}) {
|
||||
const chartTime = useStore($chartTime)
|
||||
const chartRef = useRef<HTMLDivElement>(null)
|
||||
const yAxisWidth = useYaxisWidth(chartRef)
|
||||
|
||||
const yAxisSet = useMemo(() => yAxisWidth !== 180, [yAxisWidth])
|
||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||
|
||||
return (
|
||||
<div ref={chartRef}>
|
||||
<div>
|
||||
<ChartContainer
|
||||
config={{}}
|
||||
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
|
||||
'opacity-100': yAxisSet,
|
||||
'opacity-100': yAxisWidth,
|
||||
})}
|
||||
>
|
||||
<AreaChart
|
||||
@@ -42,7 +38,7 @@ export default function CpuChart({
|
||||
width={yAxisWidth}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
unit={'%'}
|
||||
tickFormatter={(value) => updateYAxisWidth(value + '%')}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="created"
|
||||
@@ -60,8 +56,8 @@ export default function CpuChart({
|
||||
animationDuration={150}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
unit="%"
|
||||
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
||||
contentFormatter={(item) => twoDecimalString(item.value) + '%'}
|
||||
indicator="line"
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
|
||||
|
||||
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'
|
||||
import { chartTimeData, cn, formatShortDate, useYaxisWidth } from '@/lib/utils'
|
||||
import { useMemo, useRef } from 'react'
|
||||
import {
|
||||
useYAxisWidth,
|
||||
chartTimeData,
|
||||
cn,
|
||||
formatShortDate,
|
||||
twoDecimalString,
|
||||
toFixedFloat,
|
||||
getSizeVal,
|
||||
getSizeUnit,
|
||||
} from '@/lib/utils'
|
||||
// import { useMemo } from 'react'
|
||||
// import Spinner from '../spinner'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { $chartTime } from '@/lib/stores'
|
||||
@@ -11,40 +20,24 @@ import { SystemStatsRecord } from '@/types'
|
||||
export default function DiskChart({
|
||||
ticks,
|
||||
systemData,
|
||||
dataKey,
|
||||
diskSize,
|
||||
}: {
|
||||
ticks: number[]
|
||||
systemData: SystemStatsRecord[]
|
||||
dataKey: string
|
||||
diskSize: number
|
||||
}) {
|
||||
const chartTime = useStore($chartTime)
|
||||
const chartRef = useRef<HTMLDivElement>(null)
|
||||
const yAxisWidth = useYaxisWidth(chartRef)
|
||||
|
||||
const yAxisSet = useMemo(() => yAxisWidth !== 180, [yAxisWidth])
|
||||
|
||||
const diskSize = useMemo(() => {
|
||||
return Math.round(systemData[0]?.stats.d)
|
||||
}, [systemData])
|
||||
|
||||
// const ticks = useMemo(() => {
|
||||
// let ticks = [0]
|
||||
// for (let i = 1; i < diskSize; i += diskSize / 5) {
|
||||
// ticks.push(Math.trunc(i))
|
||||
// }
|
||||
// ticks.push(diskSize)
|
||||
// return ticks
|
||||
// }, [diskSize])
|
||||
|
||||
// if (!systemData.length || !ticks.length) {
|
||||
// return <Spinner />
|
||||
// }
|
||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||
|
||||
return (
|
||||
<div ref={chartRef}>
|
||||
<div>
|
||||
{/* {!yAxisSet && <Spinner />} */}
|
||||
<ChartContainer
|
||||
config={{}}
|
||||
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
|
||||
'opacity-100': yAxisSet,
|
||||
'opacity-100': yAxisWidth,
|
||||
})}
|
||||
>
|
||||
<AreaChart
|
||||
@@ -63,9 +56,12 @@ export default function DiskChart({
|
||||
width={yAxisWidth}
|
||||
domain={[0, diskSize]}
|
||||
tickCount={9}
|
||||
minTickGap={6}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
unit={' GB'}
|
||||
tickFormatter={(value) =>
|
||||
updateYAxisWidth(toFixedFloat(getSizeVal(value), 2) + getSizeUnit(value))
|
||||
}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="created"
|
||||
@@ -83,14 +79,16 @@ export default function DiskChart({
|
||||
animationDuration={150}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
unit=" GB"
|
||||
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
||||
contentFormatter={({ value }) =>
|
||||
twoDecimalString(getSizeVal(value)) + getSizeUnit(value)
|
||||
}
|
||||
indicator="line"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Area
|
||||
dataKey="stats.du"
|
||||
dataKey={dataKey}
|
||||
name="Disk Usage"
|
||||
type="monotoneX"
|
||||
fill="hsl(var(--chart-4))"
|
||||
|
||||
@@ -2,42 +2,37 @@ import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
|
||||
|
||||
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'
|
||||
import {
|
||||
useYAxisWidth,
|
||||
chartTimeData,
|
||||
cn,
|
||||
formatShortDate,
|
||||
toFixedWithoutTrailingZeros,
|
||||
useYaxisWidth,
|
||||
twoDecimalString,
|
||||
} 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 DiskIoChart({
|
||||
ticks,
|
||||
systemData,
|
||||
dataKeys,
|
||||
}: {
|
||||
ticks: number[]
|
||||
systemData: SystemStatsRecord[]
|
||||
dataKeys: 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 />
|
||||
// }
|
||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||
|
||||
return (
|
||||
<div ref={chartRef}>
|
||||
<div>
|
||||
{/* {!yAxisSet && <Spinner />} */}
|
||||
<ChartContainer
|
||||
config={{}}
|
||||
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
|
||||
'opacity-100': yAxisSet,
|
||||
'opacity-100': yAxisWidth,
|
||||
})}
|
||||
>
|
||||
<AreaChart
|
||||
@@ -55,10 +50,12 @@ export default function DiskIoChart({
|
||||
className="tracking-tighter"
|
||||
width={yAxisWidth}
|
||||
// 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}
|
||||
axisLine={false}
|
||||
unit={' MB/s'}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="created"
|
||||
@@ -76,32 +73,28 @@ export default function DiskIoChart({
|
||||
animationDuration={150}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
unit=" MB/s"
|
||||
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
||||
contentFormatter={(item) => twoDecimalString(item.value) + ' MB/s'}
|
||||
indicator="line"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Area
|
||||
dataKey="stats.dw"
|
||||
name="Write"
|
||||
type="monotoneX"
|
||||
fill="hsl(var(--chart-3))"
|
||||
fillOpacity={0.3}
|
||||
stroke="hsl(var(--chart-3))"
|
||||
// animationDuration={1200}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<Area
|
||||
dataKey="stats.dr"
|
||||
name="Read"
|
||||
type="monotoneX"
|
||||
fill="hsl(var(--chart-1))"
|
||||
fillOpacity={0.3}
|
||||
stroke="hsl(var(--chart-1))"
|
||||
// animationDuration={1200}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
{dataKeys.map((dataKey, i) => {
|
||||
const action = i ? 'Read' : 'Write'
|
||||
const color = i ? 'hsl(var(--chart-1))' : 'hsl(var(--chart-3))'
|
||||
return (
|
||||
<Area
|
||||
key={i}
|
||||
dataKey={dataKey}
|
||||
name={action}
|
||||
type="monotoneX"
|
||||
fill={color}
|
||||
fillOpacity={0.3}
|
||||
stroke={color}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
|
||||
|
||||
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'
|
||||
import { chartTimeData, cn, formatShortDate, toFixedFloat, useYaxisWidth } from '@/lib/utils'
|
||||
import { useMemo, useRef } from 'react'
|
||||
import {
|
||||
useYAxisWidth,
|
||||
chartTimeData,
|
||||
cn,
|
||||
formatShortDate,
|
||||
toFixedFloat,
|
||||
twoDecimalString,
|
||||
} from '@/lib/utils'
|
||||
import { useMemo } from 'react'
|
||||
// import Spinner from '../spinner'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { $chartTime } from '@/lib/stores'
|
||||
@@ -15,23 +22,20 @@ export default function MemChart({
|
||||
ticks: number[]
|
||||
systemData: SystemStatsRecord[]
|
||||
}) {
|
||||
const chartRef = useRef<HTMLDivElement>(null)
|
||||
const yAxisWidth = useYaxisWidth(chartRef)
|
||||
const chartTime = useStore($chartTime)
|
||||
|
||||
const yAxisSet = useMemo(() => yAxisWidth !== 180, [yAxisWidth])
|
||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||
|
||||
const totalMem = useMemo(() => {
|
||||
return toFixedFloat(systemData.at(-1)?.stats.m ?? 0, 1)
|
||||
}, [systemData])
|
||||
|
||||
return (
|
||||
<div ref={chartRef}>
|
||||
<div>
|
||||
{/* {!yAxisSet && <Spinner />} */}
|
||||
<ChartContainer
|
||||
config={{}}
|
||||
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
|
||||
'opacity-100': yAxisSet,
|
||||
'opacity-100': yAxisWidth,
|
||||
})}
|
||||
>
|
||||
<AreaChart
|
||||
@@ -51,7 +55,10 @@ export default function MemChart({
|
||||
width={yAxisWidth}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
unit={' GB'}
|
||||
tickFormatter={(value) => {
|
||||
const val = toFixedFloat(value, 1)
|
||||
return updateYAxisWidth(val + ' GB')
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<XAxis
|
||||
@@ -71,10 +78,10 @@ export default function MemChart({
|
||||
animationDuration={150}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
unit=" GB"
|
||||
// @ts-ignore
|
||||
itemSorter={(a, b) => a.name.localeCompare(b.name)}
|
||||
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
||||
contentFormatter={(item) => twoDecimalString(item.value) + ' GB'}
|
||||
indicator="line"
|
||||
/>
|
||||
}
|
||||
@@ -93,10 +100,10 @@ export default function MemChart({
|
||||
dataKey="stats.mb"
|
||||
name="Cache / Buffers"
|
||||
type="monotoneX"
|
||||
fill="hsl(var(--chart-2))"
|
||||
fillOpacity={0.2}
|
||||
strokeOpacity={0.3}
|
||||
stroke="hsl(var(--chart-2))"
|
||||
fill="hsla(160 60% 45% / 0.5)"
|
||||
fillOpacity={0.4}
|
||||
// strokeOpacity={1}
|
||||
stroke="hsla(160 60% 45% / 0.5)"
|
||||
stackId="1"
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
|
||||
@@ -2,17 +2,17 @@ import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
|
||||
|
||||
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'
|
||||
import {
|
||||
useYAxisWidth,
|
||||
chartTimeData,
|
||||
cn,
|
||||
formatShortDate,
|
||||
toFixedWithoutTrailingZeros,
|
||||
useYaxisWidth,
|
||||
twoDecimalString,
|
||||
} 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 SwapChart({
|
||||
ticks,
|
||||
@@ -22,17 +22,14 @@ export default function SwapChart({
|
||||
systemData: SystemStatsRecord[]
|
||||
}) {
|
||||
const chartTime = useStore($chartTime)
|
||||
const chartRef = useRef<HTMLDivElement>(null)
|
||||
const yAxisWidth = useYaxisWidth(chartRef)
|
||||
|
||||
const yAxisSet = useMemo(() => yAxisWidth !== 180, [yAxisWidth])
|
||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||
|
||||
return (
|
||||
<div ref={chartRef}>
|
||||
<div>
|
||||
<ChartContainer
|
||||
config={{}}
|
||||
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 }}>
|
||||
@@ -43,7 +40,7 @@ export default function SwapChart({
|
||||
width={yAxisWidth}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
unit={' GB'}
|
||||
tickFormatter={(value) => updateYAxisWidth(value + ' GB')}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="created"
|
||||
@@ -61,8 +58,8 @@ export default function SwapChart({
|
||||
animationDuration={150}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
unit=" GB"
|
||||
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
||||
contentFormatter={(item) => twoDecimalString(item.value) + ' GB'}
|
||||
indicator="line"
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -8,16 +8,17 @@ import {
|
||||
ChartTooltipContent,
|
||||
} from '@/components/ui/chart'
|
||||
import {
|
||||
useYAxisWidth,
|
||||
chartTimeData,
|
||||
cn,
|
||||
formatShortDate,
|
||||
toFixedWithoutTrailingZeros,
|
||||
useYaxisWidth,
|
||||
twoDecimalString,
|
||||
} from '@/lib/utils'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { $chartTime } from '@/lib/stores'
|
||||
import { SystemStatsRecord } from '@/types'
|
||||
import { useMemo, useRef } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
export default function TemperatureChart({
|
||||
ticks,
|
||||
@@ -26,9 +27,8 @@ export default function TemperatureChart({
|
||||
ticks: number[]
|
||||
systemData: SystemStatsRecord[]
|
||||
}) {
|
||||
const chartRef = useRef<HTMLDivElement>(null)
|
||||
const yAxisWidth = useYaxisWidth(chartRef)
|
||||
const chartTime = useStore($chartTime)
|
||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||
|
||||
/** Format temperature data for chart and assign colors */
|
||||
const newChartData = useMemo(() => {
|
||||
@@ -54,15 +54,15 @@ export default function TemperatureChart({
|
||||
return chartData
|
||||
}, [systemData])
|
||||
|
||||
const yAxisSet = useMemo(() => yAxisWidth !== 180, [yAxisWidth])
|
||||
const colors = Object.keys(newChartData.colors)
|
||||
|
||||
return (
|
||||
<div ref={chartRef}>
|
||||
<div>
|
||||
{/* {!yAxisSet && <Spinner />} */}
|
||||
<ChartContainer
|
||||
config={{}}
|
||||
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
|
||||
'opacity-100': yAxisSet,
|
||||
'opacity-100': yAxisWidth,
|
||||
})}
|
||||
>
|
||||
<LineChart
|
||||
@@ -78,11 +78,14 @@ export default function TemperatureChart({
|
||||
<CartesianGrid vertical={false} />
|
||||
<YAxis
|
||||
className="tracking-tighter"
|
||||
domain={[0, 'auto']}
|
||||
width={yAxisWidth}
|
||||
tickFormatter={(value) => toFixedWithoutTrailingZeros(value, 2)}
|
||||
tickFormatter={(value) => {
|
||||
const val = toFixedWithoutTrailingZeros(value, 2)
|
||||
return updateYAxisWidth(val + ' °C')
|
||||
}}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
unit={' °C'}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="created"
|
||||
@@ -102,13 +105,13 @@ export default function TemperatureChart({
|
||||
itemSorter={(a, b) => b.value - a.value}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
unit=" °C"
|
||||
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
||||
contentFormatter={(item) => twoDecimalString(item.value) + ' °C'}
|
||||
indicator="line"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{Object.keys(newChartData.colors).map((key) => (
|
||||
{colors.map((key) => (
|
||||
<Line
|
||||
key={key}
|
||||
dataKey={key}
|
||||
@@ -120,7 +123,7 @@ export default function TemperatureChart({
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
))}
|
||||
<ChartLegend content={<ChartLegendContent />} />
|
||||
{colors.length < 12 && <ChartLegend content={<ChartLegendContent />} />}
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
LogsIcon,
|
||||
MailIcon,
|
||||
Server,
|
||||
SettingsIcon,
|
||||
UsersIcon,
|
||||
} from 'lucide-react'
|
||||
|
||||
@@ -46,33 +47,9 @@ export default function CommandPalette() {
|
||||
<CommandInput placeholder="Search for systems or settings..." />
|
||||
<CommandList>
|
||||
<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 && (
|
||||
<>
|
||||
<CommandSeparator />
|
||||
<CommandGroup heading="Systems">
|
||||
<CommandGroup>
|
||||
{systems.map((system) => (
|
||||
<CommandItem
|
||||
key={system.id}
|
||||
@@ -87,11 +64,56 @@ export default function CommandPalette() {
|
||||
</CommandItem>
|
||||
))}
|
||||
</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() && (
|
||||
<>
|
||||
<CommandSeparator />
|
||||
<CommandSeparator className="mb-1.5" />
|
||||
<CommandGroup heading="Admin">
|
||||
<CommandItem
|
||||
keywords={['pocketbase']}
|
||||
|
||||
49
beszel/site/src/components/copy-to-clipboard.tsx
Normal file
49
beszel/site/src/components/copy-to-clipboard.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from './ui/dialog'
|
||||
import { Textarea } from './ui/textarea'
|
||||
import { $copyContent } from '@/lib/stores'
|
||||
|
||||
export default function CopyToClipboard({ content }: { content: string }) {
|
||||
return (
|
||||
<Dialog defaultOpen={true}>
|
||||
<DialogContent className="w-[90%] rounded-lg" style={{ maxWidth: 530 }}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Could not copy to clipboard</DialogTitle>
|
||||
<DialogDescription>Please copy the text manually.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<CopyTextarea content={content} />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Clipboard API requires a secure context (https, localhost, or *.localhost)
|
||||
</p>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function CopyTextarea({ content }: { content: string }) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
const rows = useMemo(() => {
|
||||
return content.split('\n').length
|
||||
}, [content])
|
||||
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.select()
|
||||
}
|
||||
}, [textareaRef])
|
||||
|
||||
useEffect(() => {
|
||||
return () => $copyContent.set('')
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Textarea
|
||||
className="font-mono overflow-hidden whitespace-pre"
|
||||
rows={rows}
|
||||
value={content}
|
||||
readOnly
|
||||
ref={textareaRef}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MoonStarIcon, Sun } from 'lucide-react'
|
||||
import { LaptopIcon, MoonStarIcon, SunIcon } from 'lucide-react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
@@ -16,15 +16,24 @@ export function ModeToggle() {
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant={'ghost'} size="icon">
|
||||
<Sun className="h-[1.2rem] w-[1.2rem] dark:opacity-0" />
|
||||
<SunIcon className="h-[1.2rem] w-[1.2rem] dark:opacity-0" />
|
||||
<MoonStarIcon className="absolute h-[1.2rem] w-[1.2rem] opacity-0 dark:opacity-100" />
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem onClick={() => setTheme('light')}>Light</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme('dark')}>Dark</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme('system')}>System</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme('light')}>
|
||||
<SunIcon className="mr-2.5 h-4 w-4" />
|
||||
Light
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme('dark')}>
|
||||
<MoonStarIcon className="mr-2.5 h-4 w-4" />
|
||||
Dark
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme('system')}>
|
||||
<LaptopIcon className="mr-2.5 h-4 w-4" />
|
||||
System
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ export const $router = createRouter(
|
||||
{
|
||||
home: '/',
|
||||
server: '/system/:name',
|
||||
'forgot-password': '/forgot-password',
|
||||
settings: '/settings/:name?',
|
||||
},
|
||||
{ links: false }
|
||||
)
|
||||
@@ -16,7 +16,7 @@ export const navigate = (urlString: string) => {
|
||||
|
||||
function onClick(e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) {
|
||||
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>) => {
|
||||
|
||||
@@ -51,7 +51,7 @@ export default function () {
|
||||
<Input
|
||||
placeholder="Filter..."
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
className="w-full md:w-56 lg:w-80 ml-auto pl-4"
|
||||
className="w-full md:w-56 lg:w-80 ml-auto px-4"
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
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,15 +1,24 @@
|
||||
import { $systems, pb, $chartTime } from '@/lib/stores'
|
||||
import { $systems, pb, $chartTime, $containerFilter, $userSettings } from '@/lib/stores'
|
||||
import { ContainerStatsRecord, SystemRecord, SystemStatsRecord } from '@/types'
|
||||
import { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '../ui/card'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import Spinner from '../spinner'
|
||||
import { ClockArrowUp, CpuIcon, GlobeIcon } from 'lucide-react'
|
||||
import { ClockArrowUp, CpuIcon, GlobeIcon, LayoutGridIcon, MonitorIcon, XIcon } from 'lucide-react'
|
||||
import ChartTimeSelect from '../charts/chart-time-select'
|
||||
import { chartTimeData, cn, getPbTimestamp, useClampedIsInViewport } from '@/lib/utils'
|
||||
import {
|
||||
chartTimeData,
|
||||
cn,
|
||||
getPbTimestamp,
|
||||
useClampedIsInViewport,
|
||||
useLocalStorage,
|
||||
} from '@/lib/utils'
|
||||
import { Separator } from '../ui/separator'
|
||||
import { scaleTime } from 'd3-scale'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip'
|
||||
import { Button, buttonVariants } from '../ui/button'
|
||||
import { Input } from '../ui/input'
|
||||
import { Rows, TuxIcon } from '../ui/icons'
|
||||
|
||||
const CpuChart = lazy(() => import('../charts/cpu-chart'))
|
||||
const ContainerCpuChart = lazy(() => import('../charts/container-cpu-chart'))
|
||||
@@ -25,10 +34,11 @@ const TemperatureChart = lazy(() => import('../charts/temperature-chart'))
|
||||
export default function SystemDetail({ name }: { name: string }) {
|
||||
const systems = useStore($systems)
|
||||
const chartTime = useStore($chartTime)
|
||||
const [grid, setGrid] = useLocalStorage('grid', true)
|
||||
const [ticks, setTicks] = useState([] as number[])
|
||||
const [system, setSystem] = useState({} as SystemRecord)
|
||||
const [systemStats, setSystemStats] = useState([] as SystemStatsRecord[])
|
||||
const [hasDockerStats, setHasDocker] = useState(false)
|
||||
const netCardRef = useRef<HTMLDivElement>(null)
|
||||
const [dockerCpuChartData, setDockerCpuChartData] = useState<Record<string, number | string>[]>(
|
||||
[]
|
||||
)
|
||||
@@ -38,13 +48,15 @@ export default function SystemDetail({ name }: { name: string }) {
|
||||
const [dockerNetChartData, setDockerNetChartData] = useState<Record<string, number | number[]>[]>(
|
||||
[]
|
||||
)
|
||||
const hasDockerStats = dockerCpuChartData.length > 0
|
||||
|
||||
useEffect(() => {
|
||||
document.title = `${name} / Beszel`
|
||||
return () => {
|
||||
resetCharts()
|
||||
$chartTime.set('1h')
|
||||
setHasDocker(false)
|
||||
$chartTime.set($userSettings.get().chartTime)
|
||||
$containerFilter.set('')
|
||||
// setHasDocker(false)
|
||||
}
|
||||
}, [name])
|
||||
|
||||
@@ -105,7 +117,7 @@ export default function SystemDetail({ name }: { name: string }) {
|
||||
if (prevTime) {
|
||||
const interval = record.created - prevTime
|
||||
// if interval is too large, add a null record
|
||||
if (interval - interval * 0.5 > expectedInterval) {
|
||||
if (interval > expectedInterval / 2 + expectedInterval) {
|
||||
// @ts-ignore
|
||||
modifiedRecords.push({ created: null, stats: null })
|
||||
}
|
||||
@@ -128,7 +140,6 @@ export default function SystemDetail({ name }: { name: string }) {
|
||||
const expectedInterval = chartTimeData[chartTime].expectedInterval
|
||||
if (containerStats.status === 'fulfilled' && containerStats.value.length) {
|
||||
makeContainerData(addEmptyValues(containerStats.value, expectedInterval))
|
||||
setHasDocker(true)
|
||||
}
|
||||
if (systemStats.status === 'fulfilled') {
|
||||
setSystemStats(addEmptyValues(systemStats.value, expectedInterval))
|
||||
@@ -178,138 +189,303 @@ export default function SystemDetail({ name }: { name: string }) {
|
||||
setDockerNetChartData(dockerNetData)
|
||||
}, [])
|
||||
|
||||
const uptime = useMemo(() => {
|
||||
let uptime = system.info?.u || 0
|
||||
if (uptime < 172800) {
|
||||
return `${Math.trunc(uptime / 3600)} hours`
|
||||
// values for system info bar
|
||||
const systemInfo = useMemo(() => {
|
||||
if (!system.info) {
|
||||
return []
|
||||
}
|
||||
return `${Math.trunc(system.info?.u / 86400)} days`
|
||||
}, [system.info?.u])
|
||||
let uptime: number | string = 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 */
|
||||
const bottomSpacing = useMemo(() => {
|
||||
if (!netCardRef.current || !dockerNetChartData.length) {
|
||||
return 0
|
||||
}
|
||||
const tooltipHeight = (Object.keys(dockerNetChartData[0]).length - 11) * 17.8 - 40
|
||||
const wrapperEl = document.getElementById('chartwrap') as HTMLDivElement
|
||||
const wrapperRect = wrapperEl.getBoundingClientRect()
|
||||
const chartRect = netCardRef.current.getBoundingClientRect()
|
||||
const distanceToBottom = wrapperRect.bottom - chartRect.bottom
|
||||
return tooltipHeight - distanceToBottom
|
||||
}, [netCardRef.current, dockerNetChartData])
|
||||
|
||||
if (!system.id) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid lg:grid-cols-2 gap-4 mb-10">
|
||||
<Card className="col-span-full">
|
||||
<div className="grid lg:flex items-center gap-4 px-4 sm:px-6 pt-3 sm:pt-4 pb-5">
|
||||
<div>
|
||||
<h1 className="text-[1.6rem] font-semibold mb-1.5">{system.name}</h1>
|
||||
<div className="flex flex-wrap items-center gap-3 gap-y-2 text-sm opacity-90">
|
||||
<div className="capitalize flex gap-2 items-center">
|
||||
<span className={cn('relative flex h-3 w-3')}>
|
||||
{system.status === 'up' && (
|
||||
<>
|
||||
<div id="chartwrap" className="grid gap-4 mb-10">
|
||||
{/* system info */}
|
||||
<Card>
|
||||
<div className="grid lg:flex items-center gap-4 px-4 sm:px-6 pt-3 sm:pt-4 pb-5">
|
||||
<div>
|
||||
<h1 className="text-[1.6rem] font-semibold mb-1.5">{system.name}</h1>
|
||||
<div className="flex flex-wrap items-center gap-3 gap-y-2 text-sm opacity-90">
|
||||
<div className="capitalize flex gap-2 items-center">
|
||||
<span className={cn('relative flex h-3 w-3')}>
|
||||
{system.status === 'up' && (
|
||||
<span
|
||||
className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"
|
||||
style={{ animationDuration: '1.5s' }}
|
||||
></span>
|
||||
)}
|
||||
<span
|
||||
className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"
|
||||
style={{ animationDuration: '1.5s' }}
|
||||
className={cn('relative inline-flex rounded-full h-3 w-3', {
|
||||
'bg-green-500': system.status === 'up',
|
||||
'bg-red-500': system.status === 'down',
|
||||
'bg-primary/40': system.status === 'paused',
|
||||
'bg-yellow-500': system.status === 'pending',
|
||||
})}
|
||||
></span>
|
||||
)}
|
||||
<span
|
||||
className={cn('relative inline-flex rounded-full h-3 w-3', {
|
||||
'bg-green-500': system.status === 'up',
|
||||
'bg-red-500': system.status === 'down',
|
||||
'bg-primary/40': system.status === 'paused',
|
||||
'bg-yellow-500': system.status === 'pending',
|
||||
})}
|
||||
></span>
|
||||
</span>
|
||||
{system.status}
|
||||
</span>
|
||||
{system.status}
|
||||
</div>
|
||||
{systemInfo.map(({ value, label, Icon, hide }, i) => {
|
||||
if (hide || !value) {
|
||||
return null
|
||||
}
|
||||
const content = (
|
||||
<div className="flex gap-1.5 items-center">
|
||||
<Icon className="h-4 w-4" /> {value}
|
||||
</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>
|
||||
<Separator orientation="vertical" className="h-4 bg-primary/30" />
|
||||
<div className="flex gap-1.5">
|
||||
<GlobeIcon className="h-4 w-4 mt-[1px]" /> {system.host}
|
||||
</div>
|
||||
{system.info?.u && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<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>
|
||||
<div className="lg:ml-auto flex items-center gap-2 max-sm:-mb-1">
|
||||
<ChartTimeSelect className="w-full lg:w-40" />
|
||||
<TooltipProvider delayDuration={100}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
aria-label="Toggle grid"
|
||||
className={cn(
|
||||
buttonVariants({ variant: 'outline', size: 'icon' }),
|
||||
'hidden lg:flex p-0 text-primary'
|
||||
)}
|
||||
onClick={() => setGrid(!grid)}
|
||||
>
|
||||
{grid ? (
|
||||
<LayoutGridIcon className="h-[1.2rem] w-[1.2rem] opacity-85" />
|
||||
) : (
|
||||
<Rows className="h-[1.3rem] w-[1.3rem] opacity-85" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Toggle grid</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
<ChartTimeSelect className="w-full lg:w-40 xl:w-52 ml-auto max-sm:-mb-1" />
|
||||
</div>
|
||||
</Card>
|
||||
</Card>
|
||||
|
||||
<ChartCard title="Total CPU Usage" description="Average system-wide CPU utilization">
|
||||
<CpuChart ticks={ticks} systemData={systemStats} />
|
||||
</ChartCard>
|
||||
|
||||
{hasDockerStats && (
|
||||
<ChartCard title="Docker CPU Usage" description="CPU utilization of docker containers">
|
||||
<ContainerCpuChart chartData={dockerCpuChartData} ticks={ticks} />
|
||||
</ChartCard>
|
||||
)}
|
||||
|
||||
<ChartCard title="Total Memory Usage" description="Precise utilization at the recorded time">
|
||||
<MemChart ticks={ticks} systemData={systemStats} />
|
||||
</ChartCard>
|
||||
|
||||
{hasDockerStats && (
|
||||
<ChartCard title="Docker Memory Usage" description="Memory usage of docker containers">
|
||||
<ContainerMemChart chartData={dockerMemChartData} ticks={ticks} />
|
||||
</ChartCard>
|
||||
)}
|
||||
|
||||
{(systemStats.at(-1)?.stats.s ?? 0) > 0 && (
|
||||
<ChartCard title="Swap Usage" description="Swap space used by the system">
|
||||
<SwapChart ticks={ticks} systemData={systemStats} />
|
||||
</ChartCard>
|
||||
)}
|
||||
|
||||
{systemStats.at(-1)?.stats.t && (
|
||||
<ChartCard title="Temperature" description="Temperatures of system sensors">
|
||||
<TemperatureChart ticks={ticks} systemData={systemStats} />
|
||||
</ChartCard>
|
||||
)}
|
||||
|
||||
<ChartCard title="Disk Usage" description="Space usage of root partition">
|
||||
<DiskChart ticks={ticks} systemData={systemStats} />
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard title="Disk I/O" description="Throughput of root filesystem">
|
||||
<DiskIoChart ticks={ticks} systemData={systemStats} />
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard title="Bandwidth" description="Network traffic of public interfaces">
|
||||
<BandwidthChart ticks={ticks} systemData={systemStats} />
|
||||
</ChartCard>
|
||||
|
||||
{hasDockerStats && dockerNetChartData.length > 0 && (
|
||||
<>
|
||||
{/* main charts */}
|
||||
<div className="grid lg:grid-cols-2 gap-4">
|
||||
<ChartCard
|
||||
title="Docker Network I/O"
|
||||
description="Includes traffic between internal services"
|
||||
grid={grid}
|
||||
title="Total CPU Usage"
|
||||
description="Average system-wide CPU utilization"
|
||||
>
|
||||
<ContainerNetChart chartData={dockerNetChartData} ticks={ticks} />
|
||||
<CpuChart ticks={ticks} systemData={systemStats} />
|
||||
</ChartCard>
|
||||
{/* add space for tooltip if more than 12 containers */}
|
||||
{Object.keys(dockerNetChartData[0]).length > 12 && (
|
||||
<span
|
||||
className="block"
|
||||
style={{
|
||||
height: (Object.keys(dockerNetChartData[0]).length - 13) * 18,
|
||||
}}
|
||||
/>
|
||||
|
||||
{hasDockerStats && (
|
||||
<ChartCard
|
||||
grid={grid}
|
||||
title="Docker CPU Usage"
|
||||
description="CPU utilization of docker containers"
|
||||
isContainerChart={true}
|
||||
>
|
||||
<ContainerCpuChart chartData={dockerCpuChartData} ticks={ticks} />
|
||||
</ChartCard>
|
||||
)}
|
||||
</>
|
||||
|
||||
<ChartCard
|
||||
grid={grid}
|
||||
title="Total Memory Usage"
|
||||
description="Precise utilization at the recorded time"
|
||||
>
|
||||
<MemChart ticks={ticks} systemData={systemStats} />
|
||||
</ChartCard>
|
||||
|
||||
{hasDockerStats && (
|
||||
<ChartCard
|
||||
grid={grid}
|
||||
title="Docker Memory Usage"
|
||||
description="Memory usage of docker containers"
|
||||
isContainerChart={true}
|
||||
>
|
||||
<ContainerMemChart chartData={dockerMemChartData} ticks={ticks} />
|
||||
</ChartCard>
|
||||
)}
|
||||
|
||||
<ChartCard grid={grid} title="Disk Space" description="Usage of root partition">
|
||||
<DiskChart
|
||||
ticks={ticks}
|
||||
systemData={systemStats}
|
||||
dataKey="stats.du"
|
||||
diskSize={Math.round(systemStats.at(-1)?.stats.d ?? NaN)}
|
||||
/>
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard grid={grid} title="Disk I/O" description="Throughput of root filesystem">
|
||||
<DiskIoChart
|
||||
ticks={ticks}
|
||||
systemData={systemStats}
|
||||
dataKeys={['stats.dw', 'stats.dr']}
|
||||
/>
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard
|
||||
grid={grid}
|
||||
title="Bandwidth"
|
||||
description="Network traffic of public interfaces"
|
||||
>
|
||||
<BandwidthChart ticks={ticks} systemData={systemStats} />
|
||||
</ChartCard>
|
||||
|
||||
{hasDockerStats && dockerNetChartData.length > 0 && (
|
||||
<div
|
||||
ref={netCardRef}
|
||||
className={cn({
|
||||
'col-span-full': !grid,
|
||||
})}
|
||||
>
|
||||
<ChartCard
|
||||
title="Docker Network I/O"
|
||||
description="Includes traffic between internal services"
|
||||
isContainerChart={true}
|
||||
>
|
||||
<ContainerNetChart chartData={dockerNetChartData} ticks={ticks} />
|
||||
</ChartCard>
|
||||
</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 && (
|
||||
<ChartCard grid={grid} title="Temperature" description="Temperatures of system sensors">
|
||||
<TemperatureChart ticks={ticks} systemData={systemStats} />
|
||||
</ChartCard>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* extra filesystem charts */}
|
||||
{Object.keys(systemStats.at(-1)?.stats.efs ?? {}).length > 0 && (
|
||||
<div className="grid lg:grid-cols-2 gap-4">
|
||||
{Object.keys(systemStats.at(-1)?.stats.efs ?? {}).map((extraFsName) => {
|
||||
return (
|
||||
<div key={extraFsName} className="contents">
|
||||
<ChartCard
|
||||
grid={grid}
|
||||
title={`${extraFsName} Usage`}
|
||||
description={`Disk usage of ${extraFsName}`}
|
||||
>
|
||||
<DiskChart
|
||||
ticks={ticks}
|
||||
systemData={systemStats}
|
||||
dataKey={`stats.efs.${extraFsName}.du`}
|
||||
diskSize={Math.round(systemStats.at(-1)?.stats.efs?.[extraFsName].d ?? NaN)}
|
||||
/>
|
||||
</ChartCard>
|
||||
<ChartCard
|
||||
grid={grid}
|
||||
title={`${extraFsName} I/O`}
|
||||
description={`Throughput of ${extraFsName}`}
|
||||
>
|
||||
<DiskIoChart
|
||||
ticks={ticks}
|
||||
systemData={systemStats}
|
||||
dataKeys={[`stats.efs.${extraFsName}.w`, `stats.efs.${extraFsName}.r`]}
|
||||
/>
|
||||
</ChartCard>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* add space for tooltip if more than 12 containers */}
|
||||
{bottomSpacing > 0 && <span className="block" style={{ height: bottomSpacing }} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ContainerFilterBar() {
|
||||
const containerFilter = useStore($containerFilter)
|
||||
|
||||
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
$containerFilter.set(e.target.value)
|
||||
}, []) // Use an empty dependency array to prevent re-creation
|
||||
|
||||
return (
|
||||
<div className="relative py-1 block sm:w-44 sm:absolute sm:top-2.5 sm:right-3.5">
|
||||
<Input
|
||||
placeholder="Filter..."
|
||||
className="pl-4 pr-8"
|
||||
value={containerFilter}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
{containerFilter && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Clear"
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100"
|
||||
onClick={() => $containerFilter.set('')}
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
@@ -319,21 +495,27 @@ function ChartCard({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
grid,
|
||||
isContainerChart,
|
||||
}: {
|
||||
title: string
|
||||
description: string
|
||||
children: React.ReactNode
|
||||
grid?: boolean
|
||||
isContainerChart?: boolean
|
||||
}) {
|
||||
const target = useRef<HTMLDivElement>(null)
|
||||
const [isInViewport, wrappedTargetRef] = useClampedIsInViewport({ target: target })
|
||||
|
||||
return (
|
||||
<Card className="pb-2 sm:pb-4 even:last-of-type:col-span-full" ref={wrappedTargetRef}>
|
||||
<Card
|
||||
className={cn('pb-2 sm:pb-4 odd:last-of-type:col-span-full', { 'col-span-full': !grid })}
|
||||
ref={wrappedTargetRef}
|
||||
>
|
||||
<CardHeader className="pb-5 pt-4 relative space-y-1 max-sm:py-3 max-sm:px-4">
|
||||
<CardTitle className="text-xl sm:text-2xl">{title}</CardTitle>
|
||||
<CardDescription>{description}</CardDescription>
|
||||
{/* <div className="w-full pt-1 sm:w-40 hidden sm:block absolute top-1.5 right-3.5">
|
||||
<ChartTimeSelect />
|
||||
</div> */}
|
||||
{isContainerChart && <ContainerFilterBar />}
|
||||
</CardHeader>
|
||||
<CardContent className="pl-0 w-[calc(100%-1.6em)] h-52 relative">
|
||||
{<Spinner />}
|
||||
|
||||
@@ -72,7 +72,7 @@ function CellFormatter(info: CellContext<SystemRecord, unknown>) {
|
||||
<span
|
||||
className={cn(
|
||||
'absolute inset-0 w-full h-full origin-left',
|
||||
(val < 60 && 'bg-green-500') || (val < 90 && 'bg-yellow-500') || 'bg-red-600'
|
||||
(val < 65 && 'bg-green-500') || (val < 90 && 'bg-yellow-500') || 'bg-red-600'
|
||||
)}
|
||||
style={{ transform: `scalex(${val}%)` }}
|
||||
></span>
|
||||
@@ -135,7 +135,7 @@ export default function SystemsTable({ filter }: { filter?: string }) {
|
||||
<Button
|
||||
data-nolink
|
||||
variant={'ghost'}
|
||||
className="text-foreground/90 h-7 px-1.5 gap-1.5"
|
||||
className="text-primary/90 h-7 px-1.5 gap-1.5"
|
||||
onClick={() => copyToClipboard(info.getValue() as string)}
|
||||
>
|
||||
{info.getValue() as string}
|
||||
@@ -324,7 +324,7 @@ export default function SystemsTable({ filter }: { filter?: string }) {
|
||||
? 'auto'
|
||||
: 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())}
|
||||
</TableCell>
|
||||
|
||||
@@ -9,12 +9,13 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { BellIcon } from 'lucide-react'
|
||||
import { cn, isAdmin } from '@/lib/utils'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { AlertRecord, SystemRecord } from '@/types'
|
||||
import { lazy, Suspense, useMemo, useState } from 'react'
|
||||
import { toast } from './ui/use-toast'
|
||||
import { Link } from './router'
|
||||
|
||||
const Slider = lazy(() => import('./ui/slider'))
|
||||
|
||||
@@ -49,20 +50,13 @@ export default function AlertsButton({ system }: { system: SystemRecord }) {
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-full overflow-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="mb-1">Alerts for {system.name}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isAdmin() && (
|
||||
<span>
|
||||
Please{' '}
|
||||
<a
|
||||
href="/_/#/settings/mail"
|
||||
className="font-medium text-primary opacity-80 hover:opacity-100 duration-100"
|
||||
>
|
||||
configure an SMTP server
|
||||
</a>{' '}
|
||||
to ensure alerts are delivered.{' '}
|
||||
</span>
|
||||
)}
|
||||
<DialogTitle className="text-xl">{system.name} alerts</DialogTitle>
|
||||
<DialogDescription className="mb-1">
|
||||
See{' '}
|
||||
<Link href="/settings/notifications" className="link">
|
||||
notification settings
|
||||
</Link>{' '}
|
||||
to configure how you receive alerts.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-3">
|
||||
@@ -86,7 +80,7 @@ export default function AlertsButton({ system }: { system: SystemRecord }) {
|
||||
alerts={systemAlerts}
|
||||
name="Disk"
|
||||
title="Disk Usage"
|
||||
description="Triggers when disk usage exceeds a threshold."
|
||||
description="Triggers when root usage exceeds a threshold."
|
||||
/>
|
||||
</div>
|
||||
</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 }
|
||||
@@ -100,6 +100,7 @@ const ChartTooltipContent = React.forwardRef<
|
||||
nameKey?: string
|
||||
labelKey?: string
|
||||
unit?: string
|
||||
filter?: string
|
||||
contentFormatter?: (item: any, key: string) => React.ReactNode | string
|
||||
}
|
||||
>(
|
||||
@@ -119,6 +120,7 @@ const ChartTooltipContent = React.forwardRef<
|
||||
nameKey,
|
||||
labelKey,
|
||||
unit,
|
||||
filter,
|
||||
itemSorter,
|
||||
contentFormatter: content = undefined,
|
||||
},
|
||||
@@ -127,6 +129,9 @@ const ChartTooltipContent = React.forwardRef<
|
||||
const { config } = useChart()
|
||||
|
||||
React.useMemo(() => {
|
||||
if (filter) {
|
||||
payload = payload?.filter((item) => (item.name as string)?.includes(filter))
|
||||
}
|
||||
if (itemSorter) {
|
||||
// @ts-ignore
|
||||
payload?.sort(itemSorter)
|
||||
@@ -229,7 +234,7 @@ const ChartTooltipContent = React.forwardRef<
|
||||
</span>
|
||||
</div>
|
||||
{item.value !== undefined && (
|
||||
<span className="font-mono font-medium tabular-nums text-foreground">
|
||||
<span className="font-medium tabular-nums text-foreground">
|
||||
{content && typeof content === 'function'
|
||||
? content(item, key)
|
||||
: item.value.toLocaleString() + (unit ? unit : '')}
|
||||
|
||||
@@ -13,7 +13,7 @@ const Command = React.forwardRef<
|
||||
<CommandPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground',
|
||||
'flex h-full w-full flex-col overflow-hidden bg-popover text-popover-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
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 }
|
||||
@@ -1,117 +1,91 @@
|
||||
import * as React from "react"
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
React.HTMLAttributes<HTMLTableElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
Table.displayName = "Table"
|
||||
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table ref={ref} className={cn('w-full caption-bottom text-sm', className)} {...props} />
|
||||
</div>
|
||||
)
|
||||
)
|
||||
Table.displayName = 'Table'
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
|
||||
))
|
||||
TableHeader.displayName = "TableHeader"
|
||||
TableHeader.displayName = 'TableHeader'
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
<tbody ref={ref} className={cn('[&_tr:last-child]:border-0', className)} {...props} />
|
||||
))
|
||||
TableBody.displayName = "TableBody"
|
||||
TableBody.displayName = 'TableBody'
|
||||
|
||||
const TableFooter = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn('border-t bg-muted/50 font-medium [&>tr]:last:border-b-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableFooter.displayName = "TableFooter"
|
||||
TableFooter.displayName = 'TableFooter'
|
||||
|
||||
const TableRow = React.forwardRef<
|
||||
HTMLTableRowElement,
|
||||
React.HTMLAttributes<HTMLTableRowElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableRow.displayName = "TableRow"
|
||||
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'border-b hover:bg-muted/40 dark:hover:bg-muted/30 data-[state=selected]:bg-muted',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
TableRow.displayName = 'TableRow'
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableHead.displayName = "TableHead"
|
||||
TableHead.displayName = 'TableHead'
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn('p-4 align-middle [&:has([role=checkbox])]:pr-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCell.displayName = "TableCell"
|
||||
TableCell.displayName = 'TableCell'
|
||||
|
||||
const TableCaption = React.forwardRef<
|
||||
HTMLTableCaptionElement,
|
||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||
HTMLTableCaptionElement,
|
||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
<caption ref={ref} className={cn('mt-4 text-sm text-muted-foreground', className)} {...props} />
|
||||
))
|
||||
TableCaption.displayName = "TableCaption"
|
||||
TableCaption.displayName = 'TableCaption'
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption }
|
||||
|
||||
23
beszel/site/src/components/ui/textarea.tsx
Normal file
23
beszel/site/src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
'flex min-h-14 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Textarea.displayName = 'Textarea'
|
||||
|
||||
export { Textarea }
|
||||
@@ -1,6 +1,7 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 30 8% 98.5%;
|
||||
@@ -23,9 +24,16 @@
|
||||
--input: 30 4.29% 72.55%;
|
||||
--ring: 30 3.97% 49.41%;
|
||||
--radius: 0.8rem;
|
||||
/* charts */
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
color-scheme: dark;
|
||||
--background: 240 10% 6.2%;
|
||||
--foreground: 0 0% 98.04%;
|
||||
--card: 240 8.57% 8%;
|
||||
@@ -49,6 +57,20 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Fonts */
|
||||
@supports (font-variation-settings: normal) {
|
||||
:root {
|
||||
font-family: Inter, InterVariable, sans-serif;
|
||||
}
|
||||
}
|
||||
@font-face {
|
||||
font-family: InterVariable;
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url('/static/InterVariable.woff2?v=4.0') format('woff2');
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
@@ -56,6 +78,9 @@
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
.link {
|
||||
@apply text-primary font-medium underline-offset-4 hover:underline;
|
||||
}
|
||||
}
|
||||
|
||||
.recharts-tooltip-wrapper {
|
||||
@@ -63,29 +88,5 @@
|
||||
}
|
||||
|
||||
.recharts-yAxis {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* charts */
|
||||
@layer base {
|
||||
:root {
|
||||
/* --chart-1: 12 76% 61%;
|
||||
--chart-2: 173 58% 39%;
|
||||
--chart-3: 197 37% 24%;
|
||||
--chart-4: 43 74% 66%;
|
||||
--chart-5: 27 87% 67%; */
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
}
|
||||
/*
|
||||
.dark {
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
} */
|
||||
@apply tabular-nums;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import PocketBase from 'pocketbase'
|
||||
import { atom, WritableAtom } from 'nanostores'
|
||||
import { AlertRecord, ChartTimes, SystemRecord } from '@/types'
|
||||
import { atom, map, WritableAtom } from 'nanostores'
|
||||
import { AlertRecord, ChartTimes, SystemRecord, UserSettings } from '@/types'
|
||||
|
||||
/** PocketBase JS Client */
|
||||
export const pb = new PocketBase('/')
|
||||
@@ -22,3 +22,20 @@ export const $hubVersion = atom('')
|
||||
|
||||
/** Chart time period */
|
||||
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 */
|
||||
export const $containerFilter = atom('')
|
||||
|
||||
/** Fallback copy to clipboard dialog content */
|
||||
export const $copyContent = atom('')
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { toast } from '@/components/ui/use-toast'
|
||||
import { type ClassValue, clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { $alerts, $systems, pb } from './stores'
|
||||
import { $alerts, $copyContent, $systems, $userSettings, pb } from './stores'
|
||||
import { AlertRecord, ChartTimeData, ChartTimes, SystemRecord } from '@/types'
|
||||
import { RecordModel, RecordSubscription } from 'pocketbase'
|
||||
import { WritableAtom } from 'nanostores'
|
||||
@@ -22,10 +22,7 @@ export async function copyToClipboard(content: string) {
|
||||
description: 'Copied to clipboard',
|
||||
})
|
||||
} catch (e: any) {
|
||||
prompt(
|
||||
'Automatic copy requires a secure context (https, localhost, or *.localhost). Please copy manually:',
|
||||
content
|
||||
)
|
||||
$copyContent.set(content)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,18 +40,14 @@ const verifyAuth = () => {
|
||||
}
|
||||
|
||||
export const updateSystemList = async () => {
|
||||
// try {
|
||||
const records = await pb.collection<SystemRecord>('systems').getFullList({ sort: '+name' })
|
||||
const records = await pb
|
||||
.collection<SystemRecord>('systems')
|
||||
.getFullList({ sort: '+name', fields: 'id,name,host,info,status' })
|
||||
if (records.length) {
|
||||
$systems.set(records)
|
||||
} else {
|
||||
verifyAuth()
|
||||
}
|
||||
// }
|
||||
// catch (e) {
|
||||
// console.log('verifying auth error', e)
|
||||
// verifyAuth()
|
||||
// }
|
||||
}
|
||||
|
||||
export const updateAlerts = () => {
|
||||
@@ -185,7 +178,7 @@ export const chartTimeData: ChartTimeData = {
|
||||
expectedInterval: 60_000 * 120,
|
||||
label: '1 week',
|
||||
ticks: 7,
|
||||
format: (timestamp: string) => formatShortDate(timestamp),
|
||||
format: (timestamp: string) => formatDay(timestamp),
|
||||
getOffset: (endTime: Date) => timeDay.offset(endTime, -7),
|
||||
},
|
||||
'30d': {
|
||||
@@ -198,22 +191,27 @@ export const chartTimeData: ChartTimeData = {
|
||||
},
|
||||
}
|
||||
|
||||
/** Hacky solution to set the correct width of the yAxis in recharts */
|
||||
export function useYaxisWidth(chartRef: React.RefObject<HTMLDivElement>) {
|
||||
const [yAxisWidth, setYAxisWidth] = useState(180)
|
||||
useEffect(() => {
|
||||
let interval = setInterval(() => {
|
||||
// console.log('chartRef', chartRef.current)
|
||||
const yAxisElement = chartRef?.current?.querySelector('.yAxis')
|
||||
if (yAxisElement) {
|
||||
// console.log('yAxisElement', yAxisElement)
|
||||
clearInterval(interval)
|
||||
setYAxisWidth(yAxisElement.getBoundingClientRect().width + 24)
|
||||
}
|
||||
}, 16)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
return yAxisWidth
|
||||
/** Sets the correct width of the y axis in recharts based on the longest label */
|
||||
export function useYAxisWidth() {
|
||||
const [yAxisWidth, setYAxisWidth] = useState(0)
|
||||
let maxChars = 0
|
||||
let timeout: Timer
|
||||
function updateYAxisWidth(str: string) {
|
||||
if (str.length > maxChars) {
|
||||
maxChars = str.length
|
||||
const div = document.createElement('div')
|
||||
div.className = 'text-xs tabular-nums tracking-tighter table sr-only'
|
||||
div.innerHTML = str
|
||||
clearTimeout(timeout)
|
||||
timeout = setTimeout(() => {
|
||||
document.body.appendChild(div)
|
||||
setYAxisWidth(div.offsetWidth + 24)
|
||||
document.body.removeChild(div)
|
||||
})
|
||||
}
|
||||
return str
|
||||
}
|
||||
return { yAxisWidth, updateYAxisWidth }
|
||||
}
|
||||
|
||||
export function useClampedIsInViewport(options: HookOptions): [boolean | null, CallbackRef] {
|
||||
@@ -241,3 +239,68 @@ export function toFixedWithoutTrailingZeros(num: number, digits: number) {
|
||||
export function toFixedFloat(num: number, digits: number) {
|
||||
return parseFloat(num.toFixed(digits))
|
||||
}
|
||||
|
||||
let twoDecimalFormatter: Intl.NumberFormat
|
||||
/** Format number to two decimal places */
|
||||
export function twoDecimalString(num: number) {
|
||||
if (!twoDecimalFormatter) {
|
||||
twoDecimalFormatter = new Intl.NumberFormat(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})
|
||||
}
|
||||
// Return a function that formats numbers using the saved formatter
|
||||
return twoDecimalFormatter.format(num)
|
||||
}
|
||||
|
||||
/** Get value from local storage */
|
||||
function getStorageValue(key: string, defaultValue: any) {
|
||||
const saved = localStorage?.getItem(key)
|
||||
return saved ? JSON.parse(saved) : defaultValue
|
||||
}
|
||||
|
||||
/** Hook to sync value in local storage */
|
||||
export const useLocalStorage = (key: string, defaultValue: any) => {
|
||||
key = `besz-${key}`
|
||||
const [value, setValue] = useState(() => {
|
||||
return getStorageValue(key, defaultValue)
|
||||
})
|
||||
useEffect(() => {
|
||||
localStorage?.setItem(key, JSON.stringify(value))
|
||||
}, [key, value])
|
||||
|
||||
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,11 +1,26 @@
|
||||
import './index.css'
|
||||
import React, { Suspense, lazy, useEffect } from 'react'
|
||||
import { Suspense, lazy, useEffect, StrictMode } from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import Home from './components/routes/home.tsx'
|
||||
import { ThemeProvider } from './components/theme-provider.tsx'
|
||||
import { $authenticated, $systems, pb, $publicKey, $hubVersion } from './lib/stores.ts'
|
||||
import {
|
||||
$authenticated,
|
||||
$systems,
|
||||
pb,
|
||||
$publicKey,
|
||||
$hubVersion,
|
||||
$copyContent,
|
||||
} from './lib/stores.ts'
|
||||
import { ModeToggle } from './components/mode-toggle.tsx'
|
||||
import { cn, isAdmin, updateAlerts, updateFavicon, updateSystemList } from './lib/utils.ts'
|
||||
import {
|
||||
cn,
|
||||
updateUserSettings,
|
||||
isAdmin,
|
||||
isReadOnlyUser,
|
||||
updateAlerts,
|
||||
updateFavicon,
|
||||
updateSystemList,
|
||||
} from './lib/utils.ts'
|
||||
import { buttonVariants } from './components/ui/button.tsx'
|
||||
import {
|
||||
DatabaseBackupIcon,
|
||||
@@ -13,6 +28,7 @@ import {
|
||||
LogOutIcon,
|
||||
LogsIcon,
|
||||
ServerIcon,
|
||||
SettingsIcon,
|
||||
UserIcon,
|
||||
UsersIcon,
|
||||
} from 'lucide-react'
|
||||
@@ -28,13 +44,15 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuLabel,
|
||||
} 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 { AddSystemButton } from './components/add-system.tsx'
|
||||
|
||||
// const ServerDetail = lazy(() => import('./components/routes/system.tsx'))
|
||||
const CommandPalette = lazy(() => import('./components/command-palette.tsx'))
|
||||
const LoginPage = lazy(() => import('./components/login/login.tsx'))
|
||||
const CopyToClipboardDialog = lazy(() => import('./components/copy-to-clipboard.tsx'))
|
||||
const Settings = lazy(() => import('./components/routes/settings/layout.tsx'))
|
||||
|
||||
const App = () => {
|
||||
const page = useStore($router)
|
||||
@@ -51,9 +69,10 @@ const App = () => {
|
||||
$publicKey.set(data.key)
|
||||
$hubVersion.set(data.v)
|
||||
})
|
||||
// get servers / alerts
|
||||
// get servers / alerts / settings
|
||||
updateSystemList()
|
||||
updateAlerts()
|
||||
updateUserSettings()
|
||||
}, [])
|
||||
|
||||
// update favicon
|
||||
@@ -84,11 +103,18 @@ const App = () => {
|
||||
return <Home />
|
||||
} else if (page.route === 'server') {
|
||||
return <SystemDetail name={page.params.name} />
|
||||
} else if (page.route === 'settings') {
|
||||
return (
|
||||
<Suspense>
|
||||
<Settings />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const Layout = () => {
|
||||
const authenticated = useStore($authenticated)
|
||||
const copyContent = useStore($copyContent)
|
||||
|
||||
if (!authenticated) {
|
||||
return (
|
||||
@@ -102,20 +128,19 @@ const Layout = () => {
|
||||
<>
|
||||
<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">
|
||||
<Link
|
||||
href="/"
|
||||
aria-label="Home"
|
||||
className={'p-2 pl-0'}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
navigate('/')
|
||||
}}
|
||||
>
|
||||
<Link href="/" aria-label="Home" className={'p-2 pl-0'}>
|
||||
<Logo className="h-[1.15em] fill-foreground" />
|
||||
</Link>
|
||||
|
||||
<div className={'flex ml-auto items-center'}>
|
||||
<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>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
@@ -125,7 +150,7 @@ const Layout = () => {
|
||||
<UserIcon className="h-[1.2rem] w-[1.2rem]" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="min-w-44">
|
||||
<DropdownMenuContent align={isReadOnlyUser() ? 'end' : 'center'} className="min-w-44">
|
||||
<DropdownMenuLabel>{pb.authStore.model?.email}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
@@ -180,16 +205,23 @@ const Layout = () => {
|
||||
<Suspense>
|
||||
<CommandPalette />
|
||||
</Suspense>
|
||||
{copyContent && (
|
||||
<Suspense>
|
||||
<CopyToClipboardDialog content={copyContent} />
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('app')!).render(
|
||||
<React.StrictMode>
|
||||
<ThemeProvider>
|
||||
<Layout />
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>
|
||||
// strict mode in dev mounts / unmounts components twice
|
||||
// and breaks the clipboard dialog
|
||||
//<StrictMode>
|
||||
<ThemeProvider>
|
||||
<Layout />
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
//</StrictMode>
|
||||
)
|
||||
|
||||
26
beszel/site/src/types.d.ts
vendored
26
beszel/site/src/types.d.ts
vendored
@@ -10,10 +10,14 @@ export interface SystemRecord extends RecordModel {
|
||||
}
|
||||
|
||||
export interface SystemInfo {
|
||||
/** hostname */
|
||||
h: string
|
||||
/** kernel **/
|
||||
k?: string
|
||||
/** cpu percent */
|
||||
cpu: number
|
||||
/** cpu threads */
|
||||
t: number
|
||||
t?: number
|
||||
/** cpu cores */
|
||||
c: number
|
||||
/** cpu model */
|
||||
@@ -59,6 +63,19 @@ export interface SystemStats {
|
||||
nr: number
|
||||
/** temperatures */
|
||||
t?: Record<string, number>
|
||||
/** extra filesystems */
|
||||
efs?: Record<string, ExtraFsStats>
|
||||
}
|
||||
|
||||
export interface ExtraFsStats {
|
||||
/** disk size (gb) */
|
||||
d: number
|
||||
/** disk used (gb) */
|
||||
du: number
|
||||
/** total read (mb) */
|
||||
r: number
|
||||
/** total write (mb) */
|
||||
w: number
|
||||
}
|
||||
|
||||
export interface ContainerStatsRecord extends RecordModel {
|
||||
@@ -105,3 +122,10 @@ export interface ChartTimeData {
|
||||
getOffset: (endTime: Date) => Date
|
||||
}
|
||||
}
|
||||
|
||||
export type UserSettings = {
|
||||
// lang?: string
|
||||
chartTime: ChartTimes
|
||||
emails?: string[]
|
||||
webhooks?: string[]
|
||||
}
|
||||
|
||||
@@ -16,12 +16,12 @@ module.exports = {
|
||||
'2xl': '1400px',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'sans-serif'],
|
||||
// body: ['Inter', 'sans-serif'],
|
||||
// display: ['Inter', 'sans-serif'],
|
||||
},
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: 'Inter, sans-serif',
|
||||
// body: ['Inter', 'sans-serif'],
|
||||
// display: ['Inter', 'sans-serif'],
|
||||
},
|
||||
screens: {
|
||||
xs: '425px',
|
||||
},
|
||||
@@ -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
|
||||
|
||||
const (
|
||||
Version = "0.2.0"
|
||||
Version = "0.5.0"
|
||||
AppName = "beszel"
|
||||
)
|
||||
|
||||
149
readme.md
149
readme.md
@@ -9,43 +9,43 @@ A lightweight server resource monitoring hub with historical data, docker stats,
|
||||
|
||||
## Features
|
||||
|
||||
- **Lightweight**: Much smaller and less demanding than leading solutions.
|
||||
- **Docker stats**: CPU and memory usage history for each container.
|
||||
- **Alerts**: Configurable alerts for CPU, memory, and disk usage, and system status.
|
||||
- **Multi-user**: Each user has their own systems. Admins can share systems across users.
|
||||
- **Simple**: Easy setup and doesn't require anything to be publicly available online.
|
||||
- **OAuth / OIDC**: Supports many OAuth2 providers. Password auth can be disabled.
|
||||
- **Automatic backups**: Save and restore your data to / from disk or S3-compatible storage.
|
||||
- **REST API**: Use your metrics in your own scripts and applications.
|
||||
- **Lightweight**: Smaller and less resource-intensive than leading solutions.
|
||||
- **Simple**: Easy setup, no need for public internet exposure.
|
||||
- **Docker stats**: Tracks CPU, memory, and network usage history for each container.
|
||||
- **Alerts**: Configurable alerts for CPU, memory, disk usage, and system status.
|
||||
- **Multi-user**: Each user manages their own systems. Admins can share systems across users.
|
||||
- **OAuth / OIDC**: Supports multiple OAuth2 providers. Password authentication can be disabled.
|
||||
- **Automatic backups**: Save and restore data from disk or S3-compatible storage.
|
||||
- **REST API**: Use or update your data in your own scripts and applications.
|
||||
|
||||
## 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
|
||||
|
||||
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)).
|
||||
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.
|
||||
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.
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
@@ -53,11 +53,11 @@ 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.
|
||||
|
||||
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**: The docker version of the agent cannot automatically detect the filesystem to use for disk I/O stats, so include the `FILESYSTEM` environment variable if you want that to work ([instructions here](#finding-the-correct-filesystem)).
|
||||
> **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.
|
||||
|
||||
### Binary
|
||||
|
||||
@@ -94,26 +94,33 @@ PORT=45876 KEY="{PASTE_YOUR_KEY}" ./beszel-agent
|
||||
|
||||
Use `./beszel update` and `./beszel-agent update` to update to the latest version.
|
||||
|
||||
## Environment Variables
|
||||
## Environment variables
|
||||
|
||||
### Hub
|
||||
|
||||
| Name | Default | Description |
|
||||
| ----------------------- | ------- | -------------------------------- |
|
||||
| `DISABLE_PASSWORD_AUTH` | false | Disables password authentication |
|
||||
| Name | Default | Description |
|
||||
| ----------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `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
|
||||
|
||||
| Name | Default | Description |
|
||||
| ------------- | ------- | ------------------------------------------------------------------ |
|
||||
| `DOCKER_HOST` | unset | Overrides the docker host (docker.sock) if using a proxy.[^socket] |
|
||||
| `FILESYSTEM` | unset | Filesystem / partition to use for disk I/O stats. |
|
||||
| `KEY` | unset | Public SSH key to use for authentication. Provided in hub. |
|
||||
| `PORT` | 45876 | Port or address:port to listen on. |
|
||||
| Name | Default | Description |
|
||||
| ------------------- | ------- | ---------------------------------------------------------------------------------------- |
|
||||
| `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) |
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `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`.
|
||||
|
||||
## OAuth / OIDC setup
|
||||
## OAuth / OIDC Setup
|
||||
|
||||
Beszel supports OpenID Connect and many OAuth2 authentication providers (see list below).
|
||||
|
||||
@@ -144,37 +151,62 @@ Visit the "Auth providers" page to enable your provider. The redirect / callback
|
||||
- Twitter
|
||||
- VK
|
||||
- Yandex
|
||||
|
||||
</details>
|
||||
|
||||
## Monitoring additional disks, partitions, or remote mounts
|
||||
|
||||
The method for adding additional disks differs depending on your deployment method.
|
||||
|
||||
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
|
||||
|
||||
Mount a folder from the target filesystem in the container's `/extra-filesystems` directory. For example:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- /mnt/disk1/.beszel:/extra-filesystems/disk1:ro
|
||||
- /dev/mmcblk0/.beszel:/extra-filesystems/sd-card:ro
|
||||
```
|
||||
|
||||
### Binary
|
||||
|
||||
Set the `EXTRA_FILESYSTEMS` environment variable to a comma-separated list of devices, partitions, or mount points to monitor. For example:
|
||||
|
||||
```bash
|
||||
EXTRA_FILESYSTEMS="sdb,sdc1,mmcblk0,/mnt/network-share"
|
||||
```
|
||||
|
||||
## REST API
|
||||
|
||||
Because Beszel is built on PocketBase, you can use the PocketBase [web APIs](https://pocketbase.io/docs/api-records/) and [client-side SDKs](https://pocketbase.io/docs/client-side-sdks/) to read or update data from outside Beszel itself.
|
||||
|
||||
## 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.
|
||||
|
||||
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
|
||||
|
||||
### 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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
### 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
|
||||
|
||||
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
|
||||
|
||||
@@ -182,38 +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:
|
||||
|
||||
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.
|
||||
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.
|
||||
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, 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
|
||||
|
||||
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
|
||||
|
||||
The filesystem / 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 filesystem 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 `df -h` and choose an option under "Filesystem"
|
||||
- Run `lsblk` and choose an option under "NAME"
|
||||
- Run `sudo fdisk -l` and choose an option under "Device"
|
||||
- Run `lsblk` and choose an option under "NAME."
|
||||
- Run `df -h` and choose an option under "Filesystem."
|
||||
- Run `sudo fdisk -l` and choose an option under "Device."
|
||||
|
||||
### Docker containers are not populating reliably
|
||||
### Docker container charts are empty or missing
|
||||
|
||||
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.
|
||||
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/>
|
||||
|
||||
### Docker Containers Are Not Populating Reliably
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
@@ -261,7 +299,6 @@ 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`.
|
||||
|
||||
<!--
|
||||
## Support
|
||||
## License
|
||||
|
||||
My country, the USA, and many others, are actively involved in the genocide of the Palestinian people. I would greatly appreciate any effort you could make to pressure your government to stop enabling this violence. -->
|
||||
Beszel is licensed under the MIT License. See the [LICENSE](LICENSE) file for more details.
|
||||
|
||||
@@ -6,7 +6,9 @@ services:
|
||||
network_mode: host
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
# monitor other disks / partitions by mounting a folder in /extra-filesystems
|
||||
# - /mnt/disk1/.beszel:/extra-filesystems/disk1:ro
|
||||
environment:
|
||||
PORT: 45876
|
||||
KEY: 'ssh-ed25519 YOUR_PUBLIC_KEY'
|
||||
# FILESYSTEM: /dev/sda1 # set to the correct filesystem for disk I/O stats
|
||||
# FILESYSTEM: /dev/sda1 # override the root partition / device for disk I/O stats
|
||||
|
||||
@@ -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.
|
||||
|
||||
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]
|
||||
> 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
|
||||
|
||||
### Hub
|
||||
|
||||
1. Create the system service at `/etc/systemd/system/beszel.service`
|
||||
|
||||
```bash
|
||||
@@ -97,9 +101,7 @@ sudo systemctl enable beszel.service
|
||||
sudo systemctl start beszel.service
|
||||
```
|
||||
|
||||
## Run the agent as a system service (Linux)
|
||||
|
||||
This runs the agent in the background continuously using systemd.
|
||||
### Agent
|
||||
|
||||
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)
|
||||
Environment="PORT={PASTE_YOUR_PORT_HERE}"
|
||||
Environment="KEY={PASTE_YOUR_KEY_HERE}"
|
||||
# Environment="EXTRA_FILESYSTEMS={sdb}"
|
||||
ExecStart={/path/to/directory}/beszel-agent
|
||||
User={YOUR_USERNAME}
|
||||
Restart=always
|
||||
|
||||
@@ -106,6 +106,7 @@ After=network.target
|
||||
[Service]
|
||||
Environment="PORT=$PORT"
|
||||
Environment="KEY=$KEY"
|
||||
# Environment="EXTRA_FILESYSTEMS=sdb"
|
||||
ExecStart=/opt/beszel-agent/beszel-agent
|
||||
User=beszel
|
||||
Restart=always
|
||||
|
||||
Reference in New Issue
Block a user