Compare commits

...

101 Commits

Author SHA1 Message Date
Henry Dollman
4eaedcf825 release 0.7.2 2024-11-03 15:31:39 -05:00
Henry Dollman
b337ba1d7f fix subheading for memory chart 2024-11-03 15:30:35 -05:00
hank
c9b72f724f New translations en.po (Ukrainian) (#251)
Co-authored-by: stanol <stanol777@gmail.com>
2024-11-03 15:02:59 -05:00
Henry Dollman
35d8996e00 release 0.7.1 2024-11-03 12:10:53 -05:00
Henry Dollman
6e61c5f1e4 update go deps 2024-11-03 12:10:17 -05:00
Henry Dollman
6bb147c349 fix en fallback if detected user locale is missing (#247) 2024-11-03 11:59:49 -05:00
hank
3668aa4e8e New Crowdin updates (#246)
* New translations en.po (French)

* New translations en.po (Spanish)

* New translations en.po (Arabic)

* New translations en.po (German)

* New translations en.po (Japanese)

* New translations en.po (Korean)

* New translations en.po (Portuguese)

* New translations en.po (Russian)

* New translations en.po (Turkish)

* New translations en.po (Ukrainian)

* New translations en.po (Chinese Simplified)

* New translations en.po (Vietnamese)

* New translations en.po (Chinese Traditional, Hong Kong)

* New translations en.po (Italian)

* New translations en.po (Ukrainian)
2024-11-03 11:28:27 -05:00
Henry Dollman
4c324bff73 release 0.7.0 2024-11-02 14:50:59 -04:00
Henry Dollman
741575df15 revert tweaks for old docker. needs more testing. 2024-11-02 14:43:35 -04:00
Henry Dollman
055fc39305 add italian and update other translations 2024-11-02 14:08:24 -04:00
Henry Dollman
5ae3a38204 Bandwidth alert max value increased to 1 Gigabit (closes #222) 2024-11-02 14:02:13 -04:00
Henry Dollman
44747e75b0 add columns filter for systems table 2024-11-02 13:35:14 -04:00
hank
e4f22ebb01 New Crowdin updates (#245)
* New translations en.po (French)

* New translations en.po (Spanish)

* New translations en.po (Arabic)

* New translations en.po (German)

* New translations en.po (Japanese)

* New translations en.po (Korean)

* New translations en.po (Portuguese)

* New translations en.po (Russian)

* New translations en.po (Turkish)

* New translations en.po (Ukrainian)

* New translations en.po (Chinese Simplified)

* New translations en.po (Vietnamese)

* New translations en.po (Chinese Traditional, Hong Kong)
2024-11-02 02:47:16 -04:00
Henry Dollman
bfb848a1ec add crowdin links to readme 2024-11-01 22:23:46 -04:00
Henry Dollman
c16c7830a4 update translation files 2024-11-01 22:11:58 -04:00
Henry Dollman
8f383c9f5e update translations 2024-11-01 21:24:49 -04:00
hank
5b68556a9a Update Crowdin configuration file 2024-11-01 20:49:15 -04:00
hank
cb1c481f54 Update Crowdin configuration file 2024-11-01 20:43:29 -04:00
Henry Dollman
a93ff63605 migrate to lingui 2024-11-01 20:31:57 -04:00
Henry Dollman
856683610a rtl layout updates 2024-10-31 22:15:21 -04:00
Henry Dollman
b9fda9dd0b update translations 2024-10-31 21:42:18 -04:00
Henry Dollman
7e27fee006 RTL layout fixes 2024-10-31 19:34:10 -04:00
Henry Dollman
f65d19ad84 add Turkish lang + style updates 2024-10-31 18:57:54 -04:00
Henry Dollman
94f771fc1c sort and format translation files 2024-10-31 17:50:05 -04:00
Weblate (bot)
0ac3d20162 Translations update from Hosted Weblate (#242)
* Translated using Weblate (Spanish)

Currently translated at 100.0% (165 of 165 strings)

Translation: Beszel/Hub web interface
Translate-URL: https://hosted.weblate.org/projects/beszel/hub-web-interface/es/

* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (165 of 165 strings)

Translation: Beszel/Hub web interface
Translate-URL: https://hosted.weblate.org/projects/beszel/hub-web-interface/uk/

---------

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Co-authored-by: stanol <stanol@users.noreply.hosted.weblate.org>
2024-10-31 17:34:16 -04:00
Henry Dollman
df0f3a154f rtl layout progress and updates to arabic translations 2024-10-31 16:48:28 -04:00
Henry Dollman
6419178d87 update readme 2024-10-30 20:49:15 -04:00
hank
91714ba0e6 weblate I18n (#238)
* update readme / weblate links

* Translated using Weblate (Arabic)

Currently translated at 96.3% (159 of 165 strings)

Translation: Beszel/Hub web interface
Translate-URL: https://hosted.weblate.org/projects/beszel/hub-web-interface/ar/

* Translated using Weblate (German)

Currently translated at 99.3% (164 of 165 strings)

Translation: Beszel/Hub web interface
Translate-URL: https://hosted.weblate.org/projects/beszel/hub-web-interface/de/

* Translated using Weblate (Spanish)

Currently translated at 99.3% (164 of 165 strings)

Translation: Beszel/Hub web interface
Translate-URL: https://hosted.weblate.org/projects/beszel/hub-web-interface/es/

* Translated using Weblate (French)

Currently translated at 99.3% (164 of 165 strings)

Translation: Beszel/Hub web interface
Translate-URL: https://hosted.weblate.org/projects/beszel/hub-web-interface/fr/

* Translated using Weblate (Japanese)

Currently translated at 100.0% (165 of 165 strings)

Translation: Beszel/Hub web interface
Translate-URL: https://hosted.weblate.org/projects/beszel/hub-web-interface/ja/

* Translated using Weblate (Korean)

Currently translated at 100.0% (165 of 165 strings)

Translation: Beszel/Hub web interface
Translate-URL: https://hosted.weblate.org/projects/beszel/hub-web-interface/ko/

* Translated using Weblate (Portuguese)

Currently translated at 99.3% (164 of 165 strings)

Translation: Beszel/Hub web interface
Translate-URL: https://hosted.weblate.org/projects/beszel/hub-web-interface/pt/

* Translated using Weblate (Russian)

Currently translated at 99.3% (164 of 165 strings)

Translation: Beszel/Hub web interface
Translate-URL: https://hosted.weblate.org/projects/beszel/hub-web-interface/ru/

* Translated using Weblate (Ukrainian)

Currently translated at 99.3% (164 of 165 strings)

Translation: Beszel/Hub web interface
Translate-URL: https://hosted.weblate.org/projects/beszel/hub-web-interface/uk/

* Translated using Weblate (Vietnamese)

Currently translated at 100.0% (165 of 165 strings)

Translation: Beszel/Hub web interface
Translate-URL: https://hosted.weblate.org/projects/beszel/hub-web-interface/vi/

* Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (165 of 165 strings)

Translation: Beszel/Hub web interface
Translate-URL: https://hosted.weblate.org/projects/beszel/hub-web-interface/zh_Hans/

* Translated using Weblate (Chinese (Traditional Han script, Hong Kong))

Currently translated at 99.3% (164 of 165 strings)

Translation: Beszel/Hub web interface
Translate-URL: https://hosted.weblate.org/projects/beszel/hub-web-interface/zh_Hant_HK/

---------

Co-authored-by: Anonymous <noreply@weblate.org>
2024-10-30 20:37:01 -04:00
Henry Dollman
b5ba5054a5 update readme / weblate links 2024-10-30 19:57:02 -04:00
Henry Dollman
6f38077ca0 add Ukrainian translations 2024-10-30 18:09:26 -04:00
Henry Dollman
7f82aafff9 add translations for chart tooltips 2024-10-30 17:53:44 -04:00
Henry Dollman
14a4715eb8 update translations 2024-10-30 16:06:57 -04:00
Henry Dollman
e4f1936698 translation updates 2024-10-30 14:16:04 -04:00
Henry Dollman
4f62a07da6 include english in main bundle (fixes fallback lang) 2024-10-30 13:53:42 -04:00
Henry Dollman
1a1fcebc46 spinner tweaks 2024-10-30 13:52:35 -04:00
Henry Dollman
f9f7db17d4 update translations + add Portuguese and Korean 2024-10-30 13:01:04 -04:00
Henry Dollman
929d94f705 update translations 2024-10-30 12:18:12 -04:00
Henry Dollman
2c4ea6f52a dynamically load translation files 2024-10-30 11:40:17 -04:00
Henry Dollman
3505b215a2 add prettier config and format files site files 2024-10-30 11:03:09 -04:00
Hank
8827996553 fix en/translation.json formatting 2024-10-30 01:20:16 -04:00
Henry Dollman
556a6b49db add Vietnamese, Japanese, and Arabic (need to check RTL) 2024-10-29 23:32:07 -04:00
ArsFy
180ec83a17 login i18n & chart loading & fix forgot pass page (#236)
Co-authored-by: hank <hank@henrygd.me>
2024-10-29 23:22:03 -04:00
Henry Dollman
062796b38c update navbar and home subtitle
* adds search button to navbar
* removes need for home.subtitle_2
2024-10-29 22:22:35 -04:00
Henry Dollman
67f88188e1 add makefile 2024-10-29 21:07:16 -04:00
Henry Dollman
3209c53201 add @esbuild/linux-arm64 as optional dependency 2024-10-29 20:08:54 -04:00
Henry Dollman
ec7aa80928 Merge branch 'ArsFy-main' 2024-10-29 18:09:29 -04:00
Henry Dollman
f6e391f8a9 i18n tweaks / layout fixes 2024-10-29 18:08:55 -04:00
Henry Dollman
e64fad9584 Merge branch 'main' of https://github.com/ArsFy/beszel into ArsFy-main 2024-10-29 15:47:07 -04:00
Bot_wxt1221
9e6ee8d239 fix(arm64/nixpkgs): add missing @esbuild for multi platform (#235) 2024-10-29 11:24:05 -04:00
Arsfy
2c66f93101 crowdin 2024-10-28 18:53:55 +08:00
Arsfy
5c2e2d7d36 i18n 2024-10-28 18:44:04 +08:00
Arsfy
376e8d4621 ctrl k & i18n 2024-10-28 13:37:21 +08:00
Henry Dollman
ec7cb53d93 update systemd install scripts to work if sudo not installed 2024-10-27 13:58:12 -04:00
Arsfy
b7176fc8f3 add copy linux install command 2024-10-27 14:30:34 +08:00
Henry Dollman
f8fc74116c rm *sensors.Warnings conversion - gopsutil windows uses different type 2024-10-26 14:02:19 -04:00
Henry Dollman
4094df3a61 fix: skip temperature collection if SENSORS is empty string (#196) 2024-10-24 15:10:20 -04:00
Henry Dollman
a5f9e2615c release 0.6.2 2024-10-23 18:39:15 -04:00
Henry Dollman
4a78ce1b16 skip temperatures code if sensors whitelist is set to empty string 2024-10-23 18:37:38 -04:00
Henry Dollman
f8f1e01cb4 add settings page and api route for generating config.yml 2024-10-23 18:30:24 -04:00
Henry Dollman
c7463f2b9f limit collection lookups and other small refactoring
* adds error handling for collection lookup (#216)
2024-10-23 13:10:39 -04:00
Henry Dollman
a975466fc7 add declarative system management with config.yml (#70, #206) 2024-10-22 18:46:52 -04:00
Henry Dollman
539c0ccb1d retry failed containers separately so we can run them in parallel (#58) 2024-10-21 17:00:13 -04:00
Henry Dollman
5f4dcb09ea release 0.6.1 2024-10-19 20:06:51 -04:00
Henry Dollman
6de5dce176 improve useeffects for favicon and system subscription 2024-10-19 19:57:35 -04:00
Henry Dollman
b5c158d1b3 update debug logs 2024-10-19 18:12:25 -04:00
Henry Dollman
7f01d1ec7e memoize alertsbutton and use string for global system alerts set 2024-10-19 18:08:02 -04:00
Henry Dollman
8bf7a0e1d6 add DOCKER_TIMEOUT env var 2024-10-19 16:33:33 -04:00
Henry Dollman
140fd93ec9 add ability to set alerts for all systems 2024-10-19 15:14:28 -04:00
Henry Dollman
bdcb34c989 update go deps 2024-10-19 15:05:27 -04:00
Henry Dollman
aaaa86b147 update js deps 2024-10-19 14:51:03 -04:00
Henry Dollman
6e9b84c6c7 use goroutines to send alerts 2024-10-19 14:28:41 -04:00
Henry Dollman
cce241caa4 update h-screen to use svh / dvh 2024-10-18 13:15:35 -04:00
Henry Dollman
1e9787c4d7 downgrade @nanostores/router to support Safari 16 (closes #210) 2024-10-18 13:11:26 -04:00
Henry Dollman
71aa9946f5 use one x axis component for all charts 2024-10-17 16:05:20 -04:00
Henry Dollman
12239808fc small updates to alert display 2024-10-17 15:46:09 -04:00
Henry Dollman
94e9d4f270 set alert to inactive if value or minute is updated 2024-10-17 11:12:04 -04:00
Yukino16
34a8053967 fix: path escape for system name in alert message (#209) 2024-10-17 10:31:36 -04:00
Henry Dollman
ee92e338cb update debug log locations 2024-10-16 18:12:43 -04:00
Henry Dollman
1a3ad04e03 release 0.6.0 2024-10-16 18:02:53 -04:00
Henry Dollman
9c061774a3 update alert notification titles 2024-10-16 18:02:38 -04:00
Henry Dollman
3336b0a7d9 fix chart null values connecting after downtime 2024-10-16 17:46:42 -04:00
Henry Dollman
f034eed431 update js packages 2024-10-16 17:39:56 -04:00
Henry Dollman
6b6d3fabc0 change disk alert to monitor usage of any disk, not only root 2024-10-16 17:21:05 -04:00
Henry Dollman
59d541dd1d fix edge case overwriting extra filesystem with root io fallback 2024-10-16 15:26:12 -04:00
Henry Dollman
abff85d61e alerts web ui refactoring 2024-10-16 13:48:36 -04:00
Henry Dollman
02641ec007 time averaged thresholds for alerts 2024-10-15 21:59:53 -04:00
Henry Dollman
92179cbbb2 show active alerts in dashboard 2024-10-15 21:59:05 -04:00
Henry Dollman
299152413a small longer record creation refactoring 2024-10-15 19:54:46 -04:00
Henry Dollman
703a3c41c9 empty info for systems that are paused 2024-10-15 18:31:03 -04:00
Henry Dollman
31d1153916 invert sorting in systems table 2024-10-15 18:20:38 -04:00
Henry Dollman
c1577d3ba5 fix bottom spacing 2024-10-14 19:03:02 -04:00
Henry Dollman
c4400eb0a3 refactor container-chart.tsx 2024-10-14 18:48:19 -04:00
Henry Dollman
a57498f8f7 update alerts dialog and icon imports 2024-10-14 17:53:49 -04:00
Henry Dollman
1b0dffc1ab combine docker charts and chart data 2024-10-14 17:25:21 -04:00
Henry Dollman
bea37d62b4 simplify container chart data and reduce rerenders 2024-10-14 11:48:33 -04:00
Henry Dollman
d53b6be5b9 update collections 2024-10-12 18:28:32 -04:00
Henry Dollman
6c31263e60 add bandwidth alerts 2024-10-12 17:22:25 -04:00
Henry Dollman
b464fa5b3f add net column to systems table 2024-10-12 16:37:09 -04:00
Henry Dollman
c0a3bbeefc change twoDecimalString to use customizable digits 2024-10-12 16:03:51 -04:00
Henry Dollman
10d348c052 temperature alerts 2024-10-12 14:57:46 -04:00
Henry Dollman
6cf6661f2e raise docker client timeout to 8 seconds if version <= 24 2024-10-12 12:24:53 -04:00
130 changed files with 25036 additions and 8596 deletions

3
.gitignore vendored
View File

@@ -11,3 +11,6 @@ dist
beszel/cmd/hub/hub beszel/cmd/hub/hub
beszel/cmd/agent/agent beszel/cmd/agent/agent
node_modules node_modules
beszel/build
*timestamp*
.swc

35
beszel/Makefile Normal file
View File

@@ -0,0 +1,35 @@
# 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

View File

@@ -9,43 +9,45 @@ 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.21 github.com/pocketbase/pocketbase v0.22.23
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.9 github.com/shirou/gopsutil/v4 v4.24.10
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.27.0 golang.org/x/crypto v0.28.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.31.0 // indirect github.com/aws/aws-sdk-go-v2 v1.32.3 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.5 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.6 // indirect
github.com/aws/aws-sdk-go-v2/config v1.27.39 // indirect github.com/aws/aws-sdk-go-v2/config v1.28.1 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.17.37 // indirect github.com/aws/aws-sdk-go-v2/credentials v1.17.42 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.14 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.18 // indirect
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.26 // indirect github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.35 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.18 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.22 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.18 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.22 // 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.18 // indirect github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.22 // 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/accept-encoding v1.12.0 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.20 // indirect github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.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/presigned-url v1.12.3 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.18 // indirect github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.3 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.64.0 // indirect github.com/aws/aws-sdk-go-v2/service/s3 v1.66.2 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.23.3 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.24.3 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.27.3 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.3 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.31.3 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.32.3 // indirect
github.com/aws/smithy-go v1.21.0 // indirect github.com/aws/smithy-go v1.22.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.0 // indirect github.com/ebitengine/purego v0.8.1 // indirect
github.com/fatih/color v1.17.0 // indirect github.com/fatih/color v1.18.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.5 // indirect github.com/gabriel-vasile/mimetype v1.4.6 // indirect
github.com/ganigeorgiev/fexpr v0.4.1 // indirect github.com/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
@@ -62,38 +64,37 @@ 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.23 // indirect github.com/mattn/go-sqlite3 v1.14.24 // 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.8.0 // indirect github.com/tklauser/numcpus v0.9.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.39.0 // indirect gocloud.dev v0.40.0 // indirect
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c // indirect
golang.org/x/image v0.20.0 // indirect golang.org/x/image v0.21.0 // indirect
golang.org/x/net v0.29.0 // indirect golang.org/x/net v0.30.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.25.0 // indirect golang.org/x/sys v0.26.0 // indirect
golang.org/x/term v0.24.0 // indirect golang.org/x/term v0.25.0 // indirect
golang.org/x/text v0.18.0 // indirect golang.org/x/text v0.19.0 // indirect
golang.org/x/time v0.6.0 // indirect golang.org/x/time v0.7.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.199.0 // indirect google.golang.org/api v0.204.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240930140551-af27646dc61f // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20241021214115-324edc3d5d38 // indirect
google.golang.org/grpc v1.67.1 // indirect google.golang.org/grpc v1.67.1 // indirect
google.golang.org/protobuf v1.34.2 // indirect google.golang.org/protobuf v1.35.1 // indirect
modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a // indirect modernc.org/gc/v3 v3.0.0-20241004144649-1aea3fae8852 // 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

View File

@@ -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.115.1 h1:Jo0SM9cQnSkYfp44+v+NQXHpcHqlnRJk2qxh6yvxxxQ= cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE=
cloud.google.com/go v0.115.1/go.mod h1:DuujITeaufu3gL68/lOFIirVNJwQeyf5UXyi+Wbgknc= cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U=
cloud.google.com/go/auth v0.9.5 h1:4CTn43Eynw40aFVr3GpPqsQponx2jv0BQpjvajsbbzw= cloud.google.com/go/auth v0.10.0 h1:tWlkvFAh+wwTOzXIjrwM64karR1iTBZ/GRr0S/DULYo=
cloud.google.com/go/auth v0.9.5/go.mod h1:Xo0n7n66eHyOWWCnitop6870Ilwo3PiZyodVkkH1xWM= cloud.google.com/go/auth v0.10.0/go.mod h1:xxA5AqpDrvS+Gkmo9RqrGGRh6WSNKKOXhY3zNOr38tI=
cloud.google.com/go/auth/oauth2adapt v0.2.4 h1:0GWE/FUsXhf6C+jAkWgYm7X9tK8cuEIfy19DBn6B6bY= cloud.google.com/go/auth/oauth2adapt v0.2.5 h1:2p29+dePqsCHPP1bqDJcKj4qxRyYCcbzKpFyKGt3MTk=
cloud.google.com/go/auth/oauth2adapt v0.2.4/go.mod h1:jC/jOpwFP6JBxhB3P5Rr0a9HLMC/Pe3eaL4NmdvqPtc= cloud.google.com/go/auth/oauth2adapt v0.2.5/go.mod h1:AlmsELtlEBnaNTL7jCj8VQFLy6mbZv0s4Q7NGBeQ5E8=
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.31.0 h1:3V05LbxTSItI5kUqNwhJrrrY1BAXxXt0sN0l72QmG5U= github.com/aws/aws-sdk-go-v2 v1.32.3 h1:T0dRlFBKcdaUPGNtkBSwHZxrtis8CQU17UpNBZYd0wk=
github.com/aws/aws-sdk-go-v2 v1.31.0/go.mod h1:ztolYtaEUtdpf9Wftr31CJfLVjOnD/CVRkKOOYgF8hA= github.com/aws/aws-sdk-go-v2 v1.32.3/go.mod h1:2SK5n0a2karNTv5tbP1SjsX0uhttou00v/HpXKM1ZUo=
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 h1:pT3hpW0cOHRJx8Y0DfJUEQuqPild8jRGmSFmBgvydr0=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.5/go.mod h1:wYSv6iDS621sEFLfKvpPE2ugjTuGlAG7iROg0hLOkfc= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.6/go.mod h1:j/I2++U0xX+cr44QjHay4Cvxj6FUbnxrgmqN3H1jTZA=
github.com/aws/aws-sdk-go-v2/config v1.27.39 h1:FCylu78eTGzW1ynHcongXK9YHtoXD5AiiUqq3YfJYjU= 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/go.mod h1:wczj2hbyskP4LjMKBEZwPRO1shXY+GsQleab+ZXT2ik= github.com/aws/aws-sdk-go-v2/config v1.28.1/go.mod h1:bRQcttQJiARbd5JZxw6wG0yIK3eLeSCPdg6uqmmlIiI=
github.com/aws/aws-sdk-go-v2/credentials v1.17.37 h1:G2aOH01yW8X373JK419THj5QVqu9vKEwxSEsGxihoW0= github.com/aws/aws-sdk-go-v2/credentials v1.17.42 h1:sBP0RPjBU4neGpIYyx8mkU2QqLPl5u9cmdTWVzIpHkM=
github.com/aws/aws-sdk-go-v2/credentials v1.17.37/go.mod h1:0ecCjlb7htYCptRD45lXJ6aJDQac6D2NlKGpZqyTG6A= github.com/aws/aws-sdk-go-v2/credentials v1.17.42/go.mod h1:FwZBfU530dJ26rv9saAbxa9Ej3eF/AK0OAY86k13n4M=
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 h1:68jFVtt3NulEzojFesM/WVarlFpCaXLKaBxDpzkQ9OQ=
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/ec2/imds v1.16.18/go.mod h1:Fjnn5jQVIo6VyedMc0/EhPpfNlPl7dHV916O6B+49aE=
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 h1:ihPPdcCVSN0IvBByXwqVp28/l4VosBZ6sDulcvU2J7w=
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/feature/s3/manager v1.17.35/go.mod h1:JkgEhs3SVF51Dj3m1Bj+yL8IznpxzkwlA3jLg3x7Kls=
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 h1:Jw50LwEkVjuVzE1NzkhNKkBf9cRN7MtE1F/b2cOKTUM=
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/configsources v1.3.22/go.mod h1:Y/SmAyPcOTmpeVaWSzSKiILfXTVJwrGmYZhcRbhWuEY=
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 h1:981MHwBaRZM7+9QSR6XamDzF/o7ouUGxFzr+nVSIhrs=
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/endpoints/v2 v2.6.22/go.mod h1:1RA1+aBEfn+CAB/Mh0MB6LsdCYCnjZm7tKXtnk499ZQ=
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.18 h1:OWYvKL53l1rbsUmW7bQyJVsYU/Ii3bbAAQIIFNbM0Tk= 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/go.mod h1:CUx0G1v3wG6l01tUB+j7Y8kclA8NSqK4ef0YG79a4cg= github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.22/go.mod h1:kbR1TL8llqB1eGnVbybcA4/wgScxdylOdyAd51yxPdw=
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 h1:TToQNkvGguu209puTojY/ozlqy2d/SFNcoLIqTFi42g=
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/accept-encoding v1.12.0/go.mod h1:0jp+ltwkf+SwG2fm/PKo8t4y8pJSgOCO4D8Lz3k0aHQ=
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 h1:kT6BcZsmMtNkP/iYMcRG+mIEA/IbeiUimXtGmqF39y0=
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/checksum v1.4.3/go.mod h1:Z8uGua2k4PPaGOYn66pK02rhMrot3Xk3tpBuUFPomZU=
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 h1:qcxX0JYlgWH3hpPUnd6U0ikcl6LLA9sLkXE2w1fpMvY=
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/presigned-url v1.12.3/go.mod h1:cLSNEmI45soc+Ef8K/L+8sEA3A3pYFEYf5B5UI+6bH4=
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 h1:ZC7Y/XgKUxwqcdhO5LE8P6oGP1eh6xlQReWNKfhvJno=
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/internal/s3shared v1.18.3/go.mod h1:WqfO7M9l9yUAw0HcHaikwRd/H6gzYdz7vjejCA5e2oY=
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 h1:p9TNFL8bFUMd+38YIpTAXpoxyz0MxC7FlbFEH4P4E1U=
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/s3 v1.66.2/go.mod h1:fNjyo0Coen9QTwQLWeV6WO2Nytwiu+cCcWaTdKCAqqE=
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 h1:UTpsIf0loCIWEbrqdLb+0RxnTXfWh2vhw4nQmFi4nPc=
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/sso v1.24.3/go.mod h1:FZ9j3PFHHAR+w0BSEjK955w5YD2UwB/l/H0yAK3MJvI=
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 h1:2YCmIXv3tmiItw0LlYf6v7gEHebLY45kBEnPezbUKyU=
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/ssooidc v1.28.3/go.mod h1:u19stRyNPxGhj6dRm+Cdgu6N75qnbW7+QN0q0dsAk58=
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 h1:wVnQ6tigGsRqSWDEEyH6lSAJ9OyFUsSnbaUWChuSGzs=
github.com/aws/aws-sdk-go-v2/service/sts v1.31.3/go.mod h1:yMWe0F+XG0DkRZK5ODZhG7BEFYhLXi2dqGsv6tX0cgI= github.com/aws/aws-sdk-go-v2/service/sts v1.32.3/go.mod h1:VZa9yTFyj4o10YGsmDO4gbQJUvvhY72fhumT8W4LqsE=
github.com/aws/smithy-go v1.21.0 h1:H7L8dtDRk0P1Qm6y0ji7MCYMQObJ5R9CRpyPhRUkLYA= github.com/aws/smithy-go v1.22.0 h1:uunKnWlcoL3zO7q+gG2Pk53joueEOsnNB28QdMsmiMM=
github.com/aws/smithy-go v1.21.0/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg= github.com/aws/smithy-go v1.22.0/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg=
github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= github.com/blang/semver v3.5.1+incompatible 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.0 h1:JbqvnEzRvPpxhCJzJJ2y0RbiZ8nyjccVUrSM3q+GvvE= github.com/ebitengine/purego v0.8.1 h1:sdRKd6plj7KYW33EH5As6YKfe8m9zbN9JMrOjNVF/BE=
github.com/ebitengine/purego v0.8.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/ebitengine/purego v0.8.1/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.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
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.5 h1:J7wGKdGu33ocBOhGy0z653k/lFKLFDPJMG8Gql0kxn4= github.com/gabriel-vasile/mimetype v1.4.6 h1:3+PzJTKLkvgjeTbts6msPJt4DixhT4YtFNf1gtGe3zc=
github.com/gabriel-vasile/mimetype v1.4.5/go.mod h1:ibHel+/kbxn9x2407k1izTA1S81ku1z/DlgOW2QE0M4= github.com/gabriel-vasile/mimetype v1.4.6/go.mod h1:JX1qVKqZd40hUPpAfiNTe0Sne7hdfKSbOqqmkq8GCXc=
github.com/ganigeorgiev/fexpr v0.4.1 h1:hpUgbUEEWIZhSDBtf4M9aUNfQQ0BZkGRaMePy7Gcx5k= github.com/ganigeorgiev/fexpr v0.4.1 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.23 h1:gbShiuAP1W5j9UOksQ06aiiqPMxYecovVGwmTxWtuw0= github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
github.com/mattn/go-sqlite3 v1.14.23/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/mgutz/ansi v0.0.0-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.21 h1:DGPCxn6co8VuTV0mton4NFO/ON49XiFMszRr+Mysy48= github.com/pocketbase/pocketbase v0.22.23 h1:cnjSiBcMf7VIhXmoBmZCAV8qKYkOubHCOQQPZMKFBAk=
github.com/pocketbase/pocketbase v0.22.21/go.mod h1:Cw5E4uoGhKItBIE2lJL3NfmiUr9Syk2xaNJ2G7Dssow= github.com/pocketbase/pocketbase v0.22.23/go.mod h1:h2ojT2pqBWH9LLl1aiawkwXiICKtzZA/kjM/8VhydR4=
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.9 h1:KIV+/HaHD5ka5f570RZq+2SaeFsb/pq+fp2DGNWYoOI= github.com/shirou/gopsutil/v4 v4.24.10 h1:7VOzPtfw/5YDU+jLEoBwXwxJbQetULywoSV4RYY7HkM=
github.com/shirou/gopsutil/v4 v4.24.9/go.mod h1:3fkaHNeYsUFCGZ8+9vZVWtbyM1k2eRnlL+bWO8Bxa/Q= github.com/shirou/gopsutil/v4 v4.24.10/go.mod h1:s4D/wg+ag4rG0WO7AiTj2BeYCRhym0vM7DHbZRxnIT8=
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.8.0 h1:Mx4Wwe/FjZLeQsK/6kt2EOepwwSl7SmJrK5bV/dXYgY= github.com/tklauser/numcpus v0.9.0 h1:lmyCHtANi8aRUgkckBgoDk1nHCux3n2cgkJLXdQGPDo=
github.com/tklauser/numcpus v0.8.0/go.mod h1:ZJZlAY+dmR4eut8epnzf0u/VwodKmryxR8txiloSqBE= github.com/tklauser/numcpus v0.9.0/go.mod h1:SN6Nq1O3VychhC1npsWostA+oW+VOQTxZrS604NSRyI=
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.39.0 h1:EYABYGhAalPUaMrbSKOr5lejxoxvXj99nE8XFtsDgds= gocloud.dev v0.40.0 h1:f8LgP+4WDqOG/RXoUcyLpeIAGOcAbZrZbDQCUee10ng=
gocloud.dev v0.39.0/go.mod h1:drz+VyYNBvrMTW0KZiBAYEdl8lbNZx+OQ7oQvdrFmSQ= gocloud.dev v0.40.0/go.mod h1:drz+VyYNBvrMTW0KZiBAYEdl8lbNZx+OQ7oQvdrFmSQ=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-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.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A= golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70= golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk= golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c h1:7dEasQXItcW1xKJ2+gg5VOiBnqWrJc+rq0DPKyvvdbY=
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY= golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c/go.mod h1:NQtJDoLvd6faHhE7m4T/1IY708gDefGGjR/iUW8yQQ8=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.20.0 h1:7cVCUjQwfL18gyBJOmYvptfSHS8Fb3YUDtfLIZ7Nbpw= golang.org/x/image v0.21.0 h1:c5qV36ajHpdj4Qi0GnE0jUc/yuo33OLFaa0d+crTD5s=
golang.org/x/image v0.20.0/go.mod h1:0a88To4CYVBAHp5FXJm8o7QbUl37Vd85ply1vyD8auM= golang.org/x/image v0.21.0/go.mod h1:vUbsLavqK/W303ZroQQVKQ+Af3Yl6Uz1Ppu5J/cLz78=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-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.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo= golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0= golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-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.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-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.24.0 h1:Mh5cbb+Zk2hqqXNO7S1iTjEphVL+jb8ZWaqh/g+JWkM= golang.org/x/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24=
golang.org/x/term v0.24.0/go.mod h1:lOBK/LVxemqiMij05LGJ0tzNr8xlmwBRJ81PX6wVLH8= golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.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.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ=
golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-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.25.0 h1:oFU9pkj/iJgs+0DT+VMHrx+oBKs/LJMV+Uvg78sl+fE= golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ=
golang.org/x/tools v0.25.0/go.mod h1:/vtpO8WL1N9cQC3FN5zPqb//fRXskFHbLKk4OW1Q7rg= golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-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.199.0 h1:aWUXClp+VFJmqE0JPvpZOK3LDQMyFKYIow4etYd9qxs= google.golang.org/api v0.204.0 h1:3PjmQQEDkR/ENVZZwIYB4W/KzYtN8OrqnNcHWpeR8E4=
google.golang.org/api v0.199.0/go.mod h1:ohG4qSztDJmZdjK/Ar6MhbAmb/Rpi4JHOqagsh90K28= google.golang.org/api v0.204.0/go.mod h1:69y8QSoKIbL9F94bWgWAq6wGqGwyjBgi2y8rAK8zLag=
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-20240903143218-8af14fe29dc1 h1:BulPr26Jqjnd4eYDVe+YvyR7Yc2vJGkO5/0UxD0/jZU= google.golang.org/genproto v0.0.0-20241021214115-324edc3d5d38 h1:Q3nlH8iSQSRUwOskjbcSMcF2jiYMNiQYZ0c2KEJLKKU=
google.golang.org/genproto v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:hL97c3SYopEHblzpxRL4lSs523++l8DYxGM1FQiYmb4= google.golang.org/genproto v0.0.0-20241021214115-324edc3d5d38/go.mod h1:xBI+tzfqGGN2JBeSebfKXFSdBpWVQ7sLW40PTupVRm4=
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-20240930140551-af27646dc61f h1:cUMEy+8oS78BWIH9OWazBkzbr090Od9tWBNtZHkOhf0= google.golang.org/genproto/googleapis/rpc v0.0.0-20241021214115-324edc3d5d38 h1:zciRKQ4kBpFgpfC5QQCVtnnNAcLIqweL7plyZRQHVpI=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240930140551-af27646dc61f/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU= google.golang.org/genproto/googleapis/rpc v0.0.0-20241021214115-324edc3d5d38/go.mod h1:GX3210XPVPUjJbTUbvwI8f2IpZDMZuPJWDzDuebbviI=
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,9 +395,10 @@ google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.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.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 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=
@@ -416,8 +417,8 @@ modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/gc/v2 v2.5.0 h1:bJ9ChznK1L1mUtAQtxi0wi5AtAs5jQuw4PrPHO5pb6M= modernc.org/gc/v2 v2.5.0 h1:bJ9ChznK1L1mUtAQtxi0wi5AtAs5jQuw4PrPHO5pb6M=
modernc.org/gc/v2 v2.5.0/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= modernc.org/gc/v2 v2.5.0/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a h1:CfbpOLEo2IwNzJdMvE8aiRbPMxoTpgAJeyePh0SmO8M= modernc.org/gc/v3 v3.0.0-20241004144649-1aea3fae8852 h1:IYXPPTTjjoSHvUClZIYexDiO7g+4x+XveKT4gCIAwiY=
modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= modernc.org/gc/v3 v3.0.0-20241004144649-1aea3fae8852/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
modernc.org/libc v1.61.0 h1:eGFcvWpqlnoGwzZeZe3PWJkkKbM/3SUGyk1DVZQ0TpE= modernc.org/libc v1.61.0 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=

View File

@@ -2,6 +2,7 @@
package agent package agent
import ( import (
"beszel"
"beszel/internal/entities/system" "beszel/internal/entities/system"
"context" "context"
"log/slog" "log/slog"
@@ -47,6 +48,8 @@ func (a *Agent) Run(pubKey []byte, addr string) {
} }
} }
slog.Debug(beszel.Version)
// Set sensors context (allows overriding sys location for sensors) // 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)
@@ -59,7 +62,9 @@ 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, ",") {
a.sensorsWhitelist[sensor] = struct{}{} if sensor != "" {
a.sensorsWhitelist[sensor] = struct{}{}
}
} }
} }
@@ -83,9 +88,11 @@ func (a *Agent) gatherStats() 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)
} }
@@ -96,5 +103,6 @@ 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
} }

View File

@@ -44,7 +44,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) key = findFallbackIoDevice(filesystem, diskIoCounters, a.fsStats)
slog.Info("Using I/O fallback", "name", key) slog.Info("Using I/O fallback", "name", key)
} }
} }
@@ -122,7 +122,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) rootDevice := findFallbackIoDevice(filepath.Base(filesystem), diskIoCounters, a.fsStats)
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 +132,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) string { func findFallbackIoDevice(filesystem string, diskIoCounters map[string]disk.IOCountersStat, fsStats map[string]*system.FsStats) string {
var maxReadBytes uint64 var maxReadBytes uint64
maxReadDevice := "/" maxReadDevice := "/"
for _, d := range diskIoCounters { for _, d := range diskIoCounters {
@@ -140,8 +140,11 @@ func findFallbackIoDevice(filesystem string, diskIoCounters map[string]disk.IOCo
return d.Name return d.Name
} }
if d.ReadBytes > maxReadBytes { if d.ReadBytes > maxReadBytes {
maxReadBytes = d.ReadBytes // don't use if device already exists in fsStats
maxReadDevice = d.Name if _, exists := fsStats[d.Name]; !exists {
maxReadBytes = d.ReadBytes
maxReadDevice = d.Name
}
} }
} }
return maxReadDevice return maxReadDevice

View File

@@ -60,6 +60,8 @@ 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{}{}
@@ -74,18 +76,33 @@ 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.deleteContainerStatsSync(ctr.IdShort) dm.containerStatsMutex.Lock()
// retry once delete(dm.containerStatsMap, ctr.IdShort)
err = dm.updateContainerStats(ctr) failedContainters = append(failedContainters, ctr)
if err != nil { dm.containerStatsMutex.Unlock()
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 {
@@ -217,9 +234,20 @@ 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: time.Millisecond * 2100, Timeout: timeout,
Transport: transport, Transport: transport,
}, },
containerStatsMap: make(map[string]*container.Stats), containerStatsMap: make(map[string]*container.Stats),
@@ -243,7 +271,7 @@ func newDockerManager() *dockerManager {
return dockerClient return dockerClient
} }
// if version > 25, one-shot works correctly and we can limit concurrent connections / goroutines to 5 // if version > 24, one-shot works correctly and we can limit concurrent operations
if dockerVersion, err := semver.Parse(versionInfo.Version); err == nil && dockerVersion.Major > 24 { if dockerVersion, err := semver.Parse(versionInfo.Version); err == nil && dockerVersion.Major > 24 {
concurrency = 5 concurrency = 5
} }

