mirror of
https://github.com/henrygd/beszel.git
synced 2025-11-15 09:36:09 +00:00
Compare commits
48 Commits
built-in-a
...
v0.6.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f4dcb09ea | ||
|
|
6de5dce176 | ||
|
|
b5c158d1b3 | ||
|
|
7f01d1ec7e | ||
|
|
8bf7a0e1d6 | ||
|
|
140fd93ec9 | ||
|
|
bdcb34c989 | ||
|
|
aaaa86b147 | ||
|
|
6e9b84c6c7 | ||
|
|
cce241caa4 | ||
|
|
1e9787c4d7 | ||
|
|
71aa9946f5 | ||
|
|
12239808fc | ||
|
|
94e9d4f270 | ||
|
|
34a8053967 | ||
|
|
ee92e338cb | ||
|
|
1a3ad04e03 | ||
|
|
9c061774a3 | ||
|
|
3336b0a7d9 | ||
|
|
f034eed431 | ||
|
|
6b6d3fabc0 | ||
|
|
59d541dd1d | ||
|
|
abff85d61e | ||
|
|
02641ec007 | ||
|
|
92179cbbb2 | ||
|
|
299152413a | ||
|
|
703a3c41c9 | ||
|
|
31d1153916 | ||
|
|
c1577d3ba5 | ||
|
|
c4400eb0a3 | ||
|
|
a57498f8f7 | ||
|
|
1b0dffc1ab | ||
|
|
bea37d62b4 | ||
|
|
d53b6be5b9 | ||
|
|
6c31263e60 | ||
|
|
b464fa5b3f | ||
|
|
c0a3bbeefc | ||
|
|
10d348c052 | ||
|
|
6cf6661f2e | ||
|
|
23ab1208cd | ||
|
|
5b0fac429b | ||
|
|
efca56ceca | ||
|
|
64f0a23969 | ||
|
|
4245da7792 | ||
|
|
cedf80a869 | ||
|
|
76cea9d3c3 | ||
|
|
10ef430826 | ||
|
|
d672017af0 |
@@ -3,7 +3,6 @@ package main
|
||||
import (
|
||||
"beszel"
|
||||
"beszel/internal/agent"
|
||||
"beszel/internal/update"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
@@ -17,7 +16,7 @@ func main() {
|
||||
case "-v":
|
||||
fmt.Println(beszel.AppName+"-agent", beszel.Version)
|
||||
case "update":
|
||||
update.UpdateBeszelAgent()
|
||||
agent.Update()
|
||||
}
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package main
|
||||
import (
|
||||
"beszel"
|
||||
"beszel/internal/hub"
|
||||
"beszel/internal/update"
|
||||
_ "beszel/migrations"
|
||||
|
||||
"github.com/pocketbase/pocketbase"
|
||||
@@ -22,7 +21,7 @@ func main() {
|
||||
app.RootCmd.AddCommand(&cobra.Command{
|
||||
Use: "update",
|
||||
Short: "Update " + beszel.AppName + " to the latest version",
|
||||
Run: func(_ *cobra.Command, _ []string) { update.UpdateBeszel() },
|
||||
Run: hub.Update,
|
||||
})
|
||||
|
||||
hub.NewHub(app).Run()
|
||||
|
||||
@@ -9,43 +9,44 @@ 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
|
||||
)
|
||||
|
||||
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 +63,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 +78,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
|
||||
|
||||
152
beszel/go.sum
152
beszel/go.sum
@@ -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,8 +395,8 @@ 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/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||
@@ -416,8 +416,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=
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"beszel"
|
||||
"beszel/internal/entities/system"
|
||||
"context"
|
||||
"log/slog"
|
||||
@@ -29,6 +30,7 @@ func NewAgent() *Agent {
|
||||
return &Agent{
|
||||
sensorsContext: context.Background(),
|
||||
memCalc: os.Getenv("MEM_CALC"),
|
||||
fsStats: make(map[string]*system.FsStats),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,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)
|
||||
@@ -77,13 +81,16 @@ func (a *Agent) Run(pubKey []byte, addr string) {
|
||||
}
|
||||
|
||||
func (a *Agent) gatherStats() system.CombinedData {
|
||||
slog.Debug("Getting stats")
|
||||
systemData := system.CombinedData{
|
||||
Stats: a.getSystemStats(),
|
||||
Info: a.systemInfo,
|
||||
}
|
||||
slog.Debug("System stats", "data", systemData)
|
||||
// add docker stats
|
||||
if containerStats, err := a.dockerManager.getDockerStats(); err == nil {
|
||||
systemData.Containers = containerStats
|
||||
slog.Debug("Docker stats", "data", systemData.Containers)
|
||||
} else {
|
||||
slog.Debug("Error getting docker stats", "err", err)
|
||||
}
|
||||
@@ -94,5 +101,6 @@ func (a *Agent) gatherStats() system.CombinedData {
|
||||
systemData.Stats.ExtraFs[name] = stats
|
||||
}
|
||||
}
|
||||
slog.Debug("Extra filesystems", "data", systemData.Stats.ExtraFs)
|
||||
return systemData
|
||||
}
|
||||
|
||||
@@ -18,9 +18,6 @@ func (a *Agent) initializeDiskInfo() {
|
||||
efPath := "/extra-filesystems"
|
||||
hasRoot := false
|
||||
|
||||
// Create map for disk stats
|
||||
a.fsStats = make(map[string]*system.FsStats)
|
||||
|
||||
partitions, err := disk.Partitions(false)
|
||||
if err != nil {
|
||||
slog.Error("Error getting disk partitions", "err", err)
|
||||
@@ -47,7 +44,7 @@ func (a *Agent) initializeDiskInfo() {
|
||||
// check if root device is in /proc/diskstats, use fallback if not
|
||||
if _, exists := diskIoCounters[key]; !exists {
|
||||
slog.Warn("Device not found in diskstats", "name", key)
|
||||
key = findFallbackIoDevice(filesystem, diskIoCounters)
|
||||
key = findFallbackIoDevice(filesystem, diskIoCounters, a.fsStats)
|
||||
slog.Info("Using I/O fallback", "name", key)
|
||||
}
|
||||
}
|
||||
@@ -125,7 +122,7 @@ func (a *Agent) initializeDiskInfo() {
|
||||
|
||||
// If no root filesystem set, use fallback
|
||||
if !hasRoot {
|
||||
rootDevice := findFallbackIoDevice(filepath.Base(filesystem), diskIoCounters)
|
||||
rootDevice := findFallbackIoDevice(filepath.Base(filesystem), diskIoCounters, a.fsStats)
|
||||
slog.Info("Root disk", "mountpoint", "/", "io", rootDevice)
|
||||
a.fsStats[rootDevice] = &system.FsStats{Root: true, Mountpoint: "/"}
|
||||
}
|
||||
@@ -135,7 +132,7 @@ func (a *Agent) initializeDiskInfo() {
|
||||
|
||||
// Returns the device with the most reads in /proc/diskstats,
|
||||
// or the device specified by the filesystem argument if it exists
|
||||
func findFallbackIoDevice(filesystem string, diskIoCounters map[string]disk.IOCountersStat) string {
|
||||
func findFallbackIoDevice(filesystem string, diskIoCounters map[string]disk.IOCountersStat, fsStats map[string]*system.FsStats) string {
|
||||
var maxReadBytes uint64
|
||||
maxReadDevice := "/"
|
||||
for _, d := range diskIoCounters {
|
||||
@@ -143,8 +140,11 @@ func findFallbackIoDevice(filesystem string, diskIoCounters map[string]disk.IOCo
|
||||
return d.Name
|
||||
}
|
||||
if d.ReadBytes > maxReadBytes {
|
||||
maxReadBytes = d.ReadBytes
|
||||
maxReadDevice = d.Name
|
||||
// don't use if device already exists in fsStats
|
||||
if _, exists := fsStats[d.Name]; !exists {
|
||||
maxReadBytes = d.ReadBytes
|
||||
maxReadDevice = d.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
return maxReadDevice
|
||||
|
||||
@@ -217,9 +217,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.Millisecond * 1100,
|
||||
Timeout: timeout,
|
||||
Transport: transport,
|
||||
},
|
||||
containerStatsMap: make(map[string]*container.Stats),
|
||||
@@ -243,7 +254,7 @@ func newDockerManager() *dockerManager {
|
||||
return dockerClient
|
||||
}
|
||||
|
||||
// if version > 25, one-shot works correctly and we can limit concurrent connections / goroutines to 5
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@ func (a *Agent) startServer(pubKey []byte, addr string) {
|
||||
|
||||
func (a *Agent) handleSession(s sshServer.Session) {
|
||||
stats := a.gatherStats()
|
||||
slog.Debug("Sending stats", "data", stats)
|
||||
if err := json.NewEncoder(s).Encode(stats); err != nil {
|
||||
slog.Error("Error encoding stats", "err", err)
|
||||
s.Exit(1)
|
||||
|
||||
@@ -173,12 +173,12 @@ 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 err != nil {
|
||||
// err.(*sensors.Warnings).Verbose = true
|
||||
slog.Debug("Sensor error", "err", err)
|
||||
}
|
||||
slog.Debug("Temperature", "sensors", temps)
|
||||
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
|
||||
@@ -208,6 +208,8 @@ func (a *Agent) getSystemStats() system.Stats {
|
||||
a.systemInfo.MemPct = systemStats.MemPct
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// Package update handles updating beszel and beszel-agent.
|
||||
package update
|
||||
package agent
|
||||
|
||||
import (
|
||||
"beszel"
|
||||
@@ -11,51 +10,8 @@ import (
|
||||
"github.com/rhysd/go-github-selfupdate/selfupdate"
|
||||
)
|
||||
|
||||
func UpdateBeszel() {
|
||||
var latest *selfupdate.Release
|
||||
var found bool
|
||||
var err error
|
||||
currentVersion := semver.MustParse(beszel.Version)
|
||||
fmt.Println("beszel", currentVersion)
|
||||
fmt.Println("Checking for updates...")
|
||||
updater, _ := selfupdate.NewUpdater(selfupdate.Config{
|
||||
Filters: []string{"beszel_"},
|
||||
})
|
||||
latest, found, err = updater.DetectLatest("henrygd/beszel")
|
||||
|
||||
if err != nil {
|
||||
fmt.Println("Error checking for updates:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if !found {
|
||||
fmt.Println("No updates found")
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
fmt.Println("Latest version:", latest.Version)
|
||||
|
||||
if latest.Version.LTE(currentVersion) {
|
||||
fmt.Println("You are up to date")
|
||||
return
|
||||
}
|
||||
|
||||
var binaryPath string
|
||||
fmt.Printf("Updating from %s to %s...\n", currentVersion, latest.Version)
|
||||
binaryPath, err = os.Executable()
|
||||
if err != nil {
|
||||
fmt.Println("Error getting binary path:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
err = selfupdate.UpdateTo(latest.AssetURL, binaryPath)
|
||||
if err != nil {
|
||||
fmt.Println("Please try rerunning with sudo. Error:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("Successfully updated to %s\n\n%s\n", latest.Version, strings.TrimSpace(latest.ReleaseNotes))
|
||||
}
|
||||
|
||||
func UpdateBeszelAgent() {
|
||||
// Update updates beszel-agent to the latest version
|
||||
func Update() {
|
||||
var latest *selfupdate.Release
|
||||
var found bool
|
||||
var err error
|
||||
@@ -6,21 +6,26 @@ import (
|
||||
"fmt"
|
||||
"net/mail"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/containrrr/shoutrrr"
|
||||
"github.com/goccy/go-json"
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase"
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tools/mailer"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
|
||||
type AlertManager struct {
|
||||
app *pocketbase.PocketBase
|
||||
}
|
||||
|
||||
type AlertData struct {
|
||||
type AlertMessageData struct {
|
||||
UserID string
|
||||
Title string
|
||||
Message string
|
||||
@@ -33,72 +38,308 @@ type UserNotificationSettings struct {
|
||||
Webhooks []string `json:"webhooks"`
|
||||
}
|
||||
|
||||
type SystemAlertStats struct {
|
||||
Cpu float64 `json:"cpu"`
|
||||
Mem float64 `json:"mp"`
|
||||
Disk float64 `json:"dp"`
|
||||
NetSent float64 `json:"ns"`
|
||||
NetRecv float64 `json:"nr"`
|
||||
Temperatures map[string]float32 `json:"t"`
|
||||
}
|
||||
|
||||
type SystemAlertData struct {
|
||||
systemRecord *models.Record
|
||||
alertRecord *models.Record
|
||||
name string
|
||||
unit string
|
||||
val float64
|
||||
threshold float64
|
||||
triggered bool
|
||||
time time.Time
|
||||
count uint8
|
||||
min uint8
|
||||
mapSums map[string]float32
|
||||
descriptor string // override descriptor in notification body (for temp sensor, disk partition, etc)
|
||||
}
|
||||
|
||||
func NewAlertManager(app *pocketbase.PocketBase) *AlertManager {
|
||||
return &AlertManager{
|
||||
app: app,
|
||||
}
|
||||
}
|
||||
|
||||
func (am *AlertManager) HandleSystemInfoAlerts(systemRecord *models.Record, systemInfo system.Info) {
|
||||
func (am *AlertManager) HandleSystemAlerts(systemRecord *models.Record, systemInfo system.Info, temperatures map[string]float64, extraFs map[string]*system.FsStats) error {
|
||||
// start := time.Now()
|
||||
// defer func() {
|
||||
// log.Println("alert stats took", time.Since(start))
|
||||
// }()
|
||||
alertRecords, err := am.app.Dao().FindRecordsByExpr("alerts",
|
||||
dbx.NewExp("system={:system}", dbx.Params{"system": systemRecord.GetId()}),
|
||||
dbx.NewExp("system={:system}", dbx.Params{"system": systemRecord.Id}),
|
||||
)
|
||||
if err != nil || len(alertRecords) == 0 {
|
||||
// log.Println("no alerts found for system")
|
||||
return
|
||||
return nil
|
||||
}
|
||||
// log.Println("found alerts", len(alertRecords))
|
||||
|
||||
var validAlerts []SystemAlertData
|
||||
now := systemRecord.Updated.Time().UTC()
|
||||
oldestTime := now
|
||||
|
||||
for _, alertRecord := range alertRecords {
|
||||
name := alertRecord.GetString("name")
|
||||
var val float64
|
||||
unit := "%"
|
||||
|
||||
switch name {
|
||||
case "CPU", "Memory", "Disk":
|
||||
if name == "CPU" {
|
||||
am.handleSlidingValueAlert(systemRecord, alertRecord, name, systemInfo.Cpu)
|
||||
} else if name == "Memory" {
|
||||
am.handleSlidingValueAlert(systemRecord, alertRecord, name, systemInfo.MemPct)
|
||||
} else if name == "Disk" {
|
||||
am.handleSlidingValueAlert(systemRecord, alertRecord, name, systemInfo.DiskPct)
|
||||
case "CPU":
|
||||
val = systemInfo.Cpu
|
||||
case "Memory":
|
||||
val = systemInfo.MemPct
|
||||
case "Bandwidth":
|
||||
val = systemInfo.Bandwidth
|
||||
unit = " MB/s"
|
||||
case "Disk":
|
||||
maxUsedPct := systemInfo.DiskPct
|
||||
for _, fs := range extraFs {
|
||||
usedPct := fs.DiskUsed / fs.DiskTotal * 100
|
||||
if usedPct > maxUsedPct {
|
||||
maxUsedPct = usedPct
|
||||
}
|
||||
}
|
||||
val = maxUsedPct
|
||||
case "Temperature":
|
||||
if temperatures == nil {
|
||||
continue
|
||||
}
|
||||
for _, temp := range temperatures {
|
||||
if temp > val {
|
||||
val = temp
|
||||
}
|
||||
}
|
||||
unit = "°C"
|
||||
}
|
||||
|
||||
triggered := alertRecord.GetBool("triggered")
|
||||
threshold := alertRecord.GetFloat("value")
|
||||
|
||||
// CONTINUE
|
||||
// IF alert is not triggered and curValue is less than threshold
|
||||
// OR alert is triggered and curValue is greater than threshold
|
||||
if (!triggered && val <= threshold) || (triggered && val > threshold) {
|
||||
// log.Printf("Skipping alert %s: val %f | threshold %f | triggered %v\n", name, val, threshold, triggered)
|
||||
continue
|
||||
}
|
||||
|
||||
min := max(1, cast.ToUint8(alertRecord.Get("min")))
|
||||
// add time to alert time to make sure it's slighty after record creation
|
||||
time := now.Add(-time.Duration(min) * time.Minute)
|
||||
if time.Before(oldestTime) {
|
||||
oldestTime = time
|
||||
}
|
||||
|
||||
validAlerts = append(validAlerts, SystemAlertData{
|
||||
systemRecord: systemRecord,
|
||||
alertRecord: alertRecord,
|
||||
name: name,
|
||||
unit: unit,
|
||||
val: val,
|
||||
threshold: threshold,
|
||||
triggered: triggered,
|
||||
time: time,
|
||||
min: min,
|
||||
})
|
||||
}
|
||||
|
||||
systemStats := []struct {
|
||||
Stats []byte `db:"stats"`
|
||||
Created types.DateTime `db:"created"`
|
||||
}{}
|
||||
|
||||
err = am.app.Dao().DB().
|
||||
Select("stats", "created").
|
||||
From("system_stats").
|
||||
Where(dbx.NewExp(
|
||||
"system={:system} AND type='1m' AND created > {:created}",
|
||||
dbx.Params{
|
||||
"system": systemRecord.Id,
|
||||
// subtract some time to give us a bit of buffer
|
||||
"created": oldestTime.Add(-time.Second * 90),
|
||||
},
|
||||
)).
|
||||
OrderBy("created").
|
||||
All(&systemStats)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// get oldest record creation time from first record in the slice
|
||||
oldestRecordTime := systemStats[0].Created.Time()
|
||||
// log.Println("oldestRecordTime", oldestRecordTime.String())
|
||||
|
||||
// delete from validAlerts if time is older than oldestRecord
|
||||
for i := 0; i < len(validAlerts); i++ {
|
||||
if validAlerts[i].time.Before(oldestRecordTime) {
|
||||
// log.Println("deleting alert - time is older than oldestRecord", validAlerts[i].name, oldestRecordTime, validAlerts[i].time)
|
||||
validAlerts = append(validAlerts[:i], validAlerts[i+1:]...)
|
||||
}
|
||||
}
|
||||
|
||||
if len(validAlerts) == 0 {
|
||||
// log.Println("no valid alerts found")
|
||||
return nil
|
||||
}
|
||||
|
||||
var stats SystemAlertStats
|
||||
|
||||
// we can skip the latest systemStats record since it's the current value
|
||||
for i := 0; i < len(systemStats); i++ {
|
||||
stat := systemStats[i]
|
||||
// subtract 10 seconds to give a small time buffer
|
||||
systemStatsCreation := stat.Created.Time().Add(-time.Second * 10)
|
||||
if err := json.Unmarshal(stat.Stats, &stats); err != nil {
|
||||
return err
|
||||
}
|
||||
// log.Println("stats", stats)
|
||||
for j := range validAlerts {
|
||||
alert := &validAlerts[j]
|
||||
// reset alert val on first iteration
|
||||
if i == 0 {
|
||||
alert.val = 0
|
||||
}
|
||||
// continue if system_stats is older than alert time range
|
||||
if systemStatsCreation.Before(alert.time) {
|
||||
continue
|
||||
}
|
||||
// add to alert value
|
||||
switch alert.name {
|
||||
case "CPU":
|
||||
alert.val += stats.Cpu
|
||||
case "Memory":
|
||||
alert.val += stats.Mem
|
||||
case "Bandwidth":
|
||||
alert.val += stats.NetSent + stats.NetRecv
|
||||
case "Disk":
|
||||
if alert.mapSums == nil {
|
||||
alert.mapSums = make(map[string]float32, len(extraFs)+1)
|
||||
}
|
||||
// add root disk
|
||||
if _, ok := alert.mapSums["root"]; !ok {
|
||||
alert.mapSums["root"] = 0.0
|
||||
}
|
||||
alert.mapSums["root"] += float32(stats.Disk)
|
||||
// add extra disks
|
||||
for key, fs := range extraFs {
|
||||
if _, ok := alert.mapSums[key]; !ok {
|
||||
alert.mapSums[key] = 0.0
|
||||
}
|
||||
alert.mapSums[key] += float32(fs.DiskUsed / fs.DiskTotal * 100)
|
||||
}
|
||||
case "Temperature":
|
||||
if alert.mapSums == nil {
|
||||
alert.mapSums = make(map[string]float32, len(stats.Temperatures))
|
||||
}
|
||||
for key, temp := range stats.Temperatures {
|
||||
if _, ok := alert.mapSums[key]; !ok {
|
||||
alert.mapSums[key] = float32(0)
|
||||
}
|
||||
alert.mapSums[key] += temp
|
||||
}
|
||||
default:
|
||||
continue
|
||||
}
|
||||
alert.count++
|
||||
}
|
||||
}
|
||||
// sum up vals for each alert
|
||||
for _, alert := range validAlerts {
|
||||
switch alert.name {
|
||||
case "Disk":
|
||||
maxPct := float32(0)
|
||||
for key, value := range alert.mapSums {
|
||||
sumPct := float32(value)
|
||||
if sumPct > maxPct {
|
||||
maxPct = sumPct
|
||||
alert.descriptor = fmt.Sprintf("Usage of %s", key)
|
||||
}
|
||||
}
|
||||
alert.val = float64(maxPct / float32(alert.count))
|
||||
case "Temperature":
|
||||
maxTemp := float32(0)
|
||||
for key, value := range alert.mapSums {
|
||||
sumTemp := float32(value) / float32(alert.count)
|
||||
if sumTemp > maxTemp {
|
||||
maxTemp = sumTemp
|
||||
alert.descriptor = fmt.Sprintf("Highest sensor %s", key)
|
||||
}
|
||||
}
|
||||
alert.val = float64(maxTemp)
|
||||
default:
|
||||
alert.val = alert.val / float64(alert.count)
|
||||
}
|
||||
minCount := float32(alert.min) / 1.2
|
||||
// log.Println("alert", alert.name, "val", alert.val, "threshold", alert.threshold, "triggered", alert.triggered)
|
||||
// log.Printf("%s: val %f | count %d | min-count %f | threshold %f\n", alert.name, alert.val, alert.count, minCount, alert.threshold)
|
||||
// pass through alert if count is greater than or equal to minCount
|
||||
if float32(alert.count) >= minCount {
|
||||
if !alert.triggered && alert.val > alert.threshold {
|
||||
alert.triggered = true
|
||||
go am.sendSystemAlert(alert)
|
||||
} else if alert.triggered && alert.val <= alert.threshold {
|
||||
alert.triggered = false
|
||||
go am.sendSystemAlert(alert)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (am *AlertManager) handleSlidingValueAlert(systemRecord *models.Record, alertRecord *models.Record, name string, curValue float64) {
|
||||
triggered := alertRecord.GetBool("triggered")
|
||||
threshold := alertRecord.GetFloat("value")
|
||||
// fmt.Println(name, curValue, "threshold", threshold, "triggered", triggered)
|
||||
var subject string
|
||||
var body string
|
||||
var systemName string
|
||||
if !triggered && curValue > threshold {
|
||||
alertRecord.Set("triggered", true)
|
||||
systemName = systemRecord.GetString("name")
|
||||
subject = fmt.Sprintf("%s usage above threshold on %s", name, systemName)
|
||||
body = fmt.Sprintf("%s usage on %s is %.1f%%.", name, systemName, curValue)
|
||||
} else if triggered && curValue <= threshold {
|
||||
alertRecord.Set("triggered", false)
|
||||
systemName = systemRecord.GetString("name")
|
||||
subject = fmt.Sprintf("%s usage below threshold on %s", name, systemName)
|
||||
body = fmt.Sprintf("%s usage on %s is below threshold at %.1f%%.", name, systemName, curValue)
|
||||
} else {
|
||||
// fmt.Println(name, "not triggered")
|
||||
return
|
||||
func (am *AlertManager) sendSystemAlert(alert SystemAlertData) {
|
||||
// log.Printf("Sending alert %s: val %f | count %d | threshold %f\n", alert.name, alert.val, alert.count, alert.threshold)
|
||||
systemName := alert.systemRecord.GetString("name")
|
||||
|
||||
// change Disk to Disk usage
|
||||
if alert.name == "Disk" {
|
||||
alert.name += " usage"
|
||||
}
|
||||
if err := am.app.Dao().SaveRecord(alertRecord); err != nil {
|
||||
|
||||
// make title alert name lowercase if not CPU
|
||||
titleAlertName := alert.name
|
||||
if titleAlertName != "CPU" {
|
||||
titleAlertName = strings.ToLower(titleAlertName)
|
||||
}
|
||||
|
||||
var subject string
|
||||
if alert.triggered {
|
||||
subject = fmt.Sprintf("%s %s above threshold", systemName, titleAlertName)
|
||||
} else {
|
||||
subject = fmt.Sprintf("%s %s below threshold", systemName, titleAlertName)
|
||||
}
|
||||
minutesLabel := "minute"
|
||||
if alert.min > 1 {
|
||||
minutesLabel += "s"
|
||||
}
|
||||
if alert.descriptor == "" {
|
||||
alert.descriptor = alert.name
|
||||
}
|
||||
body := fmt.Sprintf("%s averaged %.2f%s for the previous %v %s.", alert.descriptor, alert.val, alert.unit, alert.min, minutesLabel)
|
||||
|
||||
alert.alertRecord.Set("triggered", alert.triggered)
|
||||
if err := am.app.Dao().SaveRecord(alert.alertRecord); err != nil {
|
||||
// app.Logger().Error("failed to save alert record", "err", err.Error())
|
||||
return
|
||||
}
|
||||
// expand the user relation and send the alert
|
||||
if errs := am.app.Dao().ExpandRecord(alertRecord, []string{"user"}, nil); len(errs) > 0 {
|
||||
if errs := am.app.Dao().ExpandRecord(alert.alertRecord, []string{"user"}, nil); len(errs) > 0 {
|
||||
// app.Logger().Error("failed to expand user relation", "errs", errs)
|
||||
return
|
||||
}
|
||||
if user := alertRecord.ExpandedOne("user"); user != nil {
|
||||
am.sendAlert(AlertData{
|
||||
if user := alert.alertRecord.ExpandedOne("user"); user != nil {
|
||||
am.sendAlert(AlertMessageData{
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -145,18 +386,18 @@ func (am *AlertManager) HandleStatusAlerts(newStatus string, oldSystemRecord *mo
|
||||
}
|
||||
// send alert
|
||||
systemName := oldSystemRecord.GetString("name")
|
||||
am.sendAlert(AlertData{
|
||||
am.sendAlert(AlertMessageData{
|
||||
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,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (am *AlertManager) sendAlert(data AlertData) {
|
||||
func (am *AlertManager) sendAlert(data AlertMessageData) {
|
||||
// get user settings
|
||||
record, err := am.app.Dao().FindFirstRecordByFilter(
|
||||
"user_settings", "user={:user}",
|
||||
|
||||
@@ -6,35 +6,42 @@ import (
|
||||
)
|
||||
|
||||
type Stats struct {
|
||||
Cpu float64 `json:"cpu"`
|
||||
Mem float64 `json:"m"`
|
||||
MemUsed float64 `json:"mu"`
|
||||
MemPct float64 `json:"mp"`
|
||||
MemBuffCache float64 `json:"mb"`
|
||||
MemZfsArc float64 `json:"mz,omitempty"` // ZFS ARC memory
|
||||
Swap float64 `json:"s,omitempty"`
|
||||
SwapUsed float64 `json:"su,omitempty"`
|
||||
DiskTotal float64 `json:"d"`
|
||||
DiskUsed float64 `json:"du"`
|
||||
DiskPct float64 `json:"dp"`
|
||||
DiskReadPs float64 `json:"dr"`
|
||||
DiskWritePs float64 `json:"dw"`
|
||||
NetworkSent float64 `json:"ns"`
|
||||
NetworkRecv float64 `json:"nr"`
|
||||
Temperatures map[string]float64 `json:"t,omitempty"`
|
||||
ExtraFs map[string]*FsStats `json:"efs,omitempty"`
|
||||
Cpu float64 `json:"cpu"`
|
||||
MaxCpu float64 `json:"cpum,omitempty"`
|
||||
Mem float64 `json:"m"`
|
||||
MemUsed float64 `json:"mu"`
|
||||
MemPct float64 `json:"mp"`
|
||||
MemBuffCache float64 `json:"mb"`
|
||||
MemZfsArc float64 `json:"mz,omitempty"` // ZFS ARC memory
|
||||
Swap float64 `json:"s,omitempty"`
|
||||
SwapUsed float64 `json:"su,omitempty"`
|
||||
DiskTotal float64 `json:"d"`
|
||||
DiskUsed float64 `json:"du"`
|
||||
DiskPct float64 `json:"dp"`
|
||||
DiskReadPs float64 `json:"dr"`
|
||||
DiskWritePs float64 `json:"dw"`
|
||||
MaxDiskReadPs float64 `json:"drm,omitempty"`
|
||||
MaxDiskWritePs float64 `json:"dwm,omitempty"`
|
||||
NetworkSent float64 `json:"ns"`
|
||||
NetworkRecv float64 `json:"nr"`
|
||||
MaxNetworkSent float64 `json:"nsm,omitempty"`
|
||||
MaxNetworkRecv float64 `json:"nrm,omitempty"`
|
||||
Temperatures map[string]float64 `json:"t,omitempty"`
|
||||
ExtraFs map[string]*FsStats `json:"efs,omitempty"`
|
||||
}
|
||||
|
||||
type FsStats struct {
|
||||
Time time.Time `json:"-"`
|
||||
Root bool `json:"-"`
|
||||
Mountpoint string `json:"-"`
|
||||
DiskTotal float64 `json:"d"`
|
||||
DiskUsed float64 `json:"du"`
|
||||
TotalRead uint64 `json:"-"`
|
||||
TotalWrite uint64 `json:"-"`
|
||||
DiskWritePs float64 `json:"w"`
|
||||
DiskReadPs float64 `json:"r"`
|
||||
Time time.Time `json:"-"`
|
||||
Root bool `json:"-"`
|
||||
Mountpoint string `json:"-"`
|
||||
DiskTotal float64 `json:"d"`
|
||||
DiskUsed float64 `json:"du"`
|
||||
TotalRead uint64 `json:"-"`
|
||||
TotalWrite uint64 `json:"-"`
|
||||
DiskReadPs float64 `json:"r"`
|
||||
DiskWritePs float64 `json:"w"`
|
||||
MaxDiskReadPS float64 `json:"rm,omitempty"`
|
||||
MaxDiskWritePS float64 `json:"wm,omitempty"`
|
||||
}
|
||||
|
||||
type NetIoStats struct {
|
||||
@@ -54,6 +61,7 @@ type Info struct {
|
||||
Cpu float64 `json:"cpu"`
|
||||
MemPct float64 `json:"mp"`
|
||||
DiskPct float64 `json:"dp"`
|
||||
Bandwidth float64 `json:"b"`
|
||||
AgentVersion string `json:"v"`
|
||||
}
|
||||
|
||||
|
||||
@@ -174,6 +174,14 @@ func (h *Hub) Run() {
|
||||
h.app.OnModelBeforeCreate("users").Add(h.um.InitializeUserRole)
|
||||
h.app.OnModelBeforeCreate("user_settings").Add(h.um.InitializeUserSettings)
|
||||
|
||||
// empty info for systems that are paused
|
||||
h.app.OnModelBeforeUpdate("systems").Add(func(e *core.ModelEvent) error {
|
||||
if e.Model.(*models.Record).GetString("status") == "paused" {
|
||||
e.Model.(*models.Record).Set("info", system.Info{})
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
// do things after a systems record is updated
|
||||
h.app.OnModelAfterUpdate("systems").Add(func(e *core.ModelEvent) error {
|
||||
newRecord := e.Model.(*models.Record)
|
||||
@@ -306,8 +314,10 @@ func (h *Hub) updateSystem(record *models.Record) {
|
||||
h.app.Logger().Error("Failed to save record: ", "err", err.Error())
|
||||
}
|
||||
}
|
||||
// system info alerts (todo: temp alerts, extra fs alerts)
|
||||
h.am.HandleSystemInfoAlerts(record, systemData.Info)
|
||||
// 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())
|
||||
}
|
||||
}
|
||||
|
||||
// set system to specified status and save record
|
||||
|
||||
57
beszel/internal/hub/update.go
Normal file
57
beszel/internal/hub/update.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package hub
|
||||
|
||||
import (
|
||||
"beszel"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/blang/semver"
|
||||
"github.com/rhysd/go-github-selfupdate/selfupdate"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Update updates beszel to the latest version
|
||||
func Update(_ *cobra.Command, _ []string) {
|
||||
var latest *selfupdate.Release
|
||||
var found bool
|
||||
var err error
|
||||
currentVersion := semver.MustParse(beszel.Version)
|
||||
fmt.Println("beszel", currentVersion)
|
||||
fmt.Println("Checking for updates...")
|
||||
updater, _ := selfupdate.NewUpdater(selfupdate.Config{
|
||||
Filters: []string{"beszel_"},
|
||||
})
|
||||
latest, found, err = updater.DetectLatest("henrygd/beszel")
|
||||
|
||||
if err != nil {
|
||||
fmt.Println("Error checking for updates:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if !found {
|
||||
fmt.Println("No updates found")
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
fmt.Println("Latest version:", latest.Version)
|
||||
|
||||
if latest.Version.LTE(currentVersion) {
|
||||
fmt.Println("You are up to date")
|
||||
return
|
||||
}
|
||||
|
||||
var binaryPath string
|
||||
fmt.Printf("Updating from %s to %s...\n", currentVersion, latest.Version)
|
||||
binaryPath, err = os.Executable()
|
||||
if err != nil {
|
||||
fmt.Println("Error getting binary path:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
err = selfupdate.UpdateTo(latest.AssetURL, binaryPath)
|
||||
if err != nil {
|
||||
fmt.Println("Please try rerunning with sudo. Error:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("Successfully updated to %s\n\n%s\n", latest.Version, strings.TrimSpace(latest.ReleaseNotes))
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/goccy/go-json"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
@@ -31,6 +32,10 @@ type RecordDeletionData struct {
|
||||
retention time.Duration
|
||||
}
|
||||
|
||||
type RecordStats []*struct {
|
||||
Stats []byte `db:"stats"`
|
||||
}
|
||||
|
||||
func NewRecordManager(app *pocketbase.PocketBase) *RecordManager {
|
||||
return &RecordManager{app}
|
||||
}
|
||||
@@ -73,6 +78,7 @@ 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)
|
||||
@@ -104,31 +110,44 @@ func (rm *RecordManager) CreateLongerRecords() {
|
||||
}
|
||||
}
|
||||
// get shorter records from the past x minutes
|
||||
allShorterRecords, err := txDao.FindRecordsByExpr(
|
||||
collection.Id,
|
||||
dbx.NewExp(
|
||||
"type = {:type} AND system = {:system} AND created > {:created}",
|
||||
dbx.Params{"type": recordData.shorterType, "system": system.Id, "created": shorterRecordPeriod},
|
||||
),
|
||||
)
|
||||
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).
|
||||
AndWhere(dbx.NewExp(
|
||||
"type={:type} AND system={:system} AND created > {:created}",
|
||||
dbx.Params{
|
||||
"type": recordData.shorterType,
|
||||
"system": system.Id,
|
||||
"created": shorterRecordPeriod,
|
||||
},
|
||||
)).
|
||||
All(&stats)
|
||||
|
||||
// continue if not enough shorter records
|
||||
if err != nil || len(allShorterRecords) < recordData.minShorterRecords {
|
||||
if err != nil || len(stats) < recordData.minShorterRecords {
|
||||
// log.Println("not enough shorter records. continue.", len(allShorterRecords), recordData.expectedShorterRecords)
|
||||
continue
|
||||
}
|
||||
// average the shorter records and create longer record
|
||||
var stats interface{}
|
||||
switch collection.Name {
|
||||
case "system_stats":
|
||||
stats = rm.AverageSystemStats(allShorterRecords)
|
||||
case "container_stats":
|
||||
stats = rm.AverageContainerStats(allShorterRecords)
|
||||
}
|
||||
longerRecord := models.NewRecord(collection)
|
||||
longerRecord.Set("system", system.Id)
|
||||
longerRecord.Set("stats", stats)
|
||||
longerRecord.Set("type", recordData.longerType)
|
||||
switch collection.Name {
|
||||
case "system_stats":
|
||||
longerRecord.Set("stats", rm.AverageSystemStats(stats))
|
||||
case "container_stats":
|
||||
longerRecord.Set("stats", rm.AverageContainerStats(stats))
|
||||
}
|
||||
if err := txDao.SaveRecord(longerRecord); err != nil {
|
||||
log.Println("failed to save longer record", "err", err.Error())
|
||||
}
|
||||
@@ -143,7 +162,7 @@ func (rm *RecordManager) CreateLongerRecords() {
|
||||
}
|
||||
|
||||
// Calculate the average stats of a list of system_stats records without reflect
|
||||
func (rm *RecordManager) AverageSystemStats(records []*models.Record) system.Stats {
|
||||
func (rm *RecordManager) AverageSystemStats(records RecordStats) system.Stats {
|
||||
sum := system.Stats{
|
||||
Temperatures: make(map[string]float64),
|
||||
ExtraFs: make(map[string]*system.FsStats),
|
||||
@@ -155,7 +174,7 @@ func (rm *RecordManager) AverageSystemStats(records []*models.Record) system.Sta
|
||||
|
||||
var stats system.Stats
|
||||
for _, record := range records {
|
||||
record.UnmarshalJSONField("stats", &stats)
|
||||
json.Unmarshal(record.Stats, &stats)
|
||||
sum.Cpu += stats.Cpu
|
||||
sum.Mem += stats.Mem
|
||||
sum.MemUsed += stats.MemUsed
|
||||
@@ -171,6 +190,12 @@ func (rm *RecordManager) AverageSystemStats(records []*models.Record) system.Sta
|
||||
sum.DiskWritePs += stats.DiskWritePs
|
||||
sum.NetworkSent += stats.NetworkSent
|
||||
sum.NetworkRecv += stats.NetworkRecv
|
||||
// set peak values
|
||||
sum.MaxCpu = max(sum.MaxCpu, stats.MaxCpu, stats.Cpu)
|
||||
sum.MaxNetworkSent = max(sum.MaxNetworkSent, stats.MaxNetworkSent, stats.NetworkSent)
|
||||
sum.MaxNetworkRecv = max(sum.MaxNetworkRecv, stats.MaxNetworkRecv, stats.NetworkRecv)
|
||||
sum.MaxDiskReadPs = max(sum.MaxDiskReadPs, stats.MaxDiskReadPs, stats.DiskReadPs)
|
||||
sum.MaxDiskWritePs = max(sum.MaxDiskWritePs, stats.MaxDiskWritePs, stats.DiskWritePs)
|
||||
// add temps to sum
|
||||
if stats.Temperatures != nil {
|
||||
tempCount++
|
||||
@@ -191,43 +216,53 @@ func (rm *RecordManager) AverageSystemStats(records []*models.Record) system.Sta
|
||||
sum.ExtraFs[key].DiskUsed += value.DiskUsed
|
||||
sum.ExtraFs[key].DiskWritePs += value.DiskWritePs
|
||||
sum.ExtraFs[key].DiskReadPs += value.DiskReadPs
|
||||
// peak values
|
||||
sum.ExtraFs[key].MaxDiskReadPS = max(sum.ExtraFs[key].MaxDiskReadPS, value.MaxDiskReadPS, value.DiskReadPs)
|
||||
sum.ExtraFs[key].MaxDiskWritePS = max(sum.ExtraFs[key].MaxDiskWritePS, value.MaxDiskWritePS, value.DiskWritePs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stats = system.Stats{
|
||||
Cpu: twoDecimals(sum.Cpu / count),
|
||||
Mem: twoDecimals(sum.Mem / count),
|
||||
MemUsed: twoDecimals(sum.MemUsed / count),
|
||||
MemPct: twoDecimals(sum.MemPct / count),
|
||||
MemBuffCache: twoDecimals(sum.MemBuffCache / count),
|
||||
MemZfsArc: twoDecimals(sum.MemZfsArc / count),
|
||||
Swap: twoDecimals(sum.Swap / count),
|
||||
SwapUsed: twoDecimals(sum.SwapUsed / count),
|
||||
DiskTotal: twoDecimals(sum.DiskTotal / count),
|
||||
DiskUsed: twoDecimals(sum.DiskUsed / count),
|
||||
DiskPct: twoDecimals(sum.DiskPct / count),
|
||||
DiskReadPs: twoDecimals(sum.DiskReadPs / count),
|
||||
DiskWritePs: twoDecimals(sum.DiskWritePs / count),
|
||||
NetworkSent: twoDecimals(sum.NetworkSent / count),
|
||||
NetworkRecv: twoDecimals(sum.NetworkRecv / count),
|
||||
Cpu: twoDecimals(sum.Cpu / count),
|
||||
Mem: twoDecimals(sum.Mem / count),
|
||||
MemUsed: twoDecimals(sum.MemUsed / count),
|
||||
MemPct: twoDecimals(sum.MemPct / count),
|
||||
MemBuffCache: twoDecimals(sum.MemBuffCache / count),
|
||||
MemZfsArc: twoDecimals(sum.MemZfsArc / count),
|
||||
Swap: twoDecimals(sum.Swap / count),
|
||||
SwapUsed: twoDecimals(sum.SwapUsed / count),
|
||||
DiskTotal: twoDecimals(sum.DiskTotal / count),
|
||||
DiskUsed: twoDecimals(sum.DiskUsed / count),
|
||||
DiskPct: twoDecimals(sum.DiskPct / count),
|
||||
DiskReadPs: twoDecimals(sum.DiskReadPs / count),
|
||||
DiskWritePs: twoDecimals(sum.DiskWritePs / count),
|
||||
NetworkSent: twoDecimals(sum.NetworkSent / count),
|
||||
NetworkRecv: twoDecimals(sum.NetworkRecv / count),
|
||||
MaxCpu: sum.MaxCpu,
|
||||
MaxDiskReadPs: sum.MaxDiskReadPs,
|
||||
MaxDiskWritePs: sum.MaxDiskWritePs,
|
||||
MaxNetworkSent: sum.MaxNetworkSent,
|
||||
MaxNetworkRecv: sum.MaxNetworkRecv,
|
||||
}
|
||||
|
||||
if len(sum.Temperatures) != 0 {
|
||||
stats.Temperatures = make(map[string]float64)
|
||||
stats.Temperatures = make(map[string]float64, len(sum.Temperatures))
|
||||
for key, value := range sum.Temperatures {
|
||||
stats.Temperatures[key] = twoDecimals(value / tempCount)
|
||||
}
|
||||
}
|
||||
|
||||
if len(sum.ExtraFs) != 0 {
|
||||
stats.ExtraFs = make(map[string]*system.FsStats)
|
||||
stats.ExtraFs = make(map[string]*system.FsStats, len(sum.ExtraFs))
|
||||
for key, value := range sum.ExtraFs {
|
||||
stats.ExtraFs[key] = &system.FsStats{
|
||||
DiskTotal: twoDecimals(value.DiskTotal / count),
|
||||
DiskUsed: twoDecimals(value.DiskUsed / count),
|
||||
DiskWritePs: twoDecimals(value.DiskWritePs / count),
|
||||
DiskReadPs: twoDecimals(value.DiskReadPs / count),
|
||||
DiskTotal: twoDecimals(value.DiskTotal / count),
|
||||
DiskUsed: twoDecimals(value.DiskUsed / count),
|
||||
DiskWritePs: twoDecimals(value.DiskWritePs / count),
|
||||
DiskReadPs: twoDecimals(value.DiskReadPs / count),
|
||||
MaxDiskReadPS: value.MaxDiskReadPS,
|
||||
MaxDiskWritePS: value.MaxDiskWritePS,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -236,13 +271,17 @@ func (rm *RecordManager) AverageSystemStats(records []*models.Record) system.Sta
|
||||
}
|
||||
|
||||
// Calculate the average stats of a list of container_stats records
|
||||
func (rm *RecordManager) AverageContainerStats(records []*models.Record) []container.Stats {
|
||||
func (rm *RecordManager) AverageContainerStats(records RecordStats) []container.Stats {
|
||||
sums := make(map[string]*container.Stats)
|
||||
count := float64(len(records))
|
||||
|
||||
var containerStats []container.Stats
|
||||
for _, record := range records {
|
||||
record.UnmarshalJSONField("stats", &containerStats)
|
||||
// Reset the slice length to 0, but keep the capacity
|
||||
containerStats = containerStats[:0]
|
||||
if err := json.Unmarshal(record.Stats, &containerStats); err != nil {
|
||||
return []container.Stats{}
|
||||
}
|
||||
for _, stat := range containerStats {
|
||||
if _, ok := sums[stat.Name]; !ok {
|
||||
sums[stat.Name] = &container.Stats{Name: stat.Name}
|
||||
|
||||
@@ -15,7 +15,7 @@ func init() {
|
||||
{
|
||||
"id": "2hz5ncl8tizk5nx",
|
||||
"created": "2024-07-07 16:08:20.979Z",
|
||||
"updated": "2024-07-28 17:14:24.492Z",
|
||||
"updated": "2024-10-12 18:55:51.623Z",
|
||||
"name": "systems",
|
||||
"type": "base",
|
||||
"system": false,
|
||||
@@ -120,7 +120,7 @@ func init() {
|
||||
{
|
||||
"id": "ej9oowivz8b2mht",
|
||||
"created": "2024-07-07 16:09:09.179Z",
|
||||
"updated": "2024-07-28 17:14:24.492Z",
|
||||
"updated": "2024-10-12 18:55:51.623Z",
|
||||
"name": "system_stats",
|
||||
"type": "base",
|
||||
"system": false,
|
||||
@@ -186,7 +186,7 @@ func init() {
|
||||
{
|
||||
"id": "juohu4jipgc13v7",
|
||||
"created": "2024-07-07 16:09:57.976Z",
|
||||
"updated": "2024-07-28 17:14:24.492Z",
|
||||
"updated": "2024-10-12 18:55:51.623Z",
|
||||
"name": "container_stats",
|
||||
"type": "base",
|
||||
"system": false,
|
||||
@@ -250,7 +250,7 @@ func init() {
|
||||
{
|
||||
"id": "_pb_users_auth_",
|
||||
"created": "2024-07-14 16:25:18.226Z",
|
||||
"updated": "2024-09-12 23:19:36.280Z",
|
||||
"updated": "2024-10-12 22:27:19.081Z",
|
||||
"name": "users",
|
||||
"type": "auth",
|
||||
"system": false,
|
||||
@@ -316,7 +316,7 @@ func init() {
|
||||
{
|
||||
"id": "elngm8x1l60zi2v",
|
||||
"created": "2024-07-15 01:16:04.044Z",
|
||||
"updated": "2024-07-28 17:14:24.492Z",
|
||||
"updated": "2024-10-12 22:27:29.128Z",
|
||||
"name": "alerts",
|
||||
"type": "base",
|
||||
"system": false,
|
||||
@@ -367,7 +367,9 @@ func init() {
|
||||
"Status",
|
||||
"CPU",
|
||||
"Memory",
|
||||
"Disk"
|
||||
"Disk",
|
||||
"Temperature",
|
||||
"Bandwidth"
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -385,6 +387,20 @@ func init() {
|
||||
"noDecimal": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "fstdehcq",
|
||||
"name": "min",
|
||||
"type": "number",
|
||||
"required": false,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"min": null,
|
||||
"max": 60,
|
||||
"noDecimal": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "6hgdf6hs",
|
||||
@@ -407,7 +423,7 @@ func init() {
|
||||
{
|
||||
"id": "4afacsdnlu8q8r2",
|
||||
"created": "2024-09-12 17:42:55.324Z",
|
||||
"updated": "2024-09-12 21:19:59.114Z",
|
||||
"updated": "2024-10-12 18:55:51.624Z",
|
||||
"name": "user_settings",
|
||||
"type": "base",
|
||||
"system": false,
|
||||
Binary file not shown.
10248
beszel/site/package-lock.json
generated
10248
beszel/site/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -9,44 +9,51 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@henrygd/queue": "^1.0.7",
|
||||
"@nanostores/react": "^0.7.3",
|
||||
"@nanostores/router": "^0.15.1",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.1",
|
||||
"@radix-ui/react-dialog": "^1.1.1",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.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",
|
||||
"@radix-ui/react-select": "^2.1.1",
|
||||
"@radix-ui/react-select": "^2.1.2",
|
||||
"@radix-ui/react-separator": "^1.1.0",
|
||||
"@radix-ui/react-slider": "^1.2.0",
|
||||
"@radix-ui/react-slider": "^1.2.1",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@radix-ui/react-switch": "^1.1.0",
|
||||
"@radix-ui/react-toast": "^1.2.1",
|
||||
"@radix-ui/react-tooltip": "^1.1.2",
|
||||
"@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",
|
||||
"@vitejs/plugin-react": "^4.3.2",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.0",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-time": "^3.1.0",
|
||||
"lucide-react": "^0.407.0",
|
||||
"nanostores": "^0.10.3",
|
||||
"lucide-react": "^0.452.0",
|
||||
"nanostores": "^0.11.3",
|
||||
"pocketbase": "^0.21.5",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"recharts": "^2.13.0-alpha.5",
|
||||
"tailwind-merge": "^2.5.2",
|
||||
"recharts": "^2.13.0",
|
||||
"tailwind-merge": "^2.5.4",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"valibot": "^0.36.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.1.10",
|
||||
"@types/react": "^18.3.10",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/bun": "^1.1.11",
|
||||
"@types/react": "^18.3.11",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.47",
|
||||
"tailwindcss": "^3.4.13",
|
||||
"typescript": "^5.6.2",
|
||||
"vite": "^5.4.8"
|
||||
"tailwindcss": "^3.4.14",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.9"
|
||||
},
|
||||
"overrides": {
|
||||
"@nanostores/router": {
|
||||
"nanostores": "^0.11.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
127
beszel/site/src/components/alerts/alert-button.tsx
Normal file
127
beszel/site/src/components/alerts/alert-button.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
246
beszel/site/src/components/alerts/alerts-system.tsx
Normal file
246
beszel/site/src/components/alerts/alerts-system.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
122
beszel/site/src/components/charts/area-chart.tsx
Normal file
122
beszel/site/src/components/charts/area-chart.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import { Area, AreaChart, CartesianGrid, YAxis } from 'recharts'
|
||||
|
||||
import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from '@/components/ui/chart'
|
||||
import {
|
||||
useYAxisWidth,
|
||||
cn,
|
||||
formatShortDate,
|
||||
toFixedWithoutTrailingZeros,
|
||||
decimalString,
|
||||
chartMargin,
|
||||
} from '@/lib/utils'
|
||||
// import Spinner from '../spinner'
|
||||
import { ChartData } from '@/types'
|
||||
import { memo, useMemo } from 'react'
|
||||
|
||||
/** [label, key, color, opacity] */
|
||||
type DataKeys = [string, string, number, number]
|
||||
|
||||
const getNestedValue = (path: string, max = false, data: any): number | null => {
|
||||
// fallback value (obj?.stats?.cpum ? 0 : null) should only come into play when viewing
|
||||
// a max value which doesn't exist, or the value was zero and omitted from the stats object.
|
||||
// so we check if cpum is present. if so, return 0 to make sure the zero value is displayed.
|
||||
// if not, return null - there is no max data so do not display anything.
|
||||
return `stats.${path}${max ? 'm' : ''}`
|
||||
.split('.')
|
||||
.reduce((acc: any, key: string) => acc?.[key] ?? (data.stats?.cpum ? 0 : null), data)
|
||||
}
|
||||
|
||||
export default memo(function AreaChartDefault({
|
||||
maxToggled = false,
|
||||
unit = ' MB/s',
|
||||
chartName,
|
||||
chartData,
|
||||
}: {
|
||||
maxToggled?: boolean
|
||||
unit?: string
|
||||
chartName: string
|
||||
chartData: ChartData
|
||||
}) {
|
||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||
|
||||
const { chartTime } = chartData
|
||||
|
||||
const showMax = chartTime !== '1h' && maxToggled
|
||||
|
||||
const dataKeys: DataKeys[] = useMemo(() => {
|
||||
// [label, key, color, opacity]
|
||||
if (chartName === 'CPU Usage') {
|
||||
return [[chartName, 'cpu', 1, 0.4]]
|
||||
} else if (chartName === 'dio') {
|
||||
return [
|
||||
['Write', 'dw', 3, 0.3],
|
||||
['Read', 'dr', 1, 0.3],
|
||||
]
|
||||
} else if (chartName === 'bw') {
|
||||
return [
|
||||
['Sent', 'ns', 5, 0.2],
|
||||
['Received', 'nr', 2, 0.2],
|
||||
]
|
||||
} else if (chartName.startsWith('efs')) {
|
||||
return [
|
||||
['Write', `${chartName}.w`, 3, 0.3],
|
||||
['Read', `${chartName}.r`, 1, 0.3],
|
||||
]
|
||||
}
|
||||
return []
|
||||
}, [])
|
||||
|
||||
// console.log('Rendered at', new Date())
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ChartContainer
|
||||
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
|
||||
'opacity-100': yAxisWidth,
|
||||
})}
|
||||
>
|
||||
<AreaChart accessibilityLayer data={chartData.systemStats} margin={chartMargin}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<YAxis
|
||||
className="tracking-tighter"
|
||||
width={yAxisWidth}
|
||||
tickFormatter={(value) => {
|
||||
const val = toFixedWithoutTrailingZeros(value, 2) + unit
|
||||
return updateYAxisWidth(val)
|
||||
}}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
{xAxis(chartData)}
|
||||
<ChartTooltip
|
||||
animationEasing="ease-out"
|
||||
animationDuration={150}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
||||
contentFormatter={(item) => decimalString(item.value) + unit}
|
||||
// indicator="line"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{dataKeys.map((key, i) => {
|
||||
const color = `hsl(var(--chart-${key[2]}))`
|
||||
return (
|
||||
<Area
|
||||
key={i}
|
||||
dataKey={getNestedValue.bind(null, key[1], showMax)}
|
||||
name={key[0]}
|
||||
type="monotoneX"
|
||||
fill={color}
|
||||
fillOpacity={key[3]}
|
||||
stroke={color}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{/* <ChartLegend content={<ChartLegendContent />} /> */}
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -1,105 +0,0 @@
|
||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
|
||||
|
||||
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'
|
||||
import {
|
||||
useYAxisWidth,
|
||||
chartTimeData,
|
||||
cn,
|
||||
formatShortDate,
|
||||
toFixedWithoutTrailingZeros,
|
||||
twoDecimalString,
|
||||
} from '@/lib/utils'
|
||||
// import Spinner from '../spinner'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { $chartTime } from '@/lib/stores'
|
||||
import { SystemStatsRecord } from '@/types'
|
||||
|
||||
export default function BandwidthChart({
|
||||
ticks,
|
||||
systemData,
|
||||
}: {
|
||||
ticks: number[]
|
||||
systemData: SystemStatsRecord[]
|
||||
}) {
|
||||
const chartTime = useStore($chartTime)
|
||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* {!yAxisSet && <Spinner />} */}
|
||||
<ChartContainer
|
||||
config={{}}
|
||||
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
|
||||
'opacity-100': yAxisWidth,
|
||||
})}
|
||||
>
|
||||
<AreaChart
|
||||
accessibilityLayer
|
||||
data={systemData}
|
||||
margin={{
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 10,
|
||||
bottom: 0,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid vertical={false} />
|
||||
<YAxis
|
||||
className="tracking-tighter"
|
||||
width={yAxisWidth}
|
||||
// domain={[0, (max: number) => (max <= 0.4 ? 0.4 : Math.ceil(max))]}
|
||||
tickFormatter={(value) => {
|
||||
const val = toFixedWithoutTrailingZeros(value, 2) + ' MB/s'
|
||||
return updateYAxisWidth(val)
|
||||
}}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
// unit={' MB/s'}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="created"
|
||||
domain={[ticks[0], ticks.at(-1)!]}
|
||||
ticks={ticks}
|
||||
type="number"
|
||||
scale={'time'}
|
||||
minTickGap={35}
|
||||
tickMargin={8}
|
||||
axisLine={false}
|
||||
tickFormatter={chartTimeData[chartTime].format}
|
||||
/>
|
||||
<ChartTooltip
|
||||
animationEasing="ease-out"
|
||||
animationDuration={150}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
||||
contentFormatter={(item) => twoDecimalString(item.value) + ' MB/s'}
|
||||
indicator="line"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Area
|
||||
dataKey="stats.ns"
|
||||
name="Sent"
|
||||
type="monotoneX"
|
||||
fill="hsl(var(--chart-5))"
|
||||
fillOpacity={0.2}
|
||||
stroke="hsl(var(--chart-5))"
|
||||
// animationDuration={1200}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<Area
|
||||
dataKey="stats.nr"
|
||||
name="Received"
|
||||
type="monotoneX"
|
||||
fill="hsl(var(--chart-2))"
|
||||
fillOpacity={0.2}
|
||||
stroke="hsl(var(--chart-2))"
|
||||
// animationDuration={1200}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
189
beszel/site/src/components/charts/container-chart.tsx
Normal file
189
beszel/site/src/components/charts/container-chart.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import { Area, AreaChart, CartesianGrid, YAxis } from 'recharts'
|
||||
import {
|
||||
ChartConfig,
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
xAxis,
|
||||
} from '@/components/ui/chart'
|
||||
import { memo, useMemo } from 'react'
|
||||
import {
|
||||
useYAxisWidth,
|
||||
cn,
|
||||
formatShortDate,
|
||||
decimalString,
|
||||
chartMargin,
|
||||
toFixedFloat,
|
||||
getSizeAndUnit,
|
||||
toFixedWithoutTrailingZeros,
|
||||
} from '@/lib/utils'
|
||||
// import Spinner from '../spinner'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { $containerFilter } from '@/lib/stores'
|
||||
import { ChartData } from '@/types'
|
||||
import { Separator } from '../ui/separator'
|
||||
|
||||
export default memo(function ContainerChart({
|
||||
dataKey,
|
||||
chartData,
|
||||
chartName,
|
||||
unit = '%',
|
||||
}: {
|
||||
dataKey: string
|
||||
chartData: ChartData
|
||||
chartName: string
|
||||
unit?: string
|
||||
}) {
|
||||
const filter = useStore($containerFilter)
|
||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||
|
||||
const { containerData } = chartData
|
||||
|
||||
const isNetChart = chartName === 'net'
|
||||
|
||||
const chartConfig = useMemo(() => {
|
||||
let config = {} as Record<
|
||||
string,
|
||||
{
|
||||
label: string
|
||||
color: string
|
||||
}
|
||||
>
|
||||
const totalUsage = {} as Record<string, number>
|
||||
for (let stats of containerData) {
|
||||
for (let key in stats) {
|
||||
if (!key || key === 'created') {
|
||||
continue
|
||||
}
|
||||
if (!(key in totalUsage)) {
|
||||
totalUsage[key] = 0
|
||||
}
|
||||
if (isNetChart) {
|
||||
totalUsage[key] += (stats[key]?.nr ?? 0) + (stats[key]?.ns ?? 0)
|
||||
} else {
|
||||
// @ts-ignore
|
||||
totalUsage[key] += stats[key]?.[dataKey] ?? 0
|
||||
}
|
||||
}
|
||||
}
|
||||
let keys = Object.keys(totalUsage)
|
||||
keys.sort((a, b) => (totalUsage[a] > totalUsage[b] ? -1 : 1))
|
||||
const length = keys.length
|
||||
for (let i = 0; i < length; i++) {
|
||||
const key = keys[i]
|
||||
const hue = ((i * 360) / length) % 360
|
||||
config[key] = {
|
||||
label: key,
|
||||
color: `hsl(${hue}, 60%, 55%)`,
|
||||
}
|
||||
}
|
||||
return config satisfies ChartConfig
|
||||
}, [chartData])
|
||||
|
||||
const { toolTipFormatter, dataFunction, tickFormatter } = useMemo(() => {
|
||||
const obj = {} as {
|
||||
toolTipFormatter: (item: any, key: string) => React.ReactNode | string
|
||||
dataFunction: (key: string, data: any) => number | null
|
||||
tickFormatter: (value: any) => string
|
||||
}
|
||||
// tick formatter
|
||||
if (chartName === 'cpu') {
|
||||
obj.tickFormatter = (value) => {
|
||||
const val = toFixedWithoutTrailingZeros(value, 2) + unit
|
||||
return updateYAxisWidth(val)
|
||||
}
|
||||
} else {
|
||||
obj.tickFormatter = (value) => {
|
||||
const { v, u } = getSizeAndUnit(value, false)
|
||||
return updateYAxisWidth(`${toFixedFloat(v, 2)}${u}${isNetChart ? '/s' : ''}`)
|
||||
}
|
||||
}
|
||||
// tooltip formatter
|
||||
if (isNetChart) {
|
||||
obj.toolTipFormatter = (item: any, key: string) => {
|
||||
try {
|
||||
const sent = item?.payload?.[key]?.ns ?? 0
|
||||
const received = item?.payload?.[key]?.nr ?? 0
|
||||
return (
|
||||
<span className="flex">
|
||||
{decimalString(received)} MB/s
|
||||
<span className="opacity-70 ml-0.5"> rx </span>
|
||||
<Separator orientation="vertical" className="h-3 mx-1.5 bg-primary/40" />
|
||||
{decimalString(sent)} MB/s
|
||||
<span className="opacity-70 ml-0.5"> tx</span>
|
||||
</span>
|
||||
)
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
} else {
|
||||
obj.toolTipFormatter = (item: any) => decimalString(item.value) + unit
|
||||
}
|
||||
// data function
|
||||
if (isNetChart) {
|
||||
obj.dataFunction = (key: string, data: any) => (data[key]?.nr ?? 0) + (data[key]?.ns ?? 0)
|
||||
} else {
|
||||
obj.dataFunction = (key: string, data: any) => data[key]?.[dataKey] ?? 0
|
||||
}
|
||||
return obj
|
||||
}, [])
|
||||
|
||||
// console.log('rendered at', new Date())
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ChartContainer
|
||||
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
|
||||
'opacity-100': yAxisWidth,
|
||||
})}
|
||||
>
|
||||
<AreaChart
|
||||
accessibilityLayer
|
||||
// syncId={'cpu'}
|
||||
data={containerData}
|
||||
margin={chartMargin}
|
||||
reverseStackOrder={true}
|
||||
>
|
||||
<CartesianGrid vertical={false} />
|
||||
<YAxis
|
||||
className="tracking-tighter"
|
||||
width={yAxisWidth}
|
||||
tickFormatter={tickFormatter}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
{xAxis(chartData)}
|
||||
<ChartTooltip
|
||||
animationEasing="ease-out"
|
||||
animationDuration={150}
|
||||
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
||||
// @ts-ignore
|
||||
itemSorter={(a, b) => b.value - a.value}
|
||||
content={<ChartTooltipContent filter={filter} contentFormatter={toolTipFormatter} />}
|
||||
/>
|
||||
{Object.keys(chartConfig).map((key) => {
|
||||
const filtered = filter && !key.includes(filter)
|
||||
let fillOpacity = filtered ? 0.05 : 0.4
|
||||
let strokeOpacity = filtered ? 0.1 : 1
|
||||
return (
|
||||
<Area
|
||||
key={key}
|
||||
isAnimationActive={false}
|
||||
dataKey={dataFunction.bind(null, key)}
|
||||
name={key}
|
||||
type="monotoneX"
|
||||
fill={chartConfig[key].color}
|
||||
fillOpacity={fillOpacity}
|
||||
stroke={chartConfig[key].color}
|
||||
strokeOpacity={strokeOpacity}
|
||||
activeDot={{ opacity: filtered ? 0 : 1 }}
|
||||
stackId="a"
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -1,143 +0,0 @@
|
||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
|
||||
import {
|
||||
ChartConfig,
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from '@/components/ui/chart'
|
||||
import { useMemo } from 'react'
|
||||
import { useYAxisWidth, chartTimeData, cn, formatShortDate, twoDecimalString } from '@/lib/utils'
|
||||
// import Spinner from '../spinner'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { $chartTime, $containerFilter } from '@/lib/stores'
|
||||
|
||||
export default function ContainerCpuChart({
|
||||
chartData,
|
||||
ticks,
|
||||
}: {
|
||||
chartData: Record<string, number | string>[]
|
||||
ticks: number[]
|
||||
}) {
|
||||
const chartTime = useStore($chartTime)
|
||||
const filter = useStore($containerFilter)
|
||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||
|
||||
const chartConfig = useMemo(() => {
|
||||
let config = {} as Record<
|
||||
string,
|
||||
{
|
||||
label: string
|
||||
color: string
|
||||
}
|
||||
>
|
||||
const totalUsage = {} as Record<string, number>
|
||||
for (let stats of chartData) {
|
||||
for (let key in stats) {
|
||||
if (key === 'time') {
|
||||
continue
|
||||
}
|
||||
if (!(key in totalUsage)) {
|
||||
totalUsage[key] = 0
|
||||
}
|
||||
// @ts-ignore
|
||||
totalUsage[key] += stats[key]
|
||||
}
|
||||
}
|
||||
let keys = Object.keys(totalUsage)
|
||||
keys.sort((a, b) => (totalUsage[a] > totalUsage[b] ? -1 : 1))
|
||||
const length = keys.length
|
||||
for (let i = 0; i < length; i++) {
|
||||
const key = keys[i]
|
||||
const hue = ((i * 360) / length) % 360
|
||||
config[key] = {
|
||||
label: key,
|
||||
color: `hsl(${hue}, 60%, 55%)`,
|
||||
}
|
||||
}
|
||||
return config satisfies ChartConfig
|
||||
}, [chartData])
|
||||
|
||||
// if (!chartData.length || !ticks.length) {
|
||||
// return <Spinner />
|
||||
// }
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* {!yAxisSet && <Spinner />} */}
|
||||
<ChartContainer
|
||||
config={{}}
|
||||
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
|
||||
'opacity-100': yAxisWidth,
|
||||
})}
|
||||
>
|
||||
<AreaChart
|
||||
accessibilityLayer
|
||||
// syncId={'cpu'}
|
||||
data={chartData}
|
||||
margin={{
|
||||
top: 10,
|
||||
}}
|
||||
reverseStackOrder={true}
|
||||
>
|
||||
<CartesianGrid vertical={false} />
|
||||
<YAxis
|
||||
className="tracking-tighter"
|
||||
// domain={[0, (max: number) => Math.max(Math.ceil(max), 0.4)]}
|
||||
width={yAxisWidth}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(x) => {
|
||||
const val = (x % 1 === 0 ? x : x.toFixed(1)) + '%'
|
||||
return updateYAxisWidth(val)
|
||||
}}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
domain={[ticks[0], ticks.at(-1)!]}
|
||||
ticks={ticks}
|
||||
type="number"
|
||||
scale={'time'}
|
||||
minTickGap={35}
|
||||
tickMargin={8}
|
||||
axisLine={false}
|
||||
tickFormatter={chartTimeData[chartTime].format}
|
||||
/>
|
||||
<ChartTooltip
|
||||
// cursor={false}
|
||||
animationEasing="ease-out"
|
||||
animationDuration={150}
|
||||
labelFormatter={(_, data) => formatShortDate(data[0].payload.time)}
|
||||
// @ts-ignore
|
||||
itemSorter={(a, b) => b.value - a.value}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
filter={filter}
|
||||
contentFormatter={(item) => twoDecimalString(item.value) + '%'}
|
||||
indicator="line"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{Object.keys(chartConfig).map((key) => {
|
||||
const filtered = filter && !key.includes(filter)
|
||||
let fillOpacity = filtered ? 0.05 : 0.4
|
||||
let strokeOpacity = filtered ? 0.1 : 1
|
||||
return (
|
||||
<Area
|
||||
key={key}
|
||||
isAnimationActive={false}
|
||||
dataKey={key}
|
||||
type="monotoneX"
|
||||
fill={chartConfig[key].color}
|
||||
fillOpacity={fillOpacity}
|
||||
stroke={chartConfig[key].color}
|
||||
strokeOpacity={strokeOpacity}
|
||||
activeDot={{ opacity: filtered ? 0 : 1 }}
|
||||
stackId="a"
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
|
||||
import {
|
||||
ChartConfig,
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from '@/components/ui/chart'
|
||||
import { useMemo } from 'react'
|
||||
import {
|
||||
useYAxisWidth,
|
||||
chartTimeData,
|
||||
cn,
|
||||
formatShortDate,
|
||||
toFixedWithoutTrailingZeros,
|
||||
twoDecimalString,
|
||||
} from '@/lib/utils'
|
||||
// import Spinner from '../spinner'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { $chartTime, $containerFilter } from '@/lib/stores'
|
||||
|
||||
export default function ContainerMemChart({
|
||||
chartData,
|
||||
ticks,
|
||||
}: {
|
||||
chartData: Record<string, number | string>[]
|
||||
ticks: number[]
|
||||
}) {
|
||||
const chartTime = useStore($chartTime)
|
||||
const filter = useStore($containerFilter)
|
||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||
|
||||
const chartConfig = useMemo(() => {
|
||||
let config = {} as Record<
|
||||
string,
|
||||
{
|
||||
label: string
|
||||
color: string
|
||||
}
|
||||
>
|
||||
const totalUsage = {} as Record<string, number>
|
||||
for (let stats of chartData) {
|
||||
for (let key in stats) {
|
||||
if (key === 'time') {
|
||||
continue
|
||||
}
|
||||
if (!(key in totalUsage)) {
|
||||
totalUsage[key] = 0
|
||||
}
|
||||
// @ts-ignore
|
||||
totalUsage[key] += stats[key]
|
||||
}
|
||||
}
|
||||
let keys = Object.keys(totalUsage)
|
||||
keys.sort((a, b) => (totalUsage[a] > totalUsage[b] ? -1 : 1))
|
||||
const length = keys.length
|
||||
for (let i = 0; i < length; i++) {
|
||||
const key = keys[i]
|
||||
const hue = ((i * 360) / length) % 360
|
||||
config[key] = {
|
||||
label: key,
|
||||
color: `hsl(${hue}, 60%, 55%)`,
|
||||
}
|
||||
}
|
||||
return config satisfies ChartConfig
|
||||
}, [chartData])
|
||||
|
||||
// if (!chartData.length || !ticks.length) {
|
||||
// return <Spinner />
|
||||
// }
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* {!yAxisSet && <Spinner />} */}
|
||||
<ChartContainer
|
||||
config={{}}
|
||||
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
|
||||
'opacity-100': yAxisWidth,
|
||||
})}
|
||||
>
|
||||
<AreaChart
|
||||
accessibilityLayer
|
||||
data={chartData}
|
||||
reverseStackOrder={true}
|
||||
margin={{
|
||||
top: 10,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid vertical={false} />
|
||||
<YAxis
|
||||
className="tracking-tighter"
|
||||
// domain={[0, (max: number) => Math.ceil(max)]}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={yAxisWidth}
|
||||
tickFormatter={(value) => {
|
||||
const val = toFixedWithoutTrailingZeros(value / 1024, 2) + ' GB'
|
||||
return updateYAxisWidth(val)
|
||||
}}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
domain={[ticks[0], ticks.at(-1)!]}
|
||||
ticks={ticks}
|
||||
type="number"
|
||||
scale={'time'}
|
||||
minTickGap={35}
|
||||
tickMargin={8}
|
||||
axisLine={false}
|
||||
tickFormatter={chartTimeData[chartTime].format}
|
||||
/>
|
||||
<ChartTooltip
|
||||
// cursor={false}
|
||||
animationEasing="ease-out"
|
||||
animationDuration={150}
|
||||
labelFormatter={(_, data) => formatShortDate(data[0].payload.time)}
|
||||
// @ts-ignore
|
||||
itemSorter={(a, b) => b.value - a.value}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
filter={filter}
|
||||
contentFormatter={(item) => twoDecimalString(item.value) + ' MB'}
|
||||
indicator="line"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{Object.keys(chartConfig).map((key) => {
|
||||
const filtered = filter && !key.includes(filter)
|
||||
let fillOpacity = filtered ? 0.05 : 0.4
|
||||
let strokeOpacity = filtered ? 0.1 : 1
|
||||
return (
|
||||
<Area
|
||||
key={key}
|
||||
isAnimationActive={false}
|
||||
dataKey={key}
|
||||
type="monotoneX"
|
||||
fill={chartConfig[key].color}
|
||||
strokeOpacity={strokeOpacity}
|
||||
fillOpacity={fillOpacity}
|
||||
stroke={chartConfig[key].color}
|
||||
activeDot={filtered ? false : {}}
|
||||
stackId="a"
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
|
||||
import {
|
||||
ChartConfig,
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from '@/components/ui/chart'
|
||||
import { useMemo } from 'react'
|
||||
import {
|
||||
useYAxisWidth,
|
||||
chartTimeData,
|
||||
cn,
|
||||
formatShortDate,
|
||||
toFixedWithoutTrailingZeros,
|
||||
twoDecimalString,
|
||||
} from '@/lib/utils'
|
||||
// import Spinner from '../spinner'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { $chartTime, $containerFilter } from '@/lib/stores'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
|
||||
export default function ContainerCpuChart({
|
||||
chartData,
|
||||
ticks,
|
||||
}: {
|
||||
chartData: Record<string, number | number[]>[]
|
||||
ticks: number[]
|
||||
}) {
|
||||
const chartTime = useStore($chartTime)
|
||||
const filter = useStore($containerFilter)
|
||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||
|
||||
const chartConfig = useMemo(() => {
|
||||
let config = {} as Record<
|
||||
string,
|
||||
{
|
||||
label: string
|
||||
color: string
|
||||
}
|
||||
>
|
||||
const totalUsage = {} as Record<string, number>
|
||||
for (let stats of chartData) {
|
||||
for (let key in stats) {
|
||||
if (!Array.isArray(stats[key])) {
|
||||
continue
|
||||
}
|
||||
if (!(key in totalUsage)) {
|
||||
totalUsage[key] = 0
|
||||
}
|
||||
totalUsage[key] += stats[key][2] ?? 0
|
||||
}
|
||||
}
|
||||
let keys = Object.keys(totalUsage)
|
||||
keys.sort((a, b) => (totalUsage[a] > totalUsage[b] ? -1 : 1))
|
||||
const length = keys.length
|
||||
for (let i = 0; i < length; i++) {
|
||||
const key = keys[i]
|
||||
const hue = ((i * 360) / length) % 360
|
||||
config[key] = {
|
||||
label: key,
|
||||
color: `hsl(${hue}, 60%, 55%)`,
|
||||
}
|
||||
}
|
||||
return config satisfies ChartConfig
|
||||
}, [chartData])
|
||||
|
||||
// if (!chartData.length || !ticks.length) {
|
||||
// return <Spinner />
|
||||
// }
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* {!yAxisSet && <Spinner />} */}
|
||||
<ChartContainer
|
||||
config={{}}
|
||||
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
|
||||
'opacity-100': yAxisWidth,
|
||||
})}
|
||||
>
|
||||
<AreaChart
|
||||
accessibilityLayer
|
||||
data={chartData}
|
||||
margin={{
|
||||
top: 10,
|
||||
}}
|
||||
reverseStackOrder={true}
|
||||
>
|
||||
<CartesianGrid vertical={false} />
|
||||
<YAxis
|
||||
className="tracking-tighter"
|
||||
// domain={[0, (max: number) => Math.max(Math.ceil(max), 0.4)]}
|
||||
width={yAxisWidth}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => {
|
||||
const val = toFixedWithoutTrailingZeros(value, 2) + ' MB/s'
|
||||
return updateYAxisWidth(val)
|
||||
}}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
domain={[ticks[0], ticks.at(-1)!]}
|
||||
ticks={ticks}
|
||||
type="number"
|
||||
scale={'time'}
|
||||
minTickGap={35}
|
||||
tickMargin={8}
|
||||
axisLine={false}
|
||||
tickFormatter={chartTimeData[chartTime].format}
|
||||
/>
|
||||
<ChartTooltip
|
||||
// cursor={false}
|
||||
animationEasing="ease-out"
|
||||
animationDuration={150}
|
||||
labelFormatter={(_, data) => formatShortDate(data[0].payload.time)}
|
||||
// @ts-ignore
|
||||
itemSorter={(a, b) => b.value - a.value}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
filter={filter}
|
||||
indicator="line"
|
||||
contentFormatter={(item, key) => {
|
||||
try {
|
||||
const sent = item?.payload?.[key][0] ?? 0
|
||||
const received = item?.payload?.[key][1] ?? 0
|
||||
return (
|
||||
<span className="flex">
|
||||
{twoDecimalString(received)} MB/s
|
||||
<span className="opacity-70 ml-0.5"> rx </span>
|
||||
<Separator orientation="vertical" className="h-3 mx-1.5 bg-primary/40" />
|
||||
{twoDecimalString(sent)} MB/s<span className="opacity-70 ml-0.5"> tx</span>
|
||||
</span>
|
||||
)
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{Object.keys(chartConfig).map((key) => {
|
||||
const filtered = filter && !key.includes(filter)
|
||||
let fillOpacity = filtered ? 0.05 : 0.4
|
||||
let strokeOpacity = filtered ? 0.1 : 1
|
||||
return (
|
||||
<Area
|
||||
key={key}
|
||||
name={key}
|
||||
// animationDuration={1200}
|
||||
isAnimationActive={false}
|
||||
dataKey={(data) => data?.[key]?.[2] ?? 0}
|
||||
type="monotoneX"
|
||||
fill={chartConfig[key].color}
|
||||
fillOpacity={fillOpacity}
|
||||
stroke={chartConfig[key].color}
|
||||
strokeOpacity={strokeOpacity}
|
||||
activeDot={{ opacity: filtered ? 0 : 1 }}
|
||||
stackId="a"
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
|
||||
|
||||
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'
|
||||
import { useYAxisWidth, chartTimeData, cn, formatShortDate, twoDecimalString } from '@/lib/utils'
|
||||
// import Spinner from '../spinner'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { $chartTime } from '@/lib/stores'
|
||||
import { SystemStatsRecord } from '@/types'
|
||||
|
||||
export default function CpuChart({
|
||||
ticks,
|
||||
systemData,
|
||||
}: {
|
||||
ticks: number[]
|
||||
systemData: SystemStatsRecord[]
|
||||
}) {
|
||||
const chartTime = useStore($chartTime)
|
||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ChartContainer
|
||||
config={{}}
|
||||
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
|
||||
'opacity-100': yAxisWidth,
|
||||
})}
|
||||
>
|
||||
<AreaChart
|
||||
accessibilityLayer
|
||||
data={systemData}
|
||||
margin={{ top: 10 }}
|
||||
// syncId={'cpu'}
|
||||
>
|
||||
<CartesianGrid vertical={false} />
|
||||
<YAxis
|
||||
className="tracking-tighter"
|
||||
// domain={[0, (max: number) => Math.ceil(max)]}
|
||||
width={yAxisWidth}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => updateYAxisWidth(value + '%')}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="created"
|
||||
domain={[ticks[0], ticks.at(-1)!]}
|
||||
ticks={ticks}
|
||||
type="number"
|
||||
scale={'time'}
|
||||
minTickGap={35}
|
||||
tickMargin={8}
|
||||
axisLine={false}
|
||||
tickFormatter={chartTimeData[chartTime].format}
|
||||
/>
|
||||
<ChartTooltip
|
||||
animationEasing="ease-out"
|
||||
animationDuration={150}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
||||
contentFormatter={(item) => twoDecimalString(item.value) + '%'}
|
||||
indicator="line"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Area
|
||||
dataKey="stats.cpu"
|
||||
name="CPU Usage"
|
||||
type="monotoneX"
|
||||
fill="hsl(var(--chart-1))"
|
||||
fillOpacity={0.4}
|
||||
stroke="hsl(var(--chart-1))"
|
||||
isAnimationActive={false}
|
||||
// animationEasing="ease-out"
|
||||
// animationDuration={1200}
|
||||
// animateNewValues={true}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,55 +1,37 @@
|
||||
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,
|
||||
twoDecimalString,
|
||||
decimalString,
|
||||
toFixedFloat,
|
||||
getSizeVal,
|
||||
getSizeUnit,
|
||||
chartMargin,
|
||||
getSizeAndUnit,
|
||||
} from '@/lib/utils'
|
||||
// import { useMemo } from 'react'
|
||||
// import Spinner from '../spinner'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { $chartTime } from '@/lib/stores'
|
||||
import { SystemStatsRecord } from '@/types'
|
||||
import { ChartData } from '@/types'
|
||||
import { memo } from 'react'
|
||||
|
||||
export default function DiskChart({
|
||||
ticks,
|
||||
systemData,
|
||||
export default memo(function DiskChart({
|
||||
dataKey,
|
||||
diskSize,
|
||||
chartData,
|
||||
}: {
|
||||
ticks: number[]
|
||||
systemData: SystemStatsRecord[]
|
||||
dataKey: string
|
||||
diskSize: number
|
||||
chartData: ChartData
|
||||
}) {
|
||||
const chartTime = useStore($chartTime)
|
||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* {!yAxisSet && <Spinner />} */}
|
||||
<ChartContainer
|
||||
config={{}}
|
||||
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
|
||||
'opacity-100': yAxisWidth,
|
||||
})}
|
||||
>
|
||||
<AreaChart
|
||||
accessibilityLayer
|
||||
data={systemData}
|
||||
margin={{
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 10,
|
||||
bottom: 0,
|
||||
}}
|
||||
>
|
||||
<AreaChart accessibilityLayer data={chartData.systemStats} margin={chartMargin}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<YAxis
|
||||
className="tracking-tighter"
|
||||
@@ -59,31 +41,22 @@ export default function DiskChart({
|
||||
minTickGap={6}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) =>
|
||||
updateYAxisWidth(toFixedFloat(getSizeVal(value), 2) + getSizeUnit(value))
|
||||
}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="created"
|
||||
domain={[ticks[0], ticks.at(-1)!]}
|
||||
ticks={ticks}
|
||||
type="number"
|
||||
scale={'time'}
|
||||
minTickGap={35}
|
||||
tickMargin={8}
|
||||
axisLine={false}
|
||||
tickFormatter={chartTimeData[chartTime].format}
|
||||
tickFormatter={(value) => {
|
||||
const { v, u } = getSizeAndUnit(value)
|
||||
return updateYAxisWidth(toFixedFloat(v, 2) + u)
|
||||
}}
|
||||
/>
|
||||
{xAxis(chartData)}
|
||||
<ChartTooltip
|
||||
animationEasing="ease-out"
|
||||
animationDuration={150}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
||||
contentFormatter={({ value }) =>
|
||||
twoDecimalString(getSizeVal(value)) + getSizeUnit(value)
|
||||
}
|
||||
indicator="line"
|
||||
contentFormatter={({ value }) => {
|
||||
const { v, u } = getSizeAndUnit(value)
|
||||
return decimalString(v) + u
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -101,4 +74,4 @@ export default function DiskChart({
|
||||
</ChartContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
|
||||
|
||||
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'
|
||||
import {
|
||||
useYAxisWidth,
|
||||
chartTimeData,
|
||||
cn,
|
||||
formatShortDate,
|
||||
toFixedWithoutTrailingZeros,
|
||||
twoDecimalString,
|
||||
} from '@/lib/utils'
|
||||
// import Spinner from '../spinner'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { $chartTime } from '@/lib/stores'
|
||||
import { SystemStatsRecord } from '@/types'
|
||||
|
||||
export default function DiskIoChart({
|
||||
ticks,
|
||||
systemData,
|
||||
dataKeys,
|
||||
}: {
|
||||
ticks: number[]
|
||||
systemData: SystemStatsRecord[]
|
||||
dataKeys: string[]
|
||||
}) {
|
||||
const chartTime = useStore($chartTime)
|
||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* {!yAxisSet && <Spinner />} */}
|
||||
<ChartContainer
|
||||
config={{}}
|
||||
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
|
||||
'opacity-100': yAxisWidth,
|
||||
})}
|
||||
>
|
||||
<AreaChart
|
||||
accessibilityLayer
|
||||
data={systemData}
|
||||
margin={{
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 10,
|
||||
bottom: 0,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid vertical={false} />
|
||||
<YAxis
|
||||
className="tracking-tighter"
|
||||
width={yAxisWidth}
|
||||
// domain={[0, (max: number) => (max <= 0.4 ? 0.4 : Math.ceil(max))]}
|
||||
tickFormatter={(value) => {
|
||||
const val = toFixedWithoutTrailingZeros(value, 2) + ' MB/s'
|
||||
return updateYAxisWidth(val)
|
||||
}}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="created"
|
||||
domain={[ticks[0], ticks.at(-1)!]}
|
||||
ticks={ticks}
|
||||
type="number"
|
||||
scale={'time'}
|
||||
minTickGap={35}
|
||||
tickMargin={8}
|
||||
axisLine={false}
|
||||
tickFormatter={chartTimeData[chartTime].format}
|
||||
/>
|
||||
<ChartTooltip
|
||||
animationEasing="ease-out"
|
||||
animationDuration={150}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
||||
contentFormatter={(item) => twoDecimalString(item.value) + ' MB/s'}
|
||||
indicator="line"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{dataKeys.map((dataKey, i) => {
|
||||
const action = i ? 'Read' : 'Write'
|
||||
const color = i ? 'hsl(var(--chart-1))' : 'hsl(var(--chart-3))'
|
||||
return (
|
||||
<Area
|
||||
key={i}
|
||||
dataKey={dataKey}
|
||||
name={action}
|
||||
type="monotoneX"
|
||||
fill={color}
|
||||
fillOpacity={0.3}
|
||||
stroke={color}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,49 +1,33 @@
|
||||
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,
|
||||
twoDecimalString,
|
||||
decimalString,
|
||||
formatShortDate,
|
||||
chartMargin,
|
||||
} from '@/lib/utils'
|
||||
import { useMemo } from 'react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { $chartTime } from '@/lib/stores'
|
||||
import { SystemStatsRecord } from '@/types'
|
||||
import { memo } from 'react'
|
||||
import { ChartData } from '@/types'
|
||||
|
||||
export default function MemChart({
|
||||
ticks,
|
||||
systemData,
|
||||
}: {
|
||||
ticks: number[]
|
||||
systemData: SystemStatsRecord[]
|
||||
}) {
|
||||
const chartTime = useStore($chartTime)
|
||||
export default memo(function MemChart({ chartData }: { chartData: ChartData }) {
|
||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||
|
||||
const totalMem = useMemo(() => {
|
||||
return toFixedFloat(systemData.at(-1)?.stats.m ?? 0, 1)
|
||||
}, [systemData])
|
||||
const totalMem = toFixedFloat(chartData.systemStats.at(-1)?.stats.m ?? 0, 1)
|
||||
|
||||
// console.log('rendered at', new Date())
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* {!yAxisSet && <Spinner />} */}
|
||||
<ChartContainer
|
||||
config={{}}
|
||||
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
|
||||
'opacity-100': yAxisWidth,
|
||||
})}
|
||||
>
|
||||
<AreaChart
|
||||
accessibilityLayer
|
||||
data={systemData}
|
||||
margin={{
|
||||
top: 10,
|
||||
}}
|
||||
>
|
||||
<AreaChart accessibilityLayer data={chartData.systemStats} margin={chartMargin}>
|
||||
<CartesianGrid vertical={false} />
|
||||
{totalMem && (
|
||||
<YAxis
|
||||
@@ -60,17 +44,7 @@ export default function MemChart({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<XAxis
|
||||
dataKey="created"
|
||||
domain={[ticks[0], ticks.at(-1)!]}
|
||||
ticks={ticks}
|
||||
type="number"
|
||||
scale={'time'}
|
||||
minTickGap={35}
|
||||
tickMargin={8}
|
||||
axisLine={false}
|
||||
tickFormatter={chartTimeData[chartTime].format}
|
||||
/>
|
||||
{xAxis(chartData)}
|
||||
<ChartTooltip
|
||||
// cursor={false}
|
||||
animationEasing="ease-out"
|
||||
@@ -80,8 +54,8 @@ export default function MemChart({
|
||||
// @ts-ignore
|
||||
itemSorter={(a, b) => a.order - b.order}
|
||||
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
||||
contentFormatter={(item) => twoDecimalString(item.value) + ' GB'}
|
||||
indicator="line"
|
||||
contentFormatter={(item) => decimalString(item.value) + ' GB'}
|
||||
// indicator="line"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -96,7 +70,7 @@ export default function MemChart({
|
||||
stackId="1"
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
{systemData.at(-1)?.stats.mz && (
|
||||
{chartData.systemStats.at(-1)?.stats.mz && (
|
||||
<Area
|
||||
name="ZFS ARC"
|
||||
order={2}
|
||||
@@ -125,4 +99,4 @@ export default function MemChart({
|
||||
</ChartContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,66 +1,49 @@
|
||||
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,
|
||||
twoDecimalString,
|
||||
decimalString,
|
||||
chartMargin,
|
||||
} from '@/lib/utils'
|
||||
// import Spinner from '../spinner'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { $chartTime } from '@/lib/stores'
|
||||
import { SystemStatsRecord } from '@/types'
|
||||
import { ChartData } from '@/types'
|
||||
import { memo } from 'react'
|
||||
|
||||
export default function SwapChart({
|
||||
ticks,
|
||||
systemData,
|
||||
}: {
|
||||
ticks: number[]
|
||||
systemData: SystemStatsRecord[]
|
||||
}) {
|
||||
const chartTime = useStore($chartTime)
|
||||
export default memo(function SwapChart({ chartData }: { chartData: ChartData }) {
|
||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ChartContainer
|
||||
config={{}}
|
||||
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
|
||||
'opacity-100': yAxisWidth,
|
||||
})}
|
||||
>
|
||||
<AreaChart accessibilityLayer data={systemData} margin={{ top: 10 }}>
|
||||
<AreaChart accessibilityLayer data={chartData.systemStats} margin={chartMargin}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<YAxis
|
||||
className="tracking-tighter"
|
||||
domain={[0, () => toFixedWithoutTrailingZeros(systemData.at(-1)?.stats.s ?? 0.04, 2)]}
|
||||
domain={[
|
||||
0,
|
||||
() => toFixedWithoutTrailingZeros(chartData.systemStats.at(-1)?.stats.s ?? 0.04, 2),
|
||||
]}
|
||||
width={yAxisWidth}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => updateYAxisWidth(value + ' GB')}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="created"
|
||||
domain={[ticks[0], ticks.at(-1)!]}
|
||||
ticks={ticks}
|
||||
type="number"
|
||||
scale={'time'}
|
||||
minTickGap={35}
|
||||
tickMargin={8}
|
||||
axisLine={false}
|
||||
tickFormatter={chartTimeData[chartTime].format}
|
||||
/>
|
||||
{xAxis(chartData)}
|
||||
<ChartTooltip
|
||||
animationEasing="ease-out"
|
||||
animationDuration={150}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
||||
contentFormatter={(item) => twoDecimalString(item.value) + ' GB'}
|
||||
indicator="line"
|
||||
contentFormatter={(item) => decimalString(item.value) + ' GB'}
|
||||
// indicator="line"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -77,4 +60,4 @@ export default function SwapChart({
|
||||
</ChartContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from 'recharts'
|
||||
import { CartesianGrid, Line, LineChart, YAxis } from 'recharts'
|
||||
|
||||
import {
|
||||
ChartContainer,
|
||||
@@ -6,38 +6,30 @@ import {
|
||||
ChartLegendContent,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
xAxis,
|
||||
} from '@/components/ui/chart'
|
||||
import {
|
||||
useYAxisWidth,
|
||||
chartTimeData,
|
||||
cn,
|
||||
formatShortDate,
|
||||
toFixedWithoutTrailingZeros,
|
||||
twoDecimalString,
|
||||
decimalString,
|
||||
chartMargin,
|
||||
} from '@/lib/utils'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { $chartTime } from '@/lib/stores'
|
||||
import { SystemStatsRecord } from '@/types'
|
||||
import { useMemo } from 'react'
|
||||
import { ChartData } from '@/types'
|
||||
import { memo, useMemo } from 'react'
|
||||
|
||||
export default function TemperatureChart({
|
||||
ticks,
|
||||
systemData,
|
||||
}: {
|
||||
ticks: number[]
|
||||
systemData: SystemStatsRecord[]
|
||||
}) {
|
||||
const chartTime = useStore($chartTime)
|
||||
export default memo(function TemperatureChart({ chartData }: { chartData: ChartData }) {
|
||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||
|
||||
/** Format temperature data for chart and assign colors */
|
||||
const newChartData = useMemo(() => {
|
||||
const chartData = { data: [], colors: {} } as {
|
||||
const newChartData = { data: [], colors: {} } as {
|
||||
data: Record<string, number | string>[]
|
||||
colors: Record<string, string>
|
||||
}
|
||||
const tempSums = {} as Record<string, number>
|
||||
for (let data of systemData) {
|
||||
for (let data of chartData.systemStats) {
|
||||
let newData = { created: data.created } as Record<string, number | string>
|
||||
let keys = Object.keys(data.stats?.t ?? {})
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
@@ -45,36 +37,27 @@ export default function TemperatureChart({
|
||||
newData[key] = data.stats.t![key]
|
||||
tempSums[key] = (tempSums[key] ?? 0) + newData[key]
|
||||
}
|
||||
chartData.data.push(newData)
|
||||
newChartData.data.push(newData)
|
||||
}
|
||||
const keys = Object.keys(tempSums).sort((a, b) => tempSums[b] - tempSums[a])
|
||||
for (let key of keys) {
|
||||
chartData.colors[key] = `hsl(${((keys.indexOf(key) * 360) / keys.length) % 360}, 60%, 55%)`
|
||||
newChartData.colors[key] = `hsl(${((keys.indexOf(key) * 360) / keys.length) % 360}, 60%, 55%)`
|
||||
}
|
||||
return chartData
|
||||
}, [systemData])
|
||||
return newChartData
|
||||
}, [chartData])
|
||||
|
||||
const colors = Object.keys(newChartData.colors)
|
||||
|
||||
// console.log('rendered at', new Date())
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* {!yAxisSet && <Spinner />} */}
|
||||
<ChartContainer
|
||||
config={{}}
|
||||
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
|
||||
'opacity-100': yAxisWidth,
|
||||
})}
|
||||
>
|
||||
<LineChart
|
||||
accessibilityLayer
|
||||
data={newChartData.data}
|
||||
margin={{
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 10,
|
||||
bottom: 0,
|
||||
}}
|
||||
>
|
||||
<LineChart accessibilityLayer data={newChartData.data} margin={chartMargin}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<YAxis
|
||||
className="tracking-tighter"
|
||||
@@ -87,17 +70,7 @@ export default function TemperatureChart({
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="created"
|
||||
domain={[ticks[0], ticks.at(-1)!]}
|
||||
ticks={ticks}
|
||||
type="number"
|
||||
scale={'time'}
|
||||
minTickGap={35}
|
||||
tickMargin={8}
|
||||
axisLine={false}
|
||||
tickFormatter={chartTimeData[chartTime].format}
|
||||
/>
|
||||
{xAxis(chartData)}
|
||||
<ChartTooltip
|
||||
animationEasing="ease-out"
|
||||
animationDuration={150}
|
||||
@@ -106,8 +79,8 @@ export default function TemperatureChart({
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
||||
contentFormatter={(item) => twoDecimalString(item.value) + ' °C'}
|
||||
indicator="line"
|
||||
contentFormatter={(item) => decimalString(item.value) + ' °C'}
|
||||
// indicator="line"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -128,4 +101,4 @@ export default function TemperatureChart({
|
||||
</ChartContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -143,7 +143,7 @@ export default function CommandPalette() {
|
||||
}}
|
||||
>
|
||||
<DatabaseBackupIcon className="mr-2 h-4 w-4" />
|
||||
<span>Database backups</span>
|
||||
<span>Backups</span>
|
||||
<CommandShortcut>Admin</CommandShortcut>
|
||||
</CommandItem>
|
||||
<CommandItem
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -1,18 +1,35 @@
|
||||
import { Suspense, lazy, useEffect, useState } from 'react'
|
||||
import { Suspense, lazy, useEffect, useMemo, useState } from 'react'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card'
|
||||
import { $alerts, $hubVersion, $systems, pb } from '@/lib/stores'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { GithubIcon } from 'lucide-react'
|
||||
import { Separator } from '../ui/separator'
|
||||
import { updateRecordList, updateSystemList } from '@/lib/utils'
|
||||
import { alertInfo, updateRecordList, updateSystemList } from '@/lib/utils'
|
||||
import { AlertRecord, SystemRecord } from '@/types'
|
||||
import { Input } from '../ui/input'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Link } from '../router'
|
||||
|
||||
const SystemsTable = lazy(() => import('../systems-table/systems-table'))
|
||||
|
||||
export default function () {
|
||||
const hubVersion = useStore($hubVersion)
|
||||
const [filter, setFilter] = useState<string>()
|
||||
const alerts = useStore($alerts)
|
||||
const systems = useStore($systems)
|
||||
|
||||
// todo: maybe remove active alert if changed
|
||||
const activeAlerts = useMemo(() => {
|
||||
const activeAlerts = alerts.filter((alert) => {
|
||||
const active = alert.triggered && alert.name in alertInfo
|
||||
if (!active) {
|
||||
return false
|
||||
}
|
||||
alert.sysname = systems.find((system) => system.id === alert.system)?.name
|
||||
return true
|
||||
})
|
||||
return activeAlerts
|
||||
}, [alerts])
|
||||
|
||||
useEffect(() => {
|
||||
document.title = 'Dashboard / Beszel'
|
||||
@@ -24,17 +41,57 @@ export default function () {
|
||||
pb.collection<SystemRecord>('systems').subscribe('*', (e) => {
|
||||
updateRecordList(e, $systems)
|
||||
})
|
||||
// todo: add toast if new triggered alert comes in
|
||||
pb.collection<AlertRecord>('alerts').subscribe('*', (e) => {
|
||||
updateRecordList(e, $alerts)
|
||||
})
|
||||
return () => {
|
||||
pb.collection('systems').unsubscribe('*')
|
||||
pb.collection('alerts').unsubscribe('*')
|
||||
// pb.collection('alerts').unsubscribe('*')
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* show active alerts */}
|
||||
{activeAlerts.length > 0 && (
|
||||
<Card className="mb-4">
|
||||
<CardHeader className="pb-4 px-2 sm:px-6 max-sm:pt-5 max-sm:pb-1">
|
||||
<div className="px-2 sm:px-1">
|
||||
<CardTitle>Active Alerts</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="max-sm:p-2">
|
||||
{activeAlerts.length > 0 && (
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-3">
|
||||
{activeAlerts.map((alert) => {
|
||||
const info = alertInfo[alert.name as keyof typeof alertInfo]
|
||||
return (
|
||||
<Alert
|
||||
key={alert.id}
|
||||
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>
|
||||
{alert.sysname} {info.name}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
Exceeds {alert.value}
|
||||
{info.unit} average in last {alert.min} min
|
||||
</AlertDescription>
|
||||
<Link
|
||||
href={`/system/${encodeURIComponent(alert.sysname!)}`}
|
||||
className="absolute inset-0 w-full h-full"
|
||||
aria-label="View system"
|
||||
></Link>
|
||||
</Alert>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
<Card>
|
||||
<CardHeader className="pb-5 px-2 sm:px-6 max-sm:pt-5 max-sm:pb-1">
|
||||
<div className="grid md:flex gap-3 w-full items-end">
|
||||
@@ -61,6 +118,7 @@ export default function () {
|
||||
</Suspense>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{hubVersion && (
|
||||
<div className="flex gap-1.5 justify-end items-center pr-3 sm:pr-6 mt-3.5 text-xs opacity-80">
|
||||
<a
|
||||
|
||||
@@ -1,69 +1,139 @@
|
||||
import { $systems, pb, $chartTime, $containerFilter, $userSettings } from '@/lib/stores'
|
||||
import { ContainerStatsRecord, SystemRecord, SystemStatsRecord } from '@/types'
|
||||
import { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '../ui/card'
|
||||
import {
|
||||
ChartData,
|
||||
ChartTimes,
|
||||
ContainerStatsRecord,
|
||||
SystemRecord,
|
||||
SystemStatsRecord,
|
||||
} from '@/types'
|
||||
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'
|
||||
import { ClockArrowUp, CpuIcon, GlobeIcon, LayoutGridIcon, MonitorIcon, XIcon } from 'lucide-react'
|
||||
import ChartTimeSelect from '../charts/chart-time-select'
|
||||
import { chartTimeData, cn, getPbTimestamp, useLocalStorage } from '@/lib/utils'
|
||||
import { Separator } from '../ui/separator'
|
||||
import { scaleTime } from 'd3-scale'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip'
|
||||
import { Button, buttonVariants } from '../ui/button'
|
||||
import { Button } from '../ui/button'
|
||||
import { Input } from '../ui/input'
|
||||
import { Rows, TuxIcon } from '../ui/icons'
|
||||
import { ChartAverage, ChartMax, Rows, TuxIcon } from '../ui/icons'
|
||||
import { useIntersectionObserver } from '@/lib/use-intersection-observer'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
|
||||
import { timeTicks } from 'd3-time'
|
||||
|
||||
const CpuChart = lazy(() => import('../charts/cpu-chart'))
|
||||
const ContainerCpuChart = lazy(() => import('../charts/container-cpu-chart'))
|
||||
const AreaChartDefault = lazy(() => import('../charts/area-chart'))
|
||||
const ContainerChart = lazy(() => import('../charts/container-chart'))
|
||||
const MemChart = lazy(() => import('../charts/mem-chart'))
|
||||
const ContainerMemChart = lazy(() => import('../charts/container-mem-chart'))
|
||||
const DiskChart = lazy(() => import('../charts/disk-chart'))
|
||||
const DiskIoChart = lazy(() => import('../charts/disk-io-chart'))
|
||||
const BandwidthChart = lazy(() => import('../charts/bandwidth-chart'))
|
||||
const ContainerNetChart = lazy(() => import('../charts/container-net-chart'))
|
||||
const SwapChart = lazy(() => import('../charts/swap-chart'))
|
||||
const TemperatureChart = lazy(() => import('../charts/temperature-chart'))
|
||||
|
||||
const cache = new Map<string, any>()
|
||||
|
||||
// create ticks and domain for charts
|
||||
function getTimeData(chartTime: ChartTimes, lastCreated: number) {
|
||||
const cached = cache.get('td')
|
||||
if (cached && cached.chartTime === chartTime) {
|
||||
if (!lastCreated || cached.time >= lastCreated) {
|
||||
return cached.data
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const startTime = chartTimeData[chartTime].getOffset(now)
|
||||
const ticks = timeTicks(startTime, now, chartTimeData[chartTime].ticks ?? 12).map((date) =>
|
||||
date.getTime()
|
||||
)
|
||||
const data = {
|
||||
ticks,
|
||||
domain: [chartTimeData[chartTime].getOffset(now).getTime(), now.getTime()],
|
||||
}
|
||||
cache.set('td', { time: now.getTime(), data, chartTime })
|
||||
return data
|
||||
}
|
||||
|
||||
// add empty values between records to make gaps if interval is too large
|
||||
function addEmptyValues<T extends SystemStatsRecord | ContainerStatsRecord>(
|
||||
prevRecords: T[],
|
||||
newRecords: T[],
|
||||
expectedInterval: number
|
||||
) {
|
||||
const modifiedRecords: T[] = []
|
||||
let prevTime = (prevRecords.at(-1)?.created ?? 0) as number
|
||||
for (let i = 0; i < newRecords.length; i++) {
|
||||
const record = newRecords[i]
|
||||
record.created = new Date(record.created).getTime()
|
||||
if (prevTime) {
|
||||
const interval = record.created - prevTime
|
||||
// if interval is too large, add a null record
|
||||
if (interval > expectedInterval / 2 + expectedInterval) {
|
||||
// @ts-ignore
|
||||
modifiedRecords.push({ created: null, stats: null })
|
||||
}
|
||||
}
|
||||
prevTime = record.created
|
||||
modifiedRecords.push(record)
|
||||
}
|
||||
return modifiedRecords
|
||||
}
|
||||
|
||||
async function getStats<T>(
|
||||
collection: string,
|
||||
system: SystemRecord,
|
||||
chartTime: ChartTimes
|
||||
): Promise<T[]> {
|
||||
const lastCached = cache.get(`${system.id}_${chartTime}_${collection}`)?.at(-1)?.created as number
|
||||
return await pb.collection<T>(collection).getFullList({
|
||||
filter: pb.filter('system={:id} && created > {:created} && type={:type}', {
|
||||
id: system.id,
|
||||
created: getPbTimestamp(chartTime, lastCached ? new Date(lastCached + 1000) : undefined),
|
||||
type: chartTimeData[chartTime].type,
|
||||
}),
|
||||
fields: 'created,stats',
|
||||
sort: 'created',
|
||||
})
|
||||
}
|
||||
|
||||
export default function SystemDetail({ name }: { name: string }) {
|
||||
const systems = useStore($systems)
|
||||
const chartTime = useStore($chartTime)
|
||||
/** Max CPU toggle value */
|
||||
const cpuMaxStore = useState(false)
|
||||
const bandwidthMaxStore = useState(false)
|
||||
const diskIoMaxStore = useState(false)
|
||||
const [grid, setGrid] = useLocalStorage('grid', true)
|
||||
const [ticks, setTicks] = useState([] as number[])
|
||||
const [system, setSystem] = useState({} as SystemRecord)
|
||||
const [systemStats, setSystemStats] = useState([] as SystemStatsRecord[])
|
||||
const [containerData, setContainerData] = useState([] as ChartData['containerData'])
|
||||
const netCardRef = useRef<HTMLDivElement>(null)
|
||||
const [dockerCpuChartData, setDockerCpuChartData] = useState<Record<string, number | string>[]>(
|
||||
[]
|
||||
)
|
||||
const [dockerMemChartData, setDockerMemChartData] = useState<Record<string, number | string>[]>(
|
||||
[]
|
||||
)
|
||||
const [dockerNetChartData, setDockerNetChartData] = useState<Record<string, number | number[]>[]>(
|
||||
[]
|
||||
)
|
||||
const hasDockerStats = dockerCpuChartData.length > 0
|
||||
const [containerFilterBar, setContainerFilterBar] = useState(null as null | JSX.Element)
|
||||
const [bottomSpacing, setBottomSpacing] = useState(0)
|
||||
const isLongerChart = chartTime !== '1h'
|
||||
|
||||
useEffect(() => {
|
||||
document.title = `${name} / Beszel`
|
||||
return () => {
|
||||
resetCharts()
|
||||
$chartTime.set($userSettings.get().chartTime)
|
||||
// resetCharts()
|
||||
setSystemStats([])
|
||||
setContainerData([])
|
||||
setContainerFilterBar(null)
|
||||
$containerFilter.set('')
|
||||
// setHasDocker(false)
|
||||
cpuMaxStore[1](false)
|
||||
bandwidthMaxStore[1](false)
|
||||
diskIoMaxStore[1](false)
|
||||
}
|
||||
}, [name])
|
||||
|
||||
function resetCharts() {
|
||||
setSystemStats([])
|
||||
setDockerCpuChartData([])
|
||||
setDockerMemChartData([])
|
||||
setDockerNetChartData([])
|
||||
}
|
||||
// function resetCharts() {
|
||||
// setSystemStats([])
|
||||
// setContainerData([])
|
||||
// }
|
||||
|
||||
useEffect(resetCharts, [chartTime])
|
||||
// useEffect(resetCharts, [chartTime])
|
||||
|
||||
// find matching system
|
||||
useEffect(() => {
|
||||
if (system.id && system.name === name) {
|
||||
return
|
||||
@@ -85,43 +155,20 @@ export default function SystemDetail({ name }: { name: string }) {
|
||||
return () => {
|
||||
pb.collection('systems').unsubscribe(system.id)
|
||||
}
|
||||
}, [system])
|
||||
}, [system.id])
|
||||
|
||||
async function getStats<T>(collection: string): Promise<T[]> {
|
||||
return await pb.collection<T>(collection).getFullList({
|
||||
filter: pb.filter('system={:id} && created > {:created} && type={:type}', {
|
||||
id: system.id,
|
||||
created: getPbTimestamp(chartTime),
|
||||
type: chartTimeData[chartTime].type,
|
||||
}),
|
||||
fields: 'created,stats',
|
||||
sort: 'created',
|
||||
})
|
||||
}
|
||||
|
||||
// add empty values between records to make gaps if interval is too large
|
||||
function addEmptyValues<T extends SystemStatsRecord | ContainerStatsRecord>(
|
||||
records: T[],
|
||||
expectedInterval: number
|
||||
) {
|
||||
const modifiedRecords: T[] = []
|
||||
let prevTime = 0
|
||||
for (let i = 0; i < records.length; i++) {
|
||||
const record = records[i]
|
||||
record.created = new Date(record.created).getTime()
|
||||
if (prevTime) {
|
||||
const interval = record.created - prevTime
|
||||
// if interval is too large, add a null record
|
||||
if (interval > expectedInterval / 2 + expectedInterval) {
|
||||
// @ts-ignore
|
||||
modifiedRecords.push({ created: null, stats: null })
|
||||
}
|
||||
}
|
||||
prevTime = record.created
|
||||
modifiedRecords.push(record)
|
||||
const chartData: ChartData = useMemo(() => {
|
||||
const lastCreated = Math.max(
|
||||
(systemStats.at(-1)?.created as number) ?? 0,
|
||||
(containerData.at(-1)?.created as number) ?? 0
|
||||
)
|
||||
return {
|
||||
systemStats,
|
||||
containerData,
|
||||
chartTime,
|
||||
...getTimeData(chartTime, lastCreated),
|
||||
}
|
||||
return modifiedRecords
|
||||
}
|
||||
}, [systemStats, containerData])
|
||||
|
||||
// get stats
|
||||
useEffect(() => {
|
||||
@@ -129,59 +176,62 @@ export default function SystemDetail({ name }: { name: string }) {
|
||||
return
|
||||
}
|
||||
Promise.allSettled([
|
||||
getStats<SystemStatsRecord>('system_stats'),
|
||||
getStats<ContainerStatsRecord>('container_stats'),
|
||||
getStats<SystemStatsRecord>('system_stats', system, chartTime),
|
||||
getStats<ContainerStatsRecord>('container_stats', system, chartTime),
|
||||
]).then(([systemStats, containerStats]) => {
|
||||
const expectedInterval = chartTimeData[chartTime].expectedInterval
|
||||
const { expectedInterval } = chartTimeData[chartTime]
|
||||
// make new system stats
|
||||
const ss_cache_key = `${system.id}_${chartTime}_system_stats`
|
||||
let systemData = (cache.get(ss_cache_key) || []) as SystemStatsRecord[]
|
||||
if (systemStats.status === 'fulfilled' && systemStats.value.length) {
|
||||
systemData = systemData.concat(
|
||||
addEmptyValues(systemData, systemStats.value, expectedInterval)
|
||||
)
|
||||
if (systemData.length > 120) {
|
||||
systemData = systemData.slice(-100)
|
||||
}
|
||||
cache.set(ss_cache_key, systemData)
|
||||
}
|
||||
setSystemStats(systemData)
|
||||
// make new container stats
|
||||
const cs_cache_key = `${system.id}_${chartTime}_container_stats`
|
||||
let containerData = (cache.get(cs_cache_key) || []) as ContainerStatsRecord[]
|
||||
if (containerStats.status === 'fulfilled' && containerStats.value.length) {
|
||||
makeContainerData(addEmptyValues(containerStats.value, expectedInterval))
|
||||
containerData = containerData.concat(
|
||||
addEmptyValues(containerData, containerStats.value, expectedInterval)
|
||||
)
|
||||
if (containerData.length > 120) {
|
||||
containerData = containerData.slice(-100)
|
||||
}
|
||||
cache.set(cs_cache_key, containerData)
|
||||
}
|
||||
if (systemStats.status === 'fulfilled') {
|
||||
setSystemStats(addEmptyValues(systemStats.value, expectedInterval))
|
||||
if (containerData.length) {
|
||||
!containerFilterBar && setContainerFilterBar(<ContainerFilterBar />)
|
||||
} else if (containerFilterBar) {
|
||||
setContainerFilterBar(null)
|
||||
}
|
||||
makeContainerData(containerData)
|
||||
})
|
||||
}, [system, chartTime])
|
||||
|
||||
useEffect(() => {
|
||||
if (!systemStats.length) {
|
||||
return
|
||||
}
|
||||
const now = new Date()
|
||||
const startTime = chartTimeData[chartTime].getOffset(now)
|
||||
const scale = scaleTime([startTime.getTime(), now], [0, systemStats.length])
|
||||
setTicks(scale.ticks(chartTimeData[chartTime].ticks).map((d) => d.getTime()))
|
||||
}, [chartTime, systemStats])
|
||||
|
||||
// make container stats for charts
|
||||
const makeContainerData = useCallback((containers: ContainerStatsRecord[]) => {
|
||||
// console.log('containers', containers)
|
||||
const dockerCpuData = []
|
||||
const dockerMemData = []
|
||||
const dockerNetData = []
|
||||
const containerData = [] as ChartData['containerData']
|
||||
for (let { created, stats } of containers) {
|
||||
if (!created) {
|
||||
let nullData = { time: null } as unknown
|
||||
dockerCpuData.push(nullData as Record<string, number | string>)
|
||||
dockerMemData.push(nullData as Record<string, number | string>)
|
||||
dockerNetData.push(nullData as Record<string, number | number[]>)
|
||||
// @ts-ignore add null value for gaps
|
||||
containerData.push({ created: null })
|
||||
continue
|
||||
}
|
||||
const time = new Date(created).getTime()
|
||||
let cpuData = { time } as Record<string, number | string>
|
||||
let memData = { time } as Record<string, number | string>
|
||||
let netData = { time } as Record<string, number | number[]>
|
||||
created = new Date(created).getTime()
|
||||
// @ts-ignore not dealing with this rn
|
||||
let containerStats: ChartData['containerData'][0] = { created }
|
||||
for (let container of stats) {
|
||||
cpuData[container.n] = container.c
|
||||
memData[container.n] = container.m
|
||||
netData[container.n] = [container.ns, container.nr, container.ns + container.nr] // sent, received, total
|
||||
containerStats[container.n] = container
|
||||
}
|
||||
dockerCpuData.push(cpuData)
|
||||
dockerMemData.push(memData)
|
||||
dockerNetData.push(netData)
|
||||
containerData.push(containerStats)
|
||||
}
|
||||
setDockerCpuChartData(dockerCpuData)
|
||||
setDockerMemChartData(dockerMemData)
|
||||
setDockerNetChartData(dockerNetData)
|
||||
setContainerData(containerData)
|
||||
}, [])
|
||||
|
||||
// values for system info bar
|
||||
@@ -192,7 +242,7 @@ export default function SystemDetail({ name }: { name: string }) {
|
||||
let uptime: number | string = system.info.u
|
||||
if (system.info.u < 172800) {
|
||||
const hours = Math.trunc(uptime / 3600)
|
||||
uptime = `${hours} hour${hours > 1 ? 's' : ''}`
|
||||
uptime = `${hours} hour${hours == 1 ? '' : 's'}`
|
||||
} else {
|
||||
uptime = `${Math.trunc(system.info?.u / 86400)} days`
|
||||
}
|
||||
@@ -221,17 +271,18 @@ export default function SystemDetail({ name }: { name: string }) {
|
||||
}, [system.info])
|
||||
|
||||
/** Space for tooltip if more than 12 containers */
|
||||
const bottomSpacing = useMemo(() => {
|
||||
if (!netCardRef.current || !dockerNetChartData.length) {
|
||||
return 0
|
||||
useEffect(() => {
|
||||
if (!netCardRef.current || !containerData.length) {
|
||||
setBottomSpacing(0)
|
||||
return
|
||||
}
|
||||
const tooltipHeight = (Object.keys(dockerNetChartData[0]).length - 11) * 17.8 - 40
|
||||
const tooltipHeight = (Object.keys(containerData[0]).length - 11) * 17.8 - 40
|
||||
const wrapperEl = document.getElementById('chartwrap') as HTMLDivElement
|
||||
const wrapperRect = wrapperEl.getBoundingClientRect()
|
||||
const chartRect = netCardRef.current.getBoundingClientRect()
|
||||
const distanceToBottom = wrapperRect.bottom - chartRect.bottom
|
||||
return tooltipHeight - distanceToBottom
|
||||
}, [netCardRef.current, dockerNetChartData])
|
||||
setBottomSpacing(tooltipHeight - distanceToBottom)
|
||||
}, [netCardRef, containerData])
|
||||
|
||||
if (!system.id) {
|
||||
return null
|
||||
@@ -239,10 +290,10 @@ export default function SystemDetail({ name }: { name: string }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div id="chartwrap" className="grid gap-4 mb-10">
|
||||
<div id="chartwrap" className="grid gap-4 mb-10 overflow-x-clip">
|
||||
{/* system info */}
|
||||
<Card>
|
||||
<div className="grid lg:flex items-center gap-4 px-4 sm:px-6 pt-3 sm:pt-4 pb-5">
|
||||
<div className="grid lg:flex gap-4 px-4 sm:px-6 pt-3 sm:pt-4 pb-5">
|
||||
<div>
|
||||
<h1 className="text-[1.6rem] font-semibold mb-1.5">{system.name}</h1>
|
||||
<div className="flex flex-wrap items-center gap-3 gap-y-2 text-sm opacity-90">
|
||||
@@ -299,10 +350,9 @@ export default function SystemDetail({ name }: { name: string }) {
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
aria-label="Toggle grid"
|
||||
className={cn(
|
||||
buttonVariants({ variant: 'outline', size: 'icon' }),
|
||||
'hidden lg:flex p-0 text-primary'
|
||||
)}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="hidden lg:flex p-0 text-primary"
|
||||
onClick={() => setGrid(!grid)}
|
||||
>
|
||||
{grid ? (
|
||||
@@ -324,19 +374,27 @@ export default function SystemDetail({ name }: { name: string }) {
|
||||
<ChartCard
|
||||
grid={grid}
|
||||
title="Total CPU Usage"
|
||||
description="Average system-wide CPU utilization"
|
||||
description={`${
|
||||
cpuMaxStore[0] && isLongerChart ? 'Max 1 min ' : 'Average'
|
||||
} system-wide CPU utilization`}
|
||||
cornerEl={isLongerChart ? <SelectAvgMax store={cpuMaxStore} /> : null}
|
||||
>
|
||||
<CpuChart ticks={ticks} systemData={systemStats} />
|
||||
<AreaChartDefault
|
||||
chartData={chartData}
|
||||
chartName="CPU Usage"
|
||||
maxToggled={cpuMaxStore[0]}
|
||||
unit="%"
|
||||
/>
|
||||
</ChartCard>
|
||||
|
||||
{hasDockerStats && (
|
||||
{containerFilterBar && (
|
||||
<ChartCard
|
||||
grid={grid}
|
||||
title="Docker CPU Usage"
|
||||
description="CPU utilization of docker containers"
|
||||
isContainerChart={true}
|
||||
description="Average CPU utilization of containers"
|
||||
cornerEl={containerFilterBar}
|
||||
>
|
||||
<ContainerCpuChart chartData={dockerCpuChartData} ticks={ticks} />
|
||||
<ContainerChart chartData={chartData} dataKey="c" chartName="cpu" />
|
||||
</ChartCard>
|
||||
)}
|
||||
|
||||
@@ -345,46 +403,55 @@ export default function SystemDetail({ name }: { name: string }) {
|
||||
title="Total Memory Usage"
|
||||
description="Precise utilization at the recorded time"
|
||||
>
|
||||
<MemChart ticks={ticks} systemData={systemStats} />
|
||||
<MemChart chartData={chartData} />
|
||||
</ChartCard>
|
||||
|
||||
{hasDockerStats && (
|
||||
{containerFilterBar && (
|
||||
<ChartCard
|
||||
grid={grid}
|
||||
title="Docker Memory Usage"
|
||||
description="Memory usage of docker containers"
|
||||
isContainerChart={true}
|
||||
cornerEl={containerFilterBar}
|
||||
>
|
||||
<ContainerMemChart chartData={dockerMemChartData} ticks={ticks} />
|
||||
<ContainerChart chartData={chartData} chartName="mem" dataKey="m" unit=" MB" />
|
||||
</ChartCard>
|
||||
)}
|
||||
|
||||
<ChartCard grid={grid} title="Disk Space" description="Usage of root partition">
|
||||
<DiskChart
|
||||
ticks={ticks}
|
||||
systemData={systemStats}
|
||||
chartData={chartData}
|
||||
dataKey="stats.du"
|
||||
diskSize={Math.round(systemStats.at(-1)?.stats.d ?? NaN)}
|
||||
/>
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard grid={grid} title="Disk I/O" description="Throughput of root filesystem">
|
||||
<DiskIoChart
|
||||
ticks={ticks}
|
||||
systemData={systemStats}
|
||||
dataKeys={['stats.dw', 'stats.dr']}
|
||||
<ChartCard
|
||||
grid={grid}
|
||||
title="Disk I/O"
|
||||
description="Throughput of root filesystem"
|
||||
cornerEl={isLongerChart ? <SelectAvgMax store={diskIoMaxStore} /> : null}
|
||||
>
|
||||
<AreaChartDefault
|
||||
chartData={chartData}
|
||||
maxToggled={diskIoMaxStore[0]}
|
||||
chartName="dio"
|
||||
/>
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard
|
||||
grid={grid}
|
||||
title="Bandwidth"
|
||||
cornerEl={isLongerChart ? <SelectAvgMax store={bandwidthMaxStore} /> : null}
|
||||
description="Network traffic of public interfaces"
|
||||
>
|
||||
<BandwidthChart ticks={ticks} systemData={systemStats} />
|
||||
<AreaChartDefault
|
||||
chartData={chartData}
|
||||
maxToggled={bandwidthMaxStore[0]}
|
||||
chartName="bw"
|
||||
/>
|
||||
</ChartCard>
|
||||
|
||||
{hasDockerStats && dockerNetChartData.length > 0 && (
|
||||
{containerFilterBar && containerData.length > 0 && (
|
||||
<div
|
||||
ref={netCardRef}
|
||||
className={cn({
|
||||
@@ -394,22 +461,23 @@ export default function SystemDetail({ name }: { name: string }) {
|
||||
<ChartCard
|
||||
title="Docker Network I/O"
|
||||
description="Includes traffic between internal services"
|
||||
isContainerChart={true}
|
||||
cornerEl={containerFilterBar}
|
||||
>
|
||||
<ContainerNetChart chartData={dockerNetChartData} ticks={ticks} />
|
||||
{/* @ts-ignore */}
|
||||
<ContainerChart chartData={chartData} chartName="net" dataKey="n" />
|
||||
</ChartCard>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(systemStats.at(-1)?.stats.su ?? 0) > 0 && (
|
||||
<ChartCard grid={grid} title="Swap Usage" description="Swap space used by the system">
|
||||
<SwapChart ticks={ticks} systemData={systemStats} />
|
||||
<SwapChart chartData={chartData} />
|
||||
</ChartCard>
|
||||
)}
|
||||
|
||||
{systemStats.at(-1)?.stats.t && (
|
||||
<ChartCard grid={grid} title="Temperature" description="Temperatures of system sensors">
|
||||
<TemperatureChart ticks={ticks} systemData={systemStats} />
|
||||
<TemperatureChart chartData={chartData} />
|
||||
</ChartCard>
|
||||
)}
|
||||
</div>
|
||||
@@ -426,8 +494,7 @@ export default function SystemDetail({ name }: { name: string }) {
|
||||
description={`Disk usage of ${extraFsName}`}
|
||||
>
|
||||
<DiskChart
|
||||
ticks={ticks}
|
||||
systemData={systemStats}
|
||||
chartData={chartData}
|
||||
dataKey={`stats.efs.${extraFsName}.du`}
|
||||
diskSize={Math.round(systemStats.at(-1)?.stats.efs?.[extraFsName].d ?? NaN)}
|
||||
/>
|
||||
@@ -436,11 +503,12 @@ export default function SystemDetail({ name }: { name: string }) {
|
||||
grid={grid}
|
||||
title={`${extraFsName} I/O`}
|
||||
description={`Throughput of ${extraFsName}`}
|
||||
cornerEl={isLongerChart ? <SelectAvgMax store={diskIoMaxStore} /> : null}
|
||||
>
|
||||
<DiskIoChart
|
||||
ticks={ticks}
|
||||
systemData={systemStats}
|
||||
dataKeys={[`stats.efs.${extraFsName}.w`, `stats.efs.${extraFsName}.r`]}
|
||||
<AreaChartDefault
|
||||
chartData={chartData}
|
||||
maxToggled={diskIoMaxStore[0]}
|
||||
chartName={`efs.${extraFsName}`}
|
||||
/>
|
||||
</ChartCard>
|
||||
</div>
|
||||
@@ -461,10 +529,10 @@ function ContainerFilterBar() {
|
||||
|
||||
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
$containerFilter.set(e.target.value)
|
||||
}, []) // Use an empty dependency array to prevent re-creation
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="relative py-1 block sm:w-44 sm:absolute sm:top-2.5 sm:right-3.5">
|
||||
<>
|
||||
<Input
|
||||
placeholder="Filter..."
|
||||
className="pl-4 pr-8"
|
||||
@@ -483,7 +551,33 @@ function ContainerFilterBar() {
|
||||
<XIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectAvgMax({
|
||||
store,
|
||||
}: {
|
||||
store: [boolean, React.Dispatch<React.SetStateAction<boolean>>]
|
||||
}) {
|
||||
const [max, setMax] = store
|
||||
const Icon = max ? ChartMax : ChartAverage
|
||||
|
||||
return (
|
||||
<Select value={max ? 'max' : 'avg'} onValueChange={(e) => setMax(e === 'max')}>
|
||||
<SelectTrigger className="relative pl-10 pr-5">
|
||||
<Icon className="h-4 w-4 absolute left-4 top-1/2 -translate-y-1/2 opacity-85" />
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem key="avg" value="avg">
|
||||
Average
|
||||
</SelectItem>
|
||||
<SelectItem key="max" value="max">
|
||||
Max 1 min
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -492,13 +586,13 @@ function ChartCard({
|
||||
description,
|
||||
children,
|
||||
grid,
|
||||
isContainerChart,
|
||||
cornerEl,
|
||||
}: {
|
||||
title: string
|
||||
description: string
|
||||
children: React.ReactNode
|
||||
grid?: boolean
|
||||
isContainerChart?: boolean
|
||||
cornerEl?: JSX.Element | null
|
||||
}) {
|
||||
const { isIntersecting, ref } = useIntersectionObserver()
|
||||
|
||||
@@ -510,12 +604,16 @@ function ChartCard({
|
||||
<CardHeader className="pb-5 pt-4 relative space-y-1 max-sm:py-3 max-sm:px-4">
|
||||
<CardTitle className="text-xl sm:text-2xl">{title}</CardTitle>
|
||||
<CardDescription>{description}</CardDescription>
|
||||
{isContainerChart && <ContainerFilterBar />}
|
||||
{cornerEl && (
|
||||
<div className="relative py-1 block sm:w-44 sm:absolute sm:top-2.5 sm:right-3.5">
|
||||
{cornerEl}
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="pl-0 w-[calc(100%-1.6em)] h-52 relative">
|
||||
<div className="pl-0 w-[calc(100%-1.6em)] h-52 relative">
|
||||
{<Spinner />}
|
||||
{isIntersecting && <Suspense>{children}</Suspense>}
|
||||
</CardContent>
|
||||
{isIntersecting && children}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -44,30 +44,31 @@ import {
|
||||
|
||||
import { SystemRecord } from '@/types'
|
||||
import {
|
||||
MoreHorizontal,
|
||||
ArrowUpDown,
|
||||
Server,
|
||||
Cpu,
|
||||
MemoryStick,
|
||||
HardDrive,
|
||||
MoreHorizontalIcon,
|
||||
ArrowUpDownIcon,
|
||||
MemoryStickIcon,
|
||||
CopyIcon,
|
||||
PauseCircleIcon,
|
||||
PlayCircleIcon,
|
||||
Trash2Icon,
|
||||
WifiIcon,
|
||||
HardDriveIcon,
|
||||
ServerIcon,
|
||||
CpuIcon,
|
||||
} from 'lucide-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { $hubVersion, $systems, pb } from '@/lib/stores'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { cn, copyToClipboard, isReadOnlyUser } from '@/lib/utils'
|
||||
import AlertsButton from '../table-alerts'
|
||||
import { cn, copyToClipboard, decimalString, isReadOnlyUser } from '@/lib/utils'
|
||||
import AlertsButton from '../alerts/alert-button'
|
||||
import { navigate } from '../router'
|
||||
import { EthernetIcon } from '../ui/icons'
|
||||
|
||||
function CellFormatter(info: CellContext<SystemRecord, unknown>) {
|
||||
const val = info.getValue() as number
|
||||
return (
|
||||
<div className="flex gap-1 items-center tabular-nums tracking-tight">
|
||||
<span className="min-w-[3.5em]">{val.toFixed(1)}%</span>
|
||||
<span className="min-w-[3.5em]">{decimalString(val, 1)}%</span>
|
||||
<span className="grow min-w-10 block bg-muted h-[1em] relative rounded-sm overflow-hidden">
|
||||
<span
|
||||
className={cn(
|
||||
@@ -95,7 +96,7 @@ function sortableHeader(
|
||||
>
|
||||
<Icon className="mr-2 h-4 w-4" />
|
||||
{name}
|
||||
{!hideSortIcon && <ArrowUpDown className="ml-2 h-4 w-4" />}
|
||||
{!hideSortIcon && <ArrowUpDownIcon className="ml-2 h-4 w-4" />}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -112,7 +113,7 @@ export default function SystemsTable({ filter }: { filter?: string }) {
|
||||
}
|
||||
}, [filter])
|
||||
|
||||
const columns: ColumnDef<SystemRecord>[] = useMemo(() => {
|
||||
const columns = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
// size: 200,
|
||||
@@ -144,26 +145,46 @@ export default function SystemsTable({ filter }: { filter?: string }) {
|
||||
</span>
|
||||
)
|
||||
},
|
||||
header: ({ column }) => sortableHeader(column, 'System', Server),
|
||||
header: ({ column }) => sortableHeader(column, 'System', ServerIcon),
|
||||
},
|
||||
{
|
||||
accessorKey: 'info.cpu',
|
||||
invertSorting: true,
|
||||
cell: CellFormatter,
|
||||
header: ({ column }) => sortableHeader(column, 'CPU', Cpu),
|
||||
header: ({ column }) => sortableHeader(column, 'CPU', CpuIcon),
|
||||
},
|
||||
{
|
||||
accessorKey: 'info.mp',
|
||||
invertSorting: true,
|
||||
cell: CellFormatter,
|
||||
header: ({ column }) => sortableHeader(column, 'Memory', MemoryStick),
|
||||
header: ({ column }) => sortableHeader(column, 'Memory', MemoryStickIcon),
|
||||
},
|
||||
{
|
||||
accessorKey: 'info.dp',
|
||||
invertSorting: true,
|
||||
cell: CellFormatter,
|
||||
header: ({ column }) => sortableHeader(column, 'Disk', HardDrive),
|
||||
header: ({ column }) => sortableHeader(column, 'Disk', HardDriveIcon),
|
||||
},
|
||||
{
|
||||
accessorFn: (originalRow) => originalRow.info.b || 0,
|
||||
id: 'n',
|
||||
invertSorting: true,
|
||||
size: 115,
|
||||
header: ({ column }) => sortableHeader(column, 'Net', EthernetIcon),
|
||||
cell: (info) => {
|
||||
const val = info.getValue() as number
|
||||
return (
|
||||
<span className="tabular-nums whitespace-nowrap pl-1">
|
||||
{decimalString(val, val >= 100 ? 1 : 2)} MB/s
|
||||
</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'info.v',
|
||||
invertSorting: true,
|
||||
size: 50,
|
||||
header: ({ column }) => sortableHeader(column, 'Agent', WifiIcon, true),
|
||||
cell: (info) => {
|
||||
const version = info.getValue() as string
|
||||
if (!version || !hubVersion) {
|
||||
@@ -182,7 +203,6 @@ export default function SystemsTable({ filter }: { filter?: string }) {
|
||||
</span>
|
||||
)
|
||||
},
|
||||
header: ({ column }) => sortableHeader(column, 'Agent', WifiIcon, true),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
@@ -198,7 +218,7 @@ export default function SystemsTable({ filter }: { filter?: string }) {
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size={'icon'} data-nolink>
|
||||
<span className="sr-only">Open menu</span>
|
||||
<MoreHorizontal className="w-5" />
|
||||
<MoreHorizontalIcon className="w-5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
@@ -259,7 +279,7 @@ export default function SystemsTable({ filter }: { filter?: string }) {
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
] as ColumnDef<SystemRecord>[]
|
||||
}, [hubVersion])
|
||||
|
||||
const table = useReactTable({
|
||||
|
||||
@@ -1,228 +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 } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { AlertRecord, SystemRecord } from '@/types'
|
||||
import { lazy, Suspense, useMemo, useState } from 'react'
|
||||
import { toast } from './ui/use-toast'
|
||||
import { Link } from './router'
|
||||
|
||||
const Slider = lazy(() => import('./ui/slider'))
|
||||
|
||||
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 active = useMemo(() => {
|
||||
return alerts.find((alert) => alert.system === system.id)
|
||||
}, [alerts, system])
|
||||
|
||||
const systemAlerts = useMemo(() => {
|
||||
return alerts.filter((alert) => alert.system === system.id) as AlertRecord[]
|
||||
}, [alerts, system])
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size={'icon'} aria-label="Alerts" data-nolink>
|
||||
<BellIcon
|
||||
className={cn('h-[1.2em] w-[1.2em] pointer-events-none', {
|
||||
'fill-foreground': active,
|
||||
})}
|
||||
/>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-full overflow-auto">
|
||||
<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} />
|
||||
<AlertWithSlider
|
||||
system={system}
|
||||
alerts={systemAlerts}
|
||||
name="CPU"
|
||||
title="CPU Usage"
|
||||
description="Triggers when CPU usage exceeds a threshold."
|
||||
/>
|
||||
<AlertWithSlider
|
||||
system={system}
|
||||
alerts={systemAlerts}
|
||||
name="Memory"
|
||||
title="Memory Usage"
|
||||
description="Triggers when memory usage exceeds a threshold."
|
||||
/>
|
||||
<AlertWithSlider
|
||||
system={system}
|
||||
alerts={systemAlerts}
|
||||
name="Disk"
|
||||
title="Disk Usage"
|
||||
description="Triggers when root usage exceeds a threshold."
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertStatus({ system, alerts }: { system: SystemRecord; alerts: AlertRecord[] }) {
|
||||
const [pendingChange, setPendingChange] = useState(false)
|
||||
|
||||
const alert = useMemo(() => {
|
||||
return alerts.find((alert) => alert.name === 'Status')
|
||||
}, [alerts])
|
||||
|
||||
return (
|
||||
<label
|
||||
htmlFor="alert-status"
|
||||
className="flex flex-row items-center justify-between gap-4 rounded-lg border p-4 cursor-pointer"
|
||||
>
|
||||
<div className="grid gap-1 select-none">
|
||||
<p className="font-semibold">System Status</p>
|
||||
<span className="block text-sm text-foreground opacity-80">
|
||||
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,
|
||||
}: {
|
||||
system: SystemRecord
|
||||
alerts: AlertRecord[]
|
||||
name: string
|
||||
title: string
|
||||
description: string
|
||||
}) {
|
||||
const [pendingChange, setPendingChange] = useState(false)
|
||||
const [liveValue, setLiveValue] = useState(50)
|
||||
|
||||
const alert = useMemo(() => {
|
||||
const alert = alerts.find((alert) => alert.name === name)
|
||||
if (alert) {
|
||||
setLiveValue(alert.value)
|
||||
}
|
||||
return alert
|
||||
}, [alerts])
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border">
|
||||
<label
|
||||
htmlFor={`alert-${name}`}
|
||||
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">{title}</p>
|
||||
<span className="block text-sm text-foreground opacity-80">{description}</span>
|
||||
</div>
|
||||
<Switch
|
||||
id={`alert-${name}`}
|
||||
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,
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
failedUpdateToast()
|
||||
} finally {
|
||||
setPendingChange(false)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
{alert && (
|
||||
<div className="flex mt-2 mb-3 gap-3 px-4">
|
||||
<Suspense>
|
||||
<Slider
|
||||
defaultValue={[liveValue]}
|
||||
onValueCommit={(val) => {
|
||||
pb.collection('alerts').update(alert.id, {
|
||||
value: val[0],
|
||||
})
|
||||
}}
|
||||
onValueChange={(val) => {
|
||||
setLiveValue(val[0])
|
||||
}}
|
||||
min={10}
|
||||
max={99}
|
||||
// step={1}
|
||||
/>
|
||||
</Suspense>
|
||||
<span className="tabular-nums tracking-tighter text-[.92em]">{liveValue}%</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
59
beszel/site/src/components/ui/alert.tsx
Normal file
59
beszel/site/src/components/ui/alert.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import * as React from 'react'
|
||||
// import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
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 Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
// React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
// >(({ className, variant, ...props }, ref) => (
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<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}
|
||||
/>
|
||||
))
|
||||
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>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('text-sm [&_p]:leading-relaxed', className)} {...props} />
|
||||
))
|
||||
AlertDescription.displayName = 'AlertDescription'
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
@@ -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
|
||||
@@ -16,77 +17,77 @@ export type ChartConfig = {
|
||||
)
|
||||
}
|
||||
|
||||
type ChartContextProps = {
|
||||
config: ChartConfig
|
||||
}
|
||||
// type ChartContextProps = {
|
||||
// config: ChartConfig
|
||||
// }
|
||||
|
||||
const ChartContext = React.createContext<ChartContextProps | null>(null)
|
||||
// const ChartContext = React.createContext<ChartContextProps | null>(null)
|
||||
|
||||
function useChart() {
|
||||
const context = React.useContext(ChartContext)
|
||||
// function useChart() {
|
||||
// const context = React.useContext(ChartContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useChart must be used within a <ChartContainer />')
|
||||
}
|
||||
// if (!context) {
|
||||
// throw new Error('useChart must be used within a <ChartContainer />')
|
||||
// }
|
||||
|
||||
return context
|
||||
}
|
||||
// return context
|
||||
// }
|
||||
|
||||
const ChartContainer = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<'div'> & {
|
||||
config: ChartConfig
|
||||
// config: ChartConfig
|
||||
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>['children']
|
||||
}
|
||||
>(({ id, className, children, config, ...props }, ref) => {
|
||||
>(({ id, className, children, ...props }, ref) => {
|
||||
const uniqueId = React.useId()
|
||||
const chartId = `chart-${id || uniqueId.replace(/:/g, '')}`
|
||||
|
||||
return (
|
||||
<ChartContext.Provider value={{ config }}>
|
||||
<div
|
||||
data-chart={chartId}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line-line]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChartStyle id={chartId} config={config} />
|
||||
<RechartsPrimitive.ResponsiveContainer>{children}</RechartsPrimitive.ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
//<ChartContext.Provider value={{ config }}>
|
||||
<div
|
||||
data-chart={chartId}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line-line]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{/* <ChartStyle id={chartId} config={config} /> */}
|
||||
<RechartsPrimitive.ResponsiveContainer>{children}</RechartsPrimitive.ResponsiveContainer>
|
||||
</div>
|
||||
//</ChartContext.Provider>
|
||||
)
|
||||
})
|
||||
ChartContainer.displayName = 'Chart'
|
||||
|
||||
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||
const colorConfig = Object.entries(config).filter(([_, config]) => config.theme || config.color)
|
||||
// const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||
// const colorConfig = Object.entries(config).filter(([_, config]) => config.theme || config.color)
|
||||
|
||||
if (!colorConfig.length) {
|
||||
return null
|
||||
}
|
||||
// if (!colorConfig.length) {
|
||||
// return null
|
||||
// }
|
||||
|
||||
return (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: Object.entries(THEMES).map(
|
||||
([theme, prefix]) => `
|
||||
${prefix} [data-chart=${id}] {
|
||||
${colorConfig
|
||||
.map(([key, itemConfig]) => {
|
||||
const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color
|
||||
return color ? ` --color-${key}: ${color};` : null
|
||||
})
|
||||
.join('\n')}
|
||||
}
|
||||
`
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
// return (
|
||||
// <style
|
||||
// dangerouslySetInnerHTML={{
|
||||
// __html: Object.entries(THEMES).map(
|
||||
// ([theme, prefix]) => `
|
||||
// ${prefix} [data-chart=${id}] {
|
||||
// ${colorConfig
|
||||
// .map(([key, itemConfig]) => {
|
||||
// const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color
|
||||
// return color ? ` --color-${key}: ${color};` : null
|
||||
// })
|
||||
// .join('\n')}
|
||||
// }
|
||||
// `
|
||||
// ),
|
||||
// }}
|
||||
// />
|
||||
// )
|
||||
// }
|
||||
|
||||
const ChartTooltip = RechartsPrimitive.Tooltip
|
||||
|
||||
@@ -95,7 +96,6 @@ const ChartTooltipContent = React.forwardRef<
|
||||
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||
React.ComponentProps<'div'> & {
|
||||
hideLabel?: boolean
|
||||
hideIndicator?: boolean
|
||||
indicator?: 'line' | 'dot' | 'dashed'
|
||||
nameKey?: string
|
||||
labelKey?: string
|
||||
@@ -109,9 +109,8 @@ const ChartTooltipContent = React.forwardRef<
|
||||
active,
|
||||
payload,
|
||||
className,
|
||||
indicator = 'dot',
|
||||
indicator = 'line',
|
||||
hideLabel = false,
|
||||
hideIndicator = false,
|
||||
label,
|
||||
labelFormatter,
|
||||
labelClassName,
|
||||
@@ -126,7 +125,8 @@ const ChartTooltipContent = React.forwardRef<
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const { config } = useChart()
|
||||
// const { config } = useChart()
|
||||
const config = {}
|
||||
|
||||
React.useMemo(() => {
|
||||
if (filter) {
|
||||
@@ -144,12 +144,9 @@ const ChartTooltipContent = React.forwardRef<
|
||||
}
|
||||
|
||||
const [item] = payload
|
||||
const key = `${labelKey || item.dataKey || item.name || 'value'}`
|
||||
const key = `${labelKey || item.name || 'value'}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const value =
|
||||
!labelKey && typeof label === 'string'
|
||||
? config[label as keyof typeof config]?.label || label
|
||||
: itemConfig?.label
|
||||
const value = !labelKey && typeof label === 'string' ? label : itemConfig?.label
|
||||
|
||||
if (labelFormatter) {
|
||||
return (
|
||||
@@ -168,7 +165,8 @@ const ChartTooltipContent = React.forwardRef<
|
||||
return null
|
||||
}
|
||||
|
||||
const nestLabel = payload.length === 1 && indicator !== 'dot'
|
||||
// const nestLabel = payload.length === 1 && indicator !== 'dot'
|
||||
const nestLabel = false
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -200,26 +198,24 @@ const ChartTooltipContent = React.forwardRef<
|
||||
{itemConfig?.icon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn(
|
||||
'shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]',
|
||||
{
|
||||
'h-2.5 w-2.5': indicator === 'dot',
|
||||
'w-1': indicator === 'line',
|
||||
'w-0 border-[1.5px] border-dashed bg-transparent':
|
||||
indicator === 'dashed',
|
||||
'my-0.5': nestLabel && indicator === 'dashed',
|
||||
}
|
||||
)}
|
||||
style={
|
||||
{
|
||||
'--color-bg': indicatorColor,
|
||||
'--color-border': indicatorColor,
|
||||
} as React.CSSProperties
|
||||
<div
|
||||
className={cn(
|
||||
'shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]',
|
||||
{
|
||||
'h-2.5 w-2.5': indicator === 'dot',
|
||||
'w-1': indicator === 'line',
|
||||
'w-0 border-[1.5px] border-dashed bg-transparent':
|
||||
indicator === 'dashed',
|
||||
'my-0.5': nestLabel && indicator === 'dashed',
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
style={
|
||||
{
|
||||
'--color-bg': indicatorColor,
|
||||
'--color-border': indicatorColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
@@ -262,7 +258,7 @@ const ChartLegendContent = React.forwardRef<
|
||||
hideIcon?: boolean
|
||||
nameKey?: string
|
||||
}
|
||||
>(({ className, hideIcon = false, payload, verticalAlign = 'bottom', nameKey }, ref) => {
|
||||
>(({ className, payload, verticalAlign = 'bottom' }, ref) => {
|
||||
// const { config } = useChart()
|
||||
|
||||
if (!payload?.length) {
|
||||
@@ -336,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,
|
||||
ChartStyle,
|
||||
xAxis,
|
||||
// ChartStyle,
|
||||
}
|
||||
|
||||
26
beszel/site/src/components/ui/checkbox.tsx
Normal file
26
beszel/site/src/components/ui/checkbox.tsx
Normal 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 }
|
||||
@@ -23,3 +23,50 @@ export function Rows(props: SVGProps<SVGSVGElement>) {
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// IconPark Apache License 2.0 https://github.com/bytedance/IconPark
|
||||
export function ChartAverage(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg fill="none" viewBox="0 0 48 48" stroke="currentColor" {...props}>
|
||||
<path strokeWidth="3" d="M4 4v40h40" />
|
||||
<path strokeWidth="3" d="M10 38S15.3 4 27 4s17 34 17 34" />
|
||||
<path strokeWidth="4" d="M10 24h34" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// IconPark Apache License 2.0 https://github.com/bytedance/IconPark
|
||||
export function ChartMax(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg fill="none" viewBox="0 0 48 48" stroke="currentColor" {...props}>
|
||||
<path strokeWidth="3" d="M4 4v40h40" />
|
||||
<path strokeWidth="3" d="M10 38S15.3 4 27 4s17 34 17 34" />
|
||||
<path strokeWidth="4" d="M10 4h34" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// Lucide https://github.com/lucide-icons/lucide (not in package for some reason)
|
||||
export function EthernetIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
{...props}
|
||||
>
|
||||
<path d="m15 20 3-3h2a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h2l3 3zM6 8v1m4-1v1m4-1v1m4-1v1" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// Phosphor MIT https://github.com/phosphor-icons/core
|
||||
export function ThermometerIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 256 256" {...props} fill="currentColor">
|
||||
<path d="M212 56a28 28 0 1 0 28 28 28 28 0 0 0-28-28m0 40a12 12 0 1 1 12-12 12 12 0 0 1-12 12m-60 50V40a32 32 0 0 0-64 0v106a56 56 0 1 0 64 0m-16-42h-32V40a16 16 0 0 1 32 0Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
53
beszel/site/src/components/ui/tabs.tsx
Normal file
53
beszel/site/src/components/ui/tabs.tsx
Normal 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 }
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -7,10 +7,13 @@ import { RecordModel, RecordSubscription } from 'pocketbase'
|
||||
import { WritableAtom } from 'nanostores'
|
||||
import { timeDay, timeHour } from 'd3-time'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { CpuIcon, HardDriveIcon, MemoryStickIcon, ServerIcon } from 'lucide-react'
|
||||
import { EthernetIcon, ThermometerIcon } from '@/components/ui/icons'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
// export const cn = clsx
|
||||
|
||||
export async function copyToClipboard(content: string) {
|
||||
const duration = 1500
|
||||
@@ -51,7 +54,7 @@ export const updateSystemList = async () => {
|
||||
|
||||
export const updateAlerts = () => {
|
||||
pb.collection('alerts')
|
||||
.getFullList<AlertRecord>({ fields: 'id,name,system,value' })
|
||||
.getFullList<AlertRecord>({ fields: 'id,name,system,value,min,triggered', sort: 'updated' })
|
||||
.then((records) => {
|
||||
$alerts.set(records)
|
||||
})
|
||||
@@ -99,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'
|
||||
@@ -136,8 +140,8 @@ export function updateRecordList<T extends RecordModel>(
|
||||
$store.set(newRecords)
|
||||
}
|
||||
|
||||
export function getPbTimestamp(timeString: ChartTimes) {
|
||||
const d = chartTimeData[timeString].getOffset(new Date())
|
||||
export function getPbTimestamp(timeString: ChartTimes, d?: Date) {
|
||||
d ||= chartTimeData[timeString].getOffset(new Date())
|
||||
const year = d.getUTCFullYear()
|
||||
const month = String(d.getUTCMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getUTCDate()).padStart(2, '0')
|
||||
@@ -204,7 +208,10 @@ export function useYAxisWidth() {
|
||||
clearTimeout(timeout)
|
||||
timeout = setTimeout(() => {
|
||||
document.body.appendChild(div)
|
||||
setYAxisWidth(div.offsetWidth + 24)
|
||||
const width = div.offsetWidth + 24
|
||||
if (width > yAxisWidth) {
|
||||
setYAxisWidth(div.offsetWidth + 24)
|
||||
}
|
||||
document.body.removeChild(div)
|
||||
})
|
||||
}
|
||||
@@ -221,17 +228,18 @@ export function toFixedFloat(num: number, digits: number) {
|
||||
return parseFloat(num.toFixed(digits))
|
||||
}
|
||||
|
||||
let twoDecimalFormatter: Intl.NumberFormat
|
||||
/** Format number to two decimal places */
|
||||
export function twoDecimalString(num: number) {
|
||||
if (!twoDecimalFormatter) {
|
||||
twoDecimalFormatter = new Intl.NumberFormat(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
let decimalFormatters: Map<number, Intl.NumberFormat> = new Map()
|
||||
/** Format number to x decimal places */
|
||||
export function decimalString(num: number, digits = 2) {
|
||||
let formatter = decimalFormatters.get(digits)
|
||||
if (!formatter) {
|
||||
formatter = new Intl.NumberFormat(undefined, {
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits,
|
||||
})
|
||||
decimalFormatters.set(digits, formatter)
|
||||
}
|
||||
// Return a function that formats numbers using the saved formatter
|
||||
return twoDecimalFormatter.format(num)
|
||||
return formatter.format(num)
|
||||
}
|
||||
|
||||
/** Get value from local storage */
|
||||
@@ -273,15 +281,60 @@ export async function updateUserSettings() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the unit of size (TB or GB) for a given size in gigabytes
|
||||
* @param n size in gigabytes
|
||||
* @returns unit of size (TB or GB)
|
||||
* Get the value and unit of size (TB, GB, or MB) for a given size
|
||||
* @param n size in gigabytes or megabytes
|
||||
* @param isGigabytes boolean indicating if n represents gigabytes (true) or megabytes (false)
|
||||
* @returns an object containing the value and unit of size
|
||||
*/
|
||||
export const getSizeUnit = (n: number) => (n >= 1_000 ? ' TB' : ' GB')
|
||||
export const getSizeAndUnit = (n: number, isGigabytes = true) => {
|
||||
const sizeInGB = isGigabytes ? n : n / 1_000
|
||||
|
||||
/**
|
||||
* Get the value of number in gigabytes if less than 1000, otherwise in terabytes
|
||||
* @param n size in gigabytes
|
||||
* @returns value in GB if less than 1000, otherwise value in TB
|
||||
*/
|
||||
export const getSizeVal = (n: number) => (n >= 1_000 ? n / 1_000 : n)
|
||||
if (sizeInGB >= 1_000) {
|
||||
return { v: sizeInGB / 1_000, u: ' TB' }
|
||||
} else if (sizeInGB >= 1) {
|
||||
return { v: sizeInGB, u: ' GB' }
|
||||
}
|
||||
return { v: n, u: ' MB' }
|
||||
}
|
||||
|
||||
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: '%',
|
||||
icon: CpuIcon,
|
||||
desc: 'Triggers when CPU usage exceeds a threshold.',
|
||||
},
|
||||
Memory: {
|
||||
name: 'memory usage',
|
||||
unit: '%',
|
||||
icon: MemoryStickIcon,
|
||||
desc: 'Triggers when memory usage exceeds a threshold.',
|
||||
},
|
||||
Disk: {
|
||||
name: 'disk usage',
|
||||
unit: '%',
|
||||
icon: HardDriveIcon,
|
||||
desc: 'Triggers when usage of any disk exceeds a threshold.',
|
||||
},
|
||||
Bandwidth: {
|
||||
name: 'bandwidth',
|
||||
unit: ' MB/s',
|
||||
icon: EthernetIcon,
|
||||
desc: 'Triggers when combined up/down exceeds a threshold.',
|
||||
},
|
||||
Temperature: {
|
||||
name: 'temperature',
|
||||
unit: '°C',
|
||||
icon: ThermometerIcon,
|
||||
desc: 'Triggers when any sensor exceeds a threshold.',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -70,32 +70,30 @@ const App = () => {
|
||||
$hubVersion.set(data.v)
|
||||
})
|
||||
// get servers / alerts / settings
|
||||
updateSystemList()
|
||||
updateAlerts()
|
||||
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>
|
||||
|
||||
34
beszel/site/src/types.d.ts
vendored
34
beszel/site/src/types.d.ts
vendored
@@ -30,11 +30,17 @@ export interface SystemInfo {
|
||||
mp: number
|
||||
/** disk percent */
|
||||
dp: number
|
||||
/** bandwidth (mb) */
|
||||
b: number
|
||||
/** agent version */
|
||||
v: string
|
||||
}
|
||||
|
||||
export interface SystemStats {
|
||||
/** cpu percent */
|
||||
cpu: number
|
||||
/** peak cpu */
|
||||
cpum?: number
|
||||
/** total memory (gb) */
|
||||
m: number
|
||||
/** memory used (gb) */
|
||||
@@ -59,10 +65,18 @@ export interface SystemStats {
|
||||
dr: number
|
||||
/** disk write (mb) */
|
||||
dw: number
|
||||
/** max disk read (mb) */
|
||||
drm?: number
|
||||
/** max disk write (mb) */
|
||||
dwm?: number
|
||||
/** network sent (mb) */
|
||||
ns: number
|
||||
/** network received (mb) */
|
||||
nr: number
|
||||
/** max network sent (mb) */
|
||||
nsm?: number
|
||||
/** max network received (mb) */
|
||||
nrm?: number
|
||||
/** temperatures */
|
||||
t?: Record<string, number>
|
||||
/** extra filesystems */
|
||||
@@ -78,6 +92,10 @@ export interface ExtraFsStats {
|
||||
r: number
|
||||
/** total write (mb) */
|
||||
w: number
|
||||
/** max read (mb) */
|
||||
rm: number
|
||||
/** max write (mb) */
|
||||
wm: number
|
||||
}
|
||||
|
||||
export interface ContainerStatsRecord extends RecordModel {
|
||||
@@ -109,6 +127,8 @@ export interface AlertRecord extends RecordModel {
|
||||
id: string
|
||||
system: string
|
||||
name: string
|
||||
triggered: boolean
|
||||
sysname?: string
|
||||
// user: string
|
||||
}
|
||||
|
||||
@@ -131,3 +151,17 @@ export type UserSettings = {
|
||||
emails?: string[]
|
||||
webhooks?: string[]
|
||||
}
|
||||
|
||||
type ChartDataContainer = {
|
||||
created: number | null
|
||||
} & {
|
||||
[key: string]: key extends 'created' ? never : ContainerStats
|
||||
}
|
||||
|
||||
export interface ChartData {
|
||||
systemStats: SystemStatsRecord[]
|
||||
containerData: ChartDataContainer[]
|
||||
ticks: number[]
|
||||
domain: number[]
|
||||
chartTime: ChartTimes
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package beszel
|
||||
|
||||
const (
|
||||
Version = "0.5.2"
|
||||
Version = "0.6.1"
|
||||
AppName = "beszel"
|
||||
)
|
||||
|
||||
@@ -12,7 +12,7 @@ A lightweight server resource monitoring hub with historical data, docker stats,
|
||||
- **Lightweight**: Smaller and less resource-intensive than leading solutions.
|
||||
- **Simple**: Easy setup, no need for public internet exposure.
|
||||
- **Docker stats**: Tracks CPU, memory, and network usage history for each container.
|
||||
- **Alerts**: Configurable alerts for CPU, memory, disk usage, and system status.
|
||||
- **Alerts**: Configurable alerts for CPU, memory, disk, bandwidth, temperature, and system status.
|
||||
- **Multi-user**: Each user manages their own systems. Admins can share systems across users.
|
||||
- **OAuth / OIDC**: Supports multiple OAuth2 providers. Password authentication can be disabled.
|
||||
- **Automatic backups**: Save and restore data from disk or S3-compatible storage.
|
||||
|
||||
Reference in New Issue
Block a user