Compare commits

...

432 Commits

Author SHA1 Message Date
Henry Dollman
40cc1a875e release 0.9.1 2025-01-05 18:03:03 -05:00
Henry Dollman
1ac165d7d3 include stats in error log when encoding stats fails 2025-01-05 17:58:38 -05:00
Henry Dollman
9619e6cf89 update go dependencies 2025-01-05 17:57:43 -05:00
Henry Dollman
fc31cefd4c update initial settings with minimum log level and use new SetRandomPassword method 2025-01-05 17:56:42 -05:00
Henry Dollman
5fd9010b39 add slovenian and danish 2025-01-05 17:43:19 -05:00
hank
c2e3dd5ab1 New Crowdin updates (#352)
* New translations en.po (French)

* New translations en.po (Polish)

* New translations en.po (Spanish)

* New translations en.po (Danish)

Co-authored-by: Morten Johansen <mj@emsg.net>
Co-authored-by: Kamil <32385799+nkkfs@users.noreply.github.com>
2025-01-05 17:13:25 -05:00
Henry Dollman
8e531e6b3c fix: handle duplicate GPU names (#361) 2025-01-05 16:40:22 -05:00
Henry Dollman
527e6b57d5 remove CGO_ENABLED from build commands in Makefile 2025-01-05 12:27:53 -05:00
Chuangbo Li
245fa538e9 chore: upgrade gopsutil to fix memory leak on ARM Mac (#367) 2025-01-04 18:16:57 -05:00
Henry Dollman
e14a851398 fix: armv6l detection in install-agent script (#364) 2025-01-02 17:10:21 -05:00
Chuangbo Li
0c9bc47a3a refactor: simplify startSystemUpdateTicker using time.Tick (#347)
According to the Go 1.23 documentation, the garbage collector can now recover unreferenced tickers created with time.Tick, making time.NewTicker method unnecessary in most cases.

Reference:

* time.Tick: https://pkg.go.dev/time#Tick
2024-12-28 17:07:32 -05:00
Chuangbo Li
19b4477a75 fix: json.Unmarshal not resetting struct fields (#345)
The json.Unmarshal function was not properly resetting the Stats struct fields between iterations in the loop. This caused incorrect values to be retained from previous iterations, leading to unexpected behavior.

This commit fixes the issue by zero the struct in each loop iteration, ensures that each unmarshaling operation starts with a clean struct, preventing value carryover.
2024-12-28 17:06:37 -05:00
hank
558d051c42 New Crowdin updates (#330)
* New translations en.po (Slovenian)
* New translations en.po (Swedish)
* New translations en.po (Ukrainian)

Co-authored-by: thehijacker <thehijacker@gmail.com>
Co-authored-by: stanol <stanol777@gmail.com>
2024-12-27 11:11:05 -05:00
Arsierl
9c8528bae1 Update install-agent.sh script to support Alpine (OpenRC) (#320)
* Partial amendment

* Update install-agent.sh

* Update install-agent.sh

Fix if is_alpine function with user creation and permission issues

* Update install-agent.sh

Adding an Uninstallation Process to Alpine
Using deluser instead of userdel in Alpine
2024-12-18 17:41:15 -05:00
Henry Dollman
229ef19376 update readme 2024-12-17 20:00:16 -05:00
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
Henry Dollman
a5f9e2615c release 0.6.2 2024-10-23 18:39:15 -04:00
Henry Dollman
4a78ce1b16 skip temperatures code if sensors whitelist is set to empty string 2024-10-23 18:37:38 -04:00
Henry Dollman
f8f1e01cb4 add settings page and api route for generating config.yml 2024-10-23 18:30:24 -04:00
Henry Dollman
c7463f2b9f limit collection lookups and other small refactoring
* adds error handling for collection lookup (#216)
2024-10-23 13:10:39 -04:00
Henry Dollman
a975466fc7 add declarative system management with config.yml (#70, #206) 2024-10-22 18:46:52 -04:00
Henry Dollman
539c0ccb1d retry failed containers separately so we can run them in parallel (#58) 2024-10-21 17:00:13 -04:00
Henry Dollman
5f4dcb09ea release 0.6.1 2024-10-19 20:06:51 -04:00
Henry Dollman
6de5dce176 improve useeffects for favicon and system subscription 2024-10-19 19:57:35 -04:00
Henry Dollman
b5c158d1b3 update debug logs 2024-10-19 18:12:25 -04:00
Henry Dollman
7f01d1ec7e memoize alertsbutton and use string for global system alerts set 2024-10-19 18:08:02 -04:00
Henry Dollman
8bf7a0e1d6 add DOCKER_TIMEOUT env var 2024-10-19 16:33:33 -04:00
Henry Dollman
140fd93ec9 add ability to set alerts for all systems 2024-10-19 15:14:28 -04:00
Henry Dollman
bdcb34c989 update go deps 2024-10-19 15:05:27 -04:00
Henry Dollman
aaaa86b147 update js deps 2024-10-19 14:51:03 -04:00
Henry Dollman
6e9b84c6c7 use goroutines to send alerts 2024-10-19 14:28:41 -04:00
Henry Dollman
cce241caa4 update h-screen to use svh / dvh 2024-10-18 13:15:35 -04:00
Henry Dollman
1e9787c4d7 downgrade @nanostores/router to support Safari 16 (closes #210) 2024-10-18 13:11:26 -04:00
Henry Dollman
71aa9946f5 use one x axis component for all charts 2024-10-17 16:05:20 -04:00
Henry Dollman
12239808fc small updates to alert display 2024-10-17 15:46:09 -04:00
Henry Dollman
94e9d4f270 set alert to inactive if value or minute is updated 2024-10-17 11:12:04 -04:00
Yukino16
34a8053967 fix: path escape for system name in alert message (#209) 2024-10-17 10:31:36 -04:00
Henry Dollman
ee92e338cb update debug log locations 2024-10-16 18:12:43 -04:00
Henry Dollman
1a3ad04e03 release 0.6.0 2024-10-16 18:02:53 -04:00
Henry Dollman
9c061774a3 update alert notification titles 2024-10-16 18:02:38 -04:00
Henry Dollman
3336b0a7d9 fix chart null values connecting after downtime 2024-10-16 17:46:42 -04:00
Henry Dollman
f034eed431 update js packages 2024-10-16 17:39:56 -04:00
Henry Dollman
6b6d3fabc0 change disk alert to monitor usage of any disk, not only root 2024-10-16 17:21:05 -04:00
Henry Dollman
59d541dd1d fix edge case overwriting extra filesystem with root io fallback 2024-10-16 15:26:12 -04:00
Henry Dollman
abff85d61e alerts web ui refactoring 2024-10-16 13:48:36 -04:00
Henry Dollman
02641ec007 time averaged thresholds for alerts 2024-10-15 21:59:53 -04:00
Henry Dollman
92179cbbb2 show active alerts in dashboard 2024-10-15 21:59:05 -04:00
Henry Dollman
299152413a small longer record creation refactoring 2024-10-15 19:54:46 -04:00
Henry Dollman
703a3c41c9 empty info for systems that are paused 2024-10-15 18:31:03 -04:00
Henry Dollman
31d1153916 invert sorting in systems table 2024-10-15 18:20:38 -04:00
Henry Dollman
c1577d3ba5 fix bottom spacing 2024-10-14 19:03:02 -04:00
Henry Dollman
c4400eb0a3 refactor container-chart.tsx 2024-10-14 18:48:19 -04:00
Henry Dollman
a57498f8f7 update alerts dialog and icon imports 2024-10-14 17:53:49 -04:00
Henry Dollman
1b0dffc1ab combine docker charts and chart data 2024-10-14 17:25:21 -04:00
Henry Dollman
bea37d62b4 simplify container chart data and reduce rerenders 2024-10-14 11:48:33 -04:00
Henry Dollman
d53b6be5b9 update collections 2024-10-12 18:28:32 -04:00
Henry Dollman
6c31263e60 add bandwidth alerts 2024-10-12 17:22:25 -04:00
Henry Dollman
b464fa5b3f add net column to systems table 2024-10-12 16:37:09 -04:00
Henry Dollman
c0a3bbeefc change twoDecimalString to use customizable digits 2024-10-12 16:03:51 -04:00
Henry Dollman
10d348c052 temperature alerts 2024-10-12 14:57:46 -04:00
Henry Dollman
6cf6661f2e raise docker client timeout to 8 seconds if version <= 24 2024-10-12 12:24:53 -04:00
Henry Dollman
23ab1208cd release 0.5.3 2024-10-10 18:36:28 -04:00
Henry Dollman
5b0fac429b move update functions to agent / hub packages 2024-10-10 18:36:01 -04:00
Henry Dollman
efca56ceca add temp debug logs to troubleshoot #196 2024-10-10 18:28:24 -04:00
Henry Dollman
64f0a23969 move fsStats creation to NewAgent function 2024-10-10 18:18:57 -04:00
Henry Dollman
4245da7792 Add caching to the web app to reduce requests for the same data 2024-10-10 18:11:36 -04:00
Henry Dollman
cedf80a869 add max 1m values for cpu, bandwidth, disk io
* removes unused things from chart.tsx
* updates y axis width only if it grows
* add generic area chart component and remove individual cpu, bandwidth, disk io charts
2024-10-10 15:11:48 -04:00
Henry Dollman
76cea9d3c3 increase docker client timeout to 2100ms 2024-10-08 19:17:03 -04:00
Henry Dollman
10ef430826 small refactor in CreateLongerRecords 2024-10-08 12:42:02 -04:00
Henry Dollman
d672017af0 add peak values for cpu and net 2024-10-07 19:20:53 -04:00
Henry Dollman
7a82571921 add time back to memory chart tooltip 2024-10-07 18:19:28 -04:00
Henry Dollman
e81f8ac387 add zfs arc to longer records 2024-10-07 11:45:09 -04:00
Henry Dollman
05faa88e6a release 0.5.2 2024-10-05 18:30:10 -04:00
Henry Dollman
73aae62c2e add ZFS ARC memory accounting 2024-10-05 18:07:42 -04:00
Henry Dollman
af4877ca30 add MEM_CALC env var 2024-10-05 15:29:27 -04:00
Henry Dollman
c407fe9af0 exclude sensor if temp <=0 || temp >= 200 2024-10-05 11:14:20 -04:00
hank
13c9497951 Merge pull request #199 from Bot-wxt1221/main
fix(package-lock.json): fix for nixpkgs
2024-10-04 11:06:58 -04:00
wxt
4274096645 fix(package-lock.json): fix for nixpkgs 2024-10-03 16:18:31 +08:00
Henry Dollman
a213b70a1c release 0.5.1 2024-10-02 19:58:44 -04:00
Henry Dollman
66cc0a4b24 log stats on startup if log level is debug 2024-10-02 19:58:02 -04:00
Henry Dollman
f051f6a5f8 add dockerManager / fix for Docker 24 and older
* dockerManager now handles all docker api interaction and container metrics tracking
* sets unlimited concurrency for docker 24 and older
2024-10-02 19:45:26 -04:00
Henry Dollman
b9f142c28c update go deps (gopsutil v4.24.9) 2024-10-02 19:11:13 -04:00
Henry Dollman
45e1283b83 move system.Info to Agent struct
* cleaner to store entire info struct rather than separate properties for unchanging values
2024-10-02 12:34:42 -04:00
Henry Dollman
94cb5f2798 fix uptime hours pluralization 2024-10-02 12:33:31 -04:00
Henry Dollman
2883467b2b update docker image workflow
- change tag to "*v"
- remove cache
2024-10-02 12:32:19 -04:00
Lindemberg Barbosa
0c77190161 fix: readme link for Monitoring additional disks, partitions, or remote mounts (#195) 2024-10-02 10:40:12 -04:00
Henry Dollman
8d4d072343 fix readme backticks for telnet command 2024-09-30 15:45:46 -04:00
Henry Dollman
d6e0daf52a update js deps and add package-lock.json (#192)
- replaces use-is-in-viewport package with lib/use-intersection-observer.ts due to npm dependency conflict
2024-09-30 14:37:59 -04:00
Henry Dollman
22e9ede766 release 0.5.0 2024-09-29 16:37:09 -04:00
Henry Dollman
9ab359d3cf add SENSORS env var 2024-09-29 16:36:32 -04:00
Henry Dollman
5447ccad47 make sure temp chart starts at 0 degrees 2024-09-29 16:33:23 -04:00
Henry Dollman
3e51d79c37 update memory chart colors 2024-09-29 15:14:55 -04:00
Henry Dollman
0996d60224 update go deps 2024-09-29 14:22:21 -04:00
Henry Dollman
7a5ec067f5 refactor records package 2024-09-29 13:36:03 -04:00
Henry Dollman
98563d643d refactor alerts and move managers to hub struct
- resource alerts called from updateSystem to avoid needing to pull from db after update
2024-09-29 13:35:38 -04:00
Henry Dollman
268e364bd4 update MemoryStats type 2024-09-29 12:36:19 -04:00
Henry Dollman
dd84a9fd35 remove semaphore and limit docker host connections to 10 2024-09-29 12:30:30 -04:00
Henry Dollman
2f4e537f72 change containerStatsMutex to a RWMutex 2024-09-28 19:13:24 -04:00
Henry Dollman
9637363cf3 combine container.Stats and container.PrevContainerStats 2024-09-28 18:51:46 -04:00
Henry Dollman
73d0dd25ec agent refactoring - create agent/docker.go, agent/system.go 2024-09-28 17:49:04 -04:00
Henry Dollman
2ecf5572ba remove addr, pubKey fields from agent struct 2024-09-28 16:48:55 -04:00
Henry Dollman
5e97167ee0 Fetch kernel, hostname, cpu at start rather than every run 2024-09-28 16:38:52 -04:00
Henry Dollman
1a4862ecd9 remove containerStats mutex and add stats by index 2024-09-27 19:06:37 -04:00
Henry Dollman
6235d15fa2 tweak colors in memory chart 2024-09-27 19:03:46 -04:00
Henry Dollman
4694642674 add apiContainerList variable to reduce memory allocations 2024-09-27 16:10:31 -04:00
Henry Dollman
56c0b86025 rename variables for clarity 2024-09-27 14:56:53 -04:00
Henry Dollman
82e3f3c7c1 Fix temperature sensors not reporting if any sensor lacks valid data (#167) 2024-09-27 13:10:13 -04:00
Henry Dollman
38a9c535b8 change x axis time format for 1 week chart and add key 2024-09-27 13:08:48 -04:00
Henry Dollman
34c83e7c17 hide temp sensors chart legend if more than 11 sensors 2024-09-27 13:08:11 -04:00
Henry Dollman
fe5732d75a replace icon and remove unneeded usememo in system.tsx 2024-09-27 13:06:31 -04:00
Henry Dollman
cc32b50d82 add agent.debug and comments 2024-09-27 12:17:19 -04:00
Henry Dollman
764e043e83 add slog and LOG_LEVEL to agent 2024-09-26 20:07:35 -04:00
Henry Dollman
cec9339f6d allow monitoring remote mounts (#178) and handle I/O edge case (#183) 2024-09-26 18:01:52 -04:00
Henry Dollman
f96f04f876 change swap chart placement 2024-09-26 16:55:31 -04:00
Henry Dollman
06b1c2200b reorganize agent package 2024-09-26 15:08:26 -04:00
Henry Dollman
e88e2bf3dc agent binary - show correct cores in lxc 2024-09-26 15:00:48 -04:00
Henry Dollman
8621a45383 remove unnecessary buffer pool 2024-09-26 14:57:01 -04:00
Henry Dollman
f2ddee9216 Fix typo in extra disk I/O chart (closes #187) 2024-09-26 10:50:23 -04:00
Henry Dollman
f350d61ee2 add CSP env var to set a custom Content-Security-Policy header value 2024-09-24 15:22:47 -04:00
Henry Dollman
2d670c585d optimize deletion of old records 2024-09-23 17:17:30 -04:00
Henry Dollman
55d1c00903 use same disk I/O chart component for root and extra fs 2024-09-23 13:56:15 -04:00
Henry Dollman
78a9086b55 use same disk usage chart component for root and extra fs 2024-09-23 13:42:44 -04:00
Henry Dollman
4ee169fea5 display TB in disk usage charts if value >= 1 TB 2024-09-23 13:34:40 -04:00
Henry Dollman
a286bed54c update SystemInfo ts type 2024-09-18 13:02:53 -04:00
Henry Dollman
314cee081a system info refactoring 2024-09-18 12:47:06 -04:00
Henry Dollman
e287124632 show swap chart only if swap is in use 2024-09-18 12:46:15 -04:00
Stavros
9cccefd3fa feat: add kernel version text (#170) 2024-09-17 13:43:58 -04:00
Henry Dollman
ec95f63806 limit fields when fetching server list 2024-09-16 18:40:11 -04:00
Henry Dollman
812fe20df7 fix: update settings when visiting a settings page directly 2024-09-16 16:51:23 -04:00
Henry Dollman
ddfcbc546b release 0.4.0 2024-09-16 14:05:00 -04:00
Henry Dollman
c74d5496af update go deps 2024-09-16 14:04:48 -04:00
Henry Dollman
060846d70a update command palette items 2024-09-16 13:38:03 -04:00
Henry Dollman
e03e2b8d67 fix wrapping of y axis chart labels 2024-09-16 13:14:23 -04:00
Henry Dollman
c46879694d allow FILESYSTEM env var to override root usage stats 2024-09-15 18:16:36 -04:00
Henry Dollman
61a68e5be1 refactor findMaxReadsDevice to use disk.IOCounters 2024-09-15 16:29:55 -04:00
Henry Dollman
bd43a2a2c2 focus on input when new webhook added 2024-09-15 11:31:44 -04:00
Henry Dollman
3aeca6af2f add SYS_SENSORS env var 2024-09-14 18:46:16 -04:00
Henry Dollman
3e95269a7c add hostname to system info 2024-09-14 18:09:38 -04:00
Henry Dollman
53b02dd55f change extra disk charts to follow grid layout selection 2024-09-14 17:40:47 -04:00
Henry Dollman
43ba9d5c6a change NIC env var to NICS to support multiple interfaces 2024-09-14 17:30:42 -04:00
Henry Dollman
1cb4a711c3 add NIC env var 2024-09-14 16:47:12 -04:00
Henry Dollman
aef99c3bd9 update package comments 2024-09-14 16:46:48 -04:00
Henry Dollman
138cbc13d6 rename package user to users 2024-09-14 16:44:53 -04:00
Henry Dollman
62d5ae8236 clean up / small refactoring 2024-09-14 15:46:42 -04:00
Henry Dollman
8ce605d65e create UserManager 2024-09-14 15:45:57 -04:00
Henry Dollman
c8743201a2 allow creation of 10m record if nine 1m records 2024-09-14 15:27:42 -04:00
Henry Dollman
f16e22e521 updates to settings page and alerts 2024-09-13 18:19:12 -04:00
Henry Dollman
9710d0d2f1 shoutrrr alerts / settings page 2024-09-12 19:39:27 -04:00
Henry Dollman
2889d151ea alert / settings page updates 2024-09-11 17:47:36 -04:00
Henry Dollman
ce6e887d1b progress on settings / alerts 2024-09-11 15:50:15 -04:00
Henry Dollman
b4cf5bb1c0 alerts updates 2024-09-10 19:38:32 -04:00
Henry Dollman
9bc7773607 further progress on settings / alerts 2024-09-10 19:03:08 -04:00
Henry Dollman
3362a3d1cf progress on settings page 2024-09-09 20:00:09 -04:00
Henry Dollman
3b13fadde2 rm shoutrrr config info from readme 2024-09-09 13:37:09 -04:00
parnic
99d79f7d2d Add support for alerts via shoutrrr (#145)
* Add support for alerts via shoutrrr

This provides users the ability to use a wide variety of notification platforms instead of just email. If there's a problem sending a notification via shoutrrr, an error is logged and email is attempted as a fallback.

Since this uses Viper, users can set a notification type and URL via either a config file or environment variable. In the beszel_data folder (where the sqlite dbs reside), create an alerts.env file to set values that way.

Values:
* NOTIFICATION_TYPE
  * If this is `shoutrrr`, then the shoutrrr library is used. Any other value, including not being set, uses the fallback email behavior.
* NOTIFICATION_URL
  * If NOTIFICATION_TYPE is shoutrrr, this is the URL given to shoutrrr to send the alert. See list of supported services: https://containrrr.dev/shoutrrr/services/overview/

Note: there's currently a bug in viper v1.18.2+ where environment variable overrides aren't functioning when no config file exists, so this library should remain pinned to 1.18.1 until that's fixed. See: https://github.com/spf13/viper/issues/1895

* Update documentation

* Log shoutrrr URL instead of unused "to" var
2024-09-09 13:34:43 -04:00
hank
1fb23ff673 Create SECURITY.md 2024-09-04 16:53:13 -04:00
Henry Dollman
29529d1a84 add EXTRA_FILESYSTEMS to systemd script and example 2024-09-04 16:22:01 -04:00
Bart
9f84629b92 Enhanced README.md (#156)
* Enhanced README.md

* Changed back to 'Sentence case'

* Changed text to reflect comment
2024-09-04 15:29:14 -04:00
Henry Dollman
d2284c3fed release 0.3.0 2024-09-03 18:37:02 -04:00
Henry Dollman
eb420bef3a update go deps 2024-09-03 18:31:01 -04:00
Henry Dollman
9cf6c167b0 don't reset container filter when changing chart time 2024-09-03 18:26:28 -04:00
Henry Dollman
fbc7f79660 add instructions for monitoring additional disks / partitions 2024-09-03 18:23:09 -04:00
Henry Dollman
37170f2bdb refactor network io stats collection (ref #150) 2024-09-03 18:10:45 -04:00
Henry Dollman
af4c05e692 standardize chart values to two decimals 2024-09-03 16:49:12 -04:00
Henry Dollman
202a506485 add dialog for copy to clipboard fallback (fixes #152) 2024-09-02 19:37:44 -04:00
Henry Dollman
aa3866c8ed better alignment for readonly user account menu 2024-09-02 17:32:59 -04:00
Henry Dollman
f9c0d0b89d don't cache mail client (fixes #149) 2024-09-02 16:34:53 -04:00
Henry Dollman
ec5b1a833d extra fs charts and filter bar for container charts 2024-09-02 16:13:07 -04:00
Henry Dollman
1cfda8fb9f add icons to theme menu 2024-09-02 15:56:57 -04:00
Henry Dollman
2168db6ebd remove toggle component 2024-09-02 15:56:14 -04:00
Henry Dollman
e64ef49e97 update js deps / exclude package-lock.json 2024-09-02 15:49:41 -04:00
Henry Dollman
54e0240dd8 remove external fonts 2024-09-02 12:12:52 -04:00
Henry Dollman
05f52ad15a update readme 2024-09-01 19:17:17 -04:00
Henry Dollman
8ffb3a0cc8 update agent docker-compose.yml 2024-09-01 18:50:38 -04:00
Henry Dollman
953d7cac1e use GOGC=75 for docker images 2024-09-01 18:25:40 -04:00
Henry Dollman
1cfd3cdd30 add support for multiple disks 2024-09-01 18:23:57 -04:00
Henry Dollman
b4a3cb9ce6 add timeout to ssh session creation to avoid hanging 2024-08-30 19:06:16 -04:00
Henry Dollman
7a6fbc8346 add toggle for chart grid layout 2024-08-25 18:19:47 -04:00
Henry Dollman
76cffb16de formatting 2024-08-25 18:19:03 -04:00
Henry Dollman
13f7d016e6 fix blurry command pallette on some setups (closes #137) 2024-08-25 16:20:58 -04:00
Henry Dollman
7a8dccfc97 use css color-scheme instead of custom scrollbar styles 2024-08-25 16:19:31 -04:00
Stavros
68824935e9 feat: style scrollbar (#135)
* feat: style scrollbar

* refactor(index.css): increase the border radius a bit

* chore: revert prettier styling
2024-08-25 13:05:55 -04:00
Henry Dollman
42e2e3463e release 0.2.0 2024-08-22 15:43:44 -04:00
Henry Dollman
2ef54bae36 add linux/arm/v7 docker images 2024-08-22 15:29:14 -04:00
Henry Dollman
6766a8120a update go dependencies + hub dockerfile 2024-08-22 15:07:22 -04:00
Henry Dollman
9fcf1e7164 use grid layout for charts 2024-08-22 14:10:03 -04:00
Henry Dollman
130c9bd696 add temperature chart 2024-08-21 22:14:56 -04:00
Henry Dollman
88db920ebe add icon to time select 2024-08-21 15:36:46 -04:00
Henry Dollman
5278805d79 web ui design updates 2024-08-21 15:06:33 -04:00
Henry Dollman
55863f849c get rid of updatedSystem store 2024-08-21 12:50:12 -04:00
Henry Dollman
0590facc89 rerender table if hub version loaded after first render 2024-08-20 18:02:29 -04:00
Henry Dollman
3ff339ba1d change agent check for invalid data to use memory usage rather than networks 2024-08-20 17:40:27 -04:00
Henry Dollman
e92a1d8a4d log sent alert emails in pocketbase 2024-08-20 17:11:16 -04:00
Henry Dollman
2590f5612c add null values to create gaps in chart if expected interval is exceeded. closes #100 2024-08-20 17:10:32 -04:00
Henry Dollman
112685bdf4 Merge branch 'theRealBassist-fix-container-lifetime-check' 2024-08-20 14:23:50 -04:00
Henry Dollman
7e1455161d update container status check 2024-08-20 14:23:24 -04:00
Henry Dollman
be98b77634 Merge branch 'fix-container-lifetime-check' of https://github.com/theRealBassist/beszel into theRealBassist-fix-container-lifetime-check 2024-08-20 14:12:17 -04:00
Henry Dollman
cb75923f9f Merge branch 'theRealBassist-add-agent-version-info' 2024-08-20 14:04:37 -04:00
Henry Dollman
87ab4961fd move gh button + update agent display in table 2024-08-20 14:04:09 -04:00
Henry Dollman
d053f16058 add agent version to getkey route 2024-08-20 14:00:15 -04:00
theRealBassist
19272c05bf Adds a version listing in the hub for each agent. 2024-08-19 17:45:15 -04:00
theRealBassist
e56c5f30e0 Fixes edge cases where container health message does not have expected suffix 2024-08-19 15:29:50 -04:00
Henry Dollman
7cf7b706c1 add check to catch containers in restart loop. closes #103 2024-08-19 13:01:01 -04:00
Henry Dollman
58fe9f723a fix: uri encode system url. closes #102 2024-08-19 12:13:18 -04:00
Henry Dollman
a337ece52e Merge branch 'TOomaAh-feature/change-file-structure' 2024-08-18 18:33:17 -04:00
Henry Dollman
61723a24e0 tweak opacity of non-stacked area charts 2024-08-18 18:32:15 -04:00
Henry Dollman
b7934931cf refactor hub requestJsonFromAgent 2024-08-18 18:30:44 -04:00
Henry Dollman
0566433aa1 move longer records creation to a scheduled job 2024-08-18 18:23:17 -04:00
Henry Dollman
b5607025f7 slight improvements to agent memory usage 2024-08-18 17:45:39 -04:00
Henry Dollman
683dc74cbf update workflows 2024-08-14 14:56:01 -04:00
Henry Dollman
c7e67a9b63 refactor: agent and entities 2024-08-14 14:14:41 -04:00
Henry Dollman
083da9598e refactor: hub 2024-08-14 11:28:43 -04:00
Henry Dollman
f8d2161489 refactor: alerts / emails 2024-08-12 19:13:42 -04:00
Henry Dollman
d0d4e546d9 update dockerfiles and docker workflow 2024-08-11 16:21:02 -04:00
Henry Dollman
2ac5d797d2 update readme / .gitignore 2024-08-11 16:20:29 -04:00
Henry Dollman
4105567051 remove cobra dependency for agent 2024-08-11 15:54:27 -04:00
Henry Dollman
c06eabefe0 add docker compose examples for hub / agent to supplemental 2024-08-11 15:22:30 -04:00
Henry Dollman
9d2192f323 update migrations / gitignore 2024-08-11 15:14:58 -04:00
Henry Dollman
dd55e74ec9 update goreleaser config 2024-08-11 13:57:09 -04:00
Henry Dollman
9da1e5751a move application code into beszel folder 2024-08-11 13:41:57 -04:00
TOomaAh
ea71492d13 fix migrations 2024-08-11 02:18:24 +02:00
THOMAS B
b51770a703 Merge branch 'henrygd:main' into feature/change-file-structure 2024-08-10 11:21:02 +02:00
Henry Dollman
39596b8da8 add -p flag to hub installer and run on 0.0.0.0 2024-08-09 12:17:22 -04:00
TOomaAh
932d126117 remove global variable 2024-08-09 17:58:09 +02:00
TOomaAh
034a5c21eb change agent file structure 2024-08-09 17:52:02 +02:00
TOomaAh
d840178cc0 change hub file structure 2024-08-07 23:59:02 +02:00
Henry Dollman
8a04a9bed6 release 0.1.2 2024-08-07 16:19:27 -04:00
Henry Dollman
3f692ce528 close idle connections on timeout 2024-08-07 16:12:18 -04:00
Henry Dollman
876fb6e02e refresh auth status if no systems are found 2024-08-07 15:07:22 -04:00
Henry Dollman
2eb691661c improve y axis annoyances on charts 2024-08-06 20:23:59 -04:00
Henry Dollman
f4332d69d5 adjust y axis width 2024-08-06 18:50:12 -04:00
Henry Dollman
b958e84572 fix: down systems jamming the system update queue 2024-08-06 18:15:12 -04:00
Henry Dollman
7ce6f76315 use precise number for max mem in memory chart 2024-08-06 17:36:11 -04:00
Henry Dollman
cdd10a3011 add swap usage chart 2024-08-06 16:46:25 -04:00
Henry Dollman
dcdee1d943 update deps for agent 2024-08-06 16:25:41 -04:00
Henry Dollman
f4e82ecd59 update readme 2024-08-06 15:13:06 -04:00
Henry Dollman
b8a2d0f32f use txDao in deleteOldRecords for deletion only 2024-08-06 15:09:46 -04:00
Henry Dollman
f13f0b2f8a make sure deletion of container stats is thread safe 2024-08-06 14:44:31 -04:00
Henry Dollman
fdf0ce22dc improve chart scaling + add space below docker net chart for tooltip 2024-08-05 19:17:54 -04:00
Henry Dollman
f36b0a4528 add freebsd and mips64 binaries 2024-08-05 19:04:52 -04:00
Henry Dollman
a73a01fe37 measure docker network stats per second 2024-08-05 18:58:00 -04:00
Henry Dollman
c6b9f1ab77 use promise.allsettled to stop docker chart from populating later 2024-08-04 21:51:11 -04:00
Henry Dollman
8ef30e0733 lazy load charts and disable chart animations 2024-08-04 20:14:13 -04:00
Henry Dollman
e3ed07a999 adapt y axis width in recharts 2024-08-04 16:35:12 -04:00
Henry Dollman
b05184a654 mobile style tweaks 2024-08-04 13:58:18 -04:00
Henry Dollman
2a3b228668 add docker container net stats 2024-08-04 13:26:17 -04:00
Henry Dollman
c3e3d483b0 update js deps 2024-08-04 13:19:19 -04:00
Henry Dollman
b0c6151664 simplify system chart data 2024-08-02 19:55:38 -04:00
Henry Dollman
c9196def32 update install-agent.sh to add beszel user to docker group 2024-08-02 14:59:26 -04:00
Henry Dollman
59cbaf3009 fix FromAsCasing warning 2024-08-02 13:06:49 -04:00
Henry Dollman
bc3f7257c0 update systemd guide 2024-08-01 17:49:17 -04:00
Henry Dollman
4ae65f061c Merge branch 'delta-whiplash-main' 2024-08-01 17:44:00 -04:00
Henry Dollman
0f9aa11255 update docs for systemd / reorganize supplemental directory 2024-08-01 17:40:15 -04:00
Henry Dollman
092f09b084 update linux install scripts to work with other distros 2024-08-01 16:00:19 -04:00
DeltaWhiplash
4841b95a8d Update the Readme for new install scripts 2024-08-01 15:50:38 +02:00
DeltaWhiplash
0ab9ba0614 add dependencies install for the hub script installer 2024-08-01 15:40:06 +02:00
DeltaWhiplash
d809704ab3 Add Automated hub install script for debian/ubuntu 2024-08-01 15:36:56 +02:00
DeltaWhiplash
8d71e95d0b Add Automated agent install script for debian/ubuntu 2024-08-01 15:27:51 +02:00
Henry Dollman
e204bcf9ce add support for docker socket proxy 2024-07-31 19:14:51 -04:00
Henry Dollman
e26e9fce03 move systemd instructions to the supplemental directory 2024-07-31 17:37:38 -04:00
Henry Dollman
4dd201de0d use more specific methods to retrieve record fields 2024-07-31 16:52:26 -04:00
Henry Dollman
de7e07963d improve efficiency of hourly cleanup operation 2024-07-31 16:26:41 -04:00
Henry Dollman
ac6f50c40c update readme and add same-system docker example 2024-07-31 15:59:10 -04:00
hank
4c680a2ab9 update readme - add path to update commands 2024-07-28 22:30:31 -04:00
Henry Dollman
c2cfe8cad6 release 0.1.1 2024-07-28 18:11:15 -04:00
Henry Dollman
9a43ee8f1d Allow address in agent's PORT env var 2024-07-28 17:53:41 -04:00
Henry Dollman
556434f043 fix agent losing track of container after restart 2024-07-28 17:33:07 -04:00
Henry Dollman
f2ff27aaa2 remove unnecessary time.Sleep in getServerConnection 2024-07-28 14:30:31 -04:00
Henry Dollman
93dce463d9 update site dependencies 2024-07-28 14:19:02 -04:00
Henry Dollman
bb23673547 default values for system / update collections snapshot 2024-07-28 13:16:04 -04:00
Henry Dollman
517f949a30 uniform x axis on charts 2024-07-28 12:48:46 -04:00
Henry Dollman
f54faa6bd6 make user role optional and default to 'user' 2024-07-28 11:40:38 -04:00
Henry Dollman
c4e62bd099 only show GitHub button / dialog during onboarding 2024-07-28 11:09:48 -04:00
Henry Dollman
d3033ed72e improve error handling in getSystemStats 2024-07-27 21:45:26 -04:00
Henry Dollman
d0f51e5ca9 skip network interfaces if they have no traffic 2024-07-27 21:13:37 -04:00
Henry Dollman
935dca8679 upgrade go deps and readme 2024-07-27 20:59:17 -04:00
Henry Dollman
184445f089 possible fix for ios safari auth popup blockage 2024-07-27 20:56:23 -04:00
Henry Dollman
3dafb8ddd5 add compiling info and tutoriel en français to readme 2024-07-27 18:37:02 -04:00
Henry Dollman
463681e145 add automatic binary releases for 32 bit arm 2024-07-25 21:08:32 -04:00
Henry Dollman
26b307a629 update binary install commands and instructions 2024-07-25 21:07:23 -04:00
Henry Dollman
fe82632804 Merge branch 'MFYDev-main' 2024-07-25 16:20:00 -04:00
Fanyang Meng
94697658f2 correct typo 2024-07-25 15:20:17 -04:00
Fanyang Meng
9a1bfdd24b Update readme.md 2024-07-25 15:11:30 -04:00
Henry Dollman
b668da17f6 change hub compose ports from 127.0.0.1:8090:8090 to 8090:8090 2024-07-25 12:48:33 -04:00
Henry Dollman
26dbb1968a version 0.1.0 2024-07-24 15:34:37 -04:00
Henry Dollman
ee57e84cb8 fallback prompt for copy button in insecure contexts 2024-07-24 15:32:20 -04:00
Henry Dollman
345dbeb757 0.0.1 2024-07-24 10:53:49 -04:00
Henry Dollman
29f5d3ae62 update readme 2024-07-24 10:52:55 -04:00
Henry Dollman
d4b0887153 update forgot password cli instructions 2024-07-24 10:32:25 -04:00
224 changed files with 39950 additions and 7264 deletions

View File

@@ -3,7 +3,7 @@ name: Make docker images
on:
push:
tags:
- '*'
- 'v*'
jobs:
build:
@@ -13,11 +13,11 @@ jobs:
matrix:
include:
- image: henrygd/beszel
context: ./hub
dockerfile: ./hub/Dockerfile
context: ./beszel
dockerfile: ./beszel/dockerfile_Hub
- image: henrygd/beszel-agent
context: ./agent
dockerfile: ./agent/Dockerfile
context: ./beszel
dockerfile: ./beszel/dockerfile_Agent
permissions:
contents: read
packages: write
@@ -30,10 +30,10 @@ jobs:
uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: bun install --no-save --cwd ./hub/site
run: bun install --no-save --cwd ./beszel/site
- name: Build site
run: bun run --cwd ./hub/site build
run: bun run --cwd ./beszel/site build
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
@@ -49,6 +49,7 @@ jobs:
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
# https://github.com/docker/login-action
@@ -66,9 +67,7 @@ jobs:
with:
context: '${{ matrix.context }}'
file: ${{ matrix.dockerfile }}
platforms: linux/amd64,linux/arm64
platforms: linux/amd64,linux/arm64,linux/arm/v7
push: ${{ github.ref_type == 'tag' }}
tags: ${{ steps.metadata.outputs.tags }}
labels: ${{ steps.metadata.outputs.labels }}
cache-from: type=gha
cache-to: type=gha

View File

@@ -21,10 +21,10 @@ jobs:
uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: bun install --no-save --cwd ./hub/site
run: bun install --no-save --cwd ./beszel/site
- name: Build site
run: bun run --cwd ./hub/site build
run: bun run --cwd ./beszel/site build
- name: Set up Go
uses: actions/setup-go@v5
@@ -34,17 +34,7 @@ jobs:
- name: GoReleaser beszel
uses: goreleaser/goreleaser-action@v6
with:
workdir: ./hub
distribution: goreleaser
version: latest
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.TOKEN }}
- name: GoReleaser beszel-agent
uses: goreleaser/goreleaser-action@v6
with:
workdir: ./agent
workdir: ./beszel
distribution: goreleaser
version: latest
args: release --clean

11
.gitignore vendored
View File

@@ -3,8 +3,15 @@ pb_data
data
temp
.vscode
beszel
beszel-agent
beszel_data
beszel_data*
dist
dist
*.exe
beszel/cmd/hub/hub
beszel/cmd/agent/agent
node_modules
beszel/build
*timestamp*
.swc
beszel/site/src/locales/**/*.ts

7
SECURITY.md Normal file
View File

@@ -0,0 +1,7 @@
# Security Policy
## Reporting a Vulnerability
If you find a vulnerability in the latest version, please [submit a private advisory](https://github.com/henrygd/beszel/security/advisories/new).
If it's low severity (use best judgement) you may open an issue instead of an advisory.

View File

@@ -1,36 +0,0 @@
# version: 1
project_name: beszel-agent
before:
hooks:
- go mod tidy
builds:
- env:
- CGO_ENABLED=0
goos:
- linux
- darwin
goarch:
- amd64
- arm64
archives:
- format: tar.gz
name_template: >-
{{ .ProjectName }}_
{{- .Os }}_
{{- .Arch }}
# use zip for windows archives
format_overrides:
- goos: windows
format: zip
changelog:
disable: true
sort: asc
filters:
exclude:
- '^docs:'
- '^test:'

View File

@@ -1,20 +0,0 @@
FROM --platform=$BUILDPLATFORM golang:alpine as builder
WORKDIR /app
# Download Go modules
COPY go.mod go.sum ./
RUN go mod download
COPY *.go ./
# Build
ARG TARGETOS TARGETARCH
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /agent .
# ? -------------------------
FROM scratch
COPY --from=builder /agent /agent
ENTRYPOINT ["/agent"]

View File

@@ -1,32 +0,0 @@
module beszel-agent
go 1.22.4
require (
github.com/blang/semver v3.5.1+incompatible
github.com/gliderlabs/ssh v0.3.7
github.com/rhysd/go-github-selfupdate v1.2.3
github.com/shirou/gopsutil/v4 v4.24.6
)
require (
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/golang/protobuf v1.3.2 // indirect
github.com/google/go-github/v30 v30.1.0 // indirect
github.com/google/go-querystring v1.0.0 // indirect
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf // indirect
github.com/lufia/plan9stats v0.0.0-20240513124658-fba389f38bae // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/shoenig/go-m1cpu v0.1.6 // indirect
github.com/tcnksm/go-gitconfig v0.1.2 // indirect
github.com/tklauser/go-sysconf v0.3.14 // indirect
github.com/tklauser/numcpus v0.8.0 // indirect
github.com/ulikunitz/xz v0.5.9 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
golang.org/x/crypto v0.25.0 // indirect
golang.org/x/net v0.21.0 // indirect
golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288 // indirect
golang.org/x/sys v0.22.0 // indirect
google.golang.org/appengine v1.3.0 // indirect
)

View File

@@ -1,97 +0,0 @@
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
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/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/gliderlabs/ssh v0.3.7 h1:iV3Bqi942d9huXnzEF2Mt+CY9gLu8DNM4Obd+8bODRE=
github.com/gliderlabs/ssh v0.3.7/go.mod h1:zpHEXBstFnQYtGnB8k8kQLol82umzn/2/snG7alWVD8=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-github/v30 v30.1.0 h1:VLDx+UolQICEOKu2m4uAoMti1SxuEBAl7RSEG16L+Oo=
github.com/google/go-github/v30 v30.1.0/go.mod h1:n8jBpHl45a/rlBUtRJMOG4GhNADUQFEufcolZ95JfU8=
github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk=
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf h1:WfD7VjIE6z8dIvMsI4/s+1qr5EL+zoIGev1BQj1eoJ8=
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf/go.mod h1:hyb9oH7vZsitZCiBt0ZvifOrB+qc8PS5IiilCIb87rg=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
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/lufia/plan9stats v0.0.0-20240513124658-fba389f38bae h1:dIZY4ULFcto4tAFlj1FYZl8ztUZ13bdq+PLY+NOfbyI=
github.com/lufia/plan9stats v0.0.0-20240513124658-fba389f38bae/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/gomega v1.4.2 h1:3mYCb7aPxS/RU7TI1y4rkEn1oKmPRjNJLNEXgw7MH2I=
github.com/onsi/gomega v1.4.2/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
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/rhysd/go-github-selfupdate v1.2.3 h1:iaa+J202f+Nc+A8zi75uccC8Wg3omaM7HDeimXA22Ag=
github.com/rhysd/go-github-selfupdate v1.2.3/go.mod h1:mp/N8zj6jFfBQy/XMYoWsmfzxazpPAODuqarmPDe2Rg=
github.com/shirou/gopsutil/v4 v4.24.6 h1:9qqCSYF2pgOU+t+NgJtp7Co5+5mHF/HyKBUckySQL64=
github.com/shirou/gopsutil/v4 v4.24.6/go.mod h1:aoebb2vxetJ/yIDZISmduFvVNPHqXQ9SEJwRXxkf0RA=
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=
github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tcnksm/go-gitconfig v0.1.2 h1:iiDhRitByXAEyjgBqsKi9QU4o2TNtv9kPP3RgPgXBPw=
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/go.mod h1:1ym4lWMLUOhuBOPGtRcJm7tEGX4SCYNEEEtghGG/8uY=
github.com/tklauser/numcpus v0.8.0 h1:Mx4Wwe/FjZLeQsK/6kt2EOepwwSl7SmJrK5bV/dXYgY=
github.com/tklauser/numcpus v0.8.0/go.mod h1:ZJZlAY+dmR4eut8epnzf0u/VwodKmryxR8txiloSqBE=
github.com/ulikunitz/xz v0.5.9 h1:RsKRIA2MO8x56wkkcd3LbtcE/uMszhb6DpRf+3uwa3I=
github.com/ulikunitz/xz v0.5.9/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/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-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288 h1:JIqe8uIcRBHXDQVvZtHwp80ai3Lw3IJAeJEs55Dc1W0=
golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
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-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
golang.org/x/sys v0.22.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.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk=
golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.3.0 h1:FBSsiFRMz3LBeXIomRnVzrQwSDj4ibvcRexLG0LZGQk=
google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1 h1:mUhvW9EsL+naU5Q3cakzfE91YhliOondGd6ZrsDBHQE=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -1,379 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"io"
"log"
"math"
"net"
"net/http"
"os"
"strings"
"sync"
"time"
sshServer "github.com/gliderlabs/ssh"
"github.com/shirou/gopsutil/v4/cpu"
"github.com/shirou/gopsutil/v4/disk"
"github.com/shirou/gopsutil/v4/host"
"github.com/shirou/gopsutil/v4/mem"
psutilNet "github.com/shirou/gopsutil/v4/net"
)
var Version = "0.0.1-alpha.9"
var containerCpuMap = make(map[string][2]uint64)
var containerCpuMutex = &sync.Mutex{}
var sem = make(chan struct{}, 15)
func acquireSemaphore() {
sem <- struct{}{}
}
func releaseSemaphore() {
<-sem
}
var diskIoStats = DiskIoStats{
Read: 0,
Write: 0,
Time: time.Now(),
Filesystem: "",
}
var netIoStats = NetIoStats{
BytesRecv: 0,
BytesSent: 0,
Time: time.Now(),
Name: "",
}
// client for docker engine api
var client = &http.Client{
Timeout: time.Second,
Transport: &http.Transport{
Dial: func(proto, addr string) (net.Conn, error) {
return net.Dial("unix", "/var/run/docker.sock")
},
ForceAttemptHTTP2: false,
IdleConnTimeout: 90 * time.Second,
DisableCompression: true,
MaxIdleConnsPerHost: 50,
DisableKeepAlives: false,
},
}
func getSystemStats() (*SystemInfo, *SystemStats) {
c, _ := cpu.Percent(0, false)
v, _ := mem.VirtualMemory()
d, _ := disk.Usage("/")
cpuPct := twoDecimals(c[0])
memPct := twoDecimals(v.UsedPercent)
diskPct := twoDecimals(d.UsedPercent)
systemStats := &SystemStats{
Cpu: cpuPct,
Mem: bytesToGigabytes(v.Total),
MemUsed: bytesToGigabytes(v.Used),
MemBuffCache: bytesToGigabytes(v.Total - v.Free - v.Used),
MemPct: memPct,
Disk: bytesToGigabytes(d.Total),
DiskUsed: bytesToGigabytes(d.Used),
DiskPct: diskPct,
}
systemInfo := &SystemInfo{
Cpu: cpuPct,
MemPct: memPct,
DiskPct: diskPct,
}
// add disk stats
if io, err := disk.IOCounters(diskIoStats.Filesystem); err == nil {
for _, d := range io {
// add to systemStats
secondsElapsed := time.Since(diskIoStats.Time).Seconds()
readPerSecond := float64(d.ReadBytes-diskIoStats.Read) / secondsElapsed
systemStats.DiskRead = bytesToMegabytes(readPerSecond)
writePerSecond := float64(d.WriteBytes-diskIoStats.Write) / secondsElapsed
systemStats.DiskWrite = bytesToMegabytes(writePerSecond)
// update diskIoStats
diskIoStats.Time = time.Now()
diskIoStats.Read = d.ReadBytes
diskIoStats.Write = d.WriteBytes
}
}
// add network stats
if netIO, err := psutilNet.IOCounters(true); err == nil {
bytesSent := uint64(0)
bytesRecv := uint64(0)
for _, v := range netIO {
if skipNetworkInterface(v.Name) {
continue
}
// log.Printf("%+v: %+v recv, %+v sent\n", v.Name, v.BytesRecv, v.BytesSent)
bytesSent += v.BytesSent
bytesRecv += v.BytesRecv
}
// add to systemStats
secondsElapsed := time.Since(netIoStats.Time).Seconds()
sentPerSecond := float64(bytesSent-netIoStats.BytesSent) / secondsElapsed
recvPerSecond := float64(bytesRecv-netIoStats.BytesRecv) / secondsElapsed
systemStats.NetworkSent = bytesToMegabytes(sentPerSecond)
systemStats.NetworkRecv = bytesToMegabytes(recvPerSecond)
// update netIoStats
netIoStats.BytesSent = bytesSent
netIoStats.BytesRecv = bytesRecv
netIoStats.Time = time.Now()
}
// add host stats
if info, err := host.Info(); err == nil {
systemInfo.Uptime = info.Uptime
// systemInfo.Os = info.OS
}
// add cpu stats
if info, err := cpu.Info(); err == nil {
systemInfo.CpuModel = info[0].ModelName
}
if cores, err := cpu.Counts(false); err == nil {
systemInfo.Cores = cores
}
if threads, err := cpu.Counts(true); err == nil {
systemInfo.Threads = threads
}
return systemInfo, systemStats
}
func getDockerStats() ([]*ContainerStats, error) {
resp, err := client.Get("http://localhost/containers/json")
if err != nil {
return []*ContainerStats{}, err
}
defer resp.Body.Close()
var containers []*Container
if err := json.NewDecoder(resp.Body).Decode(&containers); err != nil {
panic(err)
}
containerStats := make([]*ContainerStats, 0, len(containers))
// store valid ids to clean up old container ids from map
validIds := make(map[string]struct{}, len(containers))
var wg sync.WaitGroup
for _, ctr := range containers {
ctr.IdShort = ctr.ID[:12]
validIds[ctr.IdShort] = struct{}{}
wg.Add(1)
go func() {
defer wg.Done()
cstats, err := getContainerStats(ctr)
if err != nil {
// retry once
cstats, err = getContainerStats(ctr)
if err != nil {
log.Printf("Error getting container stats: %+v\n", err)
return
}
}
containerStats = append(containerStats, cstats)
}()
}
wg.Wait()
for id := range containerCpuMap {
if _, exists := validIds[id]; !exists {
// log.Printf("Removing container cpu map entry: %+v\n", id)
delete(containerCpuMap, id)
}
}
return containerStats, nil
}
func getContainerStats(ctr *Container) (*ContainerStats, error) {
// use semaphore to limit concurrency
acquireSemaphore()
defer releaseSemaphore()
resp, err := client.Get("http://localhost/containers/" + ctr.IdShort + "/stats?stream=0&one-shot=1")
if err != nil {
return &ContainerStats{}, err
}
defer resp.Body.Close()
var statsJson CStats
if err := json.NewDecoder(resp.Body).Decode(&statsJson); err != nil {
panic(err)
}
name := ctr.Names[0][1:]
// memory (https://docs.docker.com/reference/cli/docker/container/stats/)
memCache := statsJson.MemoryStats.Stats["inactive_file"]
if memCache == 0 {
memCache = statsJson.MemoryStats.Stats["cache"]
}
usedMemory := statsJson.MemoryStats.Usage - memCache
// pctMemory := float64(usedMemory) / float64(statsJson.MemoryStats.Limit) * 100
// cpu
// add default values to containerCpu if it doesn't exist
containerCpuMutex.Lock()
defer containerCpuMutex.Unlock()
if _, ok := containerCpuMap[ctr.IdShort]; !ok {
containerCpuMap[ctr.IdShort] = [2]uint64{0, 0}
}
cpuDelta := statsJson.CPUStats.CPUUsage.TotalUsage - containerCpuMap[ctr.IdShort][0]
systemDelta := statsJson.CPUStats.SystemUsage - containerCpuMap[ctr.IdShort][1]
cpuPct := float64(cpuDelta) / float64(systemDelta) * 100
if cpuPct > 100 {
return &ContainerStats{}, fmt.Errorf("%s cpu pct greater than 100: %+v", name, cpuPct)
}
containerCpuMap[ctr.IdShort] = [2]uint64{statsJson.CPUStats.CPUUsage.TotalUsage, statsJson.CPUStats.SystemUsage}
cStats := &ContainerStats{
Name: name,
Cpu: twoDecimals(cpuPct),
Mem: bytesToMegabytes(float64(usedMemory)),
// MemPct: twoDecimals(pctMemory),
}
return cStats, nil
}
func gatherStats() *SystemData {
systemInfo, systemStats := getSystemStats()
stats := &SystemData{
Stats: systemStats,
Info: systemInfo,
Containers: []*ContainerStats{},
}
containerStats, err := getDockerStats()
if err == nil {
stats.Containers = containerStats
}
// fmt.Printf("%+v\n", stats)
return stats
}
func startServer(port string, pubKey []byte) {
sshServer.Handle(func(s sshServer.Session) {
stats := gatherStats()
var jsonStats []byte
jsonStats, _ = json.Marshal(stats)
io.WriteString(s, string(jsonStats))
s.Exit(0)
})
log.Printf("Starting SSH server on port %s", port)
if err := sshServer.ListenAndServe(":"+port, nil, sshServer.NoPty(),
sshServer.PublicKeyAuth(func(ctx sshServer.Context, key sshServer.PublicKey) bool {
data := []byte(pubKey)
allowed, _, _, _, _ := sshServer.ParseAuthorizedKey(data)
return sshServer.KeysEqual(key, allowed)
}),
); err != nil {
log.Fatal(err)
}
}
func main() {
// handle flags / subcommands
if len(os.Args) > 1 {
switch os.Args[1] {
case "-v":
fmt.Println("beszel-agent", Version)
case "update":
updateBeszel()
}
os.Exit(0)
}
var pubKey []byte
if pubKeyEnv, exists := os.LookupEnv("KEY"); exists {
pubKey = []byte(pubKeyEnv)
} else {
log.Fatal("KEY environment variable is not set")
}
if filesystem, exists := os.LookupEnv("FILESYSTEM"); exists {
diskIoStats.Filesystem = filesystem
} else {
diskIoStats.Filesystem = findDefaultFilesystem()
}
initializeDiskIoStats()
initializeNetIoStats()
if port, exists := os.LookupEnv("PORT"); exists {
startServer(port, pubKey)
} else {
startServer("45876", pubKey)
}
}
func bytesToMegabytes(b float64) float64 {
return twoDecimals(b / 1048576)
}
func bytesToGigabytes(b uint64) float64 {
return twoDecimals(float64(b) / 1073741824)
}
func twoDecimals(value float64) float64 {
return math.Round(value*100) / 100
}
func findDefaultFilesystem() string {
if partitions, err := disk.Partitions(false); err == nil {
for _, v := range partitions {
if v.Mountpoint == "/" {
log.Printf("Using filesystem: %+v\n", v.Device)
return v.Device
}
}
}
return ""
}
func skipNetworkInterface(name string) bool {
return strings.HasPrefix(name, "lo") || strings.HasPrefix(name, "docker") || strings.HasPrefix(name, "br-") || strings.HasPrefix(name, "veth")
}
func initializeDiskIoStats() {
if io, err := disk.IOCounters(diskIoStats.Filesystem); err == nil {
for _, d := range io {
diskIoStats.Time = time.Now()
diskIoStats.Read = d.ReadBytes
diskIoStats.Write = d.WriteBytes
}
}
}
func initializeNetIoStats() {
if netIO, err := psutilNet.IOCounters(true); err == nil {
bytesSent := uint64(0)
bytesRecv := uint64(0)
for _, v := range netIO {
if skipNetworkInterface(v.Name) {
continue
}
log.Printf("Found network interface: %+v (%+v recv, %+v sent)\n", v.Name, v.BytesRecv, v.BytesSent)
bytesSent += v.BytesSent
bytesRecv += v.BytesRecv
}
netIoStats.BytesSent = bytesSent
netIoStats.BytesRecv = bytesRecv
netIoStats.Time = time.Now()
}
}

View File

@@ -1,156 +0,0 @@
package main
import "time"
type SystemData struct {
Stats *SystemStats `json:"stats"`
Info *SystemInfo `json:"info"`
Containers []*ContainerStats `json:"container"`
}
type SystemInfo struct {
Cores int `json:"c"`
Threads int `json:"t"`
CpuModel string `json:"m"`
// Os string `json:"o"`
Uptime uint64 `json:"u"`
Cpu float64 `json:"cpu"`
MemPct float64 `json:"mp"`
DiskPct float64 `json:"dp"`
}
type SystemStats struct {
Cpu float64 `json:"cpu"`
Mem float64 `json:"m"`
MemUsed float64 `json:"mu"`
MemPct float64 `json:"mp"`
MemBuffCache float64 `json:"mb"`
Disk float64 `json:"d"`
DiskUsed float64 `json:"du"`
DiskPct float64 `json:"dp"`
DiskRead float64 `json:"dr"`
DiskWrite float64 `json:"dw"`
NetworkSent float64 `json:"ns"`
NetworkRecv float64 `json:"nr"`
}
type ContainerStats struct {
Name string `json:"n"`
Cpu float64 `json:"c"`
Mem float64 `json:"m"`
// MemPct float64 `json:"mp"`
}
type Container struct {
ID string `json:"Id"`
IdShort string
Names []string
Image string
ImageID string
Command string
Created int64
// Ports []Port
SizeRw int64 `json:",omitempty"`
SizeRootFs int64 `json:",omitempty"`
Labels map[string]string
State string
Status string
HostConfig struct {
NetworkMode string `json:",omitempty"`
Annotations map[string]string `json:",omitempty"`
}
// NetworkSettings *SummaryNetworkSettings
// Mounts []MountPoint
}
type CStats struct {
// Common stats
Read time.Time `json:"read"`
PreRead time.Time `json:"preread"`
// Linux specific stats, not populated on Windows.
// PidsStats PidsStats `json:"pids_stats,omitempty"`
// BlkioStats BlkioStats `json:"blkio_stats,omitempty"`
// Windows specific stats, not populated on Linux.
NumProcs uint32 `json:"num_procs"`
// StorageStats StorageStats `json:"storage_stats,omitempty"`
// Shared stats
CPUStats CPUStats `json:"cpu_stats,omitempty"`
PreCPUStats CPUStats `json:"precpu_stats,omitempty"` // "Pre"="Previous"
MemoryStats MemoryStats `json:"memory_stats,omitempty"`
}
type CPUStats struct {
// CPU Usage. Linux and Windows.
CPUUsage CPUUsage `json:"cpu_usage"`
// System Usage. Linux only.
SystemUsage uint64 `json:"system_cpu_usage,omitempty"`
// Online CPUs. Linux only.
OnlineCPUs uint32 `json:"online_cpus,omitempty"`
// Throttling Data. Linux only.
// ThrottlingData ThrottlingData `json:"throttling_data,omitempty"`
}
type CPUUsage struct {
// Total CPU time consumed.
// Units: nanoseconds (Linux)
// Units: 100's of nanoseconds (Windows)
TotalUsage uint64 `json:"total_usage"`
// Total CPU time consumed per core (Linux). Not used on Windows.
// Units: nanoseconds.
PercpuUsage []uint64 `json:"percpu_usage,omitempty"`
// Time spent by tasks of the cgroup in kernel mode (Linux).
// Time spent by all container processes in kernel mode (Windows).
// Units: nanoseconds (Linux).
// Units: 100's of nanoseconds (Windows). Not populated for Hyper-V Containers.
UsageInKernelmode uint64 `json:"usage_in_kernelmode"`
// Time spent by tasks of the cgroup in user mode (Linux).
// Time spent by all container processes in user mode (Windows).
// Units: nanoseconds (Linux).
// Units: 100's of nanoseconds (Windows). Not populated for Hyper-V Containers
UsageInUsermode uint64 `json:"usage_in_usermode"`
}
type MemoryStats struct {
// current res_counter usage for memory
Usage uint64 `json:"usage,omitempty"`
Cache uint64 `json:"cache,omitempty"`
// maximum usage ever recorded.
MaxUsage uint64 `json:"max_usage,omitempty"`
// TODO(vishh): Export these as stronger types.
// all the stats exported via memory.stat.
Stats map[string]uint64 `json:"stats,omitempty"`
// number of times memory usage hits limits.
Failcnt uint64 `json:"failcnt,omitempty"`
Limit uint64 `json:"limit,omitempty"`
// committed bytes
Commit uint64 `json:"commitbytes,omitempty"`
// peak committed bytes
CommitPeak uint64 `json:"commitpeakbytes,omitempty"`
// private working set
PrivateWorkingSet uint64 `json:"privateworkingset,omitempty"`
}
type DiskIoStats struct {
Read uint64
Write uint64
Time time.Time
Filesystem string
}
type NetIoStats struct {
BytesRecv uint64
BytesSent uint64
Time time.Time
Name string
}

69
beszel/.goreleaser.yml Normal file
View File

@@ -0,0 +1,69 @@
version: 2
project_name: beszel
before:
hooks:
- go mod tidy
builds:
- id: beszel
binary: beszel
main: cmd/hub/hub.go
env:
- CGO_ENABLED=0
goos:
- linux
- darwin
goarch:
- amd64
- arm64
- arm
- id: beszel-agent
binary: beszel-agent
main: cmd/agent/agent.go
env:
- CGO_ENABLED=0
goos:
- linux
- darwin
- freebsd
goarch:
- amd64
- arm64
- arm
- mips64
ignore:
- goos: freebsd
goarch: arm
archives:
- id: beszel
format: tar.gz
builds:
- beszel-agent
name_template: >-
{{ .Binary }}_
{{- .Os }}_
{{- .Arch }}
- id: beszel-agent
format: tar.gz
builds:
- beszel
name_template: >-
{{ .Binary }}_
{{- .Os }}_
{{- .Arch }}
# use zip for windows archives
# format_overrides:
# - goos: windows
# format: zip
changelog:
disable: true
sort: asc
filters:
exclude:
- '^docs:'
- '^test:'

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

41
beszel/cmd/agent/agent.go Normal file
View File

@@ -0,0 +1,41 @@
package main
import (
"beszel"
"beszel/internal/agent"
"fmt"
"log"
"os"
"strings"
)
func main() {
// handle flags / subcommands
if len(os.Args) > 1 {
switch os.Args[1] {
case "-v":
fmt.Println(beszel.AppName+"-agent", beszel.Version)
case "update":
agent.Update()
}
os.Exit(0)
}
var pubKey []byte
if pubKeyEnv, exists := os.LookupEnv("KEY"); exists {
pubKey = []byte(pubKeyEnv)
} else {
log.Fatal("KEY environment variable is not set")
}
addr := ":45876"
if portEnvVar, exists := os.LookupEnv("PORT"); exists {
// allow passing an address in the form of "127.0.0.1:45876"
if !strings.Contains(portEnvVar, ":") {
portEnvVar = ":" + portEnvVar
}
addr = portEnvVar
}
agent.NewAgent().Run(pubKey, addr)
}

29
beszel/cmd/hub/hub.go Normal file
View File

@@ -0,0 +1,29 @@
package main
import (
"beszel"
"beszel/internal/hub"
_ "beszel/migrations"
"github.com/pocketbase/pocketbase"
"github.com/spf13/cobra"
)
func main() {
app := pocketbase.NewWithConfig(pocketbase.Config{
DefaultDataDir: beszel.AppName + "_data",
})
app.RootCmd.Version = beszel.Version
app.RootCmd.Use = beszel.AppName
app.RootCmd.Short = ""
// add update command
app.RootCmd.AddCommand(&cobra.Command{
Use: "update",
Short: "Update " + beszel.AppName + " to the latest version",
Run: hub.Update,
})
hub.NewHub(app).Run()
}

20
beszel/dockerfile_Agent Normal file
View File

@@ -0,0 +1,20 @@
FROM --platform=$BUILDPLATFORM golang:alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
# RUN go mod download
COPY *.go ./
COPY cmd ./cmd
COPY internal ./internal
# Build
ARG TARGETOS TARGETARCH
RUN CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /agent ./cmd/agent
# ? -------------------------
FROM scratch
COPY --from=builder /agent /agent
ENTRYPOINT ["/agent"]

View File

@@ -1,4 +1,4 @@
FROM --platform=$BUILDPLATFORM golang:alpine as builder
FROM --platform=$BUILDPLATFORM golang:alpine AS builder
WORKDIR /app
@@ -8,6 +8,8 @@ RUN go mod download
# Copy source files
COPY *.go ./
COPY cmd ./cmd
COPY internal ./internal
COPY migrations ./migrations
COPY site/dist ./site/dist
COPY site/*.go ./site
@@ -20,7 +22,7 @@ RUN update-ca-certificates
# Build
ARG TARGETOS TARGETARCH
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /beszel .
RUN CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /beszel ./cmd/hub
# ? -------------------------
FROM scratch

101
beszel/go.mod Normal file
View File

@@ -0,0 +1,101 @@
module beszel
go 1.23
toolchain go1.23.2
require (
github.com/blang/semver v3.5.1+incompatible
github.com/containrrr/shoutrrr v0.8.0
github.com/gliderlabs/ssh v0.3.8
github.com/goccy/go-json v0.10.4
github.com/pocketbase/dbx v1.11.0
github.com/pocketbase/pocketbase v0.24.1
github.com/rhysd/go-github-selfupdate v1.2.3
github.com/shirou/gopsutil/v4 v4.24.12
github.com/spf13/cast v1.7.1
github.com/spf13/cobra v1.8.1
golang.org/x/crypto v0.31.0
golang.org/x/exp v0.0.0-20250103183323-7d7fa50e5329
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/AlecAivazis/survey/v2 v2.3.7 // indirect
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/aws/aws-sdk-go-v2 v1.32.7 // 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.7 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.17.48 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.22 // indirect
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.45 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.26 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.26 // 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.26 // 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.7 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.7 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.7 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.72.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.24.8 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.7 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.33.3 // indirect
github.com/aws/smithy-go v1.22.1 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/disintegration/imaging v1.6.2 // indirect
github.com/domodwyer/mailyak/v3 v3.6.2 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ebitengine/purego v0.8.1 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/ganigeorgiev/fexpr v0.4.1 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect
github.com/golang-jwt/jwt/v4 v4.5.1 // 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-querystring v1.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/gax-go/v2 v2.14.1 // 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/mousetrap v1.1.0 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/tcnksm/go-gitconfig v0.1.2 // indirect
github.com/tklauser/go-sysconf v0.3.14 // indirect
github.com/tklauser/numcpus v0.9.0 // indirect
github.com/ulikunitz/xz v0.5.12 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opencensus.io v0.24.0 // indirect
gocloud.dev v0.40.0 // indirect
golang.org/x/image v0.23.0 // indirect
golang.org/x/net v0.33.0 // indirect
golang.org/x/oauth2 v0.25.0 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/sys v0.29.0 // indirect
golang.org/x/term v0.28.0 // indirect
golang.org/x/text v0.21.0 // indirect
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
google.golang.org/api v0.214.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250102185135-69823020774d // indirect
google.golang.org/grpc v1.69.2 // indirect
google.golang.org/protobuf v1.36.1 // indirect
modernc.org/gc/v3 v3.0.0-20250105121824-520be1a3aee6 // indirect
modernc.org/libc v1.55.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.8.1 // indirect
modernc.org/sqlite v1.34.4 // indirect
modernc.org/strutil v1.2.1 // indirect
modernc.org/token v1.1.0 // indirect
)

View File

@@ -1,17 +1,17 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.115.0 h1:CnFSK6Xo3lDYRoBKEcAtia6VSC837/ZkJuRduSFnr14=
cloud.google.com/go v0.115.0/go.mod h1:8jIM5vVgoAEoiVxQ/O4BFTfHqulPZgs/ufEzMcFMdWU=
cloud.google.com/go/auth v0.6.1 h1:T0Zw1XM5c1GlpN2HYr2s+m3vr1p2wy+8VN+Z1FKxW38=
cloud.google.com/go/auth v0.6.1/go.mod h1:eFHG7zDzbXHKmjJddFG/rBlcGp6t25SwRUiEQSlO4x4=
cloud.google.com/go/auth/oauth2adapt v0.2.2 h1:+TTV8aXpjeChS9M+aTtN/TjdQnzJvmzKFt//oWu7HX4=
cloud.google.com/go/auth/oauth2adapt v0.2.2/go.mod h1:wcYjgpZI9+Yu7LyYBg4pqSiaRkfEK3GQcpb7C/uyF1Q=
cloud.google.com/go/compute v1.25.0 h1:H1/4SqSUhjPFE7L5ddzHOfY2bCAvjwNRZPNl6Ni5oYU=
cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc=
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
cloud.google.com/go/iam v1.1.6 h1:bEa06k05IO4f4uJonbB5iAgKTPpABy1ayxaIZV/GHVc=
cloud.google.com/go/iam v1.1.6/go.mod h1:O0zxdPeGBoFdWW3HWmBxJsk0pfvNM/p/qa82rWOGTwI=
cloud.google.com/go/storage v1.39.1 h1:MvraqHKhogCOTXTlct/9C3K3+Uy2jBmFYb3/Sp6dVtY=
cloud.google.com/go/storage v1.39.1/go.mod h1:xK6xZmxZmo+fyP7+DEF6FhNc24/JAe95OLyOHCXFH1o=
cloud.google.com/go/auth v0.13.0 h1:8Fu8TZy167JkW8Tj3q7dIkr2v4cndv41ouecJx0PAHs=
cloud.google.com/go/auth v0.13.0/go.mod h1:COOjD9gwfKNKz+IIduatIhYJQIc0mG3H102r/EMxX6Q=
cloud.google.com/go/auth/oauth2adapt v0.2.6 h1:V6a6XDu2lTwPZWOawrAa9HUK+DB2zfJyTuciBG5hFkU=
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/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I=
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/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/go.mod h1:ajvxEa7WmZS1PxvKRq4bq0tFT3vMd502JwstCcYv0Q0=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ=
@@ -19,98 +19,112 @@ github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1r
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s=
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
github.com/aws/aws-sdk-go v1.51.11 h1:El5VypsMIz7sFwAAj/j06JX9UGs4KAbAIEaZ57bNY4s=
github.com/aws/aws-sdk-go v1.51.11/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk=
github.com/aws/aws-sdk-go-v2 v1.30.1 h1:4y/5Dvfrhd1MxRDD77SrfsDaj8kUkkljU7XE83NPV+o=
github.com/aws/aws-sdk-go-v2 v1.30.1/go.mod h1:nIQjQVp5sfpQcTc9mPSr1B0PaWK5ByX9MOoDadSN4lc=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3 h1:tW1/Rkad38LA15X4UQtjXZXNKsCgkshC3EbmcUmghTg=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3/go.mod h1:UbnqO+zjqk3uIt9yCACHJ9IVNhyhOCnYk8yA19SAWrM=
github.com/aws/aws-sdk-go-v2/config v1.27.23 h1:Cr/gJEa9NAS7CDAjbnB7tHYb3aLZI2gVggfmSAasDac=
github.com/aws/aws-sdk-go-v2/config v1.27.23/go.mod h1:WMMYHqLCFu5LH05mFOF5tsq1PGEMfKbu083VKqLCd0o=
github.com/aws/aws-sdk-go-v2/credentials v1.17.23 h1:G1CfmLVoO2TdQ8z9dW+JBc/r8+MqyPQhXCafNZcXVZo=
github.com/aws/aws-sdk-go-v2/credentials v1.17.23/go.mod h1:V/DvSURn6kKgcuKEk4qwSwb/fZ2d++FFARtWSbXnLqY=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.9 h1:Aznqksmd6Rfv2HQN9cpqIV/lQRMaIpJkLLaJ1ZI76no=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.9/go.mod h1:WQr3MY7AxGNxaqAtsDWn+fBxmd4XvLkzeqQ8P1VM0/w=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.4 h1:6eKRM6fgeXG4krRO9XKz755vuRhT5UyB9M1W6vjA3JU=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.4/go.mod h1:h0TjcRi+nTob6fksqubKOe+Hra8uqfgmN+vuw4xRwWE=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.13 h1:5SAoZ4jYpGH4721ZNoS1znQrhOfZinOhc4XuTXx/nVc=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.13/go.mod h1:+rdA6ZLpaSeM7tSg/B0IEDinCIBJGmW8rKDFkYpP04g=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.13 h1:WIijqeaAO7TYFLbhsZmi2rgLEAtWOC1LhxCAVTJlSKw=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.13/go.mod h1:i+kbfa76PQbWw/ULoWnp51EYVWH4ENln76fLQE3lXT8=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 h1:hT8rVHwugYE2lEfdFE0QWVo81lF7jMrYJVDWI+f+VxU=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0/go.mod h1:8tu/lYfQfFe6IGnaOdrpVgEL2IrrDOf6/m9RQum4NkY=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.13 h1:THZJJ6TU/FOiM7DZFnisYV9d49oxXWUzsVIMTuf3VNU=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.13/go.mod h1:VISUTg6n+uBaYIWPBaIG0jk7mbBxm7DUqBtU2cUDDWI=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.3 h1:dT3MqvGhSoaIhRseqw2I0yH81l7wiR2vjs57O51EAm8=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.3/go.mod h1:GlAeCkHwugxdHaueRr4nhPuY+WW+gR8UjlcqzPr1SPI=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.15 h1:2jyRZ9rVIMisyQRnhSS/SqlckveoxXneIumECVFP91Y=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.15/go.mod h1:bDRG3m382v1KJBk1cKz7wIajg87/61EiiymEyfLvAe0=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.15 h1:I9zMeF107l0rJrpnHpjEiiTSCKYAIw8mALiXcPsGBiA=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.15/go.mod h1:9xWJ3Q/S6Ojusz1UIkfycgD1mGirJfLLKqq3LPT7WN8=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.13 h1:Eq2THzHt6P41mpjS2sUzz/3dJYFRqdWZ+vQaEMm98EM=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.13/go.mod h1:FgwTca6puegxgCInYwGjmd4tB9195Dd6LCuA+8MjpWw=
github.com/aws/aws-sdk-go-v2/service/s3 v1.58.0 h1:4rhV0Hn+bf8IAIUphRX1moBcEvKJipCPmswMCl6Q5mw=
github.com/aws/aws-sdk-go-v2/service/s3 v1.58.0/go.mod h1:hdV0NTYd0RwV4FvNKhKUNbPLZoq9CTr/lke+3I7aCAI=
github.com/aws/aws-sdk-go-v2/service/sso v1.22.1 h1:p1GahKIjyMDZtiKoIn0/jAj/TkMzfzndDv5+zi2Mhgc=
github.com/aws/aws-sdk-go-v2/service/sso v1.22.1/go.mod h1:/vWdhoIoYA5hYoPZ6fm7Sv4d8701PiG5VKe8/pPJL60=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.1 h1:lCEv9f8f+zJ8kcFeAjRZsekLd/x5SAm96Cva+VbUdo8=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.1/go.mod h1:xyFHA4zGxgYkdD73VeezHt3vSKEG9EmFnGwoKlP00u4=
github.com/aws/aws-sdk-go-v2/service/sts v1.30.1 h1:+woJ607dllHJQtsnJLi52ycuqHMwlW+Wqm2Ppsfp4nQ=
github.com/aws/aws-sdk-go-v2/service/sts v1.30.1/go.mod h1:jiNR3JqT15Dm+QWq2SRgh0x0bCNSRP2L25+CqPNpJlQ=
github.com/aws/smithy-go v1.20.3 h1:ryHwveWzPV5BIof6fyDvor6V3iUL7nTfiTKXHiW05nE=
github.com/aws/smithy-go v1.20.3/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E=
github.com/aws/aws-sdk-go v1.55.5 h1:KKUZBfBoyqy5d3swXyiC7Q76ic40rYcbqH7qjh59kzU=
github.com/aws/aws-sdk-go v1.55.5/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
github.com/aws/aws-sdk-go-v2 v1.32.7 h1:ky5o35oENWi0JYWUZkB7WYvVPP+bcRF5/Iq7JWSb5Rw=
github.com/aws/aws-sdk-go-v2 v1.32.7/go.mod h1:P5WJBrYqqbWVaOxgH0X/FYYD47/nooaPOZPlQdmiN2U=
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.7/go.mod h1:QraP0UcVlQJsmHfioCrveWOC1nbiWUl3ej08h4mXWoc=
github.com/aws/aws-sdk-go-v2/config v1.28.7 h1:GduUnoTXlhkgnxTD93g1nv4tVPILbdNQOzav+Wpg7AE=
github.com/aws/aws-sdk-go-v2/config v1.28.7/go.mod h1:vZGX6GVkIE8uECSUHB6MWAUsd4ZcG2Yq/dMa4refR3M=
github.com/aws/aws-sdk-go-v2/credentials v1.17.48 h1:IYdLD1qTJ0zanRavulofmqut4afs45mOWEI+MzZtTfQ=
github.com/aws/aws-sdk-go-v2/credentials v1.17.48/go.mod h1:tOscxHN3CGmuX9idQ3+qbkzrjVIx32lqDSU1/0d/qXs=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.22 h1:kqOrpojG71DxJm/KDPO+Z/y1phm1JlC8/iT+5XRmAn8=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.22/go.mod h1:NtSFajXVVL8TA2QNngagVZmUtXciyrHOt7xgz4faS/M=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.45 h1:ZxB8WFVYwolhDZxuZXoesHkl+L9cXLWy0K/G0QkNATc=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.45/go.mod h1:1krrbyoFFDqaNldmltPTP+mK3sAXLHPoaFtISOw2Hkk=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.26 h1:I/5wmGMffY4happ8NOCuIUEWGUvvFp5NSeQcXl9RHcI=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.26/go.mod h1:FR8f4turZtNy6baO0KJ5FJUmXH/cSkI9fOngs0yl6mA=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.26 h1:zXFLuEuMMUOvEARXFUVJdfqZ4bvvSgdGRq/ATcrQxzM=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.26/go.mod h1:3o2Wpy0bogG1kyOPrgkXA8pgIfEEv0+m19O9D5+W8y8=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 h1:VaRN3TlFdd6KxX1x3ILT5ynH6HvKgqdiXoTxAF4HQcQ=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.26 h1:GeNJsIFHB+WW5ap2Tec4K6dzcVTsRbsT1Lra46Hv9ME=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.26/go.mod h1:zfgMpwHDXX2WGoG84xG2H+ZlPTkJUU4YUvx2svLQYWo=
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.1/go.mod h1:9nu0fVANtYiAePIBh2/pFUSwtJ402hLnp854CNoDOeE=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.7 h1:tB4tNw83KcajNAzaIMhkhVI2Nt8fAZd5A5ro113FEMY=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.7/go.mod h1:lvpyBGkZ3tZ9iSsUIcC2EWp+0ywa7aK3BLT+FwZi+mQ=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.7 h1:8eUsivBQzZHqe/3FE+cqwfH+0p5Jo8PFM/QYQSmeZ+M=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.7/go.mod h1:kLPQvGUmxn/fqiCrDeohwG33bq2pQpGeY62yRO6Nrh0=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.7 h1:Hi0KGbrnr57bEHWM0bJ1QcBzxLrL/k2DHvGYhb8+W1w=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.7/go.mod h1:wKNgWgExdjjrm4qvfbTorkvocEstaoDl4WCvGfeCy9c=
github.com/aws/aws-sdk-go-v2/service/s3 v1.72.0 h1:SAfh4pNx5LuTafKKWR02Y+hL3A+3TX8cTKG1OIAJaBk=
github.com/aws/aws-sdk-go-v2/service/s3 v1.72.0/go.mod h1:r+xl5yzMk9083rMR+sJ5TYj9Tihvf/l1oxzZXDgGj2Q=
github.com/aws/aws-sdk-go-v2/service/sso v1.24.8 h1:CvuUmnXI7ebaUAhbJcDy9YQx8wHR69eZ9I7q5hszt/g=
github.com/aws/aws-sdk-go-v2/service/sso v1.24.8/go.mod h1:XDeGv1opzwm8ubxddF0cgqkZWsyOtw4lr6dxwmb6YQg=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.7 h1:F2rBfNAL5UyswqoeWv9zs74N/NanhK16ydHW1pahX6E=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.7/go.mod h1:JfyQ0g2JG8+Krq0EuZNnRwX0mU0HrwY/tG6JNfcqh4k=
github.com/aws/aws-sdk-go-v2/service/sts v1.33.3 h1:Xgv/hyNgvLda/M9l9qxXc4UFSgppnRczLxlMs5Ae/QY=
github.com/aws/aws-sdk-go-v2/service/sts v1.33.3/go.mod h1:5Gn+d+VaaRgsjewpMvGazt0WfcFO+Md4wLOuBfGR9Bc=
github.com/aws/smithy-go v1.22.1 h1:/HPHZQ0g7f4eUeK6HKglFz8uwVfZKgoI25rb/J+dnro=
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/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/containrrr/shoutrrr v0.8.0 h1:mfG2ATzIS7NR2Ec6XL+xyoHzN97H8WPjir8aYzJUSec=
github.com/containrrr/shoutrrr v0.8.0/go.mod h1:ioyQAyu1LJY6sILuNyKaQaw+9Ttik5QePU8atnAdO2o=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI=
github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8=
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/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/ebitengine/purego v0.8.1 h1:sdRKd6plj7KYW33EH5As6YKfe8m9zbN9JMrOjNVF/BE=
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.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/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.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
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/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/gabriel-vasile/mimetype v1.4.4 h1:QjV6pZ7/XZ7ryI2KuyeEDE8wnh7fHP9YnQy+R0LnH8I=
github.com/gabriel-vasile/mimetype v1.4.4/go.mod h1:JwLei5XPtWdGiMFB5Pjle1oEeoSeEuJfJE+TtfvdB/s=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/ganigeorgiev/fexpr v0.4.1 h1:hpUgbUEEWIZhSDBtf4M9aUNfQQ0BZkGRaMePy7Gcx5k=
github.com/ganigeorgiev/fexpr v0.4.1/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ=
github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
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/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 h1:byhDUpfEwjsVQb1vBunvIjh2BHQ9ead57VkAEY4V+Es=
github.com/go-ozzo/ozzo-validation/v4 v4.3.0/go.mod h1:2NKgrcHl3z6cJs+3Oo940FPRiTzuqKbvfrL2RxCj6Ew=
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/go-sql-driver/mysql v1.8.0 h1:UtktXaU2Nb64z/pLiGIxY4431SJ4/dR5cjMmlVHgnT4=
github.com/go-sql-driver/mysql v1.8.0/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=
github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
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/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls=
github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM=
github.com/goccy/go-json v0.10.4/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/golang-jwt/jwt/v4 v4.5.1 h1:JdqV9zKUdtaa9gdPlywC3aeoEsR681PlKC+4F5gQgeo=
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/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-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
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/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
@@ -129,26 +143,28 @@ github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-github/v30 v30.1.0 h1:VLDx+UolQICEOKu2m4uAoMti1SxuEBAl7RSEG16L+Oo=
github.com/google/go-github/v30 v30.1.0/go.mod h1:n8jBpHl45a/rlBUtRJMOG4GhNADUQFEufcolZ95JfU8=
github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk=
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
github.com/google/pprof v0.0.0-20240625030939-27f56978b8b0 h1:e+8XbKB6IMn8A4OAyZccO4pYfB3s7bt6azNIPE7AnPg=
github.com/google/pprof v0.0.0-20240625030939-27f56978b8b0/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo=
github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o=
github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw=
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/pprof v0.0.0-20240727154555-813a5fbdbec8 h1:FKHo8hFI3A+7w0aUQuYXQ+6EN5stWmeY/AZqtM8xk9k=
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/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
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/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs=
github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0=
github.com/googleapis/gax-go/v2 v2.12.5 h1:8gw9KZK8TiVKB6q3zHY3SBzLnrGp6HQjyfYBYGmXdxA=
github.com/googleapis/gax-go/v2 v2.12.5/go.mod h1:BUDKcWo+RaKq5SC9vVYL0wLADa3VcfswbOMMRmB9H3E=
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/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q=
github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA=
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/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog=
@@ -158,10 +174,10 @@ github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf h1:WfD7V
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf/go.mod h1:hyb9oH7vZsitZCiBt0ZvifOrB+qc8PS5IiilCIb87rg=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jarcoal/httpmock v1.3.0 h1:2RJ8GP0IIaWwcC9Fp2BmVi8Kog3v2Hn7VXM3fTd+nuc=
github.com/jarcoal/httpmock v1.3.0/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg=
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
@@ -171,8 +187,8 @@ 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.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
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/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k=
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
@@ -180,22 +196,27 @@ github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hd
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI=
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/onsi/ginkgo v1.6.0 h1:Ix8l273rp3QzYgXSR+c8d1fTG7UPgYkOSELPhiY/YGw=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/gomega v1.4.2 h1:3mYCb7aPxS/RU7TI1y4rkEn1oKmPRjNJLNEXgw7MH2I=
github.com/onsi/ginkgo/v2 v2.9.2 h1:BA2GMJOtfGAfagzYtrAlufIP0lq6QERkFmHLMLPwFSU=
github.com/onsi/ginkgo/v2 v2.9.2/go.mod h1:WHcJJG2dIlcCqVfBAwUCrJxSPFb6v4azBwgxeMeDuts=
github.com/onsi/gomega v1.4.2/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE=
github.com/onsi/gomega v1.27.6/go.mod h1:PIQNjfQwkP3aQAH7lf7j87O/5FiNr+ZR8+ipb+qQlhg=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pocketbase/dbx v1.10.1 h1:cw+vsyfCJD8YObOVeqb93YErnlxwYMkNZ4rwN0G0AaA=
github.com/pocketbase/dbx v1.10.1/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
github.com/pocketbase/pocketbase v0.22.16 h1:NMpz8s4ASqWGuxzfcpIax1z0WwRzGTSkNjTaisBG5Eo=
github.com/pocketbase/pocketbase v0.22.16/go.mod h1:tsEEQ2xXydNUeDUDkgSQDBlIuF0gkhE2tcYZThLCSHg=
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/pocketbase/dbx v1.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU=
github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
github.com/pocketbase/pocketbase v0.24.1 h1:Hhx3L4eap7g3kyBQ8OuWneZo7mYu1lrMvqQpxjK+zcs=
github.com/pocketbase/pocketbase v0.24.1/go.mod h1:VVaUmgKgjCBW3s/ob2SSHuDNtTboTbwK/VyTUguTt3o=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
@@ -204,8 +225,10 @@ 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/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/shirou/gopsutil/v4 v4.24.12 h1:qvePBOk20e0IKA1QXrIIU+jmk+zEiYVVx06WjBRlZo4=
github.com/shirou/gopsutil/v4 v4.24.12/go.mod h1:DCtMPAad2XceTeIAbGyVfycbYQNBGk2P8cvDi7/VN9o=
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
github.com/spf13/cast v1.7.1/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/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
@@ -218,47 +241,56 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tcnksm/go-gitconfig v0.1.2 h1:iiDhRitByXAEyjgBqsKi9QU4o2TNtv9kPP3RgPgXBPw=
github.com/tcnksm/go-gitconfig v0.1.2/go.mod h1:/8EhP4H7oJZdIPyT+/UIsG87kTzrzM4UsLGSItWYCpE=
github.com/ulikunitz/xz v0.5.9 h1:RsKRIA2MO8x56wkkcd3LbtcE/uMszhb6DpRf+3uwa3I=
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/numcpus v0.9.0 h1:lmyCHtANi8aRUgkckBgoDk1nHCux3n2cgkJLXdQGPDo=
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/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/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc=
github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
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/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 h1:4Pp6oUg3+e/6M4C0A/3kJ2VYa++dsWVTtGgLVj5xtHg=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0/go.mod h1:Mjt1i1INqiaoZOMGR1RIUJN+i3ChKoFRqzrRQhlkbs0=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw=
go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo=
go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo=
go.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI=
go.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8pNGJgDcSFRKBco=
go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI=
go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU=
gocloud.dev v0.37.0 h1:XF1rN6R0qZI/9DYjN16Uy0durAmSlf58DHOcb28GPro=
gocloud.dev v0.37.0/go.mod h1:7/O4kqdInCNsc6LqgmuFnS0GRew4XNNYWpA44yQnwco=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 h1:r6I7RJCN86bpD/FQwedZ0vSixDpwuWREjW9oRMsmqDc=
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/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
go.opentelemetry.io/otel v1.31.0 h1:NsJcKPIW0D0H3NgzPDHmo0WW6SptzPdqg/L1zsIm2hY=
go.opentelemetry.io/otel v1.31.0/go.mod h1:O0C14Yl9FgkjqcCZAsE053C13OaddMYr/hz6clDkEJE=
go.opentelemetry.io/otel/metric v1.31.0 h1:FSErL0ATQAmYHUIzSezZibnyVlft1ybhy4ozRPcF2fE=
go.opentelemetry.io/otel/metric v1.31.0/go.mod h1:C3dEloVbLuYoX41KpmAhOqNriGbA+qqH6PQ5E5mUfnY=
go.opentelemetry.io/otel/sdk v1.31.0 h1:xLY3abVHYZ5HSfOg3l2E5LUj2Cwva5Y7yGxnSW9H5Gk=
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/go.mod h1:drz+VyYNBvrMTW0KZiBAYEdl8lbNZx+OQ7oQvdrFmSQ=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI=
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
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-20250103183323-7d7fa50e5329 h1:9kj3STMvgqy3YA4VQXBrN7925ICMxD5wzMRcgA30588=
golang.org/x/exp v0.0.0-20250103183323-7d7fa50e5329/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
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-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
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.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4=
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-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -270,48 +302,51 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
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.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs=
golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70=
golang.org/x/oauth2 v0.25.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-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-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
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-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-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA=
golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
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-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
@@ -319,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-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.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.28.0 h1:WuB6qZ4RPCQo5aP3WdKZS7i595EdWqWR8vqJTlwTVK8=
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-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU=
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
google.golang.org/api v0.187.0 h1:Mxs7VATVC2v7CY+7Xwm4ndkX71hpElcvx0D1Ji/p1eo=
google.golang.org/api v0.187.0/go.mod h1:KIHlTc4x7N7gKKuVsdmfBXN13yEEWXWFURWY6SBp2gk=
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
google.golang.org/api v0.214.0 h1:h2Gkq07OYi6kusGOaT/9rnNljuXmqPnaig7WGPmKbwA=
google.golang.org/api v0.214.0/go.mod h1:bYPpLG8AyeMWwDU6NXoB00xC0DFkikVvd5MfwoxjLqE=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
@@ -334,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-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-20240624140628-dc46fd24d27d h1:PksQg4dV6Sem3/HkBX+Ltq8T0ke0PKIRBNBatoDTVls=
google.golang.org/genproto v0.0.0-20240624140628-dc46fd24d27d/go.mod h1:s7iA721uChleev562UJO2OYB0PPT9CMFjV+Ce7VJH5M=
google.golang.org/genproto/googleapis/api v0.0.0-20240610135401-a8a62080eff3 h1:QW9+G6Fir4VcRXVH8x3LilNAb6cxBGLa6+GM4hRwexE=
google.golang.org/genproto/googleapis/api v0.0.0-20240610135401-a8a62080eff3/go.mod h1:kdrSS/OiLkPrNUpzD4aHgCq2rVuC/YRxok32HXZ4vRE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094 h1:BwIjyKYGsK9dMCBOorzRri8MQwmi7mT9rGHsCEinZkA=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094/go.mod h1:Ue6ibwXGpU+dqIcODieyLOcgj7z8+IcskoNIgZxtrFY=
google.golang.org/genproto v0.0.0-20240812133136-8ffd90a71988 h1:CT2Thj5AuPV9phrYMtzX11k+XkzMGfRAet42PmoTATM=
google.golang.org/genproto v0.0.0-20240812133136-8ffd90a71988/go.mod h1:7uvplUBj4RjHAxIZ//98LzOvrQ04JBkaixRmCMI29hc=
google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 h1:CkkIfIt50+lT6NHAVoRYEyAvQGFM7xEwXUUywFvEb3Q=
google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576/go.mod h1:1R3kvZ1dtP3+4p4d3G8uJ8rFk/fWlScl38vanWACI08=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250102185135-69823020774d h1:xJJRGY7TJcvIlpSrN3K6LAWgNFUILlO+OMAqtg9aqnw=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250102185135-69823020774d/go.mod h1:3ENsm/5D1mzDyhpzeRi1NR784I0BcofWBoSc5QqqMK4=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
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.65.0 h1:bs/cUb4lp1G5iImFFd3u5ixQzweKizoZJAwBNLR42lc=
google.golang.org/grpc v1.65.0/go.mod h1:WgYC2ypjlB0EiQi6wdKixMqukr6lBc0Vo+oOgjrM5ZQ=
google.golang.org/grpc v1.69.2 h1:U3S9QEtbXC0bYNvRtcoklF3xGtLViumSYxWykJS+7AU=
google.golang.org/grpc v1.69.2/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4=
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-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
@@ -356,44 +391,43 @@ google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk=
google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
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-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
modernc.org/cc/v4 v4.21.2 h1:dycHFB/jDc3IyacKipCNSDrjIC0Lm1hyoWOZTRR20Lk=
modernc.org/cc/v4 v4.21.2/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
modernc.org/ccgo/v4 v4.17.10 h1:6wrtRozgrhCxieCeJh85QsxkX/2FFrT9hdaWPlbn4Zo=
modernc.org/ccgo/v4 v4.17.10/go.mod h1:0NBHgsqTTpm9cA5z2ccErvGZmtntSM9qD2kFAs6pjXM=
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b h1:BnN1t+pb1cy61zbvSUV7SeI0PwosMhlAEi/vBY4qxp8=
modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
modernc.org/libc v1.52.1 h1:uau0VoiT5hnR+SpoWekCKbLqm7v6dhRL3hI+NQhgN3M=
modernc.org/libc v1.52.1/go.mod h1:HR4nVzFDSDizP620zcMCgjb1/8xk2lg5p/8yjfGv1IQ=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
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/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
modernc.org/gc/v3 v3.0.0-20250105121824-520be1a3aee6 h1:JoKwHjIFumiKrjMbp1cNbC5E9UyCgA/ZcID0xOWQ2N8=
modernc.org/gc/v3 v3.0.0-20250105121824-520be1a3aee6/go.mod h1:LG5UO1Ran4OO0JRKz2oNiXhR5nNrgz0PzH7UKhz0aMU=
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.8.1 h1:HS1HRg1jEohnuONobEq2WrLEhLyw8+J42yLFTnllm2A=
modernc.org/memory v1.8.1/go.mod h1:ZbjSvMO5NQ1A2i3bWeDiVMxIorXwdClKE/0SZ+BMotU=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
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/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
modernc.org/sqlite v1.30.1 h1:YFhPVfu2iIgUf9kuA1CR7iiHdcEEsI2i+yjRYHscyxk=
modernc.org/sqlite v1.30.1/go.mod h1:DUmsiWQDaAvU4abhc/N+djlom/L2o8f7gZ95RCvyoLU=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/sqlite v1.34.4 h1:sjdARozcL5KJBvYQvLlZEmctRgW9xqIZc2ncN7PU0P8=
modernc.org/sqlite v1.34.4/go.mod h1:3QQFCG2SEMtc2nv+Wq4cQCH7Hjcg+p/RMlS1XK+zwbk=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

View File

@@ -0,0 +1,116 @@
// Package agent handles the agent's SSH server and system stats collection.
package agent
import (
"beszel"
"beszel/internal/entities/system"
"context"
"log/slog"
"os"
"strings"
"github.com/shirou/gopsutil/v4/common"
)
type Agent struct {
debug bool // true if LOG_LEVEL is set to debug
zfs bool // true if system has arcstats
memCalc string // Memory calculation formula
fsNames []string // List of filesystem device names being monitored
fsStats map[string]*system.FsStats // Keeps track of disk stats for each filesystem
netInterfaces map[string]struct{} // Stores all valid network interfaces
netIoStats system.NetIoStats // Keeps track of bandwidth usage
dockerManager *dockerManager // Manages Docker API requests
sensorsContext context.Context // Sensors context to override sys location
sensorsWhitelist map[string]struct{} // List of sensors to monitor
systemInfo system.Info // Host system info
gpuManager *GPUManager // Manages GPU data
}
func NewAgent() *Agent {
return &Agent{
sensorsContext: context.Background(),
memCalc: os.Getenv("MEM_CALC"),
fsStats: make(map[string]*system.FsStats),
}
}
func (a *Agent) Run(pubKey []byte, addr string) {
// Set up slog with a log level determined by the LOG_LEVEL env var
if logLevelStr, exists := os.LookupEnv("LOG_LEVEL"); exists {
switch strings.ToLower(logLevelStr) {
case "debug":
a.debug = true
slog.SetLogLoggerLevel(slog.LevelDebug)
case "warn":
slog.SetLogLoggerLevel(slog.LevelWarn)
case "error":
slog.SetLogLoggerLevel(slog.LevelError)
}
}
slog.Debug(beszel.Version)
// Set sensors context (allows overriding sys location for sensors)
if sysSensors, exists := os.LookupEnv("SYS_SENSORS"); exists {
slog.Info("SYS_SENSORS", "path", sysSensors)
a.sensorsContext = context.WithValue(a.sensorsContext,
common.EnvKey, common.EnvMap{common.HostSysEnvKey: sysSensors},
)
}
// Set sensors whitelist
if sensors, exists := os.LookupEnv("SENSORS"); exists {
a.sensorsWhitelist = make(map[string]struct{})
for _, sensor := range strings.Split(sensors, ",") {
if sensor != "" {
a.sensorsWhitelist[sensor] = struct{}{}
}
}
}
// initialize system info / docker manager
a.initializeSystemInfo()
a.initializeDiskInfo()
a.initializeNetIoStats()
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 a.debug {
slog.Debug("Stats", "data", a.gatherStats())
}
a.startServer(pubKey, addr)
}
func (a *Agent) gatherStats() system.CombinedData {
slog.Debug("Getting stats")
systemData := system.CombinedData{
Stats: a.getSystemStats(),
Info: a.systemInfo,
}
slog.Debug("System stats", "data", systemData)
// add docker stats
if containerStats, err := a.dockerManager.getDockerStats(); err == nil {
systemData.Containers = containerStats
slog.Debug("Docker stats", "data", systemData.Containers)
} else {
slog.Debug("Error getting docker stats", "err", err)
}
// add extra filesystems
systemData.Stats.ExtraFs = make(map[string]*system.FsStats)
for name, stats := range a.fsStats {
if !stats.Root && stats.DiskTotal > 0 {
systemData.Stats.ExtraFs[name] = stats
}
}
slog.Debug("Extra filesystems", "data", systemData.Stats.ExtraFs)
return systemData
}

View File

@@ -0,0 +1,185 @@
package agent
import (
"beszel/internal/entities/system"
"log/slog"
"time"
"os"
"path/filepath"
"strings"
"github.com/shirou/gopsutil/v4/disk"
)
// Sets up the filesystems to monitor for disk usage and I/O.
func (a *Agent) initializeDiskInfo() {
filesystem := os.Getenv("FILESYSTEM")
efPath := "/extra-filesystems"
hasRoot := false
partitions, err := disk.Partitions(false)
if err != nil {
slog.Error("Error getting disk partitions", "err", err)
}
slog.Debug("Disk", "partitions", partitions)
// ioContext := context.WithValue(a.sensorsContext,
// common.EnvKey, common.EnvMap{common.HostProcEnvKey: "/tmp/testproc"},
// )
// diskIoCounters, err := disk.IOCountersWithContext(ioContext)
diskIoCounters, err := disk.IOCounters()
if err != nil {
slog.Error("Error getting diskstats", "err", err)
}
slog.Debug("Disk I/O", "diskstats", diskIoCounters)
// Helper function to add a filesystem to fsStats if it doesn't exist
addFsStat := func(device, mountpoint string, root bool) {
key := filepath.Base(device)
var ioMatch bool
if _, exists := a.fsStats[key]; !exists {
if root {
slog.Info("Detected root device", "name", key)
// Check if root device is in /proc/diskstats, use fallback if not
if _, ioMatch = diskIoCounters[key]; !ioMatch {
key, ioMatch = findIoDevice(filesystem, diskIoCounters, a.fsStats)
if !ioMatch {
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}
}
}
// Use FILESYSTEM env var to find root filesystem
if filesystem != "" {
for _, p := range partitions {
if strings.HasSuffix(p.Device, filesystem) || p.Mountpoint == filesystem {
addFsStat(p.Device, p.Mountpoint, true)
hasRoot = true
break
}
}
if !hasRoot {
slog.Warn("Partition details not found", "filesystem", filesystem)
}
}
// Add EXTRA_FILESYSTEMS env var values to fsStats
if extraFilesystems, exists := os.LookupEnv("EXTRA_FILESYSTEMS"); exists {
for _, fs := range strings.Split(extraFilesystems, ",") {
found := false
for _, p := range partitions {
if strings.HasSuffix(p.Device, fs) || p.Mountpoint == fs {
addFsStat(p.Device, p.Mountpoint, false)
found = true
break
}
}
// if not in partitions, test if we can get disk usage
if !found {
if _, err := disk.Usage(fs); err == nil {
addFsStat(filepath.Base(fs), fs, false)
} else {
slog.Error("Invalid filesystem", "name", fs, "err", err)
}
}
}
}
// Process partitions for various mount points
for _, p := range partitions {
// fmt.Println(p.Device, p.Mountpoint)
// Binary root fallback or docker root fallback
if !hasRoot && (p.Mountpoint == "/" || (p.Mountpoint == "/etc/hosts" && strings.HasPrefix(p.Device, "/dev"))) {
fs, match := findIoDevice(filepath.Base(p.Device), diskIoCounters, a.fsStats)
if match {
addFsStat(fs, p.Mountpoint, true)
hasRoot = true
}
}
// Check if device is in /extra-filesystems
if strings.HasPrefix(p.Mountpoint, efPath) {
addFsStat(p.Device, p.Mountpoint, false)
}
}
// Check all folders in /extra-filesystems and add them if not already present
if folders, err := os.ReadDir(efPath); err == nil {
existingMountpoints := make(map[string]bool)
for _, stats := range a.fsStats {
existingMountpoints[stats.Mountpoint] = true
}
for _, folder := range folders {
if folder.IsDir() {
mountpoint := filepath.Join(efPath, folder.Name())
slog.Debug("/extra-filesystems", "mountpoint", mountpoint)
if !existingMountpoints[mountpoint] {
addFsStat(folder.Name(), mountpoint, false)
}
}
}
}
// If no root filesystem set, use fallback
if !hasRoot {
rootDevice, _ := findIoDevice(filepath.Base(filesystem), diskIoCounters, a.fsStats)
slog.Info("Root disk", "mountpoint", "/", "io", rootDevice)
a.fsStats[rootDevice] = &system.FsStats{Root: true, Mountpoint: "/"}
}
a.initializeDiskIoStats(diskIoCounters)
}
// Returns matching device from /proc/diskstats,
// or the device with the most reads if no match is found.
// 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
maxReadDevice := "/"
for _, d := range diskIoCounters {
if d.Name == filesystem || (d.Label != "" && d.Label == filesystem) {
return d.Name, true
}
if d.ReadBytes > maxReadBytes {
// don't use if device already exists in fsStats
if _, exists := fsStats[d.Name]; !exists {
maxReadBytes = d.ReadBytes
maxReadDevice = d.Name
}
}
}
return maxReadDevice, false
}
// Sets start values for disk I/O stats.
func (a *Agent) initializeDiskIoStats(diskIoCounters map[string]disk.IOCountersStat) {
for device, stats := range a.fsStats {
// skip if not in diskIoCounters
d, exists := diskIoCounters[device]
if !exists {
slog.Warn("Device not found in diskstats", "name", device)
continue
}
// populate initial values
stats.Time = time.Now()
stats.TotalRead = d.ReadBytes
stats.TotalWrite = d.WriteBytes
// add to list of valid io device names
a.fsNames = append(a.fsNames, device)
}
}

View File

@@ -0,0 +1,304 @@
package agent
import (
"beszel/internal/entities/container"
"context"
"encoding/json"
"fmt"
"log/slog"
"net"
"net/http"
"net/url"
"os"
"strings"
"sync"
"time"
"github.com/blang/semver"
)
type dockerManager struct {
client *http.Client // Client to query Docker API
wg sync.WaitGroup // WaitGroup to wait for all goroutines to finish
sem chan struct{} // Semaphore to limit concurrent container requests
containerStatsMutex sync.RWMutex // Mutex to prevent concurrent access to containerStatsMap
apiContainerList *[]container.ApiInfo // List of containers from Docker API
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
goodDockerVersion bool // Whether docker version is at least 25.0.0 (one-shot works correctly)
}
// Add goroutine to the queue
func (d *dockerManager) queue() {
d.wg.Add(1)
if d.goodDockerVersion {
d.sem <- struct{}{}
}
}
// Remove goroutine from the queue
func (d *dockerManager) dequeue() {
d.wg.Done()
if d.goodDockerVersion {
<-d.sem
}
}
// Returns stats for all running containers
func (dm *dockerManager) getDockerStats() ([]*container.Stats, error) {
resp, err := dm.client.Get("http://localhost/containers/json")
if err != nil {
return nil, err
}
defer resp.Body.Close()
if err := json.NewDecoder(resp.Body).Decode(&dm.apiContainerList); err != nil {
return nil, err
}
containersLength := len(*dm.apiContainerList)
// store valid ids to clean up old container ids from map
if dm.validIds == nil {
dm.validIds = make(map[string]struct{}, containersLength)
} else {
clear(dm.validIds)
}
var failedContainters []container.ApiInfo
for _, ctr := range *dm.apiContainerList {
ctr.IdShort = ctr.Id[:12]
dm.validIds[ctr.IdShort] = struct{}{}
// check if container is less than 1 minute old (possible restart)
// note: can't use Created field because it's not updated on restart
if strings.Contains(ctr.Status, "second") {
// if so, remove old container data
dm.deleteContainerStatsSync(ctr.IdShort)
}
dm.queue()
go func() {
defer dm.dequeue()
err := dm.updateContainerStats(ctr)
// if error, delete from map and add to failed list to retry
if err != nil {
dm.containerStatsMutex.Lock()
delete(dm.containerStatsMap, ctr.IdShort)
failedContainters = append(failedContainters, ctr)
dm.containerStatsMutex.Unlock()
}
}()
}
dm.wg.Wait()
// retry failed containers separately so we can run them in parallel (docker 24 bug)
if len(failedContainters) > 0 {
slog.Debug("Retrying failed containers", "count", len(failedContainters))
for _, ctr := range failedContainters {
dm.queue()
go func() {
defer dm.dequeue()
err = dm.updateContainerStats(ctr)
if err != nil {
slog.Error("Error getting container stats", "err", err)
}
}()
}
dm.wg.Wait()
}
// populate final stats and remove old / invalid container stats
stats := make([]*container.Stats, 0, containersLength)
for id, v := range dm.containerStatsMap {
if _, exists := dm.validIds[id]; !exists {
delete(dm.containerStatsMap, id)
} else {
stats = append(stats, v)
}
}
return stats, nil
}
// Updates stats for individual container
func (dm *dockerManager) updateContainerStats(ctr container.ApiInfo) error {
name := ctr.Names[0][1:]
resp, err := dm.client.Get("http://localhost/containers/" + ctr.IdShort + "/stats?stream=0&one-shot=1")
if err != nil {
return err
}
defer resp.Body.Close()
dm.containerStatsMutex.Lock()
defer dm.containerStatsMutex.Unlock()
// add empty values if they doesn't exist in map
stats, initialized := dm.containerStatsMap[ctr.IdShort]
if !initialized {
stats = &container.Stats{Name: name}
dm.containerStatsMap[ctr.IdShort] = stats
}
// reset current stats
stats.Cpu = 0
stats.Mem = 0
stats.NetworkSent = 0
stats.NetworkRecv = 0
// docker host container stats response
var res container.ApiStats
if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
return err
}
// check if container has valid data, otherwise may be in restart loop (#103)
if res.MemoryStats.Usage == 0 {
return fmt.Errorf("%s - no memory stats - see https://github.com/henrygd/beszel/issues/144", name)
}
// memory (https://docs.docker.com/reference/cli/docker/container/stats/)
memCache := res.MemoryStats.Stats.InactiveFile
if memCache == 0 {
memCache = res.MemoryStats.Stats.Cache
}
usedMemory := res.MemoryStats.Usage - memCache
// cpu
cpuDelta := res.CPUStats.CPUUsage.TotalUsage - stats.PrevCpu[0]
systemDelta := res.CPUStats.SystemUsage - stats.PrevCpu[1]
cpuPct := float64(cpuDelta) / float64(systemDelta) * 100
if cpuPct > 100 {
return fmt.Errorf("%s cpu pct greater than 100: %+v", name, cpuPct)
}
stats.PrevCpu = [2]uint64{res.CPUStats.CPUUsage.TotalUsage, res.CPUStats.SystemUsage}
// network
var total_sent, total_recv uint64
for _, v := range res.Networks {
total_sent += v.TxBytes
total_recv += v.RxBytes
}
var sent_delta, recv_delta float64
// prevent first run from sending all prev sent/recv bytes
if initialized {
secondsElapsed := time.Since(stats.PrevNet.Time).Seconds()
sent_delta = float64(total_sent-stats.PrevNet.Sent) / secondsElapsed
recv_delta = float64(total_recv-stats.PrevNet.Recv) / secondsElapsed
}
stats.PrevNet.Sent = total_sent
stats.PrevNet.Recv = total_recv
stats.PrevNet.Time = time.Now()
stats.Cpu = twoDecimals(cpuPct)
stats.Mem = bytesToMegabytes(float64(usedMemory))
stats.NetworkSent = bytesToMegabytes(sent_delta)
stats.NetworkRecv = bytesToMegabytes(recv_delta)
return nil
}
// Delete container stats from map using mutex
func (dm *dockerManager) deleteContainerStatsSync(id string) {
dm.containerStatsMutex.Lock()
defer dm.containerStatsMutex.Unlock()
delete(dm.containerStatsMap, id)
}
// Creates a new http client for Docker or Podman API
func newDockerManager(a *Agent) *dockerManager {
dockerHost, exists := os.LookupEnv("DOCKER_HOST")
if exists {
slog.Info("DOCKER_HOST", "host", dockerHost)
} else {
dockerHost = getDockerHost()
}
parsedURL, err := url.Parse(dockerHost)
if err != nil {
slog.Error("Error parsing DOCKER_HOST", "err", err)
os.Exit(1)
}
transport := &http.Transport{
DisableCompression: true,
MaxConnsPerHost: 0,
}
switch parsedURL.Scheme {
case "unix":
transport.DialContext = func(ctx context.Context, proto, addr string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, "unix", parsedURL.Path)
}
case "tcp", "http", "https":
transport.DialContext = func(ctx context.Context, proto, addr string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, "tcp", parsedURL.Host)
}
default:
slog.Error("Invalid DOCKER_HOST", "scheme", parsedURL.Scheme)
os.Exit(1)
}
// configurable timeout
timeout := time.Millisecond * 2100
if t, set := os.LookupEnv("DOCKER_TIMEOUT"); set {
timeout, err = time.ParseDuration(t)
if err != nil {
slog.Error(err.Error())
os.Exit(1)
}
slog.Info("DOCKER_TIMEOUT", "timeout", timeout)
}
dockerClient := &dockerManager{
client: &http.Client{
Timeout: timeout,
Transport: transport,
},
containerStatsMap: make(map[string]*container.Stats),
sem: make(chan struct{}, 5),
}
// If using podman, return client
if strings.Contains(dockerHost, "podman") {
a.systemInfo.Podman = true
dockerClient.goodDockerVersion = true
return dockerClient
}
// Check docker version
// (versions before 25.0.0 have a bug with one-shot which requires all requests to be made in one batch)
var versionInfo struct {
Version string `json:"Version"`
}
resp, err := dockerClient.client.Get("http://localhost/version")
if err != nil {
return dockerClient
}
if err := json.NewDecoder(resp.Body).Decode(&versionInfo); err != nil {
return dockerClient
}
// 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 {
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))
}
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,248 @@
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:"GUID"`
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()
// check for GPUs with the same name
nameCounts := make(map[string]int)
for _, gpu := range gm.GpuDataMap {
nameCounts[gpu.Name]++
}
// 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)
// reset the count
gpu.Count = 1
// dereference to avoid overwriting anything else
gpuCopy := *gpu
// append id to the name if there are multiple GPUs with the same name
if nameCounts[gpu.Name] > 1 {
gpuCopy.Name = fmt.Sprintf("%s %s", gpu.Name, id)
}
gpuData[id] = gpuCopy
}
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

@@ -0,0 +1,67 @@
package agent
import (
"log/slog"
"os"
"strings"
"time"
psutilNet "github.com/shirou/gopsutil/v4/net"
)
func (a *Agent) initializeNetIoStats() {
// reset valid network interfaces
a.netInterfaces = make(map[string]struct{}, 0)
// map of network interface names passed in via NICS env var
var nicsMap map[string]struct{}
nics, nicsEnvExists := os.LookupEnv("NICS")
if nicsEnvExists {
nicsMap = make(map[string]struct{}, 0)
for _, nic := range strings.Split(nics, ",") {
nicsMap[nic] = struct{}{}
}
}
// reset network I/O stats
a.netIoStats.BytesSent = 0
a.netIoStats.BytesRecv = 0
// get intial network I/O stats
if netIO, err := psutilNet.IOCounters(true); err == nil {
a.netIoStats.Time = time.Now()
for _, v := range netIO {
switch {
// skip if nics exists and the interface is not in the list
case nicsEnvExists:
if _, nameInNics := nicsMap[v.Name]; !nameInNics {
continue
}
// otherwise run the interface name through the skipNetworkInterface function
default:
if a.skipNetworkInterface(v) {
continue
}
}
slog.Info("Detected network interface", "name", v.Name, "sent", v.BytesSent, "recv", v.BytesRecv)
a.netIoStats.BytesSent += v.BytesSent
a.netIoStats.BytesRecv += v.BytesRecv
// store as a valid network interface
a.netInterfaces[v.Name] = struct{}{}
}
}
}
func (a *Agent) skipNetworkInterface(v psutilNet.IOCountersStat) bool {
switch {
case strings.HasPrefix(v.Name, "lo"),
strings.HasPrefix(v.Name, "docker"),
strings.HasPrefix(v.Name, "br-"),
strings.HasPrefix(v.Name, "veth"),
v.BytesRecv == 0,
v.BytesSent == 0:
return true
default:
return false
}
}

View File

@@ -0,0 +1,34 @@
package agent
import (
"encoding/json"
"log/slog"
"os"
sshServer "github.com/gliderlabs/ssh"
)
func (a *Agent) startServer(pubKey []byte, addr string) {
sshServer.Handle(a.handleSession)
slog.Info("Starting SSH server", "address", addr)
if err := sshServer.ListenAndServe(addr, nil, sshServer.NoPty(),
sshServer.PublicKeyAuth(func(ctx sshServer.Context, key sshServer.PublicKey) bool {
allowed, _, _, _, _ := sshServer.ParseAuthorizedKey(pubKey)
return sshServer.KeysEqual(key, allowed)
}),
); err != nil {
slog.Error("Error starting SSH server", "err", err)
os.Exit(1)
}
}
func (a *Agent) handleSession(s sshServer.Session) {
stats := a.gatherStats()
if err := json.NewEncoder(s).Encode(stats); err != nil {
slog.Error("Error encoding stats", "err", err, "stats", stats)
s.Exit(1)
return
}
s.Exit(0)
}

View File

@@ -0,0 +1,266 @@
package agent
import (
"beszel"
"beszel/internal/entities/system"
"bufio"
"fmt"
"log/slog"
"os"
"strconv"
"strings"
"time"
"github.com/shirou/gopsutil/v4/cpu"
"github.com/shirou/gopsutil/v4/disk"
"github.com/shirou/gopsutil/v4/host"
"github.com/shirou/gopsutil/v4/mem"
psutilNet "github.com/shirou/gopsutil/v4/net"
"github.com/shirou/gopsutil/v4/sensors"
)
// Sets initial / non-changing values about the host system
func (a *Agent) initializeSystemInfo() {
a.systemInfo.AgentVersion = beszel.Version
a.systemInfo.Hostname, _ = os.Hostname()
a.systemInfo.KernelVersion, _ = host.KernelVersion()
// cpu model
if info, err := cpu.Info(); err == nil && len(info) > 0 {
a.systemInfo.CpuModel = info[0].ModelName
}
// cores / threads
a.systemInfo.Cores, _ = cpu.Counts(false)
if threads, err := cpu.Counts(true); err == nil {
if threads > 0 && threads < a.systemInfo.Cores {
// in lxc logical cores reflects container limits, so use that as cores if lower
a.systemInfo.Cores = threads
} else {
a.systemInfo.Threads = threads
}
}
// zfs
if _, err := getARCSize(); err == nil {
a.zfs = true
} else {
slog.Debug("Not monitoring ZFS ARC", "err", err)
}
}
// Returns current info, stats about the host system
func (a *Agent) getSystemStats() system.Stats {
systemStats := system.Stats{}
// cpu percent
cpuPct, err := cpu.Percent(0, false)
if err != nil {
slog.Error("Error getting cpu percent", "err", err)
} else if len(cpuPct) > 0 {
systemStats.Cpu = twoDecimals(cpuPct[0])
}
// memory
if v, err := mem.VirtualMemory(); err == nil {
// swap
systemStats.Swap = bytesToGigabytes(v.SwapTotal)
systemStats.SwapUsed = bytesToGigabytes(v.SwapTotal - v.SwapFree - v.SwapCached)
// cache + buffers value for default mem calculation
cacheBuff := v.Total - v.Free - v.Used
// htop memory calculation overrides
if a.memCalc == "htop" {
// note: gopsutil automatically adds SReclaimable to v.Cached
cacheBuff = v.Cached + v.Buffers - v.Shared
v.Used = v.Total - (v.Free + cacheBuff)
v.UsedPercent = float64(v.Used) / float64(v.Total) * 100.0
}
// subtract ZFS ARC size from used memory and add as its own category
if a.zfs {
if arcSize, _ := getARCSize(); arcSize > 0 && arcSize < v.Used {
v.Used = v.Used - arcSize
v.UsedPercent = float64(v.Used) / float64(v.Total) * 100.0
systemStats.MemZfsArc = bytesToGigabytes(arcSize)
}
}
systemStats.Mem = bytesToGigabytes(v.Total)
systemStats.MemBuffCache = bytesToGigabytes(cacheBuff)
systemStats.MemUsed = bytesToGigabytes(v.Used)
systemStats.MemPct = twoDecimals(v.UsedPercent)
}
// disk usage
for _, stats := range a.fsStats {
if d, err := disk.Usage(stats.Mountpoint); err == nil {
stats.DiskTotal = bytesToGigabytes(d.Total)
stats.DiskUsed = bytesToGigabytes(d.Used)
if stats.Root {
systemStats.DiskTotal = bytesToGigabytes(d.Total)
systemStats.DiskUsed = bytesToGigabytes(d.Used)
systemStats.DiskPct = twoDecimals(d.UsedPercent)
}
} else {
// reset stats if error (likely unmounted)
slog.Error("Error getting disk stats", "name", stats.Mountpoint, "err", err)
stats.DiskTotal = 0
stats.DiskUsed = 0
stats.TotalRead = 0
stats.TotalWrite = 0
}
}
// disk i/o
if ioCounters, err := disk.IOCounters(a.fsNames...); err == nil {
for _, d := range ioCounters {
stats := a.fsStats[d.Name]
if stats == nil {
continue
}
secondsElapsed := time.Since(stats.Time).Seconds()
readPerSecond := bytesToMegabytes(float64(d.ReadBytes-stats.TotalRead) / 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.DiskReadPs = readPerSecond
stats.DiskWritePs = writePerSecond
stats.TotalRead = d.ReadBytes
stats.TotalWrite = d.WriteBytes
// if root filesystem, update system stats
if stats.Root {
systemStats.DiskReadPs = stats.DiskReadPs
systemStats.DiskWritePs = stats.DiskWritePs
}
}
}
// network stats
if netIO, err := psutilNet.IOCounters(true); err == nil {
secondsElapsed := time.Since(a.netIoStats.Time).Seconds()
a.netIoStats.Time = time.Now()
bytesSent := uint64(0)
bytesRecv := uint64(0)
// sum all bytes sent and received
for _, v := range netIO {
// skip if not in valid network interfaces list
if _, exists := a.netInterfaces[v.Name]; !exists {
continue
}
bytesSent += v.BytesSent
bytesRecv += v.BytesRecv
}
// add to systemStats
sentPerSecond := float64(bytesSent-a.netIoStats.BytesSent) / secondsElapsed
recvPerSecond := float64(bytesRecv-a.netIoStats.BytesRecv) / secondsElapsed
networkSentPs := bytesToMegabytes(sentPerSecond)
networkRecvPs := bytesToMegabytes(recvPerSecond)
// add check for issue (#150) where sent is a massive number
if networkSentPs > 10_000 || networkRecvPs > 10_000 {
slog.Warn("Invalid net stats. Resetting.", "sent", networkSentPs, "recv", networkRecvPs)
for _, v := range netIO {
if _, exists := a.netInterfaces[v.Name]; !exists {
continue
}
slog.Info(v.Name, "recv", v.BytesRecv, "sent", v.BytesSent)
}
// reset network I/O stats
a.initializeNetIoStats()
} else {
systemStats.NetworkSent = networkSentPs
systemStats.NetworkRecv = networkRecvPs
// update netIoStats
a.netIoStats.BytesSent = bytesSent
a.netIoStats.BytesRecv = bytesRecv
}
}
// temperatures (skip if sensors whitelist is set to empty string)
if a.sensorsWhitelist != nil && len(a.sensorsWhitelist) == 0 {
slog.Debug("Skipping temperature collection")
} else {
temps, err := sensors.TemperaturesWithContext(a.sensorsContext)
if err != nil {
slog.Debug("Sensor error", "err", err)
}
slog.Debug("Temperature", "sensors", temps)
if len(temps) > 0 {
systemStats.Temperatures = make(map[string]float64, len(temps))
for i, sensor := range temps {
// skip if temperature is 0
if sensor.Temperature <= 0 || sensor.Temperature >= 200 {
continue
}
if _, ok := systemStats.Temperatures[sensor.SensorKey]; ok {
// if key already exists, append int to key
systemStats.Temperatures[sensor.SensorKey+"_"+strconv.Itoa(i)] = twoDecimals(sensor.Temperature)
} else {
systemStats.Temperatures[sensor.SensorKey] = twoDecimals(sensor.Temperature)
}
}
// remove sensors from systemStats if whitelist exists and sensor is not in whitelist
// (do this here instead of in initial loop so we have correct keys if int was appended)
if a.sensorsWhitelist != nil {
for key := range systemStats.Temperatures {
if _, nameInWhitelist := a.sensorsWhitelist[key]; !nameInWhitelist {
delete(systemStats.Temperatures, key)
}
}
}
}
}
// 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
a.systemInfo.Cpu = systemStats.Cpu
a.systemInfo.MemPct = systemStats.MemPct
a.systemInfo.DiskPct = systemStats.DiskPct
a.systemInfo.Uptime, _ = host.Uptime()
a.systemInfo.Bandwidth = twoDecimals(systemStats.NetworkSent + systemStats.NetworkRecv)
slog.Debug("sysinfo", "data", a.systemInfo)
return systemStats
}
// Returns the size of the ZFS ARC memory cache in bytes
func getARCSize() (uint64, error) {
file, err := os.Open("/proc/spl/kstat/zfs/arcstats")
if err != nil {
return 0, err
}
defer file.Close()
// Scan the lines
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "size") {
// Example line: size 4 15032385536
fields := strings.Fields(line)
if len(fields) < 3 {
return 0, err
}
// Return the size as uint64
return strconv.ParseUint(fields[2], 10, 64)
}
}
return 0, fmt.Errorf("failed to parse size field")
}

View File

@@ -1,6 +1,7 @@
package main
package agent
import (
"beszel"
"fmt"
"os"
"strings"
@@ -9,11 +10,12 @@ import (
"github.com/rhysd/go-github-selfupdate/selfupdate"
)
func updateBeszel() {
// Update updates beszel-agent to the latest version
func Update() {
var latest *selfupdate.Release
var found bool
var err error
currentVersion := semver.MustParse(Version)
currentVersion := semver.MustParse(beszel.Version)
fmt.Println("beszel-agent", currentVersion)
fmt.Println("Checking for updates...")
updater, _ := selfupdate.NewUpdater(selfupdate.Config{

View File

@@ -0,0 +1,15 @@
package agent
import "math"
func bytesToMegabytes(b float64) float64 {
return twoDecimals(b / 1048576)
}
func bytesToGigabytes(b uint64) float64 {
return twoDecimals(float64(b) / 1073741824)
}
func twoDecimals(value float64) float64 {
return math.Round(value*100) / 100
}

View File

@@ -0,0 +1,529 @@
// Package alerts handles alert management and delivery.
package alerts
import (
"beszel/internal/entities/system"
"fmt"
"net/mail"
"net/url"
"strings"
"time"
"github.com/containrrr/shoutrrr"
"github.com/goccy/go-json"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/mailer"
"github.com/pocketbase/pocketbase/tools/types"
"github.com/spf13/cast"
)
type AlertManager struct {
app *pocketbase.PocketBase
}
type AlertMessageData struct {
UserID string
Title string
Message string
Link string
LinkText string
}
type UserNotificationSettings struct {
Emails []string `json:"emails"`
Webhooks []string `json:"webhooks"`
}
type SystemAlertStats struct {
Cpu float64 `json:"cpu"`
Mem float64 `json:"mp"`
Disk float64 `json:"dp"`
NetSent float64 `json:"ns"`
NetRecv float64 `json:"nr"`
Temperatures map[string]float32 `json:"t"`
}
type SystemAlertData struct {
systemRecord *core.Record
alertRecord *core.Record
name string
unit string
val float64
threshold float64
triggered bool
time time.Time
count uint8
min uint8
mapSums map[string]float32
descriptor string // override descriptor in notification body (for temp sensor, disk partition, etc)
}
func NewAlertManager(app *pocketbase.PocketBase) *AlertManager {
return &AlertManager{
app: app,
}
}
func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, systemInfo system.Info, temperatures map[string]float64, extraFs map[string]*system.FsStats) error {
// start := time.Now()
// defer func() {
// log.Println("alert stats took", time.Since(start))
// }()
alertRecords, err := am.app.FindAllRecords("alerts",
dbx.NewExp("system={:system}", dbx.Params{"system": systemRecord.Id}),
)
if err != nil || len(alertRecords) == 0 {
// log.Println("no alerts found for system")
return nil
}
var validAlerts []SystemAlertData
now := systemRecord.GetDateTime("updated").Time().UTC()
oldestTime := now
for _, alertRecord := range alertRecords {
name := alertRecord.GetString("name")
var val float64
unit := "%"
switch name {
case "CPU":
val = systemInfo.Cpu
case "Memory":
val = systemInfo.MemPct
case "Bandwidth":
val = systemInfo.Bandwidth
unit = " MB/s"
case "Disk":
maxUsedPct := systemInfo.DiskPct
for _, fs := range extraFs {
usedPct := fs.DiskUsed / fs.DiskTotal * 100
if usedPct > maxUsedPct {
maxUsedPct = usedPct
}
}
val = maxUsedPct
case "Temperature":
if temperatures == nil {
continue
}
for _, temp := range temperatures {
if temp > val {
val = temp
}
}
unit = "°C"
}
triggered := alertRecord.GetBool("triggered")
threshold := alertRecord.GetFloat("value")
// CONTINUE
// IF alert is not triggered and curValue is less than threshold
// OR alert is triggered and curValue is greater than threshold
if (!triggered && val <= threshold) || (triggered && val > threshold) {
// log.Printf("Skipping alert %s: val %f | threshold %f | triggered %v\n", name, val, threshold, triggered)
continue
}
min := max(1, cast.ToUint8(alertRecord.Get("min")))
// add time to alert time to make sure it's slighty after record creation
time := now.Add(-time.Duration(min) * time.Minute)
if time.Before(oldestTime) {
oldestTime = time
}
validAlerts = append(validAlerts, SystemAlertData{
systemRecord: systemRecord,
alertRecord: alertRecord,
name: name,
unit: unit,
val: val,
threshold: threshold,
triggered: triggered,
time: time,
min: min,
})
}
systemStats := []struct {
Stats []byte `db:"stats"`
Created types.DateTime `db:"created"`
}{}
err = am.app.DB().
Select("stats", "created").
From("system_stats").
Where(dbx.NewExp(
"system={:system} AND type='1m' AND created > {:created}",
dbx.Params{
"system": systemRecord.Id,
// subtract some time to give us a bit of buffer
"created": oldestTime.Add(-time.Second * 90),
},
)).
OrderBy("created").
All(&systemStats)
if err != nil {
return err
}
// get oldest record creation time from first record in the slice
oldestRecordTime := systemStats[0].Created.Time()
// log.Println("oldestRecordTime", oldestRecordTime.String())
// delete from validAlerts if time is older than oldestRecord
for i := 0; i < len(validAlerts); i++ {
if validAlerts[i].time.Before(oldestRecordTime) {
// log.Println("deleting alert - time is older than oldestRecord", validAlerts[i].name, oldestRecordTime, validAlerts[i].time)
validAlerts = append(validAlerts[:i], validAlerts[i+1:]...)
}
}
if len(validAlerts) == 0 {
// log.Println("no valid alerts found")
return nil
}
var stats SystemAlertStats
// we can skip the latest systemStats record since it's the current value
for i := 0; i < len(systemStats); i++ {
stat := systemStats[i]
// subtract 10 seconds to give a small time buffer
systemStatsCreation := stat.Created.Time().Add(-time.Second * 10)
if err := json.Unmarshal(stat.Stats, &stats); err != nil {
return err
}
// log.Println("stats", stats)
for j := range validAlerts {
alert := &validAlerts[j]
// reset alert val on first iteration
if i == 0 {
alert.val = 0
}
// continue if system_stats is older than alert time range
if systemStatsCreation.Before(alert.time) {
continue
}
// add to alert value
switch alert.name {
case "CPU":
alert.val += stats.Cpu
case "Memory":
alert.val += stats.Mem
case "Bandwidth":
alert.val += stats.NetSent + stats.NetRecv
case "Disk":
if alert.mapSums == nil {
alert.mapSums = make(map[string]float32, len(extraFs)+1)
}
// add root disk
if _, ok := alert.mapSums["root"]; !ok {
alert.mapSums["root"] = 0.0
}
alert.mapSums["root"] += float32(stats.Disk)
// add extra disks
for key, fs := range extraFs {
if _, ok := alert.mapSums[key]; !ok {
alert.mapSums[key] = 0.0
}
alert.mapSums[key] += float32(fs.DiskUsed / fs.DiskTotal * 100)
}
case "Temperature":
if alert.mapSums == nil {
alert.mapSums = make(map[string]float32, len(stats.Temperatures))
}
for key, temp := range stats.Temperatures {
if _, ok := alert.mapSums[key]; !ok {
alert.mapSums[key] = float32(0)
}
alert.mapSums[key] += temp
}
default:
continue
}
alert.count++
}
}
// sum up vals for each alert
for _, alert := range validAlerts {
switch alert.name {
case "Disk":
maxPct := float32(0)
for key, value := range alert.mapSums {
sumPct := float32(value)
if sumPct > maxPct {
maxPct = sumPct
alert.descriptor = fmt.Sprintf("Usage of %s", key)
}
}
alert.val = float64(maxPct / float32(alert.count))
case "Temperature":
maxTemp := float32(0)
for key, value := range alert.mapSums {
sumTemp := float32(value) / float32(alert.count)
if sumTemp > maxTemp {
maxTemp = sumTemp
alert.descriptor = fmt.Sprintf("Highest sensor %s", key)
}
}
alert.val = float64(maxTemp)
default:
alert.val = alert.val / float64(alert.count)
}
minCount := float32(alert.min) / 1.2
// log.Println("alert", alert.name, "val", alert.val, "threshold", alert.threshold, "triggered", alert.triggered)
// log.Printf("%s: val %f | count %d | min-count %f | threshold %f\n", alert.name, alert.val, alert.count, minCount, alert.threshold)
// pass through alert if count is greater than or equal to minCount
if float32(alert.count) >= minCount {
if !alert.triggered && alert.val > alert.threshold {
alert.triggered = true
go am.sendSystemAlert(alert)
} else if alert.triggered && alert.val <= alert.threshold {
alert.triggered = false
go am.sendSystemAlert(alert)
}
}
}
return nil
}
func (am *AlertManager) sendSystemAlert(alert SystemAlertData) {
// log.Printf("Sending alert %s: val %f | count %d | threshold %f\n", alert.name, alert.val, alert.count, alert.threshold)
systemName := alert.systemRecord.GetString("name")
// change Disk to Disk usage
if alert.name == "Disk" {
alert.name += " usage"
}
// make title alert name lowercase if not CPU
titleAlertName := alert.name
if titleAlertName != "CPU" {
titleAlertName = strings.ToLower(titleAlertName)
}
var subject string
if alert.triggered {
subject = fmt.Sprintf("%s %s above threshold", systemName, titleAlertName)
} else {
subject = fmt.Sprintf("%s %s below threshold", systemName, titleAlertName)
}
minutesLabel := "minute"
if alert.min > 1 {
minutesLabel += "s"
}
if alert.descriptor == "" {
alert.descriptor = alert.name
}
body := fmt.Sprintf("%s averaged %.2f%s for the previous %v %s.", alert.descriptor, alert.val, alert.unit, alert.min, minutesLabel)
alert.alertRecord.Set("triggered", alert.triggered)
if err := am.app.Save(alert.alertRecord); err != nil {
// app.Logger().Error("failed to save alert record", "err", err.Error())
return
}
// expand the user relation and send the alert
if errs := am.app.ExpandRecord(alert.alertRecord, []string{"user"}, nil); len(errs) > 0 {
// app.Logger().Error("failed to expand user relation", "errs", errs)
return
}
if user := alert.alertRecord.ExpandedOne("user"); user != nil {
am.sendAlert(AlertMessageData{
UserID: user.Id,
Title: subject,
Message: body,
Link: am.app.Settings().Meta.AppURL + "/system/" + url.PathEscape(systemName),
LinkText: "View " + systemName,
})
}
}
// todo: allow x minutes downtime before sending alert
func (am *AlertManager) HandleStatusAlerts(newStatus string, oldSystemRecord *core.Record) error {
var alertStatus string
switch newStatus {
case "up":
if oldSystemRecord.GetString("status") == "down" {
alertStatus = "up"
}
case "down":
if oldSystemRecord.GetString("status") == "up" {
alertStatus = "down"
}
}
if alertStatus == "" {
return nil
}
// check if use
alertRecords, err := am.app.FindAllRecords("alerts",
dbx.HashExp{
"system": oldSystemRecord.Id,
"name": "Status",
},
)
if err != nil || len(alertRecords) == 0 {
// log.Println("no alerts found for system")
return nil
}
for _, alertRecord := range alertRecords {
// expand the user relation
if errs := am.app.ExpandRecord(alertRecord, []string{"user"}, nil); len(errs) > 0 {
return fmt.Errorf("failed to expand: %v", errs)
}
user := alertRecord.ExpandedOne("user")
if user == nil {
return nil
}
emoji := "\U0001F534"
if alertStatus == "up" {
emoji = "\u2705"
}
// send alert
systemName := oldSystemRecord.GetString("name")
am.sendAlert(AlertMessageData{
UserID: user.Id,
Title: fmt.Sprintf("Connection to %s is %s %v", systemName, alertStatus, emoji),
Message: fmt.Sprintf("Connection to %s is %s", systemName, alertStatus),
Link: am.app.Settings().Meta.AppURL + "/system/" + url.PathEscape(systemName),
LinkText: "View " + systemName,
})
}
return nil
}
func (am *AlertManager) sendAlert(data AlertMessageData) {
// get user settings
record, err := am.app.FindFirstRecordByFilter(
"user_settings", "user={:user}",
dbx.Params{"user": data.UserID},
)
if err != nil {
am.app.Logger().Error("Failed to get user settings", "err", err.Error())
return
}
// unmarshal user settings
userAlertSettings := UserNotificationSettings{
Emails: []string{},
Webhooks: []string{},
}
if err := record.UnmarshalJSONField("settings", &userAlertSettings); err != nil {
am.app.Logger().Error("Failed to unmarshal user settings", "err", err.Error())
}
// send alerts via webhooks
for _, webhook := range userAlertSettings.Webhooks {
if err := am.SendShoutrrrAlert(webhook, data.Title, data.Message, data.Link, data.LinkText); err != nil {
am.app.Logger().Error("Failed to send shoutrrr alert", "err", err.Error())
}
}
// send alerts via email
if len(userAlertSettings.Emails) == 0 {
// log.Println("No email addresses found")
return
}
addresses := []mail.Address{}
for _, email := range userAlertSettings.Emails {
addresses = append(addresses, mail.Address{Address: email})
}
message := mailer.Message{
To: addresses,
Subject: data.Title,
Text: data.Message + fmt.Sprintf("\n\n%s", data.Link),
From: mail.Address{
Address: am.app.Settings().Meta.SenderAddress,
Name: am.app.Settings().Meta.SenderName,
},
}
if err := am.app.NewMailClient().Send(&message); err != nil {
am.app.Logger().Error("Failed to send alert: ", "err", err.Error())
} else {
am.app.Logger().Info("Sent email alert", "to", message.To, "subj", message.Subject)
}
}
// SendShoutrrrAlert sends an alert via a Shoutrrr URL
func (am *AlertManager) SendShoutrrrAlert(notificationUrl, title, message, link, linkText string) error {
// services that support title param
supportsTitle := []string{"bark", "discord", "gotify", "ifttt", "join", "matrix", "ntfy", "opsgenie", "pushbullet", "pushover", "slack", "teams", "telegram", "zulip"}
// Parse the URL
parsedURL, err := url.Parse(notificationUrl)
if err != nil {
return fmt.Errorf("error parsing URL: %v", err)
}
scheme := parsedURL.Scheme
queryParams := parsedURL.Query()
// Add title
if sliceContains(supportsTitle, scheme) {
queryParams.Add("title", title)
} else if scheme == "mattermost" {
// use markdown title for mattermost
message = "##### " + title + "\n\n" + message
} else if scheme == "generic" && queryParams.Has("template") {
// add title as property if using generic with template json
titleKey := queryParams.Get("titlekey")
if titleKey == "" {
titleKey = "title"
}
queryParams.Add("$"+titleKey, title)
} else {
// otherwise just add title to message
message = title + "\n\n" + message
}
// Add link
if scheme == "ntfy" {
// if ntfy, add link to actions
queryParams.Add("Actions", fmt.Sprintf("view, %s, %s", linkText, link))
} else {
// else add link directly to the message
message += "\n\n" + link
}
// Encode the modified query parameters back into the URL
parsedURL.RawQuery = queryParams.Encode()
// log.Println("URL after modification:", parsedURL.String())
err = shoutrrr.Send(parsedURL.String(), message)
if err == nil {
am.app.Logger().Info("Sent shoutrrr alert", "title", title)
} else {
am.app.Logger().Error("Error sending shoutrrr alert", "err", err.Error())
return err
}
return nil
}
// Contains checks if a string is present in a slice of strings
func sliceContains(slice []string, item string) bool {
for _, v := range slice {
if v == item {
return true
}
}
return false
}
func (am *AlertManager) SendTestNotification(e *core.RequestEvent) error {
info, _ := e.RequestInfo()
if info.Auth == nil {
return apis.NewForbiddenError("Forbidden", nil)
}
url := e.Request.URL.Query().Get("url")
// log.Println("url", url)
if url == "" {
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")
if err != nil {
return e.JSON(200, map[string]string{"err": err.Error()})
}
return e.JSON(200, map[string]bool{"err": false})
}

View File

@@ -0,0 +1,134 @@
package container
import "time"
// Docker container info from /containers/json
type ApiInfo struct {
Id string
IdShort string
Names []string
Status string
// Image string
// ImageID string
// Command string
// Created int64
// Ports []Port
// SizeRw int64 `json:",omitempty"`
// SizeRootFs int64 `json:",omitempty"`
// Labels map[string]string
// State string
// HostConfig struct {
// NetworkMode string `json:",omitempty"`
// Annotations map[string]string `json:",omitempty"`
// }
// NetworkSettings *SummaryNetworkSettings
// Mounts []MountPoint
}
// Docker container resources from /containers/{id}/stats
type ApiStats struct {
// Common stats
// Read time.Time `json:"read"`
// PreRead time.Time `json:"preread"`
// Linux specific stats, not populated on Windows.
// PidsStats PidsStats `json:"pids_stats,omitempty"`
// BlkioStats BlkioStats `json:"blkio_stats,omitempty"`
// Windows specific stats, not populated on Linux.
// NumProcs uint32 `json:"num_procs"`
// StorageStats StorageStats `json:"storage_stats,omitempty"`
// Networks request version >=1.21
Networks map[string]NetworkStats
// Shared stats
CPUStats CPUStats `json:"cpu_stats,omitempty"`
// PreCPUStats CPUStats `json:"precpu_stats,omitempty"` // "Pre"="Previous"
MemoryStats MemoryStats `json:"memory_stats,omitempty"`
}
type CPUStats struct {
// CPU Usage. Linux and Windows.
CPUUsage CPUUsage `json:"cpu_usage"`
// System Usage. Linux only.
SystemUsage uint64 `json:"system_cpu_usage,omitempty"`
// Online CPUs. Linux only.
// OnlineCPUs uint32 `json:"online_cpus,omitempty"`
// Throttling Data. Linux only.
// ThrottlingData ThrottlingData `json:"throttling_data,omitempty"`
}
type CPUUsage struct {
// Total CPU time consumed.
// Units: nanoseconds (Linux)
// Units: 100's of nanoseconds (Windows)
TotalUsage uint64 `json:"total_usage"`
// Total CPU time consumed per core (Linux). Not used on Windows.
// Units: nanoseconds.
// PercpuUsage []uint64 `json:"percpu_usage,omitempty"`
// Time spent by tasks of the cgroup in kernel mode (Linux).
// Time spent by all container processes in kernel mode (Windows).
// Units: nanoseconds (Linux).
// Units: 100's of nanoseconds (Windows). Not populated for Hyper-V Containers.
// UsageInKernelmode uint64 `json:"usage_in_kernelmode"`
// Time spent by tasks of the cgroup in user mode (Linux).
// Time spent by all container processes in user mode (Windows).
// Units: nanoseconds (Linux).
// Units: 100's of nanoseconds (Windows). Not populated for Hyper-V Containers
// UsageInUsermode uint64 `json:"usage_in_usermode"`
}
type MemoryStats struct {
// current res_counter usage for memory
Usage uint64 `json:"usage,omitempty"`
// all the stats exported via memory.stat.
Stats MemoryStatsStats `json:"stats,omitempty"`
// maximum usage ever recorded.
// MaxUsage uint64 `json:"max_usage,omitempty"`
// TODO(vishh): Export these as stronger types.
// number of times memory usage hits limits.
// Failcnt uint64 `json:"failcnt,omitempty"`
// Limit uint64 `json:"limit,omitempty"`
// // committed bytes
// Commit uint64 `json:"commitbytes,omitempty"`
// // peak committed bytes
// CommitPeak uint64 `json:"commitpeakbytes,omitempty"`
// // private working set
// PrivateWorkingSet uint64 `json:"privateworkingset,omitempty"`
}
type MemoryStatsStats struct {
Cache uint64 `json:"cache,omitempty"`
InactiveFile uint64 `json:"inactive_file,omitempty"`
}
type NetworkStats struct {
// Bytes received. Windows and Linux.
RxBytes uint64 `json:"rx_bytes"`
// Bytes sent. Windows and Linux.
TxBytes uint64 `json:"tx_bytes"`
}
type prevNetStats struct {
Sent uint64
Recv uint64
Time time.Time
}
// Docker container stats
type Stats struct {
Name string `json:"n"`
Cpu float64 `json:"c"`
Mem float64 `json:"m"`
NetworkSent float64 `json:"ns"`
NetworkRecv float64 `json:"nr"`
PrevCpu [2]uint64 `json:"-"`
PrevNet prevNetStats `json:"-"`
}

View File

@@ -0,0 +1,85 @@
package system
import (
"beszel/internal/entities/container"
"time"
)
type Stats struct {
Cpu float64 `json:"cpu"`
MaxCpu float64 `json:"cpum,omitempty"`
Mem float64 `json:"m"`
MemUsed float64 `json:"mu"`
MemPct float64 `json:"mp"`
MemBuffCache float64 `json:"mb"`
MemZfsArc float64 `json:"mz,omitempty"` // ZFS ARC memory
Swap float64 `json:"s,omitempty"`
SwapUsed float64 `json:"su,omitempty"`
DiskTotal float64 `json:"d"`
DiskUsed float64 `json:"du"`
DiskPct float64 `json:"dp"`
DiskReadPs float64 `json:"dr"`
DiskWritePs float64 `json:"dw"`
MaxDiskReadPs float64 `json:"drm,omitempty"`
MaxDiskWritePs float64 `json:"dwm,omitempty"`
NetworkSent float64 `json:"ns"`
NetworkRecv float64 `json:"nr"`
MaxNetworkSent float64 `json:"nsm,omitempty"`
MaxNetworkRecv float64 `json:"nrm,omitempty"`
Temperatures map[string]float64 `json:"t,omitempty"`
ExtraFs map[string]*FsStats `json:"efs,omitempty"`
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 {
Time time.Time `json:"-"`
Root bool `json:"-"`
Mountpoint string `json:"-"`
DiskTotal float64 `json:"d"`
DiskUsed float64 `json:"du"`
TotalRead uint64 `json:"-"`
TotalWrite uint64 `json:"-"`
DiskReadPs float64 `json:"r"`
DiskWritePs float64 `json:"w"`
MaxDiskReadPS float64 `json:"rm,omitempty"`
MaxDiskWritePS float64 `json:"wm,omitempty"`
}
type NetIoStats struct {
BytesRecv uint64
BytesSent uint64
Time time.Time
Name string
}
type Info struct {
Hostname string `json:"h"`
KernelVersion string `json:"k,omitempty"`
Cores int `json:"c"`
Threads int `json:"t,omitempty"`
CpuModel string `json:"m"`
Uptime uint64 `json:"u"`
Cpu float64 `json:"cpu"`
MemPct float64 `json:"mp"`
DiskPct float64 `json:"dp"`
Bandwidth float64 `json:"b"`
AgentVersion string `json:"v"`
Podman bool `json:"p,omitempty"`
}
// Final data structure to return to the hub
type CombinedData struct {
Stats Stats `json:"stats"`
Info Info `json:"info"`
Containers []*container.Stats `json:"container"`
}

View File

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

530
beszel/internal/hub/hub.go Normal file
View File

@@ -0,0 +1,530 @@
// Package hub handles updating systems and serving the web UI.
package hub
import (
"beszel"
"beszel/internal/alerts"
"beszel/internal/entities/system"
"beszel/internal/records"
"beszel/internal/users"
"beszel/site"
"context"
"crypto/ed25519"
"encoding/pem"
"fmt"
"log"
"net"
"net/http"
"net/http/httputil"
"net/url"
"os"
"strings"
"sync"
"time"
"github.com/goccy/go-json"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/plugins/migratecmd"
"golang.org/x/crypto/ssh"
)
type Hub struct {
app *pocketbase.PocketBase
systemConnections sync.Map
sshClientConfig *ssh.ClientConfig
pubKey string
am *alerts.AlertManager
um *users.UserManager
rm *records.RecordManager
systemStats *core.Collection
containerStats *core.Collection
}
func NewHub(app *pocketbase.PocketBase) *Hub {
return &Hub{
app: app,
am: alerts.NewAlertManager(app),
um: users.NewUserManager(app),
rm: records.NewRecordManager(app),
}
}
func (h *Hub) Run() {
// loosely check if it was executed using "go run"
isGoRun := strings.HasPrefix(os.Args[0], os.TempDir())
// enable auto creation of migration files when making collection changes in the Admin UI
migratecmd.MustRegister(h.app, h.app.RootCmd, migratecmd.Config{
// (the isGoRun check is to enable it only during development)
Automigrate: isGoRun,
Dir: "../../migrations",
})
// initial setup
h.app.OnServe().BindFunc(func(se *core.ServeEvent) error {
// create ssh client config
err := h.createSSHClientConfig()
if err != nil {
log.Fatal(err)
}
// set general settings
settings := h.app.Settings()
// batch requests (for global alerts)
settings.Batch.Enabled = true
// set auth settings
usersCollection, err := h.app.FindCollectionByNameOrId("users")
if err != nil {
return err
}
// disable email auth if DISABLE_PASSWORD_AUTH env var is set
usersCollection.PasswordAuth.Enabled = os.Getenv("DISABLE_PASSWORD_AUTH") != "true"
usersCollection.PasswordAuth.IdentityFields = []string{"email"}
// disable oauth if no providers are configured (todo: remove this in post 0.9.0 release)
if usersCollection.OAuth2.Enabled {
usersCollection.OAuth2.Enabled = len(usersCollection.OAuth2.Providers) > 0
}
// allow oauth user creation if USER_CREATION is set
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
}
// sync systems with config
h.syncSystemsWithConfig()
return se.Next()
})
// serve web ui
h.app.OnServe().BindFunc(func(se *core.ServeEvent) error {
switch isGoRun {
case true:
proxy := httputil.NewSingleHostReverseProxy(&url.URL{
Scheme: "http",
Host: "localhost:5173",
})
se.Router.Any("/", func(e *core.RequestEvent) error {
proxy.ServeHTTP(e.Response, e.Request)
return nil
})
default:
csp, cspExists := os.LookupEnv("CSP")
se.Router.Any("/{path...}", func(e *core.RequestEvent) error {
if cspExists {
e.Response.Header().Del("X-Frame-Options")
e.Response.Header().Set("Content-Security-Policy", csp)
}
indexFallback := !strings.HasPrefix(e.Request.URL.Path, "/static/")
return apis.Static(site.DistDirFS, indexFallback)(e)
})
}
return se.Next()
})
// set up scheduled jobs / ticker for system updates
h.app.OnServe().BindFunc(func(se *core.ServeEvent) error {
// 15 second ticker for system updates
go h.startSystemUpdateTicker()
// set up cron jobs
// delete old records once every hour
h.app.Cron().MustAdd("delete old records", "8 * * * *", h.rm.DeleteOldRecords)
// create longer records every 10 minutes
h.app.Cron().MustAdd("create longer records", "*/10 * * * *", func() {
if systemStats, containerStats, err := h.getCollections(); err == nil {
h.rm.CreateLongerRecords([]*core.Collection{systemStats, containerStats})
}
})
return se.Next()
})
// custom api routes
h.app.OnServe().BindFunc(func(se *core.ServeEvent) error {
// returns public key
se.Router.GET("/api/beszel/getkey", func(e *core.RequestEvent) error {
info, _ := e.RequestInfo()
if info.Auth == nil {
return apis.NewForbiddenError("Forbidden", nil)
}
return e.JSON(http.StatusOK, map[string]string{"key": h.pubKey, "v": beszel.Version})
})
// check if first time setup on login page
se.Router.GET("/api/beszel/first-run", func(e *core.RequestEvent) error {
total, err := h.app.CountRecords("users")
return e.JSON(http.StatusOK, map[string]bool{"firstRun": err == nil && total == 0})
})
// send test notification
se.Router.GET("/api/beszel/send-test-notification", h.am.SendTestNotification)
// API endpoint to get config.yml content
se.Router.GET("/api/beszel/config-yaml", h.getYamlConfig)
// 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
h.app.OnRecordCreate("systems").BindFunc(func(e *core.RecordEvent) error {
e.Record.Set("info", system.Info{})
e.Record.Set("status", "pending")
return e.Next()
})
// immediately create connection for new systems
h.app.OnRecordAfterCreateSuccess("systems").BindFunc(func(e *core.RecordEvent) error {
go h.updateSystem(e.Record)
return e.Next()
})
// handle default values for user / user_settings creation
h.app.OnRecordCreate("users").BindFunc(h.um.InitializeUserRole)
h.app.OnRecordCreate("user_settings").BindFunc(h.um.InitializeUserSettings)
// empty info for systems that are paused
h.app.OnRecordUpdate("systems").BindFunc(func(e *core.RecordEvent) error {
if e.Record.GetString("status") == "paused" {
e.Record.Set("info", system.Info{})
}
return e.Next()
})
// do things after a systems record is updated
h.app.OnRecordAfterUpdateSuccess("systems").BindFunc(func(e *core.RecordEvent) error {
newRecord := e.Record.Fresh()
oldRecord := newRecord.Original()
newStatus := newRecord.GetString("status")
// if system is disconnected and connection exists, remove it
if newStatus == "down" || newStatus == "paused" {
h.deleteSystemConnection(newRecord)
}
// if system is set to pending (unpause), try to connect immediately
if newStatus == "pending" {
go h.updateSystem(newRecord)
} else {
h.am.HandleStatusAlerts(newStatus, oldRecord)
}
return e.Next()
})
// if system is deleted, close connection
h.app.OnRecordAfterDeleteSuccess("systems").BindFunc(func(e *core.RecordEvent) error {
h.deleteSystemConnection(e.Record)
return e.Next()
})
if err := h.app.Start(); err != nil {
log.Fatal(err)
}
}
func (h *Hub) startSystemUpdateTicker() {
c := time.Tick(15 * time.Second)
for range c {
h.updateSystems()
}
}
func (h *Hub) updateSystems() {
records, err := h.app.FindRecordsByFilter(
"2hz5ncl8tizk5nx", // systems collection
"status != 'paused'", // filter
"updated", // sort
-1, // limit
0, // offset
)
// log.Println("records", len(records))
if err != nil || len(records) == 0 {
// h.app.Logger().Error("Failed to query systems")
return
}
fiftySecondsAgo := time.Now().UTC().Add(-50 * time.Second)
batchSize := len(records)/4 + 1
done := 0
for _, record := range records {
// break if batch size reached or if the system was updated less than 50 seconds ago
if done >= batchSize || record.GetDateTime("updated").Time().After(fiftySecondsAgo) {
break
}
// don't increment for down systems to avoid them jamming the queue
// because they're always first when sorted by least recently updated
if record.GetString("status") != "down" {
done++
}
go h.updateSystem(record)
}
}
func (h *Hub) updateSystem(record *core.Record) {
var client *ssh.Client
var err error
// check if system connection exists
if existingClient, ok := h.systemConnections.Load(record.Id); ok {
client = existingClient.(*ssh.Client)
} else {
// create system connection
client, err = h.createSystemConnection(record)
if err != nil {
if record.GetString("status") != "down" {
h.app.Logger().Error("Failed to connect:", "err", err.Error(), "system", record.GetString("host"), "port", record.GetString("port"))
h.updateSystemStatus(record, "down")
}
return
}
h.systemConnections.Store(record.Id, client)
}
// get system stats from agent
var systemData system.CombinedData
if err := h.requestJsonFromAgent(client, &systemData); err != nil {
if err.Error() == "bad client" {
// 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.deleteSystemConnection(record)
time.Sleep(time.Millisecond * 100)
h.updateSystem(record)
return
}
h.app.Logger().Error("Failed to get system stats: ", "err", err.Error())
h.updateSystemStatus(record, "down")
return
}
// update system record
record.Set("status", "up")
record.Set("info", systemData.Info)
if err := h.app.SaveNoValidate(record); err != nil {
h.app.Logger().Error("Failed to update record: ", "err", err.Error())
}
// add system_stats and container_stats records
if systemStats, containerStats, err := h.getCollections(); err != nil {
h.app.Logger().Error("Failed to get collections: ", "err", err.Error())
} else {
// add new system_stats record
systemStatsRecord := core.NewRecord(systemStats)
systemStatsRecord.Set("system", record.Id)
systemStatsRecord.Set("stats", systemData.Stats)
systemStatsRecord.Set("type", "1m")
if err := h.app.SaveNoValidate(systemStatsRecord); err != nil {
h.app.Logger().Error("Failed to save record: ", "err", err.Error())
}
// add new container_stats record
if len(systemData.Containers) > 0 {
containerStatsRecord := core.NewRecord(containerStats)
containerStatsRecord.Set("system", record.Id)
containerStatsRecord.Set("stats", systemData.Containers)
containerStatsRecord.Set("type", "1m")
if err := h.app.SaveNoValidate(containerStatsRecord); err != nil {
h.app.Logger().Error("Failed to save record: ", "err", err.Error())
}
}
}
// system info alerts
if err := h.am.HandleSystemAlerts(record, systemData.Info, systemData.Stats.Temperatures, systemData.Stats.ExtraFs); err != nil {
h.app.Logger().Error("System alerts error", "err", err.Error())
}
}
// return system_stats and container_stats collections
func (h *Hub) getCollections() (*core.Collection, *core.Collection, error) {
if h.systemStats == nil {
systemStats, err := h.app.FindCollectionByNameOrId("system_stats")
if err != nil {
return nil, nil, err
}
h.systemStats = systemStats
}
if h.containerStats == nil {
containerStats, err := h.app.FindCollectionByNameOrId("container_stats")
if err != nil {
return nil, nil, err
}
h.containerStats = containerStats
}
return h.systemStats, h.containerStats, nil
}
// set system to specified status and save record
func (h *Hub) updateSystemStatus(record *core.Record, status string) {
if record.Fresh().GetString("status") != status {
record.Set("status", status)
if err := h.app.SaveNoValidate(record); err != nil {
h.app.Logger().Error("Failed to update record: ", "err", err.Error())
}
}
}
// delete system connection from map and close connection
func (h *Hub) deleteSystemConnection(record *core.Record) {
if client, ok := h.systemConnections.Load(record.Id); ok {
if sshClient := client.(*ssh.Client); sshClient != nil {
sshClient.Close()
}
h.systemConnections.Delete(record.Id)
}
}
func (h *Hub) createSystemConnection(record *core.Record) (*ssh.Client, error) {
client, err := ssh.Dial("tcp", net.JoinHostPort(record.GetString("host"), record.GetString("port")), h.sshClientConfig)
if err != nil {
return nil, err
}
return client, nil
}
func (h *Hub) createSSHClientConfig() error {
key, err := h.getSSHKey()
if err != nil {
h.app.Logger().Error("Failed to get SSH key: ", "err", err.Error())
return err
}
// Create the Signer for this private key.
signer, err := ssh.ParsePrivateKey(key)
if err != nil {
return err
}
h.sshClientConfig = &ssh.ClientConfig{
User: "u",
Auth: []ssh.AuthMethod{
ssh.PublicKeys(signer),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Timeout: 4 * time.Second,
}
return nil
}
// 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 {
session, err := newSessionWithTimeout(client, 4*time.Second)
if err != nil {
return fmt.Errorf("bad client")
}
defer session.Close()
stdout, err := session.StdoutPipe()
if err != nil {
return err
}
if err := session.Shell(); err != nil {
return err
}
if err := json.NewDecoder(stdout).Decode(systemData); err != nil {
return err
}
// wait for the session to complete
if err := session.Wait(); err != nil {
return err
}
return nil
}
// Adds timeout to SSH session creation to avoid hanging in case of network issues
func newSessionWithTimeout(client *ssh.Client, timeout time.Duration) (*ssh.Session, error) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
// use goroutine to create the session
sessionChan := make(chan *ssh.Session, 1)
errChan := make(chan error, 1)
go func() {
if session, err := client.NewSession(); err != nil {
errChan <- err
} else {
sessionChan <- session
}
}()
select {
case session := <-sessionChan:
return session, nil
case err := <-errChan:
return nil, err
case <-ctx.Done():
return nil, fmt.Errorf("session creation timed out")
}
}
func (h *Hub) getSSHKey() ([]byte, error) {
dataDir := h.app.DataDir()
// check if the key pair already exists
existingKey, err := os.ReadFile(dataDir + "/id_ed25519")
if err == nil {
if pubKey, err := os.ReadFile(h.app.DataDir() + "/id_ed25519.pub"); err == nil {
h.pubKey = strings.TrimSuffix(string(pubKey), "\n")
}
// return existing private key
return existingKey, nil
}
// Generate the Ed25519 key pair
pubKey, privKey, err := ed25519.GenerateKey(nil)
if err != nil {
// h.app.Logger().Error("Error generating key pair:", "err", err.Error())
return nil, err
}
// Get the private key in OpenSSH format
privKeyBytes, err := ssh.MarshalPrivateKey(privKey, "")
if err != nil {
// h.app.Logger().Error("Error marshaling private key:", "err", err.Error())
return nil, err
}
// Save the private key to a file
privateFile, err := os.Create(dataDir + "/id_ed25519")
if err != nil {
// h.app.Logger().Error("Error creating private key file:", "err", err.Error())
return nil, err
}
defer privateFile.Close()
if err := pem.Encode(privateFile, privKeyBytes); err != nil {
// h.app.Logger().Error("Error writing private key to file:", "err", err.Error())
return nil, err
}
// Generate the public key in OpenSSH format
publicKey, err := ssh.NewPublicKey(pubKey)
if err != nil {
return nil, err
}
pubKeyBytes := ssh.MarshalAuthorizedKey(publicKey)
h.pubKey = strings.TrimSuffix(string(pubKeyBytes), "\n")
// Save the public key to a file
publicFile, err := os.Create(dataDir + "/id_ed25519.pub")
if err != nil {
return nil, err
}
defer publicFile.Close()
if _, err := publicFile.Write(pubKeyBytes); err != nil {
return nil, err
}
h.app.Logger().Info("ed25519 SSH key pair generated successfully.")
h.app.Logger().Info("Private key saved to: " + dataDir + "/id_ed25519")
h.app.Logger().Info("Public key saved to: " + dataDir + "/id_ed25519.pub")
existingKey, err = os.ReadFile(dataDir + "/id_ed25519")
if err == nil {
return existingKey, nil
}
return nil, err
}

View File

@@ -1,6 +1,7 @@
package main
package hub
import (
"beszel"
"fmt"
"os"
"strings"
@@ -10,11 +11,12 @@ import (
"github.com/spf13/cobra"
)
func updateBeszel(cmd *cobra.Command, args []string) {
// Update updates beszel to the latest version
func Update(_ *cobra.Command, _ []string) {
var latest *selfupdate.Release
var found bool
var err error
currentVersion := semver.MustParse(Version)
currentVersion := semver.MustParse(beszel.Version)
fmt.Println("beszel", currentVersion)
fmt.Println("Checking for updates...")
updater, _ := selfupdate.NewUpdater(selfupdate.Config{

View File

@@ -0,0 +1,373 @@
// Package records handles creating longer records and deleting old records.
package records
import (
"beszel/internal/entities/container"
"beszel/internal/entities/system"
"log"
"math"
"time"
"github.com/goccy/go-json"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/types"
)
type RecordManager struct {
app *pocketbase.PocketBase
}
type LongerRecordData struct {
shorterType string
longerType string
longerTimeDuration time.Duration
minShorterRecords int
}
type RecordDeletionData struct {
recordType string
retention time.Duration
}
type RecordStats []struct {
Stats []byte `db:"stats"`
}
func NewRecordManager(app *pocketbase.PocketBase) *RecordManager {
return &RecordManager{app}
}
// Create longer records by averaging shorter records
func (rm *RecordManager) CreateLongerRecords(collections []*core.Collection) {
// start := time.Now()
longerRecordData := []LongerRecordData{
{
shorterType: "1m",
// change to 9 from 10 to allow edge case timing or short pauses
minShorterRecords: 9,
longerType: "10m",
longerTimeDuration: -10 * time.Minute,
},
{
shorterType: "10m",
minShorterRecords: 2,
longerType: "20m",
longerTimeDuration: -20 * time.Minute,
},
{
shorterType: "20m",
minShorterRecords: 6,
longerType: "120m",
longerTimeDuration: -120 * time.Minute,
},
{
shorterType: "120m",
minShorterRecords: 4,
longerType: "480m",
longerTimeDuration: -480 * time.Minute,
},
}
// wrap the operations in a transaction
rm.app.RunInTransaction(func(txApp core.App) error {
activeSystems, err := txApp.FindAllRecords("systems", dbx.NewExp("status = 'up'"))
if err != nil {
log.Println("failed to get active systems", "err", err.Error())
return err
}
// loop through all active systems, time periods, and collections
for _, system := range activeSystems {
// log.Println("processing system", system.GetString("name"))
for i := range longerRecordData {
recordData := longerRecordData[i]
// log.Println("processing longer record type", recordData.longerType)
// add one minute padding for longer records because they are created slightly later than the job start time
longerRecordPeriod := time.Now().UTC().Add(recordData.longerTimeDuration + time.Minute)
// shorter records are created independently of longer records, so we shouldn't need to add padding
shorterRecordPeriod := time.Now().UTC().Add(recordData.longerTimeDuration)
// loop through both collections
for _, collection := range collections {
// check creation time of last longer record if not 10m, since 10m is created every run
if recordData.longerType != "10m" {
lastLongerRecord, err := txApp.FindFirstRecordByFilter(
collection.Id,
"type = {:type} && system = {:system} && created > {:created}",
dbx.Params{"type": recordData.longerType, "system": system.Id, "created": longerRecordPeriod},
)
// continue if longer record exists
if err == nil || lastLongerRecord != nil {
// log.Println("longer record found. continuing")
continue
}
}
// get shorter records from the past x minutes
var stats RecordStats
err := txApp.DB().
Select("stats").
From(collection.Name).
AndWhere(dbx.NewExp(
"type={:type} AND system={:system} AND created > {:created}",
dbx.Params{
"type": recordData.shorterType,
"system": system.Id,
"created": shorterRecordPeriod,
},
)).
All(&stats)
// continue if not enough shorter records
if err != nil || len(stats) < recordData.minShorterRecords {
// log.Println("not enough shorter records. continue.", len(allShorterRecords), recordData.expectedShorterRecords)
continue
}
// average the shorter records and create longer record
longerRecord := core.NewRecord(collection)
longerRecord.Set("system", system.Id)
longerRecord.Set("type", recordData.longerType)
switch collection.Name {
case "system_stats":
longerRecord.Set("stats", rm.AverageSystemStats(stats))
case "container_stats":
longerRecord.Set("stats", rm.AverageContainerStats(stats))
}
if err := txApp.SaveNoValidate(longerRecord); err != nil {
log.Println("failed to save longer record", "err", err.Error())
}
}
}
}
return nil
})
// log.Println("finished creating longer records", "time (ms)", time.Since(start).Milliseconds())
}
// Calculate the average stats of a list of system_stats records without reflect
func (rm *RecordManager) AverageSystemStats(records RecordStats) system.Stats {
sum := system.Stats{}
count := float64(len(records))
// use different counter for temps in case some records don't have them
tempCount := float64(0)
var stats system.Stats
for i := range records {
stats = system.Stats{} // Zero the struct before unmarshalling
json.Unmarshal(records[i].Stats, &stats)
sum.Cpu += stats.Cpu
sum.Mem += stats.Mem
sum.MemUsed += stats.MemUsed
sum.MemPct += stats.MemPct
sum.MemBuffCache += stats.MemBuffCache
sum.MemZfsArc += stats.MemZfsArc
sum.Swap += stats.Swap
sum.SwapUsed += stats.SwapUsed
sum.DiskTotal += stats.DiskTotal
sum.DiskUsed += stats.DiskUsed
sum.DiskPct += stats.DiskPct
sum.DiskReadPs += stats.DiskReadPs
sum.DiskWritePs += stats.DiskWritePs
sum.NetworkSent += stats.NetworkSent
sum.NetworkRecv += stats.NetworkRecv
// set peak values
sum.MaxCpu = max(sum.MaxCpu, stats.MaxCpu, stats.Cpu)
sum.MaxNetworkSent = max(sum.MaxNetworkSent, stats.MaxNetworkSent, stats.NetworkSent)
sum.MaxNetworkRecv = max(sum.MaxNetworkRecv, stats.MaxNetworkRecv, stats.NetworkRecv)
sum.MaxDiskReadPs = max(sum.MaxDiskReadPs, stats.MaxDiskReadPs, stats.DiskReadPs)
sum.MaxDiskWritePs = max(sum.MaxDiskWritePs, stats.MaxDiskWritePs, stats.DiskWritePs)
// add temps to sum
if stats.Temperatures != nil {
if sum.Temperatures == nil {
sum.Temperatures = make(map[string]float64, len(stats.Temperatures))
}
tempCount++
for key, value := range stats.Temperatures {
if _, ok := sum.Temperatures[key]; !ok {
sum.Temperatures[key] = 0
}
sum.Temperatures[key] += value
}
}
// add extra fs to sum
if stats.ExtraFs != nil {
if sum.ExtraFs == nil {
sum.ExtraFs = make(map[string]*system.FsStats, len(stats.ExtraFs))
}
for key, value := range stats.ExtraFs {
if _, ok := sum.ExtraFs[key]; !ok {
sum.ExtraFs[key] = &system.FsStats{}
}
sum.ExtraFs[key].DiskTotal += value.DiskTotal
sum.ExtraFs[key].DiskUsed += value.DiskUsed
sum.ExtraFs[key].DiskWritePs += value.DiskWritePs
sum.ExtraFs[key].DiskReadPs += value.DiskReadPs
// peak values
sum.ExtraFs[key].MaxDiskReadPS = max(sum.ExtraFs[key].MaxDiskReadPS, value.MaxDiskReadPS, value.DiskReadPs)
sum.ExtraFs[key].MaxDiskWritePS = max(sum.ExtraFs[key].MaxDiskWritePS, value.MaxDiskWritePS, value.DiskWritePs)
}
}
// 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{
Cpu: twoDecimals(sum.Cpu / count),
Mem: twoDecimals(sum.Mem / count),
MemUsed: twoDecimals(sum.MemUsed / count),
MemPct: twoDecimals(sum.MemPct / count),
MemBuffCache: twoDecimals(sum.MemBuffCache / count),
MemZfsArc: twoDecimals(sum.MemZfsArc / count),
Swap: twoDecimals(sum.Swap / count),
SwapUsed: twoDecimals(sum.SwapUsed / count),
DiskTotal: twoDecimals(sum.DiskTotal / count),
DiskUsed: twoDecimals(sum.DiskUsed / count),
DiskPct: twoDecimals(sum.DiskPct / count),
DiskReadPs: twoDecimals(sum.DiskReadPs / count),
DiskWritePs: twoDecimals(sum.DiskWritePs / count),
NetworkSent: twoDecimals(sum.NetworkSent / count),
NetworkRecv: twoDecimals(sum.NetworkRecv / count),
MaxCpu: sum.MaxCpu,
MaxDiskReadPs: sum.MaxDiskReadPs,
MaxDiskWritePs: sum.MaxDiskWritePs,
MaxNetworkSent: sum.MaxNetworkSent,
MaxNetworkRecv: sum.MaxNetworkRecv,
}
if sum.Temperatures != nil {
stats.Temperatures = make(map[string]float64, len(sum.Temperatures))
for key, value := range sum.Temperatures {
stats.Temperatures[key] = twoDecimals(value / tempCount)
}
}
if sum.ExtraFs != nil {
stats.ExtraFs = make(map[string]*system.FsStats, len(sum.ExtraFs))
for key, value := range sum.ExtraFs {
stats.ExtraFs[key] = &system.FsStats{
DiskTotal: twoDecimals(value.DiskTotal / count),
DiskUsed: twoDecimals(value.DiskUsed / count),
DiskWritePs: twoDecimals(value.DiskWritePs / count),
DiskReadPs: twoDecimals(value.DiskReadPs / count),
MaxDiskReadPS: value.MaxDiskReadPS,
MaxDiskWritePS: value.MaxDiskWritePS,
}
}
}
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
}
// Calculate the average stats of a list of container_stats records
func (rm *RecordManager) AverageContainerStats(records RecordStats) []container.Stats {
sums := make(map[string]*container.Stats)
count := float64(len(records))
var containerStats []container.Stats
for i := range records {
// Reset the slice length to 0, but keep the capacity
containerStats = containerStats[:0]
if err := json.Unmarshal(records[i].Stats, &containerStats); err != nil {
return []container.Stats{}
}
for i := range containerStats {
stat := containerStats[i]
if _, ok := sums[stat.Name]; !ok {
sums[stat.Name] = &container.Stats{Name: stat.Name}
}
sums[stat.Name].Cpu += stat.Cpu
sums[stat.Name].Mem += stat.Mem
sums[stat.Name].NetworkSent += stat.NetworkSent
sums[stat.Name].NetworkRecv += stat.NetworkRecv
}
}
result := make([]container.Stats, 0, len(sums))
for _, value := range sums {
result = append(result, container.Stats{
Name: value.Name,
Cpu: twoDecimals(value.Cpu / count),
Mem: twoDecimals(value.Mem / count),
NetworkSent: twoDecimals(value.NetworkSent / count),
NetworkRecv: twoDecimals(value.NetworkRecv / count),
})
}
return result
}
// Deletes records older than what is displayed in the UI
func (rm *RecordManager) DeleteOldRecords() {
collections := []string{"system_stats", "container_stats"}
recordData := []RecordDeletionData{
{
recordType: "1m",
retention: time.Hour,
},
{
recordType: "10m",
retention: 12 * time.Hour,
},
{
recordType: "20m",
retention: 24 * time.Hour,
},
{
recordType: "120m",
retention: 7 * 24 * time.Hour,
},
{
recordType: "480m",
retention: 30 * 24 * time.Hour,
},
}
db := rm.app.NonconcurrentDB()
for _, recordData := range recordData {
for _, collectionSlug := range collections {
formattedDate := time.Now().UTC().Add(-recordData.retention).Format(types.DefaultDateLayout)
expr := dbx.NewExp("[[created]] < {:date} AND [[type]] = {:type}", dbx.Params{"date": formattedDate, "type": recordData.recordType})
_, err := db.Delete(collectionSlug, expr).Execute()
if err != nil {
rm.app.Logger().Error("Failed to delete records", "err", err.Error())
}
}
}
}
/* Round float to two decimals */
func twoDecimals(value float64) float64 {
return math.Round(value*100) / 100
}

View File

@@ -0,0 +1,120 @@
// Package users handles user-related custom functionality.
package users
import (
"beszel/migrations"
"log"
"net/http"
"strings"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/core"
)
type UserManager struct {
app *pocketbase.PocketBase
}
type UserSettings struct {
ChartTime string `json:"chartTime"`
NotificationEmails []string `json:"emails"`
NotificationWebhooks []string `json:"webhooks"`
// Language string `json:"lang"`
}
func NewUserManager(app *pocketbase.PocketBase) *UserManager {
return &UserManager{
app: app,
}
}
// Initialize user role if not set
func (um *UserManager) InitializeUserRole(e *core.RecordEvent) error {
if e.Record.GetString("role") == "" {
e.Record.Set("role", "user")
}
return e.Next()
}
// Initialize user settings with defaults if not set
func (um *UserManager) InitializeUserSettings(e *core.RecordEvent) error {
record := e.Record
// intialize settings with defaults
settings := UserSettings{
// Language: "en",
ChartTime: "1h",
NotificationEmails: []string{},
NotificationWebhooks: []string{},
}
record.UnmarshalJSONField("settings", &settings)
if len(settings.NotificationEmails) == 0 {
// get user email from auth record
if errs := um.app.ExpandRecord(record, []string{"user"}, nil); len(errs) == 0 {
// app.Logger().Error("failed to expand user relation", "errs", errs)
if user := record.ExpandedOne("user"); user != nil {
settings.NotificationEmails = []string{user.GetString("email")}
} else {
log.Println("Failed to get user email from auth record")
}
} else {
log.Println("failed to expand user relation", "errs", errs)
}
}
// if len(settings.NotificationWebhooks) == 0 {
// settings.NotificationWebhooks = []string{""}
// }
record.Set("settings", settings)
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"})
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
package migrations
import (
"github.com/pocketbase/pocketbase/core"
m "github.com/pocketbase/pocketbase/migrations"
)
var (
TempAdminEmail = "_@b.b"
)
func init() {
m.Register(func(app core.App) error {
// initial settings
settings := app.Settings()
settings.Meta.AppName = "Beszel"
settings.Meta.HideControls = true
settings.Logs.MinLevel = 4
if err := app.Save(settings); err != nil {
return err
}
// create superuser
collection, _ := app.FindCollectionByNameOrId(core.CollectionNameSuperusers)
user := core.NewRecord(collection)
user.SetEmail(TempAdminEmail)
user.SetRandomPassword()
return app.Save(user)
}, 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
}

BIN
beszel/site/bun.lockb Executable file

Binary file not shown.

View File

@@ -0,0 +1,17 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/index.css",
"baseColor": "gray",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
}
}

13
beszel/site/embed.go Normal file
View File

@@ -0,0 +1,13 @@
// Package site handles the Beszel frontend embedding.
package site
import (
"embed"
"io/fs"
)
//go:embed all:dist
var distDir embed.FS
// DistDirFS contains the embedded dist directory files (without the "dist" prefix)
var DistDirFS, _ = fs.Sub(distDir, "dist")

View File

@@ -1,16 +1,10 @@
<!DOCTYPE html>
<html lang="en">
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Beszel</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="app"></div>

View File

@@ -0,0 +1,39 @@
import type { LinguiConfig } from "@lingui/conf"
const config: LinguiConfig = {
locales: [
"en",
"ar",
"cs",
"da",
"de",
"es",
"fa",
"fr",
"hr",
"it",
"ja",
"ko",
"nl",
"pl",
"pt",
"tr",
"ru",
"sl",
"sv",
"uk",
"vi",
"zh-CN",
"zh-HK",
],
sourceLocale: "en",
compileNamespace: "ts",
catalogs: [
{
path: "<rootDir>/src/locales/{locale}/{locale}",
include: ["src"],
},
],
}
export default config

7597
beszel/site/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

72
beszel/site/package.json Normal file
View File

@@ -0,0 +1,72 @@
{
"name": "beszel",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "lingui extract --overwrite && lingui compile && vite build",
"preview": "vite preview",
"sync": "lingui extract --overwrite && lingui compile",
"sync_and_purge": "lingui extract --overwrite --clean && lingui compile"
},
"dependencies": {
"@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/router": "^0.11.0",
"@radix-ui/react-alert-dialog": "^1.1.4",
"@radix-ui/react-checkbox": "^1.1.3",
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-direction": "^1.1.0",
"@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-select": "^2.1.4",
"@radix-ui/react-separator": "^1.1.1",
"@radix-ui/react-slider": "^1.2.2",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-toast": "^1.2.4",
"@radix-ui/react-tooltip": "^1.1.6",
"@tanstack/react-table": "^8.20.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.0.4",
"d3-time": "^3.1.0",
"lucide-react": "^0.452.0",
"nanostores": "^0.11.3",
"pocketbase": "^0.22.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"recharts": "^2.15.0",
"tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7",
"valibot": "^0.36.0"
},
"devDependencies": {
"@lingui/cli": "^4.14.1",
"@lingui/swc-plugin": "^4.1.0",
"@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",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"tailwindcss-rtl": "^0.9.0",
"typescript": "^5.7.2",
"vite": "^5.4.11"
},
"overrides": {
"@nanostores/router": {
"nanostores": "^0.11.3"
}
},
"optionalDependencies": {
"@esbuild/linux-arm64": "^0.21.5"
}
}

View File

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

Binary file not shown.

View File

Before

Width:  |  Height:  |  Size: 906 B

After

Width:  |  Height:  |  Size: 906 B

View File

Before

Width:  |  Height:  |  Size: 906 B

After

Width:  |  Height:  |  Size: 906 B

View File

Before

Width:  |  Height:  |  Size: 903 B

After

Width:  |  Height:  |  Size: 903 B

View File

Before

Width:  |  Height:  |  Size: 257 B

After

Width:  |  Height:  |  Size: 257 B

View File

@@ -0,0 +1,185 @@
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
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 { Label } from "@/components/ui/label"
import { $publicKey, pb } from "@/lib/stores"
import { Copy, PlusIcon } from "lucide-react"
import { useState, useRef, MutableRefObject } from "react"
import { useStore } from "@nanostores/react"
import { cn, copyToClipboard, isReadOnlyUser } from "@/lib/utils"
import { navigate } from "./router"
import { Trans } from "@lingui/macro"
import { i18n } from "@lingui/core"
export function AddSystemButton({ className }: { className?: string }) {
const [open, setOpen] = useState(false)
const port = useRef() as MutableRefObject<HTMLInputElement>
const publicKey = useStore($publicKey)
function copyDockerCompose(port: string) {
copyToClipboard(`services:
beszel-agent:
image: "henrygd/beszel-agent"
container_name: "beszel-agent"
restart: unless-stopped
network_mode: host
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
# monitor other disks / partitions by mounting a folder in /extra-filesystems
# - /mnt/disk/.beszel:/extra-filesystems/sda1:ro
environment:
PORT: ${port}
KEY: "${publicKey}"`)
}
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) {
e.preventDefault()
const formData = new FormData(e.target as HTMLFormElement)
const data = Object.fromEntries(formData) as Record<string, any>
data.users = pb.authStore.record!.id
try {
setOpen(false)
await pb.collection("systems").create(data)
navigate("/")
// console.log(record)
} catch (e) {
console.log(e)
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button
variant="outline"
className={cn("flex gap-1 max-xs:h-[2.4rem]", className, isReadOnlyUser() && "hidden")}
>
<PlusIcon className="h-4 w-4 -ms-1" />
<Trans>
Add <span className="hidden sm:inline">System</span>
</Trans>
</Button>
</DialogTrigger>
<DialogContent className="w-[90%] sm:max-w-[440px] rounded-lg">
<Tabs defaultValue="docker">
<DialogHeader>
<DialogTitle className="mb-2">
<Trans>Add New System</Trans>
</DialogTitle>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="docker">Docker</TabsTrigger>
<TabsTrigger value="binary">
<Trans>Binary</Trans>
</TabsTrigger>
</TabsList>
</DialogHeader>
{/* 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>
<Input id="name" name="name" className="" required />
<Label htmlFor="host" className="xs:text-end">
<Trans>Host / IP</Trans>
</Label>
<Input id="host" name="host" className="" required />
<Label htmlFor="port" className="xs:text-end">
<Trans>Port</Trans>
</Label>
<Input ref={port} name="port" id="port" defaultValue="45876" className="" required />
<Label htmlFor="pkey" className="xs:text-end whitespace-pre">
<Trans comment="Use 'Key' if your language requires many more characters">Public Key</Trans>
</Label>
<div className="relative">
<Input readOnly id="pkey" value={publicKey} className="" required></Input>
<div
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}>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant={"link"}
className="absolute end-0 top-0"
onClick={() => copyToClipboard(publicKey)}
>
<Copy className="h-4 w-4 " />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>
<Trans>Click to copy</Trans>
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
{/* Docker */}
<TabsContent value="docker">
<DialogFooter className="flex justify-end gap-2 sm:w-[calc(100%+20px)] sm:-ms-[20px]">
<Button type="button" variant={"ghost"} onClick={() => copyDockerCompose(port.current.value)}>
<Trans>Copy</Trans> docker compose
</Button>
<Button>
<Trans>Add system</Trans>
</Button>
</DialogFooter>
</TabsContent>
{/* 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>
</Dialog>
)
}

View File

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

View File

@@ -0,0 +1,248 @@
import { pb } from "@/lib/stores"
import { alertInfo, cn } from "@/lib/utils"
import { Switch } from "@/components/ui/switch"
import { AlertInfo, AlertRecord, SystemRecord } from "@/types"
import { lazy, Suspense, useRef, useState } from "react"
import { toast } from "../ui/use-toast"
import { RecordOptions } from "pocketbase"
import { Trans, t, Plural } from "@lingui/macro"
interface AlertData {
checked?: boolean
val?: number
min?: number
updateAlert?: (checked: boolean, value: number, min: number) => void
key: keyof typeof alertInfo
alert: AlertInfo
system: SystemRecord
}
const Slider = lazy(() => import("@/components/ui/slider"))
const failedUpdateToast = () =>
toast({
title: t`Failed to update alert`,
description: t`Please check logs for more details.`,
variant: "destructive",
})
export function SystemAlert({
system,
systemAlerts,
data,
}: {
system: SystemRecord
systemAlerts: AlertRecord[]
data: AlertData
}) {
const alert = systemAlerts.find((alert) => alert.name === data.key)
data.updateAlert = async (checked: boolean, value: number, min: number) => {
try {
if (alert && !checked) {
await pb.collection("alerts").delete(alert.id)
} else if (alert && checked) {
await pb.collection("alerts").update(alert.id, { value, min, triggered: false })
} else if (checked) {
pb.collection("alerts").create({
system: system.id,
user: pb.authStore.record!.id,
name: data.key,
value: value,
min: min,
})
}
} catch (e) {
failedUpdateToast()
}
}
if (alert) {
data.checked = true
data.val = alert.value
data.min = alert.min || 1
}
return <AlertContent data={data} />
}
export function SystemAlertGlobal({
data,
overwrite,
alerts,
systems,
}: {
data: AlertData
overwrite: boolean | "indeterminate"
alerts: AlertRecord[]
systems: SystemRecord[]
}) {
const systemsWithExistingAlerts = useRef<{ set: Set<string>; populatedSet: boolean }>({
set: new Set(),
populatedSet: false,
})
data.checked = false
data.val = data.min = 0
data.updateAlert = async (checked: boolean, value: number, min: number) => {
const { set, populatedSet } = systemsWithExistingAlerts.current
// if overwrite checked, make sure all alerts will be overwritten
if (overwrite) {
set.clear()
}
const recordData: Partial<AlertRecord> = {
value,
min,
triggered: false,
}
// we can only send 50 in one batch
let done = 0
while (done < systems.length) {
const batch = pb.createBatch()
let batchSize = 0
for (let i = done; i < Math.min(done + 50, systems.length); i++) {
const system = systems[i]
// 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
}
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
}
return <AlertContent data={data} />
}
function AlertContent({ data }: { data: AlertData }) {
const { key } = data
const hasSliders = !("single" in data.alert)
const [checked, setChecked] = useState(data.checked || false)
const [min, setMin] = useState(data.min || (hasSliders ? 10 : 0))
const [value, setValue] = useState(data.val || (hasSliders ? 80 : 0))
const showSliders = checked && hasSliders
const newMin = useRef(min)
const newValue = useRef(value)
const Icon = alertInfo[key].icon
const updateAlert = (c?: boolean) => data.updateAlert?.(c ?? checked, newValue.current, newMin.current)
return (
<div className="rounded-lg border border-muted-foreground/15 hover:border-muted-foreground/20 transition-colors duration-100 group">
<label
htmlFor={`s${key}`}
className={cn("flex flex-row items-center justify-between gap-4 cursor-pointer p-4", {
"pb-0": showSliders,
})}
>
<div className="grid gap-1 select-none">
<p className="font-semibold flex gap-3 items-center">
<Icon className="h-4 w-4 opacity-85" /> {data.alert.name()}
</p>
{!showSliders && <span className="block text-sm text-muted-foreground">{data.alert.desc()}</span>}
</div>
<Switch
id={`s${key}`}
checked={checked}
onCheckedChange={(checked) => {
setChecked(checked)
updateAlert(checked)
}}
/>
</label>
{showSliders && (
<div className="grid sm:grid-cols-2 mt-1.5 gap-5 px-4 pb-5 tabular-nums text-muted-foreground">
<Suspense fallback={<div className="h-10" />}>
<div>
<p id={`v${key}`} className="text-sm block h-8">
<Trans>
Average exceeds{" "}
<strong className="text-foreground">
{value}
{data.alert.unit}
</strong>
</Trans>
</p>
<div className="flex gap-3">
<Slider
aria-labelledby={`v${key}`}
defaultValue={[value]}
onValueCommit={(val) => (newValue.current = val[0]) && updateAlert()}
onValueChange={(val) => setValue(val[0])}
min={1}
max={alertInfo[key].max ?? 99}
/>
</div>
</div>
<div>
<p id={`t${key}`} className="text-sm block h-8">
<Trans>
For <strong className="text-foreground">{min}</strong>{" "}
<Plural value={min} one=" minute" other=" minutes" />
</Trans>
</p>
<div className="flex gap-3">
<Slider
aria-labelledby={`v${key}`}
defaultValue={[min]}
onValueCommit={(val) => (newMin.current = val[0]) && updateAlert()}
onValueChange={(val) => setMin(val[0])}
min={1}
max={60}
/>
</div>
</div>
</Suspense>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,143 @@
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
import {
useYAxisWidth,
cn,
formatShortDate,
toFixedWithoutTrailingZeros,
decimalString,
chartMargin,
} from "@/lib/utils"
// import Spinner from '../spinner'
import { ChartData } from "@/types"
import { memo, useMemo } from "react"
import { t } from "@lingui/macro"
import { useLingui } from "@lingui/react"
/** [label, key, color, opacity] */
type DataKeys = [string, string, number, number]
const getNestedValue = (path: string, max = false, data: any): number | null => {
// fallback value (obj?.stats?.cpum ? 0 : null) should only come into play when viewing
// a max value which doesn't exist, or the value was zero and omitted from the stats object.
// so we check if cpum is present. if so, return 0 to make sure the zero value is displayed.
// if not, return null - there is no max data so do not display anything.
return `stats.${path}${max ? "m" : ""}`
.split(".")
.reduce((acc: any, key: string) => acc?.[key] ?? (data.stats?.cpum ? 0 : null), data)
}
export default memo(function AreaChartDefault({
maxToggled = false,
unit = " MB/s",
chartName,
chartData,
max,
tickFormatter,
}: {
maxToggled?: boolean
unit?: string
chartName: string
chartData: ChartData
max?: number
tickFormatter?: (value: number) => string
}) {
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
const { i18n } = useLingui()
const { chartTime } = chartData
const showMax = chartTime !== "1h" && maxToggled
const dataKeys: DataKeys[] = useMemo(() => {
// [label, key, color, opacity]
if (chartName === "CPU Usage") {
return [[t`CPU Usage`, "cpu", 1, 0.4]]
} else if (chartName === "dio") {
return [
[t({ message: "Write", comment: "Disk write" }), "dw", 3, 0.3],
[t({ message: "Read", comment: "Disk read" }), "dr", 1, 0.3],
]
} else if (chartName === "bw") {
return [
[t({ message: "Sent", comment: "Network bytes sent (upload)" }), "ns", 5, 0.2],
[t({ message: "Received", comment: "Network bytes received (download)" }), "nr", 2, 0.2],
]
} else if (chartName.startsWith("efs")) {
return [
[t`Write`, `${chartName}.w`, 3, 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 []
}, [chartName, i18n.locale])
// console.log('Rendered at', new Date())
if (chartData.systemStats.length === 0) {
return null
}
return (
<div>
<ChartContainer
className={cn("h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity", {
"opacity-100": yAxisWidth,
})}
>
<AreaChart accessibilityLayer data={chartData.systemStats} margin={chartMargin}>
<CartesianGrid vertical={false} />
<YAxis
direction="ltr"
orientation={chartData.orientation}
className="tracking-tighter"
width={yAxisWidth}
domain={[0, max ?? "auto"]}
tickFormatter={(value) => {
let val: string
if (tickFormatter) {
val = tickFormatter(value)
} else {
val = toFixedWithoutTrailingZeros(value, 2) + unit
}
return updateYAxisWidth(val)
}}
tickLine={false}
axisLine={false}
/>
{xAxis(chartData)}
<ChartTooltip
animationEasing="ease-out"
animationDuration={150}
content={
<ChartTooltipContent
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={(item) => decimalString(item.value) + unit}
// indicator="line"
/>
}
/>
{dataKeys.map((key, i) => {
const color = `hsl(var(--chart-${key[2]}))`
return (
<Area
key={i}
dataKey={getNestedValue.bind(null, key[1], showMax)}
name={key[0]}
type="monotoneX"
fill={color}
fillOpacity={key[3]}
stroke={color}
isAnimationActive={false}
/>
)
})}
{/* <ChartLegend content={<ChartLegendContent />} /> */}
</AreaChart>
</ChartContainer>
</div>
)
})

View File

@@ -0,0 +1,26 @@
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { $chartTime } from "@/lib/stores"
import { chartTimeData, cn } from "@/lib/utils"
import { ChartTimes } from "@/types"
import { useStore } from "@nanostores/react"
import { HistoryIcon } from "lucide-react"
export default function ChartTimeSelect({ className }: { className?: string }) {
const chartTime = useStore($chartTime)
return (
<Select defaultValue="1h" value={chartTime} onValueChange={(value: ChartTimes) => $chartTime.set(value)}>
<SelectTrigger className={cn(className, "relative ps-10 pe-5")}>
<HistoryIcon className="h-4 w-4 absolute start-4 top-1/2 -translate-y-1/2 opacity-85" />
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(chartTimeData).map(([value, { label }]) => (
<SelectItem key={value} value={value}>
{label()}
</SelectItem>
))}
</SelectContent>
</Select>
)
}

View File

@@ -0,0 +1,189 @@
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
import { memo, useMemo } from "react"
import {
useYAxisWidth,
cn,
formatShortDate,
decimalString,
chartMargin,
toFixedFloat,
getSizeAndUnit,
toFixedWithoutTrailingZeros,
} from "@/lib/utils"
// import Spinner from '../spinner'
import { useStore } from "@nanostores/react"
import { $containerFilter } from "@/lib/stores"
import { ChartData } from "@/types"
import { Separator } from "../ui/separator"
export default memo(function ContainerChart({
dataKey,
chartData,
chartName,
unit = "%",
}: {
dataKey: string
chartData: ChartData
chartName: string
unit?: string
}) {
const filter = useStore($containerFilter)
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
const { containerData } = chartData
const isNetChart = chartName === "net"
const chartConfig = useMemo(() => {
let config = {} as Record<
string,
{
label: string
color: string
}
>
const totalUsage = {} as Record<string, number>
for (let stats of containerData) {
for (let key in stats) {
if (!key || key === "created") {
continue
}
if (!(key in totalUsage)) {
totalUsage[key] = 0
}
if (isNetChart) {
totalUsage[key] += (stats[key]?.nr ?? 0) + (stats[key]?.ns ?? 0)
} else {
// @ts-ignore
totalUsage[key] += stats[key]?.[dataKey] ?? 0
}
}
}
let keys = Object.keys(totalUsage)
keys.sort((a, b) => (totalUsage[a] > totalUsage[b] ? -1 : 1))
const length = keys.length
for (let i = 0; i < length; i++) {
const key = keys[i]
const hue = ((i * 360) / length) % 360
config[key] = {
label: key,
color: `hsl(${hue}, 60%, 55%)`,
}
}
return config satisfies ChartConfig
}, [chartData])
const { toolTipFormatter, dataFunction, tickFormatter } = useMemo(() => {
const obj = {} as {
toolTipFormatter: (item: any, key: string) => React.ReactNode | string
dataFunction: (key: string, data: any) => number | null
tickFormatter: (value: any) => string
}
// tick formatter
if (chartName === "cpu") {
obj.tickFormatter = (value) => {
const val = toFixedWithoutTrailingZeros(value, 2) + unit
return updateYAxisWidth(val)
}
} else {
obj.tickFormatter = (value) => {
const { v, u } = getSizeAndUnit(value, false)
return updateYAxisWidth(`${toFixedFloat(v, 2)}${u}${isNetChart ? "/s" : ""}`)
}
}
// tooltip formatter
if (isNetChart) {
obj.toolTipFormatter = (item: any, key: string) => {
try {
const sent = item?.payload?.[key]?.ns ?? 0
const received = item?.payload?.[key]?.nr ?? 0
return (
<span className="flex">
{decimalString(received)} MB/s
<span className="opacity-70 ms-0.5"> rx </span>
<Separator orientation="vertical" className="h-3 mx-1.5 bg-primary/40" />
{decimalString(sent)} MB/s
<span className="opacity-70 ms-0.5"> tx</span>
</span>
)
} catch (e) {
return null
}
}
} else {
obj.toolTipFormatter = (item: any) => decimalString(item.value) + unit
}
// data function
if (isNetChart) {
obj.dataFunction = (key: string, data: any) => (data[key] ? data[key].nr + data[key].ns : null)
} else {
obj.dataFunction = (key: string, data: any) => data[key]?.[dataKey] ?? null
}
return obj
}, [])
// console.log('rendered at', new Date())
if (containerData.length === 0) {
return null
}
return (
<div>
<ChartContainer
className={cn("h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity", {
"opacity-100": yAxisWidth,
})}
>
<AreaChart
accessibilityLayer
// syncId={'cpu'}
data={containerData}
margin={chartMargin}
reverseStackOrder={true}
>
<CartesianGrid vertical={false} />
<YAxis
direction="ltr"
orientation={chartData.orientation}
className="tracking-tighter"
width={yAxisWidth}
tickFormatter={tickFormatter}
tickLine={false}
axisLine={false}
/>
{xAxis(chartData)}
<ChartTooltip
animationEasing="ease-out"
animationDuration={150}
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
// @ts-ignore
itemSorter={(a, b) => b.value - a.value}
content={<ChartTooltipContent filter={filter} contentFormatter={toolTipFormatter} />}
/>
{Object.keys(chartConfig).map((key) => {
const filtered = filter && !key.includes(filter)
let fillOpacity = filtered ? 0.05 : 0.4
let strokeOpacity = filtered ? 0.1 : 1
return (
<Area
key={key}
isAnimationActive={false}
dataKey={dataFunction.bind(null, key)}
name={key}
type="monotoneX"
fill={chartConfig[key].color}
fillOpacity={fillOpacity}
stroke={chartConfig[key].color}
strokeOpacity={strokeOpacity}
activeDot={{ opacity: filtered ? 0 : 1 }}
stackId="a"
/>
)
})}
</AreaChart>
</ChartContainer>
</div>
)
})

View File

@@ -0,0 +1,91 @@
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
import {
useYAxisWidth,
cn,
formatShortDate,
decimalString,
toFixedFloat,
chartMargin,
getSizeAndUnit,
} from "@/lib/utils"
import { ChartData } from "@/types"
import { memo } from "react"
import { t } from "@lingui/macro"
import { useLingui } from "@lingui/react"
export default memo(function DiskChart({
dataKey,
diskSize,
chartData,
}: {
dataKey: string
diskSize: number
chartData: ChartData
}) {
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 (
<div>
<ChartContainer
className={cn("h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity", {
"opacity-100": yAxisWidth,
})}
>
<AreaChart accessibilityLayer data={chartData.systemStats} margin={chartMargin}>
<CartesianGrid vertical={false} />
<YAxis
direction="ltr"
orientation={chartData.orientation}
className="tracking-tighter"
width={yAxisWidth}
domain={[0, diskSize]}
tickCount={9}
minTickGap={6}
tickLine={false}
axisLine={false}
tickFormatter={(value) => {
const { v, u } = getSizeAndUnit(value)
return updateYAxisWidth(toFixedFloat(v, 2) + u)
}}
/>
{xAxis(chartData)}
<ChartTooltip
animationEasing="ease-out"
animationDuration={150}
content={
<ChartTooltipContent
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={({ value }) => {
const { v, u } = getSizeAndUnit(value)
return decimalString(v) + u
}}
/>
}
/>
<Area
dataKey={dataKey}
name={_(t`Disk Usage`)}
type="monotoneX"
fill="hsl(var(--chart-4))"
fillOpacity={0.4}
stroke="hsl(var(--chart-4))"
// animationDuration={1200}
isAnimationActive={false}
/>
</AreaChart>
</ChartContainer>
</div>
)
})

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

@@ -0,0 +1,104 @@
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
import { useYAxisWidth, cn, toFixedFloat, decimalString, formatShortDate, chartMargin } from "@/lib/utils"
import { memo } from "react"
import { ChartData } from "@/types"
import { t } from "@lingui/macro"
import { useLingui } from "@lingui/react"
export default memo(function MemChart({ chartData }: { chartData: ChartData }) {
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
const { _ } = useLingui()
const totalMem = toFixedFloat(chartData.systemStats.at(-1)?.stats.m ?? 0, 1)
// console.log('rendered at', new Date())
if (chartData.systemStats.length === 0) {
return null
}
return (
<div>
{/* {!yAxisSet && <Spinner />} */}
<ChartContainer
className={cn("h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity", {
"opacity-100": yAxisWidth,
})}
>
<AreaChart accessibilityLayer data={chartData.systemStats} margin={chartMargin}>
<CartesianGrid vertical={false} />
{totalMem && (
<YAxis
direction="ltr"
orientation={chartData.orientation}
// use "ticks" instead of domain / tickcount if need more control
domain={[0, totalMem]}
tickCount={9}
className="tracking-tighter"
width={yAxisWidth}
tickLine={false}
axisLine={false}
tickFormatter={(value) => {
const val = toFixedFloat(value, 1)
return updateYAxisWidth(val + " GB")
}}
/>
)}
{xAxis(chartData)}
<ChartTooltip
// cursor={false}
animationEasing="ease-out"
animationDuration={150}
content={
<ChartTooltipContent
// @ts-ignore
itemSorter={(a, b) => a.order - b.order}
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={(item) => decimalString(item.value) + " GB"}
// indicator="line"
/>
}
/>
<Area
name={_(t`Used`)}
order={3}
dataKey="stats.mu"
type="monotoneX"
fill="hsl(var(--chart-2))"
fillOpacity={0.4}
stroke="hsl(var(--chart-2))"
stackId="1"
isAnimationActive={false}
/>
{chartData.systemStats.at(-1)?.stats.mz && (
<Area
name="ZFS ARC"
order={2}
dataKey="stats.mz"
type="monotoneX"
fill="hsla(175 60% 45% / 0.8)"
fillOpacity={0.5}
stroke="hsla(175 60% 45% / 0.8)"
stackId="1"
isAnimationActive={false}
/>
)}
<Area
name={_(t`Cache / Buffers`)}
order={1}
dataKey="stats.mb"
type="monotoneX"
fill="hsla(160 60% 45% / 0.5)"
fillOpacity={0.4}
// strokeOpacity={1}
stroke="hsla(160 60% 45% / 0.5)"
stackId="1"
isAnimationActive={false}
/>
</AreaChart>
</ChartContainer>
</div>
)
})

View File

@@ -0,0 +1,67 @@
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
import {
useYAxisWidth,
cn,
formatShortDate,
toFixedWithoutTrailingZeros,
decimalString,
chartMargin,
} from "@/lib/utils"
import { ChartData } from "@/types"
import { memo } from "react"
import { t } from "@lingui/macro"
export default memo(function SwapChart({ chartData }: { chartData: ChartData }) {
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
if (chartData.systemStats.length === 0) {
return null
}
return (
<div>
<ChartContainer
className={cn("h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity", {
"opacity-100": yAxisWidth,
})}
>
<AreaChart accessibilityLayer data={chartData.systemStats} margin={chartMargin}>
<CartesianGrid vertical={false} />
<YAxis
direction="ltr"
orientation={chartData.orientation}
className="tracking-tighter"
domain={[0, () => toFixedWithoutTrailingZeros(chartData.systemStats.at(-1)?.stats.s ?? 0.04, 2)]}
width={yAxisWidth}
tickLine={false}
axisLine={false}
tickFormatter={(value) => updateYAxisWidth(value + " GB")}
/>
{xAxis(chartData)}
<ChartTooltip
animationEasing="ease-out"
animationDuration={150}
content={
<ChartTooltipContent
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={(item) => decimalString(item.value) + " GB"}
// indicator="line"
/>
}
/>
<Area
dataKey="stats.su"
name={t`Used`}
type="monotoneX"
fill="hsl(var(--chart-2))"
fillOpacity={0.4}
stroke="hsl(var(--chart-2))"
isAnimationActive={false}
/>
</AreaChart>
</ChartContainer>
</div>
)
})

View File

@@ -0,0 +1,110 @@
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 TemperatureChart({ 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 tempSums = {} as Record<string, number>
for (let data of chartData.systemStats) {
let newData = { created: data.created } as Record<string, number | string>
let keys = Object.keys(data.stats?.t ?? {})
for (let i = 0; i < keys.length; i++) {
let key = keys[i]
newData[key] = data.stats.t![key]
tempSums[key] = (tempSums[key] ?? 0) + newData[key]
}
newChartData.data.push(newData)
}
const keys = Object.keys(tempSums).sort((a, b) => tempSums[b] - tempSums[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 + " °C")
}}
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) + " °C"}
// 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 < 12 && <ChartLegend content={<ChartLegendContent />} />}
</LineChart>
</ChartContainer>
</div>
)
})

View File

@@ -0,0 +1,197 @@
import {
BookIcon,
DatabaseBackupIcon,
LayoutDashboard,
LogsIcon,
MailIcon,
Server,
SettingsIcon,
UsersIcon,
} from "lucide-react"
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut,
} from "@/components/ui/command"
import { useEffect } from "react"
import { useStore } from "@nanostores/react"
import { $systems } from "@/lib/stores"
import { isAdmin } from "@/lib/utils"
import { navigate } from "./router"
import { Trans, t } from "@lingui/macro"
export default function CommandPalette({ open, setOpen }: { open: boolean; setOpen: (open: boolean) => void }) {
const systems = useStore($systems)
useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
setOpen(!open)
}
}
document.addEventListener("keydown", down)
return () => document.removeEventListener("keydown", down)
}, [open, setOpen])
return (
<CommandDialog open={open} onOpenChange={setOpen}>
<CommandInput placeholder={t`Search for systems or settings...`} />
<CommandList>
<CommandEmpty>
<Trans>No results found.</Trans>
</CommandEmpty>
{systems.length > 0 && (
<>
<CommandGroup>
{systems.map((system) => (
<CommandItem
key={system.id}
onSelect={() => {
navigate(`/system/${encodeURIComponent(system.name)}`)
setOpen(false)
}}
>
<Server className="me-2 h-4 w-4" />
<span>{system.name}</span>
<CommandShortcut>{system.host}</CommandShortcut>
</CommandItem>
))}
</CommandGroup>
<CommandSeparator className="mb-1.5" />
</>
)}
<CommandGroup heading={t`Pages / Settings`}>
<CommandItem
keywords={["home"]}
onSelect={() => {
navigate("/")
setOpen(false)
}}
>
<LayoutDashboard className="me-2 h-4 w-4" />
<span>
<Trans>Dashboard</Trans>
</span>
<CommandShortcut>
<Trans>Page</Trans>
</CommandShortcut>
</CommandItem>
<CommandItem
onSelect={() => {
navigate("/settings/general")
setOpen(false)
}}
>
<SettingsIcon className="me-2 h-4 w-4" />
<span>
<Trans>Settings</Trans>
</span>
<CommandShortcut>
<Trans>Settings</Trans>
</CommandShortcut>
</CommandItem>
<CommandItem
keywords={["alerts"]}
onSelect={() => {
navigate("/settings/notifications")
setOpen(false)
}}
>
<MailIcon className="me-2 h-4 w-4" />
<span>
<Trans>Notifications</Trans>
</span>
<CommandShortcut>
<Trans>Settings</Trans>
</CommandShortcut>
</CommandItem>
<CommandItem
keywords={["help", "oauth", "oidc"]}
onSelect={() => {
window.location.href = "https://beszel.dev/guide/what-is-beszel"
}}
>
<BookIcon className="me-2 h-4 w-4" />
<span>
<Trans>Documentation</Trans>
</span>
<CommandShortcut>beszel.dev</CommandShortcut>
</CommandItem>
</CommandGroup>
{isAdmin() && (
<>
<CommandSeparator className="mb-1.5" />
<CommandGroup heading={t`Admin`}>
<CommandItem
keywords={["pocketbase"]}
onSelect={() => {
setOpen(false)
window.open("/_/", "_blank")
}}
>
<UsersIcon className="me-2 h-4 w-4" />
<span>
<Trans>Users</Trans>
</span>
<CommandShortcut>
<Trans>Admin</Trans>
</CommandShortcut>
</CommandItem>
<CommandItem
onSelect={() => {
setOpen(false)
window.open("/_/#/logs", "_blank")
}}
>
<LogsIcon className="me-2 h-4 w-4" />
<span>
<Trans>Logs</Trans>
</span>
<CommandShortcut>
<Trans>Admin</Trans>
</CommandShortcut>
</CommandItem>
<CommandItem
onSelect={() => {
setOpen(false)
window.open("/_/#/settings/backups", "_blank")
}}
>
<DatabaseBackupIcon className="me-2 h-4 w-4" />
<span>
<Trans>Backups</Trans>
</span>
<CommandShortcut>
<Trans>Admin</Trans>
</CommandShortcut>
</CommandItem>
<CommandItem
keywords={["email"]}
onSelect={() => {
setOpen(false)
window.open("/_/#/settings/mail", "_blank")
}}
>
<MailIcon className="me-2 h-4 w-4" />
<span>
<Trans>SMTP settings</Trans>
</span>
<CommandShortcut>
<Trans>Admin</Trans>
</CommandShortcut>
</CommandItem>
</CommandGroup>
</>
)}
</CommandList>
</CommandDialog>
)
}

View File

@@ -0,0 +1,51 @@
import { useEffect, useMemo, useRef } from "react"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "./ui/dialog"
import { Textarea } from "./ui/textarea"
import { $copyContent } from "@/lib/stores"
import { Trans } from "@lingui/macro"
export default function CopyToClipboard({ content }: { content: string }) {
return (
<Dialog defaultOpen={true}>
<DialogContent className="w-[90%] rounded-lg md:pt-4" style={{ maxWidth: 530 }}>
<DialogHeader>
<DialogTitle>
<Trans>Copy text</Trans>
</DialogTitle>
<DialogDescription className="hidden xs:block">
<Trans>Automatic copy requires a secure context.</Trans>
</DialogDescription>
</DialogHeader>
<CopyTextarea content={content} />
</DialogContent>
</Dialog>
)
}
function CopyTextarea({ content }: { content: string }) {
const textareaRef = useRef<HTMLTextAreaElement>(null)
const rows = useMemo(() => {
return content.split("\n").length
}, [content])
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.select()
}
}, [textareaRef])
useEffect(() => {
return () => $copyContent.set("")
}, [])
return (
<Textarea
className="font-mono overflow-hidden whitespace-pre"
rows={rows}
value={content}
readOnly
ref={textareaRef}
/>
)
}

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

@@ -0,0 +1,318 @@
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { LoaderCircle, LockIcon, LogInIcon, MailIcon } from "lucide-react"
import { $authenticated, pb } from "@/lib/stores"
import * as v from "valibot"
import { toast } from "../ui/use-toast"
import { Dialog, DialogContent, DialogTrigger, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { useCallback, useState } from "react"
import { AuthMethodsList, OAuth2AuthConfig } from "pocketbase"
import { Link } from "../router"
import { Trans, t } from "@lingui/macro"
const honeypot = v.literal("")
const emailSchema = v.pipe(v.string(), v.email(t`Invalid email address.`))
const passwordSchema = v.pipe(v.string(), v.minLength(8, t`Password must be at least 8 characters.`))
const LoginSchema = v.looseObject({
name: honeypot,
email: emailSchema,
password: passwordSchema,
})
const RegisterSchema = v.looseObject({
name: honeypot,
email: emailSchema,
password: passwordSchema,
passwordConfirm: passwordSchema,
})
const showLoginFaliedToast = () => {
toast({
title: t`Login attempt failed`,
description: t`Please check your credentials and try again`,
variant: "destructive",
})
}
export function UserAuthForm({
className,
isFirstRun,
authMethods,
...props
}: {
className?: string
isFirstRun: boolean
authMethods: AuthMethodsList
}) {
const [isLoading, setIsLoading] = useState<boolean>(false)
const [isOauthLoading, setIsOauthLoading] = useState<boolean>(false)
const [errors, setErrors] = useState<Record<string, string | undefined>>({})
const handleSubmit = useCallback(
async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
setIsLoading(true)
// store email for later use if mfa is enabled
let email = ""
try {
const formData = new FormData(e.target as HTMLFormElement)
const data = Object.fromEntries(formData) as Record<string, any>
const Schema = isFirstRun ? RegisterSchema : LoginSchema
const result = v.safeParse(Schema, data)
if (!result.success) {
console.log(result)
let errors = {}
for (const issue of result.issues) {
// @ts-ignore
errors[issue.path[0].key] = issue.message
}
setErrors(errors)
return
}
const { password, passwordConfirm } = result.output
email = result.output.email
if (isFirstRun) {
// check that passwords match
if (password !== passwordConfirm) {
let msg = "Passwords do not match"
setErrors({ passwordConfirm: msg })
return
}
await pb.send("/api/beszel/create-user", {
method: "POST",
body: JSON.stringify({ email, password }),
})
await pb.collection("users").authWithPassword(email, password)
} else {
await pb.collection("users").authWithPassword(email, password)
}
$authenticated.set(true)
} catch (err: any) {
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 {
setIsLoading(false)
}
},
[isFirstRun]
)
if (!authMethods) {
return null
}
const oauthEnabled = authMethods.oauth2.enabled && authMethods.oauth2.providers.length > 0
const passwordEnabled = authMethods.password.enabled
return (
<div className={cn("grid gap-6", className)} {...props}>
{passwordEnabled && (
<>
<form onSubmit={handleSubmit} onChange={() => setErrors({})}>
<div className="grid gap-2.5">
<div className="grid gap-1 relative">
<MailIcon className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Label className="sr-only" htmlFor="email">
<Trans>Email</Trans>
</Label>
<Input
id="email"
name="email"
required
placeholder="name@example.com"
type="email"
autoCapitalize="none"
autoComplete="email"
autoCorrect="off"
disabled={isLoading || isOauthLoading}
className="ps-9"
/>
{errors?.email && <p className="px-1 text-xs text-red-600">{errors.email}</p>}
</div>
<div className="grid gap-1 relative">
<LockIcon className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Label className="sr-only" htmlFor="pass">
<Trans>Password</Trans>
</Label>
<Input
id="pass"
name="password"
placeholder={t`Password`}
required
type="password"
autoComplete="current-password"
disabled={isLoading || isOauthLoading}
className="ps-9 placeholder:lowercase"
/>
{errors?.password && <p className="px-1 text-xs text-red-600">{errors.password}</p>}
</div>
{isFirstRun && (
<div className="grid gap-1 relative">
<LockIcon className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Label className="sr-only" htmlFor="pass2">
<Trans>Confirm password</Trans>
</Label>
<Input
id="pass2"
name="passwordConfirm"
placeholder={t`Confirm password`}
required
type="password"
autoComplete="current-password"
disabled={isLoading || isOauthLoading}
className="ps-9 placeholder:lowercase"
/>
{errors?.passwordConfirm && <p className="px-1 text-xs text-red-600">{errors.passwordConfirm}</p>}
</div>
)}
<div className="sr-only">
{/* honeypot */}
<label htmlFor="name"></label>
<input id="name" type="text" name="name" tabIndex={-1} autoComplete="off" />
</div>
<button className={cn(buttonVariants())} disabled={isLoading}>
{isLoading ? (
<LoaderCircle className="me-2 h-4 w-4 animate-spin" />
) : (
<LogInIcon className="me-2 h-4 w-4" />
)}
{isFirstRun ? t`Create account` : t`Sign in`}
</button>
</div>
</form>
{(isFirstRun || oauthEnabled) && (
// only show 'continue with' during onboarding or if we have auth providers
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">
<Trans>Or continue with</Trans>
</span>
</div>
</div>
)}
</>
)}
{oauthEnabled && (
<div className="grid gap-2 -mt-1">
{authMethods.oauth2.providers.map((provider) => (
<button
key={provider.name}
type="button"
className={cn(buttonVariants({ variant: "outline" }), {
"justify-self-center": !passwordEnabled,
"px-5": !passwordEnabled,
})}
onClick={() => {
setIsOauthLoading(true)
const oAuthOpts: OAuth2AuthConfig = {
provider: provider.name,
}
// https://github.com/pocketbase/pocketbase/discussions/2429#discussioncomment-5943061
if (navigator.userAgent.match(/iPhone|iPad|iPod/i)) {
const authWindow = window.open()
if (!authWindow) {
setIsOauthLoading(false)
toast({
title: t`Error`,
description: t`Please enable pop-ups for this site`,
variant: "destructive",
})
return
}
oAuthOpts.urlCallback = (url) => {
authWindow.location.href = url
}
}
pb.collection("users")
.authWithOAuth2(oAuthOpts)
.then(() => {
$authenticated.set(pb.authStore.isValid)
})
.catch(showLoginFaliedToast)
.finally(() => {
setIsOauthLoading(false)
})
}}
disabled={isLoading || isOauthLoading}
>
{isOauthLoading ? (
<LoaderCircle className="me-2 h-4 w-4 animate-spin" />
) : (
<img
className="me-2 h-4 w-4 dark:brightness-0 dark:invert"
src={`/_/images/oauth2/${provider.name}.svg`}
alt=""
onError={(e) => {
e.currentTarget.src = "/static/lock.svg"
}}
/>
)}
<span className="translate-y-[1px]">{provider.displayName}</span>
</button>
))}
</div>
)}
{!oauthEnabled && isFirstRun && (
// only show GitHub button / dialog during onboarding
<Dialog>
<DialogTrigger asChild>
<button type="button" className={cn(buttonVariants({ variant: "outline" }))}>
<img className="me-2 h-4 w-4 dark:invert" src="/_/images/oauth2/github.svg" alt="" />
<span className="translate-y-[1px]">GitHub</span>
</button>
</DialogTrigger>
<DialogContent style={{ maxWidth: 440, width: "90%" }}>
<DialogHeader>
<DialogTitle>
<Trans>OAuth 2 / OIDC support</Trans>
</DialogTitle>
</DialogHeader>
<div className="text-primary/70 text-[0.95em] contents">
<p>
<Trans>Beszel supports OpenID Connect and many OAuth2 authentication providers.</Trans>
</p>
<p>
<Trans>
Please see{" "}
<a
href="https://beszel.dev/guide/oauth"
className={cn(buttonVariants({ variant: "link" }), "p-0 h-auto")}
>
the documentation
</a>{" "}
for instructions.
</Trans>
</p>
</div>
</DialogContent>
</Dialog>
)}
{passwordEnabled && !isFirstRun && (
<Link
href="/forgot-password"
className="text-sm mx-auto hover:text-brand underline underline-offset-4 opacity-70 hover:opacity-100 transition-opacity"
>
<Trans>Forgot password?</Trans>
</Link>
)}
</div>
)
}

View File

@@ -0,0 +1,107 @@
import { LoaderCircle, MailIcon, SendHorizonalIcon } from "lucide-react"
import { Input } from "../ui/input"
import { Label } from "../ui/label"
import { useCallback, useState } from "react"
import { toast } from "../ui/use-toast"
import { buttonVariants } from "../ui/button"
import { cn } from "@/lib/utils"
import { pb } from "@/lib/stores"
import { Dialog, DialogHeader } from "../ui/dialog"
import { DialogContent, DialogTrigger, DialogTitle } from "../ui/dialog"
import { t, Trans } from "@lingui/macro"
const showLoginFaliedToast = () => {
toast({
title: t`Login attempt failed`,
description: t`Please check your credentials and try again`,
variant: "destructive",
})
}
export default function ForgotPassword() {
const [isLoading, setIsLoading] = useState<boolean>(false)
const [email, setEmail] = useState("")
const handleSubmit = useCallback(
async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
setIsLoading(true)
try {
// console.log(email)
await pb.collection("users").requestPasswordReset(email)
toast({
title: t`Password reset request received`,
description: t`Check ${email} for a reset link.`,
})
} catch (e) {
showLoginFaliedToast()
} finally {
setIsLoading(false)
setEmail("")
}
},
[email]
)
return (
<>
<form onSubmit={handleSubmit}>
<div className="grid gap-3">
<div className="grid gap-1 relative">
<MailIcon className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Label className="sr-only" htmlFor="email">
<Trans>Email</Trans>
</Label>
<Input
value={email}
onChange={(e) => setEmail(e.target.value)}
id="email"
name="email"
required
placeholder="name@example.com"
type="email"
autoCapitalize="none"
autoComplete="email"
autoCorrect="off"
disabled={isLoading}
className="ps-9"
/>
</div>
<button className={cn(buttonVariants())} disabled={isLoading}>
{isLoading ? (
<LoaderCircle className="me-2 h-4 w-4 animate-spin" />
) : (
<SendHorizonalIcon className="me-2 h-4 w-4" />
)}
<Trans>Reset Password</Trans>
</button>
</div>
</form>
<Dialog>
<DialogTrigger asChild>
<button className="text-sm mx-auto hover:text-brand underline underline-offset-4 opacity-70 hover:opacity-100 transition-opacity">
<Trans>Command line instructions</Trans>
</button>
</DialogTrigger>
<DialogContent className="max-w-[33em]">
<DialogHeader>
<DialogTitle>
<Trans>Command line instructions</Trans>
</DialogTitle>
</DialogHeader>
<p className="text-primary/70 text-[0.95em] leading-relaxed">
<Trans>
If you've lost the password to your admin account, you may reset it using the following command.
</Trans>
</p>
<p className="text-primary/70 text-[0.95em] leading-relaxed">
<Trans>Then log into the backend and reset your user account password in the users table.</Trans>
</p>
<code className="bg-muted rounded-sm py-0.5 px-2.5 me-auto text-sm">
beszel admin update youremail@example.com newpassword
</code>
</DialogContent>
</Dialog>
</>
)
}

View File

@@ -0,0 +1,70 @@
import { UserAuthForm } from "@/components/login/auth-form"
import { Logo } from "../logo"
import { useEffect, useMemo, useState } from "react"
import { pb } from "@/lib/stores"
import { useStore } from "@nanostores/react"
import ForgotPassword from "./forgot-pass-form"
import { $router } from "../router"
import { AuthMethodsList } from "pocketbase"
import { t } from "@lingui/macro"
import { useTheme } from "../theme-provider"
export default function () {
const page = useStore($router)
const [isFirstRun, setFirstRun] = useState(false)
const [authMethods, setAuthMethods] = useState<AuthMethodsList>()
const { theme } = useTheme()
useEffect(() => {
document.title = t`Login` + " / Beszel"
pb.send("/api/beszel/first-run", {}).then(({ firstRun }) => {
setFirstRun(firstRun)
})
}, [])
useEffect(() => {
pb.collection("users")
.listAuthMethods()
.then((methods) => {
setAuthMethods(methods)
})
}, [])
const subtitle = useMemo(() => {
if (isFirstRun) {
return t`Please create an admin account`
} else if (page?.path === "/forgot-password") {
return t`Enter email address to reset password`
} else {
return t`Please sign in to your account`
}
}, [isFirstRun, page])
if (!authMethods) {
return null
}
return (
<div className="min-h-svh grid items-center py-12">
<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">
<h1 className="mb-3">
<Logo className="h-7 fill-foreground mx-auto" />
<span className="sr-only">Beszel</span>
</h1>
<p className="text-sm text-muted-foreground">{subtitle}</p>
</div>
{page?.path === "/forgot-password" ? (
<ForgotPassword />
) : (
<UserAuthForm isFirstRun={isFirstRun} authMethods={authMethods} />
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,17 @@
export function Logo({ className }: { className?: string }) {
return (
// Righteous
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 285 75" className={className}>
{/* <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>
)
}

View File

@@ -0,0 +1,55 @@
import { LaptopIcon, MoonStarIcon, SunIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { useTheme } from "@/components/theme-provider"
import { cn } from "@/lib/utils"
import { t, Trans } from "@lingui/macro"
export function ModeToggle() {
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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant={"ghost"} size="icon" aria-label={t`Toggle theme`}>
<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" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
{options.map((opt) => {
const selected = opt.theme === theme
return (
<DropdownMenuItem
key={opt.theme}
className={cn("px-2.5", selected ? "font-semibold" : "")}
onClick={() => setTheme(opt.theme as "dark" | "light" | "system")}
>
<opt.Icon className={cn("me-2 h-4 w-4 opacity-80", selected && "opacity-100")} />
{opt.label}
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
</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(
{
home: '/',
server: '/system/:name',
'forgot-password': '/forgot-password',
home: "/",
server: "/system/:name",
settings: "/settings/:name?",
forgot_password: "/forgot-password",
},
{ links: false }
)
@@ -16,7 +17,7 @@ export const navigate = (urlString: string) => {
function onClick(e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) {
e.preventDefault()
$router.open(new URL((e.target as HTMLAnchorElement).href).pathname)
$router.open(new URL((e.currentTarget as HTMLAnchorElement).href).pathname)
}
export const Link = (props: React.AnchorHTMLAttributes<HTMLAnchorElement>) => {

View File

@@ -0,0 +1,123 @@
import { Suspense, lazy, useEffect, useMemo } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card"
import { $alerts, $hubVersion, $systems, pb } from "@/lib/stores"
import { useStore } from "@nanostores/react"
import { GithubIcon } from "lucide-react"
import { Separator } from "../ui/separator"
import { alertInfo, updateRecordList, updateSystemList } from "@/lib/utils"
import { AlertRecord, SystemRecord } from "@/types"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { Link } from "../router"
import { Plural, t, Trans } from "@lingui/macro"
const SystemsTable = lazy(() => import("../systems-table/systems-table"))
export default function Home() {
const hubVersion = useStore($hubVersion)
const alerts = useStore($alerts)
const systems = useStore($systems)
const activeAlerts = useMemo(() => {
const activeAlerts = alerts.filter((alert) => {
const active = alert.triggered && alert.name in alertInfo
if (!active) {
return false
}
alert.sysname = systems.find((system) => system.id === alert.system)?.name
return true
})
return activeAlerts
}, [alerts])
useEffect(() => {
document.title = t`Dashboard` + " / Beszel"
// make sure we have the latest list of systems
updateSystemList()
// subscribe to real time updates for systems / alerts
pb.collection<SystemRecord>("systems").subscribe("*", (e) => {
updateRecordList(e, $systems)
})
// todo: add toast if new triggered alert comes in
pb.collection<AlertRecord>("alerts").subscribe("*", (e) => {
updateRecordList(e, $alerts)
})
return () => {
pb.collection("systems").unsubscribe("*")
// pb.collection('alerts').unsubscribe('*')
}
}, [])
return (
<>
{/* show active alerts */}
{activeAlerts.length > 0 && (
<Card className="mb-4">
<CardHeader className="pb-4 px-2 sm:px-6 max-sm:pt-5 max-sm:pb-1">
<div className="px-2 sm:px-1">
<CardTitle>
<Trans>Active Alerts</Trans>
</CardTitle>
</div>
</CardHeader>
<CardContent className="max-sm:p-2">
{activeAlerts.length > 0 && (
<div className="grid sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-3">
{activeAlerts.map((alert) => {
const info = alertInfo[alert.name as keyof typeof alertInfo]
return (
<Alert
key={alert.id}
className="hover:-translate-y-[1px] duration-200 bg-transparent border-foreground/10 hover:shadow-md shadow-black"
>
<info.icon className="h-4 w-4" />
<AlertTitle>
{alert.sysname} {info.name().toLowerCase().replace("cpu", "CPU")}
</AlertTitle>
<AlertDescription>
<Trans>
Exceeds {alert.value}
{info.unit} in last <Plural value={alert.min} one="# minute" other="# minutes" />
</Trans>
</AlertDescription>
<Link
href={`/system/${encodeURIComponent(alert.sysname!)}`}
className="absolute inset-0 w-full h-full"
aria-label="View system"
></Link>
</Alert>
)
})}
</div>
)}
</CardContent>
</Card>
)}
<Suspense>
<SystemsTable />
</Suspense>
{hubVersion && (
<div className="flex gap-1.5 justify-end items-center pe-3 sm:pe-6 mt-3.5 text-xs opacity-80">
<a
href="https://github.com/henrygd/beszel"
target="_blank"
className="flex items-center gap-0.5 text-muted-foreground hover:text-foreground duration-75"
>
<GithubIcon className="h-3 w-3" /> GitHub
</a>
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
<a
href="https://github.com/henrygd/beszel/releases"
target="_blank"
className="text-muted-foreground hover:text-foreground duration-75"
>
Beszel {hubVersion}
</a>
</div>
)}
</>
)
}

View File

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

View File

@@ -0,0 +1,111 @@
import { Button } from "@/components/ui/button"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { chartTimeData } from "@/lib/utils"
import { Separator } from "@/components/ui/separator"
import { LanguagesIcon, LoaderCircleIcon, SaveIcon } from "lucide-react"
import { UserSettings } from "@/types"
import { saveSettings } from "./layout"
import { useState } from "react"
import { Trans } from "@lingui/macro"
import languages from "@/lib/languages"
import { dynamicActivate } from "@/lib/i18n"
import { useLingui } from "@lingui/react"
// import { setLang } from "@/lib/i18n"
export default function SettingsProfilePage({ userSettings }: { userSettings: UserSettings }) {
const [isLoading, setIsLoading] = useState(false)
const { i18n } = useLingui()
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
setIsLoading(true)
const formData = new FormData(e.target as HTMLFormElement)
const data = Object.fromEntries(formData) as Partial<UserSettings>
await saveSettings(data)
setIsLoading(false)
}
return (
<div>
<div>
<h3 className="text-xl font-medium mb-2">
<Trans>General</Trans>
</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
<Trans>Change general application options.</Trans>
</p>
</div>
<Separator className="my-4" />
<form onSubmit={handleSubmit} className="space-y-5">
<div className="space-y-2">
<div className="mb-4">
<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">
<Trans>
Want to help us make our translations even better? Check out{" "}
<a href="https://crowdin.com/project/beszel" className="link" target="_blank" rel="noopener noreferrer">
Crowdin
</a>{" "}
for more details.
</Trans>
</p>
</div>
<Label className="block" htmlFor="lang">
<Trans>Preferred Language</Trans>
</Label>
<Select value={i18n.locale} onValueChange={(lang: string) => dynamicActivate(lang)}>
<SelectTrigger id="lang">
<SelectValue />
</SelectTrigger>
<SelectContent>
{languages.map((lang) => (
<SelectItem key={lang.lang} value={lang.lang}>
<span className="me-2.5">{lang.e}</span>
{lang.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Separator />
<div className="space-y-2">
<div className="mb-4">
<h3 className="mb-1 text-lg font-medium">
<Trans>Chart options</Trans>
</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
<Trans>Adjust display options for charts.</Trans>
</p>
</div>
<Label className="block" htmlFor="chartTime">
<Trans>Default time period</Trans>
</Label>
<Select name="chartTime" key={userSettings.chartTime} defaultValue={userSettings.chartTime}>
<SelectTrigger id="chartTime">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(chartTimeData).map(([value, { label }]) => (
<SelectItem key={value} value={value}>
{label()}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[0.8rem] text-muted-foreground">
<Trans>Sets the default time range for charts when a system is viewed.</Trans>
</p>
</div>
<Separator />
<Button type="submit" className="flex items-center gap-1.5 disabled:opacity-100" disabled={isLoading}>
{isLoading ? <LoaderCircleIcon className="h-4 w-4 animate-spin" /> : <SaveIcon className="h-4 w-4" />}
<Trans>Save Settings</Trans>
</Button>
</form>
</div>
)
}

View File

@@ -0,0 +1,115 @@
import { useEffect } from "react"
import { Separator } from "../../ui/separator"
import { SidebarNav } from "./sidebar-nav.tsx"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card.tsx"
import { useStore } from "@nanostores/react"
import { $router } from "@/components/router.tsx"
import { redirectPage } from "@nanostores/router"
import { BellIcon, FileSlidersIcon, SettingsIcon } from "lucide-react"
import { $userSettings, pb } from "@/lib/stores.ts"
import { toast } from "@/components/ui/use-toast.ts"
import { UserSettings } from "@/types.js"
import General from "./general.tsx"
import Notifications from "./notifications.tsx"
import ConfigYaml from "./config-yaml.tsx"
import { Trans, t } from "@lingui/macro"
import { useLingui } from "@lingui/react"
export async function saveSettings(newSettings: Partial<UserSettings>) {
try {
// get fresh copy of settings
const req = await pb.collection("user_settings").getFirstListItem("", {
fields: "id,settings",
})
// update user settings
const updatedSettings = await pb.collection("user_settings").update(req.id, {
settings: {
...req.settings,
...newSettings,
},
})
$userSettings.set(updatedSettings.settings)
toast({
title: t`Settings saved`,
description: t`Your user settings have been updated.`,
})
} catch (e) {
// console.error('update settings', e)
toast({
title: t`Failed to save settings`,
description: t`Check logs for more details.`,
variant: "destructive",
})
}
}
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)
useEffect(() => {
document.title = t`Settings` + " / Beszel"
// redirect to account page if no page is specified
if (page?.path === "/settings") {
redirectPage($router, "settings", { name: "general" })
}
}, [])
return (
<Card className="pt-5 px-4 pb-8 sm:pt-6 sm:px-7">
<CardHeader className="p-0">
<CardTitle className="mb-1">
<Trans>Settings</Trans>
</CardTitle>
<CardDescription>
<Trans>Manage display and notification preferences.</Trans>
</CardDescription>
</CardHeader>
<CardContent className="p-0">
<Separator className="hidden md:block my-5" />
<div className="flex flex-col gap-3.5 md:flex-row md:gap-5 lg:gap-10">
<aside className="md:w-48 w-full">
<SidebarNav items={sidebarNavItems} />
</aside>
<div className="flex-1">
{/* @ts-ignore */}
<SettingsContent name={page?.params?.name ?? "general"} />
</div>
</div>
</CardContent>
</Card>
)
}
function SettingsContent({ name }: { name: string }) {
const userSettings = useStore($userSettings)
switch (name) {
case "general":
return <General userSettings={userSettings} />
case "notifications":
return <Notifications userSettings={userSettings} />
case "config":
return <ConfigYaml />
}
}

View File

@@ -0,0 +1,225 @@
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { pb } from "@/lib/stores"
import { Separator } from "@/components/ui/separator"
import { Card } from "@/components/ui/card"
import { BellIcon, LoaderCircleIcon, PlusIcon, SaveIcon, Trash2Icon } from "lucide-react"
import { ChangeEventHandler, useEffect, useState } from "react"
import { toast } from "@/components/ui/use-toast"
import { InputTags } from "@/components/ui/input-tags"
import { UserSettings } from "@/types"
import { saveSettings } from "./layout"
import * as v from "valibot"
import { isAdmin } from "@/lib/utils"
import { Trans, t } from "@lingui/macro"
interface ShoutrrrUrlCardProps {
url: string
onUrlChange: ChangeEventHandler<HTMLInputElement>
onRemove: () => void
}
const NotificationSchema = v.object({
emails: v.array(v.pipe(v.string(), v.email())),
webhooks: v.array(v.pipe(v.string(), v.url())),
})
const SettingsNotificationsPage = ({ userSettings }: { userSettings: UserSettings }) => {
const [webhooks, setWebhooks] = useState(userSettings.webhooks ?? [])
const [emails, setEmails] = useState<string[]>(userSettings.emails ?? [])
const [isLoading, setIsLoading] = useState(false)
// update values when userSettings changes
useEffect(() => {
setWebhooks(userSettings.webhooks ?? [])
setEmails(userSettings.emails ?? [])
}, [userSettings])
function addWebhook() {
setWebhooks([...webhooks, ""])
// focus on the new input
queueMicrotask(() => {
const inputs = document.querySelectorAll("#webhooks input") as NodeListOf<HTMLInputElement>
inputs[inputs.length - 1]?.focus()
})
}
const removeWebhook = (index: number) => setWebhooks(webhooks.filter((_, i) => i !== index))
function updateWebhook(index: number, value: string) {
const newWebhooks = [...webhooks]
newWebhooks[index] = value
setWebhooks(newWebhooks)
}
async function updateSettings() {
setIsLoading(true)
try {
const parsedData = v.parse(NotificationSchema, { emails, webhooks })
await saveSettings(parsedData)
} catch (e: any) {
toast({
title: t`Failed to save settings`,
description: e.message,
variant: "destructive",
})
}
setIsLoading(false)
}
return (
<div>
<div>
<h3 className="text-xl font-medium mb-2">
<Trans>Notifications</Trans>
</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
<Trans>Configure how you receive alert notifications.</Trans>
</p>
<p className="text-sm text-muted-foreground mt-1.5 leading-relaxed">
<Trans>
Looking instead for where to create alerts? Click the bell <BellIcon className="inline h-4 w-4" /> icons in
the systems table.
</Trans>
</p>
</div>
<Separator className="my-4" />
<div className="space-y-5">
<div className="space-y-2">
<div className="mb-4">
<h3 className="mb-1 text-lg font-medium">
<Trans>Email notifications</Trans>
</h3>
{isAdmin() && (
<p className="text-sm text-muted-foreground leading-relaxed">
<Trans>
Please{" "}
<a href="/_/#/settings/mail" className="link" target="_blank">
configure an SMTP server
</a>{" "}
to ensure alerts are delivered.
</Trans>
</p>
)}
</div>
<Label className="block" htmlFor="email">
<Trans>To email(s)</Trans>
</Label>
<InputTags
value={emails}
onChange={setEmails}
placeholder={t`Enter email address...`}
className="w-full"
type="email"
id="email"
/>
<p className="text-[0.8rem] text-muted-foreground">
<Trans>Save address using enter key or comma. Leave blank to disable email notifications.</Trans>
</p>
</div>
<Separator />
<div className="space-y-3">
<div>
<h3 className="mb-1 text-lg font-medium">
<Trans>Webhook / Push notifications</Trans>
</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
<Trans>
Beszel uses{" "}
<a href="https://containrrr.dev/shoutrrr/services/overview/" target="_blank" className="link">
Shoutrrr
</a>{" "}
to integrate with popular notification services.
</Trans>
</p>
</div>
{webhooks.length > 0 && (
<div className="grid gap-2.5" id="webhooks">
{webhooks.map((webhook, index) => (
<ShoutrrrUrlCard
key={index}
url={webhook}
onUrlChange={(e: React.ChangeEvent<HTMLInputElement>) => updateWebhook(index, e.target.value)}
onRemove={() => removeWebhook(index)}
/>
))}
</div>
)}
<Button
type="button"
variant="outline"
size="sm"
className="mt-2 flex items-center gap-1"
onClick={addWebhook}
>
<PlusIcon className="h-4 w-4 -ms-0.5" />
<Trans>Add URL</Trans>
</Button>
</div>
<Separator />
<Button
type="button"
className="flex items-center gap-1.5 disabled:opacity-100"
onClick={updateSettings}
disabled={isLoading}
>
{isLoading ? <LoaderCircleIcon className="h-4 w-4 animate-spin" /> : <SaveIcon className="h-4 w-4" />}
<Trans>Save Settings</Trans>
</Button>
</div>
</div>
)
}
const ShoutrrrUrlCard = ({ url, onUrlChange, onRemove }: ShoutrrrUrlCardProps) => {
const [isLoading, setIsLoading] = useState(false)
const sendTestNotification = async () => {
setIsLoading(true)
const res = await pb.send("/api/beszel/send-test-notification", { url })
if ("err" in res && !res.err) {
toast({
title: t`Test notification sent`,
description: t`Check your notification service`,
})
} else {
toast({
title: t`Error`,
description: res.err ?? t`Failed to send test notification`,
variant: "destructive",
})
}
setIsLoading(false)
}
return (
<Card className="bg-muted/40 p-2 md:p-3">
<div className="flex items-center gap-1">
<Input
type="url"
className="light:bg-card"
required
placeholder="generic://webhook.site/xxxxxx"
value={url}
onChange={onUrlChange}
/>
<Button type="button" variant="outline" disabled={isLoading || url === ""} onClick={sendTestNotification}>
{isLoading ? (
<LoaderCircleIcon className="h-4 w-4 animate-spin" />
) : (
<span>
<Trans>
Test <span className="hidden sm:inline">URL</span>
</Trans>
</span>
)}
</Button>
<Button type="button" variant="outline" size="icon" className="shrink-0" aria-label="Delete" onClick={onRemove}>
<Trash2Icon className="h-4 w-4" />
</Button>
</div>
</Card>
)
}
export default SettingsNotificationsPage

View File

@@ -0,0 +1,71 @@
import React from "react"
import { cn, isAdmin } from "@/lib/utils"
import { buttonVariants } from "../../ui/button"
import { $router, Link, navigate } from "../../router"
import { useStore } from "@nanostores/react"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Separator } from "@/components/ui/separator"
interface SidebarNavProps extends React.HTMLAttributes<HTMLElement> {
items: {
href: string
title: string
icon?: React.FC<React.SVGProps<SVGSVGElement>>
admin?: boolean
}[]
}
export function SidebarNav({ className, items, ...props }: SidebarNavProps) {
const page = useStore($router)
return (
<>
{/* Mobile View */}
<div className="md:hidden">
<Select onValueChange={(value: string) => navigate(value)} value={page?.path}>
<SelectTrigger className="w-full my-3.5">
<SelectValue placeholder="Select page" />
</SelectTrigger>
<SelectContent>
{items.map((item) => {
if (item.admin && !isAdmin()) return null
return (
<SelectItem key={item.href} value={item.href}>
<span className="flex items-center gap-2">
{item.icon && <item.icon className="h-4 w-4" />}
{item.title}
</span>
</SelectItem>
)
})}
</SelectContent>
</Select>
<Separator />
</div>
{/* Desktop View */}
<nav className={cn("hidden md:grid gap-1", className)} {...props}>
{items.map((item) => {
if (item.admin && !isAdmin()) {
return null
}
return (
<Link
key={item.href}
href={item.href}
className={cn(
buttonVariants({ variant: "ghost" }),
"flex items-center gap-3",
page?.path === item.href ? "bg-muted hover:bg-muted" : "hover:bg-muted/50",
"justify-start"
)}
>
{item.icon && <item.icon className="h-4 w-4" />}
{item.title}
</Link>
)
})}
</nav>
</>
)
}

View File

@@ -0,0 +1,671 @@
import { $systems, pb, $chartTime, $containerFilter, $userSettings, $direction } from "@/lib/stores"
import { ChartData, ChartTimes, ContainerStatsRecord, GPUData, SystemRecord, SystemStatsRecord } from "@/types"
import React, { lazy, useCallback, useEffect, useMemo, useRef, useState } from "react"
import { Card, CardHeader, CardTitle, CardDescription } from "../ui/card"
import { useStore } from "@nanostores/react"
import Spinner from "../spinner"
import { ClockArrowUp, CpuIcon, GlobeIcon, LayoutGridIcon, MonitorIcon, XIcon } from "lucide-react"
import ChartTimeSelect from "../charts/chart-time-select"
import { chartTimeData, cn, getPbTimestamp, getSizeAndUnit, toFixedFloat, useLocalStorage } from "@/lib/utils"
import { Separator } from "../ui/separator"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip"
import { Button } from "../ui/button"
import { Input } from "../ui/input"
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"
import { Plural, Trans, t } from "@lingui/macro"
import { useLingui } from "@lingui/react"
const AreaChartDefault = lazy(() => import("../charts/area-chart"))
const ContainerChart = lazy(() => import("../charts/container-chart"))
const MemChart = lazy(() => import("../charts/mem-chart"))
const DiskChart = lazy(() => import("../charts/disk-chart"))
const SwapChart = lazy(() => import("../charts/swap-chart"))
const TemperatureChart = lazy(() => import("../charts/temperature-chart"))
const GpuPowerChart = lazy(() => import("../charts/gpu-power-chart"))
const cache = new Map<string, any>()
// create ticks and domain for charts
function getTimeData(chartTime: ChartTimes, lastCreated: number) {
const cached = cache.get("td")
if (cached && cached.chartTime === chartTime) {
if (!lastCreated || cached.time >= lastCreated) {
return cached.data
}
}
const now = new Date()
const startTime = chartTimeData[chartTime].getOffset(now)
const ticks = timeTicks(startTime, now, chartTimeData[chartTime].ticks ?? 12).map((date) => date.getTime())
const data = {
ticks,
domain: [chartTimeData[chartTime].getOffset(now).getTime(), now.getTime()],
}
cache.set("td", { time: now.getTime(), data, chartTime })
return data
}
// add empty values between records to make gaps if interval is too large
function addEmptyValues<T extends SystemStatsRecord | ContainerStatsRecord>(
prevRecords: T[],
newRecords: T[],
expectedInterval: number
) {
const modifiedRecords: T[] = []
let prevTime = (prevRecords.at(-1)?.created ?? 0) as number
for (let i = 0; i < newRecords.length; i++) {
const record = newRecords[i]
record.created = new Date(record.created).getTime()
if (prevTime) {
const interval = record.created - prevTime
// if interval is too large, add a null record
if (interval > expectedInterval / 2 + expectedInterval) {
// @ts-ignore
modifiedRecords.push({ created: null, stats: null })
}
}
prevTime = record.created
modifiedRecords.push(record)
}
return modifiedRecords
}
async function getStats<T>(collection: string, system: SystemRecord, chartTime: ChartTimes): Promise<T[]> {
const lastCached = cache.get(`${system.id}_${chartTime}_${collection}`)?.at(-1)?.created as number
return await pb.collection<T>(collection).getFullList({
filter: pb.filter("system={:id} && created > {:created} && type={:type}", {
id: system.id,
created: getPbTimestamp(chartTime, lastCached ? new Date(lastCached + 1000) : undefined),
type: chartTimeData[chartTime].type,
}),
fields: "created,stats",
sort: "created",
})
}
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 }) {
const direction = useStore($direction)
const { _ } = useLingui()
const systems = useStore($systems)
const chartTime = useStore($chartTime)
/** Max CPU toggle value */
const cpuMaxStore = useState(false)
const bandwidthMaxStore = useState(false)
const diskIoMaxStore = useState(false)
const [grid, setGrid] = useLocalStorage("grid", true)
const [system, setSystem] = useState({} as SystemRecord)
const [systemStats, setSystemStats] = useState([] as SystemStatsRecord[])
const [containerData, setContainerData] = useState([] as ChartData["containerData"])
const netCardRef = useRef<HTMLDivElement>(null)
const [containerFilterBar, setContainerFilterBar] = useState(null as null | JSX.Element)
const [bottomSpacing, setBottomSpacing] = useState(0)
const [chartLoading, setChartLoading] = useState(true)
const isLongerChart = chartTime !== "1h"
useEffect(() => {
document.title = `${name} / Beszel`
return () => {
$chartTime.set($userSettings.get().chartTime)
// resetCharts()
setSystemStats([])
setContainerData([])
setContainerFilterBar(null)
$containerFilter.set("")
cpuMaxStore[1](false)
bandwidthMaxStore[1](false)
diskIoMaxStore[1](false)
}
}, [name])
// function resetCharts() {
// setSystemStats([])
// setContainerData([])
// }
// useEffect(resetCharts, [chartTime])
// find matching system
useEffect(() => {
if (system.id && system.name === name) {
return
}
const matchingSystem = systems.find((s) => s.name === name) as SystemRecord
if (matchingSystem) {
setSystem(matchingSystem)
}
}, [name, system, systems])
// update system when new data is available
useEffect(() => {
if (!system.id) {
return
}
pb.collection<SystemRecord>("systems").subscribe(system.id, (e) => {
setSystem(e.record)
})
return () => {
pb.collection("systems").unsubscribe(system.id)
}
}, [system.id])
const chartData: ChartData = useMemo(() => {
const lastCreated = Math.max(
(systemStats.at(-1)?.created as number) ?? 0,
(containerData.at(-1)?.created as number) ?? 0
)
return {
systemStats,
containerData,
chartTime,
orientation: direction === "rtl" ? "right" : "left",
...getTimeData(chartTime, lastCreated),
}
}, [systemStats, containerData, direction])
// get stats
useEffect(() => {
if (!system.id || !chartTime) {
return
}
// loading: true
setChartLoading(true)
Promise.allSettled([
getStats<SystemStatsRecord>("system_stats", system, chartTime),
getStats<ContainerStatsRecord>("container_stats", system, chartTime),
]).then(([systemStats, containerStats]) => {
// loading: false
setChartLoading(false)
const { expectedInterval } = chartTimeData[chartTime]
// make new system stats
const ss_cache_key = `${system.id}_${chartTime}_system_stats`
let systemData = (cache.get(ss_cache_key) || []) as SystemStatsRecord[]
if (systemStats.status === "fulfilled" && systemStats.value.length) {
systemData = systemData.concat(addEmptyValues(systemData, systemStats.value, expectedInterval))
if (systemData.length > 120) {
systemData = systemData.slice(-100)
}
cache.set(ss_cache_key, systemData)
}
setSystemStats(systemData)
// make new container stats
const cs_cache_key = `${system.id}_${chartTime}_container_stats`
let containerData = (cache.get(cs_cache_key) || []) as ContainerStatsRecord[]
if (containerStats.status === "fulfilled" && containerStats.value.length) {
containerData = containerData.concat(addEmptyValues(containerData, containerStats.value, expectedInterval))
if (containerData.length > 120) {
containerData = containerData.slice(-100)
}
cache.set(cs_cache_key, containerData)
}
if (containerData.length) {
!containerFilterBar && setContainerFilterBar(<ContainerFilterBar />)
} else if (containerFilterBar) {
setContainerFilterBar(null)
}
makeContainerData(containerData)
})
}, [system, chartTime])
// make container stats for charts
const makeContainerData = useCallback((containers: ContainerStatsRecord[]) => {
const containerData = [] as ChartData["containerData"]
for (let { created, stats } of containers) {
if (!created) {
// @ts-ignore add null value for gaps
containerData.push({ created: null })
continue
}
created = new Date(created).getTime()
// @ts-ignore not dealing with this rn
let containerStats: ChartData["containerData"][0] = { created }
for (let container of stats) {
containerStats[container.n] = container
}
containerData.push(containerStats)
}
setContainerData(containerData)
}, [])
// values for system info bar
const systemInfo = useMemo(() => {
if (!system.info) {
return []
}
let uptime: React.ReactNode
if (system.info.u < 172800) {
const hours = Math.trunc(system.info.u / 3600)
uptime = <Plural value={hours} one="# hour" other="# hours" />
} else {
uptime = <Plural value={Math.trunc(system.info?.u / 86400)} one="# day" other="# days" />
}
return [
{ value: system.host, Icon: GlobeIcon },
{
value: system.info.h,
Icon: MonitorIcon,
label: "Hostname",
// hide if hostname is same as host or name
hide: system.info.h === system.host || system.info.h === system.name,
},
{ value: uptime, Icon: ClockArrowUp, label: t`Uptime` },
{ 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` : ""})`,
Icon: CpuIcon,
hide: !system.info.m,
},
] as {
value: string | number | undefined
label?: string
Icon: any
hide?: boolean
}[]
}, [system.info])
/** Space for tooltip if more than 12 containers */
useEffect(() => {
if (!netCardRef.current || !containerData.length) {
setBottomSpacing(0)
return
}
const tooltipHeight = (Object.keys(containerData[0]).length - 11) * 17.8 - 40
const wrapperEl = document.getElementById("chartwrap") as HTMLDivElement
const wrapperRect = wrapperEl.getBoundingClientRect()
const chartRect = netCardRef.current.getBoundingClientRect()
const distanceToBottom = wrapperRect.bottom - chartRect.bottom
setBottomSpacing(tooltipHeight - distanceToBottom)
}, [netCardRef, containerData])
if (!system.id) {
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 (
<>
<div id="chartwrap" className="grid gap-4 mb-10 overflow-x-clip">
{/* system info */}
<Card>
<div className="grid xl:flex gap-4 px-4 sm:px-6 pt-3 sm:pt-4 pb-5">
<div>
<h1 className="text-[1.6rem] font-semibold mb-1.5">{system.name}</h1>
<div className="flex flex-wrap items-center gap-3 gap-y-2 text-sm opacity-90">
<div className="capitalize flex gap-2 items-center">
<span className={cn("relative flex h-3 w-3")}>
{system.status === "up" && (
<span
className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"
style={{ animationDuration: "1.5s" }}
></span>
)}
<span
className={cn("relative inline-flex rounded-full h-3 w-3", {
"bg-green-500": system.status === "up",
"bg-red-500": system.status === "down",
"bg-primary/40": system.status === "paused",
"bg-yellow-500": system.status === "pending",
})}
></span>
</span>
{system.status}
</div>
{systemInfo.map(({ value, label, Icon, hide }, i) => {
if (hide || !value) {
return null
}
const content = (
<div className="flex gap-1.5 items-center">
<Icon className="h-4 w-4" /> {value}
</div>
)
return (
<div key={i} className="contents">
<Separator orientation="vertical" className="h-4 bg-primary/30" />
{label ? (
<TooltipProvider>
<Tooltip delayDuration={150}>
<TooltipTrigger asChild>{content}</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
content
)}
</div>
)
})}
</div>
</div>
<div className="xl:ms-auto flex items-center gap-2 max-sm:-mb-1">
<ChartTimeSelect className="w-full xl:w-40" />
<TooltipProvider delayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label={t`Toggle grid`}
variant="outline"
size="icon"
className="hidden xl:flex p-0 text-primary"
onClick={() => setGrid(!grid)}
>
{grid ? (
<LayoutGridIcon className="h-[1.2rem] w-[1.2rem] opacity-85" />
) : (
<Rows className="h-[1.3rem] w-[1.3rem] opacity-85" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>{t`Toggle grid`}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
</Card>
{/* main charts */}
<div className="grid xl:grid-cols-2 gap-4">
<ChartCard
empty={dataEmpty}
grid={grid}
title={_(t`CPU Usage`)}
description={t`Average system-wide CPU utilization`}
cornerEl={isLongerChart ? <SelectAvgMax store={cpuMaxStore} /> : null}
>
<AreaChartDefault chartData={chartData} chartName="CPU Usage" maxToggled={cpuMaxStore[0]} unit="%" />
</ChartCard>
{containerFilterBar && (
<ChartCard
empty={dataEmpty}
grid={grid}
title={dockerOrPodman(t`Docker CPU Usage`, system)}
description={t`Average CPU utilization of containers`}
cornerEl={containerFilterBar}
>
<ContainerChart chartData={chartData} dataKey="c" chartName="cpu" />
</ChartCard>
)}
<ChartCard
empty={dataEmpty}
grid={grid}
title={t`Memory Usage`}
description={t`Precise utilization at the recorded time`}
>
<MemChart chartData={chartData} />
</ChartCard>
{containerFilterBar && (
<ChartCard
empty={dataEmpty}
grid={grid}
title={dockerOrPodman(t`Docker Memory Usage`, system)}
description={dockerOrPodman(t`Memory usage of docker containers`, system)}
cornerEl={containerFilterBar}
>
<ContainerChart chartData={chartData} chartName="mem" dataKey="m" unit=" MB" />
</ChartCard>
)}
<ChartCard empty={dataEmpty} grid={grid} title={t`Disk Usage`} description={t`Usage of root partition`}>
<DiskChart chartData={chartData} dataKey="stats.du" diskSize={systemStats.at(-1)?.stats.d ?? NaN} />
</ChartCard>
<ChartCard
empty={dataEmpty}
grid={grid}
title={t`Disk I/O`}
description={t`Throughput of root filesystem`}
cornerEl={isLongerChart ? <SelectAvgMax store={diskIoMaxStore} /> : null}
>
<AreaChartDefault chartData={chartData} maxToggled={diskIoMaxStore[0]} chartName="dio" />
</ChartCard>
<ChartCard
empty={dataEmpty}
grid={grid}
title={t`Bandwidth`}
cornerEl={isLongerChart ? <SelectAvgMax store={bandwidthMaxStore} /> : null}
description={t`Network traffic of public interfaces`}
>
<AreaChartDefault chartData={chartData} maxToggled={bandwidthMaxStore[0]} chartName="bw" />
</ChartCard>
{containerFilterBar && containerData.length > 0 && (
<div
ref={netCardRef}
className={cn({
"col-span-full": !grid,
})}
>
<ChartCard
empty={dataEmpty}
title={dockerOrPodman(t`Docker Network I/O`, system)}
description={dockerOrPodman(t`Network traffic of docker containers`, system)}
cornerEl={containerFilterBar}
>
{/* @ts-ignore */}
<ContainerChart chartData={chartData} chartName="net" dataKey="n" />
</ChartCard>
</div>
)}
{/* Swap chart */}
{(systemStats.at(-1)?.stats.su ?? 0) > 0 && (
<ChartCard
empty={dataEmpty}
grid={grid}
title={t`Swap Usage`}
description={t`Swap space used by the system`}
>
<SwapChart chartData={chartData} />
</ChartCard>
)}
{/* Temperature chart */}
{systemStats.at(-1)?.stats.t && (
<ChartCard
empty={dataEmpty}
grid={grid}
title={t`Temperature`}
description={t`Temperatures of system sensors`}
>
<TemperatureChart chartData={chartData} />
</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>
{/* 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 */}
{Object.keys(systemStats.at(-1)?.stats.efs ?? {}).length > 0 && (
<div className="grid xl:grid-cols-2 gap-4">
{Object.keys(systemStats.at(-1)?.stats.efs ?? {}).map((extraFsName) => {
return (
<div key={extraFsName} className="contents">
<ChartCard
empty={dataEmpty}
grid={grid}
title={`${extraFsName} ${t`Usage`}`}
description={t`Disk usage of ${extraFsName}`}
>
<DiskChart
chartData={chartData}
dataKey={`stats.efs.${extraFsName}.du`}
diskSize={systemStats.at(-1)?.stats.efs?.[extraFsName].d ?? NaN}
/>
</ChartCard>
<ChartCard
empty={dataEmpty}
grid={grid}
title={`${extraFsName} I/O`}
description={t`Throughput of ${extraFsName}`}
cornerEl={isLongerChart ? <SelectAvgMax store={diskIoMaxStore} /> : null}
>
<AreaChartDefault
chartData={chartData}
maxToggled={diskIoMaxStore[0]}
chartName={`efs.${extraFsName}`}
/>
</ChartCard>
</div>
)
})}
</div>
)}
</div>
{/* add space for tooltip if more than 12 containers */}
{bottomSpacing > 0 && <span className="block" style={{ height: bottomSpacing }} />}
</>
)
}
function ContainerFilterBar() {
const containerFilter = useStore($containerFilter)
const { _ } = useLingui()
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
$containerFilter.set(e.target.value)
}, [])
return (
<>
<Input placeholder={_(t`Filter...`)} className="ps-4 pe-8" value={containerFilter} onChange={handleChange} />
{containerFilter && (
<Button
type="button"
variant="ghost"
size="icon"
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"
onClick={() => $containerFilter.set("")}
>
<XIcon className="h-4 w-4" />
</Button>
)}
</>
)
}
function SelectAvgMax({ store }: { store: [boolean, React.Dispatch<React.SetStateAction<boolean>>] }) {
const [max, setMax] = store
const Icon = max ? ChartMax : ChartAverage
return (
<Select value={max ? "max" : "avg"} onValueChange={(e) => setMax(e === "max")}>
<SelectTrigger className="relative ps-10 pe-5">
<Icon className="h-4 w-4 absolute start-4 top-1/2 -translate-y-1/2 opacity-85" />
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem key="avg" value="avg">
<Trans>Average</Trans>
</SelectItem>
<SelectItem key="max" value="max">
<Trans comment="Chart select field. Please try to keep this short.">Max 1 min</Trans>
</SelectItem>
</SelectContent>
</Select>
)
}
function ChartCard({
title,
description,
children,
grid,
empty,
cornerEl,
}: {
title: string
description: string
children: React.ReactNode
grid?: boolean
empty?: boolean
cornerEl?: JSX.Element | null
}) {
const { isIntersecting, ref } = useIntersectionObserver()
return (
<Card className={cn("pb-2 sm:pb-4 odd:last-of-type:col-span-full", { "col-span-full": !grid })} ref={ref}>
<CardHeader className="pb-5 pt-4 relative space-y-1 max-sm:py-3 max-sm:px-4">
<CardTitle className="text-xl sm:text-2xl">{title}</CardTitle>
<CardDescription>{description}</CardDescription>
{cornerEl && <div className="relative py-1 block sm:w-44 sm:absolute sm:top-2.5 sm:end-3.5">{cornerEl}</div>}
</CardHeader>
<div className="ps-0 w-[calc(100%-1.5em)] h-48 md:h-52 relative group">
{
<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}
</div>
</Card>
)
}

View File

@@ -0,0 +1,14 @@
import { cn } from "@/lib/utils"
import { LoaderCircleIcon } from "lucide-react"
export default function ({ msg, className }: { msg?: string; className?: string }) {
return (
<div className={cn(className, "flex flex-col items-center justify-center h-full absolute inset-0")}>
{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>
)
}

View File

@@ -0,0 +1,593 @@
import {
CellContext,
ColumnDef,
ColumnFiltersState,
getFilteredRowModel,
SortingState,
getSortedRowModel,
flexRender,
VisibilityState,
getCoreRowModel,
useReactTable,
Column,
} from "@tanstack/react-table"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Button, buttonVariants } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { SystemRecord } from "@/types"
import {
MoreHorizontalIcon,
ArrowUpDownIcon,
MemoryStickIcon,
CopyIcon,
PauseCircleIcon,
PlayCircleIcon,
Trash2Icon,
WifiIcon,
HardDriveIcon,
ServerIcon,
CpuIcon,
LayoutGridIcon,
LayoutListIcon,
ArrowDownIcon,
ArrowUpIcon,
Settings2Icon,
EyeIcon,
} from "lucide-react"
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>) {
const val = info.getValue() as number
return (
<div className="flex gap-2 items-center tabular-nums tracking-tight">
<span className="min-w-[3.5em]">{decimalString(val, 1)}%</span>
<span className="grow min-w-10 block bg-muted h-[1em] relative rounded-sm overflow-hidden">
<span
className={cn(
"absolute inset-0 w-full h-full origin-left",
(val < 65 && "bg-green-500") || (val < 90 && "bg-yellow-500") || "bg-red-600"
)}
style={{
transform: `scalex(${val / 100})`,
}}
></span>
</span>
</div>
)
}
function sortableHeader(column: Column<SystemRecord, unknown>, hideSortIcon = false) {
return (
<Button
variant="ghost"
className="h-9 px-3 flex"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
{/* @ts-ignore */}
{column.columnDef?.icon && <column.columnDef.icon className="me-2 size-4" />}
{column.id}
{!hideSortIcon && <ArrowUpDownIcon className="ms-2 size-4" />}
</Button>
)
}
export default function SystemsTable() {
const data = useStore($systems)
const hubVersion = useStore($hubVersion)
const [filter, setFilter] = useState<string>()
const [sorting, setSorting] = useState<SortingState>([{ id: t`System`, desc: false }])
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(() => {
if (filter !== undefined) {
table.getColumn(t`System`)?.setFilterValue(filter)
}
}, [filter])
const columns = useMemo(() => {
return [
{
// size: 200,
size: 200,
minSize: 0,
accessorKey: "name",
id: t`System`,
enableHiding: false,
icon: ServerIcon,
cell: (info) => (
<span className="flex gap-0.5 items-center text-base md:pe-5">
<IndicatorDot system={info.row.original} />
<Button
data-nolink
variant={"ghost"}
className="text-primary/90 h-7 px-1.5 gap-1.5"
onClick={() => copyToClipboard(info.getValue() as string)}
>
{info.getValue() as string}
<CopyIcon className="h-2.5 w-2.5" />
</Button>
</span>
),
header: ({ column }) => sortableHeader(column),
},
{
accessorKey: "info.cpu",
id: t`CPU`,
invertSorting: true,
cell: CellFormatter,
icon: CpuIcon,
header: ({ column }) => sortableHeader(column),
},
{
accessorKey: "info.mp",
id: t`Memory`,
invertSorting: true,
cell: CellFormatter,
icon: MemoryStickIcon,
header: ({ column }) => sortableHeader(column),
},
{
accessorKey: "info.dp",
id: t`Disk`,
invertSorting: true,
cell: CellFormatter,
icon: HardDriveIcon,
header: ({ column }) => sortableHeader(column),
},
{
accessorFn: (originalRow) => originalRow.info.b || 0,
id: t`Net`,
invertSorting: true,
size: 115,
icon: EthernetIcon,
header: ({ column }) => sortableHeader(column),
cell(info) {
const val = info.getValue() as number
return (
<span
className={cn("tabular-nums whitespace-nowrap", {
"ps-1": viewMode === "table",
})}
>
{decimalString(val, val >= 100 ? 1 : 2)} MB/s
</span>
)
},
},
{
accessorKey: "info.v",
id: t`Agent`,
invertSorting: true,
size: 50,
icon: WifiIcon,
header: ({ column }) => sortableHeader(column, true),
cell(info) {
const version = info.getValue() as string
if (!version || !hubVersion) {
return null
}
return (
<span
className={cn("flex gap-2 items-center md:pe-5 tabular-nums", {
"ps-1": viewMode === "table",
})}
>
<IndicatorDot
system={info.row.original}
className={version === hubVersion ? "bg-green-500" : "bg-yellow-500"}
/>
<span>{info.getValue() as string}</span>
</span>
)
},
},
{
id: t({ message: "Actions", comment: "Table column" }),
size: 120,
cell: ({ row }) => (
<div className="flex justify-end items-center gap-1">
<AlertsButton system={row.original} />
<ActionsButton system={row.original} />
</div>
),
},
] as ColumnDef<SystemRecord>[]
}, [hubVersion, i18n.locale])
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
onSortingChange: setSorting,
getSortedRowModel: getSortedRowModel(),
onColumnFiltersChange: setColumnFilters,
getFilteredRowModel: getFilteredRowModel(),
onColumnVisibilityChange: setColumnVisibility,
state: {
sorting,
columnFilters,
columnVisibility,
},
defaultColumn: {
minSize: 0,
size: Number.MAX_SAFE_INTEGER,
maxSize: Number.MAX_SAFE_INTEGER,
},
})
return (
<Card>
<CardHeader className="pb-5 px-2 sm:px-6 max-sm:pt-5 max-sm:pb-1">
<div className="grid md:flex gap-5 w-full items-end">
<div className="px-2 sm:px-1">
<CardTitle className="mb-2.5">
<Trans>All Systems</Trans>
</CardTitle>
<CardDescription>
<Trans>Updated in real time. Click on a system to view information.</Trans>
</CardDescription>
</div>
<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>
<DropdownMenuTrigger asChild>
<Button variant="outline">
<Settings2Icon className="me-1.5 size-4 opacity-80" />
<Trans>View</Trans>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="h-72 md:h-auto min-w-48 md:min-w-auto overflow-y-auto">
<div className="grid grid-cols-1 md:grid-cols-3 divide-y md:divide-s md:divide-y-0">
<div>
<DropdownMenuLabel className="pt-2 px-3.5 flex items-center gap-2">
<LayoutGridIcon className="size-4" />
<Trans>Layout</Trans>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup
className="px-1 pb-1"
value={viewMode}
onValueChange={(view) => setViewMode(view as ViewMode)}
>
<DropdownMenuRadioItem value="table" onSelect={(e) => e.preventDefault()} className="gap-2">
<LayoutListIcon className="size-4" />
<Trans>Table</Trans>
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="grid" onSelect={(e) => e.preventDefault()} className="gap-2">
<LayoutGridIcon className="size-4" />
<Trans>Grid</Trans>
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</div>
<div>
<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>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.original.id}
data-state={row.getIsSelected() && "selected"}
className={cn("cursor-pointer transition-opacity", {
"opacity-50": row.original.status === "paused",
})}
onClick={(e) => {
const target = e.target as HTMLElement
if (!target.closest("[data-nolink]") && e.currentTarget.contains(target)) {
navigate(`/system/${encodeURIComponent(row.original.name)}`)
}
}}
>
{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 = {
children: React.ReactNode
@@ -14,7 +14,7 @@ type ThemeProviderState = {
}
const initialState: ThemeProviderState = {
theme: 'system',
theme: "system",
setTheme: () => null,
}
@@ -22,23 +22,19 @@ const ThemeProviderContext = createContext<ThemeProviderState>(initialState)
export function ThemeProvider({
children,
defaultTheme = 'system',
storageKey = 'ui-theme',
defaultTheme = "system",
storageKey = "ui-theme",
...props
}: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
)
const [theme, setTheme] = useState<Theme>(() => (localStorage.getItem(storageKey) as Theme) || defaultTheme)
useEffect(() => {
const root = window.document.documentElement
root.classList.remove('light', 'dark')
root.classList.remove("light", "dark")
if (theme === 'system') {
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light'
if (theme === "system") {
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
root.classList.add(systemTheme)
return

View File

@@ -1,8 +1,8 @@
import * as React from 'react'
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from '@/lib/utils'
import { buttonVariants } from '@/components/ui/button'
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
const AlertDialog = AlertDialogPrimitive.Root
@@ -16,7 +16,7 @@ const AlertDialogOverlay = React.forwardRef<
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
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
)}
{...props}
@@ -34,7 +34,7 @@ const AlertDialogContent = React.forwardRef<
<AlertDialogPrimitive.Content
ref={ref}
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
)}
{...props}
@@ -44,27 +44,20 @@ const AlertDialogContent = React.forwardRef<
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
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>) => (
<div
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
{...props}
/>
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-2", className)} {...props} />
)
AlertDialogFooter.displayName = 'AlertDialogFooter'
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold', className)}
{...props}
/>
<AlertDialogPrimitive.Title ref={ref} className={cn("text-lg font-semibold", className)} {...props} />
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
@@ -72,11 +65,7 @@ const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
<AlertDialogPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
))
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName
@@ -94,7 +83,7 @@ const AlertDialogCancel = React.forwardRef<
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
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}
/>
))

View File

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

View File

@@ -0,0 +1,29 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
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",
{
variants: {
variant: {
default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,47 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
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",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
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",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

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

View File

@@ -0,0 +1,354 @@
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import { chartTimeData, cn } from "@/lib/utils"
import { ChartData } from "@/types"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode
icon?: React.ComponentType
} & ({ color?: string; theme?: never } | { color?: never; theme: Record<keyof typeof THEMES, string> })
}
// type ChartContextProps = {
// config: ChartConfig
// }
// const ChartContext = React.createContext<ChartContextProps | null>(null)
// function useChart() {
// const context = React.useContext(ChartContext)
// if (!context) {
// throw new Error('useChart must be used within a <ChartContainer />')
// }
// return context
// }
const ChartContainer = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
// config: ChartConfig
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>["children"]
}
>(({ id, className, children, ...props }, ref) => {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
return (
//<ChartContext.Provider value={{ config }}>
<div
data-chart={chartId}
ref={ref}
className={cn(
"text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line-line]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
className
)}
{...props}
>
{/* <ChartStyle id={chartId} config={config} /> */}
<RechartsPrimitive.ResponsiveContainer>{children}</RechartsPrimitive.ResponsiveContainer>
</div>
//</ChartContext.Provider>
)
})
ChartContainer.displayName = "Chart"
// const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
// const colorConfig = Object.entries(config).filter(([_, config]) => config.theme || config.color)
// if (!colorConfig.length) {
// return null
// }
// return (
// <style
// dangerouslySetInnerHTML={{
// __html: Object.entries(THEMES).map(
// ([theme, prefix]) => `
// ${prefix} [data-chart=${id}] {
// ${colorConfig
// .map(([key, itemConfig]) => {
// const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color
// return color ? ` --color-${key}: ${color};` : null
// })
// .join('\n')}
// }
// `
// ),
// }}
// />
// )
// }
const ChartTooltip = RechartsPrimitive.Tooltip
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
unit?: string
filter?: string
contentFormatter?: (item: any, key: string) => React.ReactNode | string
}
>(
(
{
active,
payload,
className,
indicator = "line",
hideLabel = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
unit,
filter,
itemSorter,
contentFormatter: content = undefined,
},
ref
) => {
// const { config } = useChart()
const config = {}
React.useMemo(() => {
if (filter) {
payload = payload?.filter((item) => (item.name as string)?.includes(filter))
}
if (itemSorter) {
// @ts-ignore
payload?.sort(itemSorter)
}
}, [itemSorter, payload])
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey || item.name || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value = !labelKey && typeof label === "string" ? label : itemConfig?.label
if (labelFormatter) {
return <div className={cn("font-medium", labelClassName)}>{labelFormatter(value, payload)}</div>
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey])
if (!active || !payload?.length) {
return null
}
// const nestLabel = payload.length === 1 && indicator !== 'dot'
const nestLabel = false
return (
<div
ref={ref}
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",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.payload.fill || item.color
return (
<div
key={item?.name || item.dataKey}
className={cn(
"flex w-full items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
<div
className={cn("shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]", {
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent": indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
})}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)}
<div
className={cn(
"flex flex-1 justify-between leading-none gap-2",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">{itemConfig?.label || item.name}</span>
</div>
{item.value !== undefined && (
<span className="font-medium tabular-nums text-foreground">
{content && typeof content === "function"
? content(item, key)
: item.value.toLocaleString() + (unit ? unit : "")}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
)
ChartTooltipContent.displayName = "ChartTooltip"
const ChartLegend = RechartsPrimitive.Legend
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean
nameKey?: string
}
>(({ className, payload, verticalAlign = "bottom" }, ref) => {
// const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
ref={ref}
className={cn(
"flex items-center justify-center gap-4 gap-y-1 flex-wrap",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload.map((item) => {
// const key = `${nameKey || item.dataKey || 'value'}`
// const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={item.value}
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 text-muted-foreground"
)}
>
{/* {itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : ( */}
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
{item.value}
{/* )} */}
{/* {itemConfig?.label} */}
</div>
)
})}
</div>
)
})
ChartLegendContent.displayName = "ChartLegend"
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload && typeof payload.payload === "object" && payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (key in payload && typeof payload[key as keyof typeof payload] === "string") {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string
}
return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config]
}
let cachedAxis: JSX.Element
const xAxis = function ({ domain, ticks, chartTime }: ChartData) {
if (cachedAxis && domain[0] === cachedAxis.props.domain[0]) {
return cachedAxis
}
cachedAxis = (
<RechartsPrimitive.XAxis
dataKey="created"
domain={domain}
ticks={ticks}
allowDataOverflow
type="number"
scale="time"
minTickGap={12}
tickMargin={8}
axisLine={false}
tickFormatter={chartTimeData[chartTime].format}
/>
)
return cachedAxis
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
xAxis,
// ChartStyle,
}

View File

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

View File

@@ -1,10 +1,10 @@
import * as React from 'react'
import { DialogTitle, type DialogProps } from '@radix-ui/react-dialog'
import { Command as CommandPrimitive } from 'cmdk'
import { Search } from 'lucide-react'
import * as React from "react"
import { DialogTitle, type DialogProps } from "@radix-ui/react-dialog"
import { Command as CommandPrimitive } from "cmdk"
import { Search } from "lucide-react"
import { cn } from '@/lib/utils'
import { Dialog, DialogContent } from '@/components/ui/dialog'
import { cn } from "@/lib/utils"
import { Dialog, DialogContent } from "@/components/ui/dialog"
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
@@ -12,10 +12,7 @@ const Command = React.forwardRef<
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
'flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground',
className
)}
className={cn("flex h-full w-full flex-col overflow-hidden bg-popover text-popover-foreground", className)}
{...props}
/>
))
@@ -43,11 +40,11 @@ const CommandInput = React.forwardRef<
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<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
ref={ref}
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
)}
{...props}
@@ -63,7 +60,7 @@ const CommandList = React.forwardRef<
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
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}
/>
))
@@ -73,9 +70,7 @@ CommandList.displayName = CommandPrimitive.List.displayName
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} />
))
>((props, ref) => <CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} />)
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
@@ -86,7 +81,7 @@ const CommandGroup = React.forwardRef<
<CommandPrimitive.Group
ref={ref}
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
)}
{...props}
@@ -99,11 +94,7 @@ const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn('-mx-1 h-px bg-border', className)}
{...props}
/>
<CommandPrimitive.Separator ref={ref} className={cn("-mx-1 h-px bg-border", className)} {...props} />
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
@@ -124,14 +115,9 @@ const CommandItem = React.forwardRef<
CommandItem.displayName = CommandPrimitive.Item.displayName
const CommandShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn('ml-auto text-xs tracking-wide text-muted-foreground', className)}
{...props}
/>
)
return <span className={cn("ms-auto text-xs tracking-wide text-muted-foreground", className)} {...props} />
}
CommandShortcut.displayName = 'CommandShortcut'
CommandShortcut.displayName = "CommandShortcut"
export {
Command,

View File

@@ -1,8 +1,8 @@
import * as React from 'react'
import * as DialogPrimitive from '@radix-ui/react-dialog'
import { X } from 'lucide-react'
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from '@/lib/utils'
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
@@ -19,7 +19,7 @@ const DialogOverlay = React.forwardRef<
<DialogPrimitive.Overlay
ref={ref}
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
)}
{...props}
@@ -36,13 +36,13 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
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
)}
{...props}
>
{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" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
@@ -52,17 +52,14 @@ const DialogContent = React.forwardRef<
DialogContent.displayName = DialogPrimitive.Content.displayName
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>) => (
<div
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
{...props}
/>
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-3.5", className)} {...props} />
)
DialogFooter.displayName = 'DialogFooter'
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
@@ -70,7 +67,7 @@ const DialogTitle = React.forwardRef<
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
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}
/>
))
@@ -80,11 +77,7 @@ const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
<DialogPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
))
DialogDescription.displayName = DialogPrimitive.Description.displayName