View File

@@ -25,7 +25,6 @@ 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)

View File

@@ -53,7 +53,6 @@ func (a *Agent) getSystemStats() system.Stats {
systemStats := system.Stats{} systemStats := system.Stats{}
// cpu percent // cpu percent
slog.Debug("Getting cpu percent")
cpuPct, err := cpu.Percent(0, false) cpuPct, err := cpu.Percent(0, false)
if err != nil { if err != nil {
slog.Error("Error getting cpu percent", "err", err) slog.Error("Error getting cpu percent", "err", err)
@@ -62,7 +61,6 @@ func (a *Agent) getSystemStats() system.Stats {
} }
// memory // memory
slog.Debug("Getting memory stats")
if v, err := mem.VirtualMemory(); err == nil { if v, err := mem.VirtualMemory(); err == nil {
// swap // swap
systemStats.Swap = bytesToGigabytes(v.SwapTotal) systemStats.Swap = bytesToGigabytes(v.SwapTotal)
@@ -91,7 +89,6 @@ func (a *Agent) getSystemStats() system.Stats {
} }
// disk usage // disk usage
slog.Debug("Getting disk stats")
for _, stats := range a.fsStats { for _, stats := range a.fsStats {
if d, err := disk.Usage(stats.Mountpoint); err == nil { if d, err := disk.Usage(stats.Mountpoint); err == nil {
stats.DiskTotal = bytesToGigabytes(d.Total) stats.DiskTotal = bytesToGigabytes(d.Total)
@@ -112,7 +109,6 @@ func (a *Agent) getSystemStats() system.Stats {
} }
// disk i/o // disk i/o
slog.Debug("Getting disk I/O stats")
if ioCounters, err := disk.IOCounters(a.fsNames...); err == nil { if ioCounters, err := disk.IOCounters(a.fsNames...); err == nil {
for _, d := range ioCounters { for _, d := range ioCounters {
stats := a.fsStats[d.Name] stats := a.fsStats[d.Name]
@@ -136,7 +132,6 @@ func (a *Agent) getSystemStats() system.Stats {
} }
// network stats // network stats
slog.Debug("Getting network stats")
if netIO, err := psutilNet.IOCounters(true); err == nil { if netIO, err := psutilNet.IOCounters(true); err == nil {
secondsElapsed := time.Since(a.netIoStats.Time).Seconds() secondsElapsed := time.Since(a.netIoStats.Time).Seconds()
a.netIoStats.Time = time.Now() a.netIoStats.Time = time.Now()
@@ -176,34 +171,36 @@ func (a *Agent) getSystemStats() system.Stats {
} }
} }
// temperatures // temperatures (skip if sensors whitelist is set to empty string)
slog.Debug("Getting 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)
systemStats.Temperatures = make(map[string]float64, len(temps))
for i, sensor := range temps {
// skip if temperature is 0
if sensor.Temperature <= 0 || sensor.Temperature >= 200 {
continue
}
if _, ok := systemStats.Temperatures[sensor.SensorKey]; ok {
// if key already exists, append int to key
systemStats.Temperatures[sensor.SensorKey+"_"+strconv.Itoa(i)] = twoDecimals(sensor.Temperature)
} else {
systemStats.Temperatures[sensor.SensorKey] = twoDecimals(sensor.Temperature)
}
} }
// remove sensors from systemStats if whitelist exists and sensor is not in whitelist slog.Debug("Temperature", "sensors", temps)
// (do this here instead of in initial loop so we have correct keys if int was appended) if len(temps) > 0 {
if a.sensorsWhitelist != nil { systemStats.Temperatures = make(map[string]float64, len(temps))
for key := range systemStats.Temperatures { for i, sensor := range temps {
if _, nameInWhitelist := a.sensorsWhitelist[key]; !nameInWhitelist { // skip if temperature is 0
delete(systemStats.Temperatures, key) if sensor.Temperature <= 0 || sensor.Temperature >= 200 {
continue
}
if _, ok := systemStats.Temperatures[sensor.SensorKey]; ok {
// if key already exists, append int to key
systemStats.Temperatures[sensor.SensorKey+"_"+strconv.Itoa(i)] = twoDecimals(sensor.Temperature)
} else {
systemStats.Temperatures[sensor.SensorKey] = twoDecimals(sensor.Temperature)
}
}
// remove sensors from systemStats if whitelist exists and sensor is not in whitelist
// (do this here instead of in initial loop so we have correct keys if int was appended)
if a.sensorsWhitelist != nil {
for key := range systemStats.Temperatures {
if _, nameInWhitelist := a.sensorsWhitelist[key]; !nameInWhitelist {
delete(systemStats.Temperatures, key)
}
} }
} }
} }
@@ -214,6 +211,8 @@ 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
} }

View File

@@ -6,21 +6,26 @@ 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 AlertData struct { type AlertMessageData struct {
UserID string UserID string
Title string Title string
Message string Message string
@@ -33,72 +38,308 @@ 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) HandleSystemInfoAlerts(systemRecord *models.Record, systemInfo system.Info) { func (am *AlertManager) HandleSystemAlerts(systemRecord *models.Record, systemInfo system.Info, temperatures map[string]float64, extraFs map[string]*system.FsStats) error {
// start := time.Now()
// defer func() {
// log.Println("alert stats took", time.Since(start))
// }()
alertRecords, err := am.app.Dao().FindRecordsByExpr("alerts", alertRecords, err := am.app.Dao().FindRecordsByExpr("alerts",
dbx.NewExp("system={:system}", dbx.Params{"system": systemRecord.GetId()}), dbx.NewExp("system={:system}", dbx.Params{"system": systemRecord.Id}),
) )
if err != nil || len(alertRecords) == 0 { if err != nil || len(alertRecords) == 0 {
// log.Println("no alerts found for system") // log.Println("no alerts found for system")
return return nil
} }
// log.Println("found alerts", len(alertRecords))
var validAlerts []SystemAlertData
now := systemRecord.Updated.Time().UTC()
oldestTime := now
for _, alertRecord := range alertRecords { for _, alertRecord := range alertRecords {
name := alertRecord.GetString("name") name := alertRecord.GetString("name")
var val float64
unit := "%"
switch name { switch name {
case "CPU", "Memory", "Disk": case "CPU":
if name == "CPU" { val = systemInfo.Cpu
am.handleSlidingValueAlert(systemRecord, alertRecord, name, systemInfo.Cpu) case "Memory":
} else if name == "Memory" { val = systemInfo.MemPct
am.handleSlidingValueAlert(systemRecord, alertRecord, name, systemInfo.MemPct) case "Bandwidth":
} else if name == "Disk" { val = systemInfo.Bandwidth
am.handleSlidingValueAlert(systemRecord, alertRecord, name, systemInfo.DiskPct) unit = " MB/s"
case "Disk":
maxUsedPct := systemInfo.DiskPct
for _, fs := range extraFs {
usedPct := fs.DiskUsed / fs.DiskTotal * 100
if usedPct > maxUsedPct {
maxUsedPct = usedPct
}
}
val = maxUsedPct
case "Temperature":
if temperatures == nil {
continue
}
for _, temp := range temperatures {
if temp > val {
val = temp
}
}
unit = "°C"
}
triggered := alertRecord.GetBool("triggered")
threshold := alertRecord.GetFloat("value")
// CONTINUE
// IF alert is not triggered and curValue is less than threshold
// OR alert is triggered and curValue is greater than threshold
if (!triggered && val <= threshold) || (triggered && val > threshold) {
// log.Printf("Skipping alert %s: val %f | threshold %f | triggered %v\n", name, val, threshold, triggered)
continue
}
min := max(1, cast.ToUint8(alertRecord.Get("min")))
// add time to alert time to make sure it's slighty after record creation
time := now.Add(-time.Duration(min) * time.Minute)
if time.Before(oldestTime) {
oldestTime = time
}
validAlerts = append(validAlerts, SystemAlertData{
systemRecord: systemRecord,
alertRecord: alertRecord,
name: name,
unit: unit,
val: val,
threshold: threshold,
triggered: triggered,
time: time,
min: min,
})
}
systemStats := []struct {
Stats []byte `db:"stats"`
Created types.DateTime `db:"created"`
}{}
err = am.app.Dao().DB().
Select("stats", "created").
From("system_stats").
Where(dbx.NewExp(
"system={:system} AND type='1m' AND created > {:created}",
dbx.Params{
"system": systemRecord.Id,
// subtract some time to give us a bit of buffer
"created": oldestTime.Add(-time.Second * 90),
},
)).
OrderBy("created").
All(&systemStats)
if err != nil {
return err
}
// get oldest record creation time from first record in the slice
oldestRecordTime := systemStats[0].Created.Time()
// log.Println("oldestRecordTime", oldestRecordTime.String())
// delete from validAlerts if time is older than oldestRecord
for i := 0; i < len(validAlerts); i++ {
if validAlerts[i].time.Before(oldestRecordTime) {
// log.Println("deleting alert - time is older than oldestRecord", validAlerts[i].name, oldestRecordTime, validAlerts[i].time)
validAlerts = append(validAlerts[:i], validAlerts[i+1:]...)
}
}
if len(validAlerts) == 0 {
// log.Println("no valid alerts found")
return nil
}
var stats SystemAlertStats
// we can skip the latest systemStats record since it's the current value
for i := 0; i < len(systemStats); i++ {
stat := systemStats[i]
// subtract 10 seconds to give a small time buffer
systemStatsCreation := stat.Created.Time().Add(-time.Second * 10)
if err := json.Unmarshal(stat.Stats, &stats); err != nil {
return err
}
// log.Println("stats", stats)
for j := range validAlerts {
alert := &validAlerts[j]
// reset alert val on first iteration
if i == 0 {
alert.val = 0
}
// continue if system_stats is older than alert time range
if systemStatsCreation.Before(alert.time) {
continue
}
// add to alert value
switch alert.name {
case "CPU":
alert.val += stats.Cpu
case "Memory":
alert.val += stats.Mem
case "Bandwidth":
alert.val += stats.NetSent + stats.NetRecv
case "Disk":
if alert.mapSums == nil {
alert.mapSums = make(map[string]float32, len(extraFs)+1)
}
// add root disk
if _, ok := alert.mapSums["root"]; !ok {
alert.mapSums["root"] = 0.0
}
alert.mapSums["root"] += float32(stats.Disk)
// add extra disks
for key, fs := range extraFs {
if _, ok := alert.mapSums[key]; !ok {
alert.mapSums[key] = 0.0
}
alert.mapSums[key] += float32(fs.DiskUsed / fs.DiskTotal * 100)
}
case "Temperature":
if alert.mapSums == nil {
alert.mapSums = make(map[string]float32, len(stats.Temperatures))
}
for key, temp := range stats.Temperatures {
if _, ok := alert.mapSums[key]; !ok {
alert.mapSums[key] = float32(0)
}
alert.mapSums[key] += temp
}
default:
continue
}
alert.count++
}
}
// sum up vals for each alert
for _, alert := range validAlerts {
switch alert.name {
case "Disk":
maxPct := float32(0)
for key, value := range alert.mapSums {
sumPct := float32(value)
if sumPct > maxPct {
maxPct = sumPct
alert.descriptor = fmt.Sprintf("Usage of %s", key)
}
}
alert.val = float64(maxPct / float32(alert.count))
case "Temperature":
maxTemp := float32(0)
for key, value := range alert.mapSums {
sumTemp := float32(value) / float32(alert.count)
if sumTemp > maxTemp {
maxTemp = sumTemp
alert.descriptor = fmt.Sprintf("Highest sensor %s", key)
}
}
alert.val = float64(maxTemp)
default:
alert.val = alert.val / float64(alert.count)
}
minCount := float32(alert.min) / 1.2
// log.Println("alert", alert.name, "val", alert.val, "threshold", alert.threshold, "triggered", alert.triggered)
// log.Printf("%s: val %f | count %d | min-count %f | threshold %f\n", alert.name, alert.val, alert.count, minCount, alert.threshold)
// pass through alert if count is greater than or equal to minCount
if float32(alert.count) >= minCount {
if !alert.triggered && alert.val > alert.threshold {
alert.triggered = true
go am.sendSystemAlert(alert)
} else if alert.triggered && alert.val <= alert.threshold {
alert.triggered = false
go am.sendSystemAlert(alert)
} }
} }
} }
return nil
} }
func (am *AlertManager) handleSlidingValueAlert(systemRecord *models.Record, alertRecord *models.Record, name string, curValue float64) { func (am *AlertManager) sendSystemAlert(alert SystemAlertData) {
triggered := alertRecord.GetBool("triggered") // log.Printf("Sending alert %s: val %f | count %d | threshold %f\n", alert.name, alert.val, alert.count, alert.threshold)
threshold := alertRecord.GetFloat("value") systemName := alert.systemRecord.GetString("name")
// fmt.Println(name, curValue, "threshold", threshold, "triggered", triggered)
var subject string // change Disk to Disk usage
var body string if alert.name == "Disk" {
var systemName string alert.name += " usage"
if !triggered && curValue > threshold {
alertRecord.Set("triggered", true)
systemName = systemRecord.GetString("name")
subject = fmt.Sprintf("%s usage above threshold on %s", name, systemName)
body = fmt.Sprintf("%s usage on %s is %.1f%%.", name, systemName, curValue)
} else if triggered && curValue <= threshold {
alertRecord.Set("triggered", false)
systemName = systemRecord.GetString("name")
subject = fmt.Sprintf("%s usage below threshold on %s", name, systemName)
body = fmt.Sprintf("%s usage on %s is below threshold at %.1f%%.", name, systemName, curValue)
} else {
// fmt.Println(name, "not triggered")
return
} }
if err := am.app.Dao().SaveRecord(alertRecord); err != nil {
// make title alert name lowercase if not CPU
titleAlertName := alert.name
if titleAlertName != "CPU" {
titleAlertName = strings.ToLower(titleAlertName)
}
var subject string
if alert.triggered {
subject = fmt.Sprintf("%s %s above threshold", systemName, titleAlertName)
} else {
subject = fmt.Sprintf("%s %s below threshold", systemName, titleAlertName)
}
minutesLabel := "minute"
if alert.min > 1 {
minutesLabel += "s"
}
if alert.descriptor == "" {
alert.descriptor = alert.name
}
body := fmt.Sprintf("%s averaged %.2f%s for the previous %v %s.", alert.descriptor, alert.val, alert.unit, alert.min, minutesLabel)
alert.alertRecord.Set("triggered", alert.triggered)
if err := am.app.Dao().SaveRecord(alert.alertRecord); err != nil {
// app.Logger().Error("failed to save alert record", "err", err.Error()) // 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(alertRecord, []string{"user"}, nil); len(errs) > 0 { if errs := am.app.Dao().ExpandRecord(alert.alertRecord, []string{"user"}, nil); len(errs) > 0 {
// app.Logger().Error("failed to expand user relation", "errs", errs) // app.Logger().Error("failed to expand user relation", "errs", errs)
return return
} }
if user := alertRecord.ExpandedOne("user"); user != nil { if user := alert.alertRecord.ExpandedOne("user"); user != nil {
am.sendAlert(AlertData{ am.sendAlert(AlertMessageData{
UserID: user.GetId(), UserID: user.GetId(),
Title: subject, Title: subject,
Message: body, Message: body,
Link: am.app.Settings().Meta.AppUrl + "/system/" + url.QueryEscape(systemName), Link: am.app.Settings().Meta.AppUrl + "/system/" + url.PathEscape(systemName),
LinkText: "View " + systemName, LinkText: "View " + systemName,
}) })
} }
@@ -145,18 +386,18 @@ func (am *AlertManager) HandleStatusAlerts(newStatus string, oldSystemRecord *mo
} }
// send alert // send alert
systemName := oldSystemRecord.GetString("name") systemName := oldSystemRecord.GetString("name")
am.sendAlert(AlertData{ am.sendAlert(AlertMessageData{
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.QueryEscape(systemName), Link: am.app.Settings().Meta.AppUrl + "/system/" + url.PathEscape(systemName),
LinkText: "View " + systemName, LinkText: "View " + systemName,
}) })
} }
return nil return nil
} }
func (am *AlertManager) sendAlert(data AlertData) { func (am *AlertManager) sendAlert(data AlertMessageData) {
// 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}",

View File

@@ -61,6 +61,7 @@ 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"`
} }

View File

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

View File

@@ -42,6 +42,8 @@ type Hub struct {
am *alerts.AlertManager am *alerts.AlertManager
um *users.UserManager um *users.UserManager
rm *records.RecordManager rm *records.RecordManager
systemStats *models.Collection
containerStats *models.Collection
} }
func NewHub(app *pocketbase.PocketBase) *Hub { func NewHub(app *pocketbase.PocketBase) *Hub {
@@ -56,14 +58,10 @@ func NewHub(app *pocketbase.PocketBase) *Hub {
} }
func (h *Hub) Run() { func (h *Hub) Run() {
// rm := records.NewRecordManager(h.app)
// am := alerts.NewAlertManager(h.app)
// um := users.NewUserManager(h.app)
// loosely check if it was executed using "go run" // 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,
@@ -93,7 +91,8 @@ func (h *Hub) Run() {
if err := h.app.Dao().SaveCollection(usersCollection); err != nil { if err := h.app.Dao().SaveCollection(usersCollection); err != nil {
return err return err
} }
return nil // sync systems with config
return h.syncSystemsWithConfig()
}) })
// serve web ui // serve web ui
@@ -128,7 +127,11 @@ 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 * * * *", h.rm.CreateLongerRecords) scheduler.MustAdd("create longer records", "*/10 * * * *", func() {
if systemStats, containerStats, err := h.getCollections(); err == nil {
h.rm.CreateLongerRecords([]*models.Collection{systemStats, containerStats})
}
})
scheduler.Start() scheduler.Start()
return nil return nil
}) })
@@ -153,6 +156,8 @@ 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
}) })
@@ -174,6 +179,14 @@ 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)
@@ -281,33 +294,59 @@ func (h *Hub) updateSystem(record *models.Record) {
return return
} }
// update system record // update system record
dao := h.app.Dao()
record.Set("status", "up") record.Set("status", "up")
record.Set("info", systemData.Info) record.Set("info", systemData.Info)
if err := h.app.Dao().SaveRecord(record); err != nil { if err := dao.SaveRecord(record); err != nil {
h.app.Logger().Error("Failed to update record: ", "err", err.Error()) h.app.Logger().Error("Failed to update record: ", "err", err.Error())
} }
// add new system_stats record // add system_stats and container_stats records
system_stats, _ := h.app.Dao().FindCollectionByNameOrId("system_stats") if systemStats, containerStats, err := h.getCollections(); err != nil {
systemStatsRecord := models.NewRecord(system_stats) h.app.Logger().Error("Failed to get collections: ", "err", err.Error())
systemStatsRecord.Set("system", record.Id) } else {
systemStatsRecord.Set("stats", systemData.Stats) // add new system_stats record
systemStatsRecord.Set("type", "1m") systemStatsRecord := models.NewRecord(systemStats)
if err := h.app.Dao().SaveRecord(systemStatsRecord); err != nil { systemStatsRecord.Set("system", record.Id)
h.app.Logger().Error("Failed to save record: ", "err", err.Error()) systemStatsRecord.Set("stats", systemData.Stats)
} systemStatsRecord.Set("type", "1m")
// add new container_stats record if err := dao.SaveRecord(systemStatsRecord); err != nil {
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()) 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: temp alerts, extra fs alerts)
h.am.HandleSystemInfoAlerts(record, systemData.Info) // system info alerts (todo: extra fs alerts)
if err := h.am.HandleSystemAlerts(record, systemData.Info, systemData.Stats.Temperatures, systemData.Stats.ExtraFs); err != nil {
h.app.Logger().Error("System alerts error", "err", err.Error())
}
}
// return system_stats and container_stats collections
func (h *Hub) getCollections() (*models.Collection, *models.Collection, error) {
if h.systemStats == nil {
systemStats, err := h.app.Dao().FindCollectionByNameOrId("system_stats")
if err != nil {
return nil, nil, err
}
h.systemStats = systemStats
}
if h.containerStats == nil {
containerStats, err := h.app.Dao().FindCollectionByNameOrId("container_stats")
if err != nil {
return nil, nil, err
}
h.containerStats = containerStats
}
return h.systemStats, h.containerStats, nil
} }
// set system to specified status and save record // set system to specified status and save record

View File

@@ -8,6 +8,7 @@ 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"
@@ -31,14 +32,18 @@ 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() { func (rm *RecordManager) CreateLongerRecords(collections []*models.Collection) {
// start := time.Now() // start := time.Now()
recordData := []LongerRecordData{ longerRecordData := []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
@@ -73,16 +78,11 @@ func (rm *RecordManager) CreateLongerRecords() {
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 _, recordData := range recordData { for i := range longerRecordData {
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,16 +104,23 @@ func (rm *RecordManager) CreateLongerRecords() {
} }
} }
// get shorter records from the past x minutes // get shorter records from the past x minutes
allShorterRecords, err := txDao.FindRecordsByExpr( var stats RecordStats
collection.Id,
dbx.NewExp( err := txDao.DB().
"type = {:type} AND system = {:system} AND created > {:created}", Select("stats").
dbx.Params{"type": recordData.shorterType, "system": system.Id, "created": shorterRecordPeriod}, From(collection.Name).
), AndWhere(dbx.NewExp(
) "type={:type} AND system={:system} AND created > {:created}",
dbx.Params{
"type": recordData.shorterType,
"system": system.Id,
"created": shorterRecordPeriod,
},
)).
All(&stats)
// continue if not enough shorter records // continue if not enough shorter records
if err != nil || len(allShorterRecords) < recordData.minShorterRecords { if err != nil || len(stats) < recordData.minShorterRecords {
// log.Println("not enough shorter records. continue.", len(allShorterRecords), recordData.expectedShorterRecords) // log.Println("not enough shorter records. continue.", len(allShorterRecords), recordData.expectedShorterRecords)
continue continue
} }
@@ -123,9 +130,9 @@ func (rm *RecordManager) CreateLongerRecords() {
longerRecord.Set("type", recordData.longerType) longerRecord.Set("type", recordData.longerType)
switch collection.Name { switch collection.Name {
case "system_stats": case "system_stats":
longerRecord.Set("stats", rm.AverageSystemStats(allShorterRecords)) longerRecord.Set("stats", rm.AverageSystemStats(stats))
case "container_stats": case "container_stats":
longerRecord.Set("stats", rm.AverageContainerStats(allShorterRecords)) longerRecord.Set("stats", rm.AverageContainerStats(stats))
} }
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())
@@ -141,7 +148,7 @@ func (rm *RecordManager) CreateLongerRecords() {
} }
// Calculate the average stats of a list of system_stats records without reflect // Calculate the average stats of a list of system_stats records without reflect
func (rm *RecordManager) AverageSystemStats(records []*models.Record) system.Stats { func (rm *RecordManager) AverageSystemStats(records RecordStats) 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),
@@ -152,8 +159,8 @@ func (rm *RecordManager) AverageSystemStats(records []*models.Record) system.Sta
tempCount := float64(0) tempCount := float64(0)
var stats system.Stats var stats system.Stats
for _, record := range records { for i := range records {
record.UnmarshalJSONField("stats", &stats) json.Unmarshal(records[i].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
@@ -226,14 +233,14 @@ func (rm *RecordManager) AverageSystemStats(records []*models.Record) system.Sta
} }
if len(sum.Temperatures) != 0 { if len(sum.Temperatures) != 0 {
stats.Temperatures = make(map[string]float64) stats.Temperatures = make(map[string]float64, len(sum.Temperatures))
for key, value := range sum.Temperatures { 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) stats.ExtraFs = make(map[string]*system.FsStats, len(sum.ExtraFs))
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),
@@ -250,14 +257,19 @@ func (rm *RecordManager) AverageSystemStats(records []*models.Record) system.Sta
} }
// 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 []*models.Record) []container.Stats { func (rm *RecordManager) AverageContainerStats(records RecordStats) []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 _, record := range records { for i := range records {
record.UnmarshalJSONField("stats", &containerStats) // Reset the slice length to 0, but keep the capacity
for _, stat := range containerStats { containerStats = containerStats[:0]
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}
} }

