Compare commits

...

29 Commits

Author SHA1 Message Date
Henry Dollman
ddfcbc546b release 0.4.0 2024-09-16 14:05:00 -04:00
Henry Dollman
c74d5496af update go deps 2024-09-16 14:04:48 -04:00
Henry Dollman
060846d70a update command palette items 2024-09-16 13:38:03 -04:00
Henry Dollman
e03e2b8d67 fix wrapping of y axis chart labels 2024-09-16 13:14:23 -04:00
Henry Dollman
c46879694d allow FILESYSTEM env var to override root usage stats 2024-09-15 18:16:36 -04:00
Henry Dollman
61a68e5be1 refactor findMaxReadsDevice to use disk.IOCounters 2024-09-15 16:29:55 -04:00
Henry Dollman
bd43a2a2c2 focus on input when new webhook added 2024-09-15 11:31:44 -04:00
Henry Dollman
3aeca6af2f add SYS_SENSORS env var 2024-09-14 18:46:16 -04:00
Henry Dollman
3e95269a7c add hostname to system info 2024-09-14 18:09:38 -04:00
Henry Dollman
53b02dd55f change extra disk charts to follow grid layout selection 2024-09-14 17:40:47 -04:00
Henry Dollman
43ba9d5c6a change NIC env var to NICS to support multiple interfaces 2024-09-14 17:30:42 -04:00
Henry Dollman
1cb4a711c3 add NIC env var 2024-09-14 16:47:12 -04:00
Henry Dollman
aef99c3bd9 update package comments 2024-09-14 16:46:48 -04:00
Henry Dollman
138cbc13d6 rename package user to users 2024-09-14 16:44:53 -04:00
Henry Dollman
62d5ae8236 clean up / small refactoring 2024-09-14 15:46:42 -04:00
Henry Dollman
8ce605d65e create UserManager 2024-09-14 15:45:57 -04:00
Henry Dollman
c8743201a2 allow creation of 10m record if nine 1m records 2024-09-14 15:27:42 -04:00
Henry Dollman
f16e22e521 updates to settings page and alerts 2024-09-13 18:19:12 -04:00
Henry Dollman
9710d0d2f1 shoutrrr alerts / settings page 2024-09-12 19:39:27 -04:00
Henry Dollman
2889d151ea alert / settings page updates 2024-09-11 17:47:36 -04:00
Henry Dollman
ce6e887d1b progress on settings / alerts 2024-09-11 15:50:15 -04:00
Henry Dollman
b4cf5bb1c0 alerts updates 2024-09-10 19:38:32 -04:00
Henry Dollman
9bc7773607 further progress on settings / alerts 2024-09-10 19:03:08 -04:00
Henry Dollman
3362a3d1cf progress on settings page 2024-09-09 20:00:09 -04:00
Henry Dollman
3b13fadde2 rm shoutrrr config info from readme 2024-09-09 13:37:09 -04:00
parnic
99d79f7d2d Add support for alerts via shoutrrr (#145)
* Add support for alerts via shoutrrr

This provides users the ability to use a wide variety of notification platforms instead of just email. If there's a problem sending a notification via shoutrrr, an error is logged and email is attempted as a fallback.

Since this uses Viper, users can set a notification type and URL via either a config file or environment variable. In the beszel_data folder (where the sqlite dbs reside), create an alerts.env file to set values that way.

Values:
* NOTIFICATION_TYPE
  * If this is `shoutrrr`, then the shoutrrr library is used. Any other value, including not being set, uses the fallback email behavior.
* NOTIFICATION_URL
  * If NOTIFICATION_TYPE is shoutrrr, this is the URL given to shoutrrr to send the alert. See list of supported services: https://containrrr.dev/shoutrrr/services/overview/

Note: there's currently a bug in viper v1.18.2+ where environment variable overrides aren't functioning when no config file exists, so this library should remain pinned to 1.18.1 until that's fixed. See: https://github.com/spf13/viper/issues/1895

* Update documentation

* Log shoutrrr URL instead of unused "to" var
2024-09-09 13:34:43 -04:00
hank
1fb23ff673 Create SECURITY.md 2024-09-04 16:53:13 -04:00
Henry Dollman
29529d1a84 add EXTRA_FILESYSTEMS to systemd script and example 2024-09-04 16:22:01 -04:00
Bart
9f84629b92 Enhanced README.md (#156)
* Enhanced README.md

* Changed back to 'Sentence case'

* Changed text to reflect comment
2024-09-04 15:29:14 -04:00
45 changed files with 1468 additions and 472 deletions

9
SECURITY.md Normal file
View File

@@ -0,0 +1,9 @@
# Security Policy
## Reporting a Vulnerability
If you find a vulnerability in the latest version, please email me directly at hank@henrygd.me, or [submit a private advisory](https://github.com/henrygd/beszel/security/advisories/new).
If you submit an advisory, open an empty issue as well to let me know that you did (or email me), as I'm not sure if I get notifications for that.
If the issue is low severity (use best judgement) you may open an issue for it instead of contacting me directly.

View File

@@ -4,6 +4,7 @@ go 1.22.4
require ( require (
github.com/blang/semver v3.5.1+incompatible github.com/blang/semver v3.5.1+incompatible
github.com/containrrr/shoutrrr v0.8.0
github.com/gliderlabs/ssh v0.3.7 github.com/gliderlabs/ssh v0.3.7
github.com/goccy/go-json v0.10.3 github.com/goccy/go-json v0.10.3
github.com/labstack/echo/v5 v5.0.0-20230722203903-ec5b858dab61 github.com/labstack/echo/v5 v5.0.0-20230722203903-ec5b858dab61
@@ -12,7 +13,7 @@ require (
github.com/rhysd/go-github-selfupdate v1.2.3 github.com/rhysd/go-github-selfupdate v1.2.3
github.com/shirou/gopsutil/v4 v4.24.8 github.com/shirou/gopsutil/v4 v4.24.8
github.com/spf13/cobra v1.8.1 github.com/spf13/cobra v1.8.1
golang.org/x/crypto v0.26.0 golang.org/x/crypto v0.27.0
) )
require ( require (
@@ -21,10 +22,10 @@ require (
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/aws/aws-sdk-go-v2 v1.30.5 // indirect github.com/aws/aws-sdk-go-v2 v1.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.32 // indirect github.com/aws/aws-sdk-go-v2/config v1.27.33 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.17.31 // indirect github.com/aws/aws-sdk-go-v2/credentials v1.17.32 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.13 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.13 // indirect
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.17 // indirect github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.18 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.17 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.17 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.17 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.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
@@ -33,11 +34,12 @@ require (
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.19 // indirect github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.19 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.19 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.19 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.17 // indirect github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.17 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.61.1 // indirect github.com/aws/aws-sdk-go-v2/service/s3 v1.61.2 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.22.6 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.22.7 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.6 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.7 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.30.6 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.30.7 // indirect
github.com/aws/smithy-go v1.20.4 // indirect github.com/aws/smithy-go v1.20.4 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/disintegration/imaging v1.6.2 // indirect github.com/disintegration/imaging v1.6.2 // indirect
github.com/domodwyer/mailyak/v3 v3.6.2 // indirect github.com/domodwyer/mailyak/v3 v3.6.2 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
@@ -56,12 +58,13 @@ require (
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf // indirect github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/lufia/plan9stats v0.0.0-20240819163618-b1d8f4d146e7 // indirect github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect github.com/mattn/go-sqlite3 v1.14.23 // indirect
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/shoenig/go-m1cpu v0.1.6 // indirect github.com/shoenig/go-m1cpu v0.1.6 // indirect
@@ -76,24 +79,25 @@ require (
github.com/yusufpapurcu/wmi v1.2.4 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opencensus.io v0.24.0 // indirect go.opencensus.io v0.24.0 // indirect
gocloud.dev v0.39.0 // indirect gocloud.dev v0.39.0 // indirect
golang.org/x/image v0.19.0 // indirect golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect
golang.org/x/net v0.28.0 // indirect golang.org/x/image v0.20.0 // indirect
golang.org/x/oauth2 v0.22.0 // indirect golang.org/x/net v0.29.0 // indirect
golang.org/x/oauth2 v0.23.0 // indirect
golang.org/x/sync v0.8.0 // indirect golang.org/x/sync v0.8.0 // indirect
golang.org/x/sys v0.24.0 // indirect golang.org/x/sys v0.25.0 // indirect
golang.org/x/term v0.23.0 // indirect golang.org/x/term v0.24.0 // indirect
golang.org/x/text v0.17.0 // indirect golang.org/x/text v0.18.0 // indirect
golang.org/x/time v0.6.0 // indirect golang.org/x/time v0.6.0 // indirect
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
google.golang.org/api v0.196.0 // indirect google.golang.org/api v0.197.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect
google.golang.org/grpc v1.66.0 // indirect google.golang.org/grpc v1.66.2 // indirect
google.golang.org/protobuf v1.34.2 // indirect google.golang.org/protobuf v1.34.2 // indirect
modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a // indirect modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a // indirect
modernc.org/libc v1.60.1 // indirect modernc.org/libc v1.61.0 // indirect
modernc.org/mathutil v1.6.0 // indirect modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.8.0 // indirect modernc.org/memory v1.8.0 // indirect
modernc.org/sqlite v1.32.0 // indirect modernc.org/sqlite v1.33.1 // indirect
modernc.org/strutil v1.2.0 // indirect modernc.org/strutil v1.2.0 // indirect
modernc.org/token v1.1.0 // indirect modernc.org/token v1.1.0 // indirect
) )

View File

@@ -5,6 +5,7 @@ cloud.google.com/go/auth v0.9.3 h1:VOEUIAADkkLtyfr3BLa3R8Ed/j6w1jTBmARx+wb5w5U=
cloud.google.com/go/auth v0.9.3/go.mod h1:7z6VY+7h3KUdRov5F1i8NDP5ZzWKYmEPO842BgCsmTk= 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 v1.14.0 h1:hfm2+FfxVmnRlh6LpB7cg1ZNU+5edAHmW679JePztk0=
cloud.google.com/go/compute/metadata v0.5.0 h1:Zr0eK8JbFv6+Wi4ilXAR8FJ3wyNdpxHKJNPos6LTZOY= cloud.google.com/go/compute/metadata v0.5.0 h1:Zr0eK8JbFv6+Wi4ilXAR8FJ3wyNdpxHKJNPos6LTZOY=
cloud.google.com/go/compute/metadata v0.5.0/go.mod h1:aHnloV2TPI38yx4s9+wAZhHykWvVCfu7hQbF+9CWoiY= cloud.google.com/go/compute/metadata v0.5.0/go.mod h1:aHnloV2TPI38yx4s9+wAZhHykWvVCfu7hQbF+9CWoiY=
cloud.google.com/go/iam v1.1.13 h1:7zWBXG9ERbMLrzQBRhFliAV+kjcRToDTgQT3CTwYyv4= cloud.google.com/go/iam v1.1.13 h1:7zWBXG9ERbMLrzQBRhFliAV+kjcRToDTgQT3CTwYyv4=
@@ -29,14 +30,14 @@ github.com/aws/aws-sdk-go-v2 v1.30.5 h1:mWSRTwQAb0aLE17dSzztCVJWI9+cRMgqebndjwDy
github.com/aws/aws-sdk-go-v2 v1.30.5/go.mod h1:CT+ZPWXbYrci8chcARI3OmI/qgd+f6WtuLOoaIA8PR0= github.com/aws/aws-sdk-go-v2 v1.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.32 h1:jnAMVTJTpAQlePCUUlnXnllHEMGVWmvUJOiGjgtS9S0= github.com/aws/aws-sdk-go-v2/config v1.27.33 h1:Nof9o/MsmH4oa0s2q9a0k7tMz5x/Yj5k06lDODWz3BU=
github.com/aws/aws-sdk-go-v2/config v1.27.32/go.mod h1:JibtzKJoXT0M/MhoYL6qfCk7nm/MppwukDFZtdgVRoY= github.com/aws/aws-sdk-go-v2/config v1.27.33/go.mod h1:kEqdYzRb8dd8Sy2pOdEbExTTF5v7ozEXX0McgPE7xks=
github.com/aws/aws-sdk-go-v2/credentials v1.17.31 h1:jtyfcOfgoqWA2hW/E8sFbwdfgwD3APnF9CLCKE8dTyw= github.com/aws/aws-sdk-go-v2/credentials v1.17.32 h1:7Cxhp/BnT2RcGy4VisJ9miUPecY+lyE9I8JvcZofn9I=
github.com/aws/aws-sdk-go-v2/credentials v1.17.31/go.mod h1:RSgY5lfCfw+FoyKWtOpLolPlfQVdDBQWTUniAaE+NKY= github.com/aws/aws-sdk-go-v2/credentials v1.17.32/go.mod h1:P5/QMF3/DCHbXGEGkdbilXHsyTBX5D3HSwcrSc9p20I=
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.13 h1:pfQ2sqNpMVK6xz2RbqLEL0GH87JOwSxPV2rzm8Zsb74=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.13/go.mod h1:NG7RXPUlqfsCLLFfi0+IpKN4sCB9D9fw/qTaSB+xRoU= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.13/go.mod h1:NG7RXPUlqfsCLLFfi0+IpKN4sCB9D9fw/qTaSB+xRoU=
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.18 h1:9DIp7vhmOPmueCDwpXa45bEbLHHTt1kcxChdTJWWxvI=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.17/go.mod h1:0trBfk2z3LEozr2WZz7IxcRJWl2jv0Ro7JpByqh3coQ= github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.18/go.mod h1:aJv/Fwz8r56ozwYFRC4bzoeL1L17GYQYemfblOBux1M=
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.17 h1:pI7Bzt0BJtYA0N/JEC6B8fJ4RBrEMi1LBrkMdFYNSnQ=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.17/go.mod h1:Dh5zzJYMtxfIjYW+/evjQ8uj2OyR/ve2KROHGHlSFqE= github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.17/go.mod h1:Dh5zzJYMtxfIjYW+/evjQ8uj2OyR/ve2KROHGHlSFqE=
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.17 h1:Mqr/V5gvrhA2gvgnF42Zh5iMiQNcOYthFYwCyrnuWlc=
@@ -53,14 +54,14 @@ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.19 h1:rfprUlsd
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.19/go.mod h1:SCWkEdRq8/7EK60NcvvQ6NXKuTcchAD4ROAsC37VEZE= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.19/go.mod h1:SCWkEdRq8/7EK60NcvvQ6NXKuTcchAD4ROAsC37VEZE=
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.17 h1:u+EfGmksnJc/x5tq3A+OD7LrMbSSR/5TrKLvkdy/fhY=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.17/go.mod h1:VaMx6302JHax2vHJWgRo+5n9zvbacs3bLU/23DNQrTY= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.17/go.mod h1:VaMx6302JHax2vHJWgRo+5n9zvbacs3bLU/23DNQrTY=
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.61.2 h1:Kp6PWAlXwP1UvIflkIP6MFZYBNDCa4mFCGtxrpICVOg=
github.com/aws/aws-sdk-go-v2/service/s3 v1.61.1/go.mod h1:5FmD/Dqq57gP+XwaUnd5WFPipAuzrf0HmupX27Gvjvc= github.com/aws/aws-sdk-go-v2/service/s3 v1.61.2/go.mod h1:5FmD/Dqq57gP+XwaUnd5WFPipAuzrf0HmupX27Gvjvc=
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.7 h1:pIaGg+08llrP7Q5aiz9ICWbY8cqhTkyy+0SHvfzQpTc=
github.com/aws/aws-sdk-go-v2/service/sso v1.22.6/go.mod h1:eEygMHnTKH/3kNp9Jr1n3PdejuSNcgwLe1dWgQtO0VQ= github.com/aws/aws-sdk-go-v2/service/sso v1.22.7/go.mod h1:eEygMHnTKH/3kNp9Jr1n3PdejuSNcgwLe1dWgQtO0VQ=
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.7 h1:/Cfdu0XV3mONYKaOt1Gr0k1KvQzkzPyiKUdlWJqy+J4=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.6/go.mod h1:bCbAxKDqNvkHxRaIMnyVPXPo+OaPRwvmgzMxbz1VKSA= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.7/go.mod h1:bCbAxKDqNvkHxRaIMnyVPXPo+OaPRwvmgzMxbz1VKSA=
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.7 h1:NKTa1eqZYw8tiHSRGpP0VtTdub/8KNk8sDkNPFaOKDE=
github.com/aws/aws-sdk-go-v2/service/sts v1.30.6/go.mod h1:NXi1dIAGteSaRLqYgarlhP/Ij0cFT+qmCwiJqWh/U5o= github.com/aws/aws-sdk-go-v2/service/sts v1.30.7/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=
@@ -68,12 +69,15 @@ github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnweb
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/containrrr/shoutrrr v0.8.0 h1:mfG2ATzIS7NR2Ec6XL+xyoHzN97H8WPjir8aYzJUSec=
github.com/containrrr/shoutrrr v0.8.0/go.mod h1:ioyQAyu1LJY6sILuNyKaQaw+9Ttik5QePU8atnAdO2o=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI= github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI=
github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8= github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8=
@@ -109,6 +113,8 @@ github.com/go-ozzo/ozzo-validation/v4 v4.3.0/go.mod h1:2NKgrcHl3z6cJs+3Oo940FPRi
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls=
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=
@@ -153,8 +159,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/wire v0.6.0 h1:HBkoIh4BdSxoyo9PveV8giw7ZsaBOvzWKfcg/6MrVwI= github.com/google/wire v0.6.0 h1:HBkoIh4BdSxoyo9PveV8giw7ZsaBOvzWKfcg/6MrVwI=
github.com/google/wire v0.6.0/go.mod h1:F4QhpQ9EDIdJ1Mbop/NZBRB+5yrR6qg3BnctaoUk6NA= github.com/google/wire v0.6.0/go.mod h1:F4QhpQ9EDIdJ1Mbop/NZBRB+5yrR6qg3BnctaoUk6NA=
github.com/googleapis/enterprise-certificate-proxy v0.3.3 h1:QRje2j5GZimBzlbhGA2V2QlGNgL8G6e+wGo/+/2bWI0= github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw=
github.com/googleapis/enterprise-certificate-proxy v0.3.3/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA= github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA=
github.com/googleapis/gax-go/v2 v2.13.0 h1:yitjD5f7jQHhyDsnhKEBU52NdvvdSeGzlAnDPT0hH1s= github.com/googleapis/gax-go/v2 v2.13.0 h1:yitjD5f7jQHhyDsnhKEBU52NdvvdSeGzlAnDPT0hH1s=
github.com/googleapis/gax-go/v2 v2.13.0/go.mod h1:Z/fvTZXF8/uw7Xu5GuslPw+bplx6SS338j1Is2S+B7A= github.com/googleapis/gax-go/v2 v2.13.0/go.mod h1:Z/fvTZXF8/uw7Xu5GuslPw+bplx6SS338j1Is2S+B7A=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
@@ -166,6 +172,8 @@ github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf h1:WfD7V
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf/go.mod h1:hyb9oH7vZsitZCiBt0ZvifOrB+qc8PS5IiilCIb87rg= github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf/go.mod h1:hyb9oH7vZsitZCiBt0ZvifOrB+qc8PS5IiilCIb87rg=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jarcoal/httpmock v1.3.0 h1:2RJ8GP0IIaWwcC9Fp2BmVi8Kog3v2Hn7VXM3fTd+nuc=
github.com/jarcoal/httpmock v1.3.0/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg=
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
@@ -179,8 +187,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/labstack/echo/v5 v5.0.0-20230722203903-ec5b858dab61 h1:FwuzbVh87iLiUQj1+uQUsuw9x5t9m5n5g7rG7o4svW4= github.com/labstack/echo/v5 v5.0.0-20230722203903-ec5b858dab61 h1:FwuzbVh87iLiUQj1+uQUsuw9x5t9m5n5g7rG7o4svW4=
github.com/labstack/echo/v5 v5.0.0-20230722203903-ec5b858dab61/go.mod h1:paQfF1YtHe+GrGg5fOgjsjoCX/UKDr9bc1DoWpZfns8= github.com/labstack/echo/v5 v5.0.0-20230722203903-ec5b858dab61/go.mod h1:paQfF1YtHe+GrGg5fOgjsjoCX/UKDr9bc1DoWpZfns8=
github.com/lufia/plan9stats v0.0.0-20240819163618-b1d8f4d146e7 h1:5RK988zAqB3/AN3opGfRpoQgAVqr6/A5+qRTi67VUZY= github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 h1:7UMa6KCCMjZEMDtTVdcGu0B1GmmC7QJKiCCjyTAWQy0=
github.com/lufia/plan9stats v0.0.0-20240819163618-b1d8f4d146e7/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k= github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k=
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
@@ -188,18 +196,23 @@ github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hd
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.23 h1:gbShiuAP1W5j9UOksQ06aiiqPMxYecovVGwmTxWtuw0=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.23/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI=
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/onsi/ginkgo v1.6.0 h1:Ix8l273rp3QzYgXSR+c8d1fTG7UPgYkOSELPhiY/YGw=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/gomega v1.4.2 h1:3mYCb7aPxS/RU7TI1y4rkEn1oKmPRjNJLNEXgw7MH2I= github.com/onsi/ginkgo/v2 v2.9.2 h1:BA2GMJOtfGAfagzYtrAlufIP0lq6QERkFmHLMLPwFSU=
github.com/onsi/ginkgo/v2 v2.9.2/go.mod h1:WHcJJG2dIlcCqVfBAwUCrJxSPFb6v4azBwgxeMeDuts=
github.com/onsi/gomega v1.4.2/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/onsi/gomega v1.4.2/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE=
github.com/onsi/gomega v1.27.6/go.mod h1:PIQNjfQwkP3aQAH7lf7j87O/5FiNr+ZR8+ipb+qQlhg=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pocketbase/dbx v1.10.1 h1:cw+vsyfCJD8YObOVeqb93YErnlxwYMkNZ4rwN0G0AaA= github.com/pocketbase/dbx v1.10.1 h1:cw+vsyfCJD8YObOVeqb93YErnlxwYMkNZ4rwN0G0AaA=
github.com/pocketbase/dbx v1.10.1/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs= github.com/pocketbase/dbx v1.10.1/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
github.com/pocketbase/pocketbase v0.22.20 h1:yUkhO5bTPWlzD4ZK6EQlS4R3AcHKDlBD+DxxU2BR83I= github.com/pocketbase/pocketbase v0.22.20 h1:yUkhO5bTPWlzD4ZK6EQlS4R3AcHKDlBD+DxxU2BR83I=
@@ -270,18 +283,20 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk=
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.19.0 h1:D9FX4QWkLfkeqaC62SonffIIuYdOk/UE2XKUBgRIBIQ= golang.org/x/image v0.20.0 h1:7cVCUjQwfL18gyBJOmYvptfSHS8Fb3YUDtfLIZ7Nbpw=
golang.org/x/image v0.19.0/go.mod h1:y0zrRqlQRWQ5PXaYCOMLTW2fpsxZ8Qh9I/ohnInJEys= golang.org/x/image v0.20.0/go.mod h1:0a88To4CYVBAHp5FXJm8o7QbUl37Vd85ply1vyD8auM=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8= golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0=
golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -293,12 +308,12 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.22.0 h1:BzDx2FehcG7jJwgWLELCdmLuxk2i+x9UDpSiss2u0ZA= golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs=
golang.org/x/oauth2 v0.22.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -321,21 +336,21 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg= golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.23.0 h1:F6D4vR+EHoL9/sWAWgAR1H2DcHr4PareCbAaCo1RpuU= golang.org/x/term v0.24.0 h1:Mh5cbb+Zk2hqqXNO7S1iTjEphVL+jb8ZWaqh/g+JWkM=
golang.org/x/term v0.23.0/go.mod h1:DgV24QBUrK6jhZXl+20l6UWznPlwAHm1Q1mGHtydmSk= golang.org/x/term v0.24.0/go.mod h1:lOBK/LVxemqiMij05LGJ0tzNr8xlmwBRJ81PX6wVLH8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -345,14 +360,14 @@ golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg= golang.org/x/tools v0.25.0 h1:oFU9pkj/iJgs+0DT+VMHrx+oBKs/LJMV+Uvg78sl+fE=
golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI= golang.org/x/tools v0.25.0/go.mod h1:/vtpO8WL1N9cQC3FN5zPqb//fRXskFHbLKk4OW1Q7rg=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
google.golang.org/api v0.196.0 h1:k/RafYqebaIJBO3+SMnfEGtFVlvp5vSgqTUF54UN/zg= google.golang.org/api v0.197.0 h1:x6CwqQLsFiA5JKAiGyGBjc2bNtHtLddhJCE2IKuhhcQ=
google.golang.org/api v0.196.0/go.mod h1:g9IL21uGkYgvQ5BZg6BAtoGJQIm8r6EgaAbpNey5wBE= google.golang.org/api v0.197.0/go.mod h1:AuOuo20GoQ331nq7DquGHlU6d+2wN2fZ8O0ta60nRNw=
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=
@@ -371,8 +386,8 @@ google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyac
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
google.golang.org/grpc v1.66.0 h1:DibZuoBznOxbDQxRINckZcUvnCEvrW9pcWIE2yF9r1c= google.golang.org/grpc v1.66.2 h1:3QdXkuq3Bkh7w+ywLdLvM56cmGvQHUMZpiCzt6Rqaoo=
google.golang.org/grpc v1.66.0/go.mod h1:s3/l6xSSCURdVfAnL+TqCNMyTDAGN6+lZeVxnZR128Y= google.golang.org/grpc v1.66.2/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=
@@ -389,7 +404,6 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
@@ -406,8 +420,8 @@ modernc.org/gc/v2 v2.5.0 h1:bJ9ChznK1L1mUtAQtxi0wi5AtAs5jQuw4PrPHO5pb6M=
modernc.org/gc/v2 v2.5.0/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= modernc.org/gc/v2 v2.5.0/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a h1:CfbpOLEo2IwNzJdMvE8aiRbPMxoTpgAJeyePh0SmO8M= modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a h1:CfbpOLEo2IwNzJdMvE8aiRbPMxoTpgAJeyePh0SmO8M=
modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
modernc.org/libc v1.60.1 h1:at373l8IFRTkJIkAU85BIuUoBM4T1b51ds0E1ovPG2s= modernc.org/libc v1.61.0 h1:eGFcvWpqlnoGwzZeZe3PWJkkKbM/3SUGyk1DVZQ0TpE=
modernc.org/libc v1.60.1/go.mod h1:xJuobKuNxKH3RUatS7GjR+suWj+5c2K7bi4m/S5arOY= modernc.org/libc v1.61.0/go.mod h1:DvxVX89wtGTu+r72MLGhygpfi3aUGgZRdAYGCAVVud0=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
@@ -416,8 +430,8 @@ modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
modernc.org/sqlite v1.32.0 h1:6BM4uGza7bWypsw4fdLRsLxut6bHe4c58VeqjRgST8s= modernc.org/sqlite v1.33.1 h1:trb6Z3YYoeM9eDL1O8do81kP+0ejv+YzgyFo+Gwy0nM=
modernc.org/sqlite v1.32.0/go.mod h1:UqoylwmTb9F+IqXERT8bW9zzOWN8qwAIcLdzeBZs4hA= modernc.org/sqlite v1.33.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

View File

@@ -1,3 +1,4 @@
// Package agent handles the agent's SSH server and system stats collection.
package agent package agent
import ( import (
@@ -15,11 +16,13 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
"path/filepath"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"time" "time"
"github.com/shirou/gopsutil/v4/common"
"github.com/shirou/gopsutil/v4/cpu" "github.com/shirou/gopsutil/v4/cpu"
"github.com/shirou/gopsutil/v4/disk" "github.com/shirou/gopsutil/v4/disk"
"github.com/shirou/gopsutil/v4/host" "github.com/shirou/gopsutil/v4/host"
@@ -41,6 +44,7 @@ type Agent struct {
netInterfaces map[string]struct{} netInterfaces map[string]struct{}
netIoStats *system.NetIoStats netIoStats *system.NetIoStats
dockerClient *http.Client dockerClient *http.Client
sensorsContext context.Context
bufferPool *sync.Pool bufferPool *sync.Pool
} }
@@ -53,6 +57,7 @@ func NewAgent(pubKey []byte, addr string) *Agent {
containerStatsMutex: &sync.Mutex{}, containerStatsMutex: &sync.Mutex{},
netIoStats: &system.NetIoStats{}, netIoStats: &system.NetIoStats{},
dockerClient: newDockerClient(), dockerClient: newDockerClient(),
sensorsContext: context.Background(),
bufferPool: &sync.Pool{ bufferPool: &sync.Pool{
New: func() interface{} { New: func() interface{} {
return new(bytes.Buffer) return new(bytes.Buffer)
@@ -176,7 +181,7 @@ func (a *Agent) getSystemStats() (system.Info, system.Stats) {
} }
// temperatures // temperatures
if temps, err := sensors.SensorsTemperatures(); err == nil { if temps, err := sensors.TemperaturesWithContext(a.sensorsContext); err == nil {
systemStats.Temperatures = make(map[string]float64) systemStats.Temperatures = make(map[string]float64)
// log.Printf("Temperatures: %+v\n", temps) // log.Printf("Temperatures: %+v\n", temps)
for i, temp := range temps { for i, temp := range temps {
@@ -200,7 +205,7 @@ func (a *Agent) getSystemStats() (system.Info, system.Stats) {
// add host info // add host info
if info, err := host.Info(); err == nil { if info, err := host.Info(); err == nil {
systemInfo.Uptime = info.Uptime systemInfo.Uptime = info.Uptime
// systemInfo.Os = info.OS systemInfo.Hostname = info.Hostname
} }
// add cpu stats // add cpu stats
if info, err := cpu.Info(); err == nil && len(info) > 0 { if info, err := cpu.Info(); err == nil && len(info) > 0 {
@@ -426,19 +431,15 @@ func (a *Agent) handleSession(s sshServer.Session) {
func (a *Agent) Run() { func (a *Agent) Run() {
a.fsStats = make(map[string]*system.FsStats) a.fsStats = make(map[string]*system.FsStats)
filesystem, fsEnvVarExists := os.LookupEnv("FILESYSTEM") // set sensors context (allows overriding sys location for sensors)
if fsEnvVarExists { if sysSensors, exists := os.LookupEnv("SYS_SENSORS"); exists {
a.fsStats[filesystem] = &system.FsStats{Root: true, Mountpoint: "/"} // log.Println("Using sys location for sensors:", sysSensors)
a.sensorsContext = context.WithValue(a.sensorsContext,
common.EnvKey, common.EnvMap{common.HostSysEnvKey: sysSensors},
)
} }
if extraFilesystems, exists := os.LookupEnv("EXTRA_FILESYSTEMS"); exists { a.initializeDiskInfo()
// 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()
@@ -447,26 +448,58 @@ func (a *Agent) Run() {
} }
// Sets up the filesystems to monitor for disk usage and I/O. // Sets up the filesystems to monitor for disk usage and I/O.
func (a *Agent) initializeDiskInfo(fsEnvVarExists bool) error { func (a *Agent) initializeDiskInfo() error {
filesystem := os.Getenv("FILESYSTEM")
hasRoot := false
// add values from EXTRA_FILESYSTEMS env var to fsStats
if extraFilesystems, exists := os.LookupEnv("EXTRA_FILESYSTEMS"); exists {
for _, filesystem := range strings.Split(extraFilesystems, ",") {
a.fsStats[filepath.Base(filesystem)] = &system.FsStats{}
}
}
partitions, err := disk.Partitions(false) partitions, err := disk.Partitions(false)
if err != nil { if err != nil {
return err return err
} }
// log.Printf("Partitions: %+v\n", partitions) // if FILESYSTEM env var is set, use it to find root filesystem
for _, v := range partitions { if filesystem != "" {
// binary - use root mountpoint if not already set by env var for _, v := range partitions {
if !fsEnvVarExists && v.Mountpoint == "/" { // use filesystem env var if matching partition is found
a.fsStats[v.Device] = &system.FsStats{Root: true, Mountpoint: "/"} if strings.HasSuffix(v.Device, filesystem) || v.Mountpoint == filesystem {
a.fsStats[filepath.Base(v.Device)] = &system.FsStats{Root: true, Mountpoint: v.Mountpoint}
hasRoot = true
break
}
} }
// docker - use /etc/hosts device as root if not mapped if !hasRoot {
if !fsEnvVarExists && v.Mountpoint == "/etc/hosts" && strings.HasPrefix(v.Device, "/dev") && !strings.Contains(v.Device, "mapper") { // if no match, log available partition details
a.fsStats[v.Device] = &system.FsStats{Root: true, Mountpoint: "/"} log.Printf("Partition details not found for %s:\n", filesystem)
for _, v := range partitions {
fmt.Printf("%+v\n", v)
}
}
}
for _, v := range partitions {
// binary root fallback - use root mountpoint
if !hasRoot && v.Mountpoint == "/" {
a.fsStats[filepath.Base(v.Device)] = &system.FsStats{Root: true, Mountpoint: "/"}
hasRoot = true
}
// docker root fallback - use /etc/hosts device if not mapped
if !hasRoot && v.Mountpoint == "/etc/hosts" && strings.HasPrefix(v.Device, "/dev") && !strings.Contains(v.Device, "mapper") {
a.fsStats[filepath.Base(v.Device)] = &system.FsStats{Root: true, Mountpoint: "/"}
hasRoot = true
} }
// check if device is in /extra-filesystem // check if device is in /extra-filesystem
if strings.HasPrefix(v.Mountpoint, "/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 // add to fsStats if not already there
a.fsStats[v.Device] = &system.FsStats{Mountpoint: v.Mountpoint} if _, exists := a.fsStats[filepath.Base(v.Device)]; !exists {
a.fsStats[filepath.Base(v.Device)] = &system.FsStats{Mountpoint: v.Mountpoint}
}
continue continue
} }
// set mountpoints for extra filesystems if passed in via env var // set mountpoints for extra filesystems if passed in via env var
@@ -479,10 +512,8 @@ func (a *Agent) initializeDiskInfo(fsEnvVarExists bool) error {
} }
// remove extra filesystems that don't have a mountpoint // remove extra filesystems that don't have a mountpoint
hasRoot := false
for name, stats := range a.fsStats { for name, stats := range a.fsStats {
if stats.Root { if stats.Root {
hasRoot = true
log.Println("Detected root fs:", name) log.Println("Detected root fs:", name)
} }
if stats.Mountpoint == "" { if stats.Mountpoint == "" {
@@ -493,8 +524,8 @@ func (a *Agent) initializeDiskInfo(fsEnvVarExists bool) error {
// if no root filesystem set, use most read device in /proc/diskstats // if no root filesystem set, use most read device in /proc/diskstats
if !hasRoot { if !hasRoot {
rootDevice := findMaxReadsDevice() rootDevice := findFallbackIoDevice(filepath.Base(filesystem))
log.Printf("Detected root fs: %+s\n", rootDevice) log.Printf("Using / as mountpoint and %s for I/O\n", rootDevice)
a.fsStats[rootDevice] = &system.FsStats{Root: true, Mountpoint: "/"} a.fsStats[rootDevice] = &system.FsStats{Root: true, Mountpoint: "/"}
} }
@@ -503,24 +534,20 @@ func (a *Agent) initializeDiskInfo(fsEnvVarExists bool) error {
// Sets start values for disk I/O stats. // Sets start values for disk I/O stats.
func (a *Agent) initializeDiskIoStats() { func (a *Agent) initializeDiskIoStats() {
// create slice of fs names to pass to disk.IOCounters later // create slice of fs names to pass to disk.IOCounters
a.fsNames = make([]string, 0, len(a.fsStats)) a.fsNames = make([]string, 0, len(a.fsStats))
for name := range a.fsStats {
a.fsNames = append(a.fsNames, name)
}
for name, stats := range a.fsStats { if ioCounters, err := disk.IOCounters(a.fsNames...); err == nil {
if io, err := disk.IOCounters(name); err == nil { for _, d := range ioCounters {
for _, d := range io { if a.fsStats[d.Name] == nil {
// add name to slice continue
a.fsNames = append(a.fsNames, d.Name)
// normalize name with io counters
if name != d.Name {
// log.Println("Normalizing disk I/O stats:", name, d.Name)
a.fsStats[d.Name] = stats
delete(a.fsStats, name)
}
stats.Time = time.Now()
stats.TotalRead = d.ReadBytes
stats.TotalWrite = d.WriteBytes
} }
a.fsStats[d.Name].Time = time.Now()
a.fsStats[d.Name].TotalRead = d.ReadBytes
a.fsStats[d.Name].TotalWrite = d.WriteBytes
} }
} }
} }
@@ -528,15 +555,36 @@ func (a *Agent) initializeDiskIoStats() {
func (a *Agent) initializeNetIoStats() { func (a *Agent) initializeNetIoStats() {
// reset valid network interfaces // reset valid network interfaces
a.netInterfaces = make(map[string]struct{}, 0) a.netInterfaces = make(map[string]struct{}, 0)
// map of network interface names passed in via NICS env var
var nicsMap map[string]struct{}
nics, nicsEnvExists := os.LookupEnv("NICS")
if nicsEnvExists {
nicsMap = make(map[string]struct{}, 0)
for _, nic := range strings.Split(nics, ",") {
nicsMap[nic] = struct{}{}
}
}
// reset network I/O stats // reset network I/O stats
a.netIoStats.BytesSent = 0 a.netIoStats.BytesSent = 0
a.netIoStats.BytesRecv = 0 a.netIoStats.BytesRecv = 0
// get intial network I/O stats // get intial network I/O stats
if netIO, err := psutilNet.IOCounters(true); err == nil { if netIO, err := psutilNet.IOCounters(true); err == nil {
a.netIoStats.Time = time.Now() a.netIoStats.Time = time.Now()
for _, v := range netIO { for _, v := range netIO {
if skipNetworkInterface(v) { switch {
continue // skip if nics exists and the interface is not in the list
case nicsEnvExists:
if _, nameInNics := nicsMap[v.Name]; !nameInNics {
continue
}
// otherwise run the interface name through the skipNetworkInterface function
default:
if a.skipNetworkInterface(v) {
continue
}
} }
log.Printf("Detected 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)
a.netIoStats.BytesSent += v.BytesSent a.netIoStats.BytesSent += v.BytesSent
@@ -559,7 +607,7 @@ func twoDecimals(value float64) float64 {
return math.Round(value*100) / 100 return math.Round(value*100) / 100
} }
func skipNetworkInterface(v psutilNet.IOCountersStat) bool { func (a *Agent) 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"),
@@ -623,39 +671,24 @@ func (a *Agent) closeIdleConnections(err error) (isTimeout bool) {
return false return false
} }
// Returns the device with the most reads in /proc/diskstats // Returns the device with the most reads in /proc/diskstats,
// or the device specified by the filesystem argument if it exists
// (fallback in case the root device is not supplied or detected) // (fallback in case the root device is not supplied or detected)
func findMaxReadsDevice() string { func findFallbackIoDevice(filesystem string) string {
content, err := os.ReadFile("/proc/diskstats") var maxReadBytes uint64
maxReadDevice := "/"
counters, err := disk.IOCounters()
if err != nil { if err != nil {
return "/" return maxReadDevice
} }
for _, d := range counters {
lines := strings.Split(string(content), "\n") if d.Name == filesystem {
var maxReadsSectors int64 return d.Name
var maxReadsDevice string
for _, line := range lines {
fields := strings.Fields(line)
if len(fields) < 7 {
continue
} }
if d.ReadBytes > maxReadBytes {
deviceName := fields[2] maxReadBytes = d.ReadBytes
readsSectors, err := strconv.ParseInt(fields[5], 10, 64) maxReadDevice = d.Name
if err != nil {
continue
}
if readsSectors > maxReadsSectors {
maxReadsSectors = readsSectors
maxReadsDevice = deviceName
} }
} }
return maxReadDevice
if maxReadsDevice == "" {
return "/"
}
return maxReadsDevice
} }

View File

@@ -5,9 +5,13 @@ import (
"beszel/internal/entities/system" "beszel/internal/entities/system"
"fmt" "fmt"
"net/mail" "net/mail"
"net/url"
"github.com/containrrr/shoutrrr"
"github.com/labstack/echo/v5"
"github.com/pocketbase/dbx" "github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase" "github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/models" "github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tools/mailer" "github.com/pocketbase/pocketbase/tools/mailer"
) )
@@ -16,6 +20,19 @@ type AlertManager struct {
app *pocketbase.PocketBase app *pocketbase.PocketBase
} }
type AlertData struct {
UserID string
Title string
Message string
Link string
LinkText string
}
type UserNotificationSettings struct {
Emails []string `json:"emails"`
Webhooks []string `json:"webhooks"`
}
func NewAlertManager(app *pocketbase.PocketBase) *AlertManager { func NewAlertManager(app *pocketbase.PocketBase) *AlertManager {
return &AlertManager{ return &AlertManager{
app: app, app: app,
@@ -67,16 +84,17 @@ func (am *AlertManager) handleSlidingValueAlert(newRecord *models.Record, alertR
// fmt.Println(name, curValue, "threshold", threshold, "triggered", triggered) // fmt.Println(name, curValue, "threshold", threshold, "triggered", triggered)
var subject string var subject string
var body string var body string
var systemName string
if !triggered && curValue > threshold { if !triggered && curValue > threshold {
alertRecord.Set("triggered", true) alertRecord.Set("triggered", true)
systemName := newRecord.GetString("name") systemName = newRecord.GetString("name")
subject = fmt.Sprintf("%s usage above threshold on %s", name, systemName) subject = fmt.Sprintf("%s usage above threshold on %s", name, systemName)
body = fmt.Sprintf("%s usage on %s is %.1f%%.\n\n%s\n\n- Beszel", name, systemName, curValue, am.app.Settings().Meta.AppUrl+"/system/"+systemName) body = fmt.Sprintf("%s usage on %s is %.1f%%.", name, systemName, curValue)
} else if triggered && curValue <= threshold { } else if triggered && curValue <= threshold {
alertRecord.Set("triggered", false) alertRecord.Set("triggered", false)
systemName := newRecord.GetString("name") systemName = newRecord.GetString("name")
subject = fmt.Sprintf("%s usage below threshold on %s", name, systemName) subject = fmt.Sprintf("%s usage below threshold on %s", name, systemName)
body = fmt.Sprintf("%s usage on %s is below threshold at %.1f%%.\n\n%s\n\n- Beszel", name, systemName, curValue, am.app.Settings().Meta.AppUrl+"/system/"+systemName) body = fmt.Sprintf("%s usage on %s is below threshold at %.1f%%.", name, systemName, curValue)
} else { } else {
// fmt.Println(name, "not triggered") // fmt.Println(name, "not triggered")
return return
@@ -91,10 +109,12 @@ func (am *AlertManager) handleSlidingValueAlert(newRecord *models.Record, alertR
return return
} }
if user := alertRecord.ExpandedOne("user"); user != nil { if user := alertRecord.ExpandedOne("user"); user != nil {
am.sendAlert(&mailer.Message{ am.sendAlert(AlertData{
To: []mail.Address{{Address: user.GetString("email")}}, UserID: user.GetId(),
Subject: subject, Title: subject,
Text: body, Message: body,
Link: am.app.Settings().Meta.AppUrl + "/system/" + url.QueryEscape(systemName),
LinkText: "View " + systemName,
}) })
} }
} }
@@ -128,24 +148,143 @@ func (am *AlertManager) handleStatusAlerts(newStatus string, oldRecord *models.R
} }
// send alert // send alert
systemName := oldRecord.GetString("name") systemName := oldRecord.GetString("name")
am.sendAlert(&mailer.Message{ am.sendAlert(AlertData{
To: []mail.Address{{Address: user.GetString("email")}}, UserID: user.GetId(),
Subject: fmt.Sprintf("Connection to %s is %s %v", systemName, alertStatus, emoji), Title: fmt.Sprintf("Connection to %s is %s %v", systemName, alertStatus, emoji),
Text: fmt.Sprintf("Connection to %s is %s\n\n- Beszel", systemName, alertStatus), Message: fmt.Sprintf("Connection to %s is %s", systemName, alertStatus),
Link: am.app.Settings().Meta.AppUrl + "/system/" + url.QueryEscape(systemName),
LinkText: "View " + systemName,
}) })
return nil return nil
} }
func (am *AlertManager) sendAlert(message *mailer.Message) { func (am *AlertManager) sendAlert(data AlertData) {
// fmt.Println("sending alert", "to", message.To, "subj", message.Subject, "body", message.Text) // get user settings
message.From = mail.Address{ record, err := am.app.Dao().FindFirstRecordByFilter(
Address: am.app.Settings().Meta.SenderAddress, "user_settings", "user={:user}",
Name: am.app.Settings().Meta.SenderName, dbx.Params{"user": data.UserID},
)
if err != nil {
am.app.Logger().Error("Failed to get user settings", "err", err.Error())
return
} }
if err := am.app.NewMailClient().Send(message); err != nil { // unmarshal user settings
userAlertSettings := UserNotificationSettings{
Emails: []string{},
Webhooks: []string{},
}
if err := record.UnmarshalJSONField("settings", &userAlertSettings); err != nil {
am.app.Logger().Error("Failed to unmarshal user settings", "err", err.Error())
}
// send alerts via webhooks
for _, webhook := range userAlertSettings.Webhooks {
if err := am.SendShoutrrrAlert(webhook, data.Title, data.Message, data.Link, data.LinkText); err != nil {
am.app.Logger().Error("Failed to send shoutrrr alert", "err", err.Error())
}
}
// send alerts via email
if len(userAlertSettings.Emails) == 0 {
// log.Println("No email addresses found")
return
}
addresses := []mail.Address{}
for _, email := range userAlertSettings.Emails {
addresses = append(addresses, mail.Address{Address: email})
}
message := mailer.Message{
To: addresses,
Subject: data.Title,
Text: data.Message + fmt.Sprintf("\n\n%s", data.Link),
From: mail.Address{
Address: am.app.Settings().Meta.SenderAddress,
Name: am.app.Settings().Meta.SenderName,
},
}
if err := am.app.NewMailClient().Send(&message); err != nil {
am.app.Logger().Error("Failed to send alert: ", "err", err.Error()) am.app.Logger().Error("Failed to send alert: ", "err", err.Error())
} else { } else {
am.app.Logger().Info("Sent alert", "to", message.To, "subj", message.Subject) am.app.Logger().Info("Sent email alert", "to", message.To, "subj", message.Subject)
} }
} }
// SendShoutrrrAlert sends an alert via a Shoutrrr URL
func (am *AlertManager) SendShoutrrrAlert(notificationUrl, title, message, link, linkText string) error {
// services that support title param
supportsTitle := []string{"bark", "discord", "gotify", "ifttt", "join", "matrix", "ntfy", "opsgenie", "pushbullet", "pushover", "slack", "teams", "telegram", "zulip"}
// Parse the URL
parsedURL, err := url.Parse(notificationUrl)
if err != nil {
return fmt.Errorf("error parsing URL: %v", err)
}
scheme := parsedURL.Scheme
queryParams := parsedURL.Query()
// Add title
if sliceContains(supportsTitle, scheme) {
queryParams.Add("title", title)
} else if scheme == "mattermost" {
// use markdown title for mattermost
message = "##### " + title + "\n\n" + message
} else if scheme == "generic" && queryParams.Has("template") {
// add title as property if using generic with template json
titleKey := queryParams.Get("titlekey")
if titleKey == "" {
titleKey = "title"
}
queryParams.Add("$"+titleKey, title)
} else {
// otherwise just add title to message
message = title + "\n\n" + message
}
// Add link
if scheme == "ntfy" {
// if ntfy, add link to actions
queryParams.Add("Actions", fmt.Sprintf("view, %s, %s", linkText, link))
} else {
// else add link directly to the message
message += "\n\n" + link
}
// Encode the modified query parameters back into the URL
parsedURL.RawQuery = queryParams.Encode()
// log.Println("URL after modification:", parsedURL.String())
err = shoutrrr.Send(parsedURL.String(), message)
if err == nil {
am.app.Logger().Info("Sent shoutrrr alert", "title", title)
} else {
am.app.Logger().Error("Error sending shoutrrr alert", "err", err.Error())
return err
}
return nil
}
// Contains checks if a string is present in a slice of strings
func sliceContains(slice []string, item string) bool {
for _, v := range slice {
if v == item {
return true
}
}
return false
}
func (am *AlertManager) SendTestNotification(c echo.Context) error {
requestData := apis.RequestInfo(c)
if requestData.AuthRecord == nil {
return apis.NewForbiddenError("Forbidden", nil)
}
url := c.QueryParam("url")
// log.Println("url", url)
if url == "" {
return c.JSON(200, map[string]string{"err": "URL is required"})
}
err := am.SendShoutrrrAlert(url, "Test Alert", "This is a notification from Beszel.", am.app.Settings().Meta.AppUrl, "View Beszel")
if err != nil {
return c.JSON(200, map[string]string{"err": err.Error()})
}
return c.JSON(200, map[string]bool{"err": false})
}

View File

@@ -45,6 +45,8 @@ type NetIoStats struct {
} }
type Info struct { type Info struct {
Hostname string `json:"h"`
Cores int `json:"c"` Cores int `json:"c"`
Threads int `json:"t"` Threads int `json:"t"`
CpuModel string `json:"m"` CpuModel string `json:"m"`

View File

@@ -1,3 +1,4 @@
// Package hub handles updating systems and serving the web UI.
package hub package hub
import ( import (
@@ -5,6 +6,7 @@ import (
"beszel/internal/alerts" "beszel/internal/alerts"
"beszel/internal/entities/system" "beszel/internal/entities/system"
"beszel/internal/records" "beszel/internal/records"
"beszel/internal/users"
"beszel/site" "beszel/site"
"context" "context"
"crypto/ed25519" "crypto/ed25519"
@@ -48,8 +50,9 @@ func NewHub(app *pocketbase.PocketBase) *Hub {
} }
func (h *Hub) Run() { func (h *Hub) Run() {
var rm *records.RecordManager rm := records.NewRecordManager(h.app)
var am *alerts.AlertManager am := alerts.NewAlertManager(h.app)
um := users.NewUserManager(h.app)
// loosely check if it was executed using "go run" // loosely check if it was executed using "go run"
isGoRun := strings.HasPrefix(os.Args[0], os.TempDir()) isGoRun := strings.HasPrefix(os.Args[0], os.TempDir())
@@ -63,9 +66,6 @@ func (h *Hub) Run() {
// initial setup // initial setup
h.app.OnBeforeServe().Add(func(e *core.ServeEvent) error { h.app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
// set up record manager and alert manager
rm = records.NewRecordManager(h.app)
am = alerts.NewAlertManager(h.app)
// create ssh client config // create ssh client config
err := h.createSSHClientConfig() err := h.createSSHClientConfig()
if err != nil { if err != nil {
@@ -140,15 +140,8 @@ func (h *Hub) Run() {
} }
return c.JSON(http.StatusOK, map[string]bool{"firstRun": adminNum == 0}) return c.JSON(http.StatusOK, map[string]bool{"firstRun": adminNum == 0})
}) })
return nil // send test notification
}) e.Router.GET("/api/beszel/send-test-notification", am.SendTestNotification)
// user creation - set default role to user if unset
h.app.OnModelBeforeCreate("users").Add(func(e *core.ModelEvent) error {
user := e.Model.(*models.Record)
if user.GetString("role") == "" {
user.Set("role", "user")
}
return nil return nil
}) })
@@ -166,6 +159,10 @@ func (h *Hub) Run() {
return nil return nil
}) })
// handle default values for user / user_settings creation
h.app.OnModelBeforeCreate("users").Add(um.InitializeUserRole)
h.app.OnModelBeforeCreate("user_settings").Add(um.InitializeUserSettings)
// do things after a systems record is updated // do things after a systems record is updated
h.app.OnModelAfterUpdate("systems").Add(func(e *core.ModelEvent) error { h.app.OnModelAfterUpdate("systems").Add(func(e *core.ModelEvent) error {
newRecord := e.Model.(*models.Record) newRecord := e.Model.(*models.Record)

View File

@@ -19,10 +19,10 @@ type RecordManager struct {
} }
type LongerRecordData struct { type LongerRecordData struct {
shorterType string shorterType string
longerType string longerType string
longerTimeDuration time.Duration longerTimeDuration time.Duration
expectedShorterRecords int minShorterRecords int
} }
type RecordDeletionData struct { type RecordDeletionData struct {
@@ -39,28 +39,29 @@ func (rm *RecordManager) CreateLongerRecords() {
// start := time.Now() // start := time.Now()
recordData := []LongerRecordData{ recordData := []LongerRecordData{
{ {
shorterType: "1m", shorterType: "1m",
expectedShorterRecords: 10, // change to 9 from 10 to allow edge case timing or short pauses
longerType: "10m", minShorterRecords: 9,
longerTimeDuration: -10 * time.Minute, longerType: "10m",
longerTimeDuration: -10 * time.Minute,
}, },
{ {
shorterType: "10m", shorterType: "10m",
expectedShorterRecords: 2, minShorterRecords: 2,
longerType: "20m", longerType: "20m",
longerTimeDuration: -20 * time.Minute, longerTimeDuration: -20 * time.Minute,
}, },
{ {
shorterType: "20m", shorterType: "20m",
expectedShorterRecords: 6, minShorterRecords: 6,
longerType: "120m", longerType: "120m",
longerTimeDuration: -120 * time.Minute, longerTimeDuration: -120 * time.Minute,
}, },
{ {
shorterType: "120m", shorterType: "120m",
expectedShorterRecords: 4, minShorterRecords: 4,
longerType: "480m", longerType: "480m",
longerTimeDuration: -480 * time.Minute, longerTimeDuration: -480 * time.Minute,
}, },
} }
// wrap the operations in a transaction // wrap the operations in a transaction
@@ -111,7 +112,7 @@ func (rm *RecordManager) CreateLongerRecords() {
) )
// continue if not enough shorter records // continue if not enough shorter records
if err != nil || len(allShorterRecords) < recordData.expectedShorterRecords { if err != nil || len(allShorterRecords) < recordData.minShorterRecords {
// log.Println("not enough shorter records. continue.", len(allShorterRecords), recordData.expectedShorterRecords) // log.Println("not enough shorter records. continue.", len(allShorterRecords), recordData.expectedShorterRecords)
continue continue
} }

View File

@@ -1,3 +1,4 @@
// Package update handles updating beszel and beszel-agent.
package update package update
import ( import (

View File

@@ -0,0 +1,65 @@
// Package users handles user-related custom functionality.
package users
import (
"log"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/models"
)
type UserManager struct {
app *pocketbase.PocketBase
}
type UserSettings struct {
ChartTime string `json:"chartTime"`
NotificationEmails []string `json:"emails"`
NotificationWebhooks []string `json:"webhooks"`
// Language string `json:"lang"`
}
func NewUserManager(app *pocketbase.PocketBase) *UserManager {
return &UserManager{
app: app,
}
}
func (um *UserManager) InitializeUserRole(e *core.ModelEvent) error {
user := e.Model.(*models.Record)
if user.GetString("role") == "" {
user.Set("role", "user")
}
return nil
}
func (um *UserManager) InitializeUserSettings(e *core.ModelEvent) error {
record := e.Model.(*models.Record)
// intialize settings with defaults
settings := UserSettings{
// Language: "en",
ChartTime: "1h",
NotificationEmails: []string{},
NotificationWebhooks: []string{},
}
record.UnmarshalJSONField("settings", &settings)
if len(settings.NotificationEmails) == 0 {
// get user email from auth record
if errs := um.app.Dao().ExpandRecord(record, []string{"user"}, nil); len(errs) == 0 {
// app.Logger().Error("failed to expand user relation", "errs", errs)
if user := record.ExpandedOne("user"); user != nil {
settings.NotificationEmails = []string{user.GetString("email")}
} else {
log.Println("Failed to get user email from auth record")
}
} else {
log.Println("failed to expand user relation", "errs", errs)
}
}
// if len(settings.NotificationWebhooks) == 0 {
// settings.NotificationWebhooks = []string{""}
// }
record.Set("settings", settings)
return nil
}

View File

@@ -15,7 +15,7 @@ func init() {
{ {
"id": "2hz5ncl8tizk5nx", "id": "2hz5ncl8tizk5nx",
"created": "2024-07-07 16:08:20.979Z", "created": "2024-07-07 16:08:20.979Z",
"updated": "2024-07-28 17:00:47.996Z", "updated": "2024-07-28 17:14:24.492Z",
"name": "systems", "name": "systems",
"type": "base", "type": "base",
"system": false, "system": false,
@@ -120,7 +120,7 @@ func init() {
{ {
"id": "ej9oowivz8b2mht", "id": "ej9oowivz8b2mht",
"created": "2024-07-07 16:09:09.179Z", "created": "2024-07-07 16:09:09.179Z",
"updated": "2024-07-22 20:13:31.324Z", "updated": "2024-07-28 17:14:24.492Z",
"name": "system_stats", "name": "system_stats",
"type": "base", "type": "base",
"system": false, "system": false,
@@ -186,7 +186,7 @@ func init() {
{ {
"id": "juohu4jipgc13v7", "id": "juohu4jipgc13v7",
"created": "2024-07-07 16:09:57.976Z", "created": "2024-07-07 16:09:57.976Z",
"updated": "2024-07-22 20:13:31.324Z", "updated": "2024-07-28 17:14:24.492Z",
"name": "container_stats", "name": "container_stats",
"type": "base", "type": "base",
"system": false, "system": false,
@@ -250,7 +250,7 @@ func init() {
{ {
"id": "_pb_users_auth_", "id": "_pb_users_auth_",
"created": "2024-07-14 16:25:18.226Z", "created": "2024-07-14 16:25:18.226Z",
"updated": "2024-07-28 17:02:08.311Z", "updated": "2024-09-12 23:19:36.280Z",
"name": "users", "name": "users",
"type": "auth", "type": "auth",
"system": false, "system": false,
@@ -316,7 +316,7 @@ func init() {
{ {
"id": "elngm8x1l60zi2v", "id": "elngm8x1l60zi2v",
"created": "2024-07-15 01:16:04.044Z", "created": "2024-07-15 01:16:04.044Z",
"updated": "2024-07-22 20:13:31.324Z", "updated": "2024-07-28 17:14:24.492Z",
"name": "alerts", "name": "alerts",
"type": "base", "type": "base",
"system": false, "system": false,
@@ -403,6 +403,53 @@ func init() {
"updateRule": "@request.auth.id != \"\" && user.id = @request.auth.id", "updateRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
"deleteRule": "@request.auth.id != \"\" && user.id = @request.auth.id", "deleteRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
"options": {} "options": {}
},
{
"id": "4afacsdnlu8q8r2",
"created": "2024-09-12 17:42:55.324Z",
"updated": "2024-09-12 21:19:59.114Z",
"name": "user_settings",
"type": "base",
"system": false,
"schema": [
{
"system": false,
"id": "d5vztyxa",
"name": "user",
"type": "relation",
"required": true,
"presentable": false,
"unique": false,
"options": {
"collectionId": "_pb_users_auth_",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "xcx4qgqq",
"name": "settings",
"type": "json",
"required": false,
"presentable": false,
"unique": false,
"options": {
"maxSize": 2000000
}
}
],
"indexes": [
"CREATE UNIQUE INDEX ` + "`" + `idx_30Lwgf2` + "`" + ` ON ` + "`" + `user_settings` + "`" + ` (` + "`" + `user` + "`" + `)"
],
"listRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
"viewRule": null,
"createRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
"updateRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
"deleteRule": null,
"options": {}
} }
]` ]`

View File

@@ -2,18 +2,17 @@ import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart' import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'
import { import {
useYAxisWidth,
chartTimeData, chartTimeData,
cn, cn,
formatShortDate, formatShortDate,
toFixedWithoutTrailingZeros, toFixedWithoutTrailingZeros,
twoDecimalString, twoDecimalString,
useYaxisWidth,
} from '@/lib/utils' } from '@/lib/utils'
// import Spinner from '../spinner' // import Spinner from '../spinner'
import { useStore } from '@nanostores/react' import { useStore } from '@nanostores/react'
import { $chartTime } from '@/lib/stores' import { $chartTime } from '@/lib/stores'
import { SystemStatsRecord } from '@/types' import { SystemStatsRecord } from '@/types'
import { useMemo, useRef } from 'react'
export default function BandwidthChart({ export default function BandwidthChart({
ticks, ticks,
@@ -22,19 +21,16 @@ export default function BandwidthChart({
ticks: number[] ticks: number[]
systemData: SystemStatsRecord[] systemData: SystemStatsRecord[]
}) { }) {
const chartRef = useRef<HTMLDivElement>(null)
const yAxisWidth = useYaxisWidth(chartRef)
const chartTime = useStore($chartTime) const chartTime = useStore($chartTime)
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
const yAxisSet = useMemo(() => yAxisWidth !== 180, [yAxisWidth])
return ( return (
<div ref={chartRef}> <div>
{/* {!yAxisSet && <Spinner />} */} {/* {!yAxisSet && <Spinner />} */}
<ChartContainer <ChartContainer
config={{}} config={{}}
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', { className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
'opacity-100': yAxisSet, 'opacity-100': yAxisWidth,
})} })}
> >
<AreaChart <AreaChart
@@ -52,10 +48,13 @@ export default function BandwidthChart({
className="tracking-tighter" className="tracking-tighter"
width={yAxisWidth} width={yAxisWidth}
// domain={[0, (max: number) => (max <= 0.4 ? 0.4 : Math.ceil(max))]} // domain={[0, (max: number) => (max <= 0.4 ? 0.4 : Math.ceil(max))]}
tickFormatter={(value) => toFixedWithoutTrailingZeros(value, 2)} tickFormatter={(value) => {
const val = toFixedWithoutTrailingZeros(value, 2) + ' MB/s'
return updateYAxisWidth(val)
}}
tickLine={false} tickLine={false}
axisLine={false} axisLine={false}
unit={' MB/s'} // unit={' MB/s'}
/> />
<XAxis <XAxis
dataKey="created" dataKey="created"

View File

@@ -5,8 +5,8 @@ import {
ChartTooltip, ChartTooltip,
ChartTooltipContent, ChartTooltipContent,
} from '@/components/ui/chart' } from '@/components/ui/chart'
import { useMemo, useRef } from 'react' import { useMemo } from 'react'
import { chartTimeData, cn, formatShortDate, twoDecimalString, useYaxisWidth } from '@/lib/utils' import { useYAxisWidth, chartTimeData, cn, formatShortDate, twoDecimalString } from '@/lib/utils'
// import Spinner from '../spinner' // import Spinner from '../spinner'
import { useStore } from '@nanostores/react' import { useStore } from '@nanostores/react'
import { $chartTime, $containerFilter } from '@/lib/stores' import { $chartTime, $containerFilter } from '@/lib/stores'
@@ -18,12 +18,9 @@ export default function ContainerCpuChart({
chartData: Record<string, number | string>[] chartData: Record<string, number | string>[]
ticks: number[] ticks: number[]
}) { }) {
const chartRef = useRef<HTMLDivElement>(null)
const yAxisWidth = useYaxisWidth(chartRef)
const chartTime = useStore($chartTime) const chartTime = useStore($chartTime)
const filter = useStore($containerFilter) const filter = useStore($containerFilter)
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
const yAxisSet = useMemo(() => yAxisWidth !== 180, [yAxisWidth])
const chartConfig = useMemo(() => { const chartConfig = useMemo(() => {
let config = {} as Record< let config = {} as Record<
@@ -65,12 +62,12 @@ export default function ContainerCpuChart({
// } // }
return ( return (
<div ref={chartRef}> <div>
{/* {!yAxisSet && <Spinner />} */} {/* {!yAxisSet && <Spinner />} */}
<ChartContainer <ChartContainer
config={{}} config={{}}
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', { className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
'opacity-100': yAxisSet, 'opacity-100': yAxisWidth,
})} })}
> >
<AreaChart <AreaChart
@@ -89,8 +86,10 @@ export default function ContainerCpuChart({
width={yAxisWidth} width={yAxisWidth}
tickLine={false} tickLine={false}
axisLine={false} axisLine={false}
unit={'%'} tickFormatter={(x) => {
tickFormatter={(x) => (x % 1 === 0 ? x : x.toFixed(1))} const val = (x % 1 === 0 ? x : x.toFixed(1)) + '%'
return updateYAxisWidth(val)
}}
/> />
<XAxis <XAxis
dataKey="time" dataKey="time"

View File

@@ -5,14 +5,14 @@ import {
ChartTooltip, ChartTooltip,
ChartTooltipContent, ChartTooltipContent,
} from '@/components/ui/chart' } from '@/components/ui/chart'
import { useMemo, useRef } from 'react' import { useMemo } from 'react'
import { import {
useYAxisWidth,
chartTimeData, chartTimeData,
cn, cn,
formatShortDate, formatShortDate,
toFixedWithoutTrailingZeros, toFixedWithoutTrailingZeros,
twoDecimalString, twoDecimalString,
useYaxisWidth,
} from '@/lib/utils' } from '@/lib/utils'
// import Spinner from '../spinner' // import Spinner from '../spinner'
import { useStore } from '@nanostores/react' import { useStore } from '@nanostores/react'
@@ -26,11 +26,8 @@ export default function ContainerMemChart({
ticks: number[] ticks: number[]
}) { }) {
const chartTime = useStore($chartTime) const chartTime = useStore($chartTime)
const chartRef = useRef<HTMLDivElement>(null)
const yAxisWidth = useYaxisWidth(chartRef)
const filter = useStore($containerFilter) const filter = useStore($containerFilter)
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
const yAxisSet = useMemo(() => yAxisWidth !== 180, [yAxisWidth])
const chartConfig = useMemo(() => { const chartConfig = useMemo(() => {
let config = {} as Record< let config = {} as Record<
@@ -72,12 +69,12 @@ export default function ContainerMemChart({
// } // }
return ( return (
<div ref={chartRef}> <div>
{/* {!yAxisSet && <Spinner />} */} {/* {!yAxisSet && <Spinner />} */}
<ChartContainer <ChartContainer
config={{}} config={{}}
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', { className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
'opacity-100': yAxisSet, 'opacity-100': yAxisWidth,
})} })}
> >
<AreaChart <AreaChart
@@ -94,9 +91,11 @@ export default function ContainerMemChart({
// domain={[0, (max: number) => Math.ceil(max)]} // domain={[0, (max: number) => Math.ceil(max)]}
tickLine={false} tickLine={false}
axisLine={false} axisLine={false}
unit={' GB'}
width={yAxisWidth} width={yAxisWidth}
tickFormatter={(value) => toFixedWithoutTrailingZeros(value / 1024, 2)} tickFormatter={(value) => {
const val = toFixedWithoutTrailingZeros(value / 1024, 2) + ' GB'
return updateYAxisWidth(val)
}}
/> />
<XAxis <XAxis
dataKey="time" dataKey="time"

View File

@@ -5,14 +5,14 @@ import {
ChartTooltip, ChartTooltip,
ChartTooltipContent, ChartTooltipContent,
} from '@/components/ui/chart' } from '@/components/ui/chart'
import { useMemo, useRef } from 'react' import { useMemo } from 'react'
import { import {
useYAxisWidth,
chartTimeData, chartTimeData,
cn, cn,
formatShortDate, formatShortDate,
toFixedWithoutTrailingZeros, toFixedWithoutTrailingZeros,
twoDecimalString, twoDecimalString,
useYaxisWidth,
} from '@/lib/utils' } from '@/lib/utils'
// import Spinner from '../spinner' // import Spinner from '../spinner'
import { useStore } from '@nanostores/react' import { useStore } from '@nanostores/react'
@@ -27,11 +27,8 @@ export default function ContainerCpuChart({
ticks: number[] ticks: number[]
}) { }) {
const chartTime = useStore($chartTime) const chartTime = useStore($chartTime)
const chartRef = useRef<HTMLDivElement>(null)
const yAxisWidth = useYaxisWidth(chartRef)
const filter = useStore($containerFilter) const filter = useStore($containerFilter)
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
const yAxisSet = useMemo(() => yAxisWidth !== 180, [yAxisWidth])
const chartConfig = useMemo(() => { const chartConfig = useMemo(() => {
let config = {} as Record< let config = {} as Record<
@@ -72,12 +69,12 @@ export default function ContainerCpuChart({
// } // }
return ( return (
<div ref={chartRef}> <div>
{/* {!yAxisSet && <Spinner />} */} {/* {!yAxisSet && <Spinner />} */}
<ChartContainer <ChartContainer
config={{}} config={{}}
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', { className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
'opacity-100': yAxisSet, 'opacity-100': yAxisWidth,
})} })}
> >
<AreaChart <AreaChart
@@ -95,8 +92,10 @@ export default function ContainerCpuChart({
width={yAxisWidth} width={yAxisWidth}
tickLine={false} tickLine={false}
axisLine={false} axisLine={false}
unit={' MB/s'} tickFormatter={(value) => {
tickFormatter={(value) => toFixedWithoutTrailingZeros(value, 2)} const val = toFixedWithoutTrailingZeros(value, 2) + ' MB/s'
return updateYAxisWidth(val)
}}
/> />
<XAxis <XAxis
dataKey="time" dataKey="time"

View File

@@ -1,12 +1,11 @@
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts' import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart' import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'
import { chartTimeData, cn, formatShortDate, twoDecimalString, useYaxisWidth } from '@/lib/utils' import { useYAxisWidth, chartTimeData, cn, formatShortDate, twoDecimalString } from '@/lib/utils'
// import Spinner from '../spinner' // import Spinner from '../spinner'
import { useStore } from '@nanostores/react' import { useStore } from '@nanostores/react'
import { $chartTime } from '@/lib/stores' import { $chartTime } from '@/lib/stores'
import { SystemStatsRecord } from '@/types' import { SystemStatsRecord } from '@/types'
import { useMemo, useRef } from 'react'
export default function CpuChart({ export default function CpuChart({
ticks, ticks,
@@ -16,17 +15,14 @@ export default function CpuChart({
systemData: SystemStatsRecord[] systemData: SystemStatsRecord[]
}) { }) {
const chartTime = useStore($chartTime) const chartTime = useStore($chartTime)
const chartRef = useRef<HTMLDivElement>(null) const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
const yAxisWidth = useYaxisWidth(chartRef)
const yAxisSet = useMemo(() => yAxisWidth !== 180, [yAxisWidth])
return ( return (
<div ref={chartRef}> <div>
<ChartContainer <ChartContainer
config={{}} config={{}}
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', { className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
'opacity-100': yAxisSet, 'opacity-100': yAxisWidth,
})} })}
> >
<AreaChart <AreaChart
@@ -42,7 +38,7 @@ export default function CpuChart({
width={yAxisWidth} width={yAxisWidth}
tickLine={false} tickLine={false}
axisLine={false} axisLine={false}
unit={'%'} tickFormatter={(value) => updateYAxisWidth(value + '%')}
/> />
<XAxis <XAxis
dataKey="created" dataKey="created"

View File

@@ -1,8 +1,8 @@
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts' import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart' import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'
import { chartTimeData, cn, formatShortDate, twoDecimalString, useYaxisWidth } from '@/lib/utils' import { useYAxisWidth, chartTimeData, cn, formatShortDate, twoDecimalString } from '@/lib/utils'
import { useMemo, useRef } from 'react' import { useMemo } from 'react'
// import Spinner from '../spinner' // import Spinner from '../spinner'
import { useStore } from '@nanostores/react' import { useStore } from '@nanostores/react'
import { $chartTime } from '@/lib/stores' import { $chartTime } from '@/lib/stores'
@@ -16,22 +16,19 @@ export default function DiskChart({
systemData: SystemStatsRecord[] systemData: SystemStatsRecord[]
}) { }) {
const chartTime = useStore($chartTime) const chartTime = useStore($chartTime)
const chartRef = useRef<HTMLDivElement>(null) const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
const yAxisWidth = useYaxisWidth(chartRef)
const yAxisSet = useMemo(() => yAxisWidth !== 180, [yAxisWidth])
const diskSize = useMemo(() => { const diskSize = useMemo(() => {
return Math.round(systemData.at(-1)?.stats.d ?? NaN) return Math.round(systemData.at(-1)?.stats.d ?? NaN)
}, [systemData]) }, [systemData])
return ( return (
<div ref={chartRef}> <div>
{/* {!yAxisSet && <Spinner />} */} {/* {!yAxisSet && <Spinner />} */}
<ChartContainer <ChartContainer
config={{}} config={{}}
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', { className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
'opacity-100': yAxisSet, 'opacity-100': yAxisWidth,
})} })}
> >
<AreaChart <AreaChart
@@ -53,7 +50,7 @@ export default function DiskChart({
minTickGap={6} minTickGap={6}
tickLine={false} tickLine={false}
axisLine={false} axisLine={false}
unit={' GB'} tickFormatter={(value) => updateYAxisWidth(value + ' GB')}
/> />
<XAxis <XAxis
dataKey="created" dataKey="created"

View File

@@ -2,18 +2,17 @@ import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart' import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'
import { import {
useYAxisWidth,
chartTimeData, chartTimeData,
cn, cn,
formatShortDate, formatShortDate,
toFixedWithoutTrailingZeros, toFixedWithoutTrailingZeros,
twoDecimalString, twoDecimalString,
useYaxisWidth,
} from '@/lib/utils' } from '@/lib/utils'
// import Spinner from '../spinner' // import Spinner from '../spinner'
import { useStore } from '@nanostores/react' import { useStore } from '@nanostores/react'
import { $chartTime } from '@/lib/stores' import { $chartTime } from '@/lib/stores'
import { SystemStatsRecord } from '@/types' import { SystemStatsRecord } from '@/types'
import { useMemo, useRef } from 'react'
export default function DiskIoChart({ export default function DiskIoChart({
ticks, ticks,
@@ -23,22 +22,15 @@ export default function DiskIoChart({
systemData: SystemStatsRecord[] systemData: SystemStatsRecord[]
}) { }) {
const chartTime = useStore($chartTime) const chartTime = useStore($chartTime)
const chartRef = useRef<HTMLDivElement>(null) const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
const yAxisWidth = useYaxisWidth(chartRef)
const yAxisSet = useMemo(() => yAxisWidth !== 180, [yAxisWidth])
// if (!systemData.length || !ticks.length) {
// return <Spinner />
// }
return ( return (
<div ref={chartRef}> <div>
{/* {!yAxisSet && <Spinner />} */} {/* {!yAxisSet && <Spinner />} */}
<ChartContainer <ChartContainer
config={{}} config={{}}
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', { className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
'opacity-100': yAxisSet, 'opacity-100': yAxisWidth,
})} })}
> >
<AreaChart <AreaChart
@@ -56,10 +48,12 @@ export default function DiskIoChart({
className="tracking-tighter" className="tracking-tighter"
width={yAxisWidth} width={yAxisWidth}
// domain={[0, (max: number) => (max <= 0.4 ? 0.4 : Math.ceil(max))]} // domain={[0, (max: number) => (max <= 0.4 ? 0.4 : Math.ceil(max))]}
tickFormatter={(value) => toFixedWithoutTrailingZeros(value, 2)} tickFormatter={(value) => {
const val = toFixedWithoutTrailingZeros(value, 2) + ' MB/s'
return updateYAxisWidth(val)
}}
tickLine={false} tickLine={false}
axisLine={false} axisLine={false}
unit={' MB/s'}
/> />
<XAxis <XAxis
dataKey="created" dataKey="created"

View File

@@ -1,8 +1,8 @@
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts' import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart' import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'
import { chartTimeData, cn, formatShortDate, twoDecimalString, useYaxisWidth } from '@/lib/utils' import { useYAxisWidth, chartTimeData, cn, formatShortDate, twoDecimalString } from '@/lib/utils'
import { useMemo, useRef } from 'react' import { useMemo } from 'react'
// import Spinner from '../spinner' // import Spinner from '../spinner'
import { useStore } from '@nanostores/react' import { useStore } from '@nanostores/react'
import { $chartTime } from '@/lib/stores' import { $chartTime } from '@/lib/stores'
@@ -18,10 +18,7 @@ export default function ExFsDiskChart({
fs: string fs: string
}) { }) {
const chartTime = useStore($chartTime) const chartTime = useStore($chartTime)
const chartRef = useRef<HTMLDivElement>(null) const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
const yAxisWidth = useYaxisWidth(chartRef)
const yAxisSet = useMemo(() => yAxisWidth !== 180, [yAxisWidth])
const diskSize = useMemo(() => { const diskSize = useMemo(() => {
const size = systemData.at(-1)?.stats.efs?.[fs].d ?? 0 const size = systemData.at(-1)?.stats.efs?.[fs].d ?? 0
@@ -29,11 +26,11 @@ export default function ExFsDiskChart({
}, [systemData]) }, [systemData])
return ( return (
<div ref={chartRef}> <div>
<ChartContainer <ChartContainer
config={{}} config={{}}
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', { className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
'opacity-100': yAxisSet, 'opacity-100': yAxisWidth,
})} })}
> >
<AreaChart <AreaChart
@@ -52,7 +49,7 @@ export default function ExFsDiskChart({
minTickGap={6} minTickGap={6}
tickLine={false} tickLine={false}
axisLine={false} axisLine={false}
unit={' GB'} tickFormatter={(value) => updateYAxisWidth(value + ' GB')}
/> />
<XAxis <XAxis
dataKey="created" dataKey="created"

View File

@@ -2,18 +2,17 @@ import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart' import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'
import { import {
useYAxisWidth,
chartTimeData, chartTimeData,
cn, cn,
formatShortDate, formatShortDate,
toFixedWithoutTrailingZeros, toFixedWithoutTrailingZeros,
twoDecimalString, twoDecimalString,
useYaxisWidth,
} from '@/lib/utils' } from '@/lib/utils'
// import Spinner from '../spinner' // import Spinner from '../spinner'
import { useStore } from '@nanostores/react' import { useStore } from '@nanostores/react'
import { $chartTime } from '@/lib/stores' import { $chartTime } from '@/lib/stores'
import { SystemStatsRecord } from '@/types' import { SystemStatsRecord } from '@/types'
import { useMemo, useRef } from 'react'
export default function ExFsDiskIoChart({ export default function ExFsDiskIoChart({
ticks, ticks,
@@ -25,22 +24,15 @@ export default function ExFsDiskIoChart({
fs: string fs: string
}) { }) {
const chartTime = useStore($chartTime) const chartTime = useStore($chartTime)
const chartRef = useRef<HTMLDivElement>(null) const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
const yAxisWidth = useYaxisWidth(chartRef)
const yAxisSet = useMemo(() => yAxisWidth !== 180, [yAxisWidth])
// if (!systemData.length || !ticks.length) {
// return <Spinner />
// }
return ( return (
<div ref={chartRef}> <div>
{/* {!yAxisSet && <Spinner />} */} {/* {!yAxisSet && <Spinner />} */}
<ChartContainer <ChartContainer
config={{}} config={{}}
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', { className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
'opacity-100': yAxisSet, 'opacity-100': yAxisWidth,
})} })}
> >
<AreaChart <AreaChart
@@ -58,10 +50,12 @@ export default function ExFsDiskIoChart({
className="tracking-tighter" className="tracking-tighter"
width={yAxisWidth} width={yAxisWidth}
// domain={[0, (max: number) => (max <= 0.4 ? 0.4 : Math.ceil(max))]} // domain={[0, (max: number) => (max <= 0.4 ? 0.4 : Math.ceil(max))]}
tickFormatter={(value) => toFixedWithoutTrailingZeros(value, 2)} tickFormatter={(value) => {
const val = toFixedWithoutTrailingZeros(value, 2)
return updateYAxisWidth(val + ' MB/s')
}}
tickLine={false} tickLine={false}
axisLine={false} axisLine={false}
unit={' MB/s'}
/> />
<XAxis <XAxis
dataKey="created" dataKey="created"

View File

@@ -2,14 +2,14 @@ import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart' import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'
import { import {
useYAxisWidth,
chartTimeData, chartTimeData,
cn, cn,
formatShortDate, formatShortDate,
toFixedFloat, toFixedFloat,
twoDecimalString, twoDecimalString,
useYaxisWidth,
} from '@/lib/utils' } from '@/lib/utils'
import { useMemo, useRef } from 'react' import { useMemo } from 'react'
// import Spinner from '../spinner' // import Spinner from '../spinner'
import { useStore } from '@nanostores/react' import { useStore } from '@nanostores/react'
import { $chartTime } from '@/lib/stores' import { $chartTime } from '@/lib/stores'
@@ -22,23 +22,20 @@ export default function MemChart({
ticks: number[] ticks: number[]
systemData: SystemStatsRecord[] systemData: SystemStatsRecord[]
}) { }) {
const chartRef = useRef<HTMLDivElement>(null)
const yAxisWidth = useYaxisWidth(chartRef)
const chartTime = useStore($chartTime) const chartTime = useStore($chartTime)
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
const yAxisSet = useMemo(() => yAxisWidth !== 180, [yAxisWidth])
const totalMem = useMemo(() => { const totalMem = useMemo(() => {
return toFixedFloat(systemData.at(-1)?.stats.m ?? 0, 1) return toFixedFloat(systemData.at(-1)?.stats.m ?? 0, 1)
}, [systemData]) }, [systemData])
return ( return (
<div ref={chartRef}> <div>
{/* {!yAxisSet && <Spinner />} */} {/* {!yAxisSet && <Spinner />} */}
<ChartContainer <ChartContainer
config={{}} config={{}}
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', { className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
'opacity-100': yAxisSet, 'opacity-100': yAxisWidth,
})} })}
> >
<AreaChart <AreaChart
@@ -58,7 +55,10 @@ export default function MemChart({
width={yAxisWidth} width={yAxisWidth}
tickLine={false} tickLine={false}
axisLine={false} axisLine={false}
unit={' GB'} tickFormatter={(value) => {
const val = toFixedFloat(value, 1)
return updateYAxisWidth(val + ' GB')
}}
/> />
)} )}
<XAxis <XAxis

View File

@@ -2,18 +2,17 @@ import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart' import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'
import { import {
useYAxisWidth,
chartTimeData, chartTimeData,
cn, cn,
formatShortDate, formatShortDate,
toFixedWithoutTrailingZeros, toFixedWithoutTrailingZeros,
twoDecimalString, twoDecimalString,
useYaxisWidth,
} from '@/lib/utils' } from '@/lib/utils'
// import Spinner from '../spinner' // import Spinner from '../spinner'
import { useStore } from '@nanostores/react' import { useStore } from '@nanostores/react'
import { $chartTime } from '@/lib/stores' import { $chartTime } from '@/lib/stores'
import { SystemStatsRecord } from '@/types' import { SystemStatsRecord } from '@/types'
import { useMemo, useRef } from 'react'
export default function SwapChart({ export default function SwapChart({
ticks, ticks,
@@ -23,17 +22,14 @@ export default function SwapChart({
systemData: SystemStatsRecord[] systemData: SystemStatsRecord[]
}) { }) {
const chartTime = useStore($chartTime) const chartTime = useStore($chartTime)
const chartRef = useRef<HTMLDivElement>(null) const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
const yAxisWidth = useYaxisWidth(chartRef)
const yAxisSet = useMemo(() => yAxisWidth !== 180, [yAxisWidth])
return ( return (
<div ref={chartRef}> <div>
<ChartContainer <ChartContainer
config={{}} config={{}}
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', { className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
'opacity-100': yAxisSet, 'opacity-100': yAxisWidth,
})} })}
> >
<AreaChart accessibilityLayer data={systemData} margin={{ top: 10 }}> <AreaChart accessibilityLayer data={systemData} margin={{ top: 10 }}>
@@ -44,7 +40,7 @@ export default function SwapChart({
width={yAxisWidth} width={yAxisWidth}
tickLine={false} tickLine={false}
axisLine={false} axisLine={false}
unit={' GB'} tickFormatter={(value) => updateYAxisWidth(value + ' GB')}
/> />
<XAxis <XAxis
dataKey="created" dataKey="created"

View File

@@ -8,17 +8,17 @@ import {
ChartTooltipContent, ChartTooltipContent,
} from '@/components/ui/chart' } from '@/components/ui/chart'
import { import {
useYAxisWidth,
chartTimeData, chartTimeData,
cn, cn,
formatShortDate, formatShortDate,
toFixedWithoutTrailingZeros, toFixedWithoutTrailingZeros,
twoDecimalString, twoDecimalString,
useYaxisWidth,
} from '@/lib/utils' } from '@/lib/utils'
import { useStore } from '@nanostores/react' import { useStore } from '@nanostores/react'
import { $chartTime } from '@/lib/stores' import { $chartTime } from '@/lib/stores'
import { SystemStatsRecord } from '@/types' import { SystemStatsRecord } from '@/types'
import { useMemo, useRef } from 'react' import { useMemo } from 'react'
export default function TemperatureChart({ export default function TemperatureChart({
ticks, ticks,
@@ -27,9 +27,8 @@ export default function TemperatureChart({
ticks: number[] ticks: number[]
systemData: SystemStatsRecord[] systemData: SystemStatsRecord[]
}) { }) {
const chartRef = useRef<HTMLDivElement>(null)
const yAxisWidth = useYaxisWidth(chartRef)
const chartTime = useStore($chartTime) const chartTime = useStore($chartTime)
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
/** Format temperature data for chart and assign colors */ /** Format temperature data for chart and assign colors */
const newChartData = useMemo(() => { const newChartData = useMemo(() => {
@@ -55,15 +54,13 @@ export default function TemperatureChart({
return chartData return chartData
}, [systemData]) }, [systemData])
const yAxisSet = useMemo(() => yAxisWidth !== 180, [yAxisWidth])
return ( return (
<div ref={chartRef}> <div>
{/* {!yAxisSet && <Spinner />} */} {/* {!yAxisSet && <Spinner />} */}
<ChartContainer <ChartContainer
config={{}} config={{}}
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', { className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
'opacity-100': yAxisSet, 'opacity-100': yAxisWidth,
})} })}
> >
<LineChart <LineChart
@@ -80,10 +77,12 @@ export default function TemperatureChart({
<YAxis <YAxis
className="tracking-tighter" className="tracking-tighter"
width={yAxisWidth} width={yAxisWidth}
tickFormatter={(value) => toFixedWithoutTrailingZeros(value, 2)} tickFormatter={(value) => {
const val = toFixedWithoutTrailingZeros(value, 2)
return updateYAxisWidth(val + ' °C')
}}
tickLine={false} tickLine={false}
axisLine={false} axisLine={false}
unit={' °C'}
/> />
<XAxis <XAxis
dataKey="created" dataKey="created"

View File

@@ -6,6 +6,7 @@ import {
LogsIcon, LogsIcon,
MailIcon, MailIcon,
Server, Server,
SettingsIcon,
UsersIcon, UsersIcon,
} from 'lucide-react' } from 'lucide-react'
@@ -46,33 +47,9 @@ export default function CommandPalette() {
<CommandInput placeholder="Search for systems or settings..." /> <CommandInput placeholder="Search for systems or settings..." />
<CommandList> <CommandList>
<CommandEmpty>No results found.</CommandEmpty> <CommandEmpty>No results found.</CommandEmpty>
<CommandGroup heading="Suggestions">
<CommandItem
keywords={['home']}
onSelect={() => {
navigate('/')
setOpen((open) => !open)
}}
>
<LayoutDashboard className="mr-2 h-4 w-4" />
<span>Dashboard</span>
<CommandShortcut>Page</CommandShortcut>
</CommandItem>
<CommandItem
keywords={['github']}
onSelect={() => {
window.location.href = 'https://github.com/henrygd/beszel/blob/main/readme.md'
}}
>
<Github className="mr-2 h-4 w-4" />
<span>Documentation</span>
<CommandShortcut>GitHub</CommandShortcut>
</CommandItem>
</CommandGroup>
{systems.length > 0 && ( {systems.length > 0 && (
<> <>
<CommandSeparator /> <CommandGroup>
<CommandGroup heading="Systems">
{systems.map((system) => ( {systems.map((system) => (
<CommandItem <CommandItem
key={system.id} key={system.id}
@@ -87,11 +64,56 @@ export default function CommandPalette() {
</CommandItem> </CommandItem>
))} ))}
</CommandGroup> </CommandGroup>
<CommandSeparator className="mb-1.5" />
</> </>
)} )}
<CommandGroup heading="Pages / Settings">
<CommandItem
keywords={['home']}
onSelect={() => {
navigate('/')
setOpen((open) => !open)
}}
>
<LayoutDashboard className="mr-2 h-4 w-4" />
<span>Dashboard</span>
<CommandShortcut>Page</CommandShortcut>
</CommandItem>
<CommandItem
onSelect={() => {
navigate('/settings/general')
setOpen((open) => !open)
}}
>
<SettingsIcon className="mr-2 h-4 w-4" />
<span>Settings</span>
<CommandShortcut>Settings</CommandShortcut>
</CommandItem>
<CommandItem
keywords={['alerts']}
onSelect={() => {
navigate('/settings/notifications')
setOpen((open) => !open)
}}
>
<MailIcon className="mr-2 h-4 w-4" />
<span>Notification settings</span>
<CommandShortcut>Settings</CommandShortcut>
</CommandItem>
<CommandItem
keywords={['github']}
onSelect={() => {
window.location.href = 'https://github.com/henrygd/beszel/blob/main/readme.md'
}}
>
<Github className="mr-2 h-4 w-4" />
<span>Documentation</span>
<CommandShortcut>GitHub</CommandShortcut>
</CommandItem>
</CommandGroup>
{isAdmin() && ( {isAdmin() && (
<> <>
<CommandSeparator /> <CommandSeparator className="mb-1.5" />
<CommandGroup heading="Admin"> <CommandGroup heading="Admin">
<CommandItem <CommandItem
keywords={['pocketbase']} keywords={['pocketbase']}

View File

@@ -4,7 +4,7 @@ export const $router = createRouter(
{ {
home: '/', home: '/',
server: '/system/:name', server: '/system/:name',
'forgot-password': '/forgot-password', settings: '/settings/:name?',
}, },
{ links: false } { links: false }
) )
@@ -16,7 +16,7 @@ export const navigate = (urlString: string) => {
function onClick(e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) { function onClick(e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) {
e.preventDefault() e.preventDefault()
$router.open(new URL((e.target as HTMLAnchorElement).href).pathname) $router.open(new URL((e.currentTarget as HTMLAnchorElement).href).pathname)
} }
export const Link = (props: React.AnchorHTMLAttributes<HTMLAnchorElement>) => { export const Link = (props: React.AnchorHTMLAttributes<HTMLAnchorElement>) => {

View File

@@ -0,0 +1,106 @@
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { chartTimeData } from '@/lib/utils'
import { Separator } from '@/components/ui/separator'
import { LoaderCircleIcon, SaveIcon } from 'lucide-react'
import { UserSettings } from '@/types'
import { saveSettings } from './layout'
import { useState } from 'react'
// import { Input } from '@/components/ui/input'
export default function SettingsProfilePage({ userSettings }: { userSettings: UserSettings }) {
const [isLoading, setIsLoading] = useState(false)
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
setIsLoading(true)
const formData = new FormData(e.target as HTMLFormElement)
const data = Object.fromEntries(formData) as Partial<UserSettings>
await saveSettings(data)
setIsLoading(false)
}
return (
<div>
<div>
<h3 className="text-xl font-medium mb-2">General</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
Change general application options.
</p>
</div>
<Separator className="my-4" />
<form onSubmit={handleSubmit} className="space-y-5">
{/* <Separator />
<div className="space-y-2">
<div className="mb-4">
<h3 className="mb-1 text-lg font-medium">Language</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
Internationalization will be added in a future release. Please see the{' '}
<a href="#" className="link" target="_blank">
discussion on GitHub
</a>{' '}
for more details.
</p>
</div>
<Label className="block" htmlFor="lang">
Preferred language
</Label>
<Select defaultValue="en">
<SelectTrigger id="lang">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="en">English</SelectItem>
</SelectContent>
</Select>
</div> */}
<div className="space-y-2">
<div className="mb-4">
<h3 className="mb-1 text-lg font-medium">Chart options</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
Adjust display options for charts.
</p>
</div>
<Label className="block" htmlFor="chartTime">
Default time period
</Label>
<Select name="chartTime" defaultValue={userSettings.chartTime}>
<SelectTrigger id="chartTime">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(chartTimeData).map(([value, { label }]) => (
<SelectItem key={label} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[0.8rem] text-muted-foreground">
Sets the default time range for charts when a system is viewed.
</p>
</div>
<Separator />
<Button
type="submit"
className="flex items-center gap-1.5 disabled:opacity-100"
disabled={isLoading}
>
{isLoading ? (
<LoaderCircleIcon className="h-4 w-4 animate-spin" />
) : (
<SaveIcon className="h-4 w-4" />
)}
Save settings
</Button>
</form>
</div>
)
}

View File

@@ -0,0 +1,98 @@
import { useEffect } from 'react'
import { Separator } from '../../ui/separator'
import { SidebarNav } from './sidebar-nav.tsx'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card.tsx'
import { useStore } from '@nanostores/react'
import { $router } from '@/components/router.tsx'
import { redirectPage } from '@nanostores/router'
import { BellIcon, SettingsIcon } from 'lucide-react'
import { $userSettings, pb } from '@/lib/stores.ts'
import { toast } from '@/components/ui/use-toast.ts'
import { UserSettings } from '@/types.js'
import General from './general.tsx'
import Notifications from './notifications.tsx'
const sidebarNavItems = [
{
title: 'General',
href: '/settings/general',
icon: SettingsIcon,
},
{
title: 'Notifications',
href: '/settings/notifications',
icon: BellIcon,
},
]
export async function saveSettings(newSettings: Partial<UserSettings>) {
try {
// get fresh copy of settings
const req = await pb.collection('user_settings').getFirstListItem('', {
fields: 'id,settings',
})
// update user settings
const updatedSettings = await pb.collection('user_settings').update(req.id, {
settings: {
...req.settings,
...newSettings,
},
})
$userSettings.set(updatedSettings.settings)
toast({
title: 'Settings saved',
description: 'Your user settings have been updated.',
})
} catch (e) {
// console.error('update settings', e)
toast({
title: 'Failed to save settings',
description: 'Check logs for more details.',
variant: 'destructive',
})
}
}
export default function SettingsLayout() {
const page = useStore($router)
useEffect(() => {
document.title = 'Settings / Beszel'
// redirect to account page if no page is specified
if (page?.path === '/settings') {
redirectPage($router, 'settings', { name: 'general' })
}
}, [])
return (
<Card className="pt-5 px-4 pb-8 sm:pt-6 sm:px-7">
<CardHeader className="p-0">
<CardTitle className="mb-1">Settings</CardTitle>
<CardDescription>Manage display and notification preferences.</CardDescription>
</CardHeader>
<CardContent className="p-0">
<Separator className="hidden md:block my-5" />
<div className="flex flex-col gap-3.5 md:flex-row md:gap-5 lg:gap-10">
<aside className="md:w-48 w-full">
<SidebarNav items={sidebarNavItems} />
</aside>
<div className="flex-1">
{/* @ts-ignore */}
<SettingsContent name={page?.params?.name ?? 'general'} />
</div>
</div>
</CardContent>
</Card>
)
}
function SettingsContent({ name }: { name: string }) {
const userSettings = useStore($userSettings)
switch (name) {
case 'general':
return <General userSettings={userSettings} />
case 'notifications':
return <Notifications userSettings={userSettings} />
}
}

View File

@@ -0,0 +1,227 @@
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { pb } from '@/lib/stores'
import { Separator } from '@/components/ui/separator'
import { Card } from '@/components/ui/card'
import { BellIcon, LoaderCircleIcon, PlusIcon, SaveIcon, Trash2Icon } from 'lucide-react'
import { ChangeEventHandler, useState } from 'react'
import { toast } from '@/components/ui/use-toast'
import { InputTags } from '@/components/ui/input-tags'
import { UserSettings } from '@/types'
import { saveSettings } from './layout'
import * as v from 'valibot'
import { isAdmin } from '@/lib/utils'
interface ShoutrrrUrlCardProps {
url: string
onUrlChange: ChangeEventHandler<HTMLInputElement>
onRemove: () => void
}
const NotificationSchema = v.object({
emails: v.array(v.pipe(v.string(), v.email())),
webhooks: v.array(v.pipe(v.string(), v.url())),
})
const SettingsNotificationsPage = ({ userSettings }: { userSettings: UserSettings }) => {
const [webhooks, setWebhooks] = useState(userSettings.webhooks ?? [])
const [emails, setEmails] = useState<string[]>(userSettings.emails ?? [])
const [isLoading, setIsLoading] = useState(false)
const addWebhook = () => {
setWebhooks([...webhooks, ''])
// focus on the new input
queueMicrotask(() => {
const inputs = document.querySelectorAll('#webhooks input') as NodeListOf<HTMLInputElement>
inputs[inputs.length - 1]?.focus()
})
}
const removeWebhook = (index: number) => setWebhooks(webhooks.filter((_, i) => i !== index))
const updateWebhook = (index: number, value: string) => {
const newWebhooks = [...webhooks]
newWebhooks[index] = value
setWebhooks(newWebhooks)
}
async function updateSettings() {
setIsLoading(true)
try {
const parsedData = v.parse(NotificationSchema, { emails, webhooks })
await saveSettings(parsedData)
} catch (e: any) {
toast({
title: 'Failed to save settings',
description: e.message,
variant: 'destructive',
})
}
setIsLoading(false)
}
return (
<div>
<div>
<h3 className="text-xl font-medium mb-2">Notifications</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
Configure how you receive alert notifications.
</p>
<p className="text-sm text-muted-foreground mt-1.5 leading-relaxed">
Looking instead for where to create alerts? Click the bell{' '}
<BellIcon className="inline h-4 w-4" /> icons in the systems table.
</p>
</div>
<Separator className="my-4" />
<div className="space-y-5">
<div className="space-y-2">
<div className="mb-4">
<h3 className="mb-1 text-lg font-medium">Email notifications</h3>
{isAdmin() && (
<p className="text-sm text-muted-foreground leading-relaxed">
Please{' '}
<a href="/_/#/settings/mail" className="link" target="_blank">
configure an SMTP server
</a>{' '}
to ensure alerts are delivered.{' '}
</p>
)}
</div>
<Label className="block" htmlFor="email">
To email(s)
</Label>
<InputTags
value={emails}
onChange={setEmails}
placeholder="Enter email address..."
className="w-full"
type="email"
id="email"
/>
<p className="text-[0.8rem] text-muted-foreground">
Save address using enter key or comma. Leave blank to disable email notifications.
</p>
</div>
<Separator />
<div className="space-y-3">
<div>
<h3 className="mb-1 text-lg font-medium">Webhook / Push notifications</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
Beszel uses{' '}
<a
href="https://containrrr.dev/shoutrrr/services/overview/"
target="_blank"
className="link"
>
Shoutrrr
</a>{' '}
to integrate with popular notification services.
</p>
</div>
{webhooks.length > 0 && (
<div className="grid gap-2.5" id="webhooks">
{webhooks.map((webhook, index) => (
<ShoutrrrUrlCard
key={index}
url={webhook}
onUrlChange={(e: React.ChangeEvent<HTMLInputElement>) =>
updateWebhook(index, e.target.value)
}
onRemove={() => removeWebhook(index)}
/>
))}
</div>
)}
<Button
type="button"
variant="outline"
size="sm"
className="mt-2 flex items-center gap-1"
onClick={addWebhook}
>
<PlusIcon className="h-4 w-4 -ml-0.5" />
Add URL
</Button>
</div>
<Separator />
<Button
type="button"
className="flex items-center gap-1.5 disabled:opacity-100"
onClick={updateSettings}
disabled={isLoading}
>
{isLoading ? (
<LoaderCircleIcon className="h-4 w-4 animate-spin" />
) : (
<SaveIcon className="h-4 w-4" />
)}
Save settings
</Button>
</div>
</div>
)
}
const ShoutrrrUrlCard = ({ url, onUrlChange, onRemove }: ShoutrrrUrlCardProps) => {
const [isLoading, setIsLoading] = useState(false)
const sendTestNotification = async () => {
setIsLoading(true)
const res = await pb.send('/api/beszel/send-test-notification', { url })
if ('err' in res && !res.err) {
toast({
title: 'Test notification sent',
description: 'Check your notification service',
})
} else {
toast({
title: 'Error',
description: res.err ?? 'Failed to send test notification',
variant: 'destructive',
})
}
setIsLoading(false)
}
return (
<Card className="bg-muted/30 p-2 md:p-3">
<div className="flex items-center gap-1">
<Input
type="url"
className="light:bg-card"
required
placeholder="generic://webhook.site/xxxxxx"
value={url}
onChange={onUrlChange}
/>
<Button
type="button"
variant="outline"
className="w-20 md:w-28"
disabled={isLoading || url === ''}
onClick={sendTestNotification}
>
{isLoading ? (
<LoaderCircleIcon className="h-4 w-4 animate-spin" />
) : (
<span>
Test <span className="hidden md:inline">URL</span>
</span>
)}
</Button>
<Button
type="button"
variant="outline"
size="icon"
className="shrink-0"
aria-label="Delete"
onClick={onRemove}
>
<Trash2Icon className="h-4 w-4" />
</Button>
</div>
</Card>
)
}
export default SettingsNotificationsPage

View File

@@ -0,0 +1,68 @@
import React from 'react'
import { cn } from '@/lib/utils'
import { buttonVariants } from '../../ui/button'
import { $router, Link, navigate } from '../../router'
import { useStore } from '@nanostores/react'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Separator } from '@/components/ui/separator'
interface SidebarNavProps extends React.HTMLAttributes<HTMLElement> {
items: {
href: string
title: string
icon?: React.FC<React.SVGProps<SVGSVGElement>>
}[]
}
export function SidebarNav({ className, items, ...props }: SidebarNavProps) {
const page = useStore($router)
return (
<>
{/* Mobile View */}
<div className="md:hidden">
<Select onValueChange={(value: string) => navigate(value)} value={page?.path}>
<SelectTrigger className="w-full my-3.5">
<SelectValue placeholder="Select a page" />
</SelectTrigger>
<SelectContent>
{items.map((item) => (
<SelectItem key={item.href} value={item.href}>
<span className="flex items-center gap-2">
{item.icon && <item.icon className="h-4 w-4" />}
{item.title}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
<Separator />
</div>
{/* Desktop View */}
<nav className={cn('hidden md:grid gap-1', className)} {...props}>
{items.map((item) => (
<Link
key={item.href}
href={item.href}
className={cn(
buttonVariants({ variant: 'ghost' }),
'flex items-center gap-3',
page?.path === item.href ? 'bg-muted hover:bg-muted' : 'hover:bg-muted/50',
'justify-start'
)}
>
{item.icon && <item.icon className="h-4 w-4" />}
{item.title}
</Link>
))}
</nav>
</>
)
}

View File

@@ -1,4 +1,4 @@
import { $systems, pb, $chartTime, $containerFilter } from '@/lib/stores' import { $systems, pb, $chartTime, $containerFilter, $userSettings } from '@/lib/stores'
import { ContainerStatsRecord, SystemRecord, SystemStatsRecord } from '@/types' import { ContainerStatsRecord, SystemRecord, SystemStatsRecord } from '@/types'
import { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '../ui/card' import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '../ui/card'
@@ -9,6 +9,7 @@ import {
CpuIcon, CpuIcon,
GlobeIcon, GlobeIcon,
LayoutGridIcon, LayoutGridIcon,
MonitorIcon,
StretchHorizontalIcon, StretchHorizontalIcon,
XIcon, XIcon,
} from 'lucide-react' } from 'lucide-react'
@@ -62,7 +63,7 @@ export default function SystemDetail({ name }: { name: string }) {
document.title = `${name} / Beszel` document.title = `${name} / Beszel`
return () => { return () => {
resetCharts() resetCharts()
$chartTime.set('1h') $chartTime.set($userSettings.get().chartTime)
$containerFilter.set('') $containerFilter.set('')
setHasDocker(false) setHasDocker(false)
} }
@@ -255,13 +256,27 @@ export default function SystemDetail({ name }: { name: string }) {
<div className="flex gap-1.5 items-center"> <div className="flex gap-1.5 items-center">
<GlobeIcon className="h-4 w-4" /> {system.host} <GlobeIcon className="h-4 w-4" /> {system.host}
</div> </div>
{system.info?.u && ( {/* show hostname if it's different than host or name */}
{system.info?.h && system.info.h != system.host && system.info.h != system.name && (
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip delayDuration={150}>
<Separator orientation="vertical" className="h-4 bg-primary/30" /> <Separator orientation="vertical" className="h-4 bg-primary/30" />
<TooltipTrigger asChild> <TooltipTrigger asChild>
<div className="flex gap-1.5"> <div className="flex gap-1.5 items-center">
<ClockArrowUp className="h-4 w-4 mt-[1px]" /> {uptime} <MonitorIcon className="h-4 w-4" /> {system.info.h}
</div>
</TooltipTrigger>
<TooltipContent>Hostname</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{system.info?.u && (
<TooltipProvider>
<Tooltip delayDuration={150}>
<Separator orientation="vertical" className="h-4 bg-primary/30" />
<TooltipTrigger asChild>
<div className="flex gap-1.5 items-center">
<ClockArrowUp className="h-4 w-4" /> {uptime}
</div> </div>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>Uptime</TooltipContent> <TooltipContent>Uptime</TooltipContent>
@@ -271,9 +286,9 @@ export default function SystemDetail({ name }: { name: string }) {
{system.info?.m && ( {system.info?.m && (
<> <>
<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">
<CpuIcon className="h-4 w-4 mt-[1px]" /> <CpuIcon className="h-4 w-4" />
{system.info.m} ({system.info.c}c / {system.info.t}t) {system.info.m} ({system.info.c}c/{system.info.t}t)
</div> </div>
</> </>
)} )}
@@ -399,14 +414,14 @@ export default function SystemDetail({ name }: { name: string }) {
return ( return (
<div key={extraFsName} className="contents"> <div key={extraFsName} className="contents">
<ChartCard <ChartCard
grid={true} grid={grid}
title={`${extraFsName} Usage`} title={`${extraFsName} Usage`}
description={`Disk usage of ${extraFsName}`} description={`Disk usage of ${extraFsName}`}
> >
<ExFsDiskChart ticks={ticks} systemData={systemStats} fs={extraFsName} /> <ExFsDiskChart ticks={ticks} systemData={systemStats} fs={extraFsName} />
</ChartCard> </ChartCard>
<ChartCard <ChartCard
grid={true} grid={grid}
title={`${extraFsName} I/O`} title={`${extraFsName} I/O`}
description={`Throughput of of ${extraFsName}`} description={`Throughput of of ${extraFsName}`}
> >

View File

@@ -324,7 +324,7 @@ export default function SystemsTable({ filter }: { filter?: string }) {
? 'auto' ? 'auto'
: cell.column.getSize(), : cell.column.getSize(),
}} }}
className={'overflow-hidden relative py-2.5'} className={cn('overflow-hidden relative', data.length > 10 ? 'py-2' : 'py-2.5')}
> >
{flexRender(cell.column.columnDef.cell, cell.getContext())} {flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell> </TableCell>

View File

@@ -9,12 +9,13 @@ import {
DialogTitle, DialogTitle,
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import { BellIcon } from 'lucide-react' import { BellIcon } from 'lucide-react'
import { cn, isAdmin } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch' import { Switch } from '@/components/ui/switch'
import { AlertRecord, SystemRecord } from '@/types' import { AlertRecord, SystemRecord } from '@/types'
import { lazy, Suspense, useMemo, useState } from 'react' import { lazy, Suspense, useMemo, useState } from 'react'
import { toast } from './ui/use-toast' import { toast } from './ui/use-toast'
import { Link } from './router'
const Slider = lazy(() => import('./ui/slider')) const Slider = lazy(() => import('./ui/slider'))
@@ -49,20 +50,13 @@ export default function AlertsButton({ system }: { system: SystemRecord }) {
</DialogTrigger> </DialogTrigger>
<DialogContent className="max-h-full overflow-auto"> <DialogContent className="max-h-full overflow-auto">
<DialogHeader> <DialogHeader>
<DialogTitle className="mb-1">Alerts for {system.name}</DialogTitle> <DialogTitle className="text-xl">{system.name} alerts</DialogTitle>
<DialogDescription> <DialogDescription className="mb-1">
{isAdmin() && ( See{' '}
<span> <Link href="/settings/notifications" className="link">
Please{' '} notification settings
<a </Link>{' '}
href="/_/#/settings/mail" to configure how you receive alerts.
className="font-medium text-primary opacity-80 hover:opacity-100 duration-100"
>
configure an SMTP server
</a>{' '}
to ensure alerts are delivered.{' '}
</span>
)}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="grid gap-3"> <div className="grid gap-3">
@@ -86,7 +80,7 @@ export default function AlertsButton({ system }: { system: SystemRecord }) {
alerts={systemAlerts} alerts={systemAlerts}
name="Disk" name="Disk"
title="Disk Usage" title="Disk Usage"
description="Triggers when disk usage exceeds a threshold." description="Triggers when root usage exceeds a threshold."
/> />
</div> </div>
</DialogContent> </DialogContent>

View File

@@ -0,0 +1,36 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,81 @@
import * as React from 'react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { XIcon } from 'lucide-react'
import { type InputProps } from './input'
import { cn } from '@/lib/utils'
type InputTagsProps = Omit<InputProps, 'value' | 'onChange'> & {
value: string[]
onChange: React.Dispatch<React.SetStateAction<string[]>>
}
const InputTags = React.forwardRef<HTMLInputElement, InputTagsProps>(
({ className, value, onChange, ...props }, ref) => {
const [pendingDataPoint, setPendingDataPoint] = React.useState('')
React.useEffect(() => {
if (pendingDataPoint.includes(',')) {
const newDataPoints = new Set([
...value,
...pendingDataPoint.split(',').map((chunk) => chunk.trim()),
])
onChange(Array.from(newDataPoints))
setPendingDataPoint('')
}
}, [pendingDataPoint, onChange, value])
const addPendingDataPoint = () => {
if (pendingDataPoint) {
const newDataPoints = new Set([...value, pendingDataPoint])
onChange(Array.from(newDataPoints))
setPendingDataPoint('')
}
}
return (
<div
className={cn(
'bg-background min-h-10 flex w-full flex-wrap gap-2 rounded-md border border-input px-3 py-2 text-sm placeholder:text-muted-foreground has-[:focus-visible]:outline-none ring-offset-background has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring has-[:focus-visible]:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
>
{value.map((item) => (
<Badge key={item}>
{item}
<Button
variant="ghost"
size="icon"
className="ml-2 h-3 w-3"
onClick={() => {
onChange(value.filter((i) => i !== item))
}}
>
<XIcon className="w-3" />
</Button>
</Badge>
))}
<input
className="flex-1 outline-none bg-background placeholder:text-muted-foreground"
value={pendingDataPoint}
onChange={(e) => setPendingDataPoint(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault()
addPendingDataPoint()
} else if (e.key === 'Backspace' && pendingDataPoint.length === 0 && value.length > 0) {
e.preventDefault()
onChange(value.slice(0, -1))
}
}}
{...props}
ref={ref}
/>
</div>
)
}
)
InputTags.displayName = 'InputTags'
export { InputTags }

View File

@@ -44,7 +44,7 @@ const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTML
<tr <tr
ref={ref} ref={ref}
className={cn( className={cn(
'border-b transition-colors hover:bg-muted/40 dark:hover:bg-muted/30 data-[state=selected]:bg-muted', 'border-b hover:bg-muted/40 dark:hover:bg-muted/30 data-[state=selected]:bg-muted',
className className
)} )}
{...props} {...props}

View File

@@ -78,6 +78,9 @@
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }
.link {
@apply text-primary font-medium underline-offset-4 hover:underline;
}
} }
.recharts-tooltip-wrapper { .recharts-tooltip-wrapper {
@@ -85,5 +88,5 @@
} }
.recharts-yAxis { .recharts-yAxis {
font-variant-numeric: tabular-nums; @apply tabular-nums;
} }

View File

@@ -1,6 +1,6 @@
import PocketBase from 'pocketbase' import PocketBase from 'pocketbase'
import { atom, WritableAtom } from 'nanostores' import { atom, map, WritableAtom } from 'nanostores'
import { AlertRecord, ChartTimes, SystemRecord } from '@/types' import { AlertRecord, ChartTimes, SystemRecord, UserSettings } from '@/types'
/** PocketBase JS Client */ /** PocketBase JS Client */
export const pb = new PocketBase('/') export const pb = new PocketBase('/')
@@ -23,6 +23,17 @@ export const $hubVersion = atom('')
/** Chart time period */ /** Chart time period */
export const $chartTime = atom('1h') as WritableAtom<ChartTimes> export const $chartTime = atom('1h') as WritableAtom<ChartTimes>
/** User settings */
export const $userSettings = map<UserSettings>({
chartTime: '1h',
emails: [pb.authStore.model?.email || ''],
})
// update local storage on change
$userSettings.subscribe((value) => {
// console.log('user settings changed', value)
$chartTime.set(value.chartTime)
})
/** Container chart filter */ /** Container chart filter */
export const $containerFilter = atom('') export const $containerFilter = atom('')

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, $copyContent, $systems, pb } from './stores' import { $alerts, $copyContent, $systems, $userSettings, pb } from './stores'
import { AlertRecord, ChartTimeData, ChartTimes, SystemRecord } from '@/types' import { AlertRecord, ChartTimeData, ChartTimes, SystemRecord } from '@/types'
import { RecordModel, RecordSubscription } from 'pocketbase' import { RecordModel, RecordSubscription } from 'pocketbase'
import { WritableAtom } from 'nanostores' import { WritableAtom } from 'nanostores'
@@ -195,22 +195,27 @@ export const chartTimeData: ChartTimeData = {
}, },
} }
/** Hacky solution to set the correct width of the yAxis in recharts */ /** Sets the correct width of the y axis in recharts based on the longest label */
export function useYaxisWidth(chartRef: React.RefObject<HTMLDivElement>) { export function useYAxisWidth() {
const [yAxisWidth, setYAxisWidth] = useState(180) const [yAxisWidth, setYAxisWidth] = useState(0)
useEffect(() => { let maxChars = 0
let interval = setInterval(() => { let timeout: Timer
// console.log('chartRef', chartRef.current) function updateYAxisWidth(str: string) {
const yAxisElement = chartRef?.current?.querySelector('.yAxis') if (str.length > maxChars) {
if (yAxisElement) { maxChars = str.length
// console.log('yAxisElement', yAxisElement) const div = document.createElement('div')
clearInterval(interval) div.className = 'text-xs tabular-nums tracking-tighter table sr-only'
setYAxisWidth(yAxisElement.getBoundingClientRect().width + 24) div.innerHTML = str
} clearTimeout(timeout)
}, 16) timeout = setTimeout(() => {
return () => clearInterval(interval) document.body.appendChild(div)
}, []) setYAxisWidth(div.offsetWidth + 24)
return yAxisWidth document.body.removeChild(div)
})
}
return str
}
return { yAxisWidth, updateYAxisWidth }
} }
export function useClampedIsInViewport(options: HookOptions): [boolean | null, CallbackRef] { export function useClampedIsInViewport(options: HookOptions): [boolean | null, CallbackRef] {
@@ -270,3 +275,22 @@ export const useLocalStorage = (key: string, defaultValue: any) => {
return [value, setValue] return [value, setValue]
} }
export async function updateUserSettings() {
try {
const req = await pb.collection('user_settings').getFirstListItem('', { fields: 'settings' })
$userSettings.set(req.settings)
return
} catch (e) {
console.log('get settings', e)
}
// create user settings if error fetching existing
try {
const createdSettings = await pb
.collection('user_settings')
.create({ user: pb.authStore.model!.id })
$userSettings.set(createdSettings.settings)
} catch (e) {
console.log('create settings', e)
}
}

View File

@@ -1,5 +1,5 @@
import './index.css' import './index.css'
import React, { Suspense, lazy, useEffect } from 'react' import { Suspense, lazy, useEffect, StrictMode } from 'react'
import ReactDOM from 'react-dom/client' import ReactDOM from 'react-dom/client'
import Home from './components/routes/home.tsx' import Home from './components/routes/home.tsx'
import { ThemeProvider } from './components/theme-provider.tsx' import { ThemeProvider } from './components/theme-provider.tsx'
@@ -14,6 +14,7 @@ import {
import { ModeToggle } from './components/mode-toggle.tsx' import { ModeToggle } from './components/mode-toggle.tsx'
import { import {
cn, cn,
updateUserSettings,
isAdmin, isAdmin,
isReadOnlyUser, isReadOnlyUser,
updateAlerts, updateAlerts,
@@ -27,6 +28,7 @@ import {
LogOutIcon, LogOutIcon,
LogsIcon, LogsIcon,
ServerIcon, ServerIcon,
SettingsIcon,
UserIcon, UserIcon,
UsersIcon, UsersIcon,
} from 'lucide-react' } from 'lucide-react'
@@ -42,7 +44,7 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
DropdownMenuLabel, DropdownMenuLabel,
} from './components/ui/dropdown-menu.tsx' } from './components/ui/dropdown-menu.tsx'
import { $router, Link, navigate } from './components/router.tsx' import { $router, Link } from './components/router.tsx'
import SystemDetail from './components/routes/system.tsx' import SystemDetail from './components/routes/system.tsx'
import { AddSystemButton } from './components/add-system.tsx' import { AddSystemButton } from './components/add-system.tsx'
@@ -50,6 +52,7 @@ import { AddSystemButton } from './components/add-system.tsx'
const CommandPalette = lazy(() => import('./components/command-palette.tsx')) const CommandPalette = lazy(() => import('./components/command-palette.tsx'))
const LoginPage = lazy(() => import('./components/login/login.tsx')) const LoginPage = lazy(() => import('./components/login/login.tsx'))
const CopyToClipboardDialog = lazy(() => import('./components/copy-to-clipboard.tsx')) const CopyToClipboardDialog = lazy(() => import('./components/copy-to-clipboard.tsx'))
const Settings = lazy(() => import('./components/routes/settings/layout.tsx'))
const App = () => { const App = () => {
const page = useStore($router) const page = useStore($router)
@@ -66,9 +69,10 @@ const App = () => {
$publicKey.set(data.key) $publicKey.set(data.key)
$hubVersion.set(data.v) $hubVersion.set(data.v)
}) })
// get servers / alerts // get servers / alerts / settings
updateSystemList() updateSystemList()
updateAlerts() updateAlerts()
updateUserSettings()
}, []) }, [])
// update favicon // update favicon
@@ -99,6 +103,12 @@ const App = () => {
return <Home /> return <Home />
} else if (page.route === 'server') { } else if (page.route === 'server') {
return <SystemDetail name={page.params.name} /> return <SystemDetail name={page.params.name} />
} else if (page.route === 'settings') {
return (
<Suspense>
<Settings />
</Suspense>
)
} }
} }
@@ -118,20 +128,19 @@ const Layout = () => {
<> <>
<div className="container"> <div className="container">
<div className="flex items-center h-14 md:h-16 bg-card px-4 pr-3 sm:px-6 border bt-0 rounded-md my-4"> <div className="flex items-center h-14 md:h-16 bg-card px-4 pr-3 sm:px-6 border bt-0 rounded-md my-4">
<Link <Link href="/" aria-label="Home" className={'p-2 pl-0'}>
href="/"
aria-label="Home"
className={'p-2 pl-0'}
onClick={(e) => {
e.preventDefault()
navigate('/')
}}
>
<Logo className="h-[1.15em] fill-foreground" /> <Logo className="h-[1.15em] fill-foreground" />
</Link> </Link>
<div className={'flex ml-auto items-center'}> <div className={'flex ml-auto items-center'}>
<ModeToggle /> <ModeToggle />
<Link
href="/settings/general"
aria-label="Settings"
className={cn('', buttonVariants({ variant: 'ghost', size: 'icon' }))}
>
<SettingsIcon className="h-[1.2rem] w-[1.2rem]" />
</Link>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button <button
@@ -209,10 +218,10 @@ const Layout = () => {
ReactDOM.createRoot(document.getElementById('app')!).render( ReactDOM.createRoot(document.getElementById('app')!).render(
// strict mode in dev mounts / unmounts components twice // strict mode in dev mounts / unmounts components twice
// and breaks the clipboard dialog // and breaks the clipboard dialog
//<React.StrictMode> //<StrictMode>
<ThemeProvider> <ThemeProvider>
<Layout /> <Layout />
<Toaster /> <Toaster />
</ThemeProvider> </ThemeProvider>
//</React.StrictMode> //</StrictMode>
) )

View File

@@ -10,6 +10,8 @@ export interface SystemRecord extends RecordModel {
} }
export interface SystemInfo { export interface SystemInfo {
/** hostname */
h: string
/** cpu percent */ /** cpu percent */
cpu: number cpu: number
/** cpu threads */ /** cpu threads */
@@ -118,3 +120,10 @@ export interface ChartTimeData {
getOffset: (endTime: Date) => Date getOffset: (endTime: Date) => Date
} }
} }
export type UserSettings = {
// lang?: string
chartTime: ChartTimes
emails?: string[]
webhooks?: string[]
}

View File

@@ -94,5 +94,10 @@ module.exports = {
}, },
}, },
}, },
plugins: [require('tailwindcss-animate')], plugins: [
require('tailwindcss-animate'),
function ({ addVariant }) {
addVariant('light', '.light &')
},
],
} }

View File

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

View File

@@ -9,43 +9,43 @@ A lightweight server resource monitoring hub with historical data, docker stats,
## Features ## Features
- **Lightweight**: Much smaller and less demanding than leading solutions. - **Lightweight**: Smaller and less resource-intensive than leading solutions.
- **Docker stats**: CPU and memory usage history for each container. - **Simple**: Easy setup, no need for public internet exposure.
- **Alerts**: Configurable alerts for CPU, memory, and disk usage, and system status. - **Docker stats**: Tracks CPU, memory, and network usage history for each container.
- **Multi-user**: Each user has their own systems. Admins can share systems across users. - **Alerts**: Configurable alerts for CPU, memory, disk usage, and system status.
- **Simple**: Easy setup and doesn't require anything to be publicly available online. - **Multi-user**: Each user manages their own systems. Admins can share systems across users.
- **OAuth / OIDC**: Supports many OAuth2 providers. Password auth can be disabled. - **OAuth / OIDC**: Supports multiple OAuth2 providers. Password authentication can be disabled.
- **Automatic backups**: Save and restore your data to / from disk or S3-compatible storage. - **Automatic backups**: Save and restore data from disk or S3-compatible storage.
- **REST API**: Use your metrics in your own scripts and applications. - **REST API**: Use or update your data in your own scripts and applications.
## Introduction ## Introduction
Beszel has two components: the hub and the agent. Beszel consists of two main components: the hub and the agent.
The hub is a web application that provides a dashboard to view and manage your connected systems. It's built on top of [PocketBase](https://pocketbase.io/). - **Hub:** A web application that provides a dashboard for viewing and managing connected systems. Built on [PocketBase](https://pocketbase.io/).
The agent runs on each system you want to monitor. It creates a minimal SSH server through which it communicates system metrics to the hub. - **Agent:** Runs on each system you want to monitor, creating a minimal SSH server to communicate system metrics to the hub.
## Getting started ## Getting started
If not using docker, ignore 4-5 and run the agent using the binary instead. If not using docker, skip steps 4-5 and run the agent using the binary.
1. Start the hub (see [installation](#installation)). 1. Start the hub (see [installation](#installation)).
2. Open http://localhost:8090 and create an admin user. 2. Open <http://localhost:8090> and create an admin user.
3. Click "Add system." Enter the name and host of the system you want to monitor. 3. Click "Add system." Enter the name and host of the system you want to monitor.
4. Click "Copy docker compose" to copy the agent's docker-compose.yml file to your clipboard. 4. Click "Copy docker compose" to copy the agent's docker-compose.yml file to your clipboard.
5. On the agent system, create the compose file and run `docker compose up` to start the agent. 5. On the agent system, create the compose file and run `docker compose up` to start the agent.
6. Back in the hub, click the "Add system" button in the dialog to finish adding the system. 6. Back in the hub, click the "Add system" button in the dialog to finish adding the system.
If all goes well, you should see the system flip to green. If it goes red, check the Logs page, and see [troubleshooting tips](#faq--troubleshooting). If all goes well, the system should flip to green. If it turns red, check the Logs page and refer to [troubleshooting tips](#faq--troubleshooting).
### Tutoriel en français ### Tutoriel en français
Pour le tutoriel en français, consultez https://belginux.com/installer-beszel-avec-docker/ Pour le tutoriel en français, consultez <https://belginux.com/installer-beszel-avec-docker/>
## Installation ## Installation
You may install the hub and agent as single binaries, or by using Docker. You can install the hub and agent as single binaries or using Docker.
### Docker ### Docker
@@ -53,9 +53,9 @@ You may install the hub and agent as single binaries, or by using Docker.
**Agent**: The hub provides compose content for the agent, but you can also reference the example [docker-compose.yml](/supplemental/docker/agent/docker-compose.yml) file. **Agent**: The hub provides compose content for the agent, but you can also reference the example [docker-compose.yml](/supplemental/docker/agent/docker-compose.yml) file.
The agent uses the host network mode so it can access network interface stats. This automatically exposes the port, so change the port using an environment variable if you need to. The agent uses host network mode to access network interface stats, which automatically exposes the port. Change the port using an environment variable if needed.
If you don't need network stats, remove that line from the compose file and map the port manually. If you don't require network stats, remove that line from the compose file and map the port manually.
> **Note**: If disk I/O stats are missing or incorrect, try using the `FILESYSTEM` environment variable ([instructions here](#finding-the-correct-filesystem)). Check agent logs to see the current device being used. > **Note**: If disk I/O stats are missing or incorrect, try using the `FILESYSTEM` environment variable ([instructions here](#finding-the-correct-filesystem)). Check agent logs to see the current device being used.
@@ -94,7 +94,7 @@ PORT=45876 KEY="{PASTE_YOUR_KEY}" ./beszel-agent
Use `./beszel update` and `./beszel-agent update` to update to the latest version. Use `./beszel update` and `./beszel-agent update` to update to the latest version.
## Environment Variables ## Environment variables
### Hub ### Hub
@@ -108,10 +108,13 @@ Use `./beszel update` and `./beszel-agent update` to update to the latest versio
| ------------------- | ------- | ---------------------------------------------------------------------------------------- | | ------------------- | ------- | ---------------------------------------------------------------------------------------- |
| `DOCKER_HOST` | unset | Overrides the docker host (docker.sock) if using a proxy.[^socket] | | `DOCKER_HOST` | unset | Overrides the docker host (docker.sock) if using a proxy.[^socket] |
| `EXTRA_FILESYSTEMS` | unset | See [Monitoring additional disks / partitions](#monitoring-additional-disks--partitions) | | `EXTRA_FILESYSTEMS` | unset | See [Monitoring additional disks / partitions](#monitoring-additional-disks--partitions) |
| `FILESYSTEM` | unset | Device or partition to use for root disk I/O stats. | | `FILESYSTEM` | unset | Device, partition, or mount point to use for root disk stats. |
| `KEY` | unset | Public SSH key to use for authentication. Provided in hub. | | `KEY` | unset | Public SSH key to use for authentication. Provided in hub. |
| `NICS` | unset | Whitelist of network interfaces to monitor for bandwidth chart. |
| `PORT` | 45876 | Port or address:port to listen on. | | `PORT` | 45876 | Port or address:port to listen on. |
<!-- | `SYS_SENSORS` | unset | Overrides the sys location for sensors. | -->
[^socket]: Beszel only needs access to read container information. For [linuxserver/docker-socket-proxy](https://github.com/linuxserver/docker-socket-proxy) you would set `CONTAINERS=1`. [^socket]: Beszel only needs access to read container information. For [linuxserver/docker-socket-proxy](https://github.com/linuxserver/docker-socket-proxy) you would set `CONTAINERS=1`.
## OAuth / OIDC Setup ## OAuth / OIDC Setup
@@ -145,13 +148,11 @@ Visit the "Auth providers" page to enable your provider. The redirect / callback
- Twitter - Twitter
- VK - VK
- Yandex - Yandex
</details> </details>
## Monitoring additional disks / partitions ## Monitoring additional disks / partitions
> [!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. You can configure the agent to monitor the usage and I/O of more than one disk or partition. The approach differs depending on the deployment method.
Use `lsblk` to find the names and mount points of your partitions. If you have trouble, check the agent logs. Use `lsblk` to find the names and mount points of your partitions. If you have trouble, check the agent logs.
@@ -180,29 +181,27 @@ Because Beszel is built on PocketBase, you can use the PocketBase [web APIs](htt
## Security ## Security
The hub and agent communicate over SSH, so they don't need to be exposed to the internet. And the connection won't break if you put your own auth gateway, such as Authelia, in front of the hub. The hub and agent communicate over SSH, so they don't need to be exposed to the internet. Even if you place an external auth gateway, such as Authelia, in front of the hub, it won't disrupt or break the connection between the hub and agent.
When the hub is started for the first time, it generates an ED25519 key pair. When the hub is started for the first time, it generates an ED25519 key pair.
The agent's SSH server is configured to accept connections only using this key. It does not provide a pseudo-terminal or accept input, so it's not possible to execute commands on the agent even if your private key is compromised. The agent's SSH server is configured to accept connections using this key only. It does not provide a pseudo-terminal or accept input, so it's impossible to execute commands on the agent even if your private key is compromised.
## User roles ## User roles
### Admin ### Admin
Assumed to have an admin account in PocketBase, so links to backups, SMTP settings, etc., are shown in the hub. Admins have access to additional links in the hub, such as backups, SMTP settings, etc. The first user created is automatically an admin and can log into PocketBase.
The first user created automatically becomes an admin and can log into PocketBase. Changing a user's role does not create a PocketBase admin account for them. To do that, go to Settings > Admins in PocketBase and add them manually.
Please note that changing a user's role will not create a PocketBase admin account for them. If you want to do that, go to Settings > Admins in PocketBase and add them there.
### User ### User
Can create their own systems and alerts. Links to PocketBase settings are not shown in the hub. Users can create their own systems and alerts. Links to PocketBase settings are not shown in the hub.
### Read only ### Read only
Cannot create systems, but can view any system that has been shared with them by an admin. Can create alerts. Read-only users cannot create systems but can view any system shared with them by an admin and create alerts.
## FAQ / Troubleshooting ## FAQ / Troubleshooting
@@ -210,44 +209,44 @@ Cannot create systems, but can view any system that has been shared with them by
Assuming the agent is running, the connection is probably being blocked by a firewall. You have two options: Assuming the agent is running, the connection is probably being blocked by a firewall. You have two options:
1. Add an inbound rule to the agent system's firewall(s) to allow TCP connections to the port. Check any active firewalls, like iptables, and in your cloud provider account if applicable. 1. Add an inbound rule to the agent system's firewall(s) to allow TCP connections to the port. Check any active firewalls, like iptables, and your cloud provider's firewall settings if applicable.
2. Alternatively, software like [Cloudflare Tunnel](https://www.cloudflare.com/products/tunnel/), [WireGuard](https://www.wireguard.com/), or [Tailscale](https://tailscale.com/) can be used to securely bypass your firewall. 2. Alternatively, use software like [Cloudflare Tunnel](https://www.cloudflare.com/products/tunnel/), [WireGuard](https://www.wireguard.com/), or [Tailscale](https://tailscale.com/) to securely bypass your firewall.
Connectivity can be tested by running `telnet <agent-ip> <port>`. You can test connectivity by running telnet `<agent-ip> <port>`.
### Connecting the hub and agent on the same system using Docker ### Connecting the hub and agent on the same system using Docker
If using host network mode for the agent but not the hub, you can add your system using the hostname `host.docker.internal`, which resolves to the internal IP address used by the host. See [example docker-compose.yml](/supplemental/docker/same-system/docker-compose.yml). If using host network mode for the agent but not the hub, add your system using the hostname `host.docker.internal`, which resolves to the internal IP address used by the host. See the [example docker-compose.yml](/supplemental/docker/same-system/docker-compose.yml).
If using host network for both, you can use `localhost` as the hostname. If using host network mode for both, you can use `localhost` as the hostname.
Otherwise you can use the agent's `container_name` as the hostname if both are in the same docker network. Otherwise, use the agent's `container_name` as the hostname if both are in the same Docker network.
### Finding the correct filesystem ### Finding the correct filesystem
The filesystem / device / partition to use for disk I/O stats is specified in the `FILESYSTEM` environment variable. Specify the filesystem/device/partition for root disk stats using the `FILESYSTEM` environment variable.
If it's not set, the agent will try to find the partition mounted on `/` and use that. This doesn't seem to work in a container, so it's recommended to set this value. One of the following methods should work (you usually want the option mounted on `/`): If not set, the agent will try to find the partition mounted on `/` and use that. This may not work correctly in a container, so it's recommended to set this value. Use one of the following methods to find the correct filesystem:
- Run `lsblk` and choose an option under "NAME" - Run `lsblk` and choose an option under "NAME."
- Run `df -h` and choose an option under "Filesystem" - Run `df -h` and choose an option under "Filesystem."
- Run `sudo fdisk -l` and choose an option under "Device" - Run `sudo fdisk -l` and choose an option under "Device."
### Docker container charts are empty or missing ### Docker container charts are empty or missing
If container charts show empty data, or don't show up at all, you may need to enable cgroup memory accounting. To verify, run `docker stats`. If that also shows zero memory usage, follow this guide to fix: If container charts show empty data or don't appear at all, you may need to enable cgroup memory accounting. To verify, run `docker stats`. If that shows zero memory usage, follow this guide to fix the issue:
https://akashrajpurohit.com/blog/resolving-missing-memory-stats-in-docker-stats-on-raspberry-pi/ <https://akashrajpurohit.com/blog/resolving-missing-memory-stats-in-docker-stats-on-raspberry-pi/>
### Docker containers are not populating reliably ### Docker Containers Are Not Populating Reliably
Try upgrading your docker version on the agent system. I had this issue on a machine running version 24. It was fixed by upgrading to version 27. Try upgrading your Docker version on the agent system. This issue was observed on a machine running version 24 and was resolved by upgrading to version 27.
### Month / week records are not populating reliably ### Month / week records are not populating reliably
Records for longer time periods are made by averaging stats from the shorter time periods. They require the agent to be running uninterrupted for long enough to get a full set of data. Records for longer time periods are created by averaging stats from shorter periods. The agent must run uninterrupted for a full set of data to populate these records.
If you pause / unpause the agent for longer than one minute, the data will be incomplete and the timing for the current interval will reset. Pausing/unpausing the agent for longer than one minute will result in incomplete data, resetting the timing for the current interval.
## Compiling ## Compiling
@@ -294,3 +293,7 @@ GOOS=freebsd GOARCH=arm64 CGO_ENABLED=0 go build -ldflags "-w -s" .
``` ```
You can see a list of valid options by running `go tool dist list`. You can see a list of valid options by running `go tool dist list`.
## License
Beszel is licensed under the MIT License. See the [LICENSE](LICENSE) file for more details.

View File

@@ -8,6 +8,8 @@ There are two scripts, one for the hub and one for the agent. You can run either
The install script creates a dedicated user for the service (`beszel`), downloads the latest release, and installs the service. The install script creates a dedicated user for the service (`beszel`), downloads the latest release, and installs the service.
If you need to edit the service -- for instance, to change an environment variable -- you can edit the file(s) in `/etc/systemd/system/`. Then reload the systemd daemon and restart the service.
> [!NOTE] > [!NOTE]
> You need system administrator privileges to run the install script. If you encounter a problem, please [open an issue](https://github.com/henrygd/beszel/issues/new). > You need system administrator privileges to run the install script. If you encounter a problem, please [open an issue](https://github.com/henrygd/beszel/issues/new).
@@ -71,6 +73,8 @@ sudo /opt/beszel-agent/beszel-agent update && sudo systemctl restart beszel-agen
## Manual install ## Manual install
### Hub
1. Create the system service at `/etc/systemd/system/beszel.service` 1. Create the system service at `/etc/systemd/system/beszel.service`
```bash ```bash
@@ -97,9 +101,7 @@ sudo systemctl enable beszel.service
sudo systemctl start beszel.service sudo systemctl start beszel.service
``` ```
## Run the agent as a system service (Linux) ### Agent
This runs the agent in the background continuously using systemd.
1. Create the system service at `/etc/systemd/system/beszel-agent.service` 1. Create the system service at `/etc/systemd/system/beszel-agent.service`
@@ -112,6 +114,7 @@ After=network.target
# update the values in curly braces below (remove the braces) # update the values in curly braces below (remove the braces)
Environment="PORT={PASTE_YOUR_PORT_HERE}" Environment="PORT={PASTE_YOUR_PORT_HERE}"
Environment="KEY={PASTE_YOUR_KEY_HERE}" Environment="KEY={PASTE_YOUR_KEY_HERE}"
# Environment="EXTRA_FILESYSTEMS={sdb}"
ExecStart={/path/to/directory}/beszel-agent ExecStart={/path/to/directory}/beszel-agent
User={YOUR_USERNAME} User={YOUR_USERNAME}
Restart=always Restart=always

View File

@@ -106,6 +106,7 @@ After=network.target
[Service] [Service]
Environment="PORT=$PORT" Environment="PORT=$PORT"
Environment="KEY=$KEY" Environment="KEY=$KEY"
# Environment="EXTRA_FILESYSTEMS=sdb"
ExecStart=/opt/beszel-agent/beszel-agent ExecStart=/opt/beszel-agent/beszel-agent
User=beszel User=beszel
Restart=always Restart=always