mirror of
https://github.com/henrygd/beszel.git
synced 2025-11-28 07:53:26 +00:00
Compare commits
1 Commits
v0.7.1
...
built-in-a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8de2dee4e9 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -11,5 +11,3 @@ dist
|
|||||||
beszel/cmd/hub/hub
|
beszel/cmd/hub/hub
|
||||||
beszel/cmd/agent/agent
|
beszel/cmd/agent/agent
|
||||||
node_modules
|
node_modules
|
||||||
beszel/build
|
|
||||||
*timestamp*
|
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
# Default OS/ARCH values
|
|
||||||
OS ?= $(shell go env GOOS)
|
|
||||||
ARCH ?= $(shell go env GOARCH)
|
|
||||||
# Skip building the web UI if true
|
|
||||||
SKIP_WEB ?= false
|
|
||||||
|
|
||||||
.PHONY: tidy build-agent build-hub build clean lint
|
|
||||||
.DEFAULT_GOAL := build
|
|
||||||
|
|
||||||
tidy:
|
|
||||||
go mod tidy
|
|
||||||
|
|
||||||
build-web-ui:
|
|
||||||
@if command -v bun >/dev/null 2>&1; then \
|
|
||||||
bun install --cwd ./site && \
|
|
||||||
bun run --cwd ./site build; \
|
|
||||||
else \
|
|
||||||
npm install --prefix ./site && \
|
|
||||||
npm run --prefix ./site build; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
build-agent: tidy
|
|
||||||
CGO_ENABLED=0 GOOS=$(OS) GOARCH=$(ARCH) go build -o ./build/beszel-agent_$(OS)_$(ARCH) -ldflags "-w -s" beszel/cmd/agent
|
|
||||||
|
|
||||||
build-hub: tidy $(if $(filter false,$(SKIP_WEB)),build-web-ui)
|
|
||||||
CGO_ENABLED=0 GOOS=$(OS) GOARCH=$(ARCH) go build -o ./build/beszel_$(OS)_$(ARCH) -ldflags "-w -s" beszel/cmd/hub
|
|
||||||
|
|
||||||
build: build-agent build-hub
|
|
||||||
|
|
||||||
clean:
|
|
||||||
go clean
|
|
||||||
rm -rf ./build
|
|
||||||
|
|
||||||
lint:
|
|
||||||
golangci-lint run
|
|
||||||
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"beszel"
|
"beszel"
|
||||||
"beszel/internal/agent"
|
"beszel/internal/agent"
|
||||||
|
"beszel/internal/update"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
@@ -16,7 +17,7 @@ func main() {
|
|||||||
case "-v":
|
case "-v":
|
||||||
fmt.Println(beszel.AppName+"-agent", beszel.Version)
|
fmt.Println(beszel.AppName+"-agent", beszel.Version)
|
||||||
case "update":
|
case "update":
|
||||||
agent.Update()
|
update.UpdateBeszelAgent()
|
||||||
}
|
}
|
||||||
os.Exit(0)
|
os.Exit(0)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"beszel"
|
"beszel"
|
||||||
"beszel/internal/hub"
|
"beszel/internal/hub"
|
||||||
|
"beszel/internal/update"
|
||||||
_ "beszel/migrations"
|
_ "beszel/migrations"
|
||||||
|
|
||||||
"github.com/pocketbase/pocketbase"
|
"github.com/pocketbase/pocketbase"
|
||||||
@@ -21,7 +22,7 @@ func main() {
|
|||||||
app.RootCmd.AddCommand(&cobra.Command{
|
app.RootCmd.AddCommand(&cobra.Command{
|
||||||
Use: "update",
|
Use: "update",
|
||||||
Short: "Update " + beszel.AppName + " to the latest version",
|
Short: "Update " + beszel.AppName + " to the latest version",
|
||||||
Run: hub.Update,
|
Run: func(_ *cobra.Command, _ []string) { update.UpdateBeszel() },
|
||||||
})
|
})
|
||||||
|
|
||||||
hub.NewHub(app).Run()
|
hub.NewHub(app).Run()
|
||||||
|
|||||||
@@ -9,45 +9,43 @@ require (
|
|||||||
github.com/goccy/go-json v0.10.3
|
github.com/goccy/go-json v0.10.3
|
||||||
github.com/labstack/echo/v5 v5.0.0-20230722203903-ec5b858dab61
|
github.com/labstack/echo/v5 v5.0.0-20230722203903-ec5b858dab61
|
||||||
github.com/pocketbase/dbx v1.10.1
|
github.com/pocketbase/dbx v1.10.1
|
||||||
github.com/pocketbase/pocketbase v0.22.23
|
github.com/pocketbase/pocketbase v0.22.21
|
||||||
github.com/rhysd/go-github-selfupdate v1.2.3
|
github.com/rhysd/go-github-selfupdate v1.2.3
|
||||||
github.com/shirou/gopsutil/v4 v4.24.10
|
github.com/shirou/gopsutil/v4 v4.24.9
|
||||||
github.com/spf13/cast v1.7.0
|
|
||||||
github.com/spf13/cobra v1.8.1
|
github.com/spf13/cobra v1.8.1
|
||||||
golang.org/x/crypto v0.28.0
|
golang.org/x/crypto v0.27.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/AlecAivazis/survey/v2 v2.3.7 // indirect
|
github.com/AlecAivazis/survey/v2 v2.3.7 // indirect
|
||||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
|
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
|
||||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
|
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2 v1.32.3 // indirect
|
github.com/aws/aws-sdk-go-v2 v1.31.0 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.6 // indirect
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.5 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.28.1 // indirect
|
github.com/aws/aws-sdk-go-v2/config v1.27.39 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.42 // 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.18 // 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.35 // 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.22 // 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.22 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.18 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.22 // 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.12.0 // 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.4.3 // 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.12.3 // 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.18.3 // 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.66.2 // indirect
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.64.0 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/sso v1.24.3 // indirect
|
github.com/aws/aws-sdk-go-v2/service/sso v1.23.3 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.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.32.3 // indirect
|
github.com/aws/aws-sdk-go-v2/service/sts v1.31.3 // indirect
|
||||||
github.com/aws/smithy-go v1.22.0 // indirect
|
github.com/aws/smithy-go v1.21.0 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
github.com/disintegration/imaging v1.6.2 // indirect
|
github.com/disintegration/imaging v1.6.2 // indirect
|
||||||
github.com/domodwyer/mailyak/v3 v3.6.2 // indirect
|
github.com/domodwyer/mailyak/v3 v3.6.2 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/ebitengine/purego v0.8.1 // indirect
|
github.com/ebitengine/purego v0.8.0 // indirect
|
||||||
github.com/fatih/color v1.18.0 // indirect
|
github.com/fatih/color v1.17.0 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.6 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.5 // indirect
|
||||||
github.com/ganigeorgiev/fexpr v0.4.1 // indirect
|
github.com/ganigeorgiev/fexpr v0.4.1 // indirect
|
||||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||||
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect
|
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect
|
||||||
@@ -64,37 +62,38 @@ require (
|
|||||||
github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 // indirect
|
github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-sqlite3 v1.14.24 // indirect
|
github.com/mattn/go-sqlite3 v1.14.23 // indirect
|
||||||
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
|
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
|
||||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
github.com/spf13/cast v1.7.0 // indirect
|
||||||
github.com/spf13/pflag v1.0.5 // indirect
|
github.com/spf13/pflag v1.0.5 // indirect
|
||||||
github.com/tcnksm/go-gitconfig v0.1.2 // indirect
|
github.com/tcnksm/go-gitconfig v0.1.2 // indirect
|
||||||
github.com/tklauser/go-sysconf v0.3.14 // indirect
|
github.com/tklauser/go-sysconf v0.3.14 // indirect
|
||||||
github.com/tklauser/numcpus v0.9.0 // indirect
|
github.com/tklauser/numcpus v0.8.0 // indirect
|
||||||
github.com/ulikunitz/xz v0.5.12 // indirect
|
github.com/ulikunitz/xz v0.5.12 // indirect
|
||||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
go.opencensus.io v0.24.0 // indirect
|
go.opencensus.io v0.24.0 // indirect
|
||||||
gocloud.dev v0.40.0 // indirect
|
gocloud.dev v0.39.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c // indirect
|
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect
|
||||||
golang.org/x/image v0.21.0 // indirect
|
golang.org/x/image v0.20.0 // indirect
|
||||||
golang.org/x/net v0.30.0 // indirect
|
golang.org/x/net v0.29.0 // indirect
|
||||||
golang.org/x/oauth2 v0.23.0 // indirect
|
golang.org/x/oauth2 v0.23.0 // indirect
|
||||||
golang.org/x/sync v0.8.0 // indirect
|
golang.org/x/sync v0.8.0 // indirect
|
||||||
golang.org/x/sys v0.26.0 // indirect
|
golang.org/x/sys v0.25.0 // indirect
|
||||||
golang.org/x/term v0.25.0 // indirect
|
golang.org/x/term v0.24.0 // indirect
|
||||||
golang.org/x/text v0.19.0 // indirect
|
golang.org/x/text v0.18.0 // indirect
|
||||||
golang.org/x/time v0.7.0 // indirect
|
golang.org/x/time v0.6.0 // indirect
|
||||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
|
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
|
||||||
google.golang.org/api v0.204.0 // indirect
|
google.golang.org/api v0.199.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20241021214115-324edc3d5d38 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20240930140551-af27646dc61f // indirect
|
||||||
google.golang.org/grpc v1.67.1 // indirect
|
google.golang.org/grpc v1.67.1 // indirect
|
||||||
google.golang.org/protobuf v1.35.1 // indirect
|
google.golang.org/protobuf v1.34.2 // indirect
|
||||||
modernc.org/gc/v3 v3.0.0-20241004144649-1aea3fae8852 // indirect
|
modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a // indirect
|
||||||
modernc.org/libc v1.61.0 // indirect
|
modernc.org/libc v1.61.0 // indirect
|
||||||
modernc.org/mathutil v1.6.0 // indirect
|
modernc.org/mathutil v1.6.0 // indirect
|
||||||
modernc.org/memory v1.8.0 // indirect
|
modernc.org/memory v1.8.0 // indirect
|
||||||
|
|||||||
173
beszel/go.sum
173
beszel/go.sum
@@ -1,10 +1,10 @@
|
|||||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||||
cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE=
|
cloud.google.com/go v0.115.1 h1:Jo0SM9cQnSkYfp44+v+NQXHpcHqlnRJk2qxh6yvxxxQ=
|
||||||
cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U=
|
cloud.google.com/go v0.115.1/go.mod h1:DuujITeaufu3gL68/lOFIirVNJwQeyf5UXyi+Wbgknc=
|
||||||
cloud.google.com/go/auth v0.10.0 h1:tWlkvFAh+wwTOzXIjrwM64karR1iTBZ/GRr0S/DULYo=
|
cloud.google.com/go/auth v0.9.5 h1:4CTn43Eynw40aFVr3GpPqsQponx2jv0BQpjvajsbbzw=
|
||||||
cloud.google.com/go/auth v0.10.0/go.mod h1:xxA5AqpDrvS+Gkmo9RqrGGRh6WSNKKOXhY3zNOr38tI=
|
cloud.google.com/go/auth v0.9.5/go.mod h1:Xo0n7n66eHyOWWCnitop6870Ilwo3PiZyodVkkH1xWM=
|
||||||
cloud.google.com/go/auth/oauth2adapt v0.2.5 h1:2p29+dePqsCHPP1bqDJcKj4qxRyYCcbzKpFyKGt3MTk=
|
cloud.google.com/go/auth/oauth2adapt v0.2.4 h1:0GWE/FUsXhf6C+jAkWgYm7X9tK8cuEIfy19DBn6B6bY=
|
||||||
cloud.google.com/go/auth/oauth2adapt v0.2.5/go.mod h1:AlmsELtlEBnaNTL7jCj8VQFLy6mbZv0s4Q7NGBeQ5E8=
|
cloud.google.com/go/auth/oauth2adapt v0.2.4/go.mod h1:jC/jOpwFP6JBxhB3P5Rr0a9HLMC/Pe3eaL4NmdvqPtc=
|
||||||
cloud.google.com/go/compute v1.14.0 h1:hfm2+FfxVmnRlh6LpB7cg1ZNU+5edAHmW679JePztk0=
|
cloud.google.com/go/compute v1.14.0 h1:hfm2+FfxVmnRlh6LpB7cg1ZNU+5edAHmW679JePztk0=
|
||||||
cloud.google.com/go/compute/metadata v0.5.2 h1:UxK4uu/Tn+I3p2dYWTfiX4wva7aYlKixAHn3fyqngqo=
|
cloud.google.com/go/compute/metadata v0.5.2 h1:UxK4uu/Tn+I3p2dYWTfiX4wva7aYlKixAHn3fyqngqo=
|
||||||
cloud.google.com/go/compute/metadata v0.5.2/go.mod h1:C66sj2AluDcIqakBq/M8lw8/ybHgOZqin2obFxa/E5k=
|
cloud.google.com/go/compute/metadata v0.5.2/go.mod h1:C66sj2AluDcIqakBq/M8lw8/ybHgOZqin2obFxa/E5k=
|
||||||
@@ -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/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
|
||||||
github.com/aws/aws-sdk-go v1.55.5 h1:KKUZBfBoyqy5d3swXyiC7Q76ic40rYcbqH7qjh59kzU=
|
github.com/aws/aws-sdk-go v1.55.5 h1:KKUZBfBoyqy5d3swXyiC7Q76ic40rYcbqH7qjh59kzU=
|
||||||
github.com/aws/aws-sdk-go v1.55.5/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
|
github.com/aws/aws-sdk-go v1.55.5/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
|
||||||
github.com/aws/aws-sdk-go-v2 v1.32.3 h1:T0dRlFBKcdaUPGNtkBSwHZxrtis8CQU17UpNBZYd0wk=
|
github.com/aws/aws-sdk-go-v2 v1.31.0 h1:3V05LbxTSItI5kUqNwhJrrrY1BAXxXt0sN0l72QmG5U=
|
||||||
github.com/aws/aws-sdk-go-v2 v1.32.3/go.mod h1:2SK5n0a2karNTv5tbP1SjsX0uhttou00v/HpXKM1ZUo=
|
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.6 h1:pT3hpW0cOHRJx8Y0DfJUEQuqPild8jRGmSFmBgvydr0=
|
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.6/go.mod h1:j/I2++U0xX+cr44QjHay4Cvxj6FUbnxrgmqN3H1jTZA=
|
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.28.1 h1:oxIvOUXy8x0U3fR//0eq+RdCKimWI900+SV+10xsCBw=
|
github.com/aws/aws-sdk-go-v2/config v1.27.39 h1:FCylu78eTGzW1ynHcongXK9YHtoXD5AiiUqq3YfJYjU=
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.28.1/go.mod h1:bRQcttQJiARbd5JZxw6wG0yIK3eLeSCPdg6uqmmlIiI=
|
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.42 h1:sBP0RPjBU4neGpIYyx8mkU2QqLPl5u9cmdTWVzIpHkM=
|
github.com/aws/aws-sdk-go-v2/credentials v1.17.37 h1:G2aOH01yW8X373JK419THj5QVqu9vKEwxSEsGxihoW0=
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.42/go.mod h1:FwZBfU530dJ26rv9saAbxa9Ej3eF/AK0OAY86k13n4M=
|
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.18 h1:68jFVtt3NulEzojFesM/WVarlFpCaXLKaBxDpzkQ9OQ=
|
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.18/go.mod h1:Fjnn5jQVIo6VyedMc0/EhPpfNlPl7dHV916O6B+49aE=
|
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.35 h1:ihPPdcCVSN0IvBByXwqVp28/l4VosBZ6sDulcvU2J7w=
|
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.35/go.mod h1:JkgEhs3SVF51Dj3m1Bj+yL8IznpxzkwlA3jLg3x7Kls=
|
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.22 h1:Jw50LwEkVjuVzE1NzkhNKkBf9cRN7MtE1F/b2cOKTUM=
|
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.22/go.mod h1:Y/SmAyPcOTmpeVaWSzSKiILfXTVJwrGmYZhcRbhWuEY=
|
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.22 h1:981MHwBaRZM7+9QSR6XamDzF/o7ouUGxFzr+nVSIhrs=
|
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.22/go.mod h1:1RA1+aBEfn+CAB/Mh0MB6LsdCYCnjZm7tKXtnk499ZQ=
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.18/go.mod h1:DkKMmksZVVyat+Y+r1dEOgJEfUeA7UngIHWeKsi0yNc=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 h1:VaRN3TlFdd6KxX1x3ILT5ynH6HvKgqdiXoTxAF4HQcQ=
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 h1:VaRN3TlFdd6KxX1x3ILT5ynH6HvKgqdiXoTxAF4HQcQ=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc=
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.22 h1:yV+hCAHZZYJQcwAaszoBNwLbPItHvApxT0kVIw6jRgs=
|
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.22/go.mod h1:kbR1TL8llqB1eGnVbybcA4/wgScxdylOdyAd51yxPdw=
|
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.12.0 h1:TToQNkvGguu209puTojY/ozlqy2d/SFNcoLIqTFi42g=
|
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.12.0/go.mod h1:0jp+ltwkf+SwG2fm/PKo8t4y8pJSgOCO4D8Lz3k0aHQ=
|
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.4.3 h1:kT6BcZsmMtNkP/iYMcRG+mIEA/IbeiUimXtGmqF39y0=
|
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.4.3/go.mod h1:Z8uGua2k4PPaGOYn66pK02rhMrot3Xk3tpBuUFPomZU=
|
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.12.3 h1:qcxX0JYlgWH3hpPUnd6U0ikcl6LLA9sLkXE2w1fpMvY=
|
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.12.3/go.mod h1:cLSNEmI45soc+Ef8K/L+8sEA3A3pYFEYf5B5UI+6bH4=
|
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.18.3 h1:ZC7Y/XgKUxwqcdhO5LE8P6oGP1eh6xlQReWNKfhvJno=
|
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.18.3/go.mod h1:WqfO7M9l9yUAw0HcHaikwRd/H6gzYdz7vjejCA5e2oY=
|
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.66.2 h1:p9TNFL8bFUMd+38YIpTAXpoxyz0MxC7FlbFEH4P4E1U=
|
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.66.2/go.mod h1:fNjyo0Coen9QTwQLWeV6WO2Nytwiu+cCcWaTdKCAqqE=
|
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.24.3 h1:UTpsIf0loCIWEbrqdLb+0RxnTXfWh2vhw4nQmFi4nPc=
|
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.24.3/go.mod h1:FZ9j3PFHHAR+w0BSEjK955w5YD2UwB/l/H0yAK3MJvI=
|
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.28.3 h1:2YCmIXv3tmiItw0LlYf6v7gEHebLY45kBEnPezbUKyU=
|
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.28.3/go.mod h1:u19stRyNPxGhj6dRm+Cdgu6N75qnbW7+QN0q0dsAk58=
|
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.32.3 h1:wVnQ6tigGsRqSWDEEyH6lSAJ9OyFUsSnbaUWChuSGzs=
|
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.32.3/go.mod h1:VZa9yTFyj4o10YGsmDO4gbQJUvvhY72fhumT8W4LqsE=
|
github.com/aws/aws-sdk-go-v2/service/sts v1.31.3/go.mod h1:yMWe0F+XG0DkRZK5ODZhG7BEFYhLXi2dqGsv6tX0cgI=
|
||||||
github.com/aws/smithy-go v1.22.0 h1:uunKnWlcoL3zO7q+gG2Pk53joueEOsnNB28QdMsmiMM=
|
github.com/aws/smithy-go v1.21.0 h1:H7L8dtDRk0P1Qm6y0ji7MCYMQObJ5R9CRpyPhRUkLYA=
|
||||||
github.com/aws/smithy-go v1.22.0/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg=
|
github.com/aws/smithy-go v1.21.0/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg=
|
||||||
github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ=
|
github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ=
|
||||||
github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
|
github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
|
||||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||||
@@ -84,21 +84,21 @@ github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCO
|
|||||||
github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c=
|
github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/ebitengine/purego v0.8.1 h1:sdRKd6plj7KYW33EH5As6YKfe8m9zbN9JMrOjNVF/BE=
|
github.com/ebitengine/purego v0.8.0 h1:JbqvnEzRvPpxhCJzJJ2y0RbiZ8nyjccVUrSM3q+GvvE=
|
||||||
github.com/ebitengine/purego v0.8.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
github.com/ebitengine/purego v0.8.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4=
|
||||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI=
|
||||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
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/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/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.6 h1:3+PzJTKLkvgjeTbts6msPJt4DixhT4YtFNf1gtGe3zc=
|
github.com/gabriel-vasile/mimetype v1.4.5 h1:J7wGKdGu33ocBOhGy0z653k/lFKLFDPJMG8Gql0kxn4=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.6/go.mod h1:JX1qVKqZd40hUPpAfiNTe0Sne7hdfKSbOqqmkq8GCXc=
|
github.com/gabriel-vasile/mimetype v1.4.5/go.mod h1:ibHel+/kbxn9x2407k1izTA1S81ku1z/DlgOW2QE0M4=
|
||||||
github.com/ganigeorgiev/fexpr v0.4.1 h1:hpUgbUEEWIZhSDBtf4M9aUNfQQ0BZkGRaMePy7Gcx5k=
|
github.com/ganigeorgiev/fexpr v0.4.1 h1:hpUgbUEEWIZhSDBtf4M9aUNfQQ0BZkGRaMePy7Gcx5k=
|
||||||
github.com/ganigeorgiev/fexpr v0.4.1/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
|
github.com/ganigeorgiev/fexpr v0.4.1/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
|
||||||
github.com/gliderlabs/ssh v0.3.7 h1:iV3Bqi942d9huXnzEF2Mt+CY9gLu8DNM4Obd+8bODRE=
|
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.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
|
github.com/mattn/go-sqlite3 v1.14.23 h1:gbShiuAP1W5j9UOksQ06aiiqPMxYecovVGwmTxWtuw0=
|
||||||
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
github.com/mattn/go-sqlite3 v1.14.23/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
|
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
|
||||||
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI=
|
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI=
|
||||||
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
|
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
|
||||||
@@ -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/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/pocketbase/dbx v1.10.1 h1:cw+vsyfCJD8YObOVeqb93YErnlxwYMkNZ4rwN0G0AaA=
|
github.com/pocketbase/dbx v1.10.1 h1:cw+vsyfCJD8YObOVeqb93YErnlxwYMkNZ4rwN0G0AaA=
|
||||||
github.com/pocketbase/dbx v1.10.1/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
github.com/pocketbase/dbx v1.10.1/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
||||||
github.com/pocketbase/pocketbase v0.22.23 h1:cnjSiBcMf7VIhXmoBmZCAV8qKYkOubHCOQQPZMKFBAk=
|
github.com/pocketbase/pocketbase v0.22.21 h1:DGPCxn6co8VuTV0mton4NFO/ON49XiFMszRr+Mysy48=
|
||||||
github.com/pocketbase/pocketbase v0.22.23/go.mod h1:h2ojT2pqBWH9LLl1aiawkwXiICKtzZA/kjM/8VhydR4=
|
github.com/pocketbase/pocketbase v0.22.21/go.mod h1:Cw5E4uoGhKItBIE2lJL3NfmiUr9Syk2xaNJ2G7Dssow=
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||||
@@ -229,8 +229,8 @@ github.com/rhysd/go-github-selfupdate v1.2.3/go.mod h1:mp/N8zj6jFfBQy/XMYoWsmfzx
|
|||||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/shirou/gopsutil/v4 v4.24.10 h1:7VOzPtfw/5YDU+jLEoBwXwxJbQetULywoSV4RYY7HkM=
|
github.com/shirou/gopsutil/v4 v4.24.9 h1:KIV+/HaHD5ka5f570RZq+2SaeFsb/pq+fp2DGNWYoOI=
|
||||||
github.com/shirou/gopsutil/v4 v4.24.10/go.mod h1:s4D/wg+ag4rG0WO7AiTj2BeYCRhym0vM7DHbZRxnIT8=
|
github.com/shirou/gopsutil/v4 v4.24.9/go.mod h1:3fkaHNeYsUFCGZ8+9vZVWtbyM1k2eRnlL+bWO8Bxa/Q=
|
||||||
github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w=
|
github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w=
|
||||||
github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||||
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
|
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
|
||||||
@@ -251,8 +251,8 @@ github.com/tcnksm/go-gitconfig v0.1.2 h1:iiDhRitByXAEyjgBqsKi9QU4o2TNtv9kPP3RgPg
|
|||||||
github.com/tcnksm/go-gitconfig v0.1.2/go.mod h1:/8EhP4H7oJZdIPyT+/UIsG87kTzrzM4UsLGSItWYCpE=
|
github.com/tcnksm/go-gitconfig v0.1.2/go.mod h1:/8EhP4H7oJZdIPyT+/UIsG87kTzrzM4UsLGSItWYCpE=
|
||||||
github.com/tklauser/go-sysconf v0.3.14 h1:g5vzr9iPFFz24v2KZXs/pvpvh8/V9Fw6vQK5ZZb78yU=
|
github.com/tklauser/go-sysconf v0.3.14 h1:g5vzr9iPFFz24v2KZXs/pvpvh8/V9Fw6vQK5ZZb78yU=
|
||||||
github.com/tklauser/go-sysconf v0.3.14/go.mod h1:1ym4lWMLUOhuBOPGtRcJm7tEGX4SCYNEEEtghGG/8uY=
|
github.com/tklauser/go-sysconf v0.3.14/go.mod h1:1ym4lWMLUOhuBOPGtRcJm7tEGX4SCYNEEEtghGG/8uY=
|
||||||
github.com/tklauser/numcpus v0.9.0 h1:lmyCHtANi8aRUgkckBgoDk1nHCux3n2cgkJLXdQGPDo=
|
github.com/tklauser/numcpus v0.8.0 h1:Mx4Wwe/FjZLeQsK/6kt2EOepwwSl7SmJrK5bV/dXYgY=
|
||||||
github.com/tklauser/numcpus v0.9.0/go.mod h1:SN6Nq1O3VychhC1npsWostA+oW+VOQTxZrS604NSRyI=
|
github.com/tklauser/numcpus v0.8.0/go.mod h1:ZJZlAY+dmR4eut8epnzf0u/VwodKmryxR8txiloSqBE=
|
||||||
github.com/ulikunitz/xz v0.5.9/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
github.com/ulikunitz/xz v0.5.9/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||||
github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc=
|
github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc=
|
||||||
github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||||
@@ -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/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 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4=
|
||||||
go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ=
|
go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ=
|
||||||
gocloud.dev v0.40.0 h1:f8LgP+4WDqOG/RXoUcyLpeIAGOcAbZrZbDQCUee10ng=
|
gocloud.dev v0.39.0 h1:EYABYGhAalPUaMrbSKOr5lejxoxvXj99nE8XFtsDgds=
|
||||||
gocloud.dev v0.40.0/go.mod h1:drz+VyYNBvrMTW0KZiBAYEdl8lbNZx+OQ7oQvdrFmSQ=
|
gocloud.dev v0.39.0/go.mod h1:drz+VyYNBvrMTW0KZiBAYEdl8lbNZx+OQ7oQvdrFmSQ=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
|
golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
|
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
|
||||||
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
|
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
|
||||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c h1:7dEasQXItcW1xKJ2+gg5VOiBnqWrJc+rq0DPKyvvdbY=
|
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk=
|
||||||
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c/go.mod h1:NQtJDoLvd6faHhE7m4T/1IY708gDefGGjR/iUW8yQQ8=
|
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY=
|
||||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||||
golang.org/x/image v0.21.0 h1:c5qV36ajHpdj4Qi0GnE0jUc/yuo33OLFaa0d+crTD5s=
|
golang.org/x/image v0.20.0 h1:7cVCUjQwfL18gyBJOmYvptfSHS8Fb3YUDtfLIZ7Nbpw=
|
||||||
golang.org/x/image v0.21.0/go.mod h1:vUbsLavqK/W303ZroQQVKQ+Af3Yl6Uz1Ppu5J/cLz78=
|
golang.org/x/image v0.20.0/go.mod h1:0a88To4CYVBAHp5FXJm8o7QbUl37Vd85ply1vyD8auM=
|
||||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||||
@@ -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-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
|
golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
|
||||||
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
|
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
|
||||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||||
golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||||
golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs=
|
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.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
|
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
|
||||||
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24=
|
golang.org/x/term v0.24.0 h1:Mh5cbb+Zk2hqqXNO7S1iTjEphVL+jb8ZWaqh/g+JWkM=
|
||||||
golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M=
|
golang.org/x/term v0.24.0/go.mod h1:lOBK/LVxemqiMij05LGJ0tzNr8xlmwBRJ81PX6wVLH8=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
|
golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
|
||||||
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
||||||
golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ=
|
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
|
||||||
golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/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=
|
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-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ=
|
golang.org/x/tools v0.25.0 h1:oFU9pkj/iJgs+0DT+VMHrx+oBKs/LJMV+Uvg78sl+fE=
|
||||||
golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0=
|
golang.org/x/tools v0.25.0/go.mod h1:/vtpO8WL1N9cQC3FN5zPqb//fRXskFHbLKk4OW1Q7rg=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
|
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
|
||||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
|
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
|
||||||
google.golang.org/api v0.204.0 h1:3PjmQQEDkR/ENVZZwIYB4W/KzYtN8OrqnNcHWpeR8E4=
|
google.golang.org/api v0.199.0 h1:aWUXClp+VFJmqE0JPvpZOK3LDQMyFKYIow4etYd9qxs=
|
||||||
google.golang.org/api v0.204.0/go.mod h1:69y8QSoKIbL9F94bWgWAq6wGqGwyjBgi2y8rAK8zLag=
|
google.golang.org/api v0.199.0/go.mod h1:ohG4qSztDJmZdjK/Ar6MhbAmb/Rpi4JHOqagsh90K28=
|
||||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||||
google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||||
@@ -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-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||||
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
||||||
google.golang.org/genproto v0.0.0-20241021214115-324edc3d5d38 h1:Q3nlH8iSQSRUwOskjbcSMcF2jiYMNiQYZ0c2KEJLKKU=
|
google.golang.org/genproto v0.0.0-20240903143218-8af14fe29dc1 h1:BulPr26Jqjnd4eYDVe+YvyR7Yc2vJGkO5/0UxD0/jZU=
|
||||||
google.golang.org/genproto v0.0.0-20241021214115-324edc3d5d38/go.mod h1:xBI+tzfqGGN2JBeSebfKXFSdBpWVQ7sLW40PTupVRm4=
|
google.golang.org/genproto v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:hL97c3SYopEHblzpxRL4lSs523++l8DYxGM1FQiYmb4=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20240814211410-ddb44dafa142 h1:wKguEg1hsxI2/L3hUYrpo1RVi48K+uTyzKqprwLXsb8=
|
google.golang.org/genproto/googleapis/api v0.0.0-20240814211410-ddb44dafa142 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/api v0.0.0-20240814211410-ddb44dafa142/go.mod h1:d6be+8HhtEtucleCbxpPW9PA9XwISACu8nvpPqF0BVo=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20241021214115-324edc3d5d38 h1:zciRKQ4kBpFgpfC5QQCVtnnNAcLIqweL7plyZRQHVpI=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20240930140551-af27646dc61f h1:cUMEy+8oS78BWIH9OWazBkzbr090Od9tWBNtZHkOhf0=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20241021214115-324edc3d5d38/go.mod h1:GX3210XPVPUjJbTUbvwI8f2IpZDMZuPJWDzDuebbviI=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20240930140551-af27646dc61f/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
|
||||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||||
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||||
@@ -395,10 +395,9 @@ 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.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.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.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
||||||
google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA=
|
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
|
||||||
google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
|
|
||||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||||
@@ -417,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/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
||||||
modernc.org/gc/v2 v2.5.0 h1:bJ9ChznK1L1mUtAQtxi0wi5AtAs5jQuw4PrPHO5pb6M=
|
modernc.org/gc/v2 v2.5.0 h1:bJ9ChznK1L1mUtAQtxi0wi5AtAs5jQuw4PrPHO5pb6M=
|
||||||
modernc.org/gc/v2 v2.5.0/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
|
modernc.org/gc/v2 v2.5.0/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
|
||||||
modernc.org/gc/v3 v3.0.0-20241004144649-1aea3fae8852 h1:IYXPPTTjjoSHvUClZIYexDiO7g+4x+XveKT4gCIAwiY=
|
modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a h1:CfbpOLEo2IwNzJdMvE8aiRbPMxoTpgAJeyePh0SmO8M=
|
||||||
modernc.org/gc/v3 v3.0.0-20241004144649-1aea3fae8852/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
|
modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
|
||||||
modernc.org/libc v1.61.0 h1:eGFcvWpqlnoGwzZeZe3PWJkkKbM/3SUGyk1DVZQ0TpE=
|
modernc.org/libc v1.61.0 h1:eGFcvWpqlnoGwzZeZe3PWJkkKbM/3SUGyk1DVZQ0TpE=
|
||||||
modernc.org/libc v1.61.0/go.mod h1:DvxVX89wtGTu+r72MLGhygpfi3aUGgZRdAYGCAVVud0=
|
modernc.org/libc v1.61.0/go.mod h1:DvxVX89wtGTu+r72MLGhygpfi3aUGgZRdAYGCAVVud0=
|
||||||
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"beszel"
|
|
||||||
"beszel/internal/entities/system"
|
"beszel/internal/entities/system"
|
||||||
"context"
|
"context"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -30,7 +29,6 @@ func NewAgent() *Agent {
|
|||||||
return &Agent{
|
return &Agent{
|
||||||
sensorsContext: context.Background(),
|
sensorsContext: context.Background(),
|
||||||
memCalc: os.Getenv("MEM_CALC"),
|
memCalc: os.Getenv("MEM_CALC"),
|
||||||
fsStats: make(map[string]*system.FsStats),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,8 +46,6 @@ func (a *Agent) Run(pubKey []byte, addr string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.Debug(beszel.Version)
|
|
||||||
|
|
||||||
// Set sensors context (allows overriding sys location for sensors)
|
// Set sensors context (allows overriding sys location for sensors)
|
||||||
if sysSensors, exists := os.LookupEnv("SYS_SENSORS"); exists {
|
if sysSensors, exists := os.LookupEnv("SYS_SENSORS"); exists {
|
||||||
slog.Info("SYS_SENSORS", "path", sysSensors)
|
slog.Info("SYS_SENSORS", "path", sysSensors)
|
||||||
@@ -62,9 +58,7 @@ func (a *Agent) Run(pubKey []byte, addr string) {
|
|||||||
if sensors, exists := os.LookupEnv("SENSORS"); exists {
|
if sensors, exists := os.LookupEnv("SENSORS"); exists {
|
||||||
a.sensorsWhitelist = make(map[string]struct{})
|
a.sensorsWhitelist = make(map[string]struct{})
|
||||||
for _, sensor := range strings.Split(sensors, ",") {
|
for _, sensor := range strings.Split(sensors, ",") {
|
||||||
if sensor != "" {
|
a.sensorsWhitelist[sensor] = struct{}{}
|
||||||
a.sensorsWhitelist[sensor] = struct{}{}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,23 +70,22 @@ func (a *Agent) Run(pubKey []byte, addr string) {
|
|||||||
|
|
||||||
// if debugging, print stats
|
// if debugging, print stats
|
||||||
if a.debug {
|
if a.debug {
|
||||||
slog.Debug("Stats", "data", a.gatherStats())
|
slog.Debug("Stats", "data", a.GatherStats())
|
||||||
}
|
}
|
||||||
|
|
||||||
a.startServer(pubKey, addr)
|
if pubKey != nil {
|
||||||
|
a.startServer(pubKey, addr)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Agent) gatherStats() system.CombinedData {
|
func (a *Agent) GatherStats() system.CombinedData {
|
||||||
slog.Debug("Getting stats")
|
|
||||||
systemData := system.CombinedData{
|
systemData := system.CombinedData{
|
||||||
Stats: a.getSystemStats(),
|
Stats: a.getSystemStats(),
|
||||||
Info: a.systemInfo,
|
Info: a.systemInfo,
|
||||||
}
|
}
|
||||||
slog.Debug("System stats", "data", systemData)
|
|
||||||
// add docker stats
|
// add docker stats
|
||||||
if containerStats, err := a.dockerManager.getDockerStats(); err == nil {
|
if containerStats, err := a.dockerManager.getDockerStats(); err == nil {
|
||||||
systemData.Containers = containerStats
|
systemData.Containers = containerStats
|
||||||
slog.Debug("Docker stats", "data", systemData.Containers)
|
|
||||||
} else {
|
} else {
|
||||||
slog.Debug("Error getting docker stats", "err", err)
|
slog.Debug("Error getting docker stats", "err", err)
|
||||||
}
|
}
|
||||||
@@ -103,6 +96,5 @@ func (a *Agent) gatherStats() system.CombinedData {
|
|||||||
systemData.Stats.ExtraFs[name] = stats
|
systemData.Stats.ExtraFs[name] = stats
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
slog.Debug("Extra filesystems", "data", systemData.Stats.ExtraFs)
|
|
||||||
return systemData
|
return systemData
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ func (a *Agent) initializeDiskInfo() {
|
|||||||
efPath := "/extra-filesystems"
|
efPath := "/extra-filesystems"
|
||||||
hasRoot := false
|
hasRoot := false
|
||||||
|
|
||||||
|
// Create map for disk stats
|
||||||
|
a.fsStats = make(map[string]*system.FsStats)
|
||||||
|
|
||||||
partitions, err := disk.Partitions(false)
|
partitions, err := disk.Partitions(false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Error getting disk partitions", "err", err)
|
slog.Error("Error getting disk partitions", "err", err)
|
||||||
@@ -44,7 +47,7 @@ func (a *Agent) initializeDiskInfo() {
|
|||||||
// check if root device is in /proc/diskstats, use fallback if not
|
// check if root device is in /proc/diskstats, use fallback if not
|
||||||
if _, exists := diskIoCounters[key]; !exists {
|
if _, exists := diskIoCounters[key]; !exists {
|
||||||
slog.Warn("Device not found in diskstats", "name", key)
|
slog.Warn("Device not found in diskstats", "name", key)
|
||||||
key = findFallbackIoDevice(filesystem, diskIoCounters, a.fsStats)
|
key = findFallbackIoDevice(filesystem, diskIoCounters)
|
||||||
slog.Info("Using I/O fallback", "name", key)
|
slog.Info("Using I/O fallback", "name", key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -122,7 +125,7 @@ func (a *Agent) initializeDiskInfo() {
|
|||||||
|
|
||||||
// If no root filesystem set, use fallback
|
// If no root filesystem set, use fallback
|
||||||
if !hasRoot {
|
if !hasRoot {
|
||||||
rootDevice := findFallbackIoDevice(filepath.Base(filesystem), diskIoCounters, a.fsStats)
|
rootDevice := findFallbackIoDevice(filepath.Base(filesystem), diskIoCounters)
|
||||||
slog.Info("Root disk", "mountpoint", "/", "io", rootDevice)
|
slog.Info("Root disk", "mountpoint", "/", "io", rootDevice)
|
||||||
a.fsStats[rootDevice] = &system.FsStats{Root: true, Mountpoint: "/"}
|
a.fsStats[rootDevice] = &system.FsStats{Root: true, Mountpoint: "/"}
|
||||||
}
|
}
|
||||||
@@ -132,7 +135,7 @@ func (a *Agent) initializeDiskInfo() {
|
|||||||
|
|
||||||
// Returns the device with the most reads in /proc/diskstats,
|
// Returns the device with the most reads in /proc/diskstats,
|
||||||
// or the device specified by the filesystem argument if it exists
|
// or the device specified by the filesystem argument if it exists
|
||||||
func findFallbackIoDevice(filesystem string, diskIoCounters map[string]disk.IOCountersStat, fsStats map[string]*system.FsStats) string {
|
func findFallbackIoDevice(filesystem string, diskIoCounters map[string]disk.IOCountersStat) string {
|
||||||
var maxReadBytes uint64
|
var maxReadBytes uint64
|
||||||
maxReadDevice := "/"
|
maxReadDevice := "/"
|
||||||
for _, d := range diskIoCounters {
|
for _, d := range diskIoCounters {
|
||||||
@@ -140,11 +143,8 @@ func findFallbackIoDevice(filesystem string, diskIoCounters map[string]disk.IOCo
|
|||||||
return d.Name
|
return d.Name
|
||||||
}
|
}
|
||||||
if d.ReadBytes > maxReadBytes {
|
if d.ReadBytes > maxReadBytes {
|
||||||
// don't use if device already exists in fsStats
|
maxReadBytes = d.ReadBytes
|
||||||
if _, exists := fsStats[d.Name]; !exists {
|
maxReadDevice = d.Name
|
||||||
maxReadBytes = d.ReadBytes
|
|
||||||
maxReadDevice = d.Name
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return maxReadDevice
|
return maxReadDevice
|
||||||
|
|||||||
@@ -60,8 +60,6 @@ func (dm *dockerManager) getDockerStats() ([]*container.Stats, error) {
|
|||||||
clear(dm.validIds)
|
clear(dm.validIds)
|
||||||
}
|
}
|
||||||
|
|
||||||
var failedContainters []container.ApiInfo
|
|
||||||
|
|
||||||
for _, ctr := range *dm.apiContainerList {
|
for _, ctr := range *dm.apiContainerList {
|
||||||
ctr.IdShort = ctr.Id[:12]
|
ctr.IdShort = ctr.Id[:12]
|
||||||
dm.validIds[ctr.IdShort] = struct{}{}
|
dm.validIds[ctr.IdShort] = struct{}{}
|
||||||
@@ -76,33 +74,18 @@ func (dm *dockerManager) getDockerStats() ([]*container.Stats, error) {
|
|||||||
defer dm.dequeue()
|
defer dm.dequeue()
|
||||||
err := dm.updateContainerStats(ctr)
|
err := dm.updateContainerStats(ctr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
dm.containerStatsMutex.Lock()
|
dm.deleteContainerStatsSync(ctr.IdShort)
|
||||||
delete(dm.containerStatsMap, ctr.IdShort)
|
// retry once
|
||||||
failedContainters = append(failedContainters, ctr)
|
err = dm.updateContainerStats(ctr)
|
||||||
dm.containerStatsMutex.Unlock()
|
if err != nil {
|
||||||
|
slog.Error("Error getting container stats", "err", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
dm.wg.Wait()
|
dm.wg.Wait()
|
||||||
|
|
||||||
// retry failed containers separately so we can run them in parallel (docker 24 bug)
|
|
||||||
if len(failedContainters) > 0 {
|
|
||||||
slog.Debug("Retrying failed containers", "count", len(failedContainters))
|
|
||||||
// time.Sleep(time.Millisecond * 1100)
|
|
||||||
for _, ctr := range failedContainters {
|
|
||||||
dm.wg.Add(1)
|
|
||||||
go func() {
|
|
||||||
defer dm.wg.Done()
|
|
||||||
err = dm.updateContainerStats(ctr)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("Error getting container stats", "err", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
dm.wg.Wait()
|
|
||||||
}
|
|
||||||
|
|
||||||
// populate final stats and remove old / invalid container stats
|
// populate final stats and remove old / invalid container stats
|
||||||
stats := make([]*container.Stats, 0, containersLength)
|
stats := make([]*container.Stats, 0, containersLength)
|
||||||
for id, v := range dm.containerStatsMap {
|
for id, v := range dm.containerStatsMap {
|
||||||
@@ -234,20 +217,9 @@ func newDockerManager() *dockerManager {
|
|||||||
os.Exit(1)
|
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{
|
dockerClient := &dockerManager{
|
||||||
client: &http.Client{
|
client: &http.Client{
|
||||||
Timeout: timeout,
|
Timeout: time.Millisecond * 1100,
|
||||||
Transport: transport,
|
Transport: transport,
|
||||||
},
|
},
|
||||||
containerStatsMap: make(map[string]*container.Stats),
|
containerStatsMap: make(map[string]*container.Stats),
|
||||||
@@ -271,7 +243,7 @@ func newDockerManager() *dockerManager {
|
|||||||
return dockerClient
|
return dockerClient
|
||||||
}
|
}
|
||||||
|
|
||||||
// if version > 24, one-shot works correctly and we can limit concurrent operations
|
// if version > 25, one-shot works correctly and we can limit concurrent connections / goroutines to 5
|
||||||
if dockerVersion, err := semver.Parse(versionInfo.Version); err == nil && dockerVersion.Major > 24 {
|
if dockerVersion, err := semver.Parse(versionInfo.Version); err == nil && dockerVersion.Major > 24 {
|
||||||
concurrency = 5
|
concurrency = 5
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ func (a *Agent) startServer(pubKey []byte, addr string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *Agent) handleSession(s sshServer.Session) {
|
func (a *Agent) handleSession(s sshServer.Session) {
|
||||||
stats := a.gatherStats()
|
stats := a.GatherStats()
|
||||||
|
slog.Debug("Sending stats", "data", stats)
|
||||||
if err := json.NewEncoder(s).Encode(stats); err != nil {
|
if err := json.NewEncoder(s).Encode(stats); err != nil {
|
||||||
slog.Error("Error encoding stats", "err", err)
|
slog.Error("Error encoding stats", "err", err)
|
||||||
s.Exit(1)
|
s.Exit(1)
|
||||||
|
|||||||
@@ -171,36 +171,33 @@ func (a *Agent) getSystemStats() system.Stats {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// temperatures (skip if sensors whitelist is set to empty string)
|
// temperatures
|
||||||
if a.sensorsWhitelist != nil && len(a.sensorsWhitelist) == 0 {
|
temps, err := sensors.TemperaturesWithContext(a.sensorsContext)
|
||||||
slog.Debug("Skipping temperature collection")
|
if err != nil && a.debug {
|
||||||
} else {
|
err.(*sensors.Warnings).Verbose = true
|
||||||
temps, err := sensors.TemperaturesWithContext(a.sensorsContext)
|
slog.Debug("Sensor error", "errs", err)
|
||||||
if err != nil {
|
}
|
||||||
slog.Debug("Sensor error", "err", err)
|
if len(temps) > 0 {
|
||||||
}
|
slog.Debug("Temperatures", "data", temps)
|
||||||
slog.Debug("Temperature", "sensors", temps)
|
systemStats.Temperatures = make(map[string]float64, len(temps))
|
||||||
if len(temps) > 0 {
|
for i, sensor := range temps {
|
||||||
systemStats.Temperatures = make(map[string]float64, len(temps))
|
// skip if temperature is 0
|
||||||
for i, sensor := range temps {
|
if sensor.Temperature <= 0 || sensor.Temperature >= 200 {
|
||||||
// skip if temperature is 0
|
continue
|
||||||
if sensor.Temperature <= 0 || sensor.Temperature >= 200 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, ok := systemStats.Temperatures[sensor.SensorKey]; ok {
|
|
||||||
// if key already exists, append int to key
|
|
||||||
systemStats.Temperatures[sensor.SensorKey+"_"+strconv.Itoa(i)] = twoDecimals(sensor.Temperature)
|
|
||||||
} else {
|
|
||||||
systemStats.Temperatures[sensor.SensorKey] = twoDecimals(sensor.Temperature)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// remove sensors from systemStats if whitelist exists and sensor is not in whitelist
|
if _, ok := systemStats.Temperatures[sensor.SensorKey]; ok {
|
||||||
// (do this here instead of in initial loop so we have correct keys if int was appended)
|
// if key already exists, append int to key
|
||||||
if a.sensorsWhitelist != nil {
|
systemStats.Temperatures[sensor.SensorKey+"_"+strconv.Itoa(i)] = twoDecimals(sensor.Temperature)
|
||||||
for key := range systemStats.Temperatures {
|
} else {
|
||||||
if _, nameInWhitelist := a.sensorsWhitelist[key]; !nameInWhitelist {
|
systemStats.Temperatures[sensor.SensorKey] = twoDecimals(sensor.Temperature)
|
||||||
delete(systemStats.Temperatures, key)
|
}
|
||||||
}
|
}
|
||||||
|
// remove sensors from systemStats if whitelist exists and sensor is not in whitelist
|
||||||
|
// (do this here instead of in initial loop so we have correct keys if int was appended)
|
||||||
|
if a.sensorsWhitelist != nil {
|
||||||
|
for key := range systemStats.Temperatures {
|
||||||
|
if _, nameInWhitelist := a.sensorsWhitelist[key]; !nameInWhitelist {
|
||||||
|
delete(systemStats.Temperatures, key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -211,8 +208,6 @@ func (a *Agent) getSystemStats() system.Stats {
|
|||||||
a.systemInfo.MemPct = systemStats.MemPct
|
a.systemInfo.MemPct = systemStats.MemPct
|
||||||
a.systemInfo.DiskPct = systemStats.DiskPct
|
a.systemInfo.DiskPct = systemStats.DiskPct
|
||||||
a.systemInfo.Uptime, _ = host.Uptime()
|
a.systemInfo.Uptime, _ = host.Uptime()
|
||||||
a.systemInfo.Bandwidth = twoDecimals(systemStats.NetworkSent + systemStats.NetworkRecv)
|
|
||||||
slog.Debug("sysinfo", "data", a.systemInfo)
|
|
||||||
|
|
||||||
return systemStats
|
return systemStats
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,26 +6,21 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/mail"
|
"net/mail"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/containrrr/shoutrrr"
|
"github.com/containrrr/shoutrrr"
|
||||||
"github.com/goccy/go-json"
|
|
||||||
"github.com/labstack/echo/v5"
|
"github.com/labstack/echo/v5"
|
||||||
"github.com/pocketbase/dbx"
|
"github.com/pocketbase/dbx"
|
||||||
"github.com/pocketbase/pocketbase"
|
"github.com/pocketbase/pocketbase"
|
||||||
"github.com/pocketbase/pocketbase/apis"
|
"github.com/pocketbase/pocketbase/apis"
|
||||||
"github.com/pocketbase/pocketbase/models"
|
"github.com/pocketbase/pocketbase/models"
|
||||||
"github.com/pocketbase/pocketbase/tools/mailer"
|
"github.com/pocketbase/pocketbase/tools/mailer"
|
||||||
"github.com/pocketbase/pocketbase/tools/types"
|
|
||||||
"github.com/spf13/cast"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type AlertManager struct {
|
type AlertManager struct {
|
||||||
app *pocketbase.PocketBase
|
app *pocketbase.PocketBase
|
||||||
}
|
}
|
||||||
|
|
||||||
type AlertMessageData struct {
|
type AlertData struct {
|
||||||
UserID string
|
UserID string
|
||||||
Title string
|
Title string
|
||||||
Message string
|
Message string
|
||||||
@@ -38,308 +33,72 @@ type UserNotificationSettings struct {
|
|||||||
Webhooks []string `json:"webhooks"`
|
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 {
|
func NewAlertManager(app *pocketbase.PocketBase) *AlertManager {
|
||||||
return &AlertManager{
|
return &AlertManager{
|
||||||
app: app,
|
app: app,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (am *AlertManager) HandleSystemAlerts(systemRecord *models.Record, systemInfo system.Info, temperatures map[string]float64, extraFs map[string]*system.FsStats) error {
|
func (am *AlertManager) HandleSystemInfoAlerts(systemRecord *models.Record, systemInfo system.Info) {
|
||||||
// start := time.Now()
|
|
||||||
// defer func() {
|
|
||||||
// log.Println("alert stats took", time.Since(start))
|
|
||||||
// }()
|
|
||||||
alertRecords, err := am.app.Dao().FindRecordsByExpr("alerts",
|
alertRecords, err := am.app.Dao().FindRecordsByExpr("alerts",
|
||||||
dbx.NewExp("system={:system}", dbx.Params{"system": systemRecord.Id}),
|
dbx.NewExp("system={:system}", dbx.Params{"system": systemRecord.GetId()}),
|
||||||
)
|
)
|
||||||
if err != nil || len(alertRecords) == 0 {
|
if err != nil || len(alertRecords) == 0 {
|
||||||
// log.Println("no alerts found for system")
|
// log.Println("no alerts found for system")
|
||||||
return nil
|
return
|
||||||
}
|
}
|
||||||
|
// log.Println("found alerts", len(alertRecords))
|
||||||
var validAlerts []SystemAlertData
|
|
||||||
now := systemRecord.Updated.Time().UTC()
|
|
||||||
oldestTime := now
|
|
||||||
|
|
||||||
for _, alertRecord := range alertRecords {
|
for _, alertRecord := range alertRecords {
|
||||||
name := alertRecord.GetString("name")
|
name := alertRecord.GetString("name")
|
||||||
var val float64
|
|
||||||
unit := "%"
|
|
||||||
|
|
||||||
switch name {
|
switch name {
|
||||||
case "CPU":
|
case "CPU", "Memory", "Disk":
|
||||||
val = systemInfo.Cpu
|
if name == "CPU" {
|
||||||
case "Memory":
|
am.handleSlidingValueAlert(systemRecord, alertRecord, name, systemInfo.Cpu)
|
||||||
val = systemInfo.MemPct
|
} else if name == "Memory" {
|
||||||
case "Bandwidth":
|
am.handleSlidingValueAlert(systemRecord, alertRecord, name, systemInfo.MemPct)
|
||||||
val = systemInfo.Bandwidth
|
} else if name == "Disk" {
|
||||||
unit = " MB/s"
|
am.handleSlidingValueAlert(systemRecord, alertRecord, name, systemInfo.DiskPct)
|
||||||
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) sendSystemAlert(alert SystemAlertData) {
|
func (am *AlertManager) handleSlidingValueAlert(systemRecord *models.Record, alertRecord *models.Record, name string, curValue float64) {
|
||||||
// log.Printf("Sending alert %s: val %f | count %d | threshold %f\n", alert.name, alert.val, alert.count, alert.threshold)
|
triggered := alertRecord.GetBool("triggered")
|
||||||
systemName := alert.systemRecord.GetString("name")
|
threshold := alertRecord.GetFloat("value")
|
||||||
|
// fmt.Println(name, curValue, "threshold", threshold, "triggered", triggered)
|
||||||
// change Disk to Disk usage
|
|
||||||
if alert.name == "Disk" {
|
|
||||||
alert.name += " usage"
|
|
||||||
}
|
|
||||||
|
|
||||||
// make title alert name lowercase if not CPU
|
|
||||||
titleAlertName := alert.name
|
|
||||||
if titleAlertName != "CPU" {
|
|
||||||
titleAlertName = strings.ToLower(titleAlertName)
|
|
||||||
}
|
|
||||||
|
|
||||||
var subject string
|
var subject string
|
||||||
if alert.triggered {
|
var body string
|
||||||
subject = fmt.Sprintf("%s %s above threshold", systemName, titleAlertName)
|
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 {
|
} else {
|
||||||
subject = fmt.Sprintf("%s %s below threshold", systemName, titleAlertName)
|
// fmt.Println(name, "not triggered")
|
||||||
|
return
|
||||||
}
|
}
|
||||||
minutesLabel := "minute"
|
if err := am.app.Dao().SaveRecord(alertRecord); err != nil {
|
||||||
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())
|
// app.Logger().Error("failed to save alert record", "err", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// expand the user relation and send the alert
|
// expand the user relation and send the alert
|
||||||
if errs := am.app.Dao().ExpandRecord(alert.alertRecord, []string{"user"}, nil); len(errs) > 0 {
|
if errs := am.app.Dao().ExpandRecord(alertRecord, []string{"user"}, nil); len(errs) > 0 {
|
||||||
// app.Logger().Error("failed to expand user relation", "errs", errs)
|
// app.Logger().Error("failed to expand user relation", "errs", errs)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if user := alert.alertRecord.ExpandedOne("user"); user != nil {
|
if user := alertRecord.ExpandedOne("user"); user != nil {
|
||||||
am.sendAlert(AlertMessageData{
|
am.sendAlert(AlertData{
|
||||||
UserID: user.GetId(),
|
UserID: user.GetId(),
|
||||||
Title: subject,
|
Title: subject,
|
||||||
Message: body,
|
Message: body,
|
||||||
Link: am.app.Settings().Meta.AppUrl + "/system/" + url.PathEscape(systemName),
|
Link: am.app.Settings().Meta.AppUrl + "/system/" + url.QueryEscape(systemName),
|
||||||
LinkText: "View " + systemName,
|
LinkText: "View " + systemName,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -386,18 +145,18 @@ func (am *AlertManager) HandleStatusAlerts(newStatus string, oldSystemRecord *mo
|
|||||||
}
|
}
|
||||||
// send alert
|
// send alert
|
||||||
systemName := oldSystemRecord.GetString("name")
|
systemName := oldSystemRecord.GetString("name")
|
||||||
am.sendAlert(AlertMessageData{
|
am.sendAlert(AlertData{
|
||||||
UserID: user.GetId(),
|
UserID: user.GetId(),
|
||||||
Title: fmt.Sprintf("Connection to %s is %s %v", systemName, alertStatus, emoji),
|
Title: fmt.Sprintf("Connection to %s is %s %v", systemName, alertStatus, emoji),
|
||||||
Message: fmt.Sprintf("Connection to %s is %s", systemName, alertStatus),
|
Message: fmt.Sprintf("Connection to %s is %s", systemName, alertStatus),
|
||||||
Link: am.app.Settings().Meta.AppUrl + "/system/" + url.PathEscape(systemName),
|
Link: am.app.Settings().Meta.AppUrl + "/system/" + url.QueryEscape(systemName),
|
||||||
LinkText: "View " + systemName,
|
LinkText: "View " + systemName,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (am *AlertManager) sendAlert(data AlertMessageData) {
|
func (am *AlertManager) sendAlert(data AlertData) {
|
||||||
// get user settings
|
// get user settings
|
||||||
record, err := am.app.Dao().FindFirstRecordByFilter(
|
record, err := am.app.Dao().FindFirstRecordByFilter(
|
||||||
"user_settings", "user={:user}",
|
"user_settings", "user={:user}",
|
||||||
|
|||||||
@@ -6,42 +6,35 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Stats struct {
|
type Stats struct {
|
||||||
Cpu float64 `json:"cpu"`
|
Cpu float64 `json:"cpu"`
|
||||||
MaxCpu float64 `json:"cpum,omitempty"`
|
Mem float64 `json:"m"`
|
||||||
Mem float64 `json:"m"`
|
MemUsed float64 `json:"mu"`
|
||||||
MemUsed float64 `json:"mu"`
|
MemPct float64 `json:"mp"`
|
||||||
MemPct float64 `json:"mp"`
|
MemBuffCache float64 `json:"mb"`
|
||||||
MemBuffCache float64 `json:"mb"`
|
MemZfsArc float64 `json:"mz,omitempty"` // ZFS ARC memory
|
||||||
MemZfsArc float64 `json:"mz,omitempty"` // ZFS ARC memory
|
Swap float64 `json:"s,omitempty"`
|
||||||
Swap float64 `json:"s,omitempty"`
|
SwapUsed float64 `json:"su,omitempty"`
|
||||||
SwapUsed float64 `json:"su,omitempty"`
|
DiskTotal float64 `json:"d"`
|
||||||
DiskTotal float64 `json:"d"`
|
DiskUsed float64 `json:"du"`
|
||||||
DiskUsed float64 `json:"du"`
|
DiskPct float64 `json:"dp"`
|
||||||
DiskPct float64 `json:"dp"`
|
DiskReadPs float64 `json:"dr"`
|
||||||
DiskReadPs float64 `json:"dr"`
|
DiskWritePs float64 `json:"dw"`
|
||||||
DiskWritePs float64 `json:"dw"`
|
NetworkSent float64 `json:"ns"`
|
||||||
MaxDiskReadPs float64 `json:"drm,omitempty"`
|
NetworkRecv float64 `json:"nr"`
|
||||||
MaxDiskWritePs float64 `json:"dwm,omitempty"`
|
Temperatures map[string]float64 `json:"t,omitempty"`
|
||||||
NetworkSent float64 `json:"ns"`
|
ExtraFs map[string]*FsStats `json:"efs,omitempty"`
|
||||||
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 {
|
type FsStats struct {
|
||||||
Time time.Time `json:"-"`
|
Time time.Time `json:"-"`
|
||||||
Root bool `json:"-"`
|
Root bool `json:"-"`
|
||||||
Mountpoint string `json:"-"`
|
Mountpoint string `json:"-"`
|
||||||
DiskTotal float64 `json:"d"`
|
DiskTotal float64 `json:"d"`
|
||||||
DiskUsed float64 `json:"du"`
|
DiskUsed float64 `json:"du"`
|
||||||
TotalRead uint64 `json:"-"`
|
TotalRead uint64 `json:"-"`
|
||||||
TotalWrite uint64 `json:"-"`
|
TotalWrite uint64 `json:"-"`
|
||||||
DiskReadPs float64 `json:"r"`
|
DiskWritePs float64 `json:"w"`
|
||||||
DiskWritePs float64 `json:"w"`
|
DiskReadPs float64 `json:"r"`
|
||||||
MaxDiskReadPS float64 `json:"rm,omitempty"`
|
|
||||||
MaxDiskWritePS float64 `json:"wm,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type NetIoStats struct {
|
type NetIoStats struct {
|
||||||
@@ -61,7 +54,6 @@ type Info struct {
|
|||||||
Cpu float64 `json:"cpu"`
|
Cpu float64 `json:"cpu"`
|
||||||
MemPct float64 `json:"mp"`
|
MemPct float64 `json:"mp"`
|
||||||
DiskPct float64 `json:"dp"`
|
DiskPct float64 `json:"dp"`
|
||||||
Bandwidth float64 `json:"b"`
|
|
||||||
AgentVersion string `json:"v"`
|
AgentVersion string `json:"v"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,222 +0,0 @@
|
|||||||
package hub
|
|
||||||
|
|
||||||
import (
|
|
||||||
"beszel/internal/entities/system"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"github.com/labstack/echo/v5"
|
|
||||||
"github.com/pocketbase/dbx"
|
|
||||||
"github.com/pocketbase/pocketbase/apis"
|
|
||||||
"github.com/pocketbase/pocketbase/models"
|
|
||||||
"github.com/spf13/cast"
|
|
||||||
"gopkg.in/yaml.v3"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Config struct {
|
|
||||||
Systems []SystemConfig `yaml:"systems"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type SystemConfig struct {
|
|
||||||
Name string `yaml:"name"`
|
|
||||||
Host string `yaml:"host"`
|
|
||||||
Port uint16 `yaml:"port"`
|
|
||||||
Users []string `yaml:"users"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Syncs systems with the config.yml file
|
|
||||||
func (h *Hub) syncSystemsWithConfig() error {
|
|
||||||
configPath := filepath.Join(h.app.DataDir(), "config.yml")
|
|
||||||
configData, err := os.ReadFile(configPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var config Config
|
|
||||||
err = yaml.Unmarshal(configData, &config)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to parse config.yml: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(config.Systems) == 0 {
|
|
||||||
log.Println("No systems defined in config.yml.")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var firstUser *models.Record
|
|
||||||
|
|
||||||
// Create a map of email to user ID
|
|
||||||
userEmailToID := make(map[string]string)
|
|
||||||
users, err := h.app.Dao().FindRecordsByExpr("users", dbx.NewExp("id != ''"))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if len(users) > 0 {
|
|
||||||
firstUser = users[0]
|
|
||||||
for _, user := range users {
|
|
||||||
userEmailToID[user.GetString("email")] = user.Id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// add default settings for systems if not defined in config
|
|
||||||
for i := range config.Systems {
|
|
||||||
system := &config.Systems[i]
|
|
||||||
if system.Port == 0 {
|
|
||||||
system.Port = 45876
|
|
||||||
}
|
|
||||||
if len(users) > 0 && len(system.Users) == 0 {
|
|
||||||
// default to first user if none are defined
|
|
||||||
system.Users = []string{firstUser.Id}
|
|
||||||
} else {
|
|
||||||
// Convert email addresses to user IDs
|
|
||||||
userIDs := make([]string, 0, len(system.Users))
|
|
||||||
for _, email := range system.Users {
|
|
||||||
if id, ok := userEmailToID[email]; ok {
|
|
||||||
userIDs = append(userIDs, id)
|
|
||||||
} else {
|
|
||||||
log.Printf("User %s not found", email)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
system.Users = userIDs
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get existing systems
|
|
||||||
existingSystems, err := h.app.Dao().FindRecordsByExpr("systems", dbx.NewExp("id != ''"))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a map of existing systems for easy lookup
|
|
||||||
existingSystemsMap := make(map[string]*models.Record)
|
|
||||||
for _, system := range existingSystems {
|
|
||||||
key := system.GetString("host") + ":" + system.GetString("port")
|
|
||||||
existingSystemsMap[key] = system
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process systems from config
|
|
||||||
for _, sysConfig := range config.Systems {
|
|
||||||
key := sysConfig.Host + ":" + strconv.Itoa(int(sysConfig.Port))
|
|
||||||
if existingSystem, ok := existingSystemsMap[key]; ok {
|
|
||||||
// Update existing system
|
|
||||||
existingSystem.Set("name", sysConfig.Name)
|
|
||||||
existingSystem.Set("users", sysConfig.Users)
|
|
||||||
existingSystem.Set("port", sysConfig.Port)
|
|
||||||
if err := h.app.Dao().SaveRecord(existingSystem); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
delete(existingSystemsMap, key)
|
|
||||||
} else {
|
|
||||||
// Create new system
|
|
||||||
systemsCollection, err := h.app.Dao().FindCollectionByNameOrId("systems")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to find systems collection: %v", err)
|
|
||||||
}
|
|
||||||
newSystem := models.NewRecord(systemsCollection)
|
|
||||||
newSystem.Set("name", sysConfig.Name)
|
|
||||||
newSystem.Set("host", sysConfig.Host)
|
|
||||||
newSystem.Set("port", sysConfig.Port)
|
|
||||||
newSystem.Set("users", sysConfig.Users)
|
|
||||||
newSystem.Set("info", system.Info{})
|
|
||||||
newSystem.Set("status", "pending")
|
|
||||||
if err := h.app.Dao().SaveRecord(newSystem); err != nil {
|
|
||||||
return fmt.Errorf("failed to create new system: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete systems not in config
|
|
||||||
for _, system := range existingSystemsMap {
|
|
||||||
if err := h.app.Dao().DeleteRecord(system); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Println("Systems synced with config.yml")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generates content for the config.yml file as a YAML string
|
|
||||||
func (h *Hub) generateConfigYAML() (string, error) {
|
|
||||||
// Fetch all systems from the database
|
|
||||||
systems, err := h.app.Dao().FindRecordsByFilter("systems", "id != ''", "name", -1, 0)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a Config struct to hold the data
|
|
||||||
config := Config{
|
|
||||||
Systems: make([]SystemConfig, 0, len(systems)),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch all users at once
|
|
||||||
allUserIDs := make([]string, 0)
|
|
||||||
for _, system := range systems {
|
|
||||||
allUserIDs = append(allUserIDs, system.GetStringSlice("users")...)
|
|
||||||
}
|
|
||||||
userEmailMap, err := h.getUserEmailMap(allUserIDs)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Populate the Config struct with system data
|
|
||||||
for _, system := range systems {
|
|
||||||
userIDs := system.GetStringSlice("users")
|
|
||||||
userEmails := make([]string, 0, len(userIDs))
|
|
||||||
for _, userID := range userIDs {
|
|
||||||
if email, ok := userEmailMap[userID]; ok {
|
|
||||||
userEmails = append(userEmails, email)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sysConfig := SystemConfig{
|
|
||||||
Name: system.GetString("name"),
|
|
||||||
Host: system.GetString("host"),
|
|
||||||
Port: cast.ToUint16(system.Get("port")),
|
|
||||||
Users: userEmails,
|
|
||||||
}
|
|
||||||
config.Systems = append(config.Systems, sysConfig)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Marshal the Config struct to YAML
|
|
||||||
yamlData, err := yaml.Marshal(&config)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add a header to the YAML
|
|
||||||
yamlData = append([]byte("# Values for port and users are optional.\n# Defaults are port 45876 and the first created user.\n\n"), yamlData...)
|
|
||||||
|
|
||||||
return string(yamlData), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// New helper function to get a map of user IDs to emails
|
|
||||||
func (h *Hub) getUserEmailMap(userIDs []string) (map[string]string, error) {
|
|
||||||
users, err := h.app.Dao().FindRecordsByIds("users", userIDs)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
userEmailMap := make(map[string]string, len(users))
|
|
||||||
for _, user := range users {
|
|
||||||
userEmailMap[user.Id] = user.GetString("email")
|
|
||||||
}
|
|
||||||
|
|
||||||
return userEmailMap, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Returns the current config.yml file as a JSON object
|
|
||||||
func (h *Hub) getYamlConfig(c echo.Context) error {
|
|
||||||
requestData := apis.RequestInfo(c)
|
|
||||||
if requestData.AuthRecord == nil || requestData.AuthRecord.GetString("role") != "admin" {
|
|
||||||
return apis.NewForbiddenError("Forbidden", nil)
|
|
||||||
}
|
|
||||||
configContent, err := h.generateConfigYAML()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return c.JSON(200, map[string]string{"config": configContent})
|
|
||||||
}
|
|
||||||
@@ -3,6 +3,7 @@ package hub
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"beszel"
|
"beszel"
|
||||||
|
"beszel/internal/agent"
|
||||||
"beszel/internal/alerts"
|
"beszel/internal/alerts"
|
||||||
"beszel/internal/entities/system"
|
"beszel/internal/entities/system"
|
||||||
"beszel/internal/records"
|
"beszel/internal/records"
|
||||||
@@ -42,8 +43,7 @@ type Hub struct {
|
|||||||
am *alerts.AlertManager
|
am *alerts.AlertManager
|
||||||
um *users.UserManager
|
um *users.UserManager
|
||||||
rm *records.RecordManager
|
rm *records.RecordManager
|
||||||
systemStats *models.Collection
|
hubAgent *agent.Agent
|
||||||
containerStats *models.Collection
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHub(app *pocketbase.PocketBase) *Hub {
|
func NewHub(app *pocketbase.PocketBase) *Hub {
|
||||||
@@ -61,7 +61,7 @@ func (h *Hub) Run() {
|
|||||||
// loosely check if it was executed using "go run"
|
// loosely check if it was executed using "go run"
|
||||||
isGoRun := strings.HasPrefix(os.Args[0], os.TempDir())
|
isGoRun := strings.HasPrefix(os.Args[0], os.TempDir())
|
||||||
|
|
||||||
// enable auto creation of migration files when making collection changes in the Admin UI
|
// // enable auto creation of migration files when making collection changes in the Admin UI
|
||||||
migratecmd.MustRegister(h.app, h.app.RootCmd, migratecmd.Config{
|
migratecmd.MustRegister(h.app, h.app.RootCmd, migratecmd.Config{
|
||||||
// (the isGoRun check is to enable it only during development)
|
// (the isGoRun check is to enable it only during development)
|
||||||
Automigrate: isGoRun,
|
Automigrate: isGoRun,
|
||||||
@@ -71,28 +71,24 @@ func (h *Hub) Run() {
|
|||||||
// initial setup
|
// initial setup
|
||||||
h.app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
|
h.app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
|
||||||
// create ssh client config
|
// create ssh client config
|
||||||
err := h.createSSHClientConfig()
|
if err := h.createSSHClientConfig(); err != nil {
|
||||||
if err != nil {
|
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
// set auth settings
|
// set auth settings
|
||||||
usersCollection, err := h.app.Dao().FindCollectionByNameOrId("users")
|
if usersCollection, err := h.app.Dao().FindCollectionByNameOrId("users"); err == nil {
|
||||||
if err != nil {
|
usersAuthOptions := usersCollection.AuthOptions()
|
||||||
return err
|
usersAuthOptions.AllowUsernameAuth = false
|
||||||
|
if os.Getenv("DISABLE_PASSWORD_AUTH") == "true" {
|
||||||
|
usersAuthOptions.AllowEmailAuth = false
|
||||||
|
} else {
|
||||||
|
usersAuthOptions.AllowEmailAuth = true
|
||||||
|
}
|
||||||
|
usersCollection.SetOptions(usersAuthOptions)
|
||||||
|
if err := h.app.Dao().SaveCollection(usersCollection); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
usersAuthOptions := usersCollection.AuthOptions()
|
return nil
|
||||||
usersAuthOptions.AllowUsernameAuth = false
|
|
||||||
if os.Getenv("DISABLE_PASSWORD_AUTH") == "true" {
|
|
||||||
usersAuthOptions.AllowEmailAuth = false
|
|
||||||
} else {
|
|
||||||
usersAuthOptions.AllowEmailAuth = true
|
|
||||||
}
|
|
||||||
usersCollection.SetOptions(usersAuthOptions)
|
|
||||||
if err := h.app.Dao().SaveCollection(usersCollection); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
// sync systems with config
|
|
||||||
return h.syncSystemsWithConfig()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// serve web ui
|
// serve web ui
|
||||||
@@ -127,11 +123,7 @@ func (h *Hub) Run() {
|
|||||||
// delete old records once every hour
|
// delete old records once every hour
|
||||||
scheduler.MustAdd("delete old records", "8 * * * *", h.rm.DeleteOldRecords)
|
scheduler.MustAdd("delete old records", "8 * * * *", h.rm.DeleteOldRecords)
|
||||||
// create longer records every 10 minutes
|
// create longer records every 10 minutes
|
||||||
scheduler.MustAdd("create longer records", "*/10 * * * *", func() {
|
scheduler.MustAdd("create longer records", "*/10 * * * *", h.rm.CreateLongerRecords)
|
||||||
if systemStats, containerStats, err := h.getCollections(); err == nil {
|
|
||||||
h.rm.CreateLongerRecords([]*models.Collection{systemStats, containerStats})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
scheduler.Start()
|
scheduler.Start()
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -156,14 +148,22 @@ func (h *Hub) Run() {
|
|||||||
})
|
})
|
||||||
// send test notification
|
// send test notification
|
||||||
e.Router.GET("/api/beszel/send-test-notification", h.am.SendTestNotification)
|
e.Router.GET("/api/beszel/send-test-notification", h.am.SendTestNotification)
|
||||||
// API endpoint to get config.yml content
|
|
||||||
e.Router.GET("/api/beszel/config-yaml", h.getYamlConfig)
|
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
// system creation defaults
|
// system creation defaults
|
||||||
h.app.OnModelBeforeCreate("systems").Add(func(e *core.ModelEvent) error {
|
h.app.OnModelBeforeCreate("systems").Add(func(e *core.ModelEvent) error {
|
||||||
record := e.Model.(*models.Record)
|
record := e.Model.(*models.Record)
|
||||||
|
if record.GetString("host") == "hubsys" {
|
||||||
|
// todo: check for hubsys existance and return error if exists (or make sure user is admin)
|
||||||
|
if record.GetString("name") == "x" {
|
||||||
|
hostname, _ := os.Hostname()
|
||||||
|
if hostname == "" {
|
||||||
|
hostname = "localhost"
|
||||||
|
}
|
||||||
|
record.Set("name", hostname)
|
||||||
|
}
|
||||||
|
}
|
||||||
record.Set("info", system.Info{})
|
record.Set("info", system.Info{})
|
||||||
record.Set("status", "pending")
|
record.Set("status", "pending")
|
||||||
return nil
|
return nil
|
||||||
@@ -179,14 +179,6 @@ func (h *Hub) Run() {
|
|||||||
h.app.OnModelBeforeCreate("users").Add(h.um.InitializeUserRole)
|
h.app.OnModelBeforeCreate("users").Add(h.um.InitializeUserRole)
|
||||||
h.app.OnModelBeforeCreate("user_settings").Add(h.um.InitializeUserSettings)
|
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
|
// do things after a systems record is updated
|
||||||
h.app.OnModelAfterUpdate("systems").Add(func(e *core.ModelEvent) error {
|
h.app.OnModelAfterUpdate("systems").Add(func(e *core.ModelEvent) error {
|
||||||
newRecord := e.Model.(*models.Record)
|
newRecord := e.Model.(*models.Record)
|
||||||
@@ -259,6 +251,26 @@ func (h *Hub) updateSystems() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Hub) updateSystem(record *models.Record) {
|
func (h *Hub) updateSystem(record *models.Record) {
|
||||||
|
switch record.GetString("host") {
|
||||||
|
case "hubsys":
|
||||||
|
h.updateHubSystem(record)
|
||||||
|
default:
|
||||||
|
h.updateRemoteSystem(record)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update hub system stats with built-in agent
|
||||||
|
func (h *Hub) updateHubSystem(record *models.Record) {
|
||||||
|
if h.hubAgent == nil {
|
||||||
|
h.hubAgent = agent.NewAgent()
|
||||||
|
h.hubAgent.Run(nil, "")
|
||||||
|
}
|
||||||
|
systemData := h.hubAgent.GatherStats()
|
||||||
|
h.saveSystemStats(record, &systemData)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect to remote system and update system stats
|
||||||
|
func (h *Hub) updateRemoteSystem(record *models.Record) {
|
||||||
var client *ssh.Client
|
var client *ssh.Client
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
@@ -286,7 +298,7 @@ func (h *Hub) updateSystem(record *models.Record) {
|
|||||||
// if previous connection was closed, try again
|
// if previous connection was closed, try again
|
||||||
h.app.Logger().Error("Existing SSH connection closed. Retrying...", "host", record.GetString("host"), "port", record.GetString("port"))
|
h.app.Logger().Error("Existing SSH connection closed. Retrying...", "host", record.GetString("host"), "port", record.GetString("port"))
|
||||||
h.deleteSystemConnection(record)
|
h.deleteSystemConnection(record)
|
||||||
h.updateSystem(record)
|
h.updateRemoteSystem(record)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.app.Logger().Error("Failed to get system stats: ", "err", err.Error())
|
h.app.Logger().Error("Failed to get system stats: ", "err", err.Error())
|
||||||
@@ -294,59 +306,38 @@ func (h *Hub) updateSystem(record *models.Record) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
// update system record
|
// update system record
|
||||||
dao := h.app.Dao()
|
h.saveSystemStats(record, &systemData)
|
||||||
record.Set("status", "up")
|
|
||||||
record.Set("info", systemData.Info)
|
|
||||||
if err := dao.SaveRecord(record); err != nil {
|
|
||||||
h.app.Logger().Error("Failed to update record: ", "err", err.Error())
|
|
||||||
}
|
|
||||||
// add system_stats and container_stats records
|
|
||||||
if systemStats, containerStats, err := h.getCollections(); err != nil {
|
|
||||||
h.app.Logger().Error("Failed to get collections: ", "err", err.Error())
|
|
||||||
} else {
|
|
||||||
// add new system_stats record
|
|
||||||
systemStatsRecord := models.NewRecord(systemStats)
|
|
||||||
systemStatsRecord.Set("system", record.Id)
|
|
||||||
systemStatsRecord.Set("stats", systemData.Stats)
|
|
||||||
systemStatsRecord.Set("type", "1m")
|
|
||||||
if err := dao.SaveRecord(systemStatsRecord); err != nil {
|
|
||||||
h.app.Logger().Error("Failed to save record: ", "err", err.Error())
|
|
||||||
}
|
|
||||||
// add new container_stats record
|
|
||||||
if len(systemData.Containers) > 0 {
|
|
||||||
containerStatsRecord := models.NewRecord(containerStats)
|
|
||||||
containerStatsRecord.Set("system", record.Id)
|
|
||||||
containerStatsRecord.Set("stats", systemData.Containers)
|
|
||||||
containerStatsRecord.Set("type", "1m")
|
|
||||||
if err := dao.SaveRecord(containerStatsRecord); err != nil {
|
|
||||||
h.app.Logger().Error("Failed to save record: ", "err", err.Error())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// system info alerts (todo: extra fs alerts)
|
|
||||||
if err := h.am.HandleSystemAlerts(record, systemData.Info, systemData.Stats.Temperatures, systemData.Stats.ExtraFs); err != nil {
|
|
||||||
h.app.Logger().Error("System alerts error", "err", err.Error())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// return system_stats and container_stats collections
|
// Update system record with provided system.CombinedData
|
||||||
func (h *Hub) getCollections() (*models.Collection, *models.Collection, error) {
|
func (h *Hub) saveSystemStats(record *models.Record, systemData *system.CombinedData) {
|
||||||
if h.systemStats == nil {
|
record.Set("status", "up")
|
||||||
systemStats, err := h.app.Dao().FindCollectionByNameOrId("system_stats")
|
record.Set("info", systemData.Info)
|
||||||
if err != nil {
|
if err := h.app.Dao().SaveRecord(record); err != nil {
|
||||||
return nil, nil, err
|
h.app.Logger().Error("Failed to update record: ", "err", err.Error())
|
||||||
}
|
|
||||||
h.systemStats = systemStats
|
|
||||||
}
|
}
|
||||||
if h.containerStats == nil {
|
// add new system_stats record
|
||||||
containerStats, err := h.app.Dao().FindCollectionByNameOrId("container_stats")
|
system_stats, _ := h.app.Dao().FindCollectionByNameOrId("system_stats")
|
||||||
if err != nil {
|
systemStatsRecord := models.NewRecord(system_stats)
|
||||||
return nil, nil, err
|
systemStatsRecord.Set("system", record.Id)
|
||||||
}
|
systemStatsRecord.Set("stats", systemData.Stats)
|
||||||
h.containerStats = containerStats
|
systemStatsRecord.Set("type", "1m")
|
||||||
|
if err := h.app.Dao().SaveRecord(systemStatsRecord); err != nil {
|
||||||
|
h.app.Logger().Error("Failed to save record: ", "err", err.Error())
|
||||||
}
|
}
|
||||||
return h.systemStats, h.containerStats, nil
|
// add new container_stats record
|
||||||
|
if len(systemData.Containers) > 0 {
|
||||||
|
container_stats, _ := h.app.Dao().FindCollectionByNameOrId("container_stats")
|
||||||
|
containerStatsRecord := models.NewRecord(container_stats)
|
||||||
|
containerStatsRecord.Set("system", record.Id)
|
||||||
|
containerStatsRecord.Set("stats", systemData.Containers)
|
||||||
|
containerStatsRecord.Set("type", "1m")
|
||||||
|
if err := h.app.Dao().SaveRecord(containerStatsRecord); err != nil {
|
||||||
|
h.app.Logger().Error("Failed to save record: ", "err", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// system info alerts (todo: temp alerts, extra fs alerts)
|
||||||
|
h.am.HandleSystemInfoAlerts(record, systemData.Info)
|
||||||
}
|
}
|
||||||
|
|
||||||
// set system to specified status and save record
|
// set system to specified status and save record
|
||||||
@@ -359,14 +350,20 @@ func (h *Hub) updateSystemStatus(record *models.Record, status string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deletes the SSH connection (remote) or built-in agent reference
|
||||||
func (h *Hub) deleteSystemConnection(record *models.Record) {
|
func (h *Hub) deleteSystemConnection(record *models.Record) {
|
||||||
if _, ok := h.systemConnections[record.Id]; ok {
|
switch record.GetString("host") {
|
||||||
if h.systemConnections[record.Id] != nil {
|
case "hubsys":
|
||||||
h.systemConnections[record.Id].Close()
|
h.hubAgent = nil
|
||||||
|
default:
|
||||||
|
if _, ok := h.systemConnections[record.Id]; ok {
|
||||||
|
if h.systemConnections[record.Id] != nil {
|
||||||
|
h.systemConnections[record.Id].Close()
|
||||||
|
}
|
||||||
|
h.connectionLock.Lock()
|
||||||
|
defer h.connectionLock.Unlock()
|
||||||
|
delete(h.systemConnections, record.Id)
|
||||||
}
|
}
|
||||||
h.connectionLock.Lock()
|
|
||||||
defer h.connectionLock.Unlock()
|
|
||||||
delete(h.systemConnections, record.Id)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
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,7 +8,6 @@ import (
|
|||||||
"math"
|
"math"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/goccy/go-json"
|
|
||||||
"github.com/pocketbase/dbx"
|
"github.com/pocketbase/dbx"
|
||||||
"github.com/pocketbase/pocketbase"
|
"github.com/pocketbase/pocketbase"
|
||||||
"github.com/pocketbase/pocketbase/daos"
|
"github.com/pocketbase/pocketbase/daos"
|
||||||
@@ -32,18 +31,14 @@ type RecordDeletionData struct {
|
|||||||
retention time.Duration
|
retention time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
type RecordStats []struct {
|
|
||||||
Stats []byte `db:"stats"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewRecordManager(app *pocketbase.PocketBase) *RecordManager {
|
func NewRecordManager(app *pocketbase.PocketBase) *RecordManager {
|
||||||
return &RecordManager{app}
|
return &RecordManager{app}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create longer records by averaging shorter records
|
// Create longer records by averaging shorter records
|
||||||
func (rm *RecordManager) CreateLongerRecords(collections []*models.Collection) {
|
func (rm *RecordManager) CreateLongerRecords() {
|
||||||
// start := time.Now()
|
// start := time.Now()
|
||||||
longerRecordData := []LongerRecordData{
|
recordData := []LongerRecordData{
|
||||||
{
|
{
|
||||||
shorterType: "1m",
|
shorterType: "1m",
|
||||||
// change to 9 from 10 to allow edge case timing or short pauses
|
// change to 9 from 10 to allow edge case timing or short pauses
|
||||||
@@ -78,11 +73,16 @@ func (rm *RecordManager) CreateLongerRecords(collections []*models.Collection) {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
collections := map[string]*models.Collection{}
|
||||||
|
for _, collectionName := range []string{"system_stats", "container_stats"} {
|
||||||
|
collection, _ := txDao.FindCollectionByNameOrId(collectionName)
|
||||||
|
collections[collectionName] = collection
|
||||||
|
}
|
||||||
|
|
||||||
// loop through all active systems, time periods, and collections
|
// loop through all active systems, time periods, and collections
|
||||||
for _, system := range activeSystems {
|
for _, system := range activeSystems {
|
||||||
// log.Println("processing system", system.GetString("name"))
|
// log.Println("processing system", system.GetString("name"))
|
||||||
for i := range longerRecordData {
|
for _, recordData := range recordData {
|
||||||
recordData := longerRecordData[i]
|
|
||||||
// log.Println("processing longer record type", recordData.longerType)
|
// log.Println("processing longer record type", recordData.longerType)
|
||||||
// add one minute padding for longer records because they are created slightly later than the job start time
|
// add one minute padding for longer records because they are created slightly later than the job start time
|
||||||
longerRecordPeriod := time.Now().UTC().Add(recordData.longerTimeDuration + time.Minute)
|
longerRecordPeriod := time.Now().UTC().Add(recordData.longerTimeDuration + time.Minute)
|
||||||
@@ -104,36 +104,31 @@ func (rm *RecordManager) CreateLongerRecords(collections []*models.Collection) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// get shorter records from the past x minutes
|
// get shorter records from the past x minutes
|
||||||
var stats RecordStats
|
allShorterRecords, err := txDao.FindRecordsByExpr(
|
||||||
|
collection.Id,
|
||||||
err := txDao.DB().
|
dbx.NewExp(
|
||||||
Select("stats").
|
"type = {:type} AND system = {:system} AND created > {:created}",
|
||||||
From(collection.Name).
|
dbx.Params{"type": recordData.shorterType, "system": system.Id, "created": shorterRecordPeriod},
|
||||||
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
|
// continue if not enough shorter records
|
||||||
if err != nil || len(stats) < recordData.minShorterRecords {
|
if err != nil || len(allShorterRecords) < recordData.minShorterRecords {
|
||||||
// log.Println("not enough shorter records. continue.", len(allShorterRecords), recordData.expectedShorterRecords)
|
// log.Println("not enough shorter records. continue.", len(allShorterRecords), recordData.expectedShorterRecords)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// average the shorter records and create longer record
|
// average the shorter records and create longer record
|
||||||
longerRecord := models.NewRecord(collection)
|
var stats interface{}
|
||||||
longerRecord.Set("system", system.Id)
|
|
||||||
longerRecord.Set("type", recordData.longerType)
|
|
||||||
switch collection.Name {
|
switch collection.Name {
|
||||||
case "system_stats":
|
case "system_stats":
|
||||||
longerRecord.Set("stats", rm.AverageSystemStats(stats))
|
stats = rm.AverageSystemStats(allShorterRecords)
|
||||||
case "container_stats":
|
case "container_stats":
|
||||||
longerRecord.Set("stats", rm.AverageContainerStats(stats))
|
stats = rm.AverageContainerStats(allShorterRecords)
|
||||||
}
|
}
|
||||||
|
longerRecord := models.NewRecord(collection)
|
||||||
|
longerRecord.Set("system", system.Id)
|
||||||
|
longerRecord.Set("stats", stats)
|
||||||
|
longerRecord.Set("type", recordData.longerType)
|
||||||
if err := txDao.SaveRecord(longerRecord); err != nil {
|
if err := txDao.SaveRecord(longerRecord); err != nil {
|
||||||
log.Println("failed to save longer record", "err", err.Error())
|
log.Println("failed to save longer record", "err", err.Error())
|
||||||
}
|
}
|
||||||
@@ -148,7 +143,7 @@ func (rm *RecordManager) CreateLongerRecords(collections []*models.Collection) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Calculate the average stats of a list of system_stats records without reflect
|
// Calculate the average stats of a list of system_stats records without reflect
|
||||||
func (rm *RecordManager) AverageSystemStats(records RecordStats) system.Stats {
|
func (rm *RecordManager) AverageSystemStats(records []*models.Record) system.Stats {
|
||||||
sum := system.Stats{
|
sum := system.Stats{
|
||||||
Temperatures: make(map[string]float64),
|
Temperatures: make(map[string]float64),
|
||||||
ExtraFs: make(map[string]*system.FsStats),
|
ExtraFs: make(map[string]*system.FsStats),
|
||||||
@@ -159,8 +154,8 @@ func (rm *RecordManager) AverageSystemStats(records RecordStats) system.Stats {
|
|||||||
tempCount := float64(0)
|
tempCount := float64(0)
|
||||||
|
|
||||||
var stats system.Stats
|
var stats system.Stats
|
||||||
for i := range records {
|
for _, record := range records {
|
||||||
json.Unmarshal(records[i].Stats, &stats)
|
record.UnmarshalJSONField("stats", &stats)
|
||||||
sum.Cpu += stats.Cpu
|
sum.Cpu += stats.Cpu
|
||||||
sum.Mem += stats.Mem
|
sum.Mem += stats.Mem
|
||||||
sum.MemUsed += stats.MemUsed
|
sum.MemUsed += stats.MemUsed
|
||||||
@@ -176,12 +171,6 @@ func (rm *RecordManager) AverageSystemStats(records RecordStats) system.Stats {
|
|||||||
sum.DiskWritePs += stats.DiskWritePs
|
sum.DiskWritePs += stats.DiskWritePs
|
||||||
sum.NetworkSent += stats.NetworkSent
|
sum.NetworkSent += stats.NetworkSent
|
||||||
sum.NetworkRecv += stats.NetworkRecv
|
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
|
// add temps to sum
|
||||||
if stats.Temperatures != nil {
|
if stats.Temperatures != nil {
|
||||||
tempCount++
|
tempCount++
|
||||||
@@ -202,53 +191,43 @@ func (rm *RecordManager) AverageSystemStats(records RecordStats) system.Stats {
|
|||||||
sum.ExtraFs[key].DiskUsed += value.DiskUsed
|
sum.ExtraFs[key].DiskUsed += value.DiskUsed
|
||||||
sum.ExtraFs[key].DiskWritePs += value.DiskWritePs
|
sum.ExtraFs[key].DiskWritePs += value.DiskWritePs
|
||||||
sum.ExtraFs[key].DiskReadPs += value.DiskReadPs
|
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{
|
stats = system.Stats{
|
||||||
Cpu: twoDecimals(sum.Cpu / count),
|
Cpu: twoDecimals(sum.Cpu / count),
|
||||||
Mem: twoDecimals(sum.Mem / count),
|
Mem: twoDecimals(sum.Mem / count),
|
||||||
MemUsed: twoDecimals(sum.MemUsed / count),
|
MemUsed: twoDecimals(sum.MemUsed / count),
|
||||||
MemPct: twoDecimals(sum.MemPct / count),
|
MemPct: twoDecimals(sum.MemPct / count),
|
||||||
MemBuffCache: twoDecimals(sum.MemBuffCache / count),
|
MemBuffCache: twoDecimals(sum.MemBuffCache / count),
|
||||||
MemZfsArc: twoDecimals(sum.MemZfsArc / count),
|
MemZfsArc: twoDecimals(sum.MemZfsArc / count),
|
||||||
Swap: twoDecimals(sum.Swap / count),
|
Swap: twoDecimals(sum.Swap / count),
|
||||||
SwapUsed: twoDecimals(sum.SwapUsed / count),
|
SwapUsed: twoDecimals(sum.SwapUsed / count),
|
||||||
DiskTotal: twoDecimals(sum.DiskTotal / count),
|
DiskTotal: twoDecimals(sum.DiskTotal / count),
|
||||||
DiskUsed: twoDecimals(sum.DiskUsed / count),
|
DiskUsed: twoDecimals(sum.DiskUsed / count),
|
||||||
DiskPct: twoDecimals(sum.DiskPct / count),
|
DiskPct: twoDecimals(sum.DiskPct / count),
|
||||||
DiskReadPs: twoDecimals(sum.DiskReadPs / count),
|
DiskReadPs: twoDecimals(sum.DiskReadPs / count),
|
||||||
DiskWritePs: twoDecimals(sum.DiskWritePs / count),
|
DiskWritePs: twoDecimals(sum.DiskWritePs / count),
|
||||||
NetworkSent: twoDecimals(sum.NetworkSent / count),
|
NetworkSent: twoDecimals(sum.NetworkSent / count),
|
||||||
NetworkRecv: twoDecimals(sum.NetworkRecv / count),
|
NetworkRecv: twoDecimals(sum.NetworkRecv / count),
|
||||||
MaxCpu: sum.MaxCpu,
|
|
||||||
MaxDiskReadPs: sum.MaxDiskReadPs,
|
|
||||||
MaxDiskWritePs: sum.MaxDiskWritePs,
|
|
||||||
MaxNetworkSent: sum.MaxNetworkSent,
|
|
||||||
MaxNetworkRecv: sum.MaxNetworkRecv,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(sum.Temperatures) != 0 {
|
if len(sum.Temperatures) != 0 {
|
||||||
stats.Temperatures = make(map[string]float64, len(sum.Temperatures))
|
stats.Temperatures = make(map[string]float64)
|
||||||
for key, value := range sum.Temperatures {
|
for key, value := range sum.Temperatures {
|
||||||
stats.Temperatures[key] = twoDecimals(value / tempCount)
|
stats.Temperatures[key] = twoDecimals(value / tempCount)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(sum.ExtraFs) != 0 {
|
if len(sum.ExtraFs) != 0 {
|
||||||
stats.ExtraFs = make(map[string]*system.FsStats, len(sum.ExtraFs))
|
stats.ExtraFs = make(map[string]*system.FsStats)
|
||||||
for key, value := range sum.ExtraFs {
|
for key, value := range sum.ExtraFs {
|
||||||
stats.ExtraFs[key] = &system.FsStats{
|
stats.ExtraFs[key] = &system.FsStats{
|
||||||
DiskTotal: twoDecimals(value.DiskTotal / count),
|
DiskTotal: twoDecimals(value.DiskTotal / count),
|
||||||
DiskUsed: twoDecimals(value.DiskUsed / count),
|
DiskUsed: twoDecimals(value.DiskUsed / count),
|
||||||
DiskWritePs: twoDecimals(value.DiskWritePs / count),
|
DiskWritePs: twoDecimals(value.DiskWritePs / count),
|
||||||
DiskReadPs: twoDecimals(value.DiskReadPs / count),
|
DiskReadPs: twoDecimals(value.DiskReadPs / count),
|
||||||
MaxDiskReadPS: value.MaxDiskReadPS,
|
|
||||||
MaxDiskWritePS: value.MaxDiskWritePS,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -257,19 +236,14 @@ func (rm *RecordManager) AverageSystemStats(records RecordStats) system.Stats {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Calculate the average stats of a list of container_stats records
|
// Calculate the average stats of a list of container_stats records
|
||||||
func (rm *RecordManager) AverageContainerStats(records RecordStats) []container.Stats {
|
func (rm *RecordManager) AverageContainerStats(records []*models.Record) []container.Stats {
|
||||||
sums := make(map[string]*container.Stats)
|
sums := make(map[string]*container.Stats)
|
||||||
count := float64(len(records))
|
count := float64(len(records))
|
||||||
|
|
||||||
var containerStats []container.Stats
|
var containerStats []container.Stats
|
||||||
for i := range records {
|
for _, record := range records {
|
||||||
// Reset the slice length to 0, but keep the capacity
|
record.UnmarshalJSONField("stats", &containerStats)
|
||||||
containerStats = containerStats[:0]
|
for _, stat := range containerStats {
|
||||||
if err := json.Unmarshal(records[i].Stats, &containerStats); err != nil {
|
|
||||||
return []container.Stats{}
|
|
||||||
}
|
|
||||||
for i := range containerStats {
|
|
||||||
stat := containerStats[i]
|
|
||||||
if _, ok := sums[stat.Name]; !ok {
|
if _, ok := sums[stat.Name]; !ok {
|
||||||
sums[stat.Name] = &container.Stats{Name: stat.Name}
|
sums[stat.Name] = &container.Stats{Name: stat.Name}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
package agent
|
// Package update handles updating beszel and beszel-agent.
|
||||||
|
package update
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"beszel"
|
"beszel"
|
||||||
@@ -10,8 +11,51 @@ import (
|
|||||||
"github.com/rhysd/go-github-selfupdate/selfupdate"
|
"github.com/rhysd/go-github-selfupdate/selfupdate"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Update updates beszel-agent to the latest version
|
func UpdateBeszel() {
|
||||||
func Update() {
|
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() {
|
||||||
var latest *selfupdate.Release
|
var latest *selfupdate.Release
|
||||||
var found bool
|
var found bool
|
||||||
var err error
|
var err error
|
||||||
@@ -15,7 +15,7 @@ func init() {
|
|||||||
{
|
{
|
||||||
"id": "2hz5ncl8tizk5nx",
|
"id": "2hz5ncl8tizk5nx",
|
||||||
"created": "2024-07-07 16:08:20.979Z",
|
"created": "2024-07-07 16:08:20.979Z",
|
||||||
"updated": "2024-10-12 18:55:51.623Z",
|
"updated": "2024-07-28 17:14:24.492Z",
|
||||||
"name": "systems",
|
"name": "systems",
|
||||||
"type": "base",
|
"type": "base",
|
||||||
"system": false,
|
"system": false,
|
||||||
@@ -120,7 +120,7 @@ func init() {
|
|||||||
{
|
{
|
||||||
"id": "ej9oowivz8b2mht",
|
"id": "ej9oowivz8b2mht",
|
||||||
"created": "2024-07-07 16:09:09.179Z",
|
"created": "2024-07-07 16:09:09.179Z",
|
||||||
"updated": "2024-10-12 18:55:51.623Z",
|
"updated": "2024-07-28 17:14:24.492Z",
|
||||||
"name": "system_stats",
|
"name": "system_stats",
|
||||||
"type": "base",
|
"type": "base",
|
||||||
"system": false,
|
"system": false,
|
||||||
@@ -186,7 +186,7 @@ func init() {
|
|||||||
{
|
{
|
||||||
"id": "juohu4jipgc13v7",
|
"id": "juohu4jipgc13v7",
|
||||||
"created": "2024-07-07 16:09:57.976Z",
|
"created": "2024-07-07 16:09:57.976Z",
|
||||||
"updated": "2024-10-12 18:55:51.623Z",
|
"updated": "2024-07-28 17:14:24.492Z",
|
||||||
"name": "container_stats",
|
"name": "container_stats",
|
||||||
"type": "base",
|
"type": "base",
|
||||||
"system": false,
|
"system": false,
|
||||||
@@ -250,7 +250,7 @@ func init() {
|
|||||||
{
|
{
|
||||||
"id": "_pb_users_auth_",
|
"id": "_pb_users_auth_",
|
||||||
"created": "2024-07-14 16:25:18.226Z",
|
"created": "2024-07-14 16:25:18.226Z",
|
||||||
"updated": "2024-10-12 22:27:19.081Z",
|
"updated": "2024-09-12 23:19:36.280Z",
|
||||||
"name": "users",
|
"name": "users",
|
||||||
"type": "auth",
|
"type": "auth",
|
||||||
"system": false,
|
"system": false,
|
||||||
@@ -316,7 +316,7 @@ func init() {
|
|||||||
{
|
{
|
||||||
"id": "elngm8x1l60zi2v",
|
"id": "elngm8x1l60zi2v",
|
||||||
"created": "2024-07-15 01:16:04.044Z",
|
"created": "2024-07-15 01:16:04.044Z",
|
||||||
"updated": "2024-10-12 22:27:29.128Z",
|
"updated": "2024-07-28 17:14:24.492Z",
|
||||||
"name": "alerts",
|
"name": "alerts",
|
||||||
"type": "base",
|
"type": "base",
|
||||||
"system": false,
|
"system": false,
|
||||||
@@ -367,9 +367,7 @@ func init() {
|
|||||||
"Status",
|
"Status",
|
||||||
"CPU",
|
"CPU",
|
||||||
"Memory",
|
"Memory",
|
||||||
"Disk",
|
"Disk"
|
||||||
"Temperature",
|
|
||||||
"Bandwidth"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -387,20 +385,6 @@ func init() {
|
|||||||
"noDecimal": false
|
"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,
|
"system": false,
|
||||||
"id": "6hgdf6hs",
|
"id": "6hgdf6hs",
|
||||||
@@ -423,7 +407,7 @@ func init() {
|
|||||||
{
|
{
|
||||||
"id": "4afacsdnlu8q8r2",
|
"id": "4afacsdnlu8q8r2",
|
||||||
"created": "2024-09-12 17:42:55.324Z",
|
"created": "2024-09-12 17:42:55.324Z",
|
||||||
"updated": "2024-10-12 18:55:51.624Z",
|
"updated": "2024-09-12 21:19:59.114Z",
|
||||||
"name": "user_settings",
|
"name": "user_settings",
|
||||||
"type": "base",
|
"type": "base",
|
||||||
"system": false,
|
"system": false,
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"trailingComma": "es5",
|
|
||||||
"useTabs": true,
|
|
||||||
"tabWidth": 2,
|
|
||||||
"semi": false,
|
|
||||||
"singleQuote": false,
|
|
||||||
"printWidth": 120
|
|
||||||
}
|
|
||||||
Binary file not shown.
@@ -1,17 +1,17 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://ui.shadcn.com/schema.json",
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
"style": "default",
|
"style": "default",
|
||||||
"rsc": false,
|
"rsc": false,
|
||||||
"tsx": true,
|
"tsx": true,
|
||||||
"tailwind": {
|
"tailwind": {
|
||||||
"config": "tailwind.config.js",
|
"config": "tailwind.config.js",
|
||||||
"css": "src/index.css",
|
"css": "src/index.css",
|
||||||
"baseColor": "gray",
|
"baseColor": "gray",
|
||||||
"cssVariables": true,
|
"cssVariables": true,
|
||||||
"prefix": ""
|
"prefix": ""
|
||||||
},
|
},
|
||||||
"aliases": {
|
"aliases": {
|
||||||
"components": "@/components",
|
"components": "@/components",
|
||||||
"utils": "@/lib/utils"
|
"utils": "@/lib/utils"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en" dir="ltr">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
import type { LinguiConfig } from "@lingui/conf"
|
|
||||||
|
|
||||||
const config: LinguiConfig = {
|
|
||||||
locales: ["en", "ar", "de", "es", "fr", "it", "ja", "ko", "pt", "tr", "ru", "uk", "vi", "zh-CN", "zh-HK"],
|
|
||||||
sourceLocale: "en",
|
|
||||||
compileNamespace: "ts",
|
|
||||||
catalogs: [
|
|
||||||
{
|
|
||||||
path: "<rootDir>/src/locales/{locale}/{locale}",
|
|
||||||
include: ["src"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
export default config
|
|
||||||
13154
beszel/site/package-lock.json
generated
13154
beszel/site/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -6,67 +6,47 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview"
|
||||||
"sync": "lingui extract --overwrite && lingui compile",
|
|
||||||
"sync_and_purge": "lingui extract --overwrite --clean && lingui compile"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@henrygd/queue": "^1.0.7",
|
|
||||||
"@lingui/detect-locale": "^4.13.0",
|
|
||||||
"@lingui/macro": "^4.13.0",
|
|
||||||
"@lingui/react": "^4.13.0",
|
|
||||||
"@nanostores/react": "^0.7.3",
|
"@nanostores/react": "^0.7.3",
|
||||||
"@nanostores/router": "^0.11.0",
|
"@nanostores/router": "^0.15.1",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.2",
|
"@radix-ui/react-alert-dialog": "^1.1.1",
|
||||||
"@radix-ui/react-checkbox": "^1.1.2",
|
"@radix-ui/react-dialog": "^1.1.1",
|
||||||
"@radix-ui/react-dialog": "^1.1.2",
|
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
||||||
"@radix-ui/react-direction": "^1.1.0",
|
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
|
||||||
"@radix-ui/react-label": "^2.1.0",
|
"@radix-ui/react-label": "^2.1.0",
|
||||||
"@radix-ui/react-select": "^2.1.2",
|
"@radix-ui/react-select": "^2.1.1",
|
||||||
"@radix-ui/react-separator": "^1.1.0",
|
"@radix-ui/react-separator": "^1.1.0",
|
||||||
"@radix-ui/react-slider": "^1.2.1",
|
"@radix-ui/react-slider": "^1.2.0",
|
||||||
"@radix-ui/react-slot": "^1.1.0",
|
"@radix-ui/react-slot": "^1.1.0",
|
||||||
"@radix-ui/react-switch": "^1.1.1",
|
"@radix-ui/react-switch": "^1.1.0",
|
||||||
"@radix-ui/react-tabs": "^1.1.1",
|
"@radix-ui/react-toast": "^1.2.1",
|
||||||
"@radix-ui/react-toast": "^1.2.2",
|
"@radix-ui/react-tooltip": "^1.1.2",
|
||||||
"@radix-ui/react-tooltip": "^1.1.3",
|
|
||||||
"@tanstack/react-table": "^8.20.5",
|
"@tanstack/react-table": "^8.20.5",
|
||||||
|
"@vitejs/plugin-react": "^4.3.2",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.0.0",
|
"cmdk": "^1.0.0",
|
||||||
|
"d3-scale": "^4.0.2",
|
||||||
"d3-time": "^3.1.0",
|
"d3-time": "^3.1.0",
|
||||||
"lucide-react": "^0.452.0",
|
"lucide-react": "^0.407.0",
|
||||||
"nanostores": "^0.11.3",
|
"nanostores": "^0.10.3",
|
||||||
"pocketbase": "^0.21.5",
|
"pocketbase": "^0.21.5",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"recharts": "^2.13.0",
|
"recharts": "^2.13.0-alpha.5",
|
||||||
"tailwind-merge": "^2.5.4",
|
"tailwind-merge": "^2.5.2",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"valibot": "^0.36.0"
|
"valibot": "^0.36.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@lingui/cli": "^4.13.0",
|
"@types/bun": "^1.1.10",
|
||||||
"@lingui/swc-plugin": "^4.1.0",
|
"@types/react": "^18.3.10",
|
||||||
"@lingui/vite-plugin": "^4.13.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@types/bun": "^1.1.11",
|
|
||||||
"@types/react": "^18.3.11",
|
|
||||||
"@types/react-dom": "^18.3.1",
|
|
||||||
"@vitejs/plugin-react-swc": "^3.7.1",
|
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"postcss": "^8.4.47",
|
"postcss": "^8.4.47",
|
||||||
"tailwindcss": "^3.4.14",
|
"tailwindcss": "^3.4.13",
|
||||||
"tailwindcss-rtl": "^0.9.0",
|
"typescript": "^5.6.2",
|
||||||
"typescript": "^5.6.3",
|
"vite": "^5.4.8"
|
||||||
"vite": "^5.4.9"
|
|
||||||
},
|
|
||||||
"overrides": {
|
|
||||||
"@nanostores/router": {
|
|
||||||
"nanostores": "^0.11.3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@esbuild/linux-arm64": "^0.21.5"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export default {
|
export default {
|
||||||
plugins: {
|
plugins: {
|
||||||
tailwindcss: {},
|
tailwindcss: {},
|
||||||
autoprefixer: {},
|
autoprefixer: {},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Button } from "@/components/ui/button"
|
import { Button } from '@/components/ui/button'
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -7,19 +7,17 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/components/ui/dialog"
|
} from '@/components/ui/dialog'
|
||||||
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"
|
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
|
||||||
|
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from '@/components/ui/label'
|
||||||
import { $publicKey, pb } from "@/lib/stores"
|
import { $publicKey, pb } from '@/lib/stores'
|
||||||
import { Copy, PlusIcon } from "lucide-react"
|
import { Copy, PlusIcon } from 'lucide-react'
|
||||||
import { useState, useRef, MutableRefObject } from "react"
|
import { useState, useRef, MutableRefObject } from 'react'
|
||||||
import { useStore } from "@nanostores/react"
|
import { useStore } from '@nanostores/react'
|
||||||
import { cn, copyToClipboard, isReadOnlyUser } from "@/lib/utils"
|
import { cn, copyToClipboard, isReadOnlyUser } from '@/lib/utils'
|
||||||
import { navigate } from "./router"
|
import { navigate } from './router'
|
||||||
import { Trans } from "@lingui/macro"
|
|
||||||
|
|
||||||
export function AddSystemButton({ className }: { className?: string }) {
|
export function AddSystemButton({ className }: { className?: string }) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
@@ -39,13 +37,8 @@ export function AddSystemButton({ className }: { className?: string }) {
|
|||||||
# - /mnt/disk1/.beszel:/extra-filesystems/disk1:ro
|
# - /mnt/disk1/.beszel:/extra-filesystems/disk1:ro
|
||||||
environment:
|
environment:
|
||||||
PORT: ${port}
|
PORT: ${port}
|
||||||
KEY: "${publicKey}"`)
|
KEY: "${publicKey}"
|
||||||
}
|
# FILESYSTEM: /dev/sda1 # override the root partition / device for disk I/O stats`)
|
||||||
|
|
||||||
function copyInstallCommand(port: string) {
|
|
||||||
copyToClipboard(
|
|
||||||
`curl -sL https://raw.githubusercontent.com/henrygd/beszel/main/supplemental/scripts/install-agent.sh -o install-agent.sh && chmod +x install-agent.sh && ./install-agent.sh -p ${port} -k "${publicKey}"`
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmit(e: SubmitEvent) {
|
async function handleSubmit(e: SubmitEvent) {
|
||||||
@@ -55,8 +48,8 @@ export function AddSystemButton({ className }: { className?: string }) {
|
|||||||
data.users = pb.authStore.model!.id
|
data.users = pb.authStore.model!.id
|
||||||
try {
|
try {
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
await pb.collection("systems").create(data)
|
await pb.collection('systems').create(data)
|
||||||
navigate("/")
|
navigate('/')
|
||||||
// console.log(record)
|
// console.log(record)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(e)
|
console.log(e)
|
||||||
@@ -68,119 +61,88 @@ export function AddSystemButton({ className }: { className?: string }) {
|
|||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn("flex gap-1 max-xs:h-[2.4rem]", className, isReadOnlyUser() && "hidden")}
|
className={cn('flex gap-1 max-xs:h-[2.4rem]', className, isReadOnlyUser() && 'hidden')}
|
||||||
>
|
>
|
||||||
<PlusIcon className="h-4 w-4 -ms-1" />
|
<PlusIcon className="h-4 w-4 -ml-1" />
|
||||||
<Trans>
|
Add <span className="hidden xs:inline">System</span>
|
||||||
Add <span className="hidden sm:inline">System</span>
|
|
||||||
</Trans>
|
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="w-[90%] sm:max-w-[440px] rounded-lg">
|
<DialogContent className="w-[90%] sm:max-w-[425px] rounded-lg">
|
||||||
<Tabs defaultValue="docker">
|
<DialogHeader>
|
||||||
<DialogHeader>
|
<DialogTitle className="mb-2">Add New System</DialogTitle>
|
||||||
<DialogTitle className="mb-2">
|
<DialogDescription>
|
||||||
<Trans>Add New System</Trans>
|
The agent must be running on the system to connect. Copy the{' '}
|
||||||
</DialogTitle>
|
<code className="bg-muted px-1 rounded-sm">docker-compose.yml</code> for the agent
|
||||||
<TabsList className="grid w-full grid-cols-2">
|
below.
|
||||||
<TabsTrigger value="docker">Docker</TabsTrigger>
|
</DialogDescription>
|
||||||
<TabsTrigger value="binary">
|
</DialogHeader>
|
||||||
<Trans>Binary</Trans>
|
<form onSubmit={handleSubmit as any}>
|
||||||
</TabsTrigger>
|
<div className="grid gap-3 mt-1 mb-4">
|
||||||
</TabsList>
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
</DialogHeader>
|
<Label htmlFor="name" className="text-right">
|
||||||
{/* Docker */}
|
Name
|
||||||
<TabsContent value="docker">
|
</Label>
|
||||||
<DialogDescription className="mb-4 leading-normal">
|
<Input id="name" name="name" className="col-span-3" required />
|
||||||
<Trans>
|
|
||||||
The agent must be running on the system to connect. Copy the
|
|
||||||
<code className="bg-muted px-1 rounded-sm leading-3">docker-compose.yml</code> for the agent below.
|
|
||||||
</Trans>
|
|
||||||
</DialogDescription>
|
|
||||||
</TabsContent>
|
|
||||||
{/* Binary */}
|
|
||||||
<TabsContent value="binary">
|
|
||||||
<DialogDescription className="mb-4 leading-normal">
|
|
||||||
<Trans>
|
|
||||||
The agent must be running on the system to connect. Copy the installation command for the agent below.
|
|
||||||
</Trans>
|
|
||||||
</DialogDescription>
|
|
||||||
</TabsContent>
|
|
||||||
<form onSubmit={handleSubmit as any}>
|
|
||||||
<div className="grid gap-3 mt-1 mb-4">
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<Label htmlFor="name" className="text-end">
|
|
||||||
<Trans>Name</Trans>
|
|
||||||
</Label>
|
|
||||||
<Input id="name" name="name" className="col-span-3" required />
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<Label htmlFor="host" className="text-end">
|
|
||||||
<Trans>Host / IP</Trans>
|
|
||||||
</Label>
|
|
||||||
<Input id="host" name="host" className="col-span-3" required />
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<Label htmlFor="port" className="text-end">
|
|
||||||
<Trans>Port</Trans>
|
|
||||||
</Label>
|
|
||||||
<Input ref={port} name="port" id="port" defaultValue="45876" className="col-span-3" required />
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4 relative">
|
|
||||||
<Label htmlFor="pkey" className="text-end whitespace-pre">
|
|
||||||
<Trans comment="Use 'Key' if your language requires many more characters">Public Key</Trans>
|
|
||||||
</Label>
|
|
||||||
<Input readOnly id="pkey" value={publicKey} className="col-span-3" required></Input>
|
|
||||||
<div
|
|
||||||
className={
|
|
||||||
"h-6 w-24 bg-gradient-to-r rtl:bg-gradient-to-l from-transparent to-background to-65% absolute end-1 pointer-events-none"
|
|
||||||
}
|
|
||||||
></div>
|
|
||||||
<TooltipProvider delayDuration={100}>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant={"link"}
|
|
||||||
className="absolute end-0"
|
|
||||||
onClick={() => copyToClipboard(publicKey)}
|
|
||||||
>
|
|
||||||
<Copy className="h-4 w-4 " />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>
|
|
||||||
<Trans>Click to copy</Trans>
|
|
||||||
</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{/* Docker */}
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
<TabsContent value="docker">
|
<Label htmlFor="host" className="text-right">
|
||||||
<DialogFooter className="flex justify-end gap-2 sm:w-[calc(100%+20px)] sm:-ms-[20px]">
|
Host / IP
|
||||||
<Button type="button" variant={"ghost"} onClick={() => copyDockerCompose(port.current.value)}>
|
</Label>
|
||||||
<Trans>Copy</Trans> docker compose
|
<Input id="host" name="host" className="col-span-3" required />
|
||||||
</Button>
|
</div>
|
||||||
<Button>
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
<Trans>Add system</Trans>
|
<Label htmlFor="port" className="text-right">
|
||||||
</Button>
|
Port
|
||||||
</DialogFooter>
|
</Label>
|
||||||
</TabsContent>
|
<Input
|
||||||
{/* Binary */}
|
ref={port}
|
||||||
<TabsContent value="binary">
|
name="port"
|
||||||
<DialogFooter className="flex justify-end gap-2 sm:w-[calc(100%+20px)] sm:-ms-[20px]">
|
id="port"
|
||||||
<Button type="button" variant={"ghost"} onClick={() => copyInstallCommand(port.current.value)}>
|
defaultValue="45876"
|
||||||
<Trans>Copy Linux command</Trans>
|
className="col-span-3"
|
||||||
</Button>
|
required
|
||||||
<Button>
|
/>
|
||||||
<Trans>Add system</Trans>
|
</div>
|
||||||
</Button>
|
<div className="grid grid-cols-4 items-center gap-4 relative">
|
||||||
</DialogFooter>
|
<Label htmlFor="pkey" className="text-right whitespace-pre">
|
||||||
</TabsContent>
|
Public Key
|
||||||
</form>
|
</Label>
|
||||||
</Tabs>
|
<Input readOnly id="pkey" value={publicKey} className="col-span-3" required></Input>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
'h-6 w-24 bg-gradient-to-r from-transparent to-background to-65% absolute right-1 pointer-events-none'
|
||||||
|
}
|
||||||
|
></div>
|
||||||
|
<TooltipProvider delayDuration={100}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={'link'}
|
||||||
|
className="absolute right-0"
|
||||||
|
onClick={() => copyToClipboard(publicKey)}
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4 " />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Click to copy</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={'ghost'}
|
||||||
|
onClick={() => copyDockerCompose(port.current.value)}
|
||||||
|
>
|
||||||
|
Copy docker compose
|
||||||
|
</Button>
|
||||||
|
<Button>Add system</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,120 +0,0 @@
|
|||||||
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"
|
|
||||||
import { Trans, t } from "@lingui/macro"
|
|
||||||
|
|
||||||
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={t`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">
|
|
||||||
<Trans>Alerts</Trans>
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
<Trans>
|
|
||||||
See{" "}
|
|
||||||
<Link href="/settings/notifications" className="link">
|
|
||||||
notification settings
|
|
||||||
</Link>{" "}
|
|
||||||
to configure how you receive alerts.
|
|
||||||
</Trans>
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<Tabs defaultValue="system">
|
|
||||||
<TabsList className="mb-1 -mt-0.5">
|
|
||||||
<TabsTrigger value="system">
|
|
||||||
<ServerIcon className="me-2 h-3.5 w-3.5" />
|
|
||||||
{system.name}
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="global">
|
|
||||||
<GlobeIcon className="me-1.5 h-3.5 w-3.5" />
|
|
||||||
<Trans>All Systems</Trans>
|
|
||||||
</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}
|
|
||||||
/>
|
|
||||||
<Trans>Overwrite existing alerts</Trans>
|
|
||||||
</label>
|
|
||||||
<div className="grid gap-3">
|
|
||||||
{data.map((d) => (
|
|
||||||
<SystemAlertGlobal key={d.key} data={d} overwrite={overwriteExisting} alerts={alerts} systems={systems} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,246 +0,0 @@
|
|||||||
import { pb } from "@/lib/stores"
|
|
||||||
import { alertInfo, cn } from "@/lib/utils"
|
|
||||||
import { Switch } from "@/components/ui/switch"
|
|
||||||
import { AlertInfo, 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"
|
|
||||||
import { Trans, t, Plural } from "@lingui/macro"
|
|
||||||
|
|
||||||
interface AlertData {
|
|
||||||
checked?: boolean
|
|
||||||
val?: number
|
|
||||||
min?: number
|
|
||||||
updateAlert?: (checked: boolean, value: number, min: number) => void
|
|
||||||
key: keyof typeof alertInfo
|
|
||||||
alert: AlertInfo
|
|
||||||
system: SystemRecord
|
|
||||||
}
|
|
||||||
|
|
||||||
const Slider = lazy(() => import("@/components/ui/slider"))
|
|
||||||
|
|
||||||
let queue: Queue
|
|
||||||
|
|
||||||
const failedUpdateToast = () =>
|
|
||||||
toast({
|
|
||||||
title: t`Failed to update alert`,
|
|
||||||
description: t`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">
|
|
||||||
<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">
|
|
||||||
<Trans>
|
|
||||||
Average exceeds{" "}
|
|
||||||
<strong className="text-foreground">
|
|
||||||
{value}
|
|
||||||
{data.alert.unit}
|
|
||||||
</strong>
|
|
||||||
</Trans>
|
|
||||||
</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={alertInfo[key].max ?? 99}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p id={`t${key}`} className="text-sm block h-8">
|
|
||||||
<Trans>
|
|
||||||
For <strong className="text-foreground">{min}</strong>{" "}
|
|
||||||
<Plural value={min} one=" minute" other=" minutes" />
|
|
||||||
</Trans>
|
|
||||||
</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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
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"
|
|
||||||
import { t } from "@lingui/macro"
|
|
||||||
import { useLingui } from "@lingui/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 { i18n } = useLingui()
|
|
||||||
|
|
||||||
const { chartTime } = chartData
|
|
||||||
|
|
||||||
const showMax = chartTime !== "1h" && maxToggled
|
|
||||||
|
|
||||||
const dataKeys: DataKeys[] = useMemo(() => {
|
|
||||||
// [label, key, color, opacity]
|
|
||||||
if (chartName === "CPU Usage") {
|
|
||||||
return [[t`CPU Usage`, "cpu", 1, 0.4]]
|
|
||||||
} else if (chartName === "dio") {
|
|
||||||
return [
|
|
||||||
[t({ message: "Write", comment: "Context is disk write" }), "dw", 3, 0.3],
|
|
||||||
[t({ message: "Read", comment: "Context is disk read" }), "dr", 1, 0.3],
|
|
||||||
]
|
|
||||||
} else if (chartName === "bw") {
|
|
||||||
return [
|
|
||||||
[t({ message: "Sent", comment: "Context is network bytes sent (upload)" }), "ns", 5, 0.2],
|
|
||||||
[t({ message: "Received", comment: "Context is network bytes received (download)" }), "nr", 2, 0.2],
|
|
||||||
]
|
|
||||||
} else if (chartName.startsWith("efs")) {
|
|
||||||
return [
|
|
||||||
[t`Read`, `${chartName}.w`, 3, 0.3],
|
|
||||||
[t`Write`, `${chartName}.r`, 1, 0.3],
|
|
||||||
]
|
|
||||||
}
|
|
||||||
return []
|
|
||||||
}, [chartName, i18n.locale])
|
|
||||||
|
|
||||||
// console.log('Rendered at', new Date())
|
|
||||||
|
|
||||||
if (chartData.systemStats.length === 0) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
direction="ltr"
|
|
||||||
orientation={chartData.orientation}
|
|
||||||
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>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
105
beszel/site/src/components/charts/bandwidth-chart.tsx
Normal file
105
beszel/site/src/components/charts/bandwidth-chart.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,23 +1,33 @@
|
|||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import {
|
||||||
import { $chartTime } from "@/lib/stores"
|
Select,
|
||||||
import { chartTimeData, cn } from "@/lib/utils"
|
SelectContent,
|
||||||
import { ChartTimes } from "@/types"
|
SelectItem,
|
||||||
import { useStore } from "@nanostores/react"
|
SelectTrigger,
|
||||||
import { HistoryIcon } from "lucide-react"
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { $chartTime } from '@/lib/stores'
|
||||||
|
import { chartTimeData, cn } from '@/lib/utils'
|
||||||
|
import { ChartTimes } from '@/types'
|
||||||
|
import { useStore } from '@nanostores/react'
|
||||||
|
import { HistoryIcon } from 'lucide-react'
|
||||||
|
|
||||||
export default function ChartTimeSelect({ className }: { className?: string }) {
|
export default function ChartTimeSelect({ className }: { className?: string }) {
|
||||||
const chartTime = useStore($chartTime)
|
const chartTime = useStore($chartTime)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Select defaultValue="1h" value={chartTime} onValueChange={(value: ChartTimes) => $chartTime.set(value)}>
|
<Select
|
||||||
<SelectTrigger className={cn(className, "relative ps-10 pe-5")}>
|
defaultValue="1h"
|
||||||
<HistoryIcon className="h-4 w-4 absolute start-4 top-1/2 -translate-y-1/2 opacity-85" />
|
value={chartTime}
|
||||||
|
onValueChange={(value: ChartTimes) => $chartTime.set(value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className={cn(className, 'relative pl-10 pr-5')}>
|
||||||
|
<HistoryIcon className="h-4 w-4 absolute left-4 top-1/2 -translate-y-1/2 opacity-85" />
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{Object.entries(chartTimeData).map(([value, { label }]) => (
|
{Object.entries(chartTimeData).map(([value, { label }]) => (
|
||||||
<SelectItem key={value} value={value}>
|
<SelectItem key={label} value={value}>
|
||||||
{label()}
|
{label}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
|||||||
@@ -1,189 +0,0 @@
|
|||||||
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 ms-0.5"> rx </span>
|
|
||||||
<Separator orientation="vertical" className="h-3 mx-1.5 bg-primary/40" />
|
|
||||||
{decimalString(sent)} MB/s
|
|
||||||
<span className="opacity-70 ms-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())
|
|
||||||
|
|
||||||
if (containerData.length === 0) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
direction="ltr"
|
|
||||||
orientation={chartData.orientation}
|
|
||||||
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>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
143
beszel/site/src/components/charts/container-cpu-chart.tsx
Normal file
143
beszel/site/src/components/charts/container-cpu-chart.tsx
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
149
beszel/site/src/components/charts/container-mem-chart.tsx
Normal file
149
beszel/site/src/components/charts/container-mem-chart.tsx
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
166
beszel/site/src/components/charts/container-net-chart.tsx
Normal file
166
beszel/site/src/components/charts/container-net-chart.tsx
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
81
beszel/site/src/components/charts/cpu-chart.tsx
Normal file
81
beszel/site/src/components/charts/cpu-chart.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
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,48 +1,57 @@
|
|||||||
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
|
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
|
||||||
|
|
||||||
import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
|
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'
|
||||||
import {
|
import {
|
||||||
useYAxisWidth,
|
useYAxisWidth,
|
||||||
|
chartTimeData,
|
||||||
cn,
|
cn,
|
||||||
formatShortDate,
|
formatShortDate,
|
||||||
decimalString,
|
twoDecimalString,
|
||||||
toFixedFloat,
|
toFixedFloat,
|
||||||
chartMargin,
|
getSizeVal,
|
||||||
getSizeAndUnit,
|
getSizeUnit,
|
||||||
} from "@/lib/utils"
|
} from '@/lib/utils'
|
||||||
import { ChartData } from "@/types"
|
// import { useMemo } from 'react'
|
||||||
import { memo } from "react"
|
// import Spinner from '../spinner'
|
||||||
import { t } from "@lingui/macro"
|
import { useStore } from '@nanostores/react'
|
||||||
import { useLingui } from "@lingui/react"
|
import { $chartTime } from '@/lib/stores'
|
||||||
|
import { SystemStatsRecord } from '@/types'
|
||||||
|
|
||||||
export default memo(function DiskChart({
|
export default function DiskChart({
|
||||||
|
ticks,
|
||||||
|
systemData,
|
||||||
dataKey,
|
dataKey,
|
||||||
diskSize,
|
diskSize,
|
||||||
chartData,
|
|
||||||
}: {
|
}: {
|
||||||
|
ticks: number[]
|
||||||
|
systemData: SystemStatsRecord[]
|
||||||
dataKey: string
|
dataKey: string
|
||||||
diskSize: number
|
diskSize: number
|
||||||
chartData: ChartData
|
|
||||||
}) {
|
}) {
|
||||||
|
const chartTime = useStore($chartTime)
|
||||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||||
const { _ } = useLingui()
|
|
||||||
|
|
||||||
if (chartData.systemStats.length === 0) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
{/* {!yAxisSet && <Spinner />} */}
|
||||||
<ChartContainer
|
<ChartContainer
|
||||||
className={cn("h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity", {
|
config={{}}
|
||||||
"opacity-100": yAxisWidth,
|
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}>
|
<AreaChart
|
||||||
|
accessibilityLayer
|
||||||
|
data={systemData}
|
||||||
|
margin={{
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: 10,
|
||||||
|
bottom: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<CartesianGrid vertical={false} />
|
<CartesianGrid vertical={false} />
|
||||||
<YAxis
|
<YAxis
|
||||||
direction="ltr"
|
|
||||||
orientation={chartData.orientation}
|
|
||||||
className="tracking-tighter"
|
className="tracking-tighter"
|
||||||
width={yAxisWidth}
|
width={yAxisWidth}
|
||||||
domain={[0, diskSize]}
|
domain={[0, diskSize]}
|
||||||
@@ -50,28 +59,37 @@ export default memo(function DiskChart({
|
|||||||
minTickGap={6}
|
minTickGap={6}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickFormatter={(value) => {
|
tickFormatter={(value) =>
|
||||||
const { v, u } = getSizeAndUnit(value)
|
updateYAxisWidth(toFixedFloat(getSizeVal(value), 2) + getSizeUnit(value))
|
||||||
return updateYAxisWidth(toFixedFloat(v, 2) + u)
|
}
|
||||||
}}
|
/>
|
||||||
|
<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
|
<ChartTooltip
|
||||||
animationEasing="ease-out"
|
animationEasing="ease-out"
|
||||||
animationDuration={150}
|
animationDuration={150}
|
||||||
content={
|
content={
|
||||||
<ChartTooltipContent
|
<ChartTooltipContent
|
||||||
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
||||||
contentFormatter={({ value }) => {
|
contentFormatter={({ value }) =>
|
||||||
const { v, u } = getSizeAndUnit(value)
|
twoDecimalString(getSizeVal(value)) + getSizeUnit(value)
|
||||||
return decimalString(v) + u
|
}
|
||||||
}}
|
indicator="line"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Area
|
<Area
|
||||||
dataKey={dataKey}
|
dataKey={dataKey}
|
||||||
name={_(t`Disk Usage`)}
|
name="Disk Usage"
|
||||||
type="monotoneX"
|
type="monotoneX"
|
||||||
fill="hsl(var(--chart-4))"
|
fill="hsl(var(--chart-4))"
|
||||||
fillOpacity={0.4}
|
fillOpacity={0.4}
|
||||||
@@ -83,4 +101,4 @@ export default memo(function DiskChart({
|
|||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})
|
}
|
||||||
|
|||||||
102
beszel/site/src/components/charts/disk-io-chart.tsx
Normal file
102
beszel/site/src/components/charts/disk-io-chart.tsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
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,38 +1,52 @@
|
|||||||
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
|
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
|
||||||
|
|
||||||
import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
|
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'
|
||||||
import { useYAxisWidth, cn, toFixedFloat, decimalString, formatShortDate, chartMargin } from "@/lib/utils"
|
import {
|
||||||
import { memo } from "react"
|
useYAxisWidth,
|
||||||
import { ChartData } from "@/types"
|
chartTimeData,
|
||||||
import { t } from "@lingui/macro"
|
cn,
|
||||||
import { useLingui } from "@lingui/react"
|
toFixedFloat,
|
||||||
|
twoDecimalString,
|
||||||
|
formatShortDate,
|
||||||
|
} from '@/lib/utils'
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
import { useStore } from '@nanostores/react'
|
||||||
|
import { $chartTime } from '@/lib/stores'
|
||||||
|
import { SystemStatsRecord } from '@/types'
|
||||||
|
|
||||||
export default memo(function MemChart({ chartData }: { chartData: ChartData }) {
|
export default function MemChart({
|
||||||
|
ticks,
|
||||||
|
systemData,
|
||||||
|
}: {
|
||||||
|
ticks: number[]
|
||||||
|
systemData: SystemStatsRecord[]
|
||||||
|
}) {
|
||||||
|
const chartTime = useStore($chartTime)
|
||||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||||
const { _ } = useLingui()
|
|
||||||
|
|
||||||
const totalMem = toFixedFloat(chartData.systemStats.at(-1)?.stats.m ?? 0, 1)
|
const totalMem = useMemo(() => {
|
||||||
|
return toFixedFloat(systemData.at(-1)?.stats.m ?? 0, 1)
|
||||||
// console.log('rendered at', new Date())
|
}, [systemData])
|
||||||
|
|
||||||
if (chartData.systemStats.length === 0) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* {!yAxisSet && <Spinner />} */}
|
{/* {!yAxisSet && <Spinner />} */}
|
||||||
<ChartContainer
|
<ChartContainer
|
||||||
className={cn("h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity", {
|
config={{}}
|
||||||
"opacity-100": yAxisWidth,
|
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}>
|
<AreaChart
|
||||||
|
accessibilityLayer
|
||||||
|
data={systemData}
|
||||||
|
margin={{
|
||||||
|
top: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<CartesianGrid vertical={false} />
|
<CartesianGrid vertical={false} />
|
||||||
{totalMem && (
|
{totalMem && (
|
||||||
<YAxis
|
<YAxis
|
||||||
direction="ltr"
|
|
||||||
orientation={chartData.orientation}
|
|
||||||
// use "ticks" instead of domain / tickcount if need more control
|
// use "ticks" instead of domain / tickcount if need more control
|
||||||
domain={[0, totalMem]}
|
domain={[0, totalMem]}
|
||||||
tickCount={9}
|
tickCount={9}
|
||||||
@@ -42,11 +56,21 @@ export default memo(function MemChart({ chartData }: { chartData: ChartData }) {
|
|||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickFormatter={(value) => {
|
tickFormatter={(value) => {
|
||||||
const val = toFixedFloat(value, 1)
|
const val = toFixedFloat(value, 1)
|
||||||
return updateYAxisWidth(val + " GB")
|
return updateYAxisWidth(val + ' GB')
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{xAxis(chartData)}
|
<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
|
<ChartTooltip
|
||||||
// cursor={false}
|
// cursor={false}
|
||||||
animationEasing="ease-out"
|
animationEasing="ease-out"
|
||||||
@@ -56,13 +80,13 @@ export default memo(function MemChart({ chartData }: { chartData: ChartData }) {
|
|||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
itemSorter={(a, b) => a.order - b.order}
|
itemSorter={(a, b) => a.order - b.order}
|
||||||
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
||||||
contentFormatter={(item) => decimalString(item.value) + " GB"}
|
contentFormatter={(item) => twoDecimalString(item.value) + ' GB'}
|
||||||
// indicator="line"
|
indicator="line"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Area
|
<Area
|
||||||
name={_(t`Used`)}
|
name="Used"
|
||||||
order={3}
|
order={3}
|
||||||
dataKey="stats.mu"
|
dataKey="stats.mu"
|
||||||
type="monotoneX"
|
type="monotoneX"
|
||||||
@@ -72,7 +96,7 @@ export default memo(function MemChart({ chartData }: { chartData: ChartData }) {
|
|||||||
stackId="1"
|
stackId="1"
|
||||||
isAnimationActive={false}
|
isAnimationActive={false}
|
||||||
/>
|
/>
|
||||||
{chartData.systemStats.at(-1)?.stats.mz && (
|
{systemData.at(-1)?.stats.mz && (
|
||||||
<Area
|
<Area
|
||||||
name="ZFS ARC"
|
name="ZFS ARC"
|
||||||
order={2}
|
order={2}
|
||||||
@@ -86,7 +110,7 @@ export default memo(function MemChart({ chartData }: { chartData: ChartData }) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Area
|
<Area
|
||||||
name={_(t`Cache / Buffers`)}
|
name="Cache / Buffers"
|
||||||
order={1}
|
order={1}
|
||||||
dataKey="stats.mb"
|
dataKey="stats.mb"
|
||||||
type="monotoneX"
|
type="monotoneX"
|
||||||
@@ -101,4 +125,4 @@ export default memo(function MemChart({ chartData }: { chartData: ChartData }) {
|
|||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})
|
}
|
||||||
|
|||||||
@@ -1,59 +1,72 @@
|
|||||||
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
|
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
|
||||||
|
|
||||||
import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
|
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'
|
||||||
import {
|
import {
|
||||||
useYAxisWidth,
|
useYAxisWidth,
|
||||||
|
chartTimeData,
|
||||||
cn,
|
cn,
|
||||||
formatShortDate,
|
formatShortDate,
|
||||||
toFixedWithoutTrailingZeros,
|
toFixedWithoutTrailingZeros,
|
||||||
decimalString,
|
twoDecimalString,
|
||||||
chartMargin,
|
} from '@/lib/utils'
|
||||||
} from "@/lib/utils"
|
// import Spinner from '../spinner'
|
||||||
import { ChartData } from "@/types"
|
import { useStore } from '@nanostores/react'
|
||||||
import { memo } from "react"
|
import { $chartTime } from '@/lib/stores'
|
||||||
import { t } from "@lingui/macro"
|
import { SystemStatsRecord } from '@/types'
|
||||||
|
|
||||||
export default memo(function SwapChart({ chartData }: { chartData: ChartData }) {
|
export default function SwapChart({
|
||||||
|
ticks,
|
||||||
|
systemData,
|
||||||
|
}: {
|
||||||
|
ticks: number[]
|
||||||
|
systemData: SystemStatsRecord[]
|
||||||
|
}) {
|
||||||
|
const chartTime = useStore($chartTime)
|
||||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||||
|
|
||||||
if (chartData.systemStats.length === 0) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<ChartContainer
|
<ChartContainer
|
||||||
className={cn("h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity", {
|
config={{}}
|
||||||
"opacity-100": yAxisWidth,
|
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}>
|
<AreaChart accessibilityLayer data={systemData} margin={{ top: 10 }}>
|
||||||
<CartesianGrid vertical={false} />
|
<CartesianGrid vertical={false} />
|
||||||
<YAxis
|
<YAxis
|
||||||
direction="ltr"
|
|
||||||
orientation={chartData.orientation}
|
|
||||||
className="tracking-tighter"
|
className="tracking-tighter"
|
||||||
domain={[0, () => toFixedWithoutTrailingZeros(chartData.systemStats.at(-1)?.stats.s ?? 0.04, 2)]}
|
domain={[0, () => toFixedWithoutTrailingZeros(systemData.at(-1)?.stats.s ?? 0.04, 2)]}
|
||||||
width={yAxisWidth}
|
width={yAxisWidth}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickFormatter={(value) => updateYAxisWidth(value + " GB")}
|
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
|
<ChartTooltip
|
||||||
animationEasing="ease-out"
|
animationEasing="ease-out"
|
||||||
animationDuration={150}
|
animationDuration={150}
|
||||||
content={
|
content={
|
||||||
<ChartTooltipContent
|
<ChartTooltipContent
|
||||||
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
||||||
contentFormatter={(item) => decimalString(item.value) + " GB"}
|
contentFormatter={(item) => twoDecimalString(item.value) + ' GB'}
|
||||||
// indicator="line"
|
indicator="line"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Area
|
<Area
|
||||||
dataKey="stats.su"
|
dataKey="stats.su"
|
||||||
name={t`Used`}
|
name="Swap Usage"
|
||||||
type="monotoneX"
|
type="monotoneX"
|
||||||
fill="hsl(var(--chart-2))"
|
fill="hsl(var(--chart-2))"
|
||||||
fillOpacity={0.4}
|
fillOpacity={0.4}
|
||||||
@@ -64,4 +77,4 @@ export default memo(function SwapChart({ chartData }: { chartData: ChartData })
|
|||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { CartesianGrid, Line, LineChart, YAxis } from "recharts"
|
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from 'recharts'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ChartContainer,
|
ChartContainer,
|
||||||
@@ -6,34 +6,38 @@ import {
|
|||||||
ChartLegendContent,
|
ChartLegendContent,
|
||||||
ChartTooltip,
|
ChartTooltip,
|
||||||
ChartTooltipContent,
|
ChartTooltipContent,
|
||||||
xAxis,
|
} from '@/components/ui/chart'
|
||||||
} from "@/components/ui/chart"
|
|
||||||
import {
|
import {
|
||||||
useYAxisWidth,
|
useYAxisWidth,
|
||||||
|
chartTimeData,
|
||||||
cn,
|
cn,
|
||||||
formatShortDate,
|
formatShortDate,
|
||||||
toFixedWithoutTrailingZeros,
|
toFixedWithoutTrailingZeros,
|
||||||
decimalString,
|
twoDecimalString,
|
||||||
chartMargin,
|
} from '@/lib/utils'
|
||||||
} from "@/lib/utils"
|
import { useStore } from '@nanostores/react'
|
||||||
import { ChartData } from "@/types"
|
import { $chartTime } from '@/lib/stores'
|
||||||
import { memo, useMemo } from "react"
|
import { SystemStatsRecord } from '@/types'
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
|
||||||
export default memo(function TemperatureChart({ chartData }: { chartData: ChartData }) {
|
export default function TemperatureChart({
|
||||||
|
ticks,
|
||||||
|
systemData,
|
||||||
|
}: {
|
||||||
|
ticks: number[]
|
||||||
|
systemData: SystemStatsRecord[]
|
||||||
|
}) {
|
||||||
|
const chartTime = useStore($chartTime)
|
||||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||||
|
|
||||||
if (chartData.systemStats.length === 0) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Format temperature data for chart and assign colors */
|
/** Format temperature data for chart and assign colors */
|
||||||
const newChartData = useMemo(() => {
|
const newChartData = useMemo(() => {
|
||||||
const newChartData = { data: [], colors: {} } as {
|
const chartData = { data: [], colors: {} } as {
|
||||||
data: Record<string, number | string>[]
|
data: Record<string, number | string>[]
|
||||||
colors: Record<string, string>
|
colors: Record<string, string>
|
||||||
}
|
}
|
||||||
const tempSums = {} as Record<string, number>
|
const tempSums = {} as Record<string, number>
|
||||||
for (let data of chartData.systemStats) {
|
for (let data of systemData) {
|
||||||
let newData = { created: data.created } as Record<string, number | string>
|
let newData = { created: data.created } as Record<string, number | string>
|
||||||
let keys = Object.keys(data.stats?.t ?? {})
|
let keys = Object.keys(data.stats?.t ?? {})
|
||||||
for (let i = 0; i < keys.length; i++) {
|
for (let i = 0; i < keys.length; i++) {
|
||||||
@@ -41,42 +45,59 @@ export default memo(function TemperatureChart({ chartData }: { chartData: ChartD
|
|||||||
newData[key] = data.stats.t![key]
|
newData[key] = data.stats.t![key]
|
||||||
tempSums[key] = (tempSums[key] ?? 0) + newData[key]
|
tempSums[key] = (tempSums[key] ?? 0) + newData[key]
|
||||||
}
|
}
|
||||||
newChartData.data.push(newData)
|
chartData.data.push(newData)
|
||||||
}
|
}
|
||||||
const keys = Object.keys(tempSums).sort((a, b) => tempSums[b] - tempSums[a])
|
const keys = Object.keys(tempSums).sort((a, b) => tempSums[b] - tempSums[a])
|
||||||
for (let key of keys) {
|
for (let key of keys) {
|
||||||
newChartData.colors[key] = `hsl(${((keys.indexOf(key) * 360) / keys.length) % 360}, 60%, 55%)`
|
chartData.colors[key] = `hsl(${((keys.indexOf(key) * 360) / keys.length) % 360}, 60%, 55%)`
|
||||||
}
|
}
|
||||||
return newChartData
|
return chartData
|
||||||
}, [chartData])
|
}, [systemData])
|
||||||
|
|
||||||
const colors = Object.keys(newChartData.colors)
|
const colors = Object.keys(newChartData.colors)
|
||||||
|
|
||||||
// console.log('rendered at', new Date())
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
{/* {!yAxisSet && <Spinner />} */}
|
||||||
<ChartContainer
|
<ChartContainer
|
||||||
className={cn("h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity", {
|
config={{}}
|
||||||
"opacity-100": yAxisWidth,
|
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
|
||||||
|
'opacity-100': yAxisWidth,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<LineChart accessibilityLayer data={newChartData.data} margin={chartMargin}>
|
<LineChart
|
||||||
|
accessibilityLayer
|
||||||
|
data={newChartData.data}
|
||||||
|
margin={{
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: 10,
|
||||||
|
bottom: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<CartesianGrid vertical={false} />
|
<CartesianGrid vertical={false} />
|
||||||
<YAxis
|
<YAxis
|
||||||
direction="ltr"
|
|
||||||
orientation={chartData.orientation}
|
|
||||||
className="tracking-tighter"
|
className="tracking-tighter"
|
||||||
domain={[0, "auto"]}
|
domain={[0, 'auto']}
|
||||||
width={yAxisWidth}
|
width={yAxisWidth}
|
||||||
tickFormatter={(value) => {
|
tickFormatter={(value) => {
|
||||||
const val = toFixedWithoutTrailingZeros(value, 2)
|
const val = toFixedWithoutTrailingZeros(value, 2)
|
||||||
return updateYAxisWidth(val + " °C")
|
return updateYAxisWidth(val + ' °C')
|
||||||
}}
|
}}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
/>
|
/>
|
||||||
{xAxis(chartData)}
|
<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
|
<ChartTooltip
|
||||||
animationEasing="ease-out"
|
animationEasing="ease-out"
|
||||||
animationDuration={150}
|
animationDuration={150}
|
||||||
@@ -85,8 +106,8 @@ export default memo(function TemperatureChart({ chartData }: { chartData: ChartD
|
|||||||
content={
|
content={
|
||||||
<ChartTooltipContent
|
<ChartTooltipContent
|
||||||
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
||||||
contentFormatter={(item) => decimalString(item.value) + " °C"}
|
contentFormatter={(item) => twoDecimalString(item.value) + ' °C'}
|
||||||
// indicator="line"
|
indicator="line"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -107,4 +128,4 @@ export default memo(function TemperatureChart({ chartData }: { chartData: ChartD
|
|||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
Server,
|
Server,
|
||||||
SettingsIcon,
|
SettingsIcon,
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
} from "lucide-react"
|
} from 'lucide-react'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CommandDialog,
|
CommandDialog,
|
||||||
@@ -19,36 +19,34 @@ import {
|
|||||||
CommandList,
|
CommandList,
|
||||||
CommandSeparator,
|
CommandSeparator,
|
||||||
CommandShortcut,
|
CommandShortcut,
|
||||||
} from "@/components/ui/command"
|
} from '@/components/ui/command'
|
||||||
import { useEffect } from "react"
|
import { useEffect, useState } from 'react'
|
||||||
import { useStore } from "@nanostores/react"
|
import { useStore } from '@nanostores/react'
|
||||||
import { $systems } from "@/lib/stores"
|
import { $systems } from '@/lib/stores'
|
||||||
import { isAdmin } from "@/lib/utils"
|
import { isAdmin } from '@/lib/utils'
|
||||||
import { navigate } from "./router"
|
import { navigate } from './router'
|
||||||
import { Trans, t } from "@lingui/macro"
|
|
||||||
|
|
||||||
export default function CommandPalette({ open, setOpen }: { open: boolean; setOpen: (open: boolean) => void }) {
|
export default function CommandPalette() {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
const systems = useStore($systems)
|
const systems = useStore($systems)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const down = (e: KeyboardEvent) => {
|
const down = (e: KeyboardEvent) => {
|
||||||
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
|
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setOpen(!open)
|
setOpen((open) => !open)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener("keydown", down)
|
document.addEventListener('keydown', down)
|
||||||
return () => document.removeEventListener("keydown", down)
|
return () => document.removeEventListener('keydown', down)
|
||||||
}, [open, setOpen])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CommandDialog open={open} onOpenChange={setOpen}>
|
<CommandDialog open={open} onOpenChange={setOpen}>
|
||||||
<CommandInput placeholder={t`Search for systems or settings...`} />
|
<CommandInput placeholder="Search for systems or settings..." />
|
||||||
<CommandList>
|
<CommandList>
|
||||||
<CommandEmpty>
|
<CommandEmpty>No results found.</CommandEmpty>
|
||||||
<Trans>No results found.</Trans>
|
|
||||||
</CommandEmpty>
|
|
||||||
{systems.length > 0 && (
|
{systems.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
@@ -60,7 +58,7 @@ export default function CommandPalette({ open, setOpen }: { open: boolean; setOp
|
|||||||
setOpen(false)
|
setOpen(false)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Server className="me-2 h-4 w-4" />
|
<Server className="mr-2 h-4 w-4" />
|
||||||
<span>{system.name}</span>
|
<span>{system.name}</span>
|
||||||
<CommandShortcut>{system.host}</CommandShortcut>
|
<CommandShortcut>{system.host}</CommandShortcut>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
@@ -69,140 +67,106 @@ export default function CommandPalette({ open, setOpen }: { open: boolean; setOp
|
|||||||
<CommandSeparator className="mb-1.5" />
|
<CommandSeparator className="mb-1.5" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<CommandGroup heading={t`Pages / Settings`}>
|
<CommandGroup heading="Pages / Settings">
|
||||||
<CommandItem
|
<CommandItem
|
||||||
keywords={["home"]}
|
keywords={['home']}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
navigate("/")
|
navigate('/')
|
||||||
setOpen(false)
|
setOpen((open) => !open)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<LayoutDashboard className="me-2 h-4 w-4" />
|
<LayoutDashboard className="mr-2 h-4 w-4" />
|
||||||
<span>
|
<span>Dashboard</span>
|
||||||
<Trans>Dashboard</Trans>
|
<CommandShortcut>Page</CommandShortcut>
|
||||||
</span>
|
|
||||||
<CommandShortcut>
|
|
||||||
<Trans>Page</Trans>
|
|
||||||
</CommandShortcut>
|
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
<CommandItem
|
<CommandItem
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
navigate("/settings/general")
|
navigate('/settings/general')
|
||||||
setOpen(false)
|
setOpen((open) => !open)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SettingsIcon className="me-2 h-4 w-4" />
|
<SettingsIcon className="mr-2 h-4 w-4" />
|
||||||
<span>
|
<span>Settings</span>
|
||||||
<Trans>Settings</Trans>
|
<CommandShortcut>Settings</CommandShortcut>
|
||||||
</span>
|
|
||||||
<CommandShortcut>
|
|
||||||
<Trans>Settings</Trans>
|
|
||||||
</CommandShortcut>
|
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
<CommandItem
|
<CommandItem
|
||||||
keywords={["alerts"]}
|
keywords={['alerts']}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
navigate("/settings/notifications")
|
navigate('/settings/notifications')
|
||||||
setOpen(false)
|
setOpen((open) => !open)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MailIcon className="me-2 h-4 w-4" />
|
<MailIcon className="mr-2 h-4 w-4" />
|
||||||
<span>
|
<span>Notification settings</span>
|
||||||
<Trans>Notifications</Trans>
|
<CommandShortcut>Settings</CommandShortcut>
|
||||||
</span>
|
|
||||||
<CommandShortcut>
|
|
||||||
<Trans>Settings</Trans>
|
|
||||||
</CommandShortcut>
|
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
<CommandItem
|
<CommandItem
|
||||||
keywords={["github"]}
|
keywords={['github']}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
window.location.href = "https://github.com/henrygd/beszel/blob/main/readme.md"
|
window.location.href = 'https://github.com/henrygd/beszel/blob/main/readme.md'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Github className="me-2 h-4 w-4" />
|
<Github className="mr-2 h-4 w-4" />
|
||||||
<span>
|
<span>Documentation</span>
|
||||||
<Trans>Documentation</Trans>
|
|
||||||
</span>
|
|
||||||
<CommandShortcut>GitHub</CommandShortcut>
|
<CommandShortcut>GitHub</CommandShortcut>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
{isAdmin() && (
|
{isAdmin() && (
|
||||||
<>
|
<>
|
||||||
<CommandSeparator className="mb-1.5" />
|
<CommandSeparator className="mb-1.5" />
|
||||||
<CommandGroup heading={t`Admin`}>
|
<CommandGroup heading="Admin">
|
||||||
<CommandItem
|
<CommandItem
|
||||||
keywords={["pocketbase"]}
|
keywords={['pocketbase']}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
window.open("/_/", "_blank")
|
window.open('/_/', '_blank')
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<UsersIcon className="me-2 h-4 w-4" />
|
<UsersIcon className="mr-2 h-4 w-4" />
|
||||||
<span>
|
<span>Users</span>
|
||||||
<Trans>Users</Trans>
|
<CommandShortcut>Admin</CommandShortcut>
|
||||||
</span>
|
|
||||||
<CommandShortcut>
|
|
||||||
<Trans>Admin</Trans>
|
|
||||||
</CommandShortcut>
|
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
<CommandItem
|
<CommandItem
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
window.open("/_/#/logs", "_blank")
|
window.open('/_/#/logs', '_blank')
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<LogsIcon className="me-2 h-4 w-4" />
|
<LogsIcon className="mr-2 h-4 w-4" />
|
||||||
<span>
|
<span>Logs</span>
|
||||||
<Trans>Logs</Trans>
|
<CommandShortcut>Admin</CommandShortcut>
|
||||||
</span>
|
|
||||||
<CommandShortcut>
|
|
||||||
<Trans>Admin</Trans>
|
|
||||||
</CommandShortcut>
|
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
<CommandItem
|
<CommandItem
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
window.open("/_/#/settings/backups", "_blank")
|
window.open('/_/#/settings/backups', '_blank')
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DatabaseBackupIcon className="me-2 h-4 w-4" />
|
<DatabaseBackupIcon className="mr-2 h-4 w-4" />
|
||||||
<span>
|
<span>Database backups</span>
|
||||||
<Trans>Backups</Trans>
|
<CommandShortcut>Admin</CommandShortcut>
|
||||||
</span>
|
|
||||||
<CommandShortcut>
|
|
||||||
<Trans>Admin</Trans>
|
|
||||||
</CommandShortcut>
|
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
<CommandItem
|
<CommandItem
|
||||||
keywords={["oauth", "oicd"]}
|
keywords={['oauth', 'oicd']}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
window.open("/_/#/settings/auth-providers", "_blank")
|
window.open('/_/#/settings/auth-providers', '_blank')
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<LockKeyholeIcon className="me-2 h-4 w-4" />
|
<LockKeyholeIcon className="mr-2 h-4 w-4" />
|
||||||
<span>
|
<span>Auth Providers</span>
|
||||||
<Trans>Auth Providers</Trans>
|
<CommandShortcut>Admin</CommandShortcut>
|
||||||
</span>
|
|
||||||
<CommandShortcut>
|
|
||||||
<Trans>Admin</Trans>
|
|
||||||
</CommandShortcut>
|
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
<CommandItem
|
<CommandItem
|
||||||
keywords={["email"]}
|
keywords={['email']}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
window.open("/_/#/settings/mail", "_blank")
|
window.open('/_/#/settings/mail', '_blank')
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MailIcon className="me-2 h-4 w-4" />
|
<MailIcon className="mr-2 h-4 w-4" />
|
||||||
<span>
|
<span>SMTP settings</span>
|
||||||
<Trans>SMTP settings</Trans>
|
<CommandShortcut>Admin</CommandShortcut>
|
||||||
</span>
|
|
||||||
<CommandShortcut>
|
|
||||||
<Trans>Admin</Trans>
|
|
||||||
</CommandShortcut>
|
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,22 +1,20 @@
|
|||||||
import { useEffect, useMemo, useRef } from "react"
|
import { useEffect, useMemo, useRef } from 'react'
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "./ui/dialog"
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from './ui/dialog'
|
||||||
import { Textarea } from "./ui/textarea"
|
import { Textarea } from './ui/textarea'
|
||||||
import { $copyContent } from "@/lib/stores"
|
import { $copyContent } from '@/lib/stores'
|
||||||
import { Trans } from "@lingui/macro"
|
|
||||||
|
|
||||||
export default function CopyToClipboard({ content }: { content: string }) {
|
export default function CopyToClipboard({ content }: { content: string }) {
|
||||||
return (
|
return (
|
||||||
<Dialog defaultOpen={true}>
|
<Dialog defaultOpen={true}>
|
||||||
<DialogContent className="w-[90%] rounded-lg md:pt-4" style={{ maxWidth: 530 }}>
|
<DialogContent className="w-[90%] rounded-lg" style={{ maxWidth: 530 }}>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>Could not copy to clipboard</DialogTitle>
|
||||||
<Trans>Copy text</Trans>
|
<DialogDescription>Please copy the text manually.</DialogDescription>
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription className="hidden xs:block">
|
|
||||||
<Trans>Automatic copy requires a secure context.</Trans>
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<CopyTextarea content={content} />
|
<CopyTextarea content={content} />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Clipboard API requires a secure context (https, localhost, or *.localhost)
|
||||||
|
</p>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
@@ -26,7 +24,7 @@ function CopyTextarea({ content }: { content: string }) {
|
|||||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||||
|
|
||||||
const rows = useMemo(() => {
|
const rows = useMemo(() => {
|
||||||
return content.split("\n").length
|
return content.split('\n').length
|
||||||
}, [content])
|
}, [content])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -36,7 +34,7 @@ function CopyTextarea({ content }: { content: string }) {
|
|||||||
}, [textareaRef])
|
}, [textareaRef])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => $copyContent.set("")
|
return () => $copyContent.set('')
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
import { LanguagesIcon } from "lucide-react"
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
|
||||||
import languages from "../lib/languages.json"
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { useLingui } from "@lingui/react"
|
|
||||||
import { dynamicActivate } from "@/lib/i18n"
|
|
||||||
|
|
||||||
export function LangToggle() {
|
|
||||||
const { i18n } = useLingui()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant={"ghost"} size="icon" className="hidden 450:flex">
|
|
||||||
<LanguagesIcon className="absolute h-[1.2rem] w-[1.2rem] light:opacity-85" />
|
|
||||||
<span className="sr-only">Language</span>
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent className="grid grid-cols-3">
|
|
||||||
{languages.map(({ lang, label, e }) => (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={lang}
|
|
||||||
className={cn("px-3 flex gap-2.5", lang === i18n.locale && "font-semibold")}
|
|
||||||
onClick={() => dynamicActivate(lang)}
|
|
||||||
>
|
|
||||||
<span>{e}</span> {label}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,20 +1,28 @@
|
|||||||
import { cn } from "@/lib/utils"
|
import { cn } from '@/lib/utils'
|
||||||
import { buttonVariants } from "@/components/ui/button"
|
import { buttonVariants } from '@/components/ui/button'
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from '@/components/ui/label'
|
||||||
import { LoaderCircle, LockIcon, LogInIcon, MailIcon, UserIcon } from "lucide-react"
|
import { LoaderCircle, LockIcon, LogInIcon, MailIcon, UserIcon } from 'lucide-react'
|
||||||
import { $authenticated, pb } from "@/lib/stores"
|
import { $authenticated, pb } from '@/lib/stores'
|
||||||
import * as v from "valibot"
|
import * as v from 'valibot'
|
||||||
import { toast } from "../ui/use-toast"
|
import { toast } from '../ui/use-toast'
|
||||||
import { Dialog, DialogContent, DialogTrigger, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
import {
|
||||||
import { useCallback, useState } from "react"
|
Dialog,
|
||||||
import { AuthMethodsList, OAuth2AuthConfig } from "pocketbase"
|
DialogContent,
|
||||||
import { Link } from "../router"
|
DialogTrigger,
|
||||||
import { Trans, t } from "@lingui/macro"
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { useCallback, useState } from 'react'
|
||||||
|
import { AuthMethodsList, OAuth2AuthConfig } from 'pocketbase'
|
||||||
|
import { Link } from '../router'
|
||||||
|
|
||||||
const honeypot = v.literal("")
|
const honeypot = v.literal('')
|
||||||
const emailSchema = v.pipe(v.string(), v.email(t`Invalid email address.`))
|
const emailSchema = v.pipe(v.string(), v.email('Invalid email address.'))
|
||||||
const passwordSchema = v.pipe(v.string(), v.minLength(10, t`Password must be at least 10 characters.`))
|
const passwordSchema = v.pipe(
|
||||||
|
v.string(),
|
||||||
|
v.minLength(10, 'Password must be at least 10 characters.')
|
||||||
|
)
|
||||||
|
|
||||||
const LoginSchema = v.looseObject({
|
const LoginSchema = v.looseObject({
|
||||||
name: honeypot,
|
name: honeypot,
|
||||||
@@ -28,9 +36,9 @@ const RegisterSchema = v.looseObject({
|
|||||||
v.string(),
|
v.string(),
|
||||||
v.regex(
|
v.regex(
|
||||||
/^(?=.*[a-zA-Z])[a-zA-Z0-9_-]+$/,
|
/^(?=.*[a-zA-Z])[a-zA-Z0-9_-]+$/,
|
||||||
"Invalid username. You may use alphanumeric characters, underscores, and hyphens."
|
'Invalid username. You may use alphanumeric characters, underscores, and hyphens.'
|
||||||
),
|
),
|
||||||
v.minLength(3, "Username must be at least 3 characters long.")
|
v.minLength(3, 'Username must be at least 3 characters long.')
|
||||||
),
|
),
|
||||||
email: emailSchema,
|
email: emailSchema,
|
||||||
password: passwordSchema,
|
password: passwordSchema,
|
||||||
@@ -39,9 +47,9 @@ const RegisterSchema = v.looseObject({
|
|||||||
|
|
||||||
const showLoginFaliedToast = () => {
|
const showLoginFaliedToast = () => {
|
||||||
toast({
|
toast({
|
||||||
title: t`Login attempt failed`,
|
title: 'Login attempt failed',
|
||||||
description: t`Please check your credentials and try again`,
|
description: 'Please check your credentials and try again',
|
||||||
variant: "destructive",
|
variant: 'destructive',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,27 +90,36 @@ export function UserAuthForm({
|
|||||||
if (isFirstRun) {
|
if (isFirstRun) {
|
||||||
// check that passwords match
|
// check that passwords match
|
||||||
if (password !== passwordConfirm) {
|
if (password !== passwordConfirm) {
|
||||||
let msg = "Passwords do not match"
|
let msg = 'Passwords do not match'
|
||||||
setErrors({ passwordConfirm: msg })
|
setErrors({ passwordConfirm: msg })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// create admin user
|
||||||
await pb.admins.create({
|
await pb.admins.create({
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
passwordConfirm: password,
|
passwordConfirm: password,
|
||||||
})
|
})
|
||||||
await pb.admins.authWithPassword(email, password)
|
await pb.admins.authWithPassword(email, password)
|
||||||
await pb.collection("users").create({
|
// create regular user
|
||||||
|
const user = await pb.collection('users').create({
|
||||||
username,
|
username,
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
passwordConfirm: password,
|
passwordConfirm: password,
|
||||||
role: "admin",
|
role: 'admin',
|
||||||
verified: true,
|
verified: true,
|
||||||
})
|
})
|
||||||
await pb.collection("users").authWithPassword(email, password)
|
// create hubsys
|
||||||
|
await pb.collection('systems').create({
|
||||||
|
name: 'x',
|
||||||
|
port: 'x',
|
||||||
|
host: 'hubsys',
|
||||||
|
users: user.id,
|
||||||
|
})
|
||||||
|
await pb.collection('users').authWithPassword(email, password)
|
||||||
} else {
|
} else {
|
||||||
await pb.collection("users").authWithPassword(email, password)
|
await pb.collection('users').authWithPassword(email, password)
|
||||||
}
|
}
|
||||||
$authenticated.set(true)
|
$authenticated.set(true)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -119,7 +136,7 @@ export function UserAuthForm({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("grid gap-6", className)} {...props}>
|
<div className={cn('grid gap-6', className)} {...props}>
|
||||||
{authMethods.emailPassword && (
|
{authMethods.emailPassword && (
|
||||||
<>
|
<>
|
||||||
<form onSubmit={handleSubmit} onChange={() => setErrors({})}>
|
<form onSubmit={handleSubmit} onChange={() => setErrors({})}>
|
||||||
@@ -128,57 +145,59 @@ export function UserAuthForm({
|
|||||||
<div className="grid gap-1 relative">
|
<div className="grid gap-1 relative">
|
||||||
<UserIcon className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
<UserIcon className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||||
<Label className="sr-only" htmlFor="username">
|
<Label className="sr-only" htmlFor="username">
|
||||||
<Trans>Username</Trans>
|
Username
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
autoFocus={true}
|
autoFocus={true}
|
||||||
id="username"
|
id="username"
|
||||||
name="username"
|
name="username"
|
||||||
required
|
required
|
||||||
placeholder={t`username`}
|
placeholder="username"
|
||||||
type="username"
|
type="username"
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
autoCorrect="off"
|
autoCorrect="off"
|
||||||
disabled={isLoading || isOauthLoading}
|
disabled={isLoading || isOauthLoading}
|
||||||
className="ps-9"
|
className="pl-9"
|
||||||
/>
|
/>
|
||||||
{errors?.username && <p className="px-1 text-xs text-red-600">{errors.username}</p>}
|
{errors?.username && (
|
||||||
|
<p className="px-1 text-xs text-red-600">{errors.username}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="grid gap-1 relative">
|
<div className="grid gap-1 relative">
|
||||||
<MailIcon className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
<MailIcon className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||||
<Label className="sr-only" htmlFor="email">
|
<Label className="sr-only" htmlFor="email">
|
||||||
<Trans>Email</Trans>
|
Email
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="email"
|
id="email"
|
||||||
name="email"
|
name="email"
|
||||||
required
|
required
|
||||||
placeholder={isFirstRun ? t`email` : "name@example.com"}
|
placeholder={isFirstRun ? 'email' : 'name@example.com'}
|
||||||
type="email"
|
type="email"
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
autoCorrect="off"
|
autoCorrect="off"
|
||||||
disabled={isLoading || isOauthLoading}
|
disabled={isLoading || isOauthLoading}
|
||||||
className="ps-9"
|
className="pl-9"
|
||||||
/>
|
/>
|
||||||
{errors?.email && <p className="px-1 text-xs text-red-600">{errors.email}</p>}
|
{errors?.email && <p className="px-1 text-xs text-red-600">{errors.email}</p>}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-1 relative">
|
<div className="grid gap-1 relative">
|
||||||
<LockIcon className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
<LockIcon className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||||
<Label className="sr-only" htmlFor="pass">
|
<Label className="sr-only" htmlFor="pass">
|
||||||
<Trans>Password</Trans>
|
Password
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="pass"
|
id="pass"
|
||||||
name="password"
|
name="password"
|
||||||
placeholder={t`Password`}
|
placeholder="password"
|
||||||
required
|
required
|
||||||
type="password"
|
type="password"
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
disabled={isLoading || isOauthLoading}
|
disabled={isLoading || isOauthLoading}
|
||||||
className="ps-9 lowercase"
|
className="pl-9"
|
||||||
/>
|
/>
|
||||||
{errors?.password && <p className="px-1 text-xs text-red-600">{errors.password}</p>}
|
{errors?.password && <p className="px-1 text-xs text-red-600">{errors.password}</p>}
|
||||||
</div>
|
</div>
|
||||||
@@ -186,19 +205,21 @@ export function UserAuthForm({
|
|||||||
<div className="grid gap-1 relative">
|
<div className="grid gap-1 relative">
|
||||||
<LockIcon className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
<LockIcon className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||||
<Label className="sr-only" htmlFor="pass2">
|
<Label className="sr-only" htmlFor="pass2">
|
||||||
<Trans>Confirm password</Trans>
|
Confirm password
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="pass2"
|
id="pass2"
|
||||||
name="passwordConfirm"
|
name="passwordConfirm"
|
||||||
placeholder={t`Confirm password`}
|
placeholder="confirm password"
|
||||||
required
|
required
|
||||||
type="password"
|
type="password"
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
disabled={isLoading || isOauthLoading}
|
disabled={isLoading || isOauthLoading}
|
||||||
className="ps-9 lowercase"
|
className="pl-9"
|
||||||
/>
|
/>
|
||||||
{errors?.passwordConfirm && <p className="px-1 text-xs text-red-600">{errors.passwordConfirm}</p>}
|
{errors?.passwordConfirm && (
|
||||||
|
<p className="px-1 text-xs text-red-600">{errors.passwordConfirm}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="sr-only">
|
<div className="sr-only">
|
||||||
@@ -208,11 +229,11 @@ export function UserAuthForm({
|
|||||||
</div>
|
</div>
|
||||||
<button className={cn(buttonVariants())} disabled={isLoading}>
|
<button className={cn(buttonVariants())} disabled={isLoading}>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<LoaderCircle className="me-2 h-4 w-4 animate-spin" />
|
<LoaderCircle className="mr-2 h-4 w-4 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<LogInIcon className="me-2 h-4 w-4" />
|
<LogInIcon className="mr-2 h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
{isFirstRun ? t`Create account` : t`Sign in`}
|
{isFirstRun ? 'Create account' : 'Sign in'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -223,9 +244,7 @@ export function UserAuthForm({
|
|||||||
<span className="w-full border-t" />
|
<span className="w-full border-t" />
|
||||||
</div>
|
</div>
|
||||||
<div className="relative flex justify-center text-xs uppercase">
|
<div className="relative flex justify-center text-xs uppercase">
|
||||||
<span className="bg-background px-2 text-muted-foreground">
|
<span className="bg-background px-2 text-muted-foreground">Or continue with</span>
|
||||||
<Trans>Or continue with</Trans>
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -238,9 +257,9 @@ export function UserAuthForm({
|
|||||||
<button
|
<button
|
||||||
key={provider.name}
|
key={provider.name}
|
||||||
type="button"
|
type="button"
|
||||||
className={cn(buttonVariants({ variant: "outline" }), {
|
className={cn(buttonVariants({ variant: 'outline' }), {
|
||||||
"justify-self-center": !authMethods.emailPassword,
|
'justify-self-center': !authMethods.emailPassword,
|
||||||
"px-5": !authMethods.emailPassword,
|
'px-5': !authMethods.emailPassword,
|
||||||
})}
|
})}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsOauthLoading(true)
|
setIsOauthLoading(true)
|
||||||
@@ -253,9 +272,9 @@ export function UserAuthForm({
|
|||||||
if (!authWindow) {
|
if (!authWindow) {
|
||||||
setIsOauthLoading(false)
|
setIsOauthLoading(false)
|
||||||
toast({
|
toast({
|
||||||
title: t`Error`,
|
title: 'Error',
|
||||||
description: t`Please enable pop-ups for this site`,
|
description: 'Please enable pop-ups for this site',
|
||||||
variant: "destructive",
|
variant: 'destructive',
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -263,7 +282,7 @@ export function UserAuthForm({
|
|||||||
authWindow.location.href = url
|
authWindow.location.href = url
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pb.collection("users")
|
pb.collection('users')
|
||||||
.authWithOAuth2(oAuthOpts)
|
.authWithOAuth2(oAuthOpts)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
$authenticated.set(pb.authStore.isValid)
|
$authenticated.set(pb.authStore.isValid)
|
||||||
@@ -276,14 +295,14 @@ export function UserAuthForm({
|
|||||||
disabled={isLoading || isOauthLoading}
|
disabled={isLoading || isOauthLoading}
|
||||||
>
|
>
|
||||||
{isOauthLoading ? (
|
{isOauthLoading ? (
|
||||||
<LoaderCircle className="me-2 h-4 w-4 animate-spin" />
|
<LoaderCircle className="mr-2 h-4 w-4 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<img
|
<img
|
||||||
className="me-2 h-4 w-4 dark:invert"
|
className="mr-2 h-4 w-4 dark:invert"
|
||||||
src={`/static/${provider.name}.svg`}
|
src={`/static/${provider.name}.svg`}
|
||||||
alt=""
|
alt=""
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
e.currentTarget.src = "/static/lock.svg"
|
e.currentTarget.src = '/static/lock.svg'
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -297,32 +316,26 @@ export function UserAuthForm({
|
|||||||
// only show GitHub button / dialog during onboarding
|
// only show GitHub button / dialog during onboarding
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<button type="button" className={cn(buttonVariants({ variant: "outline" }))}>
|
<button type="button" className={cn(buttonVariants({ variant: 'outline' }))}>
|
||||||
<img className="me-2 h-4 w-4 dark:invert" src="/static/github.svg" alt="" />
|
<img className="mr-2 h-4 w-4 dark:invert" src="/static/github.svg" alt="" />
|
||||||
<span className="translate-y-[1px]">GitHub</span>
|
<span className="translate-y-[1px]">GitHub</span>
|
||||||
</button>
|
</button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent style={{ maxWidth: 440, width: "90%" }}>
|
<DialogContent style={{ maxWidth: 440, width: '90%' }}>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>OAuth 2 / OIDC support</DialogTitle>
|
||||||
<Trans>OAuth 2 / OIDC support</Trans>
|
|
||||||
</DialogTitle>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="text-primary/70 text-[0.95em] contents">
|
<div className="text-primary/70 text-[0.95em] contents">
|
||||||
|
<p>Beszel supports OpenID Connect and many OAuth2 authentication providers.</p>
|
||||||
<p>
|
<p>
|
||||||
<Trans>Beszel supports OpenID Connect and many OAuth2 authentication providers.</Trans>
|
Please view the{' '}
|
||||||
</p>
|
<a
|
||||||
<p>
|
href="https://github.com/henrygd/beszel/blob/main/readme.md#oauth--oidc-integration"
|
||||||
<Trans>
|
className={cn(buttonVariants({ variant: 'link' }), 'p-0 h-auto')}
|
||||||
Please see{" "}
|
>
|
||||||
<a
|
GitHub README
|
||||||
href="https://github.com/henrygd/beszel/blob/main/readme.md#oauth--oidc-integration"
|
</a>{' '}
|
||||||
className={cn(buttonVariants({ variant: "link" }), "p-0 h-auto")}
|
for instructions.
|
||||||
>
|
|
||||||
the documentation
|
|
||||||
</a>{" "}
|
|
||||||
for instructions.
|
|
||||||
</Trans>
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
@@ -334,7 +347,7 @@ export function UserAuthForm({
|
|||||||
href="/forgot-password"
|
href="/forgot-password"
|
||||||
className="text-sm mx-auto hover:text-brand underline underline-offset-4 opacity-70 hover:opacity-100 transition-opacity"
|
className="text-sm mx-auto hover:text-brand underline underline-offset-4 opacity-70 hover:opacity-100 transition-opacity"
|
||||||
>
|
>
|
||||||
<Trans>Forgot password?</Trans>
|
Forgot password?
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,26 +1,25 @@
|
|||||||
import { LoaderCircle, MailIcon, SendHorizonalIcon } from "lucide-react"
|
import { LoaderCircle, MailIcon, SendHorizonalIcon } from 'lucide-react'
|
||||||
import { Input } from "../ui/input"
|
import { Input } from '../ui/input'
|
||||||
import { Label } from "../ui/label"
|
import { Label } from '../ui/label'
|
||||||
import { useCallback, useState } from "react"
|
import { useCallback, useState } from 'react'
|
||||||
import { toast } from "../ui/use-toast"
|
import { toast } from '../ui/use-toast'
|
||||||
import { buttonVariants } from "../ui/button"
|
import { buttonVariants } from '../ui/button'
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from '@/lib/utils'
|
||||||
import { pb } from "@/lib/stores"
|
import { pb } from '@/lib/stores'
|
||||||
import { Dialog, DialogHeader } from "../ui/dialog"
|
import { Dialog, DialogHeader } from '../ui/dialog'
|
||||||
import { DialogContent, DialogTrigger, DialogTitle } from "../ui/dialog"
|
import { DialogContent, DialogTrigger, DialogTitle } from '../ui/dialog'
|
||||||
import { t, Trans } from "@lingui/macro"
|
|
||||||
|
|
||||||
const showLoginFaliedToast = () => {
|
const showLoginFaliedToast = () => {
|
||||||
toast({
|
toast({
|
||||||
title: t`Login attempt failed`,
|
title: 'Login attempt failed',
|
||||||
description: t`Please check your credentials and try again`,
|
description: 'Please check your credentials and try again',
|
||||||
variant: "destructive",
|
variant: 'destructive',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ForgotPassword() {
|
export default function ForgotPassword() {
|
||||||
const [isLoading, setIsLoading] = useState<boolean>(false)
|
const [isLoading, setIsLoading] = useState<boolean>(false)
|
||||||
const [email, setEmail] = useState("")
|
const [email, setEmail] = useState('')
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
async (e: React.FormEvent<HTMLFormElement>) => {
|
async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
@@ -28,16 +27,16 @@ export default function ForgotPassword() {
|
|||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
try {
|
try {
|
||||||
// console.log(email)
|
// console.log(email)
|
||||||
await pb.collection("users").requestPasswordReset(email)
|
await pb.collection('users').requestPasswordReset(email)
|
||||||
toast({
|
toast({
|
||||||
title: t`Password reset request received`,
|
title: 'Password reset request received',
|
||||||
description: t`Check ${email} for a reset link.`,
|
description: `Check ${email} for a reset link.`,
|
||||||
})
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showLoginFaliedToast()
|
showLoginFaliedToast()
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
setEmail("")
|
setEmail('')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[email]
|
[email]
|
||||||
@@ -50,7 +49,7 @@ export default function ForgotPassword() {
|
|||||||
<div className="grid gap-1 relative">
|
<div className="grid gap-1 relative">
|
||||||
<MailIcon className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
<MailIcon className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||||
<Label className="sr-only" htmlFor="email">
|
<Label className="sr-only" htmlFor="email">
|
||||||
<Trans>Email</Trans>
|
Email
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
value={email}
|
value={email}
|
||||||
@@ -64,40 +63,37 @@ export default function ForgotPassword() {
|
|||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
autoCorrect="off"
|
autoCorrect="off"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className="ps-9"
|
className="pl-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button className={cn(buttonVariants())} disabled={isLoading}>
|
<button className={cn(buttonVariants())} disabled={isLoading}>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<LoaderCircle className="me-2 h-4 w-4 animate-spin" />
|
<LoaderCircle className="mr-2 h-4 w-4 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<SendHorizonalIcon className="me-2 h-4 w-4" />
|
<SendHorizonalIcon className="mr-2 h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
<Trans>Reset Password</Trans>
|
Reset password
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<button className="text-sm mx-auto hover:text-brand underline underline-offset-4 opacity-70 hover:opacity-100 transition-opacity">
|
<button className="text-sm mx-auto hover:text-brand underline underline-offset-4 opacity-70 hover:opacity-100 transition-opacity">
|
||||||
<Trans>Command line instructions</Trans>
|
Command line instructions
|
||||||
</button>
|
</button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="max-w-[33em]">
|
<DialogContent className="max-w-[33em]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>Command line instructions</DialogTitle>
|
||||||
<Trans>Command line instructions</Trans>
|
|
||||||
</DialogTitle>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<p className="text-primary/70 text-[0.95em] leading-relaxed">
|
<p className="text-primary/70 text-[0.95em] leading-relaxed">
|
||||||
<Trans>
|
If you've lost the password to your admin account, you may reset it using the following
|
||||||
If you've lost the password to your admin account, you may reset it using the following command.
|
command.
|
||||||
</Trans>
|
|
||||||
</p>
|
</p>
|
||||||
<p className="text-primary/70 text-[0.95em] leading-relaxed">
|
<p className="text-primary/70 text-[0.95em] leading-relaxed">
|
||||||
<Trans>Then log into the backend and reset your user account password in the users table.</Trans>
|
Then log into the backend and reset your user account password in the users table.
|
||||||
</p>
|
</p>
|
||||||
<code className="bg-muted rounded-sm py-0.5 px-2.5 me-auto text-sm">
|
<code className="bg-muted rounded-sm py-0.5 px-2.5 mr-auto text-sm">
|
||||||
beszel admin update youremail@example.com newpassword
|
beszel admin update youremail@example.com newpassword
|
||||||
</code>
|
</code>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import { UserAuthForm } from "@/components/login/auth-form"
|
import { UserAuthForm } from '@/components/login/auth-form'
|
||||||
import { Logo } from "../logo"
|
import { Logo } from '../logo'
|
||||||
import { useEffect, useMemo, useState } from "react"
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { pb } from "@/lib/stores"
|
import { pb } from '@/lib/stores'
|
||||||
import { useStore } from "@nanostores/react"
|
import { useStore } from '@nanostores/react'
|
||||||
import ForgotPassword from "./forgot-pass-form"
|
import ForgotPassword from './forgot-pass-form'
|
||||||
import { $router } from "../router"
|
import { $router } from '../router'
|
||||||
import { AuthMethodsList } from "pocketbase"
|
import { AuthMethodsList } from 'pocketbase'
|
||||||
import { t } from "@lingui/macro"
|
|
||||||
|
|
||||||
export default function () {
|
export default function () {
|
||||||
const page = useStore($router)
|
const page = useStore($router)
|
||||||
@@ -14,15 +13,15 @@ export default function () {
|
|||||||
const [authMethods, setAuthMethods] = useState<AuthMethodsList>()
|
const [authMethods, setAuthMethods] = useState<AuthMethodsList>()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = t`Login` + " / Beszel"
|
document.title = 'Login / Beszel'
|
||||||
|
|
||||||
pb.send("/api/beszel/first-run", {}).then(({ firstRun }) => {
|
pb.send('/api/beszel/first-run', {}).then(({ firstRun }) => {
|
||||||
setFirstRun(firstRun)
|
setFirstRun(firstRun)
|
||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
pb.collection("users")
|
pb.collection('users')
|
||||||
.listAuthMethods()
|
.listAuthMethods()
|
||||||
.then((methods) => {
|
.then((methods) => {
|
||||||
setAuthMethods(methods)
|
setAuthMethods(methods)
|
||||||
@@ -31,11 +30,11 @@ export default function () {
|
|||||||
|
|
||||||
const subtitle = useMemo(() => {
|
const subtitle = useMemo(() => {
|
||||||
if (isFirstRun) {
|
if (isFirstRun) {
|
||||||
return t`Please create an admin account`
|
return 'Please create an admin account'
|
||||||
} else if (page?.path === "/forgot-password") {
|
} else if (page?.path === '/forgot-password') {
|
||||||
return t`Enter email address to reset password`
|
return 'Enter email address to reset password'
|
||||||
} else {
|
} else {
|
||||||
return t`Please sign in to your account`
|
return 'Please sign in to your account'
|
||||||
}
|
}
|
||||||
}, [isFirstRun, page])
|
}, [isFirstRun, page])
|
||||||
|
|
||||||
@@ -44,8 +43,8 @@ export default function () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-svh grid items-center py-12">
|
<div className="min-h-screen grid items-center py-12">
|
||||||
<div className="grid gap-5 w-full px-4 mx-auto" style={{ maxWidth: "22em" }}>
|
<div className="grid gap-5 w-full px-4 mx-auto" style={{ maxWidth: '22em' }}>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h1 className="mb-3">
|
<h1 className="mb-3">
|
||||||
<Logo className="h-7 fill-foreground mx-auto" />
|
<Logo className="h-7 fill-foreground mx-auto" />
|
||||||
@@ -53,7 +52,7 @@ export default function () {
|
|||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-muted-foreground">{subtitle}</p>
|
<p className="text-sm text-muted-foreground">{subtitle}</p>
|
||||||
</div>
|
</div>
|
||||||
{page?.path === "/forgot-password" ? (
|
{page?.path === '/forgot-password' ? (
|
||||||
<ForgotPassword />
|
<ForgotPassword />
|
||||||
) : (
|
) : (
|
||||||
<UserAuthForm isFirstRun={isFirstRun} authMethods={authMethods} />
|
<UserAuthForm isFirstRun={isFirstRun} authMethods={authMethods} />
|
||||||
|
|||||||
@@ -2,16 +2,7 @@ export function Logo({ className }: { className?: string }) {
|
|||||||
return (
|
return (
|
||||||
// Righteous
|
// Righteous
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 285 75" className={className}>
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 285 75" className={className}>
|
||||||
{/* <defs>
|
<path d="M146.4 73.1h-30.5V59.8h30.5a3.2 3.2 0 0 0 2.3-1 3.2 3.2 0 0 0 1-2.3q0-.8-.3-1.3a1.5 1.5 0 0 0-.7-.6 4.7 4.7 0 0 0-1-.3l-1.3-.1h-13.9q-3.4 0-6.5-1.3-3-1.3-5.2-3.6a16.9 16.9 0 0 1-3.6-5.3 16.3 16.3 0 0 1-1.3-6.5 16.4 16.4 0 0 1 1.3-6.4q1.3-3.1 3.6-5.4 2.2-2.2 5.2-3.5a16.3 16.3 0 0 1 6.5-1.3h27v13.3h-27a3.2 3.2 0 0 0-2.3 1 3.2 3.2 0 0 0-1 2.3 3.3 3.3 0 0 0 1 2.4 3.3 3.3 0 0 0 1.2.8 3.2 3.2 0 0 0 1.1.2h13.9a18.1 18.1 0 0 1 6 1 17.3 17.3 0 0 1 .4.2q3 1.1 5.3 3.2a15.1 15.1 0 0 1 3.6 4.9 14.7 14.7 0 0 1 1.3 5.4 17.2 17.2 0 0 1 0 .9 16 16 0 0 1-1 5.8 15.4 15.4 0 0 1-.3.7 17.3 17.3 0 0 1-3.6 5.2 16.4 16.4 0 0 1-5.3 3.6 16.2 16.2 0 0 1-6.4 1.3Zm64.5-13.3v13.3h-43.6l22-39h-22V21h43.6l-22 39h22ZM35 73.1H0v-70h35q4.4 0 8.2 1.6a21.4 21.4 0 0 1 6.6 4.6q2.9 2.8 4.5 6.6 1.7 3.8 1.7 8.2a15.4 15.4 0 0 1-.3 3.2 17.6 17.6 0 0 1-.2.8 19.4 19.4 0 0 1-1.5 4 17 17 0 0 1-2.4 3.4 13.5 13.5 0 0 1-2.6 2.3 12.5 12.5 0 0 1-.4.3q1.7 1 3 2.5 1.4 1.6 2.4 3.5a18.3 18.3 0 0 1 1.5 4A17.4 17.4 0 0 1 56 51a15.3 15.3 0 0 1 0 1.1q0 4.3-1.7 8.2a21.4 21.4 0 0 1-4.5 6.6q-2.8 2.9-6.6 4.5-3.8 1.7-8.2 1.7Zm76-43L86 60.4l1.5.3a16.7 16.7 0 0 0 1.6 0q2 0 3.8-.4 1.8-.6 3.4-1.6 1.6-1 2.8-2.4a12.8 12.8 0 0 0 2-3.2l9.8 9.8q-1.9 2.6-4.3 4.7a27 27 0 0 1-5.2 3.6 26.1 26.1 0 0 1-6 2.2 26.8 26.8 0 0 1-6.3.8 26.4 26.4 0 0 1-10.4-2 26.2 26.2 0 0 1-8.5-5.8 26.7 26.7 0 0 1-5.5-8.3 30.4 30.4 0 0 1-.2-.4q-2.1-5-2.1-11.1a31.9 31.9 0 0 1 .7-7 27 27 0 0 1 1.4-4.3 27 27 0 0 1 3.8-6.6 24.5 24.5 0 0 1 2-2.2 26 26 0 0 1 8.4-5.6 27 27 0 0 1 10.4-2 26.3 26.3 0 0 1 6.4.8 26.9 26.9 0 0 1 6 2.2q2.7 1.5 5.2 3.6 2.4 2.1 4.3 4.8Zm152.3 0-25 30.2 1.5.3a16.7 16.7 0 0 0 1.6 0q2 0 3.8-.4 1.8-.6 3.4-1.6 1.5-1 2.8-2.4a12.8 12.8 0 0 0 2-3.2l9.8 9.8q-1.9 2.6-4.3 4.7a27 27 0 0 1-5.2 3.6 26.1 26.1 0 0 1-6 2.2 26.8 26.8 0 0 1-6.3.8 26.4 26.4 0 0 1-10.4-2 26.2 26.2 0 0 1-8.5-5.8A26.7 26.7 0 0 1 217 58a30.4 30.4 0 0 1-.2-.4q-2.1-5-2.1-11.1a31.9 31.9 0 0 1 .7-7 27 27 0 0 1 1.4-4.3 27 27 0 0 1 3.8-6.6 24.5 24.5 0 0 1 2-2.2 26 26 0 0 1 8.4-5.6 27 27 0 0 1 10.4-2 26.3 26.3 0 0 1 6.4.8 26.9 26.9 0 0 1 6 2.2q2.7 1.5 5.2 3.6 2.4 2.1 4.3 4.8ZM283.4 0v73.1H270V0h13.4ZM14 17v14.1h21a7 7 0 0 0 2.3-.4 6.6 6.6 0 0 0 .4-.1Q39 30 40 29a6.9 6.9 0 0 0 1.5-2.3q.5-1.3.5-2.7a7 7 0 0 0-.4-2.3 6.6 6.6 0 0 0-.1-.5q-.6-1.2-1.5-2.2a7 7 0 0 0-2.3-1.5 6.9 6.9 0 0 0-2.5-.5 7.9 7.9 0 0 0-.2 0H14Zm0 28.1v14h21a7 7 0 0 0 2.3-.4 6.6 6.6 0 0 0 .4-.2Q39 58 40 57.1a7 7 0 0 0 1.5-2.3 6.9 6.9 0 0 0 .5-2.5 7.9 7.9 0 0 0 0-.2 7 7 0 0 0-.4-2.3 6.6 6.6 0 0 0-.1-.4Q40.9 48 40 47a7 7 0 0 0-2.3-1.4 6.9 6.9 0 0 0-2.5-.6 7.9 7.9 0 0 0-.2 0H14Zm63.3 8.3 15.5-20.6a8 8 0 0 0-1.4-.4 7 7 0 0 0-.4 0 17.2 17.2 0 0 0-1.6-.1 19.2 19.2 0 0 0-.3 0 13.3 13.3 0 0 0-5.1 1q-2.5 1-4.2 2.8a13.1 13.1 0 0 0-2.5 3.6 15.5 15.5 0 0 0-.3.9 14.7 14.7 0 0 0-1 3.5 18.7 18.7 0 0 0 0 2.4 17.6 17.6 0 0 0 0 .7v.8a29.4 29.4 0 0 0 0 .1 19.2 19.2 0 0 0 .2 2 20.2 20.2 0 0 0 .4 1.6 18.6 18.6 0 0 0 0 .2 7.5 7.5 0 0 0 .4.9 6 6 0 0 0 .3.6Zm152.3 0L245 32.8a8 8 0 0 0-1.4-.4 7 7 0 0 0-.4 0 17.2 17.2 0 0 0-1.6-.1 19.2 19.2 0 0 0-.3 0 13.3 13.3 0 0 0-5.1 1q-2.5 1-4.2 2.8a13.1 13.1 0 0 0-2.5 3.6 15.5 15.5 0 0 0-.4.9 14.7 14.7 0 0 0-.8 3.5 18.7 18.7 0 0 0-.2 2.4 17.6 17.6 0 0 0 0 .7v.8a29.4 29.4 0 0 0 .1.1 19.2 19.2 0 0 0 .2 2 20.2 20.2 0 0 0 .4 1.6 18.6 18.6 0 0 0 0 .2 7.5 7.5 0 0 0 .4.9 6 6 0 0 0 .3.6Z" />
|
||||||
<linearGradient id="gradient" x1="0%" y1="20%" x2="100%" y2="120%">
|
|
||||||
<stop offset="0%" style={{ stopColor: "#747bff" }} />
|
|
||||||
<stop offset="100%" style={{ stopColor: "#24eb5c" }} />
|
|
||||||
</linearGradient>
|
|
||||||
</defs> */}
|
|
||||||
<path
|
|
||||||
// fill="url(#gradient)"
|
|
||||||
d="M146.4 73.1h-30.5V59.8h30.5a3.2 3.2 0 0 0 2.3-1 3.2 3.2 0 0 0 1-2.3q0-.8-.3-1.3a1.5 1.5 0 0 0-.7-.6 4.7 4.7 0 0 0-1-.3l-1.3-.1h-13.9q-3.4 0-6.5-1.3-3-1.3-5.2-3.6a16.9 16.9 0 0 1-3.6-5.3 16.3 16.3 0 0 1-1.3-6.5 16.4 16.4 0 0 1 1.3-6.4q1.3-3.1 3.6-5.4 2.2-2.2 5.2-3.5a16.3 16.3 0 0 1 6.5-1.3h27v13.3h-27a3.2 3.2 0 0 0-2.3 1 3.2 3.2 0 0 0-1 2.3 3.3 3.3 0 0 0 1 2.4 3.3 3.3 0 0 0 1.2.8 3.2 3.2 0 0 0 1.1.2h13.9a18.1 18.1 0 0 1 6 1 17.3 17.3 0 0 1 .4.2q3 1.1 5.3 3.2a15.1 15.1 0 0 1 3.6 4.9 14.7 14.7 0 0 1 1.3 5.4 17.2 17.2 0 0 1 0 .9 16 16 0 0 1-1 5.8 15.4 15.4 0 0 1-.3.7 17.3 17.3 0 0 1-3.6 5.2 16.4 16.4 0 0 1-5.3 3.6 16.2 16.2 0 0 1-6.4 1.3Zm64.5-13.3v13.3h-43.6l22-39h-22V21h43.6l-22 39h22ZM35 73.1H0v-70h35q4.4 0 8.2 1.6a21.4 21.4 0 0 1 6.6 4.6q2.9 2.8 4.5 6.6 1.7 3.8 1.7 8.2a15.4 15.4 0 0 1-.3 3.2 17.6 17.6 0 0 1-.2.8 19.4 19.4 0 0 1-1.5 4 17 17 0 0 1-2.4 3.4 13.5 13.5 0 0 1-2.6 2.3 12.5 12.5 0 0 1-.4.3q1.7 1 3 2.5 1.4 1.6 2.4 3.5a18.3 18.3 0 0 1 1.5 4A17.4 17.4 0 0 1 56 51a15.3 15.3 0 0 1 0 1.1q0 4.3-1.7 8.2a21.4 21.4 0 0 1-4.5 6.6q-2.8 2.9-6.6 4.5-3.8 1.7-8.2 1.7Zm76-43L86 60.4l1.5.3a16.7 16.7 0 0 0 1.6 0q2 0 3.8-.4 1.8-.6 3.4-1.6 1.6-1 2.8-2.4a12.8 12.8 0 0 0 2-3.2l9.8 9.8q-1.9 2.6-4.3 4.7a27 27 0 0 1-5.2 3.6 26.1 26.1 0 0 1-6 2.2 26.8 26.8 0 0 1-6.3.8 26.4 26.4 0 0 1-10.4-2 26.2 26.2 0 0 1-8.5-5.8 26.7 26.7 0 0 1-5.5-8.3 30.4 30.4 0 0 1-.2-.4q-2.1-5-2.1-11.1a31.9 31.9 0 0 1 .7-7 27 27 0 0 1 1.4-4.3 27 27 0 0 1 3.8-6.6 24.5 24.5 0 0 1 2-2.2 26 26 0 0 1 8.4-5.6 27 27 0 0 1 10.4-2 26.3 26.3 0 0 1 6.4.8 26.9 26.9 0 0 1 6 2.2q2.7 1.5 5.2 3.6 2.4 2.1 4.3 4.8Zm152.3 0-25 30.2 1.5.3a16.7 16.7 0 0 0 1.6 0q2 0 3.8-.4 1.8-.6 3.4-1.6 1.5-1 2.8-2.4a12.8 12.8 0 0 0 2-3.2l9.8 9.8q-1.9 2.6-4.3 4.7a27 27 0 0 1-5.2 3.6 26.1 26.1 0 0 1-6 2.2 26.8 26.8 0 0 1-6.3.8 26.4 26.4 0 0 1-10.4-2 26.2 26.2 0 0 1-8.5-5.8A26.7 26.7 0 0 1 217 58a30.4 30.4 0 0 1-.2-.4q-2.1-5-2.1-11.1a31.9 31.9 0 0 1 .7-7 27 27 0 0 1 1.4-4.3 27 27 0 0 1 3.8-6.6 24.5 24.5 0 0 1 2-2.2 26 26 0 0 1 8.4-5.6 27 27 0 0 1 10.4-2 26.3 26.3 0 0 1 6.4.8 26.9 26.9 0 0 1 6 2.2q2.7 1.5 5.2 3.6 2.4 2.1 4.3 4.8ZM283.4 0v73.1H270V0h13.4ZM14 17v14.1h21a7 7 0 0 0 2.3-.4 6.6 6.6 0 0 0 .4-.1Q39 30 40 29a6.9 6.9 0 0 0 1.5-2.3q.5-1.3.5-2.7a7 7 0 0 0-.4-2.3 6.6 6.6 0 0 0-.1-.5q-.6-1.2-1.5-2.2a7 7 0 0 0-2.3-1.5 6.9 6.9 0 0 0-2.5-.5 7.9 7.9 0 0 0-.2 0H14Zm0 28.1v14h21a7 7 0 0 0 2.3-.4 6.6 6.6 0 0 0 .4-.2Q39 58 40 57.1a7 7 0 0 0 1.5-2.3 6.9 6.9 0 0 0 .5-2.5 7.9 7.9 0 0 0 0-.2 7 7 0 0 0-.4-2.3 6.6 6.6 0 0 0-.1-.4Q40.9 48 40 47a7 7 0 0 0-2.3-1.4 6.9 6.9 0 0 0-2.5-.6 7.9 7.9 0 0 0-.2 0H14Zm63.3 8.3 15.5-20.6a8 8 0 0 0-1.4-.4 7 7 0 0 0-.4 0 17.2 17.2 0 0 0-1.6-.1 19.2 19.2 0 0 0-.3 0 13.3 13.3 0 0 0-5.1 1q-2.5 1-4.2 2.8a13.1 13.1 0 0 0-2.5 3.6 15.5 15.5 0 0 0-.3.9 14.7 14.7 0 0 0-1 3.5 18.7 18.7 0 0 0 0 2.4 17.6 17.6 0 0 0 0 .7v.8a29.4 29.4 0 0 0 0 .1 19.2 19.2 0 0 0 .2 2 20.2 20.2 0 0 0 .4 1.6 18.6 18.6 0 0 0 0 .2 7.5 7.5 0 0 0 .4.9 6 6 0 0 0 .3.6Zm152.3 0L245 32.8a8 8 0 0 0-1.4-.4 7 7 0 0 0-.4 0 17.2 17.2 0 0 0-1.6-.1 19.2 19.2 0 0 0-.3 0 13.3 13.3 0 0 0-5.1 1q-2.5 1-4.2 2.8a13.1 13.1 0 0 0-2.5 3.6 15.5 15.5 0 0 0-.4.9 14.7 14.7 0 0 0-.8 3.5 18.7 18.7 0 0 0-.2 2.4 17.6 17.6 0 0 0 0 .7v.8a29.4 29.4 0 0 0 .1.1 19.2 19.2 0 0 0 .2 2 20.2 20.2 0 0 0 .4 1.6 18.6 18.6 0 0 0 0 .2 7.5 7.5 0 0 0 .4.9 6 6 0 0 0 .3.6Z"
|
|
||||||
/>
|
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,54 +1,39 @@
|
|||||||
import { LaptopIcon, MoonStarIcon, SunIcon } from "lucide-react"
|
import { LaptopIcon, MoonStarIcon, SunIcon } from 'lucide-react'
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from '@/components/ui/button'
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
import {
|
||||||
import { useTheme } from "@/components/theme-provider"
|
DropdownMenu,
|
||||||
import { cn } from "@/lib/utils"
|
DropdownMenuContent,
|
||||||
import { t, Trans } from "@lingui/macro"
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu'
|
||||||
|
import { useTheme } from '@/components/theme-provider'
|
||||||
|
|
||||||
export function ModeToggle() {
|
export function ModeToggle() {
|
||||||
const { theme, setTheme } = useTheme()
|
const { setTheme } = useTheme()
|
||||||
|
|
||||||
const options = [
|
|
||||||
{
|
|
||||||
theme: "light",
|
|
||||||
Icon: SunIcon,
|
|
||||||
label: <Trans comment="Light theme">Light</Trans>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
theme: "dark",
|
|
||||||
Icon: MoonStarIcon,
|
|
||||||
label: <Trans comment="Dark theme">Dark</Trans>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
theme: "system",
|
|
||||||
Icon: LaptopIcon,
|
|
||||||
label: <Trans comment="System theme">System</Trans>,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant={"ghost"} size="icon" aria-label={t`Toggle theme`}>
|
<Button variant={'ghost'} size="icon">
|
||||||
<SunIcon className="h-[1.2rem] w-[1.2rem] dark:opacity-0" />
|
<SunIcon className="h-[1.2rem] w-[1.2rem] dark:opacity-0" />
|
||||||
<MoonStarIcon className="absolute h-[1.2rem] w-[1.2rem] opacity-0 dark:opacity-100" />
|
<MoonStarIcon className="absolute h-[1.2rem] w-[1.2rem] opacity-0 dark:opacity-100" />
|
||||||
|
<span className="sr-only">Toggle theme</span>
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent>
|
<DropdownMenuContent>
|
||||||
{options.map((opt) => {
|
<DropdownMenuItem onClick={() => setTheme('light')}>
|
||||||
const selected = opt.theme === theme
|
<SunIcon className="mr-2.5 h-4 w-4" />
|
||||||
return (
|
Light
|
||||||
<DropdownMenuItem
|
</DropdownMenuItem>
|
||||||
key={opt.theme}
|
<DropdownMenuItem onClick={() => setTheme('dark')}>
|
||||||
className={cn("px-2.5", selected ? "font-semibold" : "")}
|
<MoonStarIcon className="mr-2.5 h-4 w-4" />
|
||||||
onClick={() => setTheme(opt.theme as "dark" | "light" | "system")}
|
Dark
|
||||||
>
|
</DropdownMenuItem>
|
||||||
<opt.Icon className={cn("me-2 h-4 w-4 opacity-80", selected && "opacity-100")} />
|
<DropdownMenuItem onClick={() => setTheme('system')}>
|
||||||
{opt.label}
|
<LaptopIcon className="mr-2.5 h-4 w-4" />
|
||||||
</DropdownMenuItem>
|
System
|
||||||
)
|
</DropdownMenuItem>
|
||||||
})}
|
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,154 +0,0 @@
|
|||||||
import { useState, lazy, Suspense } from "react"
|
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
|
||||||
import {
|
|
||||||
DatabaseBackupIcon,
|
|
||||||
LockKeyholeIcon,
|
|
||||||
LogOutIcon,
|
|
||||||
LogsIcon,
|
|
||||||
SearchIcon,
|
|
||||||
ServerIcon,
|
|
||||||
SettingsIcon,
|
|
||||||
UserIcon,
|
|
||||||
UsersIcon,
|
|
||||||
} from "lucide-react"
|
|
||||||
import { Link } from "./router"
|
|
||||||
import { LangToggle } from "./lang-toggle"
|
|
||||||
import { ModeToggle } from "./mode-toggle"
|
|
||||||
import { Logo } from "./logo"
|
|
||||||
import { pb } from "@/lib/stores"
|
|
||||||
import { cn, isReadOnlyUser, isAdmin } from "@/lib/utils"
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuGroup,
|
|
||||||
DropdownMenuItem,
|
|
||||||
} from "@/components/ui/dropdown-menu"
|
|
||||||
import { AddSystemButton } from "./add-system"
|
|
||||||
import { Trans } from "@lingui/macro"
|
|
||||||
|
|
||||||
const CommandPalette = lazy(() => import("./command-palette"))
|
|
||||||
|
|
||||||
const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0
|
|
||||||
|
|
||||||
export default function Navbar() {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center h-14 md:h-16 bg-card px-4 pe-3 sm:px-6 border bt-0 rounded-md my-4">
|
|
||||||
<Link href="/" aria-label="Home" className="p-2 ps-0 me-3">
|
|
||||||
<Logo className="h-[1.1rem] md:h-5 fill-foreground" />
|
|
||||||
</Link>
|
|
||||||
<SearchButton />
|
|
||||||
|
|
||||||
<div className="flex items-center ms-auto">
|
|
||||||
<LangToggle />
|
|
||||||
<ModeToggle />
|
|
||||||
<Link
|
|
||||||
href="/settings/general"
|
|
||||||
aria-label="Settings"
|
|
||||||
className={cn("", buttonVariants({ variant: "ghost", size: "icon" }))}
|
|
||||||
>
|
|
||||||
<SettingsIcon className="h-[1.2rem] w-[1.2rem]" />
|
|
||||||
</Link>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<button aria-label="User Actions" className={cn("", buttonVariants({ variant: "ghost", size: "icon" }))}>
|
|
||||||
<UserIcon className="h-[1.2rem] w-[1.2rem]" />
|
|
||||||
</button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align={isReadOnlyUser() ? "end" : "center"} className="min-w-44">
|
|
||||||
<DropdownMenuLabel>{pb.authStore.model?.email}</DropdownMenuLabel>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
{isAdmin() && (
|
|
||||||
<>
|
|
||||||
<DropdownMenuItem asChild>
|
|
||||||
<a href="/_/" target="_blank">
|
|
||||||
<UsersIcon className="me-2.5 h-4 w-4" />
|
|
||||||
<span>
|
|
||||||
<Trans>Users</Trans>
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem asChild>
|
|
||||||
<a href="/_/#/collections?collectionId=2hz5ncl8tizk5nx" target="_blank">
|
|
||||||
<ServerIcon className="me-2.5 h-4 w-4" />
|
|
||||||
<span>
|
|
||||||
<Trans>Systems</Trans>
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem asChild>
|
|
||||||
<a href="/_/#/logs" target="_blank">
|
|
||||||
<LogsIcon className="me-2.5 h-4 w-4" />
|
|
||||||
<span>
|
|
||||||
<Trans>Logs</Trans>
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem asChild>
|
|
||||||
<a href="/_/#/settings/backups" target="_blank">
|
|
||||||
<DatabaseBackupIcon className="me-2.5 h-4 w-4" />
|
|
||||||
<span>
|
|
||||||
<Trans>Backups</Trans>
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem asChild>
|
|
||||||
<a href="/_/#/settings/auth-providers" target="_blank">
|
|
||||||
<LockKeyholeIcon className="me-2.5 h-4 w-4" />
|
|
||||||
<span>
|
|
||||||
<Trans>Auth Providers</Trans>
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
<DropdownMenuItem onSelect={() => pb.authStore.clear()}>
|
|
||||||
<LogOutIcon className="me-2.5 h-4 w-4" />
|
|
||||||
<span>
|
|
||||||
<Trans>Log Out</Trans>
|
|
||||||
</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
<AddSystemButton className="ms-2" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SearchButton() {
|
|
||||||
const [open, setOpen] = useState(false)
|
|
||||||
|
|
||||||
const Kbd = ({ children }: { children: React.ReactNode }) => (
|
|
||||||
<kbd className="pointer-events-none inline-flex h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground opacity-100">
|
|
||||||
{children}
|
|
||||||
</kbd>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="hidden md:block text-sm text-muted-foreground px-4"
|
|
||||||
onClick={() => setOpen(true)}
|
|
||||||
>
|
|
||||||
<span className="flex items-center">
|
|
||||||
<SearchIcon className="me-1.5 h-4 w-4" />
|
|
||||||
<Trans>Search</Trans>
|
|
||||||
<span className="flex items-center ms-3.5">
|
|
||||||
<Kbd>{isMac ? "⌘" : "Ctrl"}</Kbd>
|
|
||||||
<Kbd>K</Kbd>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
<Suspense>
|
|
||||||
<CommandPalette open={open} setOpen={setOpen} />
|
|
||||||
</Suspense>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
import { createRouter } from "@nanostores/router"
|
import { createRouter } from '@nanostores/router'
|
||||||
|
|
||||||
export const $router = createRouter(
|
export const $router = createRouter(
|
||||||
{
|
{
|
||||||
home: "/",
|
home: '/',
|
||||||
server: "/system/:name",
|
server: '/system/:name',
|
||||||
settings: "/settings/:name?",
|
settings: '/settings/:name?',
|
||||||
forgot_password: "/forgot-password",
|
|
||||||
},
|
},
|
||||||
{ links: false }
|
{ links: false }
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,107 +1,68 @@
|
|||||||
import { Suspense, lazy, useEffect, useMemo } from "react"
|
import { Suspense, lazy, useEffect, useState } from 'react'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card'
|
||||||
import { $alerts, $hubVersion, $systems, pb } from "@/lib/stores"
|
import { $alerts, $hubVersion, $systems, pb } from '@/lib/stores'
|
||||||
import { useStore } from "@nanostores/react"
|
import { useStore } from '@nanostores/react'
|
||||||
import { GithubIcon } from "lucide-react"
|
import { GithubIcon } from 'lucide-react'
|
||||||
import { Separator } from "../ui/separator"
|
import { Separator } from '../ui/separator'
|
||||||
import { alertInfo, updateRecordList, updateSystemList } from "@/lib/utils"
|
import { updateRecordList, updateSystemList } from '@/lib/utils'
|
||||||
import { AlertRecord, SystemRecord } from "@/types"
|
import { AlertRecord, SystemRecord } from '@/types'
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
import { Input } from '../ui/input'
|
||||||
import { Link } from "../router"
|
|
||||||
import { Plural, t, Trans } from "@lingui/macro"
|
|
||||||
|
|
||||||
const SystemsTable = lazy(() => import("../systems-table/systems-table"))
|
const SystemsTable = lazy(() => import('../systems-table/systems-table'))
|
||||||
|
|
||||||
export default function Home() {
|
export default function () {
|
||||||
const hubVersion = useStore($hubVersion)
|
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(() => {
|
useEffect(() => {
|
||||||
document.title = t`Dashboard` + " / Beszel"
|
document.title = 'Dashboard / Beszel'
|
||||||
|
|
||||||
// make sure we have the latest list of systems
|
// make sure we have the latest list of systems
|
||||||
updateSystemList()
|
updateSystemList()
|
||||||
|
|
||||||
// subscribe to real time updates for systems / alerts
|
// subscribe to real time updates for systems / alerts
|
||||||
pb.collection<SystemRecord>("systems").subscribe("*", (e) => {
|
pb.collection<SystemRecord>('systems').subscribe('*', (e) => {
|
||||||
updateRecordList(e, $systems)
|
updateRecordList(e, $systems)
|
||||||
})
|
})
|
||||||
// todo: add toast if new triggered alert comes in
|
pb.collection<AlertRecord>('alerts').subscribe('*', (e) => {
|
||||||
pb.collection<AlertRecord>("alerts").subscribe("*", (e) => {
|
|
||||||
updateRecordList(e, $alerts)
|
updateRecordList(e, $alerts)
|
||||||
})
|
})
|
||||||
return () => {
|
return () => {
|
||||||
pb.collection("systems").unsubscribe("*")
|
pb.collection('systems').unsubscribe('*')
|
||||||
// pb.collection('alerts').unsubscribe('*')
|
pb.collection('alerts').unsubscribe('*')
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* show active alerts */}
|
<Card>
|
||||||
{activeAlerts.length > 0 && (
|
<CardHeader className="pb-5 px-2 sm:px-6 max-sm:pt-5 max-sm:pb-1">
|
||||||
<Card className="mb-4">
|
<div className="grid md:flex gap-3 w-full items-end">
|
||||||
<CardHeader className="pb-4 px-2 sm:px-6 max-sm:pt-5 max-sm:pb-1">
|
|
||||||
<div className="px-2 sm:px-1">
|
<div className="px-2 sm:px-1">
|
||||||
<CardTitle>
|
<CardTitle className="mb-2.5">All Systems</CardTitle>
|
||||||
<Trans>Active Alerts</Trans>
|
<CardDescription>
|
||||||
</CardTitle>
|
Updated in real time. Press{' '}
|
||||||
|
<kbd className="pointer-events-none inline-flex h-5 select-none items-center gap-0.5 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground opacity-100">
|
||||||
|
<span className="text-xs">⌘</span>K
|
||||||
|
</kbd>{' '}
|
||||||
|
to open the command palette.
|
||||||
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
<Input
|
||||||
<CardContent className="max-sm:p-2">
|
placeholder="Filter..."
|
||||||
{activeAlerts.length > 0 && (
|
onChange={(e) => setFilter(e.target.value)}
|
||||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-3">
|
className="w-full md:w-56 lg:w-80 ml-auto px-4"
|
||||||
{activeAlerts.map((alert) => {
|
/>
|
||||||
const info = alertInfo[alert.name as keyof typeof alertInfo]
|
</div>
|
||||||
return (
|
</CardHeader>
|
||||||
<Alert
|
<CardContent className="max-sm:p-2">
|
||||||
key={alert.id}
|
<Suspense>
|
||||||
className="hover:-translate-y-[1px] duration-200 bg-transparent border-foreground/10 hover:shadow-md shadow-black"
|
<SystemsTable filter={filter} />
|
||||||
>
|
</Suspense>
|
||||||
<info.icon className="h-4 w-4" />
|
</CardContent>
|
||||||
<AlertTitle>
|
</Card>
|
||||||
{alert.sysname} {info.name().toLowerCase().replace("cpu", "CPU")}
|
|
||||||
</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
<Trans>
|
|
||||||
Exceeds {alert.value}
|
|
||||||
{info.unit} in last <Plural value={alert.min} one="# minute" other="# minutes" />
|
|
||||||
</Trans>
|
|
||||||
</AlertDescription>
|
|
||||||
<Link
|
|
||||||
href={`/system/${encodeURIComponent(alert.sysname!)}`}
|
|
||||||
className="absolute inset-0 w-full h-full"
|
|
||||||
aria-label="View system"
|
|
||||||
></Link>
|
|
||||||
</Alert>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
<Suspense>
|
|
||||||
<SystemsTable />
|
|
||||||
</Suspense>
|
|
||||||
|
|
||||||
{hubVersion && (
|
{hubVersion && (
|
||||||
<div className="flex gap-1.5 justify-end items-center pe-3 sm:pe-6 mt-3.5 text-xs opacity-80">
|
<div className="flex gap-1.5 justify-end items-center pr-3 sm:pr-6 mt-3.5 text-xs opacity-80">
|
||||||
<a
|
<a
|
||||||
href="https://github.com/henrygd/beszel"
|
href="https://github.com/henrygd/beszel"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
|||||||
@@ -1,97 +0,0 @@
|
|||||||
import { isAdmin } from "@/lib/utils"
|
|
||||||
import { Separator } from "@/components/ui/separator"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { redirectPage } from "@nanostores/router"
|
|
||||||
import { $router } from "@/components/router"
|
|
||||||
import { AlertCircleIcon, FileSlidersIcon, LoaderCircleIcon } from "lucide-react"
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
|
||||||
import { pb } from "@/lib/stores"
|
|
||||||
import { useState } from "react"
|
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
|
||||||
import { toast } from "@/components/ui/use-toast"
|
|
||||||
import clsx from "clsx"
|
|
||||||
import { Trans, t } from "@lingui/macro"
|
|
||||||
|
|
||||||
export default function ConfigYaml() {
|
|
||||||
const [configContent, setConfigContent] = useState<string>("")
|
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
|
||||||
|
|
||||||
const ButtonIcon = isLoading ? LoaderCircleIcon : FileSlidersIcon
|
|
||||||
|
|
||||||
async function fetchConfig() {
|
|
||||||
try {
|
|
||||||
setIsLoading(true)
|
|
||||||
const { config } = await pb.send<{ config: string }>("/api/beszel/config-yaml", {})
|
|
||||||
setConfigContent(config)
|
|
||||||
} catch (error: any) {
|
|
||||||
toast({
|
|
||||||
title: t`Error`,
|
|
||||||
description: error.message,
|
|
||||||
variant: "destructive",
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isAdmin()) {
|
|
||||||
redirectPage($router, "settings", { name: "general" })
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-xl font-medium mb-2">
|
|
||||||
<Trans>YAML Configuration</Trans>
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
|
||||||
<Trans>Export your current systems configuration.</Trans>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Separator className="my-4" />
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="mb-4">
|
|
||||||
<p className="text-sm text-muted-foreground leading-relaxed my-1">
|
|
||||||
<Trans>
|
|
||||||
Systems may be managed in a <code className="bg-muted rounded-sm px-1 text-primary">config.yml</code> file
|
|
||||||
inside your data directory.
|
|
||||||
</Trans>
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
|
||||||
<Trans>
|
|
||||||
On each restart, systems in the database will be updated to match the systems defined in the file.
|
|
||||||
</Trans>
|
|
||||||
</p>
|
|
||||||
<Alert className="my-4 border-destructive text-destructive w-auto table md:pe-6">
|
|
||||||
<AlertCircleIcon className="h-4 w-4 stroke-destructive" />
|
|
||||||
<AlertTitle>
|
|
||||||
<Trans>Caution - potential data loss</Trans>
|
|
||||||
</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
<p>
|
|
||||||
<Trans>
|
|
||||||
Existing systems not defined in <code>config.yml</code> will be deleted. Please make regular backups.
|
|
||||||
</Trans>
|
|
||||||
</p>
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
</div>
|
|
||||||
{configContent && (
|
|
||||||
<Textarea
|
|
||||||
dir="ltr"
|
|
||||||
autoFocus
|
|
||||||
defaultValue={configContent}
|
|
||||||
spellCheck="false"
|
|
||||||
rows={Math.min(25, configContent.split("\n").length)}
|
|
||||||
className="font-mono whitespace-pre"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Separator className="my-5" />
|
|
||||||
<Button type="button" className="mt-2 flex items-center gap-1" onClick={fetchConfig} disabled={isLoading}>
|
|
||||||
<ButtonIcon className={clsx("h-4 w-4 me-0.5", isLoading && "animate-spin")} />
|
|
||||||
<Trans>Export configuration</Trans>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,21 +1,22 @@
|
|||||||
import { Button } from "@/components/ui/button"
|
import { Button } from '@/components/ui/button'
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from '@/components/ui/label'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import {
|
||||||
import { chartTimeData } from "@/lib/utils"
|
Select,
|
||||||
import { Separator } from "@/components/ui/separator"
|
SelectContent,
|
||||||
import { LanguagesIcon, LoaderCircleIcon, SaveIcon } from "lucide-react"
|
SelectItem,
|
||||||
import { UserSettings } from "@/types"
|
SelectTrigger,
|
||||||
import { saveSettings } from "./layout"
|
SelectValue,
|
||||||
import { useState } from "react"
|
} from '@/components/ui/select'
|
||||||
import { Trans } from "@lingui/macro"
|
import { chartTimeData } from '@/lib/utils'
|
||||||
import languages from "../../../lib/languages.json"
|
import { Separator } from '@/components/ui/separator'
|
||||||
import { dynamicActivate } from "@/lib/i18n"
|
import { LoaderCircleIcon, SaveIcon } from 'lucide-react'
|
||||||
import { useLingui } from "@lingui/react"
|
import { UserSettings } from '@/types'
|
||||||
// import { setLang } from "@/lib/i18n"
|
import { saveSettings } from './layout'
|
||||||
|
import { useState } from 'react'
|
||||||
|
// import { Input } from '@/components/ui/input'
|
||||||
|
|
||||||
export default function SettingsProfilePage({ userSettings }: { userSettings: UserSettings }) {
|
export default function SettingsProfilePage({ userSettings }: { userSettings: UserSettings }) {
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const { i18n } = useLingui()
|
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -29,81 +30,79 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-xl font-medium mb-2">
|
<h3 className="text-xl font-medium mb-2">General</h3>
|
||||||
<Trans>General</Trans>
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||||
<Trans>Change general application options.</Trans>
|
Change general application options.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Separator className="my-4" />
|
<Separator className="my-4" />
|
||||||
<form onSubmit={handleSubmit} className="space-y-5">
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
|
{/* <Separator />
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<h3 className="mb-1 text-lg font-medium flex items-center gap-2">
|
<h3 className="mb-1 text-lg font-medium">Language</h3>
|
||||||
<LanguagesIcon className="h-4 w-4" />
|
|
||||||
<Trans>Language</Trans>
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||||
<Trans>
|
Internationalization will be added in a future release. Please see the{' '}
|
||||||
Want to help us make our translations even better? Check out{" "}
|
<a href="#" className="link" target="_blank">
|
||||||
<a href="https://crowdin.com/project/beszel" className="link" target="_blank" rel="noopener noreferrer">
|
discussion on GitHub
|
||||||
Crowdin
|
</a>{' '}
|
||||||
</a>{" "}
|
for more details.
|
||||||
for more details.
|
|
||||||
</Trans>
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Label className="block" htmlFor="lang">
|
<Label className="block" htmlFor="lang">
|
||||||
<Trans>Preferred Language</Trans>
|
Preferred language
|
||||||
</Label>
|
</Label>
|
||||||
<Select value={i18n.locale} onValueChange={(lang: string) => dynamicActivate(lang)}>
|
<Select defaultValue="en">
|
||||||
<SelectTrigger id="lang">
|
<SelectTrigger id="lang">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{languages.map((lang) => (
|
<SelectItem value="en">English</SelectItem>
|
||||||
<SelectItem key={lang.lang} value={lang.lang}>
|
|
||||||
<span className="me-2.5">{lang.e}</span>
|
|
||||||
{lang.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div> */}
|
||||||
<Separator />
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<h3 className="mb-1 text-lg font-medium">
|
<h3 className="mb-1 text-lg font-medium">Chart options</h3>
|
||||||
<Trans>Chart options</Trans>
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||||
<Trans>Adjust display options for charts.</Trans>
|
Adjust display options for charts.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Label className="block" htmlFor="chartTime">
|
<Label className="block" htmlFor="chartTime">
|
||||||
<Trans>Default time period</Trans>
|
Default time period
|
||||||
</Label>
|
</Label>
|
||||||
<Select name="chartTime" key={userSettings.chartTime} defaultValue={userSettings.chartTime}>
|
<Select
|
||||||
|
name="chartTime"
|
||||||
|
key={userSettings.chartTime}
|
||||||
|
defaultValue={userSettings.chartTime}
|
||||||
|
>
|
||||||
<SelectTrigger id="chartTime">
|
<SelectTrigger id="chartTime">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{Object.entries(chartTimeData).map(([value, { label }]) => (
|
{Object.entries(chartTimeData).map(([value, { label }]) => (
|
||||||
<SelectItem key={value} value={value}>
|
<SelectItem key={label} value={value}>
|
||||||
{label()}
|
{label}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<p className="text-[0.8rem] text-muted-foreground">
|
<p className="text-[0.8rem] text-muted-foreground">
|
||||||
<Trans>Sets the default time range for charts when a system is viewed.</Trans>
|
Sets the default time range for charts when a system is viewed.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
<Button type="submit" className="flex items-center gap-1.5 disabled:opacity-100" disabled={isLoading}>
|
<Button
|
||||||
{isLoading ? <LoaderCircleIcon className="h-4 w-4 animate-spin" /> : <SaveIcon className="h-4 w-4" />}
|
type="submit"
|
||||||
<Trans>Save Settings</Trans>
|
className="flex items-center gap-1.5 disabled:opacity-100"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<LoaderCircleIcon className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<SaveIcon className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Save settings
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,28 +1,38 @@
|
|||||||
import { useEffect } from "react"
|
import { useEffect } from 'react'
|
||||||
import { Separator } from "../../ui/separator"
|
import { Separator } from '../../ui/separator'
|
||||||
import { SidebarNav } from "./sidebar-nav.tsx"
|
import { SidebarNav } from './sidebar-nav.tsx'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card.tsx"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card.tsx'
|
||||||
import { useStore } from "@nanostores/react"
|
import { useStore } from '@nanostores/react'
|
||||||
import { $router } from "@/components/router.tsx"
|
import { $router } from '@/components/router.tsx'
|
||||||
import { redirectPage } from "@nanostores/router"
|
import { redirectPage } from '@nanostores/router'
|
||||||
import { BellIcon, FileSlidersIcon, SettingsIcon } from "lucide-react"
|
import { BellIcon, SettingsIcon } from 'lucide-react'
|
||||||
import { $userSettings, pb } from "@/lib/stores.ts"
|
import { $userSettings, pb } from '@/lib/stores.ts'
|
||||||
import { toast } from "@/components/ui/use-toast.ts"
|
import { toast } from '@/components/ui/use-toast.ts'
|
||||||
import { UserSettings } from "@/types.js"
|
import { UserSettings } from '@/types.js'
|
||||||
import General from "./general.tsx"
|
import General from './general.tsx'
|
||||||
import Notifications from "./notifications.tsx"
|
import Notifications from './notifications.tsx'
|
||||||
import ConfigYaml from "./config-yaml.tsx"
|
|
||||||
import { Trans, t } from "@lingui/macro"
|
const sidebarNavItems = [
|
||||||
import { useLingui } from "@lingui/react"
|
{
|
||||||
|
title: 'General',
|
||||||
|
href: '/settings/general',
|
||||||
|
icon: SettingsIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Notifications',
|
||||||
|
href: '/settings/notifications',
|
||||||
|
icon: BellIcon,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
export async function saveSettings(newSettings: Partial<UserSettings>) {
|
export async function saveSettings(newSettings: Partial<UserSettings>) {
|
||||||
try {
|
try {
|
||||||
// get fresh copy of settings
|
// get fresh copy of settings
|
||||||
const req = await pb.collection("user_settings").getFirstListItem("", {
|
const req = await pb.collection('user_settings').getFirstListItem('', {
|
||||||
fields: "id,settings",
|
fields: 'id,settings',
|
||||||
})
|
})
|
||||||
// update user settings
|
// update user settings
|
||||||
const updatedSettings = await pb.collection("user_settings").update(req.id, {
|
const updatedSettings = await pb.collection('user_settings').update(req.id, {
|
||||||
settings: {
|
settings: {
|
||||||
...req.settings,
|
...req.settings,
|
||||||
...newSettings,
|
...newSettings,
|
||||||
@@ -30,60 +40,35 @@ export async function saveSettings(newSettings: Partial<UserSettings>) {
|
|||||||
})
|
})
|
||||||
$userSettings.set(updatedSettings.settings)
|
$userSettings.set(updatedSettings.settings)
|
||||||
toast({
|
toast({
|
||||||
title: t`Settings saved`,
|
title: 'Settings saved',
|
||||||
description: t`Your user settings have been updated.`,
|
description: 'Your user settings have been updated.',
|
||||||
})
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// console.error('update settings', e)
|
// console.error('update settings', e)
|
||||||
toast({
|
toast({
|
||||||
title: t`Failed to save settings`,
|
title: 'Failed to save settings',
|
||||||
description: t`Check logs for more details.`,
|
description: 'Check logs for more details.',
|
||||||
variant: "destructive",
|
variant: 'destructive',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SettingsLayout() {
|
export default function SettingsLayout() {
|
||||||
const { _ } = useLingui()
|
|
||||||
|
|
||||||
const sidebarNavItems = [
|
|
||||||
{
|
|
||||||
title: _(t({ message: `General`, comment: "Context: General settings" })),
|
|
||||||
href: "/settings/general",
|
|
||||||
icon: SettingsIcon,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t`Notifications`,
|
|
||||||
href: "/settings/notifications",
|
|
||||||
icon: BellIcon,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t`YAML Config`,
|
|
||||||
href: "/settings/config",
|
|
||||||
icon: FileSlidersIcon,
|
|
||||||
admin: true,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const page = useStore($router)
|
const page = useStore($router)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = t`Settings` + " / Beszel"
|
document.title = 'Settings / Beszel'
|
||||||
// redirect to account page if no page is specified
|
// redirect to account page if no page is specified
|
||||||
if (page?.path === "/settings") {
|
if (page?.path === '/settings') {
|
||||||
redirectPage($router, "settings", { name: "general" })
|
redirectPage($router, 'settings', { name: 'general' })
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="pt-5 px-4 pb-8 sm:pt-6 sm:px-7">
|
<Card className="pt-5 px-4 pb-8 sm:pt-6 sm:px-7">
|
||||||
<CardHeader className="p-0">
|
<CardHeader className="p-0">
|
||||||
<CardTitle className="mb-1">
|
<CardTitle className="mb-1">Settings</CardTitle>
|
||||||
<Trans>Settings</Trans>
|
<CardDescription>Manage display and notification preferences.</CardDescription>
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
<Trans>Manage display and notification preferences.</Trans>
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<Separator className="hidden md:block my-5" />
|
<Separator className="hidden md:block my-5" />
|
||||||
@@ -93,7 +78,7 @@ export default function SettingsLayout() {
|
|||||||
</aside>
|
</aside>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
{/* @ts-ignore */}
|
{/* @ts-ignore */}
|
||||||
<SettingsContent name={page?.params?.name ?? "general"} />
|
<SettingsContent name={page?.params?.name ?? 'general'} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -105,11 +90,9 @@ function SettingsContent({ name }: { name: string }) {
|
|||||||
const userSettings = useStore($userSettings)
|
const userSettings = useStore($userSettings)
|
||||||
|
|
||||||
switch (name) {
|
switch (name) {
|
||||||
case "general":
|
case 'general':
|
||||||
return <General userSettings={userSettings} />
|
return <General userSettings={userSettings} />
|
||||||
case "notifications":
|
case 'notifications':
|
||||||
return <Notifications userSettings={userSettings} />
|
return <Notifications userSettings={userSettings} />
|
||||||
case "config":
|
|
||||||
return <ConfigYaml />
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,17 @@
|
|||||||
import { Button } from "@/components/ui/button"
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from '@/components/ui/label'
|
||||||
import { pb } from "@/lib/stores"
|
import { pb } from '@/lib/stores'
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from '@/components/ui/separator'
|
||||||
import { Card } from "@/components/ui/card"
|
import { Card } from '@/components/ui/card'
|
||||||
import { BellIcon, LoaderCircleIcon, PlusIcon, SaveIcon, Trash2Icon } from "lucide-react"
|
import { BellIcon, LoaderCircleIcon, PlusIcon, SaveIcon, Trash2Icon } from 'lucide-react'
|
||||||
import { ChangeEventHandler, useEffect, useState } from "react"
|
import { ChangeEventHandler, useEffect, useState } from 'react'
|
||||||
import { toast } from "@/components/ui/use-toast"
|
import { toast } from '@/components/ui/use-toast'
|
||||||
import { InputTags } from "@/components/ui/input-tags"
|
import { InputTags } from '@/components/ui/input-tags'
|
||||||
import { UserSettings } from "@/types"
|
import { UserSettings } from '@/types'
|
||||||
import { saveSettings } from "./layout"
|
import { saveSettings } from './layout'
|
||||||
import * as v from "valibot"
|
import * as v from 'valibot'
|
||||||
import { isAdmin } from "@/lib/utils"
|
import { isAdmin } from '@/lib/utils'
|
||||||
import { Trans, t } from "@lingui/macro"
|
|
||||||
|
|
||||||
interface ShoutrrrUrlCardProps {
|
interface ShoutrrrUrlCardProps {
|
||||||
url: string
|
url: string
|
||||||
@@ -37,10 +36,10 @@ const SettingsNotificationsPage = ({ userSettings }: { userSettings: UserSetting
|
|||||||
}, [userSettings])
|
}, [userSettings])
|
||||||
|
|
||||||
function addWebhook() {
|
function addWebhook() {
|
||||||
setWebhooks([...webhooks, ""])
|
setWebhooks([...webhooks, ''])
|
||||||
// focus on the new input
|
// focus on the new input
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
const inputs = document.querySelectorAll("#webhooks input") as NodeListOf<HTMLInputElement>
|
const inputs = document.querySelectorAll('#webhooks input') as NodeListOf<HTMLInputElement>
|
||||||
inputs[inputs.length - 1]?.focus()
|
inputs[inputs.length - 1]?.focus()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -59,9 +58,9 @@ const SettingsNotificationsPage = ({ userSettings }: { userSettings: UserSetting
|
|||||||
await saveSettings(parsedData)
|
await saveSettings(parsedData)
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
toast({
|
toast({
|
||||||
title: t`Failed to save settings`,
|
title: 'Failed to save settings',
|
||||||
description: e.message,
|
description: e.message,
|
||||||
variant: "destructive",
|
variant: 'destructive',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
@@ -70,67 +69,59 @@ const SettingsNotificationsPage = ({ userSettings }: { userSettings: UserSetting
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-xl font-medium mb-2">
|
<h3 className="text-xl font-medium mb-2">Notifications</h3>
|
||||||
<Trans>Notifications</Trans>
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||||
<Trans>Configure how you receive alert notifications.</Trans>
|
Configure how you receive alert notifications.
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-muted-foreground mt-1.5 leading-relaxed">
|
<p className="text-sm text-muted-foreground mt-1.5 leading-relaxed">
|
||||||
<Trans>
|
Looking instead for where to create alerts? Click the bell{' '}
|
||||||
Looking instead for where to create alerts? Click the bell <BellIcon className="inline h-4 w-4" /> icons in
|
<BellIcon className="inline h-4 w-4" /> icons in the systems table.
|
||||||
the systems table.
|
|
||||||
</Trans>
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Separator className="my-4" />
|
<Separator className="my-4" />
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<h3 className="mb-1 text-lg font-medium">
|
<h3 className="mb-1 text-lg font-medium">Email notifications</h3>
|
||||||
<Trans>Email notifications</Trans>
|
|
||||||
</h3>
|
|
||||||
{isAdmin() && (
|
{isAdmin() && (
|
||||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||||
<Trans>
|
Please{' '}
|
||||||
Please{" "}
|
<a href="/_/#/settings/mail" className="link" target="_blank">
|
||||||
<a href="/_/#/settings/mail" className="link" target="_blank">
|
configure an SMTP server
|
||||||
configure an SMTP server
|
</a>{' '}
|
||||||
</a>{" "}
|
to ensure alerts are delivered.{' '}
|
||||||
to ensure alerts are delivered.
|
|
||||||
</Trans>
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Label className="block" htmlFor="email">
|
<Label className="block" htmlFor="email">
|
||||||
<Trans>To email(s)</Trans>
|
To email(s)
|
||||||
</Label>
|
</Label>
|
||||||
<InputTags
|
<InputTags
|
||||||
value={emails}
|
value={emails}
|
||||||
onChange={setEmails}
|
onChange={setEmails}
|
||||||
placeholder={t`Enter email address...`}
|
placeholder="Enter email address..."
|
||||||
className="w-full"
|
className="w-full"
|
||||||
type="email"
|
type="email"
|
||||||
id="email"
|
id="email"
|
||||||
/>
|
/>
|
||||||
<p className="text-[0.8rem] text-muted-foreground">
|
<p className="text-[0.8rem] text-muted-foreground">
|
||||||
<Trans>Save address using enter key or comma. Leave blank to disable email notifications.</Trans>
|
Save address using enter key or comma. Leave blank to disable email notifications.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="mb-1 text-lg font-medium">
|
<h3 className="mb-1 text-lg font-medium">Webhook / Push notifications</h3>
|
||||||
<Trans>Webhook / Push notifications</Trans>
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||||
<Trans>
|
Beszel uses{' '}
|
||||||
Beszel uses{" "}
|
<a
|
||||||
<a href="https://containrrr.dev/shoutrrr/services/overview/" target="_blank" className="link">
|
href="https://containrrr.dev/shoutrrr/services/overview/"
|
||||||
Shoutrrr
|
target="_blank"
|
||||||
</a>{" "}
|
className="link"
|
||||||
to integrate with popular notification services.
|
>
|
||||||
</Trans>
|
Shoutrrr
|
||||||
|
</a>{' '}
|
||||||
|
to integrate with popular notification services.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{webhooks.length > 0 && (
|
{webhooks.length > 0 && (
|
||||||
@@ -139,7 +130,9 @@ const SettingsNotificationsPage = ({ userSettings }: { userSettings: UserSetting
|
|||||||
<ShoutrrrUrlCard
|
<ShoutrrrUrlCard
|
||||||
key={index}
|
key={index}
|
||||||
url={webhook}
|
url={webhook}
|
||||||
onUrlChange={(e: React.ChangeEvent<HTMLInputElement>) => updateWebhook(index, e.target.value)}
|
onUrlChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
|
updateWebhook(index, e.target.value)
|
||||||
|
}
|
||||||
onRemove={() => removeWebhook(index)}
|
onRemove={() => removeWebhook(index)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -152,8 +145,8 @@ const SettingsNotificationsPage = ({ userSettings }: { userSettings: UserSetting
|
|||||||
className="mt-2 flex items-center gap-1"
|
className="mt-2 flex items-center gap-1"
|
||||||
onClick={addWebhook}
|
onClick={addWebhook}
|
||||||
>
|
>
|
||||||
<PlusIcon className="h-4 w-4 -ms-0.5" />
|
<PlusIcon className="h-4 w-4 -ml-0.5" />
|
||||||
<Trans>Add URL</Trans>
|
Add URL
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
@@ -163,8 +156,12 @@ const SettingsNotificationsPage = ({ userSettings }: { userSettings: UserSetting
|
|||||||
onClick={updateSettings}
|
onClick={updateSettings}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
>
|
>
|
||||||
{isLoading ? <LoaderCircleIcon className="h-4 w-4 animate-spin" /> : <SaveIcon className="h-4 w-4" />}
|
{isLoading ? (
|
||||||
<Trans>Save Settings</Trans>
|
<LoaderCircleIcon className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<SaveIcon className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Save settings
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -176,17 +173,17 @@ const ShoutrrrUrlCard = ({ url, onUrlChange, onRemove }: ShoutrrrUrlCardProps) =
|
|||||||
|
|
||||||
const sendTestNotification = async () => {
|
const sendTestNotification = async () => {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
const res = await pb.send("/api/beszel/send-test-notification", { url })
|
const res = await pb.send('/api/beszel/send-test-notification', { url })
|
||||||
if ("err" in res && !res.err) {
|
if ('err' in res && !res.err) {
|
||||||
toast({
|
toast({
|
||||||
title: t`Test notification sent`,
|
title: 'Test notification sent',
|
||||||
description: t`Check your notification service`,
|
description: 'Check your notification service',
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
toast({
|
toast({
|
||||||
title: t`Error`,
|
title: 'Error',
|
||||||
description: res.err ?? t`Failed to send test notification`,
|
description: res.err ?? 'Failed to send test notification',
|
||||||
variant: "destructive",
|
variant: 'destructive',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
@@ -203,18 +200,29 @@ const ShoutrrrUrlCard = ({ url, onUrlChange, onRemove }: ShoutrrrUrlCardProps) =
|
|||||||
value={url}
|
value={url}
|
||||||
onChange={onUrlChange}
|
onChange={onUrlChange}
|
||||||
/>
|
/>
|
||||||
<Button type="button" variant="outline" disabled={isLoading || url === ""} onClick={sendTestNotification}>
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="w-20 md:w-28"
|
||||||
|
disabled={isLoading || url === ''}
|
||||||
|
onClick={sendTestNotification}
|
||||||
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<LoaderCircleIcon className="h-4 w-4 animate-spin" />
|
<LoaderCircleIcon className="h-4 w-4 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<span>
|
<span>
|
||||||
<Trans>
|
Test <span className="hidden md:inline">URL</span>
|
||||||
Test <span className="hidden sm:inline">URL</span>
|
|
||||||
</Trans>
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" variant="outline" size="icon" className="shrink-0" aria-label="Delete" onClick={onRemove}>
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="shrink-0"
|
||||||
|
aria-label="Delete"
|
||||||
|
onClick={onRemove}
|
||||||
|
>
|
||||||
<Trash2Icon className="h-4 w-4" />
|
<Trash2Icon className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
import React from "react"
|
import React from 'react'
|
||||||
import { cn, isAdmin } from "@/lib/utils"
|
import { cn } from '@/lib/utils'
|
||||||
import { buttonVariants } from "../../ui/button"
|
import { buttonVariants } from '../../ui/button'
|
||||||
import { $router, Link, navigate } from "../../router"
|
import { $router, Link, navigate } from '../../router'
|
||||||
import { useStore } from "@nanostores/react"
|
import { useStore } from '@nanostores/react'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import {
|
||||||
import { Separator } from "@/components/ui/separator"
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { Separator } from '@/components/ui/separator'
|
||||||
|
|
||||||
interface SidebarNavProps extends React.HTMLAttributes<HTMLElement> {
|
interface SidebarNavProps extends React.HTMLAttributes<HTMLElement> {
|
||||||
items: {
|
items: {
|
||||||
href: string
|
href: string
|
||||||
title: string
|
title: string
|
||||||
icon?: React.FC<React.SVGProps<SVGSVGElement>>
|
icon?: React.FC<React.SVGProps<SVGSVGElement>>
|
||||||
admin?: boolean
|
|
||||||
}[]
|
}[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,36 +29,33 @@ export function SidebarNav({ className, items, ...props }: SidebarNavProps) {
|
|||||||
<div className="md:hidden">
|
<div className="md:hidden">
|
||||||
<Select onValueChange={(value: string) => navigate(value)} value={page?.path}>
|
<Select onValueChange={(value: string) => navigate(value)} value={page?.path}>
|
||||||
<SelectTrigger className="w-full my-3.5">
|
<SelectTrigger className="w-full my-3.5">
|
||||||
<SelectValue placeholder="Select page" />
|
<SelectValue placeholder="Select a page" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{items.map((item) => {
|
{items.map((item) => (
|
||||||
if (item.admin && !isAdmin()) return null
|
<SelectItem key={item.href} value={item.href}>
|
||||||
return (
|
<span className="flex items-center gap-2">
|
||||||
<SelectItem key={item.href} value={item.href}>
|
{item.icon && <item.icon className="h-4 w-4" />}
|
||||||
<span className="flex items-center gap-2">
|
{item.title}
|
||||||
{item.icon && <item.icon className="h-4 w-4" />}
|
</span>
|
||||||
{item.title}
|
</SelectItem>
|
||||||
</span>
|
))}
|
||||||
</SelectItem>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Separator />
|
<Separator />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Desktop View */}
|
{/* Desktop View */}
|
||||||
<nav className={cn("hidden md:grid gap-1", className)} {...props}>
|
<nav className={cn('hidden md:grid gap-1', className)} {...props}>
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<Link
|
<Link
|
||||||
key={item.href}
|
key={item.href}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
className={cn(
|
className={cn(
|
||||||
buttonVariants({ variant: "ghost" }),
|
buttonVariants({ variant: 'ghost' }),
|
||||||
"flex items-center gap-3",
|
'flex items-center gap-3',
|
||||||
page?.path === item.href ? "bg-muted hover:bg-muted" : "hover:bg-muted/50",
|
page?.path === item.href ? 'bg-muted hover:bg-muted' : 'hover:bg-muted/50',
|
||||||
"justify-start"
|
'justify-start'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{item.icon && <item.icon className="h-4 w-4" />}
|
{item.icon && <item.icon className="h-4 w-4" />}
|
||||||
|
|||||||
@@ -1,132 +1,69 @@
|
|||||||
import { $systems, pb, $chartTime, $containerFilter, $userSettings, $direction } from "@/lib/stores"
|
import { $systems, pb, $chartTime, $containerFilter, $userSettings } from '@/lib/stores'
|
||||||
import { ChartData, ChartTimes, ContainerStatsRecord, SystemRecord, SystemStatsRecord } from "@/types"
|
import { ContainerStatsRecord, SystemRecord, SystemStatsRecord } from '@/types'
|
||||||
import React, { lazy, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
import { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { Card, CardHeader, CardTitle, CardDescription } from "../ui/card"
|
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '../ui/card'
|
||||||
import { useStore } from "@nanostores/react"
|
import { useStore } from '@nanostores/react'
|
||||||
import Spinner from "../spinner"
|
import Spinner from '../spinner'
|
||||||
import { ClockArrowUp, CpuIcon, GlobeIcon, LayoutGridIcon, MonitorIcon, XIcon } from "lucide-react"
|
import { ClockArrowUp, CpuIcon, GlobeIcon, LayoutGridIcon, MonitorIcon, XIcon } from 'lucide-react'
|
||||||
import ChartTimeSelect from "../charts/chart-time-select"
|
import ChartTimeSelect from '../charts/chart-time-select'
|
||||||
import { chartTimeData, cn, getPbTimestamp, useLocalStorage } from "@/lib/utils"
|
import { chartTimeData, cn, getPbTimestamp, useLocalStorage } from '@/lib/utils'
|
||||||
import { Separator } from "../ui/separator"
|
import { Separator } from '../ui/separator'
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip"
|
import { scaleTime } from 'd3-scale'
|
||||||
import { Button } from "../ui/button"
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip'
|
||||||
import { Input } from "../ui/input"
|
import { Button, buttonVariants } from '../ui/button'
|
||||||
import { ChartAverage, ChartMax, Rows, TuxIcon } from "../ui/icons"
|
import { Input } from '../ui/input'
|
||||||
import { useIntersectionObserver } from "@/lib/use-intersection-observer"
|
import { Rows, TuxIcon } from '../ui/icons'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select"
|
import { useIntersectionObserver } from '@/lib/use-intersection-observer'
|
||||||
import { timeTicks } from "d3-time"
|
|
||||||
import { Plural, Trans, t } from "@lingui/macro"
|
|
||||||
import { useLingui } from "@lingui/react"
|
|
||||||
|
|
||||||
const AreaChartDefault = lazy(() => import("../charts/area-chart"))
|
const CpuChart = lazy(() => import('../charts/cpu-chart'))
|
||||||
const ContainerChart = lazy(() => import("../charts/container-chart"))
|
const ContainerCpuChart = lazy(() => import('../charts/container-cpu-chart'))
|
||||||
const MemChart = lazy(() => import("../charts/mem-chart"))
|
const MemChart = lazy(() => import('../charts/mem-chart'))
|
||||||
const DiskChart = lazy(() => import("../charts/disk-chart"))
|
const ContainerMemChart = lazy(() => import('../charts/container-mem-chart'))
|
||||||
const SwapChart = lazy(() => import("../charts/swap-chart"))
|
const DiskChart = lazy(() => import('../charts/disk-chart'))
|
||||||
const TemperatureChart = lazy(() => import("../charts/temperature-chart"))
|
const DiskIoChart = lazy(() => import('../charts/disk-io-chart'))
|
||||||
|
const BandwidthChart = lazy(() => import('../charts/bandwidth-chart'))
|
||||||
const cache = new Map<string, any>()
|
const ContainerNetChart = lazy(() => import('../charts/container-net-chart'))
|
||||||
|
const SwapChart = lazy(() => import('../charts/swap-chart'))
|
||||||
// create ticks and domain for charts
|
const TemperatureChart = lazy(() => import('../charts/temperature-chart'))
|
||||||
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 }) {
|
export default function SystemDetail({ name }: { name: string }) {
|
||||||
const direction = useStore($direction)
|
|
||||||
const { _ } = useLingui()
|
|
||||||
const systems = useStore($systems)
|
const systems = useStore($systems)
|
||||||
const chartTime = useStore($chartTime)
|
const chartTime = useStore($chartTime)
|
||||||
/** Max CPU toggle value */
|
const [grid, setGrid] = useLocalStorage('grid', true)
|
||||||
const cpuMaxStore = useState(false)
|
const [ticks, setTicks] = useState([] as number[])
|
||||||
const bandwidthMaxStore = useState(false)
|
|
||||||
const diskIoMaxStore = useState(false)
|
|
||||||
const [grid, setGrid] = useLocalStorage("grid", true)
|
|
||||||
const [system, setSystem] = useState({} as SystemRecord)
|
const [system, setSystem] = useState({} as SystemRecord)
|
||||||
const [systemStats, setSystemStats] = useState([] as SystemStatsRecord[])
|
const [systemStats, setSystemStats] = useState([] as SystemStatsRecord[])
|
||||||
const [containerData, setContainerData] = useState([] as ChartData["containerData"])
|
|
||||||
const netCardRef = useRef<HTMLDivElement>(null)
|
const netCardRef = useRef<HTMLDivElement>(null)
|
||||||
const [containerFilterBar, setContainerFilterBar] = useState(null as null | JSX.Element)
|
const [dockerCpuChartData, setDockerCpuChartData] = useState<Record<string, number | string>[]>(
|
||||||
const [bottomSpacing, setBottomSpacing] = useState(0)
|
[]
|
||||||
const [chartLoading, setChartLoading] = useState(true)
|
)
|
||||||
const isLongerChart = chartTime !== "1h"
|
const [dockerMemChartData, setDockerMemChartData] = useState<Record<string, number | string>[]>(
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
const [dockerNetChartData, setDockerNetChartData] = useState<Record<string, number | number[]>[]>(
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
const hasDockerStats = dockerCpuChartData.length > 0
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = `${name} / Beszel`
|
document.title = `${name} / Beszel`
|
||||||
return () => {
|
return () => {
|
||||||
|
resetCharts()
|
||||||
$chartTime.set($userSettings.get().chartTime)
|
$chartTime.set($userSettings.get().chartTime)
|
||||||
// resetCharts()
|
$containerFilter.set('')
|
||||||
setSystemStats([])
|
// setHasDocker(false)
|
||||||
setContainerData([])
|
|
||||||
setContainerFilterBar(null)
|
|
||||||
$containerFilter.set("")
|
|
||||||
cpuMaxStore[1](false)
|
|
||||||
bandwidthMaxStore[1](false)
|
|
||||||
diskIoMaxStore[1](false)
|
|
||||||
}
|
}
|
||||||
}, [name])
|
}, [name])
|
||||||
|
|
||||||
// function resetCharts() {
|
function resetCharts() {
|
||||||
// setSystemStats([])
|
setSystemStats([])
|
||||||
// setContainerData([])
|
setDockerCpuChartData([])
|
||||||
// }
|
setDockerMemChartData([])
|
||||||
|
setDockerNetChartData([])
|
||||||
|
}
|
||||||
|
|
||||||
// useEffect(resetCharts, [chartTime])
|
useEffect(resetCharts, [chartTime])
|
||||||
|
|
||||||
// find matching system
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (system.id && system.name === name) {
|
if (system.id && system.name === name) {
|
||||||
return
|
return
|
||||||
@@ -142,91 +79,109 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
if (!system.id) {
|
if (!system.id) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
pb.collection<SystemRecord>("systems").subscribe(system.id, (e) => {
|
pb.collection<SystemRecord>('systems').subscribe(system.id, (e) => {
|
||||||
setSystem(e.record)
|
setSystem(e.record)
|
||||||
})
|
})
|
||||||
return () => {
|
return () => {
|
||||||
pb.collection("systems").unsubscribe(system.id)
|
pb.collection('systems').unsubscribe(system.id)
|
||||||
}
|
}
|
||||||
}, [system.id])
|
}, [system])
|
||||||
|
|
||||||
const chartData: ChartData = useMemo(() => {
|
async function getStats<T>(collection: string): Promise<T[]> {
|
||||||
const lastCreated = Math.max(
|
return await pb.collection<T>(collection).getFullList({
|
||||||
(systemStats.at(-1)?.created as number) ?? 0,
|
filter: pb.filter('system={:id} && created > {:created} && type={:type}', {
|
||||||
(containerData.at(-1)?.created as number) ?? 0
|
id: system.id,
|
||||||
)
|
created: getPbTimestamp(chartTime),
|
||||||
return {
|
type: chartTimeData[chartTime].type,
|
||||||
systemStats,
|
}),
|
||||||
containerData,
|
fields: 'created,stats',
|
||||||
chartTime,
|
sort: 'created',
|
||||||
orientation: direction === "rtl" ? "right" : "left",
|
})
|
||||||
...getTimeData(chartTime, lastCreated),
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
}
|
}
|
||||||
}, [systemStats, containerData, direction])
|
return modifiedRecords
|
||||||
|
}
|
||||||
|
|
||||||
// get stats
|
// get stats
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!system.id || !chartTime) {
|
if (!system.id || !chartTime) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// loading: true
|
|
||||||
setChartLoading(true)
|
|
||||||
Promise.allSettled([
|
Promise.allSettled([
|
||||||
getStats<SystemStatsRecord>("system_stats", system, chartTime),
|
getStats<SystemStatsRecord>('system_stats'),
|
||||||
getStats<ContainerStatsRecord>("container_stats", system, chartTime),
|
getStats<ContainerStatsRecord>('container_stats'),
|
||||||
]).then(([systemStats, containerStats]) => {
|
]).then(([systemStats, containerStats]) => {
|
||||||
// loading: false
|
const expectedInterval = chartTimeData[chartTime].expectedInterval
|
||||||
setChartLoading(false)
|
if (containerStats.status === 'fulfilled' && containerStats.value.length) {
|
||||||
|
makeContainerData(addEmptyValues(containerStats.value, 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)
|
if (systemStats.status === 'fulfilled') {
|
||||||
// make new container stats
|
setSystemStats(addEmptyValues(systemStats.value, expectedInterval))
|
||||||
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) {
|
|
||||||
containerData = containerData.concat(addEmptyValues(containerData, containerStats.value, expectedInterval))
|
|
||||||
if (containerData.length > 120) {
|
|
||||||
containerData = containerData.slice(-100)
|
|
||||||
}
|
|
||||||
cache.set(cs_cache_key, containerData)
|
|
||||||
}
|
}
|
||||||
if (containerData.length) {
|
|
||||||
!containerFilterBar && setContainerFilterBar(<ContainerFilterBar />)
|
|
||||||
} else if (containerFilterBar) {
|
|
||||||
setContainerFilterBar(null)
|
|
||||||
}
|
|
||||||
makeContainerData(containerData)
|
|
||||||
})
|
})
|
||||||
}, [system, chartTime])
|
}, [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
|
// make container stats for charts
|
||||||
const makeContainerData = useCallback((containers: ContainerStatsRecord[]) => {
|
const makeContainerData = useCallback((containers: ContainerStatsRecord[]) => {
|
||||||
const containerData = [] as ChartData["containerData"]
|
// console.log('containers', containers)
|
||||||
|
const dockerCpuData = []
|
||||||
|
const dockerMemData = []
|
||||||
|
const dockerNetData = []
|
||||||
for (let { created, stats } of containers) {
|
for (let { created, stats } of containers) {
|
||||||
if (!created) {
|
if (!created) {
|
||||||
// @ts-ignore add null value for gaps
|
let nullData = { time: null } as unknown
|
||||||
containerData.push({ created: null })
|
dockerCpuData.push(nullData as Record<string, number | string>)
|
||||||
|
dockerMemData.push(nullData as Record<string, number | string>)
|
||||||
|
dockerNetData.push(nullData as Record<string, number | number[]>)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
created = new Date(created).getTime()
|
const time = new Date(created).getTime()
|
||||||
// @ts-ignore not dealing with this rn
|
let cpuData = { time } as Record<string, number | string>
|
||||||
let containerStats: ChartData["containerData"][0] = { created }
|
let memData = { time } as Record<string, number | string>
|
||||||
|
let netData = { time } as Record<string, number | number[]>
|
||||||
for (let container of stats) {
|
for (let container of stats) {
|
||||||
containerStats[container.n] = container
|
cpuData[container.n] = container.c
|
||||||
|
memData[container.n] = container.m
|
||||||
|
netData[container.n] = [container.ns, container.nr, container.ns + container.nr] // sent, received, total
|
||||||
}
|
}
|
||||||
containerData.push(containerStats)
|
dockerCpuData.push(cpuData)
|
||||||
|
dockerMemData.push(memData)
|
||||||
|
dockerNetData.push(netData)
|
||||||
}
|
}
|
||||||
setContainerData(containerData)
|
setDockerCpuChartData(dockerCpuData)
|
||||||
|
setDockerMemChartData(dockerMemData)
|
||||||
|
setDockerNetChartData(dockerNetData)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// values for system info bar
|
// values for system info bar
|
||||||
@@ -234,26 +189,26 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
if (!system.info) {
|
if (!system.info) {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
let uptime: React.ReactNode
|
let uptime: number | string = system.info.u
|
||||||
if (system.info.u < 172800) {
|
if (system.info.u < 172800) {
|
||||||
const hours = Math.trunc(system.info.u / 3600)
|
const hours = Math.trunc(uptime / 3600)
|
||||||
uptime = <Plural value={hours} one="# hour" other="# hours" />
|
uptime = `${hours} hour${hours > 1 ? 's' : ''}`
|
||||||
} else {
|
} else {
|
||||||
uptime = <Plural value={Math.trunc(system.info?.u / 86400)} one="# day" other="# days" />
|
uptime = `${Math.trunc(system.info?.u / 86400)} days`
|
||||||
}
|
}
|
||||||
return [
|
return [
|
||||||
{ value: system.host, Icon: GlobeIcon },
|
{ value: system.host, Icon: GlobeIcon, hide: system.host === 'hubsys' },
|
||||||
{
|
{
|
||||||
value: system.info.h,
|
value: system.info.h,
|
||||||
Icon: MonitorIcon,
|
Icon: MonitorIcon,
|
||||||
label: "Hostname",
|
label: 'Hostname',
|
||||||
// hide if hostname is same as host or name
|
// hide if hostname is same as host or name
|
||||||
hide: system.info.h === system.host || system.info.h === system.name,
|
hide: system.info.h === system.host || system.info.h === system.name,
|
||||||
},
|
},
|
||||||
{ value: uptime, Icon: ClockArrowUp, label: t`Uptime` },
|
{ value: uptime, Icon: ClockArrowUp, label: 'Uptime' },
|
||||||
{ value: system.info.k, Icon: TuxIcon, label: t({ comment: "Linux kernel", message: "Kernel" }) },
|
{ value: system.info.k, Icon: TuxIcon, label: 'Kernel' },
|
||||||
{
|
{
|
||||||
value: `${system.info.m} (${system.info.c}c${system.info.t ? `/${system.info.t}t` : ""})`,
|
value: `${system.info.m} (${system.info.c}c${system.info.t ? `/${system.info.t}t` : ''})`,
|
||||||
Icon: CpuIcon,
|
Icon: CpuIcon,
|
||||||
hide: !system.info.m,
|
hide: !system.info.m,
|
||||||
},
|
},
|
||||||
@@ -266,49 +221,45 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
}, [system.info])
|
}, [system.info])
|
||||||
|
|
||||||
/** Space for tooltip if more than 12 containers */
|
/** Space for tooltip if more than 12 containers */
|
||||||
useEffect(() => {
|
const bottomSpacing = useMemo(() => {
|
||||||
if (!netCardRef.current || !containerData.length) {
|
if (!netCardRef.current || !dockerNetChartData.length) {
|
||||||
setBottomSpacing(0)
|
return 0
|
||||||
return
|
|
||||||
}
|
}
|
||||||
const tooltipHeight = (Object.keys(containerData[0]).length - 11) * 17.8 - 40
|
const tooltipHeight = (Object.keys(dockerNetChartData[0]).length - 11) * 17.8 - 40
|
||||||
const wrapperEl = document.getElementById("chartwrap") as HTMLDivElement
|
const wrapperEl = document.getElementById('chartwrap') as HTMLDivElement
|
||||||
const wrapperRect = wrapperEl.getBoundingClientRect()
|
const wrapperRect = wrapperEl.getBoundingClientRect()
|
||||||
const chartRect = netCardRef.current.getBoundingClientRect()
|
const chartRect = netCardRef.current.getBoundingClientRect()
|
||||||
const distanceToBottom = wrapperRect.bottom - chartRect.bottom
|
const distanceToBottom = wrapperRect.bottom - chartRect.bottom
|
||||||
setBottomSpacing(tooltipHeight - distanceToBottom)
|
return tooltipHeight - distanceToBottom
|
||||||
}, [netCardRef, containerData])
|
}, [netCardRef.current, dockerNetChartData])
|
||||||
|
|
||||||
if (!system.id) {
|
if (!system.id) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// if no data, show empty message
|
|
||||||
const dataEmpty = !chartLoading && chartData.systemStats.length === 0
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div id="chartwrap" className="grid gap-4 mb-10 overflow-x-clip">
|
<div id="chartwrap" className="grid gap-4 mb-10">
|
||||||
{/* system info */}
|
{/* system info */}
|
||||||
<Card>
|
<Card>
|
||||||
<div className="grid lg:flex gap-4 px-4 sm:px-6 pt-3 sm:pt-4 pb-5">
|
<div className="grid lg:flex items-center gap-4 px-4 sm:px-6 pt-3 sm:pt-4 pb-5">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-[1.6rem] font-semibold mb-1.5">{system.name}</h1>
|
<h1 className="text-[1.6rem] font-semibold mb-1.5">{system.name}</h1>
|
||||||
<div className="flex flex-wrap items-center gap-3 gap-y-2 text-sm opacity-90">
|
<div className="flex flex-wrap items-center gap-3 gap-y-2 text-sm opacity-90">
|
||||||
<div className="capitalize flex gap-2 items-center">
|
<div className="capitalize flex gap-2 items-center">
|
||||||
<span className={cn("relative flex h-3 w-3")}>
|
<span className={cn('relative flex h-3 w-3')}>
|
||||||
{system.status === "up" && (
|
{system.status === 'up' && (
|
||||||
<span
|
<span
|
||||||
className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"
|
className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"
|
||||||
style={{ animationDuration: "1.5s" }}
|
style={{ animationDuration: '1.5s' }}
|
||||||
></span>
|
></span>
|
||||||
)}
|
)}
|
||||||
<span
|
<span
|
||||||
className={cn("relative inline-flex rounded-full h-3 w-3", {
|
className={cn('relative inline-flex rounded-full h-3 w-3', {
|
||||||
"bg-green-500": system.status === "up",
|
'bg-green-500': system.status === 'up',
|
||||||
"bg-red-500": system.status === "down",
|
'bg-red-500': system.status === 'down',
|
||||||
"bg-primary/40": system.status === "paused",
|
'bg-primary/40': system.status === 'paused',
|
||||||
"bg-yellow-500": system.status === "pending",
|
'bg-yellow-500': system.status === 'pending',
|
||||||
})}
|
})}
|
||||||
></span>
|
></span>
|
||||||
</span>
|
</span>
|
||||||
@@ -341,16 +292,17 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="lg:ms-auto flex items-center gap-2 max-sm:-mb-1">
|
<div className="lg:ml-auto flex items-center gap-2 max-sm:-mb-1">
|
||||||
<ChartTimeSelect className="w-full lg:w-40" />
|
<ChartTimeSelect className="w-full lg:w-40" />
|
||||||
<TooltipProvider delayDuration={100}>
|
<TooltipProvider delayDuration={100}>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
aria-label={t`Toggle grid`}
|
aria-label="Toggle grid"
|
||||||
variant="outline"
|
className={cn(
|
||||||
size="icon"
|
buttonVariants({ variant: 'outline', size: 'icon' }),
|
||||||
className="hidden lg:flex p-0 text-primary"
|
'hidden lg:flex p-0 text-primary'
|
||||||
|
)}
|
||||||
onClick={() => setGrid(!grid)}
|
onClick={() => setGrid(!grid)}
|
||||||
>
|
>
|
||||||
{grid ? (
|
{grid ? (
|
||||||
@@ -360,7 +312,7 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>{t`Toggle grid`}</TooltipContent>
|
<TooltipContent>Toggle grid</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
@@ -368,116 +320,96 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* main charts */}
|
{/* main charts */}
|
||||||
<div className="grid xl:grid-cols-2 gap-4">
|
<div className="grid lg:grid-cols-2 gap-4">
|
||||||
<ChartCard
|
<ChartCard
|
||||||
empty={dataEmpty}
|
|
||||||
grid={grid}
|
grid={grid}
|
||||||
title={_(t`CPU Usage`)}
|
title="Total CPU Usage"
|
||||||
description={t`Average system-wide CPU utilization`}
|
description="Average system-wide CPU utilization"
|
||||||
cornerEl={isLongerChart ? <SelectAvgMax store={cpuMaxStore} /> : null}
|
|
||||||
>
|
>
|
||||||
<AreaChartDefault chartData={chartData} chartName="CPU Usage" maxToggled={cpuMaxStore[0]} unit="%" />
|
<CpuChart ticks={ticks} systemData={systemStats} />
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
|
|
||||||
{containerFilterBar && (
|
{hasDockerStats && (
|
||||||
<ChartCard
|
<ChartCard
|
||||||
empty={dataEmpty}
|
|
||||||
grid={grid}
|
grid={grid}
|
||||||
title={t`Docker CPU Usage`}
|
title="Docker CPU Usage"
|
||||||
description={t`Average CPU utilization of containers`}
|
description="CPU utilization of docker containers"
|
||||||
cornerEl={containerFilterBar}
|
isContainerChart={true}
|
||||||
>
|
>
|
||||||
<ContainerChart chartData={chartData} dataKey="c" chartName="cpu" />
|
<ContainerCpuChart chartData={dockerCpuChartData} ticks={ticks} />
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ChartCard
|
<ChartCard
|
||||||
empty={dataEmpty}
|
|
||||||
grid={grid}
|
grid={grid}
|
||||||
title={t`Memory Usage`}
|
title="Total Memory Usage"
|
||||||
description={t`Triggers when memory usage exceeds a threshold.`}
|
description="Precise utilization at the recorded time"
|
||||||
>
|
>
|
||||||
<MemChart chartData={chartData} />
|
<MemChart ticks={ticks} systemData={systemStats} />
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
|
|
||||||
{containerFilterBar && (
|
{hasDockerStats && (
|
||||||
<ChartCard
|
<ChartCard
|
||||||
empty={dataEmpty}
|
|
||||||
grid={grid}
|
grid={grid}
|
||||||
title={t`Docker Memory Usage`}
|
title="Docker Memory Usage"
|
||||||
description={t`Memory usage of docker containers`}
|
description="Memory usage of docker containers"
|
||||||
cornerEl={containerFilterBar}
|
isContainerChart={true}
|
||||||
>
|
>
|
||||||
<ContainerChart chartData={chartData} chartName="mem" dataKey="m" unit=" MB" />
|
<ContainerMemChart chartData={dockerMemChartData} ticks={ticks} />
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ChartCard empty={dataEmpty} grid={grid} title={t`Disk Usage`} description={t`Usage of root partition`}>
|
<ChartCard grid={grid} title="Disk Space" description="Usage of root partition">
|
||||||
<DiskChart
|
<DiskChart
|
||||||
chartData={chartData}
|
ticks={ticks}
|
||||||
|
systemData={systemStats}
|
||||||
dataKey="stats.du"
|
dataKey="stats.du"
|
||||||
diskSize={Math.round(systemStats.at(-1)?.stats.d ?? NaN)}
|
diskSize={Math.round(systemStats.at(-1)?.stats.d ?? NaN)}
|
||||||
/>
|
/>
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
|
|
||||||
<ChartCard
|
<ChartCard grid={grid} title="Disk I/O" description="Throughput of root filesystem">
|
||||||
empty={dataEmpty}
|
<DiskIoChart
|
||||||
grid={grid}
|
ticks={ticks}
|
||||||
title={t`Disk I/O`}
|
systemData={systemStats}
|
||||||
description={t`Throughput of root filesystem`}
|
dataKeys={['stats.dw', 'stats.dr']}
|
||||||
cornerEl={isLongerChart ? <SelectAvgMax store={diskIoMaxStore} /> : null}
|
/>
|
||||||
>
|
|
||||||
<AreaChartDefault chartData={chartData} maxToggled={diskIoMaxStore[0]} chartName="dio" />
|
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
|
|
||||||
<ChartCard
|
<ChartCard
|
||||||
empty={dataEmpty}
|
|
||||||
grid={grid}
|
grid={grid}
|
||||||
title={t`Bandwidth`}
|
title="Bandwidth"
|
||||||
cornerEl={isLongerChart ? <SelectAvgMax store={bandwidthMaxStore} /> : null}
|
description="Network traffic of public interfaces"
|
||||||
description={t`Network traffic of public interfaces`}
|
|
||||||
>
|
>
|
||||||
<AreaChartDefault chartData={chartData} maxToggled={bandwidthMaxStore[0]} chartName="bw" />
|
<BandwidthChart ticks={ticks} systemData={systemStats} />
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
|
|
||||||
{containerFilterBar && containerData.length > 0 && (
|
{hasDockerStats && dockerNetChartData.length > 0 && (
|
||||||
<div
|
<div
|
||||||
ref={netCardRef}
|
ref={netCardRef}
|
||||||
className={cn({
|
className={cn({
|
||||||
"col-span-full": !grid,
|
'col-span-full': !grid,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<ChartCard
|
<ChartCard
|
||||||
empty={dataEmpty}
|
title="Docker Network I/O"
|
||||||
title={t`Docker Network I/O`}
|
description="Includes traffic between internal services"
|
||||||
description={t`Network traffic of docker containers`}
|
isContainerChart={true}
|
||||||
cornerEl={containerFilterBar}
|
|
||||||
>
|
>
|
||||||
{/* @ts-ignore */}
|
<ContainerNetChart chartData={dockerNetChartData} ticks={ticks} />
|
||||||
<ContainerChart chartData={chartData} chartName="net" dataKey="n" />
|
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(systemStats.at(-1)?.stats.su ?? 0) > 0 && (
|
{(systemStats.at(-1)?.stats.su ?? 0) > 0 && (
|
||||||
<ChartCard
|
<ChartCard grid={grid} title="Swap Usage" description="Swap space used by the system">
|
||||||
empty={dataEmpty}
|
<SwapChart ticks={ticks} systemData={systemStats} />
|
||||||
grid={grid}
|
|
||||||
title={t`Swap Usage`}
|
|
||||||
description={t`Swap space used by the system`}
|
|
||||||
>
|
|
||||||
<SwapChart chartData={chartData} />
|
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{systemStats.at(-1)?.stats.t && (
|
{systemStats.at(-1)?.stats.t && (
|
||||||
<ChartCard
|
<ChartCard grid={grid} title="Temperature" description="Temperatures of system sensors">
|
||||||
empty={dataEmpty}
|
<TemperatureChart ticks={ticks} systemData={systemStats} />
|
||||||
grid={grid}
|
|
||||||
title={t`Temperature`}
|
|
||||||
description={t`Temperatures of system sensors`}
|
|
||||||
>
|
|
||||||
<TemperatureChart chartData={chartData} />
|
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -489,28 +421,26 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
return (
|
return (
|
||||||
<div key={extraFsName} className="contents">
|
<div key={extraFsName} className="contents">
|
||||||
<ChartCard
|
<ChartCard
|
||||||
empty={dataEmpty}
|
|
||||||
grid={grid}
|
grid={grid}
|
||||||
title={`${extraFsName} ${t`Usage`}`}
|
title={`${extraFsName} Usage`}
|
||||||
description={t`Disk usage of ${extraFsName}`}
|
description={`Disk usage of ${extraFsName}`}
|
||||||
>
|
>
|
||||||
<DiskChart
|
<DiskChart
|
||||||
chartData={chartData}
|
ticks={ticks}
|
||||||
|
systemData={systemStats}
|
||||||
dataKey={`stats.efs.${extraFsName}.du`}
|
dataKey={`stats.efs.${extraFsName}.du`}
|
||||||
diskSize={Math.round(systemStats.at(-1)?.stats.efs?.[extraFsName].d ?? NaN)}
|
diskSize={Math.round(systemStats.at(-1)?.stats.efs?.[extraFsName].d ?? NaN)}
|
||||||
/>
|
/>
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
<ChartCard
|
<ChartCard
|
||||||
empty={dataEmpty}
|
|
||||||
grid={grid}
|
grid={grid}
|
||||||
title={`${extraFsName} I/O`}
|
title={`${extraFsName} I/O`}
|
||||||
description={t`Throughput of ${extraFsName}`}
|
description={`Throughput of ${extraFsName}`}
|
||||||
cornerEl={isLongerChart ? <SelectAvgMax store={diskIoMaxStore} /> : null}
|
|
||||||
>
|
>
|
||||||
<AreaChartDefault
|
<DiskIoChart
|
||||||
chartData={chartData}
|
ticks={ticks}
|
||||||
maxToggled={diskIoMaxStore[0]}
|
systemData={systemStats}
|
||||||
chartName={`efs.${extraFsName}`}
|
dataKeys={[`stats.efs.${extraFsName}.w`, `stats.efs.${extraFsName}.r`]}
|
||||||
/>
|
/>
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
</div>
|
</div>
|
||||||
@@ -528,15 +458,19 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
|
|
||||||
function ContainerFilterBar() {
|
function ContainerFilterBar() {
|
||||||
const containerFilter = useStore($containerFilter)
|
const containerFilter = useStore($containerFilter)
|
||||||
const { _ } = useLingui()
|
|
||||||
|
|
||||||
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
$containerFilter.set(e.target.value)
|
$containerFilter.set(e.target.value)
|
||||||
}, [])
|
}, []) // Use an empty dependency array to prevent re-creation
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="relative py-1 block sm:w-44 sm:absolute sm:top-2.5 sm:right-3.5">
|
||||||
<Input placeholder={_(t`Filter...`)} className="ps-4 pe-8" value={containerFilter} onChange={handleChange} />
|
<Input
|
||||||
|
placeholder="Filter..."
|
||||||
|
className="pl-4 pr-8"
|
||||||
|
value={containerFilter}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
{containerFilter && (
|
{containerFilter && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -544,34 +478,12 @@ function ContainerFilterBar() {
|
|||||||
size="icon"
|
size="icon"
|
||||||
aria-label="Clear"
|
aria-label="Clear"
|
||||||
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100"
|
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100"
|
||||||
onClick={() => $containerFilter.set("")}
|
onClick={() => $containerFilter.set('')}
|
||||||
>
|
>
|
||||||
<XIcon className="h-4 w-4" />
|
<XIcon className="h-4 w-4" />
|
||||||
</Button>
|
</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 ps-10 pe-5">
|
|
||||||
<Icon className="h-4 w-4 absolute start-4 top-1/2 -translate-y-1/2 opacity-85" />
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem key="avg" value="avg">
|
|
||||||
<Trans>Average</Trans>
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem key="max" value="max">
|
|
||||||
<Trans comment="Chart select field. Please try to keep this short.">Max 1 min</Trans>
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -580,29 +492,30 @@ function ChartCard({
|
|||||||
description,
|
description,
|
||||||
children,
|
children,
|
||||||
grid,
|
grid,
|
||||||
empty,
|
isContainerChart,
|
||||||
cornerEl,
|
|
||||||
}: {
|
}: {
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
grid?: boolean
|
grid?: boolean
|
||||||
empty?: boolean
|
isContainerChart?: boolean
|
||||||
cornerEl?: JSX.Element | null
|
|
||||||
}) {
|
}) {
|
||||||
const { isIntersecting, ref } = useIntersectionObserver()
|
const { isIntersecting, ref } = useIntersectionObserver()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className={cn("pb-2 sm:pb-4 odd:last-of-type:col-span-full", { "col-span-full": !grid })} ref={ref}>
|
<Card
|
||||||
|
className={cn('pb-2 sm:pb-4 odd:last-of-type:col-span-full', { 'col-span-full': !grid })}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
<CardHeader className="pb-5 pt-4 relative space-y-1 max-sm:py-3 max-sm:px-4">
|
<CardHeader className="pb-5 pt-4 relative space-y-1 max-sm:py-3 max-sm:px-4">
|
||||||
<CardTitle className="text-xl sm:text-2xl">{title}</CardTitle>
|
<CardTitle className="text-xl sm:text-2xl">{title}</CardTitle>
|
||||||
<CardDescription>{description}</CardDescription>
|
<CardDescription>{description}</CardDescription>
|
||||||
{cornerEl && <div className="relative py-1 block sm:w-44 sm:absolute sm:top-2.5 sm:end-3.5">{cornerEl}</div>}
|
{isContainerChart && <ContainerFilterBar />}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<div className="ps-0 w-[calc(100%-1.6em)] h-52 relative">
|
<CardContent className="pl-0 w-[calc(100%-1.6em)] h-52 relative">
|
||||||
{<Spinner msg={empty ? t`Waiting for enough records to display` : undefined} />}
|
{<Spinner />}
|
||||||
{isIntersecting && children}
|
{isIntersecting && <Suspense>{children}</Suspense>}
|
||||||
</div>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
import { LoaderCircleIcon } from "lucide-react"
|
import { LoaderCircleIcon } from 'lucide-react'
|
||||||
|
|
||||||
export default function ({ msg }: { msg?: string }) {
|
export default function () {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center h-full absolute inset-0">
|
<div className="grid place-content-center h-full absolute inset-0">
|
||||||
{msg ? (
|
<LoaderCircleIcon className="animate-spin h-10 w-10 opacity-60" />
|
||||||
<p className={"opacity-60 mb-2 text-center px-4"}>{msg}</p>
|
|
||||||
) : (
|
|
||||||
<LoaderCircleIcon className="animate-spin h-10 w-10 opacity-60" />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,24 +6,29 @@ import {
|
|||||||
SortingState,
|
SortingState,
|
||||||
getSortedRowModel,
|
getSortedRowModel,
|
||||||
flexRender,
|
flexRender,
|
||||||
VisibilityState,
|
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
useReactTable,
|
useReactTable,
|
||||||
Column,
|
Column,
|
||||||
} from "@tanstack/react-table"
|
} from '@tanstack/react-table'
|
||||||
|
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table'
|
||||||
|
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { Button, buttonVariants } from '@/components/ui/button'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuCheckboxItem,
|
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu"
|
} from '@/components/ui/dropdown-menu'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
@@ -35,45 +40,39 @@ import {
|
|||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
AlertDialogTrigger,
|
AlertDialogTrigger,
|
||||||
} from "@/components/ui/alert-dialog"
|
} from '@/components/ui/alert-dialog'
|
||||||
|
|
||||||
import { SystemRecord } from "@/types"
|
import { SystemRecord } from '@/types'
|
||||||
import {
|
import {
|
||||||
MoreHorizontalIcon,
|
MoreHorizontal,
|
||||||
ArrowUpDownIcon,
|
ArrowUpDown,
|
||||||
MemoryStickIcon,
|
Server,
|
||||||
|
Cpu,
|
||||||
|
MemoryStick,
|
||||||
|
HardDrive,
|
||||||
CopyIcon,
|
CopyIcon,
|
||||||
PauseCircleIcon,
|
PauseCircleIcon,
|
||||||
PlayCircleIcon,
|
PlayCircleIcon,
|
||||||
Trash2Icon,
|
Trash2Icon,
|
||||||
WifiIcon,
|
WifiIcon,
|
||||||
HardDriveIcon,
|
} from 'lucide-react'
|
||||||
ServerIcon,
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
CpuIcon,
|
import { $hubVersion, $systems, pb } from '@/lib/stores'
|
||||||
ChevronDownIcon,
|
import { useStore } from '@nanostores/react'
|
||||||
} from "lucide-react"
|
import { cn, copyToClipboard, isReadOnlyUser } from '@/lib/utils'
|
||||||
import { useEffect, useMemo, useState } from "react"
|
import AlertsButton from '../table-alerts'
|
||||||
import { $hubVersion, $systems, pb } from "@/lib/stores"
|
import { navigate } from '../router'
|
||||||
import { useStore } from "@nanostores/react"
|
|
||||||
import { cn, copyToClipboard, decimalString, isReadOnlyUser, useLocalStorage } from "@/lib/utils"
|
|
||||||
import AlertsButton from "../alerts/alert-button"
|
|
||||||
import { navigate } from "../router"
|
|
||||||
import { EthernetIcon } from "../ui/icons"
|
|
||||||
import { Trans, t } from "@lingui/macro"
|
|
||||||
import { useLingui } from "@lingui/react"
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card"
|
|
||||||
import { Input } from "../ui/input"
|
|
||||||
|
|
||||||
function CellFormatter(info: CellContext<SystemRecord, unknown>) {
|
function CellFormatter(info: CellContext<SystemRecord, unknown>) {
|
||||||
const val = info.getValue() as number
|
const val = info.getValue() as number
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-1 items-center tabular-nums tracking-tight">
|
<div className="flex gap-1 items-center tabular-nums tracking-tight">
|
||||||
<span className="min-w-[3.5em]">{decimalString(val, 1)}%</span>
|
<span className="min-w-[3.5em]">{val.toFixed(1)}%</span>
|
||||||
<span className="grow min-w-10 block bg-muted h-[1em] relative rounded-sm overflow-hidden">
|
<span className="grow min-w-10 block bg-muted h-[1em] relative rounded-sm overflow-hidden">
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute inset-0 w-full h-full origin-left",
|
'absolute inset-0 w-full h-full origin-left',
|
||||||
(val < 65 && "bg-green-500") || (val < 90 && "bg-yellow-500") || "bg-red-600"
|
(val < 65 && 'bg-green-500') || (val < 90 && 'bg-yellow-500') || 'bg-red-600'
|
||||||
)}
|
)}
|
||||||
style={{ transform: `scalex(${val}%)` }}
|
style={{ transform: `scalex(${val}%)` }}
|
||||||
></span>
|
></span>
|
||||||
@@ -82,60 +81,60 @@ function CellFormatter(info: CellContext<SystemRecord, unknown>) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function sortableHeader(column: Column<SystemRecord, unknown>, Icon: any, hideSortIcon = false) {
|
function sortableHeader(
|
||||||
|
column: Column<SystemRecord, unknown>,
|
||||||
|
name: string,
|
||||||
|
Icon: any,
|
||||||
|
hideSortIcon = false
|
||||||
|
) {
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="h-9 px-3 flex"
|
className="h-9 px-3"
|
||||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
|
||||||
>
|
>
|
||||||
<Icon className="me-2 h-4 w-4" />
|
<Icon className="mr-2 h-4 w-4" />
|
||||||
{column.id}
|
{name}
|
||||||
{!hideSortIcon && <ArrowUpDownIcon className="ms-2 h-4 w-4" />}
|
{!hideSortIcon && <ArrowUpDown className="ml-2 h-4 w-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SystemsTable() {
|
export default function SystemsTable({ filter }: { filter?: string }) {
|
||||||
const data = useStore($systems)
|
const data = useStore($systems)
|
||||||
const hubVersion = useStore($hubVersion)
|
const hubVersion = useStore($hubVersion)
|
||||||
const [filter, setFilter] = useState<string>()
|
|
||||||
const [sorting, setSorting] = useState<SortingState>([])
|
const [sorting, setSorting] = useState<SortingState>([])
|
||||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
||||||
const [columnVisibility, setColumnVisibility] = useLocalStorage<VisibilityState>("cols", {})
|
|
||||||
const { i18n } = useLingui()
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (filter !== undefined) {
|
if (filter !== undefined) {
|
||||||
table.getColumn(t`System`)?.setFilterValue(filter)
|
table.getColumn('name')?.setFilterValue(filter)
|
||||||
}
|
}
|
||||||
}, [filter])
|
}, [filter])
|
||||||
|
|
||||||
const columns = useMemo(() => {
|
const columns: ColumnDef<SystemRecord>[] = useMemo(() => {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
// size: 200,
|
// size: 200,
|
||||||
size: 200,
|
size: 200,
|
||||||
minSize: 0,
|
minSize: 0,
|
||||||
accessorKey: "name",
|
accessorKey: 'name',
|
||||||
id: t`System`,
|
|
||||||
enableHiding: false,
|
|
||||||
cell: (info) => {
|
cell: (info) => {
|
||||||
const { status } = info.row.original
|
const { status } = info.row.original
|
||||||
return (
|
return (
|
||||||
<span className="flex gap-0.5 items-center text-base md:pe-5">
|
<span className="flex gap-0.5 items-center text-base md:pr-5">
|
||||||
<span
|
<span
|
||||||
className={cn("w-2 h-2 left-0 rounded-full", {
|
className={cn('w-2 h-2 left-0 rounded-full', {
|
||||||
"bg-green-500": status === "up",
|
'bg-green-500': status === 'up',
|
||||||
"bg-red-500": status === "down",
|
'bg-red-500': status === 'down',
|
||||||
"bg-primary/40": status === "paused",
|
'bg-primary/40': status === 'paused',
|
||||||
"bg-yellow-500": status === "pending",
|
'bg-yellow-500': status === 'pending',
|
||||||
})}
|
})}
|
||||||
style={{ marginBottom: "-1px" }}
|
style={{ marginBottom: '-1px' }}
|
||||||
></span>
|
></span>
|
||||||
<Button
|
<Button
|
||||||
data-nolink
|
data-nolink
|
||||||
variant={"ghost"}
|
variant={'ghost'}
|
||||||
className="text-primary/90 h-7 px-1.5 gap-1.5"
|
className="text-primary/90 h-7 px-1.5 gap-1.5"
|
||||||
onClick={() => copyToClipboard(info.getValue() as string)}
|
onClick={() => copyToClipboard(info.getValue() as string)}
|
||||||
>
|
>
|
||||||
@@ -145,137 +144,113 @@ export default function SystemsTable() {
|
|||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
header: ({ column }) => sortableHeader(column, ServerIcon),
|
header: ({ column }) => sortableHeader(column, 'System', Server),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "info.cpu",
|
accessorKey: 'info.cpu',
|
||||||
id: t`CPU`,
|
|
||||||
invertSorting: true,
|
|
||||||
cell: CellFormatter,
|
cell: CellFormatter,
|
||||||
header: ({ column }) => sortableHeader(column, CpuIcon),
|
header: ({ column }) => sortableHeader(column, 'CPU', Cpu),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "info.mp",
|
accessorKey: 'info.mp',
|
||||||
id: t`Memory`,
|
|
||||||
invertSorting: true,
|
|
||||||
cell: CellFormatter,
|
cell: CellFormatter,
|
||||||
header: ({ column }) => sortableHeader(column, MemoryStickIcon),
|
header: ({ column }) => sortableHeader(column, 'Memory', MemoryStick),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "info.dp",
|
accessorKey: 'info.dp',
|
||||||
id: t`Disk`,
|
|
||||||
invertSorting: true,
|
|
||||||
cell: CellFormatter,
|
cell: CellFormatter,
|
||||||
header: ({ column }) => sortableHeader(column, HardDriveIcon),
|
header: ({ column }) => sortableHeader(column, 'Disk', HardDrive),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorFn: (originalRow) => originalRow.info.b || 0,
|
accessorKey: 'info.v',
|
||||||
id: t`Net`,
|
|
||||||
invertSorting: true,
|
|
||||||
size: 115,
|
|
||||||
header: ({ column }) => sortableHeader(column, EthernetIcon),
|
|
||||||
cell: (info) => {
|
|
||||||
const val = info.getValue() as number
|
|
||||||
return (
|
|
||||||
<span className="tabular-nums whitespace-nowrap ps-1">{decimalString(val, val >= 100 ? 1 : 2)} MB/s</span>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "info.v",
|
|
||||||
id: t`Agent`,
|
|
||||||
invertSorting: true,
|
|
||||||
size: 50,
|
size: 50,
|
||||||
header: ({ column }) => sortableHeader(column, WifiIcon, true),
|
|
||||||
cell: (info) => {
|
cell: (info) => {
|
||||||
const version = info.getValue() as string
|
const version = info.getValue() as string
|
||||||
if (!version || !hubVersion) {
|
if (!version || !hubVersion) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<span className="flex gap-2 items-center md:pe-5 tabular-nums ps-1">
|
<span className="flex gap-2 items-center md:pr-5 tabular-nums pl-1">
|
||||||
<span
|
<span
|
||||||
className={cn("w-2 h-2 left-0 rounded-full", version === hubVersion ? "bg-green-500" : "bg-yellow-500")}
|
className={cn(
|
||||||
style={{ marginBottom: "-1px" }}
|
'w-2 h-2 left-0 rounded-full',
|
||||||
|
version === hubVersion ? 'bg-green-500' : 'bg-yellow-500'
|
||||||
|
)}
|
||||||
|
style={{ marginBottom: '-1px' }}
|
||||||
></span>
|
></span>
|
||||||
<span>{info.getValue() as string}</span>
|
<span>{info.getValue() as string}</span>
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
header: ({ column }) => sortableHeader(column, 'Agent', WifiIcon, true),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: t({ message: "Actions", comment: "Table column" }),
|
id: 'actions',
|
||||||
size: 120,
|
size: 120,
|
||||||
|
// minSize: 0,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const { id, name, status, host } = row.original
|
const { id, name, status, host } = row.original
|
||||||
return (
|
return (
|
||||||
<div className={"flex justify-end items-center gap-1"}>
|
<div className={'flex justify-end items-center gap-1'}>
|
||||||
<AlertsButton system={row.original} />
|
<AlertsButton system={row.original} />
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" size={"icon"} data-nolink>
|
<Button variant="ghost" size={'icon'} data-nolink>
|
||||||
<span className="sr-only">
|
<span className="sr-only">Open menu</span>
|
||||||
<Trans>Open menu</Trans>
|
<MoreHorizontal className="w-5" />
|
||||||
</span>
|
|
||||||
<MoreHorizontalIcon className="w-5" />
|
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className={cn(isReadOnlyUser() && "hidden")}
|
className={cn(isReadOnlyUser() && 'hidden')}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
pb.collection("systems").update(id, {
|
pb.collection('systems').update(id, {
|
||||||
status: status === "paused" ? "pending" : "paused",
|
status: status === 'paused' ? 'pending' : 'paused',
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{status === "paused" ? (
|
{status === 'paused' ? (
|
||||||
<>
|
<>
|
||||||
<PlayCircleIcon className="me-2.5 h-4 w-4" />
|
<PlayCircleIcon className="mr-2.5 h-4 w-4" />
|
||||||
<Trans>Resume</Trans>
|
Resume
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<PauseCircleIcon className="me-2.5 h-4 w-4" />
|
<PauseCircleIcon className="mr-2.5 h-4 w-4" />
|
||||||
<Trans>Pause</Trans>
|
Pause
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => copyToClipboard(host)}>
|
<DropdownMenuItem onClick={() => copyToClipboard(host)}>
|
||||||
<CopyIcon className="me-2.5 h-4 w-4" />
|
<CopyIcon className="mr-2.5 h-4 w-4" />
|
||||||
<Trans>Copy host</Trans>
|
Copy host
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator className={cn(isReadOnlyUser() && "hidden")} />
|
<DropdownMenuSeparator className={cn(isReadOnlyUser() && 'hidden')} />
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<DropdownMenuItem className={cn(isReadOnlyUser() && "hidden")}>
|
<DropdownMenuItem className={cn(isReadOnlyUser() && 'hidden')}>
|
||||||
<Trash2Icon className="me-2.5 h-4 w-4" />
|
<Trash2Icon className="mr-2.5 h-4 w-4" />
|
||||||
<Trans>Delete</Trans>
|
Delete
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>
|
<AlertDialogTitle>Are you sure you want to delete {name}?</AlertDialogTitle>
|
||||||
<Trans>Are you sure you want to delete {name}?</Trans>
|
|
||||||
</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
<Trans>
|
This action cannot be undone. This will permanently delete all current records
|
||||||
This action cannot be undone. This will permanently delete all current records for {name} from
|
for <code className={'bg-muted rounded-sm px-1'}>{name}</code> from the
|
||||||
the database.
|
database.
|
||||||
</Trans>
|
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
<Trans>Cancel</Trans>
|
|
||||||
</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
className={cn(buttonVariants({ variant: "destructive" }))}
|
className={cn(buttonVariants({ variant: 'destructive' }))}
|
||||||
onClick={() => pb.collection("systems").delete(id)}
|
onClick={() => pb.collection('systems').delete(id)}
|
||||||
>
|
>
|
||||||
<Trans>Continue</Trans>
|
Continue
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
@@ -284,8 +259,8 @@ export default function SystemsTable() {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
] as ColumnDef<SystemRecord>[]
|
]
|
||||||
}, [hubVersion, i18n.locale])
|
}, [hubVersion])
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data,
|
data,
|
||||||
@@ -295,11 +270,9 @@ export default function SystemsTable() {
|
|||||||
getSortedRowModel: getSortedRowModel(),
|
getSortedRowModel: getSortedRowModel(),
|
||||||
onColumnFiltersChange: setColumnFilters,
|
onColumnFiltersChange: setColumnFilters,
|
||||||
getFilteredRowModel: getFilteredRowModel(),
|
getFilteredRowModel: getFilteredRowModel(),
|
||||||
onColumnVisibilityChange: setColumnVisibility,
|
|
||||||
state: {
|
state: {
|
||||||
sorting,
|
sorting,
|
||||||
columnFilters,
|
columnFilters,
|
||||||
columnVisibility,
|
|
||||||
},
|
},
|
||||||
defaultColumn: {
|
defaultColumn: {
|
||||||
minSize: 0,
|
minSize: 0,
|
||||||
@@ -309,102 +282,64 @@ export default function SystemsTable() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<div className="rounded-md border overflow-hidden">
|
||||||
<CardHeader className="pb-5 px-2 sm:px-6 max-sm:pt-5 max-sm:pb-1">
|
<Table>
|
||||||
<div className="grid md:flex gap-5 w-full items-end">
|
<TableHeader className="bg-muted/40">
|
||||||
<div className="px-2 sm:px-1">
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
<CardTitle className="mb-2.5">
|
<TableRow key={headerGroup.id}>
|
||||||
<Trans>All Systems</Trans>
|
{headerGroup.headers.map((header) => {
|
||||||
</CardTitle>
|
return (
|
||||||
<CardDescription>
|
<TableHead className="px-2" key={header.id}>
|
||||||
<Trans>Updated in real time. Click on a system to view information.</Trans>
|
{header.isPlaceholder
|
||||||
</CardDescription>
|
? null
|
||||||
</div>
|
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
<div className="flex gap-2 ms-auto w-full md:w-80">
|
</TableHead>
|
||||||
<Input placeholder={t`Filter...`} onChange={(e) => setFilter(e.target.value)} className="px-4" />
|
)
|
||||||
<DropdownMenu>
|
})}
|
||||||
<DropdownMenuTrigger asChild>
|
</TableRow>
|
||||||
<Button variant="outline">
|
))}
|
||||||
<Trans comment="Context: table columns">Columns</Trans>{" "}
|
</TableHeader>
|
||||||
<ChevronDownIcon className="ms-1.5 h-4 w-4 opacity-90" />
|
<TableBody>
|
||||||
</Button>
|
{table.getRowModel().rows?.length ? (
|
||||||
</DropdownMenuTrigger>
|
table.getRowModel().rows.map((row) => (
|
||||||
<DropdownMenuContent align="end">
|
<TableRow
|
||||||
{table
|
key={row.original.id}
|
||||||
.getAllColumns()
|
data-state={row.getIsSelected() && 'selected'}
|
||||||
.filter((column) => column.getCanHide())
|
className={cn('cursor-pointer transition-opacity', {
|
||||||
.map((column) => {
|
'opacity-50': row.original.status === 'paused',
|
||||||
return (
|
})}
|
||||||
<DropdownMenuCheckboxItem
|
onClick={(e) => {
|
||||||
key={column.id}
|
const target = e.target as HTMLElement
|
||||||
checked={column.getIsVisible()}
|
if (!target.closest('[data-nolink]') && e.currentTarget.contains(target)) {
|
||||||
onCheckedChange={(value) => column.toggleVisibility(!!value)}
|
navigate(`/system/${encodeURIComponent(row.original.name)}`)
|
||||||
>
|
}
|
||||||
{column.id}
|
}}
|
||||||
</DropdownMenuCheckboxItem>
|
>
|
||||||
)
|
{row.getVisibleCells().map((cell) => (
|
||||||
})}
|
<TableCell
|
||||||
</DropdownMenuContent>
|
key={cell.id}
|
||||||
</DropdownMenu>
|
style={{
|
||||||
</div>
|
width:
|
||||||
</div>
|
cell.column.getSize() === Number.MAX_SAFE_INTEGER
|
||||||
</CardHeader>
|
? 'auto'
|
||||||
<CardContent className="max-sm:p-2">
|
: cell.column.getSize(),
|
||||||
<div className="rounded-md border overflow-hidden">
|
|
||||||
<Table>
|
|
||||||
<TableHeader className="bg-muted/40">
|
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
|
||||||
<TableRow key={headerGroup.id}>
|
|
||||||
{headerGroup.headers.map((header) => {
|
|
||||||
return (
|
|
||||||
<TableHead className="px-2" key={header.id}>
|
|
||||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
|
||||||
</TableHead>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{table.getRowModel().rows?.length ? (
|
|
||||||
table.getRowModel().rows.map((row) => (
|
|
||||||
<TableRow
|
|
||||||
key={row.original.id}
|
|
||||||
data-state={row.getIsSelected() && "selected"}
|
|
||||||
className={cn("cursor-pointer transition-opacity", {
|
|
||||||
"opacity-50": row.original.status === "paused",
|
|
||||||
})}
|
|
||||||
onClick={(e) => {
|
|
||||||
const target = e.target as HTMLElement
|
|
||||||
if (!target.closest("[data-nolink]") && e.currentTarget.contains(target)) {
|
|
||||||
navigate(`/system/${encodeURIComponent(row.original.name)}`)
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
|
className={cn('overflow-hidden relative', data.length > 10 ? 'py-2' : 'py-2.5')}
|
||||||
>
|
>
|
||||||
{row.getVisibleCells().map((cell) => (
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
<TableCell
|
|
||||||
key={cell.id}
|
|
||||||
style={{
|
|
||||||
width: cell.column.getSize() === Number.MAX_SAFE_INTEGER ? "auto" : cell.column.getSize(),
|
|
||||||
}}
|
|
||||||
className={cn("overflow-hidden relative", data.length > 10 ? "py-2" : "py-2.5")}
|
|
||||||
>
|
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
||||||
</TableCell>
|
|
||||||
))}
|
|
||||||
</TableRow>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
|
||||||
<Trans>No systems found.</Trans>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
))}
|
||||||
)}
|
</TableRow>
|
||||||
</TableBody>
|
))
|
||||||
</Table>
|
) : (
|
||||||
</div>
|
<TableRow>
|
||||||
</CardContent>
|
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||||
</Card>
|
No systems found
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
228
beszel/site/src/components/table-alerts.tsx
Normal file
228
beszel/site/src/components/table-alerts.tsx
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createContext, useContext, useEffect, useState } from "react"
|
import { createContext, useContext, useEffect, useState } from 'react'
|
||||||
|
|
||||||
type Theme = "dark" | "light" | "system"
|
type Theme = 'dark' | 'light' | 'system'
|
||||||
|
|
||||||
type ThemeProviderProps = {
|
type ThemeProviderProps = {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
@@ -14,7 +14,7 @@ type ThemeProviderState = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const initialState: ThemeProviderState = {
|
const initialState: ThemeProviderState = {
|
||||||
theme: "system",
|
theme: 'system',
|
||||||
setTheme: () => null,
|
setTheme: () => null,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,19 +22,23 @@ const ThemeProviderContext = createContext<ThemeProviderState>(initialState)
|
|||||||
|
|
||||||
export function ThemeProvider({
|
export function ThemeProvider({
|
||||||
children,
|
children,
|
||||||
defaultTheme = "system",
|
defaultTheme = 'system',
|
||||||
storageKey = "ui-theme",
|
storageKey = 'ui-theme',
|
||||||
...props
|
...props
|
||||||
}: ThemeProviderProps) {
|
}: ThemeProviderProps) {
|
||||||
const [theme, setTheme] = useState<Theme>(() => (localStorage.getItem(storageKey) as Theme) || defaultTheme)
|
const [theme, setTheme] = useState<Theme>(
|
||||||
|
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
|
||||||
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const root = window.document.documentElement
|
const root = window.document.documentElement
|
||||||
|
|
||||||
root.classList.remove("light", "dark")
|
root.classList.remove('light', 'dark')
|
||||||
|
|
||||||
if (theme === "system") {
|
if (theme === 'system') {
|
||||||
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
|
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
|
? 'dark'
|
||||||
|
: 'light'
|
||||||
|
|
||||||
root.classList.add(systemTheme)
|
root.classList.add(systemTheme)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import * as React from "react"
|
import * as React from 'react'
|
||||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from '@/lib/utils'
|
||||||
import { buttonVariants } from "@/components/ui/button"
|
import { buttonVariants } from '@/components/ui/button'
|
||||||
|
|
||||||
const AlertDialog = AlertDialogPrimitive.Root
|
const AlertDialog = AlertDialogPrimitive.Root
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ const AlertDialogOverlay = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<AlertDialogPrimitive.Overlay
|
<AlertDialogPrimitive.Overlay
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
'fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -34,7 +34,7 @@ const AlertDialogContent = React.forwardRef<
|
|||||||
<AlertDialogPrimitive.Content
|
<AlertDialogPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -44,20 +44,27 @@ const AlertDialogContent = React.forwardRef<
|
|||||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
||||||
|
|
||||||
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
<div className={cn("flex flex-col space-y-2 text-center sm:text-start", className)} {...props} />
|
<div className={cn('flex flex-col space-y-2 text-center sm:text-left', className)} {...props} />
|
||||||
)
|
)
|
||||||
AlertDialogHeader.displayName = "AlertDialogHeader"
|
AlertDialogHeader.displayName = 'AlertDialogHeader'
|
||||||
|
|
||||||
const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-2", className)} {...props} />
|
<div
|
||||||
|
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
AlertDialogFooter.displayName = "AlertDialogFooter"
|
AlertDialogFooter.displayName = 'AlertDialogFooter'
|
||||||
|
|
||||||
const AlertDialogTitle = React.forwardRef<
|
const AlertDialogTitle = React.forwardRef<
|
||||||
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<AlertDialogPrimitive.Title ref={ref} className={cn("text-lg font-semibold", className)} {...props} />
|
<AlertDialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-lg font-semibold', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
))
|
))
|
||||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
|
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
|
||||||
|
|
||||||
@@ -65,7 +72,11 @@ const AlertDialogDescription = React.forwardRef<
|
|||||||
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<AlertDialogPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
|
<AlertDialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-sm text-muted-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
))
|
))
|
||||||
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName
|
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName
|
||||||
|
|
||||||
@@ -83,7 +94,7 @@ const AlertDialogCancel = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<AlertDialogPrimitive.Cancel
|
<AlertDialogPrimitive.Cancel
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(buttonVariants({ variant: "outline" }), "mt-2 sm:mt-0", className)}
|
className={cn(buttonVariants({ variant: 'outline' }), 'mt-2 sm:mt-0', className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
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~*]:ps-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~*]:ps-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 }
|
|
||||||
@@ -4,26 +4,33 @@ import { cva, type VariantProps } from "class-variance-authority"
|
|||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const badgeVariants = cva(
|
const badgeVariants = cva(
|
||||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
default:
|
||||||
secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||||
destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
secondary:
|
||||||
outline: "text-foreground",
|
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
},
|
destructive:
|
||||||
},
|
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||||
defaultVariants: {
|
outline: "text-foreground",
|
||||||
variant: "default",
|
},
|
||||||
},
|
},
|
||||||
}
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
|
export interface BadgeProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
|
return (
|
||||||
|
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Badge, badgeVariants }
|
export { Badge, badgeVariants }
|
||||||
|
|||||||
@@ -1,31 +1,31 @@
|
|||||||
import * as React from "react"
|
import * as React from 'react'
|
||||||
import { Slot } from "@radix-ui/react-slot"
|
import { Slot } from '@radix-ui/react-slot'
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||||
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||||
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
link: 'text-primary underline-offset-4 hover:underline',
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: "h-10 px-4 py-2",
|
default: 'h-10 px-4 py-2',
|
||||||
sm: "h-9 rounded-md px-3",
|
sm: 'h-9 rounded-md px-3',
|
||||||
lg: "h-11 rounded-md px-8",
|
lg: 'h-11 rounded-md px-8',
|
||||||
icon: "h-10 w-10",
|
icon: 'h-10 w-10',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: "default",
|
variant: 'default',
|
||||||
size: "default",
|
size: 'default',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -38,10 +38,12 @@ export interface ButtonProps
|
|||||||
|
|
||||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
const Comp = asChild ? Slot : "button"
|
const Comp = asChild ? Slot : 'button'
|
||||||
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
|
return (
|
||||||
|
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
|
||||||
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
Button.displayName = "Button"
|
Button.displayName = 'Button'
|
||||||
|
|
||||||
export { Button, buttonVariants }
|
export { Button, buttonVariants }
|
||||||
|
|||||||
@@ -2,40 +2,78 @@ import * as React from "react"
|
|||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
|
const Card = React.forwardRef<
|
||||||
<div ref={ref} className={cn("rounded-lg border bg-card text-card-foreground shadow-sm", className)} {...props} />
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
))
|
))
|
||||||
Card.displayName = "Card"
|
Card.displayName = "Card"
|
||||||
|
|
||||||
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
const CardHeader = React.forwardRef<
|
||||||
({ className, ...props }, ref) => (
|
HTMLDivElement,
|
||||||
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
)
|
>(({ className, ...props }, ref) => (
|
||||||
)
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
CardHeader.displayName = "CardHeader"
|
CardHeader.displayName = "CardHeader"
|
||||||
|
|
||||||
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
const CardTitle = React.forwardRef<
|
||||||
({ className, ...props }, ref) => (
|
HTMLParagraphElement,
|
||||||
<h3 ref={ref} className={cn("text-2xl font-semibold leading-none tracking-tight", className)} {...props} />
|
React.HTMLAttributes<HTMLHeadingElement>
|
||||||
)
|
>(({ className, ...props }, ref) => (
|
||||||
)
|
<h3
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"text-2xl font-semibold leading-none tracking-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
CardTitle.displayName = "CardTitle"
|
CardTitle.displayName = "CardTitle"
|
||||||
|
|
||||||
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
const CardDescription = React.forwardRef<
|
||||||
({ className, ...props }, ref) => (
|
HTMLParagraphElement,
|
||||||
<p ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
)
|
>(({ className, ...props }, ref) => (
|
||||||
)
|
<p
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
CardDescription.displayName = "CardDescription"
|
CardDescription.displayName = "CardDescription"
|
||||||
|
|
||||||
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
const CardContent = React.forwardRef<
|
||||||
({ className, ...props }, ref) => <div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
HTMLDivElement,
|
||||||
)
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||||
|
))
|
||||||
CardContent.displayName = "CardContent"
|
CardContent.displayName = "CardContent"
|
||||||
|
|
||||||
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
const CardFooter = React.forwardRef<
|
||||||
({ className, ...props }, ref) => <div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
|
HTMLDivElement,
|
||||||
)
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex items-center p-6 pt-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
CardFooter.displayName = "CardFooter"
|
CardFooter.displayName = "CardFooter"
|
||||||
|
|
||||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||||
|
|||||||
@@ -1,99 +1,102 @@
|
|||||||
import * as React from "react"
|
import * as React from 'react'
|
||||||
import * as RechartsPrimitive from "recharts"
|
import * as RechartsPrimitive from 'recharts'
|
||||||
|
|
||||||
import { chartTimeData, cn } from "@/lib/utils"
|
import { cn } from '@/lib/utils'
|
||||||
import { ChartData } from "@/types"
|
|
||||||
|
|
||||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||||
const THEMES = { light: "", dark: ".dark" } as const
|
const THEMES = { light: '', dark: '.dark' } as const
|
||||||
|
|
||||||
export type ChartConfig = {
|
export type ChartConfig = {
|
||||||
[k in string]: {
|
[k in string]: {
|
||||||
label?: React.ReactNode
|
label?: React.ReactNode
|
||||||
icon?: React.ComponentType
|
icon?: React.ComponentType
|
||||||
} & ({ color?: string; theme?: never } | { color?: never; theme: Record<keyof typeof THEMES, string> })
|
} & (
|
||||||
|
| { color?: string; theme?: never }
|
||||||
|
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// type ChartContextProps = {
|
type ChartContextProps = {
|
||||||
// config: ChartConfig
|
config: ChartConfig
|
||||||
// }
|
}
|
||||||
|
|
||||||
// const ChartContext = React.createContext<ChartContextProps | null>(null)
|
const ChartContext = React.createContext<ChartContextProps | null>(null)
|
||||||
|
|
||||||
// function useChart() {
|
function useChart() {
|
||||||
// const context = React.useContext(ChartContext)
|
const context = React.useContext(ChartContext)
|
||||||
|
|
||||||
// if (!context) {
|
if (!context) {
|
||||||
// throw new Error('useChart must be used within a <ChartContainer />')
|
throw new Error('useChart must be used within a <ChartContainer />')
|
||||||
// }
|
}
|
||||||
|
|
||||||
// return context
|
return context
|
||||||
// }
|
}
|
||||||
|
|
||||||
const ChartContainer = React.forwardRef<
|
const ChartContainer = React.forwardRef<
|
||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
React.ComponentProps<"div"> & {
|
React.ComponentProps<'div'> & {
|
||||||
// config: ChartConfig
|
config: ChartConfig
|
||||||
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>["children"]
|
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>['children']
|
||||||
}
|
}
|
||||||
>(({ id, className, children, ...props }, ref) => {
|
>(({ id, className, children, config, ...props }, ref) => {
|
||||||
const uniqueId = React.useId()
|
const uniqueId = React.useId()
|
||||||
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
|
const chartId = `chart-${id || uniqueId.replace(/:/g, '')}`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
//<ChartContext.Provider value={{ config }}>
|
<ChartContext.Provider value={{ config }}>
|
||||||
<div
|
<div
|
||||||
data-chart={chartId}
|
data-chart={chartId}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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",
|
"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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{/* <ChartStyle id={chartId} config={config} /> */}
|
<ChartStyle id={chartId} config={config} />
|
||||||
<RechartsPrimitive.ResponsiveContainer>{children}</RechartsPrimitive.ResponsiveContainer>
|
<RechartsPrimitive.ResponsiveContainer>{children}</RechartsPrimitive.ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
//</ChartContext.Provider>
|
</ChartContext.Provider>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
ChartContainer.displayName = "Chart"
|
ChartContainer.displayName = 'Chart'
|
||||||
|
|
||||||
// const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||||
// const colorConfig = Object.entries(config).filter(([_, config]) => config.theme || config.color)
|
const colorConfig = Object.entries(config).filter(([_, config]) => config.theme || config.color)
|
||||||
|
|
||||||
// if (!colorConfig.length) {
|
if (!colorConfig.length) {
|
||||||
// return null
|
return null
|
||||||
// }
|
}
|
||||||
|
|
||||||
// return (
|
return (
|
||||||
// <style
|
<style
|
||||||
// dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
// __html: Object.entries(THEMES).map(
|
__html: Object.entries(THEMES).map(
|
||||||
// ([theme, prefix]) => `
|
([theme, prefix]) => `
|
||||||
// ${prefix} [data-chart=${id}] {
|
${prefix} [data-chart=${id}] {
|
||||||
// ${colorConfig
|
${colorConfig
|
||||||
// .map(([key, itemConfig]) => {
|
.map(([key, itemConfig]) => {
|
||||||
// const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color
|
const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color
|
||||||
// return color ? ` --color-${key}: ${color};` : null
|
return color ? ` --color-${key}: ${color};` : null
|
||||||
// })
|
})
|
||||||
// .join('\n')}
|
.join('\n')}
|
||||||
// }
|
}
|
||||||
// `
|
`
|
||||||
// ),
|
),
|
||||||
// }}
|
}}
|
||||||
// />
|
/>
|
||||||
// )
|
)
|
||||||
// }
|
}
|
||||||
|
|
||||||
const ChartTooltip = RechartsPrimitive.Tooltip
|
const ChartTooltip = RechartsPrimitive.Tooltip
|
||||||
|
|
||||||
const ChartTooltipContent = React.forwardRef<
|
const ChartTooltipContent = React.forwardRef<
|
||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||||
React.ComponentProps<"div"> & {
|
React.ComponentProps<'div'> & {
|
||||||
hideLabel?: boolean
|
hideLabel?: boolean
|
||||||
indicator?: "line" | "dot" | "dashed"
|
hideIndicator?: boolean
|
||||||
|
indicator?: 'line' | 'dot' | 'dashed'
|
||||||
nameKey?: string
|
nameKey?: string
|
||||||
labelKey?: string
|
labelKey?: string
|
||||||
unit?: string
|
unit?: string
|
||||||
@@ -106,8 +109,9 @@ const ChartTooltipContent = React.forwardRef<
|
|||||||
active,
|
active,
|
||||||
payload,
|
payload,
|
||||||
className,
|
className,
|
||||||
indicator = "line",
|
indicator = 'dot',
|
||||||
hideLabel = false,
|
hideLabel = false,
|
||||||
|
hideIndicator = false,
|
||||||
label,
|
label,
|
||||||
labelFormatter,
|
labelFormatter,
|
||||||
labelClassName,
|
labelClassName,
|
||||||
@@ -122,8 +126,7 @@ const ChartTooltipContent = React.forwardRef<
|
|||||||
},
|
},
|
||||||
ref
|
ref
|
||||||
) => {
|
) => {
|
||||||
// const { config } = useChart()
|
const { config } = useChart()
|
||||||
const config = {}
|
|
||||||
|
|
||||||
React.useMemo(() => {
|
React.useMemo(() => {
|
||||||
if (filter) {
|
if (filter) {
|
||||||
@@ -141,40 +144,44 @@ const ChartTooltipContent = React.forwardRef<
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [item] = payload
|
const [item] = payload
|
||||||
const key = `${labelKey || item.name || "value"}`
|
const key = `${labelKey || item.dataKey || item.name || 'value'}`
|
||||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||||
const value = !labelKey && typeof label === "string" ? label : itemConfig?.label
|
const value =
|
||||||
|
!labelKey && typeof label === 'string'
|
||||||
|
? config[label as keyof typeof config]?.label || label
|
||||||
|
: itemConfig?.label
|
||||||
|
|
||||||
if (labelFormatter) {
|
if (labelFormatter) {
|
||||||
return <div className={cn("font-medium", labelClassName)}>{labelFormatter(value, payload)}</div>
|
return (
|
||||||
|
<div className={cn('font-medium', labelClassName)}>{labelFormatter(value, payload)}</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return <div className={cn("font-medium", labelClassName)}>{value}</div>
|
return <div className={cn('font-medium', labelClassName)}>{value}</div>
|
||||||
}, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey])
|
}, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey])
|
||||||
|
|
||||||
if (!active || !payload?.length) {
|
if (!active || !payload?.length) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// const nestLabel = payload.length === 1 && indicator !== 'dot'
|
const nestLabel = payload.length === 1 && indicator !== 'dot'
|
||||||
const nestLabel = false
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"grid min-w-[7rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
|
'grid min-w-[7rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{!nestLabel ? tooltipLabel : null}
|
{!nestLabel ? tooltipLabel : null}
|
||||||
<div className="grid gap-1.5">
|
<div className="grid gap-1.5">
|
||||||
{payload.map((item, index) => {
|
{payload.map((item, index) => {
|
||||||
const key = `${nameKey || item.name || item.dataKey || "value"}`
|
const key = `${nameKey || item.name || item.dataKey || 'value'}`
|
||||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||||
const indicatorColor = color || item.payload.fill || item.color
|
const indicatorColor = color || item.payload.fill || item.color
|
||||||
|
|
||||||
@@ -182,8 +189,8 @@ const ChartTooltipContent = React.forwardRef<
|
|||||||
<div
|
<div
|
||||||
key={item?.name || item.dataKey}
|
key={item?.name || item.dataKey}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
|
'flex w-full items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground',
|
||||||
indicator === "dot" && "items-center"
|
indicator === 'dot' && 'items-center'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{formatter && item?.value !== undefined && item.name ? (
|
{formatter && item?.value !== undefined && item.name ? (
|
||||||
@@ -193,36 +200,44 @@ const ChartTooltipContent = React.forwardRef<
|
|||||||
{itemConfig?.icon ? (
|
{itemConfig?.icon ? (
|
||||||
<itemConfig.icon />
|
<itemConfig.icon />
|
||||||
) : (
|
) : (
|
||||||
<div
|
!hideIndicator && (
|
||||||
className={cn("shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]", {
|
<div
|
||||||
"h-2.5 w-2.5": indicator === "dot",
|
className={cn(
|
||||||
"w-1": indicator === "line",
|
'shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]',
|
||||||
"w-0 border-[1.5px] border-dashed bg-transparent": indicator === "dashed",
|
{
|
||||||
"my-0.5": nestLabel && indicator === "dashed",
|
'h-2.5 w-2.5': indicator === 'dot',
|
||||||
})}
|
'w-1': indicator === 'line',
|
||||||
style={
|
'w-0 border-[1.5px] border-dashed bg-transparent':
|
||||||
{
|
indicator === 'dashed',
|
||||||
"--color-bg": indicatorColor,
|
'my-0.5': nestLabel && indicator === 'dashed',
|
||||||
"--color-border": indicatorColor,
|
}
|
||||||
} as React.CSSProperties
|
)}
|
||||||
}
|
style={
|
||||||
/>
|
{
|
||||||
|
'--color-bg': indicatorColor,
|
||||||
|
'--color-border': indicatorColor,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-1 justify-between leading-none gap-2",
|
'flex flex-1 justify-between leading-none gap-2',
|
||||||
nestLabel ? "items-end" : "items-center"
|
nestLabel ? 'items-end' : 'items-center'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="grid gap-1.5">
|
<div className="grid gap-1.5">
|
||||||
{nestLabel ? tooltipLabel : null}
|
{nestLabel ? tooltipLabel : null}
|
||||||
<span className="text-muted-foreground">{itemConfig?.label || item.name}</span>
|
<span className="text-muted-foreground">
|
||||||
|
{itemConfig?.label || item.name}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{item.value !== undefined && (
|
{item.value !== undefined && (
|
||||||
<span className="font-medium tabular-nums text-foreground">
|
<span className="font-medium tabular-nums text-foreground">
|
||||||
{content && typeof content === "function"
|
{content && typeof content === 'function'
|
||||||
? content(item, key)
|
? content(item, key)
|
||||||
: item.value.toLocaleString() + (unit ? unit : "")}
|
: item.value.toLocaleString() + (unit ? unit : '')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -236,18 +251,18 @@ const ChartTooltipContent = React.forwardRef<
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
ChartTooltipContent.displayName = "ChartTooltip"
|
ChartTooltipContent.displayName = 'ChartTooltip'
|
||||||
|
|
||||||
const ChartLegend = RechartsPrimitive.Legend
|
const ChartLegend = RechartsPrimitive.Legend
|
||||||
|
|
||||||
const ChartLegendContent = React.forwardRef<
|
const ChartLegendContent = React.forwardRef<
|
||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
React.ComponentProps<"div"> &
|
React.ComponentProps<'div'> &
|
||||||
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
Pick<RechartsPrimitive.LegendProps, 'payload' | 'verticalAlign'> & {
|
||||||
hideIcon?: boolean
|
hideIcon?: boolean
|
||||||
nameKey?: string
|
nameKey?: string
|
||||||
}
|
}
|
||||||
>(({ className, payload, verticalAlign = "bottom" }, ref) => {
|
>(({ className, hideIcon = false, payload, verticalAlign = 'bottom', nameKey }, ref) => {
|
||||||
// const { config } = useChart()
|
// const { config } = useChart()
|
||||||
|
|
||||||
if (!payload?.length) {
|
if (!payload?.length) {
|
||||||
@@ -258,8 +273,8 @@ const ChartLegendContent = React.forwardRef<
|
|||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center justify-center gap-4 gap-y-1 flex-wrap",
|
'flex items-center justify-center gap-4 gap-y-1 flex-wrap',
|
||||||
verticalAlign === "top" ? "pb-3" : "pt-3",
|
verticalAlign === 'top' ? 'pb-3' : 'pt-3',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -272,7 +287,7 @@ const ChartLegendContent = React.forwardRef<
|
|||||||
key={item.value}
|
key={item.value}
|
||||||
className={cn(
|
className={cn(
|
||||||
// 'flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground text-muted-foreground'
|
// 'flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground text-muted-foreground'
|
||||||
"flex items-center gap-1.5 text-muted-foreground"
|
'flex items-center gap-1.5 text-muted-foreground'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* {itemConfig?.icon && !hideIcon ? (
|
{/* {itemConfig?.icon && !hideIcon ? (
|
||||||
@@ -293,27 +308,27 @@ const ChartLegendContent = React.forwardRef<
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
ChartLegendContent.displayName = "ChartLegend"
|
ChartLegendContent.displayName = 'ChartLegend'
|
||||||
|
|
||||||
// Helper to extract item config from a payload.
|
// Helper to extract item config from a payload.
|
||||||
function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) {
|
function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) {
|
||||||
if (typeof payload !== "object" || payload === null) {
|
if (typeof payload !== 'object' || payload === null) {
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
const payloadPayload =
|
const payloadPayload =
|
||||||
"payload" in payload && typeof payload.payload === "object" && payload.payload !== null
|
'payload' in payload && typeof payload.payload === 'object' && payload.payload !== null
|
||||||
? payload.payload
|
? payload.payload
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
let configLabelKey: string = key
|
let configLabelKey: string = key
|
||||||
|
|
||||||
if (key in payload && typeof payload[key as keyof typeof payload] === "string") {
|
if (key in payload && typeof payload[key as keyof typeof payload] === 'string') {
|
||||||
configLabelKey = payload[key as keyof typeof payload] as string
|
configLabelKey = payload[key as keyof typeof payload] as string
|
||||||
} else if (
|
} else if (
|
||||||
payloadPayload &&
|
payloadPayload &&
|
||||||
key in payloadPayload &&
|
key in payloadPayload &&
|
||||||
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
typeof payloadPayload[key as keyof typeof payloadPayload] === 'string'
|
||||||
) {
|
) {
|
||||||
configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string
|
configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string
|
||||||
}
|
}
|
||||||
@@ -321,34 +336,11 @@ function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key:
|
|||||||
return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config]
|
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 {
|
export {
|
||||||
ChartContainer,
|
ChartContainer,
|
||||||
ChartTooltip,
|
ChartTooltip,
|
||||||
ChartTooltipContent,
|
ChartTooltipContent,
|
||||||
ChartLegend,
|
ChartLegend,
|
||||||
ChartLegendContent,
|
ChartLegendContent,
|
||||||
xAxis,
|
ChartStyle,
|
||||||
// ChartStyle,
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
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 }
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import * as React from "react"
|
import * as React from 'react'
|
||||||
import { DialogTitle, type DialogProps } from "@radix-ui/react-dialog"
|
import { DialogTitle, type DialogProps } from '@radix-ui/react-dialog'
|
||||||
import { Command as CommandPrimitive } from "cmdk"
|
import { Command as CommandPrimitive } from 'cmdk'
|
||||||
import { Search } from "lucide-react"
|
import { Search } from 'lucide-react'
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from '@/lib/utils'
|
||||||
import { Dialog, DialogContent } from "@/components/ui/dialog"
|
import { Dialog, DialogContent } from '@/components/ui/dialog'
|
||||||
|
|
||||||
const Command = React.forwardRef<
|
const Command = React.forwardRef<
|
||||||
React.ElementRef<typeof CommandPrimitive>,
|
React.ElementRef<typeof CommandPrimitive>,
|
||||||
@@ -12,7 +12,10 @@ const Command = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<CommandPrimitive
|
<CommandPrimitive
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("flex h-full w-full flex-col overflow-hidden bg-popover text-popover-foreground", className)}
|
className={cn(
|
||||||
|
'flex h-full w-full flex-col overflow-hidden bg-popover text-popover-foreground',
|
||||||
|
className
|
||||||
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
@@ -40,11 +43,11 @@ const CommandInput = React.forwardRef<
|
|||||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
||||||
<Search className="me-2 h-4 w-4 shrink-0 opacity-50" />
|
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
<CommandPrimitive.Input
|
<CommandPrimitive.Input
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
'flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -60,7 +63,7 @@ const CommandList = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<CommandPrimitive.List
|
<CommandPrimitive.List
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
|
className={cn('max-h-[300px] overflow-y-auto overflow-x-hidden', className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
@@ -70,7 +73,9 @@ CommandList.displayName = CommandPrimitive.List.displayName
|
|||||||
const CommandEmpty = React.forwardRef<
|
const CommandEmpty = React.forwardRef<
|
||||||
React.ElementRef<typeof CommandPrimitive.Empty>,
|
React.ElementRef<typeof CommandPrimitive.Empty>,
|
||||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||||
>((props, ref) => <CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} />)
|
>((props, ref) => (
|
||||||
|
<CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} />
|
||||||
|
))
|
||||||
|
|
||||||
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
|
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
|
||||||
|
|
||||||
@@ -81,7 +86,7 @@ const CommandGroup = React.forwardRef<
|
|||||||
<CommandPrimitive.Group
|
<CommandPrimitive.Group
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
|
'overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -94,7 +99,11 @@ const CommandSeparator = React.forwardRef<
|
|||||||
React.ElementRef<typeof CommandPrimitive.Separator>,
|
React.ElementRef<typeof CommandPrimitive.Separator>,
|
||||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<CommandPrimitive.Separator ref={ref} className={cn("-mx-1 h-px bg-border", className)} {...props} />
|
<CommandPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn('-mx-1 h-px bg-border', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
))
|
))
|
||||||
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
|
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
|
||||||
|
|
||||||
@@ -115,9 +124,14 @@ const CommandItem = React.forwardRef<
|
|||||||
CommandItem.displayName = CommandPrimitive.Item.displayName
|
CommandItem.displayName = CommandPrimitive.Item.displayName
|
||||||
|
|
||||||
const CommandShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
|
const CommandShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||||
return <span className={cn("ms-auto text-xs tracking-wide text-muted-foreground", className)} {...props} />
|
return (
|
||||||
|
<span
|
||||||
|
className={cn('ml-auto text-xs tracking-wide text-muted-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
CommandShortcut.displayName = "CommandShortcut"
|
CommandShortcut.displayName = 'CommandShortcut'
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Command,
|
Command,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import * as React from "react"
|
import * as React from 'react'
|
||||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||||
import { X } from "lucide-react"
|
import { X } from 'lucide-react'
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
const Dialog = DialogPrimitive.Root
|
const Dialog = DialogPrimitive.Root
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ const DialogOverlay = React.forwardRef<
|
|||||||
<DialogPrimitive.Overlay
|
<DialogPrimitive.Overlay
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
'fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -36,13 +36,13 @@ const DialogContent = React.forwardRef<
|
|||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-card p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-card p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<DialogPrimitive.Close className="absolute end-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">Close</span>
|
||||||
</DialogPrimitive.Close>
|
</DialogPrimitive.Close>
|
||||||
@@ -52,14 +52,17 @@ const DialogContent = React.forwardRef<
|
|||||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||||
|
|
||||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-start", className)} {...props} />
|
<div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} />
|
||||||
)
|
)
|
||||||
DialogHeader.displayName = "DialogHeader"
|
DialogHeader.displayName = 'DialogHeader'
|
||||||
|
|
||||||
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-3.5", className)} {...props} />
|
<div
|
||||||
|
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
DialogFooter.displayName = "DialogFooter"
|
DialogFooter.displayName = 'DialogFooter'
|
||||||
|
|
||||||
const DialogTitle = React.forwardRef<
|
const DialogTitle = React.forwardRef<
|
||||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||||
@@ -67,7 +70,7 @@ const DialogTitle = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<DialogPrimitive.Title
|
<DialogPrimitive.Title
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
|
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
@@ -77,7 +80,11 @@ const DialogDescription = React.forwardRef<
|
|||||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<DialogPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
|
<DialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-sm text-muted-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
))
|
))
|
||||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||||
|
|
||||||
|
|||||||
@@ -17,163 +17,182 @@ const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
|||||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||||
|
|
||||||
const DropdownMenuSubTrigger = React.forwardRef<
|
const DropdownMenuSubTrigger = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
inset?: boolean
|
inset?: boolean
|
||||||
}
|
}
|
||||||
>(({ className, inset, children, ...props }, ref) => (
|
>(({ className, inset, children, ...props }, ref) => (
|
||||||
<DropdownMenuPrimitive.SubTrigger
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex cursor-default select-none items-center rounded-sm px-2.5 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
|
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
|
||||||
inset && "ps-8",
|
inset && "pl-8",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<ChevronRight className="ms-auto h-4 w-4" />
|
<ChevronRight className="ml-auto h-4 w-4" />
|
||||||
</DropdownMenuPrimitive.SubTrigger>
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
))
|
))
|
||||||
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName
|
DropdownMenuSubTrigger.displayName =
|
||||||
|
DropdownMenuPrimitive.SubTrigger.displayName
|
||||||
|
|
||||||
const DropdownMenuSubContent = React.forwardRef<
|
const DropdownMenuSubContent = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<DropdownMenuPrimitive.SubContent
|
<DropdownMenuPrimitive.SubContent
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName
|
DropdownMenuSubContent.displayName =
|
||||||
|
DropdownMenuPrimitive.SubContent.displayName
|
||||||
|
|
||||||
const DropdownMenuContent = React.forwardRef<
|
const DropdownMenuContent = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
<DropdownMenuPrimitive.Portal>
|
<DropdownMenuPrimitive.Portal>
|
||||||
<DropdownMenuPrimitive.Content
|
<DropdownMenuPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
className={cn(
|
||||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</DropdownMenuPrimitive.Portal>
|
</DropdownMenuPrimitive.Portal>
|
||||||
))
|
))
|
||||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||||
|
|
||||||
const DropdownMenuItem = React.forwardRef<
|
const DropdownMenuItem = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||||
inset?: boolean
|
inset?: boolean
|
||||||
}
|
}
|
||||||
>(({ className, inset, ...props }, ref) => (
|
>(({ className, inset, ...props }, ref) => (
|
||||||
<DropdownMenuPrimitive.Item
|
<DropdownMenuPrimitive.Item
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex cursor-default select-none items-center rounded-sm px-2.5 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
inset && "ps-8",
|
inset && "pl-8",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||||
|
|
||||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||||
>(({ className, children, checked, ...props }, ref) => (
|
>(({ className, children, checked, ...props }, ref) => (
|
||||||
<DropdownMenuPrimitive.CheckboxItem
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 ps-8 pe-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
checked={checked}
|
checked={checked}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
<DropdownMenuPrimitive.ItemIndicator>
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
<Check className="h-4 w-4" />
|
<Check className="h-4 w-4" />
|
||||||
</DropdownMenuPrimitive.ItemIndicator>
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
</span>
|
</span>
|
||||||
{children}
|
{children}
|
||||||
</DropdownMenuPrimitive.CheckboxItem>
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
))
|
))
|
||||||
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName
|
DropdownMenuCheckboxItem.displayName =
|
||||||
|
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||||
|
|
||||||
const DropdownMenuRadioItem = React.forwardRef<
|
const DropdownMenuRadioItem = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||||
>(({ className, children, ...props }, ref) => (
|
>(({ className, children, ...props }, ref) => (
|
||||||
<DropdownMenuPrimitive.RadioItem
|
<DropdownMenuPrimitive.RadioItem
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 ps-8 pe-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
<DropdownMenuPrimitive.ItemIndicator>
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
<Circle className="h-2 w-2 fill-current" />
|
<Circle className="h-2 w-2 fill-current" />
|
||||||
</DropdownMenuPrimitive.ItemIndicator>
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
</span>
|
</span>
|
||||||
{children}
|
{children}
|
||||||
</DropdownMenuPrimitive.RadioItem>
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
))
|
))
|
||||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||||
|
|
||||||
const DropdownMenuLabel = React.forwardRef<
|
const DropdownMenuLabel = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||||
inset?: boolean
|
inset?: boolean
|
||||||
}
|
}
|
||||||
>(({ className, inset, ...props }, ref) => (
|
>(({ className, inset, ...props }, ref) => (
|
||||||
<DropdownMenuPrimitive.Label
|
<DropdownMenuPrimitive.Label
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("px-2.5 py-1.5 text-sm font-semibold", inset && "ps-8", className)}
|
className={cn(
|
||||||
{...props}
|
"px-2 py-1.5 text-sm font-semibold",
|
||||||
/>
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
))
|
))
|
||||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||||
|
|
||||||
const DropdownMenuSeparator = React.forwardRef<
|
const DropdownMenuSeparator = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<DropdownMenuPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-muted", className)} {...props} />
|
<DropdownMenuPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
))
|
))
|
||||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||||
|
|
||||||
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
|
const DropdownMenuShortcut = ({
|
||||||
return <span className={cn("ms-auto text-xs tracking-widest opacity-60", className)} {...props} />
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||||
|
|
||||||
export {
|
export {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuCheckboxItem,
|
DropdownMenuCheckboxItem,
|
||||||
DropdownMenuRadioItem,
|
DropdownMenuRadioItem,
|
||||||
DropdownMenuLabel,
|
DropdownMenuLabel,
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuShortcut,
|
DropdownMenuShortcut,
|
||||||
DropdownMenuGroup,
|
DropdownMenuGroup,
|
||||||
DropdownMenuPortal,
|
DropdownMenuPortal,
|
||||||
DropdownMenuSub,
|
DropdownMenuSub,
|
||||||
DropdownMenuSubContent,
|
DropdownMenuSubContent,
|
||||||
DropdownMenuSubTrigger,
|
DropdownMenuSubTrigger,
|
||||||
DropdownMenuRadioGroup,
|
DropdownMenuRadioGroup,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { SVGProps } from "react"
|
import { SVGProps } from 'react'
|
||||||
|
|
||||||
// linux-logo-bold from https://github.com/phosphor-icons/core (MIT license)
|
// linux-logo-bold from https://github.com/phosphor-icons/core (MIT license)
|
||||||
export function TuxIcon(props: SVGProps<SVGSVGElement>) {
|
export function TuxIcon(props: SVGProps<SVGSVGElement>) {
|
||||||
@@ -23,43 +23,3 @@ export function Rows(props: SVGProps<SVGSVGElement>) {
|
|||||||
</svg>
|
</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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,24 +1,27 @@
|
|||||||
import * as React from "react"
|
import * as React from 'react'
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from '@/components/ui/button'
|
||||||
import { XIcon } from "lucide-react"
|
import { XIcon } from 'lucide-react'
|
||||||
import { type InputProps } from "./input"
|
import { type InputProps } from './input'
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
type InputTagsProps = Omit<InputProps, "value" | "onChange"> & {
|
type InputTagsProps = Omit<InputProps, 'value' | 'onChange'> & {
|
||||||
value: string[]
|
value: string[]
|
||||||
onChange: React.Dispatch<React.SetStateAction<string[]>>
|
onChange: React.Dispatch<React.SetStateAction<string[]>>
|
||||||
}
|
}
|
||||||
|
|
||||||
const InputTags = React.forwardRef<HTMLInputElement, InputTagsProps>(
|
const InputTags = React.forwardRef<HTMLInputElement, InputTagsProps>(
|
||||||
({ className, value, onChange, ...props }, ref) => {
|
({ className, value, onChange, ...props }, ref) => {
|
||||||
const [pendingDataPoint, setPendingDataPoint] = React.useState("")
|
const [pendingDataPoint, setPendingDataPoint] = React.useState('')
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (pendingDataPoint.includes(",")) {
|
if (pendingDataPoint.includes(',')) {
|
||||||
const newDataPoints = new Set([...value, ...pendingDataPoint.split(",").map((chunk) => chunk.trim())])
|
const newDataPoints = new Set([
|
||||||
|
...value,
|
||||||
|
...pendingDataPoint.split(',').map((chunk) => chunk.trim()),
|
||||||
|
])
|
||||||
onChange(Array.from(newDataPoints))
|
onChange(Array.from(newDataPoints))
|
||||||
setPendingDataPoint("")
|
setPendingDataPoint('')
|
||||||
}
|
}
|
||||||
}, [pendingDataPoint, onChange, value])
|
}, [pendingDataPoint, onChange, value])
|
||||||
|
|
||||||
@@ -26,14 +29,14 @@ const InputTags = React.forwardRef<HTMLInputElement, InputTagsProps>(
|
|||||||
if (pendingDataPoint) {
|
if (pendingDataPoint) {
|
||||||
const newDataPoints = new Set([...value, pendingDataPoint])
|
const newDataPoints = new Set([...value, pendingDataPoint])
|
||||||
onChange(Array.from(newDataPoints))
|
onChange(Array.from(newDataPoints))
|
||||||
setPendingDataPoint("")
|
setPendingDataPoint('')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-background min-h-10 flex w-full flex-wrap gap-2 rounded-md border border-input px-3 py-2 text-sm placeholder:text-muted-foreground has-[:focus-visible]:outline-none ring-offset-background has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring has-[:focus-visible]:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
'bg-background min-h-10 flex w-full flex-wrap gap-2 rounded-md border border-input px-3 py-2 text-sm placeholder:text-muted-foreground has-[:focus-visible]:outline-none ring-offset-background has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring has-[:focus-visible]:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -43,7 +46,7 @@ const InputTags = React.forwardRef<HTMLInputElement, InputTagsProps>(
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="ms-2 h-3 w-3"
|
className="ml-2 h-3 w-3"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onChange(value.filter((i) => i !== item))
|
onChange(value.filter((i) => i !== item))
|
||||||
}}
|
}}
|
||||||
@@ -57,10 +60,10 @@ const InputTags = React.forwardRef<HTMLInputElement, InputTagsProps>(
|
|||||||
value={pendingDataPoint}
|
value={pendingDataPoint}
|
||||||
onChange={(e) => setPendingDataPoint(e.target.value)}
|
onChange={(e) => setPendingDataPoint(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter" || e.key === ",") {
|
if (e.key === 'Enter' || e.key === ',') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
addPendingDataPoint()
|
addPendingDataPoint()
|
||||||
} else if (e.key === "Backspace" && pendingDataPoint.length === 0 && value.length > 0) {
|
} else if (e.key === 'Backspace' && pendingDataPoint.length === 0 && value.length > 0) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
onChange(value.slice(0, -1))
|
onChange(value.slice(0, -1))
|
||||||
}
|
}
|
||||||
@@ -73,6 +76,6 @@ const InputTags = React.forwardRef<HTMLInputElement, InputTagsProps>(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
InputTags.displayName = "InputTags"
|
InputTags.displayName = 'InputTags'
|
||||||
|
|
||||||
export { InputTags }
|
export { InputTags }
|
||||||
|
|||||||
@@ -2,21 +2,24 @@ import * as React from "react"
|
|||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
export interface InputProps
|
||||||
|
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||||
|
|
||||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, type, ...props }, ref) => {
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
return (
|
({ className, type, ...props }, ref) => {
|
||||||
<input
|
return (
|
||||||
type={type}
|
<input
|
||||||
className={cn(
|
type={type}
|
||||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
className={cn(
|
||||||
className
|
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
)}
|
className
|
||||||
ref={ref}
|
)}
|
||||||
{...props}
|
ref={ref}
|
||||||
/>
|
{...props}
|
||||||
)
|
/>
|
||||||
})
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
Input.displayName = "Input"
|
Input.displayName = "Input"
|
||||||
|
|
||||||
export { Input }
|
export { Input }
|
||||||
|
|||||||
@@ -4,13 +4,20 @@ import { cva, type VariantProps } from "class-variance-authority"
|
|||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const labelVariants = cva("text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70")
|
const labelVariants = cva(
|
||||||
|
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
)
|
||||||
|
|
||||||
const Label = React.forwardRef<
|
const Label = React.forwardRef<
|
||||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||||
|
VariantProps<typeof labelVariants>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
|
<LabelPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(labelVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
))
|
))
|
||||||
Label.displayName = LabelPrimitive.Root.displayName
|
Label.displayName = LabelPrimitive.Root.displayName
|
||||||
|
|
||||||
|
|||||||
@@ -11,133 +11,148 @@ const SelectGroup = SelectPrimitive.Group
|
|||||||
const SelectValue = SelectPrimitive.Value
|
const SelectValue = SelectPrimitive.Value
|
||||||
|
|
||||||
const SelectTrigger = React.forwardRef<
|
const SelectTrigger = React.forwardRef<
|
||||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||||
>(({ className, children, ...props }, ref) => (
|
>(({ className, children, ...props }, ref) => (
|
||||||
<SelectPrimitive.Trigger
|
<SelectPrimitive.Trigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<SelectPrimitive.Icon asChild>
|
<SelectPrimitive.Icon asChild>
|
||||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||||
</SelectPrimitive.Icon>
|
</SelectPrimitive.Icon>
|
||||||
</SelectPrimitive.Trigger>
|
</SelectPrimitive.Trigger>
|
||||||
))
|
))
|
||||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||||
|
|
||||||
const SelectScrollUpButton = React.forwardRef<
|
const SelectScrollUpButton = React.forwardRef<
|
||||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<SelectPrimitive.ScrollUpButton
|
<SelectPrimitive.ScrollUpButton
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
className={cn(
|
||||||
{...props}
|
"flex cursor-default items-center justify-center py-1",
|
||||||
>
|
className
|
||||||
<ChevronUp className="h-4 w-4" />
|
)}
|
||||||
</SelectPrimitive.ScrollUpButton>
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
))
|
))
|
||||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||||
|
|
||||||
const SelectScrollDownButton = React.forwardRef<
|
const SelectScrollDownButton = React.forwardRef<
|
||||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<SelectPrimitive.ScrollDownButton
|
<SelectPrimitive.ScrollDownButton
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
className={cn(
|
||||||
{...props}
|
"flex cursor-default items-center justify-center py-1",
|
||||||
>
|
className
|
||||||
<ChevronDown className="h-4 w-4" />
|
)}
|
||||||
</SelectPrimitive.ScrollDownButton>
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
))
|
))
|
||||||
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName
|
SelectScrollDownButton.displayName =
|
||||||
|
SelectPrimitive.ScrollDownButton.displayName
|
||||||
|
|
||||||
const SelectContent = React.forwardRef<
|
const SelectContent = React.forwardRef<
|
||||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||||
<SelectPrimitive.Portal>
|
<SelectPrimitive.Portal>
|
||||||
<SelectPrimitive.Content
|
<SelectPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
position === "popper" &&
|
position === "popper" &&
|
||||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
position={position}
|
position={position}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<SelectScrollUpButton />
|
<SelectScrollUpButton />
|
||||||
<SelectPrimitive.Viewport
|
<SelectPrimitive.Viewport
|
||||||
className={cn(
|
className={cn(
|
||||||
"p-1",
|
"p-1",
|
||||||
position === "popper" &&
|
position === "popper" &&
|
||||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</SelectPrimitive.Viewport>
|
</SelectPrimitive.Viewport>
|
||||||
<SelectScrollDownButton />
|
<SelectScrollDownButton />
|
||||||
</SelectPrimitive.Content>
|
</SelectPrimitive.Content>
|
||||||
</SelectPrimitive.Portal>
|
</SelectPrimitive.Portal>
|
||||||
))
|
))
|
||||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||||
|
|
||||||
const SelectLabel = React.forwardRef<
|
const SelectLabel = React.forwardRef<
|
||||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<SelectPrimitive.Label ref={ref} className={cn("py-1.5 ps-8 pe-2 text-sm font-semibold", className)} {...props} />
|
<SelectPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
))
|
))
|
||||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||||
|
|
||||||
const SelectItem = React.forwardRef<
|
const SelectItem = React.forwardRef<
|
||||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||||
>(({ className, children, ...props }, ref) => (
|
>(({ className, children, ...props }, ref) => (
|
||||||
<SelectPrimitive.Item
|
<SelectPrimitive.Item
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 ps-8 pe-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
<SelectPrimitive.ItemIndicator>
|
<SelectPrimitive.ItemIndicator>
|
||||||
<Check className="h-4 w-4" />
|
<Check className="h-4 w-4" />
|
||||||
</SelectPrimitive.ItemIndicator>
|
</SelectPrimitive.ItemIndicator>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
</SelectPrimitive.Item>
|
</SelectPrimitive.Item>
|
||||||
))
|
))
|
||||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||||
|
|
||||||
const SelectSeparator = React.forwardRef<
|
const SelectSeparator = React.forwardRef<
|
||||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<SelectPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-muted", className)} {...props} />
|
<SelectPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
))
|
))
|
||||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Select,
|
Select,
|
||||||
SelectGroup,
|
SelectGroup,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
SelectLabel,
|
SelectLabel,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
SelectSeparator,
|
SelectSeparator,
|
||||||
SelectScrollUpButton,
|
SelectScrollUpButton,
|
||||||
SelectScrollDownButton,
|
SelectScrollDownButton,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,17 +4,26 @@ import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
|||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const Separator = React.forwardRef<
|
const Separator = React.forwardRef<
|
||||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||||
>(({ className, orientation = "horizontal", decorative = true, ...props }, ref) => (
|
>(
|
||||||
<SeparatorPrimitive.Root
|
(
|
||||||
ref={ref}
|
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||||
decorative={decorative}
|
ref
|
||||||
orientation={orientation}
|
) => (
|
||||||
className={cn("shrink-0 bg-border", orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]", className)}
|
<SeparatorPrimitive.Root
|
||||||
{...props}
|
ref={ref}
|
||||||
/>
|
decorative={decorative}
|
||||||
))
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 bg-border",
|
||||||
|
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)
|
||||||
Separator.displayName = SeparatorPrimitive.Root.displayName
|
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||||
|
|
||||||
export { Separator }
|
export { Separator }
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import * as React from "react"
|
import * as React from 'react'
|
||||||
import * as SliderPrimitive from "@radix-ui/react-slider"
|
import * as SliderPrimitive from '@radix-ui/react-slider'
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
const Slider = React.forwardRef<
|
const Slider = React.forwardRef<
|
||||||
React.ElementRef<typeof SliderPrimitive.Root>,
|
React.ElementRef<typeof SliderPrimitive.Root>,
|
||||||
@@ -9,7 +9,7 @@ const Slider = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<SliderPrimitive.Root
|
<SliderPrimitive.Root
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("relative flex w-full touch-none select-none items-center", className)}
|
className={cn('relative flex w-full touch-none select-none items-center', className)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
|
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
|
||||||
|
|||||||
@@ -4,23 +4,23 @@ import * as SwitchPrimitives from "@radix-ui/react-switch"
|
|||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const Switch = React.forwardRef<
|
const Switch = React.forwardRef<
|
||||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<SwitchPrimitives.Root
|
<SwitchPrimitives.Root
|
||||||
className={cn(
|
className={cn(
|
||||||
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
>
|
>
|
||||||
<SwitchPrimitives.Thumb
|
<SwitchPrimitives.Thumb
|
||||||
className={cn(
|
className={cn(
|
||||||
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 rtl:data-[state=checked]:-translate-x-5 data-[state=unchecked]:translate-x-0"
|
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</SwitchPrimitives.Root>
|
</SwitchPrimitives.Root>
|
||||||
))
|
))
|
||||||
Switch.displayName = SwitchPrimitives.Root.displayName
|
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||||
|
|
||||||
|
|||||||
@@ -1,72 +1,91 @@
|
|||||||
import * as React from "react"
|
import * as React from 'react'
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
|
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
|
||||||
({ className, ...props }, ref) => (
|
({ className, ...props }, ref) => (
|
||||||
<div className="relative w-full overflow-auto">
|
<div className="relative w-full overflow-auto">
|
||||||
<table ref={ref} className={cn("w-full caption-bottom text-sm", className)} {...props} />
|
<table ref={ref} className={cn('w-full caption-bottom text-sm', className)} {...props} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
Table.displayName = "Table"
|
Table.displayName = 'Table'
|
||||||
|
|
||||||
const TableHeader = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
|
const TableHeader = React.forwardRef<
|
||||||
({ className, ...props }, ref) => <thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
HTMLTableSectionElement,
|
||||||
)
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
TableHeader.displayName = "TableHeader"
|
>(({ className, ...props }, ref) => (
|
||||||
|
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
|
||||||
|
))
|
||||||
|
TableHeader.displayName = 'TableHeader'
|
||||||
|
|
||||||
const TableBody = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
|
const TableBody = React.forwardRef<
|
||||||
({ className, ...props }, ref) => (
|
HTMLTableSectionElement,
|
||||||
<tbody ref={ref} className={cn("[&_tr:last-child]:border-0", className)} {...props} />
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
)
|
>(({ className, ...props }, ref) => (
|
||||||
)
|
<tbody ref={ref} className={cn('[&_tr:last-child]:border-0', className)} {...props} />
|
||||||
TableBody.displayName = "TableBody"
|
))
|
||||||
|
TableBody.displayName = 'TableBody'
|
||||||
|
|
||||||
const TableFooter = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
|
const TableFooter = React.forwardRef<
|
||||||
({ className, ...props }, ref) => (
|
HTMLTableSectionElement,
|
||||||
<tfoot ref={ref} className={cn("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", className)} {...props} />
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
)
|
>(({ className, ...props }, ref) => (
|
||||||
)
|
<tfoot
|
||||||
TableFooter.displayName = "TableFooter"
|
ref={ref}
|
||||||
|
className={cn('border-t bg-muted/50 font-medium [&>tr]:last:border-b-0', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableFooter.displayName = 'TableFooter'
|
||||||
|
|
||||||
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
|
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
|
||||||
({ className, ...props }, ref) => (
|
({ className, ...props }, ref) => (
|
||||||
<tr
|
<tr
|
||||||
ref={ref}
|
|
||||||
className={cn("border-b hover:bg-muted/40 dark:hover:bg-muted/30 data-[state=selected]:bg-muted", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
)
|
|
||||||
TableRow.displayName = "TableRow"
|
|
||||||
|
|
||||||
const TableHead = React.forwardRef<HTMLTableCellElement, React.ThHTMLAttributes<HTMLTableCellElement>>(
|
|
||||||
({ className, ...props }, ref) => (
|
|
||||||
<th
|
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-12 px-4 text-start align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pe-0",
|
'border-b hover:bg-muted/40 dark:hover:bg-muted/30 data-[state=selected]:bg-muted',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
TableHead.displayName = "TableHead"
|
TableRow.displayName = 'TableRow'
|
||||||
|
|
||||||
const TableCell = React.forwardRef<HTMLTableCellElement, React.TdHTMLAttributes<HTMLTableCellElement>>(
|
const TableHead = React.forwardRef<
|
||||||
({ className, ...props }, ref) => (
|
HTMLTableCellElement,
|
||||||
<td ref={ref} className={cn("p-4 align-middle [&:has([role=checkbox])]:pe-0", className)} {...props} />
|
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||||
)
|
>(({ className, ...props }, ref) => (
|
||||||
)
|
<th
|
||||||
TableCell.displayName = "TableCell"
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableHead.displayName = 'TableHead'
|
||||||
|
|
||||||
const TableCaption = React.forwardRef<HTMLTableCaptionElement, React.HTMLAttributes<HTMLTableCaptionElement>>(
|
const TableCell = React.forwardRef<
|
||||||
({ className, ...props }, ref) => (
|
HTMLTableCellElement,
|
||||||
<caption ref={ref} className={cn("mt-4 text-sm text-muted-foreground", className)} {...props} />
|
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||||
)
|
>(({ className, ...props }, ref) => (
|
||||||
)
|
<td
|
||||||
TableCaption.displayName = "TableCaption"
|
ref={ref}
|
||||||
|
className={cn('p-4 align-middle [&:has([role=checkbox])]:pr-0', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableCell.displayName = 'TableCell'
|
||||||
|
|
||||||
|
const TableCaption = React.forwardRef<
|
||||||
|
HTMLTableCaptionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<caption ref={ref} className={cn('mt-4 text-sm text-muted-foreground', className)} {...props} />
|
||||||
|
))
|
||||||
|
TableCaption.displayName = 'TableCaption'
|
||||||
|
|
||||||
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption }
|
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption }
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
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,21 +1,23 @@
|
|||||||
import * as React from "react"
|
import * as React from 'react'
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||||
|
|
||||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(({ className, ...props }, ref) => {
|
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||||
return (
|
({ className, ...props }, ref) => {
|
||||||
<textarea
|
return (
|
||||||
className={cn(
|
<textarea
|
||||||
"flex min-h-14 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
className={cn(
|
||||||
className
|
'flex min-h-14 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
)}
|
className
|
||||||
ref={ref}
|
)}
|
||||||
{...props}
|
ref={ref}
|
||||||
/>
|
{...props}
|
||||||
)
|
/>
|
||||||
})
|
)
|
||||||
Textarea.displayName = "Textarea"
|
}
|
||||||
|
)
|
||||||
|
Textarea.displayName = 'Textarea'
|
||||||
|
|
||||||
export { Textarea }
|
export { Textarea }
|
||||||
|
|||||||
@@ -8,89 +8,105 @@ import { cn } from "@/lib/utils"
|
|||||||
const ToastProvider = ToastPrimitives.Provider
|
const ToastProvider = ToastPrimitives.Provider
|
||||||
|
|
||||||
const ToastViewport = React.forwardRef<
|
const ToastViewport = React.forwardRef<
|
||||||
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<ToastPrimitives.Viewport
|
<ToastPrimitives.Viewport
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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]",
|
"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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
|
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
|
||||||
|
|
||||||
const toastVariants = cva(
|
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 pe-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",
|
"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: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "border bg-background text-foreground",
|
default: "border bg-background text-foreground",
|
||||||
destructive: "destructive group border-destructive bg-destructive text-destructive-foreground",
|
destructive:
|
||||||
},
|
"destructive group border-destructive bg-destructive text-destructive-foreground",
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
},
|
||||||
variant: "default",
|
defaultVariants: {
|
||||||
},
|
variant: "default",
|
||||||
}
|
},
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
const Toast = React.forwardRef<
|
const Toast = React.forwardRef<
|
||||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & VariantProps<typeof toastVariants>
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
||||||
|
VariantProps<typeof toastVariants>
|
||||||
>(({ className, variant, ...props }, ref) => {
|
>(({ 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
|
Toast.displayName = ToastPrimitives.Root.displayName
|
||||||
|
|
||||||
const ToastAction = React.forwardRef<
|
const ToastAction = React.forwardRef<
|
||||||
React.ElementRef<typeof ToastPrimitives.Action>,
|
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<ToastPrimitives.Action
|
<ToastPrimitives.Action
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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",
|
"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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
ToastAction.displayName = ToastPrimitives.Action.displayName
|
ToastAction.displayName = ToastPrimitives.Action.displayName
|
||||||
|
|
||||||
const ToastClose = React.forwardRef<
|
const ToastClose = React.forwardRef<
|
||||||
React.ElementRef<typeof ToastPrimitives.Close>,
|
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<ToastPrimitives.Close
|
<ToastPrimitives.Close
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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",
|
"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
|
className
|
||||||
)}
|
)}
|
||||||
toast-close=""
|
toast-close=""
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</ToastPrimitives.Close>
|
</ToastPrimitives.Close>
|
||||||
))
|
))
|
||||||
ToastClose.displayName = ToastPrimitives.Close.displayName
|
ToastClose.displayName = ToastPrimitives.Close.displayName
|
||||||
|
|
||||||
const ToastTitle = React.forwardRef<
|
const ToastTitle = React.forwardRef<
|
||||||
React.ElementRef<typeof ToastPrimitives.Title>,
|
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ 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
|
ToastTitle.displayName = ToastPrimitives.Title.displayName
|
||||||
|
|
||||||
const ToastDescription = React.forwardRef<
|
const ToastDescription = React.forwardRef<
|
||||||
React.ElementRef<typeof ToastPrimitives.Description>,
|
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ 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
|
ToastDescription.displayName = ToastPrimitives.Description.displayName
|
||||||
|
|
||||||
@@ -99,13 +115,13 @@ type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
|
|||||||
type ToastActionElement = React.ReactElement<typeof ToastAction>
|
type ToastActionElement = React.ReactElement<typeof ToastAction>
|
||||||
|
|
||||||
export {
|
export {
|
||||||
type ToastProps,
|
type ToastProps,
|
||||||
type ToastActionElement,
|
type ToastActionElement,
|
||||||
ToastProvider,
|
ToastProvider,
|
||||||
ToastViewport,
|
ToastViewport,
|
||||||
Toast,
|
Toast,
|
||||||
ToastTitle,
|
ToastTitle,
|
||||||
ToastDescription,
|
ToastDescription,
|
||||||
ToastClose,
|
ToastClose,
|
||||||
ToastAction,
|
ToastAction,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,33 @@
|
|||||||
import { Toast, ToastClose, ToastDescription, ToastProvider, ToastTitle, ToastViewport } from "@/components/ui/toast"
|
import {
|
||||||
|
Toast,
|
||||||
|
ToastClose,
|
||||||
|
ToastDescription,
|
||||||
|
ToastProvider,
|
||||||
|
ToastTitle,
|
||||||
|
ToastViewport,
|
||||||
|
} from "@/components/ui/toast"
|
||||||
import { useToast } from "@/components/ui/use-toast"
|
import { useToast } from "@/components/ui/use-toast"
|
||||||
|
|
||||||
export function Toaster() {
|
export function Toaster() {
|
||||||
const { toasts } = useToast()
|
const { toasts } = useToast()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
{toasts.map(function ({ id, title, description, action, ...props }) {
|
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||||
return (
|
return (
|
||||||
<Toast key={id} {...props}>
|
<Toast key={id} {...props}>
|
||||||
<div className="grid gap-1">
|
<div className="grid gap-1">
|
||||||
{title && <ToastTitle>{title}</ToastTitle>}
|
{title && <ToastTitle>{title}</ToastTitle>}
|
||||||
{description && <ToastDescription>{description}</ToastDescription>}
|
{description && (
|
||||||
</div>
|
<ToastDescription>{description}</ToastDescription>
|
||||||
{action}
|
)}
|
||||||
<ToastClose />
|
</div>
|
||||||
</Toast>
|
{action}
|
||||||
)
|
<ToastClose />
|
||||||
})}
|
</Toast>
|
||||||
<ToastViewport />
|
)
|
||||||
</ToastProvider>
|
})}
|
||||||
)
|
<ToastViewport />
|
||||||
|
</ToastProvider>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import * as React from "react"
|
import * as React from 'react'
|
||||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
const TooltipProvider = TooltipPrimitive.Provider
|
const TooltipProvider = TooltipPrimitive.Provider
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ const TooltipContent = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
className={cn(
|
||||||
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
'z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -1,125 +1,130 @@
|
|||||||
// Inspired by react-hot-toast library
|
// Inspired by react-hot-toast library
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
|
|
||||||
import type { ToastActionElement, ToastProps } from "@/components/ui/toast"
|
import type {
|
||||||
|
ToastActionElement,
|
||||||
|
ToastProps,
|
||||||
|
} from "@/components/ui/toast"
|
||||||
|
|
||||||
const TOAST_LIMIT = 1
|
const TOAST_LIMIT = 1
|
||||||
const TOAST_REMOVE_DELAY = 1000000
|
const TOAST_REMOVE_DELAY = 1000000
|
||||||
|
|
||||||
type ToasterToast = ToastProps & {
|
type ToasterToast = ToastProps & {
|
||||||
id: string
|
id: string
|
||||||
title?: React.ReactNode
|
title?: React.ReactNode
|
||||||
description?: React.ReactNode
|
description?: React.ReactNode
|
||||||
action?: ToastActionElement
|
action?: ToastActionElement
|
||||||
}
|
}
|
||||||
|
|
||||||
const actionTypes = {
|
const actionTypes = {
|
||||||
ADD_TOAST: "ADD_TOAST",
|
ADD_TOAST: "ADD_TOAST",
|
||||||
UPDATE_TOAST: "UPDATE_TOAST",
|
UPDATE_TOAST: "UPDATE_TOAST",
|
||||||
DISMISS_TOAST: "DISMISS_TOAST",
|
DISMISS_TOAST: "DISMISS_TOAST",
|
||||||
REMOVE_TOAST: "REMOVE_TOAST",
|
REMOVE_TOAST: "REMOVE_TOAST",
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
let count = 0
|
let count = 0
|
||||||
|
|
||||||
function genId() {
|
function genId() {
|
||||||
count = (count + 1) % Number.MAX_SAFE_INTEGER
|
count = (count + 1) % Number.MAX_SAFE_INTEGER
|
||||||
return count.toString()
|
return count.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
type ActionType = typeof actionTypes
|
type ActionType = typeof actionTypes
|
||||||
|
|
||||||
type Action =
|
type Action =
|
||||||
| {
|
| {
|
||||||
type: ActionType["ADD_TOAST"]
|
type: ActionType["ADD_TOAST"]
|
||||||
toast: ToasterToast
|
toast: ToasterToast
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: ActionType["UPDATE_TOAST"]
|
type: ActionType["UPDATE_TOAST"]
|
||||||
toast: Partial<ToasterToast>
|
toast: Partial<ToasterToast>
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: ActionType["DISMISS_TOAST"]
|
type: ActionType["DISMISS_TOAST"]
|
||||||
toastId?: ToasterToast["id"]
|
toastId?: ToasterToast["id"]
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: ActionType["REMOVE_TOAST"]
|
type: ActionType["REMOVE_TOAST"]
|
||||||
toastId?: ToasterToast["id"]
|
toastId?: ToasterToast["id"]
|
||||||
}
|
}
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
toasts: ToasterToast[]
|
toasts: ToasterToast[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
||||||
|
|
||||||
const addToRemoveQueue = (toastId: string) => {
|
const addToRemoveQueue = (toastId: string) => {
|
||||||
if (toastTimeouts.has(toastId)) {
|
if (toastTimeouts.has(toastId)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
toastTimeouts.delete(toastId)
|
toastTimeouts.delete(toastId)
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "REMOVE_TOAST",
|
type: "REMOVE_TOAST",
|
||||||
toastId: toastId,
|
toastId: toastId,
|
||||||
})
|
})
|
||||||
}, TOAST_REMOVE_DELAY)
|
}, TOAST_REMOVE_DELAY)
|
||||||
|
|
||||||
toastTimeouts.set(toastId, timeout)
|
toastTimeouts.set(toastId, timeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const reducer = (state: State, action: Action): State => {
|
export const reducer = (state: State, action: Action): State => {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case "ADD_TOAST":
|
case "ADD_TOAST":
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||||
}
|
}
|
||||||
|
|
||||||
case "UPDATE_TOAST":
|
case "UPDATE_TOAST":
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
toasts: state.toasts.map((t) => (t.id === action.toast.id ? { ...t, ...action.toast } : t)),
|
toasts: state.toasts.map((t) =>
|
||||||
}
|
t.id === action.toast.id ? { ...t, ...action.toast } : t
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
case "DISMISS_TOAST": {
|
case "DISMISS_TOAST": {
|
||||||
const { toastId } = action
|
const { toastId } = action
|
||||||
|
|
||||||
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
||||||
// but I'll keep it here for simplicity
|
// but I'll keep it here for simplicity
|
||||||
if (toastId) {
|
if (toastId) {
|
||||||
addToRemoveQueue(toastId)
|
addToRemoveQueue(toastId)
|
||||||
} else {
|
} else {
|
||||||
state.toasts.forEach((toast) => {
|
state.toasts.forEach((toast) => {
|
||||||
addToRemoveQueue(toast.id)
|
addToRemoveQueue(toast.id)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
toasts: state.toasts.map((t) =>
|
toasts: state.toasts.map((t) =>
|
||||||
t.id === toastId || toastId === undefined
|
t.id === toastId || toastId === undefined
|
||||||
? {
|
? {
|
||||||
...t,
|
...t,
|
||||||
open: false,
|
open: false,
|
||||||
}
|
}
|
||||||
: t
|
: t
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case "REMOVE_TOAST":
|
case "REMOVE_TOAST":
|
||||||
if (action.toastId === undefined) {
|
if (action.toastId === undefined) {
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
toasts: [],
|
toasts: [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const listeners: Array<(state: State) => void> = []
|
const listeners: Array<(state: State) => void> = []
|
||||||
@@ -127,61 +132,61 @@ const listeners: Array<(state: State) => void> = []
|
|||||||
let memoryState: State = { toasts: [] }
|
let memoryState: State = { toasts: [] }
|
||||||
|
|
||||||
function dispatch(action: Action) {
|
function dispatch(action: Action) {
|
||||||
memoryState = reducer(memoryState, action)
|
memoryState = reducer(memoryState, action)
|
||||||
listeners.forEach((listener) => {
|
listeners.forEach((listener) => {
|
||||||
listener(memoryState)
|
listener(memoryState)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
type Toast = Omit<ToasterToast, "id">
|
type Toast = Omit<ToasterToast, "id">
|
||||||
|
|
||||||
function toast({ ...props }: Toast) {
|
function toast({ ...props }: Toast) {
|
||||||
const id = genId()
|
const id = genId()
|
||||||
|
|
||||||
const update = (props: ToasterToast) =>
|
const update = (props: ToasterToast) =>
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "UPDATE_TOAST",
|
type: "UPDATE_TOAST",
|
||||||
toast: { ...props, id },
|
toast: { ...props, id },
|
||||||
})
|
})
|
||||||
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
|
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
|
||||||
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "ADD_TOAST",
|
type: "ADD_TOAST",
|
||||||
toast: {
|
toast: {
|
||||||
...props,
|
...props,
|
||||||
id,
|
id,
|
||||||
open: true,
|
open: true,
|
||||||
onOpenChange: (open) => {
|
onOpenChange: (open) => {
|
||||||
if (!open) dismiss()
|
if (!open) dismiss()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: id,
|
id: id,
|
||||||
dismiss,
|
dismiss,
|
||||||
update,
|
update,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function useToast() {
|
function useToast() {
|
||||||
const [state, setState] = React.useState<State>(memoryState)
|
const [state, setState] = React.useState<State>(memoryState)
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
listeners.push(setState)
|
listeners.push(setState)
|
||||||
return () => {
|
return () => {
|
||||||
const index = listeners.indexOf(setState)
|
const index = listeners.indexOf(setState)
|
||||||
if (index > -1) {
|
if (index > -1) {
|
||||||
listeners.splice(index, 1)
|
listeners.splice(index, 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [state])
|
}, [state])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
toast,
|
toast,
|
||||||
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export { useToast, toast }
|
export { useToast, toast }
|
||||||
|
|||||||
@@ -48,7 +48,7 @@
|
|||||||
--muted-foreground: 240 5.03% 64.9%;
|
--muted-foreground: 240 5.03% 64.9%;
|
||||||
--accent: 240 3.7% 15.88%;
|
--accent: 240 3.7% 15.88%;
|
||||||
--accent-foreground: 0 0% 98.04%;
|
--accent-foreground: 0 0% 98.04%;
|
||||||
--destructive: 0 59% 46%;
|
--destructive: 0 56.48% 42.35%;
|
||||||
--destructive-foreground: 0 0% 98.04%;
|
--destructive-foreground: 0 0% 98.04%;
|
||||||
--border: 240 2.86% 12%;
|
--border: 240 2.86% 12%;
|
||||||
--input: 240 3.7% 15.88%;
|
--input: 240 3.7% 15.88%;
|
||||||
@@ -68,7 +68,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 100 900;
|
font-weight: 100 900;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url("/static/InterVariable.woff2?v=4.0") format("woff2");
|
src: url('/static/InterVariable.woff2?v=4.0') format('woff2');
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
import { $direction } from "./stores"
|
|
||||||
import { i18n } from "@lingui/core"
|
|
||||||
import type { Messages } from "@lingui/core"
|
|
||||||
import languages from "@/lib/languages.json"
|
|
||||||
import { detect, fromUrl, fromStorage, fromNavigator } from "@lingui/detect-locale"
|
|
||||||
import { messages as enMessages } from "../locales/en/en.ts"
|
|
||||||
|
|
||||||
console.log(languages)
|
|
||||||
|
|
||||||
// let locale = detect(fromUrl("lang"), fromStorage("lang"), fromNavigator(), "en")
|
|
||||||
let locale = detect(fromStorage("lang"), fromNavigator(), "en")
|
|
||||||
|
|
||||||
// log if dev
|
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
console.log("detected locale", locale)
|
|
||||||
}
|
|
||||||
|
|
||||||
// activates locale
|
|
||||||
function activateLocale(locale: string, messages: Messages = enMessages) {
|
|
||||||
i18n.load(locale, messages)
|
|
||||||
i18n.activate(locale)
|
|
||||||
document.documentElement.lang = locale
|
|
||||||
$direction.set(locale.startsWith("ar") ? "rtl" : "ltr")
|
|
||||||
}
|
|
||||||
|
|
||||||
// dynamically loads translations for the given locale
|
|
||||||
export async function dynamicActivate(locale: string) {
|
|
||||||
try {
|
|
||||||
const { messages }: { messages: Messages } = await import(`../locales/${locale}/${locale}.ts`)
|
|
||||||
activateLocale(locale, messages)
|
|
||||||
localStorage.setItem("lang", locale)
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error loading ${locale}`, error)
|
|
||||||
activateLocale("en")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// handle zh variants
|
|
||||||
if (locale?.startsWith("zh-")) {
|
|
||||||
// map zh variants to zh-CN
|
|
||||||
const zhVariantMap: Record<string, string> = {
|
|
||||||
"zh-CN": "zh-CN",
|
|
||||||
"zh-SG": "zh-CN",
|
|
||||||
"zh-MY": "zh-CN",
|
|
||||||
zh: "zh-CN",
|
|
||||||
"zh-Hans": "zh-CN",
|
|
||||||
"zh-HK": "zh-HK",
|
|
||||||
"zh-TW": "zh-HK",
|
|
||||||
"zh-MO": "zh-HK",
|
|
||||||
"zh-Hant": "zh-HK",
|
|
||||||
}
|
|
||||||
dynamicActivate(zhVariantMap[locale] || "zh-CN")
|
|
||||||
} else {
|
|
||||||
locale = (locale || "en").split("-")[0]
|
|
||||||
// use en if locale is not in languages
|
|
||||||
if (!languages.some((l) => l.lang === locale)) {
|
|
||||||
locale = "en"
|
|
||||||
}
|
|
||||||
// handle non-english locales
|
|
||||||
if (locale !== "en") {
|
|
||||||
dynamicActivate(locale)
|
|
||||||
} else {
|
|
||||||
// fallback to en
|
|
||||||
activateLocale("en")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"lang": "ar",
|
|
||||||
"label": "العربية",
|
|
||||||
"e": "🇵🇸"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"lang": "de",
|
|
||||||
"label": "Deutsch",
|
|
||||||
"e": "🇩🇪"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"lang": "en",
|
|
||||||
"label": "English",
|
|
||||||
"e": "🇺🇸"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"lang": "es",
|
|
||||||
"label": "Español",
|
|
||||||
"e": "🇲🇽"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"lang": "fr",
|
|
||||||
"label": "Français",
|
|
||||||
"e": "🇫🇷"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"lang": "it",
|
|
||||||
"label": "Italiano",
|
|
||||||
"e": "🇮🇹"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"lang": "ja",
|
|
||||||
"label": "日本語",
|
|
||||||
"e": "🇯🇵"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"lang": "ko",
|
|
||||||
"label": "한국어",
|
|
||||||
"e": "🇰🇷"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"lang": "pt",
|
|
||||||
"label": "Português",
|
|
||||||
"e": "🇧🇷"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"lang": "tr",
|
|
||||||
"label": "Türkçe",
|
|
||||||
"e": "🇹🇷"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"lang": "ru",
|
|
||||||
"label": "Русский",
|
|
||||||
"e": "🇷🇺"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"lang": "uk",
|
|
||||||
"label": "Українська",
|
|
||||||
"e": "🇺🇦"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"lang": "vi",
|
|
||||||
"label": "Tiếng Việt",
|
|
||||||
"e": "🇻🇳"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"lang": "zh-CN",
|
|
||||||
"label": "简体中文",
|
|
||||||
"e": "🇨🇳"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"lang": "zh-HK",
|
|
||||||
"label": "繁體中文",
|
|
||||||
"e": "🇭🇰"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import PocketBase from "pocketbase"
|
import PocketBase from 'pocketbase'
|
||||||
import { atom, map, WritableAtom } from "nanostores"
|
import { atom, map, WritableAtom } from 'nanostores'
|
||||||
import { AlertRecord, ChartTimes, SystemRecord, UserSettings } from "@/types"
|
import { AlertRecord, ChartTimes, SystemRecord, UserSettings } from '@/types'
|
||||||
|
|
||||||
/** PocketBase JS Client */
|
/** PocketBase JS Client */
|
||||||
export const pb = new PocketBase("/")
|
export const pb = new PocketBase('/')
|
||||||
|
|
||||||
/** Store if user is authenticated */
|
/** Store if user is authenticated */
|
||||||
export const $authenticated = atom(pb.authStore.isValid)
|
export const $authenticated = atom(pb.authStore.isValid)
|
||||||
@@ -15,18 +15,18 @@ export const $systems = atom([] as SystemRecord[])
|
|||||||
export const $alerts = atom([] as AlertRecord[])
|
export const $alerts = atom([] as AlertRecord[])
|
||||||
|
|
||||||
/** SSH public key */
|
/** SSH public key */
|
||||||
export const $publicKey = atom("")
|
export const $publicKey = atom('')
|
||||||
|
|
||||||
/** Beszel hub version */
|
/** Beszel hub version */
|
||||||
export const $hubVersion = atom("")
|
export const $hubVersion = atom('')
|
||||||
|
|
||||||
/** Chart time period */
|
/** Chart time period */
|
||||||
export const $chartTime = atom("1h") as WritableAtom<ChartTimes>
|
export const $chartTime = atom('1h') as WritableAtom<ChartTimes>
|
||||||
|
|
||||||
/** User settings */
|
/** User settings */
|
||||||
export const $userSettings = map<UserSettings>({
|
export const $userSettings = map<UserSettings>({
|
||||||
chartTime: "1h",
|
chartTime: '1h',
|
||||||
emails: [pb.authStore.model?.email || ""],
|
emails: [pb.authStore.model?.email || ''],
|
||||||
})
|
})
|
||||||
// update local storage on change
|
// update local storage on change
|
||||||
$userSettings.subscribe((value) => {
|
$userSettings.subscribe((value) => {
|
||||||
@@ -35,10 +35,7 @@ $userSettings.subscribe((value) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
/** Container chart filter */
|
/** Container chart filter */
|
||||||
export const $containerFilter = atom("")
|
export const $containerFilter = atom('')
|
||||||
|
|
||||||
/** Fallback copy to clipboard dialog content */
|
/** Fallback copy to clipboard dialog content */
|
||||||
export const $copyContent = atom("")
|
export const $copyContent = atom('')
|
||||||
|
|
||||||
/** Direction for localization */
|
|
||||||
export const $direction = atom<"ltr" | "rtl">("ltr")
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useState } from "react"
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
// adapted from usehooks-ts/use-intersection-observer
|
// adapted from usehooks-ts/use-intersection-observer
|
||||||
|
|
||||||
@@ -72,7 +72,7 @@ type IntersectionReturn = {
|
|||||||
export function useIntersectionObserver({
|
export function useIntersectionObserver({
|
||||||
threshold = 0,
|
threshold = 0,
|
||||||
root = null,
|
root = null,
|
||||||
rootMargin = "0%",
|
rootMargin = '0%',
|
||||||
freeze = true,
|
freeze = true,
|
||||||
initialIsIntersecting = false,
|
initialIsIntersecting = false,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -84,7 +84,7 @@ export function useIntersectionObserver({
|
|||||||
entry: undefined,
|
entry: undefined,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const callbackRef = useRef<UseIntersectionObserverOptions["onChange"]>()
|
const callbackRef = useRef<UseIntersectionObserverOptions['onChange']>()
|
||||||
|
|
||||||
callbackRef.current = onChange
|
callbackRef.current = onChange
|
||||||
|
|
||||||
@@ -95,7 +95,7 @@ export function useIntersectionObserver({
|
|||||||
if (!ref) return
|
if (!ref) return
|
||||||
|
|
||||||
// Ensure the browser supports the Intersection Observer API
|
// Ensure the browser supports the Intersection Observer API
|
||||||
if (!("IntersectionObserver" in window)) return
|
if (!('IntersectionObserver' in window)) return
|
||||||
|
|
||||||
// Skip if frozen
|
// Skip if frozen
|
||||||
if (frozen) return
|
if (frozen) return
|
||||||
@@ -104,11 +104,14 @@ export function useIntersectionObserver({
|
|||||||
|
|
||||||
const observer = new IntersectionObserver(
|
const observer = new IntersectionObserver(
|
||||||
(entries: IntersectionObserverEntry[]): void => {
|
(entries: IntersectionObserverEntry[]): void => {
|
||||||
const thresholds = Array.isArray(observer.thresholds) ? observer.thresholds : [observer.thresholds]
|
const thresholds = Array.isArray(observer.thresholds)
|
||||||
|
? observer.thresholds
|
||||||
|
: [observer.thresholds]
|
||||||
|
|
||||||
entries.forEach((entry) => {
|
entries.forEach((entry) => {
|
||||||
const isIntersecting =
|
const isIntersecting =
|
||||||
entry.isIntersecting && thresholds.some((threshold) => entry.intersectionRatio >= threshold)
|
entry.isIntersecting &&
|
||||||
|
thresholds.some((threshold) => entry.intersectionRatio >= threshold)
|
||||||
|
|
||||||
setState({ isIntersecting, entry })
|
setState({ isIntersecting, entry })
|
||||||
|
|
||||||
@@ -146,7 +149,13 @@ export function useIntersectionObserver({
|
|||||||
const prevRef = useRef<Element | null>(null)
|
const prevRef = useRef<Element | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ref && state.entry?.target && !freeze && !frozen && prevRef.current !== state.entry.target) {
|
if (
|
||||||
|
!ref &&
|
||||||
|
state.entry?.target &&
|
||||||
|
!freeze &&
|
||||||
|
!frozen &&
|
||||||
|
prevRef.current !== state.entry.target
|
||||||
|
) {
|
||||||
prevRef.current = state.entry.target
|
prevRef.current = state.entry.target
|
||||||
setState({ isIntersecting: initialIsIntersecting, entry: undefined })
|
setState({ isIntersecting: initialIsIntersecting, entry: undefined })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,16 @@
|
|||||||
import { toast } from "@/components/ui/use-toast"
|
import { toast } from '@/components/ui/use-toast'
|
||||||
import { type ClassValue, clsx } from "clsx"
|
import { type ClassValue, clsx } from 'clsx'
|
||||||
import { twMerge } from "tailwind-merge"
|
import { twMerge } from 'tailwind-merge'
|
||||||
import { $alerts, $copyContent, $systems, $userSettings, pb } from "./stores"
|
import { $alerts, $copyContent, $systems, $userSettings, pb } from './stores'
|
||||||
import { AlertInfo, AlertRecord, ChartTimeData, ChartTimes, SystemRecord } from "@/types"
|
import { AlertRecord, ChartTimeData, ChartTimes, SystemRecord } from '@/types'
|
||||||
import { RecordModel, RecordSubscription } from "pocketbase"
|
import { RecordModel, RecordSubscription } from 'pocketbase'
|
||||||
import { WritableAtom } from "nanostores"
|
import { WritableAtom } from 'nanostores'
|
||||||
import { timeDay, timeHour } from "d3-time"
|
import { timeDay, timeHour } from 'd3-time'
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from 'react'
|
||||||
import { CpuIcon, HardDriveIcon, MemoryStickIcon, ServerIcon } from "lucide-react"
|
|
||||||
import { EthernetIcon, ThermometerIcon } from "@/components/ui/icons"
|
|
||||||
import { t } from "@lingui/macro"
|
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs))
|
||||||
}
|
}
|
||||||
// export const cn = clsx
|
|
||||||
|
|
||||||
export async function copyToClipboard(content: string) {
|
export async function copyToClipboard(content: string) {
|
||||||
const duration = 1500
|
const duration = 1500
|
||||||
@@ -22,7 +18,7 @@ export async function copyToClipboard(content: string) {
|
|||||||
await navigator.clipboard.writeText(content)
|
await navigator.clipboard.writeText(content)
|
||||||
toast({
|
toast({
|
||||||
duration,
|
duration,
|
||||||
description: t`Copied to clipboard`,
|
description: 'Copied to clipboard',
|
||||||
})
|
})
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
$copyContent.set(content)
|
$copyContent.set(content)
|
||||||
@@ -30,22 +26,22 @@ export async function copyToClipboard(content: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const verifyAuth = () => {
|
const verifyAuth = () => {
|
||||||
pb.collection("users")
|
pb.collection('users')
|
||||||
.authRefresh()
|
.authRefresh()
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
pb.authStore.clear()
|
pb.authStore.clear()
|
||||||
toast({
|
toast({
|
||||||
title: t`Failed to authenticate`,
|
title: 'Failed to authenticate',
|
||||||
description: t`Please log in again`,
|
description: 'Please log in again',
|
||||||
variant: "destructive",
|
variant: 'destructive',
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const updateSystemList = async () => {
|
export const updateSystemList = async () => {
|
||||||
const records = await pb
|
const records = await pb
|
||||||
.collection<SystemRecord>("systems")
|
.collection<SystemRecord>('systems')
|
||||||
.getFullList({ sort: "+name", fields: "id,name,host,info,status" })
|
.getFullList({ sort: '+name', fields: 'id,name,host,info,status' })
|
||||||
if (records.length) {
|
if (records.length) {
|
||||||
$systems.set(records)
|
$systems.set(records)
|
||||||
} else {
|
} else {
|
||||||
@@ -54,51 +50,71 @@ export const updateSystemList = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const updateAlerts = () => {
|
export const updateAlerts = () => {
|
||||||
pb.collection("alerts")
|
pb.collection('alerts')
|
||||||
.getFullList<AlertRecord>({ fields: "id,name,system,value,min,triggered", sort: "updated" })
|
.getFullList<AlertRecord>({ fields: 'id,name,system,value' })
|
||||||
.then((records) => {
|
.then((records) => {
|
||||||
$alerts.set(records)
|
$alerts.set(records)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const hourWithMinutesFormatter = new Intl.DateTimeFormat(undefined, {
|
const hourWithMinutesFormatter = new Intl.DateTimeFormat(undefined, {
|
||||||
hour: "numeric",
|
hour: 'numeric',
|
||||||
minute: "numeric",
|
minute: 'numeric',
|
||||||
})
|
})
|
||||||
export const hourWithMinutes = (timestamp: string) => {
|
export const hourWithMinutes = (timestamp: string) => {
|
||||||
return hourWithMinutesFormatter.format(new Date(timestamp))
|
return hourWithMinutesFormatter.format(new Date(timestamp))
|
||||||
}
|
}
|
||||||
|
|
||||||
const shortDateFormatter = new Intl.DateTimeFormat(undefined, {
|
const shortDateFormatter = new Intl.DateTimeFormat(undefined, {
|
||||||
day: "numeric",
|
day: 'numeric',
|
||||||
month: "short",
|
month: 'short',
|
||||||
hour: "numeric",
|
hour: 'numeric',
|
||||||
minute: "numeric",
|
minute: 'numeric',
|
||||||
})
|
})
|
||||||
export const formatShortDate = (timestamp: string) => {
|
export const formatShortDate = (timestamp: string) => {
|
||||||
|
// console.log('ts', timestamp)
|
||||||
return shortDateFormatter.format(new Date(timestamp))
|
return shortDateFormatter.format(new Date(timestamp))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// const dayTimeFormatter = new Intl.DateTimeFormat(undefined, {
|
||||||
|
// // day: 'numeric',
|
||||||
|
// // month: 'short',
|
||||||
|
// hour: 'numeric',
|
||||||
|
// weekday: 'short',
|
||||||
|
// minute: 'numeric',
|
||||||
|
// // dateStyle: 'short',
|
||||||
|
// })
|
||||||
|
// export const formatDayTime = (timestamp: string) => {
|
||||||
|
// // console.log('ts', timestamp)
|
||||||
|
// return dayTimeFormatter.format(new Date(timestamp))
|
||||||
|
// }
|
||||||
|
|
||||||
const dayFormatter = new Intl.DateTimeFormat(undefined, {
|
const dayFormatter = new Intl.DateTimeFormat(undefined, {
|
||||||
day: "numeric",
|
day: 'numeric',
|
||||||
month: "short",
|
month: 'short',
|
||||||
|
// dateStyle: 'medium',
|
||||||
})
|
})
|
||||||
export const formatDay = (timestamp: string) => {
|
export const formatDay = (timestamp: string) => {
|
||||||
|
// console.log('ts', timestamp)
|
||||||
return dayFormatter.format(new Date(timestamp))
|
return dayFormatter.format(new Date(timestamp))
|
||||||
}
|
}
|
||||||
|
|
||||||
export const updateFavicon = (newIcon: string) => {
|
export const updateFavicon = (newIcon: string) =>
|
||||||
;(document.querySelector("link[rel='icon']") as HTMLLinkElement).href = `/static/${newIcon}`
|
((document.querySelector("link[rel='icon']") as HTMLLinkElement).href = `/static/${newIcon}`)
|
||||||
}
|
|
||||||
|
|
||||||
export const isAdmin = () => pb.authStore.model?.role === "admin"
|
export const isAdmin = () => pb.authStore.model?.role === 'admin'
|
||||||
export const isReadOnlyUser = () => pb.authStore.model?.role === "readonly"
|
export const isReadOnlyUser = () => pb.authStore.model?.role === 'readonly'
|
||||||
|
// export const isDefaultUser = () => pb.authStore.model?.role === 'user'
|
||||||
|
|
||||||
/** Update systems / alerts list when records change */
|
/** Update systems / alerts list when records change */
|
||||||
export function updateRecordList<T extends RecordModel>(e: RecordSubscription<T>, $store: WritableAtom<T[]>) {
|
export function updateRecordList<T extends RecordModel>(
|
||||||
|
e: RecordSubscription<T>,
|
||||||
|
$store: WritableAtom<T[]>
|
||||||
|
) {
|
||||||
const curRecords = $store.get()
|
const curRecords = $store.get()
|
||||||
const newRecords = []
|
const newRecords = []
|
||||||
if (e.action === "delete") {
|
// console.log('e', e)
|
||||||
|
if (e.action === 'delete') {
|
||||||
for (const server of curRecords) {
|
for (const server of curRecords) {
|
||||||
if (server.id !== e.record.id) {
|
if (server.id !== e.record.id) {
|
||||||
newRecords.push(server)
|
newRecords.push(server)
|
||||||
@@ -120,54 +136,54 @@ export function updateRecordList<T extends RecordModel>(e: RecordSubscription<T>
|
|||||||
$store.set(newRecords)
|
$store.set(newRecords)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPbTimestamp(timeString: ChartTimes, d?: Date) {
|
export function getPbTimestamp(timeString: ChartTimes) {
|
||||||
d ||= chartTimeData[timeString].getOffset(new Date())
|
const d = chartTimeData[timeString].getOffset(new Date())
|
||||||
const year = d.getUTCFullYear()
|
const year = d.getUTCFullYear()
|
||||||
const month = String(d.getUTCMonth() + 1).padStart(2, "0")
|
const month = String(d.getUTCMonth() + 1).padStart(2, '0')
|
||||||
const day = String(d.getUTCDate()).padStart(2, "0")
|
const day = String(d.getUTCDate()).padStart(2, '0')
|
||||||
const hours = String(d.getUTCHours()).padStart(2, "0")
|
const hours = String(d.getUTCHours()).padStart(2, '0')
|
||||||
const minutes = String(d.getUTCMinutes()).padStart(2, "0")
|
const minutes = String(d.getUTCMinutes()).padStart(2, '0')
|
||||||
const seconds = String(d.getUTCSeconds()).padStart(2, "0")
|
const seconds = String(d.getUTCSeconds()).padStart(2, '0')
|
||||||
|
|
||||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
|
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export const chartTimeData: ChartTimeData = {
|
export const chartTimeData: ChartTimeData = {
|
||||||
"1h": {
|
'1h': {
|
||||||
type: "1m",
|
type: '1m',
|
||||||
expectedInterval: 60_000,
|
expectedInterval: 60_000,
|
||||||
label: () => t`1 hour`,
|
label: '1 hour',
|
||||||
// ticks: 12,
|
// ticks: 12,
|
||||||
format: (timestamp: string) => hourWithMinutes(timestamp),
|
format: (timestamp: string) => hourWithMinutes(timestamp),
|
||||||
getOffset: (endTime: Date) => timeHour.offset(endTime, -1),
|
getOffset: (endTime: Date) => timeHour.offset(endTime, -1),
|
||||||
},
|
},
|
||||||
"12h": {
|
'12h': {
|
||||||
type: "10m",
|
type: '10m',
|
||||||
expectedInterval: 60_000 * 10,
|
expectedInterval: 60_000 * 10,
|
||||||
label: () => t`12 hours`,
|
label: '12 hours',
|
||||||
ticks: 12,
|
ticks: 12,
|
||||||
format: (timestamp: string) => hourWithMinutes(timestamp),
|
format: (timestamp: string) => hourWithMinutes(timestamp),
|
||||||
getOffset: (endTime: Date) => timeHour.offset(endTime, -12),
|
getOffset: (endTime: Date) => timeHour.offset(endTime, -12),
|
||||||
},
|
},
|
||||||
"24h": {
|
'24h': {
|
||||||
type: "20m",
|
type: '20m',
|
||||||
expectedInterval: 60_000 * 20,
|
expectedInterval: 60_000 * 20,
|
||||||
label: () => t`24 hours`,
|
label: '24 hours',
|
||||||
format: (timestamp: string) => hourWithMinutes(timestamp),
|
format: (timestamp: string) => hourWithMinutes(timestamp),
|
||||||
getOffset: (endTime: Date) => timeHour.offset(endTime, -24),
|
getOffset: (endTime: Date) => timeHour.offset(endTime, -24),
|
||||||
},
|
},
|
||||||
"1w": {
|
'1w': {
|
||||||
type: "120m",
|
type: '120m',
|
||||||
expectedInterval: 60_000 * 120,
|
expectedInterval: 60_000 * 120,
|
||||||
label: () => t`1 week`,
|
label: '1 week',
|
||||||
ticks: 7,
|
ticks: 7,
|
||||||
format: (timestamp: string) => formatDay(timestamp),
|
format: (timestamp: string) => formatDay(timestamp),
|
||||||
getOffset: (endTime: Date) => timeDay.offset(endTime, -7),
|
getOffset: (endTime: Date) => timeDay.offset(endTime, -7),
|
||||||
},
|
},
|
||||||
"30d": {
|
'30d': {
|
||||||
type: "480m",
|
type: '480m',
|
||||||
expectedInterval: 60_000 * 480,
|
expectedInterval: 60_000 * 480,
|
||||||
label: () => t`30 days`,
|
label: '30 days',
|
||||||
ticks: 30,
|
ticks: 30,
|
||||||
format: (timestamp: string) => formatDay(timestamp),
|
format: (timestamp: string) => formatDay(timestamp),
|
||||||
getOffset: (endTime: Date) => timeDay.offset(endTime, -30),
|
getOffset: (endTime: Date) => timeDay.offset(endTime, -30),
|
||||||
@@ -182,16 +198,13 @@ export function useYAxisWidth() {
|
|||||||
function updateYAxisWidth(str: string) {
|
function updateYAxisWidth(str: string) {
|
||||||
if (str.length > maxChars) {
|
if (str.length > maxChars) {
|
||||||
maxChars = str.length
|
maxChars = str.length
|
||||||
const div = document.createElement("div")
|
const div = document.createElement('div')
|
||||||
div.className = "text-xs tabular-nums tracking-tighter table sr-only"
|
div.className = 'text-xs tabular-nums tracking-tighter table sr-only'
|
||||||
div.innerHTML = str
|
div.innerHTML = str
|
||||||
clearTimeout(timeout)
|
clearTimeout(timeout)
|
||||||
timeout = setTimeout(() => {
|
timeout = setTimeout(() => {
|
||||||
document.body.appendChild(div)
|
document.body.appendChild(div)
|
||||||
const width = div.offsetWidth + 24
|
setYAxisWidth(div.offsetWidth + 24)
|
||||||
if (width > yAxisWidth) {
|
|
||||||
setYAxisWidth(div.offsetWidth + 24)
|
|
||||||
}
|
|
||||||
document.body.removeChild(div)
|
document.body.removeChild(div)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -208,18 +221,17 @@ export function toFixedFloat(num: number, digits: number) {
|
|||||||
return parseFloat(num.toFixed(digits))
|
return parseFloat(num.toFixed(digits))
|
||||||
}
|
}
|
||||||
|
|
||||||
let decimalFormatters: Map<number, Intl.NumberFormat> = new Map()
|
let twoDecimalFormatter: Intl.NumberFormat
|
||||||
/** Format number to x decimal places */
|
/** Format number to two decimal places */
|
||||||
export function decimalString(num: number, digits = 2) {
|
export function twoDecimalString(num: number) {
|
||||||
let formatter = decimalFormatters.get(digits)
|
if (!twoDecimalFormatter) {
|
||||||
if (!formatter) {
|
twoDecimalFormatter = new Intl.NumberFormat(undefined, {
|
||||||
formatter = new Intl.NumberFormat(undefined, {
|
minimumFractionDigits: 2,
|
||||||
minimumFractionDigits: digits,
|
maximumFractionDigits: 2,
|
||||||
maximumFractionDigits: digits,
|
|
||||||
})
|
})
|
||||||
decimalFormatters.set(digits, formatter)
|
|
||||||
}
|
}
|
||||||
return formatter.format(num)
|
// Return a function that formats numbers using the saved formatter
|
||||||
|
return twoDecimalFormatter.format(num)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get value from local storage */
|
/** Get value from local storage */
|
||||||
@@ -229,7 +241,7 @@ function getStorageValue(key: string, defaultValue: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Hook to sync value in local storage */
|
/** Hook to sync value in local storage */
|
||||||
export function useLocalStorage<T>(key: string, defaultValue: T) {
|
export const useLocalStorage = (key: string, defaultValue: any) => {
|
||||||
key = `besz-${key}`
|
key = `besz-${key}`
|
||||||
const [value, setValue] = useState(() => {
|
const [value, setValue] = useState(() => {
|
||||||
return getStorageValue(key, defaultValue)
|
return getStorageValue(key, defaultValue)
|
||||||
@@ -243,77 +255,33 @@ export function useLocalStorage<T>(key: string, defaultValue: T) {
|
|||||||
|
|
||||||
export async function updateUserSettings() {
|
export async function updateUserSettings() {
|
||||||
try {
|
try {
|
||||||
const req = await pb.collection("user_settings").getFirstListItem("", { fields: "settings" })
|
const req = await pb.collection('user_settings').getFirstListItem('', { fields: 'settings' })
|
||||||
$userSettings.set(req.settings)
|
$userSettings.set(req.settings)
|
||||||
return
|
return
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log("get settings", e)
|
console.log('get settings', e)
|
||||||
}
|
}
|
||||||
// create user settings if error fetching existing
|
// create user settings if error fetching existing
|
||||||
try {
|
try {
|
||||||
const createdSettings = await pb.collection("user_settings").create({ user: pb.authStore.model!.id })
|
const createdSettings = await pb
|
||||||
|
.collection('user_settings')
|
||||||
|
.create({ user: pb.authStore.model!.id })
|
||||||
$userSettings.set(createdSettings.settings)
|
$userSettings.set(createdSettings.settings)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log("create settings", e)
|
console.log('create settings', e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the value and unit of size (TB, GB, or MB) for a given size
|
* Get the unit of size (TB or GB) for a given size in gigabytes
|
||||||
* @param n size in gigabytes or megabytes
|
* @param n size in gigabytes
|
||||||
* @param isGigabytes boolean indicating if n represents gigabytes (true) or megabytes (false)
|
* @returns unit of size (TB or GB)
|
||||||
* @returns an object containing the value and unit of size
|
|
||||||
*/
|
*/
|
||||||
export const getSizeAndUnit = (n: number, isGigabytes = true) => {
|
export const getSizeUnit = (n: number) => (n >= 1_000 ? ' TB' : ' GB')
|
||||||
const sizeInGB = isGigabytes ? n : n / 1_000
|
|
||||||
|
|
||||||
if (sizeInGB >= 1_000) {
|
/**
|
||||||
return { v: sizeInGB / 1_000, u: " TB" }
|
* Get the value of number in gigabytes if less than 1000, otherwise in terabytes
|
||||||
} else if (sizeInGB >= 1) {
|
* @param n size in gigabytes
|
||||||
return { v: sizeInGB, u: " GB" }
|
* @returns value in GB if less than 1000, otherwise value in TB
|
||||||
}
|
*/
|
||||||
return { v: n, u: " MB" }
|
export const getSizeVal = (n: number) => (n >= 1_000 ? n / 1_000 : n)
|
||||||
}
|
|
||||||
|
|
||||||
export const chartMargin = { top: 12 }
|
|
||||||
|
|
||||||
export const alertInfo: Record<string, AlertInfo> = {
|
|
||||||
Status: {
|
|
||||||
name: () => t`Status`,
|
|
||||||
unit: "",
|
|
||||||
icon: ServerIcon,
|
|
||||||
desc: () => t`Triggers when status switches between up and down`,
|
|
||||||
single: true,
|
|
||||||
},
|
|
||||||
CPU: {
|
|
||||||
name: () => t`CPU Usage`,
|
|
||||||
unit: "%",
|
|
||||||
icon: CpuIcon,
|
|
||||||
desc: () => t`Triggers when CPU usage exceeds a threshold`,
|
|
||||||
},
|
|
||||||
Memory: {
|
|
||||||
name: () => t`Memory Usage`,
|
|
||||||
unit: "%",
|
|
||||||
icon: MemoryStickIcon,
|
|
||||||
desc: () => t`Triggers when memory usage exceeds a threshold`,
|
|
||||||
},
|
|
||||||
Disk: {
|
|
||||||
name: () => t`Disk Usage`,
|
|
||||||
unit: "%",
|
|
||||||
icon: HardDriveIcon,
|
|
||||||
desc: () => t`Triggers when usage of any disk exceeds a threshold`,
|
|
||||||
},
|
|
||||||
Bandwidth: {
|
|
||||||
name: () => t`Bandwidth`,
|
|
||||||
unit: " MB/s",
|
|
||||||
icon: EthernetIcon,
|
|
||||||
desc: () => t`Triggers when combined up/down exceeds a threshold`,
|
|
||||||
max: 125,
|
|
||||||
},
|
|
||||||
Temperature: {
|
|
||||||
name: () => t`Temperature`,
|
|
||||||
unit: "°C",
|
|
||||||
icon: ThermometerIcon,
|
|
||||||
desc: () => t`Triggers when any sensor exceeds a threshold`,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,809 +0,0 @@
|
|||||||
msgid ""
|
|
||||||
msgstr ""
|
|
||||||
"POT-Creation-Date: 2024-11-01 11:30-0400\n"
|
|
||||||
"MIME-Version: 1.0\n"
|
|
||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
|
||||||
"Content-Transfer-Encoding: 8bit\n"
|
|
||||||
"X-Generator: @lingui/cli\n"
|
|
||||||
"Language: ar\n"
|
|
||||||
"Project-Id-Version: beszel\n"
|
|
||||||
"Report-Msgid-Bugs-To: \n"
|
|
||||||
"PO-Revision-Date: 2024-11-02 19:37\n"
|
|
||||||
"Last-Translator: \n"
|
|
||||||
"Language-Team: Arabic\n"
|
|
||||||
"Plural-Forms: nplurals=6; plural=(n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5);\n"
|
|
||||||
"X-Crowdin-Project: beszel\n"
|
|
||||||
"X-Crowdin-Project-ID: 733311\n"
|
|
||||||
"X-Crowdin-Language: ar\n"
|
|
||||||
"X-Crowdin-File: /main/beszel/site/src/locales/en/en.po\n"
|
|
||||||
"X-Crowdin-File-ID: 16\n"
|
|
||||||
|
|
||||||
#: src/components/routes/system.tsx:242
|
|
||||||
msgid "{0, plural, one {# day} other {# days}}"
|
|
||||||
msgstr "{0, plural, one {# يوم} other {# أيام}}"
|
|
||||||
|
|
||||||
#: src/components/routes/system.tsx:240
|
|
||||||
msgid "{hours, plural, one {# hour} other {# hours}}"
|
|
||||||
msgstr "{hours, plural, one {# ساعة} other {# ساعات}}"
|
|
||||||
|
|
||||||
#: src/lib/utils.ts:139
|
|
||||||
msgid "1 hour"
|
|
||||||
msgstr "1 ساعة"
|
|
||||||
|
|
||||||
#: src/lib/utils.ts:162
|
|
||||||
msgid "1 week"
|
|
||||||
msgstr "1 أسبوع"
|
|
||||||
|
|
||||||
#: src/lib/utils.ts:147
|
|
||||||
msgid "12 hours"
|
|
||||||
msgstr "12 ساعة"
|
|
||||||
|
|
||||||
#: src/lib/utils.ts:155
|
|
||||||
msgid "24 hours"
|
|
||||||
msgstr "24 ساعة"
|
|
||||||
|
|
||||||
#: src/lib/utils.ts:170
|
|
||||||
msgid "30 days"
|
|
||||||
msgstr "30 يومًا"
|
|
||||||
|
|
||||||
#. Table column
|
|
||||||
#: src/components/systems-table/systems-table.tsx:207
|
|
||||||
msgid "Actions"
|
|
||||||
msgstr "إجراءات"
|
|
||||||
|
|
||||||
#: src/components/routes/home.tsx:62
|
|
||||||
msgid "Active Alerts"
|
|
||||||
msgstr "التنبيهات النشطة"
|
|
||||||
|
|
||||||
#: src/components/add-system.tsx:74
|
|
||||||
msgid "Add <0>System</0>"
|
|
||||||
msgstr "إضافة <0>نظام</0>"
|
|
||||||
|
|
||||||
#: src/components/add-system.tsx:83
|
|
||||||
msgid "Add New System"
|
|
||||||
msgstr "إضافة نظام جديد"
|
|
||||||
|
|
||||||
#: src/components/add-system.tsx:167
|
|
||||||
#: src/components/add-system.tsx:178
|
|
||||||
msgid "Add system"
|
|
||||||
msgstr "إضافة نظام"
|
|
||||||
|
|
||||||
#: src/components/routes/settings/notifications.tsx:156
|
|
||||||
msgid "Add URL"
|
|
||||||
msgstr "إضافة عنوان URL"
|
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx:81
|
|
||||||
msgid "Adjust display options for charts."
|
|
||||||
msgstr "تعديل خيارات العرض للرسوم البيانية."
|
|
||||||
|
|
||||||
#: src/components/command-palette.tsx:133
|
|
||||||
#: src/components/command-palette.tsx:146
|
|
||||||
#: src/components/command-palette.tsx:160
|
|
||||||
#: src/components/command-palette.tsx:174
|
|
||||||
#: src/components/command-palette.tsx:189
|
|
||||||
#: src/components/command-palette.tsx:204
|
|
||||||
msgid "Admin"
|
|
||||||
msgstr "مسؤول"
|
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx:186
|
|
||||||
msgid "Agent"
|
|
||||||
msgstr "وكيل"
|
|
||||||
|
|
||||||
#: src/components/alerts/alert-button.tsx:32
|
|
||||||
#: src/components/alerts/alert-button.tsx:68
|
|
||||||
msgid "Alerts"
|
|
||||||
msgstr "التنبيهات"
|
|
||||||
|
|
||||||
#: src/components/alerts/alert-button.tsx:88
|
|
||||||
#: src/components/systems-table/systems-table.tsx:317
|
|
||||||
msgid "All Systems"
|
|
||||||
msgstr "جميع الأنظمة"
|
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx:261
|
|
||||||
msgid "Are you sure you want to delete {name}?"
|
|
||||||
msgstr "هل أنت متأكد أنك تريد حذف {name}؟"
|
|
||||||
|
|
||||||
#: src/components/command-palette.tsx:186
|
|
||||||
#: src/components/navbar.tsx:102
|
|
||||||
msgid "Auth Providers"
|
|
||||||
msgstr "مزودو المصادقة"
|
|
||||||
|
|
||||||
#: src/components/copy-to-clipboard.tsx:16
|
|
||||||
msgid "Automatic copy requires a secure context."
|
|
||||||
msgstr "النسخ التلقائي يتطلب سياقًا آمنًا."
|
|
||||||
|
|
||||||
#: src/components/routes/system.tsx:568
|
|
||||||
msgid "Average"
|
|
||||||
msgstr "متوسط"
|
|
||||||
|
|
||||||
#: src/components/routes/system.tsx:387
|
|
||||||
msgid "Average CPU utilization of containers"
|
|
||||||
msgstr "متوسط استخدام وحدة المعالجة المركزية للحاويات"
|
|
||||||
|
|
||||||
#: src/components/alerts/alerts-system.tsx:204
|
|
||||||
msgid "Average exceeds <0>{value}{0}</0>"
|
|
||||||
msgstr "المتوسط يتجاوز <0>{value}{0}</0>"
|
|
||||||
|
|
||||||
#: src/components/routes/system.tsx:376
|
|
||||||
msgid "Average system-wide CPU utilization"
|
|
||||||
msgstr "متوسط استخدام وحدة المعالجة المركزية على مستوى النظام"
|
|
||||||
|
|
||||||
#: src/components/command-palette.tsx:171
|
|
||||||
#: src/components/navbar.tsx:94
|
|
||||||
msgid "Backups"
|
|
||||||
msgstr "النسخ الاحتياطية"
|
|
||||||
|
|
||||||
#: src/components/routes/system.tsx:436
|
|
||||||
#: src/lib/utils.ts:307
|
|
||||||
msgid "Bandwidth"
|
|
||||||
msgstr "عرض النطاق الترددي"
|
|
||||||
|
|
||||||
#: src/components/login/auth-form.tsx:313
|
|
||||||
msgid "Beszel supports OpenID Connect and many OAuth2 authentication providers."
|
|
||||||
msgstr "يدعم Beszel OpenID Connect والعديد من مزودي المصادقة OAuth2."
|
|
||||||
|
|
||||||
#: src/components/routes/settings/notifications.tsx:127
|
|
||||||
msgid "Beszel uses <0>Shoutrrr</0> to integrate with popular notification services."
|
|
||||||
msgstr "يستخدم Beszel <0>Shoutrrr</0> للتكامل مع خدمات الإشعارات الشهيرة."
|
|
||||||
|
|
||||||
#: src/components/add-system.tsx:88
|
|
||||||
msgid "Binary"
|
|
||||||
msgstr "ثنائي"
|
|
||||||
|
|
||||||
#: src/components/charts/mem-chart.tsx:89
|
|
||||||
msgid "Cache / Buffers"
|
|
||||||
msgstr "ذاكرة التخزين المؤقت / المخازن المؤقتة"
|
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx:272
|
|
||||||
msgid "Cancel"
|
|
||||||
msgstr "إلغاء"
|
|
||||||
|
|
||||||
#: src/components/routes/settings/config-yaml.tsx:68
|
|
||||||
msgid "Caution - potential data loss"
|
|
||||||
msgstr "تحذير - فقدان محتمل للبيانات"
|
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx:36
|
|
||||||
msgid "Change general application options."
|
|
||||||
msgstr "تغيير خيارات التطبيق العامة."
|
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx:78
|
|
||||||
msgid "Chart options"
|
|
||||||
msgstr "خيارات الرسم البياني"
|
|
||||||
|
|
||||||
#: src/components/login/forgot-pass-form.tsx:34
|
|
||||||
msgid "Check {email} for a reset link."
|
|
||||||
msgstr "تحقق من {email} للحصول على رابط إعادة التعيين."
|
|
||||||
|
|
||||||
#: src/components/routes/settings/layout.tsx:40
|
|
||||||
msgid "Check logs for more details."
|
|
||||||
msgstr "تحقق من السجلات لمزيد من التفاصيل."
|
|
||||||
|
|
||||||
#: src/components/routes/settings/notifications.tsx:183
|
|
||||||
msgid "Check your notification service"
|
|
||||||
msgstr "تحقق من خدمة الإشعارات الخاصة بك"
|
|
||||||
|
|
||||||
#: src/components/add-system.tsx:153
|
|
||||||
msgid "Click to copy"
|
|
||||||
msgstr "انقر للنسخ"
|
|
||||||
|
|
||||||
#. Context: table columns
|
|
||||||
#: src/components/systems-table/systems-table.tsx:328
|
|
||||||
msgid "Columns"
|
|
||||||
msgstr "أعمدة"
|
|
||||||
|
|
||||||
#: src/components/login/forgot-pass-form.tsx:83
|
|
||||||
#: src/components/login/forgot-pass-form.tsx:89
|
|
||||||
msgid "Command line instructions"
|
|
||||||
msgstr "تعليمات سطر الأوامر"
|
|
||||||
|
|
||||||
#: src/components/routes/settings/notifications.tsx:77
|
|
||||||
msgid "Configure how you receive alert notifications."
|
|
||||||
msgstr "قم بتكوين كيفية تلقي إشعارات التنبيه."
|
|
||||||
|
|
||||||
#: src/components/login/auth-form.tsx:189
|
|
||||||
#: src/components/login/auth-form.tsx:194
|
|
||||||
msgid "Confirm password"
|
|
||||||
msgstr "تأكيد كلمة المرور"
|
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx:278
|
|
||||||
msgid "Continue"
|
|
||||||
msgstr "متابعة"
|
|
||||||
|
|
||||||
#: src/lib/utils.ts:25
|
|
||||||
msgid "Copied to clipboard"
|
|
||||||
msgstr "تم النسخ إلى الحافظة"
|
|
||||||
|
|
||||||
#: src/components/add-system.tsx:164
|
|
||||||
msgid "Copy"
|
|
||||||
msgstr "نسخ"
|
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx:247
|
|
||||||
msgid "Copy host"
|
|
||||||
msgstr "نسخ المضيف"
|
|
||||||
|
|
||||||
#: src/components/add-system.tsx:175
|
|
||||||
msgid "Copy Linux command"
|
|
||||||
msgstr "نسخ أمر لينكس"
|
|
||||||
|
|
||||||
#: src/components/copy-to-clipboard.tsx:13
|
|
||||||
msgid "Copy text"
|
|
||||||
msgstr "نسخ النص"
|
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx:152
|
|
||||||
msgid "CPU"
|
|
||||||
msgstr "المعالج"
|
|
||||||
|
|
||||||
#: src/components/charts/area-chart.tsx:52
|
|
||||||
#: src/components/routes/system.tsx:375
|
|
||||||
#: src/lib/utils.ts:289
|
|
||||||
msgid "CPU Usage"
|
|
||||||
msgstr "استخدام وحدة المعالجة المركزية"
|
|
||||||
|
|
||||||
#: src/components/login/auth-form.tsx:215
|
|
||||||
msgid "Create account"
|
|
||||||
msgstr "إنشاء حساب"
|
|
||||||
|
|
||||||
#. Dark theme
|
|
||||||
#: src/components/mode-toggle.tsx:21
|
|
||||||
msgid "Dark"
|
|
||||||
msgstr "داكن"
|
|
||||||
|
|
||||||
#: src/components/command-palette.tsx:82
|
|
||||||
#: src/components/routes/home.tsx:35
|
|
||||||
msgid "Dashboard"
|
|
||||||
msgstr "لوحة التحكم"
|
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx:85
|
|
||||||
msgid "Default time period"
|
|
||||||
msgstr "الفترة الزمنية الافتراضية"
|
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx:253
|
|
||||||
msgid "Delete"
|
|
||||||
msgstr "حذف"
|
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx:166
|
|
||||||
msgid "Disk"
|
|
||||||
msgstr "القرص"
|
|
||||||
|
|
||||||
#: src/components/routes/system.tsx:426
|
|
||||||
msgid "Disk I/O"
|
|
||||||
msgstr "إدخال/إخراج القرص"
|
|
||||||
|
|
||||||
#: src/components/charts/disk-chart.tsx:74
|
|
||||||
#: src/components/routes/system.tsx:415
|
|
||||||
#: src/lib/utils.ts:301
|
|
||||||
msgid "Disk Usage"
|
|
||||||
msgstr "استخدام القرص"
|
|
||||||
|
|
||||||
#: src/components/routes/system.tsx:495
|
|
||||||
msgid "Disk usage of {extraFsName}"
|
|
||||||
msgstr "استخدام القرص لـ {extraFsName}"
|
|
||||||
|
|
||||||
#: src/components/routes/system.tsx:386
|
|
||||||
msgid "Docker CPU Usage"
|
|
||||||
msgstr "استخدام وحدة المعالجة المركزية لـ Docker"
|
|
||||||
|
|
||||||
#: src/components/routes/system.tsx:407
|
|
||||||
msgid "Docker Memory Usage"
|
|
||||||
msgstr "استخدام الذاكرة لـ Docker"
|
|
||||||
|
|
||||||
#: src/components/routes/system.tsx:452
|
|
||||||
msgid "Docker Network I/O"
|
|
||||||
msgstr "إدخال/إخراج الشبكة لـ Docker"
|
|
||||||
|
|
||||||
#: src/components/command-palette.tsx:125
|
|
||||||
msgid "Documentation"
|
|
||||||
msgstr "التوثيق"
|
|
||||||
|
|
||||||
#: src/components/login/auth-form.tsx:158
|
|
||||||
msgid "email"
|
|
||||||
msgstr "البريد الإلكتروني"
|
|
||||||
|
|
||||||
#: src/components/login/auth-form.tsx:152
|
|
||||||
#: src/components/login/forgot-pass-form.tsx:53
|
|
||||||
msgid "Email"
|
|
||||||
msgstr "البريد الإلكتروني"
|
|
||||||
|
|
||||||
#: src/components/routes/settings/notifications.tsx:91
|
|
||||||
msgid "Email notifications"
|
|
||||||
msgstr "إشعارات البريد الإلكتروني"
|
|
||||||
|
|
||||||
#: src/components/login/login.tsx:36
|
|
||||||
msgid "Enter email address to reset password"
|
|
||||||
msgstr "أدخل عنوان البريد الإلكتروني لإعادة تعيين كلمة المرور"
|
|
||||||
|
|
||||||
#: src/components/routes/settings/notifications.tsx:111
|
|
||||||
msgid "Enter email address..."
|
|
||||||
msgstr "أدخل عنوان البريد الإلكتروني..."
|
|
||||||
|
|
||||||
#: src/components/login/auth-form.tsx:256
|
|
||||||
#: src/components/routes/settings/config-yaml.tsx:28
|
|
||||||
#: src/components/routes/settings/notifications.tsx:187
|
|
||||||
msgid "Error"
|
|
||||||
msgstr "خطأ"
|
|
||||||
|
|
||||||
#: src/components/routes/home.tsx:81
|
|
||||||
msgid "Exceeds {0}{1} in last {2, plural, one {# minute} other {# minutes}}"
|
|
||||||
msgstr "يتجاوز {0}{1} في آخر {2, plural, one {# دقيقة} other {# دقائق}}"
|
|
||||||
|
|
||||||
#: src/components/routes/settings/config-yaml.tsx:72
|
|
||||||
msgid "Existing systems not defined in <0>config.yml</0> will be deleted. Please make regular backups."
|
|
||||||
msgstr "سيتم حذف الأنظمة الحالية غير المعرفة في <0>config.yml</0>. يرجى عمل نسخ احتياطية بانتظام."
|
|
||||||
|
|
||||||
#: src/components/routes/settings/config-yaml.tsx:93
|
|
||||||
msgid "Export configuration"
|
|
||||||
msgstr "تصدير التكوين"
|
|
||||||
|
|
||||||
#: src/components/routes/settings/config-yaml.tsx:48
|
|
||||||
msgid "Export your current systems configuration."
|
|
||||||
msgstr "تصدير تكوين الأنظمة الحالية الخاصة بك."
|
|
||||||
|
|
||||||
#: src/lib/utils.ts:38
|
|
||||||
msgid "Failed to authenticate"
|
|
||||||
msgstr "فشل في المصادقة"
|
|
||||||
|
|
||||||
#: src/components/routes/settings/layout.tsx:39
|
|
||||||
#: src/components/routes/settings/notifications.tsx:62
|
|
||||||
msgid "Failed to save settings"
|
|
||||||
msgstr "فشل في حفظ الإعدادات"
|
|
||||||
|
|
||||||
#: src/components/routes/settings/notifications.tsx:188
|
|
||||||
msgid "Failed to send test notification"
|
|
||||||
msgstr "فشل في إرسال إشعار الاختبار"
|
|
||||||
|
|
||||||
#: src/components/alerts/alerts-system.tsx:27
|
|
||||||
msgid "Failed to update alert"
|
|
||||||
msgstr "فشل في تحديث التنبيه"
|
|
||||||
|
|
||||||
#: src/components/routes/system.tsx:539
|
|
||||||
#: src/components/systems-table/systems-table.tsx:324
|
|
||||||
msgid "Filter..."
|
|
||||||
msgstr "تصفية..."
|
|
||||||
|
|
||||||
#: src/components/alerts/alerts-system.tsx:225
|
|
||||||
msgid "For <0>{min}</0> {min, plural, one {minute} other {minutes}}"
|
|
||||||
msgstr "لمدة <0>{min}</0> {min, plural, one {دقيقة} other {دقائق}}"
|
|
||||||
|
|
||||||
#: src/components/login/auth-form.tsx:337
|
|
||||||
msgid "Forgot password?"
|
|
||||||
msgstr "هل نسيت كلمة المرور؟"
|
|
||||||
|
|
||||||
#. Context: General settings
|
|
||||||
#: src/components/routes/settings/general.tsx:33
|
|
||||||
#: src/components/routes/settings/layout.tsx:51
|
|
||||||
msgid "General"
|
|
||||||
msgstr "عام"
|
|
||||||
|
|
||||||
#: src/components/add-system.tsx:119
|
|
||||||
msgid "Host / IP"
|
|
||||||
msgstr "مضيف / IP"
|
|
||||||
|
|
||||||
#: src/components/login/forgot-pass-form.tsx:93
|
|
||||||
msgid "If you've lost the password to your admin account, you may reset it using the following command."
|
|
||||||
msgstr "إذا فقدت كلمة المرور لحساب المسؤول الخاص بك، يمكنك إعادة تعيينها باستخدام الأمر التالي."
|
|
||||||
|
|
||||||
#: src/components/login/auth-form.tsx:16
|
|
||||||
msgid "Invalid email address."
|
|
||||||
msgstr "عنوان البريد الإلكتروني غير صالح."
|
|
||||||
|
|
||||||
#. Linux kernel
|
|
||||||
#: src/components/routes/system.tsx:254
|
|
||||||
msgid "Kernel"
|
|
||||||
msgstr "كيرنل"
|
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx:45
|
|
||||||
msgid "Language"
|
|
||||||
msgstr "اللغة"
|
|
||||||
|
|
||||||
#. Light theme
|
|
||||||
#: src/components/mode-toggle.tsx:16
|
|
||||||
msgid "Light"
|
|
||||||
msgstr "فاتح"
|
|
||||||
|
|
||||||
#: src/components/navbar.tsx:113
|
|
||||||
msgid "Log Out"
|
|
||||||
msgstr "تسجيل الخروج"
|
|
||||||
|
|
||||||
#: src/components/login/login.tsx:17
|
|
||||||
msgid "Login"
|
|
||||||
msgstr "تسجيل الدخول"
|
|
||||||
|
|
||||||
#: src/components/login/auth-form.tsx:42
|
|
||||||
#: src/components/login/forgot-pass-form.tsx:15
|
|
||||||
msgid "Login attempt failed"
|
|
||||||
msgstr "فشل محاولة تسجيل الدخول"
|
|
||||||
|
|
||||||
#: src/components/command-palette.tsx:157
|
|
||||||
#: src/components/navbar.tsx:86
|
|
||||||
msgid "Logs"
|
|
||||||
msgstr "السجلات"
|
|
||||||
|
|
||||||
#: src/components/routes/settings/notifications.tsx:80
|
|
||||||
msgid "Looking instead for where to create alerts? Click the bell <0/> icons in the systems table."
|
|
||||||
msgstr "هل تبحث عن مكان لإنشاء التنبيهات؟ انقر على أيقونات الجرس <0/> في جدول الأنظمة."
|
|
||||||
|
|
||||||
#: src/components/routes/settings/layout.tsx:85
|
|
||||||
msgid "Manage display and notification preferences."
|
|
||||||
msgstr "إدارة تفضيلات العرض والإشعارات."
|
|
||||||
|
|
||||||
#. Chart select field. Please try to keep this short.
|
|
||||||
#: src/components/routes/system.tsx:571
|
|
||||||
msgid "Max 1 min"
|
|
||||||
msgstr "1 دقيقة كحد"
|
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx:159
|
|
||||||
msgid "Memory"
|
|
||||||
msgstr "الذاكرة"
|
|
||||||
|
|
||||||
#: src/components/routes/system.tsx:397
|
|
||||||
#: src/lib/utils.ts:295
|
|
||||||
msgid "Memory Usage"
|
|
||||||
msgstr "استخدام الذاكرة"
|
|
||||||
|
|
||||||
#: src/components/routes/system.tsx:408
|
|
||||||
msgid "Memory usage of docker containers"
|
|
||||||
msgstr "استخدام الذاكرة لحاويات Docker"
|
|
||||||
|
|
||||||
#: src/components/add-system.tsx:113
|
|
||||||
msgid "Name"
|
|
||||||
msgstr "الاسم"
|
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx:173
|
|
||||||
msgid "Net"
|
|
||||||
msgstr "الشبكة"
|
|
||||||
|
|
||||||
#: src/components/routes/system.tsx:453
|
|
||||||
msgid "Network traffic of docker containers"
|
|
||||||
msgstr "حركة مرور الشبكة لحاويات Docker"
|
|
||||||
|
|
||||||
#: src/components/routes/system.tsx:438
|
|
||||||
msgid "Network traffic of public interfaces"
|
|
||||||
msgstr "حركة مرور الشبكة للواجهات العامة"
|
|
||||||
|
|
||||||
#: src/components/command-palette.tsx:50
|
|
||||||
msgid "No results found."
|
|
||||||
msgstr "لم يتم العثور على نتائج."
|
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx:400
|
|
||||||
msgid "No systems found."
|
|
||||||
msgstr "لم يتم العثور على أنظمة."
|
|
||||||
|
|
||||||
#: src/components/command-palette.tsx:111
|
|
||||||
#: src/components/routes/settings/layout.tsx:56
|
|
||||||
#: src/components/routes/settings/notifications.tsx:74
|
|
||||||
msgid "Notifications"
|
|
||||||
msgstr "الإشعارات"
|
|
||||||
|
|
||||||
#: src/components/login/auth-form.tsx:308
|
|
||||||
msgid "OAuth 2 / OIDC support"
|
|
||||||
msgstr "دعم OAuth 2 / OIDC"
|
|
||||||
|
|
||||||
#: src/components/routes/settings/config-yaml.tsx:61
|
|
||||||
msgid "On each restart, systems in the database will be updated to match the systems defined in the file."
|
|
||||||
msgstr "في كل إعادة تشغيل، سيتم تحديث الأنظمة في قاعدة البيانات لتتطابق مع الأنظمة المعرفة في الملف."
|
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx:219
|
|
||||||
msgid "Open menu"
|
|
||||||
msgstr "فتح القائمة"
|
|
||||||
|
|
||||||
#: src/components/login/auth-form.tsx:227
|
|
||||||
msgid "Or continue with"
|
|
||||||
msgstr "أو المتابعة باستخدام"
|
|
||||||
|
|
||||||
#: src/components/alerts/alert-button.tsx:109
|
|
||||||
msgid "Overwrite existing alerts"
|
|
||||||
msgstr "الكتابة فوق التنبيهات الحالية"
|
|
||||||
|
|
||||||
#: src/components/command-palette.tsx:85
|
|
||||||
msgid "Page"
|
|
||||||
msgstr "صفحة"
|
|
||||||
|
|
||||||
#: src/components/command-palette.tsx:72
|
|
||||||
msgid "Pages / Settings"
|
|
||||||
msgstr "الصفحات / الإعدادات"
|
|
||||||
|
|
||||||
#: src/components/login/auth-form.tsx:171
|
|
||||||
#: src/components/login/auth-form.tsx:176
|
|
||||||
msgid "Password"
|
|
||||||
msgstr "كلمة المرور"
|
|
||||||
|
|
||||||
#: src/components/login/auth-form.tsx:17
|
|
||||||
msgid "Password must be at least 10 characters."
|
|
||||||
msgstr "يجب أن تكون كلمة المرور مكونة من 10 أحرف على الأقل."
|
|
||||||
|
|
||||||
#: src/components/login/forgot-pass-form.tsx:33
|
|
||||||
msgid "Password reset request received"
|
|
||||||
msgstr "تم استلام طلب إعادة تعيين كلمة المرور"
|
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx:241
|
|
||||||
msgid "Pause"
|
|
||||||
msgstr "إيقاف مؤقت"
|
|
||||||
|
|
||||||
#: src/components/routes/settings/notifications.tsx:95
|
|
||||||
msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered."
|
|
||||||
msgstr "يرجى <0>تكوين خادم SMTP</0> لضمان تسليم التنبيهات."
|
|
||||||
|
|
||||||
#: src/components/alerts/alerts-system.tsx:28
|
|
||||||
msgid "Please check logs for more details."
|
|
||||||
msgstr "يرجى التحقق من السجلات لمزيد من التفاصيل."
|
|
||||||
|
|
||||||
#: src/components/login/auth-form.tsx:43
|
|
||||||
#: src/components/login/forgot-pass-form.tsx:16
|
|
||||||
msgid "Please check your credentials and try again"
|
|
||||||
msgstr "يرجى التحقق من بيانات الاعتماد الخاصة بك والمحاولة مرة أخرى"
|
|
||||||
|
|
||||||
#: src/components/login/login.tsx:34
|
|
||||||
msgid "Please create an admin account"
|
|
||||||
msgstr "يرجى إنشاء حساب مسؤول"
|
|
||||||
|
|
||||||
#: src/components/login/auth-form.tsx:257
|
|
||||||
msgid "Please enable pop-ups for this site"
|
|
||||||
msgstr "يرجى تمكين النوافذ المنبثقة لهذا الموقع"
|
|
||||||
|
|
||||||
#: src/lib/utils.ts:39
|
|
||||||
msgid "Please log in again"
|
|
||||||
msgstr "يرجى تسجيل الدخول مرة أخرى"
|
|
||||||
|
|
||||||
#: src/components/login/auth-form.tsx:316
|
|
||||||
msgid "Please see <0>the documentation</0> for instructions."
|
|
||||||
msgstr "يرجى الاطلاع على <0>التوثيق</0> للحصول على التعليمات."
|
|
||||||
|
|
||||||
#: src/components/login/login.tsx:38
|
|
||||||
msgid "Please sign in to your account"
|
|
||||||
msgstr "يرجى تسجيل الدخول إلى حسابك"
|
|
||||||
|
|
||||||
#: src/components/add-system.tsx:125
|
|
||||||
msgid "Port"
|
|
||||||
msgstr "المنفذ"
|
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx:58
|
|
||||||
msgid "Preferred Language"
|
|
||||||
msgstr "اللغة المفضلة"
|
|
||||||
|
|
||||||
#. Use 'Key' if your language requires many more characters
|
|
||||||
#: src/components/add-system.tsx:131
|
|
||||||
msgid "Public Key"
|
|
||||||
msgstr "المفتاح العام"
|
|
||||||
|
|
||||||
#. Context is disk read
|
|
||||||
#: src/components/charts/area-chart.tsx:56
|
|
||||||
#: src/components/charts/area-chart.tsx:65
|
|
||||||
msgid "Read"
|
|
||||||
msgstr "قراءة"
|
|
||||||
|
|
||||||
#. Context is network bytes received (download)
|
|
||||||
#: src/components/charts/area-chart.tsx:61
|
|
||||||
msgid "Received"
|
|
||||||
msgstr "تم الاستلام"
|
|
||||||
|
|
||||||
#: src/components/login/forgot-pass-form.tsx:76
|
|
||||||
msgid "Reset Password"
|
|
||||||
msgstr "إعادة تعيين كلمة المرور"
|
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx:236
|
|
||||||
msgid "Resume"
|
|
||||||
msgstr "استئناف"
|
|
||||||
|
|
||||||
#: src/components/routes/settings/notifications.tsx:117
|
|
||||||
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
|
|
||||||
msgstr "احفظ العنوان باستخدام مفتاح الإدخال أو الفاصلة. اتركه فارغًا لتعطيل إشعارات البريد الإلكتروني."
|
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx:106
|
|
||||||
#: src/components/routes/settings/notifications.tsx:167
|
|
||||||
msgid "Save Settings"
|
|
||||||
msgstr "حفظ الإعدادات"
|
|
||||||
|
|
||||||
#: src/components/navbar.tsx:142
|
|
||||||
msgid "Search"
|
|
||||||
msgstr "بحث"
|
|
||||||
|
|
||||||
#: src/components/command-palette.tsx:47
|
|
||||||
msgid "Search for systems or settings..."
|
|
||||||
msgstr "البحث عن الأنظمة أو الإعدادات..."
|
|
||||||
|
|
||||||
#: src/components/alerts/alert-button.tsx:71
|
|
||||||
msgid "See <0>notification settings</0> to configure how you receive alerts."
|
|
||||||
msgstr "راجع <0>إعدادات الإشعارات</0> لتكوين كيفية تلقي التنبيهات."
|
|
||||||
|
|
||||||
#. Context is network bytes sent (upload)
|
|
||||||
#: src/components/charts/area-chart.tsx:60
|
|
||||||
msgid "Sent"
|
|
||||||
msgstr "تم الإرسال"
|
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx:100
|
|
||||||
msgid "Sets the default time range for charts when a system is viewed."
|
|
||||||
msgstr "يحدد النطاق الزمني الافتراضي للرسوم البيانية عند عرض النظام."
|
|
||||||
|
|
||||||
#: src/components/command-palette.tsx:96
|
|
||||||
#: src/components/command-palette.tsx:99
|
|
||||||
#: src/components/command-palette.tsx:114
|
|
||||||
#: src/components/routes/settings/layout.tsx:71
|
|
||||||
#: src/components/routes/settings/layout.tsx:82
|
|
||||||
msgid "Settings"
|
|
||||||
msgstr "الإعدادات"
|
|
||||||
|
|
||||||
#: src/components/routes/settings/layout.tsx:33
|
|
||||||
msgid "Settings saved"
|
|
||||||
msgstr "تم حفظ الإعدادات"
|
|
||||||
|
|
||||||
#: src/components/login/auth-form.tsx:215
|
|
||||||
msgid "Sign in"
|
|
||||||
msgstr "تسجيل الدخول"
|
|
||||||
|
|
||||||
#: src/components/command-palette.tsx:201
|
|
||||||
msgid "SMTP settings"
|
|
||||||
msgstr "إعدادات SMTP"
|
|
||||||
|
|
||||||
#: src/lib/utils.ts:282
|
|
||||||
msgid "Status"
|
|
||||||
msgstr "الحالة"
|
|
||||||
|
|
||||||
#: src/components/routes/system.tsx:467
|
|
||||||
msgid "Swap space used by the system"
|
|
||||||
msgstr "مساحة التبديل المستخدمة من قبل النظام"
|
|
||||||
|
|
||||||
#: src/components/routes/system.tsx:466
|
|
||||||
msgid "Swap Usage"
|
|
||||||
msgstr "استخدام التبديل"
|
|
||||||
|
|
||||||
#. System theme
|
|
||||||
#: src/components/mode-toggle.tsx:26
|
|
||||||
#: src/components/systems-table/systems-table.tsx:110
|
|
||||||
#: src/components/systems-table/systems-table.tsx:121
|
|
||||||
msgid "System"
|
|
||||||
msgstr "النظام"
|
|
||||||
|
|
||||||
#: src/components/navbar.tsx:78
|
|
||||||
msgid "Systems"
|
|
||||||
msgstr "الأنظمة"
|
|
||||||
|
|
||||||
#: src/components/routes/settings/config-yaml.tsx:55
|
|
||||||
msgid "Systems may be managed in a <0>config.yml</0> file inside your data directory."
|
|
||||||
msgstr "يمكن إدارة الأنظمة في ملف <0>config.yml</0> داخل دليل البيانات الخاص بك."
|
|
||||||
|
|
||||||
#: src/components/routes/system.tsx:477
|
|
||||||
#: src/lib/utils.ts:314
|
|
||||||
msgid "Temperature"
|
|
||||||
msgstr "درجة الحرارة"
|
|
||||||
|
|
||||||
#: src/components/routes/system.tsx:478
|
|
||||||
msgid "Temperatures of system sensors"
|
|
||||||
msgstr "درجات حرارة مستشعرات النظام"
|
|
||||||
|
|
||||||
#: src/components/routes/settings/notifications.tsx:211
|
|
||||||
msgid "Test <0>URL</0>"
|
|
||||||
msgstr "اختبار <0>URL</0>"
|
|
||||||
|
|
||||||
#: src/components/routes/settings/notifications.tsx:182
|
|
||||||
msgid "Test notification sent"
|
|
||||||
msgstr "تم إرسال إشعار الاختبار"
|
|
||||||
|
|
||||||
#: src/components/add-system.tsx:104
|
|
||||||
msgid "The agent must be running on the system to connect. Copy the installation command for the agent below."
|
|
||||||
msgstr "يجب أن يكون الوكيل قيد التشغيل على النظام للاتصال. انسخ أمر التثبيت للوكيل أدناه."
|
|
||||||
|
|
||||||
#: src/components/add-system.tsx:95
|
|
||||||
msgid "The agent must be running on the system to connect. Copy the<0>docker-compose.yml</0> for the agent below."
|
|
||||||
msgstr "يجب أن يكون الوكيل قيد التشغيل على النظام للاتصال. انسخ <0>docker-compose.yml</0> للوكيل أدناه."
|
|
||||||
|
|
||||||
#: src/components/login/forgot-pass-form.tsx:98
|
|
||||||
msgid "Then log into the backend and reset your user account password in the users table."
|
|
||||||
msgstr "ثم قم بتسجيل الدخول إلى الواجهة الخلفية وأعد تعيين كلمة مرور حساب المستخدم الخاص بك في جدول المستخدمين."
|
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx:264
|
|
||||||
msgid "This action cannot be undone. This will permanently delete all current records for {name} from the database."
|
|
||||||
msgstr "لا يمكن التراجع عن هذا الإجراء. سيؤدي ذلك إلى حذف جميع السجلات الحالية لـ {name} من قاعدة البيانات بشكل دائم."
|
|
||||||
|
|
||||||
#: src/components/routes/system.tsx:507
|
|
||||||
msgid "Throughput of {extraFsName}"
|
|
||||||
msgstr "معدل نقل {extraFsName}"
|
|
||||||
|
|
||||||
#: src/components/routes/system.tsx:427
|
|
||||||
msgid "Throughput of root filesystem"
|
|
||||||
msgstr "معدل نقل نظام الملفات الجذر"
|
|
||||||
|
|
||||||
#: src/components/routes/settings/notifications.tsx:106
|
|
||||||
msgid "To email(s)"
|
|
||||||
msgstr "إلى البريد الإلكتروني"
|
|
||||||
|
|
||||||
#: src/components/routes/system.tsx:350
|
|
||||||
#: src/components/routes/system.tsx:363
|
|
||||||
msgid "Toggle grid"
|
|
||||||
msgstr "تبديل الشبكة"
|
|
||||||
|
|
||||||
#: src/components/mode-toggle.tsx:33
|
|
||||||
msgid "Toggle theme"
|
|
||||||
msgstr "تبديل السمة"
|
|
||||||
|
|
||||||
#: src/lib/utils.ts:317
|
|
||||||
msgid "Triggers when any sensor exceeds a threshold"
|
|
||||||
msgstr "يتم التفعيل عندما <20><>تجاوز أي مستشعر عتبة معينة"
|
|
||||||
|
|
||||||
#: src/lib/utils.ts:310
|
|
||||||
msgid "Triggers when combined up/down exceeds a threshold"
|
|
||||||
msgstr "يتم التفعيل عندما يتجاوز الجمع بين الصعود/الهبوط عتبة معينة"
|
|
||||||
|
|
||||||
#: src/lib/utils.ts:292
|
|
||||||
msgid "Triggers when CPU usage exceeds a threshold"
|
|
||||||
msgstr "يتم التفعيل عندما يتجاوز استخدام وحدة المعالجة المركزية عتبة معينة"
|
|
||||||
|
|
||||||
#: src/lib/utils.ts:298
|
|
||||||
msgid "Triggers when memory usage exceeds a threshold"
|
|
||||||
msgstr "يتم التفعيل عندما يتجاوز استخدام الذاكرة عتبة معينة"
|
|
||||||
|
|
||||||
#: src/components/routes/system.tsx:398
|
|
||||||
msgid "Triggers when memory usage exceeds a threshold."
|
|
||||||
msgstr "يتم التفعيل عندما يتجاوز استخدام الذاكرة عتبة معينة."
|
|
||||||
|
|
||||||
#: src/lib/utils.ts:285
|
|
||||||
msgid "Triggers when status switches between up and down"
|
|
||||||
msgstr "يتم التفعيل عندما يتغير الحالة بين التشغيل والإيقاف"
|
|
||||||
|
|
||||||
#: src/lib/utils.ts:304
|
|
||||||
msgid "Triggers when usage of any disk exceeds a threshold"
|
|
||||||
msgstr "يتم التفعيل عندما يتجاوز استخدام أي قرص عتبة معينة"
|
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx:320
|
|
||||||
msgid "Updated in real time. Click on a system to view information."
|
|
||||||
msgstr "محدث في الوقت الحقيقي. انقر على نظام لعرض المعلومات."
|
|
||||||
|
|
||||||
#: src/components/routes/system.tsx:253
|
|
||||||
msgid "Uptime"
|
|
||||||
msgstr "مدة التشغيل"
|
|
||||||
|
|
||||||
#: src/components/routes/system.tsx:494
|
|
||||||
msgid "Usage"
|
|
||||||
msgstr "الاستخدام"
|
|
||||||
|
|
||||||
#: src/components/routes/system.tsx:415
|
|
||||||
msgid "Usage of root partition"
|
|
||||||
msgstr "استخدام القسم الجذر"
|
|
||||||
|
|
||||||
#: src/components/charts/mem-chart.tsx:65
|
|
||||||
#: src/components/charts/swap-chart.tsx:56
|
|
||||||
msgid "Used"
|
|
||||||
msgstr "مستخدم"
|
|
||||||
|
|
||||||
#: src/components/login/auth-form.tsx:138
|
|
||||||
msgid "username"
|
|
||||||
msgstr "اسم المستخدم"
|
|
||||||
|
|
||||||
#: src/components/login/auth-form.tsx:131
|
|
||||||
msgid "Username"
|
|
||||||
msgstr "اسم المستخدم"
|
|
||||||
|
|
||||||
#: src/components/command-palette.tsx:143
|
|
||||||
#: src/components/navbar.tsx:70
|
|
||||||
msgid "Users"
|
|
||||||
msgstr "المستخدمون"
|
|
||||||
|
|
||||||
#: src/components/routes/system.tsx:603
|
|
||||||
msgid "Waiting for enough records to display"
|
|
||||||
msgstr "في انتظار وجود سجلات كافية للعرض"
|
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx:48
|
|
||||||
msgid "Want to help us make our translations even better? Check out <0>Crowdin</0> for more details."
|
|
||||||
msgstr "هل تريد مساعدتنا في تحسين ترجماتنا؟ تحقق من <0>Crowdin</0> لمزيد من التفاصيل."
|
|
||||||
|
|
||||||
#: src/components/routes/settings/notifications.tsx:124
|
|
||||||
msgid "Webhook / Push notifications"
|
|
||||||
msgstr "إشعارات Webhook / Push"
|
|
||||||
|
|
||||||
#. Context is disk write
|
|
||||||
#: src/components/charts/area-chart.tsx:55
|
|
||||||
#: src/components/charts/area-chart.tsx:66
|
|
||||||
msgid "Write"
|
|
||||||
msgstr "كتابة"
|
|
||||||
|
|
||||||
#: src/components/routes/settings/layout.tsx:61
|
|
||||||
msgid "YAML Config"
|
|
||||||
msgstr "تكوين YAML"
|
|
||||||
|
|
||||||
#: src/components/routes/settings/config-yaml.tsx:45
|
|
||||||
msgid "YAML Configuration"
|
|
||||||
msgstr "تكوين YAML"
|
|
||||||
|
|
||||||
#: src/components/routes/settings/layout.tsx:34
|
|
||||||
msgid "Your user settings have been updated."
|
|
||||||
msgstr "تم تحديث إعدادات المستخدم الخاصة بك."
|
|
||||||
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,809 +0,0 @@
|
|||||||
msgid ""
|
|
||||||
msgstr ""
|
|
||||||
"POT-Creation-Date: 2024-11-01 11:30-0400\n"
|
|
||||||
"MIME-Version: 1.0\n"
|
|
||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
|
||||||
"Content-Transfer-Encoding: 8bit\n"
|
|
||||||
"X-Generator: @lingui/cli\n"
|
|
||||||
"Language: de\n"
|
|
||||||
"Project-Id-Version: beszel\n"
|
|
||||||
"Report-Msgid-Bugs-To: \n"
|
|
||||||
"PO-Revision-Date: 2024-11-02 19:37\n"
|
|
||||||
"Last-Translator: \n"
|
|
||||||
"Language-Team: German\n"
|
|
||||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
|
||||||
"X-Crowdin-Project: beszel\n"
|
|
||||||
"X-Crowdin-Project-ID: 733311\n"
|
|
||||||
"X-Crowdin-Language: de\n"
|
|
||||||
"X-Crowdin-File: /main/beszel/site/src/locales/en/en.po\n"
|
|
||||||
"X-Crowdin-File-ID: 16\n"
|
|
||||||
|
|
||||||
#: src/components/routes/system.tsx:242
|
|
||||||
msgid "{0, plural, one {# day} other {# days}}"
|
|
||||||
msgstr "{0, plural, one {# Tag} other {# Tage}}"
|
|
||||||
|
|
||||||
#: src/components/routes/system.tsx:240
|
|
||||||
msgid "{hours, plural, one {# hour} other {# hours}}"
|
|
||||||
msgstr "{hours, plural, one {# Stunde} other {# Stunden}}"
|
|
||||||
|
|
||||||
#: src/lib/utils.ts:139
|
|
||||||
msgid "1 hour"
|
|
||||||
msgstr "1 Stunde"
|
|
||||||
|
|
||||||
#: src/lib/utils.ts:162
|
|
||||||
msgid "1 week"
|
|
||||||
msgstr "1 Woche"
|
|
||||||
|
|
||||||
#: src/lib/utils.ts:147
|
|
||||||
msgid "12 hours"
|
|
||||||
msgstr "12 Stunden"
|
|
||||||
|
|
||||||
#: src/lib/utils.ts:155
|
|
||||||
msgid "24 hours"
|
|
||||||
msgstr "24 Stunden"
|
|
||||||
|
|
||||||
#: src/lib/utils.ts:170
|
|
||||||
msgid "30 days"
|
|
||||||
msgstr "30 Tage"
|
|
||||||
|
|
||||||
#. Table column
|
|
||||||
#: src/components/systems-table/systems-table.tsx:207
|
|
||||||
msgid "Actions"
|
|
||||||
msgstr "Aktionen"
|
|
||||||
|
|
||||||
#: src/components/routes/home.tsx:62
|
|
||||||
msgid "Active Alerts"
|
|
||||||
msgstr "Aktive Warnungen"
|
|
||||||
|
|
||||||
#: src/components/add-system.tsx:74
|
|
||||||
msgid "Add <0>System</0>"
|
|
||||||
msgstr "<0>System</0> hinzufügen"
|
|
||||||
|
|
||||||
#: src/components/add-system.tsx:83
|
|
||||||
msgid "Add New System"
|
|
||||||
msgstr "Neues System hinzufügen"
|
|
||||||
|
|
||||||
#: src/components/add-system.tsx:167
|
|
||||||
#: src/components/add-system.tsx:178
|
|
||||||
msgid "Add system"
|
|
||||||
msgstr "System hinzufügen"
|
|
||||||
|
|
||||||
#: src/components/routes/settings/notifications.tsx:156
|
|
||||||
msgid "Add URL"
|
|
||||||
msgstr "URL hinzufügen"
|
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx:81
|
|
||||||
msgid "Adjust display options for charts."
|
|
||||||
msgstr "Anzeigeoptionen für Diagramme anpassen."
|
|
||||||
|
|
||||||
#: src/components/command-palette.tsx:133
|
|
||||||
#: src/components/command-palette.tsx:146
|
|
||||||
#: src/components/command-palette.tsx:160
|
|
||||||
#: src/components/command-palette.tsx:174
|
|
||||||
#: src/components/command-palette.tsx:189
|
|
||||||
#: src/components/command-palette.tsx:204
|
|
||||||
msgid "Admin"
|
|
||||||
msgstr "Admin"
|
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx:186
|
|
||||||
msgid "Agent"
|
|
||||||
msgstr "Agent"
|
|
||||||
|
|
||||||
#: src/components/alerts/alert-button.tsx:32
|
|
||||||
#: src/components/alerts/alert-button.tsx:68
|
|
||||||
msgid "Alerts"
|
|
||||||
msgstr "Warnungen"
|
|
||||||
|
|
||||||
#: src/components/alerts/alert-button.tsx:88
|
|
||||||
#: src/components/systems-table/systems-table.tsx:317
|
|
||||||
msgid "All Systems"
|
|
||||||
msgstr "Alle Systeme"
|
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx:261
|
|
||||||
msgid "Are you sure you want to delete {name}?"
|
|
||||||
msgstr "Möchten Sie {name} wirklich löschen?"
|
|
||||||
|
|
||||||
#: src/components/command-palette.tsx:186
|
|
||||||
#: src/components/navbar.tsx:102
|
|
||||||
msgid "Auth Providers"
|
|
||||||
msgstr "Authentifizierungsanbieter"
|
|
||||||
|
|
||||||
#: src/components/copy-to-clipboard.tsx:16
|
|
||||||
msgid "Automatic copy requires a secure context."
|
|
||||||
msgstr "Automatisches Kopieren erfordert einen sicheren Kontext."
|
|
||||||
|
|
||||||
#: src/components/routes/system.tsx:568
|
|
||||||
msgid "Average"
|
|
||||||
msgstr "Durchschnitt"
|
|
||||||
|
|
||||||
#: src/components/routes/system.tsx:387
|
|
||||||
msgid "Average CPU utilization of containers"
|
|
||||||
msgstr "Durchschnittliche CPU-Auslastung der Container"
|
|
||||||
|
|
||||||
#: src/components/alerts/alerts-system.tsx:204
|
|
||||||
msgid "Average exceeds <0>{value}{0}</0>"
|
|
||||||
msgstr "Durchschnitt überschreitet <0>{value}{0}</0>"
|
|
||||||
|
|
||||||
#: src/components/routes/system.tsx:376
|
|
||||||
msgid "Average system-wide CPU utilization"
|
|
||||||
msgstr "Durchschnittliche systemweite CPU-Auslastung"
|
|
||||||
|
|
||||||
#: src/components/command-palette.tsx:171
|
|
||||||
#: src/components/navbar.tsx:94
|
|
||||||
msgid "Backups"
|
|
||||||
msgstr "Backups"
|
|
||||||
|
|
||||||
#: src/components/routes/system.tsx:436
|
|
||||||
#: src/lib/utils.ts:307
|
|
||||||
msgid "Bandwidth"
|
|
||||||
msgstr "Bandbreite"
|
|
||||||
|
|
||||||
#: src/components/login/auth-form.tsx:313
|
|
||||||
msgid "Beszel supports OpenID Connect and many OAuth2 authentication providers."
|
|
||||||
msgstr "Beszel unterstützt OpenID Connect und viele OAuth2-Authentifizierungsanbieter."
|
|
||||||
|
|
||||||
#: src/components/routes/settings/notifications.tsx:127
|
|
||||||
msgid "Beszel uses <0>Shoutrrr</0> to integrate with popular notification services."
|
|
||||||
msgstr "Beszel verwendet <0>Shoutrrr</0>, um sich mit beliebten Benachrichtigungsdiensten zu integrieren."
|
|
||||||
|
|
||||||
#: src/components/add-system.tsx:88
|
|
||||||
msgid "Binary"
|
|
||||||
msgstr "Binär"
|
|
||||||
|
|
||||||
#: src/components/charts/mem-chart.tsx:89
|
|
||||||
msgid "Cache / Buffers"
|
|
||||||
msgstr "Cache / Puffer"
|
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx:272
|
|
||||||
msgid "Cancel"
|
|
||||||
msgstr "Abbrechen"
|
|
||||||
|
|
||||||
#: src/components/routes/settings/config-yaml.tsx:68
|
|
||||||
msgid "Caution - potential data loss"
|
|
||||||
msgstr "Vorsicht - potenzieller Datenverlust"
|
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx:36
|
|
||||||
msgid "Change general application options."
|
|
||||||
msgstr "Allgemeine Anwendungsoptionen ändern."
|
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx:78
|
|
||||||
msgid "Chart options"
|
|
||||||
msgstr "Diagrammoptionen"
|
|
||||||
|
|
||||||
#: src/components/login/forgot-pass-form.tsx:34
|
|
||||||
msgid "Check {email} for a reset link."
|
|
||||||
msgstr "Überprüfen Sie {email} auf einen Rücksetzlink."
|
|
||||||
|
|
||||||
#: src/components/routes/settings/layout.tsx:40
|
|
||||||
msgid "Check logs for more details."
|
|
||||||
msgstr "Überprüfen Sie die Protokolle für weitere Details."
|
|
||||||
|
|
||||||
#: src/components/routes/settings/notifications.tsx:183
|
|
||||||
msgid "Check your notification service"
|
|
||||||
msgstr "Überprüfen Sie Ihren Benachrichtigungsdienst"
|
|
||||||
|
|
||||||
#: src/components/add-system.tsx:153
|
|
||||||
msgid "Click to copy"
|
|
||||||
msgstr "Zum Kopieren klicken"
|
|
||||||
|
|
||||||
#. Context: table columns
|
|
||||||
#: src/components/systems-table/systems-table.tsx:328
|
|
||||||
msgid "Columns"
|
|
||||||
msgstr "Spalten"
|
|
||||||
|
|
||||||
#: src/components/login/forgot-pass-form.tsx:83
|
|
||||||
#: src/components/login/forgot-pass-form.tsx:89
|
|
||||||
msgid "Command line instructions"
|
|
||||||
msgstr "Befehlszeilenanweisungen"
|
|
||||||
|
|
||||||
#: src/components/routes/settings/notifications.tsx:77
|
|
||||||
msgid "Configure how you receive alert notifications."
|
|
||||||
msgstr "Konfigurieren Sie, wie Sie Warnbenachrichtigungen erhalten."
|
|
||||||
|
|
||||||
#: src/components/login/auth-form.tsx:189
|
|
||||||
#: src/components/login/auth-form.tsx:194
|
|
||||||
msgid "Confirm password"
|
|
||||||
msgstr "Passwort bestätigen"
|
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx:278
|
|
||||||
msgid "Continue"
|
|
||||||
msgstr "Fortfahren"
|
|
||||||
|
|
||||||
#: src/lib/utils.ts:25
|
|
||||||
msgid "Copied to clipboard"
|
|
||||||
msgstr "In die Zwischenablage kopiert"
|
|
||||||
|
|
||||||
#: src/components/add-system.tsx:164
|
|
||||||
msgid "Copy"
|
|
||||||
msgstr "Kopieren"
|
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx:247
|
|
||||||
msgid "Copy host"
|
|
||||||
msgstr "Host kopieren"
|
|
||||||
|
|
||||||
#: src/components/add-system.tsx:175
|
|
||||||
msgid "Copy Linux command"
|
|
||||||
msgstr "Linux-Befehl kopieren"
|
|
||||||
|
|
||||||
#: src/components/copy-to-clipboard.tsx:13
|
|
||||||
msgid "Copy text"
|
|
||||||
msgstr "Text kopieren"
|
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx:152
|
|
||||||
msgid "CPU"
|
|
||||||
msgstr "CPU"
|
|
||||||
|
|
||||||
#: src/components/charts/area-chart.tsx:52
|
|
||||||
#: src/components/routes/system.tsx:375
|
|
||||||
#: src/lib/utils.ts:289
|
|
||||||
msgid "CPU Usage"
|
|
||||||
msgstr "CPU-Auslastung"
|
|
||||||
|
|
||||||
#: src/components/login/auth-form.tsx:215
|
|
||||||
msgid "Create account"
|
|
||||||
msgstr "Konto erstellen"
|
|
||||||
|
|
||||||
#. Dark theme
|
|
||||||
#: src/components/mode-toggle.tsx:21
|
|
||||||
msgid "Dark"
|
|
||||||
msgstr "Dunkel"
|
|
||||||
|
|
||||||
#: src/components/command-palette.tsx:82
|
|
||||||
#: src/components/routes/home.tsx:35
|
|
||||||
msgid "Dashboard"
|
|
||||||
msgstr "Dashboard"
|
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx:85
|
|
||||||
msgid "Default time period"
|
|
||||||
msgstr "Standardzeitraum"
|
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx:253
|
|
||||||
msgid "Delete"
|
|
||||||
msgstr "Löschen"
|
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx:166
|
|
||||||
msgid "Disk"
|
|
||||||
msgstr "Festplatte"
|
|
||||||
|
|
||||||
#: src/components/routes/system.tsx:426
|
|
||||||
msgid "Disk I/O"
|
|
||||||
msgstr "Festplatten-I/O"
|
|
||||||
|
|
||||||
#: src/components/charts/disk-chart.tsx:74
|
|
||||||
#: src/components/routes/system.tsx:415
|
|
||||||
#: src/lib/utils.ts:301
|
|
||||||
msgid "Disk Usage"
|
|
||||||
msgstr "Festplattennutzung"
|
|
||||||
|
|
||||||
#: src/components/routes/system.tsx:495
|
|
||||||
msgid "Disk usage of {extraFsName}"
|
|
||||||
msgstr "Festplattennutzung von {extraFsName}"
|
|
||||||
|
|
||||||
#: src/components/routes/system.tsx:386
|
|
||||||
msgid "Docker CPU Usage"
|
|
||||||
msgstr "Docker-CPU-Auslastung"
|
|
||||||
|
|
||||||
#: src/components/routes/system.tsx:407
|
|
||||||
msgid "Docker Memory Usage"
|
|
||||||
msgstr "Docker-Speichernutzung"
|
|
||||||
|
|
||||||
#: src/components/routes/system.tsx:452
|
|
||||||
msgid "Docker Network I/O"
|
|
||||||
msgstr "Docker-Netzwerk-I/O"
|
|
||||||
|
|
||||||
#: src/components/command-palette.tsx:125
|
|
||||||
msgid "Documentation"
|
|
||||||
msgstr "Dokumentation"
|
|
||||||
|
|
||||||
#: src/components/login/auth-form.tsx:158
|
|
||||||
msgid "email"
|
|
||||||
msgstr "E-Mail"
|
|
||||||
|
|
||||||
#: src/components/login/auth-form.tsx:152
|
|
||||||
#: src/components/login/forgot-pass-form.tsx:53
|
|
||||||
msgid "Email"
|
|
||||||
msgstr "E-Mail"
|
|
||||||
|
|
||||||
#: src/components/routes/settings/notifications.tsx:91
|
|
||||||
msgid "Email notifications"
|
|
||||||
msgstr "E-Mail-Benachrichtigungen"
|
|
||||||
|
|
||||||
#: src/components/login/login.tsx:36
|
|
||||||
msgid "Enter email address to reset password"
|
|
||||||
msgstr "E-Mail-Adresse eingeben, um das Passwort zurückzusetzen"
|
|
||||||
|
|
||||||
#: src/components/routes/settings/notifications.tsx:111
|
|
||||||
msgid "Enter email address..."
|
|
||||||
msgstr "E-Mail-Adresse eingeben..."
|
|
||||||
|
|
||||||
#: src/components/login/auth-form.tsx:256
|
|
||||||
#: src/components/routes/settings/config-yaml.tsx:28
|
|
||||||
#: src/components/routes/settings/notifications.tsx:187
|
|
||||||
msgid "Error"
|
|
||||||
msgstr "Fehler"
|
|
||||||
|
|
||||||
#: src/components/routes/home.tsx:81
|
|
||||||
msgid "Exceeds {0}{1} in last {2, plural, one {# minute} other {# minutes}}"
|
|
||||||
msgstr "Überschreitet {0}{1} in den letzten {2, plural, one {# Minute} other {# Minuten}}"
|
|
||||||
|
|
||||||
#: src/components/routes/settings/config-yaml.tsx:72
|
|
||||||
msgid "Existing systems not defined in <0>config.yml</0> will be deleted. Please make regular backups."
|
|
||||||
msgstr "Bestehende Systeme, die nicht in <0>config.yml</0> definiert sind, werden gelöscht. Bitte machen Sie regelmäßige Backups."
|
|
||||||
|
|
||||||
#: src/components/routes/settings/config-yaml.tsx:93
|
|
||||||
msgid "Export configuration"
|
|
||||||
msgstr "Konfiguration exportieren"
|
|
||||||
|
|
||||||
#: src/components/routes/settings/config-yaml.tsx:48
|
|
||||||
msgid "Export your current systems configuration."
|
|
||||||
msgstr "Exportieren Sie Ihre aktuelle Systemkonfiguration."
|
|
||||||
|
|
||||||
#: src/lib/utils.ts:38
|
|
||||||
msgid "Failed to authenticate"
|
|
||||||
msgstr "Authentifizierung fehlgeschlagen"
|
|
||||||
|
|
||||||
#: src/components/routes/settings/layout.tsx:39
|
|
||||||
#: src/components/routes/settings/notifications.tsx:62
|
|
||||||
msgid "Failed to save settings"
|
|
||||||
msgstr "Einstellungen konnten nicht gespeichert werden"
|
|
||||||
|
|
||||||
#: src/components/routes/settings/notifications.tsx:188
|
|
||||||
msgid "Failed to send test notification"
|
|
||||||
msgstr "Testbenachrichtigung konnte nicht gesendet werden"
|
|
||||||
|
|
||||||
#: src/components/alerts/alerts-system.tsx:27
|
|
||||||
msgid "Failed to update alert"
|
|
||||||
msgstr "Warnung konnte nicht aktualisiert werden"
|
|
||||||
|
|
||||||
#: src/components/routes/system.tsx:539
|
|
||||||
#: src/components/systems-table/systems-table.tsx:324
|
|
||||||
msgid "Filter..."
|
|
||||||
msgstr "Filter..."
|
|
||||||
|
|
||||||
#: src/components/alerts/alerts-system.tsx:225
|
|
||||||
msgid "For <0>{min}</0> {min, plural, one {minute} other {minutes}}"
|
|
||||||
msgstr "Für <0>{min}</0> {min, plural, one {Minute} other {Minuten}}"
|
|
||||||
|
|
||||||
#: src/components/login/auth-form.tsx:337
|
|
||||||
msgid "Forgot password?"
|
|
||||||
msgstr "Passwort vergessen?"
|
|
||||||
|
|
||||||
#. Context: General settings
|
|
||||||
#: src/components/routes/settings/general.tsx:33
|
|
||||||
#: src/components/routes/settings/layout.tsx:51
|
|
||||||
msgid "General"
|
|
||||||
msgstr "Allgemein"
|
|
||||||
|
|
||||||
#: src/components/add-system.tsx:119
|
|
||||||
msgid "Host / IP"
|
|
||||||
msgstr "Host / IP"
|
|
||||||
|
|
||||||
#: src/components/login/forgot-pass-form.tsx:93
|
|
||||||
msgid "If you've lost the password to your admin account, you may reset it using the following command."
|
|
||||||
msgstr "Wenn Sie das Passwort für Ihr Administratorkonto verloren haben, können Sie es mit dem folgenden Befehl zurücksetzen."
|
|
||||||
|
|
||||||
#: src/components/login/auth-form.tsx:16
|
|
||||||
msgid "Invalid email address."
|
|
||||||
msgstr "Ungültige E-Mail-Adresse."
|
|
||||||
|
|
||||||
#. Linux kernel
|
|
||||||
#: src/components/routes/system.tsx:254
|
|
||||||
msgid "Kernel"
|
|
||||||
msgstr "Kernel"
|
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx:45
|
|
||||||
msgid "Language"
|
|
||||||
msgstr "Sprache"
|
|
||||||
|
|
||||||
#. Light theme
|
|
||||||
#: src/components/mode-toggle.tsx:16
|
|
||||||
msgid "Light"
|
|
||||||
msgstr "Hell"
|
|
||||||
|
|
||||||
#: src/components/navbar.tsx:113
|
|
||||||
msgid "Log Out"
|
|
||||||
msgstr "Abmelden"
|
|
||||||
|
|
||||||
#: src/components/login/login.tsx:17
|
|
||||||
msgid "Login"
|
|
||||||
msgstr "Anmelden"
|
|
||||||
|
|
||||||
#: src/components/login/auth-form.tsx:42
|
|
||||||
#: src/components/login/forgot-pass-form.tsx:15
|
|
||||||
msgid "Login attempt failed"
|
|
||||||
msgstr "Anmeldeversuch fehlgeschlagen"
|
|
||||||
|
|
||||||
#: src/components/command-palette.tsx:157
|
|
||||||
#: src/components/navbar.tsx:86
|
|
||||||
msgid "Logs"
|
|
||||||
msgstr "Protokolle"
|
|
||||||
|
|
||||||
#: src/components/routes/settings/notifications.tsx:80
|
|
||||||
msgid "Looking instead for where to create alerts? Click the bell <0/> icons in the systems table."
|
|
||||||
msgstr "Suchen Sie stattdessen nach der Erstellung von Warnungen? Klicken Sie auf die Glocken-<0/>-Symbole in der Systemtabelle."
|
|
||||||
|
|
||||||
#: src/components/routes/settings/layout.tsx:85
|
|
||||||
msgid "Manage display and notification preferences."
|
|
||||||
msgstr "Anzeige- und Benachrichtigungseinstellungen verwalten."
|
|
||||||
|
|
||||||
#. Chart select field. Please try to keep this short.
|
|
||||||
#: src/components/routes/system.tsx:571
|
|
||||||
msgid "Max 1 min"
|
|
||||||
msgstr "Max 1 Min"
|
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx:159
|
|
||||||
msgid "Memory"
|
|
||||||
msgstr "Speicher"
|
|
||||||
|
|
||||||
#: src/components/routes/system.tsx:397
|
|
||||||
#: src/lib/utils.ts:295
|
|
||||||
msgid "Memory Usage"
|
|
||||||
msgstr "Speichernutzung"
|
|
||||||
|
|
||||||
#: src/components/routes/system.tsx:408
|
|
||||||
msgid "Memory usage of docker containers"
|
|
||||||
msgstr "Speichernutzung der Docker-Container"
|
|
||||||
|
|
||||||
#: src/components/add-system.tsx:113
|
|
||||||
msgid "Name"
|
|
||||||
msgstr "Name"
|
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx:173
|
|
||||||
msgid "Net"
|
|
||||||
msgstr "Netz"
|
|
||||||
|
|
||||||
#: src/components/routes/system.tsx:453
|
|
||||||
msgid "Network traffic of docker containers"
|
|
||||||
msgstr "Netzwerkverkehr der Docker-Container"
|
|
||||||
|
|
||||||
#: src/components/routes/system.tsx:438
|
|
||||||
msgid "Network traffic of public interfaces"
|
|
||||||
msgstr "Netzwerkverkehr der öffentlichen Schnittstellen"
|
|
||||||
|
|
||||||
#: src/components/command-palette.tsx:50
|
|
||||||
msgid "No results found."
|
|
||||||
msgstr "Keine Ergebnisse gefunden."
|
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx:400
|
|
||||||
msgid "No systems found."
|
|
||||||
msgstr "Keine Systeme gefunden."
|
|
||||||
|
|
||||||
#: src/components/command-palette.tsx:111
|
|
||||||
#: src/components/routes/settings/layout.tsx:56
|
|
||||||
#: src/components/routes/settings/notifications.tsx:74
|
|
||||||
msgid "Notifications"
|
|
||||||
msgstr "Benachrichtigungen"
|
|
||||||
|
|
||||||
#: src/components/login/auth-form.tsx:308
|
|
||||||
msgid "OAuth 2 / OIDC support"
|
|
||||||
msgstr "OAuth 2 / OIDC-Unterstützung"
|
|
||||||
|
|
||||||
#: src/components/routes/settings/config-yaml.tsx:61
|
|
||||||
msgid "On each restart, systems in the database will be updated to match the systems defined in the file."
|
|
||||||
msgstr "Bei jedem Neustart werden die Systeme in der Datenbank aktualisiert, um den im Datei definierten Systemen zu entsprechen."
|
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx:219
|
|
||||||
msgid "Open menu"
|
|
||||||
msgstr "Menü öffnen"
|
|
||||||
|
|
||||||
#: src/components/login/auth-form.tsx:227
|
|
||||||
msgid "Or continue with"
|
|
||||||
msgstr "Oder fortfahren mit"
|
|
||||||
|
|
||||||
#: src/components/alerts/alert-button.tsx:109
|
|
||||||
msgid "Overwrite existing alerts"
|
|
||||||
msgstr "Bestehende Warnungen überschreiben"
|
|
||||||
|
|
||||||
#: src/components/command-palette.tsx:85
|
|
||||||
msgid "Page"
|
|
||||||
msgstr "Seite"
|
|
||||||
|
|
||||||
#: src/components/command-palette.tsx:72
|
|
||||||
msgid "Pages / Settings"
|
|
||||||
msgstr "Seiten / Einstellungen"
|
|
||||||
|
|
||||||
#: src/components/login/auth-form.tsx:171
|
|
||||||
#: src/components/login/auth-form.tsx:176
|
|
||||||
msgid "Password"
|
|
||||||
msgstr "Passwort"
|
|
||||||
|
|
||||||
#: src/components/login/auth-form.tsx:17
|
|
||||||
msgid "Password must be at least 10 characters."
|
|
||||||
msgstr "Das Passwort muss mindestens 10 Zeichen lang sein."
|
|
||||||
|
|
||||||
#: src/components/login/forgot-pass-form.tsx:33
|
|
||||||
msgid "Password reset request received"
|
|
||||||
msgstr "Anfrage zum Zurücksetzen des Passworts erhalten"
|
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx:241
|
|
||||||
msgid "Pause"
|
|
||||||
msgstr "Pause"
|
|
||||||
|
|
||||||
#: src/components/routes/settings/notifications.tsx:95
|
|
||||||
msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered."
|
|
||||||
msgstr "Bitte <0>konfigurieren Sie einen SMTP-Server</0>, um sicherzustellen, dass Warnungen zugestellt werden."
|
|
||||||
|
|
||||||
#: src/components/alerts/alerts-system.tsx:28
|
|
||||||
msgid "Please check logs for more details."
|
|
||||||
msgstr "Bitte überprüfen Sie die Protokolle für weitere Details."
|
|
||||||
|
|
||||||
#: src/components/login/auth-form.tsx:43
|
|
||||||
#: src/components/login/forgot-pass-form.tsx:16
|
|
||||||
msgid "Please check your credentials and try again"
|
|
||||||
msgstr "Bitte überprüfen Sie Ihre Anmeldedaten und versuchen Sie es erneut"
|
|
||||||
|
|
||||||
#: src/components/login/login.tsx:34
|
|
||||||
msgid "Please create an admin account"
|
|
||||||
msgstr "Bitte erstellen Sie ein Administratorkonto"
|
|
||||||
|
|
||||||
#: src/components/login/auth-form.tsx:257
|
|
||||||
msgid "Please enable pop-ups for this site"
|
|
||||||
msgstr "Bitte aktivieren Sie Pop-ups für diese Seite"
|
|
||||||
|
|
||||||
#: src/lib/utils.ts:39
|
|
||||||
msgid "Please log in again"
|
|
||||||
msgstr "Bitte melden Sie sich erneut an"
|
|
||||||
|
|
||||||
#: src/components/login/auth-form.tsx:316
|
|
||||||
msgid "Please see <0>the documentation</0> for instructions."
|
|
||||||
msgstr "Bitte sehen Sie sich <0>die Dokumentation</0> für Anweisungen an."
|
|
||||||
|
|
||||||
#: src/components/login/login.tsx:38
|
|
||||||
msgid "Please sign in to your account"
|
|
||||||
msgstr "Bitte melden Sie sich bei Ihrem Konto an"
|
|
||||||
|
|
||||||
#: src/components/add-system.tsx:125
|
|
||||||
msgid "Port"
|
|
||||||
msgstr "Port"
|
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx:58
|
|
||||||
msgid "Preferred Language"
|
|
||||||
msgstr "Bevorzugte Sprache"
|
|
||||||
|
|
||||||
#. Use 'Key' if your language requires many more characters
|
|
||||||
#: src/components/add-system.tsx:131
|
|
||||||
msgid "Public Key"
|
|
||||||
msgstr "Schlüssel"
|
|
||||||
|
|
||||||
#. Context is disk read
|
|
||||||
#: src/components/charts/area-chart.tsx:56
|
|
||||||
#: src/components/charts/area-chart.tsx:65
|
|
||||||
msgid "Read"
|
|
||||||
msgstr "Lesen"
|
|
||||||
|
|
||||||
#. Context is network bytes received (download)
|
|
||||||
#: src/components/charts/area-chart.tsx:61
|
|
||||||
msgid "Received"
|
|
||||||
msgstr "Empfangen"
|
|
||||||
|
|
||||||
#: src/components/login/forgot-pass-form.tsx:76
|
|
||||||
msgid "Reset Password"
|
|
||||||
msgstr "Passwort zurücksetzen"
|
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx:236
|
|
||||||
msgid "Resume"
|
|
||||||
msgstr "Fortsetzen"
|
|
||||||
|
|
||||||
#: src/components/routes/settings/notifications.tsx:117
|
|
||||||
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
|
|
||||||
msgstr "Adresse mit der Eingabetaste oder Komma speichern. Leer lassen, um E-Mail-Benachrichtigungen zu deaktivieren."
|
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx:106
|
|
||||||
#: src/components/routes/settings/notifications.tsx:167
|
|
||||||
msgid "Save Settings"
|
|
||||||
msgstr "Einstellungen speichern"
|
|
||||||
|
|
||||||
#: src/components/navbar.tsx:142
|
|
||||||
msgid "Search"
|
|
||||||
msgstr "Suche"
|
|
||||||
|
|
||||||
#: src/components/command-palette.tsx:47
|
|
||||||
msgid "Search for systems or settings..."
|
|
||||||
msgstr "Nach Systemen oder Einstellungen suchen..."
|
|
||||||
|
|
||||||
#: src/components/alerts/alert-button.tsx:71
|
|
||||||
msgid "See <0>notification settings</0> to configure how you receive alerts."
|
|
||||||
msgstr "Siehe <0>Benachrichtigungseinstellungen</0>, um zu konfigurieren, wie Sie Warnungen erhalten."
|
|
||||||
|
|
||||||
#. Context is network bytes sent (upload)
|
|
||||||
#: src/components/charts/area-chart.tsx:60
|
|
||||||
msgid "Sent"
|
|
||||||
msgstr "Gesendet"
|
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx:100
|
|
||||||
msgid "Sets the default time range for charts when a system is viewed."
|
|
||||||
msgstr "Legt den Standardzeitraum für Diagramme fest, wenn ein System angezeigt wird."
|
|
||||||
|
|
||||||
#: src/components/command-palette.tsx:96
|
|
||||||
#: src/components/command-palette.tsx:99
|
|
||||||
#: src/components/command-palette.tsx:114
|
|
||||||
#: src/components/routes/settings/layout.tsx:71
|
|
||||||
#: src/components/routes/settings/layout.tsx:82
|
|
||||||
msgid "Settings"
|
|
||||||
msgstr "Einstellungen"
|
|
||||||
|
|
||||||
#: src/components/routes/settings/layout.tsx:33
|
|
||||||
msgid "Settings saved"
|
|
||||||
msgstr "Einstellungen gespeichert"
|
|
||||||
|
|
||||||
#: src/components/login/auth-form.tsx:215
|
|
||||||
msgid "Sign in"
|
|
||||||
msgstr "Anmelden"
|
|
||||||
|
|
||||||
#: src/components/command-palette.tsx:201
|
|
||||||
msgid "SMTP settings"
|
|
||||||
msgstr "SMTP-Einstellungen"
|
|
||||||
|
|
||||||
#: src/lib/utils.ts:282
|
|
||||||
msgid "Status"
|
|
||||||
msgstr "Status"
|
|
||||||
|
|
||||||
#: src/components/routes/system.tsx:467
|
|
||||||
msgid "Swap space used by the system"
|
|
||||||
msgstr "Vom System genutzter Swap-Speicher"
|
|
||||||
|
|
||||||
#: src/components/routes/system.tsx:466
|
|
||||||
msgid "Swap Usage"
|
|
||||||
msgstr "Swap-Nutzung"
|
|
||||||
|
|
||||||
#. System theme
|
|
||||||
#: src/components/mode-toggle.tsx:26
|
|
||||||
#: src/components/systems-table/systems-table.tsx:110
|
|
||||||
#: src/components/systems-table/systems-table.tsx:121
|
|
||||||
msgid "System"
|
|
||||||
msgstr "System"
|
|
||||||
|
|
||||||
#: src/components/navbar.tsx:78
|
|
||||||
msgid "Systems"
|
|
||||||
msgstr "Systeme"
|
|
||||||
|
|
||||||
#: src/components/routes/settings/config-yaml.tsx:55
|
|
||||||
msgid "Systems may be managed in a <0>config.yml</0> file inside your data directory."
|
|
||||||
msgstr "Systeme können in einer <0>config.yml</0>-Datei in Ihrem Datenverzeichnis verwaltet werden."
|
|
||||||
|
|
||||||
#: src/components/routes/system.tsx:477
|
|
||||||
#: src/lib/utils.ts:314
|
|
||||||
msgid "Temperature"
|
|
||||||
msgstr "Temperatur"
|
|
||||||
|
|
||||||
#: src/components/routes/system.tsx:478
|
|
||||||
msgid "Temperatures of system sensors"
|
|
||||||
msgstr "Temperaturen der Systemsensoren"
|
|
||||||
|
|
||||||
#: src/components/routes/settings/notifications.tsx:211
|
|
||||||
msgid "Test <0>URL</0>"
|
|
||||||
msgstr "Test <0>URL</0>"
|
|
||||||
|
|
||||||
#: src/components/routes/settings/notifications.tsx:182
|
|
||||||
msgid "Test notification sent"
|
|
||||||
msgstr "Testbenachrichtigung gesendet"
|
|
||||||
|
|
||||||
#: src/components/add-system.tsx:104
|
|
||||||
msgid "The agent must be running on the system to connect. Copy the installation command for the agent below."
|
|
||||||
msgstr "Der Agent muss auf dem System laufen, um eine Verbindung herzustellen. Kopieren Sie den Installationsbefehl für den Agenten unten."
|
|
||||||
|
|
||||||
#: src/components/add-system.tsx:95
|
|
||||||
msgid "The agent must be running on the system to connect. Copy the<0>docker-compose.yml</0> for the agent below."
|
|
||||||
msgstr "Der Agent muss auf dem System laufen, um eine Verbindung herzustellen. Kopieren Sie die <0>docker-compose.yml</0> für den Agenten unten."
|
|
||||||
|
|
||||||
#: src/components/login/forgot-pass-form.tsx:98
|
|
||||||
msgid "Then log into the backend and reset your user account password in the users table."
|
|
||||||
msgstr "Melden Sie sich dann im Backend an und setzen Sie Ihr Benutzerkontopasswort in der Benutzertabelle zurück."
|
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx:264
|
|
||||||
msgid "This action cannot be undone. This will permanently delete all current records for {name} from the database."
|
|
||||||
msgstr "Diese Aktion kann nicht rückgängig gemacht werden. Dadurch werden alle aktuellen Datensätze für {name} dauerhaft aus der Datenbank gelöscht."
|
|
||||||
|
|
||||||
#: src/components/routes/system.tsx:507
|
|
||||||
msgid "Throughput of {extraFsName}"
|
|
||||||
msgstr "Durchsatz von {extraFsName}"
|
|
||||||
|
|
||||||
#: src/components/routes/system.tsx:427
|
|
||||||
msgid "Throughput of root filesystem"
|
|
||||||
msgstr "Durchsatz des Root-Dateisystems"
|
|
||||||
|
|
||||||
#: src/components/routes/settings/notifications.tsx:106
|
|
||||||
msgid "To email(s)"
|
|
||||||
msgstr "An E-Mail(s)"
|
|
||||||
|
|
||||||
#: src/components/routes/system.tsx:350
|
|
||||||
#: src/components/routes/system.tsx:363
|
|
||||||
msgid "Toggle grid"
|
|
||||||
msgstr "Raster umschalten"
|
|
||||||
|
|
||||||
#: src/components/mode-toggle.tsx:33
|
|
||||||
msgid "Toggle theme"
|
|
||||||
msgstr "Thema umschalten"
|
|
||||||
|
|
||||||
#: src/lib/utils.ts:317
|
|
||||||
msgid "Triggers when any sensor exceeds a threshold"
|
|
||||||
msgstr "Löst aus, wenn ein Sensor einen Schwellenwert überschreitet"
|
|
||||||
|
|
||||||
#: src/lib/utils.ts:310
|
|
||||||
msgid "Triggers when combined up/down exceeds a threshold"
|
|
||||||
msgstr "Löst aus, wenn die kombinierte Auf-/Abwärtsbewegung einen Schwellenwert überschreitet"
|
|
||||||
|
|
||||||
#: src/lib/utils.ts:292
|
|
||||||
msgid "Triggers when CPU usage exceeds a threshold"
|
|
||||||
msgstr "Löst aus, wenn die CPU-Auslastung einen Schwellenwert überschreitet"
|
|
||||||
|
|
||||||
#: src/lib/utils.ts:298
|
|
||||||
msgid "Triggers when memory usage exceeds a threshold"
|
|
||||||
msgstr "Löst aus, wenn die Speichernutzung einen Schwellenwert überschreitet"
|
|
||||||
|
|
||||||
#: src/components/routes/system.tsx:398
|
|
||||||
msgid "Triggers when memory usage exceeds a threshold."
|
|
||||||
msgstr "Löst aus, wenn die Speichernutzung einen Schwellenwert überschreitet."
|
|
||||||
|
|
||||||
#: src/lib/utils.ts:285
|
|
||||||
msgid "Triggers when status switches between up and down"
|
|
||||||
msgstr "Löst aus, wenn der Status zwischen oben und unten wechselt"
|
|
||||||
|
|
||||||
#: src/lib/utils.ts:304
|
|
||||||
msgid "Triggers when usage of any disk exceeds a threshold"
|
|
||||||
msgstr "Löst aus, wenn die Nutzung einer Festplatte einen Schwellenwert überschreitet"
|
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx:320
|
|
||||||
msgid "Updated in real time. Click on a system to view information."
|
|
||||||
msgstr "In Echtzeit aktualisiert. Klicken Sie auf ein System, um Informationen anzuzeigen."
|
|
||||||
|
|
||||||
#: src/components/routes/system.tsx:253
|
|
||||||
msgid "Uptime"
|
|
||||||
msgstr "Betriebszeit"
|
|
||||||
|
|
||||||
#: src/components/routes/system.tsx:494
|
|
||||||
msgid "Usage"
|
|
||||||
msgstr "Nutzung"
|
|
||||||
|
|
||||||
#: src/components/routes/system.tsx:415
|
|
||||||
msgid "Usage of root partition"
|
|
||||||
msgstr "Nutzung der Root-Partition"
|
|
||||||
|
|
||||||
#: src/components/charts/mem-chart.tsx:65
|
|
||||||
#: src/components/charts/swap-chart.tsx:56
|
|
||||||
msgid "Used"
|
|
||||||
msgstr "Verwendet"
|
|
||||||
|
|
||||||
#: src/components/login/auth-form.tsx:138
|
|
||||||
msgid "username"
|
|
||||||
msgstr "Benutzername"
|
|
||||||
|
|
||||||
#: src/components/login/auth-form.tsx:131
|
|
||||||
msgid "Username"
|
|
||||||
msgstr "Benutzername"
|
|
||||||
|
|
||||||
#: src/components/command-palette.tsx:143
|
|
||||||
#: src/components/navbar.tsx:70
|
|
||||||
msgid "Users"
|
|
||||||
msgstr "Benutzer"
|
|
||||||
|
|
||||||
#: src/components/routes/system.tsx:603
|
|
||||||
msgid "Waiting for enough records to display"
|
|
||||||
msgstr "Warten auf genügend Datensätze zur Anzeige"
|
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx:48
|
|
||||||
msgid "Want to help us make our translations even better? Check out <0>Crowdin</0> for more details."
|
|
||||||
msgstr "Möchten Sie uns helfen, unsere Übersetzungen noch besser zu machen? Schauen Sie sich <0>Crowdin</0> für weitere Details an."
|
|
||||||
|
|
||||||
#: src/components/routes/settings/notifications.tsx:124
|
|
||||||
msgid "Webhook / Push notifications"
|
|
||||||
msgstr "Webhook / Push-Benachrichtigungen"
|
|
||||||
|
|
||||||
#. Context is disk write
|
|
||||||
#: src/components/charts/area-chart.tsx:55
|
|
||||||
#: src/components/charts/area-chart.tsx:66
|
|
||||||
msgid "Write"
|
|
||||||
msgstr "Schreiben"
|
|
||||||
|
|
||||||
#: src/components/routes/settings/layout.tsx:61
|
|
||||||
msgid "YAML Config"
|
|
||||||
msgstr "YAML-Konfiguration"
|
|
||||||
|
|
||||||
#: src/components/routes/settings/config-yaml.tsx:45
|
|
||||||
msgid "YAML Configuration"
|
|
||||||
msgstr "YAML-Konfiguration"
|
|
||||||
|
|
||||||
#: src/components/routes/settings/layout.tsx:34
|
|
||||||
msgid "Your user settings have been updated."
|
|
||||||
msgstr "Ihre Benutzereinstellungen wurden aktualisiert."
|
|
||||||
|
|
||||||
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user