View File

@@ -15,7 +15,7 @@ func init() {
{ {
"id": "2hz5ncl8tizk5nx", "id": "2hz5ncl8tizk5nx",
"created": "2024-07-07 16:08:20.979Z", "created": "2024-07-07 16:08:20.979Z",
"updated": "2024-07-28 17:14:24.492Z", "updated": "2024-10-12 18:55:51.623Z",
"name": "systems", "name": "systems",
"type": "base", "type": "base",
"system": false, "system": false,
@@ -120,7 +120,7 @@ func init() {
{ {
"id": "ej9oowivz8b2mht", "id": "ej9oowivz8b2mht",
"created": "2024-07-07 16:09:09.179Z", "created": "2024-07-07 16:09:09.179Z",
"updated": "2024-07-28 17:14:24.492Z", "updated": "2024-10-12 18:55:51.623Z",
"name": "system_stats", "name": "system_stats",
"type": "base", "type": "base",
"system": false, "system": false,
@@ -186,7 +186,7 @@ func init() {
{ {
"id": "juohu4jipgc13v7", "id": "juohu4jipgc13v7",
"created": "2024-07-07 16:09:57.976Z", "created": "2024-07-07 16:09:57.976Z",
"updated": "2024-07-28 17:14:24.492Z", "updated": "2024-10-12 18:55:51.623Z",
"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-09-12 23:19:36.280Z", "updated": "2024-10-12 22:27:19.081Z",
"name": "users", "name": "users",
"type": "auth", "type": "auth",
"system": false, "system": false,
@@ -316,7 +316,7 @@ func init() {
{ {
"id": "elngm8x1l60zi2v", "id": "elngm8x1l60zi2v",
"created": "2024-07-15 01:16:04.044Z", "created": "2024-07-15 01:16:04.044Z",
"updated": "2024-07-28 17:14:24.492Z", "updated": "2024-10-12 22:27:29.128Z",
"name": "alerts", "name": "alerts",
"type": "base", "type": "base",
"system": false, "system": false,
@@ -367,7 +367,9 @@ func init() {
"Status", "Status",
"CPU", "CPU",
"Memory", "Memory",
"Disk" "Disk",
"Temperature",
"Bandwidth"
] ]
} }
}, },
@@ -385,6 +387,20 @@ 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",
@@ -407,7 +423,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-09-12 21:19:59.114Z", "updated": "2024-10-12 18:55:51.624Z",
"name": "user_settings", "name": "user_settings",
"type": "base", "type": "base",
"system": false, "system": false,

8
beszel/site/.prettierrc Normal file
View File

@@ -0,0 +1,8 @@
{
"trailingComma": "es5",
"useTabs": true,
"tabWidth": 2,
"semi": false,
"singleQuote": false,
"printWidth": 120
}

Binary file not shown.

View File

@@ -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"
} }
} }

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en" dir="ltr">
<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" />

View File

@@ -0,0 +1,15 @@
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

File diff suppressed because it is too large Load Diff

View File

@@ -6,47 +6,67 @@
"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.15.1", "@nanostores/router": "^0.11.0",
"@radix-ui/react-alert-dialog": "^1.1.1", "@radix-ui/react-alert-dialog": "^1.1.2",
"@radix-ui/react-dialog": "^1.1.1", "@radix-ui/react-checkbox": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.1", "@radix-ui/react-dialog": "^1.1.2",
"@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.1", "@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slider": "^1.2.0", "@radix-ui/react-slider": "^1.2.1",
"@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-switch": "^1.1.0", "@radix-ui/react-switch": "^1.1.1",
"@radix-ui/react-toast": "^1.2.1", "@radix-ui/react-tabs": "^1.1.1",
"@radix-ui/react-tooltip": "^1.1.2", "@radix-ui/react-toast": "^1.2.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.407.0", "lucide-react": "^0.452.0",
"nanostores": "^0.10.3", "nanostores": "^0.11.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-alpha.5", "recharts": "^2.13.0",
"tailwind-merge": "^2.5.2", "tailwind-merge": "^2.5.4",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"valibot": "^0.36.0" "valibot": "^0.36.0"
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "^1.1.10", "@lingui/cli": "^4.13.0",
"@types/react": "^18.3.10", "@lingui/swc-plugin": "^4.1.0",
"@types/react-dom": "^18.3.0", "@lingui/vite-plugin": "^4.13.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.13", "tailwindcss": "^3.4.14",
"typescript": "^5.6.2", "tailwindcss-rtl": "^0.9.0",
"vite": "^5.4.8" "typescript": "^5.6.3",
"vite": "^5.4.9"
},
"overrides": {
"@nanostores/router": {
"nanostores": "^0.11.3"
}
},
"optionalDependencies": {
"@esbuild/linux-arm64": "^0.21.5"
} }
} }

View File

@@ -1,6 +1,6 @@
export default { export default {
plugins: { plugins: {
tailwindcss: {}, tailwindcss: {},
autoprefixer: {}, autoprefixer: {},
}, },
} }

View File

@@ -1,4 +1,4 @@
import { Button } from '@/components/ui/button' import { Button } from "@/components/ui/button"
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -7,17 +7,19 @@ 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)
@@ -37,8 +39,13 @@ 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) {
@@ -48,8 +55,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)
@@ -61,88 +68,119 @@ 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 -ml-1" /> <PlusIcon className="h-4 w-4 -ms-1" />
Add <span className="hidden xs:inline">System</span> <Trans>
Add <span className="hidden sm:inline">System</span>
</Trans>
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="w-[90%] sm:max-w-[425px] rounded-lg"> <DialogContent className="w-[90%] sm:max-w-[440px] rounded-lg">
<DialogHeader> <Tabs defaultValue="docker">
<DialogTitle className="mb-2">Add New System</DialogTitle> <DialogHeader>
<DialogDescription> <DialogTitle className="mb-2">
The agent must be running on the system to connect. Copy the{' '} <Trans>Add New System</Trans>
<code className="bg-muted px-1 rounded-sm">docker-compose.yml</code> for the agent </DialogTitle>
below. <TabsList className="grid w-full grid-cols-2">
</DialogDescription> <TabsTrigger value="docker">Docker</TabsTrigger>
</DialogHeader> <TabsTrigger value="binary">
<form onSubmit={handleSubmit as any}> <Trans>Binary</Trans>
<div className="grid gap-3 mt-1 mb-4"> </TabsTrigger>
<div className="grid grid-cols-4 items-center gap-4"> </TabsList>
<Label htmlFor="name" className="text-right"> </DialogHeader>
Name {/* Docker */}
</Label> <TabsContent value="docker">
<Input id="name" name="name" className="col-span-3" required /> <DialogDescription className="mb-4 leading-normal">
<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>
<div className="grid grid-cols-4 items-center gap-4"> {/* Docker */}
<Label htmlFor="host" className="text-right"> <TabsContent value="docker">
Host / IP <DialogFooter className="flex justify-end gap-2 sm:w-[calc(100%+20px)] sm:-ms-[20px]">
</Label> <Button type="button" variant={"ghost"} onClick={() => copyDockerCompose(port.current.value)}>
<Input id="host" name="host" className="col-span-3" required /> <Trans>Copy</Trans> docker compose
</div> </Button>
<div className="grid grid-cols-4 items-center gap-4"> <Button>
<Label htmlFor="port" className="text-right"> <Trans>Add system</Trans>
Port </Button>
</Label> </DialogFooter>
<Input </TabsContent>
ref={port} {/* Binary */}
name="port" <TabsContent value="binary">
id="port" <DialogFooter className="flex justify-end gap-2 sm:w-[calc(100%+20px)] sm:-ms-[20px]">
defaultValue="45876" <Button type="button" variant={"ghost"} onClick={() => copyInstallCommand(port.current.value)}>
className="col-span-3" <Trans>Copy Linux command</Trans>
required </Button>
/> <Button>
</div> <Trans>Add system</Trans>
<div className="grid grid-cols-4 items-center gap-4 relative"> </Button>
<Label htmlFor="pkey" className="text-right whitespace-pre"> </DialogFooter>
Public Key </TabsContent>
</Label> </form>
<Input readOnly id="pkey" value={publicKey} className="col-span-3" required></Input> </Tabs>
<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>
) )

View File

@@ -0,0 +1,120 @@
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>
</>
)
}

View File

@@ -0,0 +1,246 @@
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>
)
}

View File

@@ -1,18 +1,19 @@
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts' import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart' import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
import { import {
useYAxisWidth, useYAxisWidth,
chartTimeData,
cn, cn,
formatShortDate, formatShortDate,
toFixedWithoutTrailingZeros, toFixedWithoutTrailingZeros,
twoDecimalString, decimalString,
chartMargin, chartMargin,
} from '@/lib/utils' } from "@/lib/utils"
// import Spinner from '../spinner' // import Spinner from '../spinner'
import { ChartTimes, SystemStatsRecord } from '@/types' import { ChartData } from "@/types"
import { useMemo } from 'react' import { memo, useMemo } from "react"
import { t } from "@lingui/macro"
import { useLingui } from "@lingui/react"
/** [label, key, color, opacity] */ /** [label, key, color, opacity] */
type DataKeys = [string, string, number, number] type DataKeys = [string, string, number, number]
@@ -22,61 +23,70 @@ const getNestedValue = (path: string, max = false, data: any): number | null =>
// a max value which doesn't exist, or the value was zero and omitted from the stats object. // 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. // 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. // if not, return null - there is no max data so do not display anything.
return `stats.${path}${max ? 'm' : ''}` return `stats.${path}${max ? "m" : ""}`
.split('.') .split(".")
.reduce((acc: any, key: string) => acc?.[key] ?? (data.stats?.cpum ? 0 : null), data) .reduce((acc: any, key: string) => acc?.[key] ?? (data.stats?.cpum ? 0 : null), data)
} }
export default function AreaChartDefault({ export default memo(function AreaChartDefault({
ticks, maxToggled = false,
systemData, unit = " MB/s",
showMax = false,
unit = ' MB/s',
chartName, chartName,
chartTime, chartData,
}: { }: {
ticks: number[] maxToggled?: boolean
systemData: SystemStatsRecord[]
showMax?: boolean
unit?: string unit?: string
chartName: string chartName: string
chartTime: ChartTimes chartData: ChartData
}) { }) {
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth() const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
const { i18n } = useLingui()
const { chartTime } = chartData
const showMax = chartTime !== "1h" && maxToggled
const dataKeys: DataKeys[] = useMemo(() => { const dataKeys: DataKeys[] = useMemo(() => {
// [label, key, color, opacity] // [label, key, color, opacity]
if (chartName === 'CPU Usage') { if (chartName === "CPU Usage") {
return [[chartName, 'cpu', 1, 0.4]] return [[t`CPU Usage`, "cpu", 1, 0.4]]
} else if (chartName === 'dio') { } else if (chartName === "dio") {
return [ return [
['Write', 'dw', 3, 0.3], [t({ message: "Write", comment: "Context is disk write" }), "dw", 3, 0.3],
['Read', 'dr', 1, 0.3], [t({ message: "Read", comment: "Context is disk read" }), "dr", 1, 0.3],
] ]
} else if (chartName === 'bw') { } else if (chartName === "bw") {
return [ return [
['Sent', 'ns', 5, 0.2], [t({ message: "Sent", comment: "Context is network bytes sent (upload)" }), "ns", 5, 0.2],
['Received', 'nr', 2, 0.2], [t({ message: "Received", comment: "Context is network bytes received (download)" }), "nr", 2, 0.2],
] ]
} else if (chartName.startsWith('efs')) { } else if (chartName.startsWith("efs")) {
return [ return [
['Write', `${chartName}.w`, 3, 0.3], [t`Read`, `${chartName}.w`, 3, 0.3],
['Read', `${chartName}.r`, 1, 0.3], [t`Write`, `${chartName}.r`, 1, 0.3],
] ]
} }
return [] return []
}, []) }, [chartName, i18n.locale])
// console.log('Rendered at', new Date())
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', { className={cn("h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity", {
'opacity-100': yAxisWidth, "opacity-100": yAxisWidth,
})} })}
> >
<AreaChart accessibilityLayer data={systemData} margin={chartMargin}> <AreaChart accessibilityLayer data={chartData.systemStats} margin={chartMargin}>
<CartesianGrid vertical={false} /> <CartesianGrid vertical={false} />
<YAxis <YAxis
direction="ltr"
orientation={chartData.orientation}
className="tracking-tighter" className="tracking-tighter"
width={yAxisWidth} width={yAxisWidth}
tickFormatter={(value) => { tickFormatter={(value) => {
@@ -86,25 +96,15 @@ export default function AreaChartDefault({
tickLine={false} tickLine={false}
axisLine={false} axisLine={false}
/> />
<XAxis {xAxis(chartData)}
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}
content={ content={
<ChartTooltipContent <ChartTooltipContent
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)} labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={(item) => twoDecimalString(item.value) + unit} contentFormatter={(item) => decimalString(item.value) + unit}
indicator="line" // indicator="line"
/> />
} }
/> />
@@ -128,4 +128,4 @@ export default function AreaChartDefault({
</ChartContainer> </ChartContainer>
</div> </div>
) )
} })

View File

@@ -1,33 +1,23 @@
import { import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
Select, import { $chartTime } from "@/lib/stores"
SelectContent, import { chartTimeData, cn } from "@/lib/utils"
SelectItem, import { ChartTimes } from "@/types"
SelectTrigger, import { useStore } from "@nanostores/react"
SelectValue, import { HistoryIcon } from "lucide-react"
} 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 <Select defaultValue="1h" value={chartTime} onValueChange={(value: ChartTimes) => $chartTime.set(value)}>
defaultValue="1h" <SelectTrigger className={cn(className, "relative ps-10 pe-5")}>
value={chartTime} <HistoryIcon className="h-4 w-4 absolute start-4 top-1/2 -translate-y-1/2 opacity-85" />
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={label} value={value}> <SelectItem key={value} value={value}>
{label} {label()}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>

View File

@@ -0,0 +1,189 @@
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
import { memo, useMemo } from "react"
import {
useYAxisWidth,
cn,
formatShortDate,
decimalString,
chartMargin,
toFixedFloat,
getSizeAndUnit,
toFixedWithoutTrailingZeros,
} from "@/lib/utils"
// import Spinner from '../spinner'
import { useStore } from "@nanostores/react"
import { $containerFilter } from "@/lib/stores"
import { ChartData } from "@/types"
import { Separator } from "../ui/separator"
export default memo(function ContainerChart({
dataKey,
chartData,
chartName,
unit = "%",
}: {
dataKey: string
chartData: ChartData
chartName: string
unit?: string
}) {
const filter = useStore($containerFilter)
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
const { containerData } = chartData
const isNetChart = chartName === "net"
const chartConfig = useMemo(() => {
let config = {} as Record<
string,
{
label: string
color: string
}
>
const totalUsage = {} as Record<string, number>
for (let stats of containerData) {
for (let key in stats) {
if (!key || key === "created") {
continue
}
if (!(key in totalUsage)) {
totalUsage[key] = 0
}
if (isNetChart) {
totalUsage[key] += (stats[key]?.nr ?? 0) + (stats[key]?.ns ?? 0)
} else {
// @ts-ignore
totalUsage[key] += stats[key]?.[dataKey] ?? 0
}
}
}
let keys = Object.keys(totalUsage)
keys.sort((a, b) => (totalUsage[a] > totalUsage[b] ? -1 : 1))
const length = keys.length
for (let i = 0; i < length; i++) {
const key = keys[i]
const hue = ((i * 360) / length) % 360
config[key] = {
label: key,
color: `hsl(${hue}, 60%, 55%)`,
}
}
return config satisfies ChartConfig
}, [chartData])
const { toolTipFormatter, dataFunction, tickFormatter } = useMemo(() => {
const obj = {} as {
toolTipFormatter: (item: any, key: string) => React.ReactNode | string
dataFunction: (key: string, data: any) => number | null
tickFormatter: (value: any) => string
}
// tick formatter
if (chartName === "cpu") {
obj.tickFormatter = (value) => {
const val = toFixedWithoutTrailingZeros(value, 2) + unit
return updateYAxisWidth(val)
}
} else {
obj.tickFormatter = (value) => {
const { v, u } = getSizeAndUnit(value, false)
return updateYAxisWidth(`${toFixedFloat(v, 2)}${u}${isNetChart ? "/s" : ""}`)
}
}
// tooltip formatter
if (isNetChart) {
obj.toolTipFormatter = (item: any, key: string) => {
try {
const sent = item?.payload?.[key]?.ns ?? 0
const received = item?.payload?.[key]?.nr ?? 0
return (
<span className="flex">
{decimalString(received)} MB/s
<span className="opacity-70 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>
)
})

View File

@@ -1,147 +0,0 @@
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
import {
ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from '@/components/ui/chart'
import { useMemo } from 'react'
import {
useYAxisWidth,
chartTimeData,
cn,
formatShortDate,
twoDecimalString,
chartMargin,
} 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
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={chartMargin}
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>
)
}

View File

@@ -1,147 +0,0 @@
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
import {
ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from '@/components/ui/chart'
import { useMemo } from 'react'
import {
useYAxisWidth,
chartTimeData,
cn,
formatShortDate,
toFixedWithoutTrailingZeros,
twoDecimalString,
chartMargin,
} 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
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={chartMargin}
>
<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>
)
}

View File

@@ -1,160 +0,0 @@
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
import {
ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from '@/components/ui/chart'
import { useMemo } from 'react'
import {
useYAxisWidth,
chartTimeData,
cn,
formatShortDate,
toFixedWithoutTrailingZeros,
twoDecimalString,
chartMargin,
} 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])
return (
<div>
{/* {!yAxisSet && <Spinner />} */}
<ChartContainer
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
'opacity-100': yAxisWidth,
})}
>
<AreaChart
accessibilityLayer
data={chartData}
margin={chartMargin}
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>
)
}

View File

@@ -1,91 +0,0 @@
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'
import {
useYAxisWidth,
chartTimeData,
cn,
formatShortDate,
twoDecimalString,
chartMargin,
} from '@/lib/utils'
// import Spinner from '../spinner'
import { useStore } from '@nanostores/react'
import { $chartTime, $cpuMax } from '@/lib/stores'
import { SystemStatsRecord } from '@/types'
import { useMemo } from 'react'
export default function CpuChart({
ticks,
systemData,
}: {
ticks: number[]
systemData: SystemStatsRecord[]
}) {
const chartTime = useStore($chartTime)
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
const showMax = useStore($cpuMax)
const dataKey = useMemo(
() => `stats.cpu${showMax && chartTime !== '1h' ? 'm' : ''}`,
[showMax, systemData]
)
return (
<div>
<ChartContainer
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
'opacity-100': yAxisWidth,
})}
>
<AreaChart
accessibilityLayer
data={systemData}
margin={chartMargin}
// 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={dataKey}
name="CPU Usage"
type="monotoneX"
fill="hsl(var(--chart-1))"
fillOpacity={0.4}
stroke="hsl(var(--chart-1))"
isAnimationActive={false}
/>
</AreaChart>
</ChartContainer>
</div>
)
}

View File

@@ -1,48 +1,48 @@
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts' import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart' import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
import { import {
useYAxisWidth, useYAxisWidth,
chartTimeData,
cn, cn,
formatShortDate, formatShortDate,
twoDecimalString, decimalString,
toFixedFloat, toFixedFloat,
getSizeVal,
getSizeUnit,
chartMargin, chartMargin,
} from '@/lib/utils' getSizeAndUnit,
// import { useMemo } from 'react' } 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' import { useLingui } from "@lingui/react"
export default function DiskChart({ export default memo(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', { className={cn("h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity", {
'opacity-100': yAxisWidth, "opacity-100": yAxisWidth,
})} })}
> >
<AreaChart accessibilityLayer data={systemData} margin={chartMargin}> <AreaChart accessibilityLayer data={chartData.systemStats} margin={chartMargin}>
<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,37 +50,28 @@ export default function DiskChart({
minTickGap={6} minTickGap={6}
tickLine={false} tickLine={false}
axisLine={false} axisLine={false}
tickFormatter={(value) => tickFormatter={(value) => {
updateYAxisWidth(toFixedFloat(getSizeVal(value), 2) + getSizeUnit(value)) const { v, u } = getSizeAndUnit(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 }) => {
twoDecimalString(getSizeVal(value)) + getSizeUnit(value) const { v, u } = getSizeAndUnit(value)
} return decimalString(v) + u
indicator="line" }}
/> />
} }
/> />
<Area <Area
dataKey={dataKey} dataKey={dataKey}
name="Disk Usage" name={_(t`Disk Usage`)}
type="monotoneX" type="monotoneX"
fill="hsl(var(--chart-4))" fill="hsl(var(--chart-4))"
fillOpacity={0.4} fillOpacity={0.4}
@@ -92,4 +83,4 @@ export default function DiskChart({
</ChartContainer> </ChartContainer>
</div> </div>
) )
} })

View File

@@ -1,46 +1,38 @@
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts' import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart' import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
import { import { useYAxisWidth, cn, toFixedFloat, decimalString, formatShortDate, chartMargin } from "@/lib/utils"
useYAxisWidth, import { memo } from "react"
chartTimeData, import { ChartData } from "@/types"
cn, import { t } from "@lingui/macro"
toFixedFloat, import { useLingui } from "@lingui/react"
twoDecimalString,
formatShortDate,
chartMargin,
} from '@/lib/utils'
import { useMemo } from 'react'
import { useStore } from '@nanostores/react'
import { $chartTime } from '@/lib/stores'
import { SystemStatsRecord } from '@/types'
export default function MemChart({ export default memo(function MemChart({ chartData }: { chartData: ChartData }) {
ticks,
systemData,
}: {
ticks: number[]
systemData: SystemStatsRecord[]
}) {
const chartTime = useStore($chartTime)
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth() const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
const { _ } = useLingui()
const totalMem = useMemo(() => { const totalMem = toFixedFloat(chartData.systemStats.at(-1)?.stats.m ?? 0, 1)
return toFixedFloat(systemData.at(-1)?.stats.m ?? 0, 1)
}, [systemData]) // console.log('rendered at', new Date())
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', { className={cn("h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity", {
'opacity-100': yAxisWidth, "opacity-100": yAxisWidth,
})} })}
> >
<AreaChart accessibilityLayer data={systemData} margin={chartMargin}> <AreaChart accessibilityLayer data={chartData.systemStats} margin={chartMargin}>
<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}
@@ -50,21 +42,11 @@ export default function MemChart({
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 {xAxis(chartData)}
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"
@@ -74,13 +56,13 @@ export default function MemChart({
// @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) => twoDecimalString(item.value) + ' GB'} contentFormatter={(item) => decimalString(item.value) + " GB"}
indicator="line" // indicator="line"
/> />
} }
/> />
<Area <Area
name="Used" name={_(t`Used`)}
order={3} order={3}
dataKey="stats.mu" dataKey="stats.mu"
type="monotoneX" type="monotoneX"
@@ -90,7 +72,7 @@ export default function MemChart({
stackId="1" stackId="1"
isAnimationActive={false} isAnimationActive={false}
/> />
{systemData.at(-1)?.stats.mz && ( {chartData.systemStats.at(-1)?.stats.mz && (
<Area <Area
name="ZFS ARC" name="ZFS ARC"
order={2} order={2}
@@ -104,7 +86,7 @@ export default function MemChart({
/> />
)} )}
<Area <Area
name="Cache / Buffers" name={_(t`Cache / Buffers`)}
order={1} order={1}
dataKey="stats.mb" dataKey="stats.mb"
type="monotoneX" type="monotoneX"
@@ -119,4 +101,4 @@ export default function MemChart({
</ChartContainer> </ChartContainer>
</div> </div>
) )
} })

View File

@@ -1,71 +1,59 @@
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts' import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart' import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
import { import {
useYAxisWidth, useYAxisWidth,
chartTimeData,
cn, cn,
formatShortDate, formatShortDate,
toFixedWithoutTrailingZeros, toFixedWithoutTrailingZeros,
twoDecimalString, decimalString,
chartMargin, chartMargin,
} from '@/lib/utils' } from "@/lib/utils"
import { useStore } from '@nanostores/react' import { ChartData } from "@/types"
import { $chartTime } from '@/lib/stores' import { memo } from "react"
import { SystemStatsRecord } from '@/types' import { t } from "@lingui/macro"
export default function SwapChart({ export default memo(function SwapChart({ chartData }: { chartData: ChartData }) {
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', { className={cn("h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity", {
'opacity-100': yAxisWidth, "opacity-100": yAxisWidth,
})} })}
> >
<AreaChart accessibilityLayer data={systemData} margin={chartMargin}> <AreaChart accessibilityLayer data={chartData.systemStats} margin={chartMargin}>
<CartesianGrid vertical={false} /> <CartesianGrid vertical={false} />
<YAxis <YAxis
direction="ltr"
orientation={chartData.orientation}
className="tracking-tighter" className="tracking-tighter"
domain={[0, () => toFixedWithoutTrailingZeros(systemData.at(-1)?.stats.s ?? 0.04, 2)]} domain={[0, () => toFixedWithoutTrailingZeros(chartData.systemStats.at(-1)?.stats.s ?? 0.04, 2)]}
width={yAxisWidth} 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) => twoDecimalString(item.value) + ' GB'} contentFormatter={(item) => decimalString(item.value) + " GB"}
indicator="line" // indicator="line"
/> />
} }
/> />
<Area <Area
dataKey="stats.su" dataKey="stats.su"
name="Swap Usage" name={t`Used`}
type="monotoneX" type="monotoneX"
fill="hsl(var(--chart-2))" fill="hsl(var(--chart-2))"
fillOpacity={0.4} fillOpacity={0.4}
@@ -76,4 +64,4 @@ export default function SwapChart({
</ChartContainer> </ChartContainer>
</div> </div>
) )
} })

View File

@@ -1,4 +1,4 @@
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from 'recharts' import { CartesianGrid, Line, LineChart, YAxis } from "recharts"
import { import {
ChartContainer, ChartContainer,
@@ -6,39 +6,34 @@ import {
ChartLegendContent, ChartLegendContent,
ChartTooltip, ChartTooltip,
ChartTooltipContent, ChartTooltipContent,
} from '@/components/ui/chart' xAxis,
} from "@/components/ui/chart"
import { import {
useYAxisWidth, useYAxisWidth,
chartTimeData,
cn, cn,
formatShortDate, formatShortDate,
toFixedWithoutTrailingZeros, toFixedWithoutTrailingZeros,
twoDecimalString, decimalString,
chartMargin, 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 function TemperatureChart({ export default memo(function TemperatureChart({ chartData }: { chartData: ChartData }) {
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 chartData = { data: [], colors: {} } as { const newChartData = { 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 systemData) { for (let data of chartData.systemStats) {
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++) {
@@ -46,49 +41,42 @@ export default function TemperatureChart({
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]
} }
chartData.data.push(newData) newChartData.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) {
chartData.colors[key] = `hsl(${((keys.indexOf(key) * 360) / keys.length) % 360}, 60%, 55%)` newChartData.colors[key] = `hsl(${((keys.indexOf(key) * 360) / keys.length) % 360}, 60%, 55%)`
} }
return chartData return newChartData
}, [systemData]) }, [chartData])
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', { className={cn("h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity", {
'opacity-100': yAxisWidth, "opacity-100": yAxisWidth,
})} })}
> >
<LineChart accessibilityLayer data={newChartData.data} margin={chartMargin}> <LineChart accessibilityLayer data={newChartData.data} margin={chartMargin}>
<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 {xAxis(chartData)}
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}
@@ -97,8 +85,8 @@ export default function TemperatureChart({
content={ content={
<ChartTooltipContent <ChartTooltipContent
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)} labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={(item) => twoDecimalString(item.value) + ' °C'} contentFormatter={(item) => decimalString(item.value) + " °C"}
indicator="line" // indicator="line"
/> />
} }
/> />
@@ -119,4 +107,4 @@ export default function TemperatureChart({
</ChartContainer> </ChartContainer>
</div> </div>
) )
} })

View File

@@ -8,7 +8,7 @@ import {
Server, Server,
SettingsIcon, SettingsIcon,
UsersIcon, UsersIcon,
} from 'lucide-react' } from "lucide-react"
import { import {
CommandDialog, CommandDialog,
@@ -19,34 +19,36 @@ import {
CommandList, CommandList,
CommandSeparator, CommandSeparator,
CommandShortcut, CommandShortcut,
} from '@/components/ui/command' } from "@/components/ui/command"
import { useEffect, useState } from 'react' import { useEffect } 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() { export default function CommandPalette({ open, setOpen }: { open: boolean; setOpen: (open: boolean) => void }) {
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) => !open) setOpen(!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="Search for systems or settings..." /> <CommandInput placeholder={t`Search for systems or settings...`} />
<CommandList> <CommandList>
<CommandEmpty>No results found.</CommandEmpty> <CommandEmpty>
<Trans>No results found.</Trans>
</CommandEmpty>
{systems.length > 0 && ( {systems.length > 0 && (
<> <>
<CommandGroup> <CommandGroup>
@@ -58,7 +60,7 @@ export default function CommandPalette() {
setOpen(false) setOpen(false)
}} }}
> >
<Server className="mr-2 h-4 w-4" /> <Server className="me-2 h-4 w-4" />
<span>{system.name}</span> <span>{system.name}</span>
<CommandShortcut>{system.host}</CommandShortcut> <CommandShortcut>{system.host}</CommandShortcut>
</CommandItem> </CommandItem>
@@ -67,106 +69,140 @@ export default function CommandPalette() {
<CommandSeparator className="mb-1.5" /> <CommandSeparator className="mb-1.5" />
</> </>
)} )}
<CommandGroup heading="Pages / Settings"> <CommandGroup heading={t`Pages / Settings`}>
<CommandItem <CommandItem
keywords={['home']} keywords={["home"]}
onSelect={() => { onSelect={() => {
navigate('/') navigate("/")
setOpen((open) => !open) setOpen(false)
}} }}
> >
<LayoutDashboard className="mr-2 h-4 w-4" /> <LayoutDashboard className="me-2 h-4 w-4" />
<span>Dashboard</span> <span>
<CommandShortcut>Page</CommandShortcut> <Trans>Dashboard</Trans>
</span>
<CommandShortcut>
<Trans>Page</Trans>
</CommandShortcut>
</CommandItem> </CommandItem>
<CommandItem <CommandItem
onSelect={() => { onSelect={() => {
navigate('/settings/general') navigate("/settings/general")
setOpen((open) => !open) setOpen(false)
}} }}
> >
<SettingsIcon className="mr-2 h-4 w-4" /> <SettingsIcon className="me-2 h-4 w-4" />
<span>Settings</span> <span>
<CommandShortcut>Settings</CommandShortcut> <Trans>Settings</Trans>
</span>
<CommandShortcut>
<Trans>Settings</Trans>
</CommandShortcut>
</CommandItem> </CommandItem>
<CommandItem <CommandItem
keywords={['alerts']} keywords={["alerts"]}
onSelect={() => { onSelect={() => {
navigate('/settings/notifications') navigate("/settings/notifications")
setOpen((open) => !open) setOpen(false)
}} }}
> >
<MailIcon className="mr-2 h-4 w-4" /> <MailIcon className="me-2 h-4 w-4" />
<span>Notification settings</span> <span>
<CommandShortcut>Settings</CommandShortcut> <Trans>Notifications</Trans>
</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="mr-2 h-4 w-4" /> <Github className="me-2 h-4 w-4" />
<span>Documentation</span> <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="Admin"> <CommandGroup heading={t`Admin`}>
<CommandItem <CommandItem
keywords={['pocketbase']} keywords={["pocketbase"]}
onSelect={() => { onSelect={() => {
setOpen(false) setOpen(false)
window.open('/_/', '_blank') window.open("/_/", "_blank")
}} }}
> >
<UsersIcon className="mr-2 h-4 w-4" /> <UsersIcon className="me-2 h-4 w-4" />
<span>Users</span> <span>
<CommandShortcut>Admin</CommandShortcut> <Trans>Users</Trans>
</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="mr-2 h-4 w-4" /> <LogsIcon className="me-2 h-4 w-4" />
<span>Logs</span> <span>
<CommandShortcut>Admin</CommandShortcut> <Trans>Logs</Trans>
</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="mr-2 h-4 w-4" /> <DatabaseBackupIcon className="me-2 h-4 w-4" />
<span>Database backups</span> <span>
<CommandShortcut>Admin</CommandShortcut> <Trans>Backups</Trans>
</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="mr-2 h-4 w-4" /> <LockKeyholeIcon className="me-2 h-4 w-4" />
<span>Auth Providers</span> <span>
<CommandShortcut>Admin</CommandShortcut> <Trans>Auth Providers</Trans>
</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="mr-2 h-4 w-4" /> <MailIcon className="me-2 h-4 w-4" />
<span>SMTP settings</span> <span>
<CommandShortcut>Admin</CommandShortcut> <Trans>SMTP settings</Trans>
</span>
<CommandShortcut>
<Trans>Admin</Trans>
</CommandShortcut>
</CommandItem> </CommandItem>
</CommandGroup> </CommandGroup>
</> </>

