Compare commits

...

24 Commits

Author SHA1 Message Date
Henry Dollman
d2284c3fed release 0.3.0 2024-09-03 18:37:02 -04:00
Henry Dollman
eb420bef3a update go deps 2024-09-03 18:31:01 -04:00
Henry Dollman
9cf6c167b0 don't reset container filter when changing chart time 2024-09-03 18:26:28 -04:00
Henry Dollman
fbc7f79660 add instructions for monitoring additional disks / partitions 2024-09-03 18:23:09 -04:00
Henry Dollman
37170f2bdb refactor network io stats collection (ref #150) 2024-09-03 18:10:45 -04:00
Henry Dollman
af4c05e692 standardize chart values to two decimals 2024-09-03 16:49:12 -04:00
Henry Dollman
202a506485 add dialog for copy to clipboard fallback (fixes #152) 2024-09-02 19:37:44 -04:00
Henry Dollman
aa3866c8ed better alignment for readonly user account menu 2024-09-02 17:32:59 -04:00
Henry Dollman
f9c0d0b89d don't cache mail client (fixes #149) 2024-09-02 16:34:53 -04:00
Henry Dollman
ec5b1a833d extra fs charts and filter bar for container charts 2024-09-02 16:13:07 -04:00
Henry Dollman
1cfda8fb9f add icons to theme menu 2024-09-02 15:56:57 -04:00
Henry Dollman
2168db6ebd remove toggle component 2024-09-02 15:56:14 -04:00
Henry Dollman
e64ef49e97 update js deps / exclude package-lock.json 2024-09-02 15:49:41 -04:00
Henry Dollman
54e0240dd8 remove external fonts 2024-09-02 12:12:52 -04:00
Henry Dollman
05f52ad15a update readme 2024-09-01 19:17:17 -04:00
Henry Dollman
8ffb3a0cc8 update agent docker-compose.yml 2024-09-01 18:50:38 -04:00
Henry Dollman
953d7cac1e use GOGC=75 for docker images 2024-09-01 18:25:40 -04:00
Henry Dollman
1cfd3cdd30 add support for multiple disks 2024-09-01 18:23:57 -04:00
Henry Dollman
b4a3cb9ce6 add timeout to ssh session creation to avoid hanging 2024-08-30 19:06:16 -04:00
Henry Dollman
7a6fbc8346 add toggle for chart grid layout 2024-08-25 18:19:47 -04:00
Henry Dollman
76cffb16de formatting 2024-08-25 18:19:03 -04:00
Henry Dollman
13f7d016e6 fix blurry command pallette on some setups (closes #137) 2024-08-25 16:20:58 -04:00
Henry Dollman
7a8dccfc97 use css color-scheme instead of custom scrollbar styles 2024-08-25 16:19:31 -04:00
Stavros
68824935e9 feat: style scrollbar (#135)
* feat: style scrollbar

* refactor(index.css): increase the border radius a bit

* chore: revert prettier styling
2024-08-25 13:05:55 -04:00
47 changed files with 1329 additions and 5204 deletions

1
.gitignore vendored
View File

@@ -11,3 +11,4 @@ dist
beszel/cmd/hub/hub beszel/cmd/hub/hub
beszel/cmd/agent/agent beszel/cmd/agent/agent
node_modules node_modules
package-lock.json

View File

@@ -10,7 +10,7 @@ COPY internal ./internal
# Build # Build
ARG TARGETOS TARGETARCH 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 FROM scratch

View File

@@ -22,7 +22,7 @@ RUN update-ca-certificates
# Build # Build
ARG TARGETOS TARGETARCH 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 FROM scratch

View File

@@ -8,9 +8,9 @@ require (
github.com/goccy/go-json v0.10.3 github.com/goccy/go-json v0.10.3
github.com/labstack/echo/v5 v5.0.0-20230722203903-ec5b858dab61 github.com/labstack/echo/v5 v5.0.0-20230722203903-ec5b858dab61
github.com/pocketbase/dbx v1.10.1 github.com/pocketbase/dbx v1.10.1
github.com/pocketbase/pocketbase v0.22.19 github.com/pocketbase/pocketbase v0.22.20
github.com/rhysd/go-github-selfupdate v1.2.3 github.com/rhysd/go-github-selfupdate v1.2.3
github.com/shirou/gopsutil/v4 v4.24.7 github.com/shirou/gopsutil/v4 v4.24.8
github.com/spf13/cobra v1.8.1 github.com/spf13/cobra v1.8.1
golang.org/x/crypto v0.26.0 golang.org/x/crypto v0.26.0
) )
@@ -19,24 +19,24 @@ require (
github.com/AlecAivazis/survey/v2 v2.3.7 // indirect github.com/AlecAivazis/survey/v2 v2.3.7 // indirect
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/aws/aws-sdk-go-v2 v1.30.4 // indirect github.com/aws/aws-sdk-go-v2 v1.30.5 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.4 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.4 // indirect
github.com/aws/aws-sdk-go-v2/config v1.27.28 // indirect github.com/aws/aws-sdk-go-v2/config v1.27.32 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.17.28 // indirect github.com/aws/aws-sdk-go-v2/credentials v1.17.31 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.12 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.13 // indirect
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.12 // indirect github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.17 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.16 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.17 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.16 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.17 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.16 // indirect github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.17 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.4 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.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/checksum v1.3.19 // 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/presigned-url v1.11.19 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.16 // indirect github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.17 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.60.0 // indirect github.com/aws/aws-sdk-go-v2/service/s3 v1.61.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.22.5 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.22.6 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.5 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.6 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.30.4 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.30.6 // indirect
github.com/aws/smithy-go v1.20.4 // indirect github.com/aws/smithy-go v1.20.4 // indirect
github.com/disintegration/imaging v1.6.2 // indirect github.com/disintegration/imaging v1.6.2 // indirect
github.com/domodwyer/mailyak/v3 v3.6.2 // indirect github.com/domodwyer/mailyak/v3 v3.6.2 // indirect
@@ -84,13 +84,13 @@ require (
golang.org/x/term v0.23.0 // indirect golang.org/x/term v0.23.0 // indirect
golang.org/x/text v0.17.0 // indirect golang.org/x/text v0.17.0 // indirect
golang.org/x/time v0.6.0 // indirect golang.org/x/time v0.6.0 // indirect
golang.org/x/xerrors v0.0.0-20240716161551-93cc26a95ae9 // indirect golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
google.golang.org/api v0.194.0 // indirect google.golang.org/api v0.196.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240822170219-fc7c04adadcd // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect
google.golang.org/grpc v1.65.0 // indirect google.golang.org/grpc v1.66.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect google.golang.org/protobuf v1.34.2 // indirect
modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a // indirect modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a // indirect
modernc.org/libc v1.59.9 // indirect modernc.org/libc v1.60.1 // indirect
modernc.org/mathutil v1.6.0 // indirect modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.8.0 // indirect modernc.org/memory v1.8.0 // indirect
modernc.org/sqlite v1.32.0 // indirect modernc.org/sqlite v1.32.0 // indirect

View File

@@ -1,8 +1,8 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.115.1 h1:Jo0SM9cQnSkYfp44+v+NQXHpcHqlnRJk2qxh6yvxxxQ= cloud.google.com/go v0.115.1 h1:Jo0SM9cQnSkYfp44+v+NQXHpcHqlnRJk2qxh6yvxxxQ=
cloud.google.com/go v0.115.1/go.mod h1:DuujITeaufu3gL68/lOFIirVNJwQeyf5UXyi+Wbgknc= cloud.google.com/go v0.115.1/go.mod h1:DuujITeaufu3gL68/lOFIirVNJwQeyf5UXyi+Wbgknc=
cloud.google.com/go/auth v0.9.1 h1:+pMtLEV2k0AXKvs/tGZojuj6QaioxfUjOpMsG5Gtx+w= cloud.google.com/go/auth v0.9.3 h1:VOEUIAADkkLtyfr3BLa3R8Ed/j6w1jTBmARx+wb5w5U=
cloud.google.com/go/auth v0.9.1/go.mod h1:Sw8ocT5mhhXxFklyhT12Eiy0ed6tTrPMCJjSI8KhYLk= cloud.google.com/go/auth v0.9.3/go.mod h1:7z6VY+7h3KUdRov5F1i8NDP5ZzWKYmEPO842BgCsmTk=
cloud.google.com/go/auth/oauth2adapt v0.2.4 h1:0GWE/FUsXhf6C+jAkWgYm7X9tK8cuEIfy19DBn6B6bY= cloud.google.com/go/auth/oauth2adapt v0.2.4 h1:0GWE/FUsXhf6C+jAkWgYm7X9tK8cuEIfy19DBn6B6bY=
cloud.google.com/go/auth/oauth2adapt v0.2.4/go.mod h1:jC/jOpwFP6JBxhB3P5Rr0a9HLMC/Pe3eaL4NmdvqPtc= cloud.google.com/go/auth/oauth2adapt v0.2.4/go.mod h1:jC/jOpwFP6JBxhB3P5Rr0a9HLMC/Pe3eaL4NmdvqPtc=
cloud.google.com/go/compute/metadata v0.5.0 h1:Zr0eK8JbFv6+Wi4ilXAR8FJ3wyNdpxHKJNPos6LTZOY= cloud.google.com/go/compute/metadata v0.5.0 h1:Zr0eK8JbFv6+Wi4ilXAR8FJ3wyNdpxHKJNPos6LTZOY=
@@ -25,42 +25,42 @@ github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3d
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
github.com/aws/aws-sdk-go v1.55.5 h1:KKUZBfBoyqy5d3swXyiC7Q76ic40rYcbqH7qjh59kzU= github.com/aws/aws-sdk-go v1.55.5 h1:KKUZBfBoyqy5d3swXyiC7Q76ic40rYcbqH7qjh59kzU=
github.com/aws/aws-sdk-go v1.55.5/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= github.com/aws/aws-sdk-go v1.55.5/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
github.com/aws/aws-sdk-go-v2 v1.30.4 h1:frhcagrVNrzmT95RJImMHgabt99vkXGslubDaDagTk8= github.com/aws/aws-sdk-go-v2 v1.30.5 h1:mWSRTwQAb0aLE17dSzztCVJWI9+cRMgqebndjwDyK0g=
github.com/aws/aws-sdk-go-v2 v1.30.4/go.mod h1:CT+ZPWXbYrci8chcARI3OmI/qgd+f6WtuLOoaIA8PR0= github.com/aws/aws-sdk-go-v2 v1.30.5/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 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/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.32 h1:jnAMVTJTpAQlePCUUlnXnllHEMGVWmvUJOiGjgtS9S0=
github.com/aws/aws-sdk-go-v2/config v1.27.28/go.mod h1:uzVRVtJSU5EFv6Fu82AoVFKozJi2ZCY6WRCXj06rbvs= github.com/aws/aws-sdk-go-v2/config v1.27.32/go.mod h1:JibtzKJoXT0M/MhoYL6qfCk7nm/MppwukDFZtdgVRoY=
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.31 h1:jtyfcOfgoqWA2hW/E8sFbwdfgwD3APnF9CLCKE8dTyw=
github.com/aws/aws-sdk-go-v2/credentials v1.17.28/go.mod h1:6TF7dSc78ehD1SL6KpRIPKMA1GyyWflIkjqg+qmf4+c= github.com/aws/aws-sdk-go-v2/credentials v1.17.31/go.mod h1:RSgY5lfCfw+FoyKWtOpLolPlfQVdDBQWTUniAaE+NKY=
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.13 h1:pfQ2sqNpMVK6xz2RbqLEL0GH87JOwSxPV2rzm8Zsb74=
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/ec2/imds v1.16.13/go.mod h1:NG7RXPUlqfsCLLFfi0+IpKN4sCB9D9fw/qTaSB+xRoU=
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.17 h1:QbV9wh6vtB3UAZvdfktPj8jT+w6yIrKYd4PngLWDmCE=
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/feature/s3/manager v1.17.17/go.mod h1:0trBfk2z3LEozr2WZz7IxcRJWl2jv0Ro7JpByqh3coQ=
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.17 h1:pI7Bzt0BJtYA0N/JEC6B8fJ4RBrEMi1LBrkMdFYNSnQ=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.16/go.mod h1:2DwJF39FlNAUiX5pAc0UNeiz16lK2t7IaFcm0LFHEgc= github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.17/go.mod h1:Dh5zzJYMtxfIjYW+/evjQ8uj2OyR/ve2KROHGHlSFqE=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.16 h1:jYfy8UPmd+6kJW5YhY0L1/KftReOGxI/4NtVSTh9O/I= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.17 h1:Mqr/V5gvrhA2gvgnF42Zh5iMiQNcOYthFYwCyrnuWlc=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.16/go.mod h1:7ZfEPZxkW42Afq4uQB8H2E2e6ebh6mXTueEpYzjCzcs= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.17/go.mod h1:aLJpZlCmjE+V+KtN1q1uyZkfnUWpQGpbsn89XPKyzfU=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 h1:VaRN3TlFdd6KxX1x3ILT5ynH6HvKgqdiXoTxAF4HQcQ= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 h1:VaRN3TlFdd6KxX1x3ILT5ynH6HvKgqdiXoTxAF4HQcQ=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.16 h1:mimdLQkIX1zr8GIPY1ZtALdBQGxcASiBd2MOp8m/dMc= github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.17 h1:Roo69qTpfu8OlJ2Tb7pAYVuF0CpuUMB0IYWwYP/4DZM=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.16/go.mod h1:YHk6owoSwrIsok+cAH9PENCOGoH5PU2EllX4vLtSrsY= github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.17/go.mod h1:NcWPxQzGM1USQggaTVwz6VpqMZPX1CvDJLDh6jnOCa4=
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 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/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.19 h1:FLMkfEiRjhgeDTCjjLoc3URo/TBkgeQbocA78lfkzSI=
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/checksum v1.3.19/go.mod h1:Vx+GucNSsdhaxs3aZIKfSUjKVGsxN25nX2SRcdhuw08=
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.19 h1:rfprUlsdzgl7ZL2KlXiUAoJnI/VxfHCvDFr2QDFj6u4=
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/presigned-url v1.11.19/go.mod h1:SCWkEdRq8/7EK60NcvvQ6NXKuTcchAD4ROAsC37VEZE=
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.17 h1:u+EfGmksnJc/x5tq3A+OD7LrMbSSR/5TrKLvkdy/fhY=
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/internal/s3shared v1.17.17/go.mod h1:VaMx6302JHax2vHJWgRo+5n9zvbacs3bLU/23DNQrTY=
github.com/aws/aws-sdk-go-v2/service/s3 v1.60.0 h1:2QXGJvG19QwqXUvgcdoCOZPyLuvZf8LiXPCN4P53TdI= github.com/aws/aws-sdk-go-v2/service/s3 v1.61.1 h1:6ZRIbdMbN83W2/EIAU5z8FQZpmuULsBojTaok+uBEIg=
github.com/aws/aws-sdk-go-v2/service/s3 v1.60.0/go.mod h1:BSPI0EfnYUuNHPS0uqIo5VrRwzie+Fp+YhQOUs16sKI= github.com/aws/aws-sdk-go-v2/service/s3 v1.61.1/go.mod h1:5FmD/Dqq57gP+XwaUnd5WFPipAuzrf0HmupX27Gvjvc=
github.com/aws/aws-sdk-go-v2/service/sso v1.22.5 h1:zCsFCKvbj25i7p1u94imVoO447I/sFv8qq+lGJhRN0c= github.com/aws/aws-sdk-go-v2/service/sso v1.22.6 h1:o++HUDXlbrTl4PSal3YHtdErQxB8mDGAtkKNXBWPfIU=
github.com/aws/aws-sdk-go-v2/service/sso v1.22.5/go.mod h1:ZeDX1SnKsVlejeuz41GiajjZpRSWR7/42q/EyA/QEiM= github.com/aws/aws-sdk-go-v2/service/sso v1.22.6/go.mod h1:eEygMHnTKH/3kNp9Jr1n3PdejuSNcgwLe1dWgQtO0VQ=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.5 h1:SKvPgvdvmiTWoi0GAJ7AsJfOz3ngVkD/ERbs5pUnHNI= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.6 h1:yCHcQCOwTfIsc8DoEhM3qXPxD+j8CbI6t1K3dNzsWV0=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.5/go.mod h1:20sz31hv/WsPa3HhU3hfrIet2kxM4Pe0r20eBZ20Tac= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.6/go.mod h1:bCbAxKDqNvkHxRaIMnyVPXPo+OaPRwvmgzMxbz1VKSA=
github.com/aws/aws-sdk-go-v2/service/sts v1.30.4 h1:iAckBT2OeEK/kBDyN/jDtpEExhjeeA/Im2q4X0rJZT8= github.com/aws/aws-sdk-go-v2/service/sts v1.30.6 h1:TrQadF7GcqvQ63kgwEcjlrVc2Fa0wpgLT0xtc73uAd8=
github.com/aws/aws-sdk-go-v2/service/sts v1.30.4/go.mod h1:vmSqFK+BVIwVpDAGZB3CoCXHzurt4qBE8lf+I/kRTh0= github.com/aws/aws-sdk-go-v2/service/sts v1.30.6/go.mod h1:NXi1dIAGteSaRLqYgarlhP/Ij0cFT+qmCwiJqWh/U5o=
github.com/aws/smithy-go v1.20.4 h1:2HK1zBdPgRbjFOHlfeQZfpC4r72MOb9bZkiFwggKO+4= 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/smithy-go v1.20.4/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg=
github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ=
@@ -144,8 +144,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.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 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 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-20240827171923-fa2c70bbbfe5 h1:5iH8iuqE5apketRbSFBy+X1V0o+l+8NF1avt4HWl7cA=
github.com/google/pprof v0.0.0-20240625030939-27f56978b8b0/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo= 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 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM=
github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA= 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= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@@ -153,8 +153,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/wire v0.6.0 h1:HBkoIh4BdSxoyo9PveV8giw7ZsaBOvzWKfcg/6MrVwI= github.com/google/wire v0.6.0 h1:HBkoIh4BdSxoyo9PveV8giw7ZsaBOvzWKfcg/6MrVwI=
github.com/google/wire v0.6.0/go.mod h1:F4QhpQ9EDIdJ1Mbop/NZBRB+5yrR6qg3BnctaoUk6NA= github.com/google/wire v0.6.0/go.mod h1:F4QhpQ9EDIdJ1Mbop/NZBRB+5yrR6qg3BnctaoUk6NA=
github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs= github.com/googleapis/enterprise-certificate-proxy v0.3.3 h1:QRje2j5GZimBzlbhGA2V2QlGNgL8G6e+wGo/+/2bWI0=
github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0= github.com/googleapis/enterprise-certificate-proxy v0.3.3/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA=
github.com/googleapis/gax-go/v2 v2.13.0 h1:yitjD5f7jQHhyDsnhKEBU52NdvvdSeGzlAnDPT0hH1s= github.com/googleapis/gax-go/v2 v2.13.0 h1:yitjD5f7jQHhyDsnhKEBU52NdvvdSeGzlAnDPT0hH1s=
github.com/googleapis/gax-go/v2 v2.13.0/go.mod h1:Z/fvTZXF8/uw7Xu5GuslPw+bplx6SS338j1Is2S+B7A= github.com/googleapis/gax-go/v2 v2.13.0/go.mod h1:Z/fvTZXF8/uw7Xu5GuslPw+bplx6SS338j1Is2S+B7A=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
@@ -202,8 +202,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pocketbase/dbx v1.10.1 h1:cw+vsyfCJD8YObOVeqb93YErnlxwYMkNZ4rwN0G0AaA= github.com/pocketbase/dbx v1.10.1 h1:cw+vsyfCJD8YObOVeqb93YErnlxwYMkNZ4rwN0G0AaA=
github.com/pocketbase/dbx v1.10.1/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs= github.com/pocketbase/dbx v1.10.1/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
github.com/pocketbase/pocketbase v0.22.19 h1:Hu9J2nsRQIaw8MiDLzE65xUPyMPjf4DcS2f+QmH1G+c= github.com/pocketbase/pocketbase v0.22.20 h1:yUkhO5bTPWlzD4ZK6EQlS4R3AcHKDlBD+DxxU2BR83I=
github.com/pocketbase/pocketbase v0.22.19/go.mod h1:0QFvDOOW7ANId78ChZSagyHbmP6CgMxDQrQFXzeaDpA= github.com/pocketbase/pocketbase v0.22.20/go.mod h1:Cw5E4uoGhKItBIE2lJL3NfmiUr9Syk2xaNJ2G7Dssow=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
@@ -214,8 +214,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 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 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/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.8 h1:pVQjIenQkIhqO81mwTaXjTzOMT7d3TZkf43PlVFHENI=
github.com/shirou/gopsutil/v4 v4.24.7/go.mod h1:0uW/073rP7FYLOkvxolUQM5rMOLTNmRXnFKafpb71rw= 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 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU= github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=
@@ -254,16 +254,16 @@ github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= 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 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= 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.54.0 h1:r6I7RJCN86bpD/FQwedZ0vSixDpwuWREjW9oRMsmqDc=
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/google.golang.org/grpc/otelgrpc v0.54.0/go.mod h1:B9yO6b04uB80CzjedvewuqDhxJxi11s7/GtiGa8bAjI=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 h1:4K4tsIXefpVJtvA/8srF4V4y0akAoPHkIslgAkjixJA= 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.53.0/go.mod h1:jjdQuTGVsXV4vSs+CJ2qYDeDPf9yIJV23qlIzBm73Vg= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
go.opentelemetry.io/otel v1.28.0 h1:/SqNcYk+idO0CxKEUOtKQClMK/MimZihKYMruSMViUo= go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
go.opentelemetry.io/otel v1.28.0/go.mod h1:q68ijF8Fc8CnMHKyzqL6akLO46ePnjkgfIMIjUIX9z4= go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8=
go.opentelemetry.io/otel/metric v1.28.0 h1:f0HGvSl1KRAU1DLgLGFjrwVyismPlnuU6JD6bOeuA5Q= go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc=
go.opentelemetry.io/otel/metric v1.28.0/go.mod h1:Fb1eVBFZmLVTMb6PPohq3TO9IIhUisDsbJoL/+uQW4s= go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8=
go.opentelemetry.io/otel/trace v1.28.0 h1:GhQ9cUuQGmNDd5BTCP2dAvv75RdMxEfTmYejp+lkx9g= go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4=
go.opentelemetry.io/otel/trace v1.28.0/go.mod h1:jPyXzNPg6da9+38HEwElrQiHlVMTnVfM3/yv2OlIHaI= 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 h1:EYABYGhAalPUaMrbSKOr5lejxoxvXj99nE8XFtsDgds=
gocloud.dev v0.39.0/go.mod h1:drz+VyYNBvrMTW0KZiBAYEdl8lbNZx+OQ7oQvdrFmSQ= 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-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
@@ -349,10 +349,10 @@ 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.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20240716161551-93cc26a95ae9 h1:LLhsEBxRTBLuKlQxFBYUOU8xyFgXv6cOTp2HASDlsDk= golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
golang.org/x/xerrors v0.0.0-20240716161551-93cc26a95ae9/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
google.golang.org/api v0.194.0 h1:dztZKG9HgtIpbI35FhfuSNR/zmaMVdxNlntHj1sIS4s= google.golang.org/api v0.196.0 h1:k/RafYqebaIJBO3+SMnfEGtFVlvp5vSgqTUF54UN/zg=
google.golang.org/api v0.194.0/go.mod h1:AgvUFdojGANh3vI+P7EVnxj3AISHllxGCJSFmggmnd0= google.golang.org/api v0.196.0/go.mod h1:g9IL21uGkYgvQ5BZg6BAtoGJQIm8r6EgaAbpNey5wBE=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
@@ -360,19 +360,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-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-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-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-20240903143218-8af14fe29dc1 h1:BulPr26Jqjnd4eYDVe+YvyR7Yc2vJGkO5/0UxD0/jZU=
google.golang.org/genproto v0.0.0-20240814211410-ddb44dafa142/go.mod h1:G11eXq53iI5Q+kyNOmCvnzBaxEA2Q/Ik5Tj7nqBE8j4= google.golang.org/genproto v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:hL97c3SYopEHblzpxRL4lSs523++l8DYxGM1FQiYmb4=
google.golang.org/genproto/googleapis/api v0.0.0-20240812133136-8ffd90a71988 h1:+/tmTy5zAieooKIXfzDm9KiA3Bv6JBwriRN9LY+yayk= google.golang.org/genproto/googleapis/api v0.0.0-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/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-20240903143218-8af14fe29dc1 h1:pPJltXNxVzT4pK9yD8vR9X75DaWYYmLGMsEvBfFQZzQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240822170219-fc7c04adadcd/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU= google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
google.golang.org/grpc v1.65.0 h1:bs/cUb4lp1G5iImFFd3u5ixQzweKizoZJAwBNLR42lc= google.golang.org/grpc v1.66.0 h1:DibZuoBznOxbDQxRINckZcUvnCEvrW9pcWIE2yF9r1c=
google.golang.org/grpc v1.65.0/go.mod h1:WgYC2ypjlB0EiQi6wdKixMqukr6lBc0Vo+oOgjrM5ZQ= google.golang.org/grpc v1.66.0/go.mod h1:s3/l6xSSCURdVfAnL+TqCNMyTDAGN6+lZeVxnZR128Y=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
@@ -398,16 +398,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= 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 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= 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.21.0 h1:kKPI3dF7RIag8YcToh5ZwDcVMIv6VGa0ED5cvh0LMW4=
modernc.org/ccgo/v4 v4.20.7/go.mod h1:UOkI3JSG2zT4E2ioHlncSOZsXbuDCZLvPi3uMlZT5GY= 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 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= 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 h1:bJ9ChznK1L1mUtAQtxi0wi5AtAs5jQuw4PrPHO5pb6M=
modernc.org/gc/v2 v2.5.0/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= modernc.org/gc/v2 v2.5.0/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a h1:CfbpOLEo2IwNzJdMvE8aiRbPMxoTpgAJeyePh0SmO8M= modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a h1:CfbpOLEo2IwNzJdMvE8aiRbPMxoTpgAJeyePh0SmO8M=
modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
modernc.org/libc v1.59.9 h1:k+nNDDakwipimgmJ1D9H466LhFeSkaPPycAs1OZiDmY= modernc.org/libc v1.60.1 h1:at373l8IFRTkJIkAU85BIuUoBM4T1b51ds0E1ovPG2s=
modernc.org/libc v1.59.9/go.mod h1:EY/egGEU7Ju66eU6SBqCNYaFUDuc4npICkMWnU5EE3A= modernc.org/libc v1.60.1/go.mod h1:xJuobKuNxKH3RUatS7GjR+suWj+5c2K7bi4m/S5arOY=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=

View File

@@ -36,10 +36,11 @@ type Agent struct {
sem chan struct{} sem chan struct{}
containerStatsMap map[string]*container.PrevContainerStats containerStatsMap map[string]*container.PrevContainerStats
containerStatsMutex *sync.Mutex containerStatsMutex *sync.Mutex
diskIoStats *system.DiskIoStats fsNames []string
fsStats map[string]*system.FsStats
netInterfaces map[string]struct{}
netIoStats *system.NetIoStats netIoStats *system.NetIoStats
dockerClient *http.Client dockerClient *http.Client
containerStatsPool *sync.Pool
bufferPool *sync.Pool bufferPool *sync.Pool
} }
@@ -50,14 +51,8 @@ func NewAgent(pubKey []byte, addr string) *Agent {
sem: make(chan struct{}, 15), sem: make(chan struct{}, 15),
containerStatsMap: make(map[string]*container.PrevContainerStats), containerStatsMap: make(map[string]*container.PrevContainerStats),
containerStatsMutex: &sync.Mutex{}, containerStatsMutex: &sync.Mutex{},
diskIoStats: &system.DiskIoStats{},
netIoStats: &system.NetIoStats{}, netIoStats: &system.NetIoStats{},
dockerClient: newDockerClient(), dockerClient: newDockerClient(),
containerStatsPool: &sync.Pool{
New: func() interface{} {
return new(container.Stats)
},
},
bufferPool: &sync.Pool{ bufferPool: &sync.Pool{
New: func() interface{} { New: func() interface{} {
return new(bytes.Buffer) return new(bytes.Buffer)
@@ -74,8 +69,8 @@ func (a *Agent) releaseSemaphore() {
<-a.sem <-a.sem
} }
func (a *Agent) getSystemStats() (*system.Info, *system.Stats) { func (a *Agent) getSystemStats() (system.Info, system.Stats) {
systemStats := &system.Stats{} systemStats := system.Stats{}
// cpu percent // cpu percent
cpuPct, err := cpu.Percent(0, false) cpuPct, err := cpu.Percent(0, false)
@@ -96,34 +91,59 @@ func (a *Agent) getSystemStats() (*system.Info, *system.Stats) {
} }
// disk usage // disk usage
if d, err := disk.Usage("/"); err == nil { for _, stats := range a.fsStats {
systemStats.Disk = bytesToGigabytes(d.Total) // log.Println("Reading filesystem:", fs.Mountpoint)
if d, err := disk.Usage(stats.Mountpoint); err == nil {
stats.DiskTotal = bytesToGigabytes(d.Total)
stats.DiskUsed = bytesToGigabytes(d.Used)
if stats.Root {
systemStats.DiskTotal = bytesToGigabytes(d.Total)
systemStats.DiskUsed = bytesToGigabytes(d.Used) systemStats.DiskUsed = bytesToGigabytes(d.Used)
systemStats.DiskPct = twoDecimals(d.UsedPercent) systemStats.DiskPct = twoDecimals(d.UsedPercent)
} }
} else {
// reset stats if error (likely unmounted)
log.Printf("Error reading %s: %+v\n", stats.Mountpoint, err)
stats.DiskTotal = 0
stats.DiskUsed = 0
stats.TotalRead = 0
stats.TotalWrite = 0
}
}
// disk i/o // disk i/o
if io, err := disk.IOCounters(a.diskIoStats.Filesystem); err == nil { if ioCounters, err := disk.IOCounters(a.fsNames...); err == nil {
for _, d := range io { for _, d := range ioCounters {
// add to systemStats stats := a.fsStats[d.Name]
secondsElapsed := time.Since(a.diskIoStats.Time).Seconds() if stats == nil {
readPerSecond := float64(d.ReadBytes-a.diskIoStats.Read) / secondsElapsed continue
systemStats.DiskRead = bytesToMegabytes(readPerSecond) }
writePerSecond := float64(d.WriteBytes-a.diskIoStats.Write) / secondsElapsed secondsElapsed := time.Since(stats.Time).Seconds()
systemStats.DiskWrite = bytesToMegabytes(writePerSecond) readPerSecond := float64(d.ReadBytes-stats.TotalRead) / secondsElapsed
// update diskIoStats writePerSecond := float64(d.WriteBytes-stats.TotalWrite) / secondsElapsed
a.diskIoStats.Time = time.Now() stats.Time = time.Now()
a.diskIoStats.Read = d.ReadBytes stats.DiskReadPs = bytesToMegabytes(readPerSecond)
a.diskIoStats.Write = d.WriteBytes 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 // network stats
if netIO, err := psutilNet.IOCounters(true); err == nil { if netIO, err := psutilNet.IOCounters(true); err == nil {
secondsElapsed := time.Since(a.netIoStats.Time).Seconds()
a.netIoStats.Time = time.Now()
bytesSent := uint64(0) bytesSent := uint64(0)
bytesRecv := uint64(0) bytesRecv := uint64(0)
// sum all bytes sent and received
for _, v := range netIO { for _, v := range netIO {
if skipNetworkInterface(&v) { // skip if not in valid network interfaces list
if _, exists := a.netInterfaces[v.Name]; !exists {
continue continue
} }
// log.Printf("%+v: %+v recv, %+v sent\n", v.Name, v.BytesRecv, v.BytesSent) // log.Printf("%+v: %+v recv, %+v sent\n", v.Name, v.BytesRecv, v.BytesSent)
@@ -131,15 +151,28 @@ func (a *Agent) getSystemStats() (*system.Info, *system.Stats) {
bytesRecv += v.BytesRecv bytesRecv += v.BytesRecv
} }
// add to systemStats // add to systemStats
secondsElapsed := time.Since(a.netIoStats.Time).Seconds()
sentPerSecond := float64(bytesSent-a.netIoStats.BytesSent) / secondsElapsed sentPerSecond := float64(bytesSent-a.netIoStats.BytesSent) / secondsElapsed
recvPerSecond := float64(bytesRecv-a.netIoStats.BytesRecv) / secondsElapsed recvPerSecond := float64(bytesRecv-a.netIoStats.BytesRecv) / secondsElapsed
systemStats.NetworkSent = bytesToMegabytes(sentPerSecond) networkSentPs := bytesToMegabytes(sentPerSecond)
systemStats.NetworkRecv = bytesToMegabytes(recvPerSecond) networkRecvPs := bytesToMegabytes(recvPerSecond)
// add check for issue (#150) where sent is a massive number
if networkSentPs > 10_000 || networkRecvPs > 10_000 {
log.Printf("Warning: network sent/recv is %.2f/%.2f MB/s. Resetting stats.\n", networkSentPs, networkRecvPs)
for _, v := range netIO {
if _, exists := a.netInterfaces[v.Name]; !exists {
continue
}
log.Printf("%+s: %v recv, %v sent\n", v.Name, v.BytesRecv, v.BytesSent)
}
// reset network I/O stats
a.initializeNetIoStats()
} else {
systemStats.NetworkSent = networkSentPs
systemStats.NetworkRecv = networkRecvPs
// update netIoStats // update netIoStats
a.netIoStats.BytesSent = bytesSent a.netIoStats.BytesSent = bytesSent
a.netIoStats.BytesRecv = bytesRecv a.netIoStats.BytesRecv = bytesRecv
a.netIoStats.Time = time.Now() }
} }
// temperatures // temperatures
@@ -157,7 +190,7 @@ func (a *Agent) getSystemStats() (*system.Info, *system.Stats) {
// log.Printf("Temperature map: %+v\n", systemStats.Temperatures) // log.Printf("Temperature map: %+v\n", systemStats.Temperatures)
} }
systemInfo := &system.Info{ systemInfo := system.Info{
Cpu: systemStats.Cpu, Cpu: systemStats.Cpu,
MemPct: systemStats.MemPct, MemPct: systemStats.MemPct,
DiskPct: systemStats.DiskPct, DiskPct: systemStats.DiskPct,
@@ -183,7 +216,7 @@ func (a *Agent) getSystemStats() (*system.Info, *system.Stats) {
return systemInfo, systemStats return systemInfo, systemStats
} }
func (a *Agent) getDockerStats() ([]*container.Stats, error) { func (a *Agent) getDockerStats() ([]container.Stats, error) {
resp, err := a.dockerClient.Get("http://localhost/containers/json") resp, err := a.dockerClient.Get("http://localhost/containers/json")
if err != nil { if err != nil {
a.closeIdleConnections(err) a.closeIdleConnections(err)
@@ -191,13 +224,14 @@ func (a *Agent) getDockerStats() ([]*container.Stats, error) {
} }
defer resp.Body.Close() defer resp.Body.Close()
var containers []*container.ApiInfo var containers []container.ApiInfo
if err := json.NewDecoder(resp.Body).Decode(&containers); err != nil { if err := json.NewDecoder(resp.Body).Decode(&containers); err != nil {
log.Printf("Error decoding containers: %+v\n", err) log.Printf("Error decoding containers: %+v\n", err)
return nil, err return nil, err
} }
containerStats := make([]*container.Stats, 0, len(containers)) containerStats := make([]container.Stats, 0, len(containers))
containerStatsMutex := sync.Mutex{}
// store valid ids to clean up old container ids from map // store valid ids to clean up old container ids from map
validIds := make(map[string]struct{}, len(containers)) validIds := make(map[string]struct{}, len(containers))
@@ -214,7 +248,9 @@ func (a *Agent) getDockerStats() ([]*container.Stats, error) {
a.deleteContainerStatsSync(ctr.IdShort) a.deleteContainerStatsSync(ctr.IdShort)
} }
wg.Add(1) wg.Add(1)
a.acquireSemaphore()
go func() { go func() {
defer a.releaseSemaphore()
defer wg.Done() defer wg.Done()
cstats, err := a.getContainerStats(ctr) cstats, err := a.getContainerStats(ctr)
if err != nil { if err != nil {
@@ -231,6 +267,8 @@ func (a *Agent) getDockerStats() ([]*container.Stats, error) {
return return
} }
} }
containerStatsMutex.Lock()
defer containerStatsMutex.Unlock()
containerStats = append(containerStats, cstats) containerStats = append(containerStats, cstats)
}() }()
} }
@@ -247,38 +285,35 @@ func (a *Agent) getDockerStats() ([]*container.Stats, error) {
return containerStats, nil return containerStats, nil
} }
func (a *Agent) getContainerStats(ctr *container.ApiInfo) (*container.Stats, error) { func (a *Agent) getContainerStats(ctr container.ApiInfo) (container.Stats, error) {
// use semaphore to limit concurrency cStats := container.Stats{}
a.acquireSemaphore()
defer a.releaseSemaphore()
resp, err := a.dockerClient.Get("http://localhost/containers/" + ctr.IdShort + "/stats?stream=0&one-shot=1") resp, err := a.dockerClient.Get("http://localhost/containers/" + ctr.IdShort + "/stats?stream=0&one-shot=1")
if err != nil { if err != nil {
return nil, err return cStats, err
} }
defer resp.Body.Close() defer resp.Body.Close()
// get a buffer from the pool // use a pooled buffer to store the response body
buf := a.bufferPool.Get().(*bytes.Buffer) buf := a.bufferPool.Get().(*bytes.Buffer)
defer a.bufferPool.Put(buf) defer a.bufferPool.Put(buf)
buf.Reset() buf.Reset()
// read the response body into the buffer
_, err = io.Copy(buf, resp.Body) _, err = io.Copy(buf, resp.Body)
if err != nil { if err != nil {
return nil, err return cStats, err
} }
// unmarshal the json data from the buffer // unmarshal the json data from the buffer
var statsJson container.ApiStats var statsJson container.ApiStats
if err := json.Unmarshal(buf.Bytes(), &statsJson); err != nil { if err := json.Unmarshal(buf.Bytes(), &statsJson); err != nil {
return nil, err return cStats, err
} }
name := ctr.Names[0][1:] name := ctr.Names[0][1:]
// check if container has valid data, otherwise may be in restart loop (#103) // check if container has valid data, otherwise may be in restart loop (#103)
if statsJson.MemoryStats.Usage == 0 { if statsJson.MemoryStats.Usage == 0 {
return nil, fmt.Errorf("%s - invalid data", name) return cStats, fmt.Errorf("%s - invalid data", name)
} }
// memory (https://docs.docker.com/reference/cli/docker/container/stats/) // memory (https://docs.docker.com/reference/cli/docker/container/stats/)
@@ -303,7 +338,7 @@ func (a *Agent) getContainerStats(ctr *container.ApiInfo) (*container.Stats, err
systemDelta := statsJson.CPUStats.SystemUsage - stats.Cpu[1] systemDelta := statsJson.CPUStats.SystemUsage - stats.Cpu[1]
cpuPct := float64(cpuDelta) / float64(systemDelta) * 100 cpuPct := float64(cpuDelta) / float64(systemDelta) * 100
if cpuPct > 100 { if cpuPct > 100 {
return nil, fmt.Errorf("%s cpu pct greater than 100: %+v", name, cpuPct) return cStats, fmt.Errorf("%s cpu pct greater than 100: %+v", name, cpuPct)
} }
stats.Cpu = [2]uint64{statsJson.CPUStats.CPUUsage.TotalUsage, statsJson.CPUStats.SystemUsage} stats.Cpu = [2]uint64{statsJson.CPUStats.CPUUsage.TotalUsage, statsJson.CPUStats.SystemUsage}
@@ -325,7 +360,7 @@ func (a *Agent) getContainerStats(ctr *container.ApiInfo) (*container.Stats, err
stats.Net.Recv = total_recv stats.Net.Recv = total_recv
stats.Net.Time = time.Now() stats.Net.Time = time.Now()
cStats := a.containerStatsPool.Get().(*container.Stats) // cStats := a.containerStatsPool.Get().(*container.Stats)
cStats.Name = name cStats.Name = name
cStats.Cpu = twoDecimals(cpuPct) cStats.Cpu = twoDecimals(cpuPct)
cStats.Mem = bytesToMegabytes(float64(usedMemory)) cStats.Mem = bytesToMegabytes(float64(usedMemory))
@@ -342,24 +377,25 @@ func (a *Agent) deleteContainerStatsSync(id string) {
delete(a.containerStatsMap, id) delete(a.containerStatsMap, id)
} }
func (a *Agent) gatherStats() *system.CombinedData { func (a *Agent) gatherStats() system.CombinedData {
systemInfo, systemStats := a.getSystemStats() systemInfo, systemStats := a.getSystemStats()
systemData := &system.CombinedData{ systemData := system.CombinedData{
Stats: systemStats, Stats: systemStats,
Info: systemInfo, Info: systemInfo,
} }
// add docker stats
if containerStats, err := a.getDockerStats(); err == nil { if containerStats, err := a.getDockerStats(); err == nil {
systemData.Containers = containerStats systemData.Containers = containerStats
} }
// fmt.Printf("%+v\n", systemData) // add extra filesystems
return systemData systemData.Stats.ExtraFs = make(map[string]*system.FsStats)
} for name, stats := range a.fsStats {
if !stats.Root && stats.DiskTotal > 0 {
// return container stats to pool systemData.Stats.ExtraFs[name] = stats
func (a *Agent) returnStatsToPool(containerStats []*container.Stats) {
for _, stats := range containerStats {
a.containerStatsPool.Put(stats)
} }
}
// log.Printf("%+v\n", systemData)
return systemData
} }
func (a *Agent) startServer() { func (a *Agent) startServer() {
@@ -378,7 +414,6 @@ func (a *Agent) startServer() {
func (a *Agent) handleSession(s sshServer.Session) { func (a *Agent) handleSession(s sshServer.Session) {
stats := a.gatherStats() stats := a.gatherStats()
defer a.returnStatsToPool(stats.Containers)
encoder := json.NewEncoder(s) encoder := json.NewEncoder(s)
if err := encoder.Encode(stats); err != nil { if err := encoder.Encode(stats); err != nil {
log.Println("Error encoding stats:", err.Error()) log.Println("Error encoding stats:", err.Error())
@@ -389,43 +424,126 @@ func (a *Agent) handleSession(s sshServer.Session) {
} }
func (a *Agent) Run() { func (a *Agent) Run() {
if filesystem, exists := os.LookupEnv("FILESYSTEM"); exists { a.fsStats = make(map[string]*system.FsStats)
a.diskIoStats.Filesystem = filesystem
} else { filesystem, fsEnvVarExists := os.LookupEnv("FILESYSTEM")
a.diskIoStats.Filesystem = findDefaultFilesystem() if fsEnvVarExists {
a.fsStats[filesystem] = &system.FsStats{Root: true, Mountpoint: "/"}
} }
if extraFilesystems, exists := os.LookupEnv("EXTRA_FILESYSTEMS"); exists {
// parse comma separated list of filesystems
for _, filesystem := range strings.Split(extraFilesystems, ",") {
a.fsStats[filesystem] = &system.FsStats{}
}
}
a.initializeDiskInfo(fsEnvVarExists)
a.initializeDiskIoStats() a.initializeDiskIoStats()
a.initializeNetIoStats() a.initializeNetIoStats()
// log.Printf("Filesystems: %+v\n", a.fsStats)
a.startServer() a.startServer()
} }
// Sets up the filesystems to monitor for disk usage and I/O.
func (a *Agent) initializeDiskInfo(fsEnvVarExists bool) error {
partitions, err := disk.Partitions(false)
if err != nil {
return err
}
// log.Printf("Partitions: %+v\n", partitions)
for _, v := range partitions {
// binary - use root mountpoint if not already set by env var
if !fsEnvVarExists && v.Mountpoint == "/" {
a.fsStats[v.Device] = &system.FsStats{Root: true, Mountpoint: "/"}
}
// docker - use /etc/hosts device as root if not mapped
if !fsEnvVarExists && v.Mountpoint == "/etc/hosts" && strings.HasPrefix(v.Device, "/dev") && !strings.Contains(v.Device, "mapper") {
a.fsStats[v.Device] = &system.FsStats{Root: true, Mountpoint: "/"}
}
// check if device is in /extra-filesystem
if strings.HasPrefix(v.Mountpoint, "/extra-filesystem") {
// todo: may be able to tweak this to be able to mount custom root at /extra-filesystems/root
a.fsStats[v.Device] = &system.FsStats{Mountpoint: v.Mountpoint}
continue
}
// set mountpoints for extra filesystems if passed in via env var
for name, stats := range a.fsStats {
if strings.HasSuffix(v.Device, name) {
stats.Mountpoint = v.Mountpoint
break
}
}
}
// remove extra filesystems that don't have a mountpoint
hasRoot := false
for name, stats := range a.fsStats {
if stats.Root {
hasRoot = true
log.Println("Detected root fs:", name)
}
if stats.Mountpoint == "" {
log.Printf("Ignoring %s. No mountpoint found.\n", name)
delete(a.fsStats, name)
}
}
// if no root filesystem set, use most read device in /proc/diskstats
if !hasRoot {
rootDevice := findMaxReadsDevice()
log.Printf("Detected root fs: %+s\n", rootDevice)
a.fsStats[rootDevice] = &system.FsStats{Root: true, Mountpoint: "/"}
}
return nil
}
// Sets start values for disk I/O stats.
func (a *Agent) initializeDiskIoStats() { func (a *Agent) initializeDiskIoStats() {
if io, err := disk.IOCounters(a.diskIoStats.Filesystem); err == nil { // create slice of fs names to pass to disk.IOCounters later
a.fsNames = make([]string, 0, len(a.fsStats))
for name, stats := range a.fsStats {
if io, err := disk.IOCounters(name); err == nil {
for _, d := range io { for _, d := range io {
a.diskIoStats.Time = time.Now() // add name to slice
a.diskIoStats.Read = d.ReadBytes a.fsNames = append(a.fsNames, d.Name)
a.diskIoStats.Write = d.WriteBytes // normalize name with io counters
if name != d.Name {
// log.Println("Normalizing disk I/O stats:", name, d.Name)
a.fsStats[d.Name] = stats
delete(a.fsStats, name)
}
stats.Time = time.Now()
stats.TotalRead = d.ReadBytes
stats.TotalWrite = d.WriteBytes
}
} }
} }
} }
func (a *Agent) initializeNetIoStats() { func (a *Agent) initializeNetIoStats() {
// reset valid network interfaces
a.netInterfaces = make(map[string]struct{}, 0)
// reset network I/O stats
a.netIoStats.BytesSent = 0
a.netIoStats.BytesRecv = 0
// get intial network I/O stats
if netIO, err := psutilNet.IOCounters(true); err == nil { if netIO, err := psutilNet.IOCounters(true); err == nil {
bytesSent := uint64(0) a.netIoStats.Time = time.Now()
bytesRecv := uint64(0)
for _, v := range netIO { for _, v := range netIO {
if skipNetworkInterface(&v) { if skipNetworkInterface(v) {
continue continue
} }
log.Printf("Found network interface: %+v (%+v recv, %+v sent)\n", v.Name, v.BytesRecv, v.BytesSent) log.Printf("Detected network interface: %+v (%+v recv, %+v sent)\n", v.Name, v.BytesRecv, v.BytesSent)
bytesSent += v.BytesSent a.netIoStats.BytesSent += v.BytesSent
bytesRecv += v.BytesRecv a.netIoStats.BytesRecv += v.BytesRecv
// store as a valid network interface
a.netInterfaces[v.Name] = struct{}{}
} }
a.netIoStats.BytesSent = bytesSent
a.netIoStats.BytesRecv = bytesRecv
a.netIoStats.Time = time.Now()
} }
} }
@@ -441,19 +559,7 @@ func twoDecimals(value float64) float64 {
return math.Round(value*100) / 100 return math.Round(value*100) / 100
} }
func findDefaultFilesystem() string { func skipNetworkInterface(v psutilNet.IOCountersStat) bool {
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 { switch {
case strings.HasPrefix(v.Name, "lo"), case strings.HasPrefix(v.Name, "lo"),
strings.HasPrefix(v.Name, "docker"), strings.HasPrefix(v.Name, "docker"),
@@ -516,3 +622,40 @@ func (a *Agent) closeIdleConnections(err error) (isTimeout bool) {
} }
return false return false
} }
// Returns the device with the most reads in /proc/diskstats
// (fallback in case the root device is not supplied or detected)
func findMaxReadsDevice() string {
content, err := os.ReadFile("/proc/diskstats")
if err != nil {
return "/"
}
lines := strings.Split(string(content), "\n")
var maxReadsSectors int64
var maxReadsDevice string
for _, line := range lines {
fields := strings.Fields(line)
if len(fields) < 7 {
continue
}
deviceName := fields[2]
readsSectors, err := strconv.ParseInt(fields[5], 10, 64)
if err != nil {
continue
}
if readsSectors > maxReadsSectors {
maxReadsSectors = readsSectors
maxReadsDevice = deviceName
}
}
if maxReadsDevice == "" {
return "/"
}
return maxReadsDevice
}

View File

@@ -14,13 +14,11 @@ import (
type AlertManager struct { type AlertManager struct {
app *pocketbase.PocketBase app *pocketbase.PocketBase
mailClient mailer.Mailer
} }
func NewAlertManager(app *pocketbase.PocketBase) *AlertManager { func NewAlertManager(app *pocketbase.PocketBase) *AlertManager {
return &AlertManager{ return &AlertManager{
app: app, app: app,
mailClient: app.NewMailClient(),
} }
} }
@@ -145,7 +143,7 @@ func (am *AlertManager) sendAlert(message *mailer.Message) {
Address: am.app.Settings().Meta.SenderAddress, Address: am.app.Settings().Meta.SenderAddress,
Name: am.app.Settings().Meta.SenderName, Name: am.app.Settings().Meta.SenderName,
} }
if err := am.mailClient.Send(message); err != nil { if err := am.app.NewMailClient().Send(message); err != nil {
am.app.Logger().Error("Failed to send alert: ", "err", err.Error()) am.app.Logger().Error("Failed to send alert: ", "err", err.Error())
} else { } else {
am.app.Logger().Info("Sent alert", "to", message.To, "subj", message.Subject) am.app.Logger().Info("Sent alert", "to", message.To, "subj", message.Subject)

View File

@@ -11,23 +11,30 @@ type Stats struct {
MemUsed float64 `json:"mu"` MemUsed float64 `json:"mu"`
MemPct float64 `json:"mp"` MemPct float64 `json:"mp"`
MemBuffCache float64 `json:"mb"` MemBuffCache float64 `json:"mb"`
Swap float64 `json:"s"` Swap float64 `json:"s,omitempty"`
SwapUsed float64 `json:"su"` SwapUsed float64 `json:"su,omitempty"`
Disk float64 `json:"d"` DiskTotal float64 `json:"d"`
DiskUsed float64 `json:"du"` DiskUsed float64 `json:"du"`
DiskPct float64 `json:"dp"` DiskPct float64 `json:"dp"`
DiskRead float64 `json:"dr"` DiskReadPs float64 `json:"dr"`
DiskWrite float64 `json:"dw"` DiskWritePs float64 `json:"dw"`
NetworkSent float64 `json:"ns"` NetworkSent float64 `json:"ns"`
NetworkRecv float64 `json:"nr"` NetworkRecv float64 `json:"nr"`
Temperatures map[string]float64 `json:"t,omitempty"` Temperatures map[string]float64 `json:"t,omitempty"`
ExtraFs map[string]*FsStats `json:"efs,omitempty"`
} }
type DiskIoStats struct { type FsStats struct {
Read uint64 Time time.Time `json:"-"`
Write uint64 Device string `json:"-"`
Time time.Time Root bool `json:"-"`
Filesystem string 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 { type NetIoStats struct {
@@ -51,7 +58,7 @@ type Info struct {
// Final data structure to return to the hub // Final data structure to return to the hub
type CombinedData struct { type CombinedData struct {
Stats *Stats `json:"stats"` Stats Stats `json:"stats"`
Info *Info `json:"info"` Info Info `json:"info"`
Containers []*container.Stats `json:"container"` Containers []container.Stats `json:"container"`
} }

View File

@@ -6,6 +6,7 @@ import (
"beszel/internal/entities/system" "beszel/internal/entities/system"
"beszel/internal/records" "beszel/internal/records"
"beszel/site" "beszel/site"
"context"
"crypto/ed25519" "crypto/ed25519"
"encoding/pem" "encoding/pem"
"fmt" "fmt"
@@ -246,8 +247,10 @@ func (h *Hub) updateSystem(record *models.Record) {
// create system connection // create system connection
client, err = h.createSystemConnection(record) client, err = h.createSystemConnection(record)
if err != nil { if err != nil {
if record.GetString("status") != "down" {
h.app.Logger().Error("Failed to connect:", "err", err.Error(), "system", record.GetString("host"), "port", record.GetString("port")) h.app.Logger().Error("Failed to connect:", "err", err.Error(), "system", record.GetString("host"), "port", record.GetString("port"))
h.updateSystemStatus(record, "down") h.updateSystemStatus(record, "down")
}
return return
} }
h.connectionLock.Lock() h.connectionLock.Lock()
@@ -350,7 +353,7 @@ func (h *Hub) createSSHClientConfig() error {
} }
func requestJsonFromAgent(client *ssh.Client, systemData *system.CombinedData) error { func requestJsonFromAgent(client *ssh.Client, systemData *system.CombinedData) error {
session, err := client.NewSession() session, err := newSessionWithTimeout(client, 5*time.Second)
if err != nil { if err != nil {
return fmt.Errorf("bad client") return fmt.Errorf("bad client")
} }
@@ -377,6 +380,32 @@ func requestJsonFromAgent(client *ssh.Client, systemData *system.CombinedData) e
return nil 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) { func (h *Hub) getSSHKey() ([]byte, error) {
dataDir := h.app.DataDir() dataDir := h.app.DataDir()
// check if the key pair already exists // check if the key pair already exists

View File

@@ -140,33 +140,11 @@ func (rm *RecordManager) CreateLongerRecords() {
// log.Println("finished creating longer records", "time (ms)", time.Since(start).Milliseconds()) // 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 // Calculate the average stats of a list of system_stats records without reflect
func (rm *RecordManager) AverageSystemStats(records []*models.Record) system.Stats { func (rm *RecordManager) AverageSystemStats(records []*models.Record) system.Stats {
var sum system.Stats var sum system.Stats
sum.Temperatures = make(map[string]float64) sum.Temperatures = make(map[string]float64)
sum.ExtraFs = make(map[string]*system.FsStats)
count := float64(len(records)) count := float64(len(records))
// use different counter for temps in case some records don't have them // use different counter for temps in case some records don't have them
@@ -182,13 +160,14 @@ func (rm *RecordManager) AverageSystemStats(records []*models.Record) system.Sta
sum.MemBuffCache += stats.MemBuffCache sum.MemBuffCache += stats.MemBuffCache
sum.Swap += stats.Swap sum.Swap += stats.Swap
sum.SwapUsed += stats.SwapUsed sum.SwapUsed += stats.SwapUsed
sum.Disk += stats.Disk sum.DiskTotal += stats.DiskTotal
sum.DiskUsed += stats.DiskUsed sum.DiskUsed += stats.DiskUsed
sum.DiskPct += stats.DiskPct sum.DiskPct += stats.DiskPct
sum.DiskRead += stats.DiskRead sum.DiskReadPs += stats.DiskReadPs
sum.DiskWrite += stats.DiskWrite sum.DiskWritePs += stats.DiskWritePs
sum.NetworkSent += stats.NetworkSent sum.NetworkSent += stats.NetworkSent
sum.NetworkRecv += stats.NetworkRecv sum.NetworkRecv += stats.NetworkRecv
// add temps to sum
if stats.Temperatures != nil { if stats.Temperatures != nil {
tempCount++ tempCount++
for key, value := range stats.Temperatures { for key, value := range stats.Temperatures {
@@ -198,6 +177,18 @@ func (rm *RecordManager) AverageSystemStats(records []*models.Record) system.Sta
sum.Temperatures[key] += value 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{ stats = system.Stats{
@@ -208,11 +199,11 @@ func (rm *RecordManager) AverageSystemStats(records []*models.Record) system.Sta
MemBuffCache: twoDecimals(sum.MemBuffCache / count), MemBuffCache: twoDecimals(sum.MemBuffCache / count),
Swap: twoDecimals(sum.Swap / count), Swap: twoDecimals(sum.Swap / count),
SwapUsed: twoDecimals(sum.SwapUsed / count), SwapUsed: twoDecimals(sum.SwapUsed / count),
Disk: twoDecimals(sum.Disk / count), DiskTotal: twoDecimals(sum.DiskTotal / count),
DiskUsed: twoDecimals(sum.DiskUsed / count), DiskUsed: twoDecimals(sum.DiskUsed / count),
DiskPct: twoDecimals(sum.DiskPct / count), DiskPct: twoDecimals(sum.DiskPct / count),
DiskRead: twoDecimals(sum.DiskRead / count), DiskReadPs: twoDecimals(sum.DiskReadPs / count),
DiskWrite: twoDecimals(sum.DiskWrite / count), DiskWritePs: twoDecimals(sum.DiskWritePs / count),
NetworkSent: twoDecimals(sum.NetworkSent / count), NetworkSent: twoDecimals(sum.NetworkSent / count),
NetworkRecv: twoDecimals(sum.NetworkRecv / count), NetworkRecv: twoDecimals(sum.NetworkRecv / count),
} }
@@ -224,6 +215,18 @@ 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 return stats
} }

Binary file not shown.

View File

@@ -5,12 +5,6 @@
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Beszel</title> <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> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

File diff suppressed because it is too large Load Diff

View File

@@ -22,7 +22,7 @@
"@radix-ui/react-switch": "^1.1.0", "@radix-ui/react-switch": "^1.1.0",
"@radix-ui/react-toast": "^1.2.1", "@radix-ui/react-toast": "^1.2.1",
"@radix-ui/react-tooltip": "^1.1.2", "@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", "@vitejs/plugin-react": "^4.3.1",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@@ -31,23 +31,23 @@
"d3-time": "^3.1.0", "d3-time": "^3.1.0",
"lucide-react": "^0.407.0", "lucide-react": "^0.407.0",
"nanostores": "^0.10.3", "nanostores": "^0.10.3",
"pocketbase": "^0.21.4", "pocketbase": "^0.21.5",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"recharts": "^2.13.0-alpha.4", "recharts": "^2.13.0-alpha.4",
"tailwind-merge": "^2.4.0", "tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"use-is-in-viewport": "^1.0.9", "use-is-in-viewport": "^1.0.9",
"valibot": "^0.36.0" "valibot": "^0.36.0"
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "^1.1.6", "@types/bun": "^1.1.8",
"@types/react": "^18.3.3", "@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"postcss": "^8.4.40", "postcss": "^8.4.44",
"tailwindcss": "^3.4.7", "tailwindcss": "^3.4.10",
"typescript": "^5.5.4", "typescript": "^5.5.4",
"vite": "^5.3.5" "vite": "^5.4.2"
} }
} }

Binary file not shown.

View File

@@ -33,10 +33,12 @@ export function AddSystemButton({ className }: { className?: string }) {
network_mode: host network_mode: host
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro - /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: environment:
PORT: ${port} PORT: ${port}
KEY: "${publicKey}" 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) { async function handleSubmit(e: SubmitEvent) {

View File

@@ -6,6 +6,7 @@ import {
cn, cn,
formatShortDate, formatShortDate,
toFixedWithoutTrailingZeros, toFixedWithoutTrailingZeros,
twoDecimalString,
useYaxisWidth, useYaxisWidth,
} from '@/lib/utils' } from '@/lib/utils'
// import Spinner from '../spinner' // import Spinner from '../spinner'
@@ -72,8 +73,8 @@ export default function BandwidthChart({
animationDuration={150} animationDuration={150}
content={ content={
<ChartTooltipContent <ChartTooltipContent
unit=" MB/s"
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)} labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={(item) => twoDecimalString(item.value) + ' MB/s'}
indicator="line" indicator="line"
/> />
} }

View File

@@ -21,7 +21,7 @@ export default function ChartTimeSelect({ className }: { className?: string }) {
onValueChange={(value: ChartTimes) => $chartTime.set(value)} onValueChange={(value: ChartTimes) => $chartTime.set(value)}
> >
<SelectTrigger className={cn(className, 'relative pl-10 pr-5')}> <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 /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>

View File

@@ -6,10 +6,10 @@ import {
ChartTooltipContent, ChartTooltipContent,
} from '@/components/ui/chart' } from '@/components/ui/chart'
import { useMemo, useRef } from 'react' import { useMemo, useRef } from 'react'
import { chartTimeData, cn, formatShortDate, useYaxisWidth } from '@/lib/utils' import { chartTimeData, cn, formatShortDate, twoDecimalString, useYaxisWidth } from '@/lib/utils'
// import Spinner from '../spinner' // import Spinner from '../spinner'
import { useStore } from '@nanostores/react' import { useStore } from '@nanostores/react'
import { $chartTime } from '@/lib/stores' import { $chartTime, $containerFilter } from '@/lib/stores'
export default function ContainerCpuChart({ export default function ContainerCpuChart({
chartData, chartData,
@@ -21,6 +21,7 @@ export default function ContainerCpuChart({
const chartRef = useRef<HTMLDivElement>(null) const chartRef = useRef<HTMLDivElement>(null)
const yAxisWidth = useYaxisWidth(chartRef) const yAxisWidth = useYaxisWidth(chartRef)
const chartTime = useStore($chartTime) const chartTime = useStore($chartTime)
const filter = useStore($containerFilter)
const yAxisSet = useMemo(() => yAxisWidth !== 180, [yAxisWidth]) const yAxisSet = useMemo(() => yAxisWidth !== 180, [yAxisWidth])
@@ -109,23 +110,33 @@ export default function ContainerCpuChart({
labelFormatter={(_, data) => formatShortDate(data[0].payload.time)} labelFormatter={(_, data) => formatShortDate(data[0].payload.time)}
// @ts-ignore // @ts-ignore
itemSorter={(a, b) => b.value - a.value} 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) => ( }
/>
{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 <Area
key={key} key={key}
// isAnimationActive={chartData.length < 20}
isAnimationActive={false} isAnimationActive={false}
// animateNewValues={false}
// animationDuration={1200}
dataKey={key} dataKey={key}
type="monotoneX" type="monotoneX"
fill={chartConfig[key].color} fill={chartConfig[key].color}
fillOpacity={0.4} fillOpacity={fillOpacity}
stroke={chartConfig[key].color} stroke={chartConfig[key].color}
strokeOpacity={strokeOpacity}
activeDot={{ opacity: filtered ? 0 : 1 }}
stackId="a" stackId="a"
/> />
))} )
})}
</AreaChart> </AreaChart>
</ChartContainer> </ChartContainer>
</div> </div>

View File

@@ -11,11 +11,12 @@ import {
cn, cn,
formatShortDate, formatShortDate,
toFixedWithoutTrailingZeros, toFixedWithoutTrailingZeros,
twoDecimalString,
useYaxisWidth, useYaxisWidth,
} from '@/lib/utils' } from '@/lib/utils'
// import Spinner from '../spinner' // import Spinner from '../spinner'
import { useStore } from '@nanostores/react' import { useStore } from '@nanostores/react'
import { $chartTime } from '@/lib/stores' import { $chartTime, $containerFilter } from '@/lib/stores'
export default function ContainerMemChart({ export default function ContainerMemChart({
chartData, chartData,
@@ -27,6 +28,7 @@ export default function ContainerMemChart({
const chartTime = useStore($chartTime) const chartTime = useStore($chartTime)
const chartRef = useRef<HTMLDivElement>(null) const chartRef = useRef<HTMLDivElement>(null)
const yAxisWidth = useYaxisWidth(chartRef) const yAxisWidth = useYaxisWidth(chartRef)
const filter = useStore($containerFilter)
const yAxisSet = useMemo(() => yAxisWidth !== 180, [yAxisWidth]) const yAxisSet = useMemo(() => yAxisWidth !== 180, [yAxisWidth])
@@ -114,21 +116,33 @@ export default function ContainerMemChart({
labelFormatter={(_, data) => formatShortDate(data[0].payload.time)} labelFormatter={(_, data) => formatShortDate(data[0].payload.time)}
// @ts-ignore // @ts-ignore
itemSorter={(a, b) => b.value - a.value} 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) => ( }
/>
{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 <Area
key={key} key={key}
// animationDuration={1200}
isAnimationActive={false} isAnimationActive={false}
dataKey={key} dataKey={key}
type="monotoneX" type="monotoneX"
fill={chartConfig[key].color} fill={chartConfig[key].color}
fillOpacity={0.4} strokeOpacity={strokeOpacity}
fillOpacity={fillOpacity}
stroke={chartConfig[key].color} stroke={chartConfig[key].color}
activeDot={filtered ? false : {}}
stackId="a" stackId="a"
/> />
))} )
})}
</AreaChart> </AreaChart>
</ChartContainer> </ChartContainer>
</div> </div>

View File

@@ -11,11 +11,12 @@ import {
cn, cn,
formatShortDate, formatShortDate,
toFixedWithoutTrailingZeros, toFixedWithoutTrailingZeros,
twoDecimalString,
useYaxisWidth, useYaxisWidth,
} from '@/lib/utils' } from '@/lib/utils'
// import Spinner from '../spinner' // import Spinner from '../spinner'
import { useStore } from '@nanostores/react' import { useStore } from '@nanostores/react'
import { $chartTime } from '@/lib/stores' import { $chartTime, $containerFilter } from '@/lib/stores'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
export default function ContainerCpuChart({ export default function ContainerCpuChart({
@@ -28,6 +29,7 @@ export default function ContainerCpuChart({
const chartTime = useStore($chartTime) const chartTime = useStore($chartTime)
const chartRef = useRef<HTMLDivElement>(null) const chartRef = useRef<HTMLDivElement>(null)
const yAxisWidth = useYaxisWidth(chartRef) const yAxisWidth = useYaxisWidth(chartRef)
const filter = useStore($containerFilter)
const yAxisSet = useMemo(() => yAxisWidth !== 180, [yAxisWidth]) const yAxisSet = useMemo(() => yAxisWidth !== 180, [yAxisWidth])
@@ -116,6 +118,7 @@ export default function ContainerCpuChart({
itemSorter={(a, b) => b.value - a.value} itemSorter={(a, b) => b.value - a.value}
content={ content={
<ChartTooltipContent <ChartTooltipContent
filter={filter}
indicator="line" indicator="line"
contentFormatter={(item, key) => { contentFormatter={(item, key) => {
try { try {
@@ -123,10 +126,10 @@ export default function ContainerCpuChart({
const received = item?.payload?.[key][1] ?? 0 const received = item?.payload?.[key][1] ?? 0
return ( return (
<span className="flex"> <span className="flex">
{received.toLocaleString()} MB/s {twoDecimalString(received)} MB/s
<span className="opacity-70 ml-0.5"> rx </span> <span className="opacity-70 ml-0.5"> rx </span>
<Separator orientation="vertical" className="h-3 mx-1.5 bg-primary/40" /> <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> </span>
) )
} catch (e) { } catch (e) {
@@ -136,7 +139,11 @@ export default function ContainerCpuChart({
/> />
} }
/> />
{Object.keys(chartConfig).map((key) => ( {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 <Area
key={key} key={key}
name={key} name={key}
@@ -145,11 +152,14 @@ export default function ContainerCpuChart({
dataKey={(data) => data?.[key]?.[2] ?? 0} dataKey={(data) => data?.[key]?.[2] ?? 0}
type="monotoneX" type="monotoneX"
fill={chartConfig[key].color} fill={chartConfig[key].color}
fillOpacity={0.4} fillOpacity={fillOpacity}
stroke={chartConfig[key].color} stroke={chartConfig[key].color}
strokeOpacity={strokeOpacity}
activeDot={{ opacity: filtered ? 0 : 1 }}
stackId="a" stackId="a"
/> />
))} )
})}
</AreaChart> </AreaChart>
</ChartContainer> </ChartContainer>
</div> </div>

View File

@@ -1,7 +1,7 @@
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts' import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart' import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'
import { chartTimeData, cn, formatShortDate, useYaxisWidth } from '@/lib/utils' import { chartTimeData, cn, formatShortDate, twoDecimalString, useYaxisWidth } from '@/lib/utils'
// import Spinner from '../spinner' // import Spinner from '../spinner'
import { useStore } from '@nanostores/react' import { useStore } from '@nanostores/react'
import { $chartTime } from '@/lib/stores' import { $chartTime } from '@/lib/stores'
@@ -60,8 +60,8 @@ export default function CpuChart({
animationDuration={150} animationDuration={150}
content={ content={
<ChartTooltipContent <ChartTooltipContent
unit="%"
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)} labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={(item) => twoDecimalString(item.value) + '%'}
indicator="line" indicator="line"
/> />
} }

View File

@@ -1,7 +1,7 @@
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts' import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart' import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'
import { chartTimeData, cn, formatShortDate, useYaxisWidth } from '@/lib/utils' import { chartTimeData, cn, formatShortDate, twoDecimalString, useYaxisWidth } from '@/lib/utils'
import { useMemo, useRef } from 'react' import { useMemo, useRef } from 'react'
// import Spinner from '../spinner' // import Spinner from '../spinner'
import { useStore } from '@nanostores/react' import { useStore } from '@nanostores/react'
@@ -22,22 +22,9 @@ export default function DiskChart({
const yAxisSet = useMemo(() => yAxisWidth !== 180, [yAxisWidth]) const yAxisSet = useMemo(() => yAxisWidth !== 180, [yAxisWidth])
const diskSize = useMemo(() => { const diskSize = useMemo(() => {
return Math.round(systemData[0]?.stats.d) return Math.round(systemData.at(-1)?.stats.d ?? NaN)
}, [systemData]) }, [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 />
// }
return ( return (
<div ref={chartRef}> <div ref={chartRef}>
{/* {!yAxisSet && <Spinner />} */} {/* {!yAxisSet && <Spinner />} */}
@@ -63,6 +50,7 @@ export default function DiskChart({
width={yAxisWidth} width={yAxisWidth}
domain={[0, diskSize]} domain={[0, diskSize]}
tickCount={9} tickCount={9}
minTickGap={6}
tickLine={false} tickLine={false}
axisLine={false} axisLine={false}
unit={' GB'} unit={' GB'}
@@ -83,8 +71,8 @@ export default function DiskChart({
animationDuration={150} animationDuration={150}
content={ content={
<ChartTooltipContent <ChartTooltipContent
unit=" GB"
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)} labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={(item) => twoDecimalString(item.value) + ' GB'}
indicator="line" indicator="line"
/> />
} }

View File

@@ -6,6 +6,7 @@ import {
cn, cn,
formatShortDate, formatShortDate,
toFixedWithoutTrailingZeros, toFixedWithoutTrailingZeros,
twoDecimalString,
useYaxisWidth, useYaxisWidth,
} from '@/lib/utils' } from '@/lib/utils'
// import Spinner from '../spinner' // import Spinner from '../spinner'
@@ -76,8 +77,8 @@ export default function DiskIoChart({
animationDuration={150} animationDuration={150}
content={ content={
<ChartTooltipContent <ChartTooltipContent
unit=" MB/s"
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)} labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={(item) => twoDecimalString(item.value) + ' MB/s'}
indicator="line" indicator="line"
/> />
} }

View File

@@ -0,0 +1,92 @@
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'
import { chartTimeData, cn, formatShortDate, twoDecimalString, useYaxisWidth } from '@/lib/utils'
import { useMemo, useRef } from 'react'
// import Spinner from '../spinner'
import { useStore } from '@nanostores/react'
import { $chartTime } from '@/lib/stores'
import { SystemStatsRecord } from '@/types'
export default function ExFsDiskChart({
ticks,
systemData,
fs,
}: {
ticks: number[]
systemData: SystemStatsRecord[]
fs: string
}) {
const chartTime = useStore($chartTime)
const chartRef = useRef<HTMLDivElement>(null)
const yAxisWidth = useYaxisWidth(chartRef)
const yAxisSet = useMemo(() => yAxisWidth !== 180, [yAxisWidth])
const diskSize = useMemo(() => {
const size = systemData.at(-1)?.stats.efs?.[fs].d ?? 0
return size > 10 ? Math.round(size) : size
}, [systemData])
return (
<div ref={chartRef}>
<ChartContainer
config={{}}
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
'opacity-100': yAxisSet,
})}
>
<AreaChart
accessibilityLayer
data={systemData}
margin={{
top: 10,
}}
>
<CartesianGrid vertical={false} />
<YAxis
className="tracking-tighter"
width={yAxisWidth}
domain={[0, diskSize]}
tickCount={9}
minTickGap={6}
tickLine={false}
axisLine={false}
unit={' GB'}
/>
<XAxis
dataKey="created"
domain={[ticks[0], ticks.at(-1)!]}
ticks={ticks}
type="number"
scale={'time'}
minTickGap={35}
tickMargin={8}
axisLine={false}
tickFormatter={chartTimeData[chartTime].format}
/>
<ChartTooltip
animationEasing="ease-out"
animationDuration={150}
content={
<ChartTooltipContent
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={(item) => twoDecimalString(item.value) + ' GB'}
indicator="line"
/>
}
/>
<Area
dataKey={`stats.efs.${fs}.du`}
name="Disk Usage"
type="monotoneX"
fill="hsl(var(--chart-4))"
fillOpacity={0.4}
stroke="hsl(var(--chart-4))"
isAnimationActive={false}
/>
</AreaChart>
</ChartContainer>
</div>
)
}

View File

@@ -0,0 +1,112 @@
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'
import {
chartTimeData,
cn,
formatShortDate,
toFixedWithoutTrailingZeros,
twoDecimalString,
useYaxisWidth,
} from '@/lib/utils'
// import Spinner from '../spinner'
import { useStore } from '@nanostores/react'
import { $chartTime } from '@/lib/stores'
import { SystemStatsRecord } from '@/types'
import { useMemo, useRef } from 'react'
export default function ExFsDiskIoChart({
ticks,
systemData,
fs,
}: {
ticks: number[]
systemData: SystemStatsRecord[]
fs: string
}) {
const chartTime = useStore($chartTime)
const chartRef = useRef<HTMLDivElement>(null)
const yAxisWidth = useYaxisWidth(chartRef)
const yAxisSet = useMemo(() => yAxisWidth !== 180, [yAxisWidth])
// if (!systemData.length || !ticks.length) {
// return <Spinner />
// }
return (
<div ref={chartRef}>
{/* {!yAxisSet && <Spinner />} */}
<ChartContainer
config={{}}
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
'opacity-100': yAxisSet,
})}
>
<AreaChart
accessibilityLayer
data={systemData}
margin={{
left: 0,
right: 0,
top: 10,
bottom: 0,
}}
>
<CartesianGrid vertical={false} />
<YAxis
className="tracking-tighter"
width={yAxisWidth}
// domain={[0, (max: number) => (max <= 0.4 ? 0.4 : Math.ceil(max))]}
tickFormatter={(value) => toFixedWithoutTrailingZeros(value, 2)}
tickLine={false}
axisLine={false}
unit={' MB/s'}
/>
<XAxis
dataKey="created"
domain={[ticks[0], ticks.at(-1)!]}
ticks={ticks}
type="number"
scale={'time'}
minTickGap={35}
tickMargin={8}
axisLine={false}
tickFormatter={chartTimeData[chartTime].format}
/>
<ChartTooltip
animationEasing="ease-out"
animationDuration={150}
content={
<ChartTooltipContent
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={(item) => twoDecimalString(item.value) + ' MB/s'}
indicator="line"
/>
}
/>
<Area
dataKey={`stats.efs.${fs}.w`}
name="Write"
type="monotoneX"
fill="hsl(var(--chart-3))"
fillOpacity={0.3}
stroke="hsl(var(--chart-3))"
// animationDuration={1200}
isAnimationActive={false}
/>
<Area
dataKey={`stats.efs.${fs}.r`}
name="Read"
type="monotoneX"
fill="hsl(var(--chart-1))"
fillOpacity={0.3}
stroke="hsl(var(--chart-1))"
// animationDuration={1200}
isAnimationActive={false}
/>
</AreaChart>
</ChartContainer>
</div>
)
}

View File

@@ -1,7 +1,14 @@
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts' import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart' import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'
import { chartTimeData, cn, formatShortDate, toFixedFloat, useYaxisWidth } from '@/lib/utils' import {
chartTimeData,
cn,
formatShortDate,
toFixedFloat,
twoDecimalString,
useYaxisWidth,
} from '@/lib/utils'
import { useMemo, useRef } from 'react' import { useMemo, useRef } from 'react'
// import Spinner from '../spinner' // import Spinner from '../spinner'
import { useStore } from '@nanostores/react' import { useStore } from '@nanostores/react'
@@ -71,10 +78,10 @@ export default function MemChart({
animationDuration={150} animationDuration={150}
content={ content={
<ChartTooltipContent <ChartTooltipContent
unit=" GB"
// @ts-ignore // @ts-ignore
itemSorter={(a, b) => a.name.localeCompare(b.name)} itemSorter={(a, b) => a.name.localeCompare(b.name)}
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)} labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={(item) => twoDecimalString(item.value) + ' GB'}
indicator="line" indicator="line"
/> />
} }

View File

@@ -6,6 +6,7 @@ import {
cn, cn,
formatShortDate, formatShortDate,
toFixedWithoutTrailingZeros, toFixedWithoutTrailingZeros,
twoDecimalString,
useYaxisWidth, useYaxisWidth,
} from '@/lib/utils' } from '@/lib/utils'
// import Spinner from '../spinner' // import Spinner from '../spinner'
@@ -61,8 +62,8 @@ export default function SwapChart({
animationDuration={150} animationDuration={150}
content={ content={
<ChartTooltipContent <ChartTooltipContent
unit=" GB"
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)} labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={(item) => twoDecimalString(item.value) + ' GB'}
indicator="line" indicator="line"
/> />
} }

View File

@@ -12,6 +12,7 @@ import {
cn, cn,
formatShortDate, formatShortDate,
toFixedWithoutTrailingZeros, toFixedWithoutTrailingZeros,
twoDecimalString,
useYaxisWidth, useYaxisWidth,
} from '@/lib/utils' } from '@/lib/utils'
import { useStore } from '@nanostores/react' import { useStore } from '@nanostores/react'
@@ -102,8 +103,8 @@ export default function TemperatureChart({
itemSorter={(a, b) => b.value - a.value} itemSorter={(a, b) => b.value - a.value}
content={ content={
<ChartTooltipContent <ChartTooltipContent
unit=" °C"
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)} labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={(item) => twoDecimalString(item.value) + ' °C'}
indicator="line" indicator="line"
/> />
} }

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

View File

@@ -1,4 +1,4 @@
import { MoonStarIcon, Sun } from 'lucide-react' import { LaptopIcon, MoonStarIcon, SunIcon } from 'lucide-react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import {
@@ -16,15 +16,24 @@ export function ModeToggle() {
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant={'ghost'} size="icon"> <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" /> <MoonStarIcon className="absolute h-[1.2rem] w-[1.2rem] opacity-0 dark:opacity-100" />
<span className="sr-only">Toggle theme</span> <span className="sr-only">Toggle theme</span>
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent> <DropdownMenuContent>
<DropdownMenuItem onClick={() => setTheme('light')}>Light</DropdownMenuItem> <DropdownMenuItem onClick={() => setTheme('light')}>
<DropdownMenuItem onClick={() => setTheme('dark')}>Dark</DropdownMenuItem> <SunIcon className="mr-2.5 h-4 w-4" />
<DropdownMenuItem onClick={() => setTheme('system')}>System</DropdownMenuItem> 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> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
) )

View File

@@ -51,7 +51,7 @@ export default function () {
<Input <Input
placeholder="Filter..." placeholder="Filter..."
onChange={(e) => setFilter(e.target.value)} 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> </div>
</CardHeader> </CardHeader>

View File

@@ -1,15 +1,30 @@
import { $systems, pb, $chartTime } from '@/lib/stores' import { $systems, pb, $chartTime, $containerFilter } from '@/lib/stores'
import { ContainerStatsRecord, SystemRecord, SystemStatsRecord } from '@/types' import { ContainerStatsRecord, SystemRecord, SystemStatsRecord } from '@/types'
import { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '../ui/card' import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '../ui/card'
import { useStore } from '@nanostores/react' import { useStore } from '@nanostores/react'
import Spinner from '../spinner' import Spinner from '../spinner'
import { ClockArrowUp, CpuIcon, GlobeIcon } from 'lucide-react' import {
ClockArrowUp,
CpuIcon,
GlobeIcon,
LayoutGridIcon,
StretchHorizontalIcon,
XIcon,
} from 'lucide-react'
import ChartTimeSelect from '../charts/chart-time-select' 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 { Separator } from '../ui/separator'
import { scaleTime } from 'd3-scale' import { scaleTime } from 'd3-scale'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip'
import { Button, buttonVariants } from '../ui/button'
import { Input } from '../ui/input'
const CpuChart = lazy(() => import('../charts/cpu-chart')) const CpuChart = lazy(() => import('../charts/cpu-chart'))
const ContainerCpuChart = lazy(() => import('../charts/container-cpu-chart')) const ContainerCpuChart = lazy(() => import('../charts/container-cpu-chart'))
@@ -21,14 +36,18 @@ const BandwidthChart = lazy(() => import('../charts/bandwidth-chart'))
const ContainerNetChart = lazy(() => import('../charts/container-net-chart')) const ContainerNetChart = lazy(() => import('../charts/container-net-chart'))
const SwapChart = lazy(() => import('../charts/swap-chart')) const SwapChart = lazy(() => import('../charts/swap-chart'))
const TemperatureChart = lazy(() => import('../charts/temperature-chart')) const TemperatureChart = lazy(() => import('../charts/temperature-chart'))
const ExFsDiskChart = lazy(() => import('../charts/extra-fs-disk-chart'))
const ExFsDiskIoChart = lazy(() => import('../charts/extra-fs-disk-io-chart'))
export default function SystemDetail({ name }: { name: string }) { export default function SystemDetail({ name }: { name: string }) {
const systems = useStore($systems) const systems = useStore($systems)
const chartTime = useStore($chartTime) const chartTime = useStore($chartTime)
const [grid, setGrid] = useLocalStorage('grid', true)
const [ticks, setTicks] = useState([] as number[]) const [ticks, setTicks] = useState([] as number[])
const [system, setSystem] = useState({} as SystemRecord) const [system, setSystem] = useState({} as SystemRecord)
const [systemStats, setSystemStats] = useState([] as SystemStatsRecord[]) const [systemStats, setSystemStats] = useState([] as SystemStatsRecord[])
const [hasDockerStats, setHasDocker] = useState(false) const [hasDockerStats, setHasDocker] = useState(false)
const netCardRef = useRef<HTMLDivElement>(null)
const [dockerCpuChartData, setDockerCpuChartData] = useState<Record<string, number | string>[]>( const [dockerCpuChartData, setDockerCpuChartData] = useState<Record<string, number | string>[]>(
[] []
) )
@@ -44,6 +63,7 @@ export default function SystemDetail({ name }: { name: string }) {
return () => { return () => {
resetCharts() resetCharts()
$chartTime.set('1h') $chartTime.set('1h')
$containerFilter.set('')
setHasDocker(false) setHasDocker(false)
} }
}, [name]) }, [name])
@@ -105,7 +125,7 @@ export default function SystemDetail({ name }: { name: string }) {
if (prevTime) { if (prevTime) {
const interval = record.created - prevTime const interval = record.created - prevTime
// if interval is too large, add a null record // if interval is too large, add a null record
if (interval - interval * 0.5 > expectedInterval) { if (interval > expectedInterval / 2 + expectedInterval) {
// @ts-ignore // @ts-ignore
modifiedRecords.push({ created: null, stats: null }) modifiedRecords.push({ created: null, stats: null })
} }
@@ -186,13 +206,28 @@ export default function SystemDetail({ name }: { name: string }) {
return `${Math.trunc(system.info?.u / 86400)} days` return `${Math.trunc(system.info?.u / 86400)} days`
}, [system.info?.u]) }, [system.info?.u])
/** 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) { if (!system.id) {
return null return null
} }
return ( return (
<div className="grid lg:grid-cols-2 gap-4 mb-10"> <>
<Card className="col-span-full"> <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 className="grid lg:flex items-center gap-4 px-4 sm:px-6 pt-3 sm:pt-4 pb-5">
<div> <div>
<h1 className="text-[1.6rem] font-semibold mb-1.5">{system.name}</h1> <h1 className="text-[1.6rem] font-semibold mb-1.5">{system.name}</h1>
@@ -217,8 +252,8 @@ export default function SystemDetail({ name }: { name: string }) {
{system.status} {system.status}
</div> </div>
<Separator orientation="vertical" className="h-4 bg-primary/30" /> <Separator orientation="vertical" className="h-4 bg-primary/30" />
<div className="flex gap-1.5"> <div className="flex gap-1.5 items-center">
<GlobeIcon className="h-4 w-4 mt-[1px]" /> {system.host} <GlobeIcon className="h-4 w-4" /> {system.host}
</div> </div>
{system.info?.u && ( {system.info?.u && (
<TooltipProvider> <TooltipProvider>
@@ -244,72 +279,178 @@ export default function SystemDetail({ name }: { name: string }) {
)} )}
</div> </div>
</div> </div>
<ChartTimeSelect className="w-full lg:w-40 xl:w-52 ml-auto max-sm:-mb-1" /> <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" />
) : (
<StretchHorizontalIcon className="h-[1.2rem] w-[1.2rem] opacity-85" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>Toggle grid</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div> </div>
</Card> </Card>
<ChartCard title="Total CPU Usage" description="Average system-wide CPU utilization"> {/* main charts */}
<div className="grid lg:grid-cols-2 gap-4">
<ChartCard
grid={grid}
title="Total CPU Usage"
description="Average system-wide CPU utilization"
>
<CpuChart ticks={ticks} systemData={systemStats} /> <CpuChart ticks={ticks} systemData={systemStats} />
</ChartCard> </ChartCard>
{hasDockerStats && ( {hasDockerStats && (
<ChartCard title="Docker CPU Usage" description="CPU utilization of docker containers"> <ChartCard
grid={grid}
title="Docker CPU Usage"
description="CPU utilization of docker containers"
isContainerChart={true}
>
<ContainerCpuChart chartData={dockerCpuChartData} ticks={ticks} /> <ContainerCpuChart chartData={dockerCpuChartData} ticks={ticks} />
</ChartCard> </ChartCard>
)} )}
<ChartCard title="Total Memory Usage" description="Precise utilization at the recorded time"> <ChartCard
grid={grid}
title="Total Memory Usage"
description="Precise utilization at the recorded time"
>
<MemChart ticks={ticks} systemData={systemStats} /> <MemChart ticks={ticks} systemData={systemStats} />
</ChartCard> </ChartCard>
{hasDockerStats && ( {hasDockerStats && (
<ChartCard title="Docker Memory Usage" description="Memory usage of docker containers"> <ChartCard
grid={grid}
title="Docker Memory Usage"
description="Memory usage of docker containers"
isContainerChart={true}
>
<ContainerMemChart chartData={dockerMemChartData} ticks={ticks} /> <ContainerMemChart chartData={dockerMemChartData} ticks={ticks} />
</ChartCard> </ChartCard>
)} )}
{(systemStats.at(-1)?.stats.s ?? 0) > 0 && ( {(systemStats.at(-1)?.stats.s ?? 0) > 0 && (
<ChartCard title="Swap Usage" description="Swap space used by the system"> <ChartCard grid={grid} title="Swap Usage" description="Swap space used by the system">
<SwapChart ticks={ticks} systemData={systemStats} /> <SwapChart ticks={ticks} systemData={systemStats} />
</ChartCard> </ChartCard>
)} )}
{systemStats.at(-1)?.stats.t && ( <ChartCard grid={grid} title="Disk Space" description="Usage of root partition">
<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} /> <DiskChart ticks={ticks} systemData={systemStats} />
</ChartCard> </ChartCard>
<ChartCard title="Disk I/O" description="Throughput of root filesystem"> <ChartCard grid={grid} title="Disk I/O" description="Throughput of root filesystem">
<DiskIoChart ticks={ticks} systemData={systemStats} /> <DiskIoChart ticks={ticks} systemData={systemStats} />
</ChartCard> </ChartCard>
<ChartCard title="Bandwidth" description="Network traffic of public interfaces"> <ChartCard
grid={grid}
title="Bandwidth"
description="Network traffic of public interfaces"
>
<BandwidthChart ticks={ticks} systemData={systemStats} /> <BandwidthChart ticks={ticks} systemData={systemStats} />
</ChartCard> </ChartCard>
{hasDockerStats && dockerNetChartData.length > 0 && ( {hasDockerStats && dockerNetChartData.length > 0 && (
<> <div
ref={netCardRef}
className={cn({
'col-span-full': !grid,
})}
>
<ChartCard <ChartCard
title="Docker Network I/O" title="Docker Network I/O"
description="Includes traffic between internal services" description="Includes traffic between internal services"
isContainerChart={true}
> >
<ContainerNetChart chartData={dockerNetChartData} ticks={ticks} /> <ContainerNetChart chartData={dockerNetChartData} ticks={ticks} />
</ChartCard> </ChartCard>
{/* add space for tooltip if more than 12 containers */} </div>
{Object.keys(dockerNetChartData[0]).length > 12 && (
<span
className="block"
style={{
height: (Object.keys(dockerNetChartData[0]).length - 13) * 18,
}}
/>
)} )}
{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={true}
title={`${extraFsName} Usage`}
description={`Disk usage of ${extraFsName}`}
>
<ExFsDiskChart ticks={ticks} systemData={systemStats} fs={extraFsName} />
</ChartCard>
<ChartCard
grid={true}
title={`${extraFsName} I/O`}
description={`Throughput of of ${extraFsName}`}
>
<ExFsDiskIoChart ticks={ticks} systemData={systemStats} fs={extraFsName} />
</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> </div>
) )
@@ -319,21 +460,27 @@ function ChartCard({
title, title,
description, description,
children, children,
grid,
isContainerChart,
}: { }: {
title: string title: string
description: string description: string
children: React.ReactNode children: React.ReactNode
grid?: boolean
isContainerChart?: boolean
}) { }) {
const target = useRef<HTMLDivElement>(null) const target = useRef<HTMLDivElement>(null)
const [isInViewport, wrappedTargetRef] = useClampedIsInViewport({ target: target }) const [isInViewport, wrappedTargetRef] = useClampedIsInViewport({ target: target })
return ( 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"> <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> <CardTitle className="text-xl sm:text-2xl">{title}</CardTitle>
<CardDescription>{description}</CardDescription> <CardDescription>{description}</CardDescription>
{/* <div className="w-full pt-1 sm:w-40 hidden sm:block absolute top-1.5 right-3.5"> {isContainerChart && <ContainerFilterBar />}
<ChartTimeSelect />
</div> */}
</CardHeader> </CardHeader>
<CardContent className="pl-0 w-[calc(100%-1.6em)] h-52 relative"> <CardContent className="pl-0 w-[calc(100%-1.6em)] h-52 relative">
{<Spinner />} {<Spinner />}

View File

@@ -72,7 +72,7 @@ function CellFormatter(info: CellContext<SystemRecord, unknown>) {
<span <span
className={cn( className={cn(
'absolute inset-0 w-full h-full origin-left', '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}%)` }} style={{ transform: `scalex(${val}%)` }}
></span> ></span>
@@ -135,7 +135,7 @@ export default function SystemsTable({ filter }: { filter?: string }) {
<Button <Button
data-nolink data-nolink
variant={'ghost'} 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)} onClick={() => copyToClipboard(info.getValue() as string)}
> >
{info.getValue() as string} {info.getValue() as string}

View File

@@ -100,6 +100,7 @@ const ChartTooltipContent = React.forwardRef<
nameKey?: string nameKey?: string
labelKey?: string labelKey?: string
unit?: string unit?: string
filter?: string
contentFormatter?: (item: any, key: string) => React.ReactNode | string contentFormatter?: (item: any, key: string) => React.ReactNode | string
} }
>( >(
@@ -119,6 +120,7 @@ const ChartTooltipContent = React.forwardRef<
nameKey, nameKey,
labelKey, labelKey,
unit, unit,
filter,
itemSorter, itemSorter,
contentFormatter: content = undefined, contentFormatter: content = undefined,
}, },
@@ -127,6 +129,9 @@ const ChartTooltipContent = React.forwardRef<
const { config } = useChart() const { config } = useChart()
React.useMemo(() => { React.useMemo(() => {
if (filter) {
payload = payload?.filter((item) => (item.name as string)?.includes(filter))
}
if (itemSorter) { if (itemSorter) {
// @ts-ignore // @ts-ignore
payload?.sort(itemSorter) payload?.sort(itemSorter)
@@ -229,7 +234,7 @@ const ChartTooltipContent = React.forwardRef<
</span> </span>
</div> </div>
{item.value !== undefined && ( {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 && typeof content === 'function'
? content(item, key) ? content(item, key)
: item.value.toLocaleString() + (unit ? unit : '')} : item.value.toLocaleString() + (unit ? unit : '')}

View File

@@ -13,7 +13,7 @@ const Command = React.forwardRef<
<CommandPrimitive <CommandPrimitive
ref={ref} ref={ref}
className={cn( 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 className
)} )}
{...props} {...props}

View File

@@ -1,40 +1,31 @@
import * as React from "react" import * as React from 'react'
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils'
const Table = React.forwardRef< const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
HTMLTableElement, ({ className, ...props }, ref) => (
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto"> <div className="relative w-full overflow-auto">
<table <table ref={ref} className={cn('w-full caption-bottom text-sm', className)} {...props} />
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div> </div>
)) )
Table.displayName = "Table" )
Table.displayName = 'Table'
const TableHeader = React.forwardRef< const TableHeader = React.forwardRef<
HTMLTableSectionElement, HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement> React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => ( >(({ 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< const TableBody = React.forwardRef<
HTMLTableSectionElement, HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement> React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<tbody <tbody ref={ref} className={cn('[&_tr:last-child]:border-0', className)} {...props} />
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)) ))
TableBody.displayName = "TableBody" TableBody.displayName = 'TableBody'
const TableFooter = React.forwardRef< const TableFooter = React.forwardRef<
HTMLTableSectionElement, HTMLTableSectionElement,
@@ -42,29 +33,25 @@ const TableFooter = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<tfoot <tfoot
ref={ref} ref={ref}
className={cn( className={cn('border-t bg-muted/50 font-medium [&>tr]:last:border-b-0', className)}
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props} {...props}
/> />
)) ))
TableFooter.displayName = "TableFooter" TableFooter.displayName = 'TableFooter'
const TableRow = React.forwardRef< const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
HTMLTableRowElement, ({ className, ...props }, ref) => (
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr <tr
ref={ref} ref={ref}
className={cn( className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted", 'border-b transition-colors hover:bg-muted/40 dark:hover:bg-muted/30 data-[state=selected]:bg-muted',
className className
)} )}
{...props} {...props}
/> />
)) )
TableRow.displayName = "TableRow" )
TableRow.displayName = 'TableRow'
const TableHead = React.forwardRef< const TableHead = React.forwardRef<
HTMLTableCellElement, HTMLTableCellElement,
@@ -73,13 +60,13 @@ const TableHead = React.forwardRef<
<th <th
ref={ref} ref={ref}
className={cn( className={cn(
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0", 'h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0',
className className
)} )}
{...props} {...props}
/> />
)) ))
TableHead.displayName = "TableHead" TableHead.displayName = 'TableHead'
const TableCell = React.forwardRef< const TableCell = React.forwardRef<
HTMLTableCellElement, HTMLTableCellElement,
@@ -87,31 +74,18 @@ const TableCell = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<td <td
ref={ref} ref={ref}
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)} className={cn('p-4 align-middle [&:has([role=checkbox])]:pr-0', className)}
{...props} {...props}
/> />
)) ))
TableCell.displayName = "TableCell" TableCell.displayName = 'TableCell'
const TableCaption = React.forwardRef< const TableCaption = React.forwardRef<
HTMLTableCaptionElement, HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement> React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<caption <caption ref={ref} className={cn('mt-4 text-sm text-muted-foreground', className)} {...props} />
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
)) ))
TableCaption.displayName = "TableCaption" TableCaption.displayName = 'TableCaption'
export { export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption }
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

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

View File

@@ -1,6 +1,7 @@
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
@layer base { @layer base {
:root { :root {
--background: 30 8% 98.5%; --background: 30 8% 98.5%;
@@ -23,9 +24,16 @@
--input: 30 4.29% 72.55%; --input: 30 4.29% 72.55%;
--ring: 30 3.97% 49.41%; --ring: 30 3.97% 49.41%;
--radius: 0.8rem; --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 { .dark {
color-scheme: dark;
--background: 240 10% 6.2%; --background: 240 10% 6.2%;
--foreground: 0 0% 98.04%; --foreground: 0 0% 98.04%;
--card: 240 8.57% 8%; --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 { @layer base {
* { * {
@apply border-border; @apply border-border;
@@ -65,27 +87,3 @@
.recharts-yAxis { .recharts-yAxis {
font-variant-numeric: tabular-nums; 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%;
} */
}

View File

@@ -22,3 +22,9 @@ export const $hubVersion = atom('')
/** Chart time period */ /** Chart time period */
export const $chartTime = atom('1h') as WritableAtom<ChartTimes> export const $chartTime = atom('1h') as WritableAtom<ChartTimes>
/** Container chart filter */
export const $containerFilter = atom('')
/** Fallback copy to clipboard dialog content */
export const $copyContent = atom('')

View File

@@ -1,7 +1,7 @@
import { toast } from '@/components/ui/use-toast' import { toast } from '@/components/ui/use-toast'
import { type ClassValue, clsx } from 'clsx' import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge' import { twMerge } from 'tailwind-merge'
import { $alerts, $systems, pb } from './stores' import { $alerts, $copyContent, $systems, pb } from './stores'
import { AlertRecord, ChartTimeData, ChartTimes, SystemRecord } from '@/types' import { AlertRecord, ChartTimeData, ChartTimes, SystemRecord } from '@/types'
import { RecordModel, RecordSubscription } from 'pocketbase' import { RecordModel, RecordSubscription } from 'pocketbase'
import { WritableAtom } from 'nanostores' import { WritableAtom } from 'nanostores'
@@ -22,10 +22,7 @@ export async function copyToClipboard(content: string) {
description: 'Copied to clipboard', description: 'Copied to clipboard',
}) })
} catch (e: any) { } catch (e: any) {
prompt( $copyContent.set(content)
'Automatic copy requires a secure context (https, localhost, or *.localhost). Please copy manually:',
content
)
} }
} }
@@ -241,3 +238,35 @@ export function toFixedWithoutTrailingZeros(num: number, digits: number) {
export function toFixedFloat(num: number, digits: number) { export function toFixedFloat(num: number, digits: number) {
return parseFloat(num.toFixed(digits)) 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]
}

View File

@@ -3,9 +3,23 @@ import React, { Suspense, lazy, useEffect } from 'react'
import ReactDOM from 'react-dom/client' import ReactDOM from 'react-dom/client'
import Home from './components/routes/home.tsx' import Home from './components/routes/home.tsx'
import { ThemeProvider } from './components/theme-provider.tsx' import { ThemeProvider } from './components/theme-provider.tsx'
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 { ModeToggle } from './components/mode-toggle.tsx'
import { cn, isAdmin, updateAlerts, updateFavicon, updateSystemList } from './lib/utils.ts' import {
cn,
isAdmin,
isReadOnlyUser,
updateAlerts,
updateFavicon,
updateSystemList,
} from './lib/utils.ts'
import { buttonVariants } from './components/ui/button.tsx' import { buttonVariants } from './components/ui/button.tsx'
import { import {
DatabaseBackupIcon, DatabaseBackupIcon,
@@ -35,6 +49,7 @@ import { AddSystemButton } from './components/add-system.tsx'
// const ServerDetail = lazy(() => import('./components/routes/system.tsx')) // const ServerDetail = lazy(() => import('./components/routes/system.tsx'))
const CommandPalette = lazy(() => import('./components/command-palette.tsx')) const CommandPalette = lazy(() => import('./components/command-palette.tsx'))
const LoginPage = lazy(() => import('./components/login/login.tsx')) const LoginPage = lazy(() => import('./components/login/login.tsx'))
const CopyToClipboardDialog = lazy(() => import('./components/copy-to-clipboard.tsx'))
const App = () => { const App = () => {
const page = useStore($router) const page = useStore($router)
@@ -89,6 +104,7 @@ const App = () => {
const Layout = () => { const Layout = () => {
const authenticated = useStore($authenticated) const authenticated = useStore($authenticated)
const copyContent = useStore($copyContent)
if (!authenticated) { if (!authenticated) {
return ( return (
@@ -125,7 +141,7 @@ const Layout = () => {
<UserIcon className="h-[1.2rem] w-[1.2rem]" /> <UserIcon className="h-[1.2rem] w-[1.2rem]" />
</button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent className="min-w-44"> <DropdownMenuContent align={isReadOnlyUser() ? 'end' : 'center'} className="min-w-44">
<DropdownMenuLabel>{pb.authStore.model?.email}</DropdownMenuLabel> <DropdownMenuLabel>{pb.authStore.model?.email}</DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuGroup> <DropdownMenuGroup>
@@ -180,16 +196,23 @@ const Layout = () => {
<Suspense> <Suspense>
<CommandPalette /> <CommandPalette />
</Suspense> </Suspense>
{copyContent && (
<Suspense>
<CopyToClipboardDialog content={copyContent} />
</Suspense>
)}
</div> </div>
</> </>
) )
} }
ReactDOM.createRoot(document.getElementById('app')!).render( ReactDOM.createRoot(document.getElementById('app')!).render(
<React.StrictMode> // strict mode in dev mounts / unmounts components twice
// and breaks the clipboard dialog
//<React.StrictMode>
<ThemeProvider> <ThemeProvider>
<Layout /> <Layout />
<Toaster /> <Toaster />
</ThemeProvider> </ThemeProvider>
</React.StrictMode> //</React.StrictMode>
) )

View File

@@ -59,6 +59,19 @@ export interface SystemStats {
nr: number nr: number
/** temperatures */ /** temperatures */
t?: Record<string, number> 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 { export interface ContainerStatsRecord extends RecordModel {

View File

@@ -16,12 +16,12 @@ module.exports = {
'2xl': '1400px', '2xl': '1400px',
}, },
}, },
extend: {
fontFamily: { fontFamily: {
sans: ['Inter', 'sans-serif'], sans: 'Inter, sans-serif',
// body: ['Inter', 'sans-serif'], // body: ['Inter', 'sans-serif'],
// display: ['Inter', 'sans-serif'], // display: ['Inter', 'sans-serif'],
}, },
extend: {
screens: { screens: {
xs: '425px', xs: '425px',
}, },

View File

@@ -1,6 +1,6 @@
package beszel package beszel
const ( const (
Version = "0.2.0" Version = "0.3.0"
AppName = "beszel" AppName = "beszel"
) )

View File

@@ -57,7 +57,7 @@ The agent uses the host network mode so it can access network interface stats. T
If you don't need network stats, remove that line from the compose file and map the port manually. If you don't need 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 ### Binary
@@ -105,15 +105,16 @@ Use `./beszel update` and `./beszel-agent update` to update to the latest versio
### Agent ### Agent
| Name | Default | Description | | Name | Default | Description |
| ------------- | ------- | ------------------------------------------------------------------ | | ------------------- | ------- | ---------------------------------------------------------------------------------------- |
| `DOCKER_HOST` | unset | Overrides the docker host (docker.sock) if using a proxy.[^socket] | | `DOCKER_HOST` | unset | Overrides the docker host (docker.sock) if using a proxy.[^socket] |
| `FILESYSTEM` | unset | Filesystem / partition to use for disk I/O stats. | | `EXTRA_FILESYSTEMS` | unset | See [Monitoring additional disks / partitions](#monitoring-additional-disks--partitions) |
| `FILESYSTEM` | unset | Device or partition to use for root disk I/O stats. |
| `KEY` | unset | Public SSH key to use for authentication. Provided in hub. | | `KEY` | unset | Public SSH key to use for authentication. Provided in hub. |
| `PORT` | 45876 | Port or address:port to listen on. | | `PORT` | 45876 | Port or address:port to listen on. |
[^socket]: Beszel only needs access to read container information. For [linuxserver/docker-socket-proxy](https://github.com/linuxserver/docker-socket-proxy) you would set `CONTAINERS=1`. [^socket]: Beszel only needs access to read container information. For [linuxserver/docker-socket-proxy](https://github.com/linuxserver/docker-socket-proxy) you would set `CONTAINERS=1`.
## OAuth / OIDC setup ## OAuth / OIDC Setup
Beszel supports OpenID Connect and many OAuth2 authentication providers (see list below). Beszel supports OpenID Connect and many OAuth2 authentication providers (see list below).
@@ -146,6 +147,33 @@ Visit the "Auth providers" page to enable your provider. The redirect / callback
- Yandex - Yandex
</details> </details>
## Monitoring additional disks / partitions
> [!NOTE]
> This feature is new and has been tested on a limited number of systems. Please report any issues.
You can configure the agent to monitor the usage and I/O of more than one disk or partition. The approach differs depending on the deployment method.
Use `lsblk` to find the names and mount points of your partitions. If you have trouble, check the agent logs.
### Docker
Mount a folder from the partition's filesystem in the container's `/extra-filesystems` directory, like the example below. The charts will use the name of the device or partition, not the name of the folder.
```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 or partitions to monitor. For example:
```bash
EXTRA_FILESYSTEMS="sdb,sdc1,mmcblk0"
```
## REST API ## 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. 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.
@@ -197,14 +225,20 @@ Otherwise you can use the agent's `container_name` as the hostname if both are i
### Finding the correct filesystem ### Finding the correct filesystem
The filesystem / partition to use for disk I/O stats is specified in the `FILESYSTEM` environment variable. The filesystem / device / partition to use for disk I/O stats is specified in 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 it's not set, the agent will try to find the partition mounted on `/` and use that. This doesn't seem to work in a container, so it's recommended to set this value. One of the following methods should work (you usually want the option mounted on `/`):
- Run `df -h` and choose an option under "Filesystem"
- Run `lsblk` and choose an option under "NAME" - Run `lsblk` and choose an option under "NAME"
- Run `df -h` and choose an option under "Filesystem"
- Run `sudo fdisk -l` and choose an option under "Device" - Run `sudo fdisk -l` and choose an option under "Device"
### Docker container charts are empty or missing
If container charts show empty data, or don't show up at all, you may need to enable cgroup memory accounting. To verify, run `docker stats`. If that also shows zero memory usage, follow this guide to fix:
https://akashrajpurohit.com/blog/resolving-missing-memory-stats-in-docker-stats-on-raspberry-pi/
### Docker containers are not populating reliably ### Docker containers are not populating reliably
Try upgrading your docker version on the agent system. I had this issue on a machine running version 24. It was fixed by upgrading to version 27. Try upgrading your docker version on the agent system. I had this issue on a machine running version 24. It was fixed by upgrading to version 27.
@@ -260,8 +294,3 @@ GOOS=freebsd GOARCH=arm64 CGO_ENABLED=0 go build -ldflags "-w -s" .
``` ```
You can see a list of valid options by running `go tool dist list`. You can see a list of valid options by running `go tool dist list`.
<!--
## Support
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. -->

View File

@@ -6,7 +6,9 @@ services:
network_mode: host network_mode: host
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro - /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: environment:
PORT: 45876 PORT: 45876
KEY: 'ssh-ed25519 YOUR_PUBLIC_KEY' 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