View File

@@ -0,0 +1,179 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2.5 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
inset && "ps-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ms-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
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",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
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",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2.5 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "ps-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 ps-8 pe-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 ps-8 pe-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn("px-2.5 py-1.5 text-sm font-semibold", inset && "ps-8", className)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-muted", className)} {...props} />
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return <span className={cn("ms-auto text-xs tracking-widest opacity-60", className)} {...props} />
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

View File

@@ -0,0 +1,65 @@
import { SVGProps } from "react"
// linux-logo-bold from https://github.com/phosphor-icons/core (MIT license)
export function TuxIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 256 256" {...props}>
<path
fill="currentColor"
d="M231 217a12 12 0 0 1-16-2c-2-1-35-44-35-127a52 52 0 1 0-104 0c0 83-33 126-35 127a12 12 0 0 1-18-14c0-1 29-39 29-113a76 76 0 1 1 152 0c0 74 29 112 29 113a12 12 0 0 1-2 16m-127-97a16 16 0 1 0-16-16 16 16 0 0 0 16 16m64-16a16 16 0 1 0-16 16 16 16 0 0 0 16-16m-73 51 28 12a12 12 0 0 0 10 0l28-12a12 12 0 0 0-10-22l-23 10-23-10a12 12 0 0 0-10 22m33 29a57 57 0 0 0-39 15 12 12 0 0 0 17 18 33 33 0 0 1 44 0 12 12 0 1 0 17-18 57 57 0 0 0-39-15"
/>
</svg>
)
}
// MingCute Apache License 2.0 https://github.com/Richard9394/MingCute
export function Rows(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" {...props}>
<path
fill="currentColor"
d="M5 3a2 2 0 0 0-2 2v4a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2zm0 2h14v4H5zm0 8a2 2 0 0 0-2 2v4a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-4a2 2 0 0 0-2-2zm0 2h14v4H5z"
/>
</svg>
)
}
// IconPark Apache License 2.0 https://github.com/bytedance/IconPark
export function ChartAverage(props: SVGProps<SVGSVGElement>) {
return (
<svg fill="none" viewBox="0 0 48 48" stroke="currentColor" {...props}>
<path strokeWidth="3" d="M4 4v40h40" />
<path strokeWidth="3" d="M10 38S15.3 4 27 4s17 34 17 34" />
<path strokeWidth="4" d="M10 24h34" />
</svg>
)
}
// IconPark Apache License 2.0 https://github.com/bytedance/IconPark
export function ChartMax(props: SVGProps<SVGSVGElement>) {
return (
<svg fill="none" viewBox="0 0 48 48" stroke="currentColor" {...props}>
<path strokeWidth="3" d="M4 4v40h40" />
<path strokeWidth="3" d="M10 38S15.3 4 27 4s17 34 17 34" />
<path strokeWidth="4" d="M10 4h34" />
</svg>
)
}
// Lucide https://github.com/lucide-icons/lucide (not in package for some reason)
export function EthernetIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg fill="none" stroke="currentColor" strokeLinecap="round" strokeWidth="2" viewBox="0 0 24 24" {...props}>
<path d="m15 20 3-3h2a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h2l3 3zM6 8v1m4-1v1m4-1v1m4-1v1" />
</svg>
)
}
// Phosphor MIT https://github.com/phosphor-icons/core
export function ThermometerIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 256 256" {...props} fill="currentColor">
<path d="M212 56a28 28 0 1 0 28 28 28 28 0 0 0-28-28m0 40a12 12 0 1 1 12-12 12 12 0 0 1-12 12m-60 50V40a32 32 0 0 0-64 0v106a56 56 0 1 0 64 0m-16-42h-32V40a16 16 0 0 1 32 0Z" />
</svg>
)
}