View File

@@ -1,20 +1,22 @@
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" style={{ maxWidth: 530 }}> <DialogContent className="w-[90%] rounded-lg md:pt-4" style={{ maxWidth: 530 }}>
<DialogHeader> <DialogHeader>
<DialogTitle>Could not copy to clipboard</DialogTitle> <DialogTitle>
<DialogDescription>Please copy the text manually.</DialogDescription> <Trans>Copy text</Trans>
</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>
) )
@@ -24,7 +26,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(() => {
@@ -34,7 +36,7 @@ function CopyTextarea({ content }: { content: string }) {
}, [textareaRef]) }, [textareaRef])
useEffect(() => { useEffect(() => {
return () => $copyContent.set('') return () => $copyContent.set("")
}, []) }, [])
return ( return (

View File

@@ -0,0 +1,34 @@
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>
)
}

View File

@@ -1,28 +1,20 @@
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 { import { Dialog, DialogContent, DialogTrigger, DialogHeader, DialogTitle } from "@/components/ui/dialog"
Dialog, import { useCallback, useState } from "react"
DialogContent, import { AuthMethodsList, OAuth2AuthConfig } from "pocketbase"
DialogTrigger, import { Link } from "../router"
DialogHeader, import { Trans, t } from "@lingui/macro"
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('Invalid email address.')) const emailSchema = v.pipe(v.string(), v.email(t`Invalid email address.`))
const passwordSchema = v.pipe( const passwordSchema = v.pipe(v.string(), v.minLength(10, t`Password must be at least 10 characters.`))
v.string(),
v.minLength(10, 'Password must be at least 10 characters.')
)
const LoginSchema = v.looseObject({ const LoginSchema = v.looseObject({
name: honeypot, name: honeypot,
@@ -36,9 +28,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,
@@ -47,9 +39,9 @@ const RegisterSchema = v.looseObject({
const showLoginFaliedToast = () => { const showLoginFaliedToast = () => {
toast({ toast({
title: 'Login attempt failed', title: t`Login attempt failed`,
description: 'Please check your credentials and try again', description: t`Please check your credentials and try again`,
variant: 'destructive', variant: "destructive",
}) })
} }
@@ -90,7 +82,7 @@ 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
} }
@@ -100,17 +92,17 @@ export function UserAuthForm({
passwordConfirm: password, passwordConfirm: password,
}) })
await pb.admins.authWithPassword(email, password) await pb.admins.authWithPassword(email, password)
await pb.collection('users').create({ 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) 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) {
@@ -127,7 +119,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({})}>
@@ -136,59 +128,57 @@ 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">
Username <Trans>Username</Trans>
</Label> </Label>
<Input <Input
autoFocus={true} autoFocus={true}
id="username" id="username"
name="username" name="username"
required required
placeholder="username" placeholder={t`username`}
type="username" type="username"
autoCapitalize="none" autoCapitalize="none"
autoComplete="username" autoComplete="username"
autoCorrect="off" autoCorrect="off"
disabled={isLoading || isOauthLoading} disabled={isLoading || isOauthLoading}
className="pl-9" className="ps-9"
/> />
{errors?.username && ( {errors?.username && <p className="px-1 text-xs text-red-600">{errors.username}</p>}
<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">
Email <Trans>Email</Trans>
</Label> </Label>
<Input <Input
id="email" id="email"
name="email" name="email"
required required
placeholder={isFirstRun ? 'email' : 'name@example.com'} placeholder={isFirstRun ? t`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="pl-9" className="ps-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">
Password <Trans>Password</Trans>
</Label> </Label>
<Input <Input
id="pass" id="pass"
name="password" name="password"
placeholder="password" placeholder={t`Password`}
required required
type="password" type="password"
autoComplete="current-password" autoComplete="current-password"
disabled={isLoading || isOauthLoading} disabled={isLoading || isOauthLoading}
className="pl-9" className="ps-9 lowercase"
/> />
{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>
@@ -196,21 +186,19 @@ 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">
Confirm password <Trans>Confirm password</Trans>
</Label> </Label>
<Input <Input
id="pass2" id="pass2"
name="passwordConfirm" name="passwordConfirm"
placeholder="confirm password" placeholder={t`Confirm password`}
required required
type="password" type="password"
autoComplete="current-password" autoComplete="current-password"
disabled={isLoading || isOauthLoading} disabled={isLoading || isOauthLoading}
className="pl-9" className="ps-9 lowercase"
/> />
{errors?.passwordConfirm && ( {errors?.passwordConfirm && <p className="px-1 text-xs text-red-600">{errors.passwordConfirm}</p>}
<p className="px-1 text-xs text-red-600">{errors.passwordConfirm}</p>
)}
</div> </div>
)} )}
<div className="sr-only"> <div className="sr-only">
@@ -220,11 +208,11 @@ export function UserAuthForm({
</div> </div>
<button className={cn(buttonVariants())} disabled={isLoading}> <button className={cn(buttonVariants())} disabled={isLoading}>
{isLoading ? ( {isLoading ? (
<LoaderCircle className="mr-2 h-4 w-4 animate-spin" /> <LoaderCircle className="me-2 h-4 w-4 animate-spin" />
) : ( ) : (
<LogInIcon className="mr-2 h-4 w-4" /> <LogInIcon className="me-2 h-4 w-4" />
)} )}
{isFirstRun ? 'Create account' : 'Sign in'} {isFirstRun ? t`Create account` : t`Sign in`}
</button> </button>
</div> </div>
</form> </form>
@@ -235,7 +223,9 @@ 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">Or continue with</span> <span className="bg-background px-2 text-muted-foreground">
<Trans>Or continue with</Trans>
</span>
</div> </div>
</div> </div>
)} )}
@@ -248,9 +238,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)
@@ -263,9 +253,9 @@ export function UserAuthForm({
if (!authWindow) { if (!authWindow) {
setIsOauthLoading(false) setIsOauthLoading(false)
toast({ toast({
title: 'Error', title: t`Error`,
description: 'Please enable pop-ups for this site', description: t`Please enable pop-ups for this site`,
variant: 'destructive', variant: "destructive",
}) })
return return
} }
@@ -273,7 +263,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)
@@ -286,14 +276,14 @@ export function UserAuthForm({
disabled={isLoading || isOauthLoading} disabled={isLoading || isOauthLoading}
> >
{isOauthLoading ? ( {isOauthLoading ? (
<LoaderCircle className="mr-2 h-4 w-4 animate-spin" /> <LoaderCircle className="me-2 h-4 w-4 animate-spin" />
) : ( ) : (
<img <img
className="mr-2 h-4 w-4 dark:invert" className="me-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"
}} }}
/> />
)} )}
@@ -307,26 +297,32 @@ 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="mr-2 h-4 w-4 dark:invert" src="/static/github.svg" alt="" /> <img className="me-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>OAuth 2 / OIDC support</DialogTitle> <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>
Please view the{' '} <Trans>Beszel supports OpenID Connect and many OAuth2 authentication providers.</Trans>
<a </p>
href="https://github.com/henrygd/beszel/blob/main/readme.md#oauth--oidc-integration" <p>
className={cn(buttonVariants({ variant: 'link' }), 'p-0 h-auto')} <Trans>
> Please see{" "}
GitHub README <a
</a>{' '} href="https://github.com/henrygd/beszel/blob/main/readme.md#oauth--oidc-integration"
for instructions. className={cn(buttonVariants({ variant: "link" }), "p-0 h-auto")}
>
the documentation
</a>{" "}
for instructions.
</Trans>
</p> </p>
</div> </div>
</DialogContent> </DialogContent>
@@ -338,7 +334,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"
> >
Forgot password? <Trans>Forgot password?</Trans>
</Link> </Link>
)} )}
</div> </div>

View File

@@ -1,25 +1,26 @@
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: 'Login attempt failed', title: t`Login attempt failed`,
description: 'Please check your credentials and try again', description: t`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>) => {
@@ -27,16 +28,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: 'Password reset request received', title: t`Password reset request received`,
description: `Check ${email} for a reset link.`, description: t`Check ${email} for a reset link.`,
}) })
} catch (e) { } catch (e) {
showLoginFaliedToast() showLoginFaliedToast()
} finally { } finally {
setIsLoading(false) setIsLoading(false)
setEmail('') setEmail("")
} }
}, },
[email] [email]
@@ -49,7 +50,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">
Email <Trans>Email</Trans>
</Label> </Label>
<Input <Input
value={email} value={email}
@@ -63,37 +64,40 @@ export default function ForgotPassword() {
autoComplete="email" autoComplete="email"
autoCorrect="off" autoCorrect="off"
disabled={isLoading} disabled={isLoading}
className="pl-9" className="ps-9"
/> />
</div> </div>
<button className={cn(buttonVariants())} disabled={isLoading}> <button className={cn(buttonVariants())} disabled={isLoading}>
{isLoading ? ( {isLoading ? (
<LoaderCircle className="mr-2 h-4 w-4 animate-spin" /> <LoaderCircle className="me-2 h-4 w-4 animate-spin" />
) : ( ) : (
<SendHorizonalIcon className="mr-2 h-4 w-4" /> <SendHorizonalIcon className="me-2 h-4 w-4" />
)} )}
Reset password <Trans>Reset Password</Trans>
</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">
Command line instructions <Trans>Command line instructions</Trans>
</button> </button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="max-w-[33em]"> <DialogContent className="max-w-[33em]">
<DialogHeader> <DialogHeader>
<DialogTitle>Command line instructions</DialogTitle> <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">
If you've lost the password to your admin account, you may reset it using the following <Trans>
command. If you've lost the password to your admin account, you may reset it using the following command.
</Trans>
</p> </p>
<p className="text-primary/70 text-[0.95em] leading-relaxed"> <p className="text-primary/70 text-[0.95em] leading-relaxed">
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.</Trans>
</p> </p>
<code className="bg-muted rounded-sm py-0.5 px-2.5 mr-auto text-sm"> <code className="bg-muted rounded-sm py-0.5 px-2.5 me-auto text-sm">
beszel admin update youremail@example.com newpassword beszel admin update youremail@example.com newpassword
</code> </code>
</DialogContent> </DialogContent>

View File

@@ -1,11 +1,12 @@
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)
@@ -13,15 +14,15 @@ export default function () {
const [authMethods, setAuthMethods] = useState<AuthMethodsList>() const [authMethods, setAuthMethods] = useState<AuthMethodsList>()
useEffect(() => { useEffect(() => {
document.title = 'Login / Beszel' document.title = t`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)
@@ -30,11 +31,11 @@ export default function () {
const subtitle = useMemo(() => { const subtitle = useMemo(() => {
if (isFirstRun) { if (isFirstRun) {
return 'Please create an admin account' return t`Please create an admin account`
} else if (page?.path === '/forgot-password') { } else if (page?.path === "/forgot-password") {
return 'Enter email address to reset password' return t`Enter email address to reset password`
} else { } else {
return 'Please sign in to your account' return t`Please sign in to your account`
} }
}, [isFirstRun, page]) }, [isFirstRun, page])
@@ -43,8 +44,8 @@ export default function () {
} }
return ( return (
<div className="min-h-screen grid items-center py-12"> <div className="min-h-svh grid items-center py-12">
<div className="grid gap-5 w-full px-4 mx-auto" style={{ maxWidth: '22em' }}> <div className="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" />
@@ -52,7 +53,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} />

View File

@@ -2,7 +2,16 @@ 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}>
<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" /> {/* <defs>
<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>
) )
} }

View File

@@ -1,39 +1,54 @@
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 { import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
DropdownMenu, import { useTheme } from "@/components/theme-provider"
DropdownMenuContent, import { cn } from "@/lib/utils"
DropdownMenuItem, import { t, Trans } from "@lingui/macro"
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { useTheme } from '@/components/theme-provider'
export function ModeToggle() { export function ModeToggle() {
const { setTheme } = useTheme() const { theme, 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"> <Button variant={"ghost"} size="icon" aria-label={t`Toggle theme`}>
<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>
<DropdownMenuItem onClick={() => setTheme('light')}> {options.map((opt) => {
<SunIcon className="mr-2.5 h-4 w-4" /> const selected = opt.theme === theme
Light return (
</DropdownMenuItem> <DropdownMenuItem
<DropdownMenuItem onClick={() => setTheme('dark')}> key={opt.theme}
<MoonStarIcon className="mr-2.5 h-4 w-4" /> className={cn("px-2.5", selected ? "font-semibold" : "")}
Dark onClick={() => setTheme(opt.theme as "dark" | "light" | "system")}
</DropdownMenuItem> >
<DropdownMenuItem onClick={() => setTheme('system')}> <opt.Icon className={cn("me-2 h-4 w-4 opacity-80", selected && "opacity-100")} />
<LaptopIcon className="mr-2.5 h-4 w-4" /> {opt.label}
System </DropdownMenuItem>
</DropdownMenuItem> )
})}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
) )

View File

@@ -0,0 +1,154 @@
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>
</>
)
}

View File

@@ -1,10 +1,11 @@
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 }
) )

View File

@@ -1,68 +1,107 @@
import { Suspense, lazy, useEffect, useState } from 'react' import { Suspense, lazy, useEffect, useMemo } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card' import { Card, CardContent, 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 { updateRecordList, updateSystemList } from '@/lib/utils' import { alertInfo, updateRecordList, updateSystemList } from "@/lib/utils"
import { AlertRecord, SystemRecord } from '@/types' import { AlertRecord, SystemRecord } from "@/types"
import { Input } from '../ui/input' import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
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 () { export default function Home() {
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 = 'Dashboard / Beszel' document.title = t`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)
}) })
pb.collection<AlertRecord>('alerts').subscribe('*', (e) => { // todo: add toast if new triggered alert comes in
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 (
<> <>
<Card> {/* show active alerts */}
<CardHeader className="pb-5 px-2 sm:px-6 max-sm:pt-5 max-sm:pb-1"> {activeAlerts.length > 0 && (
<div className="grid md:flex gap-3 w-full items-end"> <Card className="mb-4">
<CardHeader className="pb-4 px-2 sm:px-6 max-sm:pt-5 max-sm:pb-1">
<div className="px-2 sm:px-1"> <div className="px-2 sm:px-1">
<CardTitle className="mb-2.5">All Systems</CardTitle> <CardTitle>
<CardDescription> <Trans>Active Alerts</Trans>
Updated in real time. Press{' '} </CardTitle>
<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>
<Input </CardHeader>
placeholder="Filter..." <CardContent className="max-sm:p-2">
onChange={(e) => setFilter(e.target.value)} {activeAlerts.length > 0 && (
className="w-full md:w-56 lg:w-80 ml-auto px-4" <div className="grid sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-3">
/> {activeAlerts.map((alert) => {
</div> const info = alertInfo[alert.name as keyof typeof alertInfo]
</CardHeader> return (
<CardContent className="max-sm:p-2"> <Alert
<Suspense> key={alert.id}
<SystemsTable filter={filter} /> className="hover:-translate-y-[1px] duration-200 bg-transparent border-foreground/10 hover:shadow-md shadow-black"
</Suspense> >
</CardContent> <info.icon className="h-4 w-4" />
</Card> <AlertTitle>
{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 pr-3 sm:pr-6 mt-3.5 text-xs opacity-80"> <div className="flex gap-1.5 justify-end items-center pe-3 sm:pe-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"

View File

@@ -0,0 +1,97 @@
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>
)
}

View File

@@ -1,22 +1,21 @@
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 { import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
Select, import { chartTimeData } from "@/lib/utils"
SelectContent, import { Separator } from "@/components/ui/separator"
SelectItem, import { LanguagesIcon, LoaderCircleIcon, SaveIcon } from "lucide-react"
SelectTrigger, import { UserSettings } from "@/types"
SelectValue, import { saveSettings } from "./layout"
} from '@/components/ui/select' import { useState } from "react"
import { chartTimeData } from '@/lib/utils' import { Trans } from "@lingui/macro"
import { Separator } from '@/components/ui/separator' import languages from "../../../lib/languages.json"
import { LoaderCircleIcon, SaveIcon } from 'lucide-react' import { dynamicActivate } from "@/lib/i18n"
import { UserSettings } from '@/types' import { useLingui } from "@lingui/react"
import { saveSettings } from './layout' // import { setLang } from "@/lib/i18n"
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()
@@ -30,79 +29,81 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
return ( return (
<div> <div>
<div> <div>
<h3 className="text-xl font-medium mb-2">General</h3> <h3 className="text-xl font-medium mb-2">
<Trans>General</Trans>
</h3>
<p className="text-sm text-muted-foreground leading-relaxed"> <p className="text-sm text-muted-foreground leading-relaxed">
Change general application options. <Trans>Change general application options.</Trans>
</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">Language</h3> <h3 className="mb-1 text-lg font-medium flex items-center gap-2">
<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">
Internationalization will be added in a future release. Please see the{' '} <Trans>
<a href="#" className="link" target="_blank"> Want to help us make our translations even better? Check out{" "}
discussion on GitHub <a href="https://crowdin.com/project/beszel" className="link" target="_blank" rel="noopener noreferrer">
</a>{' '} Crowdin
for more details. </a>{" "}
for more details.
</Trans>
</p> </p>
</div> </div>
<Label className="block" htmlFor="lang"> <Label className="block" htmlFor="lang">
Preferred language <Trans>Preferred Language</Trans>
</Label> </Label>
<Select defaultValue="en"> <Select value={i18n.locale} onValueChange={(lang: string) => dynamicActivate(lang)}>
<SelectTrigger id="lang"> <SelectTrigger id="lang">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="en">English</SelectItem> {languages.map((lang) => (
<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">Chart options</h3> <h3 className="mb-1 text-lg font-medium">
<Trans>Chart options</Trans>
</h3>
<p className="text-sm text-muted-foreground leading-relaxed"> <p className="text-sm text-muted-foreground leading-relaxed">
Adjust display options for charts. <Trans>Adjust display options for charts.</Trans>
</p> </p>
</div> </div>
<Label className="block" htmlFor="chartTime"> <Label className="block" htmlFor="chartTime">
Default time period <Trans>Default time period</Trans>
</Label> </Label>
<Select <Select name="chartTime" key={userSettings.chartTime} defaultValue={userSettings.chartTime}>
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={label} value={value}> <SelectItem key={value} 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">
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.</Trans>
</p> </p>
</div> </div>
<Separator /> <Separator />
<Button <Button type="submit" className="flex items-center gap-1.5 disabled:opacity-100" disabled={isLoading}>
type="submit" {isLoading ? <LoaderCircleIcon className="h-4 w-4 animate-spin" /> : <SaveIcon className="h-4 w-4" />}
className="flex items-center gap-1.5 disabled:opacity-100" <Trans>Save Settings</Trans>
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>

View File

@@ -1,38 +1,28 @@
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, SettingsIcon } from 'lucide-react' import { BellIcon, FileSlidersIcon, 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"
const sidebarNavItems = [ import { Trans, t } from "@lingui/macro"
{ 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,
@@ -40,35 +30,60 @@ export async function saveSettings(newSettings: Partial<UserSettings>) {
}) })
$userSettings.set(updatedSettings.settings) $userSettings.set(updatedSettings.settings)
toast({ toast({
title: 'Settings saved', title: t`Settings saved`,
description: 'Your user settings have been updated.', description: t`Your user settings have been updated.`,
}) })
} catch (e) { } catch (e) {
// console.error('update settings', e) // console.error('update settings', e)
toast({ toast({
title: 'Failed to save settings', title: t`Failed to save settings`,
description: 'Check logs for more details.', description: t`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 = 'Settings / Beszel' document.title = t`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">Settings</CardTitle> <CardTitle className="mb-1">
<CardDescription>Manage display and notification preferences.</CardDescription> <Trans>Settings</Trans>
</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" />
@@ -78,7 +93,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>
@@ -90,9 +105,11 @@ 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 />
} }
} }

View File

@@ -1,17 +1,18 @@
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
@@ -36,10 +37,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()
}) })
} }
@@ -58,9 +59,9 @@ const SettingsNotificationsPage = ({ userSettings }: { userSettings: UserSetting
await saveSettings(parsedData) await saveSettings(parsedData)
} catch (e: any) { } catch (e: any) {
toast({ toast({
title: 'Failed to save settings', title: t`Failed to save settings`,
description: e.message, description: e.message,
variant: 'destructive', variant: "destructive",
}) })
} }
setIsLoading(false) setIsLoading(false)
@@ -69,59 +70,67 @@ const SettingsNotificationsPage = ({ userSettings }: { userSettings: UserSetting
return ( return (
<div> <div>
<div> <div>
<h3 className="text-xl font-medium mb-2">Notifications</h3> <h3 className="text-xl font-medium mb-2">
<Trans>Notifications</Trans>
</h3>
<p className="text-sm text-muted-foreground leading-relaxed"> <p className="text-sm text-muted-foreground leading-relaxed">
Configure how you receive alert notifications. <Trans>Configure how you receive alert notifications.</Trans>
</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">
Looking instead for where to create alerts? Click the bell{' '} <Trans>
<BellIcon className="inline h-4 w-4" /> icons in the systems table. Looking instead for where to create alerts? Click the bell <BellIcon className="inline h-4 w-4" /> icons in
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">Email notifications</h3> <h3 className="mb-1 text-lg font-medium">
<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">
Please{' '} <Trans>
<a href="/_/#/settings/mail" className="link" target="_blank"> Please{" "}
configure an SMTP server <a href="/_/#/settings/mail" className="link" target="_blank">
</a>{' '} configure an SMTP server
to ensure alerts are delivered.{' '} </a>{" "}
to ensure alerts are delivered.
</Trans>
</p> </p>
)} )}
</div> </div>
<Label className="block" htmlFor="email"> <Label className="block" htmlFor="email">
To email(s) <Trans>To email(s)</Trans>
</Label> </Label>
<InputTags <InputTags
value={emails} value={emails}
onChange={setEmails} onChange={setEmails}
placeholder="Enter email address..." placeholder={t`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">
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.</Trans>
</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">Webhook / Push notifications</h3> <h3 className="mb-1 text-lg font-medium">
<Trans>Webhook / Push notifications</Trans>
</h3>
<p className="text-sm text-muted-foreground leading-relaxed"> <p className="text-sm text-muted-foreground leading-relaxed">
Beszel uses{' '} <Trans>
<a Beszel uses{" "}
href="https://containrrr.dev/shoutrrr/services/overview/" <a href="https://containrrr.dev/shoutrrr/services/overview/" target="_blank" className="link">
target="_blank" Shoutrrr
className="link" </a>{" "}
> to integrate with popular notification services.
Shoutrrr </Trans>
</a>{' '}
to integrate with popular notification services.
</p> </p>
</div> </div>
{webhooks.length > 0 && ( {webhooks.length > 0 && (
@@ -130,9 +139,7 @@ const SettingsNotificationsPage = ({ userSettings }: { userSettings: UserSetting
<ShoutrrrUrlCard <ShoutrrrUrlCard
key={index} key={index}
url={webhook} url={webhook}
onUrlChange={(e: React.ChangeEvent<HTMLInputElement>) => onUrlChange={(e: React.ChangeEvent<HTMLInputElement>) => updateWebhook(index, e.target.value)}
updateWebhook(index, e.target.value)
}
onRemove={() => removeWebhook(index)} onRemove={() => removeWebhook(index)}
/> />
))} ))}
@@ -145,8 +152,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 -ml-0.5" /> <PlusIcon className="h-4 w-4 -ms-0.5" />
Add URL <Trans>Add URL</Trans>
</Button> </Button>
</div> </div>
<Separator /> <Separator />
@@ -156,12 +163,8 @@ const SettingsNotificationsPage = ({ userSettings }: { userSettings: UserSetting
onClick={updateSettings} onClick={updateSettings}
disabled={isLoading} disabled={isLoading}
> >
{isLoading ? ( {isLoading ? <LoaderCircleIcon className="h-4 w-4 animate-spin" /> : <SaveIcon className="h-4 w-4" />}
<LoaderCircleIcon className="h-4 w-4 animate-spin" /> <Trans>Save Settings</Trans>
) : (
<SaveIcon className="h-4 w-4" />
)}
Save settings
</Button> </Button>
</div> </div>
</div> </div>
@@ -173,17 +176,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: 'Test notification sent', title: t`Test notification sent`,
description: 'Check your notification service', description: t`Check your notification service`,
}) })
} else { } else {
toast({ toast({
title: 'Error', title: t`Error`,
description: res.err ?? 'Failed to send test notification', description: res.err ?? t`Failed to send test notification`,
variant: 'destructive', variant: "destructive",
}) })
} }
setIsLoading(false) setIsLoading(false)
@@ -200,29 +203,18 @@ const ShoutrrrUrlCard = ({ url, onUrlChange, onRemove }: ShoutrrrUrlCardProps) =
value={url} value={url}
onChange={onUrlChange} onChange={onUrlChange}
/> />
<Button <Button type="button" variant="outline" disabled={isLoading || url === ""} onClick={sendTestNotification}>
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>
Test <span className="hidden md:inline">URL</span> <Trans>
Test <span className="hidden sm:inline">URL</span>
</Trans>
</span> </span>
)} )}
</Button> </Button>
<Button <Button type="button" variant="outline" size="icon" className="shrink-0" aria-label="Delete" onClick={onRemove}>
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>

View File

@@ -1,22 +1,17 @@
import React from 'react' import React from "react"
import { cn } from '@/lib/utils' import { cn, isAdmin } 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 { import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
Select, import { Separator } from "@/components/ui/separator"
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
}[] }[]
} }
@@ -29,33 +24,36 @@ 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 a page" /> <SelectValue placeholder="Select page" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{items.map((item) => ( {items.map((item) => {
<SelectItem key={item.href} value={item.href}> if (item.admin && !isAdmin()) return null
<span className="flex items-center gap-2"> return (
{item.icon && <item.icon className="h-4 w-4" />} <SelectItem key={item.href} value={item.href}>
{item.title} <span className="flex items-center gap-2">
</span> {item.icon && <item.icon className="h-4 w-4" />}
</SelectItem> {item.title}
))} </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" />}

View File

@@ -1,78 +1,132 @@
import { $systems, pb, $chartTime, $containerFilter, $userSettings } from '@/lib/stores' import { $systems, pb, $chartTime, $containerFilter, $userSettings, $direction } from "@/lib/stores"
import { ContainerStatsRecord, SystemRecord, SystemStatsRecord } from '@/types' import { ChartData, ChartTimes, ContainerStatsRecord, SystemRecord, SystemStatsRecord } from "@/types"
import React, { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react' import React, { lazy, useCallback, useEffect, useMemo, useRef, useState } from "react"
import { Card, CardHeader, CardTitle, CardDescription } from '../ui/card' import { Card, CardHeader, CardTitle, CardDescription } 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 { scaleTime } from 'd3-scale' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip' import { Button } from "../ui/button"
import { Button, buttonVariants } from '../ui/button' import { Input } from "../ui/input"
import { Input } from '../ui/input' import { ChartAverage, ChartMax, Rows, TuxIcon } from "../ui/icons"
import { ChartAverage, ChartMax, Rows, TuxIcon } from '../ui/icons' import { useIntersectionObserver } from "@/lib/use-intersection-observer"
import { useIntersectionObserver } from '@/lib/use-intersection-observer' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' import { timeTicks } from "d3-time"
import { Plural, Trans, t } from "@lingui/macro"
import { useLingui } from "@lingui/react"
const ContainerCpuChart = lazy(() => import('../charts/container-cpu-chart')) const AreaChartDefault = lazy(() => import("../charts/area-chart"))
const MemChart = lazy(() => import('../charts/mem-chart')) const ContainerChart = lazy(() => import("../charts/container-chart"))
const ContainerMemChart = lazy(() => import('../charts/container-mem-chart')) const MemChart = lazy(() => import("../charts/mem-chart"))
const DiskChart = lazy(() => import('../charts/disk-chart')) const DiskChart = lazy(() => import("../charts/disk-chart"))
const AreaChartDefault = lazy(() => import('../charts/area-chart')) const SwapChart = lazy(() => import("../charts/swap-chart"))
const ContainerNetChart = lazy(() => import('../charts/container-net-chart')) const TemperatureChart = lazy(() => import("../charts/temperature-chart"))
const SwapChart = lazy(() => import('../charts/swap-chart'))
const TemperatureChart = lazy(() => import('../charts/temperature-chart'))
const cache = new Map<string, SystemStatsRecord[] | ContainerStatsRecord[]>() const cache = new Map<string, any>()
// create ticks and domain for charts
function getTimeData(chartTime: ChartTimes, lastCreated: number) {
const cached = cache.get("td")
if (cached && cached.chartTime === chartTime) {
if (!lastCreated || cached.time >= lastCreated) {
return cached.data
}
}
const now = new Date()
const startTime = chartTimeData[chartTime].getOffset(now)
const ticks = timeTicks(startTime, now, chartTimeData[chartTime].ticks ?? 12).map((date) => date.getTime())
const data = {
ticks,
domain: [chartTimeData[chartTime].getOffset(now).getTime(), now.getTime()],
}
cache.set("td", { time: now.getTime(), data, chartTime })
return data
}
// add empty values between records to make gaps if interval is too large
function addEmptyValues<T extends SystemStatsRecord | ContainerStatsRecord>(
prevRecords: T[],
newRecords: T[],
expectedInterval: number
) {
const modifiedRecords: T[] = []
let prevTime = (prevRecords.at(-1)?.created ?? 0) as number
for (let i = 0; i < newRecords.length; i++) {
const record = newRecords[i]
record.created = new Date(record.created).getTime()
if (prevTime) {
const interval = record.created - prevTime
// if interval is too large, add a null record
if (interval > expectedInterval / 2 + expectedInterval) {
// @ts-ignore
modifiedRecords.push({ created: null, stats: null })
}
}
prevTime = record.created
modifiedRecords.push(record)
}
return modifiedRecords
}
async function getStats<T>(collection: string, system: SystemRecord, chartTime: ChartTimes): Promise<T[]> {
const lastCached = cache.get(`${system.id}_${chartTime}_${collection}`)?.at(-1)?.created as number
return await pb.collection<T>(collection).getFullList({
filter: pb.filter("system={:id} && created > {:created} && type={:type}", {
id: system.id,
created: getPbTimestamp(chartTime, lastCached ? new Date(lastCached + 1000) : undefined),
type: chartTimeData[chartTime].type,
}),
fields: "created,stats",
sort: "created",
})
}
export default function SystemDetail({ name }: { name: string }) { 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 */ /** Max CPU toggle value */
const cpuMaxStore = useState(false) const cpuMaxStore = useState(false)
const bandwidthMaxStore = useState(false) const bandwidthMaxStore = useState(false)
const diskIoMaxStore = useState(false) const diskIoMaxStore = useState(false)
const [grid, setGrid] = useLocalStorage('grid', true) const [grid, setGrid] = useLocalStorage("grid", true)
const [ticks, setTicks] = useState([] as number[])
const [system, setSystem] = useState({} as SystemRecord) const [system, setSystem] = useState({} as SystemRecord)
const [systemStats, setSystemStats] = useState([] as SystemStatsRecord[]) const [systemStats, setSystemStats] = useState([] as SystemStatsRecord[])
const [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 [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 isLongerChart = chartTime !== '1h'
useEffect(() => { useEffect(() => {
document.title = `${name} / Beszel` document.title = `${name} / Beszel`
return () => { return () => {
resetCharts()
$chartTime.set($userSettings.get().chartTime) $chartTime.set($userSettings.get().chartTime)
// resetCharts()
setSystemStats([])
setContainerData([])
setContainerFilterBar(null) setContainerFilterBar(null)
$containerFilter.set('') $containerFilter.set("")
cpuMaxStore[1](false) cpuMaxStore[1](false)
bandwidthMaxStore[1](false) bandwidthMaxStore[1](false)
diskIoMaxStore[1](false) diskIoMaxStore[1](false)
} }
}, [name]) }, [name])
function resetCharts() { // function resetCharts() {
setSystemStats([]) // setSystemStats([])
setDockerCpuChartData([]) // setContainerData([])
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
@@ -88,67 +142,48 @@ 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]) }, [system.id])
async function getStats<T>(collection: string): Promise<T[]> { const chartData: ChartData = useMemo(() => {
const lastCached = cache.get(`${system.id}_${chartTime}_${collection}`)?.at(-1) const lastCreated = Math.max(
?.created as number (systemStats.at(-1)?.created as number) ?? 0,
return await pb.collection<T>(collection).getFullList({ (containerData.at(-1)?.created as number) ?? 0
filter: pb.filter('system={:id} && created > {:created} && type={:type}', { )
id: system.id, return {
created: getPbTimestamp(chartTime, lastCached ? new Date(lastCached + 1000) : undefined), systemStats,
type: chartTimeData[chartTime].type, containerData,
}), chartTime,
fields: 'created,stats', orientation: direction === "rtl" ? "right" : "left",
sort: 'created', ...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)
} }
return modifiedRecords }, [systemStats, containerData, direction])
}
// 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'), getStats<SystemStatsRecord>("system_stats", system, chartTime),
getStats<ContainerStatsRecord>('container_stats'), getStats<ContainerStatsRecord>("container_stats", system, chartTime),
]).then(([systemStats, containerStats]) => { ]).then(([systemStats, containerStats]) => {
// loading: false
setChartLoading(false)
const { expectedInterval } = chartTimeData[chartTime] const { expectedInterval } = chartTimeData[chartTime]
// make new system stats // make new system stats
const ss_cache_key = `${system.id}_${chartTime}_system_stats` const ss_cache_key = `${system.id}_${chartTime}_system_stats`
let systemData = (cache.get(ss_cache_key) || []) as SystemStatsRecord[] let systemData = (cache.get(ss_cache_key) || []) as SystemStatsRecord[]
if (systemStats.status === 'fulfilled' && systemStats.value.length) { if (systemStats.status === "fulfilled" && systemStats.value.length) {
systemData = systemData.concat(addEmptyValues(systemStats.value, expectedInterval)) systemData = systemData.concat(addEmptyValues(systemData, systemStats.value, expectedInterval))
if (systemData.length > 120) { if (systemData.length > 120) {
systemData = systemData.slice(-100) systemData = systemData.slice(-100)
} }
@@ -158,8 +193,8 @@ export default function SystemDetail({ name }: { name: string }) {
// make new container stats // make new container stats
const cs_cache_key = `${system.id}_${chartTime}_container_stats` const cs_cache_key = `${system.id}_${chartTime}_container_stats`
let containerData = (cache.get(cs_cache_key) || []) as ContainerStatsRecord[] let containerData = (cache.get(cs_cache_key) || []) as ContainerStatsRecord[]
if (containerStats.status === 'fulfilled' && containerStats.value.length) { if (containerStats.status === "fulfilled" && containerStats.value.length) {
containerData = containerData.concat(addEmptyValues(containerStats.value, expectedInterval)) containerData = containerData.concat(addEmptyValues(containerData, containerStats.value, expectedInterval))
if (containerData.length > 120) { if (containerData.length > 120) {
containerData = containerData.slice(-100) containerData = containerData.slice(-100)
} }
@@ -174,49 +209,24 @@ export default function SystemDetail({ name }: { name: string }) {
}) })
}, [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])
const newTicks = scale.ticks(chartTimeData[chartTime].ticks).map((d) => d.getTime())
if (newTicks[0] !== ticks[0]) {
setTicks(newTicks)
}
}, [chartTime, systemStats])
// make container stats for charts // make container stats for charts
const makeContainerData = useCallback((containers: ContainerStatsRecord[]) => { const makeContainerData = useCallback((containers: ContainerStatsRecord[]) => {
// console.log('containers', containers) const containerData = [] as ChartData["containerData"]
const dockerCpuData = []
const dockerMemData = []
const dockerNetData = []
for (let { created, stats } of containers) { for (let { created, stats } of containers) {
if (!created) { if (!created) {
let nullData = { time: null } as unknown // @ts-ignore add null value for gaps
dockerCpuData.push(nullData as Record<string, number | string>) containerData.push({ created: null })
dockerMemData.push(nullData as Record<string, number | string>)
dockerNetData.push(nullData as Record<string, number | number[]>)
continue continue
} }
const time = new Date(created).getTime() created = new Date(created).getTime()
let cpuData = { time } as Record<string, number | string> // @ts-ignore not dealing with this rn
let memData = { time } as Record<string, number | string> let containerStats: ChartData["containerData"][0] = { created }
let netData = { time } as Record<string, number | number[]>
for (let container of stats) { for (let container of stats) {
cpuData[container.n] = container.c containerStats[container.n] = container
memData[container.n] = container.m
netData[container.n] = [container.ns, container.nr, container.ns + container.nr] // sent, received, total
} }
dockerCpuData.push(cpuData) containerData.push(containerStats)
dockerMemData.push(memData)
dockerNetData.push(netData)
} }
setDockerCpuChartData(dockerCpuData) setContainerData(containerData)
setDockerMemChartData(dockerMemData)
setDockerNetChartData(dockerNetData)
}, []) }, [])
// values for system info bar // values for system info bar
@@ -224,26 +234,26 @@ export default function SystemDetail({ name }: { name: string }) {
if (!system.info) { if (!system.info) {
return [] return []
} }
let uptime: number | string = system.info.u let uptime: React.ReactNode
if (system.info.u < 172800) { if (system.info.u < 172800) {
const hours = Math.trunc(uptime / 3600) const hours = Math.trunc(system.info.u / 3600)
uptime = `${hours} hour${hours == 1 ? '' : 's'}` uptime = <Plural value={hours} one="# hour" other="# hours" />
} else { } else {
uptime = `${Math.trunc(system.info?.u / 86400)} days` uptime = <Plural value={Math.trunc(system.info?.u / 86400)} one="# day" other="# days" />
} }
return [ return [
{ value: system.host, Icon: GlobeIcon }, { value: system.host, Icon: GlobeIcon },
{ {
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: 'Uptime' }, { value: uptime, Icon: ClockArrowUp, label: t`Uptime` },
{ value: system.info.k, Icon: TuxIcon, label: 'Kernel' }, { value: system.info.k, Icon: TuxIcon, label: t({ comment: "Linux kernel", message: "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,
}, },
@@ -256,45 +266,49 @@ 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 */
const bottomSpacing = useMemo(() => { useEffect(() => {
if (!netCardRef.current || !dockerNetChartData.length) { if (!netCardRef.current || !containerData.length) {
return 0 setBottomSpacing(0)
return
} }
const tooltipHeight = (Object.keys(dockerNetChartData[0]).length - 11) * 17.8 - 40 const tooltipHeight = (Object.keys(containerData[0]).length - 11) * 17.8 - 40
const wrapperEl = document.getElementById('chartwrap') as HTMLDivElement const 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
return tooltipHeight - distanceToBottom setBottomSpacing(tooltipHeight - distanceToBottom)
}, [netCardRef.current, dockerNetChartData]) }, [netCardRef, containerData])
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 overflow-x-clip">
{/* system info */} {/* system info */}
<Card> <Card>
<div className="grid lg:flex items-center gap-4 px-4 sm:px-6 pt-3 sm:pt-4 pb-5"> <div className="grid lg:flex gap-4 px-4 sm:px-6 pt-3 sm:pt-4 pb-5">
<div> <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>
@@ -327,17 +341,16 @@ export default function SystemDetail({ name }: { name: string }) {
})} })}
</div> </div>
</div> </div>
<div className="lg:ml-auto flex items-center gap-2 max-sm:-mb-1"> <div className="lg:ms-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="Toggle grid" aria-label={t`Toggle grid`}
className={cn( variant="outline"
buttonVariants({ variant: 'outline', size: 'icon' }), size="icon"
'hidden lg:flex p-0 text-primary' className="hidden lg:flex p-0 text-primary"
)}
onClick={() => setGrid(!grid)} onClick={() => setGrid(!grid)}
> >
{grid ? ( {grid ? (
@@ -347,7 +360,7 @@ export default function SystemDetail({ name }: { name: string }) {
)} )}
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>Toggle grid</TooltipContent> <TooltipContent>{t`Toggle grid`}</TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
</div> </div>
@@ -355,120 +368,116 @@ export default function SystemDetail({ name }: { name: string }) {
</Card> </Card>
{/* main charts */} {/* main charts */}
<div className="grid lg:grid-cols-2 gap-4"> <div className="grid xl:grid-cols-2 gap-4">
<ChartCard <ChartCard
empty={dataEmpty}
grid={grid} grid={grid}
title="Total CPU Usage" title={_(t`CPU Usage`)}
description={`${ description={t`Average system-wide CPU utilization`}
cpuMaxStore[0] && isLongerChart ? 'Max 1 min ' : 'Average'
} system-wide CPU utilization`}
cornerEl={isLongerChart ? <SelectAvgMax store={cpuMaxStore} /> : null} cornerEl={isLongerChart ? <SelectAvgMax store={cpuMaxStore} /> : null}
> >
<AreaChartDefault <AreaChartDefault chartData={chartData} chartName="CPU Usage" maxToggled={cpuMaxStore[0]} unit="%" />
ticks={ticks}
systemData={systemStats}
chartName="CPU Usage"
showMax={isLongerChart && cpuMaxStore[0]}
unit="%"
chartTime={chartTime}
/>
</ChartCard> </ChartCard>
{containerFilterBar && ( {containerFilterBar && (
<ChartCard <ChartCard
empty={dataEmpty}
grid={grid} grid={grid}
title="Docker CPU Usage" title={t`Docker CPU Usage`}
description="Average CPU utilization of containers" description={t`Average CPU utilization of containers`}
cornerEl={containerFilterBar} cornerEl={containerFilterBar}
> >
<ContainerCpuChart chartData={dockerCpuChartData} ticks={ticks} /> <ContainerChart chartData={chartData} dataKey="c" chartName="cpu" />
</ChartCard> </ChartCard>
)} )}
<ChartCard <ChartCard
empty={dataEmpty}
grid={grid} grid={grid}
title="Total Memory Usage" title={t`Memory Usage`}
description="Precise utilization at the recorded time" description={t`Precise utilization at the recorded time`}
> >
<MemChart ticks={ticks} systemData={systemStats} /> <MemChart chartData={chartData} />
</ChartCard> </ChartCard>
{containerFilterBar && ( {containerFilterBar && (
<ChartCard <ChartCard
empty={dataEmpty}
grid={grid} grid={grid}
title="Docker Memory Usage" title={t`Docker Memory Usage`}
description="Memory usage of docker containers" description={t`Memory usage of docker containers`}
cornerEl={containerFilterBar} cornerEl={containerFilterBar}
> >
<ContainerMemChart chartData={dockerMemChartData} ticks={ticks} /> <ContainerChart chartData={chartData} chartName="mem" dataKey="m" unit=" MB" />
</ChartCard> </ChartCard>
)} )}
<ChartCard grid={grid} title="Disk Space" description="Usage of root partition"> <ChartCard empty={dataEmpty} grid={grid} title={t`Disk Usage`} description={t`Usage of root partition`}>
<DiskChart <DiskChart
ticks={ticks} chartData={chartData}
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
empty={dataEmpty}
grid={grid} grid={grid}
title="Disk I/O" title={t`Disk I/O`}
description="Throughput of root filesystem" description={t`Throughput of root filesystem`}
cornerEl={isLongerChart ? <SelectAvgMax store={diskIoMaxStore} /> : null} cornerEl={isLongerChart ? <SelectAvgMax store={diskIoMaxStore} /> : null}
> >
<AreaChartDefault <AreaChartDefault chartData={chartData} maxToggled={diskIoMaxStore[0]} chartName="dio" />
ticks={ticks}
systemData={systemStats}
showMax={isLongerChart && diskIoMaxStore[0]}
chartName="dio"
chartTime={chartTime}
/>
</ChartCard> </ChartCard>
<ChartCard <ChartCard
empty={dataEmpty}
grid={grid} grid={grid}
title="Bandwidth" title={t`Bandwidth`}
cornerEl={isLongerChart ? <SelectAvgMax store={bandwidthMaxStore} /> : null} cornerEl={isLongerChart ? <SelectAvgMax store={bandwidthMaxStore} /> : null}
description="Network traffic of public interfaces" description={t`Network traffic of public interfaces`}
> >
<AreaChartDefault <AreaChartDefault chartData={chartData} maxToggled={bandwidthMaxStore[0]} chartName="bw" />
ticks={ticks}
systemData={systemStats}
showMax={isLongerChart && bandwidthMaxStore[0]}
chartName="bw"
chartTime={chartTime}
/>
</ChartCard> </ChartCard>
{containerFilterBar && dockerNetChartData.length > 0 && ( {containerFilterBar && containerData.length > 0 && (
<div <div
ref={netCardRef} ref={netCardRef}
className={cn({ className={cn({
'col-span-full': !grid, "col-span-full": !grid,
})} })}
> >
<ChartCard <ChartCard
title="Docker Network I/O" empty={dataEmpty}
description="Includes traffic between internal services" title={t`Docker Network I/O`}
description={t`Network traffic of docker containers`}
cornerEl={containerFilterBar} cornerEl={containerFilterBar}
> >
<ContainerNetChart chartData={dockerNetChartData} ticks={ticks} /> {/* @ts-ignore */}
<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 grid={grid} title="Swap Usage" description="Swap space used by the system"> <ChartCard
<SwapChart ticks={ticks} systemData={systemStats} /> empty={dataEmpty}
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 grid={grid} title="Temperature" description="Temperatures of system sensors"> <ChartCard
<TemperatureChart ticks={ticks} systemData={systemStats} /> empty={dataEmpty}
grid={grid}
title={t`Temperature`}
description={t`Temperatures of system sensors`}
>
<TemperatureChart chartData={chartData} />
</ChartCard> </ChartCard>
)} )}
</div> </div>
@@ -480,29 +489,28 @@ 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} Usage`} title={`${extraFsName} ${t`Usage`}`}
description={`Disk usage of ${extraFsName}`} description={t`Disk usage of ${extraFsName}`}
> >
<DiskChart <DiskChart
ticks={ticks} chartData={chartData}
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={`Throughput of ${extraFsName}`} description={t`Throughput of ${extraFsName}`}
cornerEl={isLongerChart ? <SelectAvgMax store={diskIoMaxStore} /> : null} cornerEl={isLongerChart ? <SelectAvgMax store={diskIoMaxStore} /> : null}
> >
<AreaChartDefault <AreaChartDefault
ticks={ticks} chartData={chartData}
systemData={systemStats} maxToggled={diskIoMaxStore[0]}
showMax={isLongerChart && diskIoMaxStore[0]}
chartName={`efs.${extraFsName}`} chartName={`efs.${extraFsName}`}
chartTime={chartTime}
/> />
</ChartCard> </ChartCard>
</div> </div>
@@ -520,6 +528,7 @@ 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)
@@ -527,12 +536,7 @@ function ContainerFilterBar() {
return ( return (
<> <>
<Input <Input placeholder={_(t`Filter...`)} className="ps-4 pe-8" value={containerFilter} onChange={handleChange} />
placeholder="Filter..."
className="pl-4 pr-8"
value={containerFilter}
onChange={handleChange}
/>
{containerFilter && ( {containerFilter && (
<Button <Button
type="button" type="button"
@@ -540,7 +544,7 @@ 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>
@@ -549,26 +553,22 @@ function ContainerFilterBar() {
) )
} }
function SelectAvgMax({ function SelectAvgMax({ store }: { store: [boolean, React.Dispatch<React.SetStateAction<boolean>>] }) {
store,
}: {
store: [boolean, React.Dispatch<React.SetStateAction<boolean>>]
}) {
const [max, setMax] = store const [max, setMax] = store
const Icon = max ? ChartMax : ChartAverage const Icon = max ? ChartMax : ChartAverage
return ( return (
<Select value={max ? 'max' : 'avg'} onValueChange={(e) => setMax(e === 'max')}> <Select value={max ? "max" : "avg"} onValueChange={(e) => setMax(e === "max")}>
<SelectTrigger className="relative pl-10 pr-5"> <SelectTrigger className="relative ps-10 pe-5">
<Icon className="h-4 w-4 absolute left-4 top-1/2 -translate-y-1/2 opacity-85" /> <Icon className="h-4 w-4 absolute start-4 top-1/2 -translate-y-1/2 opacity-85" />
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem key="avg" value="avg"> <SelectItem key="avg" value="avg">
Average <Trans>Average</Trans>
</SelectItem> </SelectItem>
<SelectItem key="max" value="max"> <SelectItem key="max" value="max">
Max 1 min <Trans comment="Chart select field. Please try to keep this short.">Max 1 min</Trans>
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
@@ -580,33 +580,28 @@ function ChartCard({
description, description,
children, children,
grid, grid,
empty,
cornerEl, cornerEl,
}: { }: {
title: string title: string
description: string description: string
children: React.ReactNode children: React.ReactNode
grid?: boolean grid?: boolean
empty?: boolean
cornerEl?: JSX.Element | null cornerEl?: JSX.Element | null
}) { }) {
const { isIntersecting, ref } = useIntersectionObserver() const { isIntersecting, ref } = useIntersectionObserver()
return ( return (
<Card <Card className={cn("pb-2 sm:pb-4 odd:last-of-type:col-span-full", { "col-span-full": !grid })} ref={ref}>
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 && ( {cornerEl && <div className="relative py-1 block sm:w-44 sm:absolute sm:top-2.5 sm:end-3.5">{cornerEl}</div>}
<div className="relative py-1 block sm:w-44 sm:absolute sm:top-2.5 sm:right-3.5">
{cornerEl}
</div>
)}
</CardHeader> </CardHeader>
<div className="pl-0 w-[calc(100%-1.6em)] h-52 relative"> <div className="ps-0 w-[calc(100%-1.6em)] h-52 relative">
{<Spinner />} {<Spinner msg={empty ? t`Waiting for enough records to display` : undefined} />}
{isIntersecting && <Suspense>{children}</Suspense>} {isIntersecting && children}
</div> </div>
</Card> </Card>
) )

View File

@@ -1,9 +1,13 @@
import { LoaderCircleIcon } from 'lucide-react' import { LoaderCircleIcon } from "lucide-react"
export default function () { export default function ({ msg }: { msg?: string }) {
return ( return (
<div className="grid place-content-center h-full absolute inset-0"> <div className="flex flex-col items-center justify-center h-full absolute inset-0">
<LoaderCircleIcon className="animate-spin h-10 w-10 opacity-60" /> {msg ? (
<p className={"opacity-60 mb-2 text-center px-4"}>{msg}</p>
) : (
<LoaderCircleIcon className="animate-spin h-10 w-10 opacity-60" />
)}
</div> </div>
) )
} }

View File

@@ -6,29 +6,24 @@ import {
SortingState, SortingState,
getSortedRowModel, getSortedRowModel,
flexRender, flexRender,
VisibilityState,
getCoreRowModel, getCoreRowModel,
useReactTable, useReactTable,
Column, Column,
} from '@tanstack/react-table' } from "@tanstack/react-table"
import { import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
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,
@@ -40,39 +35,45 @@ 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 {
MoreHorizontal, MoreHorizontalIcon,
ArrowUpDown, ArrowUpDownIcon,
Server, MemoryStickIcon,
Cpu,
MemoryStick,
HardDrive,
CopyIcon, CopyIcon,
PauseCircleIcon, PauseCircleIcon,
PlayCircleIcon, PlayCircleIcon,
Trash2Icon, Trash2Icon,
WifiIcon, WifiIcon,
} from 'lucide-react' HardDriveIcon,
import { useEffect, useMemo, useState } from 'react' ServerIcon,
import { $hubVersion, $systems, pb } from '@/lib/stores' CpuIcon,
import { useStore } from '@nanostores/react' ChevronDownIcon,
import { cn, copyToClipboard, isReadOnlyUser } from '@/lib/utils' } from "lucide-react"
import AlertsButton from '../table-alerts' import { useEffect, useMemo, useState } from "react"
import { navigate } from '../router' import { $hubVersion, $systems, pb } from "@/lib/stores"
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]">{val.toFixed(1)}%</span> <span className="min-w-[3.5em]">{decimalString(val, 1)}%</span>
<span className="grow min-w-10 block bg-muted h-[1em] relative rounded-sm overflow-hidden"> <span className="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>
@@ -81,60 +82,60 @@ function CellFormatter(info: CellContext<SystemRecord, unknown>) {
) )
} }
function sortableHeader( function sortableHeader(column: Column<SystemRecord, unknown>, Icon: any, hideSortIcon = false) {
column: Column<SystemRecord, unknown>,
name: string,
Icon: any,
hideSortIcon = false
) {
return ( return (
<Button <Button
variant="ghost" variant="ghost"
className="h-9 px-3" className="h-9 px-3 flex"
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')} onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
> >
<Icon className="mr-2 h-4 w-4" /> <Icon className="me-2 h-4 w-4" />
{name} {column.id}
{!hideSortIcon && <ArrowUpDown className="ml-2 h-4 w-4" />} {!hideSortIcon && <ArrowUpDownIcon className="ms-2 h-4 w-4" />}
</Button> </Button>
) )
} }
export default function SystemsTable({ filter }: { filter?: string }) { export default function SystemsTable() {
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('name')?.setFilterValue(filter) table.getColumn(t`System`)?.setFilterValue(filter)
} }
}, [filter]) }, [filter])
const columns: ColumnDef<SystemRecord>[] = useMemo(() => { const columns = 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:pr-5"> <span className="flex gap-0.5 items-center text-base md:pe-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)}
> >
@@ -144,113 +145,137 @@ export default function SystemsTable({ filter }: { filter?: string }) {
</span> </span>
) )
}, },
header: ({ column }) => sortableHeader(column, 'System', Server), header: ({ column }) => sortableHeader(column, ServerIcon),
}, },
{ {
accessorKey: 'info.cpu', accessorKey: "info.cpu",
id: t`CPU`,
invertSorting: true,
cell: CellFormatter, cell: CellFormatter,
header: ({ column }) => sortableHeader(column, 'CPU', Cpu), header: ({ column }) => sortableHeader(column, CpuIcon),
}, },
{ {
accessorKey: 'info.mp', accessorKey: "info.mp",
id: t`Memory`,
invertSorting: true,
cell: CellFormatter, cell: CellFormatter,
header: ({ column }) => sortableHeader(column, 'Memory', MemoryStick), header: ({ column }) => sortableHeader(column, MemoryStickIcon),
}, },
{ {
accessorKey: 'info.dp', accessorKey: "info.dp",
id: t`Disk`,
invertSorting: true,
cell: CellFormatter, cell: CellFormatter,
header: ({ column }) => sortableHeader(column, 'Disk', HardDrive), header: ({ column }) => sortableHeader(column, HardDriveIcon),
}, },
{ {
accessorKey: 'info.v', accessorFn: (originalRow) => originalRow.info.b || 0,
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:pr-5 tabular-nums pl-1"> <span className="flex gap-2 items-center md:pe-5 tabular-nums ps-1">
<span <span
className={cn( className={cn("w-2 h-2 left-0 rounded-full", version === hubVersion ? "bg-green-500" : "bg-yellow-500")}
'w-2 h-2 left-0 rounded-full', style={{ marginBottom: "-1px" }}
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: 'actions', id: t({ message: "Actions", comment: "Table column" }),
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">Open menu</span> <span className="sr-only">
<MoreHorizontal className="w-5" /> <Trans>Open menu</Trans>
</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="mr-2.5 h-4 w-4" /> <PlayCircleIcon className="me-2.5 h-4 w-4" />
Resume <Trans>Resume</Trans>
</> </>
) : ( ) : (
<> <>
<PauseCircleIcon className="mr-2.5 h-4 w-4" /> <PauseCircleIcon className="me-2.5 h-4 w-4" />
Pause <Trans>Pause</Trans>
</> </>
)} )}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => copyToClipboard(host)}> <DropdownMenuItem onClick={() => copyToClipboard(host)}>
<CopyIcon className="mr-2.5 h-4 w-4" /> <CopyIcon className="me-2.5 h-4 w-4" />
Copy host <Trans>Copy host</Trans>
</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="mr-2.5 h-4 w-4" /> <Trash2Icon className="me-2.5 h-4 w-4" />
Delete <Trans>Delete</Trans>
</DropdownMenuItem> </DropdownMenuItem>
</AlertDialogTrigger> </AlertDialogTrigger>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Are you sure you want to delete {name}?</AlertDialogTitle> <AlertDialogTitle>
<Trans>Are you sure you want to delete {name}?</Trans>
</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
This action cannot be undone. This will permanently delete all current records <Trans>
for <code className={'bg-muted rounded-sm px-1'}>{name}</code> from the This action cannot be undone. This will permanently delete all current records for {name} from
database. the database.
</Trans>
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel> <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)}
> >
Continue <Trans>Continue</Trans>
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
@@ -259,8 +284,8 @@ export default function SystemsTable({ filter }: { filter?: string }) {
) )
}, },
}, },
] ] as ColumnDef<SystemRecord>[]
}, [hubVersion]) }, [hubVersion, i18n.locale])
const table = useReactTable({ const table = useReactTable({
data, data,
@@ -270,9 +295,11 @@ export default function SystemsTable({ filter }: { filter?: string }) {
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,
@@ -282,64 +309,102 @@ export default function SystemsTable({ filter }: { filter?: string }) {
}) })
return ( return (
<div className="rounded-md border overflow-hidden"> <Card>
<Table> <CardHeader className="pb-5 px-2 sm:px-6 max-sm:pt-5 max-sm:pb-1">
<TableHeader className="bg-muted/40"> <div className="grid md:flex gap-5 w-full items-end">
{table.getHeaderGroups().map((headerGroup) => ( <div className="px-2 sm:px-1">
<TableRow key={headerGroup.id}> <CardTitle className="mb-2.5">
{headerGroup.headers.map((header) => { <Trans>All Systems</Trans>
return ( </CardTitle>
<TableHead className="px-2" key={header.id}> <CardDescription>
{header.isPlaceholder <Trans>Updated in real time. Click on a system to view information.</Trans>
? null </CardDescription>
: flexRender(header.column.columnDef.header, header.getContext())} </div>
</TableHead> <div className="flex gap-2 ms-auto w-full md:w-80">
) <Input placeholder={t`Filter...`} onChange={(e) => setFilter(e.target.value)} className="px-4" />
})} <DropdownMenu>
</TableRow> <DropdownMenuTrigger asChild>
))} <Button variant="outline">
</TableHeader> <Trans comment="Context: table columns">Columns</Trans>{" "}
<TableBody> <ChevronDownIcon className="ms-1.5 h-4 w-4 opacity-90" />
{table.getRowModel().rows?.length ? ( </Button>
table.getRowModel().rows.map((row) => ( </DropdownMenuTrigger>
<TableRow <DropdownMenuContent align="end">
key={row.original.id} {table
data-state={row.getIsSelected() && 'selected'} .getAllColumns()
className={cn('cursor-pointer transition-opacity', { .filter((column) => column.getCanHide())
'opacity-50': row.original.status === 'paused', .map((column) => {
})} return (
onClick={(e) => { <DropdownMenuCheckboxItem
const target = e.target as HTMLElement key={column.id}
if (!target.closest('[data-nolink]') && e.currentTarget.contains(target)) { checked={column.getIsVisible()}
navigate(`/system/${encodeURIComponent(row.original.name)}`) onCheckedChange={(value) => column.toggleVisibility(!!value)}
} >
}} {column.id}
> </DropdownMenuCheckboxItem>
{row.getVisibleCells().map((cell) => ( )
<TableCell })}
key={cell.id} </DropdownMenuContent>
style={{ </DropdownMenu>
width: </div>
cell.column.getSize() === Number.MAX_SAFE_INTEGER </div>
? 'auto' </CardHeader>
: cell.column.getSize(), <CardContent className="max-sm:p-2">
<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')}
> >
{flexRender(cell.column.columnDef.cell, cell.getContext())} {row.getVisibleCells().map((cell) => (
<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>
<TableRow> </div>
<TableCell colSpan={columns.length} className="h-24 text-center"> </CardContent>
No systems found </Card>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
) )
} }

View File

@@ -1,228 +0,0 @@
import { $alerts, pb } from '@/lib/stores'
import { useStore } from '@nanostores/react'
import {
Dialog,
DialogTrigger,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { BellIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
import { AlertRecord, SystemRecord } from '@/types'
import { lazy, Suspense, useMemo, useState } from 'react'
import { toast } from './ui/use-toast'
import { Link } from './router'
const Slider = lazy(() => import('./ui/slider'))
const failedUpdateToast = () =>
toast({
title: 'Failed to update alert',
description: 'Please check logs for more details.',
variant: 'destructive',
})
export default function AlertsButton({ system }: { system: SystemRecord }) {
const alerts = useStore($alerts)
const active = useMemo(() => {
return alerts.find((alert) => alert.system === system.id)
}, [alerts, system])
const systemAlerts = useMemo(() => {
return alerts.filter((alert) => alert.system === system.id) as AlertRecord[]
}, [alerts, system])
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="ghost" size={'icon'} aria-label="Alerts" data-nolink>
<BellIcon
className={cn('h-[1.2em] w-[1.2em] pointer-events-none', {
'fill-foreground': active,
})}
/>
</Button>
</DialogTrigger>
<DialogContent className="max-h-full overflow-auto">
<DialogHeader>
<DialogTitle className="text-xl">{system.name} alerts</DialogTitle>
<DialogDescription className="mb-1">
See{' '}
<Link href="/settings/notifications" className="link">
notification settings
</Link>{' '}
to configure how you receive alerts.
</DialogDescription>
</DialogHeader>
<div className="grid gap-3">
<AlertStatus system={system} alerts={systemAlerts} />
<AlertWithSlider
system={system}
alerts={systemAlerts}
name="CPU"
title="CPU Usage"
description="Triggers when CPU usage exceeds a threshold."
/>
<AlertWithSlider
system={system}
alerts={systemAlerts}
name="Memory"
title="Memory Usage"
description="Triggers when memory usage exceeds a threshold."
/>
<AlertWithSlider
system={system}
alerts={systemAlerts}
name="Disk"
title="Disk Usage"
description="Triggers when root usage exceeds a threshold."
/>
</div>
</DialogContent>
</Dialog>
)
}
function AlertStatus({ system, alerts }: { system: SystemRecord; alerts: AlertRecord[] }) {
const [pendingChange, setPendingChange] = useState(false)
const alert = useMemo(() => {
return alerts.find((alert) => alert.name === 'Status')
}, [alerts])
return (
<label
htmlFor="alert-status"
className="flex flex-row items-center justify-between gap-4 rounded-lg border p-4 cursor-pointer"
>
<div className="grid gap-1 select-none">
<p className="font-semibold">System Status</p>
<span className="block text-sm text-foreground opacity-80">
Triggers when status switches between up and down.
</span>
</div>
<Switch
id="alert-status"
className={cn('transition-opacity', pendingChange && 'opacity-40')}
checked={!!alert}
value={!!alert ? 'on' : 'off'}
onCheckedChange={async (active) => {
if (pendingChange) {
return
}
setPendingChange(true)
try {
if (!active && alert) {
await pb.collection('alerts').delete(alert.id)
} else if (active) {
pb.collection('alerts').create({
system: system.id,
user: pb.authStore.model!.id,
name: 'Status',
})
}
} catch (e) {
failedUpdateToast()
} finally {
setPendingChange(false)
}
}}
/>
</label>
)
}
function AlertWithSlider({
system,
alerts,
name,
title,
description,
}: {
system: SystemRecord
alerts: AlertRecord[]
name: string
title: string
description: string
}) {
const [pendingChange, setPendingChange] = useState(false)
const [liveValue, setLiveValue] = useState(50)
const alert = useMemo(() => {
const alert = alerts.find((alert) => alert.name === name)
if (alert) {
setLiveValue(alert.value)
}
return alert
}, [alerts])
return (
<div className="rounded-lg border">
<label
htmlFor={`alert-${name}`}
className={cn('flex flex-row items-center justify-between gap-4 cursor-pointer p-4', {
'pb-0': !!alert,
})}
>
<div className="grid gap-1 select-none">
<p className="font-semibold">{title}</p>
<span className="block text-sm text-foreground opacity-80">{description}</span>
</div>
<Switch
id={`alert-${name}`}
className={cn('transition-opacity', pendingChange && 'opacity-40')}
checked={!!alert}
value={!!alert ? 'on' : 'off'}
onCheckedChange={async (active) => {
if (pendingChange) {
return
}
setPendingChange(true)
try {
if (!active && alert) {
await pb.collection('alerts').delete(alert.id)
} else if (active) {
pb.collection('alerts').create({
system: system.id,
user: pb.authStore.model!.id,
name,
value: liveValue,
})
}
} catch (e) {
failedUpdateToast()
} finally {
setPendingChange(false)
}
}}
/>
</label>
{alert && (
<div className="flex mt-2 mb-3 gap-3 px-4">
<Suspense>
<Slider
defaultValue={[liveValue]}
onValueCommit={(val) => {
pb.collection('alerts').update(alert.id, {
value: val[0],
})
}}
onValueChange={(val) => {
setLiveValue(val[0])
}}
min={10}
max={99}
// step={1}
/>
</Suspense>
<span className="tabular-nums tracking-tighter text-[.92em]">{liveValue}%</span>
</div>
)}
</div>
)
}

View File

@@ -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,23 +22,19 @@ 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>( const [theme, setTheme] = useState<Theme>(() => (localStorage.getItem(storageKey) as Theme) || defaultTheme)
() => (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 const systemTheme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
? 'dark'
: 'light'
root.classList.add(systemTheme) root.classList.add(systemTheme)
return return

View File

@@ -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,27 +44,20 @@ 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-left', className)} {...props} /> <div className={cn("flex flex-col space-y-2 text-center sm:text-start", className)} {...props} />
) )
AlertDialogHeader.displayName = 'AlertDialogHeader' AlertDialogHeader.displayName = "AlertDialogHeader"
const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => ( const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div <div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-2", className)} {...props} />
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 <AlertDialogPrimitive.Title ref={ref} className={cn("text-lg font-semibold", className)} {...props} />
ref={ref}
className={cn('text-lg font-semibold', className)}
{...props}
/>
)) ))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
@@ -72,11 +65,7 @@ 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 <AlertDialogPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
)) ))
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName
@@ -94,7 +83,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}
/> />
)) ))

View File

@@ -0,0 +1,54 @@
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 }

