Compare commits

...

144 Commits

Author SHA1 Message Date
Henry Dollman
e5fb4d611a release 0.9.0 2024-12-17 18:55:02 -05:00
Henry Dollman
bc9dc9704c remove log from initial-settings migration 2024-12-17 18:54:52 -05:00
Henry Dollman
e88eb1a884 update docs link in command palette 2024-12-17 17:42:58 -05:00
Henry Dollman
d8f3206e8b update go dependencies 2024-12-17 17:25:01 -05:00
Henry Dollman
729d306157 update js dependencies 2024-12-17 17:24:38 -05:00
Henry Dollman
c35df48754 update language files 2024-12-17 17:20:44 -05:00
hank
0f97f37a79 New Crowdin updates (#329)
* New translations en.po (Russian)

* New translations en.po (Turkish)

* New translations en.po (Ukrainian)

* New translations en.po (Czech)

* New translations en.po (French)

* New translations en.po (Spanish)

* New translations en.po (Arabic)

* New translations en.po (German)

* New translations en.po (Italian)

* New translations en.po (Japanese)

* New translations en.po (Korean)

* New translations en.po (Dutch)

* New translations en.po (Polish)

* New translations en.po (Portuguese)

* New translations en.po (Chinese Simplified)

* New translations en.po (Vietnamese)

* New translations en.po (Croatian)

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

* New translations en.po (Persian)
2024-12-17 17:17:33 -05:00
Henry Dollman
b08219dacf refactor agent gpu code to make it easier to add intel / jetson 2024-12-17 17:12:58 -05:00
Henry Dollman
dd10fb97c0 web ui style tweaks 2024-12-17 15:47:49 -05:00
Henry Dollman
87354df2de update po files, add farsi and swedish 2024-12-17 15:36:09 -05:00
hank
1bd04498b9 New Crowdin updates (#322) 2024-12-17 15:31:28 -05:00
Henry Dollman
52394bc99b update security policy 2024-12-16 14:42:10 -05:00
Henry Dollman
add85e9747 add generate-locales to makefile 2024-12-16 13:48:17 -05:00
Henry Dollman
e82986adff add dev tasks to makefile 2024-12-15 15:35:16 -05:00
Henry Dollman
f201267e4e generate first user username from email 2024-12-09 17:34:04 -05:00
Henry Dollman
9db41f8830 update translations 2024-12-09 17:29:03 -05:00
Henry Dollman
ba64c59632 add autoComplete="off" to login honeypot field 2024-12-09 17:07:43 -05:00
Henry Dollman
d2626d8337 update translations 2024-12-09 17:04:40 -05:00
hank
ded1090190 New Crowdin updates (#318)
* New translations en.po (Arabic)

* New translations en.po (Dutch)

Co-authored-by: Mathy Vandersmissen <mathy.vds@gmail.com>
2024-12-09 16:24:45 -05:00
hank
1114baaaa0 New Crowdin updates (#313)
* New translations en.po (German)

* New translations en.po (Czech)

Co-authored-by: doluk <69309597+doluk@users.noreply.github.com>
Co-authored-by: NickAss512 <143963746+NickAss512@users.noreply.github.com>
2024-12-08 18:18:53 -05:00
Henry Dollman
cf13c1c671 Merge branch '0xMMMMMM-main' 2024-12-08 18:09:20 -05:00
Henry Dollman
e70de6a59e systems table updates
- component refactoring
- style updates for "view" menu and grid
2024-12-08 18:08:54 -05:00
0xMMMMMM
5110eaf10f refactor: reorganize systems table options into single dropdown
Combines view type, sort by and visible fields into a single dropdown menu for better organization.
2024-12-08 06:26:12 +07:00
0xMMMMMM
0234682720 feat: add grid view option for systems table
- Add toggle button to switch between table and grid layouts
- Implement card-based grid view with system metrics
- Add sort dropdown menu for grid view
- Display system status, metrics with icons and labels
- Improve handling of long system names
- Maintain consistent sorting and filtering between views
- Persist view preference in localStorage
2024-12-06 15:09:37 +07:00
Henry Dollman
80a7322fa1 update readme screenshot 2024-12-04 18:10:27 -05:00
Henry Dollman
59bdc0ce0d add USER_CREATION env var and update migrations 2024-12-04 17:36:36 -05:00
Spedon
a288d0925b chore: enhance script usability (#311)
- Add a `RestartSec` directive to the service configuration for both agent and hub scripts

Signed-off-by: Spedon Wen <realsped0n@outlook.com>
2024-12-03 17:29:19 -05:00
hank
e7d2f0d82b New Crowdin updates (#308)
* New translations en.po

Co-authored-by: NickAss512 <143963746+NickAss512@users.noreply.github.com>
2024-12-02 15:14:20 -05:00
Henry Dollman
825d8269ff add Czech language and update locale files 2024-12-01 18:21:34 -05:00
hank
f7775d173a New Crowdin updates (#301)
* New translations en.po (Turkish)

* New translations en.po (Czech)

Co-authored-by: Ramazan Sancar <ramazansancar4545@gmail.com>
Co-authored-by: NickAss512 <@NickAss512>
2024-12-01 16:11:25 -05:00
Henry Dollman
58bced5f09 rm 'auth providers' links - dedicated page removed from pocketbase 2024-11-29 17:24:42 -05:00
Henry Dollman
6e08507dde remove todo comments 2024-11-29 17:16:40 -05:00
Henry Dollman
617a03fc15 upgrade go deps 2024-11-29 17:08:59 -05:00
Henry Dollman
f86bda304d pocketbase js updates 2024-11-29 17:03:31 -05:00
Henry Dollman
1d414e659b use auth logos from pocketbase 2024-11-29 16:57:18 -05:00
Henry Dollman
87f7390eca use new batch api for setting global alerts 2024-11-27 17:07:44 -05:00
Henry Dollman
ed01752546 update admin creation for pocketbase 0.23.0 2024-11-27 16:32:23 -05:00
Henry Dollman
46002a2171 remove echo dependency 2024-11-24 18:34:42 -05:00
Henry Dollman
14716d36a6 refactor go code for pocketbase 0.23.0 (#300) 2024-11-24 18:15:24 -05:00
Henry Dollman
b4bc8a31aa add check / reset for invalid disk i/o rates 2024-11-24 15:56:12 -05:00
hank
b01fc316c3 New Crowdin updates (#293)
* New translations en.po (Turkish)

* New translations en.po (Russian)

* New translations en.po (Ukrainian)

Co-authored-by: zoixc <113115585+zoixc@users.noreply.github.com>
Co-authored-by: stanol <stanol777@gmail.com>
2024-11-22 13:11:26 -05:00
Henry Dollman
4479249ac7 feat: add --china-mirrors flag when locale is zh-CN in install command 2024-11-19 14:42:56 -05:00
Henry Dollman
0529837ac8 update agent install script arg parsing 2024-11-19 14:31:50 -05:00
Henry Dollman
d51ffa17ed Merge branch 'main' of https://github.com/Alice39s/beszel 2024-11-19 13:45:50 -05:00
al1cE
c434a44bc4 feat: use --china-mirrors instead of -c 2024-11-19 13:30:40 +09:00
al1cE
7b5ac23a4b feat(install): enhance install-agent.sh with China mirror support
- Add `-c` flag to use GitHub mirror (ghp.ci) for mainland China users
- Implement checksum verification for downloaded files
- Add progress bar for file downloads

This change improves installation reliability and user experience,
especially for users in regions (China mainland etc.) with limited GitHub access.
2024-11-19 12:56:35 +09:00
Henry Dollman
87ef769086 Merge branch 'main' of https://github.com/wwng2333/beszel 2024-11-17 11:23:06 -05:00
D
bcefb8e43c fix IPv6 connection problem
use net.JoinHostPort to fix ipv6 address at createSystemConnection
2024-11-17 16:34:05 +08:00
Henry Dollman
a1641c5bcc change ssh related timeouts from 5s to 4s 2024-11-15 16:29:36 -05:00
Henry Dollman
e6839480d9 use sync.Map for system connections 2024-11-15 15:35:26 -05:00
hank
4e64d9efad New Crowdin updates (#285)
* New translations en.po (Dutch)

* New translations en.po (Ukrainian)

* New translations en.po (German)

Co-authored-by: Mathy Vandersmissen <mathy.vds@gmail.com>
Co-authored-by: stanol <stanol777@gmail.com>
Co-authored-by: Henry <henry@obamium.net>
2024-11-14 15:28:38 -05:00
hank
d68f4514cc New Crowdin updates (#282)
* New translations en.po (Chinese Simplified)

Co-authored-by: Sliots <Sliots@hotmail.com>
2024-11-13 12:02:29 -05:00
Henry Dollman
8a69c09939 release 0.8.0 2024-11-12 18:28:25 -05:00
Henry Dollman
e87af81db4 change spinner to visibility hidden after chart loads 2024-11-12 18:25:27 -05:00
Henry Dollman
6043c59da8 update translations 2024-11-12 18:19:37 -05:00
Henry Dollman
4cb7b97416 change podman socket path to use current uid 2024-11-12 18:14:43 -05:00
Henry Dollman
b1db450e00 enable gpu monitoring by default 2024-11-12 18:13:57 -05:00
Henry Dollman
2e8ac98924 Improve disk discovery slightly by checking partition labels 2024-11-12 18:11:44 -05:00
Henry Dollman
529a628368 remove stopped containers from net chart 2024-11-12 17:33:18 -05:00
Henry Dollman
8a2e821c8f style updates 2024-11-12 15:03:15 -05:00
Henry Dollman
3cd11d6bc4 improve podman support (#211) 2024-11-12 11:59:56 -05:00
Henry Dollman
db092d2440 Merge branch 'main' of github.com:henrygd/beszel 2024-11-11 16:56:16 -05:00
Henry Dollman
a4a7c91fc1 dark mode tweaks 2024-11-11 16:56:10 -05:00
hank
543fd44cb2 New Crowdin updates (#273)
* New translations en.po (Chinese Simplified)

Co-authored-by: D <17147265+wwng2333@users.noreply.github.com>
2024-11-11 13:32:58 -05:00
Henry Dollman
eab262c3f7 dark theme updates 2024-11-11 12:55:44 -05:00
Henry Dollman
52bde8ea6d release 0.7.4 2024-11-08 20:32:27 -05:00
Henry Dollman
03de73560c add gpu power consumption chart 2024-11-08 20:31:22 -05:00
Henry Dollman
bcb7de1b9a add Dutch language 2024-11-08 19:02:49 -05:00
hank
ca94bd32f2 New Crowdin updates (#272)
* New translations en.po (Dutch)

* New translations en.po (Dutch)

* New translations en.po (Dutch)

Co-authored-by: Mathy Vandersmissen <mathy.vds@gmail.com>
2024-11-08 18:07:05 -05:00
Henry Dollman
cd10727795 gpu usage and vram charts 2024-11-08 18:00:30 -05:00
Henry Dollman
8262a9a45b progress on gpu metrics 2024-11-08 16:52:50 -05:00
hank
b433437636 New Crowdin updates (#271)
* New translations en.po (Chinese Simplified)

* New translations en.po (Chinese Simplified)

Co-authored-by: D <17147265+wwng2333@users.noreply.github.com>
2024-11-07 15:57:07 -05:00
Henry Dollman
02825ed109 add Polish and Croatian languages 2024-11-07 13:11:58 -05:00
Henry Dollman
a97e6149bb add option for automatic updates to install-agent.sh (#268) 2024-11-07 13:02:01 -05:00
hank
946b1e7f54 New Crowdin updates (#259)
* New translations en.po (Polish)

* New translations en.po (Polish)

* New translations en.po (Polish)

* New translations en.po (Polish)

* New translations en.po (Croatian)

* New translations en.po (Croatian)

* New translations en.po (French)

* New translations en.po (French)

Co-authored-by: Dino Horvat <73183619+DinoHorvat96@users.noreply.github.com>
Co-authored-by: dymek37 <122745160+dymek37@users.noreply.github.com>
Co-authored-by: Adam Gąsowski <gander@users.noreply.github.com>
Co-authored-by: Damien Fajole <60252259+damsdev1@users.noreply.github.com>
2024-11-06 19:06:16 -05:00
Henry Dollman
b5ed7cd555 fix display of values under 1 GB in disk usage chart (closes #261) 2024-11-06 13:52:35 -05:00
Henry Dollman
233349fb2a release 0.7.3 2024-11-04 21:33:19 -05:00
Henry Dollman
c54e6ff0ea po file formatting 2024-11-04 21:33:05 -05:00
Henry Dollman
98c4102f72 revert to previous behavior for displaying stopped containers
* Previously, a stopped container was completely removed from the chart/tooltip during the time period when it was not running. In the last few releases, the container remained in the chart with zero values if it was running at any time during the chart's duration. This restores the previous functionality.
2024-11-04 21:24:44 -05:00
hank
640ee7a88e New translations en.po (Polish) (#257) 2024-11-04 20:57:01 -05:00
Henry Dollman
8a85246a0b set lang in activateLocale func instead of dynamicActivate 2024-11-04 20:56:33 -05:00
Henry Dollman
655bfc95ca add ability to specify partition for extra disk using folder name 2024-11-04 20:52:27 -05:00
hank
37a066e6bd New Crowdin updates (#256)
* 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)
2024-11-04 16:01:01 -05:00
Henry Dollman
9e959a6b7b update translations 2024-11-04 15:42:23 -05:00
Henry Dollman
2b6560b9e1 update translation messages on build
* add lingui extract to build script
* delete translation ts files and add to gitignore
2024-11-04 15:34:59 -05:00
hank
d8836d53bf New Crowdin updates (#252)
* 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)
2024-11-04 14:30:27 -05:00
Henry Dollman
aa15876aa2 fix: read/write labels swapped for extra disk charts (#254) 2024-11-04 14:29:10 -05:00
Henry Dollman
7ca960b521 update system view grid to min xl 2024-11-04 14:18:08 -05:00
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
140 changed files with 26709 additions and 4973 deletions

4
.gitignore vendored
View File

@@ -11,3 +11,7 @@ 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
beszel/site/src/locales/**/*.ts

View File

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

67
beszel/Makefile Normal file
View File

@@ -0,0 +1,67 @@
# 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 dev-server dev-agent dev-hub dev generate-locales
.DEFAULT_GOAL := build
clean:
go clean
rm -rf ./build
lint:
golangci-lint run
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
generate-locales:
@if [ ! -f ./site/src/locales/en/en.ts ]; then \
echo "Generating locales..."; \
command -v bun >/dev/null 2>&1 && cd ./site && bun install && bun run sync || cd ./site && npm install && npm run sync; \
fi
dev-server: generate-locales
cd ./site
@if command -v bun >/dev/null 2>&1; then \
cd ./site && bun run dev; \
else \
cd ./site && npm run dev; \
fi
dev-hub:
mkdir -p ./site/dist && touch ./site/dist/index.html
@if command -v entr >/dev/null 2>&1; then \
find ./cmd/hub/*.go ./internal/{alerts,hub,records,users}/*.go | entr -r -s "cd ./cmd/hub && go run . serve"; \
else \
cd ./cmd/hub && go run . serve; \
fi
dev-agent:
@if command -v entr >/dev/null 2>&1; then \
find ./cmd/agent/*.go ./internal/agent/*.go | entr -r go run beszel/cmd/agent; \
else \
go run beszel/cmd/agent; \
fi
# KEY="..." make -j dev
dev: dev-server dev-hub dev-agent

View File

@@ -3,6 +3,7 @@ package main
import ( import (
"beszel" "beszel"
"beszel/internal/hub" "beszel/internal/hub"
_ "beszel/migrations" _ "beszel/migrations"
"github.com/pocketbase/pocketbase" "github.com/pocketbase/pocketbase"

View File

@@ -1,20 +1,22 @@
module beszel module beszel
go 1.22.4 go 1.23
toolchain go1.23.2
require ( require (
github.com/blang/semver v3.5.1+incompatible github.com/blang/semver v3.5.1+incompatible
github.com/containrrr/shoutrrr v0.8.0 github.com/containrrr/shoutrrr v0.8.0
github.com/gliderlabs/ssh v0.3.7 github.com/gliderlabs/ssh v0.3.8
github.com/goccy/go-json v0.10.3 github.com/goccy/go-json v0.10.4
github.com/labstack/echo/v5 v5.0.0-20230722203903-ec5b858dab61 github.com/pocketbase/dbx v1.11.0
github.com/pocketbase/dbx v1.10.1 github.com/pocketbase/pocketbase v0.23.9
github.com/pocketbase/pocketbase v0.22.22
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.11
github.com/spf13/cast v1.7.0 github.com/spf13/cast v1.7.0
github.com/spf13/cobra v1.8.1 github.com/spf13/cobra v1.8.1
golang.org/x/crypto v0.28.0 golang.org/x/crypto v0.31.0
golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
@@ -22,41 +24,41 @@ require (
github.com/AlecAivazis/survey/v2 v2.3.7 // indirect github.com/AlecAivazis/survey/v2 v2.3.7 // indirect
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/aws/aws-sdk-go-v2 v1.32.2 // indirect github.com/aws/aws-sdk-go-v2 v1.32.6 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.6 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.7 // indirect
github.com/aws/aws-sdk-go-v2/config v1.28.0 // indirect github.com/aws/aws-sdk-go-v2/config v1.28.6 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.17.41 // indirect github.com/aws/aws-sdk-go-v2/credentials v1.17.47 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.17 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.21 // indirect
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.33 // indirect github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.43 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.21 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.25 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.21 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.25 // 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.21 // indirect github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.25 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.0 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.2 // indirect github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.6 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.2 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.6 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.2 // indirect github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.6 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.66.0 // indirect github.com/aws/aws-sdk-go-v2/service/s3 v1.71.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.24.2 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.24.7 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.2 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.6 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.32.2 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.33.2 // indirect
github.com/aws/smithy-go v1.22.0 // indirect github.com/aws/smithy-go v1.22.1 // 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.6 // indirect github.com/gabriel-vasile/mimetype v1.4.7 // 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
github.com/golang-jwt/jwt/v4 v4.5.0 // indirect github.com/golang-jwt/jwt/v4 v4.5.1 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/google/go-github/v30 v30.1.0 // indirect github.com/google/go-github/v30 v30.1.0 // indirect
github.com/google/go-querystring v1.1.0 // indirect github.com/google/go-querystring v1.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/gax-go/v2 v2.13.0 // indirect github.com/googleapis/gax-go/v2 v2.14.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf // indirect github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
@@ -64,7 +66,6 @@ require (
github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 // indirect github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.24 // indirect
github.com/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
@@ -73,32 +74,28 @@ require (
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/fasttemplate v1.2.2 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opencensus.io v0.24.0 // indirect go.opencensus.io v0.24.0 // indirect
gocloud.dev v0.40.0 // indirect gocloud.dev v0.40.0 // indirect
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c // indirect golang.org/x/image v0.23.0 // indirect
golang.org/x/image v0.21.0 // indirect golang.org/x/net v0.32.0 // indirect
golang.org/x/net v0.30.0 // indirect golang.org/x/oauth2 v0.24.0 // indirect
golang.org/x/oauth2 v0.23.0 // indirect golang.org/x/sync v0.10.0 // indirect
golang.org/x/sync v0.8.0 // indirect golang.org/x/sys v0.28.0 // indirect
golang.org/x/sys v0.26.0 // indirect golang.org/x/term v0.27.0 // indirect
golang.org/x/term v0.25.0 // indirect golang.org/x/text v0.21.0 // indirect
golang.org/x/text v0.19.0 // indirect
golang.org/x/time v0.7.0 // indirect
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
google.golang.org/api v0.201.0 // indirect google.golang.org/api v0.212.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20241015192408-796eee8c2d53 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20241216192217-9240e9c98484 // indirect
google.golang.org/grpc v1.67.1 // indirect google.golang.org/grpc v1.69.0 // indirect
google.golang.org/protobuf v1.35.1 // indirect google.golang.org/protobuf v1.36.0 // indirect
modernc.org/gc/v3 v3.0.0-20241004144649-1aea3fae8852 // indirect modernc.org/gc/v3 v3.0.0-20241213165251-3bc300f6d0c9 // indirect
modernc.org/libc v1.61.0 // indirect modernc.org/libc v1.61.4 // indirect
modernc.org/mathutil v1.6.0 // indirect modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.8.0 // indirect modernc.org/memory v1.8.0 // indirect
modernc.org/sqlite v1.33.1 // indirect modernc.org/sqlite v1.34.2 // indirect
modernc.org/strutil v1.2.0 // indirect modernc.org/strutil v1.2.0 // indirect
modernc.org/token v1.1.0 // indirect modernc.org/token v1.1.0 // indirect
) )

View File

@@ -1,13 +1,13 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE= cloud.google.com/go v0.115.0 h1:CnFSK6Xo3lDYRoBKEcAtia6VSC837/ZkJuRduSFnr14=
cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U= cloud.google.com/go v0.115.0/go.mod h1:8jIM5vVgoAEoiVxQ/O4BFTfHqulPZgs/ufEzMcFMdWU=
cloud.google.com/go/auth v0.9.8 h1:+CSJ0Gw9iVeSENVCKJoLHhdUykDgXSc4Qn+gu2BRtR8= cloud.google.com/go/auth v0.13.0 h1:8Fu8TZy167JkW8Tj3q7dIkr2v4cndv41ouecJx0PAHs=
cloud.google.com/go/auth v0.9.8/go.mod h1:xxA5AqpDrvS+Gkmo9RqrGGRh6WSNKKOXhY3zNOr38tI= cloud.google.com/go/auth v0.13.0/go.mod h1:COOjD9gwfKNKz+IIduatIhYJQIc0mG3H102r/EMxX6Q=
cloud.google.com/go/auth/oauth2adapt v0.2.4 h1:0GWE/FUsXhf6C+jAkWgYm7X9tK8cuEIfy19DBn6B6bY= cloud.google.com/go/auth/oauth2adapt v0.2.6 h1:V6a6XDu2lTwPZWOawrAa9HUK+DB2zfJyTuciBG5hFkU=
cloud.google.com/go/auth/oauth2adapt v0.2.4/go.mod h1:jC/jOpwFP6JBxhB3P5Rr0a9HLMC/Pe3eaL4NmdvqPtc= cloud.google.com/go/auth/oauth2adapt v0.2.6/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.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I=
cloud.google.com/go/compute/metadata v0.5.2/go.mod h1:C66sj2AluDcIqakBq/M8lw8/ybHgOZqin2obFxa/E5k= cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg=
cloud.google.com/go/iam v1.1.13 h1:7zWBXG9ERbMLrzQBRhFliAV+kjcRToDTgQT3CTwYyv4= cloud.google.com/go/iam v1.1.13 h1:7zWBXG9ERbMLrzQBRhFliAV+kjcRToDTgQT3CTwYyv4=
cloud.google.com/go/iam v1.1.13/go.mod h1:K8mY0uSXwEXS30KrnVb+j54LB/ntfZu1dr+4zFMNbus= cloud.google.com/go/iam v1.1.13/go.mod h1:K8mY0uSXwEXS30KrnVb+j54LB/ntfZu1dr+4zFMNbus=
cloud.google.com/go/storage v1.43.0 h1:CcxnSohZwizt4LCzQHWvBf1/kvtHUn7gk9QERXPyXFs= cloud.google.com/go/storage v1.43.0 h1:CcxnSohZwizt4LCzQHWvBf1/kvtHUn7gk9QERXPyXFs=
@@ -26,44 +26,44 @@ github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3d
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
github.com/aws/aws-sdk-go v1.55.5 h1:KKUZBfBoyqy5d3swXyiC7Q76ic40rYcbqH7qjh59kzU= github.com/aws/aws-sdk-go v1.55.5 h1:KKUZBfBoyqy5d3swXyiC7Q76ic40rYcbqH7qjh59kzU=
github.com/aws/aws-sdk-go v1.55.5/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= github.com/aws/aws-sdk-go v1.55.5/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
github.com/aws/aws-sdk-go-v2 v1.32.2 h1:AkNLZEyYMLnx/Q/mSKkcMqwNFXMAvFto9bNsHqcTduI= github.com/aws/aws-sdk-go-v2 v1.32.6 h1:7BokKRgRPuGmKkFMhEg/jSul+tB9VvXhcViILtfG8b4=
github.com/aws/aws-sdk-go-v2 v1.32.2/go.mod h1:2SK5n0a2karNTv5tbP1SjsX0uhttou00v/HpXKM1ZUo= github.com/aws/aws-sdk-go-v2 v1.32.6/go.mod h1:P5WJBrYqqbWVaOxgH0X/FYYD47/nooaPOZPlQdmiN2U=
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.7 h1:lL7IfaFzngfx0ZwUGOZdsFFnQ5uLvR0hWqqhyE7Q9M8=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.6/go.mod h1:j/I2++U0xX+cr44QjHay4Cvxj6FUbnxrgmqN3H1jTZA= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.7/go.mod h1:QraP0UcVlQJsmHfioCrveWOC1nbiWUl3ej08h4mXWoc=
github.com/aws/aws-sdk-go-v2/config v1.28.0 h1:FosVYWcqEtWNxHn8gB/Vs6jOlNwSoyOCA/g/sxyySOQ= github.com/aws/aws-sdk-go-v2/config v1.28.6 h1:D89IKtGrs/I3QXOLNTH93NJYtDhm8SYa9Q5CsPShmyo=
github.com/aws/aws-sdk-go-v2/config v1.28.0/go.mod h1:pYhbtvg1siOOg8h5an77rXle9tVG8T+BWLWAo7cOukc= github.com/aws/aws-sdk-go-v2/config v1.28.6/go.mod h1:GDzxJ5wyyFSCoLkS+UhGB0dArhb9mI+Co4dHtoTxbko=
github.com/aws/aws-sdk-go-v2/credentials v1.17.41 h1:7gXo+Axmp+R4Z+AK8YFQO0ZV3L0gizGINCOWxSLY9W8= github.com/aws/aws-sdk-go-v2/credentials v1.17.47 h1:48bA+3/fCdi2yAwVt+3COvmatZ6jUDNkDTIsqDiMUdw=
github.com/aws/aws-sdk-go-v2/credentials v1.17.41/go.mod h1:u4Eb8d3394YLubphT4jLEwN1rLNq2wFOlT6OuxFwPzU= github.com/aws/aws-sdk-go-v2/credentials v1.17.47/go.mod h1:+KdckOejLW3Ks3b0E3b5rHsr2f9yuORBum0WPnE5o5w=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.17 h1:TMH3f/SCAWdNtXXVPPu5D6wrr4G5hI1rAxbcocKfC7Q= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.21 h1:AmoU1pziydclFT/xRV+xXE/Vb8fttJCLRPv8oAkprc0=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.17/go.mod h1:1ZRXLdTpzdJb9fwTMXiLipENRxkGMTn1sfKexGllQCw= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.21/go.mod h1:AjUdLYe4Tgs6kpH4Bv7uMZo7pottoyHMn4eTcIcneaY=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.33 h1:X+4YY5kZRI/cOoSMVMGTqFXHAMg1bvvay7IBcqHpybQ= github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.43 h1:iLdpkYZ4cXIQMO7ud+cqMWR1xK5ESbt1rvN77tRi1BY=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.33/go.mod h1:DPynzu+cn92k5UQ6tZhX+wfTB4ah6QDU/NgdHqatmvk= github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.43/go.mod h1:OgbsKPAswXDd5kxnR4vZov69p3oYjbvUyIRBAAV0y9o=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.21 h1:UAsR3xA31QGf79WzpG/ixT9FZvQlh5HY1NRqSHBNOCk= github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.25 h1:s/fF4+yDQDoElYhfIVvSNyeCydfbuTKzhxSXDXCPasU=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.21/go.mod h1:JNr43NFf5L9YaG3eKTm7HQzls9J+A9YYcGI5Quh1r2Y= github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.25/go.mod h1:IgPfDv5jqFIzQSNbUEMoitNooSMXjRSDkhXv8jiROvU=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.21 h1:6jZVETqmYCadGFvrYEQfC5fAQmlo80CeL5psbno6r0s= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.25 h1:ZntTCl5EsYnhN/IygQEUugpdwbhdkom9uHcbCftiGgA=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.21/go.mod h1:1SR0GbLlnN3QUmYaflZNiH1ql+1qrSiB2vwcJ+4UM60= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.25/go.mod h1:DBdPrgeocww+CSl1C8cEV8PN1mHMBhuCDLpXezyvWkE=
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.21 h1:7edmS3VOBDhK00b/MwGtGglCm7hhwNYnjJs/PgFdMQE= github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.25 h1:r67ps7oHCYnflpgDy2LZU0MAQtQbYIOqNNnqGO6xQkE=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.21/go.mod h1:Q9o5h4HoIWG8XfzxqiuK/CGUbepCJ8uTlaE3bAbxytQ= github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.25/go.mod h1:GrGY+Q4fIokYLtjCVB/aFfCVL6hhGUFl8inD18fDalE=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.0 h1:TToQNkvGguu209puTojY/ozlqy2d/SFNcoLIqTFi42g= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1 h1:iXtILhvDxB6kPvEXgsDhGaZCSC6LQET5ZHSdJozeI0Y=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.0/go.mod h1:0jp+ltwkf+SwG2fm/PKo8t4y8pJSgOCO4D8Lz3k0aHQ= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1/go.mod h1:9nu0fVANtYiAePIBh2/pFUSwtJ402hLnp854CNoDOeE=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.2 h1:4FMHqLfk0efmTqhXVRL5xYRqlEBNBiRI7N6w4jsEdd4= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.6 h1:HCpPsWqmYQieU7SS6E9HXfdAMSud0pteVXieJmcpIRI=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.2/go.mod h1:LWoqeWlK9OZeJxsROW2RqrSPvQHKTpp69r/iDjwsSaw= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.6/go.mod h1:ngUiVRCco++u+soRRVBIvBZxSMMvOVMXA4PJ36JLfSw=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.2 h1:s7NA1SOw8q/5c0wr8477yOPp0z+uBaXBnLE0XYb0POA= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.6 h1:50+XsN70RS7dwJ2CkVNXzj7U2L1HKP8nqTd3XWEXBN4=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.2/go.mod h1:fnjjWyAW/Pj5HYOxl9LJqWtEwS7W2qgcRLWP+uWbss0= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.6/go.mod h1:WqgLmwY7so32kG01zD8CPTJWVWM+TzJoOVHwTg4aPug=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.2 h1:t7iUP9+4wdc5lt3E41huP+GvQZJD38WLsgVp4iOtAjg= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.6 h1:BbGDtTi0T1DYlmjBiCr/le3wzhA37O8QTC5/Ab8+EXk=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.2/go.mod h1:/niFCtmuQNxqx9v8WAPq5qh7EH25U4BF6tjoyq9bObM= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.6/go.mod h1:hLMJt7Q8ePgViKupeymbqI0la+t9/iYFBjxQCFwuAwI=
github.com/aws/aws-sdk-go-v2/service/s3 v1.66.0 h1:xA6XhTF7PE89BCNHJbQi8VvPzcgMtmGC5dr8S8N7lHk= github.com/aws/aws-sdk-go-v2/service/s3 v1.71.0 h1:nyuzXooUNJexRT0Oy0UQY6AhOzxPxhtt4DcBIHyCnmw=
github.com/aws/aws-sdk-go-v2/service/s3 v1.66.0/go.mod h1:cB6oAuus7YXRZhWCc1wIwPywwZ1XwweNp2TVAEGYeB8= github.com/aws/aws-sdk-go-v2/service/s3 v1.71.0/go.mod h1:sT/iQz8JK3u/5gZkT+Hmr7GzVZehUMkRZpOaAwYXeGY=
github.com/aws/aws-sdk-go-v2/service/sso v1.24.2 h1:bSYXVyUzoTHoKalBmwaZxs97HU9DWWI3ehHSAMa7xOk= github.com/aws/aws-sdk-go-v2/service/sso v1.24.7 h1:rLnYAfXQ3YAccocshIH5mzNNwZBkBo+bP6EhIxak6Hw=
github.com/aws/aws-sdk-go-v2/service/sso v1.24.2/go.mod h1:skMqY7JElusiOUjMJMOv1jJsP7YUg7DrhgqZZWuzu1U= github.com/aws/aws-sdk-go-v2/service/sso v1.24.7/go.mod h1:ZHtuQJ6t9A/+YDuxOLnbryAmITtr8UysSny3qcyvJTc=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.2 h1:AhmO1fHINP9vFYUE0LHzCWg/LfUWUF+zFPEcY9QXb7o= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.6 h1:JnhTZR3PiYDNKlXy50/pNeix9aGMo6lLpXwJ1mw8MD4=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.2/go.mod h1:o8aQygT2+MVP0NaV6kbdE1YnnIM8RRVQzoeUH45GOdI= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.6/go.mod h1:URronUEGfXZN1VpdktPSD1EkAL9mfrV+2F4sjH38qOY=
github.com/aws/aws-sdk-go-v2/service/sts v1.32.2 h1:CiS7i0+FUe+/YY1GvIBLLrR/XNGZ4CtM1Ll0XavNuVo= github.com/aws/aws-sdk-go-v2/service/sts v1.33.2 h1:s4074ZO1Hk8qv65GqNXqDjmkf4HSQqJukaLuuW0TpDA=
github.com/aws/aws-sdk-go-v2/service/sts v1.32.2/go.mod h1:HtaiBI8CjYoNVde8arShXb94UbQQi9L4EMr6D+xGBwo= github.com/aws/aws-sdk-go-v2/service/sts v1.33.2/go.mod h1:mVggCnIWoM09jP71Wh+ea7+5gAp53q+49wDFs1SW5z8=
github.com/aws/smithy-go v1.22.0 h1:uunKnWlcoL3zO7q+gG2Pk53joueEOsnNB28QdMsmiMM= github.com/aws/smithy-go v1.22.1 h1:/HPHZQ0g7f4eUeK6HKglFz8uwVfZKgoI25rb/J+dnro=
github.com/aws/smithy-go v1.22.0/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg= github.com/aws/smithy-go v1.22.1/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,25 +84,25 @@ 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.6 h1:3+PzJTKLkvgjeTbts6msPJt4DixhT4YtFNf1gtGe3zc= github.com/gabriel-vasile/mimetype v1.4.7 h1:SKFKl7kD0RiPdbht0s7hFtjl489WcQ1VyPW8ZzUMYCA=
github.com/gabriel-vasile/mimetype v1.4.6/go.mod h1:JX1qVKqZd40hUPpAfiNTe0Sne7hdfKSbOqqmkq8GCXc= github.com/gabriel-vasile/mimetype v1.4.7/go.mod h1:GDlAgAyIRT27BhFl53XNAFtfjzOkLaF35JdEG0P7LtU=
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.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.7/go.mod h1:zpHEXBstFnQYtGnB8k8kQLol82umzn/2/snG7alWVD8= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
@@ -117,14 +117,14 @@ github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpv
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls=
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM=
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-json v0.10.4/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= github.com/golang-jwt/jwt/v4 v4.5.1 h1:JdqV9zKUdtaa9gdPlywC3aeoEsR681PlKC+4F5gQgeo=
github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v4 v4.5.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
@@ -152,8 +152,8 @@ github.com/google/go-github/v30 v30.1.0/go.mod h1:n8jBpHl45a/rlBUtRJMOG4GhNADUQF
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/google/pprof v0.0.0-20240827171923-fa2c70bbbfe5 h1:5iH8iuqE5apketRbSFBy+X1V0o+l+8NF1avt4HWl7cA= github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 h1:FKHo8hFI3A+7w0aUQuYXQ+6EN5stWmeY/AZqtM8xk9k=
github.com/google/pprof v0.0.0-20240827171923-fa2c70bbbfe5/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo=
github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM= github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM=
github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA= github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@@ -163,8 +163,8 @@ github.com/google/wire v0.6.0 h1:HBkoIh4BdSxoyo9PveV8giw7ZsaBOvzWKfcg/6MrVwI=
github.com/google/wire v0.6.0/go.mod h1:F4QhpQ9EDIdJ1Mbop/NZBRB+5yrR6qg3BnctaoUk6NA= github.com/google/wire v0.6.0/go.mod h1:F4QhpQ9EDIdJ1Mbop/NZBRB+5yrR6qg3BnctaoUk6NA=
github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw= github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw=
github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA= github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA=
github.com/googleapis/gax-go/v2 v2.13.0 h1:yitjD5f7jQHhyDsnhKEBU52NdvvdSeGzlAnDPT0hH1s= github.com/googleapis/gax-go/v2 v2.14.0 h1:f+jMrjBPl+DL9nI4IQzLUxMq7XrAqFYB7hBPqMNIe8o=
github.com/googleapis/gax-go/v2 v2.13.0/go.mod h1:Z/fvTZXF8/uw7Xu5GuslPw+bplx6SS338j1Is2S+B7A= github.com/googleapis/gax-go/v2 v2.14.0/go.mod h1:lhBCnjdLrWRaPvLWhmc8IS24m9mr07qSYnHncrgo+zk=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog= github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog=
@@ -187,8 +187,6 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/labstack/echo/v5 v5.0.0-20230722203903-ec5b858dab61 h1:FwuzbVh87iLiUQj1+uQUsuw9x5t9m5n5g7rG7o4svW4=
github.com/labstack/echo/v5 v5.0.0-20230722203903-ec5b858dab61/go.mod h1:paQfF1YtHe+GrGg5fOgjsjoCX/UKDr9bc1DoWpZfns8=
github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 h1:7UMa6KCCMjZEMDtTVdcGu0B1GmmC7QJKiCCjyTAWQy0= github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 h1:7UMa6KCCMjZEMDtTVdcGu0B1GmmC7QJKiCCjyTAWQy0=
github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k= github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k=
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
@@ -198,8 +196,6 @@ github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hd
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
github.com/mattn/go-sqlite3 v1.14.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=
@@ -215,10 +211,10 @@ github.com/onsi/gomega v1.27.6/go.mod h1:PIQNjfQwkP3aQAH7lf7j87O/5FiNr+ZR8+ipb+q
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
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.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU=
github.com/pocketbase/dbx v1.10.1/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs= github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
github.com/pocketbase/pocketbase v0.22.22 h1:iA128U+cmM9euxPpuCN7blmQ2FZNzOix2aUUcnbbQu8= github.com/pocketbase/pocketbase v0.23.9 h1:0P3BaMTUO8QzyamYqd/OpPM4L7zmu6HrmDGFQmX+eu4=
github.com/pocketbase/pocketbase v0.22.22/go.mod h1:u+l7T04g7eBXetoodXLch3WoV/QonRf1qYq+2vuTKuI= github.com/pocketbase/pocketbase v0.23.9/go.mod h1:8qIx1v60b+YES3e8H4J2QQF48J0uiydPhRi4ZHlKNjk=
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 +225,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.11 h1:WaU9xqGFKvFfsUv94SXcUPD7rCkU0vr/asVdQOBZNj8=
github.com/shirou/gopsutil/v4 v4.24.9/go.mod h1:3fkaHNeYsUFCGZ8+9vZVWtbyM1k2eRnlL+bWO8Bxa/Q= github.com/shirou/gopsutil/v4 v4.24.11/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,15 +247,11 @@ 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=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
@@ -269,32 +261,36 @@ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.5
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0/go.mod h1:B9yO6b04uB80CzjedvewuqDhxJxi11s7/GtiGa8bAjI= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0/go.mod h1:B9yO6b04uB80CzjedvewuqDhxJxi11s7/GtiGa8bAjI=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw= go.opentelemetry.io/otel v1.31.0 h1:NsJcKPIW0D0H3NgzPDHmo0WW6SptzPdqg/L1zsIm2hY=
go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8= go.opentelemetry.io/otel v1.31.0/go.mod h1:O0C14Yl9FgkjqcCZAsE053C13OaddMYr/hz6clDkEJE=
go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc= go.opentelemetry.io/otel/metric v1.31.0 h1:FSErL0ATQAmYHUIzSezZibnyVlft1ybhy4ozRPcF2fE=
go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8= go.opentelemetry.io/otel/metric v1.31.0/go.mod h1:C3dEloVbLuYoX41KpmAhOqNriGbA+qqH6PQ5E5mUfnY=
go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4= go.opentelemetry.io/otel/sdk v1.31.0 h1:xLY3abVHYZ5HSfOg3l2E5LUj2Cwva5Y7yGxnSW9H5Gk=
go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ= go.opentelemetry.io/otel/sdk v1.31.0/go.mod h1:TfRbMdhvxIIr/B2N2LQW2S5v9m3gOQ/08KsbbO5BPT0=
go.opentelemetry.io/otel/sdk/metric v1.31.0 h1:i9hxxLJF/9kkvfHppyLL55aW7iIJz4JjxTeYusH7zMc=
go.opentelemetry.io/otel/sdk/metric v1.31.0/go.mod h1:CRInTMVvNhUKgSAMbKyTMxqOBC0zgyxzW55lZzX43Y8=
go.opentelemetry.io/otel/trace v1.31.0 h1:ffjsj1aRouKewfr85U2aGagJ46+MvodynlQ1HYdmJys=
go.opentelemetry.io/otel/trace v1.31.0/go.mod h1:TXZkRk7SM2ZQLtR6eoAWQFIHPvzQ06FJAsO1tJg480A=
gocloud.dev v0.40.0 h1:f8LgP+4WDqOG/RXoUcyLpeIAGOcAbZrZbDQCUee10ng= gocloud.dev v0.40.0 h1:f8LgP+4WDqOG/RXoUcyLpeIAGOcAbZrZbDQCUee10ng=
gocloud.dev v0.40.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.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c h1:7dEasQXItcW1xKJ2+gg5VOiBnqWrJc+rq0DPKyvvdbY= golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67 h1:1UoZQm6f0P/ZO0w1Ri+f+ifG/gXhegadRdwBIXEFWDo=
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c/go.mod h1:NQtJDoLvd6faHhE7m4T/1IY708gDefGGjR/iUW8yQQ8= golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.21.0 h1:c5qV36ajHpdj4Qi0GnE0jUc/yuo33OLFaa0d+crTD5s= golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
golang.org/x/image v0.21.0/go.mod h1:vUbsLavqK/W303ZroQQVKQ+Af3Yl6Uz1Ppu5J/cLz78= golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0= golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4=
golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -306,18 +302,18 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4= golang.org/x/net v0.32.0 h1:ZqPmj8Kzc+Y6e0+skZsuACbx+wzMgo5MQsJh9Qd6aYI=
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU= golang.org/x/net v0.32.0/go.mod h1:CwU0IoeOlnQQWJ6ioyFrfRuomB8GKF6KbYXZVyeXNfs=
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.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE=
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -334,23 +330,23 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24= golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ= golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/time v0.8.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 +354,14 @@ golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ= golang.org/x/tools v0.28.0 h1:WuB6qZ4RPCQo5aP3WdKZS7i595EdWqWR8vqJTlwTVK8=
golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0= golang.org/x/tools v0.28.0/go.mod h1:dcIOrVd3mfQKTgrDVQHqCPMWy6lnhfhtX3hLXYVLfRw=
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.201.0 h1:+7AD9JNM3tREtawRMu8sOjSbb8VYcYXJG/2eEOmfDu0= google.golang.org/api v0.212.0 h1:BcRj3MJfHF3FYD29rk7u9kuu1SyfGqfHcA0hSwKqkHg=
google.golang.org/api v0.201.0/go.mod h1:HVY0FCHVs89xIW9fzf/pBvOEm+OolHa86G/txFezyq4= google.golang.org/api v0.212.0/go.mod h1:gICpLlpp12/E8mycRMzgy3SQ9cFh2XnVJ6vJi/kQbvI=
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,19 +369,19 @@ 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-20241007155032-5fefd90f89a9 h1:nFS3IivktIU5Mk6KQa+v6RKkHUpdQpphqGNLxqNnbEk= google.golang.org/genproto v0.0.0-20240812133136-8ffd90a71988 h1:CT2Thj5AuPV9phrYMtzX11k+XkzMGfRAet42PmoTATM=
google.golang.org/genproto v0.0.0-20241007155032-5fefd90f89a9/go.mod h1:tEzYTYZxbmVNOu0OAFH9HzdJtLn6h4Aj89zzlBCdHms= google.golang.org/genproto v0.0.0-20240812133136-8ffd90a71988/go.mod h1:7uvplUBj4RjHAxIZ//98LzOvrQ04JBkaixRmCMI29hc=
google.golang.org/genproto/googleapis/api v0.0.0-20240814211410-ddb44dafa142 h1:wKguEg1hsxI2/L3hUYrpo1RVi48K+uTyzKqprwLXsb8= google.golang.org/genproto/googleapis/api v0.0.0-20241104194629-dd2ea8efbc28 h1:M0KvPgPmDZHPlbRbaNU1APr28TvwvvdUPlSv7PUvy8g=
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-20241104194629-dd2ea8efbc28/go.mod h1:dguCy7UOdZhTvLzDyt15+rOrawrpM4q7DD9dQ1P11P4=
google.golang.org/genproto/googleapis/rpc v0.0.0-20241015192408-796eee8c2d53 h1:X58yt85/IXCx0Y3ZwN6sEIKZzQtDEYaBWrDvErdXrRE= google.golang.org/genproto/googleapis/rpc v0.0.0-20241216192217-9240e9c98484 h1:Z7FRVJPSMaHQxD0uXU8WdgFh8PseLM8Q8NzhnpMrBhQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20241015192408-796eee8c2d53/go.mod h1:GX3210XPVPUjJbTUbvwI8f2IpZDMZuPJWDzDuebbviI= google.golang.org/genproto/googleapis/rpc v0.0.0-20241216192217-9240e9c98484/go.mod h1:lcTa1sDdWEIHMWlITnIczmw5w60CF9ffkb8Z+DVmmjA=
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=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E= google.golang.org/grpc v1.69.0 h1:quSiOM1GJPmPH5XtU+BCoVXcDVJJAzNcoyfC2cCjGkI=
google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA= google.golang.org/grpc v1.69.0/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
@@ -395,8 +391,8 @@ google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA= google.golang.org/protobuf v1.36.0 h1:mjIs9gYtt56AzC4ZaffQuh88TZurBGhIJMBZGSxNerQ=
google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= google.golang.org/protobuf v1.36.0/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 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=
@@ -409,18 +405,18 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= modernc.org/cc/v4 v4.23.1 h1:WqJoPL3x4cUufQVHkXpXX7ThFJ1C4ik80i2eXEXbhD8=
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= modernc.org/cc/v4 v4.23.1/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
modernc.org/ccgo/v4 v4.21.0 h1:kKPI3dF7RIag8YcToh5ZwDcVMIv6VGa0ED5cvh0LMW4= modernc.org/ccgo/v4 v4.23.1 h1:N49a7JiWGWV7lkPE4yYcvjkBGZQi93/JabRYjdWmJXc=
modernc.org/ccgo/v4 v4.21.0/go.mod h1:h6kt6H/A2+ew/3MW/p6KEoQmrq/i3pr0J/SiwiaF/g0= modernc.org/ccgo/v4 v4.23.1/go.mod h1:JoIUegEIfutvoWV/BBfDFpPpfR2nc3U0jKucGcbmwDU=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/gc/v2 v2.5.0 h1:bJ9ChznK1L1mUtAQtxi0wi5AtAs5jQuw4PrPHO5pb6M= modernc.org/gc/v2 v2.5.0 h1:bJ9ChznK1L1mUtAQtxi0wi5AtAs5jQuw4PrPHO5pb6M=
modernc.org/gc/v2 v2.5.0/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= modernc.org/gc/v2 v2.5.0/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
modernc.org/gc/v3 v3.0.0-20241004144649-1aea3fae8852 h1:IYXPPTTjjoSHvUClZIYexDiO7g+4x+XveKT4gCIAwiY= modernc.org/gc/v3 v3.0.0-20241213165251-3bc300f6d0c9 h1:ovz6yUKX71igz2yvk4NpiCL5fvdjZAI+DhuDEGx1xyU=
modernc.org/gc/v3 v3.0.0-20241004144649-1aea3fae8852/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= modernc.org/gc/v3 v3.0.0-20241213165251-3bc300f6d0c9/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
modernc.org/libc v1.61.0 h1:eGFcvWpqlnoGwzZeZe3PWJkkKbM/3SUGyk1DVZQ0TpE= modernc.org/libc v1.61.4 h1:wVyqEx6tlltte9lPTjq0kDAdtdM9c4JH8rU6M1ZVawA=
modernc.org/libc v1.61.0/go.mod h1:DvxVX89wtGTu+r72MLGhygpfi3aUGgZRdAYGCAVVud0= modernc.org/libc v1.61.4/go.mod h1:VfXVuM/Shh5XsMNrh3C6OkfL78G3loa4ZC/Ljv9k7xc=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
@@ -429,8 +425,8 @@ modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
modernc.org/sqlite v1.33.1 h1:trb6Z3YYoeM9eDL1O8do81kP+0ejv+YzgyFo+Gwy0nM= modernc.org/sqlite v1.34.2 h1:J9n76TPsfYYkFkZ9Uy1QphILYifiVEwwOT7yP5b++2Y=
modernc.org/sqlite v1.33.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k= modernc.org/sqlite v1.34.2/go.mod h1:dnR723UrTtjKpoHCAMN0Q/gZ9MT4r+iRvIBb9umWFkU=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

View File

@@ -24,6 +24,7 @@ type Agent struct {
sensorsContext context.Context // Sensors context to override sys location sensorsContext context.Context // Sensors context to override sys location
sensorsWhitelist map[string]struct{} // List of sensors to monitor sensorsWhitelist map[string]struct{} // List of sensors to monitor
systemInfo system.Info // Host system info systemInfo system.Info // Host system info
gpuManager *GPUManager // Manages GPU data
} }
func NewAgent() *Agent { func NewAgent() *Agent {
@@ -62,7 +63,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{}{}
}
} }
} }
@@ -70,7 +73,14 @@ func (a *Agent) Run(pubKey []byte, addr string) {
a.initializeSystemInfo() a.initializeSystemInfo()
a.initializeDiskInfo() a.initializeDiskInfo()
a.initializeNetIoStats() a.initializeNetIoStats()
a.dockerManager = newDockerManager() a.dockerManager = newDockerManager(a)
// initialize GPU manager
if gm, err := NewGPUManager(); err != nil {
slog.Debug("GPU", "err", err)
} else {
a.gpuManager = gm
}
// if debugging, print stats // if debugging, print stats
if a.debug { if a.debug {

View File

@@ -38,14 +38,26 @@ func (a *Agent) initializeDiskInfo() {
// Helper function to add a filesystem to fsStats if it doesn't exist // Helper function to add a filesystem to fsStats if it doesn't exist
addFsStat := func(device, mountpoint string, root bool) { addFsStat := func(device, mountpoint string, root bool) {
key := filepath.Base(device) key := filepath.Base(device)
var ioMatch bool
if _, exists := a.fsStats[key]; !exists { if _, exists := a.fsStats[key]; !exists {
if root { if root {
slog.Info("Detected root device", "name", key) slog.Info("Detected root device", "name", key)
// 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 _, ioMatch = diskIoCounters[key]; !ioMatch {
slog.Warn("Device not found in diskstats", "name", key) key, ioMatch = findIoDevice(filesystem, diskIoCounters, a.fsStats)
key = findFallbackIoDevice(filesystem, diskIoCounters, a.fsStats) if !ioMatch {
slog.Info("Using I/O fallback", "name", key) slog.Info("Using I/O fallback", "device", device, "mountpoint", mountpoint, "fallback", key)
}
}
} else {
// Check if non-root has diskstats and fall back to folder name if not
// Scenario: device is encrypted and named luks-2bcb02be-999d-4417-8d18-5c61e660fb6e - not in /proc/diskstats.
// However, the device can be specified by mounting folder from luks device at /extra-filesystems/sda1
if _, ioMatch = diskIoCounters[key]; !ioMatch {
efBase := filepath.Base(mountpoint)
if _, ioMatch = diskIoCounters[efBase]; ioMatch {
key = efBase
}
} }
} }
a.fsStats[key] = &system.FsStats{Root: root, Mountpoint: mountpoint} a.fsStats[key] = &system.FsStats{Root: root, Mountpoint: mountpoint}
@@ -92,9 +104,12 @@ func (a *Agent) initializeDiskInfo() {
for _, p := range partitions { for _, p := range partitions {
// fmt.Println(p.Device, p.Mountpoint) // fmt.Println(p.Device, p.Mountpoint)
// Binary root fallback or docker root fallback // Binary root fallback or docker root fallback
if !hasRoot && (p.Mountpoint == "/" || (p.Mountpoint == "/etc/hosts" && strings.HasPrefix(p.Device, "/dev") && !strings.Contains(p.Device, "mapper"))) { if !hasRoot && (p.Mountpoint == "/" || (p.Mountpoint == "/etc/hosts" && strings.HasPrefix(p.Device, "/dev"))) {
addFsStat(p.Device, "/", true) fs, match := findIoDevice(filepath.Base(p.Device), diskIoCounters, a.fsStats)
hasRoot = true if match {
addFsStat(fs, p.Mountpoint, true)
hasRoot = true
}
} }
// Check if device is in /extra-filesystems // Check if device is in /extra-filesystems
@@ -114,7 +129,7 @@ func (a *Agent) initializeDiskInfo() {
mountpoint := filepath.Join(efPath, folder.Name()) mountpoint := filepath.Join(efPath, folder.Name())
slog.Debug("/extra-filesystems", "mountpoint", mountpoint) slog.Debug("/extra-filesystems", "mountpoint", mountpoint)
if !existingMountpoints[mountpoint] { if !existingMountpoints[mountpoint] {
a.fsStats[folder.Name()] = &system.FsStats{Mountpoint: mountpoint} addFsStat(folder.Name(), mountpoint, false)
} }
} }
} }
@@ -122,7 +137,7 @@ func (a *Agent) initializeDiskInfo() {
// If no root filesystem set, use fallback // If no root filesystem set, use fallback
if !hasRoot { if !hasRoot {
rootDevice := findFallbackIoDevice(filepath.Base(filesystem), diskIoCounters, a.fsStats) rootDevice, _ := findIoDevice(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: "/"}
} }
@@ -130,14 +145,15 @@ func (a *Agent) initializeDiskInfo() {
a.initializeDiskIoStats(diskIoCounters) a.initializeDiskIoStats(diskIoCounters)
} }
// Returns the device with the most reads in /proc/diskstats, // Returns matching device from /proc/diskstats,
// or the device specified by the filesystem argument if it exists // or the device with the most reads if no match is found.
func findFallbackIoDevice(filesystem string, diskIoCounters map[string]disk.IOCountersStat, fsStats map[string]*system.FsStats) string { // bool is true if a match was found.
func findIoDevice(filesystem string, diskIoCounters map[string]disk.IOCountersStat, fsStats map[string]*system.FsStats) (string, bool) {
var maxReadBytes uint64 var maxReadBytes uint64
maxReadDevice := "/" maxReadDevice := "/"
for _, d := range diskIoCounters { for _, d := range diskIoCounters {
if d.Name == filesystem { if d.Name == filesystem || (d.Label != "" && d.Label == filesystem) {
return d.Name return d.Name, true
} }
if d.ReadBytes > maxReadBytes { if d.ReadBytes > maxReadBytes {
// don't use if device already exists in fsStats // don't use if device already exists in fsStats
@@ -147,7 +163,7 @@ func findFallbackIoDevice(filesystem string, diskIoCounters map[string]disk.IOCo
} }
} }
} }
return maxReadDevice return maxReadDevice, false
} }
// Sets start values for disk I/O stats. // Sets start values for disk I/O stats.

View File

@@ -25,18 +25,23 @@ type dockerManager struct {
apiContainerList *[]container.ApiInfo // List of containers from Docker API apiContainerList *[]container.ApiInfo // List of containers from Docker API
containerStatsMap map[string]*container.Stats // Keeps track of container stats containerStatsMap map[string]*container.Stats // Keeps track of container stats
validIds map[string]struct{} // Map of valid container ids, used to prune invalid containers from containerStatsMap validIds map[string]struct{} // Map of valid container ids, used to prune invalid containers from containerStatsMap
goodDockerVersion bool // Whether docker version is at least 25.0.0 (one-shot works correctly)
} }
// Add goroutine to the queue // Add goroutine to the queue
func (d *dockerManager) queue() { func (d *dockerManager) queue() {
d.sem <- struct{}{}
d.wg.Add(1) d.wg.Add(1)
if d.goodDockerVersion {
d.sem <- struct{}{}
}
} }
// Remove goroutine from the queue // Remove goroutine from the queue
func (d *dockerManager) dequeue() { func (d *dockerManager) dequeue() {
<-d.sem
d.wg.Done() d.wg.Done()
if d.goodDockerVersion {
<-d.sem
}
} }
// Returns stats for all running containers // Returns stats for all running containers
@@ -75,6 +80,7 @@ func (dm *dockerManager) getDockerStats() ([]*container.Stats, error) {
go func() { go func() {
defer dm.dequeue() defer dm.dequeue()
err := dm.updateContainerStats(ctr) err := dm.updateContainerStats(ctr)
// if error, delete from map and add to failed list to retry
if err != nil { if err != nil {
dm.containerStatsMutex.Lock() dm.containerStatsMutex.Lock()
delete(dm.containerStatsMap, ctr.IdShort) delete(dm.containerStatsMap, ctr.IdShort)
@@ -89,11 +95,10 @@ func (dm *dockerManager) getDockerStats() ([]*container.Stats, error) {
// retry failed containers separately so we can run them in parallel (docker 24 bug) // retry failed containers separately so we can run them in parallel (docker 24 bug)
if len(failedContainters) > 0 { if len(failedContainters) > 0 {
slog.Debug("Retrying failed containers", "count", len(failedContainters)) slog.Debug("Retrying failed containers", "count", len(failedContainters))
// time.Sleep(time.Millisecond * 1100)
for _, ctr := range failedContainters { for _, ctr := range failedContainters {
dm.wg.Add(1) dm.queue()
go func() { go func() {
defer dm.wg.Done() defer dm.dequeue()
err = dm.updateContainerStats(ctr) err = dm.updateContainerStats(ctr)
if err != nil { if err != nil {
slog.Error("Error getting container stats", "err", err) slog.Error("Error getting container stats", "err", err)
@@ -201,12 +206,13 @@ func (dm *dockerManager) deleteContainerStatsSync(id string) {
delete(dm.containerStatsMap, id) delete(dm.containerStatsMap, id)
} }
// Creates a new http client for Docker API // Creates a new http client for Docker or Podman API
func newDockerManager() *dockerManager { func newDockerManager(a *Agent) *dockerManager {
dockerHost := "unix:///var/run/docker.sock" dockerHost, exists := os.LookupEnv("DOCKER_HOST")
if dockerHostEnv, exists := os.LookupEnv("DOCKER_HOST"); exists { if exists {
slog.Info("DOCKER_HOST", "host", dockerHostEnv) slog.Info("DOCKER_HOST", "host", dockerHost)
dockerHost = dockerHostEnv } else {
dockerHost = getDockerHost()
} }
parsedURL, err := url.Parse(dockerHost) parsedURL, err := url.Parse(dockerHost)
@@ -251,11 +257,15 @@ func newDockerManager() *dockerManager {
Transport: transport, Transport: transport,
}, },
containerStatsMap: make(map[string]*container.Stats), containerStatsMap: make(map[string]*container.Stats),
sem: make(chan struct{}, 5),
} }
// Make sure sem is initialized // If using podman, return client
concurrency := 200 if strings.Contains(dockerHost, "podman") {
defer func() { dockerClient.sem = make(chan struct{}, concurrency) }() a.systemInfo.Podman = true
dockerClient.goodDockerVersion = true
return dockerClient
}
// Check docker version // Check docker version
// (versions before 25.0.0 have a bug with one-shot which requires all requests to be made in one batch) // (versions before 25.0.0 have a bug with one-shot which requires all requests to be made in one batch)
@@ -273,9 +283,22 @@ func newDockerManager() *dockerManager {
// if version > 24, one-shot works correctly and we can limit concurrent operations // 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 dockerClient.goodDockerVersion = true
} else {
slog.Info(fmt.Sprintf("Docker %s is outdated. Upgrade if possible. See https://github.com/henrygd/beszel/issues/58", versionInfo.Version))
} }
slog.Debug("Docker", "version", versionInfo.Version, "concurrency", concurrency)
return dockerClient return dockerClient
} }
// Test docker / podman sockets and return if one exists
func getDockerHost() string {
scheme := "unix://"
socks := []string{"/var/run/docker.sock", fmt.Sprintf("/run/user/%v/podman/podman.sock", os.Getuid())}
for _, sock := range socks {
if _, err := os.Stat(sock); err == nil {
return scheme + sock
}
}
return scheme + socks[0]
}

View File

@@ -0,0 +1,235 @@
package agent
import (
"beszel/internal/entities/system"
"bufio"
"encoding/json"
"fmt"
"os/exec"
"strconv"
"strings"
"sync"
"time"
"golang.org/x/exp/slog"
)
// GPUManager manages data collection for GPUs (either Nvidia or AMD)
type GPUManager struct {
nvidiaSmi bool
rocmSmi bool
GpuDataMap map[string]*system.GPUData
mutex sync.Mutex
}
// RocmSmiJson represents the JSON structure of rocm-smi output
type RocmSmiJson struct {
ID string `json:"Device ID"`
Name string `json:"Card series"`
Temperature string `json:"Temperature (Sensor edge) (C)"`
MemoryUsed string `json:"VRAM Total Used Memory (B)"`
MemoryTotal string `json:"VRAM Total Memory (B)"`
Usage string `json:"GPU use (%)"`
Power string `json:"Current Socket Graphics Package Power (W)"`
}
// gpuCollector defines a collector for a specific GPU management utility (nvidia-smi or rocm-smi)
type gpuCollector struct {
name string
cmd *exec.Cmd
parse func([]byte) bool // returns true if valid data was found
}
var errNoValidData = fmt.Errorf("no valid GPU data found") // Error for missing data
// starts and manages the ongoing collection of GPU data for the specified GPU management utility
func (c *gpuCollector) start() {
for {
err := c.collect()
if err != nil {
if err == errNoValidData {
slog.Warn(c.name + " found no valid GPU data, stopping")
break
}
slog.Warn(c.name+" failed, restarting", "err", err)
time.Sleep(time.Second * 5)
continue
}
}
}
// collect executes the command, parses output with the assigned parser function
func (c *gpuCollector) collect() error {
stdout, err := c.cmd.StdoutPipe()
if err != nil {
return err
}
if err := c.cmd.Start(); err != nil {
return err
}
scanner := bufio.NewScanner(stdout)
buf := make([]byte, 0, 8*1024)
scanner.Buffer(buf, bufio.MaxScanTokenSize)
hasValidData := false
for scanner.Scan() {
if c.parse(scanner.Bytes()) {
hasValidData = true
}
}
if !hasValidData {
return errNoValidData
}
if err := scanner.Err(); err != nil {
return fmt.Errorf("scanner error: %w", err)
}
return c.cmd.Wait()
}
// parseNvidiaData parses the output of nvidia-smi and updates the GPUData map
func (gm *GPUManager) parseNvidiaData(output []byte) bool {
fields := strings.Split(string(output), ", ")
if len(fields) < 7 {
return false
}
gm.mutex.Lock()
defer gm.mutex.Unlock()
lines := strings.Split(string(output), "\n")
for _, line := range lines {
if line != "" {
fields := strings.Split(line, ", ")
if len(fields) >= 7 {
id := fields[0]
temp, _ := strconv.ParseFloat(fields[2], 64)
memoryUsage, _ := strconv.ParseFloat(fields[3], 64)
totalMemory, _ := strconv.ParseFloat(fields[4], 64)
usage, _ := strconv.ParseFloat(fields[5], 64)
power, _ := strconv.ParseFloat(fields[6], 64)
// add gpu if not exists
if _, ok := gm.GpuDataMap[id]; !ok {
name := strings.TrimPrefix(fields[1], "NVIDIA ")
gm.GpuDataMap[id] = &system.GPUData{Name: strings.TrimSuffix(name, " Laptop GPU")}
}
// update gpu data
gpu := gm.GpuDataMap[id]
gpu.Temperature = temp
gpu.MemoryUsed = memoryUsage / 1.024
gpu.MemoryTotal = totalMemory / 1.024
gpu.Usage += usage
gpu.Power += power
gpu.Count++
}
}
}
return true
}
// parseAmdData parses the output of rocm-smi and updates the GPUData map
func (gm *GPUManager) parseAmdData(output []byte) bool {
var rocmSmiInfo map[string]RocmSmiJson
if err := json.Unmarshal(output, &rocmSmiInfo); err != nil || len(rocmSmiInfo) == 0 {
return false
}
gm.mutex.Lock()
defer gm.mutex.Unlock()
for _, v := range rocmSmiInfo {
temp, _ := strconv.ParseFloat(v.Temperature, 64)
memoryUsage, _ := strconv.ParseFloat(v.MemoryUsed, 64)
totalMemory, _ := strconv.ParseFloat(v.MemoryTotal, 64)
usage, _ := strconv.ParseFloat(v.Usage, 64)
power, _ := strconv.ParseFloat(v.Power, 64)
memoryUsage = bytesToMegabytes(memoryUsage)
totalMemory = bytesToMegabytes(totalMemory)
if _, ok := gm.GpuDataMap[v.ID]; !ok {
gm.GpuDataMap[v.ID] = &system.GPUData{Name: v.Name}
}
gpu := gm.GpuDataMap[v.ID]
gpu.Temperature = temp
gpu.MemoryUsed = memoryUsage
gpu.MemoryTotal = totalMemory
gpu.Usage += usage
gpu.Power += power
gpu.Count++
}
return true
}
// sums and resets the current GPU utilization data since the last update
func (gm *GPUManager) GetCurrentData() map[string]system.GPUData {
gm.mutex.Lock()
defer gm.mutex.Unlock()
// copy / reset the data
gpuData := make(map[string]system.GPUData, len(gm.GpuDataMap))
for id, gpu := range gm.GpuDataMap {
// sum the data
gpu.Temperature = twoDecimals(gpu.Temperature)
gpu.MemoryUsed = twoDecimals(gpu.MemoryUsed)
gpu.MemoryTotal = twoDecimals(gpu.MemoryTotal)
gpu.Usage = twoDecimals(gpu.Usage / gpu.Count)
gpu.Power = twoDecimals(gpu.Power / gpu.Count)
gpuData[id] = *gpu
// reset the count
gpu.Count = 1
}
return gpuData
}
// detectGPUs returns the GPU brand (nvidia or amd) or an error if none is found
// todo: make sure there's actually a GPU, not just if the command exists
func (gm *GPUManager) detectGPUs() error {
if err := exec.Command("nvidia-smi").Run(); err == nil {
gm.nvidiaSmi = true
}
if err := exec.Command("rocm-smi").Run(); err == nil {
gm.rocmSmi = true
}
if gm.nvidiaSmi || gm.rocmSmi {
return nil
}
return fmt.Errorf("no GPU found - install nvidia-smi or rocm-smi")
}
// startCollector starts the appropriate GPU data collector based on the command
func (gm *GPUManager) startCollector(command string) {
switch command {
case "nvidia-smi":
nvidia := gpuCollector{
name: "nvidia-smi",
cmd: exec.Command("nvidia-smi", "-l", "4",
"--query-gpu=index,name,temperature.gpu,memory.used,memory.total,utilization.gpu,power.draw",
"--format=csv,noheader,nounits"),
parse: gm.parseNvidiaData,
}
go nvidia.start()
case "rocm-smi":
amdCollector := gpuCollector{
name: "rocm-smi",
cmd: exec.Command("/bin/sh", "-c",
"while true; do rocm-smi --showid --showtemp --showuse --showpower --showproductname --showmeminfo vram --json; sleep 4.3; done"),
parse: gm.parseAmdData,
}
go amdCollector.start()
}
}
// NewGPUManager creates and initializes a new GPUManager
func NewGPUManager() (*GPUManager, error) {
var gm GPUManager
if err := gm.detectGPUs(); err != nil {
return nil, err
}
gm.GpuDataMap = make(map[string]*system.GPUData, 1)
if gm.nvidiaSmi {
gm.startCollector("nvidia-smi")
}
if gm.rocmSmi {
gm.startCollector("rocm-smi")
}
return &gm, nil
}

View File

@@ -116,11 +116,17 @@ func (a *Agent) getSystemStats() system.Stats {
continue continue
} }
secondsElapsed := time.Since(stats.Time).Seconds() secondsElapsed := time.Since(stats.Time).Seconds()
readPerSecond := float64(d.ReadBytes-stats.TotalRead) / secondsElapsed readPerSecond := bytesToMegabytes(float64(d.ReadBytes-stats.TotalRead) / secondsElapsed)
writePerSecond := float64(d.WriteBytes-stats.TotalWrite) / secondsElapsed writePerSecond := bytesToMegabytes(float64(d.WriteBytes-stats.TotalWrite) / secondsElapsed)
// check for invalid values and reset stats if so
if readPerSecond < 0 || writePerSecond < 0 || readPerSecond > 50_000 || writePerSecond > 50_000 {
slog.Warn("Invalid disk I/O. Resetting.", "name", d.Name, "read", readPerSecond, "write", writePerSecond)
a.initializeDiskIoStats(ioCounters)
break
}
stats.Time = time.Now() stats.Time = time.Now()
stats.DiskReadPs = bytesToMegabytes(readPerSecond) stats.DiskReadPs = readPerSecond
stats.DiskWritePs = bytesToMegabytes(writePerSecond) stats.DiskWritePs = writePerSecond
stats.TotalRead = d.ReadBytes stats.TotalRead = d.ReadBytes
stats.TotalWrite = d.WriteBytes stats.TotalWrite = d.WriteBytes
// if root filesystem, update system stats // if root filesystem, update system stats
@@ -153,7 +159,7 @@ func (a *Agent) getSystemStats() system.Stats {
networkRecvPs := bytesToMegabytes(recvPerSecond) networkRecvPs := bytesToMegabytes(recvPerSecond)
// add check for issue (#150) where sent is a massive number // add check for issue (#150) where sent is a massive number
if networkSentPs > 10_000 || networkRecvPs > 10_000 { if networkSentPs > 10_000 || networkRecvPs > 10_000 {
slog.Warn("Invalid network stats. Resetting.", "sent", networkSentPs, "recv", networkRecvPs) slog.Warn("Invalid net stats. Resetting.", "sent", networkSentPs, "recv", networkRecvPs)
for _, v := range netIO { for _, v := range netIO {
if _, exists := a.netInterfaces[v.Name]; !exists { if _, exists := a.netInterfaces[v.Name]; !exists {
continue continue
@@ -172,10 +178,11 @@ func (a *Agent) getSystemStats() system.Stats {
} }
// temperatures (skip if sensors whitelist is set to empty string) // temperatures (skip if sensors whitelist is set to empty string)
if a.sensorsWhitelist == nil || len(a.sensorsWhitelist) > 0 { if a.sensorsWhitelist != nil && len(a.sensorsWhitelist) == 0 {
slog.Debug("Skipping temperature collection")
} else {
temps, err := sensors.TemperaturesWithContext(a.sensorsContext) temps, err := sensors.TemperaturesWithContext(a.sensorsContext)
if err != nil { if err != nil {
// err.(*sensors.Warnings).Verbose = true
slog.Debug("Sensor error", "err", err) slog.Debug("Sensor error", "err", err)
} }
slog.Debug("Temperature", "sensors", temps) slog.Debug("Temperature", "sensors", temps)
@@ -205,6 +212,22 @@ func (a *Agent) getSystemStats() system.Stats {
} }
} }
// GPU data
if a.gpuManager != nil {
if gpuData := a.gpuManager.GetCurrentData(); len(gpuData) > 0 {
systemStats.GPUData = gpuData
// add temperatures
if systemStats.Temperatures == nil {
systemStats.Temperatures = make(map[string]float64, len(gpuData))
}
for _, gpu := range gpuData {
if gpu.Temperature > 0 {
systemStats.Temperatures[gpu.Name] = gpu.Temperature
}
}
}
}
// update base system info // update base system info
a.systemInfo.Cpu = systemStats.Cpu a.systemInfo.Cpu = systemStats.Cpu
a.systemInfo.MemPct = systemStats.MemPct a.systemInfo.MemPct = systemStats.MemPct

View File

@@ -11,11 +11,10 @@ import (
"github.com/containrrr/shoutrrr" "github.com/containrrr/shoutrrr"
"github.com/goccy/go-json" "github.com/goccy/go-json"
"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/core"
"github.com/pocketbase/pocketbase/tools/mailer" "github.com/pocketbase/pocketbase/tools/mailer"
"github.com/pocketbase/pocketbase/tools/types" "github.com/pocketbase/pocketbase/tools/types"
"github.com/spf13/cast" "github.com/spf13/cast"
@@ -48,8 +47,8 @@ type SystemAlertStats struct {
} }
type SystemAlertData struct { type SystemAlertData struct {
systemRecord *models.Record systemRecord *core.Record
alertRecord *models.Record alertRecord *core.Record
name string name string
unit string unit string
val float64 val float64
@@ -68,12 +67,12 @@ func NewAlertManager(app *pocketbase.PocketBase) *AlertManager {
} }
} }
func (am *AlertManager) HandleSystemAlerts(systemRecord *models.Record, systemInfo system.Info, temperatures map[string]float64, extraFs map[string]*system.FsStats) error { func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, systemInfo system.Info, temperatures map[string]float64, extraFs map[string]*system.FsStats) error {
// start := time.Now() // start := time.Now()
// defer func() { // defer func() {
// log.Println("alert stats took", time.Since(start)) // log.Println("alert stats took", time.Since(start))
// }() // }()
alertRecords, err := am.app.Dao().FindRecordsByExpr("alerts", alertRecords, err := am.app.FindAllRecords("alerts",
dbx.NewExp("system={:system}", dbx.Params{"system": systemRecord.Id}), dbx.NewExp("system={:system}", dbx.Params{"system": systemRecord.Id}),
) )
if err != nil || len(alertRecords) == 0 { if err != nil || len(alertRecords) == 0 {
@@ -82,7 +81,7 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *models.Record, systemIn
} }
var validAlerts []SystemAlertData var validAlerts []SystemAlertData
now := systemRecord.Updated.Time().UTC() now := systemRecord.GetDateTime("updated").Time().UTC()
oldestTime := now oldestTime := now
for _, alertRecord := range alertRecords { for _, alertRecord := range alertRecords {
@@ -155,7 +154,7 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *models.Record, systemIn
Created types.DateTime `db:"created"` Created types.DateTime `db:"created"`
}{} }{}
err = am.app.Dao().DB(). err = am.app.DB().
Select("stats", "created"). Select("stats", "created").
From("system_stats"). From("system_stats").
Where(dbx.NewExp( Where(dbx.NewExp(
@@ -325,27 +324,28 @@ func (am *AlertManager) sendSystemAlert(alert SystemAlertData) {
body := fmt.Sprintf("%s averaged %.2f%s for the previous %v %s.", alert.descriptor, alert.val, alert.unit, alert.min, minutesLabel) 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) alert.alertRecord.Set("triggered", alert.triggered)
if err := am.app.Dao().SaveRecord(alert.alertRecord); err != nil { if err := am.app.Save(alert.alertRecord); err != nil {
// app.Logger().Error("failed to save alert record", "err", err.Error()) // app.Logger().Error("failed to save alert record", "err", err.Error())
return return
} }
// expand the user relation and send the alert // expand the user relation and send the alert
if errs := am.app.Dao().ExpandRecord(alert.alertRecord, []string{"user"}, nil); len(errs) > 0 { if errs := am.app.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 := alert.alertRecord.ExpandedOne("user"); user != nil { if user := alert.alertRecord.ExpandedOne("user"); user != nil {
am.sendAlert(AlertMessageData{ am.sendAlert(AlertMessageData{
UserID: user.GetId(), UserID: user.Id,
Title: subject, Title: subject,
Message: body, Message: body,
Link: am.app.Settings().Meta.AppUrl + "/system/" + url.PathEscape(systemName), Link: am.app.Settings().Meta.AppURL + "/system/" + url.PathEscape(systemName),
LinkText: "View " + systemName, LinkText: "View " + systemName,
}) })
} }
} }
func (am *AlertManager) HandleStatusAlerts(newStatus string, oldSystemRecord *models.Record) error { // todo: allow x minutes downtime before sending alert
func (am *AlertManager) HandleStatusAlerts(newStatus string, oldSystemRecord *core.Record) error {
var alertStatus string var alertStatus string
switch newStatus { switch newStatus {
case "up": case "up":
@@ -361,9 +361,9 @@ func (am *AlertManager) HandleStatusAlerts(newStatus string, oldSystemRecord *mo
return nil return nil
} }
// check if use // check if use
alertRecords, err := am.app.Dao().FindRecordsByExpr("alerts", alertRecords, err := am.app.FindAllRecords("alerts",
dbx.HashExp{ dbx.HashExp{
"system": oldSystemRecord.GetId(), "system": oldSystemRecord.Id,
"name": "Status", "name": "Status",
}, },
) )
@@ -373,7 +373,7 @@ func (am *AlertManager) HandleStatusAlerts(newStatus string, oldSystemRecord *mo
} }
for _, alertRecord := range alertRecords { for _, alertRecord := range alertRecords {
// expand the user relation // expand the user relation
if errs := am.app.Dao().ExpandRecord(alertRecord, []string{"user"}, nil); len(errs) > 0 { if errs := am.app.ExpandRecord(alertRecord, []string{"user"}, nil); len(errs) > 0 {
return fmt.Errorf("failed to expand: %v", errs) return fmt.Errorf("failed to expand: %v", errs)
} }
user := alertRecord.ExpandedOne("user") user := alertRecord.ExpandedOne("user")
@@ -387,10 +387,10 @@ func (am *AlertManager) HandleStatusAlerts(newStatus string, oldSystemRecord *mo
// send alert // send alert
systemName := oldSystemRecord.GetString("name") systemName := oldSystemRecord.GetString("name")
am.sendAlert(AlertMessageData{ am.sendAlert(AlertMessageData{
UserID: user.GetId(), UserID: user.Id,
Title: fmt.Sprintf("Connection to %s is %s %v", systemName, alertStatus, emoji), Title: fmt.Sprintf("Connection to %s is %s %v", systemName, alertStatus, emoji),
Message: fmt.Sprintf("Connection to %s is %s", systemName, alertStatus), Message: fmt.Sprintf("Connection to %s is %s", systemName, alertStatus),
Link: am.app.Settings().Meta.AppUrl + "/system/" + url.PathEscape(systemName), Link: am.app.Settings().Meta.AppURL + "/system/" + url.PathEscape(systemName),
LinkText: "View " + systemName, LinkText: "View " + systemName,
}) })
} }
@@ -399,7 +399,7 @@ func (am *AlertManager) HandleStatusAlerts(newStatus string, oldSystemRecord *mo
func (am *AlertManager) sendAlert(data AlertMessageData) { func (am *AlertManager) sendAlert(data AlertMessageData) {
// get user settings // get user settings
record, err := am.app.Dao().FindFirstRecordByFilter( record, err := am.app.FindFirstRecordByFilter(
"user_settings", "user={:user}", "user_settings", "user={:user}",
dbx.Params{"user": data.UserID}, dbx.Params{"user": data.UserID},
) )
@@ -511,19 +511,19 @@ func sliceContains(slice []string, item string) bool {
return false return false
} }
func (am *AlertManager) SendTestNotification(c echo.Context) error { func (am *AlertManager) SendTestNotification(e *core.RequestEvent) error {
requestData := apis.RequestInfo(c) info, _ := e.RequestInfo()
if requestData.AuthRecord == nil { if info.Auth == nil {
return apis.NewForbiddenError("Forbidden", nil) return apis.NewForbiddenError("Forbidden", nil)
} }
url := c.QueryParam("url") url := e.Request.URL.Query().Get("url")
// log.Println("url", url) // log.Println("url", url)
if url == "" { if url == "" {
return c.JSON(200, map[string]string{"err": "URL is required"}) return e.JSON(200, map[string]string{"err": "URL is required"})
} }
err := am.SendShoutrrrAlert(url, "Test Alert", "This is a notification from Beszel.", am.app.Settings().Meta.AppUrl, "View Beszel") err := am.SendShoutrrrAlert(url, "Test Alert", "This is a notification from Beszel.", am.app.Settings().Meta.AppURL, "View Beszel")
if err != nil { if err != nil {
return c.JSON(200, map[string]string{"err": err.Error()}) return e.JSON(200, map[string]string{"err": err.Error()})
} }
return c.JSON(200, map[string]bool{"err": false}) return e.JSON(200, map[string]bool{"err": false})
} }

View File

@@ -28,6 +28,17 @@ type Stats struct {
MaxNetworkRecv float64 `json:"nrm,omitempty"` MaxNetworkRecv float64 `json:"nrm,omitempty"`
Temperatures map[string]float64 `json:"t,omitempty"` Temperatures map[string]float64 `json:"t,omitempty"`
ExtraFs map[string]*FsStats `json:"efs,omitempty"` ExtraFs map[string]*FsStats `json:"efs,omitempty"`
GPUData map[string]GPUData `json:"g,omitempty"`
}
type GPUData struct {
Name string `json:"n"`
Temperature float64 `json:"-"`
MemoryUsed float64 `json:"mu,omitempty"`
MemoryTotal float64 `json:"mt,omitempty"`
Usage float64 `json:"u"`
Power float64 `json:"p,omitempty"`
Count float64 `json:"-"`
} }
type FsStats struct { type FsStats struct {
@@ -63,6 +74,7 @@ type Info struct {
DiskPct float64 `json:"dp"` DiskPct float64 `json:"dp"`
Bandwidth float64 `json:"b"` Bandwidth float64 `json:"b"`
AgentVersion string `json:"v"` AgentVersion string `json:"v"`
Podman bool `json:"p,omitempty"`
} }
// Final data structure to return to the hub // Final data structure to return to the hub

View File

@@ -8,10 +8,9 @@ import (
"path/filepath" "path/filepath"
"strconv" "strconv"
"github.com/labstack/echo/v5"
"github.com/pocketbase/dbx" "github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/apis" "github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/models" "github.com/pocketbase/pocketbase/core"
"github.com/spf13/cast" "github.com/spf13/cast"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
@@ -46,11 +45,11 @@ func (h *Hub) syncSystemsWithConfig() error {
return nil return nil
} }
var firstUser *models.Record var firstUser *core.Record
// Create a map of email to user ID // Create a map of email to user ID
userEmailToID := make(map[string]string) userEmailToID := make(map[string]string)
users, err := h.app.Dao().FindRecordsByExpr("users", dbx.NewExp("id != ''")) users, err := h.app.FindAllRecords("users", dbx.NewExp("id != ''"))
if err != nil { if err != nil {
return err return err
} }
@@ -85,13 +84,13 @@ func (h *Hub) syncSystemsWithConfig() error {
} }
// Get existing systems // Get existing systems
existingSystems, err := h.app.Dao().FindRecordsByExpr("systems", dbx.NewExp("id != ''")) existingSystems, err := h.app.FindAllRecords("systems", dbx.NewExp("id != ''"))
if err != nil { if err != nil {
return err return err
} }
// Create a map of existing systems for easy lookup // Create a map of existing systems for easy lookup
existingSystemsMap := make(map[string]*models.Record) existingSystemsMap := make(map[string]*core.Record)
for _, system := range existingSystems { for _, system := range existingSystems {
key := system.GetString("host") + ":" + system.GetString("port") key := system.GetString("host") + ":" + system.GetString("port")
existingSystemsMap[key] = system existingSystemsMap[key] = system
@@ -105,24 +104,24 @@ func (h *Hub) syncSystemsWithConfig() error {
existingSystem.Set("name", sysConfig.Name) existingSystem.Set("name", sysConfig.Name)
existingSystem.Set("users", sysConfig.Users) existingSystem.Set("users", sysConfig.Users)
existingSystem.Set("port", sysConfig.Port) existingSystem.Set("port", sysConfig.Port)
if err := h.app.Dao().SaveRecord(existingSystem); err != nil { if err := h.app.Save(existingSystem); err != nil {
return err return err
} }
delete(existingSystemsMap, key) delete(existingSystemsMap, key)
} else { } else {
// Create new system // Create new system
systemsCollection, err := h.app.Dao().FindCollectionByNameOrId("systems") systemsCollection, err := h.app.FindCollectionByNameOrId("systems")
if err != nil { if err != nil {
return fmt.Errorf("failed to find systems collection: %v", err) return fmt.Errorf("failed to find systems collection: %v", err)
} }
newSystem := models.NewRecord(systemsCollection) newSystem := core.NewRecord(systemsCollection)
newSystem.Set("name", sysConfig.Name) newSystem.Set("name", sysConfig.Name)
newSystem.Set("host", sysConfig.Host) newSystem.Set("host", sysConfig.Host)
newSystem.Set("port", sysConfig.Port) newSystem.Set("port", sysConfig.Port)
newSystem.Set("users", sysConfig.Users) newSystem.Set("users", sysConfig.Users)
newSystem.Set("info", system.Info{}) newSystem.Set("info", system.Info{})
newSystem.Set("status", "pending") newSystem.Set("status", "pending")
if err := h.app.Dao().SaveRecord(newSystem); err != nil { if err := h.app.Save(newSystem); err != nil {
return fmt.Errorf("failed to create new system: %v", err) return fmt.Errorf("failed to create new system: %v", err)
} }
} }
@@ -130,7 +129,7 @@ func (h *Hub) syncSystemsWithConfig() error {
// Delete systems not in config // Delete systems not in config
for _, system := range existingSystemsMap { for _, system := range existingSystemsMap {
if err := h.app.Dao().DeleteRecord(system); err != nil { if err := h.app.Delete(system); err != nil {
return err return err
} }
} }
@@ -142,7 +141,7 @@ func (h *Hub) syncSystemsWithConfig() error {
// Generates content for the config.yml file as a YAML string // Generates content for the config.yml file as a YAML string
func (h *Hub) generateConfigYAML() (string, error) { func (h *Hub) generateConfigYAML() (string, error) {
// Fetch all systems from the database // Fetch all systems from the database
systems, err := h.app.Dao().FindRecordsByFilter("systems", "id != ''", "name", -1, 0) systems, err := h.app.FindRecordsByFilter("systems", "id != ''", "name", -1, 0)
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -195,7 +194,7 @@ func (h *Hub) generateConfigYAML() (string, error) {
// New helper function to get a map of user IDs to emails // New helper function to get a map of user IDs to emails
func (h *Hub) getUserEmailMap(userIDs []string) (map[string]string, error) { func (h *Hub) getUserEmailMap(userIDs []string) (map[string]string, error) {
users, err := h.app.Dao().FindRecordsByIds("users", userIDs) users, err := h.app.FindRecordsByIds("users", userIDs)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -209,14 +208,14 @@ func (h *Hub) getUserEmailMap(userIDs []string) (map[string]string, error) {
} }
// Returns the current config.yml file as a JSON object // Returns the current config.yml file as a JSON object
func (h *Hub) getYamlConfig(c echo.Context) error { func (h *Hub) getYamlConfig(e *core.RequestEvent) error {
requestData := apis.RequestInfo(c) info, _ := e.RequestInfo()
if requestData.AuthRecord == nil || requestData.AuthRecord.GetString("role") != "admin" { if info.Auth == nil || info.Auth.GetString("role") != "admin" {
return apis.NewForbiddenError("Forbidden", nil) return apis.NewForbiddenError("Forbidden", nil)
} }
configContent, err := h.generateConfigYAML() configContent, err := h.generateConfigYAML()
if err != nil { if err != nil {
return err return err
} }
return c.JSON(200, map[string]string{"config": configContent}) return e.JSON(200, map[string]string{"config": configContent})
} }

View File

@@ -14,6 +14,7 @@ import (
"encoding/pem" "encoding/pem"
"fmt" "fmt"
"log" "log"
"net"
"net/http" "net/http"
"net/http/httputil" "net/http/httputil"
"net/url" "net/url"
@@ -23,37 +24,31 @@ import (
"time" "time"
"github.com/goccy/go-json" "github.com/goccy/go-json"
"github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase" "github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/apis" "github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/plugins/migratecmd" "github.com/pocketbase/pocketbase/plugins/migratecmd"
"github.com/pocketbase/pocketbase/tools/cron"
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
) )
type Hub struct { type Hub struct {
app *pocketbase.PocketBase app *pocketbase.PocketBase
connectionLock *sync.Mutex systemConnections sync.Map
systemConnections map[string]*ssh.Client
sshClientConfig *ssh.ClientConfig sshClientConfig *ssh.ClientConfig
pubKey string pubKey string
am *alerts.AlertManager am *alerts.AlertManager
um *users.UserManager um *users.UserManager
rm *records.RecordManager rm *records.RecordManager
systemStats *models.Collection systemStats *core.Collection
containerStats *models.Collection containerStats *core.Collection
} }
func NewHub(app *pocketbase.PocketBase) *Hub { func NewHub(app *pocketbase.PocketBase) *Hub {
return &Hub{ return &Hub{
app: app, app: app,
connectionLock: &sync.Mutex{}, am: alerts.NewAlertManager(app),
systemConnections: make(map[string]*ssh.Client), um: users.NewUserManager(app),
am: alerts.NewAlertManager(app), rm: records.NewRecordManager(app),
um: users.NewUserManager(app),
rm: records.NewRecordManager(app),
} }
} }
@@ -69,128 +64,140 @@ func (h *Hub) Run() {
}) })
// initial setup // initial setup
h.app.OnBeforeServe().Add(func(e *core.ServeEvent) error { h.app.OnServe().BindFunc(func(se *core.ServeEvent) error {
// create ssh client config // create ssh client config
err := h.createSSHClientConfig() err := h.createSSHClientConfig()
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
// set general settings
settings := h.app.Settings()
// batch requests (for global alerts)
settings.Batch.Enabled = true
// set auth settings // set auth settings
usersCollection, err := h.app.Dao().FindCollectionByNameOrId("users") usersCollection, err := h.app.FindCollectionByNameOrId("users")
if err != nil { if err != nil {
return err return err
} }
usersAuthOptions := usersCollection.AuthOptions() // disable email auth if DISABLE_PASSWORD_AUTH env var is set
usersAuthOptions.AllowUsernameAuth = false usersCollection.PasswordAuth.Enabled = os.Getenv("DISABLE_PASSWORD_AUTH") != "true"
if os.Getenv("DISABLE_PASSWORD_AUTH") == "true" { usersCollection.PasswordAuth.IdentityFields = []string{"email"}
usersAuthOptions.AllowEmailAuth = false // disable oauth if no providers are configured (todo: remove this in post 0.9.0 release)
} else { if usersCollection.OAuth2.Enabled {
usersAuthOptions.AllowEmailAuth = true usersCollection.OAuth2.Enabled = len(usersCollection.OAuth2.Providers) > 0
} }
usersCollection.SetOptions(usersAuthOptions) // allow oauth user creation if USER_CREATION is set
if err := h.app.Dao().SaveCollection(usersCollection); err != nil { if os.Getenv("USER_CREATION") == "true" {
cr := "@request.context = 'oauth2'"
usersCollection.CreateRule = &cr
} else {
usersCollection.CreateRule = nil
}
if err := h.app.Save(usersCollection); err != nil {
return err return err
} }
// sync systems with config // sync systems with config
return h.syncSystemsWithConfig() h.syncSystemsWithConfig()
return se.Next()
}) })
// serve web ui // serve web ui
h.app.OnBeforeServe().Add(func(e *core.ServeEvent) error { h.app.OnServe().BindFunc(func(se *core.ServeEvent) error {
switch isGoRun { switch isGoRun {
case true: case true:
proxy := httputil.NewSingleHostReverseProxy(&url.URL{ proxy := httputil.NewSingleHostReverseProxy(&url.URL{
Scheme: "http", Scheme: "http",
Host: "localhost:5173", Host: "localhost:5173",
}) })
e.Router.Any("/*", echo.WrapHandler(proxy)) se.Router.Any("/", func(e *core.RequestEvent) error {
proxy.ServeHTTP(e.Response, e.Request)
return nil
})
default: default:
csp, cspExists := os.LookupEnv("CSP") csp, cspExists := os.LookupEnv("CSP")
e.Router.Any("/*", func(c echo.Context) error { se.Router.Any("/{path...}", func(e *core.RequestEvent) error {
if cspExists { if cspExists {
c.Response().Header().Del("X-Frame-Options") e.Response.Header().Del("X-Frame-Options")
c.Response().Header().Set("Content-Security-Policy", csp) e.Response.Header().Set("Content-Security-Policy", csp)
} }
indexFallback := !strings.HasPrefix(c.Request().URL.Path, "/static/") indexFallback := !strings.HasPrefix(e.Request.URL.Path, "/static/")
return apis.StaticDirectoryHandler(site.Dist, indexFallback)(c) return apis.Static(site.DistDirFS, indexFallback)(e)
}) })
} }
return nil return se.Next()
}) })
// set up scheduled jobs / ticker for system updates // set up scheduled jobs / ticker for system updates
h.app.OnBeforeServe().Add(func(e *core.ServeEvent) error { h.app.OnServe().BindFunc(func(se *core.ServeEvent) error {
// 15 second ticker for system updates // 15 second ticker for system updates
go h.startSystemUpdateTicker() go h.startSystemUpdateTicker()
// set up cron jobs // set up cron jobs
scheduler := cron.New()
// delete old records once every hour // delete old records once every hour
scheduler.MustAdd("delete old records", "8 * * * *", h.rm.DeleteOldRecords) h.app.Cron().MustAdd("delete old records", "8 * * * *", h.rm.DeleteOldRecords)
// create longer records every 10 minutes // create longer records every 10 minutes
scheduler.MustAdd("create longer records", "*/10 * * * *", func() { h.app.Cron().MustAdd("create longer records", "*/10 * * * *", func() {
if systemStats, containerStats, err := h.getCollections(); err == nil { if systemStats, containerStats, err := h.getCollections(); err == nil {
h.rm.CreateLongerRecords([]*models.Collection{systemStats, containerStats}) h.rm.CreateLongerRecords([]*core.Collection{systemStats, containerStats})
} }
}) })
scheduler.Start() return se.Next()
return nil
}) })
// custom api routes // custom api routes
h.app.OnBeforeServe().Add(func(e *core.ServeEvent) error { h.app.OnServe().BindFunc(func(se *core.ServeEvent) error {
// returns public key // returns public key
e.Router.GET("/api/beszel/getkey", func(c echo.Context) error { se.Router.GET("/api/beszel/getkey", func(e *core.RequestEvent) error {
requestData := apis.RequestInfo(c) info, _ := e.RequestInfo()
if requestData.AuthRecord == nil { if info.Auth == nil {
return apis.NewForbiddenError("Forbidden", nil) return apis.NewForbiddenError("Forbidden", nil)
} }
return c.JSON(http.StatusOK, map[string]string{"key": h.pubKey, "v": beszel.Version}) return e.JSON(http.StatusOK, map[string]string{"key": h.pubKey, "v": beszel.Version})
}) })
// check if first time setup on login page // check if first time setup on login page
e.Router.GET("/api/beszel/first-run", func(c echo.Context) error { se.Router.GET("/api/beszel/first-run", func(e *core.RequestEvent) error {
adminNum, err := h.app.Dao().TotalAdmins() total, err := h.app.CountRecords("users")
if err != nil { return e.JSON(http.StatusOK, map[string]bool{"firstRun": err == nil && total == 0})
return err
}
return c.JSON(http.StatusOK, map[string]bool{"firstRun": adminNum == 0})
}) })
// send test notification // send test notification
e.Router.GET("/api/beszel/send-test-notification", h.am.SendTestNotification) se.Router.GET("/api/beszel/send-test-notification", h.am.SendTestNotification)
// API endpoint to get config.yml content // API endpoint to get config.yml content
e.Router.GET("/api/beszel/config-yaml", h.getYamlConfig) se.Router.GET("/api/beszel/config-yaml", h.getYamlConfig)
return nil // create first user endpoint only needed if no users exist
if totalUsers, _ := h.app.CountRecords("users"); totalUsers == 0 {
se.Router.POST("/api/beszel/create-user", h.um.CreateFirstUser)
}
return se.Next()
}) })
// system creation defaults // system creation defaults
h.app.OnModelBeforeCreate("systems").Add(func(e *core.ModelEvent) error { h.app.OnRecordCreate("systems").BindFunc(func(e *core.RecordEvent) error {
record := e.Model.(*models.Record) e.Record.Set("info", system.Info{})
record.Set("info", system.Info{}) e.Record.Set("status", "pending")
record.Set("status", "pending") return e.Next()
return nil
}) })
// immediately create connection for new systems // immediately create connection for new systems
h.app.OnModelAfterCreate("systems").Add(func(e *core.ModelEvent) error { h.app.OnRecordAfterCreateSuccess("systems").BindFunc(func(e *core.RecordEvent) error {
go h.updateSystem(e.Model.(*models.Record)) go h.updateSystem(e.Record)
return nil return e.Next()
}) })
// handle default values for user / user_settings creation // handle default values for user / user_settings creation
h.app.OnModelBeforeCreate("users").Add(h.um.InitializeUserRole) h.app.OnRecordCreate("users").BindFunc(h.um.InitializeUserRole)
h.app.OnModelBeforeCreate("user_settings").Add(h.um.InitializeUserSettings) h.app.OnRecordCreate("user_settings").BindFunc(h.um.InitializeUserSettings)
// empty info for systems that are paused // empty info for systems that are paused
h.app.OnModelBeforeUpdate("systems").Add(func(e *core.ModelEvent) error { h.app.OnRecordUpdate("systems").BindFunc(func(e *core.RecordEvent) error {
if e.Model.(*models.Record).GetString("status") == "paused" { if e.Record.GetString("status") == "paused" {
e.Model.(*models.Record).Set("info", system.Info{}) e.Record.Set("info", system.Info{})
} }
return nil return e.Next()
}) })
// 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.OnRecordAfterUpdateSuccess("systems").BindFunc(func(e *core.RecordEvent) error {
newRecord := e.Model.(*models.Record) newRecord := e.Record.Fresh()
oldRecord := newRecord.OriginalCopy() oldRecord := newRecord.Original()
newStatus := newRecord.GetString("status") newStatus := newRecord.GetString("status")
// if system is disconnected and connection exists, remove it // if system is disconnected and connection exists, remove it
@@ -205,15 +212,13 @@ func (h *Hub) Run() {
h.am.HandleStatusAlerts(newStatus, oldRecord) h.am.HandleStatusAlerts(newStatus, oldRecord)
} }
return e.Next()
return nil
}) })
// do things after a systems record is deleted // if system is deleted, close connection
h.app.OnModelAfterDelete("systems").Add(func(e *core.ModelEvent) error { h.app.OnRecordAfterDeleteSuccess("systems").BindFunc(func(e *core.RecordEvent) error {
// if system connection exists, close it h.deleteSystemConnection(e.Record)
h.deleteSystemConnection(e.Model.(*models.Record)) return e.Next()
return nil
}) })
if err := h.app.Start(); err != nil { if err := h.app.Start(); err != nil {
@@ -229,7 +234,7 @@ func (h *Hub) startSystemUpdateTicker() {
} }
func (h *Hub) updateSystems() { func (h *Hub) updateSystems() {
records, err := h.app.Dao().FindRecordsByFilter( records, err := h.app.FindRecordsByFilter(
"2hz5ncl8tizk5nx", // systems collection "2hz5ncl8tizk5nx", // systems collection
"status != 'paused'", // filter "status != 'paused'", // filter
"updated", // sort "updated", // sort
@@ -258,13 +263,13 @@ func (h *Hub) updateSystems() {
} }
} }
func (h *Hub) updateSystem(record *models.Record) { func (h *Hub) updateSystem(record *core.Record) {
var client *ssh.Client var client *ssh.Client
var err error var err error
// check if system connection data exists // check if system connection exists
if _, ok := h.systemConnections[record.Id]; ok { if existingClient, ok := h.systemConnections.Load(record.Id); ok {
client = h.systemConnections[record.Id] client = existingClient.(*ssh.Client)
} else { } else {
// create system connection // create system connection
client, err = h.createSystemConnection(record) client, err = h.createSystemConnection(record)
@@ -275,9 +280,7 @@ func (h *Hub) updateSystem(record *models.Record) {
} }
return return
} }
h.connectionLock.Lock() h.systemConnections.Store(record.Id, client)
h.systemConnections[record.Id] = client
h.connectionLock.Unlock()
} }
// get system stats from agent // get system stats from agent
var systemData system.CombinedData var systemData system.CombinedData
@@ -286,6 +289,7 @@ func (h *Hub) updateSystem(record *models.Record) {
// if previous connection was closed, try again // if previous connection was closed, try again
h.app.Logger().Error("Existing SSH connection closed. Retrying...", "host", record.GetString("host"), "port", record.GetString("port")) h.app.Logger().Error("Existing SSH connection closed. Retrying...", "host", record.GetString("host"), "port", record.GetString("port"))
h.deleteSystemConnection(record) h.deleteSystemConnection(record)
time.Sleep(time.Millisecond * 100)
h.updateSystem(record) h.updateSystem(record)
return return
} }
@@ -294,10 +298,9 @@ 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 := dao.SaveRecord(record); err != nil { if err := h.app.SaveNoValidate(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 system_stats and container_stats records // add system_stats and container_stats records
@@ -305,42 +308,42 @@ func (h *Hub) updateSystem(record *models.Record) {
h.app.Logger().Error("Failed to get collections: ", "err", err.Error()) h.app.Logger().Error("Failed to get collections: ", "err", err.Error())
} else { } else {
// add new system_stats record // add new system_stats record
systemStatsRecord := models.NewRecord(systemStats) systemStatsRecord := core.NewRecord(systemStats)
systemStatsRecord.Set("system", record.Id) systemStatsRecord.Set("system", record.Id)
systemStatsRecord.Set("stats", systemData.Stats) systemStatsRecord.Set("stats", systemData.Stats)
systemStatsRecord.Set("type", "1m") systemStatsRecord.Set("type", "1m")
if err := dao.SaveRecord(systemStatsRecord); err != nil { if err := h.app.SaveNoValidate(systemStatsRecord); 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 // add new container_stats record
if len(systemData.Containers) > 0 { if len(systemData.Containers) > 0 {
containerStatsRecord := models.NewRecord(containerStats) containerStatsRecord := core.NewRecord(containerStats)
containerStatsRecord.Set("system", record.Id) containerStatsRecord.Set("system", record.Id)
containerStatsRecord.Set("stats", systemData.Containers) containerStatsRecord.Set("stats", systemData.Containers)
containerStatsRecord.Set("type", "1m") containerStatsRecord.Set("type", "1m")
if err := dao.SaveRecord(containerStatsRecord); err != nil { if err := h.app.SaveNoValidate(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())
} }
} }
} }
// system info alerts (todo: extra fs alerts) // system info alerts
if err := h.am.HandleSystemAlerts(record, systemData.Info, systemData.Stats.Temperatures, systemData.Stats.ExtraFs); err != nil { 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()) h.app.Logger().Error("System alerts error", "err", err.Error())
} }
} }
// return system_stats and container_stats collections // return system_stats and container_stats collections
func (h *Hub) getCollections() (*models.Collection, *models.Collection, error) { func (h *Hub) getCollections() (*core.Collection, *core.Collection, error) {
if h.systemStats == nil { if h.systemStats == nil {
systemStats, err := h.app.Dao().FindCollectionByNameOrId("system_stats") systemStats, err := h.app.FindCollectionByNameOrId("system_stats")
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
h.systemStats = systemStats h.systemStats = systemStats
} }
if h.containerStats == nil { if h.containerStats == nil {
containerStats, err := h.app.Dao().FindCollectionByNameOrId("container_stats") containerStats, err := h.app.FindCollectionByNameOrId("container_stats")
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
@@ -350,28 +353,27 @@ func (h *Hub) getCollections() (*models.Collection, *models.Collection, error) {
} }
// set system to specified status and save record // set system to specified status and save record
func (h *Hub) updateSystemStatus(record *models.Record, status string) { func (h *Hub) updateSystemStatus(record *core.Record, status string) {
if record.GetString("status") != status { if record.Fresh().GetString("status") != status {
record.Set("status", status) record.Set("status", status)
if err := h.app.Dao().SaveRecord(record); err != nil { if err := h.app.SaveNoValidate(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())
} }
} }
} }
func (h *Hub) deleteSystemConnection(record *models.Record) { // delete system connection from map and close connection
if _, ok := h.systemConnections[record.Id]; ok { func (h *Hub) deleteSystemConnection(record *core.Record) {
if h.systemConnections[record.Id] != nil { if client, ok := h.systemConnections.Load(record.Id); ok {
h.systemConnections[record.Id].Close() if sshClient := client.(*ssh.Client); sshClient != nil {
sshClient.Close()
} }
h.connectionLock.Lock() h.systemConnections.Delete(record.Id)
defer h.connectionLock.Unlock()
delete(h.systemConnections, record.Id)
} }
} }
func (h *Hub) createSystemConnection(record *models.Record) (*ssh.Client, error) { func (h *Hub) createSystemConnection(record *core.Record) (*ssh.Client, error) {
client, err := ssh.Dial("tcp", fmt.Sprintf("%s:%s", record.GetString("host"), record.GetString("port")), h.sshClientConfig) client, err := ssh.Dial("tcp", net.JoinHostPort(record.GetString("host"), record.GetString("port")), h.sshClientConfig)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -397,14 +399,14 @@ func (h *Hub) createSSHClientConfig() error {
ssh.PublicKeys(signer), ssh.PublicKeys(signer),
}, },
HostKeyCallback: ssh.InsecureIgnoreHostKey(), HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Timeout: 5 * time.Second, Timeout: 4 * time.Second,
} }
return nil return nil
} }
// Fetches system stats from the agent and decodes the json data into the provided struct // Fetches system stats from the agent and decodes the json data into the provided struct
func (h *Hub) requestJsonFromAgent(client *ssh.Client, systemData *system.CombinedData) error { func (h *Hub) requestJsonFromAgent(client *ssh.Client, systemData *system.CombinedData) error {
session, err := newSessionWithTimeout(client, 5*time.Second) session, err := newSessionWithTimeout(client, 4*time.Second)
if err != nil { if err != nil {
return fmt.Errorf("bad client") return fmt.Errorf("bad client")
} }

View File

@@ -11,8 +11,7 @@ import (
"github.com/goccy/go-json" "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/core"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tools/types" "github.com/pocketbase/pocketbase/tools/types"
) )
@@ -41,7 +40,7 @@ func NewRecordManager(app *pocketbase.PocketBase) *RecordManager {
} }
// Create longer records by averaging shorter records // Create longer records by averaging shorter records
func (rm *RecordManager) CreateLongerRecords(collections []*models.Collection) { func (rm *RecordManager) CreateLongerRecords(collections []*core.Collection) {
// start := time.Now() // start := time.Now()
longerRecordData := []LongerRecordData{ longerRecordData := []LongerRecordData{
{ {
@@ -71,8 +70,8 @@ func (rm *RecordManager) CreateLongerRecords(collections []*models.Collection) {
}, },
} }
// wrap the operations in a transaction // wrap the operations in a transaction
rm.app.Dao().RunInTransaction(func(txDao *daos.Dao) error { rm.app.RunInTransaction(func(txApp core.App) error {
activeSystems, err := txDao.FindRecordsByExpr("systems", dbx.NewExp("status = 'up'")) activeSystems, err := txApp.FindAllRecords("systems", dbx.NewExp("status = 'up'"))
if err != nil { if err != nil {
log.Println("failed to get active systems", "err", err.Error()) log.Println("failed to get active systems", "err", err.Error())
return err return err
@@ -92,7 +91,7 @@ func (rm *RecordManager) CreateLongerRecords(collections []*models.Collection) {
for _, collection := range collections { for _, collection := range collections {
// check creation time of last longer record if not 10m, since 10m is created every run // check creation time of last longer record if not 10m, since 10m is created every run
if recordData.longerType != "10m" { if recordData.longerType != "10m" {
lastLongerRecord, err := txDao.FindFirstRecordByFilter( lastLongerRecord, err := txApp.FindFirstRecordByFilter(
collection.Id, collection.Id,
"type = {:type} && system = {:system} && created > {:created}", "type = {:type} && system = {:system} && created > {:created}",
dbx.Params{"type": recordData.longerType, "system": system.Id, "created": longerRecordPeriod}, dbx.Params{"type": recordData.longerType, "system": system.Id, "created": longerRecordPeriod},
@@ -106,7 +105,7 @@ func (rm *RecordManager) CreateLongerRecords(collections []*models.Collection) {
// get shorter records from the past x minutes // get shorter records from the past x minutes
var stats RecordStats var stats RecordStats
err := txDao.DB(). err := txApp.DB().
Select("stats"). Select("stats").
From(collection.Name). From(collection.Name).
AndWhere(dbx.NewExp( AndWhere(dbx.NewExp(
@@ -125,7 +124,7 @@ func (rm *RecordManager) CreateLongerRecords(collections []*models.Collection) {
continue continue
} }
// average the shorter records and create longer record // average the shorter records and create longer record
longerRecord := models.NewRecord(collection) longerRecord := core.NewRecord(collection)
longerRecord.Set("system", system.Id) longerRecord.Set("system", system.Id)
longerRecord.Set("type", recordData.longerType) longerRecord.Set("type", recordData.longerType)
switch collection.Name { switch collection.Name {
@@ -134,7 +133,7 @@ func (rm *RecordManager) CreateLongerRecords(collections []*models.Collection) {
case "container_stats": case "container_stats":
longerRecord.Set("stats", rm.AverageContainerStats(stats)) longerRecord.Set("stats", rm.AverageContainerStats(stats))
} }
if err := txDao.SaveRecord(longerRecord); err != nil { if err := txApp.SaveNoValidate(longerRecord); err != nil {
log.Println("failed to save longer record", "err", err.Error()) log.Println("failed to save longer record", "err", err.Error())
} }
} }
@@ -149,11 +148,7 @@ func (rm *RecordManager) CreateLongerRecords(collections []*models.Collection) {
// Calculate the average stats of a list of system_stats records without reflect // Calculate the average stats of a list of system_stats records without reflect
func (rm *RecordManager) AverageSystemStats(records RecordStats) system.Stats { func (rm *RecordManager) AverageSystemStats(records RecordStats) system.Stats {
sum := system.Stats{ sum := system.Stats{}
Temperatures: make(map[string]float64),
ExtraFs: make(map[string]*system.FsStats),
}
count := float64(len(records)) count := float64(len(records))
// use different counter for temps in case some records don't have them // use different counter for temps in case some records don't have them
tempCount := float64(0) tempCount := float64(0)
@@ -184,6 +179,9 @@ func (rm *RecordManager) AverageSystemStats(records RecordStats) system.Stats {
sum.MaxDiskWritePs = max(sum.MaxDiskWritePs, stats.MaxDiskWritePs, stats.DiskWritePs) sum.MaxDiskWritePs = max(sum.MaxDiskWritePs, stats.MaxDiskWritePs, stats.DiskWritePs)
// add temps to sum // add temps to sum
if stats.Temperatures != nil { if stats.Temperatures != nil {
if sum.Temperatures == nil {
sum.Temperatures = make(map[string]float64, len(stats.Temperatures))
}
tempCount++ tempCount++
for key, value := range stats.Temperatures { for key, value := range stats.Temperatures {
if _, ok := sum.Temperatures[key]; !ok { if _, ok := sum.Temperatures[key]; !ok {
@@ -194,6 +192,9 @@ func (rm *RecordManager) AverageSystemStats(records RecordStats) system.Stats {
} }
// add extra fs to sum // add extra fs to sum
if stats.ExtraFs != nil { if stats.ExtraFs != nil {
if sum.ExtraFs == nil {
sum.ExtraFs = make(map[string]*system.FsStats, len(stats.ExtraFs))
}
for key, value := range stats.ExtraFs { for key, value := range stats.ExtraFs {
if _, ok := sum.ExtraFs[key]; !ok { if _, ok := sum.ExtraFs[key]; !ok {
sum.ExtraFs[key] = &system.FsStats{} sum.ExtraFs[key] = &system.FsStats{}
@@ -207,6 +208,25 @@ func (rm *RecordManager) AverageSystemStats(records RecordStats) system.Stats {
sum.ExtraFs[key].MaxDiskWritePS = max(sum.ExtraFs[key].MaxDiskWritePS, value.MaxDiskWritePS, value.DiskWritePs) sum.ExtraFs[key].MaxDiskWritePS = max(sum.ExtraFs[key].MaxDiskWritePS, value.MaxDiskWritePS, value.DiskWritePs)
} }
} }
// add GPU data
if stats.GPUData != nil {
if sum.GPUData == nil {
sum.GPUData = make(map[string]system.GPUData, len(stats.GPUData))
}
for id, value := range stats.GPUData {
if _, ok := sum.GPUData[id]; !ok {
sum.GPUData[id] = system.GPUData{Name: value.Name}
}
gpu := sum.GPUData[id]
gpu.Temperature += value.Temperature
gpu.MemoryUsed += value.MemoryUsed
gpu.MemoryTotal += value.MemoryTotal
gpu.Usage += value.Usage
gpu.Power += value.Power
gpu.Count += value.Count
sum.GPUData[id] = gpu
}
}
} }
stats = system.Stats{ stats = system.Stats{
@@ -232,14 +252,14 @@ func (rm *RecordManager) AverageSystemStats(records RecordStats) system.Stats {
MaxNetworkRecv: sum.MaxNetworkRecv, MaxNetworkRecv: sum.MaxNetworkRecv,
} }
if len(sum.Temperatures) != 0 { if sum.Temperatures != nil {
stats.Temperatures = make(map[string]float64, len(sum.Temperatures)) 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 sum.ExtraFs != nil {
stats.ExtraFs = make(map[string]*system.FsStats, len(sum.ExtraFs)) 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{
@@ -253,6 +273,21 @@ func (rm *RecordManager) AverageSystemStats(records RecordStats) system.Stats {
} }
} }
if sum.GPUData != nil {
stats.GPUData = make(map[string]system.GPUData, len(sum.GPUData))
for id, value := range sum.GPUData {
stats.GPUData[id] = system.GPUData{
Name: value.Name,
Temperature: twoDecimals(value.Temperature / count),
MemoryUsed: twoDecimals(value.MemoryUsed / count),
MemoryTotal: twoDecimals(value.MemoryTotal / count),
Usage: twoDecimals(value.Usage / count),
Power: twoDecimals(value.Power / count),
Count: twoDecimals(value.Count / count),
}
}
}
return stats return stats
} }
@@ -318,7 +353,7 @@ func (rm *RecordManager) DeleteOldRecords() {
retention: 30 * 24 * time.Hour, retention: 30 * 24 * time.Hour,
}, },
} }
db := rm.app.Dao().NonconcurrentDB() db := rm.app.NonconcurrentDB()
for _, recordData := range recordData { for _, recordData := range recordData {
for _, collectionSlug := range collections { for _, collectionSlug := range collections {
formattedDate := time.Now().UTC().Add(-recordData.retention).Format(types.DefaultDateLayout) formattedDate := time.Now().UTC().Add(-recordData.retention).Format(types.DefaultDateLayout)

View File

@@ -2,11 +2,13 @@
package users package users
import ( import (
"beszel/migrations"
"log" "log"
"net/http"
"strings"
"github.com/pocketbase/pocketbase" "github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/models"
) )
type UserManager struct { type UserManager struct {
@@ -26,16 +28,17 @@ func NewUserManager(app *pocketbase.PocketBase) *UserManager {
} }
} }
func (um *UserManager) InitializeUserRole(e *core.ModelEvent) error { // Initialize user role if not set
user := e.Model.(*models.Record) func (um *UserManager) InitializeUserRole(e *core.RecordEvent) error {
if user.GetString("role") == "" { if e.Record.GetString("role") == "" {
user.Set("role", "user") e.Record.Set("role", "user")
} }
return nil return e.Next()
} }
func (um *UserManager) InitializeUserSettings(e *core.ModelEvent) error { // Initialize user settings with defaults if not set
record := e.Model.(*models.Record) func (um *UserManager) InitializeUserSettings(e *core.RecordEvent) error {
record := e.Record
// intialize settings with defaults // intialize settings with defaults
settings := UserSettings{ settings := UserSettings{
// Language: "en", // Language: "en",
@@ -46,7 +49,7 @@ func (um *UserManager) InitializeUserSettings(e *core.ModelEvent) error {
record.UnmarshalJSONField("settings", &settings) record.UnmarshalJSONField("settings", &settings)
if len(settings.NotificationEmails) == 0 { if len(settings.NotificationEmails) == 0 {
// get user email from auth record // get user email from auth record
if errs := um.app.Dao().ExpandRecord(record, []string{"user"}, nil); len(errs) == 0 { if errs := um.app.ExpandRecord(record, []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)
if user := record.ExpandedOne("user"); user != nil { if user := record.ExpandedOne("user"); user != nil {
settings.NotificationEmails = []string{user.GetString("email")} settings.NotificationEmails = []string{user.GetString("email")}
@@ -61,5 +64,57 @@ func (um *UserManager) InitializeUserSettings(e *core.ModelEvent) error {
// settings.NotificationWebhooks = []string{""} // settings.NotificationWebhooks = []string{""}
// } // }
record.Set("settings", settings) record.Set("settings", settings)
return nil return e.Next()
}
// Custom API endpoint to create the first user.
// Mimics previous default behavior in PocketBase < 0.23.0 allowing user to be created through the Beszel UI.
func (um *UserManager) CreateFirstUser(e *core.RequestEvent) error {
// check that there are no users
totalUsers, err := um.app.CountRecords("users")
if err != nil || totalUsers > 0 {
return e.JSON(http.StatusForbidden, map[string]string{"err": "Forbidden"})
}
// check that there is only one superuser and the email matches the email of the superuser we set up in initial-settings.go
adminUsers, err := um.app.FindAllRecords(core.CollectionNameSuperusers)
if err != nil || len(adminUsers) != 1 || adminUsers[0].GetString("email") != migrations.TempAdminEmail {
return e.JSON(http.StatusForbidden, map[string]string{"err": "Forbidden"})
}
// create first user using supplied email and password in request body
data := struct {
Email string `json:"email"`
Password string `json:"password"`
}{}
if err := e.BindBody(&data); err != nil {
return e.JSON(http.StatusBadRequest, map[string]string{"err": err.Error()})
}
if data.Email == "" || data.Password == "" {
return e.JSON(http.StatusBadRequest, map[string]string{"err": "Bad request"})
}
collection, _ := um.app.FindCollectionByNameOrId("users")
user := core.NewRecord(collection)
user.SetEmail(data.Email)
user.SetPassword(data.Password)
user.Set("role", "admin")
user.Set("verified", true)
if username := strings.Split(data.Email, "@")[0]; len(username) > 2 {
user.Set("username", username)
}
if err := um.app.Save(user); err != nil {
return e.JSON(http.StatusInternalServerError, map[string]string{"err": err.Error()})
}
// create superuser using the email of the first user
collection, _ = um.app.FindCollectionByNameOrId(core.CollectionNameSuperusers)
adminUser := core.NewRecord(collection)
adminUser.SetEmail(data.Email)
adminUser.SetPassword(data.Password)
if err := um.app.Save(adminUser); err != nil {
return e.JSON(http.StatusInternalServerError, map[string]string{"err": err.Error()})
}
// delete the intial superuser
if err := um.app.Delete(adminUsers[0]); err != nil {
return e.JSON(http.StatusInternalServerError, map[string]string{"err": err.Error()})
}
return e.JSON(http.StatusOK, map[string]string{"msg": "User created"})
} }

View File

@@ -1,481 +0,0 @@
package migrations
import (
"encoding/json"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
m "github.com/pocketbase/pocketbase/migrations"
"github.com/pocketbase/pocketbase/models"
)
func init() {
m.Register(func(db dbx.Builder) error {
jsonData := `[
{
"id": "2hz5ncl8tizk5nx",
"created": "2024-07-07 16:08:20.979Z",
"updated": "2024-10-12 18:55:51.623Z",
"name": "systems",
"type": "base",
"system": false,
"schema": [
{
"system": false,
"id": "7xloxkwk",
"name": "name",
"type": "text",
"required": true,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"system": false,
"id": "waj7seaf",
"name": "status",
"type": "select",
"required": false,
"presentable": false,
"unique": false,
"options": {
"maxSelect": 1,
"values": [
"up",
"down",
"paused",
"pending"
]
}
},
{
"system": false,
"id": "ve781smf",
"name": "host",
"type": "text",
"required": true,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"system": false,
"id": "pij0k2jk",
"name": "port",
"type": "text",
"required": true,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"system": false,
"id": "qoq64ntl",
"name": "info",
"type": "json",
"required": false,
"presentable": false,
"unique": false,
"options": {
"maxSize": 2000000
}
},
{
"system": false,
"id": "jcarjnjj",
"name": "users",
"type": "relation",
"required": true,
"presentable": false,
"unique": false,
"options": {
"collectionId": "_pb_users_auth_",
"cascadeDelete": true,
"minSelect": null,
"maxSelect": null,
"displayFields": null
}
}
],
"indexes": [],
"listRule": "@request.auth.id != \"\" && users.id ?= @request.auth.id",
"viewRule": "@request.auth.id != \"\" && users.id ?= @request.auth.id",
"createRule": "@request.auth.id != \"\" && users.id ?= @request.auth.id && @request.auth.role != \"readonly\"",
"updateRule": "@request.auth.id != \"\" && users.id ?= @request.auth.id && @request.auth.role != \"readonly\"",
"deleteRule": "@request.auth.id != \"\" && users.id ?= @request.auth.id && @request.auth.role != \"readonly\"",
"options": {}
},
{
"id": "ej9oowivz8b2mht",
"created": "2024-07-07 16:09:09.179Z",
"updated": "2024-10-12 18:55:51.623Z",
"name": "system_stats",
"type": "base",
"system": false,
"schema": [
{
"system": false,
"id": "h9sg148r",
"name": "system",
"type": "relation",
"required": true,
"presentable": false,
"unique": false,
"options": {
"collectionId": "2hz5ncl8tizk5nx",
"cascadeDelete": true,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "azftn0be",
"name": "stats",
"type": "json",
"required": true,
"presentable": false,
"unique": false,
"options": {
"maxSize": 2000000
}
},
{
"system": false,
"id": "m1ekhli3",
"name": "type",
"type": "select",
"required": true,
"presentable": false,
"unique": false,
"options": {
"maxSelect": 1,
"values": [
"1m",
"10m",
"20m",
"120m",
"480m"
]
}
}
],
"indexes": [
"CREATE INDEX ` + "`" + `idx_GxIee0j` + "`" + ` ON ` + "`" + `system_stats` + "`" + ` (` + "`" + `system` + "`" + `)"
],
"listRule": "@request.auth.id != \"\"",
"viewRule": null,
"createRule": null,
"updateRule": null,
"deleteRule": null,
"options": {}
},
{
"id": "juohu4jipgc13v7",
"created": "2024-07-07 16:09:57.976Z",
"updated": "2024-10-12 18:55:51.623Z",
"name": "container_stats",
"type": "base",
"system": false,
"schema": [
{
"system": false,
"id": "hutcu6ps",
"name": "system",
"type": "relation",
"required": true,
"presentable": false,
"unique": false,
"options": {
"collectionId": "2hz5ncl8tizk5nx",
"cascadeDelete": true,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "r39hhnil",
"name": "stats",
"type": "json",
"required": true,
"presentable": false,
"unique": false,
"options": {
"maxSize": 2000000
}
},
{
"system": false,
"id": "vo7iuj96",
"name": "type",
"type": "select",
"required": true,
"presentable": false,
"unique": false,
"options": {
"maxSelect": 1,
"values": [
"1m",
"10m",
"20m",
"120m",
"480m"
]
}
}
],
"indexes": [],
"listRule": "@request.auth.id != \"\"",
"viewRule": null,
"createRule": null,
"updateRule": null,
"deleteRule": null,
"options": {}
},
{
"id": "_pb_users_auth_",
"created": "2024-07-14 16:25:18.226Z",
"updated": "2024-10-12 22:27:19.081Z",
"name": "users",
"type": "auth",
"system": false,
"schema": [
{
"system": false,
"id": "qkbp58ae",
"name": "role",
"type": "select",
"required": false,
"presentable": false,
"unique": false,
"options": {
"maxSelect": 1,
"values": [
"user",
"admin",
"readonly"
]
}
},
{
"system": false,
"id": "users_avatar",
"name": "avatar",
"type": "file",
"required": false,
"presentable": false,
"unique": false,
"options": {
"mimeTypes": [
"image/jpeg",
"image/png",
"image/svg+xml",
"image/gif",
"image/webp"
],
"thumbs": null,
"maxSelect": 1,
"maxSize": 5242880,
"protected": false
}
}
],
"indexes": [],
"listRule": "id = @request.auth.id",
"viewRule": "id = @request.auth.id",
"createRule": null,
"updateRule": null,
"deleteRule": null,
"options": {
"allowEmailAuth": true,
"allowOAuth2Auth": true,
"allowUsernameAuth": false,
"exceptEmailDomains": null,
"manageRule": null,
"minPasswordLength": 8,
"onlyEmailDomains": null,
"onlyVerified": true,
"requireEmail": false
}
},
{
"id": "elngm8x1l60zi2v",
"created": "2024-07-15 01:16:04.044Z",
"updated": "2024-10-12 22:27:29.128Z",
"name": "alerts",
"type": "base",
"system": false,
"schema": [
{
"system": false,
"id": "hn5ly3vi",
"name": "user",
"type": "relation",
"required": true,
"presentable": false,
"unique": false,
"options": {
"collectionId": "_pb_users_auth_",
"cascadeDelete": true,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "g5sl3jdg",
"name": "system",
"type": "relation",
"required": true,
"presentable": false,
"unique": false,
"options": {
"collectionId": "2hz5ncl8tizk5nx",
"cascadeDelete": true,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "zj3ingrv",
"name": "name",
"type": "select",
"required": true,
"presentable": false,
"unique": false,
"options": {
"maxSelect": 1,
"values": [
"Status",
"CPU",
"Memory",
"Disk",
"Temperature",
"Bandwidth"
]
}
},
{
"system": false,
"id": "o2ablxvn",
"name": "value",
"type": "number",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"noDecimal": false
}
},
{
"system": false,
"id": "fstdehcq",
"name": "min",
"type": "number",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": 60,
"noDecimal": true
}
},
{
"system": false,
"id": "6hgdf6hs",
"name": "triggered",
"type": "bool",
"required": false,
"presentable": false,
"unique": false,
"options": {}
}
],
"indexes": [],
"listRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
"viewRule": "",
"createRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
"updateRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
"deleteRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
"options": {}
},
{
"id": "4afacsdnlu8q8r2",
"created": "2024-09-12 17:42:55.324Z",
"updated": "2024-10-12 18:55:51.624Z",
"name": "user_settings",
"type": "base",
"system": false,
"schema": [
{
"system": false,
"id": "d5vztyxa",
"name": "user",
"type": "relation",
"required": true,
"presentable": false,
"unique": false,
"options": {
"collectionId": "_pb_users_auth_",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "xcx4qgqq",
"name": "settings",
"type": "json",
"required": false,
"presentable": false,
"unique": false,
"options": {
"maxSize": 2000000
}
}
],
"indexes": [
"CREATE UNIQUE INDEX ` + "`" + `idx_30Lwgf2` + "`" + ` ON ` + "`" + `user_settings` + "`" + ` (` + "`" + `user` + "`" + `)"
],
"listRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
"viewRule": null,
"createRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
"updateRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
"deleteRule": null,
"options": {}
}
]`
collections := []*models.Collection{}
if err := json.Unmarshal([]byte(jsonData), &collections); err != nil {
return err
}
return daos.New(db).ImportCollections(collections, true, nil)
}, func(db dbx.Builder) error {
return nil
})
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,19 +1,29 @@
package migrations package migrations
import ( import (
"github.com/pocketbase/dbx" "github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
m "github.com/pocketbase/pocketbase/migrations" m "github.com/pocketbase/pocketbase/migrations"
"github.com/pocketbase/pocketbase/tools/security"
)
var (
TempAdminEmail = "_@b.b"
) )
func init() { func init() {
m.Register(func(db dbx.Builder) error { m.Register(func(app core.App) error {
dao := daos.New(db) // initial settings
settings := app.Settings()
settings, _ := dao.FindSettings()
settings.Meta.AppName = "Beszel" settings.Meta.AppName = "Beszel"
settings.Meta.HideControls = true settings.Meta.HideControls = true
if err := app.Save(settings); err != nil {
return dao.SaveSettings(settings) return err
}
// create superuser
collection, _ := app.FindCollectionByNameOrId(core.CollectionNameSuperusers)
user := core.NewRecord(collection)
user.SetEmail(TempAdminEmail)
user.SetPassword(security.RandomString(12))
return app.Save(user)
}, nil) }, nil)
} }

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

@@ -3,11 +3,11 @@ package site
import ( import (
"embed" "embed"
"io/fs"
"github.com/labstack/echo/v5"
) )
//go:embed all:dist //go:embed all:dist
var assets embed.FS var distDir embed.FS
var Dist = echo.MustSubFS(assets, "dist") // DistDirFS contains the embedded dist directory files (without the "dist" prefix)
var DistDirFS, _ = fs.Sub(distDir, "dist")

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,37 @@
import type { LinguiConfig } from "@lingui/conf"
const config: LinguiConfig = {
locales: [
"en",
"ar",
"cs",
"de",
"es",
"fa",
"fr",
"hr",
"it",
"ja",
"ko",
"nl",
"pl",
"pt",
"tr",
"ru",
"sv",
"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

@@ -5,55 +5,68 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "lingui extract --overwrite && lingui compile && 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", "@henrygd/queue": "^1.0.7",
"@lingui/detect-locale": "^4.14.1",
"@lingui/macro": "^4.14.1",
"@lingui/react": "^4.14.1",
"@nanostores/react": "^0.7.3", "@nanostores/react": "^0.7.3",
"@nanostores/router": "^0.11.0", "@nanostores/router": "^0.11.0",
"@radix-ui/react-alert-dialog": "^1.1.2", "@radix-ui/react-alert-dialog": "^1.1.4",
"@radix-ui/react-checkbox": "^1.1.2", "@radix-ui/react-checkbox": "^1.1.3",
"@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.2", "@radix-ui/react-direction": "^1.1.0",
"@radix-ui/react-label": "^2.1.0", "@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-select": "^2.1.2", "@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-select": "^2.1.4",
"@radix-ui/react-slider": "^1.2.1", "@radix-ui/react-separator": "^1.1.1",
"@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-slider": "^1.2.2",
"@radix-ui/react-switch": "^1.1.1", "@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-tabs": "^1.1.1", "@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-toast": "^1.2.2", "@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.3", "@radix-ui/react-toast": "^1.2.4",
"@tanstack/react-table": "^8.20.5", "@radix-ui/react-tooltip": "^1.1.6",
"@vitejs/plugin-react": "^4.3.2", "@tanstack/react-table": "^8.20.6",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.0.0", "cmdk": "^1.0.4",
"d3-time": "^3.1.0", "d3-time": "^3.1.0",
"lucide-react": "^0.452.0", "lucide-react": "^0.452.0",
"nanostores": "^0.11.3", "nanostores": "^0.11.3",
"pocketbase": "^0.21.5", "pocketbase": "^0.22.1",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"recharts": "^2.13.0", "recharts": "^2.15.0",
"tailwind-merge": "^2.5.4", "tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"valibot": "^0.36.0" "valibot": "^0.36.0"
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "^1.1.11", "@lingui/cli": "^4.14.1",
"@types/react": "^18.3.11", "@lingui/swc-plugin": "^4.1.0",
"@types/react-dom": "^18.3.1", "@lingui/vite-plugin": "^4.14.1",
"@types/bun": "^1.1.14",
"@types/react": "^18.3.17",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react-swc": "^3.7.2",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"postcss": "^8.4.47", "postcss": "^8.4.49",
"tailwindcss": "^3.4.14", "tailwindcss": "^3.4.17",
"typescript": "^5.6.3", "tailwindcss-rtl": "^0.9.0",
"vite": "^5.4.9" "typescript": "^5.7.2",
"vite": "^5.4.11"
}, },
"overrides": { "overrides": {
"@nanostores/router": { "@nanostores/router": {
"nanostores": "^0.11.3" "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 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12.2 6.9c-1 0-2.5-1-4-1-2 0-4 1.1-5 3-2 3.6-.5 9 1.5 12 1 1.5 2.3 3.2 3.8 3.1 1.6 0 2.1-1 4-1 1.8 0 2.3 1 4 1 1.6 0 2.6-1.5 3.6-3a13 13 0 0 0 1.7-3.4 5.3 5.3 0 0 1-.6-9.4 5.6 5.6 0 0 0-4.4-2.4C14.8 5.6 13 7 12.2 7zm3.3-3c.9-1 1.4-2.5 1.3-3.9-1.2 0-2.7.8-3.6 1.8A5 5 0 0 0 12 5.5c1.3.1 2.7-.7 3.5-1.7"/></svg>

Before

Width:  |  Height:  |  Size: 378 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M.8 1.2a.8.8 0 0 0-.8 1l3.3 19.7c0 .5.5.9 1 .9h15.6a.8.8 0 0 0 .8-.7l3.3-20a.8.8 0 0 0-.8-.9zm13.7 14.3h-5l-1.3-7h7.5z"/></svg>

Before

Width:  |  Height:  |  Size: 196 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M20.3 4.4a19.8 19.8 0 0 0-4.9-1.5L14.7 4C13 4 11.1 4 9.3 4.1L8.6 3a19.7 19.7 0 0 0-5 1.5C.6 9-.4 13.6.1 18.1c2 1.5 4 2.4 6 3h.1c.5-.6.9-1.3 1.2-2l-1.9-1V18l.4-.3c4 1.8 8.2 1.8 12.1 0h.1l.4.3v.1a12.3 12.3 0 0 1-2 1l1.3 2c2-.6 4-1.5 6-3h.1c.5-5.2-.8-9.7-3.6-13.7zM8 15.4c-1.2 0-2.1-1.2-2.1-2.5s1-2.4 2.1-2.4c1.2 0 2.2 1 2.2 2.4 0 1.3-1 2.4-2.2 2.4zm8 0c-1.2 0-2.2-1.2-2.2-2.5s1-2.4 2.2-2.4c1.2 0 2.2 1 2.2 2.4 0 1.3-1 2.4-2.2 2.4Z"/></svg>

Before

Width:  |  Height:  |  Size: 506 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M9.1 23.7v-8H6.6V12h2.5v-1.5c0-4.1 1.8-6 5.9-6h1.4a8.7 8.7 0 0 1 1.2.3V8a8.6 8.6 0 0 0-.7 0 26.8 26.8 0 0 0-.7 0c-.7 0-1.3 0-1.7.3a1.7 1.7 0 0 0-.7.6c-.2.4-.3 1-.3 1.7V12h3.9l-.4 2.1-.3 1.6h-3.2V24a12 12 0 1 0-4.4-.3Z"/></svg>

Before

Width:  |  Height:  |  Size: 295 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M4.2 4.6a4.2 4.2 0 0 0-2.9 1.1C-.4 7.3 0 9.7.1 10.1c0 .4.3 1.6 1.2 2.7C3 15 6.8 15 6.8 15S7.3 16 8 17c1 1.3 2 2.3 2.9 2.4H18s.4 0 1-.4c.6-.3 1-.9 1-.9s.6-.5 1.3-1.7l.5-1s2.1-4.6 2.1-9c0-1.2-.4-1.5-.4-1.5l-.4-.2s-4.5.3-6.8.3h-1.5v4.5l-.6-.3V5h-3.5l-6-.4h-.6zm.4 1.8s.3 2.3.7 3.6c.2 1.1 1 3 1 3l-1.7-.3c-1-.4-1.4-.8-1.4-.8s-.8-.5-1.1-1.5c-.7-1.7 0-2.7 0-2.7s.2-.9 1.4-1.1c.4-.2.9-.2 1-.2zM12.9 9l.5.1.9.4-.6 1.1a.7.7 0 0 0-.6.4.7.7 0 0 0 .1.7l-1 2a.7.7 0 0 0-.6.5.7.7 0 0 0 .3.7.7.7 0 0 0 1-.2.7.7 0 0 0-.2-.8l1-2a.7.7 0 0 0 .2 0 .7.7 0 0 0 .3 0 8.8 8.8 0 0 1 1 .4.8.8 0 0 1 .3.3l-.1.6c0 .3-.7 1.5-.7 1.5a.7.7 0 0 0-.7.5.7.7 0 1 0 1.2-.2l.2-.5.5-1.1c0-.1.2-.4.1-.8a1 1 0 0 0-.5-.7l-1-.6-.1-.2a.7.7 0 0 0-.2-.3l.5-1 3 1.4s.4.2.5.6v.6L16 16.8s-.2.5-.7.5a1 1 0 0 1-.4 0h-.2L10.4 15s-.4-.2-.5-.6l.1-.7 2-4.2s.3-.4.5-.5A.9.9 0 0 1 13 9z"/></svg>

Before

Width:  |  Height:  |  Size: 907 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0zm6 5.3c.4 0 .7.3.7.6v1.5a.6.6 0 0 1-.6.6H9.8C8.8 8 8 8.8 8 9.8v5.6c0 .3.3.6.6.6h5.6c1 0 1.8-.8 1.8-1.8V14a.6.6 0 0 0-.6-.6h-4.1a.6.6 0 0 1-.6-.6v-1.4a.6.6 0 0 1 .6-.6H18c.3 0 .6.2.6.6v3.4a4 4 0 0 1-4 4H5.9a.6.6 0 0 1-.6-.6V9.8a4.4 4.4 0 0 1 4.5-4.5H18Z"/></svg>

Before

Width:  |  Height:  |  Size: 406 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 .3a12 12 0 0 0-3.8 23.4c.6.1.8-.3.8-.6v-2c-3.3.7-4-1.6-4-1.6-.6-1.4-1.4-1.8-1.4-1.8-1-.7.1-.7.1-.7 1.2 0 1.9 1.2 1.9 1.2 1 1.8 2.8 1.3 3.5 1 0-.8.4-1.3.7-1.6-2.7-.3-5.5-1.3-5.5-6 0-1.2.5-2.3 1.3-3.1-.2-.4-.6-1.6 0-3.2 0 0 1-.3 3.4 1.2a11.5 11.5 0 0 1 6 0c2.3-1.5 3.3-1.2 3.3-1.2.6 1.6.2 2.8 0 3.2.9.8 1.3 1.9 1.3 3.2 0 4.6-2.8 5.6-5.5 5.9.5.4.9 1 .9 2.2v3.3c0 .3.1.7.8.6A12 12 0 0 0 12 .3"/></svg>

Before

Width:  |  Height:  |  Size: 470 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M23.6 9.6 20.3 1a.9.9 0 0 0-.3-.4.9.9 0 0 0-1 0 .9.9 0 0 0-.3.5l-2.2 6.7h-9L5.3 1.1A.9.9 0 0 0 5 .6a.9.9 0 0 0-1 0 .9.9 0 0 0-.3.4L.4 9.5a6 6 0 0 0 2 7.1l5 3.8 2.5 1.8 1.5 1.1a1 1 0 0 0 1.2 0l1.5-1 2.5-2 5-3.7a6 6 0 0 0 2-7z"/></svg>

Before

Width:  |  Height:  |  Size: 302 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12.5 11v3.2h7.8a7 7 0 0 1-1.8 4.1 8 8 0 0 1-6 2.4c-4.8 0-8.6-3.9-8.6-8.7a8.6 8.6 0 0 1 14.5-6.4l2.3-2.3C18.7 1.4 16 0 12.5 0 5.9 0 .3 5.4.3 12S6 24 12.5 24a11 11 0 0 0 8.4-3.4c2.1-2.1 2.8-5.2 2.8-7.6 0-.8 0-1.5-.2-2h-11z"/></svg>

Before

Width:  |  Height:  |  Size: 299 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M7 0C5.8.2 5 .4 4.1.7 3.3 1 2.7 1.4 2 2c-.7.7-1 1.4-1.4 2.2C.3 4.9.1 5.8.1 7a84.6 84.6 0 0 0 .5 12.8c.4.8.8 1.4 1.4 2.1.7.7 1.4 1 2.2 1.4.7.3 1.6.5 2.9.5a85 85 0 0 0 12.8-.5c.8-.4 1.4-.8 2.1-1.4.7-.7 1-1.4 1.4-2.2.3-.7.5-1.6.5-2.9a85 85 0 0 0-.5-12.8C23 3.3 22.6 2.7 22 2c-.7-.7-1.4-1-2.2-1.4-.7-.3-1.6-.5-2.9-.5A85.5 85.5 0 0 0 7 0m.2 21.7c-1.2 0-1.8-.3-2.3-.4-.5-.2-1-.5-1.3-1-.5-.3-.7-.7-1-1.3-.1-.4-.3-1-.4-2.2a84.8 84.8 0 0 1 .4-12c.2-.5.5-1 1-1.3.3-.5.7-.7 1.3-1 .4-.1 1-.3 2.2-.4a84.4 84.4 0 0 1 12 .4c.5.3 1 .5 1.3 1 .5.3.7.7 1 1.3.1.4.3 1 .4 2.2a82.7 82.7 0 0 1-.4 12c-.2.5-.5 1-1 1.3-.3.5-.7.7-1.3 1-.4.1-1 .3-2.2.4a84.9 84.9 0 0 1-9.7 0M17 5.6A1.4 1.4 0 1 0 18.4 4 1.4 1.4 0 0 0 17 5.6M5.8 12a6.2 6.2 0 1 0 12.4 0 6.2 6.2 0 0 0-12.4 0M8 12a4 4 0 1 1 4 4 4 4 0 0 1-4-4"/></svg>

Before

Width:  |  Height:  |  Size: 856 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M14.5.9 11 2.7v18.1c-4.1-.5-7.3-2.7-7.3-5.5 0-2.5 2.8-4.7 6.7-5.4V7.6C4.4 8.3 0 11.5 0 15.3c0 4 4.7 7.3 11 7.8l3.5-1.7V.9m.7 6.7V10c1.4.3 2.7.7 3.7 1.3l-2 1.1L24 14l-.5-5.2-1.9 1c-1.7-1-4-1.8-6.4-2z"/></svg>

Before

Width:  |  Height:  |  Size: 276 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M23 7.2c0-3-2.4-5.6-5.2-6.5-3.5-1.1-8.1-1-11.4.6-4 2-5.3 6-5.4 10.2C1 15 1.3 24 6.4 24c3.8 0 4.3-4.8 6-7.1 1.3-1.7 3-2.2 4.9-2.7a7.1 7.1 0 0 0 5.7-7Z"/></svg>

Before

Width:  |  Height:  |  Size: 227 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.7 0 12 0zm5.5 17.3c-.2.4-.6.5-1 .3-2.8-1.8-6.4-2.1-10.6-1.2-.4.2-.7-.1-.9-.5 0-.4.2-.8.6-.9 4.5-1 8.5-.6 11.6 1.3.4.2.5.7.3 1zM19 14c-.3.5-.9.6-1.3.3-3.2-2-8.2-2.5-12-1.3-.4 0-1-.2-1-.6-.2-.5 0-1 .5-1.2 4.4-1.3 9.8-.6 13.5 1.6.4.2.6.8.3 1.2zm0-3.3A19.9 19.9 0 0 0 5.3 9.3c-.6.2-1.2-.2-1.4-.7-.2-.6.2-1.2.7-1.4 4.3-1.3 11.3-1 15.7 1.6.6.3.7 1 .4 1.6-.3.4-1 .6-1.5.3z"/></svg>

Before

Width:  |  Height:  |  Size: 495 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="m15.4 18-2.1-4.2h-3l5 10.2 5.2-10.2h-3m-7-5.6 2.8 5.6h4.2L10.5 0l-7 13.8h4.1"/></svg>

Before

Width:  |  Height:  |  Size: 154 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M11.6 4.7h1.7V10h-1.7zm4.7 0H18V10h-1.7zM6 0 1.7 4.3v15.4H7V24l4.2-4.3h3.5l7.7-7.7V0zm14.6 11.1L17 14.6h-3.4l-3 3v-3H7V1.7h13.7Z"/></svg>

Before

Width:  |  Height:  |  Size: 206 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M22.5 6c-.8.3-1.6.6-2.5.7.9-.5 1.6-1.4 1.9-2.4-.8.5-1.8.9-2.7 1a4.3 4.3 0 0 0-7.3 4C8.2 9 5 7.3 3 4.8a4.2 4.2 0 0 0 1.3 5.7c-.7 0-1.3-.2-2-.5 0 2.1 1.6 3.8 3.5 4.2a4.2 4.2 0 0 1-2 .1 4.3 4.3 0 0 0 4 3A8.5 8.5 0 0 1 2.7 19h-1A12.1 12.1 0 0 0 20.3 8.8v-.6c.8-.6 1.5-1.3 2-2.2"/></svg>

Before

Width:  |  Height:  |  Size: 371 B

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,20 @@ 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"
import { i18n } from "@lingui/core"
export function AddSystemButton({ className }: { className?: string }) { export function AddSystemButton({ className }: { className?: string }) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
@@ -34,22 +37,30 @@ export function AddSystemButton({ className }: { className?: string }) {
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro - /var/run/docker.sock:/var/run/docker.sock:ro
# monitor other disks / partitions by mounting a folder in /extra-filesystems # monitor other disks / partitions by mounting a folder in /extra-filesystems
# - /mnt/disk1/.beszel:/extra-filesystems/disk1:ro # - /mnt/disk/.beszel:/extra-filesystems/sda1: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) {
let cmd = `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}"`
// add china mirrors flag if zh-CN
if ((i18n.locale + navigator.language).includes("zh-CN")) {
cmd += ` --china-mirrors`
}
copyToClipboard(cmd)
} }
async function handleSubmit(e: SubmitEvent) { async function handleSubmit(e: SubmitEvent) {
e.preventDefault() e.preventDefault()
const formData = new FormData(e.target as HTMLFormElement) const formData = new FormData(e.target as HTMLFormElement)
const data = Object.fromEntries(formData) as Record<string, any> const data = Object.fromEntries(formData) as Record<string, any>
data.users = pb.authStore.model!.id data.users = pb.authStore.record!.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 +72,113 @@ 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 */}
<TabsContent value="docker">
<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 xs:grid-cols-[auto_1fr] gap-y-3 gap-x-4 items-center mt-1 mb-4">
<Label htmlFor="name" className="xs:text-end">
<Trans>Name</Trans>
</Label> </Label>
<Input id="name" name="name" className="col-span-3" required /> <Input id="name" name="name" className="" required />
</div> <Label htmlFor="host" className="xs:text-end">
<div className="grid grid-cols-4 items-center gap-4"> <Trans>Host / IP</Trans>
<Label htmlFor="host" className="text-right">
Host / IP
</Label> </Label>
<Input id="host" name="host" className="col-span-3" required /> <Input id="host" name="host" className="" required />
</div> <Label htmlFor="port" className="xs:text-end">
<div className="grid grid-cols-4 items-center gap-4"> <Trans>Port</Trans>
<Label htmlFor="port" className="text-right">
Port
</Label> </Label>
<Input <Input ref={port} name="port" id="port" defaultValue="45876" className="" required />
ref={port} <Label htmlFor="pkey" className="xs:text-end whitespace-pre">
name="port" <Trans comment="Use 'Key' if your language requires many more characters">Public Key</Trans>
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-right whitespace-pre">
Public Key
</Label> </Label>
<Input readOnly id="pkey" value={publicKey} className="col-span-3" required></Input> <div className="relative">
<div <Input readOnly id="pkey" value={publicKey} className="" required></Input>
className={ <div
'h-6 w-24 bg-gradient-to-r from-transparent to-background to-65% absolute right-1 pointer-events-none' className={
} "h-6 w-24 bg-gradient-to-r rtl:bg-gradient-to-l from-transparent to-background to-65% absolute top-2 end-1 pointer-events-none"
></div> }
<TooltipProvider delayDuration={100}> ></div>
<Tooltip> <TooltipProvider delayDuration={100}>
<TooltipTrigger asChild> <Tooltip>
<Button <TooltipTrigger asChild>
type="button" <Button
variant={'link'} type="button"
className="absolute right-0" variant={"link"}
onClick={() => copyToClipboard(publicKey)} className="absolute end-0 top-0"
> onClick={() => copyToClipboard(publicKey)}
<Copy className="h-4 w-4 " /> >
</Button> <Copy className="h-4 w-4 " />
</TooltipTrigger> </Button>
<TooltipContent> </TooltipTrigger>
<p>Click to copy</p> <TooltipContent>
</TooltipContent> <p>
</Tooltip> <Trans>Click to copy</Trans>
</TooltipProvider> </p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div> </div>
</div> {/* Docker */}
<DialogFooter className="flex justify-end gap-2"> <TabsContent value="docker">
<Button <DialogFooter className="flex justify-end gap-2 sm:w-[calc(100%+20px)] sm:-ms-[20px]">
type="button" <Button type="button" variant={"ghost"} onClick={() => copyDockerCompose(port.current.value)}>
variant={'ghost'} <Trans>Copy</Trans> docker compose
onClick={() => copyDockerCompose(port.current.value)} </Button>
> <Button>
Copy docker compose <Trans>Add system</Trans>
</Button> </Button>
<Button>Add system</Button> </DialogFooter>
</DialogFooter> </TabsContent>
</form> {/* Binary */}
<TabsContent value="binary">
<DialogFooter className="flex justify-end gap-2 sm:w-[calc(100%+20px)] sm:-ms-[20px]">
<Button type="button" variant={"ghost"} onClick={() => copyInstallCommand(port.current.value)}>
<Trans>Copy Linux command</Trans>
</Button>
<Button>
<Trans>Add system</Trans>
</Button>
</DialogFooter>
</TabsContent>
</form>
</Tabs>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
) )

View File

@@ -1,6 +1,6 @@
import { memo, useState } from 'react' import { memo, useState } from "react"
import { useStore } from '@nanostores/react' import { useStore } from "@nanostores/react"
import { $alerts, $systems } from '@/lib/stores' import { $alerts, $systems } from "@/lib/stores"
import { import {
Dialog, Dialog,
DialogTrigger, DialogTrigger,
@@ -8,15 +8,16 @@ import {
DialogDescription, DialogDescription,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog' } from "@/components/ui/dialog"
import { BellIcon, GlobeIcon, ServerIcon } from 'lucide-react' import { BellIcon, GlobeIcon, ServerIcon } from "lucide-react"
import { alertInfo, cn } from '@/lib/utils' import { alertInfo, cn } from "@/lib/utils"
import { Button } from '@/components/ui/button' import { Button } from "@/components/ui/button"
import { AlertRecord, SystemRecord } from '@/types' import { AlertRecord, SystemRecord } from "@/types"
import { Link } from '../router' import { Link } from "../router"
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Checkbox } from '../ui/checkbox' import { Checkbox } from "../ui/checkbox"
import { SystemAlert, SystemAlertGlobal } from './alerts-system' import { SystemAlert, SystemAlertGlobal } from "./alerts-system"
import { Trans, t } from "@lingui/macro"
export default memo(function AlertsButton({ system }: { system: SystemRecord }) { export default memo(function AlertsButton({ system }: { system: SystemRecord }) {
const alerts = useStore($alerts) const alerts = useStore($alerts)
@@ -28,16 +29,10 @@ export default memo(function AlertsButton({ system }: { system: SystemRecord })
return ( return (
<Dialog> <Dialog>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button <Button variant="ghost" size="icon" aria-label={t`Alerts`} data-nolink onClick={() => setOpened(true)}>
variant="ghost"
size="icon"
aria-label="Alerts"
data-nolink
onClick={() => setOpened(true)}
>
<BellIcon <BellIcon
className={cn('h-[1.2em] w-[1.2em] pointer-events-none', { className={cn("h-[1.2em] w-[1.2em] pointer-events-none", {
'fill-primary': active, "fill-primary": active,
})} })}
/> />
</Button> </Button>
@@ -54,7 +49,7 @@ function TheContent({
}: { }: {
data: { system: SystemRecord; alerts: AlertRecord[]; systemAlerts: AlertRecord[] } data: { system: SystemRecord; alerts: AlertRecord[]; systemAlerts: AlertRecord[] }
}) { }) {
const [overwriteExisting, setOverwriteExisting] = useState<boolean | 'indeterminate'>(false) const [overwriteExisting, setOverwriteExisting] = useState<boolean | "indeterminate">(false)
const systems = $systems.get() const systems = $systems.get()
const data = Object.keys(alertInfo).map((key) => { const data = Object.keys(alertInfo).map((key) => {
@@ -69,24 +64,28 @@ function TheContent({
return ( return (
<> <>
<DialogHeader> <DialogHeader>
<DialogTitle className="text-xl">Alerts</DialogTitle> <DialogTitle className="text-xl">
<Trans>Alerts</Trans>
</DialogTitle>
<DialogDescription> <DialogDescription>
See{' '} <Trans>
<Link href="/settings/notifications" className="link"> See{" "}
notification settings <Link href="/settings/notifications" className="link">
</Link>{' '} notification settings
to configure how you receive alerts. </Link>{" "}
to configure how you receive alerts.
</Trans>
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<Tabs defaultValue="system"> <Tabs defaultValue="system">
<TabsList className="mb-1 -mt-0.5"> <TabsList className="mb-1 -mt-0.5">
<TabsTrigger value="system"> <TabsTrigger value="system">
<ServerIcon className="mr-2 h-3.5 w-3.5" /> <ServerIcon className="me-2 h-3.5 w-3.5" />
{system.name} {system.name}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="global"> <TabsTrigger value="global">
<GlobeIcon className="mr-1.5 h-3.5 w-3.5" /> <GlobeIcon className="me-1.5 h-3.5 w-3.5" />
All systems <Trans>All Systems</Trans>
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="system"> <TabsContent value="system">
@@ -107,17 +106,11 @@ function TheContent({
checked={overwriteExisting} checked={overwriteExisting}
onCheckedChange={setOverwriteExisting} onCheckedChange={setOverwriteExisting}
/> />
Overwrite existing alerts <Trans>Overwrite existing alerts</Trans>
</label> </label>
<div className="grid gap-3"> <div className="grid gap-3">
{data.map((d) => ( {data.map((d) => (
<SystemAlertGlobal <SystemAlertGlobal key={d.key} data={d} overwrite={overwriteExisting} alerts={alerts} systems={systems} />
key={d.key}
data={d}
overwrite={overwriteExisting}
alerts={alerts}
systems={systems}
/>
))} ))}
</div> </div>
</TabsContent> </TabsContent>

View File

@@ -1,11 +1,11 @@
import { pb } from '@/lib/stores' import { pb } from "@/lib/stores"
import { alertInfo, cn } from '@/lib/utils' import { alertInfo, cn } from "@/lib/utils"
import { Switch } from '@/components/ui/switch' import { Switch } from "@/components/ui/switch"
import { AlertRecord, SystemRecord } from '@/types' import { AlertInfo, AlertRecord, SystemRecord } from "@/types"
import { lazy, Suspense, useRef, useState } from 'react' import { lazy, Suspense, useRef, useState } from "react"
import { toast } from '../ui/use-toast' import { toast } from "../ui/use-toast"
import { RecordOptions } from 'pocketbase' import { RecordOptions } from "pocketbase"
import { newQueue, Queue } from '@henrygd/queue' import { Trans, t, Plural } from "@lingui/macro"
interface AlertData { interface AlertData {
checked?: boolean checked?: boolean
@@ -13,19 +13,17 @@ interface AlertData {
min?: number min?: number
updateAlert?: (checked: boolean, value: number, min: number) => void updateAlert?: (checked: boolean, value: number, min: number) => void
key: keyof typeof alertInfo key: keyof typeof alertInfo
alert: (typeof alertInfo)[keyof typeof alertInfo] alert: AlertInfo
system: SystemRecord system: SystemRecord
} }
const Slider = lazy(() => import('@/components/ui/slider')) const Slider = lazy(() => import("@/components/ui/slider"))
let queue: Queue
const failedUpdateToast = () => const failedUpdateToast = () =>
toast({ toast({
title: 'Failed to update alert', title: t`Failed to update alert`,
description: 'Please check logs for more details.', description: t`Please check logs for more details.`,
variant: 'destructive', variant: "destructive",
}) })
export function SystemAlert({ export function SystemAlert({
@@ -42,13 +40,13 @@ export function SystemAlert({
data.updateAlert = async (checked: boolean, value: number, min: number) => { data.updateAlert = async (checked: boolean, value: number, min: number) => {
try { try {
if (alert && !checked) { if (alert && !checked) {
await pb.collection('alerts').delete(alert.id) await pb.collection("alerts").delete(alert.id)
} else if (alert && checked) { } else if (alert && checked) {
await pb.collection('alerts').update(alert.id, { value, min, triggered: false }) await pb.collection("alerts").update(alert.id, { value, min, triggered: false })
} else if (checked) { } else if (checked) {
pb.collection('alerts').create({ pb.collection("alerts").create({
system: system.id, system: system.id,
user: pb.authStore.model!.id, user: pb.authStore.record!.id,
name: data.key, name: data.key,
value: value, value: value,
min: min, min: min,
@@ -75,7 +73,7 @@ export function SystemAlertGlobal({
systems, systems,
}: { }: {
data: AlertData data: AlertData
overwrite: boolean | 'indeterminate' overwrite: boolean | "indeterminate"
alerts: AlertRecord[] alerts: AlertRecord[]
systems: SystemRecord[] systems: SystemRecord[]
}) { }) {
@@ -87,11 +85,7 @@ export function SystemAlertGlobal({
data.checked = false data.checked = false
data.val = data.min = 0 data.val = data.min = 0
data.updateAlert = (checked: boolean, value: number, min: number) => { data.updateAlert = async (checked: boolean, value: number, min: number) => {
if (!queue) {
queue = newQueue(5)
}
const { set, populatedSet } = systemsWithExistingAlerts.current const { set, populatedSet } = systemsWithExistingAlerts.current
// if overwrite checked, make sure all alerts will be overwritten // if overwrite checked, make sure all alerts will be overwritten
@@ -104,50 +98,57 @@ export function SystemAlertGlobal({
min, min,
triggered: false, 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 // we can only send 50 in one batch
if (checked) { let done = 0
if (existingAlert) {
// console.log('updating', system.name) while (done < systems.length) {
queue const batch = pb.createBatch()
.add(() => pb.collection('alerts').update(existingAlert.id, recordData, requestOptions)) let batchSize = 0
.catch(failedUpdateToast)
} else { for (let i = done; i < Math.min(done + 50, systems.length); i++) {
// console.log('creating', system.name) const system = systems[i]
queue // if overwrite is false and system is in set (alert existed), skip
.add(() => if (!overwrite && set.has(system.id)) {
pb.collection('alerts').create( continue
{
system: system.id,
user: pb.authStore.model!.id,
name: data.key,
...recordData,
},
requestOptions
)
)
.catch(failedUpdateToast)
} }
} else if (existingAlert) { // find matching existing alert
// console.log('deleting', system.name) const existingAlert = alerts.find((alert) => alert.system === system.id && data.key === alert.name)
queue.add(() => pb.collection('alerts').delete(existingAlert.id)).catch(failedUpdateToast) // if first run, add system to set (alert already existed when global panel was opened)
if (existingAlert && !populatedSet && !overwrite) {
set.add(system.id)
continue
}
batchSize++
const requestOptions: RecordOptions = {
requestKey: system.id,
}
// checked - make sure alert is created or updated
if (checked) {
if (existingAlert) {
batch.collection("alerts").update(existingAlert.id, recordData, requestOptions)
} else {
batch.collection("alerts").create(
{
system: system.id,
user: pb.authStore.record!.id,
name: data.key,
...recordData,
},
requestOptions
)
}
} else if (existingAlert) {
batch.collection("alerts").delete(existingAlert.id)
}
}
try {
batchSize && batch.send()
} catch (e) {
failedUpdateToast()
} finally {
done += 50
} }
} }
systemsWithExistingAlerts.current.populatedSet = true systemsWithExistingAlerts.current.populatedSet = true
@@ -159,7 +160,7 @@ export function SystemAlertGlobal({
function AlertContent({ data }: { data: AlertData }) { function AlertContent({ data }: { data: AlertData }) {
const { key } = data const { key } = data
const hasSliders = !('single' in data.alert) const hasSliders = !("single" in data.alert)
const [checked, setChecked] = useState(data.checked || false) const [checked, setChecked] = useState(data.checked || false)
const [min, setMin] = useState(data.min || (hasSliders ? 10 : 0)) const [min, setMin] = useState(data.min || (hasSliders ? 10 : 0))
@@ -172,24 +173,21 @@ function AlertContent({ data }: { data: AlertData }) {
const Icon = alertInfo[key].icon const Icon = alertInfo[key].icon
const updateAlert = (c?: boolean) => const updateAlert = (c?: boolean) => data.updateAlert?.(c ?? checked, newValue.current, newMin.current)
data.updateAlert?.(c ?? checked, newValue.current, newMin.current)
return ( return (
<div className="rounded-lg border border-muted-foreground/15 hover:border-muted-foreground/20 transition-colors duration-100 group"> <div className="rounded-lg border border-muted-foreground/15 hover:border-muted-foreground/20 transition-colors duration-100 group">
<label <label
htmlFor={`s${key}`} htmlFor={`s${key}`}
className={cn('flex flex-row items-center justify-between gap-4 cursor-pointer p-4', { className={cn("flex flex-row items-center justify-between gap-4 cursor-pointer p-4", {
'pb-0': showSliders, "pb-0": showSliders,
})} })}
> >
<div className="grid gap-1 select-none"> <div className="grid gap-1 select-none">
<p className="font-semibold flex gap-3 items-center capitalize"> <p className="font-semibold flex gap-3 items-center">
<Icon className="h-4 w-4 opacity-85" /> {data.alert.name} <Icon className="h-4 w-4 opacity-85" /> {data.alert.name()}
</p> </p>
{!showSliders && ( {!showSliders && <span className="block text-sm text-muted-foreground">{data.alert.desc()}</span>}
<span className="block text-sm text-muted-foreground">{data.alert.desc}</span>
)}
</div> </div>
<Switch <Switch
id={`s${key}`} id={`s${key}`}
@@ -205,11 +203,13 @@ function AlertContent({ data }: { data: AlertData }) {
<Suspense fallback={<div className="h-10" />}> <Suspense fallback={<div className="h-10" />}>
<div> <div>
<p id={`v${key}`} className="text-sm block h-8"> <p id={`v${key}`} className="text-sm block h-8">
Average exceeds{' '} <Trans>
<strong className="text-foreground"> Average exceeds{" "}
{value} <strong className="text-foreground">
{data.alert.unit} {value}
</strong> {data.alert.unit}
</strong>
</Trans>
</p> </p>
<div className="flex gap-3"> <div className="flex gap-3">
<Slider <Slider
@@ -218,14 +218,16 @@ function AlertContent({ data }: { data: AlertData }) {
onValueCommit={(val) => (newValue.current = val[0]) && updateAlert()} onValueCommit={(val) => (newValue.current = val[0]) && updateAlert()}
onValueChange={(val) => setValue(val[0])} onValueChange={(val) => setValue(val[0])}
min={1} min={1}
max={99} max={alertInfo[key].max ?? 99}
/> />
</div> </div>
</div> </div>
<div> <div>
<p id={`t${key}`} className="text-sm block h-8"> <p id={`t${key}`} className="text-sm block h-8">
For <strong className="text-foreground">{min}</strong> minute <Trans>
{min > 1 && 's'} For <strong className="text-foreground">{min}</strong>{" "}
<Plural value={min} one=" minute" other=" minutes" />
</Trans>
</p> </p>
<div className="flex gap-3"> <div className="flex gap-3">
<Slider <Slider

View File

@@ -1,6 +1,6 @@
import { Area, AreaChart, CartesianGrid, YAxis } from 'recharts' import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from '@/components/ui/chart' import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
import { import {
useYAxisWidth, useYAxisWidth,
cn, cn,
@@ -8,10 +8,12 @@ import {
toFixedWithoutTrailingZeros, toFixedWithoutTrailingZeros,
decimalString, decimalString,
chartMargin, chartMargin,
} from '@/lib/utils' } from "@/lib/utils"
// import Spinner from '../spinner' // import Spinner from '../spinner'
import { ChartData } from '@/types' import { ChartData } from "@/types"
import { memo, 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]
@@ -21,67 +23,86 @@ 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 memo(function AreaChartDefault({ export default memo(function AreaChartDefault({
maxToggled = false, maxToggled = false,
unit = ' MB/s', unit = " MB/s",
chartName, chartName,
chartData, chartData,
max,
tickFormatter,
}: { }: {
maxToggled?: boolean maxToggled?: boolean
unit?: string unit?: string
chartName: string chartName: string
chartData: ChartData chartData: ChartData
max?: number
tickFormatter?: (value: number) => string
}) { }) {
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth() const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
const { i18n } = useLingui()
const { chartTime } = chartData const { chartTime } = chartData
const showMax = chartTime !== '1h' && maxToggled 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: "Disk write" }), "dw", 3, 0.3],
['Read', 'dr', 1, 0.3], [t({ message: "Read", comment: "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: "Network bytes sent (upload)" }), "ns", 5, 0.2],
['Received', 'nr', 2, 0.2], [t({ message: "Received", comment: "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`Write`, `${chartName}.w`, 3, 0.3],
['Read', `${chartName}.r`, 1, 0.3], [t`Read`, `${chartName}.r`, 1, 0.3],
] ]
} else if (chartName.startsWith("g.")) {
return [chartName.includes("mu") ? [t`Used`, chartName, 2, 0.25] : [t`Usage`, chartName, 1, 0.4]]
} }
return [] return []
}, []) }, [chartName, i18n.locale])
// console.log('Rendered at', new Date()) // 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={chartData.systemStats} 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, max ?? "auto"]}
tickFormatter={(value) => { tickFormatter={(value) => {
const val = toFixedWithoutTrailingZeros(value, 2) + unit let val: string
if (tickFormatter) {
val = tickFormatter(value)
} else {
val = toFixedWithoutTrailingZeros(value, 2) + unit
}
return updateYAxisWidth(val) return updateYAxisWidth(val)
}} }}
tickLine={false} tickLine={false}

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

@@ -1,12 +1,6 @@
import { Area, AreaChart, CartesianGrid, YAxis } from 'recharts' import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
import { import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
ChartConfig, import { memo, useMemo } from "react"
ChartContainer,
ChartTooltip,
ChartTooltipContent,
xAxis,
} from '@/components/ui/chart'
import { memo, useMemo } from 'react'
import { import {
useYAxisWidth, useYAxisWidth,
cn, cn,
@@ -16,18 +10,18 @@ import {
toFixedFloat, toFixedFloat,
getSizeAndUnit, getSizeAndUnit,
toFixedWithoutTrailingZeros, toFixedWithoutTrailingZeros,
} from '@/lib/utils' } from "@/lib/utils"
// import Spinner from '../spinner' // import Spinner from '../spinner'
import { useStore } from '@nanostores/react' import { useStore } from "@nanostores/react"
import { $containerFilter } from '@/lib/stores' import { $containerFilter } from "@/lib/stores"
import { ChartData } from '@/types' import { ChartData } from "@/types"
import { Separator } from '../ui/separator' import { Separator } from "../ui/separator"
export default memo(function ContainerChart({ export default memo(function ContainerChart({
dataKey, dataKey,
chartData, chartData,
chartName, chartName,
unit = '%', unit = "%",
}: { }: {
dataKey: string dataKey: string
chartData: ChartData chartData: ChartData
@@ -39,7 +33,7 @@ export default memo(function ContainerChart({
const { containerData } = chartData const { containerData } = chartData
const isNetChart = chartName === 'net' const isNetChart = chartName === "net"
const chartConfig = useMemo(() => { const chartConfig = useMemo(() => {
let config = {} as Record< let config = {} as Record<
@@ -52,7 +46,7 @@ export default memo(function ContainerChart({
const totalUsage = {} as Record<string, number> const totalUsage = {} as Record<string, number>
for (let stats of containerData) { for (let stats of containerData) {
for (let key in stats) { for (let key in stats) {
if (!key || key === 'created') { if (!key || key === "created") {
continue continue
} }
if (!(key in totalUsage)) { if (!(key in totalUsage)) {
@@ -87,7 +81,7 @@ export default memo(function ContainerChart({
tickFormatter: (value: any) => string tickFormatter: (value: any) => string
} }
// tick formatter // tick formatter
if (chartName === 'cpu') { if (chartName === "cpu") {
obj.tickFormatter = (value) => { obj.tickFormatter = (value) => {
const val = toFixedWithoutTrailingZeros(value, 2) + unit const val = toFixedWithoutTrailingZeros(value, 2) + unit
return updateYAxisWidth(val) return updateYAxisWidth(val)
@@ -95,7 +89,7 @@ export default memo(function ContainerChart({
} else { } else {
obj.tickFormatter = (value) => { obj.tickFormatter = (value) => {
const { v, u } = getSizeAndUnit(value, false) const { v, u } = getSizeAndUnit(value, false)
return updateYAxisWidth(`${toFixedFloat(v, 2)}${u}${isNetChart ? '/s' : ''}`) return updateYAxisWidth(`${toFixedFloat(v, 2)}${u}${isNetChart ? "/s" : ""}`)
} }
} }
// tooltip formatter // tooltip formatter
@@ -107,10 +101,10 @@ export default memo(function ContainerChart({
return ( return (
<span className="flex"> <span className="flex">
{decimalString(received)} MB/s {decimalString(received)} MB/s
<span className="opacity-70 ml-0.5"> rx </span> <span className="opacity-70 ms-0.5"> rx </span>
<Separator orientation="vertical" className="h-3 mx-1.5 bg-primary/40" /> <Separator orientation="vertical" className="h-3 mx-1.5 bg-primary/40" />
{decimalString(sent)} MB/s {decimalString(sent)} MB/s
<span className="opacity-70 ml-0.5"> tx</span> <span className="opacity-70 ms-0.5"> tx</span>
</span> </span>
) )
} catch (e) { } catch (e) {
@@ -122,20 +116,24 @@ export default memo(function ContainerChart({
} }
// data function // data function
if (isNetChart) { if (isNetChart) {
obj.dataFunction = (key: string, data: any) => (data[key]?.nr ?? 0) + (data[key]?.ns ?? 0) obj.dataFunction = (key: string, data: any) => (data[key] ? data[key].nr + data[key].ns : null)
} else { } else {
obj.dataFunction = (key: string, data: any) => data[key]?.[dataKey] ?? 0 obj.dataFunction = (key: string, data: any) => data[key]?.[dataKey] ?? null
} }
return obj return obj
}, []) }, [])
// console.log('rendered at', new Date()) // console.log('rendered at', new Date())
if (containerData.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 <AreaChart
@@ -147,6 +145,8 @@ export default memo(function ContainerChart({
> >
<CartesianGrid vertical={false} /> <CartesianGrid vertical={false} />
<YAxis <YAxis
direction="ltr"
orientation={chartData.orientation}
className="tracking-tighter" className="tracking-tighter"
width={yAxisWidth} width={yAxisWidth}
tickFormatter={tickFormatter} tickFormatter={tickFormatter}

View File

@@ -1,6 +1,6 @@
import { Area, AreaChart, CartesianGrid, YAxis } from 'recharts' import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from '@/components/ui/chart' import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
import { import {
useYAxisWidth, useYAxisWidth,
cn, cn,
@@ -9,9 +9,11 @@ import {
toFixedFloat, toFixedFloat,
chartMargin, chartMargin,
getSizeAndUnit, getSizeAndUnit,
} from '@/lib/utils' } from "@/lib/utils"
import { ChartData } from '@/types' import { ChartData } from "@/types"
import { memo } from 'react' import { memo } from "react"
import { t } from "@lingui/macro"
import { useLingui } from "@lingui/react"
export default memo(function DiskChart({ export default memo(function DiskChart({
dataKey, dataKey,
@@ -23,17 +25,29 @@ export default memo(function DiskChart({
chartData: ChartData chartData: ChartData
}) { }) {
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth() const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
const { _ } = useLingui()
// round to nearest GB
if (diskSize >= 100) {
diskSize = Math.round(diskSize)
}
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={chartData.systemStats} 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]}
@@ -62,7 +76,7 @@ export default memo(function DiskChart({
/> />
<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}

View File

@@ -0,0 +1,112 @@
import { CartesianGrid, Line, LineChart, YAxis } from "recharts"
import {
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
xAxis,
} from "@/components/ui/chart"
import {
useYAxisWidth,
cn,
formatShortDate,
toFixedWithoutTrailingZeros,
decimalString,
chartMargin,
} from "@/lib/utils"
import { ChartData } from "@/types"
import { memo, useMemo } from "react"
export default memo(function GpuPowerChart({ chartData }: { chartData: ChartData }) {
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
if (chartData.systemStats.length === 0) {
return null
}
/** Format temperature data for chart and assign colors */
const newChartData = useMemo(() => {
const newChartData = { data: [], colors: {} } as {
data: Record<string, number | string>[]
colors: Record<string, string>
}
const powerSums = {} as Record<string, number>
for (let data of chartData.systemStats) {
let newData = { created: data.created } as Record<string, number | string>
for (let gpu of Object.values(data.stats?.g ?? {})) {
if (gpu.p) {
const name = gpu.n
newData[name] = gpu.p
powerSums[name] = (powerSums[name] ?? 0) + newData[name]
}
}
newChartData.data.push(newData)
}
const keys = Object.keys(powerSums).sort((a, b) => powerSums[b] - powerSums[a])
for (let key of keys) {
newChartData.colors[key] = `hsl(${((keys.indexOf(key) * 360) / keys.length) % 360}, 60%, 55%)`
}
return newChartData
}, [chartData])
const colors = Object.keys(newChartData.colors)
// console.log('rendered at', new Date())
return (
<div>
<ChartContainer
className={cn("h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity", {
"opacity-100": yAxisWidth,
})}
>
<LineChart accessibilityLayer data={newChartData.data} margin={chartMargin}>
<CartesianGrid vertical={false} />
<YAxis
direction="ltr"
orientation={chartData.orientation}
className="tracking-tighter"
domain={[0, "auto"]}
width={yAxisWidth}
tickFormatter={(value) => {
const val = toFixedWithoutTrailingZeros(value, 2)
return updateYAxisWidth(val + "W")
}}
tickLine={false}
axisLine={false}
/>
{xAxis(chartData)}
<ChartTooltip
animationEasing="ease-out"
animationDuration={150}
// @ts-ignore
itemSorter={(a, b) => b.value - a.value}
content={
<ChartTooltipContent
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={(item) => decimalString(item.value) + "W"}
// indicator="line"
/>
}
/>
{colors.map((key) => (
<Line
key={key}
dataKey={key}
name={key}
type="monotoneX"
dot={false}
strokeWidth={1.5}
stroke={newChartData.colors[key]}
isAnimationActive={false}
/>
))}
{colors.length > 1 && <ChartLegend content={<ChartLegendContent />} />}
</LineChart>
</ChartContainer>
</div>
)
})

View File

@@ -1,36 +1,38 @@
import { Area, AreaChart, CartesianGrid, YAxis } from 'recharts' import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } 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"
cn, import { ChartData } from "@/types"
toFixedFloat, import { t } from "@lingui/macro"
decimalString, import { useLingui } from "@lingui/react"
formatShortDate,
chartMargin,
} from '@/lib/utils'
import { memo } from 'react'
import { ChartData } from '@/types'
export default memo(function MemChart({ chartData }: { chartData: ChartData }) { export default memo(function MemChart({ chartData }: { chartData: ChartData }) {
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth() const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
const { _ } = useLingui()
const totalMem = toFixedFloat(chartData.systemStats.at(-1)?.stats.m ?? 0, 1) const totalMem = toFixedFloat(chartData.systemStats.at(-1)?.stats.m ?? 0, 1)
// console.log('rendered at', new Date()) // 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={chartData.systemStats} 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}
@@ -40,7 +42,7 @@ export default memo(function MemChart({ chartData }: { chartData: ChartData }) {
axisLine={false} axisLine={false}
tickFormatter={(value) => { tickFormatter={(value) => {
const val = toFixedFloat(value, 1) const val = toFixedFloat(value, 1)
return updateYAxisWidth(val + ' GB') return updateYAxisWidth(val + " GB")
}} }}
/> />
)} )}
@@ -54,13 +56,13 @@ export default memo(function MemChart({ chartData }: { chartData: ChartData }) {
// @ts-ignore // @ts-ignore
itemSorter={(a, b) => a.order - b.order} itemSorter={(a, b) => a.order - b.order}
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)} labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={(item) => decimalString(item.value) + ' GB'} contentFormatter={(item) => 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"
@@ -84,7 +86,7 @@ export default memo(function MemChart({ chartData }: { chartData: ChartData }) {
/> />
)} )}
<Area <Area
name="Cache / Buffers" name={_(t`Cache / Buffers`)}
order={1} order={1}
dataKey="stats.mb" dataKey="stats.mb"
type="monotoneX" type="monotoneX"

View File

@@ -1,6 +1,6 @@
import { Area, AreaChart, CartesianGrid, YAxis } from 'recharts' import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from '@/components/ui/chart' import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
import { import {
useYAxisWidth, useYAxisWidth,
cn, cn,
@@ -8,32 +8,36 @@ import {
toFixedWithoutTrailingZeros, toFixedWithoutTrailingZeros,
decimalString, decimalString,
chartMargin, chartMargin,
} from '@/lib/utils' } from "@/lib/utils"
import { ChartData } from '@/types' import { ChartData } from "@/types"
import { memo } from 'react' import { memo } from "react"
import { t } from "@lingui/macro"
export default memo(function SwapChart({ chartData }: { chartData: ChartData }) { export default memo(function SwapChart({ chartData }: { chartData: ChartData }) {
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={chartData.systemStats} 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={[ domain={[0, () => toFixedWithoutTrailingZeros(chartData.systemStats.at(-1)?.stats.s ?? 0.04, 2)]}
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(chartData)} {xAxis(chartData)}
<ChartTooltip <ChartTooltip
@@ -42,14 +46,14 @@ export default memo(function SwapChart({ chartData }: { chartData: ChartData })
content={ content={
<ChartTooltipContent <ChartTooltipContent
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)} labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={(item) => decimalString(item.value) + ' GB'} contentFormatter={(item) => 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}

View File

@@ -1,4 +1,4 @@
import { CartesianGrid, Line, LineChart, YAxis } from 'recharts' import { CartesianGrid, Line, LineChart, YAxis } from "recharts"
import { import {
ChartContainer, ChartContainer,
@@ -7,7 +7,7 @@ import {
ChartTooltip, ChartTooltip,
ChartTooltipContent, ChartTooltipContent,
xAxis, xAxis,
} from '@/components/ui/chart' } from "@/components/ui/chart"
import { import {
useYAxisWidth, useYAxisWidth,
cn, cn,
@@ -15,13 +15,17 @@ import {
toFixedWithoutTrailingZeros, toFixedWithoutTrailingZeros,
decimalString, decimalString,
chartMargin, chartMargin,
} from '@/lib/utils' } from "@/lib/utils"
import { ChartData } from '@/types' import { ChartData } from "@/types"
import { memo, useMemo } from 'react' import { memo, useMemo } from "react"
export default memo(function TemperatureChart({ chartData }: { chartData: ChartData }) { export default memo(function TemperatureChart({ chartData }: { chartData: ChartData }) {
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth() const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
if (chartData.systemStats.length === 0) {
return null
}
/** Format temperature data for chart and assign colors */ /** Format temperature data for chart and assign colors */
const newChartData = useMemo(() => { const newChartData = useMemo(() => {
const newChartData = { data: [], colors: {} } as { const newChartData = { data: [], colors: {} } as {
@@ -53,19 +57,21 @@ export default memo(function TemperatureChart({ chartData }: { chartData: ChartD
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,
})} })}
> >
<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}
@@ -79,7 +85,7 @@ export default memo(function TemperatureChart({ chartData }: { chartData: ChartD
content={ content={
<ChartTooltipContent <ChartTooltipContent
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)} labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={(item) => decimalString(item.value) + ' °C'} contentFormatter={(item) => decimalString(item.value) + " °C"}
// indicator="line" // indicator="line"
/> />
} }

View File

@@ -1,14 +1,13 @@
import { import {
BookIcon,
DatabaseBackupIcon, DatabaseBackupIcon,
Github,
LayoutDashboard, LayoutDashboard,
LockKeyholeIcon,
LogsIcon, LogsIcon,
MailIcon, MailIcon,
Server, Server,
SettingsIcon, SettingsIcon,
UsersIcon, UsersIcon,
} from 'lucide-react' } from "lucide-react"
import { import {
CommandDialog, CommandDialog,
@@ -19,34 +18,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 +59,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 +68,125 @@ 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={["help", "oauth", "oidc"]}
onSelect={() => { onSelect={() => {
window.location.href = 'https://github.com/henrygd/beszel/blob/main/readme.md' window.location.href = "https://beszel.dev/guide/what-is-beszel"
}} }}
> >
<Github className="mr-2 h-4 w-4" /> <BookIcon className="me-2 h-4 w-4" />
<span>Documentation</span> <span>
<CommandShortcut>GitHub</CommandShortcut> <Trans>Documentation</Trans>
</span>
<CommandShortcut>beszel.dev</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>Backups</span> <span>
<CommandShortcut>Admin</CommandShortcut> <Trans>Backups</Trans>
</span>
<CommandShortcut>
<Trans>Admin</Trans>
</CommandShortcut>
</CommandItem> </CommandItem>
<CommandItem <CommandItem
keywords={['oauth', 'oicd']} keywords={["email"]}
onSelect={() => { onSelect={() => {
setOpen(false) setOpen(false)
window.open('/_/#/settings/auth-providers', '_blank') window.open("/_/#/settings/mail", "_blank")
}} }}
> >
<LockKeyholeIcon className="mr-2 h-4 w-4" /> <MailIcon className="me-2 h-4 w-4" />
<span>Auth Providers</span> <span>
<CommandShortcut>Admin</CommandShortcut> <Trans>SMTP settings</Trans>
</CommandItem> </span>
<CommandItem <CommandShortcut>
keywords={['email']} <Trans>Admin</Trans>
onSelect={() => { </CommandShortcut>
setOpen(false)
window.open('/_/#/settings/mail', '_blank')
}}
>
<MailIcon className="mr-2 h-4 w-4" />
<span>SMTP settings</span>
<CommandShortcut>Admin</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"
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-2.5 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 } 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(8, t`Password must be at least 8 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,
@@ -32,14 +24,6 @@ const LoginSchema = v.looseObject({
const RegisterSchema = v.looseObject({ const RegisterSchema = v.looseObject({
name: honeypot, name: honeypot,
username: v.pipe(
v.string(),
v.regex(
/^(?=.*[a-zA-Z])[a-zA-Z0-9_-]+$/,
'Invalid username. You may use alphanumeric characters, underscores, and hyphens.'
),
v.minLength(3, 'Username must be at least 3 characters long.')
),
email: emailSchema, email: emailSchema,
password: passwordSchema, password: passwordSchema,
passwordConfirm: passwordSchema, passwordConfirm: passwordSchema,
@@ -47,9 +31,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",
}) })
} }
@@ -71,6 +55,8 @@ export function UserAuthForm({
async (e: React.FormEvent<HTMLFormElement>) => { async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault() e.preventDefault()
setIsLoading(true) setIsLoading(true)
// store email for later use if mfa is enabled
let email = ""
try { try {
const formData = new FormData(e.target as HTMLFormElement) const formData = new FormData(e.target as HTMLFormElement)
const data = Object.fromEntries(formData) as Record<string, any> const data = Object.fromEntries(formData) as Record<string, any>
@@ -86,35 +72,36 @@ export function UserAuthForm({
setErrors(errors) setErrors(errors)
return return
} }
const { email, password, passwordConfirm, username } = result.output const { password, passwordConfirm } = result.output
email = result.output.email
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
} }
await pb.admins.create({ await pb.send("/api/beszel/create-user", {
email, method: "POST",
password, body: JSON.stringify({ email, password }),
passwordConfirm: password,
}) })
await pb.admins.authWithPassword(email, password) await pb.collection("users").authWithPassword(email, password)
await pb.collection('users').create({
username,
email,
password,
passwordConfirm: password,
role: 'admin',
verified: true,
})
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 (err: any) {
showLoginFaliedToast() showLoginFaliedToast()
// todo: implement MFA
// const mfaId = err.response?.mfaId
// if (!mfaId) {
// showLoginFaliedToast()
// throw err
// }
// the user needs to authenticate again with another auth method, for example OTP
// const result = await pb.collection("users").requestOTP(email)
// ... show a modal for users to check their email and to enter the received code ...
// await pb.collection("users").authWithOTP(result.otpId, "EMAIL_CODE", { mfaId: mfaId })
} finally { } finally {
setIsLoading(false) setIsLoading(false)
} }
@@ -126,69 +113,48 @@ export function UserAuthForm({
return null return null
} }
const oauthEnabled = authMethods.oauth2.enabled && authMethods.oauth2.providers.length > 0
const passwordEnabled = authMethods.password.enabled
return ( return (
<div className={cn('grid gap-6', className)} {...props}> <div className={cn("grid gap-6", className)} {...props}>
{authMethods.emailPassword && ( {passwordEnabled && (
<> <>
<form onSubmit={handleSubmit} onChange={() => setErrors({})}> <form onSubmit={handleSubmit} onChange={() => setErrors({})}>
<div className="grid gap-2.5"> <div className="grid gap-2.5">
{isFirstRun && (
<div className="grid gap-1 relative">
<UserIcon className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Label className="sr-only" htmlFor="username">
Username
</Label>
<Input
autoFocus={true}
id="username"
name="username"
required
placeholder="username"
type="username"
autoCapitalize="none"
autoComplete="username"
autoCorrect="off"
disabled={isLoading || isOauthLoading}
className="pl-9"
/>
{errors?.username && (
<p className="px-1 text-xs text-red-600">{errors.username}</p>
)}
</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="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 placeholder: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,61 +162,61 @@ 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 placeholder: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">
{/* honeypot */} {/* honeypot */}
<label htmlFor="name"></label> <label htmlFor="name"></label>
<input id="name" type="text" name="name" tabIndex={-1} /> <input id="name" type="text" name="name" tabIndex={-1} autoComplete="off" />
</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>
{(isFirstRun || authMethods.authProviders.length > 0) && ( {(isFirstRun || oauthEnabled) && (
// only show 'continue with' during onboarding or if we have auth providers // only show 'continue with' during onboarding or if we have auth providers
<div className="relative"> <div className="relative">
<div className="absolute inset-0 flex items-center"> <div className="absolute inset-0 flex items-center">
<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>
)} )}
</> </>
)} )}
{authMethods.authProviders.length > 0 && ( {oauthEnabled && (
<div className="grid gap-2 -mt-1"> <div className="grid gap-2 -mt-1">
{authMethods.authProviders.map((provider) => ( {authMethods.oauth2.providers.map((provider) => (
<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": !passwordEnabled,
'px-5': !authMethods.emailPassword, "px-5": !passwordEnabled,
})} })}
onClick={() => { onClick={() => {
setIsOauthLoading(true) setIsOauthLoading(true)
@@ -263,9 +229,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 +239,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 +252,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:brightness-0 dark:invert"
src={`/static/${provider.name}.svg`} src={`/_/images/oauth2/${provider.name}.svg`}
alt="" alt=""
onError={(e) => { onError={(e) => {
e.currentTarget.src = '/static/lock.svg' e.currentTarget.src = "/static/lock.svg"
}} }}
/> />
)} )}
@@ -303,42 +269,48 @@ export function UserAuthForm({
</div> </div>
)} )}
{!authMethods.authProviders.length && isFirstRun && ( {!oauthEnabled && isFirstRun && (
// 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="/_/images/oauth2/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://beszel.dev/guide/oauth"
for instructions. className={cn(buttonVariants({ variant: "link" }), "p-0 h-auto")}
>
the documentation
</a>{" "}
for instructions.
</Trans>
</p> </p>
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
)} )}
{authMethods.emailPassword && !isFirstRun && ( {passwordEnabled && !isFirstRun && (
<Link <Link
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,27 +1,30 @@
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"
import { useTheme } from "../theme-provider"
export default function () { export default function () {
const page = useStore($router) const page = useStore($router)
const [isFirstRun, setFirstRun] = useState(false) const [isFirstRun, setFirstRun] = useState(false)
const [authMethods, setAuthMethods] = useState<AuthMethodsList>() const [authMethods, setAuthMethods] = useState<AuthMethodsList>()
const { theme } = useTheme()
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 +33,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])
@@ -44,7 +47,11 @@ export default function () {
return ( return (
<div className="min-h-svh 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"
// @ts-ignore
style={{ maxWidth: "22em", "--border": theme == "light" ? "30 8% 80%" : "220 3% 20%" }}
>
<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 +59,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,145 @@
import { useState, lazy, Suspense } from "react"
import { Button, buttonVariants } from "@/components/ui/button"
import {
DatabaseBackupIcon,
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 border-border/60 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.record?.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>
<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,24 +1,23 @@
import { Suspense, lazy, useEffect, useMemo, 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 { alertInfo, 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 { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' import { Link } from "../router"
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 alerts = useStore($alerts)
const systems = useStore($systems) const systems = useStore($systems)
// todo: maybe remove active alert if changed
const activeAlerts = useMemo(() => { const activeAlerts = useMemo(() => {
const activeAlerts = alerts.filter((alert) => { const activeAlerts = alerts.filter((alert) => {
const active = alert.triggered && alert.name in alertInfo const active = alert.triggered && alert.name in alertInfo
@@ -32,21 +31,21 @@ export default function () {
}, [alerts]) }, [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)
}) })
// todo: add toast if new triggered alert comes in // todo: add toast if new triggered alert comes in
pb.collection<AlertRecord>('alerts').subscribe('*', (e) => { pb.collection<AlertRecord>("alerts").subscribe("*", (e) => {
updateRecordList(e, $alerts) updateRecordList(e, $alerts)
}) })
return () => { return () => {
pb.collection('systems').unsubscribe('*') pb.collection("systems").unsubscribe("*")
// pb.collection('alerts').unsubscribe('*') // pb.collection('alerts').unsubscribe('*')
} }
}, []) }, [])
@@ -58,7 +57,9 @@ export default function () {
<Card className="mb-4"> <Card className="mb-4">
<CardHeader className="pb-4 px-2 sm:px-6 max-sm:pt-5 max-sm:pb-1"> <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>Active Alerts</CardTitle> <CardTitle>
<Trans>Active Alerts</Trans>
</CardTitle>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="max-sm:p-2"> <CardContent className="max-sm:p-2">
@@ -73,11 +74,13 @@ export default function () {
> >
<info.icon className="h-4 w-4" /> <info.icon className="h-4 w-4" />
<AlertTitle> <AlertTitle>
{alert.sysname} {info.name} {alert.sysname} {info.name().toLowerCase().replace("cpu", "CPU")}
</AlertTitle> </AlertTitle>
<AlertDescription> <AlertDescription>
Exceeds {alert.value} <Trans>
{info.unit} average in last {alert.min} min Exceeds {alert.value}
{info.unit} in last <Plural value={alert.min} one="# minute" other="# minutes" />
</Trans>
</AlertDescription> </AlertDescription>
<Link <Link
href={`/system/${encodeURIComponent(alert.sysname!)}`} href={`/system/${encodeURIComponent(alert.sysname!)}`}
@@ -92,35 +95,12 @@ export default function () {
</CardContent> </CardContent>
</Card> </Card>
)} )}
<Card> <Suspense>
<CardHeader className="pb-5 px-2 sm:px-6 max-sm:pt-5 max-sm:pb-1"> <SystemsTable />
<div className="grid md:flex gap-3 w-full items-end"> </Suspense>
<div className="px-2 sm:px-1">
<CardTitle className="mb-2.5">All Systems</CardTitle>
<CardDescription>
Updated in real time. Press{' '}
<kbd className="pointer-events-none inline-flex h-5 select-none items-center gap-0.5 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground opacity-100">
<span className="text-xs"></span>K
</kbd>{' '}
to open the command palette.
</CardDescription>
</div>
<Input
placeholder="Filter..."
onChange={(e) => setFilter(e.target.value)}
className="w-full md:w-56 lg:w-80 ml-auto px-4"
/>
</div>
</CardHeader>
<CardContent className="max-sm:p-2">
<Suspense>
<SystemsTable filter={filter} />
</Suspense>
</CardContent>
</Card>
{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

@@ -1,18 +1,19 @@
import { isAdmin } from '@/lib/utils' import { isAdmin } from "@/lib/utils"
import { Separator } from '@/components/ui/separator' import { Separator } from "@/components/ui/separator"
import { Button } from '@/components/ui/button' import { Button } from "@/components/ui/button"
import { redirectPage } from '@nanostores/router' import { redirectPage } from "@nanostores/router"
import { $router } from '@/components/router' import { $router } from "@/components/router"
import { AlertCircleIcon, FileSlidersIcon, LoaderCircleIcon } from 'lucide-react' import { AlertCircleIcon, FileSlidersIcon, LoaderCircleIcon } from "lucide-react"
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { pb } from '@/lib/stores' import { pb } from "@/lib/stores"
import { useState } from 'react' import { useState } from "react"
import { Textarea } from '@/components/ui/textarea' import { Textarea } from "@/components/ui/textarea"
import { toast } from '@/components/ui/use-toast' import { toast } from "@/components/ui/use-toast"
import clsx from 'clsx' import clsx from "clsx"
import { Trans, t } from "@lingui/macro"
export default function ConfigYaml() { export default function ConfigYaml() {
const [configContent, setConfigContent] = useState<string>('') const [configContent, setConfigContent] = useState<string>("")
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const ButtonIcon = isLoading ? LoaderCircleIcon : FileSlidersIcon const ButtonIcon = isLoading ? LoaderCircleIcon : FileSlidersIcon
@@ -20,13 +21,13 @@ export default function ConfigYaml() {
async function fetchConfig() { async function fetchConfig() {
try { try {
setIsLoading(true) setIsLoading(true)
const { config } = await pb.send<{ config: string }>('/api/beszel/config-yaml', {}) const { config } = await pb.send<{ config: string }>("/api/beszel/config-yaml", {})
setConfigContent(config) setConfigContent(config)
} catch (error: any) { } catch (error: any) {
toast({ toast({
title: 'Error', title: t`Error`,
description: error.message, description: error.message,
variant: 'destructive', variant: "destructive",
}) })
} finally { } finally {
setIsLoading(false) setIsLoading(false)
@@ -34,59 +35,62 @@ export default function ConfigYaml() {
} }
if (!isAdmin()) { if (!isAdmin()) {
redirectPage($router, 'settings', { name: 'general' }) redirectPage($router, "settings", { name: "general" })
} }
return ( return (
<div> <div>
<div> <div>
<h3 className="text-xl font-medium mb-2">YAML Configuration</h3> <h3 className="text-xl font-medium mb-2">
<Trans>YAML Configuration</Trans>
</h3>
<p className="text-sm text-muted-foreground leading-relaxed"> <p className="text-sm text-muted-foreground leading-relaxed">
Export your current systems configuration. <Trans>Export your current systems configuration.</Trans>
</p> </p>
</div> </div>
<Separator className="my-4" /> <Separator className="my-4" />
<div className="space-y-2"> <div className="space-y-2">
<div className="mb-4"> <div className="mb-4">
<p className="text-sm text-muted-foreground leading-relaxed my-1"> <p className="text-sm text-muted-foreground leading-relaxed my-1">
Systems may be managed in a{' '} <Trans>
<code className="bg-muted rounded-sm px-1 text-primary">config.yml</code> file inside Systems may be managed in a <code className="bg-muted rounded-sm px-1 text-primary">config.yml</code> file
your data directory. inside your data directory.
</Trans>
</p> </p>
<p className="text-sm text-muted-foreground leading-relaxed"> <p className="text-sm text-muted-foreground leading-relaxed">
On each restart, systems in the database will be updated to match the systems defined in <Trans>
the file. On each restart, systems in the database will be updated to match the systems defined in the file.
</Trans>
</p> </p>
<Alert className="my-4 border-destructive text-destructive w-auto table md:pr-6"> <Alert className="my-4 border-destructive text-destructive w-auto table md:pe-6">
<AlertCircleIcon className="h-4 w-4 stroke-destructive" /> <AlertCircleIcon className="h-4 w-4 stroke-destructive" />
<AlertTitle>Caution - potential data loss</AlertTitle> <AlertTitle>
<Trans>Caution - potential data loss</Trans>
</AlertTitle>
<AlertDescription> <AlertDescription>
<p> <p>
Existing systems not defined in <code>config.yml</code> will be deleted. Please make <Trans>
regular backups. Existing systems not defined in <code>config.yml</code> will be deleted. Please make regular backups.
</Trans>
</p> </p>
</AlertDescription> </AlertDescription>
</Alert> </Alert>
</div> </div>
{configContent && ( {configContent && (
<Textarea <Textarea
dir="ltr"
autoFocus autoFocus
defaultValue={configContent} defaultValue={configContent}
spellCheck="false" spellCheck="false"
rows={Math.min(25, configContent.split('\n').length)} rows={Math.min(25, configContent.split("\n").length)}
className="font-mono whitespace-pre" className="font-mono whitespace-pre"
/> />
)} )}
</div> </div>
<Separator className="my-5" /> <Separator className="my-5" />
<Button <Button type="button" className="mt-2 flex items-center gap-1" onClick={fetchConfig} disabled={isLoading}>
type="button" <ButtonIcon className={clsx("h-4 w-4 me-0.5", isLoading && "animate-spin")} />
className="mt-2 flex items-center gap-1" <Trans>Export configuration</Trans>
onClick={fetchConfig}
disabled={isLoading}
>
<ButtonIcon className={clsx('h-4 w-4 mr-0.5', isLoading && 'animate-spin')} />
Export configuration
</Button> </Button>
</div> </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"
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,48 +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, FileSlidersIcon, 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' import ConfigYaml from "./config-yaml.tsx"
import { isAdmin } from '@/lib/utils.ts' import { Trans, t } from "@lingui/macro"
import { useLingui } from "@lingui/react"
const sidebarNavItems = [
{
title: 'General',
href: '/settings/general',
icon: SettingsIcon,
},
{
title: 'Notifications',
href: '/settings/notifications',
icon: BellIcon,
},
]
if (isAdmin()) {
sidebarNavItems.push({
title: 'YAML Config',
href: '/settings/config',
icon: FileSlidersIcon,
})
}
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,
@@ -50,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" />
@@ -88,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>
@@ -100,11 +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': case "config":
return <ConfigYaml /> 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,24 +176,24 @@ 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)
} }
return ( return (
<Card className="bg-muted/30 p-2 md:p-3"> <Card className="bg-muted/40 p-2 md:p-3">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Input <Input
type="url" type="url"
@@ -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,39 +24,47 @@ 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 if (item.admin && !isAdmin()) {
key={item.href} return null
href={item.href} }
className={cn( return (
buttonVariants({ variant: 'ghost' }), <Link
'flex items-center gap-3', key={item.href}
page?.path === item.href ? 'bg-muted hover:bg-muted' : 'hover:bg-muted/50', href={item.href}
'justify-start' className={cn(
)} buttonVariants({ variant: "ghost" }),
> "flex items-center gap-3",
{item.icon && <item.icon className="h-4 w-4" />} page?.path === item.href ? "bg-muted hover:bg-muted" : "hover:bg-muted/50",
{item.title} "justify-start"
</Link> )}
))} >
{item.icon && <item.icon className="h-4 w-4" />}
{item.title}
</Link>
)
})}
</nav> </nav>
</> </>
) )

View File

@@ -1,39 +1,36 @@
import { $systems, pb, $chartTime, $containerFilter, $userSettings } from '@/lib/stores' import { $systems, pb, $chartTime, $containerFilter, $userSettings, $direction } from "@/lib/stores"
import { import { ChartData, ChartTimes, ContainerStatsRecord, GPUData, SystemRecord, SystemStatsRecord } from "@/types"
ChartData, import React, { lazy, useCallback, useEffect, useMemo, useRef, useState } from "react"
ChartTimes, import { Card, CardHeader, CardTitle, CardDescription } from "../ui/card"
ContainerStatsRecord, import { useStore } from "@nanostores/react"
SystemRecord, import Spinner from "../spinner"
SystemStatsRecord, import { ClockArrowUp, CpuIcon, GlobeIcon, LayoutGridIcon, MonitorIcon, XIcon } from "lucide-react"
} from '@/types' import ChartTimeSelect from "../charts/chart-time-select"
import React, { lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { chartTimeData, cn, getPbTimestamp, getSizeAndUnit, toFixedFloat, useLocalStorage } from "@/lib/utils"
import { Card, CardHeader, CardTitle, CardDescription } from '../ui/card' import { Separator } from "../ui/separator"
import { useStore } from '@nanostores/react' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip"
import Spinner from '../spinner' import { Button } from "../ui/button"
import { ClockArrowUp, CpuIcon, GlobeIcon, LayoutGridIcon, MonitorIcon, XIcon } from 'lucide-react' import { Input } from "../ui/input"
import ChartTimeSelect from '../charts/chart-time-select' import { ChartAverage, ChartMax, Rows, TuxIcon } from "../ui/icons"
import { chartTimeData, cn, getPbTimestamp, useLocalStorage } from '@/lib/utils' import { useIntersectionObserver } from "@/lib/use-intersection-observer"
import { Separator } from '../ui/separator' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip' import { timeTicks } from "d3-time"
import { Button } from '../ui/button' import { Plural, Trans, t } from "@lingui/macro"
import { Input } from '../ui/input' import { useLingui } from "@lingui/react"
import { ChartAverage, ChartMax, Rows, TuxIcon } from '../ui/icons'
import { useIntersectionObserver } from '@/lib/use-intersection-observer'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
import { timeTicks } from 'd3-time'
const AreaChartDefault = lazy(() => import('../charts/area-chart')) const AreaChartDefault = lazy(() => import("../charts/area-chart"))
const ContainerChart = lazy(() => import('../charts/container-chart')) const ContainerChart = lazy(() => import("../charts/container-chart"))
const MemChart = lazy(() => import('../charts/mem-chart')) const MemChart = lazy(() => import("../charts/mem-chart"))
const DiskChart = lazy(() => import('../charts/disk-chart')) const DiskChart = lazy(() => import("../charts/disk-chart"))
const SwapChart = lazy(() => import('../charts/swap-chart')) const SwapChart = lazy(() => import("../charts/swap-chart"))
const TemperatureChart = lazy(() => import('../charts/temperature-chart')) const TemperatureChart = lazy(() => import("../charts/temperature-chart"))
const GpuPowerChart = lazy(() => import("../charts/gpu-power-chart"))
const cache = new Map<string, any>() const cache = new Map<string, any>()
// create ticks and domain for charts // create ticks and domain for charts
function getTimeData(chartTime: ChartTimes, lastCreated: number) { function getTimeData(chartTime: ChartTimes, lastCreated: number) {
const cached = cache.get('td') const cached = cache.get("td")
if (cached && cached.chartTime === chartTime) { if (cached && cached.chartTime === chartTime) {
if (!lastCreated || cached.time >= lastCreated) { if (!lastCreated || cached.time >= lastCreated) {
return cached.data return cached.data
@@ -42,14 +39,12 @@ function getTimeData(chartTime: ChartTimes, lastCreated: number) {
const now = new Date() const now = new Date()
const startTime = chartTimeData[chartTime].getOffset(now) const startTime = chartTimeData[chartTime].getOffset(now)
const ticks = timeTicks(startTime, now, chartTimeData[chartTime].ticks ?? 12).map((date) => const ticks = timeTicks(startTime, now, chartTimeData[chartTime].ticks ?? 12).map((date) => date.getTime())
date.getTime()
)
const data = { const data = {
ticks, ticks,
domain: [chartTimeData[chartTime].getOffset(now).getTime(), now.getTime()], domain: [chartTimeData[chartTime].getOffset(now).getTime(), now.getTime()],
} }
cache.set('td', { time: now.getTime(), data, chartTime }) cache.set("td", { time: now.getTime(), data, chartTime })
return data return data
} }
@@ -78,38 +73,44 @@ function addEmptyValues<T extends SystemStatsRecord | ContainerStatsRecord>(
return modifiedRecords return modifiedRecords
} }
async function getStats<T>( async function getStats<T>(collection: string, system: SystemRecord, chartTime: ChartTimes): Promise<T[]> {
collection: string,
system: SystemRecord,
chartTime: ChartTimes
): Promise<T[]> {
const lastCached = cache.get(`${system.id}_${chartTime}_${collection}`)?.at(-1)?.created as number const lastCached = cache.get(`${system.id}_${chartTime}_${collection}`)?.at(-1)?.created as number
return await pb.collection<T>(collection).getFullList({ return await pb.collection<T>(collection).getFullList({
filter: pb.filter('system={:id} && created > {:created} && type={:type}', { filter: pb.filter("system={:id} && created > {:created} && type={:type}", {
id: system.id, id: system.id,
created: getPbTimestamp(chartTime, lastCached ? new Date(lastCached + 1000) : undefined), created: getPbTimestamp(chartTime, lastCached ? new Date(lastCached + 1000) : undefined),
type: chartTimeData[chartTime].type, type: chartTimeData[chartTime].type,
}), }),
fields: 'created,stats', fields: "created,stats",
sort: 'created', sort: "created",
}) })
} }
function dockerOrPodman(str: string, system: SystemRecord) {
if (system.info.p) {
str = str.replace("docker", "podman").replace("Docker", "Podman")
}
return str
}
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 [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 [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 [bottomSpacing, setBottomSpacing] = useState(0) const [bottomSpacing, setBottomSpacing] = useState(0)
const isLongerChart = chartTime !== '1h' const [chartLoading, setChartLoading] = useState(true)
const isLongerChart = chartTime !== "1h"
useEffect(() => { useEffect(() => {
document.title = `${name} / Beszel` document.title = `${name} / Beszel`
@@ -119,7 +120,7 @@ export default function SystemDetail({ name }: { name: string }) {
setSystemStats([]) setSystemStats([])
setContainerData([]) 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)
@@ -149,11 +150,11 @@ export default function SystemDetail({ name }: { name: string }) {
if (!system.id) { if (!system.id) {
return return
} }
pb.collection<SystemRecord>('systems').subscribe(system.id, (e) => { pb.collection<SystemRecord>("systems").subscribe(system.id, (e) => {
setSystem(e.record) setSystem(e.record)
}) })
return () => { return () => {
pb.collection('systems').unsubscribe(system.id) pb.collection("systems").unsubscribe(system.id)
} }
}, [system.id]) }, [system.id])
@@ -166,27 +167,31 @@ export default function SystemDetail({ name }: { name: string }) {
systemStats, systemStats,
containerData, containerData,
chartTime, chartTime,
orientation: direction === "rtl" ? "right" : "left",
...getTimeData(chartTime, lastCreated), ...getTimeData(chartTime, lastCreated),
} }
}, [systemStats, containerData]) }, [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', system, chartTime), getStats<SystemStatsRecord>("system_stats", system, chartTime),
getStats<ContainerStatsRecord>('container_stats', system, chartTime), 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( systemData = systemData.concat(addEmptyValues(systemData, systemStats.value, expectedInterval))
addEmptyValues(systemData, systemStats.value, expectedInterval)
)
if (systemData.length > 120) { if (systemData.length > 120) {
systemData = systemData.slice(-100) systemData = systemData.slice(-100)
} }
@@ -196,10 +201,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( containerData = containerData.concat(addEmptyValues(containerData, containerStats.value, expectedInterval))
addEmptyValues(containerData, containerStats.value, expectedInterval)
)
if (containerData.length > 120) { if (containerData.length > 120) {
containerData = containerData.slice(-100) containerData = containerData.slice(-100)
} }
@@ -216,7 +219,7 @@ export default function SystemDetail({ name }: { name: string }) {
// make container stats for charts // make container stats for charts
const makeContainerData = useCallback((containers: ContainerStatsRecord[]) => { const makeContainerData = useCallback((containers: ContainerStatsRecord[]) => {
const containerData = [] as ChartData['containerData'] const containerData = [] as ChartData["containerData"]
for (let { created, stats } of containers) { for (let { created, stats } of containers) {
if (!created) { if (!created) {
// @ts-ignore add null value for gaps // @ts-ignore add null value for gaps
@@ -225,7 +228,7 @@ export default function SystemDetail({ name }: { name: string }) {
} }
created = new Date(created).getTime() created = new Date(created).getTime()
// @ts-ignore not dealing with this rn // @ts-ignore not dealing with this rn
let containerStats: ChartData['containerData'][0] = { created } let containerStats: ChartData["containerData"][0] = { created }
for (let container of stats) { for (let container of stats) {
containerStats[container.n] = container containerStats[container.n] = container
} }
@@ -239,26 +242,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,
}, },
@@ -277,7 +280,7 @@ export default function SystemDetail({ name }: { name: string }) {
return return
} }
const tooltipHeight = (Object.keys(containerData[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
@@ -288,29 +291,33 @@ export default function SystemDetail({ name }: { name: string }) {
return null return null
} }
// if no data, show empty message
const dataEmpty = !chartLoading && chartData.systemStats.length === 0
const hasGpuData = Object.keys(systemStats.at(-1)?.stats.g ?? {}).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 gap-4 px-4 sm:px-6 pt-3 sm:pt-4 pb-5"> <div className="grid xl: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>
@@ -343,16 +350,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="xl:ms-auto flex items-center gap-2 max-sm:-mb-1">
<ChartTimeSelect className="w-full lg:w-40" /> <ChartTimeSelect className="w-full xl: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`}
variant="outline" variant="outline"
size="icon" size="icon"
className="hidden lg:flex p-0 text-primary" className="hidden xl:flex p-0 text-primary"
onClick={() => setGrid(!grid)} onClick={() => setGrid(!grid)}
> >
{grid ? ( {grid ? (
@@ -362,7 +369,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>
@@ -370,28 +377,23 @@ 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="%" />
chartData={chartData}
chartName="CPU Usage"
maxToggled={cpuMaxStore[0]}
unit="%"
/>
</ChartCard> </ChartCard>
{containerFilterBar && ( {containerFilterBar && (
<ChartCard <ChartCard
empty={dataEmpty}
grid={grid} grid={grid}
title="Docker CPU Usage" title={dockerOrPodman(t`Docker CPU Usage`, system)}
description="Average CPU utilization of containers" description={t`Average CPU utilization of containers`}
cornerEl={containerFilterBar} cornerEl={containerFilterBar}
> >
<ContainerChart chartData={chartData} dataKey="c" chartName="cpu" /> <ContainerChart chartData={chartData} dataKey="c" chartName="cpu" />
@@ -399,68 +401,61 @@ export default function SystemDetail({ name }: { name: string }) {
)} )}
<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 chartData={chartData} /> <MemChart chartData={chartData} />
</ChartCard> </ChartCard>
{containerFilterBar && ( {containerFilterBar && (
<ChartCard <ChartCard
empty={dataEmpty}
grid={grid} grid={grid}
title="Docker Memory Usage" title={dockerOrPodman(t`Docker Memory Usage`, system)}
description="Memory usage of docker containers" description={dockerOrPodman(t`Memory usage of docker containers`, system)}
cornerEl={containerFilterBar} cornerEl={containerFilterBar}
> >
<ContainerChart chartData={chartData} chartName="mem" dataKey="m" unit=" MB" /> <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 chartData={chartData} dataKey="stats.du" diskSize={systemStats.at(-1)?.stats.d ?? NaN} />
chartData={chartData}
dataKey="stats.du"
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" />
chartData={chartData}
maxToggled={diskIoMaxStore[0]}
chartName="dio"
/>
</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" />
chartData={chartData}
maxToggled={bandwidthMaxStore[0]}
chartName="bw"
/>
</ChartCard> </ChartCard>
{containerFilterBar && containerData.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={dockerOrPodman(t`Docker Network I/O`, system)}
description={dockerOrPodman(t`Network traffic of docker containers`, system)}
cornerEl={containerFilterBar} cornerEl={containerFilterBar}
> >
{/* @ts-ignore */} {/* @ts-ignore */}
@@ -469,40 +464,104 @@ export default function SystemDetail({ name }: { name: string }) {
</div> </div>
)} )}
{/* Swap chart */}
{(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
empty={dataEmpty}
grid={grid}
title={t`Swap Usage`}
description={t`Swap space used by the system`}
>
<SwapChart chartData={chartData} /> <SwapChart chartData={chartData} />
</ChartCard> </ChartCard>
)} )}
{/* Temperature chart */}
{systemStats.at(-1)?.stats.t && ( {systemStats.at(-1)?.stats.t && (
<ChartCard grid={grid} title="Temperature" description="Temperatures of system sensors"> <ChartCard
empty={dataEmpty}
grid={grid}
title={t`Temperature`}
description={t`Temperatures of system sensors`}
>
<TemperatureChart chartData={chartData} /> <TemperatureChart chartData={chartData} />
</ChartCard> </ChartCard>
)} )}
{/* GPU power draw chart */}
{hasGpuData && (
<ChartCard
empty={dataEmpty}
grid={grid}
title={t`GPU Power Draw`}
description={t`Average power consumption of GPUs`}
>
<GpuPowerChart chartData={chartData} />
</ChartCard>
)}
</div> </div>
{/* GPU charts */}
{hasGpuData && (
<div className="grid xl:grid-cols-2 gap-4">
{Object.keys(systemStats.at(-1)?.stats.g ?? {}).map((id) => {
const gpu = systemStats.at(-1)?.stats.g?.[id] as GPUData
return (
<div key={id} className="contents">
<ChartCard
empty={dataEmpty}
grid={grid}
title={`${gpu.n} ${t`Usage`}`}
description={t`Average utilization of ${gpu.n}`}
>
<AreaChartDefault chartData={chartData} chartName={`g.${id}.u`} unit="%" />
</ChartCard>
<ChartCard
empty={dataEmpty}
grid={grid}
title={`${gpu.n} VRAM`}
description={t`Precise utilization at the recorded time`}
>
<AreaChartDefault
chartData={chartData}
chartName={`g.${id}.mu`}
unit=" MB"
max={gpu.mt}
tickFormatter={(value) => {
const { v, u } = getSizeAndUnit(value, false)
return toFixedFloat(v, 1) + u
}}
/>
</ChartCard>
</div>
)
})}
</div>
)}
{/* extra filesystem charts */} {/* extra filesystem charts */}
{Object.keys(systemStats.at(-1)?.stats.efs ?? {}).length > 0 && ( {Object.keys(systemStats.at(-1)?.stats.efs ?? {}).length > 0 && (
<div className="grid lg:grid-cols-2 gap-4"> <div className="grid xl:grid-cols-2 gap-4">
{Object.keys(systemStats.at(-1)?.stats.efs ?? {}).map((extraFsName) => { {Object.keys(systemStats.at(-1)?.stats.efs ?? {}).map((extraFsName) => {
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
chartData={chartData} chartData={chartData}
dataKey={`stats.efs.${extraFsName}.du`} dataKey={`stats.efs.${extraFsName}.du`}
diskSize={Math.round(systemStats.at(-1)?.stats.efs?.[extraFsName].d ?? NaN)} diskSize={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
@@ -526,6 +585,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)
@@ -533,12 +593,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"
@@ -546,7 +601,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>
@@ -555,26 +610,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>
@@ -586,32 +637,33 @@ 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.5em)] h-48 md:h-52 relative group">
{<Spinner />} {
<Spinner
msg={empty ? t`Waiting for enough records to display` : undefined}
// className="group-has-[.opacity-100]:opacity-0 transition-opacity"
className="group-has-[.opacity-100]:invisible duration-100"
/>
}
{isIntersecting && children} {isIntersecting && children}
</div> </div>
</Card> </Card>

View File

@@ -1,9 +1,14 @@
import { LoaderCircleIcon } from 'lucide-react' import { cn } from "@/lib/utils"
import { LoaderCircleIcon } from "lucide-react"
export default function () { export default function ({ msg, className }: { msg?: string; className?: string }) {
return ( return (
<div className="grid place-content-center h-full absolute inset-0"> <div className={cn(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 text-sm px-4"}>{msg}</p>
) : (
<LoaderCircleIcon className="animate-spin h-10 w-10 opacity-60" />
)}
</div> </div>
) )
} }

View File

@@ -6,29 +6,27 @@ 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,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu' } from "@/components/ui/dropdown-menu"
import { import {
AlertDialog, AlertDialog,
@@ -40,9 +38,9 @@ import {
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
AlertDialogTrigger, AlertDialogTrigger,
} from '@/components/ui/alert-dialog' } from "@/components/ui/alert-dialog"
import { SystemRecord } from '@/types' import { SystemRecord } from "@/types"
import { import {
MoreHorizontalIcon, MoreHorizontalIcon,
ArrowUpDownIcon, ArrowUpDownIcon,
@@ -55,61 +53,76 @@ import {
HardDriveIcon, HardDriveIcon,
ServerIcon, ServerIcon,
CpuIcon, CpuIcon,
} from 'lucide-react' LayoutGridIcon,
import { useEffect, useMemo, useState } from 'react' LayoutListIcon,
import { $hubVersion, $systems, pb } from '@/lib/stores' ArrowDownIcon,
import { useStore } from '@nanostores/react' ArrowUpIcon,
import { cn, copyToClipboard, decimalString, isReadOnlyUser } from '@/lib/utils' Settings2Icon,
import AlertsButton from '../alerts/alert-button' EyeIcon,
import { navigate } from '../router' } from "lucide-react"
import { EthernetIcon } from '../ui/icons' import { useEffect, useMemo, useState } from "react"
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 { Link, 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"
import { ClassValue } from "clsx"
type ViewMode = "table" | "grid"
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-2 items-center tabular-nums tracking-tight">
<span className="min-w-[3.5em]">{decimalString(val, 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 / 100})`,
}}
></span> ></span>
</span> </span>
</div> </div>
) )
} }
function sortableHeader( function sortableHeader(column: Column<SystemRecord, unknown>, 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" /> {/* @ts-ignore */}
{name} {column.columnDef?.icon && <column.columnDef.icon className="me-2 size-4" />}
{!hideSortIcon && <ArrowUpDownIcon className="ml-2 h-4 w-4" />} {column.id}
{!hideSortIcon && <ArrowUpDownIcon className="ms-2 size-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 [sorting, setSorting] = useState<SortingState>([]) const [filter, setFilter] = useState<string>()
const [sorting, setSorting] = useState<SortingState>([{ id: t`System`, desc: false }])
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]) const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
const [columnVisibility, setColumnVisibility] = useLocalStorage<VisibilityState>("cols", {})
const [viewMode, setViewMode] = useLocalStorage<ViewMode>("viewMode", window.innerWidth > 1024 ? "table" : "grid")
const { i18n } = useLingui()
useEffect(() => { useEffect(() => {
if (filter !== undefined) { if (filter !== undefined) {
table.getColumn('name')?.setFilterValue(filter) table.getColumn(t`System`)?.setFilterValue(filter)
} }
}, [filter]) }, [filter])
@@ -119,168 +132,109 @@ export default function SystemsTable({ filter }: { filter?: string }) {
// size: 200, // size: 200,
size: 200, size: 200,
minSize: 0, minSize: 0,
accessorKey: 'name', accessorKey: "name",
cell: (info) => { id: t`System`,
const { status } = info.row.original enableHiding: false,
return ( icon: ServerIcon,
<span className="flex gap-0.5 items-center text-base md:pr-5"> cell: (info) => (
<span <span className="flex gap-0.5 items-center text-base md:pe-5">
className={cn('w-2 h-2 left-0 rounded-full', { <IndicatorDot system={info.row.original} />
'bg-green-500': status === 'up', <Button
'bg-red-500': status === 'down', data-nolink
'bg-primary/40': status === 'paused', variant={"ghost"}
'bg-yellow-500': status === 'pending', className="text-primary/90 h-7 px-1.5 gap-1.5"
})} onClick={() => copyToClipboard(info.getValue() as string)}
style={{ marginBottom: '-1px' }} >
></span> {info.getValue() as string}
<Button <CopyIcon className="h-2.5 w-2.5" />
data-nolink </Button>
variant={'ghost'} </span>
className="text-primary/90 h-7 px-1.5 gap-1.5" ),
onClick={() => copyToClipboard(info.getValue() as string)} header: ({ column }) => sortableHeader(column),
>
{info.getValue() as string}
<CopyIcon className="h-2.5 w-2.5" />
</Button>
</span>
)
},
header: ({ column }) => sortableHeader(column, 'System', ServerIcon),
}, },
{ {
accessorKey: 'info.cpu', accessorKey: "info.cpu",
id: t`CPU`,
invertSorting: true, invertSorting: true,
cell: CellFormatter, cell: CellFormatter,
header: ({ column }) => sortableHeader(column, 'CPU', CpuIcon), icon: CpuIcon,
header: ({ column }) => sortableHeader(column),
}, },
{ {
accessorKey: 'info.mp', accessorKey: "info.mp",
id: t`Memory`,
invertSorting: true, invertSorting: true,
cell: CellFormatter, cell: CellFormatter,
header: ({ column }) => sortableHeader(column, 'Memory', MemoryStickIcon), icon: MemoryStickIcon,
header: ({ column }) => sortableHeader(column),
}, },
{ {
accessorKey: 'info.dp', accessorKey: "info.dp",
id: t`Disk`,
invertSorting: true, invertSorting: true,
cell: CellFormatter, cell: CellFormatter,
header: ({ column }) => sortableHeader(column, 'Disk', HardDriveIcon), icon: HardDriveIcon,
header: ({ column }) => sortableHeader(column),
}, },
{ {
accessorFn: (originalRow) => originalRow.info.b || 0, accessorFn: (originalRow) => originalRow.info.b || 0,
id: 'n', id: t`Net`,
invertSorting: true, invertSorting: true,
size: 115, size: 115,
header: ({ column }) => sortableHeader(column, 'Net', EthernetIcon), icon: EthernetIcon,
cell: (info) => { header: ({ column }) => sortableHeader(column),
cell(info) {
const val = info.getValue() as number const val = info.getValue() as number
return ( return (
<span className="tabular-nums whitespace-nowrap pl-1"> <span
className={cn("tabular-nums whitespace-nowrap", {
"ps-1": viewMode === "table",
})}
>
{decimalString(val, val >= 100 ? 1 : 2)} MB/s {decimalString(val, val >= 100 ? 1 : 2)} MB/s
</span> </span>
) )
}, },
}, },
{ {
accessorKey: 'info.v', accessorKey: "info.v",
id: t`Agent`,
invertSorting: true, invertSorting: true,
size: 50, size: 50,
header: ({ column }) => sortableHeader(column, 'Agent', WifiIcon, true), icon: WifiIcon,
cell: (info) => { header: ({ column }) => sortableHeader(column, true),
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
<span className={cn("flex gap-2 items-center md:pe-5 tabular-nums", {
className={cn( "ps-1": viewMode === "table",
'w-2 h-2 left-0 rounded-full', })}
version === hubVersion ? 'bg-green-500' : 'bg-yellow-500' >
)} <IndicatorDot
style={{ marginBottom: '-1px' }} system={info.row.original}
></span> className={version === hubVersion ? "bg-green-500" : "bg-yellow-500"}
/>
<span>{info.getValue() as string}</span> <span>{info.getValue() as string}</span>
</span> </span>
) )
}, },
}, },
{ {
id: 'actions', id: t({ message: "Actions", comment: "Table column" }),
size: 120, size: 120,
// minSize: 0, cell: ({ row }) => (
cell: ({ row }) => { <div className="flex justify-end items-center gap-1">
const { id, name, status, host } = row.original <AlertsButton system={row.original} />
return ( <ActionsButton system={row.original} />
<div className={'flex justify-end items-center gap-1'}> </div>
<AlertsButton system={row.original} /> ),
<AlertDialog>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size={'icon'} data-nolink>
<span className="sr-only">Open menu</span>
<MoreHorizontalIcon className="w-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
className={cn(isReadOnlyUser() && 'hidden')}
onClick={() => {
pb.collection('systems').update(id, {
status: status === 'paused' ? 'pending' : 'paused',
})
}}
>
{status === 'paused' ? (
<>
<PlayCircleIcon className="mr-2.5 h-4 w-4" />
Resume
</>
) : (
<>
<PauseCircleIcon className="mr-2.5 h-4 w-4" />
Pause
</>
)}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => copyToClipboard(host)}>
<CopyIcon className="mr-2.5 h-4 w-4" />
Copy host
</DropdownMenuItem>
<DropdownMenuSeparator className={cn(isReadOnlyUser() && 'hidden')} />
<AlertDialogTrigger asChild>
<DropdownMenuItem className={cn(isReadOnlyUser() && 'hidden')}>
<Trash2Icon className="mr-2.5 h-4 w-4" />
Delete
</DropdownMenuItem>
</AlertDialogTrigger>
</DropdownMenuContent>
</DropdownMenu>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you sure you want to delete {name}?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete all current records
for <code className="bg-muted rounded-sm px-1">{name}</code> from the
database.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className={cn(buttonVariants({ variant: 'destructive' }))}
onClick={() => pb.collection('systems').delete(id)}
>
Continue
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
},
}, },
] as ColumnDef<SystemRecord>[] ] as ColumnDef<SystemRecord>[]
}, [hubVersion]) }, [hubVersion, i18n.locale])
const table = useReactTable({ const table = useReactTable({
data, data,
@@ -290,9 +244,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,
@@ -302,64 +258,336 @@ 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> <Settings2Icon className="me-1.5 size-4 opacity-80" />
<TableBody> <Trans>View</Trans>
{table.getRowModel().rows?.length ? ( </Button>
table.getRowModel().rows.map((row) => ( </DropdownMenuTrigger>
<TableRow <DropdownMenuContent align="end" className="h-72 md:h-auto min-w-48 md:min-w-auto overflow-y-auto">
key={row.original.id} <div className="grid grid-cols-1 md:grid-cols-3 divide-y md:divide-s md:divide-y-0">
data-state={row.getIsSelected() && 'selected'} <div>
className={cn('cursor-pointer transition-opacity', { <DropdownMenuLabel className="pt-2 px-3.5 flex items-center gap-2">
'opacity-50': row.original.status === 'paused', <LayoutGridIcon className="size-4" />
})} <Trans>Layout</Trans>
onClick={(e) => { </DropdownMenuLabel>
const target = e.target as HTMLElement <DropdownMenuSeparator />
if (!target.closest('[data-nolink]') && e.currentTarget.contains(target)) { <DropdownMenuRadioGroup
navigate(`/system/${encodeURIComponent(row.original.name)}`) className="px-1 pb-1"
} value={viewMode}
}} onValueChange={(view) => setViewMode(view as ViewMode)}
> >
{row.getVisibleCells().map((cell) => ( <DropdownMenuRadioItem value="table" onSelect={(e) => e.preventDefault()} className="gap-2">
<TableCell <LayoutListIcon className="size-4" />
key={cell.id} <Trans>Table</Trans>
style={{ </DropdownMenuRadioItem>
width: <DropdownMenuRadioItem value="grid" onSelect={(e) => e.preventDefault()} className="gap-2">
cell.column.getSize() === Number.MAX_SAFE_INTEGER <LayoutGridIcon className="size-4" />
? 'auto' <Trans>Grid</Trans>
: cell.column.getSize(), </DropdownMenuRadioItem>
}} </DropdownMenuRadioGroup>
className={cn('overflow-hidden relative', data.length > 10 ? 'py-2' : 'py-2.5')} </div>
>
{flexRender(cell.column.columnDef.cell, cell.getContext())} <div>
</TableCell> <DropdownMenuLabel className="pt-2 px-3.5 flex items-center gap-2">
<ArrowUpDownIcon className="size-4" />
<Trans>Sort By</Trans>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<div className="px-1 pb-1">
{table.getAllColumns().map((column) => {
if (column.id === t`Actions` || !column.getCanSort()) return null
let Icon = <span className="w-6"></span>
// if current sort column, show sort direction
if (sorting[0]?.id === column.id) {
if (sorting[0]?.desc) {
Icon = <ArrowUpIcon className="me-2 size-4" />
} else {
Icon = <ArrowDownIcon className="me-2 size-4" />
}
}
return (
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault()
setSorting([{ id: column.id, desc: sorting[0]?.id === column.id && !sorting[0]?.desc }])
}}
key={column.id}
>
{Icon}
{column.id}
</DropdownMenuItem>
)
})}
</div>
</div>
<div>
<DropdownMenuLabel className="pt-2 px-3.5 flex items-center gap-2">
<EyeIcon className="size-4" />
<Trans>Visible Fields</Trans>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<div className="px-1.5 pb-1">
{table
.getAllColumns()
.filter((column) => column.getCanHide())
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
onSelect={(e) => e.preventDefault()}
checked={column.getIsVisible()}
onCheckedChange={(value) => column.toggleVisibility(!!value)}
>
{column.id}
</DropdownMenuCheckboxItem>
)
})}
</div>
</div>
</div>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</CardHeader>
<div className="p-6 pt-0 max-sm:py-3 max-sm:px-2">
{viewMode === "table" ? (
// table layout
<div className="rounded-md border overflow-hidden">
<Table>
<TableHeader>
{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>
))} ))}
</TableRow> </TableHeader>
)) <TableBody>
) : ( {table.getRowModel().rows?.length ? (
<TableRow> table.getRowModel().rows.map((row) => (
<TableCell colSpan={columns.length} className="h-24 text-center"> <TableRow
No systems found key={row.original.id}
</TableCell> data-state={row.getIsSelected() && "selected"}
</TableRow> className={cn("cursor-pointer transition-opacity", {
)} "opacity-50": row.original.status === "paused",
</TableBody> })}
</Table> onClick={(e) => {
</div> const target = e.target as HTMLElement
if (!target.closest("[data-nolink]") && e.currentTarget.contains(target)) {
navigate(`/system/${encodeURIComponent(row.original.name)}`)
}
}}
>
{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>
</TableRow>
)}
</TableBody>
</Table>
</div>
) : (
// grid layout
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => {
const system = row.original
const { status } = system
return (
<Card
key={system.id}
className={cn(
"cursor-pointer hover:shadow-md transition-all bg-transparent w-full dark:border-border duration-200 relative",
{
"opacity-50": status === "paused",
}
)}
>
<CardHeader className="py-1 ps-5 pe-3 bg-muted/30 border-b border-border/60">
<div className="flex items-center justify-between gap-2">
<CardTitle className="text-base tracking-normal shrink-1 text-primary/90 flex items-center min-h-10 gap-2.5 min-w-0">
<div className="flex items-center gap-2.5 min-w-0">
<IndicatorDot system={system} />
<CardTitle className="text-[.95em]/normal tracking-normal truncate text-primary/90">
{system.name}
</CardTitle>
</div>
</CardTitle>
{table.getColumn(t`Actions`)?.getIsVisible() && (
<div className="flex gap-1 flex-shrink-0 relative z-10">
<AlertsButton system={system} />
<ActionsButton system={system} />
</div>
)}
</div>
</CardHeader>
<CardContent className="space-y-2.5 text-sm px-5 pt-3.5 pb-4">
{table.getAllColumns().map((column) => {
if (!column.getIsVisible() || column.id === t`System` || column.id === t`Actions`) return null
const cell = row.getAllCells().find((cell) => cell.column.id === column.id)
if (!cell) return null
return (
<div key={column.id} className="flex items-center gap-3">
{/* @ts-ignore */}
{column.columnDef?.icon && (
// @ts-ignore
<column.columnDef.icon className="size-4 text-muted-foreground" />
)}
<div className="flex items-center gap-3 flex-1">
<span className="text-muted-foreground min-w-16">{column.id}:</span>
<div className="flex-1">{flexRender(cell.column.columnDef.cell, cell.getContext())}</div>
</div>
</div>
)
})}
</CardContent>
<Link
href={`/system/${encodeURIComponent(row.original.name)}`}
className="inset-0 absolute w-full h-full"
>
<span className="sr-only">{row.original.name}</span>
</Link>
</Card>
)
})
) : (
<div className="col-span-full text-center py-8">
<Trans>No systems found.</Trans>
</div>
)}
</div>
)}
</div>
</Card>
)
}
function IndicatorDot({ system, className }: { system: SystemRecord; className?: ClassValue }) {
className ||= {
"bg-green-500": system.status === "up",
"bg-red-500": system.status === "down",
"bg-primary/40": system.status === "paused",
"bg-yellow-500": system.status === "pending",
}
return (
<span
className={cn("flex-shrink-0 size-2 rounded-full", className)}
// style={{ marginBottom: "-1px" }}
/>
)
}
function ActionsButton({ system }: { system: SystemRecord }) {
// const [opened, setOpened] = useState(false)
const { id, status, host, name } = system
return (
<AlertDialog>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size={"icon"} data-nolink>
<span className="sr-only">
<Trans>Open menu</Trans>
</span>
<MoreHorizontalIcon className="w-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
className={cn(isReadOnlyUser() && "hidden")}
onClick={() => {
pb.collection("systems").update(id, {
status: status === "paused" ? "pending" : "paused",
})
}}
>
{status === "paused" ? (
<>
<PlayCircleIcon className="me-2.5 size-4" />
<Trans>Resume</Trans>
</>
) : (
<>
<PauseCircleIcon className="me-2.5 size-4" />
<Trans>Pause</Trans>
</>
)}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => copyToClipboard(host)}>
<CopyIcon className="me-2.5 size-4" />
<Trans>Copy host</Trans>
</DropdownMenuItem>
<DropdownMenuSeparator className={cn(isReadOnlyUser() && "hidden")} />
<AlertDialogTrigger asChild>
<DropdownMenuItem className={cn(isReadOnlyUser() && "hidden")}>
<Trash2Icon className="me-2.5 size-4" />
<Trans>Delete</Trans>
</DropdownMenuItem>
</AlertDialogTrigger>
</DropdownMenuContent>
</DropdownMenu>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
<Trans>Are you sure you want to delete {name}?</Trans>
</AlertDialogTitle>
<AlertDialogDescription>
<Trans>
This action cannot be undone. This will permanently delete all current records for {name} from the
database.
</Trans>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
<Trans>Cancel</Trans>
</AlertDialogCancel>
<AlertDialogAction
className={cn(buttonVariants({ variant: "destructive" }))}
onClick={() => pb.collection("systems").delete(id)}
>
<Trans>Continue</Trans>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
) )
} }

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/40 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

@@ -1,10 +1,10 @@
import * as React from 'react' import * as React from "react"
// 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 alertVariants = cva( // const alertVariants = cva(
// "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", // "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: { // variants: {
// variant: { // variant: {
@@ -29,31 +29,26 @@ const Alert = React.forwardRef<
ref={ref} ref={ref}
role="alert" role="alert"
className={cn( className={cn(
'relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground bg-background text-foreground', "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 className
)} )}
{...props} {...props}
/> />
)) ))
Alert.displayName = 'Alert' Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>( const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => ( ({ className, ...props }, ref) => (
<h5 <h5 ref={ref} className={cn("mb-1 -mt-0.5 font-medium leading-tight tracking-tight", className)} {...props} />
ref={ref}
className={cn('mb-1 -mt-0.5 font-medium leading-tight tracking-tight', className)}
{...props}
/>
) )
) )
AlertTitle.displayName = 'AlertTitle' AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef< const AlertDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
HTMLParagraphElement, ({ className, ...props }, ref) => (
React.HTMLAttributes<HTMLParagraphElement> <div ref={ref} className={cn("text-sm [&_p]:leading-relaxed", className)} {...props} />
>(({ className, ...props }, ref) => ( )
<div ref={ref} className={cn('text-sm [&_p]:leading-relaxed', className)} {...props} /> )
)) AlertDescription.displayName = "AlertDescription"
AlertDescription.displayName = 'AlertDescription'
export { Alert, AlertTitle, 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 bg-background hover:bg-accent/70 dark:hover:bg-accent/50 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,44 @@ 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
React.HTMLAttributes<HTMLDivElement> ref={ref}
>(({ className, ...props }, ref) => ( className={cn("rounded-lg border border-border/60 bg-card text-card-foreground shadow-sm", className)}
<div {...props}
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,20 +1,17 @@
import * as React from 'react' import * as React from "react"
import * as RechartsPrimitive from 'recharts' import * as RechartsPrimitive from "recharts"
import { chartTimeData, cn } from '@/lib/utils' import { chartTimeData, cn } from "@/lib/utils"
import { ChartData } from '@/types' 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 = {
@@ -35,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 }}>
@@ -60,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)
@@ -94,9 +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
indicator?: 'line' | 'dot' | 'dashed' indicator?: "line" | "dot" | "dashed"
nameKey?: string nameKey?: string
labelKey?: string labelKey?: string
unit?: string unit?: string
@@ -109,7 +106,7 @@ const ChartTooltipContent = React.forwardRef<
active, active,
payload, payload,
className, className,
indicator = 'line', indicator = "line",
hideLabel = false, hideLabel = false,
label, label,
labelFormatter, labelFormatter,
@@ -144,21 +141,19 @@ const ChartTooltipContent = React.forwardRef<
} }
const [item] = payload const [item] = payload
const key = `${labelKey || 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) {
@@ -172,14 +167,14 @@ const ChartTooltipContent = React.forwardRef<
<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 ? (
@@ -199,41 +194,35 @@ const ChartTooltipContent = React.forwardRef<
<itemConfig.icon /> <itemConfig.icon />
) : ( ) : (
<div <div
className={cn( className={cn("shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]", {
'shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]', "h-2.5 w-2.5": indicator === "dot",
{ "w-1": indicator === "line",
'h-2.5 w-2.5': indicator === 'dot', "w-0 border-[1.5px] border-dashed bg-transparent": indicator === "dashed",
'w-1': indicator === 'line', "my-0.5": nestLabel && indicator === "dashed",
'w-0 border-[1.5px] border-dashed bg-transparent': })}
indicator === 'dashed',
'my-0.5': nestLabel && indicator === 'dashed',
}
)}
style={ style={
{ {
'--color-bg': indicatorColor, "--color-bg": indicatorColor,
'--color-border': indicatorColor, "--color-border": indicatorColor,
} as React.CSSProperties } 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>
@@ -247,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) {
@@ -269,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
)} )}
> >
@@ -283,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 ? (
@@ -304,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
} }
@@ -345,7 +334,7 @@ const xAxis = function ({ domain, ticks, chartTime }: ChartData) {
allowDataOverflow allowDataOverflow
type="number" type="number"
scale="time" scale="time"
minTickGap={15} minTickGap={12}
tickMargin={8} tickMargin={8}
axisLine={false} axisLine={false}
tickFormatter={chartTimeData[chartTime].format} tickFormatter={chartTimeData[chartTime].format}

View File

@@ -1,8 +1,8 @@
import * as React from 'react' import * as React from "react"
import * as CheckboxPrimitive from '@radix-ui/react-checkbox' import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from 'lucide-react' import { Check } from "lucide-react"
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef< const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>, React.ElementRef<typeof CheckboxPrimitive.Root>,
@@ -11,12 +11,12 @@ const Checkbox = React.forwardRef<
<CheckboxPrimitive.Root <CheckboxPrimitive.Root
ref={ref} ref={ref}
className={cn( 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', "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 className
)} )}
{...props} {...props}
> >
<CheckboxPrimitive.Indicator className={cn('flex items-center justify-center text-current')}> <CheckboxPrimitive.Indicator className={cn("flex items-center justify-center text-current")}>
<Check className="h-4 w-4" /> <Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator> </CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root> </CheckboxPrimitive.Root>

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/40 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>) {
@@ -49,14 +49,7 @@ export function ChartMax(props: SVGProps<SVGSVGElement>) {
// Lucide https://github.com/lucide-icons/lucide (not in package for some reason) // Lucide https://github.com/lucide-icons/lucide (not in package for some reason)
export function EthernetIcon(props: SVGProps<SVGSVGElement>) { export function EthernetIcon(props: SVGProps<SVGSVGElement>) {
return ( return (
<svg <svg fill="none" stroke="currentColor" strokeLinecap="round" strokeWidth="2" viewBox="0 0 24 24" {...props}>
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" /> <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> </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 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 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 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,77 @@
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) => (
React.HTMLAttributes<HTMLTableSectionElement> <thead ref={ref} className={cn("bg-muted/30 [&_tr]:border-b", className)} {...props} />
>(({ className, ...props }, ref) => ( )
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} /> )
)) TableHeader.displayName = "TableHeader"
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} ref={ref}
className={cn( className={cn(
'border-b hover:bg-muted/40 dark:hover:bg-muted/30 data-[state=selected]:bg-muted', "border-b border-border/60 hover:bg-muted/40 dark:hover:bg-muted/20 data-[state=selected]:bg-muted",
className className
)} )}
{...props} {...props}
/> />
) )
) )
TableRow.displayName = 'TableRow' TableRow.displayName = "TableRow"
const TableHead = React.forwardRef< const TableHead = React.forwardRef<HTMLTableCellElement, React.ThHTMLAttributes<HTMLTableCellElement>>(
HTMLTableCellElement, ({ className, ...props }, ref) => (
React.ThHTMLAttributes<HTMLTableCellElement> <th
>(({ className, ...props }, ref) => ( ref={ref}
<th className={cn(
ref={ref} "h-12 px-4 text-start align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pe-0",
className={cn( className
'h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0', )}
className {...props}
)} />
{...props} )
/> )
)) TableHead.displayName = "TableHead"
TableHead.displayName = 'TableHead'
const TableCell = React.forwardRef< const TableCell = React.forwardRef<HTMLTableCellElement, React.TdHTMLAttributes<HTMLTableCellElement>>(
HTMLTableCellElement, ({ className, ...props }, ref) => (
React.TdHTMLAttributes<HTMLTableCellElement> <td ref={ref} className={cn("p-4 align-middle [&:has([role=checkbox])]:pe-0", className)} {...props} />
>(({ className, ...props }, ref) => ( )
<td )
ref={ref} TableCell.displayName = "TableCell"
className={cn('p-4 align-middle [&:has([role=checkbox])]:pr-0', className)}
{...props}
/>
))
TableCell.displayName = 'TableCell'
const TableCaption = React.forwardRef< const TableCaption = React.forwardRef<HTMLTableCaptionElement, React.HTMLAttributes<HTMLTableCaptionElement>>(
HTMLTableCaptionElement, ({ className, ...props }, ref) => (
React.HTMLAttributes<HTMLTableCaptionElement> <caption ref={ref} className={cn("mt-4 text-sm text-muted-foreground", className)} {...props} />
>(({ className, ...props }, ref) => ( )
<caption ref={ref} className={cn('mt-4 text-sm text-muted-foreground', className)} {...props} /> )
)) TableCaption.displayName = "TableCaption"
TableCaption.displayName = 'TableCaption'
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption } export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption }

View File

@@ -6,47 +6,47 @@ import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef< const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>, React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<TabsPrimitive.List <TabsPrimitive.List
ref={ref} ref={ref}
className={cn( className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground", "inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className className
)} )}
{...props} {...props}
/> />
)) ))
TabsList.displayName = TabsPrimitive.List.displayName TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef< const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>, React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger> React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger <TabsPrimitive.Trigger
ref={ref} ref={ref}
className={cn( 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", "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 className
)} )}
{...props} {...props}
/> />
)) ))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef< const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>, React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content> React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<TabsPrimitive.Content <TabsPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", "mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className className
)} )}
{...props} {...props}
/> />
)) ))
TabsContent.displayName = TabsPrimitive.Content.displayName TabsContent.displayName = TabsPrimitive.Content.displayName

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

@@ -1,9 +1,9 @@
import * as React from 'react' import * as React from "react"
import * as ToastPrimitives from '@radix-ui/react-toast' import * as ToastPrimitives from "@radix-ui/react-toast"
import { cva, type VariantProps } from 'class-variance-authority' import { cva, type VariantProps } from "class-variance-authority"
import { X } from 'lucide-react' import { X } from "lucide-react"
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils"
const ToastProvider = ToastPrimitives.Provider const ToastProvider = ToastPrimitives.Provider
@@ -14,7 +14,7 @@ const ToastViewport = React.forwardRef<
<ToastPrimitives.Viewport <ToastPrimitives.Viewport
ref={ref} ref={ref}
className={cn( className={cn(
'fixed top-0 z-[100] flex max-h-dvh w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]', "fixed top-0 z-[100] flex max-h-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}
@@ -23,17 +23,16 @@ const ToastViewport = React.forwardRef<
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",
}, },
} }
) )
@@ -42,13 +41,7 @@ const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>, React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & VariantProps<typeof toastVariants> React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => { >(({ className, variant, ...props }, ref) => {
return ( 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
@@ -59,7 +52,7 @@ const ToastAction = React.forwardRef<
<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}
@@ -74,7 +67,7 @@ const ToastClose = React.forwardRef<
<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=""
@@ -89,7 +82,7 @@ const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>, React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title> React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<ToastPrimitives.Title ref={ref} className={cn('text-sm font-semibold', className)} {...props} /> <ToastPrimitives.Title ref={ref} className={cn("text-sm font-semibold", className)} {...props} />
)) ))
ToastTitle.displayName = ToastPrimitives.Title.displayName ToastTitle.displayName = ToastPrimitives.Title.displayName
@@ -97,11 +90,7 @@ 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

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

Some files were not shown because too many files have changed in this diff Show More