Compare commits

...

21 Commits

Author SHA1 Message Date
Henry Dollman
a5f9e2615c release 0.6.2 2024-10-23 18:39:15 -04:00
Henry Dollman
4a78ce1b16 skip temperatures code if sensors whitelist is set to empty string 2024-10-23 18:37:38 -04:00
Henry Dollman
f8f1e01cb4 add settings page and api route for generating config.yml 2024-10-23 18:30:24 -04:00
Henry Dollman
c7463f2b9f limit collection lookups and other small refactoring
* adds error handling for collection lookup (#216)
2024-10-23 13:10:39 -04:00
Henry Dollman
a975466fc7 add declarative system management with config.yml (#70, #206) 2024-10-22 18:46:52 -04:00
Henry Dollman
539c0ccb1d retry failed containers separately so we can run them in parallel (#58) 2024-10-21 17:00:13 -04:00
Henry Dollman
5f4dcb09ea release 0.6.1 2024-10-19 20:06:51 -04:00
Henry Dollman
6de5dce176 improve useeffects for favicon and system subscription 2024-10-19 19:57:35 -04:00
Henry Dollman
b5c158d1b3 update debug logs 2024-10-19 18:12:25 -04:00
Henry Dollman
7f01d1ec7e memoize alertsbutton and use string for global system alerts set 2024-10-19 18:08:02 -04:00
Henry Dollman
8bf7a0e1d6 add DOCKER_TIMEOUT env var 2024-10-19 16:33:33 -04:00
Henry Dollman
140fd93ec9 add ability to set alerts for all systems 2024-10-19 15:14:28 -04:00
Henry Dollman
bdcb34c989 update go deps 2024-10-19 15:05:27 -04:00
Henry Dollman
aaaa86b147 update js deps 2024-10-19 14:51:03 -04:00
Henry Dollman
6e9b84c6c7 use goroutines to send alerts 2024-10-19 14:28:41 -04:00
Henry Dollman
cce241caa4 update h-screen to use svh / dvh 2024-10-18 13:15:35 -04:00
Henry Dollman
1e9787c4d7 downgrade @nanostores/router to support Safari 16 (closes #210) 2024-10-18 13:11:26 -04:00
Henry Dollman
71aa9946f5 use one x axis component for all charts 2024-10-17 16:05:20 -04:00
Henry Dollman
12239808fc small updates to alert display 2024-10-17 15:46:09 -04:00
Henry Dollman
94e9d4f270 set alert to inactive if value or minute is updated 2024-10-17 11:12:04 -04:00
Yukino16
34a8053967 fix: path escape for system name in alert message (#209) 2024-10-17 10:31:36 -04:00
36 changed files with 1347 additions and 730 deletions

View File

@@ -9,43 +9,45 @@ require (
github.com/goccy/go-json v0.10.3
github.com/labstack/echo/v5 v5.0.0-20230722203903-ec5b858dab61
github.com/pocketbase/dbx v1.10.1
github.com/pocketbase/pocketbase v0.22.21
github.com/pocketbase/pocketbase v0.22.22
github.com/rhysd/go-github-selfupdate v1.2.3
github.com/shirou/gopsutil/v4 v4.24.9
github.com/spf13/cast v1.7.0
github.com/spf13/cobra v1.8.1
golang.org/x/crypto v0.27.0
golang.org/x/crypto v0.28.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/AlecAivazis/survey/v2 v2.3.7 // indirect
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/aws/aws-sdk-go-v2 v1.31.0 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.5 // indirect
github.com/aws/aws-sdk-go-v2/config v1.27.39 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.17.37 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.14 // indirect
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.26 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.18 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.18 // indirect
github.com/aws/aws-sdk-go-v2 v1.32.2 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.6 // indirect
github.com/aws/aws-sdk-go-v2/config v1.28.0 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.17.41 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.17 // indirect
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.33 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.18 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.5 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.20 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.20 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.18 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.64.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.23.3 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.27.3 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.31.3 // indirect
github.com/aws/smithy-go v1.21.0 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.21 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.0 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.2 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.2 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.2 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.66.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.24.2 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.2 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.32.2 // indirect
github.com/aws/smithy-go v1.22.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/disintegration/imaging v1.6.2 // indirect
github.com/domodwyer/mailyak/v3 v3.6.2 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ebitengine/purego v0.8.0 // indirect
github.com/fatih/color v1.17.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.5 // indirect
github.com/gabriel-vasile/mimetype v1.4.6 // indirect
github.com/ganigeorgiev/fexpr v0.4.1 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect
@@ -62,13 +64,12 @@ require (
github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.23 // indirect
github.com/mattn/go-sqlite3 v1.14.24 // indirect
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/spf13/cast v1.7.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/tcnksm/go-gitconfig v0.1.2 // indirect
github.com/tklauser/go-sysconf v0.3.14 // indirect
@@ -78,22 +79,22 @@ require (
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opencensus.io v0.24.0 // indirect
gocloud.dev v0.39.0 // indirect
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect
golang.org/x/image v0.20.0 // indirect
golang.org/x/net v0.29.0 // indirect
gocloud.dev v0.40.0 // indirect
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c // indirect
golang.org/x/image v0.21.0 // indirect
golang.org/x/net v0.30.0 // indirect
golang.org/x/oauth2 v0.23.0 // indirect
golang.org/x/sync v0.8.0 // indirect
golang.org/x/sys v0.25.0 // indirect
golang.org/x/term v0.24.0 // indirect
golang.org/x/text v0.18.0 // indirect
golang.org/x/time v0.6.0 // indirect
golang.org/x/sys v0.26.0 // indirect
golang.org/x/term v0.25.0 // indirect
golang.org/x/text v0.19.0 // indirect
golang.org/x/time v0.7.0 // indirect
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
google.golang.org/api v0.199.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240930140551-af27646dc61f // indirect
google.golang.org/api v0.201.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20241015192408-796eee8c2d53 // indirect
google.golang.org/grpc v1.67.1 // indirect
google.golang.org/protobuf v1.34.2 // indirect
modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a // indirect
google.golang.org/protobuf v1.35.1 // indirect
modernc.org/gc/v3 v3.0.0-20241004144649-1aea3fae8852 // indirect
modernc.org/libc v1.61.0 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.8.0 // indirect

View File

@@ -1,8 +1,8 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.115.1 h1:Jo0SM9cQnSkYfp44+v+NQXHpcHqlnRJk2qxh6yvxxxQ=
cloud.google.com/go v0.115.1/go.mod h1:DuujITeaufu3gL68/lOFIirVNJwQeyf5UXyi+Wbgknc=
cloud.google.com/go/auth v0.9.5 h1:4CTn43Eynw40aFVr3GpPqsQponx2jv0BQpjvajsbbzw=
cloud.google.com/go/auth v0.9.5/go.mod h1:Xo0n7n66eHyOWWCnitop6870Ilwo3PiZyodVkkH1xWM=
cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE=
cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U=
cloud.google.com/go/auth v0.9.8 h1:+CSJ0Gw9iVeSENVCKJoLHhdUykDgXSc4Qn+gu2BRtR8=
cloud.google.com/go/auth v0.9.8/go.mod h1:xxA5AqpDrvS+Gkmo9RqrGGRh6WSNKKOXhY3zNOr38tI=
cloud.google.com/go/auth/oauth2adapt v0.2.4 h1:0GWE/FUsXhf6C+jAkWgYm7X9tK8cuEIfy19DBn6B6bY=
cloud.google.com/go/auth/oauth2adapt v0.2.4/go.mod h1:jC/jOpwFP6JBxhB3P5Rr0a9HLMC/Pe3eaL4NmdvqPtc=
cloud.google.com/go/compute v1.14.0 h1:hfm2+FfxVmnRlh6LpB7cg1ZNU+5edAHmW679JePztk0=
@@ -26,44 +26,44 @@ github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3d
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
github.com/aws/aws-sdk-go v1.55.5 h1:KKUZBfBoyqy5d3swXyiC7Q76ic40rYcbqH7qjh59kzU=
github.com/aws/aws-sdk-go v1.55.5/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
github.com/aws/aws-sdk-go-v2 v1.31.0 h1:3V05LbxTSItI5kUqNwhJrrrY1BAXxXt0sN0l72QmG5U=
github.com/aws/aws-sdk-go-v2 v1.31.0/go.mod h1:ztolYtaEUtdpf9Wftr31CJfLVjOnD/CVRkKOOYgF8hA=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.5 h1:xDAuZTn4IMm8o1LnBZvmrL8JA1io4o3YWNXgohbf20g=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.5/go.mod h1:wYSv6iDS621sEFLfKvpPE2ugjTuGlAG7iROg0hLOkfc=
github.com/aws/aws-sdk-go-v2/config v1.27.39 h1:FCylu78eTGzW1ynHcongXK9YHtoXD5AiiUqq3YfJYjU=
github.com/aws/aws-sdk-go-v2/config v1.27.39/go.mod h1:wczj2hbyskP4LjMKBEZwPRO1shXY+GsQleab+ZXT2ik=
github.com/aws/aws-sdk-go-v2/credentials v1.17.37 h1:G2aOH01yW8X373JK419THj5QVqu9vKEwxSEsGxihoW0=
github.com/aws/aws-sdk-go-v2/credentials v1.17.37/go.mod h1:0ecCjlb7htYCptRD45lXJ6aJDQac6D2NlKGpZqyTG6A=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.14 h1:C/d03NAmh8C4BZXhuRNboF/DqhBkBCeDiJDcaqIT5pA=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.14/go.mod h1:7I0Ju7p9mCIdlrfS+JCgqcYD0VXz/N4yozsox+0o078=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.26 h1:BTfwWNFVGLxW2bih/V2xhgCsYDQwG1cAWhWoW9Jx7wE=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.26/go.mod h1:LA1/FxoEFFmv7XpkB8KKqLAUz8AePdK9H0Ec7PUKazs=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.18 h1:kYQ3H1u0ANr9KEKlGs/jTLrBFPo8P8NaH/w7A01NeeM=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.18/go.mod h1:r506HmK5JDUh9+Mw4CfGJGSSoqIiLCndAuqXuhbv67Y=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.18 h1:Z7IdFUONvTcvS7YuhtVxN99v2cCoHRXOS4mTr0B/pUc=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.18/go.mod h1:DkKMmksZVVyat+Y+r1dEOgJEfUeA7UngIHWeKsi0yNc=
github.com/aws/aws-sdk-go-v2 v1.32.2 h1:AkNLZEyYMLnx/Q/mSKkcMqwNFXMAvFto9bNsHqcTduI=
github.com/aws/aws-sdk-go-v2 v1.32.2/go.mod h1:2SK5n0a2karNTv5tbP1SjsX0uhttou00v/HpXKM1ZUo=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.6 h1:pT3hpW0cOHRJx8Y0DfJUEQuqPild8jRGmSFmBgvydr0=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.6/go.mod h1:j/I2++U0xX+cr44QjHay4Cvxj6FUbnxrgmqN3H1jTZA=
github.com/aws/aws-sdk-go-v2/config v1.28.0 h1:FosVYWcqEtWNxHn8gB/Vs6jOlNwSoyOCA/g/sxyySOQ=
github.com/aws/aws-sdk-go-v2/config v1.28.0/go.mod h1:pYhbtvg1siOOg8h5an77rXle9tVG8T+BWLWAo7cOukc=
github.com/aws/aws-sdk-go-v2/credentials v1.17.41 h1:7gXo+Axmp+R4Z+AK8YFQO0ZV3L0gizGINCOWxSLY9W8=
github.com/aws/aws-sdk-go-v2/credentials v1.17.41/go.mod h1:u4Eb8d3394YLubphT4jLEwN1rLNq2wFOlT6OuxFwPzU=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.17 h1:TMH3f/SCAWdNtXXVPPu5D6wrr4G5hI1rAxbcocKfC7Q=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.17/go.mod h1:1ZRXLdTpzdJb9fwTMXiLipENRxkGMTn1sfKexGllQCw=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.33 h1:X+4YY5kZRI/cOoSMVMGTqFXHAMg1bvvay7IBcqHpybQ=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.33/go.mod h1:DPynzu+cn92k5UQ6tZhX+wfTB4ah6QDU/NgdHqatmvk=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.21 h1:UAsR3xA31QGf79WzpG/ixT9FZvQlh5HY1NRqSHBNOCk=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.21/go.mod h1:JNr43NFf5L9YaG3eKTm7HQzls9J+A9YYcGI5Quh1r2Y=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.21 h1:6jZVETqmYCadGFvrYEQfC5fAQmlo80CeL5psbno6r0s=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.21/go.mod h1:1SR0GbLlnN3QUmYaflZNiH1ql+1qrSiB2vwcJ+4UM60=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 h1:VaRN3TlFdd6KxX1x3ILT5ynH6HvKgqdiXoTxAF4HQcQ=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.18 h1:OWYvKL53l1rbsUmW7bQyJVsYU/Ii3bbAAQIIFNbM0Tk=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.18/go.mod h1:CUx0G1v3wG6l01tUB+j7Y8kclA8NSqK4ef0YG79a4cg=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.5 h1:QFASJGfT8wMXtuP3D5CRmMjARHv9ZmzFUMJznHDOY3w=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.5/go.mod h1:QdZ3OmoIjSX+8D1OPAzPxDfjXASbBMDsz9qvtyIhtik=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.20 h1:rTWjG6AvWekO2B1LHeM3ktU7MqyX9rzWQ7hgzneZW7E=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.20/go.mod h1:RGW2DDpVc8hu6Y6yG8G5CHVmVOAn1oV8rNKOHRJyswg=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.20 h1:Xbwbmk44URTiHNx6PNo0ujDE6ERlsCKJD3u1zfnzAPg=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.20/go.mod h1:oAfOFzUB14ltPZj1rWwRc3d/6OgD76R8KlvU3EqM9Fg=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.18 h1:eb+tFOIl9ZsUe2259/BKPeniKuz4/02zZFH/i4Nf8Rg=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.18/go.mod h1:GVCC2IJNJTmdlyEsSmofEy7EfJncP7DNnXDzRjJ5Keg=
github.com/aws/aws-sdk-go-v2/service/s3 v1.64.0 h1:I0p8knB/IDYSQ3dbanaCr4UhiYQ96bvKRhGYxvLyiD8=
github.com/aws/aws-sdk-go-v2/service/s3 v1.64.0/go.mod h1:NLTqRLe3pUNu3nTEHI6XlHLKYmc8fbHUdMxAB6+s41Q=
github.com/aws/aws-sdk-go-v2/service/sso v1.23.3 h1:rs4JCczF805+FDv2tRhZ1NU0RB2H6ryAvsWPanAr72Y=
github.com/aws/aws-sdk-go-v2/service/sso v1.23.3/go.mod h1:XRlMvmad0ZNL+75C5FYdMvbbLkd6qiqz6foR1nA1PXY=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.27.3 h1:S7EPdMVZod8BGKQQPTBK+FcX9g7bKR7c4+HxWqHP7Vg=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.27.3/go.mod h1:FnvDM4sfa+isJ3kDXIzAB9GAwVSzFzSy97uZ3IsHo4E=
github.com/aws/aws-sdk-go-v2/service/sts v1.31.3 h1:VzudTFrDCIDakXtemR7l6Qzt2+JYsVqo2MxBPt5k8T8=
github.com/aws/aws-sdk-go-v2/service/sts v1.31.3/go.mod h1:yMWe0F+XG0DkRZK5ODZhG7BEFYhLXi2dqGsv6tX0cgI=
github.com/aws/smithy-go v1.21.0 h1:H7L8dtDRk0P1Qm6y0ji7MCYMQObJ5R9CRpyPhRUkLYA=
github.com/aws/smithy-go v1.21.0/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.21 h1:7edmS3VOBDhK00b/MwGtGglCm7hhwNYnjJs/PgFdMQE=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.21/go.mod h1:Q9o5h4HoIWG8XfzxqiuK/CGUbepCJ8uTlaE3bAbxytQ=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.0 h1:TToQNkvGguu209puTojY/ozlqy2d/SFNcoLIqTFi42g=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.0/go.mod h1:0jp+ltwkf+SwG2fm/PKo8t4y8pJSgOCO4D8Lz3k0aHQ=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.2 h1:4FMHqLfk0efmTqhXVRL5xYRqlEBNBiRI7N6w4jsEdd4=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.2/go.mod h1:LWoqeWlK9OZeJxsROW2RqrSPvQHKTpp69r/iDjwsSaw=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.2 h1:s7NA1SOw8q/5c0wr8477yOPp0z+uBaXBnLE0XYb0POA=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.2/go.mod h1:fnjjWyAW/Pj5HYOxl9LJqWtEwS7W2qgcRLWP+uWbss0=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.2 h1:t7iUP9+4wdc5lt3E41huP+GvQZJD38WLsgVp4iOtAjg=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.2/go.mod h1:/niFCtmuQNxqx9v8WAPq5qh7EH25U4BF6tjoyq9bObM=
github.com/aws/aws-sdk-go-v2/service/s3 v1.66.0 h1:xA6XhTF7PE89BCNHJbQi8VvPzcgMtmGC5dr8S8N7lHk=
github.com/aws/aws-sdk-go-v2/service/s3 v1.66.0/go.mod h1:cB6oAuus7YXRZhWCc1wIwPywwZ1XwweNp2TVAEGYeB8=
github.com/aws/aws-sdk-go-v2/service/sso v1.24.2 h1:bSYXVyUzoTHoKalBmwaZxs97HU9DWWI3ehHSAMa7xOk=
github.com/aws/aws-sdk-go-v2/service/sso v1.24.2/go.mod h1:skMqY7JElusiOUjMJMOv1jJsP7YUg7DrhgqZZWuzu1U=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.2 h1:AhmO1fHINP9vFYUE0LHzCWg/LfUWUF+zFPEcY9QXb7o=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.2/go.mod h1:o8aQygT2+MVP0NaV6kbdE1YnnIM8RRVQzoeUH45GOdI=
github.com/aws/aws-sdk-go-v2/service/sts v1.32.2 h1:CiS7i0+FUe+/YY1GvIBLLrR/XNGZ4CtM1Ll0XavNuVo=
github.com/aws/aws-sdk-go-v2/service/sts v1.32.2/go.mod h1:HtaiBI8CjYoNVde8arShXb94UbQQi9L4EMr6D+xGBwo=
github.com/aws/smithy-go v1.22.0 h1:uunKnWlcoL3zO7q+gG2Pk53joueEOsnNB28QdMsmiMM=
github.com/aws/smithy-go v1.22.0/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg=
github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ=
github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
@@ -97,8 +97,8 @@ github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSw
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/gabriel-vasile/mimetype v1.4.5 h1:J7wGKdGu33ocBOhGy0z653k/lFKLFDPJMG8Gql0kxn4=
github.com/gabriel-vasile/mimetype v1.4.5/go.mod h1:ibHel+/kbxn9x2407k1izTA1S81ku1z/DlgOW2QE0M4=
github.com/gabriel-vasile/mimetype v1.4.6 h1:3+PzJTKLkvgjeTbts6msPJt4DixhT4YtFNf1gtGe3zc=
github.com/gabriel-vasile/mimetype v1.4.6/go.mod h1:JX1qVKqZd40hUPpAfiNTe0Sne7hdfKSbOqqmkq8GCXc=
github.com/ganigeorgiev/fexpr v0.4.1 h1:hpUgbUEEWIZhSDBtf4M9aUNfQQ0BZkGRaMePy7Gcx5k=
github.com/ganigeorgiev/fexpr v0.4.1/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
github.com/gliderlabs/ssh v0.3.7 h1:iV3Bqi942d9huXnzEF2Mt+CY9gLu8DNM4Obd+8bODRE=
@@ -198,8 +198,8 @@ github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hd
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.23 h1:gbShiuAP1W5j9UOksQ06aiiqPMxYecovVGwmTxWtuw0=
github.com/mattn/go-sqlite3 v1.14.23/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI=
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
@@ -217,8 +217,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pocketbase/dbx v1.10.1 h1:cw+vsyfCJD8YObOVeqb93YErnlxwYMkNZ4rwN0G0AaA=
github.com/pocketbase/dbx v1.10.1/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
github.com/pocketbase/pocketbase v0.22.21 h1:DGPCxn6co8VuTV0mton4NFO/ON49XiFMszRr+Mysy48=
github.com/pocketbase/pocketbase v0.22.21/go.mod h1:Cw5E4uoGhKItBIE2lJL3NfmiUr9Syk2xaNJ2G7Dssow=
github.com/pocketbase/pocketbase v0.22.22 h1:iA128U+cmM9euxPpuCN7blmQ2FZNzOix2aUUcnbbQu8=
github.com/pocketbase/pocketbase v0.22.22/go.mod h1:u+l7T04g7eBXetoodXLch3WoV/QonRf1qYq+2vuTKuI=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
@@ -275,20 +275,20 @@ go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2
go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8=
go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4=
go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ=
gocloud.dev v0.39.0 h1:EYABYGhAalPUaMrbSKOr5lejxoxvXj99nE8XFtsDgds=
gocloud.dev v0.39.0/go.mod h1:drz+VyYNBvrMTW0KZiBAYEdl8lbNZx+OQ7oQvdrFmSQ=
gocloud.dev v0.40.0 h1:f8LgP+4WDqOG/RXoUcyLpeIAGOcAbZrZbDQCUee10ng=
gocloud.dev v0.40.0/go.mod h1:drz+VyYNBvrMTW0KZiBAYEdl8lbNZx+OQ7oQvdrFmSQ=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
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/exp v0.0.0-20241009180824-f66d83c29e7c h1:7dEasQXItcW1xKJ2+gg5VOiBnqWrJc+rq0DPKyvvdbY=
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c/go.mod h1:NQtJDoLvd6faHhE7m4T/1IY708gDefGGjR/iUW8yQQ8=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.20.0 h1:7cVCUjQwfL18gyBJOmYvptfSHS8Fb3YUDtfLIZ7Nbpw=
golang.org/x/image v0.20.0/go.mod h1:0a88To4CYVBAHp5FXJm8o7QbUl37Vd85ply1vyD8auM=
golang.org/x/image v0.21.0 h1:c5qV36ajHpdj4Qi0GnE0jUc/yuo33OLFaa0d+crTD5s=
golang.org/x/image v0.21.0/go.mod h1:vUbsLavqK/W303ZroQQVKQ+Af3Yl6Uz1Ppu5J/cLz78=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
@@ -306,8 +306,8 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
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.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs=
@@ -334,23 +334,23 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.24.0 h1:Mh5cbb+Zk2hqqXNO7S1iTjEphVL+jb8ZWaqh/g+JWkM=
golang.org/x/term v0.24.0/go.mod h1:lOBK/LVxemqiMij05LGJ0tzNr8xlmwBRJ81PX6wVLH8=
golang.org/x/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24=
golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ=
golang.org/x/time v0.7.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-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
@@ -358,14 +358,14 @@ golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.25.0 h1:oFU9pkj/iJgs+0DT+VMHrx+oBKs/LJMV+Uvg78sl+fE=
golang.org/x/tools v0.25.0/go.mod h1:/vtpO8WL1N9cQC3FN5zPqb//fRXskFHbLKk4OW1Q7rg=
golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ=
golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0=
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-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
google.golang.org/api v0.199.0 h1:aWUXClp+VFJmqE0JPvpZOK3LDQMyFKYIow4etYd9qxs=
google.golang.org/api v0.199.0/go.mod h1:ohG4qSztDJmZdjK/Ar6MhbAmb/Rpi4JHOqagsh90K28=
google.golang.org/api v0.201.0 h1:+7AD9JNM3tREtawRMu8sOjSbb8VYcYXJG/2eEOmfDu0=
google.golang.org/api v0.201.0/go.mod h1:HVY0FCHVs89xIW9fzf/pBvOEm+OolHa86G/txFezyq4=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
@@ -373,12 +373,12 @@ google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCID
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/genproto v0.0.0-20240903143218-8af14fe29dc1 h1:BulPr26Jqjnd4eYDVe+YvyR7Yc2vJGkO5/0UxD0/jZU=
google.golang.org/genproto v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:hL97c3SYopEHblzpxRL4lSs523++l8DYxGM1FQiYmb4=
google.golang.org/genproto v0.0.0-20241007155032-5fefd90f89a9 h1:nFS3IivktIU5Mk6KQa+v6RKkHUpdQpphqGNLxqNnbEk=
google.golang.org/genproto v0.0.0-20241007155032-5fefd90f89a9/go.mod h1:tEzYTYZxbmVNOu0OAFH9HzdJtLn6h4Aj89zzlBCdHms=
google.golang.org/genproto/googleapis/api v0.0.0-20240814211410-ddb44dafa142 h1:wKguEg1hsxI2/L3hUYrpo1RVi48K+uTyzKqprwLXsb8=
google.golang.org/genproto/googleapis/api v0.0.0-20240814211410-ddb44dafa142/go.mod h1:d6be+8HhtEtucleCbxpPW9PA9XwISACu8nvpPqF0BVo=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240930140551-af27646dc61f h1:cUMEy+8oS78BWIH9OWazBkzbr090Od9tWBNtZHkOhf0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240930140551-af27646dc61f/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20241015192408-796eee8c2d53 h1:X58yt85/IXCx0Y3ZwN6sEIKZzQtDEYaBWrDvErdXrRE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20241015192408-796eee8c2d53/go.mod h1:GX3210XPVPUjJbTUbvwI8f2IpZDMZuPJWDzDuebbviI=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
@@ -395,9 +395,10 @@ google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA=
google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
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=
@@ -416,8 +417,8 @@ modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/gc/v2 v2.5.0 h1:bJ9ChznK1L1mUtAQtxi0wi5AtAs5jQuw4PrPHO5pb6M=
modernc.org/gc/v2 v2.5.0/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a h1:CfbpOLEo2IwNzJdMvE8aiRbPMxoTpgAJeyePh0SmO8M=
modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
modernc.org/gc/v3 v3.0.0-20241004144649-1aea3fae8852 h1:IYXPPTTjjoSHvUClZIYexDiO7g+4x+XveKT4gCIAwiY=
modernc.org/gc/v3 v3.0.0-20241004144649-1aea3fae8852/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
modernc.org/libc v1.61.0 h1:eGFcvWpqlnoGwzZeZe3PWJkkKbM/3SUGyk1DVZQ0TpE=
modernc.org/libc v1.61.0/go.mod h1:DvxVX89wtGTu+r72MLGhygpfi3aUGgZRdAYGCAVVud0=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=

View File

@@ -2,6 +2,7 @@
package agent
import (
"beszel"
"beszel/internal/entities/system"
"context"
"log/slog"
@@ -47,6 +48,8 @@ func (a *Agent) Run(pubKey []byte, addr string) {
}
}
slog.Debug(beszel.Version)
// Set sensors context (allows overriding sys location for sensors)
if sysSensors, exists := os.LookupEnv("SYS_SENSORS"); exists {
slog.Info("SYS_SENSORS", "path", sysSensors)

View File

@@ -60,6 +60,8 @@ func (dm *dockerManager) getDockerStats() ([]*container.Stats, error) {
clear(dm.validIds)
}
var failedContainters []container.ApiInfo
for _, ctr := range *dm.apiContainerList {
ctr.IdShort = ctr.Id[:12]
dm.validIds[ctr.IdShort] = struct{}{}
@@ -74,18 +76,33 @@ func (dm *dockerManager) getDockerStats() ([]*container.Stats, error) {
defer dm.dequeue()
err := dm.updateContainerStats(ctr)
if err != nil {
dm.deleteContainerStatsSync(ctr.IdShort)
// retry once
err = dm.updateContainerStats(ctr)
if err != nil {
slog.Error("Error getting container stats", "err", err)
}
dm.containerStatsMutex.Lock()
delete(dm.containerStatsMap, ctr.IdShort)
failedContainters = append(failedContainters, ctr)
dm.containerStatsMutex.Unlock()
}
}()
}
dm.wg.Wait()
// retry failed containers separately so we can run them in parallel (docker 24 bug)
if len(failedContainters) > 0 {
slog.Debug("Retrying failed containers", "count", len(failedContainters))
// time.Sleep(time.Millisecond * 1100)
for _, ctr := range failedContainters {
dm.wg.Add(1)
go func() {
defer dm.wg.Done()
err = dm.updateContainerStats(ctr)
if err != nil {
slog.Error("Error getting container stats", "err", err)
}
}()
}
dm.wg.Wait()
}
// populate final stats and remove old / invalid container stats
stats := make([]*container.Stats, 0, containersLength)
for id, v := range dm.containerStatsMap {
@@ -217,9 +234,20 @@ func newDockerManager() *dockerManager {
os.Exit(1)
}
// configurable timeout
timeout := time.Millisecond * 2100
if t, set := os.LookupEnv("DOCKER_TIMEOUT"); set {
timeout, err = time.ParseDuration(t)
if err != nil {
slog.Error(err.Error())
os.Exit(1)
}
slog.Info("DOCKER_TIMEOUT", "timeout", timeout)
}
dockerClient := &dockerManager{
client: &http.Client{
Timeout: time.Second * 8,
Timeout: timeout,
Transport: transport,
},
containerStatsMap: make(map[string]*container.Stats),
@@ -246,7 +274,6 @@ func newDockerManager() *dockerManager {
// if version > 24, one-shot works correctly and we can limit concurrent operations
if dockerVersion, err := semver.Parse(versionInfo.Version); err == nil && dockerVersion.Major > 24 {
concurrency = 5
dockerClient.client.Timeout = time.Millisecond * 1100
}
slog.Debug("Docker", "version", versionInfo.Version, "concurrency", concurrency)

View File

@@ -171,33 +171,35 @@ func (a *Agent) getSystemStats() system.Stats {
}
}
// temperatures
temps, err := sensors.TemperaturesWithContext(a.sensorsContext)
if err != nil && a.debug {
err.(*sensors.Warnings).Verbose = true
slog.Debug("Sensor error", "errs", err)
}
if len(temps) > 0 {
slog.Debug("Temperatures", "data", temps)
systemStats.Temperatures = make(map[string]float64, len(temps))
for i, sensor := range temps {
// skip if temperature is 0
if sensor.Temperature <= 0 || sensor.Temperature >= 200 {
continue
}
if _, ok := systemStats.Temperatures[sensor.SensorKey]; ok {
// if key already exists, append int to key
systemStats.Temperatures[sensor.SensorKey+"_"+strconv.Itoa(i)] = twoDecimals(sensor.Temperature)
} else {
systemStats.Temperatures[sensor.SensorKey] = twoDecimals(sensor.Temperature)
}
// temperatures (skip if sensors whitelist is set to empty string)
if a.sensorsWhitelist == nil || len(a.sensorsWhitelist) > 0 {
temps, err := sensors.TemperaturesWithContext(a.sensorsContext)
if err != nil {
// err.(*sensors.Warnings).Verbose = true
slog.Debug("Sensor error", "err", err)
}
// remove sensors from systemStats if whitelist exists and sensor is not in whitelist
// (do this here instead of in initial loop so we have correct keys if int was appended)
if a.sensorsWhitelist != nil {
for key := range systemStats.Temperatures {
if _, nameInWhitelist := a.sensorsWhitelist[key]; !nameInWhitelist {
delete(systemStats.Temperatures, key)
slog.Debug("Temperature", "sensors", temps)
if len(temps) > 0 {
systemStats.Temperatures = make(map[string]float64, len(temps))
for i, sensor := range temps {
// skip if temperature is 0
if sensor.Temperature <= 0 || sensor.Temperature >= 200 {
continue
}
if _, ok := systemStats.Temperatures[sensor.SensorKey]; ok {
// if key already exists, append int to key
systemStats.Temperatures[sensor.SensorKey+"_"+strconv.Itoa(i)] = twoDecimals(sensor.Temperature)
} else {
systemStats.Temperatures[sensor.SensorKey] = twoDecimals(sensor.Temperature)
}
}
// remove sensors from systemStats if whitelist exists and sensor is not in whitelist
// (do this here instead of in initial loop so we have correct keys if int was appended)
if a.sensorsWhitelist != nil {
for key := range systemStats.Temperatures {
if _, nameInWhitelist := a.sensorsWhitelist[key]; !nameInWhitelist {
delete(systemStats.Temperatures, key)
}
}
}
}
@@ -209,6 +211,7 @@ func (a *Agent) getSystemStats() system.Stats {
a.systemInfo.DiskPct = systemStats.DiskPct
a.systemInfo.Uptime, _ = host.Uptime()
a.systemInfo.Bandwidth = twoDecimals(systemStats.NetworkSent + systemStats.NetworkRecv)
slog.Debug("sysinfo", "data", a.systemInfo)
return systemStats
}

View File

@@ -284,10 +284,10 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *models.Record, systemIn
if float32(alert.count) >= minCount {
if !alert.triggered && alert.val > alert.threshold {
alert.triggered = true
am.sendSystemAlert(alert)
go am.sendSystemAlert(alert)
} else if alert.triggered && alert.val <= alert.threshold {
alert.triggered = false
am.sendSystemAlert(alert)
go am.sendSystemAlert(alert)
}
}
}
@@ -339,7 +339,7 @@ func (am *AlertManager) sendSystemAlert(alert SystemAlertData) {
UserID: user.GetId(),
Title: subject,
Message: body,
Link: am.app.Settings().Meta.AppUrl + "/system/" + url.QueryEscape(systemName),
Link: am.app.Settings().Meta.AppUrl + "/system/" + url.PathEscape(systemName),
LinkText: "View " + systemName,
})
}
@@ -390,7 +390,7 @@ func (am *AlertManager) HandleStatusAlerts(newStatus string, oldSystemRecord *mo
UserID: user.GetId(),
Title: fmt.Sprintf("Connection to %s is %s %v", systemName, alertStatus, emoji),
Message: fmt.Sprintf("Connection to %s is %s", systemName, alertStatus),
Link: am.app.Settings().Meta.AppUrl + "/system/" + url.QueryEscape(systemName),
Link: am.app.Settings().Meta.AppUrl + "/system/" + url.PathEscape(systemName),
LinkText: "View " + systemName,
})
}

View File

@@ -0,0 +1,222 @@
package hub
import (
"beszel/internal/entities/system"
"fmt"
"log"
"os"
"path/filepath"
"strconv"
"github.com/labstack/echo/v5"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/models"
"github.com/spf13/cast"
"gopkg.in/yaml.v3"
)
type Config struct {
Systems []SystemConfig `yaml:"systems"`
}
type SystemConfig struct {
Name string `yaml:"name"`
Host string `yaml:"host"`
Port uint16 `yaml:"port"`
Users []string `yaml:"users"`
}
// Syncs systems with the config.yml file
func (h *Hub) syncSystemsWithConfig() error {
configPath := filepath.Join(h.app.DataDir(), "config.yml")
configData, err := os.ReadFile(configPath)
if err != nil {
return nil
}
var config Config
err = yaml.Unmarshal(configData, &config)
if err != nil {
return fmt.Errorf("failed to parse config.yml: %v", err)
}
if len(config.Systems) == 0 {
log.Println("No systems defined in config.yml.")
return nil
}
var firstUser *models.Record
// Create a map of email to user ID
userEmailToID := make(map[string]string)
users, err := h.app.Dao().FindRecordsByExpr("users", dbx.NewExp("id != ''"))
if err != nil {
return err
}
if len(users) > 0 {
firstUser = users[0]
for _, user := range users {
userEmailToID[user.GetString("email")] = user.Id
}
}
// add default settings for systems if not defined in config
for i := range config.Systems {
system := &config.Systems[i]
if system.Port == 0 {
system.Port = 45876
}
if len(users) > 0 && len(system.Users) == 0 {
// default to first user if none are defined
system.Users = []string{firstUser.Id}
} else {
// Convert email addresses to user IDs
userIDs := make([]string, 0, len(system.Users))
for _, email := range system.Users {
if id, ok := userEmailToID[email]; ok {
userIDs = append(userIDs, id)
} else {
log.Printf("User %s not found", email)
}
}
system.Users = userIDs
}
}
// Get existing systems
existingSystems, err := h.app.Dao().FindRecordsByExpr("systems", dbx.NewExp("id != ''"))
if err != nil {
return err
}
// Create a map of existing systems for easy lookup
existingSystemsMap := make(map[string]*models.Record)
for _, system := range existingSystems {
key := system.GetString("host") + ":" + system.GetString("port")
existingSystemsMap[key] = system
}
// Process systems from config
for _, sysConfig := range config.Systems {
key := sysConfig.Host + ":" + strconv.Itoa(int(sysConfig.Port))
if existingSystem, ok := existingSystemsMap[key]; ok {
// Update existing system
existingSystem.Set("name", sysConfig.Name)
existingSystem.Set("users", sysConfig.Users)
existingSystem.Set("port", sysConfig.Port)
if err := h.app.Dao().SaveRecord(existingSystem); err != nil {
return err
}
delete(existingSystemsMap, key)
} else {
// Create new system
systemsCollection, err := h.app.Dao().FindCollectionByNameOrId("systems")
if err != nil {
return fmt.Errorf("failed to find systems collection: %v", err)
}
newSystem := models.NewRecord(systemsCollection)
newSystem.Set("name", sysConfig.Name)
newSystem.Set("host", sysConfig.Host)
newSystem.Set("port", sysConfig.Port)
newSystem.Set("users", sysConfig.Users)
newSystem.Set("info", system.Info{})
newSystem.Set("status", "pending")
if err := h.app.Dao().SaveRecord(newSystem); err != nil {
return fmt.Errorf("failed to create new system: %v", err)
}
}
}
// Delete systems not in config
for _, system := range existingSystemsMap {
if err := h.app.Dao().DeleteRecord(system); err != nil {
return err
}
}
log.Println("Systems synced with config.yml")
return nil
}
// Generates content for the config.yml file as a YAML string
func (h *Hub) generateConfigYAML() (string, error) {
// Fetch all systems from the database
systems, err := h.app.Dao().FindRecordsByFilter("systems", "id != ''", "name", -1, 0)
if err != nil {
return "", err
}
// Create a Config struct to hold the data
config := Config{
Systems: make([]SystemConfig, 0, len(systems)),
}
// Fetch all users at once
allUserIDs := make([]string, 0)
for _, system := range systems {
allUserIDs = append(allUserIDs, system.GetStringSlice("users")...)
}
userEmailMap, err := h.getUserEmailMap(allUserIDs)
if err != nil {
return "", err
}
// Populate the Config struct with system data
for _, system := range systems {
userIDs := system.GetStringSlice("users")
userEmails := make([]string, 0, len(userIDs))
for _, userID := range userIDs {
if email, ok := userEmailMap[userID]; ok {
userEmails = append(userEmails, email)
}
}
sysConfig := SystemConfig{
Name: system.GetString("name"),
Host: system.GetString("host"),
Port: cast.ToUint16(system.Get("port")),
Users: userEmails,
}
config.Systems = append(config.Systems, sysConfig)
}
// Marshal the Config struct to YAML
yamlData, err := yaml.Marshal(&config)
if err != nil {
return "", err
}
// Add a header to the YAML
yamlData = append([]byte("# Values for port and users are optional.\n# Defaults are port 45876 and the first created user.\n\n"), yamlData...)
return string(yamlData), nil
}
// New helper function to get a map of user IDs to emails
func (h *Hub) getUserEmailMap(userIDs []string) (map[string]string, error) {
users, err := h.app.Dao().FindRecordsByIds("users", userIDs)
if err != nil {
return nil, err
}
userEmailMap := make(map[string]string, len(users))
for _, user := range users {
userEmailMap[user.Id] = user.GetString("email")
}
return userEmailMap, nil
}
// Returns the current config.yml file as a JSON object
func (h *Hub) getYamlConfig(c echo.Context) error {
requestData := apis.RequestInfo(c)
if requestData.AuthRecord == nil || requestData.AuthRecord.GetString("role") != "admin" {
return apis.NewForbiddenError("Forbidden", nil)
}
configContent, err := h.generateConfigYAML()
if err != nil {
return err
}
return c.JSON(200, map[string]string{"config": configContent})
}

View File

@@ -42,6 +42,8 @@ type Hub struct {
am *alerts.AlertManager
um *users.UserManager
rm *records.RecordManager
systemStats *models.Collection
containerStats *models.Collection
}
func NewHub(app *pocketbase.PocketBase) *Hub {
@@ -56,14 +58,10 @@ func NewHub(app *pocketbase.PocketBase) *Hub {
}
func (h *Hub) Run() {
// rm := records.NewRecordManager(h.app)
// am := alerts.NewAlertManager(h.app)
// um := users.NewUserManager(h.app)
// loosely check if it was executed using "go run"
isGoRun := strings.HasPrefix(os.Args[0], os.TempDir())
// // enable auto creation of migration files when making collection changes in the Admin UI
// enable auto creation of migration files when making collection changes in the Admin UI
migratecmd.MustRegister(h.app, h.app.RootCmd, migratecmd.Config{
// (the isGoRun check is to enable it only during development)
Automigrate: isGoRun,
@@ -93,7 +91,8 @@ func (h *Hub) Run() {
if err := h.app.Dao().SaveCollection(usersCollection); err != nil {
return err
}
return nil
// sync systems with config
return h.syncSystemsWithConfig()
})
// serve web ui
@@ -128,7 +127,11 @@ func (h *Hub) Run() {
// delete old records once every hour
scheduler.MustAdd("delete old records", "8 * * * *", h.rm.DeleteOldRecords)
// create longer records every 10 minutes
scheduler.MustAdd("create longer records", "*/10 * * * *", h.rm.CreateLongerRecords)
scheduler.MustAdd("create longer records", "*/10 * * * *", func() {
if systemStats, containerStats, err := h.getCollections(); err == nil {
h.rm.CreateLongerRecords([]*models.Collection{systemStats, containerStats})
}
})
scheduler.Start()
return nil
})
@@ -153,6 +156,8 @@ func (h *Hub) Run() {
})
// send test notification
e.Router.GET("/api/beszel/send-test-notification", h.am.SendTestNotification)
// API endpoint to get config.yml content
e.Router.GET("/api/beszel/config-yaml", h.getYamlConfig)
return nil
})
@@ -289,37 +294,61 @@ func (h *Hub) updateSystem(record *models.Record) {
return
}
// update system record
dao := h.app.Dao()
record.Set("status", "up")
record.Set("info", systemData.Info)
if err := h.app.Dao().SaveRecord(record); err != nil {
if err := dao.SaveRecord(record); err != nil {
h.app.Logger().Error("Failed to update record: ", "err", err.Error())
}
// add new system_stats record
system_stats, _ := h.app.Dao().FindCollectionByNameOrId("system_stats")
systemStatsRecord := models.NewRecord(system_stats)
systemStatsRecord.Set("system", record.Id)
systemStatsRecord.Set("stats", systemData.Stats)
systemStatsRecord.Set("type", "1m")
if err := h.app.Dao().SaveRecord(systemStatsRecord); err != nil {
h.app.Logger().Error("Failed to save record: ", "err", err.Error())
}
// add new container_stats record
if len(systemData.Containers) > 0 {
container_stats, _ := h.app.Dao().FindCollectionByNameOrId("container_stats")
containerStatsRecord := models.NewRecord(container_stats)
containerStatsRecord.Set("system", record.Id)
containerStatsRecord.Set("stats", systemData.Containers)
containerStatsRecord.Set("type", "1m")
if err := h.app.Dao().SaveRecord(containerStatsRecord); err != nil {
// add system_stats and container_stats records
if systemStats, containerStats, err := h.getCollections(); err != nil {
h.app.Logger().Error("Failed to get collections: ", "err", err.Error())
} else {
// add new system_stats record
systemStatsRecord := models.NewRecord(systemStats)
systemStatsRecord.Set("system", record.Id)
systemStatsRecord.Set("stats", systemData.Stats)
systemStatsRecord.Set("type", "1m")
if err := dao.SaveRecord(systemStatsRecord); err != nil {
h.app.Logger().Error("Failed to save record: ", "err", err.Error())
}
// add new container_stats record
if len(systemData.Containers) > 0 {
containerStatsRecord := models.NewRecord(containerStats)
containerStatsRecord.Set("system", record.Id)
containerStatsRecord.Set("stats", systemData.Containers)
containerStatsRecord.Set("type", "1m")
if err := dao.SaveRecord(containerStatsRecord); err != nil {
h.app.Logger().Error("Failed to save record: ", "err", err.Error())
}
}
}
// system info alerts (todo: extra fs alerts)
if err := h.am.HandleSystemAlerts(record, systemData.Info, systemData.Stats.Temperatures, systemData.Stats.ExtraFs); err != nil {
h.app.Logger().Error("System alerts error", "err", err.Error())
}
}
// return system_stats and container_stats collections
func (h *Hub) getCollections() (*models.Collection, *models.Collection, error) {
if h.systemStats == nil {
systemStats, err := h.app.Dao().FindCollectionByNameOrId("system_stats")
if err != nil {
return nil, nil, err
}
h.systemStats = systemStats
}
if h.containerStats == nil {
containerStats, err := h.app.Dao().FindCollectionByNameOrId("container_stats")
if err != nil {
return nil, nil, err
}
h.containerStats = containerStats
}
return h.systemStats, h.containerStats, nil
}
// set system to specified status and save record
func (h *Hub) updateSystemStatus(record *models.Record, status string) {
if record.GetString("status") != status {

View File

@@ -32,7 +32,7 @@ type RecordDeletionData struct {
retention time.Duration
}
type RecordStats []*struct {
type RecordStats []struct {
Stats []byte `db:"stats"`
}
@@ -41,9 +41,9 @@ func NewRecordManager(app *pocketbase.PocketBase) *RecordManager {
}
// Create longer records by averaging shorter records
func (rm *RecordManager) CreateLongerRecords() {
func (rm *RecordManager) CreateLongerRecords(collections []*models.Collection) {
// start := time.Now()
recordData := []LongerRecordData{
longerRecordData := []LongerRecordData{
{
shorterType: "1m",
// change to 9 from 10 to allow edge case timing or short pauses
@@ -78,17 +78,11 @@ func (rm *RecordManager) CreateLongerRecords() {
return err
}
// need *models.Collection to create a new record with models.NewRecord
collections := map[string]*models.Collection{}
for _, collectionName := range []string{"system_stats", "container_stats"} {
collection, _ := txDao.FindCollectionByNameOrId(collectionName)
collections[collectionName] = collection
}
// loop through all active systems, time periods, and collections
for _, system := range activeSystems {
// log.Println("processing system", system.GetString("name"))
for _, recordData := range recordData {
for i := range longerRecordData {
recordData := longerRecordData[i]
// log.Println("processing longer record type", recordData.longerType)
// add one minute padding for longer records because they are created slightly later than the job start time
longerRecordPeriod := time.Now().UTC().Add(recordData.longerTimeDuration + time.Minute)
@@ -112,14 +106,6 @@ func (rm *RecordManager) CreateLongerRecords() {
// get shorter records from the past x minutes
var stats RecordStats
// allShorterRecords, err := txDao.FindRecordsByExpr(
// collection,
// dbx.NewExp(
// "type = {:type} AND system = {:system} AND created > {:created}",
// dbx.Params{"type": recordData.shorterType, "system": system.Id, "created": shorterRecordPeriod},
// ),
// )
err := txDao.DB().
Select("stats").
From(collection.Name).
@@ -173,8 +159,8 @@ func (rm *RecordManager) AverageSystemStats(records RecordStats) system.Stats {
tempCount := float64(0)
var stats system.Stats
for _, record := range records {
json.Unmarshal(record.Stats, &stats)
for i := range records {
json.Unmarshal(records[i].Stats, &stats)
sum.Cpu += stats.Cpu
sum.Mem += stats.Mem
sum.MemUsed += stats.MemUsed
@@ -276,13 +262,14 @@ func (rm *RecordManager) AverageContainerStats(records RecordStats) []container.
count := float64(len(records))
var containerStats []container.Stats
for _, record := range records {
for i := range records {
// Reset the slice length to 0, but keep the capacity
containerStats = containerStats[:0]
if err := json.Unmarshal(record.Stats, &containerStats); err != nil {
if err := json.Unmarshal(records[i].Stats, &containerStats); err != nil {
return []container.Stats{}
}
for _, stat := range containerStats {
for i := range containerStats {
stat := containerStats[i]
if _, ok := sums[stat.Name]; !ok {
sums[stat.Name] = &container.Stats{Name: stat.Name}
}

Binary file not shown.

View File

@@ -8,9 +8,11 @@
"name": "beszel",
"version": "0.0.0",
"dependencies": {
"@henrygd/queue": "^1.0.7",
"@nanostores/react": "^0.7.3",
"@nanostores/router": "^0.15.1",
"@nanostores/router": "^0.11.0",
"@radix-ui/react-alert-dialog": "^1.1.2",
"@radix-ui/react-checkbox": "^1.1.2",
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-label": "^2.1.0",
@@ -19,6 +21,7 @@
"@radix-ui/react-slider": "^1.2.1",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-switch": "^1.1.1",
"@radix-ui/react-tabs": "^1.1.1",
"@radix-ui/react-toast": "^1.2.2",
"@radix-ui/react-tooltip": "^1.1.3",
"@tanstack/react-table": "^8.20.5",
@@ -28,7 +31,7 @@
"cmdk": "^1.0.0",
"d3-time": "^3.1.0",
"lucide-react": "^0.452.0",
"nanostores": "^0.10.3",
"nanostores": "^0.11.3",
"pocketbase": "^0.21.5",
"react": "^18.3.1",
"react-dom": "^18.3.1",
@@ -730,6 +733,12 @@
"integrity": "sha512-dWO2pw8hhi+WrXq1YJy2yCuWoL20PddgGaqTgVe4cOS9Q6qklXCiA1tJEqX6BEwRNSCP84/afac9hd4MS+zEUA==",
"license": "MIT"
},
"node_modules/@henrygd/queue": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/@henrygd/queue/-/queue-1.0.7.tgz",
"integrity": "sha512-Jmt/iO6yDlz9UYGILkm/Qzi/ckkEiTNZcqDvt3QFLE4OThPeiCj6tKsynHFm/ppl8RumWXAx1dZPBPiRPaaGig==",
"license": "MIT"
},
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -815,9 +824,9 @@
}
},
"node_modules/@nanostores/router": {
"version": "0.15.1",
"resolved": "https://registry.npmjs.org/@nanostores/router/-/router-0.15.1.tgz",
"integrity": "sha512-uEDiHPjkHGSrqEE4NRuqOLepR6lFmfLQ90c4sLLvwSNiWTY3MxejkD7tALmpj9cN4fmLnqt54r51tCNEHJL+mw==",
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@nanostores/router/-/router-0.11.0.tgz",
"integrity": "sha512-QlcneDqXVIsQL3agOS59d9gJQ/9M3qyVOWVttARL5Xvpmovtq91oOYcQxKbLq9i7iitGs5yDJmabe/O3QjWddQ==",
"funding": [
{
"type": "github",
@@ -826,10 +835,10 @@
],
"license": "MIT",
"engines": {
"node": "^18.0.0 || >=20.0.0"
"node": "^16.0.0 || ^18.0.0 || >=20.0.0"
},
"peerDependencies": {
"nanostores": "^0.9.0 || ^0.10.0 || ^0.11.0"
"nanostores": "^0.9.0"
}
},
"node_modules/@nodelib/fs.scandir": {
@@ -955,6 +964,51 @@
}
}
},
"node_modules/@radix-ui/react-checkbox": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.1.2.tgz",
"integrity": "sha512-/i0fl686zaJbDQLNKrkCbMyDm6FQMt4jg323k7HuqitoANm9sE23Ql8yOK3Wusk34HSLKDChhMux05FnP6KUkw==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.0",
"@radix-ui/react-compose-refs": "1.1.0",
"@radix-ui/react-context": "1.1.1",
"@radix-ui/react-presence": "1.1.1",
"@radix-ui/react-primitive": "2.0.0",
"@radix-ui/react-use-controllable-state": "1.1.0",
"@radix-ui/react-use-previous": "1.1.0",
"@radix-ui/react-use-size": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-context": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz",
"integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-collection": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.0.tgz",
@@ -1609,6 +1663,51 @@
}
}
},
"node_modules/@radix-ui/react-tabs": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.1.tgz",
"integrity": "sha512-3GBUDmP2DvzmtYLMsHmpA1GtR46ZDZ+OreXM/N+kkQJOPIgytFWWTfDQmBQKBvaFS0Vno0FktdbVzN28KGrMdw==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.0",
"@radix-ui/react-context": "1.1.1",
"@radix-ui/react-direction": "1.1.0",
"@radix-ui/react-id": "1.1.0",
"@radix-ui/react-presence": "1.1.1",
"@radix-ui/react-primitive": "2.0.0",
"@radix-ui/react-roving-focus": "1.1.0",
"@radix-ui/react-use-controllable-state": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-context": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz",
"integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-toast": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.2.tgz",
@@ -3944,9 +4043,9 @@
}
},
"node_modules/nanostores": {
"version": "0.10.3",
"resolved": "https://registry.npmjs.org/nanostores/-/nanostores-0.10.3.tgz",
"integrity": "sha512-Nii8O1XqmawqSCf9o2aWqVxhKRN01+iue9/VEd1TiJCr9VT5XxgPFbF1Edl1XN6pwJcZRsl8Ki+z01yb/T/C2g==",
"version": "0.11.3",
"resolved": "https://registry.npmjs.org/nanostores/-/nanostores-0.11.3.tgz",
"integrity": "sha512-TUes3xKIX33re4QzdxwZ6tdbodjmn3tWXCEc1uokiEmo14sI1EaGYNs2k3bU2pyyGNmBqFGAVl6jAGWd06AVIg==",
"funding": [
{
"type": "github",

View File

@@ -9,9 +9,11 @@
"preview": "vite preview"
},
"dependencies": {
"@henrygd/queue": "^1.0.7",
"@nanostores/react": "^0.7.3",
"@nanostores/router": "^0.15.1",
"@nanostores/router": "^0.11.0",
"@radix-ui/react-alert-dialog": "^1.1.2",
"@radix-ui/react-checkbox": "^1.1.2",
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-label": "^2.1.0",
@@ -20,6 +22,7 @@
"@radix-ui/react-slider": "^1.2.1",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-switch": "^1.1.1",
"@radix-ui/react-tabs": "^1.1.1",
"@radix-ui/react-toast": "^1.2.2",
"@radix-ui/react-tooltip": "^1.1.3",
"@tanstack/react-table": "^8.20.5",
@@ -29,7 +32,7 @@
"cmdk": "^1.0.0",
"d3-time": "^3.1.0",
"lucide-react": "^0.452.0",
"nanostores": "^0.10.3",
"nanostores": "^0.11.3",
"pocketbase": "^0.21.5",
"react": "^18.3.1",
"react-dom": "^18.3.1",
@@ -47,5 +50,10 @@
"tailwindcss": "^3.4.14",
"typescript": "^5.6.3",
"vite": "^5.4.9"
},
"overrides": {
"@nanostores/router": {
"nanostores": "^0.11.3"
}
}
}

View File

@@ -0,0 +1,127 @@
import { memo, useState } from 'react'
import { useStore } from '@nanostores/react'
import { $alerts, $systems } from '@/lib/stores'
import {
Dialog,
DialogTrigger,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { BellIcon, GlobeIcon, ServerIcon } from 'lucide-react'
import { alertInfo, cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { AlertRecord, SystemRecord } from '@/types'
import { Link } from '../router'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Checkbox } from '../ui/checkbox'
import { SystemAlert, SystemAlertGlobal } from './alerts-system'
export default memo(function AlertsButton({ system }: { system: SystemRecord }) {
const alerts = useStore($alerts)
const [opened, setOpened] = useState(false)
const systemAlerts = alerts.filter((alert) => alert.system === system.id) as AlertRecord[]
const active = systemAlerts.length > 0
return (
<Dialog>
<DialogTrigger asChild>
<Button
variant="ghost"
size="icon"
aria-label="Alerts"
data-nolink
onClick={() => setOpened(true)}
>
<BellIcon
className={cn('h-[1.2em] w-[1.2em] pointer-events-none', {
'fill-primary': active,
})}
/>
</Button>
</DialogTrigger>
<DialogContent className="max-h-full overflow-auto max-w-[35rem]">
{opened && <TheContent data={{ system, alerts, systemAlerts }} />}
</DialogContent>
</Dialog>
)
})
function TheContent({
data: { system, alerts, systemAlerts },
}: {
data: { system: SystemRecord; alerts: AlertRecord[]; systemAlerts: AlertRecord[] }
}) {
const [overwriteExisting, setOverwriteExisting] = useState<boolean | 'indeterminate'>(false)
const systems = $systems.get()
const data = Object.keys(alertInfo).map((key) => {
const alert = alertInfo[key as keyof typeof alertInfo]
return {
key: key as keyof typeof alertInfo,
alert,
system,
}
})
return (
<>
<DialogHeader>
<DialogTitle className="text-xl">Alerts</DialogTitle>
<DialogDescription>
See{' '}
<Link href="/settings/notifications" className="link">
notification settings
</Link>{' '}
to configure how you receive alerts.
</DialogDescription>
</DialogHeader>
<Tabs defaultValue="system">
<TabsList className="mb-1 -mt-0.5">
<TabsTrigger value="system">
<ServerIcon className="mr-2 h-3.5 w-3.5" />
{system.name}
</TabsTrigger>
<TabsTrigger value="global">
<GlobeIcon className="mr-1.5 h-3.5 w-3.5" />
All systems
</TabsTrigger>
</TabsList>
<TabsContent value="system">
<div className="grid gap-3">
{data.map((d) => (
<SystemAlert key={d.key} system={system} data={d} systemAlerts={systemAlerts} />
))}
</div>
</TabsContent>
<TabsContent value="global">
<label
htmlFor="ovw"
className="mb-3 flex gap-2 items-center justify-center cursor-pointer border rounded-sm py-3 px-4 border-destructive text-destructive font-semibold text-sm"
>
<Checkbox
id="ovw"
className="text-destructive border-destructive data-[state=checked]:bg-destructive"
checked={overwriteExisting}
onCheckedChange={setOverwriteExisting}
/>
Overwrite existing alerts
</label>
<div className="grid gap-3">
{data.map((d) => (
<SystemAlertGlobal
key={d.key}
data={d}
overwrite={overwriteExisting}
alerts={alerts}
systems={systems}
/>
))}
</div>
</TabsContent>
</Tabs>
</>
)
}

View File

@@ -0,0 +1,246 @@
import { pb } from '@/lib/stores'
import { alertInfo, cn } from '@/lib/utils'
import { Switch } from '@/components/ui/switch'
import { AlertRecord, SystemRecord } from '@/types'
import { lazy, Suspense, useRef, useState } from 'react'
import { toast } from '../ui/use-toast'
import { RecordOptions } from 'pocketbase'
import { newQueue, Queue } from '@henrygd/queue'
interface AlertData {
checked?: boolean
val?: number
min?: number
updateAlert?: (checked: boolean, value: number, min: number) => void
key: keyof typeof alertInfo
alert: (typeof alertInfo)[keyof typeof alertInfo]
system: SystemRecord
}
const Slider = lazy(() => import('@/components/ui/slider'))
let queue: Queue
const failedUpdateToast = () =>
toast({
title: 'Failed to update alert',
description: 'Please check logs for more details.',
variant: 'destructive',
})
export function SystemAlert({
system,
systemAlerts,
data,
}: {
system: SystemRecord
systemAlerts: AlertRecord[]
data: AlertData
}) {
const alert = systemAlerts.find((alert) => alert.name === data.key)
data.updateAlert = async (checked: boolean, value: number, min: number) => {
try {
if (alert && !checked) {
await pb.collection('alerts').delete(alert.id)
} else if (alert && checked) {
await pb.collection('alerts').update(alert.id, { value, min, triggered: false })
} else if (checked) {
pb.collection('alerts').create({
system: system.id,
user: pb.authStore.model!.id,
name: data.key,
value: value,
min: min,
})
}
} catch (e) {
failedUpdateToast()
}
}
if (alert) {
data.checked = true
data.val = alert.value
data.min = alert.min || 1
}
return <AlertContent data={data} />
}
export function SystemAlertGlobal({
data,
overwrite,
alerts,
systems,
}: {
data: AlertData
overwrite: boolean | 'indeterminate'
alerts: AlertRecord[]
systems: SystemRecord[]
}) {
const systemsWithExistingAlerts = useRef<{ set: Set<string>; populatedSet: boolean }>({
set: new Set(),
populatedSet: false,
})
data.checked = false
data.val = data.min = 0
data.updateAlert = (checked: boolean, value: number, min: number) => {
if (!queue) {
queue = newQueue(5)
}
const { set, populatedSet } = systemsWithExistingAlerts.current
// if overwrite checked, make sure all alerts will be overwritten
if (overwrite) {
set.clear()
}
const recordData: Partial<AlertRecord> = {
value,
min,
triggered: false,
}
for (let system of systems) {
// if overwrite is false and system is in set (alert existed), skip
if (!overwrite && set.has(system.id)) {
continue
}
// find matching existing alert
const existingAlert = alerts.find(
(alert) => alert.system === system.id && data.key === alert.name
)
// if first run, add system to set (alert already existed when global panel was opened)
if (existingAlert && !populatedSet && !overwrite) {
set.add(system.id)
continue
}
const requestOptions: RecordOptions = {
requestKey: system.id,
}
// checked - make sure alert is created or updated
if (checked) {
if (existingAlert) {
// console.log('updating', system.name)
queue
.add(() => pb.collection('alerts').update(existingAlert.id, recordData, requestOptions))
.catch(failedUpdateToast)
} else {
// console.log('creating', system.name)
queue
.add(() =>
pb.collection('alerts').create(
{
system: system.id,
user: pb.authStore.model!.id,
name: data.key,
...recordData,
},
requestOptions
)
)
.catch(failedUpdateToast)
}
} else if (existingAlert) {
// console.log('deleting', system.name)
queue.add(() => pb.collection('alerts').delete(existingAlert.id)).catch(failedUpdateToast)
}
}
systemsWithExistingAlerts.current.populatedSet = true
}
return <AlertContent data={data} />
}
function AlertContent({ data }: { data: AlertData }) {
const { key } = data
const hasSliders = !('single' in data.alert)
const [checked, setChecked] = useState(data.checked || false)
const [min, setMin] = useState(data.min || (hasSliders ? 10 : 0))
const [value, setValue] = useState(data.val || (hasSliders ? 80 : 0))
const showSliders = checked && hasSliders
const newMin = useRef(min)
const newValue = useRef(value)
const Icon = alertInfo[key].icon
const updateAlert = (c?: boolean) =>
data.updateAlert?.(c ?? checked, newValue.current, newMin.current)
return (
<div className="rounded-lg border border-muted-foreground/15 hover:border-muted-foreground/20 transition-colors duration-100 group">
<label
htmlFor={`s${key}`}
className={cn('flex flex-row items-center justify-between gap-4 cursor-pointer p-4', {
'pb-0': showSliders,
})}
>
<div className="grid gap-1 select-none">
<p className="font-semibold flex gap-3 items-center capitalize">
<Icon className="h-4 w-4 opacity-85" /> {data.alert.name}
</p>
{!showSliders && (
<span className="block text-sm text-muted-foreground">{data.alert.desc}</span>
)}
</div>
<Switch
id={`s${key}`}
checked={checked}
onCheckedChange={(checked) => {
setChecked(checked)
updateAlert(checked)
}}
/>
</label>
{showSliders && (
<div className="grid sm:grid-cols-2 mt-1.5 gap-5 px-4 pb-5 tabular-nums text-muted-foreground">
<Suspense fallback={<div className="h-10" />}>
<div>
<p id={`v${key}`} className="text-sm block h-8">
Average exceeds{' '}
<strong className="text-foreground">
{value}
{data.alert.unit}
</strong>
</p>
<div className="flex gap-3">
<Slider
aria-labelledby={`v${key}`}
defaultValue={[value]}
onValueCommit={(val) => (newValue.current = val[0]) && updateAlert()}
onValueChange={(val) => setValue(val[0])}
min={1}
max={99}
/>
</div>
</div>
<div>
<p id={`t${key}`} className="text-sm block h-8">
For <strong className="text-foreground">{min}</strong> minute
{min > 1 && 's'}
</p>
<div className="flex gap-3">
<Slider
aria-labelledby={`v${key}`}
defaultValue={[min]}
onValueCommit={(val) => (newMin.current = val[0]) && updateAlert()}
onValueChange={(val) => setMin(val[0])}
min={1}
max={60}
/>
</div>
</div>
</Suspense>
</div>
)}
</div>
)
}

View File

@@ -1,9 +1,8 @@
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
import { Area, AreaChart, CartesianGrid, YAxis } from 'recharts'
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'
import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from '@/components/ui/chart'
import {
useYAxisWidth,
chartTimeData,
cn,
formatShortDate,
toFixedWithoutTrailingZeros,
@@ -88,18 +87,7 @@ export default memo(function AreaChartDefault({
tickLine={false}
axisLine={false}
/>
<XAxis
dataKey="created"
domain={chartData.domain}
ticks={chartData.ticks}
allowDataOverflow
type="number"
scale="time"
minTickGap={30}
tickMargin={8}
axisLine={false}
tickFormatter={chartTimeData[chartTime].format}
/>
{xAxis(chartData)}
<ChartTooltip
animationEasing="ease-out"
animationDuration={150}

View File

@@ -1,14 +1,14 @@
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
import { Area, AreaChart, CartesianGrid, YAxis } from 'recharts'
import {
ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
xAxis,
} from '@/components/ui/chart'
import { memo, useMemo } from 'react'
import {
useYAxisWidth,
chartTimeData,
cn,
formatShortDate,
decimalString,
@@ -37,7 +37,7 @@ export default memo(function ContainerChart({
const filter = useStore($containerFilter)
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
const { containerData, ticks, domain, chartTime } = chartData
const { containerData } = chartData
const isNetChart = chartName === 'net'
@@ -153,18 +153,7 @@ export default memo(function ContainerChart({
tickLine={false}
axisLine={false}
/>
<XAxis
dataKey="created"
domain={domain}
allowDataOverflow
ticks={ticks}
type="number"
scale="time"
minTickGap={35}
tickMargin={8}
axisLine={false}
tickFormatter={chartTimeData[chartTime].format}
/>
{xAxis(chartData)}
<ChartTooltip
animationEasing="ease-out"
animationDuration={150}

View File

@@ -1,9 +1,8 @@
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
import { Area, AreaChart, CartesianGrid, YAxis } from 'recharts'
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'
import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from '@/components/ui/chart'
import {
useYAxisWidth,
chartTimeData,
cn,
formatShortDate,
decimalString,
@@ -25,8 +24,6 @@ export default memo(function DiskChart({
}) {
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
// console.log('rendered at', new Date())
return (
<div>
<ChartContainer
@@ -49,18 +46,7 @@ export default memo(function DiskChart({
return updateYAxisWidth(toFixedFloat(v, 2) + u)
}}
/>
<XAxis
dataKey="created"
domain={chartData.domain}
ticks={chartData.ticks}
allowDataOverflow
type="number"
scale="time"
minTickGap={30}
tickMargin={8}
axisLine={false}
tickFormatter={chartTimeData[chartData.chartTime].format}
/>
{xAxis(chartData)}
<ChartTooltip
animationEasing="ease-out"
animationDuration={150}

View File

@@ -1,9 +1,8 @@
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
import { Area, AreaChart, CartesianGrid, YAxis } from 'recharts'
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'
import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from '@/components/ui/chart'
import {
useYAxisWidth,
chartTimeData,
cn,
toFixedFloat,
decimalString,
@@ -45,18 +44,7 @@ export default memo(function MemChart({ chartData }: { chartData: ChartData }) {
}}
/>
)}
<XAxis
dataKey="created"
domain={chartData.domain}
ticks={chartData.ticks}
allowDataOverflow
type="number"
scale="time"
minTickGap={30}
tickMargin={8}
axisLine={false}
tickFormatter={chartTimeData[chartData.chartTime].format}
/>
{xAxis(chartData)}
<ChartTooltip
// cursor={false}
animationEasing="ease-out"

View File

@@ -1,9 +1,8 @@
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
import { Area, AreaChart, CartesianGrid, YAxis } from 'recharts'
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'
import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from '@/components/ui/chart'
import {
useYAxisWidth,
chartTimeData,
cn,
formatShortDate,
toFixedWithoutTrailingZeros,
@@ -36,18 +35,7 @@ export default memo(function SwapChart({ chartData }: { chartData: ChartData })
axisLine={false}
tickFormatter={(value) => updateYAxisWidth(value + ' GB')}
/>
<XAxis
dataKey="created"
domain={chartData.domain}
ticks={chartData.ticks}
allowDataOverflow
type="number"
scale="time"
minTickGap={30}
tickMargin={8}
axisLine={false}
tickFormatter={chartTimeData[chartData.chartTime].format}
/>
{xAxis(chartData)}
<ChartTooltip
animationEasing="ease-out"
animationDuration={150}

View File

@@ -1,4 +1,4 @@
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from 'recharts'
import { CartesianGrid, Line, LineChart, YAxis } from 'recharts'
import {
ChartContainer,
@@ -6,10 +6,10 @@ import {
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
xAxis,
} from '@/components/ui/chart'
import {
useYAxisWidth,
chartTimeData,
cn,
formatShortDate,
toFixedWithoutTrailingZeros,
@@ -70,18 +70,7 @@ export default memo(function TemperatureChart({ chartData }: { chartData: ChartD
tickLine={false}
axisLine={false}
/>
<XAxis
dataKey="created"
domain={chartData.domain}
ticks={chartData.ticks}
allowDataOverflow
type="number"
scale="time"
minTickGap={30}
tickMargin={8}
axisLine={false}
tickFormatter={chartTimeData[chartData.chartTime].format}
/>
{xAxis(chartData)}
<ChartTooltip
animationEasing="ease-out"
animationDuration={150}

View File

@@ -43,7 +43,7 @@ export default function () {
}
return (
<div className="min-h-screen grid items-center py-12">
<div className="min-h-svh grid items-center py-12">
<div className="grid gap-5 w-full px-4 mx-auto" style={{ maxWidth: '22em' }}>
<div className="text-center">
<h1 className="mb-3">

View File

@@ -72,7 +72,7 @@ export default function () {
className="hover:-translate-y-[1px] duration-200 bg-transparent border-foreground/10 hover:shadow-md shadow-black"
>
<info.icon className="h-4 w-4" />
<AlertTitle className="mb-2">
<AlertTitle>
{alert.sysname} {info.name}
</AlertTitle>
<AlertDescription>

View File

@@ -0,0 +1,93 @@
import { isAdmin } from '@/lib/utils'
import { Separator } from '@/components/ui/separator'
import { Button } from '@/components/ui/button'
import { redirectPage } from '@nanostores/router'
import { $router } from '@/components/router'
import { AlertCircleIcon, FileSlidersIcon, LoaderCircleIcon } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { pb } from '@/lib/stores'
import { useState } from 'react'
import { Textarea } from '@/components/ui/textarea'
import { toast } from '@/components/ui/use-toast'
import clsx from 'clsx'
export default function ConfigYaml() {
const [configContent, setConfigContent] = useState<string>('')
const [isLoading, setIsLoading] = useState(false)
const ButtonIcon = isLoading ? LoaderCircleIcon : FileSlidersIcon
async function fetchConfig() {
try {
setIsLoading(true)
const { config } = await pb.send<{ config: string }>('/api/beszel/config-yaml', {})
setConfigContent(config)
} catch (error: any) {
toast({
title: 'Error',
description: error.message,
variant: 'destructive',
})
} finally {
setIsLoading(false)
}
}
if (!isAdmin()) {
redirectPage($router, 'settings', { name: 'general' })
}
return (
<div>
<div>
<h3 className="text-xl font-medium mb-2">YAML Configuration</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
Export your current systems configuration.
</p>
</div>
<Separator className="my-4" />
<div className="space-y-2">
<div className="mb-4">
<p className="text-sm text-muted-foreground leading-relaxed my-1">
Systems may be managed in a{' '}
<code className="bg-muted rounded-sm px-1 text-primary">config.yml</code> file inside
your data directory.
</p>
<p className="text-sm text-muted-foreground leading-relaxed">
On each restart, systems in the database will be updated to match the systems defined in
the file.
</p>
<Alert className="my-4 border-destructive text-destructive w-auto table md:pr-6">
<AlertCircleIcon className="h-4 w-4 stroke-destructive" />
<AlertTitle>Caution - potential data loss</AlertTitle>
<AlertDescription>
<p>
Existing systems not defined in <code>config.yml</code> will be deleted. Please make
regular backups.
</p>
</AlertDescription>
</Alert>
</div>
{configContent && (
<Textarea
autoFocus
defaultValue={configContent}
spellCheck="false"
rows={Math.min(25, configContent.split('\n').length)}
className="font-mono whitespace-pre"
/>
)}
</div>
<Separator className="my-5" />
<Button
type="button"
className="mt-2 flex items-center gap-1"
onClick={fetchConfig}
disabled={isLoading}
>
<ButtonIcon className={clsx('h-4 w-4 mr-0.5', isLoading && 'animate-spin')} />
Export configuration
</Button>
</div>
)
}

View File

@@ -5,12 +5,14 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { useStore } from '@nanostores/react'
import { $router } from '@/components/router.tsx'
import { redirectPage } from '@nanostores/router'
import { BellIcon, SettingsIcon } from 'lucide-react'
import { BellIcon, FileSlidersIcon, 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'
import ConfigYaml from './config-yaml.tsx'
import { isAdmin } from '@/lib/utils.ts'
const sidebarNavItems = [
{
@@ -25,6 +27,14 @@ const sidebarNavItems = [
},
]
if (isAdmin()) {
sidebarNavItems.push({
title: 'YAML Config',
href: '/settings/config',
icon: FileSlidersIcon,
})
}
export async function saveSettings(newSettings: Partial<UserSettings>) {
try {
// get fresh copy of settings
@@ -94,5 +104,7 @@ function SettingsContent({ name }: { name: string }) {
return <General userSettings={userSettings} />
case 'notifications':
return <Notifications userSettings={userSettings} />
case 'config':
return <ConfigYaml />
}
}

View File

@@ -6,7 +6,7 @@ import {
SystemRecord,
SystemStatsRecord,
} from '@/types'
import React, { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import React, { lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Card, CardHeader, CardTitle, CardDescription } from '../ui/card'
import { useStore } from '@nanostores/react'
import Spinner from '../spinner'
@@ -155,7 +155,7 @@ export default function SystemDetail({ name }: { name: string }) {
return () => {
pb.collection('systems').unsubscribe(system.id)
}
}, [system])
}, [system.id])
const chartData: ChartData = useMemo(() => {
const lastCreated = Math.max(
@@ -612,7 +612,7 @@ function ChartCard({
</CardHeader>
<div className="pl-0 w-[calc(100%-1.6em)] h-52 relative">
{<Spinner />}
{isIntersecting && <Suspense>{children}</Suspense>}
{isIntersecting && children}
</div>
</Card>
)

View File

@@ -60,7 +60,7 @@ import { useEffect, useMemo, useState } from 'react'
import { $hubVersion, $systems, pb } from '@/lib/stores'
import { useStore } from '@nanostores/react'
import { cn, copyToClipboard, decimalString, isReadOnlyUser } from '@/lib/utils'
import AlertsButton from '../table-alerts'
import AlertsButton from '../alerts/alert-button'
import { navigate } from '../router'
import { EthernetIcon } from '../ui/icons'
@@ -260,7 +260,7 @@ export default function SystemsTable({ filter }: { filter?: string }) {
<AlertDialogTitle>Are you sure you want to delete {name}?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete all current records
for <code className={'bg-muted rounded-sm px-1'}>{name}</code> from the
for <code className="bg-muted rounded-sm px-1">{name}</code> from the
database.
</AlertDialogDescription>
</AlertDialogHeader>

View File

@@ -1,273 +0,0 @@
import { $alerts, pb } from '@/lib/stores'
import { useStore } from '@nanostores/react'
import {
Dialog,
DialogTrigger,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { BellIcon, ServerIcon } from 'lucide-react'
import { alertInfo, cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
import { AlertRecord, SystemRecord } from '@/types'
import { lazy, Suspense, useMemo, useState } from 'react'
import { toast } from './ui/use-toast'
import { Link } from './router'
const Slider = lazy(() => import('./ui/slider'))
const failedUpdateToast = () =>
toast({
title: 'Failed to update alert',
description: 'Please check logs for more details.',
variant: 'destructive',
})
export default function AlertsButton({ system }: { system: SystemRecord }) {
const alerts = useStore($alerts)
const [opened, setOpened] = useState(false)
const systemAlerts = alerts.filter((alert) => alert.system === system.id) as AlertRecord[]
const active = systemAlerts.length > 0
return (
<Dialog>
<DialogTrigger asChild>
<Button
variant="ghost"
size={'icon'}
aria-label="Alerts"
data-nolink
onClick={() => setOpened(true)}
>
<BellIcon
className={cn('h-[1.2em] w-[1.2em] pointer-events-none', {
'fill-foreground': active,
})}
/>
</Button>
</DialogTrigger>
<DialogContent
className="max-h-full overflow-auto max-w-[35rem]"
// onCloseAutoFocus={() => setOpened(false)}
>
{opened && (
<>
<DialogHeader>
<DialogTitle className="text-xl">{system.name} alerts</DialogTitle>
<DialogDescription className="mb-1">
See{' '}
<Link href="/settings/notifications" className="link">
notification settings
</Link>{' '}
to configure how you receive alerts.
</DialogDescription>
</DialogHeader>
<div className="grid gap-3">
<AlertStatus system={system} alerts={systemAlerts} />
{Object.keys(alertInfo).map((key) => {
const alert = alertInfo[key as keyof typeof alertInfo]
return (
<AlertWithSlider
key={key}
system={system}
alerts={systemAlerts}
name={key}
title={alert.name}
description={alert.desc}
unit={alert.unit}
Icon={alert.icon}
/>
)
})}
</div>
</>
)}
</DialogContent>
</Dialog>
)
}
function AlertStatus({ system, alerts }: { system: SystemRecord; alerts: AlertRecord[] }) {
const [pendingChange, setPendingChange] = useState(false)
const alert = alerts.find((alert) => alert.name === 'Status')
return (
<label
htmlFor="alert-status"
className="flex flex-row items-center justify-between gap-4 rounded-lg border border-muted-foreground/15 hover:border-muted-foreground/20 transition-colors duration-100 p-4 cursor-pointer"
>
<div className="grid gap-1 select-none">
<p className="font-semibold flex gap-3 items-center">
<ServerIcon className="h-4 w-4 opacity-85" /> System status
</p>
<span className="block text-sm text-muted-foreground">
Triggers when status switches between up and down.
</span>
</div>
<Switch
id="alert-status"
className={cn('transition-opacity', pendingChange && 'opacity-40')}
checked={!!alert}
value={!!alert ? 'on' : 'off'}
onCheckedChange={async (active) => {
if (pendingChange) {
return
}
setPendingChange(true)
try {
if (!active && alert) {
await pb.collection('alerts').delete(alert.id)
} else if (active) {
pb.collection('alerts').create({
system: system.id,
user: pb.authStore.model!.id,
name: 'Status',
})
}
} catch (e) {
failedUpdateToast()
} finally {
setPendingChange(false)
}
}}
/>
</label>
)
}
function AlertWithSlider({
system,
alerts,
name,
title,
description,
unit = '%',
max = 99,
Icon,
}: {
system: SystemRecord
alerts: AlertRecord[]
name: string
title: string
description: string
unit?: string
max?: number
Icon: React.FC<React.SVGProps<SVGSVGElement>>
}) {
const [pendingChange, setPendingChange] = useState(false)
const [liveValue, setLiveValue] = useState(80)
const [liveMinutes, setLiveMinutes] = useState(10)
const key = name.replaceAll(' ', '-')
const alert = useMemo(() => {
const alert = alerts.find((alert) => alert.name === name)
if (alert) {
setLiveValue(alert.value)
setLiveMinutes(alert.min || 1)
}
return alert
}, [alerts])
return (
<div className="rounded-lg border border-muted-foreground/15 hover:border-muted-foreground/20 transition-colors duration-100 group">
<label
htmlFor={`s${key}`}
className={cn('flex flex-row items-center justify-between gap-4 cursor-pointer p-4', {
'pb-0': !!alert,
})}
>
<div className="grid gap-1 select-none">
<p className="font-semibold flex gap-3 items-center">
<Icon className="h-4 w-4 opacity-85" /> {title}
</p>
{!alert && <span className="block text-sm text-muted-foreground">{description}</span>}
</div>
<Switch
id={`s${key}`}
className={cn('transition-opacity', pendingChange && 'opacity-40')}
checked={!!alert}
value={!!alert ? 'on' : 'off'}
onCheckedChange={async (active) => {
if (pendingChange) {
return
}
setPendingChange(true)
try {
if (!active && alert) {
await pb.collection('alerts').delete(alert.id)
} else if (active) {
pb.collection('alerts').create({
system: system.id,
user: pb.authStore.model!.id,
name,
value: liveValue,
min: liveMinutes,
})
}
} catch (e) {
failedUpdateToast()
} finally {
setPendingChange(false)
}
}}
/>
</label>
{alert && (
<div className="grid sm:grid-cols-2 mt-1.5 gap-5 px-4 pb-5 tabular-nums text-muted-foreground">
<Suspense fallback={<div className="h-10" />}>
<div>
<p id={`v${key}`} className="text-sm block h-8">
Average exceeds{' '}
<strong className="text-foreground">
{liveValue}
{unit}
</strong>
</p>
<div className="flex gap-3">
<Slider
aria-labelledby={`v${key}`}
defaultValue={[liveValue]}
onValueCommit={(val) => {
pb.collection('alerts').update(alert.id, {
value: val[0],
})
}}
onValueChange={(val) => setLiveValue(val[0])}
min={1}
max={max}
/>
</div>
</div>
<div>
<p id={`t${key}`} className="text-sm block h-8">
For <strong className="text-foreground">{liveMinutes}</strong> minute
{liveMinutes > 1 && 's'}
</p>
<div className="flex gap-3">
<Slider
aria-labelledby={`v${key}`}
defaultValue={[liveMinutes]}
onValueCommit={(val) => {
pb.collection('alerts').update(alert.id, {
min: val[0],
})
}}
onValueChange={(val) => setLiveMinutes(val[0])}
min={1}
max={60}
/>
</div>
</div>
</Suspense>
</div>
)}
</div>
)
}

View File

@@ -1,59 +1,59 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import * as React from 'react'
// import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils'
const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
// const alertVariants = cva(
// "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
// {
// variants: {
// variant: {
// default: "bg-background text-foreground",
// destructive:
// "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
// },
// },
// defaultVariants: {
// variant: "default",
// },
// }
// )
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
HTMLDivElement,
// React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
// >(({ className, variant, ...props }, ref) => (
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
<div
ref={ref}
role="alert"
className={cn(
'relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground bg-background text-foreground',
className
)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
Alert.displayName = 'Alert'
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn('mb-1 -mt-0.5 font-medium leading-tight tracking-tight', className)}
{...props}
/>
)
)
AlertTitle.displayName = 'AlertTitle'
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
<div ref={ref} className={cn('text-sm [&_p]:leading-relaxed', className)} {...props} />
))
AlertDescription.displayName = "AlertDescription"
AlertDescription.displayName = 'AlertDescription'
export { Alert, AlertTitle, AlertDescription }

View File

@@ -1,7 +1,8 @@
import * as React from 'react'
import * as RechartsPrimitive from 'recharts'
import { cn } from '@/lib/utils'
import { chartTimeData, cn } from '@/lib/utils'
import { ChartData } from '@/types'
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: '', dark: '.dark' } as const
@@ -331,11 +332,34 @@ function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key:
return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config]
}
let cachedAxis: JSX.Element
const xAxis = function ({ domain, ticks, chartTime }: ChartData) {
if (cachedAxis && domain[0] === cachedAxis.props.domain[0]) {
return cachedAxis
}
cachedAxis = (
<RechartsPrimitive.XAxis
dataKey="created"
domain={domain}
ticks={ticks}
allowDataOverflow
type="number"
scale="time"
minTickGap={15}
tickMargin={8}
axisLine={false}
tickFormatter={chartTimeData[chartTime].format}
/>
)
return cachedAxis
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
xAxis,
// ChartStyle,
}

View File

@@ -0,0 +1,26 @@
import * as React from 'react'
import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
import { Check } from 'lucide-react'
import { cn } from '@/lib/utils'
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
'peer h-4 w-4 shrink-0 rounded-[.3em] border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
className
)}
{...props}
>
<CheckboxPrimitive.Indicator className={cn('flex items-center justify-center text-current')}>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }

View File

@@ -0,0 +1,53 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -1,112 +1,107 @@
import * as React from "react"
import * as ToastPrimitives from "@radix-ui/react-toast"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import * as React from 'react'
import * as ToastPrimitives from '@radix-ui/react-toast'
import { cva, type VariantProps } from 'class-variance-authority'
import { X } from 'lucide-react'
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils'
const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className
)}
{...props}
/>
<ToastPrimitives.Viewport
ref={ref}
className={cn(
'fixed top-0 z-[100] flex max-h-dvh w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]',
className
)}
{...props}
/>
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
'group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full',
{
variants: {
variant: {
default: 'border bg-background text-foreground',
destructive:
'destructive group border-destructive bg-destructive text-destructive-foreground',
},
},
defaultVariants: {
variant: 'default',
},
}
)
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
)
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
)
})
Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className
)}
{...props}
/>
<ToastPrimitives.Action
ref={ref}
className={cn(
'inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive',
className
)}
{...props}
/>
))
ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
<ToastPrimitives.Close
ref={ref}
className={cn(
'absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600',
className
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
))
ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold", className)}
{...props}
/>
<ToastPrimitives.Title ref={ref} className={cn('text-sm font-semibold', className)} {...props} />
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
<ToastPrimitives.Description
ref={ref}
className={cn('text-sm opacity-90', className)}
{...props}
/>
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
@@ -115,13 +110,13 @@ type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
type ToastActionElement = React.ReactElement<typeof ToastAction>
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
}

View File

@@ -48,7 +48,7 @@
--muted-foreground: 240 5.03% 64.9%;
--accent: 240 3.7% 15.88%;
--accent-foreground: 0 0% 98.04%;
--destructive: 0 56.48% 42.35%;
--destructive: 0 59% 46%;
--destructive-foreground: 0 0% 98.04%;
--border: 240 2.86% 12%;
--input: 240 3.7% 15.88%;

View File

@@ -7,7 +7,7 @@ import { RecordModel, RecordSubscription } from 'pocketbase'
import { WritableAtom } from 'nanostores'
import { timeDay, timeHour } from 'd3-time'
import { useEffect, useState } from 'react'
import { CpuIcon, HardDriveIcon, MemoryStickIcon } from 'lucide-react'
import { CpuIcon, HardDriveIcon, MemoryStickIcon, ServerIcon } from 'lucide-react'
import { EthernetIcon, ThermometerIcon } from '@/components/ui/icons'
export function cn(...inputs: ClassValue[]) {
@@ -102,8 +102,9 @@ export const formatDay = (timestamp: string) => {
return dayFormatter.format(new Date(timestamp))
}
export const updateFavicon = (newIcon: string) =>
((document.querySelector("link[rel='icon']") as HTMLLinkElement).href = `/static/${newIcon}`)
export const updateFavicon = (newIcon: string) => {
;(document.querySelector("link[rel='icon']") as HTMLLinkElement).href = `/static/${newIcon}`
}
export const isAdmin = () => pb.authStore.model?.role === 'admin'
export const isReadOnlyUser = () => pb.authStore.model?.role === 'readonly'
@@ -299,6 +300,13 @@ export const getSizeAndUnit = (n: number, isGigabytes = true) => {
export const chartMargin = { top: 12 }
export const alertInfo = {
Status: {
name: 'Status',
unit: '',
icon: ServerIcon,
desc: 'Triggers when status switches between up and down.',
single: true,
},
CPU: {
name: 'CPU usage',
unit: '%',
@@ -306,25 +314,25 @@ export const alertInfo = {
desc: 'Triggers when CPU usage exceeds a threshold.',
},
Memory: {
name: 'Memory usage',
name: 'memory usage',
unit: '%',
icon: MemoryStickIcon,
desc: 'Triggers when memory usage exceeds a threshold.',
},
Disk: {
name: 'Disk usage',
name: 'disk usage',
unit: '%',
icon: HardDriveIcon,
desc: 'Triggers when usage of any disk exceeds a threshold.',
},
Bandwidth: {
name: 'Bandwidth',
name: 'bandwidth',
unit: ' MB/s',
icon: EthernetIcon,
desc: 'Triggers when combined up/down exceeds a threshold.',
},
Temperature: {
name: 'Temperature',
name: 'temperature',
unit: '°C',
icon: ThermometerIcon,
desc: 'Triggers when any sensor exceeds a threshold.',

View File

@@ -73,29 +73,27 @@ const App = () => {
updateUserSettings()
// get alerts after system list is loaded
updateSystemList().then(updateAlerts)
return () => updateFavicon('favicon.svg')
}, [])
// update favicon
useEffect(() => {
if (!authenticated || !systems.length) {
if (!systems.length || !authenticated) {
updateFavicon('favicon.svg')
} else {
let up = false
for (const system of systems) {
if (system.status === 'down') {
updateFavicon('favicon-red.svg')
return () => updateFavicon('favicon.svg')
return
} else if (system.status === 'up') {
up = true
}
}
updateFavicon(up ? 'favicon-green.svg' : 'favicon.svg')
return () => updateFavicon('favicon.svg')
}
return () => {
updateFavicon('favicon.svg')
}
}, [authenticated, systems])
}, [systems])
if (!page) {
return <h1 className="text-3xl text-center my-14">404</h1>

View File

@@ -1,6 +1,6 @@
package beszel
const (
Version = "0.6.0"
Version = "0.6.2"
AppName = "beszel"
)