View File

@@ -4,33 +4,26 @@ 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: default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80", secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
secondary: destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", outline: "text-foreground",
destructive: },
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", },
outline: "text-foreground", defaultVariants: {
}, variant: "default",
}, },
defaultVariants: { }
variant: "default",
},
}
) )
export interface BadgeProps export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) { function Badge({ className, variant, ...props }: BadgeProps) {
return ( return <div className={cn(badgeVariants({ variant }), className)} {...props} />
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
} }
export { Badge, badgeVariants } export { Badge, badgeVariants }

View File

@@ -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,12 +38,10 @@ 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 ( return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
)
} }
) )
Button.displayName = 'Button' Button.displayName = "Button"
export { Button, buttonVariants } export { Button, buttonVariants }

View File

@@ -2,78 +2,40 @@ import * as React from "react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const Card = React.forwardRef< const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
HTMLDivElement, <div ref={ref} className={cn("rounded-lg border bg-card text-card-foreground shadow-sm", className)} {...props} />
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< const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
HTMLDivElement, ({ className, ...props }, ref) => (
React.HTMLAttributes<HTMLDivElement> <div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
>(({ 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< const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
HTMLParagraphElement, ({ className, ...props }, ref) => (
React.HTMLAttributes<HTMLHeadingElement> <h3 ref={ref} className={cn("text-2xl font-semibold leading-none tracking-tight", className)} {...props} />
>(({ 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< const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
HTMLParagraphElement, ({ className, ...props }, ref) => (
React.HTMLAttributes<HTMLParagraphElement> <p ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
>(({ 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< const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
HTMLDivElement, ({ className, ...props }, ref) => <div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
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< const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
HTMLDivElement, ({ className, ...props }, ref) => <div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
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 }

View File

@@ -1,19 +1,17 @@
import * as React from 'react' import * as React from "react"
import * as RechartsPrimitive from 'recharts' import * as RechartsPrimitive from "recharts"
import { cn } from '@/lib/utils' import { chartTimeData, cn } from "@/lib/utils"
import { ChartData } from "@/types"
// Format: { THEME_NAME: CSS_SELECTOR } // 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 = {
@@ -34,13 +32,13 @@ export type ChartConfig = {
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, ...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 }}>
@@ -59,7 +57,7 @@ const ChartContainer = React.forwardRef<
//</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)
@@ -93,10 +91,9 @@ 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
hideIndicator?: boolean indicator?: "line" | "dot" | "dashed"
indicator?: 'line' | 'dot' | 'dashed'
nameKey?: string nameKey?: string
labelKey?: string labelKey?: string
unit?: string unit?: string
@@ -109,9 +106,8 @@ const ChartTooltipContent = React.forwardRef<
active, active,
payload, payload,
className, className,
indicator = 'dot', indicator = "line",
hideLabel = false, hideLabel = false,
hideIndicator = false,
label, label,
labelFormatter, labelFormatter,
labelClassName, labelClassName,
@@ -145,41 +141,40 @@ const ChartTooltipContent = React.forwardRef<
} }
const [item] = payload const [item] = payload
const key = `${labelKey || item.dataKey || item.name || 'value'}` const key = `${labelKey || 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" ? label : itemConfig?.label
if (labelFormatter) { if (labelFormatter) {
return ( return <div className={cn("font-medium", labelClassName)}>{labelFormatter(value, payload)}</div>
<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
@@ -187,8 +182,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 ? (
@@ -198,44 +193,36 @@ const ChartTooltipContent = React.forwardRef<
{itemConfig?.icon ? ( {itemConfig?.icon ? (
<itemConfig.icon /> <itemConfig.icon />
) : ( ) : (
!hideIndicator && ( <div
<div className={cn("shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]", {
className={cn( "h-2.5 w-2.5": indicator === "dot",
'shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]', "w-1": indicator === "line",
{ "w-0 border-[1.5px] border-dashed bg-transparent": indicator === "dashed",
'h-2.5 w-2.5': indicator === 'dot', "my-0.5": nestLabel && indicator === "dashed",
'w-1': indicator === 'line', })}
'w-0 border-[1.5px] border-dashed bg-transparent': style={
indicator === 'dashed', {
'my-0.5': nestLabel && indicator === 'dashed', "--color-bg": indicatorColor,
} "--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"> <span className="text-muted-foreground">{itemConfig?.label || item.name}</span>
{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>
@@ -249,18 +236,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, payload, verticalAlign = "bottom" }, ref) => {
// const { config } = useChart() // const { config } = useChart()
if (!payload?.length) { if (!payload?.length) {
@@ -271,8 +258,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
)} )}
> >
@@ -285,7 +272,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 ? (
@@ -306,27 +293,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
} }
@@ -334,11 +321,34 @@ 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,
} }

View File

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

View File

@@ -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,10 +12,7 @@ const Command = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<CommandPrimitive <CommandPrimitive
ref={ref} ref={ref}
className={cn( className={cn("flex h-full w-full flex-col overflow-hidden bg-popover text-popover-foreground", className)}
'flex h-full w-full flex-col overflow-hidden bg-popover text-popover-foreground',
className
)}
{...props} {...props}
/> />
)) ))
@@ -43,11 +40,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="mr-2 h-4 w-4 shrink-0 opacity-50" /> <Search className="me-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}
@@ -63,7 +60,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}
/> />
)) ))
@@ -73,9 +70,7 @@ 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) => ( >((props, ref) => <CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} />)
<CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} />
))
CommandEmpty.displayName = CommandPrimitive.Empty.displayName CommandEmpty.displayName = CommandPrimitive.Empty.displayName
@@ -86,7 +81,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}
@@ -99,11 +94,7 @@ 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 <CommandPrimitive.Separator ref={ref} className={cn("-mx-1 h-px bg-border", className)} {...props} />
ref={ref}
className={cn('-mx-1 h-px bg-border', className)}
{...props}
/>
)) ))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName CommandSeparator.displayName = CommandPrimitive.Separator.displayName
@@ -124,14 +115,9 @@ 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 ( return <span className={cn("ms-auto text-xs tracking-wide text-muted-foreground", className)} {...props} />
<span
className={cn('ml-auto text-xs tracking-wide text-muted-foreground', className)}
{...props}
/>
)
} }
CommandShortcut.displayName = 'CommandShortcut' CommandShortcut.displayName = "CommandShortcut"
export { export {
Command, Command,

View File

@@ -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 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"> <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">
<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,17 +52,14 @@ 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-left', className)} {...props} /> <div className={cn("flex flex-col space-y-1.5 text-center sm:text-start", className)} {...props} />
) )
DialogHeader.displayName = 'DialogHeader' DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => ( const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div <div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-3.5", className)} {...props} />
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>,
@@ -70,7 +67,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}
/> />
)) ))
@@ -80,11 +77,7 @@ 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 <DialogPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
)) ))
DialogDescription.displayName = DialogPrimitive.Description.displayName DialogDescription.displayName = DialogPrimitive.Description.displayName

View File

@@ -17,182 +17,163 @@ 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 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.5 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
inset && "pl-8", inset && "ps-8",
className className
)} )}
{...props} {...props}
> >
{children} {children}
<ChevronRight className="ml-auto h-4 w-4" /> <ChevronRight className="ms-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger> </DropdownMenuPrimitive.SubTrigger>
)) ))
DropdownMenuSubTrigger.displayName = DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.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 = DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.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 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.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",
inset && "pl-8", inset && "ps-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 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", "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",
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 = DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.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 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", "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",
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( className={cn("px-2.5 py-1.5 text-sm font-semibold", inset && "ps-8", className)}
"px-2 py-1.5 text-sm font-semibold", {...props}
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 <DropdownMenuPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-muted", className)} {...props} />
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 = ({ const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
className, return <span className={cn("ms-auto text-xs tracking-widest opacity-60", className)} {...props} />
...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,
} }

View File

@@ -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>) {
@@ -45,3 +45,21 @@ export function ChartMax(props: SVGProps<SVGSVGElement>) {
</svg> </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>
)
}

View File

@@ -1,27 +1,24 @@
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([ const newDataPoints = new Set([...value, ...pendingDataPoint.split(",").map((chunk) => chunk.trim())])
...value,
...pendingDataPoint.split(',').map((chunk) => chunk.trim()),
])
onChange(Array.from(newDataPoints)) onChange(Array.from(newDataPoints))
setPendingDataPoint('') setPendingDataPoint("")
} }
}, [pendingDataPoint, onChange, value]) }, [pendingDataPoint, onChange, value])
@@ -29,14 +26,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
)} )}
> >
@@ -46,7 +43,7 @@ const InputTags = React.forwardRef<HTMLInputElement, InputTagsProps>(
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="ml-2 h-3 w-3" className="ms-2 h-3 w-3"
onClick={() => { onClick={() => {
onChange(value.filter((i) => i !== item)) onChange(value.filter((i) => i !== item))
}} }}
@@ -60,10 +57,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))
} }
@@ -76,6 +73,6 @@ const InputTags = React.forwardRef<HTMLInputElement, InputTagsProps>(
} }
) )
InputTags.displayName = 'InputTags' InputTags.displayName = "InputTags"
export { InputTags } export { InputTags }

View File

@@ -2,24 +2,21 @@ import * as React from "react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
export interface InputProps export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>( const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, type, ...props }, ref) => {
({ className, type, ...props }, ref) => { return (
return ( <input
<input type={type}
type={type} className={cn(
className={cn( "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",
"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
className )}
)} ref={ref}
ref={ref} {...props}
{...props} />
/> )
) })
}
)
Input.displayName = "Input" Input.displayName = "Input"
export { Input } export { Input }

View File

@@ -4,20 +4,13 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const labelVariants = cva( const labelVariants = cva("text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70")
"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> & React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<LabelPrimitive.Root <LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
)) ))
Label.displayName = LabelPrimitive.Root.displayName Label.displayName = LabelPrimitive.Root.displayName

View File

@@ -11,148 +11,133 @@ 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( className={cn("flex cursor-default items-center justify-center py-1", className)}
"flex cursor-default items-center justify-center py-1", {...props}
className >
)} <ChevronUp className="h-4 w-4" />
{...props} </SelectPrimitive.ScrollUpButton>
>
<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( className={cn("flex cursor-default items-center justify-center py-1", className)}
"flex cursor-default items-center justify-center py-1", {...props}
className >
)} <ChevronDown className="h-4 w-4" />
{...props} </SelectPrimitive.ScrollDownButton>
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
)) ))
SelectScrollDownButton.displayName = SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.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 <SelectPrimitive.Label ref={ref} className={cn("py-1.5 ps-8 pe-2 text-sm font-semibold", className)} {...props} />
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 pl-8 pr-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 ps-8 pe-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 <SelectPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-muted", className)} {...props} />
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,
} }

View File

@@ -4,26 +4,17 @@ 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
{ className, orientation = "horizontal", decorative = true, ...props }, ref={ref}
ref decorative={decorative}
) => ( orientation={orientation}
<SeparatorPrimitive.Root className={cn("shrink-0 bg-border", orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]", className)}
ref={ref} {...props}
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 }

View File

@@ -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">

View File

@@ -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 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 rtl: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

View File

@@ -1,91 +1,72 @@
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< const TableHeader = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
HTMLTableSectionElement, ({ className, ...props }, ref) => <thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
React.HTMLAttributes<HTMLTableSectionElement> )
>(({ className, ...props }, ref) => ( TableHeader.displayName = "TableHeader"
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
))
TableHeader.displayName = 'TableHeader'
const TableBody = React.forwardRef< const TableBody = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
HTMLTableSectionElement, ({ className, ...props }, ref) => (
React.HTMLAttributes<HTMLTableSectionElement> <tbody ref={ref} className={cn("[&_tr:last-child]:border-0", className)} {...props} />
>(({ 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< const TableFooter = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
HTMLTableSectionElement, ({ className, ...props }, ref) => (
React.HTMLAttributes<HTMLTableSectionElement> <tfoot ref={ref} className={cn("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", className)} {...props} />
>(({ className, ...props }, ref) => ( )
<tfoot )
ref={ref} TableFooter.displayName = "TableFooter"
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(
'border-b hover:bg-muted/40 dark:hover:bg-muted/30 data-[state=selected]:bg-muted', "h-12 px-4 text-start align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pe-0",
className className
)} )}
{...props} {...props}
/> />
) )
) )
TableRow.displayName = 'TableRow' TableHead.displayName = "TableHead"
const TableHead = React.forwardRef< const TableCell = React.forwardRef<HTMLTableCellElement, React.TdHTMLAttributes<HTMLTableCellElement>>(
HTMLTableCellElement, ({ className, ...props }, ref) => (
React.ThHTMLAttributes<HTMLTableCellElement> <td ref={ref} className={cn("p-4 align-middle [&:has([role=checkbox])]:pe-0", className)} {...props} />
>(({ className, ...props }, ref) => ( )
<th )
ref={ref} TableCell.displayName = "TableCell"
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 TableCell = React.forwardRef< const TableCaption = React.forwardRef<HTMLTableCaptionElement, React.HTMLAttributes<HTMLTableCaptionElement>>(
HTMLTableCellElement, ({ className, ...props }, ref) => (
React.TdHTMLAttributes<HTMLTableCellElement> <caption ref={ref} className={cn("mt-4 text-sm text-muted-foreground", className)} {...props} />
>(({ className, ...props }, ref) => ( )
<td )
ref={ref} TableCaption.displayName = "TableCaption"
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 }

View File

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

View File

@@ -1,23 +1,21 @@
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>( const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(({ className, ...props }, ref) => {
({ className, ...props }, ref) => { return (
return ( <textarea
<textarea className={cn(
className={cn( "flex min-h-14 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
'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
className )}
)} ref={ref}
ref={ref} {...props}
{...props} />
/> )
) })
} Textarea.displayName = "Textarea"
)
Textarea.displayName = 'Textarea'
export { Textarea } export { Textarea }

View File

@@ -8,105 +8,89 @@ 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-screen 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-dvh w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className 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 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", "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",
{ {
variants: { variants: {
variant: { variant: {
default: "border bg-background text-foreground", default: "border bg-background text-foreground",
destructive: destructive: "destructive group border-destructive bg-destructive text-destructive-foreground",
"destructive group border-destructive bg-destructive text-destructive-foreground", },
}, },
}, defaultVariants: {
defaultVariants: { variant: "default",
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> & React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & VariantProps<typeof toastVariants>
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => { >(({ className, variant, ...props }, ref) => {
return ( return <ToastPrimitives.Root ref={ref} className={cn(toastVariants({ variant }), className)} {...props} />
<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 <ToastPrimitives.Title ref={ref} className={cn("text-sm font-semibold", className)} {...props} />
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 <ToastPrimitives.Description ref={ref} className={cn("text-sm opacity-90", className)} {...props} />
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
)) ))
ToastDescription.displayName = ToastPrimitives.Description.displayName ToastDescription.displayName = ToastPrimitives.Description.displayName
@@ -115,13 +99,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,
} }

View File

@@ -1,33 +1,24 @@
import { import { Toast, ToastClose, ToastDescription, ToastProvider, ToastTitle, ToastViewport } from "@/components/ui/toast"
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 && ( {description && <ToastDescription>{description}</ToastDescription>}
<ToastDescription>{description}</ToastDescription> </div>
)} {action}
</div> <ToastClose />
{action} </Toast>
<ToastClose /> )
</Toast> })}
) <ToastViewport />
})} </ToastProvider>
<ToastViewport /> )
</ToastProvider>
)
} }

View File

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

View File

@@ -1,130 +1,125 @@
// Inspired by react-hot-toast library // Inspired by react-hot-toast library
import * as React from "react" import * as React from "react"
import type { import type { ToastActionElement, ToastProps } from "@/components/ui/toast"
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) => toasts: state.toasts.map((t) => (t.id === action.toast.id ? { ...t, ...action.toast } : 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> = []
@@ -132,61 +127,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 }

View File

@@ -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 56.48% 42.35%; --destructive: 0 59% 46%;
--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 {

View File

@@ -0,0 +1,66 @@
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")
}
}

View File

@@ -0,0 +1,77 @@
[
{
"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": "🇭🇰"
}
]

View File

@@ -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,7 +35,10 @@ $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")

View File

@@ -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,14 +104,11 @@ export function useIntersectionObserver({
const observer = new IntersectionObserver( const observer = new IntersectionObserver(
(entries: IntersectionObserverEntry[]): void => { (entries: IntersectionObserverEntry[]): void => {
const thresholds = Array.isArray(observer.thresholds) const thresholds = Array.isArray(observer.thresholds) ? observer.thresholds : [observer.thresholds]
? observer.thresholds
: [observer.thresholds]
entries.forEach((entry) => { entries.forEach((entry) => {
const isIntersecting = const isIntersecting =
entry.isIntersecting && entry.isIntersecting && thresholds.some((threshold) => entry.intersectionRatio >= threshold)
thresholds.some((threshold) => entry.intersectionRatio >= threshold)
setState({ isIntersecting, entry }) setState({ isIntersecting, entry })
@@ -149,13 +146,7 @@ export function useIntersectionObserver({
const prevRef = useRef<Element | null>(null) const prevRef = useRef<Element | null>(null)
useEffect(() => { useEffect(() => {
if ( if (!ref && state.entry?.target && !freeze && !frozen && prevRef.current !== state.entry.target) {
!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 })
} }

View File

@@ -1,16 +1,20 @@
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 { AlertRecord, ChartTimeData, ChartTimes, SystemRecord } from '@/types' import { AlertInfo, 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
@@ -18,7 +22,7 @@ export async function copyToClipboard(content: string) {
await navigator.clipboard.writeText(content) await navigator.clipboard.writeText(content)
toast({ toast({
duration, duration,
description: 'Copied to clipboard', description: t`Copied to clipboard`,
}) })
} catch (e: any) { } catch (e: any) {
$copyContent.set(content) $copyContent.set(content)
@@ -26,22 +30,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: 'Failed to authenticate', title: t`Failed to authenticate`,
description: 'Please log in again', description: t`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 {
@@ -50,71 +54,51 @@ export const updateSystemList = async () => {
} }
export const updateAlerts = () => { export const updateAlerts = () => {
pb.collection('alerts') pb.collection("alerts")
.getFullList<AlertRecord>({ fields: 'id,name,system,value' }) .getFullList<AlertRecord>({ fields: "id,name,system,value,min,triggered", sort: "updated" })
.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>( export function updateRecordList<T extends RecordModel>(e: RecordSubscription<T>, $store: WritableAtom<T[]>) {
e: RecordSubscription<T>,
$store: WritableAtom<T[]>
) {
const curRecords = $store.get() const curRecords = $store.get()
const newRecords = [] const newRecords = []
// console.log('e', e) if (e.action === "delete") {
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)
@@ -139,51 +123,51 @@ export function updateRecordList<T extends RecordModel>(
export function getPbTimestamp(timeString: ChartTimes, d?: Date) { export function getPbTimestamp(timeString: ChartTimes, d?: Date) {
d ||= chartTimeData[timeString].getOffset(new Date()) 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: '1 hour', label: () => t`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: '12 hours', label: () => t`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: '24 hours', label: () => t`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: '1 week', label: () => t`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: '30 days', label: () => t`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),
@@ -198,8 +182,8 @@ 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(() => {
@@ -224,17 +208,18 @@ export function toFixedFloat(num: number, digits: number) {
return parseFloat(num.toFixed(digits)) return parseFloat(num.toFixed(digits))
} }
let twoDecimalFormatter: Intl.NumberFormat let decimalFormatters: Map<number, Intl.NumberFormat> = new Map()
/** Format number to two decimal places */ /** Format number to x decimal places */
export function twoDecimalString(num: number) { export function decimalString(num: number, digits = 2) {
if (!twoDecimalFormatter) { let formatter = decimalFormatters.get(digits)
twoDecimalFormatter = new Intl.NumberFormat(undefined, { if (!formatter) {
minimumFractionDigits: 2, formatter = new Intl.NumberFormat(undefined, {
maximumFractionDigits: 2, minimumFractionDigits: digits,
maximumFractionDigits: digits,
}) })
decimalFormatters.set(digits, formatter)
} }
// Return a function that formats numbers using the saved formatter return formatter.format(num)
return twoDecimalFormatter.format(num)
} }
/** Get value from local storage */ /** Get value from local storage */
@@ -244,7 +229,7 @@ function getStorageValue(key: string, defaultValue: any) {
} }
/** Hook to sync value in local storage */ /** Hook to sync value in local storage */
export const useLocalStorage = (key: string, defaultValue: any) => { export function useLocalStorage<T>(key: string, defaultValue: T) {
key = `besz-${key}` key = `besz-${key}`
const [value, setValue] = useState(() => { const [value, setValue] = useState(() => {
return getStorageValue(key, defaultValue) return getStorageValue(key, defaultValue)
@@ -258,35 +243,77 @@ export const useLocalStorage = (key: string, defaultValue: any) => {
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 const createdSettings = await pb.collection("user_settings").create({ user: pb.authStore.model!.id })
.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 unit of size (TB or GB) for a given size in gigabytes * Get the value and unit of size (TB, GB, or MB) for a given size
* @param n size in gigabytes * @param n size in gigabytes or megabytes
* @returns unit of size (TB or GB) * @param isGigabytes boolean indicating if n represents gigabytes (true) or megabytes (false)
* @returns an object containing the value and unit of size
*/ */
export const getSizeUnit = (n: number) => (n >= 1_000 ? ' TB' : ' GB') export const getSizeAndUnit = (n: number, isGigabytes = true) => {
const sizeInGB = isGigabytes ? n : n / 1_000
/** if (sizeInGB >= 1_000) {
* Get the value of number in gigabytes if less than 1000, otherwise in terabytes return { v: sizeInGB / 1_000, u: " TB" }
* @param n size in gigabytes } else if (sizeInGB >= 1) {
* @returns value in GB if less than 1000, otherwise value in TB return { v: sizeInGB, u: " GB" }
*/ }
export const getSizeVal = (n: number) => (n >= 1_000 ? n / 1_000 : n) return { v: n, u: " MB" }
}
export const chartMargin = { top: 12 } 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`,
},
}

View File

@@ -0,0 +1,808 @@
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/system.tsx:398
msgid "Precise utilization at the recorded time"
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/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

View File

@@ -0,0 +1,808 @@
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/system.tsx:398
msgid "Precise utilization at the recorded time"
msgstr "Genaue Nutzung zum aufgezeichneten Zeitpunkt"
#: 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/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

View File

@@ -0,0 +1,803 @@
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: en\n"
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: \n"
"Last-Translator: \n"
"Language-Team: \n"
"Plural-Forms: \n"
#: src/components/routes/system.tsx:242
msgid "{0, plural, one {# day} other {# days}}"
msgstr "{0, plural, one {# day} other {# days}}"
#: src/components/routes/system.tsx:240
msgid "{hours, plural, one {# hour} other {# hours}}"
msgstr "{hours, plural, one {# hour} other {# hours}}"
#: src/lib/utils.ts:139
msgid "1 hour"
msgstr "1 hour"
#: src/lib/utils.ts:162
msgid "1 week"
msgstr "1 week"
#: src/lib/utils.ts:147
msgid "12 hours"
msgstr "12 hours"
#: src/lib/utils.ts:155
msgid "24 hours"
msgstr "24 hours"
#: src/lib/utils.ts:170
msgid "30 days"
msgstr "30 days"
#. Table column
#: src/components/systems-table/systems-table.tsx:207
msgid "Actions"
msgstr "Actions"
#: src/components/routes/home.tsx:62
msgid "Active Alerts"
msgstr "Active Alerts"
#: src/components/add-system.tsx:74
msgid "Add <0>System</0>"
msgstr "Add <0>System</0>"
#: src/components/add-system.tsx:83
msgid "Add New System"
msgstr "Add New System"
#: src/components/add-system.tsx:167
#: src/components/add-system.tsx:178
msgid "Add system"
msgstr "Add system"
#: src/components/routes/settings/notifications.tsx:156
msgid "Add URL"
msgstr "Add URL"
#: src/components/routes/settings/general.tsx:81
msgid "Adjust display options for charts."
msgstr "Adjust display options for charts."
#: 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 "Alerts"
#: src/components/alerts/alert-button.tsx:88
#: src/components/systems-table/systems-table.tsx:317
msgid "All Systems"
msgstr "All Systems"
#: src/components/systems-table/systems-table.tsx:261
msgid "Are you sure you want to delete {name}?"
msgstr "Are you sure you want to delete {name}?"
#: src/components/command-palette.tsx:186
#: src/components/navbar.tsx:102
msgid "Auth Providers"
msgstr "Auth Providers"
#: src/components/copy-to-clipboard.tsx:16
msgid "Automatic copy requires a secure context."
msgstr "Automatic copy requires a secure context."
#: src/components/routes/system.tsx:568
msgid "Average"
msgstr "Average"
#: src/components/routes/system.tsx:387
msgid "Average CPU utilization of containers"
msgstr "Average CPU utilization of containers"
#: src/components/alerts/alerts-system.tsx:204
msgid "Average exceeds <0>{value}{0}</0>"
msgstr "Average exceeds <0>{value}{0}</0>"
#: src/components/routes/system.tsx:376
msgid "Average system-wide CPU utilization"
msgstr "Average system-wide CPU utilization"
#: 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 "Bandwidth"
#: src/components/login/auth-form.tsx:313
msgid "Beszel supports OpenID Connect and many OAuth2 authentication providers."
msgstr "Beszel supports OpenID Connect and many OAuth2 authentication providers."
#: src/components/routes/settings/notifications.tsx:127
msgid "Beszel uses <0>Shoutrrr</0> to integrate with popular notification services."
msgstr "Beszel uses <0>Shoutrrr</0> to integrate with popular notification services."
#: src/components/add-system.tsx:88
msgid "Binary"
msgstr "Binary"
#: src/components/charts/mem-chart.tsx:89
msgid "Cache / Buffers"
msgstr "Cache / Buffers"
#: src/components/systems-table/systems-table.tsx:272
msgid "Cancel"
msgstr "Cancel"
#: src/components/routes/settings/config-yaml.tsx:68
msgid "Caution - potential data loss"
msgstr "Caution - potential data loss"
#: src/components/routes/settings/general.tsx:36
msgid "Change general application options."
msgstr "Change general application options."
#: src/components/routes/settings/general.tsx:78
msgid "Chart options"
msgstr "Chart options"
#: src/components/login/forgot-pass-form.tsx:34
msgid "Check {email} for a reset link."
msgstr "Check {email} for a reset link."
#: src/components/routes/settings/layout.tsx:40
msgid "Check logs for more details."
msgstr "Check logs for more details."
#: src/components/routes/settings/notifications.tsx:183
msgid "Check your notification service"
msgstr "Check your notification service"
#: src/components/add-system.tsx:153
msgid "Click to copy"
msgstr "Click to copy"
#. Context: table columns
#: src/components/systems-table/systems-table.tsx:328
msgid "Columns"
msgstr "Columns"
#: src/components/login/forgot-pass-form.tsx:83
#: src/components/login/forgot-pass-form.tsx:89
msgid "Command line instructions"
msgstr "Command line instructions"
#: src/components/routes/settings/notifications.tsx:77
msgid "Configure how you receive alert notifications."
msgstr "Configure how you receive alert notifications."
#: src/components/login/auth-form.tsx:189
#: src/components/login/auth-form.tsx:194
msgid "Confirm password"
msgstr "Confirm password"
#: src/components/systems-table/systems-table.tsx:278
msgid "Continue"
msgstr "Continue"
#: src/lib/utils.ts:25
msgid "Copied to clipboard"
msgstr "Copied to clipboard"
#: src/components/add-system.tsx:164
msgid "Copy"
msgstr "Copy"
#: src/components/systems-table/systems-table.tsx:247
msgid "Copy host"
msgstr "Copy host"
#: src/components/add-system.tsx:175
msgid "Copy Linux command"
msgstr "Copy Linux command"
#: src/components/copy-to-clipboard.tsx:13
msgid "Copy text"
msgstr "Copy text"
#: 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 Usage"
#: src/components/login/auth-form.tsx:215
msgid "Create account"
msgstr "Create account"
#. Dark theme
#: src/components/mode-toggle.tsx:21
msgid "Dark"
msgstr "Dark"
#: 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 "Default time period"
#: src/components/systems-table/systems-table.tsx:253
msgid "Delete"
msgstr "Delete"
#: src/components/systems-table/systems-table.tsx:166
msgid "Disk"
msgstr "Disk"
#: src/components/routes/system.tsx:426
msgid "Disk I/O"
msgstr "Disk I/O"
#: src/components/charts/disk-chart.tsx:74
#: src/components/routes/system.tsx:415
#: src/lib/utils.ts:301
msgid "Disk Usage"
msgstr "Disk Usage"
#: src/components/routes/system.tsx:495
msgid "Disk usage of {extraFsName}"
msgstr "Disk usage of {extraFsName}"
#: src/components/routes/system.tsx:386
msgid "Docker CPU Usage"
msgstr "Docker CPU Usage"
#: src/components/routes/system.tsx:407
msgid "Docker Memory Usage"
msgstr "Docker Memory Usage"
#: src/components/routes/system.tsx:452
msgid "Docker Network I/O"
msgstr "Docker Network I/O"
#: src/components/command-palette.tsx:125
msgid "Documentation"
msgstr "Documentation"
#: src/components/login/auth-form.tsx:158
msgid "email"
msgstr "email"
#: src/components/login/auth-form.tsx:152
#: src/components/login/forgot-pass-form.tsx:53
msgid "Email"
msgstr "Email"
#: src/components/routes/settings/notifications.tsx:91
msgid "Email notifications"
msgstr "Email notifications"
#: src/components/login/login.tsx:36
msgid "Enter email address to reset password"
msgstr "Enter email address to reset password"
#: src/components/routes/settings/notifications.tsx:111
msgid "Enter email address..."
msgstr "Enter email address..."
#: 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 "Error"
#: src/components/routes/home.tsx:81
msgid "Exceeds {0}{1} in last {2, plural, one {# minute} other {# minutes}}"
msgstr "Exceeds {0}{1} in last {2, plural, one {# minute} other {# minutes}}"
#: 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 "Existing systems not defined in <0>config.yml</0> will be deleted. Please make regular backups."
#: src/components/routes/settings/config-yaml.tsx:93
msgid "Export configuration"
msgstr "Export configuration"
#: src/components/routes/settings/config-yaml.tsx:48
msgid "Export your current systems configuration."
msgstr "Export your current systems configuration."
#: src/lib/utils.ts:38
msgid "Failed to authenticate"
msgstr "Failed to authenticate"
#: src/components/routes/settings/layout.tsx:39
#: src/components/routes/settings/notifications.tsx:62
msgid "Failed to save settings"
msgstr "Failed to save settings"
#: src/components/routes/settings/notifications.tsx:188
msgid "Failed to send test notification"
msgstr "Failed to send test notification"
#: src/components/alerts/alerts-system.tsx:27
msgid "Failed to update alert"
msgstr "Failed to update alert"
#: 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 "For <0>{min}</0> {min, plural, one {minute} other {minutes}}"
#: src/components/login/auth-form.tsx:337
msgid "Forgot password?"
msgstr "Forgot password?"
#. Context: General settings
#: src/components/routes/settings/general.tsx:33
#: src/components/routes/settings/layout.tsx:51
msgid "General"
msgstr "General"
#: 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 "If you've lost the password to your admin account, you may reset it using the following command."
#: src/components/login/auth-form.tsx:16
msgid "Invalid email address."
msgstr "Invalid email address."
#. Linux kernel
#: src/components/routes/system.tsx:254
msgid "Kernel"
msgstr "Kernel"
#: src/components/routes/settings/general.tsx:45
msgid "Language"
msgstr "Language"
#. Light theme
#: src/components/mode-toggle.tsx:16
msgid "Light"
msgstr "Light"
#: src/components/navbar.tsx:113
msgid "Log Out"
msgstr "Log Out"
#: src/components/login/login.tsx:17
msgid "Login"
msgstr "Login"
#: src/components/login/auth-form.tsx:42
#: src/components/login/forgot-pass-form.tsx:15
msgid "Login attempt failed"
msgstr "Login attempt failed"
#: src/components/command-palette.tsx:157
#: src/components/navbar.tsx:86
msgid "Logs"
msgstr "Logs"
#: 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 "Looking instead for where to create alerts? Click the bell <0/> icons in the systems table."
#: src/components/routes/settings/layout.tsx:85
msgid "Manage display and notification preferences."
msgstr "Manage display and notification preferences."
#. 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 "Memory"
#: src/components/routes/system.tsx:397
#: src/lib/utils.ts:295
msgid "Memory Usage"
msgstr "Memory Usage"
#: src/components/routes/system.tsx:408
msgid "Memory usage of docker containers"
msgstr "Memory usage of docker containers"
#: src/components/add-system.tsx:113
msgid "Name"
msgstr "Name"
#: src/components/systems-table/systems-table.tsx:173
msgid "Net"
msgstr "Net"
#: src/components/routes/system.tsx:453
msgid "Network traffic of docker containers"
msgstr "Network traffic of docker containers"
#: src/components/routes/system.tsx:438
msgid "Network traffic of public interfaces"
msgstr "Network traffic of public interfaces"
#: src/components/command-palette.tsx:50
msgid "No results found."
msgstr "No results found."
#: src/components/systems-table/systems-table.tsx:400
msgid "No systems found."
msgstr "No systems found."
#: src/components/command-palette.tsx:111
#: src/components/routes/settings/layout.tsx:56
#: src/components/routes/settings/notifications.tsx:74
msgid "Notifications"
msgstr "Notifications"
#: src/components/login/auth-form.tsx:308
msgid "OAuth 2 / OIDC support"
msgstr "OAuth 2 / OIDC support"
#: 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 "On each restart, systems in the database will be updated to match the systems defined in the file."
#: src/components/systems-table/systems-table.tsx:219
msgid "Open menu"
msgstr "Open menu"
#: src/components/login/auth-form.tsx:227
msgid "Or continue with"
msgstr "Or continue with"
#: src/components/alerts/alert-button.tsx:109
msgid "Overwrite existing alerts"
msgstr "Overwrite existing alerts"
#: src/components/command-palette.tsx:85
msgid "Page"
msgstr "Page"
#: src/components/command-palette.tsx:72
msgid "Pages / Settings"
msgstr "Pages / Settings"
#: src/components/login/auth-form.tsx:171
#: src/components/login/auth-form.tsx:176
msgid "Password"
msgstr "Password"
#: src/components/login/auth-form.tsx:17
msgid "Password must be at least 10 characters."
msgstr "Password must be at least 10 characters."
#: src/components/login/forgot-pass-form.tsx:33
msgid "Password reset request received"
msgstr "Password reset request received"
#: 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 "Please <0>configure an SMTP server</0> to ensure alerts are delivered."
#: src/components/alerts/alerts-system.tsx:28
msgid "Please check logs for more details."
msgstr "Please check logs for more 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 "Please check your credentials and try again"
#: src/components/login/login.tsx:34
msgid "Please create an admin account"
msgstr "Please create an admin account"
#: src/components/login/auth-form.tsx:257
msgid "Please enable pop-ups for this site"
msgstr "Please enable pop-ups for this site"
#: src/lib/utils.ts:39
msgid "Please log in again"
msgstr "Please log in again"
#: src/components/login/auth-form.tsx:316
msgid "Please see <0>the documentation</0> for instructions."
msgstr "Please see <0>the documentation</0> for instructions."
#: src/components/login/login.tsx:38
msgid "Please sign in to your account"
msgstr "Please sign in to your account"
#: src/components/add-system.tsx:125
msgid "Port"
msgstr "Port"
#: src/components/routes/system.tsx:398
msgid "Precise utilization at the recorded time"
msgstr "Precise utilization at the recorded time"
#: src/components/routes/settings/general.tsx:58
msgid "Preferred Language"
msgstr "Preferred Language"
#. Use 'Key' if your language requires many more characters
#: src/components/add-system.tsx:131
msgid "Public Key"
msgstr "Public Key"
#. Context is disk read
#: src/components/charts/area-chart.tsx:56
#: src/components/charts/area-chart.tsx:65
msgid "Read"
msgstr "Read"
#. Context is network bytes received (download)
#: src/components/charts/area-chart.tsx:61
msgid "Received"
msgstr "Received"
#: src/components/login/forgot-pass-form.tsx:76
msgid "Reset Password"
msgstr "Reset Password"
#: src/components/systems-table/systems-table.tsx:236
msgid "Resume"
msgstr "Resume"
#: src/components/routes/settings/notifications.tsx:117
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
msgstr "Save address using enter key or comma. Leave blank to disable email notifications."
#: src/components/routes/settings/general.tsx:106
#: src/components/routes/settings/notifications.tsx:167
msgid "Save Settings"
msgstr "Save Settings"
#: src/components/navbar.tsx:142
msgid "Search"
msgstr "Search"
#: src/components/command-palette.tsx:47
msgid "Search for systems or settings..."
msgstr "Search for systems or settings..."
#: src/components/alerts/alert-button.tsx:71
msgid "See <0>notification settings</0> to configure how you receive alerts."
msgstr "See <0>notification settings</0> to configure how you receive alerts."
#. Context is network bytes sent (upload)
#: src/components/charts/area-chart.tsx:60
msgid "Sent"
msgstr "Sent"
#: src/components/routes/settings/general.tsx:100
msgid "Sets the default time range for charts when a system is viewed."
msgstr "Sets the default time range for charts when a system is viewed."
#: 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 "Settings"
#: src/components/routes/settings/layout.tsx:33
msgid "Settings saved"
msgstr "Settings saved"
#: src/components/login/auth-form.tsx:215
msgid "Sign in"
msgstr "Sign in"
#: src/components/command-palette.tsx:201
msgid "SMTP settings"
msgstr "SMTP settings"
#: src/lib/utils.ts:282
msgid "Status"
msgstr "Status"
#: src/components/routes/system.tsx:467
msgid "Swap space used by the system"
msgstr "Swap space used by the system"
#: src/components/routes/system.tsx:466
msgid "Swap Usage"
msgstr "Swap Usage"
#. 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 "Systems"
#: 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 "Systems may be managed in a <0>config.yml</0> file inside your data directory."
#: src/components/routes/system.tsx:477
#: src/lib/utils.ts:314
msgid "Temperature"
msgstr "Temperature"
#: src/components/routes/system.tsx:478
msgid "Temperatures of system sensors"
msgstr "Temperatures of system sensors"
#: 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 "Test notification sent"
#: 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 "The agent must be running on the system to connect. Copy the installation command for the agent below."
#: 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 "The agent must be running on the system to connect. Copy the<0>docker-compose.yml</0> for the agent below."
#: 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 "Then log into the backend and reset your user account password in the users table."
#: 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 "This action cannot be undone. This will permanently delete all current records for {name} from the database."
#: src/components/routes/system.tsx:507
msgid "Throughput of {extraFsName}"
msgstr "Throughput of {extraFsName}"
#: src/components/routes/system.tsx:427
msgid "Throughput of root filesystem"
msgstr "Throughput of root filesystem"
#: src/components/routes/settings/notifications.tsx:106
msgid "To email(s)"
msgstr "To email(s)"
#: src/components/routes/system.tsx:350
#: src/components/routes/system.tsx:363
msgid "Toggle grid"
msgstr "Toggle grid"
#: src/components/mode-toggle.tsx:33
msgid "Toggle theme"
msgstr "Toggle theme"
#: src/lib/utils.ts:317
msgid "Triggers when any sensor exceeds a threshold"
msgstr "Triggers when any sensor exceeds a threshold"
#: src/lib/utils.ts:310
msgid "Triggers when combined up/down exceeds a threshold"
msgstr "Triggers when combined up/down exceeds a threshold"
#: src/lib/utils.ts:292
msgid "Triggers when CPU usage exceeds a threshold"
msgstr "Triggers when CPU usage exceeds a threshold"
#: src/lib/utils.ts:298
msgid "Triggers when memory usage exceeds a threshold"
msgstr "Triggers when memory usage exceeds a threshold"
#: src/lib/utils.ts:285
msgid "Triggers when status switches between up and down"
msgstr "Triggers when status switches between up and down"
#: src/lib/utils.ts:304
msgid "Triggers when usage of any disk exceeds a threshold"
msgstr "Triggers when usage of any disk exceeds a threshold"
#: src/components/systems-table/systems-table.tsx:320
msgid "Updated in real time. Click on a system to view information."
msgstr "Updated in real time. Click on a system to view information."
#: src/components/routes/system.tsx:253
msgid "Uptime"
msgstr "Uptime"
#: src/components/routes/system.tsx:494
msgid "Usage"
msgstr "Usage"
#: src/components/routes/system.tsx:415
msgid "Usage of root partition"
msgstr "Usage of root partition"
#: src/components/charts/mem-chart.tsx:65
#: src/components/charts/swap-chart.tsx:56
msgid "Used"
msgstr "Used"
#: src/components/login/auth-form.tsx:138
msgid "username"
msgstr "username"
#: src/components/login/auth-form.tsx:131
msgid "Username"
msgstr "Username"
#: src/components/command-palette.tsx:143
#: src/components/navbar.tsx:70
msgid "Users"
msgstr "Users"
#: src/components/routes/system.tsx:603
msgid "Waiting for enough records to display"
msgstr "Waiting for enough records to display"
#: 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 "Want to help us make our translations even better? Check out <0>Crowdin</0> for more details."
#: src/components/routes/settings/notifications.tsx:124
msgid "Webhook / Push notifications"
msgstr "Webhook / Push notifications"
#. Context is disk write
#: src/components/charts/area-chart.tsx:55
#: src/components/charts/area-chart.tsx:66
msgid "Write"
msgstr "Write"
#: src/components/routes/settings/layout.tsx:61
msgid "YAML Config"
msgstr "YAML Config"
#: src/components/routes/settings/config-yaml.tsx:45
msgid "YAML Configuration"
msgstr "YAML Configuration"
#: src/components/routes/settings/layout.tsx:34
msgid "Your user settings have been updated."
msgstr "Your user settings have been updated."

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,808 @@
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: es\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: Spanish\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Crowdin-Project: beszel\n"
"X-Crowdin-Project-ID: 733311\n"
"X-Crowdin-Language: es-ES\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 {# día} other {# días}}"
#: src/components/routes/system.tsx:240
msgid "{hours, plural, one {# hour} other {# hours}}"
msgstr "{hours, plural, one {# hora} other {# horas}}"
#: src/lib/utils.ts:139
msgid "1 hour"
msgstr "1 hora"
#: src/lib/utils.ts:162
msgid "1 week"
msgstr "1 semana"
#: src/lib/utils.ts:147
msgid "12 hours"
msgstr "12 horas"
#: src/lib/utils.ts:155
msgid "24 hours"
msgstr "24 horas"
#: src/lib/utils.ts:170
msgid "30 days"
msgstr "30 días"
#. Table column
#: src/components/systems-table/systems-table.tsx:207
msgid "Actions"
msgstr "Acciones"
#: src/components/routes/home.tsx:62
msgid "Active Alerts"
msgstr "Alertas Activas"
#: src/components/add-system.tsx:74
msgid "Add <0>System</0>"
msgstr "Agregar <0>Sistema</0>"
#: src/components/add-system.tsx:83
msgid "Add New System"
msgstr "Agregar Nuevo Sistema"
#: src/components/add-system.tsx:167
#: src/components/add-system.tsx:178
msgid "Add system"
msgstr "Agregar sistema"
#: src/components/routes/settings/notifications.tsx:156
msgid "Add URL"
msgstr "Agregar URL"
#: src/components/routes/settings/general.tsx:81
msgid "Adjust display options for charts."
msgstr "Ajustar las opciones de visualización para los gráficos."
#: 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 "Administrador"
#: src/components/systems-table/systems-table.tsx:186
msgid "Agent"
msgstr "Agente"
#: src/components/alerts/alert-button.tsx:32
#: src/components/alerts/alert-button.tsx:68
msgid "Alerts"
msgstr "Alertas"
#: src/components/alerts/alert-button.tsx:88
#: src/components/systems-table/systems-table.tsx:317
msgid "All Systems"
msgstr "Todos los Sistemas"
#: src/components/systems-table/systems-table.tsx:261
msgid "Are you sure you want to delete {name}?"
msgstr "¿Está seguro de que desea eliminar {name}?"
#: src/components/command-palette.tsx:186
#: src/components/navbar.tsx:102
msgid "Auth Providers"
msgstr "Proveedores de Autenticación"
#: src/components/copy-to-clipboard.tsx:16
msgid "Automatic copy requires a secure context."
msgstr "La copia automática requiere un contexto seguro."
#: src/components/routes/system.tsx:568
msgid "Average"
msgstr "Promedio"
#: src/components/routes/system.tsx:387
msgid "Average CPU utilization of containers"
msgstr "Utilización promedio de CPU de los contenedores"
#: src/components/alerts/alerts-system.tsx:204
msgid "Average exceeds <0>{value}{0}</0>"
msgstr "El promedio excede <0>{value}{0}</0>"
#: src/components/routes/system.tsx:376
msgid "Average system-wide CPU utilization"
msgstr "Utilización promedio de CPU del sistema"
#: src/components/command-palette.tsx:171
#: src/components/navbar.tsx:94
msgid "Backups"
msgstr "Copias de Seguridad"
#: src/components/routes/system.tsx:436
#: src/lib/utils.ts:307
msgid "Bandwidth"
msgstr "Ancho de banda"
#: src/components/login/auth-form.tsx:313
msgid "Beszel supports OpenID Connect and many OAuth2 authentication providers."
msgstr "Beszel admite OpenID Connect y muchos proveedores de autenticación OAuth2."
#: src/components/routes/settings/notifications.tsx:127
msgid "Beszel uses <0>Shoutrrr</0> to integrate with popular notification services."
msgstr "Beszel utiliza <0>Shoutrrr</0> para integrarse con servicios populares de notificación."
#: src/components/add-system.tsx:88
msgid "Binary"
msgstr "Binario"
#: src/components/charts/mem-chart.tsx:89
msgid "Cache / Buffers"
msgstr "Caché / Buffers"
#: src/components/systems-table/systems-table.tsx:272
msgid "Cancel"
msgstr "Cancelar"
#: src/components/routes/settings/config-yaml.tsx:68
msgid "Caution - potential data loss"
msgstr "Precaución - posible pérdida de datos"
#: src/components/routes/settings/general.tsx:36
msgid "Change general application options."
msgstr "Cambiar las opciones generales de la aplicación."
#: src/components/routes/settings/general.tsx:78
msgid "Chart options"
msgstr "Opciones de Gráficos"
#: src/components/login/forgot-pass-form.tsx:34
msgid "Check {email} for a reset link."
msgstr "Revise {email} para un enlace de restablecimiento."
#: src/components/routes/settings/layout.tsx:40
msgid "Check logs for more details."
msgstr "Revise los registros para más detalles."
#: src/components/routes/settings/notifications.tsx:183
msgid "Check your notification service"
msgstr "Verifique su servicio de notificaciones"
#: src/components/add-system.tsx:153
msgid "Click to copy"
msgstr "Haga clic para copiar"
#. Context: table columns
#: src/components/systems-table/systems-table.tsx:328
msgid "Columns"
msgstr "Columnas"
#: src/components/login/forgot-pass-form.tsx:83
#: src/components/login/forgot-pass-form.tsx:89
msgid "Command line instructions"
msgstr "Instrucciones de línea de comandos"
#: src/components/routes/settings/notifications.tsx:77
msgid "Configure how you receive alert notifications."
msgstr "Configure cómo recibe las notificaciones de alertas."
#: src/components/login/auth-form.tsx:189
#: src/components/login/auth-form.tsx:194
msgid "Confirm password"
msgstr "Confirmar contraseña"
#: src/components/systems-table/systems-table.tsx:278
msgid "Continue"
msgstr "Continuar"
#: src/lib/utils.ts:25
msgid "Copied to clipboard"
msgstr "Copiado al portapapeles"
#: src/components/add-system.tsx:164
msgid "Copy"
msgstr "Copiar"
#: src/components/systems-table/systems-table.tsx:247
msgid "Copy host"
msgstr "Copiar host"
#: src/components/add-system.tsx:175
msgid "Copy Linux command"
msgstr "Copiar comando de Linux"
#: src/components/copy-to-clipboard.tsx:13
msgid "Copy text"
msgstr "Copiar texto"
#: 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 "Uso de CPU"
#: src/components/login/auth-form.tsx:215
msgid "Create account"
msgstr "Crear cuenta"
#. Dark theme
#: src/components/mode-toggle.tsx:21
msgid "Dark"
msgstr "Oscuro"
#: src/components/command-palette.tsx:82
#: src/components/routes/home.tsx:35
msgid "Dashboard"
msgstr "Tablero"
#: src/components/routes/settings/general.tsx:85
msgid "Default time period"
msgstr "Período de tiempo predeterminado"
#: src/components/systems-table/systems-table.tsx:253
msgid "Delete"
msgstr "Eliminar"
#: src/components/systems-table/systems-table.tsx:166
msgid "Disk"
msgstr "Disco"
#: src/components/routes/system.tsx:426
msgid "Disk I/O"
msgstr "E/S de Disco"
#: src/components/charts/disk-chart.tsx:74
#: src/components/routes/system.tsx:415
#: src/lib/utils.ts:301
msgid "Disk Usage"
msgstr "Uso de Disco"
#: src/components/routes/system.tsx:495
msgid "Disk usage of {extraFsName}"
msgstr "Uso de disco de {extraFsName}"
#: src/components/routes/system.tsx:386
msgid "Docker CPU Usage"
msgstr "Uso de CPU de Docker"
#: src/components/routes/system.tsx:407
msgid "Docker Memory Usage"
msgstr "Uso de Memoria de Docker"
#: src/components/routes/system.tsx:452
msgid "Docker Network I/O"
msgstr "E/S de Red de Docker"
#: src/components/command-palette.tsx:125
msgid "Documentation"
msgstr "Documentación"
#: src/components/login/auth-form.tsx:158
msgid "email"
msgstr "correo electrónico"
#: src/components/login/auth-form.tsx:152
#: src/components/login/forgot-pass-form.tsx:53
msgid "Email"
msgstr "Correo electrónico"
#: src/components/routes/settings/notifications.tsx:91
msgid "Email notifications"
msgstr "Notificaciones por correo"
#: src/components/login/login.tsx:36
msgid "Enter email address to reset password"
msgstr "Ingrese la dirección de correo electrónico para restablecer la contraseña"
#: src/components/routes/settings/notifications.tsx:111
msgid "Enter email address..."
msgstr "Ingrese dirección de correo..."
#: 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 "Error"
#: src/components/routes/home.tsx:81
msgid "Exceeds {0}{1} in last {2, plural, one {# minute} other {# minutes}}"
msgstr "Excede {0}{1} en el último {2, plural, one {# minuto} other {# minutos}}"
#: 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 "Los sistemas existentes no definidos en <0>config.yml</0> serán eliminados. Por favor, haga copias de seguridad regularmente."
#: src/components/routes/settings/config-yaml.tsx:93
msgid "Export configuration"
msgstr "Exportar configuración"
#: src/components/routes/settings/config-yaml.tsx:48
msgid "Export your current systems configuration."
msgstr "Exporte la configuración actual de sus sistemas."
#: src/lib/utils.ts:38
msgid "Failed to authenticate"
msgstr "Error al autenticar"
#: src/components/routes/settings/layout.tsx:39
#: src/components/routes/settings/notifications.tsx:62
msgid "Failed to save settings"
msgstr "Error al guardar la configuración"
#: src/components/routes/settings/notifications.tsx:188
msgid "Failed to send test notification"
msgstr "Error al enviar la notificación de prueba"
#: src/components/alerts/alerts-system.tsx:27
msgid "Failed to update alert"
msgstr "Error al actualizar la alerta"
#: src/components/routes/system.tsx:539
#: src/components/systems-table/systems-table.tsx:324
msgid "Filter..."
msgstr "Filtrar..."
#: src/components/alerts/alerts-system.tsx:225
msgid "For <0>{min}</0> {min, plural, one {minute} other {minutes}}"
msgstr "Por <0>{min}</0> {min, plural, one {minuto} other {minutos}}"
#: src/components/login/auth-form.tsx:337
msgid "Forgot password?"
msgstr "¿Olvidó su contraseña?"
#. Context: General settings
#: src/components/routes/settings/general.tsx:33
#: src/components/routes/settings/layout.tsx:51
msgid "General"
msgstr "General"
#: 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 "Si ha perdido la contraseña de su cuenta de administrador, puede restablecerla usando el siguiente comando."
#: src/components/login/auth-form.tsx:16
msgid "Invalid email address."
msgstr "Dirección de correo electrónico no válida."
#. Linux kernel
#: src/components/routes/system.tsx:254
msgid "Kernel"
msgstr "Kernel"
#: src/components/routes/settings/general.tsx:45
msgid "Language"
msgstr "Idioma"
#. Light theme
#: src/components/mode-toggle.tsx:16
msgid "Light"
msgstr "Claro"
#: src/components/navbar.tsx:113
msgid "Log Out"
msgstr "Cerrar Sesión"
#: src/components/login/login.tsx:17
msgid "Login"
msgstr "Iniciar sesión"
#: src/components/login/auth-form.tsx:42
#: src/components/login/forgot-pass-form.tsx:15
msgid "Login attempt failed"
msgstr "Intento de inicio de sesión fallido"
#: src/components/command-palette.tsx:157
#: src/components/navbar.tsx:86
msgid "Logs"
msgstr "Registros"
#: 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 "¿Busca dónde crear alertas? Haga clic en los iconos de campana <0/> en la tabla de sistemas."
#: src/components/routes/settings/layout.tsx:85
msgid "Manage display and notification preferences."
msgstr "Administrar preferencias de visualización y notificaciones."
#. Chart select field. Please try to keep this short.
#: src/components/routes/system.tsx:571
msgid "Max 1 min"
msgstr "Máx 1 min"
#: src/components/systems-table/systems-table.tsx:159
msgid "Memory"
msgstr "Memoria"
#: src/components/routes/system.tsx:397
#: src/lib/utils.ts:295
msgid "Memory Usage"
msgstr "Uso de Memoria"
#: src/components/routes/system.tsx:408
msgid "Memory usage of docker containers"
msgstr "Uso de memoria de los contenedores de Docker"
#: src/components/add-system.tsx:113
msgid "Name"
msgstr "Nombre"
#: src/components/systems-table/systems-table.tsx:173
msgid "Net"
msgstr "Red"
#: src/components/routes/system.tsx:453
msgid "Network traffic of docker containers"
msgstr "Tráfico de red de los contenedores de Docker"
#: src/components/routes/system.tsx:438
msgid "Network traffic of public interfaces"
msgstr "Tráfico de red de interfaces públicas"
#: src/components/command-palette.tsx:50
msgid "No results found."
msgstr "No se encontraron resultados."
#: src/components/systems-table/systems-table.tsx:400
msgid "No systems found."
msgstr "No se encontraron sistemas."
#: src/components/command-palette.tsx:111
#: src/components/routes/settings/layout.tsx:56
#: src/components/routes/settings/notifications.tsx:74
msgid "Notifications"
msgstr "Notificaciones"
#: src/components/login/auth-form.tsx:308
msgid "OAuth 2 / OIDC support"
msgstr "Soporte para 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 "En cada reinicio, los sistemas en la base de datos se actualizarán para coincidir con los sistemas definidos en el archivo."
#: src/components/systems-table/systems-table.tsx:219
msgid "Open menu"
msgstr "Abrir menú"
#: src/components/login/auth-form.tsx:227
msgid "Or continue with"
msgstr "O continuar con"
#: src/components/alerts/alert-button.tsx:109
msgid "Overwrite existing alerts"
msgstr "Sobrescribir alertas existentes"
#: src/components/command-palette.tsx:85
msgid "Page"
msgstr "Página"
#: src/components/command-palette.tsx:72
msgid "Pages / Settings"
msgstr "Páginas / Configuraciones"
#: src/components/login/auth-form.tsx:171
#: src/components/login/auth-form.tsx:176
msgid "Password"
msgstr "Contraseña"
#: src/components/login/auth-form.tsx:17
msgid "Password must be at least 10 characters."
msgstr "La contraseña debe tener al menos 10 caracteres."
#: src/components/login/forgot-pass-form.tsx:33
msgid "Password reset request received"
msgstr "Solicitud de restablecimiento de contraseña recibida"
#: src/components/systems-table/systems-table.tsx:241
msgid "Pause"
msgstr "Pausar"
#: src/components/routes/settings/notifications.tsx:95
msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered."
msgstr "Por favor, <0>configure un servidor SMTP</0> para asegurar que las alertas sean entregadas."
#: src/components/alerts/alerts-system.tsx:28
msgid "Please check logs for more details."
msgstr "Por favor, revise los registros para más detalles."
#: src/components/login/auth-form.tsx:43
#: src/components/login/forgot-pass-form.tsx:16
msgid "Please check your credentials and try again"
msgstr "Por favor, verifique sus credenciales e intente de nuevo"
#: src/components/login/login.tsx:34
msgid "Please create an admin account"
msgstr "Por favor, cree una cuenta de administrador"
#: src/components/login/auth-form.tsx:257
msgid "Please enable pop-ups for this site"
msgstr "Por favor, habilite las ventanas emergentes para este sitio"
#: src/lib/utils.ts:39
msgid "Please log in again"
msgstr "Por favor, inicie sesión de nuevo"
#: src/components/login/auth-form.tsx:316
msgid "Please see <0>the documentation</0> for instructions."
msgstr "Por favor, consulte <0>la documentación</0> para obtener instrucciones."
#: src/components/login/login.tsx:38
msgid "Please sign in to your account"
msgstr "Por favor, inicie sesión en su cuenta"
#: src/components/add-system.tsx:125
msgid "Port"
msgstr "Puerto"
#: src/components/routes/system.tsx:398
msgid "Precise utilization at the recorded time"
msgstr "Utilización precisa en el momento registrado"
#: src/components/routes/settings/general.tsx:58
msgid "Preferred Language"
msgstr "Idioma Preferido"
#. Use 'Key' if your language requires many more characters
#: src/components/add-system.tsx:131
msgid "Public Key"
msgstr "Clave Pública"
#. Context is disk read
#: src/components/charts/area-chart.tsx:56
#: src/components/charts/area-chart.tsx:65
msgid "Read"
msgstr "Lectura"
#. Context is network bytes received (download)
#: src/components/charts/area-chart.tsx:61
msgid "Received"
msgstr "Recibido"
#: src/components/login/forgot-pass-form.tsx:76
msgid "Reset Password"
msgstr "Restablecer Contraseña"
#: src/components/systems-table/systems-table.tsx:236
msgid "Resume"
msgstr "Reanudar"
#: src/components/routes/settings/notifications.tsx:117
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
msgstr "Guarde la dirección usando la tecla enter o coma. Deje en blanco para desactivar las notificaciones por correo."
#: src/components/routes/settings/general.tsx:106
#: src/components/routes/settings/notifications.tsx:167
msgid "Save Settings"
msgstr "Guardar Configuración"
#: src/components/navbar.tsx:142
msgid "Search"
msgstr "Buscar"
#: src/components/command-palette.tsx:47
msgid "Search for systems or settings..."
msgstr "Buscar sistemas o configuraciones..."
#: src/components/alerts/alert-button.tsx:71
msgid "See <0>notification settings</0> to configure how you receive alerts."
msgstr "Consulte <0>configuración de notificaciones</0> para configurar cómo recibe alertas."
#. Context is network bytes sent (upload)
#: src/components/charts/area-chart.tsx:60
msgid "Sent"
msgstr "Enviado"
#: src/components/routes/settings/general.tsx:100
msgid "Sets the default time range for charts when a system is viewed."
msgstr "Establece el rango de tiempo predeterminado para los gráficos cuando se visualiza un sistema."
#: 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 "Configuración"
#: src/components/routes/settings/layout.tsx:33
msgid "Settings saved"
msgstr "Configuración guardada"
#: src/components/login/auth-form.tsx:215
msgid "Sign in"
msgstr "Iniciar sesión"
#: src/components/command-palette.tsx:201
msgid "SMTP settings"
msgstr "Configuración SMTP"
#: src/lib/utils.ts:282
msgid "Status"
msgstr "Estado"
#: src/components/routes/system.tsx:467
msgid "Swap space used by the system"
msgstr "Espacio de swap utilizado por el sistema"
#: src/components/routes/system.tsx:466
msgid "Swap Usage"
msgstr "Uso de Swap"
#. 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 "Sistema"
#: src/components/navbar.tsx:78
msgid "Systems"
msgstr "Sistemas"
#: 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 "Los sistemas pueden ser gestionados en un archivo <0>config.yml</0> dentro de su directorio de datos."
#: src/components/routes/system.tsx:477
#: src/lib/utils.ts:314
msgid "Temperature"
msgstr "Temperatura"
#: src/components/routes/system.tsx:478
msgid "Temperatures of system sensors"
msgstr "Temperaturas de los sensores del sistema"
#: src/components/routes/settings/notifications.tsx:211
msgid "Test <0>URL</0>"
msgstr "Probar <0>URL</0>"
#: src/components/routes/settings/notifications.tsx:182
msgid "Test notification sent"
msgstr "Notificación de prueba enviada"
#: 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 "El agente debe estar ejecutándose en el sistema para conectarse. Copie el comando de instalación para el agente a continuación."
#: 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 "El agente debe estar ejecutándose en el sistema para conectarse. Copie el <0>docker-compose.yml</0> para el agente a continuación."
#: 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 "Luego inicie sesión en el backend y restablezca la contraseña de su cuenta de usuario en la tabla de usuarios."
#: 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 "Esta acción no se puede deshacer. Esto eliminará permanentemente todos los registros actuales de {name} de la base de datos."
#: src/components/routes/system.tsx:507
msgid "Throughput of {extraFsName}"
msgstr "Rendimiento de {extraFsName}"
#: src/components/routes/system.tsx:427
msgid "Throughput of root filesystem"
msgstr "Rendimiento del sistema de archivos raíz"
#: src/components/routes/settings/notifications.tsx:106
msgid "To email(s)"
msgstr "A correo(s)"
#: src/components/routes/system.tsx:350
#: src/components/routes/system.tsx:363
msgid "Toggle grid"
msgstr "Alternar cuadrícula"
#: src/components/mode-toggle.tsx:33
msgid "Toggle theme"
msgstr "Alternar tema"
#: src/lib/utils.ts:317
msgid "Triggers when any sensor exceeds a threshold"
msgstr "Se activa cuando cualquier sensor supera un umbral"
#: src/lib/utils.ts:310
msgid "Triggers when combined up/down exceeds a threshold"
msgstr "Se activa cuando la suma de subida/bajada supera un umbral"
#: src/lib/utils.ts:292
msgid "Triggers when CPU usage exceeds a threshold"
msgstr "Se activa cuando el uso de CPU supera un umbral"
#: src/lib/utils.ts:298
msgid "Triggers when memory usage exceeds a threshold"
msgstr "Se activa cuando el uso de memoria supera un umbral"
#: src/lib/utils.ts:285
msgid "Triggers when status switches between up and down"
msgstr "Se activa cuando el estado cambia entre activo e inactivo"
#: src/lib/utils.ts:304
msgid "Triggers when usage of any disk exceeds a threshold"
msgstr "Se activa cuando el uso de cualquier disco supera un umbral"
#: src/components/systems-table/systems-table.tsx:320
msgid "Updated in real time. Click on a system to view information."
msgstr "Actualizado en tiempo real. Haga clic en un sistema para ver la información."
#: src/components/routes/system.tsx:253
msgid "Uptime"
msgstr "Tiempo de actividad"
#: src/components/routes/system.tsx:494
msgid "Usage"
msgstr "Uso"
#: src/components/routes/system.tsx:415
msgid "Usage of root partition"
msgstr "Uso de la partición raíz"
#: src/components/charts/mem-chart.tsx:65
#: src/components/charts/swap-chart.tsx:56
msgid "Used"
msgstr "Usado"
#: src/components/login/auth-form.tsx:138
msgid "username"
msgstr "nombre de usuario"
#: src/components/login/auth-form.tsx:131
msgid "Username"
msgstr "Nombre de usuario"
#: src/components/command-palette.tsx:143
#: src/components/navbar.tsx:70
msgid "Users"
msgstr "Usuarios"
#: src/components/routes/system.tsx:603
msgid "Waiting for enough records to display"
msgstr "Esperando suficientes registros para mostrar"
#: 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 "¿Quieres ayudarnos a mejorar nuestras traducciones? Consulta <0>Crowdin</0> para más detalles."
#: src/components/routes/settings/notifications.tsx:124
msgid "Webhook / Push notifications"
msgstr "Notificaciones Webhook / Push"
#. Context is disk write
#: src/components/charts/area-chart.tsx:55
#: src/components/charts/area-chart.tsx:66
msgid "Write"
msgstr "Escritura"
#: src/components/routes/settings/layout.tsx:61
msgid "YAML Config"
msgstr "Configuración YAML"
#: src/components/routes/settings/config-yaml.tsx:45
msgid "YAML Configuration"
msgstr "Configuración YAML"
#: src/components/routes/settings/layout.tsx:34
msgid "Your user settings have been updated."
msgstr "Su configuración de usuario ha sido actualizada."

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,808 @@
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: fr\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: French\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
"X-Crowdin-Project: beszel\n"
"X-Crowdin-Project-ID: 733311\n"
"X-Crowdin-Language: fr\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 {# jour} other {# jours}}"
#: src/components/routes/system.tsx:240
msgid "{hours, plural, one {# hour} other {# hours}}"
msgstr "{hours, plural, one {# heure} other {# heures}}"
#: src/lib/utils.ts:139
msgid "1 hour"
msgstr "1 heure"
#: src/lib/utils.ts:162
msgid "1 week"
msgstr "1 semaine"
#: src/lib/utils.ts:147
msgid "12 hours"
msgstr "12 heures"
#: src/lib/utils.ts:155
msgid "24 hours"
msgstr "24 heures"
#: src/lib/utils.ts:170
msgid "30 days"
msgstr "30 jours"
#. Table column
#: src/components/systems-table/systems-table.tsx:207
msgid "Actions"
msgstr "Actions"
#: src/components/routes/home.tsx:62
msgid "Active Alerts"
msgstr "Alertes actives"
#: src/components/add-system.tsx:74
msgid "Add <0>System</0>"
msgstr "Ajouter <0>Système</0>"
#: src/components/add-system.tsx:83
msgid "Add New System"
msgstr "Ajouter un nouveau système"
#: src/components/add-system.tsx:167
#: src/components/add-system.tsx:178
msgid "Add system"
msgstr "Ajouter un système"
#: src/components/routes/settings/notifications.tsx:156
msgid "Add URL"
msgstr "Ajouter URL"
#: src/components/routes/settings/general.tsx:81
msgid "Adjust display options for charts."
msgstr "Ajuster les options d'affichage pour les graphiques."
#: 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 "Alertes"
#: src/components/alerts/alert-button.tsx:88
#: src/components/systems-table/systems-table.tsx:317
msgid "All Systems"
msgstr "Tous les systèmes"
#: src/components/systems-table/systems-table.tsx:261
msgid "Are you sure you want to delete {name}?"
msgstr "Êtes-vous sûr de vouloir supprimer {name} ?"
#: src/components/command-palette.tsx:186
#: src/components/navbar.tsx:102
msgid "Auth Providers"
msgstr "Fournisseurs d'authentification"
#: src/components/copy-to-clipboard.tsx:16
msgid "Automatic copy requires a secure context."
msgstr "La copie automatique nécessite un contexte sécurisé."
#: src/components/routes/system.tsx:568
msgid "Average"
msgstr "Moyenne"
#: src/components/routes/system.tsx:387
msgid "Average CPU utilization of containers"
msgstr "Utilisation moyenne du CPU des conteneurs"
#: src/components/alerts/alerts-system.tsx:204
msgid "Average exceeds <0>{value}{0}</0>"
msgstr "La moyenne dépasse <0>{value}{0}</0>"
#: src/components/routes/system.tsx:376
msgid "Average system-wide CPU utilization"
msgstr "Utilisation moyenne du CPU à l'échelle du système"
#: src/components/command-palette.tsx:171
#: src/components/navbar.tsx:94
msgid "Backups"
msgstr "Sauvegardes"
#: src/components/routes/system.tsx:436
#: src/lib/utils.ts:307
msgid "Bandwidth"
msgstr "Bande passante"
#: src/components/login/auth-form.tsx:313
msgid "Beszel supports OpenID Connect and many OAuth2 authentication providers."
msgstr "Beszel prend en charge OpenID Connect et de nombreux fournisseurs d'authentification OAuth2."
#: src/components/routes/settings/notifications.tsx:127
msgid "Beszel uses <0>Shoutrrr</0> to integrate with popular notification services."
msgstr "Beszel utilise <0>Shoutrrr</0> pour s'intégrer aux services de notification populaires."
#: src/components/add-system.tsx:88
msgid "Binary"
msgstr "Binaire"
#: src/components/charts/mem-chart.tsx:89
msgid "Cache / Buffers"
msgstr "Cache / Tampons"
#: src/components/systems-table/systems-table.tsx:272
msgid "Cancel"
msgstr "Annuler"
#: src/components/routes/settings/config-yaml.tsx:68
msgid "Caution - potential data loss"
msgstr "Attention - perte de données potentielle"
#: src/components/routes/settings/general.tsx:36
msgid "Change general application options."
msgstr "Modifier les options générales de l'application."
#: src/components/routes/settings/general.tsx:78
msgid "Chart options"
msgstr "Options de graphique"
#: src/components/login/forgot-pass-form.tsx:34
msgid "Check {email} for a reset link."
msgstr "Vérifiez {email} pour un lien de réinitialisation."
#: src/components/routes/settings/layout.tsx:40
msgid "Check logs for more details."
msgstr "Vérifiez les journaux pour plus de détails."
#: src/components/routes/settings/notifications.tsx:183
msgid "Check your notification service"
msgstr "Vérifiez votre service de notification"
#: src/components/add-system.tsx:153
msgid "Click to copy"
msgstr "Cliquez pour copier"
#. Context: table columns
#: src/components/systems-table/systems-table.tsx:328
msgid "Columns"
msgstr "Colonnes"
#: src/components/login/forgot-pass-form.tsx:83
#: src/components/login/forgot-pass-form.tsx:89
msgid "Command line instructions"
msgstr "Instructions en ligne de commande"
#: src/components/routes/settings/notifications.tsx:77
msgid "Configure how you receive alert notifications."
msgstr "Configurez comment vous recevez les notifications d'alerte."
#: src/components/login/auth-form.tsx:189
#: src/components/login/auth-form.tsx:194
msgid "Confirm password"
msgstr "Confirmer le mot de passe"
#: src/components/systems-table/systems-table.tsx:278
msgid "Continue"
msgstr "Continuer"
#: src/lib/utils.ts:25
msgid "Copied to clipboard"
msgstr "Copié dans le presse-papiers"
#: src/components/add-system.tsx:164
msgid "Copy"
msgstr "Copier"
#: src/components/systems-table/systems-table.tsx:247
msgid "Copy host"
msgstr "Copier l'hôte"
#: src/components/add-system.tsx:175
msgid "Copy Linux command"
msgstr "Copier la commande Linux"
#: src/components/copy-to-clipboard.tsx:13
msgid "Copy text"
msgstr "Copier le texte"
#: 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 "Utilisation du CPU"
#: src/components/login/auth-form.tsx:215
msgid "Create account"
msgstr "Créer un compte"
#. Dark theme
#: src/components/mode-toggle.tsx:21
msgid "Dark"
msgstr "Sombre"
#: src/components/command-palette.tsx:82
#: src/components/routes/home.tsx:35
msgid "Dashboard"
msgstr "Tableau de bord"
#: src/components/routes/settings/general.tsx:85
msgid "Default time period"
msgstr "Période par défaut"
#: src/components/systems-table/systems-table.tsx:253
msgid "Delete"
msgstr "Supprimer"
#: src/components/systems-table/systems-table.tsx:166
msgid "Disk"
msgstr "Disque"
#: src/components/routes/system.tsx:426
msgid "Disk I/O"
msgstr "Entrée/Sortie disque"
#: src/components/charts/disk-chart.tsx:74
#: src/components/routes/system.tsx:415
#: src/lib/utils.ts:301
msgid "Disk Usage"
msgstr "Utilisation du disque"
#: src/components/routes/system.tsx:495
msgid "Disk usage of {extraFsName}"
msgstr "Utilisation du disque de {extraFsName}"
#: src/components/routes/system.tsx:386
msgid "Docker CPU Usage"
msgstr "Utilisation du CPU Docker"
#: src/components/routes/system.tsx:407
msgid "Docker Memory Usage"
msgstr "Utilisation de la mémoire Docker"
#: src/components/routes/system.tsx:452
msgid "Docker Network I/O"
msgstr "Entrée/Sortie réseau Docker"
#: src/components/command-palette.tsx:125
msgid "Documentation"
msgstr "Documentation"
#: src/components/login/auth-form.tsx:158
msgid "email"
msgstr "email"
#: src/components/login/auth-form.tsx:152
#: src/components/login/forgot-pass-form.tsx:53
msgid "Email"
msgstr "Email"
#: src/components/routes/settings/notifications.tsx:91
msgid "Email notifications"
msgstr "Notifications par email"
#: src/components/login/login.tsx:36
msgid "Enter email address to reset password"
msgstr "Entrez l'adresse email pour réinitialiser le mot de passe"
#: src/components/routes/settings/notifications.tsx:111
msgid "Enter email address..."
msgstr "Entrez l'adresse email..."
#: 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 "Erreur"
#: src/components/routes/home.tsx:81
msgid "Exceeds {0}{1} in last {2, plural, one {# minute} other {# minutes}}"
msgstr "Dépasse {0}{1} dans la dernière {2, plural, one {# minute} other {# minutes}}"
#: 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 "Les systèmes existants non définis dans <0>config.yml</0> seront supprimés. Veuillez faire des sauvegardes régulières."
#: src/components/routes/settings/config-yaml.tsx:93
msgid "Export configuration"
msgstr "Exporter la configuration"
#: src/components/routes/settings/config-yaml.tsx:48
msgid "Export your current systems configuration."
msgstr "Exportez la configuration actuelle de vos systèmes."
#: src/lib/utils.ts:38
msgid "Failed to authenticate"
msgstr "Échec de l'authentification"
#: src/components/routes/settings/layout.tsx:39
#: src/components/routes/settings/notifications.tsx:62
msgid "Failed to save settings"
msgstr "Échec de l'enregistrement des paramètres"
#: src/components/routes/settings/notifications.tsx:188
msgid "Failed to send test notification"
msgstr "Échec de l'envoi de la notification de test"
#: src/components/alerts/alerts-system.tsx:27
msgid "Failed to update alert"
msgstr "Échec de la mise à jour de l'alerte"
#: src/components/routes/system.tsx:539
#: src/components/systems-table/systems-table.tsx:324
msgid "Filter..."
msgstr "Filtrer..."
#: src/components/alerts/alerts-system.tsx:225
msgid "For <0>{min}</0> {min, plural, one {minute} other {minutes}}"
msgstr "Pour <0>{min}</0> {min, plural, one {minute} other {minutes}}"
#: src/components/login/auth-form.tsx:337
msgid "Forgot password?"
msgstr "Mot de passe oublié ?"
#. Context: General settings
#: src/components/routes/settings/general.tsx:33
#: src/components/routes/settings/layout.tsx:51
msgid "General"
msgstr "Général"
#: src/components/add-system.tsx:119
msgid "Host / IP"
msgstr "Hôte / 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 "Si vous avez perdu le mot de passe de votre compte administrateur, vous pouvez le réinitialiser en utilisant la commande suivante."
#: src/components/login/auth-form.tsx:16
msgid "Invalid email address."
msgstr "Adresse email invalide."
#. Linux kernel
#: src/components/routes/system.tsx:254
msgid "Kernel"
msgstr "Noyau"
#: src/components/routes/settings/general.tsx:45
msgid "Language"
msgstr "Langue"
#. Light theme
#: src/components/mode-toggle.tsx:16
msgid "Light"
msgstr "Clair"
#: src/components/navbar.tsx:113
msgid "Log Out"
msgstr "Déconnexion"
#: src/components/login/login.tsx:17
msgid "Login"
msgstr "Connexion"
#: src/components/login/auth-form.tsx:42
#: src/components/login/forgot-pass-form.tsx:15
msgid "Login attempt failed"
msgstr "Échec de la tentative de connexion"
#: src/components/command-palette.tsx:157
#: src/components/navbar.tsx:86
msgid "Logs"
msgstr "Journaux"
#: 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 "Vous cherchez plutôt où créer des alertes ? Cliquez sur les icônes de cloche <0/> dans le tableau des systèmes."
#: src/components/routes/settings/layout.tsx:85
msgid "Manage display and notification preferences."
msgstr "Gérer les préférences d'affichage et de notification."
#. 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 "Mémoire"
#: src/components/routes/system.tsx:397
#: src/lib/utils.ts:295
msgid "Memory Usage"
msgstr "Utilisation de la mémoire"
#: src/components/routes/system.tsx:408
msgid "Memory usage of docker containers"
msgstr "Utilisation de la mémoire des conteneurs Docker"
#: src/components/add-system.tsx:113
msgid "Name"
msgstr "Nom"
#: src/components/systems-table/systems-table.tsx:173
msgid "Net"
msgstr "Net"
#: src/components/routes/system.tsx:453
msgid "Network traffic of docker containers"
msgstr "Trafic réseau des conteneurs Docker"
#: src/components/routes/system.tsx:438
msgid "Network traffic of public interfaces"
msgstr "Trafic réseau des interfaces publiques"
#: src/components/command-palette.tsx:50
msgid "No results found."
msgstr "Aucun résultat trouvé."
#: src/components/systems-table/systems-table.tsx:400
msgid "No systems found."
msgstr "Aucun système trouvé."
#: src/components/command-palette.tsx:111
#: src/components/routes/settings/layout.tsx:56
#: src/components/routes/settings/notifications.tsx:74
msgid "Notifications"
msgstr "Notifications"
#: src/components/login/auth-form.tsx:308
msgid "OAuth 2 / OIDC support"
msgstr "Support 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 "À chaque redémarrage, les systèmes dans la base de données seront mis à jour pour correspondre aux systèmes définis dans le fichier."
#: src/components/systems-table/systems-table.tsx:219
msgid "Open menu"
msgstr "Ouvrir le menu"
#: src/components/login/auth-form.tsx:227
msgid "Or continue with"
msgstr "Ou continuer avec"
#: src/components/alerts/alert-button.tsx:109
msgid "Overwrite existing alerts"
msgstr "Écraser les alertes existantes"
#: src/components/command-palette.tsx:85
msgid "Page"
msgstr "Page"
#: src/components/command-palette.tsx:72
msgid "Pages / Settings"
msgstr "Pages / Paramètres"
#: src/components/login/auth-form.tsx:171
#: src/components/login/auth-form.tsx:176
msgid "Password"
msgstr "Mot de passe"
#: src/components/login/auth-form.tsx:17
msgid "Password must be at least 10 characters."
msgstr "Le mot de passe doit contenir au moins 10 caractères."
#: src/components/login/forgot-pass-form.tsx:33
msgid "Password reset request received"
msgstr "Demande de réinitialisation du mot de passe reçue"
#: 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 "Veuillez <0>configurer un serveur SMTP</0> pour garantir la livraison des alertes."
#: src/components/alerts/alerts-system.tsx:28
msgid "Please check logs for more details."
msgstr "Veuillez vérifier les journaux pour plus de détails."
#: src/components/login/auth-form.tsx:43
#: src/components/login/forgot-pass-form.tsx:16
msgid "Please check your credentials and try again"
msgstr "Veuillez vérifier vos identifiants et réessayer"
#: src/components/login/login.tsx:34
msgid "Please create an admin account"
msgstr "Veuillez créer un compte administrateur"
#: src/components/login/auth-form.tsx:257
msgid "Please enable pop-ups for this site"
msgstr "Veuillez activer les pop-ups pour ce site"
#: src/lib/utils.ts:39
msgid "Please log in again"
msgstr "Veuillez vous reconnecter"
#: src/components/login/auth-form.tsx:316
msgid "Please see <0>the documentation</0> for instructions."
msgstr "Veuillez consulter <0>la documentation</0> pour les instructions."
#: src/components/login/login.tsx:38
msgid "Please sign in to your account"
msgstr "Veuillez vous connecter à votre compte"
#: src/components/add-system.tsx:125
msgid "Port"
msgstr "Port"
#: src/components/routes/system.tsx:398
msgid "Precise utilization at the recorded time"
msgstr "Utilisation précise au moment enregistré"
#: src/components/routes/settings/general.tsx:58
msgid "Preferred Language"
msgstr "Langue préférée"
#. Use 'Key' if your language requires many more characters
#: src/components/add-system.tsx:131
msgid "Public Key"
msgstr "Clé publique"
#. Context is disk read
#: src/components/charts/area-chart.tsx:56
#: src/components/charts/area-chart.tsx:65
msgid "Read"
msgstr "Lecture"
#. Context is network bytes received (download)
#: src/components/charts/area-chart.tsx:61
msgid "Received"
msgstr "Reçu"
#: src/components/login/forgot-pass-form.tsx:76
msgid "Reset Password"
msgstr "Réinitialiser le mot de passe"
#: src/components/systems-table/systems-table.tsx:236
msgid "Resume"
msgstr "Reprendre"
#: src/components/routes/settings/notifications.tsx:117
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
msgstr "Enregistrez l'adresse en utilisant la touche Entrée ou la virgule. Laissez vide pour désactiver les notifications par email."
#: src/components/routes/settings/general.tsx:106
#: src/components/routes/settings/notifications.tsx:167
msgid "Save Settings"
msgstr "Enregistrer les paramètres"
#: src/components/navbar.tsx:142
msgid "Search"
msgstr "Recherche"
#: src/components/command-palette.tsx:47
msgid "Search for systems or settings..."
msgstr "Rechercher des systèmes ou des paramètres..."
#: src/components/alerts/alert-button.tsx:71
msgid "See <0>notification settings</0> to configure how you receive alerts."
msgstr "Voir les <0>paramètres de notification</0> pour configurer comment vous recevez les alertes."
#. Context is network bytes sent (upload)
#: src/components/charts/area-chart.tsx:60
msgid "Sent"
msgstr "Envoyé"
#: src/components/routes/settings/general.tsx:100
msgid "Sets the default time range for charts when a system is viewed."
msgstr "Définit la plage de temps par défaut pour les graphiques lorsqu'un système est consulté."
#: 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 "Paramètres"
#: src/components/routes/settings/layout.tsx:33
msgid "Settings saved"
msgstr "Paramètres enregistrés"
#: src/components/login/auth-form.tsx:215
msgid "Sign in"
msgstr "Se connecter"
#: src/components/command-palette.tsx:201
msgid "SMTP settings"
msgstr "Paramètres SMTP"
#: src/lib/utils.ts:282
msgid "Status"
msgstr "Statut"
#: src/components/routes/system.tsx:467
msgid "Swap space used by the system"
msgstr "Espace d'échange utilisé par le système"
#: src/components/routes/system.tsx:466
msgid "Swap Usage"
msgstr "Utilisation de l'échange"
#. 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 "Système"
#: src/components/navbar.tsx:78
msgid "Systems"
msgstr "Systèmes"
#: 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 "Les systèmes peuvent être gérés dans un fichier <0>config.yml</0> à l'intérieur de votre répertoire de données."
#: src/components/routes/system.tsx:477
#: src/lib/utils.ts:314
msgid "Temperature"
msgstr "Température"
#: src/components/routes/system.tsx:478
msgid "Temperatures of system sensors"
msgstr "Températures des capteurs du système"
#: src/components/routes/settings/notifications.tsx:211
msgid "Test <0>URL</0>"
msgstr "Tester <0>URL</0>"
#: src/components/routes/settings/notifications.tsx:182
msgid "Test notification sent"
msgstr "Notification de test envoyée"
#: 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 "L'agent doit être en cours d'exécution sur le système pour se connecter. Copiez la commande d'installation pour l'agent ci-dessous."
#: 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 "L'agent doit être en cours d'exécution sur le système pour se connecter. Copiez le <0>docker-compose.yml</0> pour l'agent ci-dessous."
#: 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 "Ensuite, connectez-vous au backend et réinitialisez le mot de passe de votre compte utilisateur dans la table des utilisateurs."
#: 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 "Cette action ne peut pas être annulée. Cela supprimera définitivement tous les enregistrements actuels pour {name} de la base de données."
#: src/components/routes/system.tsx:507
msgid "Throughput of {extraFsName}"
msgstr "Débit de {extraFsName}"
#: src/components/routes/system.tsx:427
msgid "Throughput of root filesystem"
msgstr "Débit du système de fichiers racine"
#: src/components/routes/settings/notifications.tsx:106
msgid "To email(s)"
msgstr "Aux email(s)"
#: src/components/routes/system.tsx:350
#: src/components/routes/system.tsx:363
msgid "Toggle grid"
msgstr "Basculer la grille"
#: src/components/mode-toggle.tsx:33
msgid "Toggle theme"
msgstr "Basculer le thème"
#: src/lib/utils.ts:317
msgid "Triggers when any sensor exceeds a threshold"
msgstr "Déclenchement lorsque tout capteur dépasse un seuil"
#: src/lib/utils.ts:310
msgid "Triggers when combined up/down exceeds a threshold"
msgstr "Déclenchement lorsque la montée/descente combinée dépasse un seuil"
#: src/lib/utils.ts:292
msgid "Triggers when CPU usage exceeds a threshold"
msgstr "Déclenchement lorsque l'utilisation du CPU dépasse un seuil"
#: src/lib/utils.ts:298
msgid "Triggers when memory usage exceeds a threshold"
msgstr "Déclenchement lorsque l'utilisation de la mémoire dépasse un seuil"
#: src/lib/utils.ts:285
msgid "Triggers when status switches between up and down"
msgstr "Déclenchement lorsque le statut passe de haut en bas"
#: src/lib/utils.ts:304
msgid "Triggers when usage of any disk exceeds a threshold"
msgstr "Déclenchement lorsque l'utilisation de tout disque dépasse un seuil"
#: src/components/systems-table/systems-table.tsx:320
msgid "Updated in real time. Click on a system to view information."
msgstr "Mis à jour en temps réel. Cliquez sur un système pour voir les informations."
#: src/components/routes/system.tsx:253
msgid "Uptime"
msgstr "Temps de fonctionnement"
#: src/components/routes/system.tsx:494
msgid "Usage"
msgstr "Utilisation"
#: src/components/routes/system.tsx:415
msgid "Usage of root partition"
msgstr "Utilisation de la partition racine"
#: src/components/charts/mem-chart.tsx:65
#: src/components/charts/swap-chart.tsx:56
msgid "Used"
msgstr "Utilisé"
#: src/components/login/auth-form.tsx:138
msgid "username"
msgstr "nom d'utilisateur"
#: src/components/login/auth-form.tsx:131
msgid "Username"
msgstr "Nom d'utilisateur"
#: src/components/command-palette.tsx:143
#: src/components/navbar.tsx:70
msgid "Users"
msgstr "Utilisateurs"
#: src/components/routes/system.tsx:603
msgid "Waiting for enough records to display"
msgstr "En attente de suffisamment d'enregistrements à afficher"
#: 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 "Vous voulez nous aider à améliorer nos traductions ? Consultez <0>Crowdin</0> pour plus de détails."
#: src/components/routes/settings/notifications.tsx:124
msgid "Webhook / Push notifications"
msgstr "Notifications Webhook / Push"
#. Context is disk write
#: src/components/charts/area-chart.tsx:55
#: src/components/charts/area-chart.tsx:66
msgid "Write"
msgstr "Écriture"
#: src/components/routes/settings/layout.tsx:61
msgid "YAML Config"
msgstr "Configuration YAML"
#: src/components/routes/settings/config-yaml.tsx:45
msgid "YAML Configuration"
msgstr "Configuration YAML"
#: src/components/routes/settings/layout.tsx:34
msgid "Your user settings have been updated."
msgstr "Vos paramètres utilisateur ont été mis à jour."

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