View File

@@ -0,0 +1,78 @@
import * as React from "react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
import { type InputProps } from "./input"
import { cn } from "@/lib/utils"
type InputTagsProps = Omit<InputProps, "value" | "onChange"> & {
value: string[]
onChange: React.Dispatch<React.SetStateAction<string[]>>
}
const InputTags = React.forwardRef<HTMLInputElement, InputTagsProps>(
({ className, value, onChange, ...props }, ref) => {
const [pendingDataPoint, setPendingDataPoint] = React.useState("")
React.useEffect(() => {
if (pendingDataPoint.includes(",")) {
const newDataPoints = new Set([...value, ...pendingDataPoint.split(",").map((chunk) => chunk.trim())])
onChange(Array.from(newDataPoints))
setPendingDataPoint("")
}
}, [pendingDataPoint, onChange, value])
const addPendingDataPoint = () => {
if (pendingDataPoint) {
const newDataPoints = new Set([...value, pendingDataPoint])
onChange(Array.from(newDataPoints))
setPendingDataPoint("")
}
}
return (
<div
className={cn(
"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
)}
>
{value.map((item) => (
<Badge key={item}>
{item}
<Button
variant="ghost"
size="icon"
className="ms-2 h-3 w-3"
onClick={() => {
onChange(value.filter((i) => i !== item))
}}
>
<XIcon className="w-3" />
</Button>
</Badge>
))}
<input
className="flex-1 outline-none bg-background placeholder:text-muted-foreground"
value={pendingDataPoint}
onChange={(e) => setPendingDataPoint(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault()
addPendingDataPoint()
} else if (e.key === "Backspace" && pendingDataPoint.length === 0 && value.length > 0) {
e.preventDefault()
onChange(value.slice(0, -1))
}
}}
{...props}
ref={ref}
/>
</div>
)
}
)
InputTags.displayName = "InputTags"
export { InputTags }

View File

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

View File

@@ -0,0 +1,17 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva("text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70")
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@@ -0,0 +1,143 @@
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"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
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
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",
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",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label ref={ref} className={cn("py-1.5 ps-8 pe-2 text-sm font-semibold", className)} {...props} />
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 ps-8 pe-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-muted", className)} {...props} />
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

View File

@@ -0,0 +1,20 @@
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(({ className, orientation = "horizontal", decorative = true, ...props }, ref) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn("shrink-0 bg-border", orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]", className)}
{...props}
/>
))
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }

View File

@@ -1,7 +1,7 @@
import * as React from 'react'
import * as SliderPrimitive from '@radix-ui/react-slider'
import * as React from "react"
import * as SliderPrimitive from "@radix-ui/react-slider"
import { cn } from '@/lib/utils'
import { cn } from "@/lib/utils"
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
@@ -9,7 +9,7 @@ const Slider = React.forwardRef<
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
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}
>
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">

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