Compare commits

...

480 Commits

Author SHA1 Message Date
henrygd
fca13004bd release 0.12.9 2025-09-17 16:06:20 -04:00
henrygd
376a86829c fix divide by zero error (#1175) 2025-09-17 16:00:50 -04:00
henrygd
ef48613f3f improve style of chart sheet button on mobile
- also update changelog
2025-09-17 15:13:10 -04:00
henrygd
49976c6f61 fix nvidia agent dockerfile after project reorganization 2025-09-17 14:10:02 -04:00
henrygd
d68f1f0985 0.12.8 release :) 2025-09-17 14:02:17 -04:00
henrygd
273a090200 update translations 2025-09-17 14:01:20 -04:00
henrygd
59057a2ba4 add check for status alerts which are not properly resolved (#1052) 2025-09-17 13:31:49 -04:00
henrygd
1b9e781d45 refactor deltatracker
- embed mutex
- add example function
2025-09-16 22:09:46 -04:00
henrygd
4e0ca7c2ba formatting (biome) 2025-09-15 18:04:13 -04:00
henrygd
a9e7bcd37f add per-interface and cumulative network traffic charts (#926)
Co-authored-by: Sven van Ginkel <svenvanginkel@icloud.com>
2025-09-15 17:59:21 -04:00
henrygd
4635f24fb2 fix entre arg in makefile dev server 2025-09-15 17:26:07 -04:00
henrygd
3e73399b87 fix battery detection on newer macs (#1170) 2025-09-15 12:02:50 -04:00
Ryan W
e149366451 Fixing service name in helm chart and making default values unopinionated (#1166) 2025-09-13 12:09:36 -04:00
henrygd
8da1ded73e strip whitespace from TOKEN_FILE (#984) 2025-09-12 12:59:53 -04:00
henrygd
efa37b2312 web: extra check for valid system before adding (#1063) 2025-09-11 15:37:11 -04:00
henrygd
bcdb4c92b5 add freebsd to list of copyable commands 2025-09-11 15:07:37 -04:00
henrygd
a7d07310b6 Add AUTO_LOGIN environment variable for automatic login. (#399) 2025-09-11 14:01:09 -04:00
hank
8db87e5497 Update Crowdin configuration file 2025-09-11 12:45:43 -04:00
henrygd
e601a0d564 add TRUSTED_AUTH_HEADER for auth forwarding (#399) 2025-09-10 21:26:59 -04:00
Fankesyooni
07491108cd new zh-CN translations (#1160) 2025-09-10 13:34:17 -04:00
henrygd
42ab17de1f move i18n.yml to project root 2025-09-10 13:30:42 -04:00
henrygd
2d14174f61 update i18n.yml 2025-09-10 13:28:06 -04:00
henrygd
a19ccc9263 comments 2025-09-09 17:13:15 -04:00
henrygd
956880aa59 improve container filtering performance
- also a few instances of autoformat
2025-09-09 16:59:16 -04:00
henrygd
b2b54db409 update makefile with proper site src path 2025-09-09 15:41:15 -04:00
henrygd
32d5188eef path updates after reorganization 2025-09-09 13:53:36 -04:00
henrygd
46dab7f531 fix gitignore after reorganization 2025-09-09 13:51:46 -04:00
henrygd
c898a9ebbc update /src to /internal 2025-09-09 13:35:34 -04:00
henrygd
8a13b05c20 rename /src to /internal (sorry i'll fix the prs) 2025-09-09 13:29:07 -04:00
henrygd
86ea23fe39 refactor install-agent.sh for freebsd
- Updated paths for FreeBSD installation, changing default directories from /opt/beszel-agent to /usr/local/etc/beszel-agent and /usr/local/sbin.
- Introduced conditional path setting based on the operating system.
- Updated cron job creation to use /etc/cron.d
2025-09-08 19:46:39 -04:00
henrygd
a284dd74dd add time format (12h, 24h) settings (#424)
- Some biome formatting :/
- Changed chartTime update to use listenkeys
2025-09-08 17:12:43 -04:00
Alexander Mnich
6a0075291c [FIX] OpenWRT auto update (#1155)
* switch to root crontab for OpenWRT auto update

* fix: OpenWRT auto update restart condition
2025-09-08 16:50:39 -04:00
henrygd
f542bc70a1 add biome and apply selected formatting / fixes 2025-09-08 15:22:04 -04:00
henrygd
270e59d9ea add support for otp and mfa 2025-09-07 20:46:34 -04:00
henrygd
0d97a604f8 rename main.go to beszel.go 2025-09-07 17:34:56 -04:00
henrygd
f6078fc232 fix govulncheck workflow 2025-09-07 16:52:46 -04:00
henrygd
6f5d95031c update project structure
- move agent to /agent
- change /beszel to /src
- update workflows and docker builds
2025-09-07 16:42:15 -04:00
henrygd
4e26defdca update imports for gh package name 2025-09-07 14:48:39 -04:00
Ayman Nedjmeddine
cda8fa7efd Rename Go module to github.com/henrygd/beszel 2025-09-07 14:29:56 -04:00
henrygd
fd050f2a8f revert to previous version behavior of setting hub.appURL (#1148)
- remove fallback that sets appUrl to settings.Meta.AppURL
- prefer using current browser URL in generated config if APP_URL not set
2025-09-07 14:10:47 -04:00
henrygd
e53d41dcec refactor: launch web url for dev server + css update 2025-09-07 14:10:34 -04:00
Sasha Blue
a1eb15dabb Adding openwrt restart procedure after updating the agent automatically (#1151) 2025-09-07 13:25:23 -04:00
henrygd
eb4bdafbea add freebsd support to agent install script / update command (#39) 2025-09-05 19:15:21 -04:00
Alexander Mnich
fea2330534 fix: avoid goreleaser truthy evaluation (#1146)
Goreleaser performs truthy evaluation on templates. As .Env.IS_FORK is a string the env variable containing a non empty-string already evaluated to true.
2025-09-05 17:25:57 -04:00
henrygd
5e37469ea9 0.12.7 release :) 2025-09-05 14:00:24 -04:00
henrygd
e027479bb1 make sure initial user is verfied when supplying user/pass 2025-09-05 14:00:21 -04:00
henrygd
1597e869c1 update translations 2025-09-05 13:53:17 -04:00
henrygd
862399d8ec update language files 2025-09-05 12:36:45 -04:00
henrygd
f6f85f8f9d Add USER_EMAIL and USER_PASSWORD env vars to set the email / pass of initial user (#1137) 2025-09-05 11:42:43 -04:00
Riedel, Max
e22d7ca801 fix: add nextSystemToken to deps of useEffect to generate a new token after system creation (#1142) 2025-09-05 11:00:32 -04:00
henrygd
c382c1d5f6 windows: make LHM opt-in with LHM=true (#1130) 2025-09-05 10:39:18 -04:00
henrygd
f7618ed6b0 update go version for vulcheck action 2025-09-04 19:17:44 -04:00
henrygd
d1295b7c50 alerts tests and small refactoring 2025-09-04 19:13:10 -04:00
henrygd
a162a54a58 bump go version and add keyword 2025-09-04 19:13:10 -04:00
henrygd
794db0ac6a make sure old names are removed in systemsbyname store 2025-09-04 19:13:10 -04:00
henrygd
e9fb9b856f install script: remove newlines from KEY (#1139) 2025-09-04 11:26:53 -04:00
Sven van Ginkel
66bca11d36 [Bug] Update install script to use crontab on Alpine (#1136)
* add cron

* update the install script
2025-09-03 23:10:38 -04:00
henrygd
86e87f0d47 refactor hub dev server
- moved html replacement functionality from vite to go
2025-09-01 22:16:57 -04:00
henrygd
fadfc5d81d refactor(hub): separate development and production server logic 2025-09-01 19:27:11 -04:00
henrygd
fc39ff1e4d add pflag to go deps 2025-09-01 19:24:26 -04:00
henrygd
82ccfc66e0 refactor: shared container charts config hook 2025-09-01 18:41:30 -04:00
henrygd
890bad1c39 refactor: improve runOnce with weakmap cache 2025-09-01 18:34:29 -04:00
henrygd
9c458885f1 refactor (hub): add systemsManager module
- Removed the `updateSystemList` function and replaced it with a more efficient system management approach using `systemsManager`.
- Updated the `App` component to initialize and subscribe to system updates through the new `systemsManager`.
- Refactored the `SystemsTable` and `SystemDetail` components to utilize the new state management for systems, improving performance and maintainability.
- Enhanced the `ActiveAlerts` component to fetch system names directly from the new state structure.
2025-09-01 17:29:33 -04:00
henrygd
d2aed0dc72 refactor: replace useLocalStorage with useBrowserStorage 2025-09-01 17:28:13 -04:00
Augustin ROLET
3dbcb5d7da Minor UI changes (#1110)
* ui: add devices count from #1078

* ux: save sortMode in localStorage from #1024

* fix: reload component when system switch to "up"

* ux: move running systems to desc field
2025-08-31 18:16:25 -04:00
Alexander Mnich
57a1a8b39e [Feature] improved support for mips and mipsle architectures (#1112)
* switch mipsle to softfloat

* feat: add support for mips
2025-08-30 15:50:15 -04:00
Alexander Mnich
ab81c04569 [Fix] fix GitHub workflow errors in forks (#1113)
* feat: do not run winget/homebrew/scoop release in fork

* fix: replaced deprecated goreleaser fields

https://goreleaser.com/deprecations/#archivesbuilds

* fix: push docker images only with access to the registry
2025-08-30 15:49:49 -04:00
henrygd
0c32be3bea 0.12.6 release :) 2025-08-29 17:24:45 -04:00
henrygd
81d43fbf6e refactor: small style improvements 2025-08-29 17:23:47 -04:00
henrygd
96f441de40 Virtualize All Systems table to improve performance with hundreds of systems (#1100)
- Also truncate long system names in tables and alerts sheet. (#1104)
2025-08-29 16:16:45 -04:00
henrygd
0e95caaee9 update command ui component 2025-08-29 15:04:26 -04:00
Sven van Ginkel
7697a12b42 fix alignment for metrics (#1109) 2025-08-29 14:00:17 -04:00
henrygd
94245a9ba4 fix update mirror and make opt-in with --china-mirrors (#1035) 2025-08-29 13:46:24 -04:00
henrygd
b084814aea auth form: fix border style and add theme toggle 2025-08-28 21:17:44 -04:00
Impact
cce74246ee Use older cuda image for increased compatibility (#1103) 2025-08-28 20:49:52 -04:00
henrygd
a3420b8c67 add max 1 min memory 2025-08-28 20:07:22 -04:00
henrygd
e1bb17ee9e update locale files 2025-08-28 18:23:40 -04:00
henrygd
52983f60b7 refactor: add api module and page preloading 2025-08-28 18:23:24 -04:00
henrygd
1f053fd85d update 2025-08-28 17:31:18 -04:00
Sven van Ginkel
a989d121d3 [Feature] Add Status Filtering to Systems Table (#927) 2025-08-28 17:30:44 -04:00
Sven van Ginkel
50d2406423 [Bug] Fix system table in Safari (#1092)
Co-authored-by: henrygd <hank@henrygd.me>
2025-08-28 12:07:27 -04:00
Sven van Ginkel
059d2d0a5b Add missing os.Chmod step to hub update command (#1093) 2025-08-27 13:00:03 -04:00
henrygd
621bef30b5 update changelog 2025-08-26 21:26:18 -04:00
henrygd
5f4d3dc730 0.12.5 release :) 2025-08-26 21:04:46 -04:00
henrygd
8fa9aece63 change long german translation 2025-08-26 20:50:35 -04:00
henrygd
2f1a022e2a refactor: use width for meters instead of scale 2025-08-26 20:49:31 -04:00
henrygd
4815cd29bc ghupdate: rename plugin struct 2025-08-26 18:41:42 -04:00
henrygd
e49bfaf5d7 downgrade gopsutil to fix freebsd bug (#1083) 2025-08-26 18:40:32 -04:00
henrygd
b13915b76f freebsd: fix battery-related bug (#1081) 2025-08-26 18:39:42 -04:00
henrygd
e2a57dc43b update tooltip component for tailwind 4 2025-08-26 16:16:38 -04:00
henrygd
7222224b40 add battery to supported metrics 2025-08-25 23:15:19 -04:00
henrygd
02ff475b84 improve language toggle selected style 2025-08-25 22:14:48 -04:00
henrygd
09cd8d0db9 add check for battery array length (#1076) 2025-08-25 21:34:34 -04:00
henrygd
36f1a0c53b update synctest.run to synctest.test 2025-08-25 21:25:19 -04:00
henrygd
0b0e94e045 remove pocketbase imports from ghupdate 2025-08-25 21:16:43 -04:00
henrygd
20ca6edf81 0.12.4 release :) 2025-08-25 20:23:38 -04:00
henrygd
1990f8c6df use mirror for asset download in update command (#1035) 2025-08-25 20:18:31 -04:00
henrygd
6e9dbf863f remove rhysd/go-github-selfupdate dependency 2025-08-25 20:05:02 -04:00
henrygd
fa921d77f1 update updater (#1009) 2025-08-25 20:04:23 -04:00
Sven van Ginkel
ff854d481d [Chore] Improve auto update mechanism (#1009) 2025-08-25 17:30:42 -04:00
henrygd
4ce491fe48 i18n: add default machine translations for new strings 2025-08-25 17:02:34 -04:00
henrygd
493bae7eb6 i18n: new battery strings 2025-08-25 16:46:15 -04:00
henrygd
ae5532aa36 new translations 2025-08-25 16:37:20 -04:00
NaNomicon
a1eae6413a New Vietnamese translations 2025-08-25 16:36:41 -04:00
henrygd
ee52bf1fbf New Polish translations by dymek37 2025-08-25 16:32:15 -04:00
Alex van Steenhoven
2ff0bd6b44 New Dutch translations 2025-08-25 16:30:57 -04:00
harupong
a385233b7d New Japanese translations 2025-08-25 16:28:02 -04:00
henrygd
f5648a415d New Italian translations by Tommaso Cavazza 2025-08-25 16:27:35 -04:00
Radoslav Mandev
556fb18953 New Bulgarian translations 2025-08-25 16:26:03 -04:00
henrygd
a482f78739 add changelog 2025-08-25 16:21:07 -04:00
henrygd
4a580ce972 tailwind 4 upgrade + update js deps 2025-08-25 16:12:15 -04:00
henrygd
e07558237f remove dropdown from theme mode toggle 2025-08-25 16:09:49 -04:00
henrygd
fb3c70a1bc update battery charge to include all batteries 2025-08-24 22:34:38 -04:00
henrygd
cba4d60895 improve chart legend alignment 2025-08-24 22:34:26 -04:00
henrygd
8b655ef2b9 add battery charge monitoring 2025-08-24 20:45:38 -04:00
henrygd
0188418055 update go deps 2025-08-24 19:57:40 -04:00
henrygd
72334c42d0 refactor: add @/lib/alerts 2025-08-24 19:57:28 -04:00
henrygd
0638ff3c21 refactor: add subscribe / unsubscribe to alertManager 2025-08-24 19:48:21 -04:00
henrygd
b64318d9e8 fix: frontend token generation in insecure contexts 2025-08-24 17:24:34 -04:00
henrygd
0f5b1b5157 refactor: replace status strings with systemstatus enum
- also small style changes after tailwind update
2025-08-23 20:35:18 -04:00
henrygd
3c4ae46f50 remove usememo return in add system dialog
- forgot to remove this before last commit. interferes with token display.
2025-08-22 20:27:12 -04:00
henrygd
c158b1aeeb move alerts ui to sheet component 2025-08-22 19:28:00 -04:00
henrygd
684d92c497 upgrade to tailwind 4 2025-08-22 18:06:19 -04:00
henrygd
bbd9595ec0 upgrade js dependencies (react 19) 2025-08-22 14:39:48 -04:00
henrygd
bbebb3e301 update Link component to support opening links in new tabs with ctrl/cmd key 2025-08-21 19:00:34 -04:00
henrygd
9d25181d1d use anchor tag for system table links 2025-08-21 18:44:38 -04:00
henrygd
7ba1f366ba remove batch api in favor of custom endpoint for alerts management (#1039, #1023) 2025-08-19 20:56:12 -04:00
henrygd
37c6b920f9 refactor: move useStore above possible return nulls 2025-08-19 20:20:47 -04:00
henrygd
49db81dac8 refactor: api router groups and auth handling
- require auth for `/api/beszel/getkey`
- Change `GET /api/beszel/send-test-notification` endpoint to `POST /api/beszel/test-notification`.
- add tests for API endpoints
2025-08-19 20:14:01 -04:00
henrygd
a9e90ec19c update resolveAlertHistoryRecord params to use alert ID directly 2025-08-19 19:56:51 -04:00
henrygd
2ad60507b7 small style updates 2025-08-19 19:48:25 -04:00
henrygd
12059ee3db refactor: js performance improvements 2025-08-06 22:21:48 -04:00
henrygd
de56544ca3 0.12.3 release :) 2025-08-03 22:10:51 -04:00
henrygd
065c7facb6 update language files 2025-08-03 22:10:25 -04:00
NickAss512
630c92c139 New Czech translations 2025-08-03 21:53:51 -04:00
henrygd
e11d452d91 separate agent dockerfiles 2025-08-03 21:14:43 -04:00
dalton-baker
99c7f7bd8a Add GPU-enabled build target in dockerfile_Agent (nvidia-smi support) (#898) 2025-08-03 13:31:26 -04:00
henrygd
8af3a0eb5b refactor: add getMeterState function 2025-08-02 23:44:07 -04:00
henrygd
5f7950b474 tweaks to custom meter percentages 2025-08-02 20:53:49 -04:00
Sven van Ginkel
df9e2dec28 [Feature] Add custom meter percentages (#942) 2025-08-02 17:58:52 -04:00
henrygd
a0f271545a refactoring (no functionality changes) 2025-08-02 17:04:38 -04:00
Bradley Varol
aa2bc9f118 fix systems table names wrapping (#1027) 2025-08-02 12:28:49 -04:00
henrygd
b22ae87022 disable winget auto pr and reactivate docker workflow 2025-08-01 21:15:37 -04:00
henrygd
79e79079bc fix goreleaser winget token field and temp disable docker workflow 2025-08-01 20:43:33 -04:00
henrygd
1811ebdee4 0.12.2 release :) 2025-08-01 20:36:29 -04:00
henrygd
137f3f3e24 update language files 2025-08-01 20:34:38 -04:00
Mikael Richardsson
ed1d1e77c0 Update Swedish translations 2025-08-01 20:29:02 -04:00
henrygd
8c36dd1caa windows: embed LibreHardwareMonitorLib for better sensors detection
- Updated GitHub Actions release workflow to set up .NET and build the LHM executable.
- Modified Makefile to include a conditional build step for the .NET executable on Windows.
2025-08-01 20:04:40 -04:00
hank
57bfe72486 Update readme.md 2025-07-31 19:43:08 -04:00
henrygd
75f66b0246 fix: handle missing docker group in debian postinstall script (#1012)
Check if docker group exists before attempting to add beszel user to it, preventing installation failure when Docker is not installed.
2025-07-30 19:09:10 -04:00
henrygd
ce93d54aa7 fix agent data directory resolution (#991) 2025-07-30 14:34:36 -04:00
henrygd
39dbe0eac5 ensure /etc/machine-id exists for persistent fingerprint in install-agent.sh 2025-07-30 14:25:41 -04:00
henrygd
7282044f80 improve memo deps for default area chart 2025-07-29 20:53:44 -04:00
henrygd
d77c37c0b0 winget upgrade: make sure service is stopped before updating package 2025-07-29 18:37:34 -04:00
Sven van Ginkel
e362cbbca5 Move copy button (#1010)
Thank you!
2025-07-28 19:20:37 -04:00
evrial
118544926b [Fix] OpenWrt agent install script (#1005)
* Update install-agent.sh

* Update install-agent.sh

* Update install-agent.sh
2025-07-28 14:56:31 -04:00
henrygd
d4bb0a0a30 fix: consolidate OpenWRT environment variables into single procd_set_param call 2025-07-27 18:51:26 -04:00
henrygd
fe5e35d1a9 agent install script: improve openwrt compatibility 2025-07-27 18:44:36 -04:00
henrygd
60a6ae2caa add winget token for goreleaser action 2025-07-25 20:13:31 -04:00
henrygd
80338d36aa release 0.12.1 :) 2025-07-25 19:23:53 -04:00
henrygd
f0d2c242e8 update language files 2025-07-25 19:19:49 -04:00
zoixc
559f83d99c Updated Russian translations 2025-07-25 19:14:08 -04:00
Kamil
d3a751ee6c Updated Polish translations 2025-07-25 19:13:18 -04:00
Sven van Ginkel
fb70a166fa Updated Dutch translations 2025-07-25 19:13:18 -04:00
Alberto Rizzi
c12457b707 Updated Italian translations 2025-07-25 19:08:03 -04:00
Lucas Pedro
3e53d73d56 Updated Portuguese translations 2025-07-25 19:03:45 -04:00
Mikki Alexander Mousing Sørensen
80338c5e98 Updated Danish translations 2025-07-25 19:01:34 -04:00
NickAss512
249cd8ad19 Updated Czech translations
Co-authored-by: Gear <88455107+GearCzech@users.noreply.github.com>
2025-07-25 19:01:34 -04:00
henrygd
ccdff46370 add TOKEN_FILE env var 2025-07-25 18:26:31 -04:00
henrygd
91679b5cc0 refactor load average handling (#982)
- Transitioned from individual load average fields (LoadAvg1, LoadAvg5,
LoadAvg15) to a single array (LoadAvg)
- Ensure load is displayed when all zero values.
2025-07-25 18:07:29 -04:00
henrygd
6953edf59e sort token / fingerprint table by system name 2025-07-25 15:53:40 -04:00
henrygd
b91c77ec92 make sure only 200 records are returned for alert history table 2025-07-25 15:43:19 -04:00
henrygd
3ac0b185d1 fix oidc icon display issue (#990) 2025-07-25 13:55:02 -04:00
henrygd
1e675cabb5 refactor agent data directory resolution (#991) 2025-07-25 13:37:23 -04:00
henrygd
5f44965c2c improve table formatting and fix #983
- should fix NaN display bug for dasboard cpu
- standardize decimals for dash meters
2025-07-25 00:29:08 -04:00
henrygd
f080929296 Update goreleaser configuration
- Removed the name field for brew.
- Enabled pull requests for winget.
2025-07-24 22:31:27 -04:00
henrygd
f055658eba update bun.lockb and package-lock.json 2025-07-24 20:03:19 -04:00
henrygd
e430c747fe 0.12.0 release :) 2025-07-24 19:51:51 -04:00
henrygd
ca62b1db36 update translations 2025-07-24 19:47:25 -04:00
henrygd
38569b7057 update install scripts for 0.12.0 release 2025-07-24 18:39:13 -04:00
henrygd
203244090f update alert history icon 2025-07-24 18:11:39 -04:00
henrygd
2bed722045 add windows upgrade scripts 2025-07-24 17:36:17 -04:00
henrygd
13f3a52760 remove beta scripts from copy/paste commands 2025-07-24 17:35:21 -04:00
henrygd
16b9827c70 bump migration name for 0.12.0 release 2025-07-24 17:35:06 -04:00
henrygd
0fc352d7fc update js deps 2025-07-23 19:51:19 -04:00
凉心
8a2bee11d4 Update viewport meta to prevent input zoom on iOS (#858)
- Prevents page zoom when tapping input fields on iPhone
2025-07-23 19:39:39 -04:00
henrygd
485f7d16ff add tests for agent/client.go and hub/ws/ws.go 2025-07-23 15:54:02 -04:00
henrygd
46fdc94cb8 improve bandwidth measurement precision + refactor default area chart 2025-07-23 15:52:02 -04:00
henrygd
261f7fb76c update go dependencies 2025-07-21 20:14:31 -04:00
henrygd
18d9258907 Alert history updates 2025-07-21 20:07:52 -04:00
Sven van Ginkel
9d7fb8ab80 [Feature] Add Alerts History page (#973)
* Add alert history

* refactor

* fix one colunm

* update migration

* add retention
2025-07-20 19:20:51 -04:00
henrygd
3730a78e5a update load avg display and include it in longer records 2025-07-16 21:24:42 -04:00
Sven van Ginkel
7cdd0907e8 [Feature][0.12.0-Beta] Enhance Load Average Display, Charting & Alert Grouping (#960)
* Add 1m load

* update alart dialog

* fix null data

* Remove omit zero

* change table and alert view
2025-07-16 16:03:26 -04:00
henrygd
3586f73f30 check for malformed sensor names on darwin (#796) 2025-07-16 14:56:13 -04:00
henrygd
752ccc6beb hide tokens page for readonly users 2025-07-16 14:41:23 -04:00
henrygd
f577476c81 clear systems from memory on logout (#970) 2025-07-16 14:39:15 -04:00
henrygd
49ae424698 refactor: consolidate fixedFloat funcs + remove trailing zeroes from y axis 2025-07-15 21:46:41 -04:00
henrygd
d4fd19522b fix races when loading user settings 2025-07-15 21:29:51 -04:00
henrygd
5c047e4afd Refactor unit preferences and update chart components
* Refactor user settings to use enum for unit preferences (temperature,
network, disk).
* Update chart components to utilize new unit formatting functions
* Remove deprecated conversion functions and streamline unit handling
across charts.
* Enhance settings page to allow user selection of unit preferences with
updated labels.
2025-07-15 18:57:37 -04:00
Anish Chanda
6576141f54 Adds display unit preference (#938)
* Adds temperature unit preference

* add unit preferences for networking

* adds options for MB/s and bps.

* supports disk throughput unit preferences
2025-07-14 14:46:13 -04:00
Sven van Ginkel
926e807020 [Chore] Fix CI labels (#964) 2025-07-14 14:03:56 -04:00
Sven van Ginkel
d91847c6c5 [Chrore] Improve Issue Templates with Categorization and Label Automation (#939)
* Update templates

* update script

* update labels

* add PR template

* update dropdown
2025-07-13 19:51:29 -04:00
Alexander Mnich
0abd88270c fix docker edge tag (#959) 2025-07-13 11:18:25 -04:00
henrygd
806c4e51c5 v0.12.0-beta2 release 2025-07-12 21:19:57 -04:00
henrygd
6520783fe9 small ui tweaks 2025-07-12 21:16:40 -04:00
henrygd
48c8a3a4a5 update package-lock.json 2025-07-12 21:06:01 -04:00
henrygd
e0c839f78c add skip_upload: auto to goreleaser homebrew config 2025-07-12 19:56:48 -04:00
NeMeow
1ba362bafe Add 5m and 10m load avg alerts and table values (#816)
Co-authored-by: henrygd <hank@henrygd.me>
2025-07-12 19:47:33 -04:00
henrygd
b5d55ead4a send websocket close message to agent 2025-07-12 18:49:40 -04:00
henrygd
4f879ccc66 Fix universal tokens registering multiple systems 2025-07-12 17:11:53 -04:00
henrygd
cd9e0f7b5b fix typo in install scripts 2025-07-10 15:10:44 -04:00
henrygd
780644eeae Update translations on tokens page 2025-07-08 21:32:16 -04:00
henrygd
71f081da20 add :edge image tag 2025-07-08 20:40:51 -04:00
henrygd
11c61bcf42 update translations for 0.12.0 beta 2025-07-08 19:28:30 -04:00
henrygd
402a1584d7 Add CBOR and agent initiated WebSocket connections (#51, #490, #646, #845, etc)
- Add version exchange between hub and agent.
- Introduce ConnectionManager for managing WebSocket and SSH connections.
- Implement fingerprint generation and storage in agent.
- Create expiry map package to store universal tokens.
- Update config.yml configuration to include tokens.
- Enhance system management with new methods for handling system states and alerts.
- Update front-end components to support token / fingerprint management features.
- Introduce utility functions for token generation and hub URL retrieval.

Co-authored-by: nhas <jordanatararimu@gmail.com>
2025-07-08 18:41:36 -04:00
henrygd
99d61a0193 update makefile and other tiny refactoring
- remove goccy/json dep
- add explicit types in gpu.go
2025-07-08 18:21:14 -04:00
henrygd
5ddb200a75 improve memory efficiency of records.go 2025-07-08 18:03:49 -04:00
henrygd
faa247dbda add agent data directory handling 2025-07-08 16:45:14 -04:00
henrygd
6d1cec3c42 new agent healthcheck to support non-ssh connections 2025-07-08 16:43:33 -04:00
henrygd
529df84273 make sure agent container has /tmp directory 2025-07-08 15:35:50 -04:00
henrygd
e0e21eedd6 update deps 2025-07-08 15:35:06 -04:00
henrygd
4356ffbe9b add install scripts for beta versions 2025-07-08 15:33:26 -04:00
henrygd
be1366b785 update docker workflow to clearly handle beta / rc tags 2025-07-08 15:28:23 -04:00
henrygd
3dc7e02ed0 exclude bond network interfaces by default 2025-07-08 15:27:33 -04:00
henrygd
d67d638a6b Refactor dockerManager
- Introduced a reusable buffer and JSON decoder in dockerManager for efficient decoding of Docker API responses.
- Adjusted network statistics calculations to ensure accurate data handling.
2025-07-08 15:27:01 -04:00
henrygd
7b36036455 add vulncheck workflow 2025-07-03 21:06:26 -04:00
Sven van Ginkel
1b58560acf Update MakeFile to serve outside local host (#934) 2025-06-27 21:41:16 -04:00
henrygd
1627c41f84 fix gpu name issue introduced in previous commit 2025-06-27 18:00:47 -04:00
henrygd
4395520a28 Probable fix for Jetson gpu issue (#895) 2025-06-26 22:11:48 -04:00
Alexander Mnich
8c52f30a71 add GITHUB_TOKEN fallback for goreleaser (#925)
adding the fallback to the GITHUB_TOKEN allows execution of goreleaser in a fork without additional configuration
2025-06-26 21:03:19 -04:00
SSU
46316ebffa fix(install): suppress scoop output to avoid nssm path pollution (#918)
Suppressed the output of “scoop install beszel-agent” to ensure the NSSM service path
contains only the executable location.

Closes #915

Co-authored-by: suseol <suseol@geosr.com>
2025-06-25 13:52:45 -04:00
henrygd
0b04f60b6c Add panic recovery for sensors.TemperaturesWithContext (#796) 2025-06-23 19:50:11 -04:00
HansAndreManfredson
20b822d072 Fix missing groups #892 (#893) 2025-06-17 16:08:32 -04:00
Tobias Gruetzmacher
ca7642cc91 Create service user as system user (#867) 2025-06-12 14:54:36 -04:00
henrygd
68009c85a5 Add ppc64le agent build (#682) 2025-05-28 18:47:47 -04:00
Leon Blakey
1c7c64c4aa Add user to docker group in Debian package (#847) 2025-05-28 14:49:19 -04:00
henrygd
b05966d30b add help section to readme 2025-05-26 15:49:24 -04:00
Nikolas Garofil
ea90f6a596 Update readme.md 2025-05-26 14:45:23 -04:00
henrygd
f1e43b2593 scale fractional temperature values to reasonable Celsius values (#688) 2025-05-26 01:08:03 -04:00
henrygd
748d18321d fix: windows paths when regular and admin install users differ (#739) 2025-05-26 00:51:44 -04:00
henrygd
ae84919c39 update windows install command to use bypass execution policy (#739) 2025-05-24 21:49:43 -04:00
henrygd
b23221702e Handle systemd nvidia rules automatically during agent installation 2025-05-21 17:24:54 -04:00
henrygd
4d5b096230 Improve 'add system' dropdown buttons 2025-05-09 22:31:47 -04:00
henrygd
7caf7d1b31 Clear system's active alerts when system is paused 2025-05-08 20:41:44 -04:00
henrygd
6107f52d07 Fix system path in notification urls 2025-05-08 19:06:19 -04:00
henrygd
f4fb7a89e5 Add tests for GetSSHKey and handle read errors on key file 2025-05-08 18:54:14 -04:00
henrygd
5439066f4d hub.MakeLink method to assure URLs are formatted properly (#805)
- Updated AlertManager to replace direct app references with a hub interface.
- Changed AlertManager.app to AlertManager.hub
- Add tests for MakeLink
2025-05-08 17:47:15 -04:00
henrygd
7c18f3d8b4 Add mipsle agent build (#802) 2025-05-07 20:11:48 -04:00
henrygd
63af81666b Refactor SSH configuration and key management
- Restrict to specific key exchanges / MACs / ciphers.
- Refactored GetSSHKey method to return an ssh.Signer instead of byte array.
- Added common package.

Co-authored-by: nhas <jordanatararimu@gmail.com>
2025-05-07 20:03:21 -04:00
henrygd
c0a6153a43 Update goreleaser configuration for beszel-agent to include restart delay and process type 2025-05-05 20:22:15 -04:00
henrygd
df334caca6 update install-agent.ps1 to support installing as admin (#797) 2025-05-05 20:21:37 -04:00
henrygd
ffb3ec0477 Fix broken link to notifications when using base path 2025-05-02 20:02:23 -04:00
henrygd
3a97edd0d5 add winget support to windows install script 2025-05-01 17:40:20 -04:00
henrygd
ab1d1c1273 Remove PrivateTmp setting from Systemd rules in install-agent.sh
Allows sharing socket in /tmp
2025-05-01 17:00:08 -04:00
henrygd
0fb39edae4 rename ssh imports in server.go 2025-04-30 18:09:25 -04:00
henrygd
3a977a8e1f goreleaser: update deprecated format field 2025-04-30 16:15:37 -04:00
henrygd
081979de24 Add winget configuration for beszel-agent 2025-04-30 15:00:26 -04:00
henrygd
23fe189797 Add SELinux context management to install-agent.sh (#788)
- Introduced functions to set and clean up SELinux contexts.
- Added SELinux context checks during the update process (systemd only).
- Updated the service execution command to use a dedicated update script.
2025-04-29 18:59:36 -04:00
henrygd
e9d429b9b8 Enhance service start check in install-agent.ps1
- Added logic to handle service start failures and check status with retries.
- Improved user feedback for service status during startup process.
2025-04-28 21:47:02 -04:00
henrygd
99202c85b6 re-enable docker image creation workflow 2025-04-28 21:16:32 -04:00
henrygd
d5c3d8f84e release 0.11.1 with new line so goreleaser doesn't fail :)
- Temp disable docker workflow bc image was already build
2025-04-28 20:57:27 -04:00
Alexander Mnich
8f442992e6 New German translations 2025-04-28 20:48:38 -04:00
henrygd
39820c8ac1 release 0.11.1 2025-04-28 20:38:24 -04:00
henrygd
0c8b10af99 Escape backslashes in windows agent install command (#785) 2025-04-28 20:37:25 -04:00
henrygd
8e072492b7 Skip checking Docker if DOCKER_HOST is set to an empty string 2025-04-28 20:23:54 -04:00
henrygd
88d6307ce0 Add FreeBSD icon 2025-04-28 19:37:00 -04:00
henrygd
2cc516f9e5 update readme and notifications link 2025-04-27 20:05:07 -04:00
henrygd
ab6ea71695 Release v0.11.0 2025-04-27 19:20:17 -04:00
henrygd
6280323cb1 Always display two decimals in container memory tooltip
- Also removes --china-mirrors when copying brew install script
2025-04-27 19:18:44 -04:00
henrygd
17c8e7e1bd Update bark notification to use url param for link 2025-04-27 14:07:33 -04:00
henrygd
f60fb6f8a9 Handle title and link for Lark notifications 2025-04-26 21:25:18 -04:00
henrygd
3eebbce2d4 Update Go dependencies
Also replaces containrrr/shoutrrr with pinned version of
nicholas-fedor/shoutrrr
2025-04-26 17:47:34 -04:00
henrygd
e92a94a24d update translations 2025-04-26 17:45:12 -04:00
Andypsl8
7c7c073ae4 New zh-CN translations 2025-04-26 14:58:06 -04:00
zoixc
c009a40749 New Russian translations 2025-04-26 14:56:23 -04:00
Alex van Steenhoven
5e85b803e0 New Dutch translations 2025-04-26 14:55:05 -04:00
henrygd
256d3c5ba1 Italian translations from @saamdotexe 2025-04-26 14:51:29 -04:00
henrygd
bd048a8989 French translations from leo.info54 on crowdin 2025-04-26 14:46:24 -04:00
henrygd
f6b4231500 new Arabic translations from rihla on crowdin 2025-04-26 14:41:31 -04:00
henrygd
bda06f30b3 Add temperature chart filtering (#430)
- Refactored ContainerFilterBar to accept a dynamic store prop.
- Updated filtering logic in ContainerChart to be case-insensitive.
2025-04-25 19:19:19 -04:00
henrygd
38f2ba3984 Small refactoring of docker manager
- Add isWindows flag to dockerManager
- `CalculateCpuPercentWindows` and `CalculateCpuPercentLinux` methods added to container.ApiStats
- Remove prevNetStats.Time in favor of Stats.PrevRead
- Replace regex Windows check with strings.Contains, and check the `/containers/json` response
2025-04-25 18:39:24 -04:00
ViryBe
1a7d897bdc compute cpu and memory stats for docker on windows (#653)
The Docker daemon's API returns different values on Windows and non-Windows systems. This impacts
the calculation of `cpuPct` and `usedMemory` for each container. The systems are disciminate on the
`Server` header send by the server. Uses the unix stats calculation in case the header is not set.

`docker stats` implementation for reference:
https://github.com/docker/cli/blob/master/cli/command/container/stats_helpers.go#L100

Co-authored-by: Benoit VIRY <benoit.viry@cgx-group.com>
2025-04-25 18:27:36 -04:00
henrygd
c74e7430ef Disable h/l/left/right changing system if shift, ctrl, or meta keys are pressed (#703) 2025-04-23 16:40:58 -04:00
henrygd
2467bbc0f0 Add support for copying Windows and Homebrew installation commands 2025-04-23 14:17:14 -04:00
henrygd
ea665e02da Improve system information retrieval for macOS and Windows
- Introduce `Os` enum to represent supported operating systems.
- Update `SystemInfo` interface to include OS type.
- Refactor `ContainerChart` component to use `ChartType` enum for better clarity.
- Switched to dynamic units in container memory chart.
2025-04-22 20:29:17 -04:00
henrygd
358e05d544 truncate tooltip container name if very long 2025-04-22 20:16:11 -04:00
henrygd
aab5725d82 Use gpu temp as primary sensor if no other sensors 2025-04-18 18:00:39 -04:00
henrygd
e94a1cd421 brew install - change env var from PORT to LISTEN 2025-04-18 17:59:59 -04:00
henrygd
73c1a1b208 Refactor sensor configuration handling in tests and implementation
- Add skipCollection propery
- Ensure that sensors are initialized as an empty map
2025-04-18 17:59:25 -04:00
henrygd
0526c88ce0 support blacklisting and wildcard matching in SENSORS env var (#650)
- Moved sensor related code to sensors.go
- Added SensorConfig struct
- Added newSensorConfig
- Added tests
2025-04-17 21:08:05 -04:00
henrygd
a2e9056a00 update macos agent install script
- adds option to install homebrew if not installed
2025-04-15 17:54:53 -04:00
henrygd
fd4ac60908 Remove -Program parameter from windows firewall rule (#739) 2025-04-14 17:19:24 -04:00
henrygd
330e4c67f3 Update release workflow and goreleaser configuration
- Change tag pattern in release workflow to 'v*'
- Update description
2025-04-14 17:16:02 -04:00
henrygd
5d840bd473 Windows agent install script improvements
- Remove unneeded set-executionpolicy (in parent command)
- Wait before checking status
- Use direct binary path instead of shim
- Log to one file

Co-authored-by: vmhomelab <info@vmhomelab.de>
2025-04-13 19:56:23 -04:00
henrygd
54e3f3eba1 add goreleaser homebrew config and brew helper script 2025-04-09 19:58:46 -04:00
henrygd
d79111fce4 remove nvidia-smi dependency for jetson / tegrastats (#286) 2025-04-07 20:02:14 -04:00
henrygd
93c3c7b9d8 add windows agent install script 2025-04-06 22:09:43 -04:00
henrygd
410d236f89 fix EXTRA_FILESYSTEMS for windows (#422)
Co-authored-by: coosir <git@coosir.com>
2025-04-05 17:57:34 -04:00
henrygd
9a8071c314 prepend base path for command palette links 2025-04-05 17:34:33 -04:00
henrygd
80df0efccd add correct icon / label for windows build number 2025-04-05 17:33:41 -04:00
henrygd
3f1f4c7596 goreleaser: fix archive ids and add scoop for beszel-agent 2025-04-03 19:15:46 -04:00
Val V
04ac688be4 Agent OpenBSD release (#726) 2025-04-03 18:28:23 -04:00
henrygd
ace83172ff agent install script: improvements to --china-mirrors and --auto-update flags
- Allow using = to define flags
- Allow passing --auto-update=false to skip prompt
2025-03-18 15:58:41 -04:00
Daniel Hiller
e8b864b515 agent installer script: option to skip auto update question 2025-03-16 05:22:42 +01:00
henrygd
7057f2e917 release 0.10.2 2025-03-15 03:01:57 -04:00
henrygd
47b2689f24 updates for lingui 5 2025-03-15 03:01:49 -04:00
henrygd
9b65110aef fix dumb error i pushed five min ago :) 2025-03-15 01:16:18 -04:00
henrygd
3935a9bf00 i18n: update language files 2025-03-15 01:03:20 -04:00
henrygd
fb2adf08dc remove "GPU" from translations 2025-03-15 01:03:11 -04:00
stanol
61441b115b i18n: new Ukrainian translations 2025-03-15 00:58:38 -04:00
aroxu
3ad78a2588 i18n: new Korean translations 2025-03-15 00:57:51 -04:00
harupong
81514d4deb i18n: new Japanese translations 2025-03-15 00:55:14 -04:00
NickAss512
faeb801512 i18n: new cs translations 2025-03-15 00:54:08 -04:00
henrygd
968ca70670 agent temperature fixes (#648, #663)
- Fixes a bad sensor returning an error instead of other good sensors
- Adds ability to set GPU as PRIMARY_SENSOR
2025-03-15 00:29:41 -04:00
henrygd
5837b4f25c Refactor hub initialization and error handling 2025-03-15 00:25:42 -04:00
henrygd
c38d04b34b Add health command for hub and align agent health command 2025-03-15 00:23:12 -04:00
henrygd
cadc09b493 delete duplicate alerts as part of 0.10.2 migrations 2025-03-14 23:03:07 -04:00
henrygd
edefc6f53e add health check for agent
- Updated command-line flag parsing.
- Moved GetAddress and GetNetwork to server.go
2025-03-14 03:33:25 -04:00
henrygd
400ea89587 correct version in upcoming migration name 2025-03-13 22:05:29 -04:00
henrygd
3058c24e82 Allow dynamic size value in default area chart
- Fixes system pause / unpause rendering
- Some formatting
2025-03-13 22:04:29 -04:00
henrygd
521be05bc1 gpu.go refactoring and jetson fixes
- Fixed usage and power values
- Added new test cases
- Moved some variables to constants
2025-03-13 21:32:53 -04:00
henrygd
6b766b2653 add unique multicolumn index to prevent duplicate alerts (#662) 2025-03-13 02:20:27 -04:00
henrygd
d36b8369cc react refactoring for better performance with lots of systems 2025-03-13 02:16:54 -04:00
henrygd
ae22334645 pass hub version through html and update dependencies
- Changed Vite configuration to replace version during development.
2025-03-13 02:15:03 -04:00
henrygd
1d7c0ebc27 fix: error adding alerts to 50+ systems (#664)
- Fixed PB SDK autocancelling requests.
- Refactored SystemAlert and SystemAlertGlobal components.
- Introduced a batchWrapper function for handling batch operations on alerts.
2025-03-13 02:07:58 -04:00
henrygd
3b9910351d release 0.10.1 2025-03-06 05:40:38 -05:00
henrygd
f397ab0797 fix: improve error logging for temperature sensor retrieval 2025-03-06 05:38:49 -05:00
henrygd
b1fc715ec9 fix: prevent 404 on initial startup by moving h.initialize after hooks
- I don't know why this works. Need to look further into it tomorrow :)
2025-03-06 05:38:33 -05:00
henrygd
d25c7c58c1 fix: SYS_SENSORS context error (#643) 2025-03-06 05:36:20 -05:00
henrygd
a6daa70010 chore: bump version to 0.10.0 🎉 2025-03-06 03:11:22 -05:00
henrygd
d722e4712c i18n: update language files 2025-03-06 03:10:34 -05:00
henrygd
1d61ad5d7c deps: update pocketbase 2025-03-06 03:10:01 -05:00
henrygd
28589455bf feat: keep chart time when navigating with arrow keys 2025-03-06 02:23:30 -05:00
henrygd
dd21c18939 feat: add SHARE_ALL_SYSTEMS env var 2025-03-06 01:28:36 -05:00
henrygd
fd79bc3341 i18n: update language files and fix strings for new lingui version 2025-03-06 00:58:29 -05:00
henrygd
7edcf8db85 i18n: new Spanish translations (by Elkin Torres on crowdin) 2025-03-06 00:21:32 -05:00
Roy W. Andersen
245a047062 i18n: new Norwegian translations 2025-03-06 00:19:58 -05:00
stanol
520b52e532 i18n: new Ukrainian translations 2025-03-06 00:19:26 -05:00
Stepan
c421ffac70 i18n: new Russian translations 2025-03-06 00:18:49 -05:00
henrygd
6767392ea8 refactor: update some types in docker.go 2025-03-05 23:40:23 -05:00
henrygd
25b73bfb85 fix: make sure system alerts are checked after records are committed 2025-03-05 23:39:01 -05:00
henrygd
5fbc0de07f refactor: rename address flag and environment variable to 'listen' 2025-03-05 23:37:51 -05:00
henrygd
c8130a10d4 fix: allow logout if auto login if enabled 2025-03-05 23:36:57 -05:00
henrygd
0619eabec2 feat: add keyboard navigation for systems 2025-03-05 23:35:46 -05:00
henrygd
5b4d5c648e chore: update migrations 2025-03-05 23:34:15 -05:00
henrygd
0443a85015 fix: correct typo in Docker stats collection variable name 2025-03-04 17:39:49 -05:00
henrygd
c4d8deb986 feat: agent data cache to support connections to multiple hubs (#341) 2025-03-04 16:25:45 -05:00
henrygd
681286eb4f fix: add User-Agent to resolve Docker Desktop bug (#513, #603)
- also added body closure I forgot earlier whoops
2025-03-04 01:56:22 -05:00
henrygd
99cdb196ca feat: persist selected tab in system dialog (#602) 2025-03-04 01:09:48 -05:00
henrygd
31431fd211 refactor: improve GPU data parsing
- Use byte-based regex matching instead of string-based matching
- Increase buffer size for GPU data
- Switch to `bufio.Scanner`
2025-03-04 00:15:10 -05:00
henrygd
9e56f4611f refactor: restructure hub initialization and startup process
- Separated hub initialization logic into distinct methods
- Move command specific things to cmd/hub
- Add compatibility with new systems package
2025-03-03 23:54:25 -05:00
henrygd
a1f6eeb9eb refactor: alerts package compatiblity with new systems package 2025-03-03 23:52:27 -05:00
henrygd
f8a1d9fc5d refactor: optimize system updates and create systems package
- Created SystemManager to handle system lifecycle and events
- Created tests for system management operations
- Added test helpers for creating and managing test systems
- Introduced optional port configuration in system config
2025-03-03 23:50:19 -05:00
henrygd
d81db6e319 refactor: optimize record management and deletion logic 2025-03-03 23:44:50 -05:00
henrygd
17a163de26 feat: allow searching by status in systems table 2025-03-03 23:39:50 -05:00
henrygd
85db31a8cd chore: add Makefile test target for running Go tests 2025-03-03 23:38:41 -05:00
henrygd
327db38953 chore: update go dependencies 2025-03-03 23:38:01 -05:00
henrygd
0413368762 chore: update js dependencies 2025-03-03 23:37:43 -05:00
henrygd
db73928604 chore: update gitignore 2025-03-03 23:36:26 -05:00
henrygd
add1b27346 remove unreleased migration 2025-03-03 23:35:18 -05:00
henrygd
2ef1fe6b2a systemd: remove ProtectKernelTunables=yes (#176)
https://github.com/henrygd/beszel/issues/176#issuecomment-2691192334
2025-02-28 14:28:00 -05:00
henrygd
2b43ba3cbe i18n: update language files 2025-02-27 17:20:12 -05:00
ArsFy
b2b1a0b6ea i18n: new Chinese translations 2025-02-27 17:19:04 -05:00
stanol
b11d0aae61 i18n: new Ukrainian translations 2025-02-27 17:18:35 -05:00
henrygd
2b73d8845a feat: allow x min downtime before alerting (#595, #625)
- splits alerts package into three files. status alerts were not
modified aside from updating to slices.Delete method
2025-02-27 17:12:25 -05:00
henrygd
41e3e3d760 chore: update .gitignore 2025-02-27 00:37:29 -05:00
henrygd
c22b57ce67 refactor(dev): use ENV var to enable hub dev mode 2025-02-26 18:42:34 -05:00
hank
23bee0aa7c build: add go patch version in go.mod 2025-02-26 15:21:54 -05:00
henrygd
0c2629f57e i18n: update language files 2025-02-23 21:07:25 -05:00
harupong
a4b689e8f1 i18n: new Japanese translations 2025-02-23 21:05:52 -05:00
Roy W. Andersen
0c5841133b i18n: new Norwegian translations 2025-02-23 21:03:47 -05:00
henrygd
78a645fa05 i18n: Portuguese translations by PoetaGA 2025-02-23 21:02:24 -05:00
aronov
f1208a9f00 i18n: progress on Icelandic 2025-02-23 20:55:14 -05:00
Alexander Mnich
aeb5f1424b i18n: update German 2025-02-23 20:54:08 -05:00
henrygd
4a1fb513c5 i18n: update language files 2025-02-23 20:53:08 -05:00
henrygd
e5a66cc156 feat(add-system): add link to binary install docs in copy button 2025-02-23 20:39:57 -05:00
Alexander Mnich
aef3b3e610 publish docker images to ghcr.io (#615) 2025-02-22 17:58:49 -05:00
henrygd
1d13ecd8ec ui: truncate full path of host if using unix socket 2025-02-22 17:58:30 -05:00
henrygd
6404895a47 refactor: centralize chart max/avg value state to global store 2025-02-22 15:28:07 -05:00
henrygd
ba7db28e80 test(gpu): add case for AMD multi-GPU and different power property (#414) 2025-02-22 12:45:47 -05:00
henrygd
6b41a98338 gpu: add tests and refactor to support amd on windows 2025-02-21 00:56:40 -05:00
henrygd
b958e9eefe fix: agent network not set to unix if addr provided by env var 2025-02-21 00:56:22 -05:00
henrygd
baf56fe83b fix: refresh interfaces if agent starts before network online (#466) 2025-02-21 00:21:47 -05:00
henrygd
96f9128d1a agent: add lock for gatherStats 2025-02-21 00:20:41 -05:00
henrygd
1fb60e05d7 i18n: add Norwegian to web ui 2025-02-19 21:54:35 -05:00
Roy W. Andersen
a9a9a932a6 New translations (Norwegian) 2025-02-19 21:42:53 -05:00
henrygd
e0a1a49a8f chore: update language files 2025-02-19 21:06:55 -05:00
henrygd
79eb42d04d add CGO_ENABLED=0 back to docker images / goreleaser config 2025-02-19 21:05:31 -05:00
henrygd
25b70af196 feat: add unix socket support for system connections 2025-02-19 20:29:45 -05:00
henrygd
c12b27afb5 refactor: update system table and improve add-system dialog 2025-02-19 20:28:45 -05:00
henrygd
7485f79071 refactor(agent): refactor option parsing logic for agent command 2025-02-19 19:39:24 -05:00
henrygd
d170e7a00d feat(agent): NETWORK env var and support for multiple keys
- merges agent.Run with agent.NewAgent
- separates StartServer method
- bumps go version to 1.24
- add tests
2025-02-19 00:32:27 -05:00
Alexander Mnich
1a6a2a64f2 implement agent startup arguments and help message (#581) 2025-02-19 00:19:49 -05:00
henrygd
646b899851 i18n: add hungarian
Co-authored-by: mathis <37186532+maaaathis@users.noreply.github.com>
2025-02-14 21:08:49 -05:00
Roland
821e2e3a78 New translations (Hungarian)
Co-authored-by: skidoodle <contact@albert.lol>
2025-02-14 20:47:15 -05:00
ikumasudo
9be3fcb8ca New translations (Japanese) 2025-02-14 20:36:48 -05:00
henrygd
f271b5a56c issues: add note about zfs to bug report template
a
2025-02-14 14:59:04 -05:00
Kal McFate
4f80a58929 Update add-system.tsx docker-compose.yml template. (#565)
Adding `version: "3"` makes this compatible with more recent docker tools.
2025-02-13 19:52:49 -05:00
henrygd
2ab2cc83de refactor(hub): embed pocketbase fields in hub struct 2025-02-11 19:18:36 -05:00
henrygd
3376a97bea refactor(hub): replace sync.map with app.store 2025-02-11 19:18:28 -05:00
henrygd
0c54f95546 feat: auto-login if one oauth provider and password auth disabled 2025-02-11 17:44:18 -05:00
henrygd
5ea6eb08a1 feat: PRIMARY_SENSOR env var to choose dashboard temp 2025-02-11 15:11:46 -05:00
henrygd
6b2a9463ca feat: edit system dialog in dashboard 2025-02-11 14:12:25 -05:00
henrygd
a94cfff965 fix: web ui should only respond to get requests 2025-02-11 11:04:19 -05:00
Daniel Nikoloski
1f69937572 Initial beszel-hub k8s chart (#537)
* Initial beszel-hub k8s chart

* Moved beszel-hub chart in supplemental
2025-02-10 18:29:52 -05:00
henrygd
aa3de511b9 refactor: get rid of console warnings on dashboard 2025-02-08 19:40:32 -05:00
henrygd
3afab00937 feat: display peak GPU usage in dashboard 2025-02-08 19:24:38 -05:00
henrygd
e6054058b9 feat: add temperatures to dashboard
- Refactor temperature related code and move to standalone function
2025-02-07 21:27:15 -05:00
Henry Dollman
31d52d5e15 update style of add system dialog 2025-02-06 20:01:04 -05:00
Venipa
44d930a700 feat(AddAgent): added docker run command copy (#534) 2025-02-06 19:41:01 -05:00
Henry Dollman
d7ada1b1c5 add manifest.json with icon for home screen (closes #525) 2025-02-05 20:27:23 -05:00
Henry Dollman
1daf35406a auth: ensure realtime subscriptions are cleared on logout 2025-02-05 20:24:39 -05:00
Henry Dollman
2216e40f04 chore: update go and js deps 2025-02-05 20:22:37 -05:00
Henry Dollman
f4480c7aa7 refactor: updateSystemList function 2025-02-05 20:21:57 -05:00
ompathak2004
5b478c11eb fix: prevent multiple concurrent fetches in updateSystemList function
fix: correctly reset isFetchingSystems flag in updateSystemList function
2025-02-05 19:04:18 -05:00
Henry Dollman
58085bf300 feature: support serving from subpath (#33)
Co-authored-by: Karthik T <karthikt.holmes+github@gmail.com>
2025-02-04 21:22:40 -05:00
Henry Dollman
ce171cf375 systemd: remove ProtectKernel=yes from unit file (invalid option) 2025-02-03 19:12:33 -05:00
Daniel Lo Nigro
e689f547ef Create Debian package for beszel-agent (#497)
* Add Debian package to build

Signed-off-by: Daniel Lo Nigro <git@d.sb>

* Fix Lintian issues

Signed-off-by: Daniel Lo Nigro <git@d.sb>

* - Add prerm script to stop and disable service
- Add security/sandboxing options to systemd unit

Signed-off-by: Daniel Lo Nigro <git@d.sb>

---------

Signed-off-by: Daniel Lo Nigro <git@d.sb>
2025-02-03 19:08:01 -05:00
Henry Dollman
5a8e8c1512 agent-install: add security options to systemd unit file 2025-02-03 19:06:03 -05:00
Henry Dollman
ff5eb07716 users: remove unused username field and require email (#24) 2025-02-03 18:29:02 -05:00
Henry Dollman
fdbbbc77b0 auth-form: red field border on error + firefox ux improvement (#24) 2025-02-03 17:59:39 -05:00
ompathak2004
20cba1b695 feat: update password max length to 72 characters 2025-02-04 01:48:25 +05:30
Henry Dollman
207d58a07e build: remove CGO_ENABLED=0 from docker build 2025-01-31 17:33:49 -05:00
Henry Dollman
0759a3607c supress pocketbase auto cancellation error 2025-01-31 17:16:21 -05:00
Henry Dollman
0b4742d064 update instructions for resetting password via docker 2025-01-31 15:19:18 -05:00
Henry Dollman
4557f18195 fix: only show GPU power chart if power data exists 2025-01-30 20:30:40 -05:00
Henry Dollman
83668e5727 fix(gpu): handle power for dedicated amd gpus (#414) 2025-01-30 20:28:31 -05:00
Henry Dollman
120aff0d18 config: prefix environment variables with BESZEL_AGENT_ (#502) 2025-01-29 20:13:07 -05:00
Henry Dollman
7170b24160 config: prefix environment variables with BESZEL_HUB_ (#502) 2025-01-29 19:25:12 -05:00
Henry Dollman
3441b39a02 update password reset instructions 2025-01-29 18:55:17 -05:00
Henry Dollman
31d306f8be Merge branch 'jetson' 2025-01-24 23:21:48 -05:00
hank
76347f25e5 fix(gpu): prevent nvidia-smi from running on tegra devices 2025-01-24 23:12:39 -05:00
hank
c157f38957 gpu: Add closure for Jetson and improve compatibility 2025-01-24 22:07:37 -05:00
Links
d185dfdef8 get Jetson GPU Information 2025-01-24 19:17:33 -05:00
hank
319a9895b0 New Crowdin updates (#462)
* New translations en.po (Korean)

* New translations en.po (Hungarian)

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

Co-authored-by: skidoodle <contact@albert.lol>
Co-authored-by: Tony <TonyRL@users.noreply.github.com>
Co-authored-by: aroxu <30624945+aroxu@users.noreply.github.com>
2025-01-23 22:34:24 -05:00
Mikael Silvén
68dae3967d feat(release): add riscv agent builds to goreleaser config (#480)
This change adds a riscv build config to for the agent to goreleaser.
As far as I am aware, only freebsd and linux supports this, so ignore
mac and windows.
2025-01-23 15:54:10 -05:00
Henry Dollman
0a331524cc feat(release): add windows agent to goreleaser config 2025-01-22 16:19:33 -05:00
Henry Dollman
5b625db57c fix(systemd): Ensure agent starts after network is online (#466) 2025-01-21 20:25:07 -05:00
Henry Dollman
0943e01b71 feat(install): Default to gh.beszel.dev GitHub proxy for mainland China
- also removes CHINA_MAINLAND variable
2025-01-21 20:20:36 -05:00
Henry Dollman
cfda7d0740 Merge branch 'main' of https://github.com/Oaklight/beszel 2025-01-21 18:51:34 -05:00
Henry Dollman
4e3d198b7b fix(navigation): Update systems collection link to use collection name 2025-01-21 18:21:46 -05:00
Henry Dollman
a6a9719565 config(issues): Remove alternatives field in feature request 2025-01-20 13:22:23 -05:00
Peng Ding
ddb4f1c8f8 fix compatibility issue with sh. 2025-01-20 05:38:50 -06:00
Peng Ding
55d13c551a Enhance proxy handling in install scripts for GitHub mirrors 2025-01-20 05:27:58 -06:00
hank
fef30b1750 config(issues): Remove report vuln link since it's added automatically 2025-01-19 20:18:36 -05:00
hank
0cee9e4e4b docs(config): Update issue and discussion templates 2025-01-19 20:14:50 -05:00
hank
90378d09a3 Merge branch 'main' of https://github.com/svenvg93/beszel 2025-01-19 20:09:51 -05:00
Henry Dollman
7adf7ef549 config(goreleaser): rm CGO_ENABLED=0 and set releases to draft by default 2025-01-18 19:01:09 -05:00
Henry Dollman
ee6a456b66 chore(install-agent): formatting + rm unused variable 2025-01-18 18:09:02 -05:00
Nicolás Martínez Van der Looven
4789f48ad0 Add OpenWRT support for binary quick script installation (#445) 2025-01-18 17:41:21 -05:00
Sven van Ginkel
ea098fd61c Remove label 2025-01-18 10:16:25 +01:00
Sven van Ginkel
e7c214799a Add OS option 2025-01-18 10:16:01 +01:00
Sven van Ginkel
9cabc103e5 Add Templates 2025-01-18 10:12:28 +01:00
hank
be955e0122 New Crowdin updates (#409)
* New translations en.po (Italian)

* New translations en.po (Korean)

Co-authored-by: davtur19 <dav.tur19@gmail.com>
Co-authored-by: aroxu <30624945+aroxu@users.noreply.github.com>
2025-01-17 16:49:33 -05:00
Henry Dollman
8a0f2d61a8 style(auth-form): remove redundant placeholder class from password inputs 2025-01-17 16:45:12 -05:00
Alexander Mnich
48ed4abc02 remove lowercase of placeholders in auth-form 2025-01-17 11:46:59 +00:00
Henry Dollman
e8c680bda7 refactor: streamline key retrieval logic in agent.go 2025-01-16 17:49:20 -05:00
Henry Dollman
37c7a32c10 add funding.yml 2025-01-16 17:48:49 -05:00
Ben Morgenstren
1c4533f1f2 adding reading agent ssh key from file 2025-01-14 22:13:36 -05:00
Henry Dollman
5d88599c9a beszel-agent-update.service: avoid non-zero exit status and add logging (#397) 2025-01-14 20:31:50 -05:00
Henry Dollman
73427306d1 add zh-tw 2025-01-14 19:56:36 -05:00
hank
f24a7313d6 New Crowdin updates (#404)
* New translations en.po (Chinese Traditional)

Co-authored-by: Kin Lo <kinabcd@gmail.com>
2025-01-14 12:13:19 -05:00
Henry Dollman
ad55d1ca88 add Bulgarian and update locale references 2025-01-13 19:26:01 -05:00
hank
9c669d8833 New Crowdin updates (#372)
* New translations en.po (Bulgarian)

* New translations en.po (German)

Co-authored-by: vetyp <176803899+vetyp@users.noreply.github.com>
2025-01-10 15:39:01 -05:00
Henry Dollman
81fa4f16d6 ui: grey meters in systems table if system is not up (#370) 2025-01-06 13:22:51 -05:00
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
329 changed files with 66128 additions and 29519 deletions

48
.dockerignore Normal file
View File

@@ -0,0 +1,48 @@
# Node.js dependencies
node_modules
internalsite/node_modules
# Go build artifacts and binaries
build
dist
*.exe
beszel-agent
beszel_data*
pb_data
data
temp
# Development and IDE files
.vscode
.idea*
*.swc
__debug_*
# Git and version control
.git
.gitignore
# Documentation and supplemental files
*.md
supplemental
freebsd-port
# Test files (exclude from production builds)
*_test.go
coverage
# Docker files
dockerfile_*
# Temporary files
*.tmp
*.bak
*.log
# OS specific files
.DS_Store
Thumbs.db
# .NET build artifacts
agent/lhm/obj
agent/lhm/bin

61
.github/DISCUSSION_TEMPLATE/support.yml vendored Normal file
View File

@@ -0,0 +1,61 @@
body:
- type: markdown
attributes:
value: |
### Before opening a discussion:
- Check the [common issues guide](https://beszel.dev/guide/common-issues).
- Search existing [issues](https://github.com/henrygd/beszel/issues) and [discussions](https://github.com/henrygd/beszel/discussions) (including closed).
- type: textarea
id: description
attributes:
label: Description
description: A clear and concise description of the issue or question. If applicable, add screenshots to help explain your problem.
validations:
required: true
- type: input
id: system
attributes:
label: OS / Architecture
placeholder: linux/amd64 (agent), freebsd/arm64 (hub)
validations:
required: true
- type: input
id: version
attributes:
label: Beszel version
placeholder: 0.9.1
validations:
required: true
- type: dropdown
id: install-method
attributes:
label: Installation method
options:
- Docker
- Binary
- Nix
- Unraid
- Coolify
- Other (please describe above)
validations:
required: true
- type: textarea
id: config
attributes:
label: Configuration
description: Please provide any relevant service configuration
render: yaml
- type: textarea
id: hub-logs
attributes:
label: Hub Logs
description: Check the logs page in PocketBase (`/_/#/logs`) for relevant errors (copy JSON).
render: json
- type: textarea
id: agent-logs
attributes:
label: Agent Logs
description: Please provide any logs from the agent, if relevant. Use `LOG_LEVEL=debug` for more info.
render: shell

134
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View File

@@ -0,0 +1,134 @@
name: 🐛 Bug report
description: Report a new bug or issue.
title: '[Bug]: '
labels: ['bug', "needs confirmation"]
body:
- type: dropdown
id: component
attributes:
label: Component
description: Which part of Beszel is this about?
options:
- Hub
- Agent
- Hub & Agent
validations:
required: true
- type: markdown
attributes:
value: |
### Thanks for taking the time to fill out this bug report!
- For more general support, please [start a support thread](https://github.com/henrygd/beszel/discussions/new?category=support).
- To request a change or feature, use the [feature request form](https://github.com/henrygd/beszel/issues/new?template=feature_request.yml).
- Please do not submit bugs that are specific to ZFS. We plan to add integration with ZFS utilities in the near future.
### Before submitting a bug report:
- Check the [common issues guide](https://beszel.dev/guide/common-issues).
- Search existing [issues](https://github.com/henrygd/beszel/issues) and [discussions](https://github.com/henrygd/beszel/discussions) (including closed).
- type: textarea
id: description
attributes:
label: Description
description: Explain the issue you experienced clearly and concisely.
placeholder: I went to the coffee pot and it was empty.
validations:
required: true
- type: textarea
id: expected-behavior
attributes:
label: Expected Behavior
description: In a perfect world, what should have happened?
placeholder: When I got to the coffee pot, it should have been full.
validations:
required: true
- type: textarea
id: steps-to-reproduce
attributes:
label: Steps to Reproduce
description: Describe how to reproduce the issue in repeatable steps.
placeholder: |
1. Go to the coffee pot.
2. Make more coffee.
3. Pour it into a cup.
validations:
required: true
- type: dropdown
id: category
attributes:
label: Category
description: Which category does this relate to most?
options:
- Metrics
- Charts & Visualization
- Settings & Configuration
- Notifications & Alerts
- Authentication
- Installation
- Performance
- UI / UX
- Other
validations:
required: true
- type: dropdown
id: metrics
attributes:
label: Affected Metrics
description: If applicable, which specific metric does this relate to most?
options:
- CPU
- Memory
- Storage
- Network
- Containers
- GPU
- Sensors
- Other
validations:
required: true
- type: input
id: system
attributes:
label: OS / Architecture
placeholder: linux/amd64 (agent), freebsd/arm64 (hub)
validations:
required: true
- type: input
id: version
attributes:
label: Beszel version
placeholder: 0.9.1
validations:
required: true
- type: dropdown
id: install-method
attributes:
label: Installation method
options:
- Docker
- Binary
- Nix
- Unraid
- Coolify
- Other (please describe above)
validations:
required: true
- type: textarea
id: config
attributes:
label: Configuration
description: Please provide any relevant service configuration
render: yaml
- type: textarea
id: hub-logs
attributes:
label: Hub Logs
description: Check the logs page in PocketBase (`/_/#/logs`) for relevant errors (copy JSON).
render: json
- type: textarea
id: agent-logs
attributes:
label: Agent Logs
description: Please provide any logs from the agent, if relevant. Use `LOG_LEVEL=debug` for more info.
render: shell

8
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: 💬 Support and questions
url: https://github.com/henrygd/beszel/discussions
about: Ask and answer questions here.
- name: View the Common Issues page
url: https://beszel.dev/guide/common-issues
about: Find information about commonly encountered problems.

View File

@@ -0,0 +1,76 @@
name: 🚀 Feature request
description: Request a new feature or change.
title: "[Feature]: "
labels: ["enhancement", "needs review"]
body:
- type: dropdown
id: component
attributes:
label: Component
description: Which part of Beszel is this about?
options:
- Hub
- Agent
- Hub & Agent
validations:
required: true
- type: markdown
attributes:
value: Before submitting, please search existing [issues](https://github.com/henrygd/beszel/issues) and [discussions](https://github.com/henrygd/beszel/discussions) (including closed).
- type: textarea
attributes:
label: Describe the feature you would like to see
validations:
required: true
- type: textarea
id: motivation
attributes:
label: Motivation / Use Case
description: Why do you want this feature? What problem does it solve?
validations:
required: true
- type: textarea
attributes:
label: Describe how you would like to see this feature implemented
validations:
required: true
- type: textarea
id: logs
attributes:
label: Screenshots
description: Please attach any relevant screenshots, such as images from your current solution or similar implementations.
validations:
required: false
- type: dropdown
id: category
attributes:
label: Category
description: Which category does this relate to most?
options:
- Metrics
- Charts & Visualization
- Settings & Configuration
- Notifications & Alerts
- Authentication
- Installation
- Performance
- UI / UX
- Other
validations:
required: true
- type: dropdown
id: metrics
attributes:
label: Affected Metrics
description: If applicable, which specific metric does this relate to most?
options:
- CPU
- Memory
- Storage
- Network
- Containers
- GPU
- Sensors
- Other
validations:
required: true

1
.github/funding.yml vendored Normal file
View File

@@ -0,0 +1 @@
buy_me_a_coffee: henrygd

33
.github/pull_request_template.md vendored Normal file
View File

@@ -0,0 +1,33 @@
## 📃 Description
A short description of the pull request changes should go here and the sections below should list in detail all changes. You can remove the sections you don't need.
## 📖 Documentation
Add a link to the PR for [documentation](https://github.com/henrygd/beszel-docs) changes.
## 🪵 Changelog
### Added
- one
- two
### ✏️ Changed
- one
- two
### 🔧 Fixed
- one
- two
### 🗑️ Removed
- one
- two
## 📷 Screenshots
If this PR has any UI/UX changes it's strongly suggested you add screenshots here.

View File

@@ -3,7 +3,7 @@ name: Make docker images
on:
push:
tags:
- 'v*'
- "v*"
jobs:
build:
@@ -13,11 +13,49 @@ jobs:
matrix:
include:
- image: henrygd/beszel
context: ./beszel
dockerfile: ./beszel/dockerfile_Hub
context: ./
dockerfile: ./internal/dockerfile_hub
registry: docker.io
username_secret: DOCKERHUB_USERNAME
password_secret: DOCKERHUB_TOKEN
- image: henrygd/beszel-agent
context: ./beszel
dockerfile: ./beszel/dockerfile_Agent
context: ./
dockerfile: ./internal/dockerfile_agent
registry: docker.io
username_secret: DOCKERHUB_USERNAME
password_secret: DOCKERHUB_TOKEN
- image: henrygd/beszel-agent-nvidia
context: ./
dockerfile: ./internal/dockerfile_agent_nvidia
platforms: linux/amd64
registry: docker.io
username_secret: DOCKERHUB_USERNAME
password_secret: DOCKERHUB_TOKEN
- image: ghcr.io/${{ github.repository }}/beszel
context: ./
dockerfile: ./internal/dockerfile_hub
registry: ghcr.io
username: ${{ github.actor }}
password_secret: GITHUB_TOKEN
- image: ghcr.io/${{ github.repository }}/beszel-agent
context: ./
dockerfile: ./internal/dockerfile_agent
registry: ghcr.io
username: ${{ github.actor }}
password_secret: GITHUB_TOKEN
- image: ghcr.io/${{ github.repository }}/beszel-agent-nvidia
context: ./
dockerfile: ./internal/dockerfile_agent_nvidia
platforms: linux/amd64
registry: ghcr.io
username: ${{ github.actor }}
password_secret: GITHUB_TOKEN
permissions:
contents: read
packages: write
@@ -30,10 +68,10 @@ jobs:
uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: bun install --no-save --cwd ./beszel/site
run: bun install --no-save --cwd ./internal/site
- name: Build site
run: bun run --cwd ./beszel/site build
run: bun run --cwd ./internal/site build
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
@@ -47,6 +85,7 @@ jobs:
with:
images: ${{ matrix.image }}
tags: |
type=raw,value=edge
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
@@ -54,20 +93,23 @@ jobs:
# https://github.com/docker/login-action
- name: Login to Docker Hub
if: github.event_name != 'pull_request'
env:
password_secret_exists: ${{ secrets[matrix.password_secret] != '' && 'true' || 'false' }}
if: github.event_name != 'pull_request' && env.password_secret_exists == 'true'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
username: ${{ matrix.username || secrets[matrix.username_secret] }}
password: ${{ secrets[matrix.password_secret] }}
registry: ${{ matrix.registry }}
# Build and push Docker image with Buildx (don't push on PR)
# https://github.com/docker/build-push-action
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: '${{ matrix.context }}'
context: "${{ matrix.context }}"
file: ${{ matrix.dockerfile }}
platforms: linux/amd64,linux/arm64,linux/arm/v7
push: ${{ github.ref_type == 'tag' }}
platforms: ${{ matrix.platforms || 'linux/amd64,linux/arm64,linux/arm/v7' }}
push: ${{ github.ref_type == 'tag' && secrets[matrix.password_secret] != '' }}
tags: ${{ steps.metadata.outputs.tags }}
labels: ${{ steps.metadata.outputs.labels }}

View File

@@ -0,0 +1,43 @@
name: 'Issue and PR Maintenance'
on:
schedule:
- cron: '0 0 * * *' # runs at midnight UTC
workflow_dispatch:
permissions:
issues: write
pull-requests: write
jobs:
close-stale:
name: Close Stale Issues
runs-on: ubuntu-24.04
steps:
- name: Close Stale Issues
uses: actions/stale@v9
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
# Messaging
stale-issue-message: >
👋 This issue has been automatically marked as stale due to inactivity.
If this issue is still relevant, please comment to keep it open.
Without activity, it will be closed in 7 days.
close-issue-message: >
🔒 This issue has been automatically closed due to prolonged inactivity.
Feel free to open a new issue if you have further questions or concerns.
# Timing
days-before-issue-stale: 14
days-before-issue-close: 7
# Labels
stale-issue-label: 'stale'
remove-stale-when-updated: true
only-issue-labels: 'awaiting-requester'
# Exemptions
exempt-assignees: true
exempt-milestones: true

View File

@@ -0,0 +1,82 @@
name: Label issues from dropdowns
on:
issues:
types: [opened]
jobs:
label_from_dropdown:
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- name: Apply labels based on dropdown choices
uses: actions/github-script@v7
with:
script: |
const issueNumber = context.issue.number;
const owner = context.repo.owner;
const repo = context.repo.repo;
// Get the issue body
const body = context.payload.issue.body;
// Helper to find dropdown value in the body (assuming markdown format)
function extractSectionValue(heading) {
const regex = new RegExp(`### ${heading}\\s+([\\s\\S]*?)(?:\\n###|$)`, 'i');
const match = body.match(regex);
if (match) {
// Get the first non-empty line after the heading
const lines = match[1].split('\n').map(l => l.trim()).filter(Boolean);
return lines[0] || null;
}
return null;
}
// Extract dropdown selections
const category = extractSectionValue('Category');
const metrics = extractSectionValue('Affected Metrics');
const component = extractSectionValue('Component');
// Build labels to add
let labelsToAdd = [];
if (category) labelsToAdd.push(category);
if (metrics) labelsToAdd.push(metrics);
if (component) labelsToAdd.push(component);
// Get existing labels in the repo
const { data: existingLabels } = await github.rest.issues.listLabelsForRepo({
owner,
repo,
per_page: 100
});
const existingLabelNames = existingLabels.map(l => l.name);
// Find labels that need to be created
const labelsToCreate = labelsToAdd.filter(label => !existingLabelNames.includes(label));
// Create missing labels (with a default color)
for (const label of labelsToCreate) {
try {
await github.rest.issues.createLabel({
owner,
repo,
name: label,
color: 'ededed' // light gray, you can pick any hex color
});
} catch (e) {
// Ignore if label already exists (race condition), otherwise rethrow
if (!e || e.status !== 422) throw e;
}
}
// Now apply all labels (they all exist now)
if (labelsToAdd.length > 0) {
await github.rest.issues.addLabels({
owner,
repo,
issue_number: issueNumber,
labels: labelsToAdd
});
}

View File

@@ -3,7 +3,7 @@ name: Make release and binaries
on:
push:
tags:
- '*'
- "v*"
permissions:
contents: write
@@ -21,22 +21,34 @@ jobs:
uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: bun install --no-save --cwd ./beszel/site
run: bun install --no-save --cwd ./internal/site
- name: Build site
run: bun run --cwd ./beszel/site build
run: bun run --cwd ./internal/site build
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '^1.22.1'
go-version: "^1.22.1"
- name: Set up .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: "9.0.x"
- name: Build .NET LHM executable for Windows sensors
run: |
dotnet build -c Release ./agent/lhm/beszel_lhm.csproj
shell: bash
- name: GoReleaser beszel
uses: goreleaser/goreleaser-action@v6
with:
workdir: ./beszel
workdir: ./
distribution: goreleaser
version: latest
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.TOKEN }}
GITHUB_TOKEN: ${{ secrets.TOKEN || secrets.GITHUB_TOKEN }}
WINGET_TOKEN: ${{ secrets.WINGET_TOKEN }}
IS_FORK: ${{ github.repository_owner != 'henrygd' }}

33
.github/workflows/vulncheck.yml vendored Normal file
View File

@@ -0,0 +1,33 @@
# https://github.com/minio/minio/blob/master/.github/workflows/vulncheck.yml
name: VulnCheck
on:
pull_request:
branches:
- main
push:
branches:
- main
permissions:
contents: read # to fetch code (actions/checkout)
jobs:
vulncheck:
name: VulnCheck
runs-on: ubuntu-latest
steps:
- name: Check out code into the Go module directory
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: 1.25.x
# cached: false
- name: Get official govulncheck
run: go install golang.org/x/vuln/cmd/govulncheck@latest
shell: bash
- name: Run govulncheck
run: govulncheck -show verbose ./...
shell: bash

13
.gitignore vendored
View File

@@ -8,10 +8,15 @@ beszel_data
beszel_data*
dist
*.exe
beszel/cmd/hub/hub
beszel/cmd/agent/agent
internal/cmd/hub/hub
internal/cmd/agent/agent
node_modules
beszel/build
build
*timestamp*
.swc
beszel/site/src/locales/**/*.ts
internal/site/src/locales/**/*.ts
*.bak
__debug_*
agent/lhm/obj
agent/lhm/bin
dockerfile_agent_dev

237
.goreleaser.yml Normal file
View File

@@ -0,0 +1,237 @@
version: 2
project_name: beszel
before:
hooks:
- go mod tidy
builds:
- id: beszel
binary: beszel
main: internal/cmd/hub/hub.go
env:
- CGO_ENABLED=0
goos:
- linux
- darwin
goarch:
- amd64
- arm64
- arm
- id: beszel-agent
binary: beszel-agent
main: internal/cmd/agent/agent.go
env:
- CGO_ENABLED=0
goos:
- linux
- darwin
- freebsd
- openbsd
- windows
goarch:
- amd64
- arm64
- arm
- mips64
- riscv64
- mipsle
- mips
- ppc64le
gomips:
- hardfloat
- softfloat
ignore:
- goos: freebsd
goarch: arm
- goos: openbsd
goarch: arm
- goos: linux
goarch: mips64
gomips: softfloat
- goos: linux
goarch: mipsle
gomips: hardfloat
- goos: linux
goarch: mips
gomips: hardfloat
- goos: windows
goarch: arm
- goos: darwin
goarch: riscv64
- goos: windows
goarch: riscv64
archives:
- id: beszel-agent
formats: [tar.gz]
ids:
- beszel-agent
name_template: >-
{{ .Binary }}_
{{- .Os }}_
{{- .Arch }}
format_overrides:
- goos: windows
formats: [zip]
- id: beszel
formats: [tar.gz]
ids:
- beszel
name_template: >-
{{ .Binary }}_
{{- .Os }}_
{{- .Arch }}
nfpms:
- id: beszel-agent
package_name: beszel-agent
description: |-
Agent for Beszel
Beszel is a lightweight server monitoring platform that includes Docker
statistics, historical data, and alert functions. It has a friendly web
interface, simple configuration, and is ready to use out of the box.
It supports automatic backup, multi-user, OAuth authentication, and
API access.
maintainer: henrygd <hank@henrygd.me>
section: net
ids:
- beszel-agent
formats:
- deb
contents:
- src: ./supplemental/debian/beszel-agent.service
dst: lib/systemd/system/beszel-agent.service
packager: deb
- src: ./supplemental/debian/copyright
dst: usr/share/doc/beszel-agent/copyright
packager: deb
- src: ./supplemental/debian/lintian-overrides
dst: usr/share/lintian/overrides/beszel-agent
packager: deb
scripts:
postinstall: ./supplemental/debian/postinstall.sh
preremove: ./supplemental/debian/prerm.sh
postremove: ./supplemental/debian/postrm.sh
deb:
predepends:
- adduser
- debconf
scripts:
templates: ./supplemental/debian/templates
# Currently broken due to a bug in goreleaser
# https://github.com/goreleaser/goreleaser/issues/5487
#config: ./supplemental/debian/config.sh
scoops:
- ids: [beszel-agent]
name: beszel-agent
repository:
owner: henrygd
name: beszel-scoops
homepage: "https://beszel.dev"
description: "Agent for Beszel, a lightweight server monitoring platform."
license: MIT
skip_upload: '{{ if eq (tolower .Env.IS_FORK) "true" }}true{{ else }}auto{{ end }}'
# # Needs choco installed, so doesn't build on linux / default gh workflow :(
# chocolateys:
# - title: Beszel Agent
# ids: [beszel-agent]
# package_source_url: https://github.com/henrygd/beszel-chocolatey
# owners: henrygd
# authors: henrygd
# summary: 'Agent for Beszel, a lightweight server monitoring platform.'
# description: |
# Beszel is a lightweight server monitoring platform that includes Docker statistics, historical data, and alert functions.
# It has a friendly web interface, simple configuration, and is ready to use out of the box. It supports automatic backup, multi-user, OAuth authentication, and API access.
# license_url: https://github.com/henrygd/beszel/blob/main/LICENSE
# project_url: https://beszel.dev
# project_source_url: https://github.com/henrygd/beszel
# docs_url: https://beszel.dev/guide/getting-started
# icon_url: https://cdn.jsdelivr.net/gh/selfhst/icons/png/beszel.png
# bug_tracker_url: https://github.com/henrygd/beszel/issues
# copyright: 2025 henrygd
# tags: foss cross-platform admin monitoring
# require_license_acceptance: false
# release_notes: 'https://github.com/henrygd/beszel/releases/tag/v{{ .Version }}'
brews:
- ids: [beszel-agent]
name: beszel-agent
repository:
owner: henrygd
name: homebrew-beszel
homepage: "https://beszel.dev"
description: "Agent for Beszel, a lightweight server monitoring platform."
license: MIT
skip_upload: '{{ if eq (tolower .Env.IS_FORK) "true" }}true{{ else }}auto{{ end }}'
extra_install: |
(bin/"beszel-agent-launcher").write <<~EOS
#!/bin/bash
set -a
if [ -f "$HOME/.config/beszel/beszel-agent.env" ]; then
source "$HOME/.config/beszel/beszel-agent.env"
fi
set +a
exec #{bin}/beszel-agent "$@"
EOS
(bin/"beszel-agent-launcher").chmod 0755
service: |
run ["#{bin}/beszel-agent-launcher"]
log_path "#{Dir.home}/.cache/beszel/beszel-agent.log"
error_log_path "#{Dir.home}/.cache/beszel/beszel-agent.log"
keep_alive true
restart_delay 5
process_type :background
winget:
- ids: [beszel-agent]
name: beszel-agent
package_identifier: henrygd.beszel-agent
publisher: henrygd
license: MIT
license_url: "https://github.com/henrygd/beszel/blob/main/LICENSE"
copyright: "2025 henrygd"
homepage: "https://beszel.dev"
release_notes_url: "https://github.com/henrygd/beszel/releases/tag/v{{ .Version }}"
publisher_support_url: "https://github.com/henrygd/beszel/issues"
short_description: "Agent for Beszel, a lightweight server monitoring platform."
skip_upload: '{{ if eq (tolower .Env.IS_FORK) "true" }}true{{ else }}auto{{ end }}'
description: |
Beszel is a lightweight server monitoring platform that includes Docker
statistics, historical data, and alert functions. It has a friendly web
interface, simple configuration, and is ready to use out of the box.
It supports automatic backup, multi-user, OAuth authentication, and
API access.
tags:
- homelab
- monitoring
- self-hosted
repository:
owner: henrygd
name: beszel-winget
branch: henrygd.beszel-agent-{{ .Version }}
token: "{{ .Env.WINGET_TOKEN }}"
# pull_request:
# enabled: true
# draft: false
# base:
# owner: microsoft
# name: winget-pkgs
# branch: master
release:
draft: true
changelog:
disable: true
sort: asc
filters:
exclude:
- "^docs:"
- "^test:"

102
Makefile Normal file
View File

@@ -0,0 +1,102 @@
# Default OS/ARCH values
OS ?= $(shell go env GOOS)
ARCH ?= $(shell go env GOARCH)
# Skip building the web UI if true
SKIP_WEB ?= false
# Set executable extension based on target OS
EXE_EXT := $(if $(filter windows,$(OS)),.exe,)
.PHONY: tidy build-agent build-hub build-hub-dev 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
test: export GOEXPERIMENT=synctest
test:
go test -tags=testing ./...
tidy:
go mod tidy
build-web-ui:
@if command -v bun >/dev/null 2>&1; then \
bun install --cwd ./internal/site && \
bun run --cwd ./internal/site build; \
else \
npm install --prefix ./internal/site && \
npm run --prefix ./internal/site build; \
fi
# Conditional .NET build - only for Windows
build-dotnet-conditional:
@if [ "$(OS)" = "windows" ]; then \
echo "Building .NET executable for Windows..."; \
if command -v dotnet >/dev/null 2>&1; then \
rm -rf ./agent/lhm/bin; \
dotnet build -c Release ./agent/lhm/beszel_lhm.csproj; \
else \
echo "Error: dotnet not found. Install .NET SDK to build Windows agent."; \
exit 1; \
fi; \
fi
# Update build-agent to include conditional .NET build
build-agent: tidy build-dotnet-conditional
GOOS=$(OS) GOARCH=$(ARCH) go build -o ./build/beszel-agent_$(OS)_$(ARCH)$(EXE_EXT) -ldflags "-w -s" ./internal/cmd/agent
build-hub: tidy $(if $(filter false,$(SKIP_WEB)),build-web-ui)
GOOS=$(OS) GOARCH=$(ARCH) go build -o ./build/beszel_$(OS)_$(ARCH)$(EXE_EXT) -ldflags "-w -s" ./internal/cmd/hub
build-hub-dev: tidy
mkdir -p ./internal/site/dist && touch ./internal/site/dist/index.html
GOOS=$(OS) GOARCH=$(ARCH) go build -tags development -o ./build/beszel-dev_$(OS)_$(ARCH)$(EXE_EXT) -ldflags "-w -s" ./internal/cmd/hub
build: build-agent build-hub
generate-locales:
@if [ ! -f ./internal/site/src/locales/en/en.ts ]; then \
echo "Generating locales..."; \
command -v bun >/dev/null 2>&1 && cd ./internal/site && bun install && bun run sync || cd ./internal/site && npm install && npm run sync; \
fi
dev-server: generate-locales
cd ./internal/site
@if command -v bun >/dev/null 2>&1; then \
cd ./internal/site && bun run dev --host 0.0.0.0; \
else \
cd ./internal/site && npm run dev --host 0.0.0.0; \
fi
dev-hub: export ENV=dev
dev-hub:
mkdir -p ./internal/site/dist && touch ./internal/site/dist/index.html
@if command -v entr >/dev/null 2>&1; then \
find ./internal -type f -name '*.go' | entr -r -s "cd ./internal/cmd/hub && go run -tags development . serve --http 0.0.0.0:8090"; \
else \
cd ./internal/cmd/hub && go run -tags development . serve --http 0.0.0.0:8090; \
fi
dev-agent:
@if command -v entr >/dev/null 2>&1; then \
find ./internal/cmd/agent/*.go ./agent/*.go | entr -r go run github.com/henrygd/beszel/internal/cmd/agent; \
else \
go run github.com/henrygd/beszel/internal/cmd/agent; \
fi
build-dotnet:
@if command -v dotnet >/dev/null 2>&1; then \
rm -rf ./agent/lhm/bin; \
dotnet build -c Release ./agent/lhm/beszel_lhm.csproj; \
else \
echo "dotnet not found"; \
fi
# KEY="..." make -j dev
dev: dev-server dev-hub dev-agent

185
agent/agent.go Normal file
View File

@@ -0,0 +1,185 @@
// Package agent implements the Beszel monitoring agent that collects and serves system metrics.
//
// The agent runs on monitored systems and communicates collected data
// to the Beszel hub for centralized monitoring and alerting.
package agent
import (
"crypto/sha256"
"encoding/hex"
"log/slog"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/gliderlabs/ssh"
"github.com/henrygd/beszel"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/shirou/gopsutil/v4/host"
gossh "golang.org/x/crypto/ssh"
)
type Agent struct {
sync.Mutex // Used to lock agent while collecting data
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
sensorConfig *SensorConfig // Sensors config
systemInfo system.Info // Host system info
gpuManager *GPUManager // Manages GPU data
cache *SessionCache // Cache for system stats based on primary session ID
connectionManager *ConnectionManager // Channel to signal connection events
server *ssh.Server // SSH server
dataDir string // Directory for persisting data
keys []gossh.PublicKey // SSH public keys
}
// NewAgent creates a new agent with the given data directory for persisting data.
// If the data directory is not set, it will attempt to find the optimal directory.
func NewAgent(dataDir ...string) (agent *Agent, err error) {
agent = &Agent{
fsStats: make(map[string]*system.FsStats),
cache: NewSessionCache(69 * time.Second),
}
agent.dataDir, err = getDataDir(dataDir...)
if err != nil {
slog.Warn("Data directory not found")
} else {
slog.Info("Data directory", "path", agent.dataDir)
}
agent.memCalc, _ = GetEnv("MEM_CALC")
agent.sensorConfig = agent.newSensorConfig()
// Set up slog with a log level determined by the LOG_LEVEL env var
if logLevelStr, exists := GetEnv("LOG_LEVEL"); exists {
switch strings.ToLower(logLevelStr) {
case "debug":
agent.debug = true
slog.SetLogLoggerLevel(slog.LevelDebug)
case "warn":
slog.SetLogLoggerLevel(slog.LevelWarn)
case "error":
slog.SetLogLoggerLevel(slog.LevelError)
}
}
slog.Debug(beszel.Version)
// initialize system info
agent.initializeSystemInfo()
// initialize connection manager
agent.connectionManager = newConnectionManager(agent)
// initialize disk info
agent.initializeDiskInfo()
// initialize net io stats
agent.initializeNetIoStats()
// initialize docker manager
agent.dockerManager = newDockerManager(agent)
// initialize GPU manager
if gm, err := NewGPUManager(); err != nil {
slog.Debug("GPU", "err", err)
} else {
agent.gpuManager = gm
}
// if debugging, print stats
if agent.debug {
slog.Debug("Stats", "data", agent.gatherStats(""))
}
return agent, nil
}
// GetEnv retrieves an environment variable with a "BESZEL_AGENT_" prefix, or falls back to the unprefixed key.
func GetEnv(key string) (value string, exists bool) {
if value, exists = os.LookupEnv("BESZEL_AGENT_" + key); exists {
return value, exists
}
// Fallback to the old unprefixed key
return os.LookupEnv(key)
}
func (a *Agent) gatherStats(sessionID string) *system.CombinedData {
a.Lock()
defer a.Unlock()
data, isCached := a.cache.Get(sessionID)
if isCached {
slog.Debug("Cached data", "session", sessionID)
return data
}
*data = system.CombinedData{
Stats: a.getSystemStats(),
Info: a.systemInfo,
}
slog.Debug("System data", "data", data)
if a.dockerManager != nil {
if containerStats, err := a.dockerManager.getDockerStats(); err == nil {
data.Containers = containerStats
slog.Debug("Containers", "data", data.Containers)
} else {
slog.Debug("Containers", "err", err)
}
}
data.Stats.ExtraFs = make(map[string]*system.FsStats)
for name, stats := range a.fsStats {
if !stats.Root && stats.DiskTotal > 0 {
data.Stats.ExtraFs[name] = stats
}
}
slog.Debug("Extra FS", "data", data.Stats.ExtraFs)
a.cache.Set(sessionID, data)
return data
}
// StartAgent initializes and starts the agent with optional WebSocket connection
func (a *Agent) Start(serverOptions ServerOptions) error {
a.keys = serverOptions.Keys
return a.connectionManager.Start(serverOptions)
}
func (a *Agent) getFingerprint() string {
// first look for a fingerprint in the data directory
if a.dataDir != "" {
if fp, err := os.ReadFile(filepath.Join(a.dataDir, "fingerprint")); err == nil {
return string(fp)
}
}
// if no fingerprint is found, generate one
fingerprint, err := host.HostID()
if err != nil || fingerprint == "" {
fingerprint = a.systemInfo.Hostname + a.systemInfo.CpuModel
}
// hash fingerprint
sum := sha256.Sum256([]byte(fingerprint))
fingerprint = hex.EncodeToString(sum[:24])
// save fingerprint to data directory
if a.dataDir != "" {
err = os.WriteFile(filepath.Join(a.dataDir, "fingerprint"), []byte(fingerprint), 0644)
if err != nil {
slog.Warn("Failed to save fingerprint", "err", err)
}
}
return fingerprint
}

37
agent/agent_cache.go Normal file
View File

@@ -0,0 +1,37 @@
package agent
import (
"time"
"github.com/henrygd/beszel/internal/entities/system"
)
// Not thread safe since we only access from gatherStats which is already locked
type SessionCache struct {
data *system.CombinedData
lastUpdate time.Time
primarySession string
leaseTime time.Duration
}
func NewSessionCache(leaseTime time.Duration) *SessionCache {
return &SessionCache{
leaseTime: leaseTime,
data: &system.CombinedData{},
}
}
func (c *SessionCache) Get(sessionID string) (stats *system.CombinedData, isCached bool) {
if sessionID != c.primarySession && time.Since(c.lastUpdate) < c.leaseTime {
return c.data, true
}
return c.data, false
}
func (c *SessionCache) Set(sessionID string, data *system.CombinedData) {
if data != nil {
*c.data = *data
}
c.primarySession = sessionID
c.lastUpdate = time.Now()
}

89
agent/agent_cache_test.go Normal file
View File

@@ -0,0 +1,89 @@
//go:build testing
// +build testing
package agent
import (
"testing"
"testing/synctest"
"time"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSessionCache_GetSet(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
cache := NewSessionCache(69 * time.Second)
testData := &system.CombinedData{
Info: system.Info{
Hostname: "test-host",
Cores: 4,
},
Stats: system.Stats{
Cpu: 50.0,
MemPct: 30.0,
DiskPct: 40.0,
},
}
// Test initial state - should not be cached
data, isCached := cache.Get("session1")
assert.False(t, isCached, "Expected no cached data initially")
assert.NotNil(t, data, "Expected data to be initialized")
// Set data for session1
cache.Set("session1", testData)
time.Sleep(15 * time.Second)
// Get data for a different session - should be cached
data, isCached = cache.Get("session2")
assert.True(t, isCached, "Expected data to be cached for non-primary session")
require.NotNil(t, data, "Expected cached data to be returned")
assert.Equal(t, "test-host", data.Info.Hostname, "Hostname should match test data")
assert.Equal(t, 4, data.Info.Cores, "Cores should match test data")
assert.Equal(t, 50.0, data.Stats.Cpu, "CPU should match test data")
assert.Equal(t, 30.0, data.Stats.MemPct, "Memory percentage should match test data")
assert.Equal(t, 40.0, data.Stats.DiskPct, "Disk percentage should match test data")
time.Sleep(10 * time.Second)
// Get data for the primary session - should not be cached
data, isCached = cache.Get("session1")
assert.False(t, isCached, "Expected data not to be cached for primary session")
require.NotNil(t, data, "Expected data to be returned even if not cached")
assert.Equal(t, "test-host", data.Info.Hostname, "Hostname should match test data")
// if not cached, agent will update the data
cache.Set("session1", testData)
time.Sleep(45 * time.Second)
// Get data for a different session - should still be cached
_, isCached = cache.Get("session2")
assert.True(t, isCached, "Expected data to be cached for non-primary session")
// Wait for the lease to expire
time.Sleep(30 * time.Second)
// Get data for session2 - should not be cached
_, isCached = cache.Get("session2")
assert.False(t, isCached, "Expected data not to be cached after lease expiration")
})
}
func TestSessionCache_NilData(t *testing.T) {
// Create a new SessionCache
cache := NewSessionCache(30 * time.Second)
// Test setting nil data (should not panic)
assert.NotPanics(t, func() {
cache.Set("session1", nil)
}, "Setting nil data should not panic")
// Get data - should not be nil even though we set nil
data, _ := cache.Get("session2")
assert.NotNil(t, data, "Expected data to not be nil after setting nil data")
}

View File

@@ -0,0 +1,9 @@
//go:build testing
// +build testing
package agent
// TESTING ONLY: GetConnectionManager is a helper function to get the connection manager for testing.
func (a *Agent) GetConnectionManager() *ConnectionManager {
return a.connectionManager
}

52
agent/battery/battery.go Normal file
View File

@@ -0,0 +1,52 @@
//go:build !freebsd
// Package battery provides functions to check if the system has a battery and to get the battery stats.
package battery
import (
"errors"
"log/slog"
"github.com/distatus/battery"
)
var systemHasBattery = false
var haveCheckedBattery = false
// HasReadableBattery checks if the system has a battery and returns true if it does.
func HasReadableBattery() bool {
if haveCheckedBattery {
return systemHasBattery
}
haveCheckedBattery = true
bat, err := battery.Get(0)
systemHasBattery = err == nil && bat != nil && bat.Design != 0 && bat.Full != 0
if !systemHasBattery {
slog.Debug("No battery found", "err", err)
}
return systemHasBattery
}
// GetBatteryStats returns the current battery percent and charge state
func GetBatteryStats() (batteryPercent uint8, batteryState uint8, err error) {
if !systemHasBattery {
return batteryPercent, batteryState, errors.ErrUnsupported
}
batteries, err := battery.GetAll()
if err != nil || len(batteries) == 0 {
return batteryPercent, batteryState, err
}
totalCapacity := float64(0)
totalCharge := float64(0)
for _, bat := range batteries {
if bat.Design != 0 {
totalCapacity += bat.Design
} else {
totalCapacity += bat.Full
}
totalCharge += bat.Current
}
batteryPercent = uint8(totalCharge / totalCapacity * 100)
batteryState = uint8(batteries[0].State.Raw)
return batteryPercent, batteryState, nil
}

View File

@@ -0,0 +1,13 @@
//go:build freebsd
package battery
import "errors"
func HasReadableBattery() bool {
return false
}
func GetBatteryStats() (uint8, uint8, error) {
return 0, 0, errors.ErrUnsupported
}

266
agent/client.go Normal file
View File

@@ -0,0 +1,266 @@
package agent
import (
"crypto/tls"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"net/url"
"os"
"path"
"strings"
"time"
"github.com/henrygd/beszel"
"github.com/henrygd/beszel/internal/common"
"github.com/fxamacker/cbor/v2"
"github.com/lxzan/gws"
"golang.org/x/crypto/ssh"
)
const (
wsDeadline = 70 * time.Second
)
// WebSocketClient manages the WebSocket connection between the agent and hub.
// It handles authentication, message routing, and connection lifecycle management.
type WebSocketClient struct {
gws.BuiltinEventHandler
options *gws.ClientOption // WebSocket client configuration options
agent *Agent // Reference to the parent agent
Conn *gws.Conn // Active WebSocket connection
hubURL *url.URL // Parsed hub URL for connection
token string // Authentication token for hub registration
fingerprint string // System fingerprint for identification
hubRequest *common.HubRequest[cbor.RawMessage] // Reusable request structure for message parsing
lastConnectAttempt time.Time // Timestamp of last connection attempt
hubVerified bool // Whether the hub has been cryptographically verified
}
// newWebSocketClient creates a new WebSocket client for the given agent.
// It reads configuration from environment variables and validates the hub URL.
func newWebSocketClient(agent *Agent) (client *WebSocketClient, err error) {
hubURLStr, exists := GetEnv("HUB_URL")
if !exists {
return nil, errors.New("HUB_URL environment variable not set")
}
client = &WebSocketClient{}
client.hubURL, err = url.Parse(hubURLStr)
if err != nil {
return nil, errors.New("invalid hub URL")
}
// get registration token
client.token, err = getToken()
if err != nil {
return nil, err
}
client.agent = agent
client.hubRequest = &common.HubRequest[cbor.RawMessage]{}
client.fingerprint = agent.getFingerprint()
return client, nil
}
// getToken returns the token for the WebSocket client.
// It first checks the TOKEN environment variable, then the TOKEN_FILE environment variable.
// If neither is set, it returns an error.
func getToken() (string, error) {
// get token from env var
token, _ := GetEnv("TOKEN")
if token != "" {
return token, nil
}
// get token from file
tokenFile, _ := GetEnv("TOKEN_FILE")
if tokenFile == "" {
return "", errors.New("must set TOKEN or TOKEN_FILE")
}
tokenBytes, err := os.ReadFile(tokenFile)
if err != nil {
return "", err
}
return strings.TrimSpace(string(tokenBytes)), nil
}
// getOptions returns the WebSocket client options, creating them if necessary.
// It configures the connection URL, TLS settings, and authentication headers.
func (client *WebSocketClient) getOptions() *gws.ClientOption {
if client.options != nil {
return client.options
}
// update the hub url to use websocket scheme and api path
if client.hubURL.Scheme == "https" {
client.hubURL.Scheme = "wss"
} else {
client.hubURL.Scheme = "ws"
}
client.hubURL.Path = path.Join(client.hubURL.Path, "api/beszel/agent-connect")
client.options = &gws.ClientOption{
Addr: client.hubURL.String(),
TlsConfig: &tls.Config{InsecureSkipVerify: true},
RequestHeader: http.Header{
"User-Agent": []string{getUserAgent()},
"X-Token": []string{client.token},
"X-Beszel": []string{beszel.Version},
},
}
return client.options
}
// Connect establishes a WebSocket connection to the hub.
// It closes any existing connection before attempting to reconnect.
func (client *WebSocketClient) Connect() (err error) {
client.lastConnectAttempt = time.Now()
// make sure previous connection is closed
client.Close()
client.Conn, _, err = gws.NewClient(client, client.getOptions())
if err != nil {
return err
}
go client.Conn.ReadLoop()
return nil
}
// OnOpen handles WebSocket connection establishment.
// It sets a deadline for the connection to prevent hanging.
func (client *WebSocketClient) OnOpen(conn *gws.Conn) {
conn.SetDeadline(time.Now().Add(wsDeadline))
}
// OnClose handles WebSocket connection closure.
// It logs the closure reason and notifies the connection manager.
func (client *WebSocketClient) OnClose(conn *gws.Conn, err error) {
slog.Warn("Connection closed", "err", strings.TrimPrefix(err.Error(), "gws: "))
client.agent.connectionManager.eventChan <- WebSocketDisconnect
}
// OnMessage handles incoming WebSocket messages from the hub.
// It decodes CBOR messages and routes them to appropriate handlers.
func (client *WebSocketClient) OnMessage(conn *gws.Conn, message *gws.Message) {
defer message.Close()
conn.SetDeadline(time.Now().Add(wsDeadline))
if message.Opcode != gws.OpcodeBinary {
return
}
if err := cbor.NewDecoder(message.Data).Decode(client.hubRequest); err != nil {
slog.Error("Error parsing message", "err", err)
return
}
if err := client.handleHubRequest(client.hubRequest); err != nil {
slog.Error("Error handling message", "err", err)
}
}
// OnPing handles WebSocket ping frames.
// It responds with a pong and updates the connection deadline.
func (client *WebSocketClient) OnPing(conn *gws.Conn, message []byte) {
conn.SetDeadline(time.Now().Add(wsDeadline))
conn.WritePong(message)
}
// handleAuthChallenge verifies the authenticity of the hub and returns the system's fingerprint.
func (client *WebSocketClient) handleAuthChallenge(msg *common.HubRequest[cbor.RawMessage]) (err error) {
var authRequest common.FingerprintRequest
if err := cbor.Unmarshal(msg.Data, &authRequest); err != nil {
return err
}
if err := client.verifySignature(authRequest.Signature); err != nil {
return err
}
client.hubVerified = true
client.agent.connectionManager.eventChan <- WebSocketConnect
response := &common.FingerprintResponse{
Fingerprint: client.fingerprint,
}
if authRequest.NeedSysInfo {
response.Hostname = client.agent.systemInfo.Hostname
serverAddr := client.agent.connectionManager.serverOptions.Addr
_, response.Port, _ = net.SplitHostPort(serverAddr)
}
return client.sendMessage(response)
}
// verifySignature verifies the signature of the token using the public keys.
func (client *WebSocketClient) verifySignature(signature []byte) (err error) {
for _, pubKey := range client.agent.keys {
sig := ssh.Signature{
Format: pubKey.Type(),
Blob: signature,
}
if err = pubKey.Verify([]byte(client.token), &sig); err == nil {
return nil
}
}
return errors.New("invalid signature - check KEY value")
}
// Close closes the WebSocket connection gracefully.
// This method is safe to call multiple times.
func (client *WebSocketClient) Close() {
if client.Conn != nil {
_ = client.Conn.WriteClose(1000, nil)
}
}
// handleHubRequest routes the request to the appropriate handler.
// It ensures the hub is verified before processing most requests.
func (client *WebSocketClient) handleHubRequest(msg *common.HubRequest[cbor.RawMessage]) error {
if !client.hubVerified && msg.Action != common.CheckFingerprint {
return errors.New("hub not verified")
}
switch msg.Action {
case common.GetData:
return client.sendSystemData()
case common.CheckFingerprint:
return client.handleAuthChallenge(msg)
}
return nil
}
// sendSystemData gathers and sends current system statistics to the hub.
func (client *WebSocketClient) sendSystemData() error {
sysStats := client.agent.gatherStats(client.token)
return client.sendMessage(sysStats)
}
// sendMessage encodes the given data to CBOR and sends it as a binary message over the WebSocket connection to the hub.
func (client *WebSocketClient) sendMessage(data any) error {
bytes, err := cbor.Marshal(data)
if err != nil {
return err
}
return client.Conn.WriteMessage(gws.OpcodeBinary, bytes)
}
// getUserAgent returns one of two User-Agent strings based on current time.
// This is used to avoid being blocked by Cloudflare or other anti-bot measures.
func getUserAgent() string {
const (
uaBase = "Mozilla/5.0 (%s) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
uaWindows = "Windows NT 11.0; Win64; x64"
uaMac = "Macintosh; Intel Mac OS X 14_0_0"
)
if time.Now().UnixNano()%2 == 0 {
return fmt.Sprintf(uaBase, uaWindows)
}
return fmt.Sprintf(uaBase, uaMac)
}

561
agent/client_test.go Normal file
View File

@@ -0,0 +1,561 @@
//go:build testing
// +build testing
package agent
import (
"crypto/ed25519"
"net/url"
"os"
"strings"
"testing"
"time"
"github.com/henrygd/beszel"
"github.com/henrygd/beszel/internal/common"
"github.com/fxamacker/cbor/v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/ssh"
)
// TestNewWebSocketClient tests WebSocket client creation
func TestNewWebSocketClient(t *testing.T) {
agent := createTestAgent(t)
testCases := []struct {
name string
hubURL string
token string
expectError bool
errorMsg string
}{
{
name: "valid configuration",
hubURL: "http://localhost:8080",
token: "test-token-123",
expectError: false,
},
{
name: "valid https URL",
hubURL: "https://hub.example.com",
token: "secure-token",
expectError: false,
},
{
name: "missing hub URL",
hubURL: "",
token: "test-token",
expectError: true,
errorMsg: "HUB_URL environment variable not set",
},
{
name: "invalid URL",
hubURL: "ht\ttp://invalid",
token: "test-token",
expectError: true,
errorMsg: "invalid hub URL",
},
{
name: "missing token",
hubURL: "http://localhost:8080",
token: "",
expectError: true,
errorMsg: "must set TOKEN or TOKEN_FILE",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Set up environment
if tc.hubURL != "" {
os.Setenv("BESZEL_AGENT_HUB_URL", tc.hubURL)
} else {
os.Unsetenv("BESZEL_AGENT_HUB_URL")
}
if tc.token != "" {
os.Setenv("BESZEL_AGENT_TOKEN", tc.token)
} else {
os.Unsetenv("BESZEL_AGENT_TOKEN")
}
defer func() {
os.Unsetenv("BESZEL_AGENT_HUB_URL")
os.Unsetenv("BESZEL_AGENT_TOKEN")
}()
client, err := newWebSocketClient(agent)
if tc.expectError {
assert.Error(t, err)
if err != nil && tc.errorMsg != "" {
assert.Contains(t, err.Error(), tc.errorMsg)
}
assert.Nil(t, client)
} else {
require.NoError(t, err)
assert.NotNil(t, client)
assert.Equal(t, agent, client.agent)
assert.Equal(t, tc.token, client.token)
assert.Equal(t, tc.hubURL, client.hubURL.String())
assert.NotEmpty(t, client.fingerprint)
assert.NotNil(t, client.hubRequest)
}
})
}
}
// TestWebSocketClient_GetOptions tests WebSocket client options configuration
func TestWebSocketClient_GetOptions(t *testing.T) {
agent := createTestAgent(t)
testCases := []struct {
name string
inputURL string
expectedScheme string
expectedPath string
}{
{
name: "http to ws conversion",
inputURL: "http://localhost:8080",
expectedScheme: "ws",
expectedPath: "/api/beszel/agent-connect",
},
{
name: "https to wss conversion",
inputURL: "https://hub.example.com",
expectedScheme: "wss",
expectedPath: "/api/beszel/agent-connect",
},
{
name: "existing path preservation",
inputURL: "http://localhost:8080/custom/path",
expectedScheme: "ws",
expectedPath: "/custom/path/api/beszel/agent-connect",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Set up environment
os.Setenv("BESZEL_AGENT_HUB_URL", tc.inputURL)
os.Setenv("BESZEL_AGENT_TOKEN", "test-token")
defer func() {
os.Unsetenv("BESZEL_AGENT_HUB_URL")
os.Unsetenv("BESZEL_AGENT_TOKEN")
}()
client, err := newWebSocketClient(agent)
require.NoError(t, err)
options := client.getOptions()
// Parse the WebSocket URL
wsURL, err := url.Parse(options.Addr)
require.NoError(t, err)
assert.Equal(t, tc.expectedScheme, wsURL.Scheme)
assert.Equal(t, tc.expectedPath, wsURL.Path)
// Check headers
assert.Equal(t, "test-token", options.RequestHeader.Get("X-Token"))
assert.Equal(t, beszel.Version, options.RequestHeader.Get("X-Beszel"))
assert.Contains(t, options.RequestHeader.Get("User-Agent"), "Mozilla/5.0")
// Test options caching
options2 := client.getOptions()
assert.Same(t, options, options2, "Options should be cached")
})
}
}
// TestWebSocketClient_VerifySignature tests signature verification
func TestWebSocketClient_VerifySignature(t *testing.T) {
agent := createTestAgent(t)
// Generate test key pairs
_, goodPrivKey, err := ed25519.GenerateKey(nil)
require.NoError(t, err)
goodPubKey, err := ssh.NewPublicKey(goodPrivKey.Public().(ed25519.PublicKey))
require.NoError(t, err)
_, badPrivKey, err := ed25519.GenerateKey(nil)
require.NoError(t, err)
badPubKey, err := ssh.NewPublicKey(badPrivKey.Public().(ed25519.PublicKey))
require.NoError(t, err)
// Set up environment
os.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080")
os.Setenv("BESZEL_AGENT_TOKEN", "test-token")
defer func() {
os.Unsetenv("BESZEL_AGENT_HUB_URL")
os.Unsetenv("BESZEL_AGENT_TOKEN")
}()
client, err := newWebSocketClient(agent)
require.NoError(t, err)
testCases := []struct {
name string
keys []ssh.PublicKey
token string
signWith ed25519.PrivateKey
expectError bool
}{
{
name: "valid signature with correct key",
keys: []ssh.PublicKey{goodPubKey},
token: "test-token",
signWith: goodPrivKey,
expectError: false,
},
{
name: "invalid signature with wrong key",
keys: []ssh.PublicKey{goodPubKey},
token: "test-token",
signWith: badPrivKey,
expectError: true,
},
{
name: "valid signature with multiple keys",
keys: []ssh.PublicKey{badPubKey, goodPubKey},
token: "test-token",
signWith: goodPrivKey,
expectError: false,
},
{
name: "no valid keys",
keys: []ssh.PublicKey{badPubKey},
token: "test-token",
signWith: goodPrivKey,
expectError: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Set up agent with test keys
agent.keys = tc.keys
client.token = tc.token
// Create signature
signature := ed25519.Sign(tc.signWith, []byte(tc.token))
err := client.verifySignature(signature)
if tc.expectError {
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid signature")
} else {
assert.NoError(t, err)
}
})
}
}
// TestWebSocketClient_HandleHubRequest tests hub request routing (basic verification logic)
func TestWebSocketClient_HandleHubRequest(t *testing.T) {
agent := createTestAgent(t)
// Set up environment
os.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080")
os.Setenv("BESZEL_AGENT_TOKEN", "test-token")
defer func() {
os.Unsetenv("BESZEL_AGENT_HUB_URL")
os.Unsetenv("BESZEL_AGENT_TOKEN")
}()
client, err := newWebSocketClient(agent)
require.NoError(t, err)
testCases := []struct {
name string
action common.WebSocketAction
hubVerified bool
expectError bool
errorMsg string
}{
{
name: "CheckFingerprint without verification",
action: common.CheckFingerprint,
hubVerified: false,
expectError: false, // CheckFingerprint is allowed without verification
},
{
name: "GetData without verification",
action: common.GetData,
hubVerified: false,
expectError: true,
errorMsg: "hub not verified",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
client.hubVerified = tc.hubVerified
// Create minimal request
hubRequest := &common.HubRequest[cbor.RawMessage]{
Action: tc.action,
Data: cbor.RawMessage{},
}
err := client.handleHubRequest(hubRequest)
if tc.expectError {
assert.Error(t, err)
if tc.errorMsg != "" {
assert.Contains(t, err.Error(), tc.errorMsg)
}
} else {
// For CheckFingerprint, we expect a decode error since we're not providing valid data,
// but it shouldn't be the "hub not verified" error
if err != nil && tc.errorMsg != "" {
assert.NotContains(t, err.Error(), tc.errorMsg)
}
}
})
}
}
// TestWebSocketClient_GetUserAgent tests user agent generation
func TestGetUserAgent(t *testing.T) {
// Run multiple times to check both variants
userAgents := make(map[string]bool)
for range 20 {
ua := getUserAgent()
userAgents[ua] = true
// Check that it's a valid Mozilla user agent
assert.Contains(t, ua, "Mozilla/5.0")
assert.Contains(t, ua, "AppleWebKit/537.36")
assert.Contains(t, ua, "Chrome/124.0.0.0")
assert.Contains(t, ua, "Safari/537.36")
// Should contain either Windows or Mac
isWindows := strings.Contains(ua, "Windows NT 11.0")
isMac := strings.Contains(ua, "Macintosh; Intel Mac OS X 14_0_0")
assert.True(t, isWindows || isMac, "User agent should contain either Windows or Mac identifier")
}
// With enough iterations, we should see both variants
// though this might occasionally fail
if len(userAgents) == 1 {
t.Log("Note: Only one user agent variant was generated in this test run")
}
}
// TestWebSocketClient_Close tests connection closing
func TestWebSocketClient_Close(t *testing.T) {
agent := createTestAgent(t)
// Set up environment
os.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080")
os.Setenv("BESZEL_AGENT_TOKEN", "test-token")
defer func() {
os.Unsetenv("BESZEL_AGENT_HUB_URL")
os.Unsetenv("BESZEL_AGENT_TOKEN")
}()
client, err := newWebSocketClient(agent)
require.NoError(t, err)
// Test closing with nil connection (should not panic)
assert.NotPanics(t, func() {
client.Close()
})
}
// TestWebSocketClient_ConnectRateLimit tests connection rate limiting
func TestWebSocketClient_ConnectRateLimit(t *testing.T) {
agent := createTestAgent(t)
// Set up environment
os.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080")
os.Setenv("BESZEL_AGENT_TOKEN", "test-token")
defer func() {
os.Unsetenv("BESZEL_AGENT_HUB_URL")
os.Unsetenv("BESZEL_AGENT_TOKEN")
}()
client, err := newWebSocketClient(agent)
require.NoError(t, err)
// Set recent connection attempt
client.lastConnectAttempt = time.Now()
// Test that connection fails quickly due to rate limiting
// This won't actually connect but should fail fast
err = client.Connect()
assert.Error(t, err, "Connection should fail but not hang")
}
// TestGetToken tests the getToken function with various scenarios
func TestGetToken(t *testing.T) {
unsetEnvVars := func() {
os.Unsetenv("BESZEL_AGENT_TOKEN")
os.Unsetenv("TOKEN")
os.Unsetenv("BESZEL_AGENT_TOKEN_FILE")
os.Unsetenv("TOKEN_FILE")
}
t.Run("token from TOKEN environment variable", func(t *testing.T) {
unsetEnvVars()
// Set TOKEN env var
expectedToken := "test-token-from-env"
os.Setenv("TOKEN", expectedToken)
defer os.Unsetenv("TOKEN")
token, err := getToken()
assert.NoError(t, err)
assert.Equal(t, expectedToken, token)
})
t.Run("token from BESZEL_AGENT_TOKEN environment variable", func(t *testing.T) {
unsetEnvVars()
// Set BESZEL_AGENT_TOKEN env var (should take precedence)
expectedToken := "test-token-from-beszel-env"
os.Setenv("BESZEL_AGENT_TOKEN", expectedToken)
defer os.Unsetenv("BESZEL_AGENT_TOKEN")
token, err := getToken()
assert.NoError(t, err)
assert.Equal(t, expectedToken, token)
})
t.Run("token from TOKEN_FILE", func(t *testing.T) {
unsetEnvVars()
// Create a temporary token file
expectedToken := "test-token-from-file"
tokenFile, err := os.CreateTemp("", "token-test-*.txt")
require.NoError(t, err)
defer os.Remove(tokenFile.Name())
_, err = tokenFile.WriteString(expectedToken)
require.NoError(t, err)
tokenFile.Close()
// Set TOKEN_FILE env var
os.Setenv("TOKEN_FILE", tokenFile.Name())
defer os.Unsetenv("TOKEN_FILE")
token, err := getToken()
assert.NoError(t, err)
assert.Equal(t, expectedToken, token)
})
t.Run("token from BESZEL_AGENT_TOKEN_FILE", func(t *testing.T) {
unsetEnvVars()
// Create a temporary token file
expectedToken := "test-token-from-beszel-file"
tokenFile, err := os.CreateTemp("", "token-test-*.txt")
require.NoError(t, err)
defer os.Remove(tokenFile.Name())
_, err = tokenFile.WriteString(expectedToken)
require.NoError(t, err)
tokenFile.Close()
// Set BESZEL_AGENT_TOKEN_FILE env var (should take precedence)
os.Setenv("BESZEL_AGENT_TOKEN_FILE", tokenFile.Name())
defer os.Unsetenv("BESZEL_AGENT_TOKEN_FILE")
token, err := getToken()
assert.NoError(t, err)
assert.Equal(t, expectedToken, token)
})
t.Run("TOKEN takes precedence over TOKEN_FILE", func(t *testing.T) {
unsetEnvVars()
// Create a temporary token file
fileToken := "token-from-file"
tokenFile, err := os.CreateTemp("", "token-test-*.txt")
require.NoError(t, err)
defer os.Remove(tokenFile.Name())
_, err = tokenFile.WriteString(fileToken)
require.NoError(t, err)
tokenFile.Close()
// Set both TOKEN and TOKEN_FILE
envToken := "token-from-env"
os.Setenv("TOKEN", envToken)
os.Setenv("TOKEN_FILE", tokenFile.Name())
defer func() {
os.Unsetenv("TOKEN")
os.Unsetenv("TOKEN_FILE")
}()
token, err := getToken()
assert.NoError(t, err)
assert.Equal(t, envToken, token, "TOKEN should take precedence over TOKEN_FILE")
})
t.Run("error when neither TOKEN nor TOKEN_FILE is set", func(t *testing.T) {
unsetEnvVars()
token, err := getToken()
assert.Error(t, err)
assert.Equal(t, "", token)
assert.Contains(t, err.Error(), "must set TOKEN or TOKEN_FILE")
})
t.Run("error when TOKEN_FILE points to non-existent file", func(t *testing.T) {
unsetEnvVars()
// Set TOKEN_FILE to a non-existent file
os.Setenv("TOKEN_FILE", "/non/existent/file.txt")
defer os.Unsetenv("TOKEN_FILE")
token, err := getToken()
assert.Error(t, err)
assert.Equal(t, "", token)
assert.Contains(t, err.Error(), "no such file or directory")
})
t.Run("handles empty token file", func(t *testing.T) {
unsetEnvVars()
// Create an empty token file
tokenFile, err := os.CreateTemp("", "token-test-*.txt")
require.NoError(t, err)
defer os.Remove(tokenFile.Name())
tokenFile.Close()
// Set TOKEN_FILE env var
os.Setenv("TOKEN_FILE", tokenFile.Name())
defer os.Unsetenv("TOKEN_FILE")
token, err := getToken()
assert.NoError(t, err)
assert.Equal(t, "", token, "Empty file should return empty string")
})
t.Run("strips whitespace from TOKEN_FILE", func(t *testing.T) {
unsetEnvVars()
tokenWithWhitespace := " test-token-with-whitespace \n\t"
expectedToken := "test-token-with-whitespace"
tokenFile, err := os.CreateTemp("", "token-test-*.txt")
require.NoError(t, err)
defer os.Remove(tokenFile.Name())
_, err = tokenFile.WriteString(tokenWithWhitespace)
require.NoError(t, err)
tokenFile.Close()
os.Setenv("TOKEN_FILE", tokenFile.Name())
defer os.Unsetenv("TOKEN_FILE")
token, err := getToken()
assert.NoError(t, err)
assert.Equal(t, expectedToken, token, "Whitespace should be stripped from token file content")
})
}

221
agent/connection_manager.go Normal file
View File

@@ -0,0 +1,221 @@
package agent
import (
"errors"
"log/slog"
"os"
"os/signal"
"syscall"
"time"
"github.com/henrygd/beszel/agent/health"
)
// ConnectionManager manages the connection state and events for the agent.
// It handles both WebSocket and SSH connections, automatically switching between
// them based on availability and managing reconnection attempts.
type ConnectionManager struct {
agent *Agent // Reference to the parent agent
State ConnectionState // Current connection state
eventChan chan ConnectionEvent // Channel for connection events
wsClient *WebSocketClient // WebSocket client for hub communication
serverOptions ServerOptions // Configuration for SSH server
wsTicker *time.Ticker // Ticker for WebSocket connection attempts
isConnecting bool // Prevents multiple simultaneous reconnection attempts
}
// ConnectionState represents the current connection state of the agent.
type ConnectionState uint8
// ConnectionEvent represents connection-related events that can occur.
type ConnectionEvent uint8
// Connection states
const (
Disconnected ConnectionState = iota // No active connection
WebSocketConnected // Connected via WebSocket
SSHConnected // Connected via SSH
)
// Connection events
const (
WebSocketConnect ConnectionEvent = iota // WebSocket connection established
WebSocketDisconnect // WebSocket connection lost
SSHConnect // SSH connection established
SSHDisconnect // SSH connection lost
)
const wsTickerInterval = 10 * time.Second
// newConnectionManager creates a new connection manager for the given agent.
func newConnectionManager(agent *Agent) *ConnectionManager {
cm := &ConnectionManager{
agent: agent,
State: Disconnected,
}
return cm
}
// startWsTicker starts or resets the WebSocket connection attempt ticker.
func (c *ConnectionManager) startWsTicker() {
if c.wsTicker == nil {
c.wsTicker = time.NewTicker(wsTickerInterval)
} else {
c.wsTicker.Reset(wsTickerInterval)
}
}
// stopWsTicker stops the WebSocket connection attempt ticker.
func (c *ConnectionManager) stopWsTicker() {
if c.wsTicker != nil {
c.wsTicker.Stop()
}
}
// Start begins connection attempts and enters the main event loop.
// It handles connection events, periodic health updates, and graceful shutdown.
func (c *ConnectionManager) Start(serverOptions ServerOptions) error {
if c.eventChan != nil {
return errors.New("already started")
}
wsClient, err := newWebSocketClient(c.agent)
if err != nil {
slog.Warn("Error creating WebSocket client", "err", err)
}
c.wsClient = wsClient
c.serverOptions = serverOptions
c.eventChan = make(chan ConnectionEvent, 1)
// signal handling for shutdown
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
c.startWsTicker()
c.connect()
// update health status immediately and every 90 seconds
_ = health.Update()
healthTicker := time.Tick(90 * time.Second)
for {
select {
case connectionEvent := <-c.eventChan:
c.handleEvent(connectionEvent)
case <-c.wsTicker.C:
_ = c.startWebSocketConnection()
case <-healthTicker:
_ = health.Update()
case <-sigChan:
slog.Info("Shutting down")
_ = c.agent.StopServer()
c.closeWebSocket()
return health.CleanUp()
}
}
}
// handleEvent processes connection events and updates the connection state accordingly.
func (c *ConnectionManager) handleEvent(event ConnectionEvent) {
switch event {
case WebSocketConnect:
c.handleStateChange(WebSocketConnected)
case SSHConnect:
c.handleStateChange(SSHConnected)
case WebSocketDisconnect:
if c.State == WebSocketConnected {
c.handleStateChange(Disconnected)
}
case SSHDisconnect:
if c.State == SSHConnected {
c.handleStateChange(Disconnected)
}
}
}
// handleStateChange updates the connection state and performs necessary actions
// based on the new state, including stopping services and initiating reconnections.
func (c *ConnectionManager) handleStateChange(newState ConnectionState) {
if c.State == newState {
return
}
c.State = newState
switch newState {
case WebSocketConnected:
slog.Info("WebSocket connected", "host", c.wsClient.hubURL.Host)
c.stopWsTicker()
_ = c.agent.StopServer()
c.isConnecting = false
case SSHConnected:
// stop new ws connection attempts
slog.Info("SSH connection established")
c.stopWsTicker()
c.isConnecting = false
case Disconnected:
if c.isConnecting {
// Already handling reconnection, avoid duplicate attempts
return
}
c.isConnecting = true
slog.Warn("Disconnected from hub")
// make sure old ws connection is closed
c.closeWebSocket()
// reconnect
go c.connect()
}
}
// connect handles the connection logic with proper delays and priority.
// It attempts WebSocket connection first, falling back to SSH server if needed.
func (c *ConnectionManager) connect() {
c.isConnecting = true
defer func() {
c.isConnecting = false
}()
if c.wsClient != nil && time.Since(c.wsClient.lastConnectAttempt) < 5*time.Second {
time.Sleep(5 * time.Second)
}
// Try WebSocket first, if it fails, start SSH server
err := c.startWebSocketConnection()
if err != nil && c.State == Disconnected {
c.startSSHServer()
c.startWsTicker()
}
}
// startWebSocketConnection attempts to establish a WebSocket connection to the hub.
func (c *ConnectionManager) startWebSocketConnection() error {
if c.State != Disconnected {
return errors.New("already connected")
}
if c.wsClient == nil {
return errors.New("WebSocket client not initialized")
}
if time.Since(c.wsClient.lastConnectAttempt) < 5*time.Second {
return errors.New("already connecting")
}
err := c.wsClient.Connect()
if err != nil {
slog.Warn("WebSocket connection failed", "err", err)
c.closeWebSocket()
}
return err
}
// startSSHServer starts the SSH server if the agent is currently disconnected.
func (c *ConnectionManager) startSSHServer() {
if c.State == Disconnected {
go c.agent.StartServer(c.serverOptions)
}
}
// closeWebSocket closes the WebSocket connection if it exists.
func (c *ConnectionManager) closeWebSocket() {
if c.wsClient != nil {
c.wsClient.Close()
}
}

View File

@@ -0,0 +1,315 @@
//go:build testing
// +build testing
package agent
import (
"crypto/ed25519"
"fmt"
"net"
"net/url"
"os"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/ssh"
)
func createTestAgent(t *testing.T) *Agent {
dataDir := t.TempDir()
agent, err := NewAgent(dataDir)
require.NoError(t, err)
return agent
}
func createTestServerOptions(t *testing.T) ServerOptions {
// Generate test key pair
_, privKey, err := ed25519.GenerateKey(nil)
require.NoError(t, err)
sshPubKey, err := ssh.NewPublicKey(privKey.Public().(ed25519.PublicKey))
require.NoError(t, err)
// Find available port
listener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
port := listener.Addr().(*net.TCPAddr).Port
listener.Close()
return ServerOptions{
Network: "tcp",
Addr: fmt.Sprintf("127.0.0.1:%d", port),
Keys: []ssh.PublicKey{sshPubKey},
}
}
// TestConnectionManager_NewConnectionManager tests connection manager creation
func TestConnectionManager_NewConnectionManager(t *testing.T) {
agent := createTestAgent(t)
cm := newConnectionManager(agent)
assert.NotNil(t, cm, "Connection manager should not be nil")
assert.Equal(t, agent, cm.agent, "Agent reference should be set")
assert.Equal(t, Disconnected, cm.State, "Initial state should be Disconnected")
assert.Nil(t, cm.eventChan, "Event channel should be nil initially")
assert.Nil(t, cm.wsClient, "WebSocket client should be nil initially")
assert.Nil(t, cm.wsTicker, "WebSocket ticker should be nil initially")
assert.False(t, cm.isConnecting, "isConnecting should be false initially")
}
// TestConnectionManager_StateTransitions tests basic state transitions
func TestConnectionManager_StateTransitions(t *testing.T) {
agent := createTestAgent(t)
cm := agent.connectionManager
initialState := cm.State
cm.wsClient = &WebSocketClient{
hubURL: &url.URL{
Host: "localhost:8080",
},
}
assert.NotNil(t, cm, "Connection manager should not be nil")
assert.Equal(t, Disconnected, initialState, "Initial state should be Disconnected")
// Test state transitions
cm.handleStateChange(WebSocketConnected)
assert.Equal(t, WebSocketConnected, cm.State, "State should change to WebSocketConnected")
cm.handleStateChange(SSHConnected)
assert.Equal(t, SSHConnected, cm.State, "State should change to SSHConnected")
cm.handleStateChange(Disconnected)
assert.Equal(t, Disconnected, cm.State, "State should change to Disconnected")
// Test that same state doesn't trigger changes
cm.State = WebSocketConnected
cm.handleStateChange(WebSocketConnected)
assert.Equal(t, WebSocketConnected, cm.State, "Same state should not trigger change")
}
// TestConnectionManager_EventHandling tests event handling logic
func TestConnectionManager_EventHandling(t *testing.T) {
agent := createTestAgent(t)
cm := agent.connectionManager
cm.wsClient = &WebSocketClient{
hubURL: &url.URL{
Host: "localhost:8080",
},
}
testCases := []struct {
name string
initialState ConnectionState
event ConnectionEvent
expectedState ConnectionState
}{
{
name: "WebSocket connect from disconnected",
initialState: Disconnected,
event: WebSocketConnect,
expectedState: WebSocketConnected,
},
{
name: "SSH connect from disconnected",
initialState: Disconnected,
event: SSHConnect,
expectedState: SSHConnected,
},
{
name: "WebSocket disconnect from connected",
initialState: WebSocketConnected,
event: WebSocketDisconnect,
expectedState: Disconnected,
},
{
name: "SSH disconnect from connected",
initialState: SSHConnected,
event: SSHDisconnect,
expectedState: Disconnected,
},
{
name: "WebSocket disconnect from SSH connected (no change)",
initialState: SSHConnected,
event: WebSocketDisconnect,
expectedState: SSHConnected,
},
{
name: "SSH disconnect from WebSocket connected (no change)",
initialState: WebSocketConnected,
event: SSHDisconnect,
expectedState: WebSocketConnected,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
cm.State = tc.initialState
cm.handleEvent(tc.event)
assert.Equal(t, tc.expectedState, cm.State, "State should match expected after event")
})
}
}
// TestConnectionManager_TickerManagement tests WebSocket ticker management
func TestConnectionManager_TickerManagement(t *testing.T) {
agent := createTestAgent(t)
cm := agent.connectionManager
// Test starting ticker
cm.startWsTicker()
assert.NotNil(t, cm.wsTicker, "Ticker should be created")
// Test stopping ticker (should not panic)
assert.NotPanics(t, func() {
cm.stopWsTicker()
}, "Stopping ticker should not panic")
// Test stopping nil ticker (should not panic)
cm.wsTicker = nil
assert.NotPanics(t, func() {
cm.stopWsTicker()
}, "Stopping nil ticker should not panic")
// Test restarting ticker
cm.startWsTicker()
assert.NotNil(t, cm.wsTicker, "Ticker should be recreated")
// Test resetting existing ticker
firstTicker := cm.wsTicker
cm.startWsTicker()
assert.Equal(t, firstTicker, cm.wsTicker, "Same ticker instance should be reused")
cm.stopWsTicker()
}
// TestConnectionManager_WebSocketConnectionFlow tests WebSocket connection logic
func TestConnectionManager_WebSocketConnectionFlow(t *testing.T) {
if testing.Short() {
t.Skip("Skipping WebSocket connection test in short mode")
}
agent := createTestAgent(t)
cm := agent.connectionManager
// Test WebSocket connection without proper environment
err := cm.startWebSocketConnection()
assert.Error(t, err, "WebSocket connection should fail without proper environment")
assert.Equal(t, Disconnected, cm.State, "State should remain Disconnected after failed connection")
// Test with invalid URL
os.Setenv("BESZEL_AGENT_HUB_URL", "invalid-url")
os.Setenv("BESZEL_AGENT_TOKEN", "test-token")
defer func() {
os.Unsetenv("BESZEL_AGENT_HUB_URL")
os.Unsetenv("BESZEL_AGENT_TOKEN")
}()
// Test with missing token
os.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080")
os.Unsetenv("BESZEL_AGENT_TOKEN")
_, err2 := newWebSocketClient(agent)
assert.Error(t, err2, "WebSocket client creation should fail without token")
}
// TestConnectionManager_ReconnectionLogic tests reconnection prevention logic
func TestConnectionManager_ReconnectionLogic(t *testing.T) {
agent := createTestAgent(t)
cm := agent.connectionManager
cm.eventChan = make(chan ConnectionEvent, 1)
// Test that isConnecting flag prevents duplicate reconnection attempts
// Start from connected state, then simulate disconnect
cm.State = WebSocketConnected
cm.isConnecting = false
// First disconnect should trigger reconnection logic
cm.handleStateChange(Disconnected)
assert.Equal(t, Disconnected, cm.State, "Should change to disconnected")
assert.True(t, cm.isConnecting, "Should set isConnecting flag")
}
// TestConnectionManager_ConnectWithRateLimit tests connection rate limiting
func TestConnectionManager_ConnectWithRateLimit(t *testing.T) {
agent := createTestAgent(t)
cm := agent.connectionManager
// Set up environment for WebSocket client creation
os.Setenv("BESZEL_AGENT_HUB_URL", "ws://localhost:8080")
os.Setenv("BESZEL_AGENT_TOKEN", "test-token")
defer func() {
os.Unsetenv("BESZEL_AGENT_HUB_URL")
os.Unsetenv("BESZEL_AGENT_TOKEN")
}()
// Create WebSocket client
wsClient, err := newWebSocketClient(agent)
require.NoError(t, err)
cm.wsClient = wsClient
// Set recent connection attempt
cm.wsClient.lastConnectAttempt = time.Now()
// Test that connection is rate limited
err = cm.startWebSocketConnection()
assert.Error(t, err, "Should error due to rate limiting")
assert.Contains(t, err.Error(), "already connecting", "Error should indicate rate limiting")
// Test connection after rate limit expires
cm.wsClient.lastConnectAttempt = time.Now().Add(-10 * time.Second)
err = cm.startWebSocketConnection()
// This will fail due to no actual server, but should not be rate limited
assert.Error(t, err, "Connection should fail but not due to rate limiting")
assert.NotContains(t, err.Error(), "already connecting", "Error should not indicate rate limiting")
}
// TestConnectionManager_StartWithInvalidConfig tests starting with invalid configuration
func TestConnectionManager_StartWithInvalidConfig(t *testing.T) {
agent := createTestAgent(t)
cm := agent.connectionManager
serverOptions := createTestServerOptions(t)
// Test starting when already started
cm.eventChan = make(chan ConnectionEvent, 5)
err := cm.Start(serverOptions)
assert.Error(t, err, "Should error when starting already started connection manager")
}
// TestConnectionManager_CloseWebSocket tests WebSocket closing
func TestConnectionManager_CloseWebSocket(t *testing.T) {
agent := createTestAgent(t)
cm := agent.connectionManager
// Test closing when no WebSocket client exists
assert.NotPanics(t, func() {
cm.closeWebSocket()
}, "Should not panic when closing nil WebSocket client")
// Set up environment and create WebSocket client
os.Setenv("BESZEL_AGENT_HUB_URL", "ws://localhost:8080")
os.Setenv("BESZEL_AGENT_TOKEN", "test-token")
defer func() {
os.Unsetenv("BESZEL_AGENT_HUB_URL")
os.Unsetenv("BESZEL_AGENT_TOKEN")
}()
wsClient, err := newWebSocketClient(agent)
require.NoError(t, err)
cm.wsClient = wsClient
// Test closing when WebSocket client exists
assert.NotPanics(t, func() {
cm.closeWebSocket()
}, "Should not panic when closing WebSocket client")
}
// TestConnectionManager_ConnectFlow tests the connect method
func TestConnectionManager_ConnectFlow(t *testing.T) {
agent := createTestAgent(t)
cm := agent.connectionManager
// Test connect without WebSocket client
assert.NotPanics(t, func() {
cm.connect()
}, "Connect should not panic without WebSocket client")
}

117
agent/data_dir.go Normal file
View File

@@ -0,0 +1,117 @@
package agent
import (
"errors"
"fmt"
"os"
"path/filepath"
"runtime"
)
// getDataDir returns the path to the data directory for the agent and an error
// if the directory is not valid. Attempts to find the optimal data directory if
// no data directories are provided.
func getDataDir(dataDirs ...string) (string, error) {
if len(dataDirs) > 0 {
return testDataDirs(dataDirs)
}
dataDir, _ := GetEnv("DATA_DIR")
if dataDir != "" {
dataDirs = append(dataDirs, dataDir)
}
if runtime.GOOS == "windows" {
dataDirs = append(dataDirs,
filepath.Join(os.Getenv("APPDATA"), "beszel-agent"),
filepath.Join(os.Getenv("LOCALAPPDATA"), "beszel-agent"),
)
} else {
dataDirs = append(dataDirs, "/var/lib/beszel-agent")
if homeDir, err := os.UserHomeDir(); err == nil {
dataDirs = append(dataDirs, filepath.Join(homeDir, ".config", "beszel"))
}
}
return testDataDirs(dataDirs)
}
func testDataDirs(paths []string) (string, error) {
// first check if the directory exists and is writable
for _, path := range paths {
if valid, _ := isValidDataDir(path, false); valid {
return path, nil
}
}
// if the directory doesn't exist, try to create it
for _, path := range paths {
exists, _ := directoryExists(path)
if exists {
continue
}
if err := os.MkdirAll(path, 0755); err != nil {
continue
}
// Verify the created directory is actually writable
writable, _ := directoryIsWritable(path)
if !writable {
continue
}
return path, nil
}
return "", errors.New("data directory not found")
}
func isValidDataDir(path string, createIfNotExists bool) (bool, error) {
exists, err := directoryExists(path)
if err != nil {
return false, err
}
if !exists {
if !createIfNotExists {
return false, nil
}
if err = os.MkdirAll(path, 0755); err != nil {
return false, err
}
}
// Always check if the directory is writable
writable, err := directoryIsWritable(path)
if err != nil {
return false, err
}
return writable, nil
}
// directoryExists checks if a directory exists
func directoryExists(path string) (bool, error) {
// Check if directory exists
stat, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
if !stat.IsDir() {
return false, fmt.Errorf("%s is not a directory", path)
}
return true, nil
}
// directoryIsWritable tests if a directory is writable by creating and removing a temporary file
func directoryIsWritable(path string) (bool, error) {
testFile := filepath.Join(path, ".write-test")
file, err := os.Create(testFile)
if err != nil {
return false, err
}
defer file.Close()
defer os.Remove(testFile)
return true, nil
}

263
agent/data_dir_test.go Normal file
View File

@@ -0,0 +1,263 @@
//go:build testing
// +build testing
package agent
import (
"os"
"path/filepath"
"runtime"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGetDataDir(t *testing.T) {
// Test with explicit dataDir parameter
t.Run("explicit data dir", func(t *testing.T) {
tempDir := t.TempDir()
result, err := getDataDir(tempDir)
require.NoError(t, err)
assert.Equal(t, tempDir, result)
})
// Test with explicit non-existent dataDir that can be created
t.Run("explicit data dir - create new", func(t *testing.T) {
tempDir := t.TempDir()
newDir := filepath.Join(tempDir, "new-data-dir")
result, err := getDataDir(newDir)
require.NoError(t, err)
assert.Equal(t, newDir, result)
// Verify directory was created
stat, err := os.Stat(newDir)
require.NoError(t, err)
assert.True(t, stat.IsDir())
})
// Test with DATA_DIR environment variable
t.Run("DATA_DIR environment variable", func(t *testing.T) {
tempDir := t.TempDir()
// Set environment variable
oldValue := os.Getenv("DATA_DIR")
defer func() {
if oldValue == "" {
os.Unsetenv("BESZEL_AGENT_DATA_DIR")
} else {
os.Setenv("BESZEL_AGENT_DATA_DIR", oldValue)
}
}()
os.Setenv("BESZEL_AGENT_DATA_DIR", tempDir)
result, err := getDataDir()
require.NoError(t, err)
assert.Equal(t, tempDir, result)
})
// Test with invalid explicit dataDir
t.Run("invalid explicit data dir", func(t *testing.T) {
invalidPath := "/invalid/path/that/cannot/be/created"
_, err := getDataDir(invalidPath)
assert.Error(t, err)
})
// Test fallback behavior (empty dataDir, no env var)
t.Run("fallback to default directories", func(t *testing.T) {
// Clear DATA_DIR environment variable
oldValue := os.Getenv("DATA_DIR")
defer func() {
if oldValue == "" {
os.Unsetenv("DATA_DIR")
} else {
os.Setenv("DATA_DIR", oldValue)
}
}()
os.Unsetenv("DATA_DIR")
// This will try platform-specific defaults, which may or may not work
// We're mainly testing that it doesn't panic and returns some result
result, err := getDataDir()
// We don't assert success/failure here since it depends on system permissions
// Just verify we get a string result if no error
if err == nil {
assert.NotEmpty(t, result)
}
})
}
func TestTestDataDirs(t *testing.T) {
// Test with existing valid directory
t.Run("existing valid directory", func(t *testing.T) {
tempDir := t.TempDir()
result, err := testDataDirs([]string{tempDir})
require.NoError(t, err)
assert.Equal(t, tempDir, result)
})
// Test with multiple directories, first one valid
t.Run("multiple dirs - first valid", func(t *testing.T) {
tempDir := t.TempDir()
invalidDir := "/invalid/path"
result, err := testDataDirs([]string{tempDir, invalidDir})
require.NoError(t, err)
assert.Equal(t, tempDir, result)
})
// Test with multiple directories, second one valid
t.Run("multiple dirs - second valid", func(t *testing.T) {
tempDir := t.TempDir()
invalidDir := "/invalid/path"
result, err := testDataDirs([]string{invalidDir, tempDir})
require.NoError(t, err)
assert.Equal(t, tempDir, result)
})
// Test with non-existing directory that can be created
t.Run("create new directory", func(t *testing.T) {
tempDir := t.TempDir()
newDir := filepath.Join(tempDir, "new-dir")
result, err := testDataDirs([]string{newDir})
require.NoError(t, err)
assert.Equal(t, newDir, result)
// Verify directory was created
stat, err := os.Stat(newDir)
require.NoError(t, err)
assert.True(t, stat.IsDir())
})
// Test with no valid directories
t.Run("no valid directories", func(t *testing.T) {
invalidPaths := []string{"/invalid/path1", "/invalid/path2"}
_, err := testDataDirs(invalidPaths)
assert.Error(t, err)
assert.Contains(t, err.Error(), "data directory not found")
})
}
func TestIsValidDataDir(t *testing.T) {
// Test with existing directory
t.Run("existing directory", func(t *testing.T) {
tempDir := t.TempDir()
valid, err := isValidDataDir(tempDir, false)
require.NoError(t, err)
assert.True(t, valid)
})
// Test with non-existing directory, createIfNotExists=false
t.Run("non-existing dir - no create", func(t *testing.T) {
tempDir := t.TempDir()
nonExistentDir := filepath.Join(tempDir, "does-not-exist")
valid, err := isValidDataDir(nonExistentDir, false)
require.NoError(t, err)
assert.False(t, valid)
})
// Test with non-existing directory, createIfNotExists=true
t.Run("non-existing dir - create", func(t *testing.T) {
tempDir := t.TempDir()
newDir := filepath.Join(tempDir, "new-dir")
valid, err := isValidDataDir(newDir, true)
require.NoError(t, err)
assert.True(t, valid)
// Verify directory was created
stat, err := os.Stat(newDir)
require.NoError(t, err)
assert.True(t, stat.IsDir())
})
// Test with file instead of directory
t.Run("file instead of directory", func(t *testing.T) {
tempDir := t.TempDir()
tempFile := filepath.Join(tempDir, "testfile")
err := os.WriteFile(tempFile, []byte("test"), 0644)
require.NoError(t, err)
valid, err := isValidDataDir(tempFile, false)
assert.Error(t, err)
assert.False(t, valid)
assert.Contains(t, err.Error(), "is not a directory")
})
}
func TestDirectoryExists(t *testing.T) {
// Test with existing directory
t.Run("existing directory", func(t *testing.T) {
tempDir := t.TempDir()
exists, err := directoryExists(tempDir)
require.NoError(t, err)
assert.True(t, exists)
})
// Test with non-existing directory
t.Run("non-existing directory", func(t *testing.T) {
tempDir := t.TempDir()
nonExistentDir := filepath.Join(tempDir, "does-not-exist")
exists, err := directoryExists(nonExistentDir)
require.NoError(t, err)
assert.False(t, exists)
})
// Test with file instead of directory
t.Run("file instead of directory", func(t *testing.T) {
tempDir := t.TempDir()
tempFile := filepath.Join(tempDir, "testfile")
err := os.WriteFile(tempFile, []byte("test"), 0644)
require.NoError(t, err)
exists, err := directoryExists(tempFile)
assert.Error(t, err)
assert.False(t, exists)
assert.Contains(t, err.Error(), "is not a directory")
})
}
func TestDirectoryIsWritable(t *testing.T) {
// Test with writable directory
t.Run("writable directory", func(t *testing.T) {
tempDir := t.TempDir()
writable, err := directoryIsWritable(tempDir)
require.NoError(t, err)
assert.True(t, writable)
})
// Test with non-existing directory
t.Run("non-existing directory", func(t *testing.T) {
tempDir := t.TempDir()
nonExistentDir := filepath.Join(tempDir, "does-not-exist")
writable, err := directoryIsWritable(nonExistentDir)
assert.Error(t, err)
assert.False(t, writable)
})
// Test with non-writable directory (Unix-like systems only)
t.Run("non-writable directory", func(t *testing.T) {
if runtime.GOOS != "linux" && runtime.GOOS != "darwin" {
t.Skip("Skipping non-writable directory test on", runtime.GOOS)
}
tempDir := t.TempDir()
readOnlyDir := filepath.Join(tempDir, "readonly")
// Create the directory
err := os.Mkdir(readOnlyDir, 0755)
require.NoError(t, err)
// Make it read-only
err = os.Chmod(readOnlyDir, 0444)
require.NoError(t, err)
// Restore permissions after test for cleanup
defer func() {
os.Chmod(readOnlyDir, 0755)
}()
writable, err := directoryIsWritable(readOnlyDir)
assert.Error(t, err)
assert.False(t, writable)
})
}

View File

@@ -0,0 +1,81 @@
// Package deltatracker provides a tracker for calculating differences in numeric values over time.
package deltatracker
import (
"sync"
"golang.org/x/exp/constraints"
)
// Numeric is a constraint that permits any integer or floating-point type.
type Numeric interface {
constraints.Integer | constraints.Float
}
// DeltaTracker is a generic, thread-safe tracker for calculating differences
// in numeric values over time.
// K is the key type (e.g., int, string).
// V is the value type (e.g., int, int64, float32, float64).
type DeltaTracker[K comparable, V Numeric] struct {
sync.RWMutex
current map[K]V
previous map[K]V
}
// NewDeltaTracker creates a new generic tracker.
func NewDeltaTracker[K comparable, V Numeric]() *DeltaTracker[K, V] {
return &DeltaTracker[K, V]{
current: make(map[K]V),
previous: make(map[K]V),
}
}
// Set records the current value for a given ID.
func (t *DeltaTracker[K, V]) Set(id K, value V) {
t.Lock()
defer t.Unlock()
t.current[id] = value
}
// Deltas returns a map of all calculated deltas for the current interval.
func (t *DeltaTracker[K, V]) Deltas() map[K]V {
t.RLock()
defer t.RUnlock()
deltas := make(map[K]V)
for id, currentVal := range t.current {
if previousVal, ok := t.previous[id]; ok {
deltas[id] = currentVal - previousVal
} else {
deltas[id] = 0
}
}
return deltas
}
// Delta returns the delta for a single key.
// Returns 0 if the key doesn't exist or has no previous value.
func (t *DeltaTracker[K, V]) Delta(id K) V {
t.RLock()
defer t.RUnlock()
currentVal, currentOk := t.current[id]
if !currentOk {
return 0
}
previousVal, previousOk := t.previous[id]
if !previousOk {
return 0
}
return currentVal - previousVal
}
// Cycle prepares the tracker for the next interval.
func (t *DeltaTracker[K, V]) Cycle() {
t.Lock()
defer t.Unlock()
t.previous = t.current
t.current = make(map[K]V)
}

View File

@@ -0,0 +1,217 @@
package deltatracker
import (
"fmt"
"testing"
"github.com/stretchr/testify/assert"
)
func ExampleDeltaTracker() {
tracker := NewDeltaTracker[string, int]()
tracker.Set("key1", 10)
tracker.Set("key2", 20)
tracker.Cycle()
tracker.Set("key1", 15)
tracker.Set("key2", 30)
fmt.Println(tracker.Delta("key1"))
fmt.Println(tracker.Delta("key2"))
fmt.Println(tracker.Deltas())
// Output: 5
// 10
// map[key1:5 key2:10]
}
func TestNewDeltaTracker(t *testing.T) {
tracker := NewDeltaTracker[string, int]()
assert.NotNil(t, tracker)
assert.Empty(t, tracker.current)
assert.Empty(t, tracker.previous)
}
func TestSet(t *testing.T) {
tracker := NewDeltaTracker[string, int]()
tracker.Set("key1", 10)
tracker.RLock()
defer tracker.RUnlock()
assert.Equal(t, 10, tracker.current["key1"])
}
func TestDeltas(t *testing.T) {
tracker := NewDeltaTracker[string, int]()
// Test with no previous values
tracker.Set("key1", 10)
tracker.Set("key2", 20)
deltas := tracker.Deltas()
assert.Equal(t, 0, deltas["key1"])
assert.Equal(t, 0, deltas["key2"])
// Cycle to move current to previous
tracker.Cycle()
// Set new values and check deltas
tracker.Set("key1", 15) // Delta should be 5 (15-10)
tracker.Set("key2", 25) // Delta should be 5 (25-20)
tracker.Set("key3", 30) // New key, delta should be 0
deltas = tracker.Deltas()
assert.Equal(t, 5, deltas["key1"])
assert.Equal(t, 5, deltas["key2"])
assert.Equal(t, 0, deltas["key3"])
}
func TestCycle(t *testing.T) {
tracker := NewDeltaTracker[string, int]()
tracker.Set("key1", 10)
tracker.Set("key2", 20)
// Verify current has values
tracker.RLock()
assert.Equal(t, 10, tracker.current["key1"])
assert.Equal(t, 20, tracker.current["key2"])
assert.Empty(t, tracker.previous)
tracker.RUnlock()
tracker.Cycle()
// After cycle, previous should have the old current values
// and current should be empty
tracker.RLock()
assert.Empty(t, tracker.current)
assert.Equal(t, 10, tracker.previous["key1"])
assert.Equal(t, 20, tracker.previous["key2"])
tracker.RUnlock()
}
func TestCompleteWorkflow(t *testing.T) {
tracker := NewDeltaTracker[string, int]()
// First interval
tracker.Set("server1", 100)
tracker.Set("server2", 200)
// Get deltas for first interval (should be zero)
firstDeltas := tracker.Deltas()
assert.Equal(t, 0, firstDeltas["server1"])
assert.Equal(t, 0, firstDeltas["server2"])
// Cycle to next interval
tracker.Cycle()
// Second interval
tracker.Set("server1", 150) // Delta: 50
tracker.Set("server2", 180) // Delta: -20
tracker.Set("server3", 300) // New server, delta: 300
secondDeltas := tracker.Deltas()
assert.Equal(t, 50, secondDeltas["server1"])
assert.Equal(t, -20, secondDeltas["server2"])
assert.Equal(t, 0, secondDeltas["server3"])
}
func TestDeltaTrackerWithDifferentTypes(t *testing.T) {
// Test with int64
intTracker := NewDeltaTracker[string, int64]()
intTracker.Set("pid1", 1000)
intTracker.Cycle()
intTracker.Set("pid1", 1200)
intDeltas := intTracker.Deltas()
assert.Equal(t, int64(200), intDeltas["pid1"])
// Test with float64
floatTracker := NewDeltaTracker[string, float64]()
floatTracker.Set("cpu1", 1.5)
floatTracker.Cycle()
floatTracker.Set("cpu1", 2.7)
floatDeltas := floatTracker.Deltas()
assert.InDelta(t, 1.2, floatDeltas["cpu1"], 0.0001)
// Test with int keys
pidTracker := NewDeltaTracker[int, int64]()
pidTracker.Set(101, 20000)
pidTracker.Cycle()
pidTracker.Set(101, 22500)
pidDeltas := pidTracker.Deltas()
assert.Equal(t, int64(2500), pidDeltas[101])
}
func TestDelta(t *testing.T) {
tracker := NewDeltaTracker[string, int]()
// Test getting delta for non-existent key
result := tracker.Delta("nonexistent")
assert.Equal(t, 0, result)
// Test getting delta for key with no previous value
tracker.Set("key1", 10)
result = tracker.Delta("key1")
assert.Equal(t, 0, result)
// Cycle to move current to previous
tracker.Cycle()
// Test getting delta for key with previous value
tracker.Set("key1", 15)
result = tracker.Delta("key1")
assert.Equal(t, 5, result)
// Test getting delta for key that exists in previous but not current
result = tracker.Delta("key1")
assert.Equal(t, 5, result) // Should still return 5
// Test getting delta for key that exists in current but not previous
tracker.Set("key2", 20)
result = tracker.Delta("key2")
assert.Equal(t, 0, result)
}
func TestDeltaWithDifferentTypes(t *testing.T) {
// Test with int64
intTracker := NewDeltaTracker[string, int64]()
intTracker.Set("pid1", 1000)
intTracker.Cycle()
intTracker.Set("pid1", 1200)
result := intTracker.Delta("pid1")
assert.Equal(t, int64(200), result)
// Test with float64
floatTracker := NewDeltaTracker[string, float64]()
floatTracker.Set("cpu1", 1.5)
floatTracker.Cycle()
floatTracker.Set("cpu1", 2.7)
floatResult := floatTracker.Delta("cpu1")
assert.InDelta(t, 1.2, floatResult, 0.0001)
// Test with int keys
pidTracker := NewDeltaTracker[int, int64]()
pidTracker.Set(101, 20000)
pidTracker.Cycle()
pidTracker.Set(101, 22500)
pidResult := pidTracker.Delta(101)
assert.Equal(t, int64(2500), pidResult)
}
func TestDeltaConcurrentAccess(t *testing.T) {
tracker := NewDeltaTracker[string, int]()
// Set initial values
tracker.Set("key1", 10)
tracker.Set("key2", 20)
tracker.Cycle()
// Set new values
tracker.Set("key1", 15)
tracker.Set("key2", 25)
// Test concurrent access safety
result1 := tracker.Delta("key1")
result2 := tracker.Delta("key2")
assert.Equal(t, 5, result1)
assert.Equal(t, 5, result2)
}

View File

@@ -1,20 +1,21 @@
package agent
import (
"beszel/internal/entities/system"
"log/slog"
"time"
"os"
"path/filepath"
"runtime"
"strings"
"time"
"github.com/henrygd/beszel/internal/entities/system"
"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")
filesystem, _ := GetEnv("FILESYSTEM")
efPath := "/extra-filesystems"
hasRoot := false
@@ -37,7 +38,12 @@ func (a *Agent) initializeDiskInfo() {
// 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 key string
if runtime.GOOS == "windows" {
key = device
} else {
key = filepath.Base(device)
}
var ioMatch bool
if _, exists := a.fsStats[key]; !exists {
if root {
@@ -79,7 +85,7 @@ func (a *Agent) initializeDiskInfo() {
}
// Add EXTRA_FILESYSTEMS env var values to fsStats
if extraFilesystems, exists := os.LookupEnv("EXTRA_FILESYSTEMS"); exists {
if extraFilesystems, exists := GetEnv("EXTRA_FILESYSTEMS"); exists {
for _, fs := range strings.Split(extraFilesystems, ",") {
found := false
for _, p := range partitions {

View File

@@ -1,7 +1,7 @@
package agent
import (
"beszel/internal/entities/container"
"bytes"
"context"
"encoding/json"
"fmt"
@@ -14,6 +14,8 @@ import (
"sync"
"time"
"github.com/henrygd/beszel/internal/entities/container"
"github.com/blang/semver"
)
@@ -22,10 +24,26 @@ type dockerManager struct {
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
apiContainerList []*container.ApiInfo // List of containers from Docker API (no pointer)
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)
isWindows bool // Whether the Docker Engine API is running on Windows
buf *bytes.Buffer // Buffer to store and read response bodies
decoder *json.Decoder // Reusable JSON decoder that reads from buf
apiStats *container.ApiStats // Reusable API stats object
}
// userAgentRoundTripper is a custom http.RoundTripper that adds a User-Agent header to all requests
type userAgentRoundTripper struct {
rt http.RoundTripper
userAgent string
}
// RoundTrip implements the http.RoundTripper interface
func (u *userAgentRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
req.Header.Set("User-Agent", u.userAgent)
return u.rt.RoundTrip(req)
}
// Add goroutine to the queue
@@ -50,13 +68,15 @@ func (dm *dockerManager) getDockerStats() ([]*container.Stats, error) {
if err != nil {
return nil, err
}
defer resp.Body.Close()
if err := json.NewDecoder(resp.Body).Decode(&dm.apiContainerList); err != nil {
dm.apiContainerList = dm.apiContainerList[:0]
if err := dm.decode(resp, &dm.apiContainerList); err != nil {
return nil, err
}
containersLength := len(*dm.apiContainerList)
dm.isWindows = strings.Contains(resp.Header.Get("Server"), "windows")
containersLength := len(dm.apiContainerList)
// store valid ids to clean up old container ids from map
if dm.validIds == nil {
@@ -65,9 +85,10 @@ func (dm *dockerManager) getDockerStats() ([]*container.Stats, error) {
clear(dm.validIds)
}
var failedContainters []container.ApiInfo
var failedContainers []*container.ApiInfo
for _, ctr := range *dm.apiContainerList {
for i := range dm.apiContainerList {
ctr := dm.apiContainerList[i]
ctr.IdShort = ctr.Id[:12]
dm.validIds[ctr.IdShort] = struct{}{}
// check if container is less than 1 minute old (possible restart)
@@ -84,7 +105,7 @@ func (dm *dockerManager) getDockerStats() ([]*container.Stats, error) {
if err != nil {
dm.containerStatsMutex.Lock()
delete(dm.containerStatsMap, ctr.IdShort)
failedContainters = append(failedContainters, ctr)
failedContainers = append(failedContainers, ctr)
dm.containerStatsMutex.Unlock()
}
}()
@@ -93,9 +114,10 @@ func (dm *dockerManager) getDockerStats() ([]*container.Stats, error) {
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 {
if len(failedContainers) > 0 {
slog.Debug("Retrying failed containers", "count", len(failedContainers))
for i := range failedContainers {
ctr := failedContainers[i]
dm.queue()
go func() {
defer dm.dequeue()
@@ -122,7 +144,7 @@ func (dm *dockerManager) getDockerStats() ([]*container.Stats, error) {
}
// Updates stats for individual container
func (dm *dockerManager) updateContainerStats(ctr container.ApiInfo) error {
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")
@@ -148,31 +170,45 @@ func (dm *dockerManager) updateContainerStats(ctr container.ApiInfo) error {
stats.NetworkRecv = 0
// docker host container stats response
var res container.ApiStats
if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
// res := dm.getApiStats()
// defer dm.putApiStats(res)
//
res := dm.apiStats
res.Networks = nil
if err := dm.decode(resp, 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)
// calculate cpu and memory stats
var usedMemory uint64
var cpuPct float64
// store current cpu stats
prevCpuContainer, prevCpuSystem := stats.CpuContainer, stats.CpuSystem
stats.CpuContainer = res.CPUStats.CPUUsage.TotalUsage
stats.CpuSystem = res.CPUStats.SystemUsage
if dm.isWindows {
usedMemory = res.MemoryStats.PrivateWorkingSet
cpuPct = res.CalculateCpuPercentWindows(prevCpuContainer, stats.PrevReadTime)
} else {
// 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)
}
memCache := res.MemoryStats.Stats.InactiveFile
if memCache == 0 {
memCache = res.MemoryStats.Stats.Cache
}
usedMemory = res.MemoryStats.Usage - memCache
cpuPct = res.CalculateCpuPercentLinux(prevCpuContainer, prevCpuSystem)
}
// 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
@@ -180,21 +216,25 @@ func (dm *dockerManager) updateContainerStats(ctr container.ApiInfo) error {
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
var sent_delta, recv_delta uint64
millisecondsElapsed := uint64(time.Since(stats.PrevReadTime).Milliseconds())
if initialized && millisecondsElapsed > 0 {
// get bytes per second
sent_delta = (total_sent - stats.PrevNet.Sent) * 1000 / millisecondsElapsed
recv_delta = (total_recv - stats.PrevNet.Recv) * 1000 / millisecondsElapsed
// check for unrealistic network values (> 5GB/s)
if sent_delta > 5e9 || recv_delta > 5e9 {
slog.Warn("Bad network delta", "container", name)
sent_delta, recv_delta = 0, 0
}
}
stats.PrevNet.Sent = total_sent
stats.PrevNet.Recv = total_recv
stats.PrevNet.Time = time.Now()
stats.PrevNet.Sent, stats.PrevNet.Recv = total_sent, total_recv
stats.Cpu = twoDecimals(cpuPct)
stats.Mem = bytesToMegabytes(float64(usedMemory))
stats.NetworkSent = bytesToMegabytes(sent_delta)
stats.NetworkRecv = bytesToMegabytes(recv_delta)
stats.NetworkSent = bytesToMegabytes(float64(sent_delta))
stats.NetworkRecv = bytesToMegabytes(float64(recv_delta))
stats.PrevReadTime = res.Read
return nil
}
@@ -208,16 +248,18 @@ func (dm *dockerManager) deleteContainerStatsSync(id string) {
// Creates a new http client for Docker or Podman API
func newDockerManager(a *Agent) *dockerManager {
dockerHost, exists := os.LookupEnv("DOCKER_HOST")
dockerHost, exists := GetEnv("DOCKER_HOST")
if exists {
slog.Info("DOCKER_HOST", "host", dockerHost)
// return nil if set to empty string
if dockerHost == "" {
return nil
}
} else {
dockerHost = getDockerHost()
}
parsedURL, err := url.Parse(dockerHost)
if err != nil {
slog.Error("Error parsing DOCKER_HOST", "err", err)
os.Exit(1)
}
@@ -242,7 +284,7 @@ func newDockerManager(a *Agent) *dockerManager {
// configurable timeout
timeout := time.Millisecond * 2100
if t, set := os.LookupEnv("DOCKER_TIMEOUT"); set {
if t, set := GetEnv("DOCKER_TIMEOUT"); set {
timeout, err = time.ParseDuration(t)
if err != nil {
slog.Error(err.Error())
@@ -251,20 +293,28 @@ func newDockerManager(a *Agent) *dockerManager {
slog.Info("DOCKER_TIMEOUT", "timeout", timeout)
}
dockerClient := &dockerManager{
// Custom user-agent to avoid docker bug: https://github.com/docker/for-mac/issues/7575
userAgentTransport := &userAgentRoundTripper{
rt: transport,
userAgent: "Docker-Client/",
}
manager := &dockerManager{
client: &http.Client{
Timeout: timeout,
Transport: transport,
Transport: userAgentTransport,
},
containerStatsMap: make(map[string]*container.Stats),
sem: make(chan struct{}, 5),
apiContainerList: []*container.ApiInfo{},
apiStats: &container.ApiStats{},
}
// If using podman, return client
if strings.Contains(dockerHost, "podman") {
a.systemInfo.Podman = true
dockerClient.goodDockerVersion = true
return dockerClient
manager.goodDockerVersion = true
return manager
}
// Check docker version
@@ -272,23 +322,39 @@ func newDockerManager(a *Agent) *dockerManager {
var versionInfo struct {
Version string `json:"Version"`
}
resp, err := dockerClient.client.Get("http://localhost/version")
resp, err := manager.client.Get("http://localhost/version")
if err != nil {
return dockerClient
return manager
}
if err := json.NewDecoder(resp.Body).Decode(&versionInfo); err != nil {
return dockerClient
if err := manager.decode(resp, &versionInfo); err != nil {
return manager
}
// 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
manager.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
return manager
}
// Decodes Docker API JSON response using a reusable buffer and decoder. Not thread safe.
func (dm *dockerManager) decode(resp *http.Response, d any) error {
if dm.buf == nil {
// initialize buffer with 256kb starting size
dm.buf = bytes.NewBuffer(make([]byte, 0, 1024*256))
dm.decoder = json.NewDecoder(dm.buf)
}
defer resp.Body.Close()
defer dm.buf.Reset()
_, err := dm.buf.ReadFrom(resp.Body)
if err != nil {
return err
}
return dm.decoder.Decode(d)
}
// Test docker / podman sockets and return if one exists

349
agent/gpu.go Normal file
View File

@@ -0,0 +1,349 @@
package agent
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"os/exec"
"regexp"
"strconv"
"strings"
"sync"
"time"
"github.com/henrygd/beszel/internal/entities/system"
"golang.org/x/exp/slog"
)
const (
// Commands
nvidiaSmiCmd string = "nvidia-smi"
rocmSmiCmd string = "rocm-smi"
tegraStatsCmd string = "tegrastats"
// Polling intervals
nvidiaSmiInterval string = "4" // in seconds
tegraStatsInterval string = "3700" // in milliseconds
rocmSmiInterval time.Duration = 4300 * time.Millisecond
// Command retry and timeout constants
retryWaitTime time.Duration = 5 * time.Second
maxFailureRetries int = 5
cmdBufferSize uint16 = 10 * 1024
// Unit Conversions
mebibytesInAMegabyte float64 = 1.024 // nvidia-smi reports memory in MiB
milliwattsInAWatt float64 = 1000.0 // tegrastats reports power in mW
)
// GPUManager manages data collection for GPUs (either Nvidia or AMD)
type GPUManager struct {
sync.Mutex
nvidiaSmi bool
rocmSmi bool
tegrastats bool
GpuDataMap map[string]*system.GPUData
}
// 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 (%)"`
PowerPackage string `json:"Average Graphics Package Power (W)"`
PowerSocket 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
cmdArgs []string
parse func([]byte) bool // returns true if valid data was found
buf []byte
}
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(retryWaitTime)
continue
}
}
}
// collect executes the command, parses output with the assigned parser function
func (c *gpuCollector) collect() error {
cmd := exec.Command(c.name, c.cmdArgs...)
stdout, err := cmd.StdoutPipe()
if err != nil {
return err
}
if err := cmd.Start(); err != nil {
return err
}
scanner := bufio.NewScanner(stdout)
if c.buf == nil {
c.buf = make([]byte, 0, cmdBufferSize)
}
scanner.Buffer(c.buf, bufio.MaxScanTokenSize)
for scanner.Scan() {
hasValidData := c.parse(scanner.Bytes())
if !hasValidData {
return errNoValidData
}
}
if err := scanner.Err(); err != nil {
return fmt.Errorf("scanner error: %w", err)
}
return cmd.Wait()
}
// getJetsonParser returns a function to parse the output of tegrastats and update the GPUData map
func (gm *GPUManager) getJetsonParser() func(output []byte) bool {
// use closure to avoid recompiling the regex
ramPattern := regexp.MustCompile(`RAM (\d+)/(\d+)MB`)
gr3dPattern := regexp.MustCompile(`GR3D_FREQ (\d+)%`)
tempPattern := regexp.MustCompile(`tj@(\d+\.?\d*)C`)
// Orin Nano / NX do not have GPU specific power monitor
// TODO: Maybe use VDD_IN for Nano / NX and add a total system power chart
powerPattern := regexp.MustCompile(`(GPU_SOC|CPU_GPU_CV) (\d+)mW`)
// jetson devices have only one gpu so we'll just initialize here
gpuData := &system.GPUData{Name: "GPU"}
gm.GpuDataMap["0"] = gpuData
return func(output []byte) bool {
gm.Lock()
defer gm.Unlock()
// Parse RAM usage
ramMatches := ramPattern.FindSubmatch(output)
if ramMatches != nil {
gpuData.MemoryUsed, _ = strconv.ParseFloat(string(ramMatches[1]), 64)
gpuData.MemoryTotal, _ = strconv.ParseFloat(string(ramMatches[2]), 64)
}
// Parse GR3D (GPU) usage
gr3dMatches := gr3dPattern.FindSubmatch(output)
if gr3dMatches != nil {
gr3dUsage, _ := strconv.ParseFloat(string(gr3dMatches[1]), 64)
gpuData.Usage += gr3dUsage
}
// Parse temperature
tempMatches := tempPattern.FindSubmatch(output)
if tempMatches != nil {
gpuData.Temperature, _ = strconv.ParseFloat(string(tempMatches[1]), 64)
}
// Parse power usage
powerMatches := powerPattern.FindSubmatch(output)
if powerMatches != nil {
power, _ := strconv.ParseFloat(string(powerMatches[2]), 64)
gpuData.Power += power / milliwattsInAWatt
}
gpuData.Count++
return true
}
}
// parseNvidiaData parses the output of nvidia-smi and updates the GPUData map
func (gm *GPUManager) parseNvidiaData(output []byte) bool {
gm.Lock()
defer gm.Unlock()
scanner := bufio.NewScanner(bytes.NewReader(output))
var valid bool
for scanner.Scan() {
line := scanner.Text() // Or use scanner.Bytes() for []byte
fields := strings.Split(strings.TrimSpace(line), ", ")
if len(fields) < 7 {
continue
}
valid = true
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 / mebibytesInAMegabyte
gpu.MemoryTotal = totalMemory / mebibytesInAMegabyte
gpu.Usage += usage
gpu.Power += power
gpu.Count++
}
return valid
}
// 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.Lock()
defer gm.Unlock()
for _, v := range rocmSmiInfo {
var power float64
if v.PowerPackage != "" {
power, _ = strconv.ParseFloat(v.PowerPackage, 64)
} else {
power, _ = strconv.ParseFloat(v.PowerSocket, 64)
}
memoryUsage, _ := strconv.ParseFloat(v.MemoryUsed, 64)
totalMemory, _ := strconv.ParseFloat(v.MemoryTotal, 64)
usage, _ := strconv.ParseFloat(v.Usage, 64)
if _, ok := gm.GpuDataMap[v.ID]; !ok {
gm.GpuDataMap[v.ID] = &system.GPUData{Name: v.Name}
}
gpu := gm.GpuDataMap[v.ID]
gpu.Temperature, _ = strconv.ParseFloat(v.Temperature, 64)
gpu.MemoryUsed = bytesToMegabytes(memoryUsage)
gpu.MemoryTotal = bytesToMegabytes(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.Lock()
defer gm.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 {
gpuAvg := *gpu
gpuAvg.Temperature = twoDecimals(gpu.Temperature)
gpuAvg.MemoryUsed = twoDecimals(gpu.MemoryUsed)
gpuAvg.MemoryTotal = twoDecimals(gpu.MemoryTotal)
// avoid division by zero
if gpu.Count > 0 {
gpuAvg.Usage = twoDecimals(gpu.Usage / gpu.Count)
gpuAvg.Power = twoDecimals(gpu.Power / gpu.Count)
}
// reset accumulators in the original
gpu.Usage, gpu.Power, gpu.Count = 0, 0, 0
// append id to the name if there are multiple GPUs with the same name
if nameCounts[gpu.Name] > 1 {
gpuAvg.Name = fmt.Sprintf("%s %s", gpu.Name, id)
}
gpuData[id] = gpuAvg
}
slog.Debug("GPU", "data", gpuData)
return gpuData
}
// detectGPUs checks for the presence of GPU management tools (nvidia-smi, rocm-smi, tegrastats)
// in the system path. It sets the corresponding flags in the GPUManager struct if any of these
// tools are found. If none of the tools are found, it returns an error indicating that no GPU
// management tools are available.
func (gm *GPUManager) detectGPUs() error {
if _, err := exec.LookPath(nvidiaSmiCmd); err == nil {
gm.nvidiaSmi = true
}
if _, err := exec.LookPath(rocmSmiCmd); err == nil {
gm.rocmSmi = true
}
if _, err := exec.LookPath(tegraStatsCmd); err == nil {
gm.tegrastats = true
gm.nvidiaSmi = false
}
if gm.nvidiaSmi || gm.rocmSmi || gm.tegrastats {
return nil
}
return fmt.Errorf("no GPU found - install nvidia-smi, rocm-smi, or tegrastats")
}
// startCollector starts the appropriate GPU data collector based on the command
func (gm *GPUManager) startCollector(command string) {
collector := gpuCollector{
name: command,
}
switch command {
case nvidiaSmiCmd:
collector.cmdArgs = []string{
"-l", nvidiaSmiInterval,
"--query-gpu=index,name,temperature.gpu,memory.used,memory.total,utilization.gpu,power.draw",
"--format=csv,noheader,nounits",
}
collector.parse = gm.parseNvidiaData
go collector.start()
case tegraStatsCmd:
collector.cmdArgs = []string{"--interval", tegraStatsInterval}
collector.parse = gm.getJetsonParser()
go collector.start()
case rocmSmiCmd:
collector.cmdArgs = []string{"--showid", "--showtemp", "--showuse", "--showpower", "--showproductname", "--showmeminfo", "vram", "--json"}
collector.parse = gm.parseAmdData
go func() {
failures := 0
for {
if err := collector.collect(); err != nil {
failures++
if failures > maxFailureRetries {
break
}
slog.Warn("Error collecting AMD GPU data", "err", err)
}
time.Sleep(rocmSmiInterval)
}
}()
}
}
// 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)
if gm.nvidiaSmi {
gm.startCollector(nvidiaSmiCmd)
}
if gm.rocmSmi {
gm.startCollector(rocmSmiCmd)
}
if gm.tegrastats {
gm.startCollector(tegraStatsCmd)
}
return &gm, nil
}

794
agent/gpu_test.go Normal file
View File

@@ -0,0 +1,794 @@
//go:build testing
// +build testing
package agent
import (
"os"
"path/filepath"
"testing"
"time"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseNvidiaData(t *testing.T) {
tests := []struct {
name string
input string
wantData map[string]system.GPUData
wantValid bool
}{
{
name: "valid multi-gpu data",
input: "0, NVIDIA GeForce RTX 3050 Ti Laptop GPU, 48, 12, 4096, 26.3, 12.73\n1, NVIDIA A100-PCIE-40GB, 38, 74, 40960, [N/A], 36.79",
wantData: map[string]system.GPUData{
"0": {
Name: "GeForce RTX 3050 Ti",
Temperature: 48.0,
MemoryUsed: 12.0 / 1.024,
MemoryTotal: 4096.0 / 1.024,
Usage: 26.3,
Power: 12.73,
Count: 1,
},
"1": {
Name: "A100-PCIE-40GB",
Temperature: 38.0,
MemoryUsed: 74.0 / 1.024,
MemoryTotal: 40960.0 / 1.024,
Usage: 0.0,
Power: 36.79,
Count: 1,
},
},
wantValid: true,
},
{
name: "more valid multi-gpu data",
input: `0, NVIDIA A10, 45, 19676, 23028, 0, 58.98
1, NVIDIA A10, 45, 19638, 23028, 0, 62.35
2, NVIDIA A10, 44, 21700, 23028, 0, 59.57
3, NVIDIA A10, 45, 18222, 23028, 0, 61.76`,
wantData: map[string]system.GPUData{
"0": {
Name: "A10",
Temperature: 45.0,
MemoryUsed: 19676.0 / 1.024,
MemoryTotal: 23028.0 / 1.024,
Usage: 0.0,
Power: 58.98,
Count: 1,
},
"1": {
Name: "A10",
Temperature: 45.0,
MemoryUsed: 19638.0 / 1.024,
MemoryTotal: 23028.0 / 1.024,
Usage: 0.0,
Power: 62.35,
Count: 1,
},
"2": {
Name: "A10",
Temperature: 44.0,
MemoryUsed: 21700.0 / 1.024,
MemoryTotal: 23028.0 / 1.024,
Usage: 0.0,
Power: 59.57,
Count: 1,
},
"3": {
Name: "A10",
Temperature: 45.0,
MemoryUsed: 18222.0 / 1.024,
MemoryTotal: 23028.0 / 1.024,
Usage: 0.0,
Power: 61.76,
Count: 1,
},
},
wantValid: true,
},
{
name: "empty input",
input: "",
wantData: map[string]system.GPUData{},
wantValid: false,
},
{
name: "malformed data",
input: "bad, data, here",
wantData: map[string]system.GPUData{},
wantValid: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gm := &GPUManager{
GpuDataMap: make(map[string]*system.GPUData),
}
valid := gm.parseNvidiaData([]byte(tt.input))
assert.Equal(t, tt.wantValid, valid)
if tt.wantValid {
for id, want := range tt.wantData {
got := gm.GpuDataMap[id]
require.NotNil(t, got)
assert.Equal(t, want.Name, got.Name)
assert.InDelta(t, want.Temperature, got.Temperature, 0.01)
assert.InDelta(t, want.MemoryUsed, got.MemoryUsed, 0.01)
assert.InDelta(t, want.MemoryTotal, got.MemoryTotal, 0.01)
assert.InDelta(t, want.Usage, got.Usage, 0.01)
assert.InDelta(t, want.Power, got.Power, 0.01)
assert.Equal(t, want.Count, got.Count)
}
}
})
}
}
func TestParseAmdData(t *testing.T) {
tests := []struct {
name string
input string
wantData map[string]system.GPUData
wantValid bool
}{
{
name: "valid single gpu data",
input: `{
"card0": {
"GUID": "34756",
"Temperature (Sensor edge) (C)": "47.0",
"Current Socket Graphics Package Power (W)": "9.215",
"GPU use (%)": "0",
"VRAM Total Memory (B)": "536870912",
"VRAM Total Used Memory (B)": "482263040",
"Card Series": "Rembrandt [Radeon 680M]"
}
}`,
wantData: map[string]system.GPUData{
"34756": {
Name: "Rembrandt [Radeon 680M]",
Temperature: 47.0,
MemoryUsed: 482263040.0 / (1024 * 1024),
MemoryTotal: 536870912.0 / (1024 * 1024),
Usage: 0.0,
Power: 9.215,
Count: 1,
},
},
wantValid: true,
},
{
name: "valid multi gpu data",
input: `{
"card0": {
"GUID": "34756",
"Temperature (Sensor edge) (C)": "47.0",
"Current Socket Graphics Package Power (W)": "9.215",
"GPU use (%)": "0",
"VRAM Total Memory (B)": "536870912",
"VRAM Total Used Memory (B)": "482263040",
"Card Series": "Rembrandt [Radeon 680M]"
},
"card1": {
"GUID": "38294",
"Temperature (Sensor edge) (C)": "49.0",
"Temperature (Sensor junction) (C)": "49.0",
"Temperature (Sensor memory) (C)": "62.0",
"Average Graphics Package Power (W)": "19.0",
"GPU use (%)": "20.3",
"VRAM Total Memory (B)": "25753026560",
"VRAM Total Used Memory (B)": "794341376",
"Card Series": "Navi 31 [Radeon RX 7900 XT]"
}
}`,
wantData: map[string]system.GPUData{
"34756": {
Name: "Rembrandt [Radeon 680M]",
Temperature: 47.0,
MemoryUsed: 482263040.0 / (1024 * 1024),
MemoryTotal: 536870912.0 / (1024 * 1024),
Usage: 0.0,
Power: 9.215,
Count: 1,
},
"38294": {
Name: "Navi 31 [Radeon RX 7900 XT]",
Temperature: 49.0,
MemoryUsed: 794341376.0 / (1024 * 1024),
MemoryTotal: 25753026560.0 / (1024 * 1024),
Usage: 20.3,
Power: 19.0,
Count: 1,
},
},
wantValid: true,
},
{
name: "invalid json",
input: "{bad json",
},
{
name: "invalid json",
input: "{bad json",
wantData: map[string]system.GPUData{},
wantValid: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gm := &GPUManager{
GpuDataMap: make(map[string]*system.GPUData),
}
valid := gm.parseAmdData([]byte(tt.input))
assert.Equal(t, tt.wantValid, valid)
if tt.wantValid {
for id, want := range tt.wantData {
got := gm.GpuDataMap[id]
require.NotNil(t, got)
assert.Equal(t, want.Name, got.Name)
assert.InDelta(t, want.Temperature, got.Temperature, 0.01)
assert.InDelta(t, want.MemoryUsed, got.MemoryUsed, 0.01)
assert.InDelta(t, want.MemoryTotal, got.MemoryTotal, 0.01)
assert.InDelta(t, want.Usage, got.Usage, 0.01)
assert.InDelta(t, want.Power, got.Power, 0.01)
assert.Equal(t, want.Count, got.Count)
}
}
})
}
}
func TestParseJetsonData(t *testing.T) {
tests := []struct {
name string
input string
wantMetrics *system.GPUData
}{
{
name: "valid data",
input: "11-14-2024 22:54:33 RAM 4300/30698MB GR3D_FREQ 45% tj@52.468C VDD_GPU_SOC 2171mW",
wantMetrics: &system.GPUData{
Name: "GPU",
MemoryUsed: 4300.0,
MemoryTotal: 30698.0,
Usage: 45.0,
Temperature: 52.468,
Power: 2.171,
Count: 1,
},
},
{
name: "more valid data",
input: "11-15-2024 08:38:09 RAM 6185/7620MB (lfb 8x2MB) SWAP 851/3810MB (cached 1MB) CPU [15%@729,11%@729,14%@729,13%@729,11%@729,8%@729] EMC_FREQ 43%@2133 GR3D_FREQ 63%@[621] NVDEC off NVJPG off NVJPG1 off VIC off OFA off APE 200 cpu@53.968C soc2@52.437C soc0@50.75C gpu@53.343C tj@53.968C soc1@51.656C VDD_IN 12479mW/12479mW VDD_CPU_GPU_CV 4667mW/4667mW VDD_SOC 2817mW/2817mW",
wantMetrics: &system.GPUData{
Name: "GPU",
MemoryUsed: 6185.0,
MemoryTotal: 7620.0,
Usage: 63.0,
Temperature: 53.968,
Power: 4.667,
Count: 1,
},
},
{
name: "orin nano",
input: "06-18-2025 11:25:24 RAM 3452/7620MB (lfb 25x4MB) SWAP 1518/16384MB (cached 174MB) CPU [1%@1420,2%@1420,0%@1420,2%@1420,2%@729,1%@729] GR3D_FREQ 0% cpu@50.031C soc2@49.031C soc0@50C gpu@49.031C tj@50.25C soc1@50.25C VDD_IN 4824mW/4824mW VDD_CPU_GPU_CV 518mW/518mW VDD_SOC 1475mW/1475mW",
wantMetrics: &system.GPUData{
Name: "GPU",
MemoryUsed: 3452.0,
MemoryTotal: 7620.0,
Usage: 0.0,
Temperature: 50.25,
Power: 0.518,
Count: 1,
},
},
{
name: "missing temperature",
input: "11-14-2024 22:54:33 RAM 4300/30698MB GR3D_FREQ 45% VDD_GPU_SOC 2171mW",
wantMetrics: &system.GPUData{
Name: "GPU",
MemoryUsed: 4300.0,
MemoryTotal: 30698.0,
Usage: 45.0,
Power: 2.171,
Count: 1,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gm := &GPUManager{
GpuDataMap: make(map[string]*system.GPUData),
}
parser := gm.getJetsonParser()
valid := parser([]byte(tt.input))
assert.Equal(t, true, valid)
got := gm.GpuDataMap["0"]
require.NotNil(t, got)
assert.Equal(t, tt.wantMetrics.Name, got.Name)
assert.InDelta(t, tt.wantMetrics.MemoryUsed, got.MemoryUsed, 0.01)
assert.InDelta(t, tt.wantMetrics.MemoryTotal, got.MemoryTotal, 0.01)
assert.InDelta(t, tt.wantMetrics.Usage, got.Usage, 0.01)
if tt.wantMetrics.Temperature > 0 {
assert.InDelta(t, tt.wantMetrics.Temperature, got.Temperature, 0.01)
}
assert.InDelta(t, tt.wantMetrics.Power, got.Power, 0.01)
assert.Equal(t, tt.wantMetrics.Count, got.Count)
})
}
}
func TestGetCurrentData(t *testing.T) {
t.Run("calculates averages and resets accumulators", func(t *testing.T) {
gm := &GPUManager{
GpuDataMap: map[string]*system.GPUData{
"0": {
Name: "GPU1",
Temperature: 50,
MemoryUsed: 2048,
MemoryTotal: 4096,
Usage: 100, // 100 over 2 counts = 50 avg
Power: 200, // 200 over 2 counts = 100 avg
Count: 2,
},
"1": {
Name: "GPU1",
Temperature: 60,
MemoryUsed: 3072,
MemoryTotal: 8192,
Usage: 30,
Power: 60,
Count: 1,
},
"2": {
Name: "GPU 2",
Temperature: 70,
MemoryUsed: 4096,
MemoryTotal: 8192,
Usage: 200,
Power: 400,
Count: 1,
},
},
}
result := gm.GetCurrentData()
// Verify name disambiguation
assert.Equal(t, "GPU1 0", result["0"].Name)
assert.Equal(t, "GPU1 1", result["1"].Name)
assert.Equal(t, "GPU 2", result["2"].Name)
// Check averaged values in the result
assert.InDelta(t, 50.0, result["0"].Usage, 0.01)
assert.InDelta(t, 100.0, result["0"].Power, 0.01)
assert.InDelta(t, 30.0, result["1"].Usage, 0.01)
assert.InDelta(t, 60.0, result["1"].Power, 0.01)
// Verify that accumulators in the original map are reset
assert.Equal(t, float64(0), gm.GpuDataMap["0"].Count, "GPU 0 Count should be reset")
assert.Equal(t, float64(0), gm.GpuDataMap["0"].Usage, "GPU 0 Usage should be reset")
assert.Equal(t, float64(0), gm.GpuDataMap["0"].Power, "GPU 0 Power should be reset")
assert.Equal(t, float64(0), gm.GpuDataMap["1"].Count, "GPU 1 Count should be reset")
assert.Equal(t, float64(0), gm.GpuDataMap["1"].Usage, "GPU 1 Usage should be reset")
assert.Equal(t, float64(0), gm.GpuDataMap["1"].Power, "GPU 1 Power should be reset")
})
t.Run("handles zero count without panicking", func(t *testing.T) {
gm := &GPUManager{
GpuDataMap: map[string]*system.GPUData{
"0": {
Name: "TestGPU",
Count: 0,
Usage: 0,
Power: 0,
},
},
}
var result map[string]system.GPUData
assert.NotPanics(t, func() {
result = gm.GetCurrentData()
})
// Check that usage and power are 0
assert.Equal(t, 0.0, result["0"].Usage)
assert.Equal(t, 0.0, result["0"].Power)
// Verify reset count
assert.Equal(t, float64(0), gm.GpuDataMap["0"].Count)
})
}
func TestDetectGPUs(t *testing.T) {
// Save original PATH
origPath := os.Getenv("PATH")
defer os.Setenv("PATH", origPath)
// Set up temp dir with the commands
tempDir := t.TempDir()
os.Setenv("PATH", tempDir)
tests := []struct {
name string
setupCommands func() error
wantNvidiaSmi bool
wantRocmSmi bool
wantTegrastats bool
wantErr bool
}{
{
name: "nvidia-smi not available",
setupCommands: func() error {
return nil
},
wantNvidiaSmi: false,
wantRocmSmi: false,
wantTegrastats: false,
wantErr: true,
},
{
name: "nvidia-smi available",
setupCommands: func() error {
path := filepath.Join(tempDir, "nvidia-smi")
script := `#!/bin/sh
echo "test"`
if err := os.WriteFile(path, []byte(script), 0755); err != nil {
return err
}
return nil
},
wantNvidiaSmi: true,
wantTegrastats: false,
wantRocmSmi: false,
wantErr: false,
},
{
name: "rocm-smi available",
setupCommands: func() error {
path := filepath.Join(tempDir, "rocm-smi")
script := `#!/bin/sh
echo "test"`
if err := os.WriteFile(path, []byte(script), 0755); err != nil {
return err
}
return nil
},
wantNvidiaSmi: true,
wantRocmSmi: true,
wantTegrastats: false,
wantErr: false,
},
{
name: "tegrastats available",
setupCommands: func() error {
path := filepath.Join(tempDir, "tegrastats")
script := `#!/bin/sh
echo "test"`
if err := os.WriteFile(path, []byte(script), 0755); err != nil {
return err
}
return nil
},
wantNvidiaSmi: false,
wantRocmSmi: true,
wantTegrastats: true,
wantErr: false,
},
{
name: "no gpu tools available",
setupCommands: func() error {
os.Setenv("PATH", "")
return nil
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if err := tt.setupCommands(); err != nil {
t.Fatal(err)
}
gm := &GPUManager{}
err := gm.detectGPUs()
t.Logf("nvidiaSmi: %v, rocmSmi: %v, tegrastats: %v", gm.nvidiaSmi, gm.rocmSmi, gm.tegrastats)
if tt.wantErr {
assert.Error(t, err)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.wantNvidiaSmi, gm.nvidiaSmi)
assert.Equal(t, tt.wantRocmSmi, gm.rocmSmi)
assert.Equal(t, tt.wantTegrastats, gm.tegrastats)
})
}
}
func TestStartCollector(t *testing.T) {
// Save original PATH
origPath := os.Getenv("PATH")
defer os.Setenv("PATH", origPath)
// Set up temp dir with the commands
dir := t.TempDir()
os.Setenv("PATH", dir)
tests := []struct {
name string
command string
setup func(t *testing.T) error
validate func(t *testing.T, gm *GPUManager)
gm *GPUManager
}{
{
name: "nvidia-smi collector",
command: "nvidia-smi",
setup: func(t *testing.T) error {
path := filepath.Join(dir, "nvidia-smi")
script := `#!/bin/sh
echo "0, NVIDIA Test GPU, 50, 1024, 4096, 25, 100"`
if err := os.WriteFile(path, []byte(script), 0755); err != nil {
return err
}
return nil
},
validate: func(t *testing.T, gm *GPUManager) {
gpu, exists := gm.GpuDataMap["0"]
assert.True(t, exists)
if exists {
assert.Equal(t, "Test GPU", gpu.Name)
assert.Equal(t, 50.0, gpu.Temperature)
}
},
},
{
name: "rocm-smi collector",
command: "rocm-smi",
setup: func(t *testing.T) error {
path := filepath.Join(dir, "rocm-smi")
script := `#!/bin/sh
echo '{"card0": {"Temperature (Sensor edge) (C)": "49.0", "Current Socket Graphics Package Power (W)": "28.159", "GPU use (%)": "0", "VRAM Total Memory (B)": "536870912", "VRAM Total Used Memory (B)": "445550592", "Card Series": "Rembrandt [Radeon 680M]", "Card Model": "0x1681", "Card Vendor": "Advanced Micro Devices, Inc. [AMD/ATI]", "Card SKU": "REMBRANDT", "Subsystem ID": "0x8a22", "Device Rev": "0xc8", "Node ID": "1", "GUID": "34756", "GFX Version": "gfx1035"}}'`
if err := os.WriteFile(path, []byte(script), 0755); err != nil {
return err
}
return nil
},
validate: func(t *testing.T, gm *GPUManager) {
gpu, exists := gm.GpuDataMap["34756"]
assert.True(t, exists)
if exists {
assert.Equal(t, "Rembrandt [Radeon 680M]", gpu.Name)
assert.InDelta(t, 49.0, gpu.Temperature, 0.01)
assert.InDelta(t, 28.159, gpu.Power, 0.01)
}
},
},
{
name: "tegrastats collector",
command: "tegrastats",
setup: func(t *testing.T) error {
path := filepath.Join(dir, "tegrastats")
script := `#!/bin/sh
echo "11-14-2024 22:54:33 RAM 1024/4096MB GR3D_FREQ 80% tj@70C VDD_GPU_SOC 1000mW"`
if err := os.WriteFile(path, []byte(script), 0755); err != nil {
return err
}
return nil
},
validate: func(t *testing.T, gm *GPUManager) {
gpu, exists := gm.GpuDataMap["0"]
assert.True(t, exists)
if exists {
assert.InDelta(t, 70.0, gpu.Temperature, 0.1)
}
},
gm: &GPUManager{
GpuDataMap: map[string]*system.GPUData{
"0": {},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if err := tt.setup(t); err != nil {
t.Fatal(err)
}
if tt.gm == nil {
tt.gm = &GPUManager{
GpuDataMap: make(map[string]*system.GPUData),
}
}
tt.gm.startCollector(tt.command)
time.Sleep(50 * time.Millisecond) // Give collector time to run
tt.validate(t, tt.gm)
})
}
}
// TestAccumulationTableDriven tests the accumulation behavior for all three GPU types
func TestAccumulation(t *testing.T) {
type expectedGPUValues struct {
temperature float64
memoryUsed float64
memoryTotal float64
usage float64
power float64
count float64
avgUsage float64
avgPower float64
}
tests := []struct {
name string
initialGPUData map[string]*system.GPUData
dataSamples [][]byte
parser func(*GPUManager) func([]byte) bool
expectedValues map[string]expectedGPUValues
}{
{
name: "Jetson GPU accumulation",
initialGPUData: map[string]*system.GPUData{
"0": {
Name: "Jetson",
Temperature: 0,
Usage: 0,
Power: 0,
Count: 0,
},
},
dataSamples: [][]byte{
[]byte("11-14-2024 22:54:33 RAM 1024/4096MB GR3D_FREQ 30% tj@50.5C VDD_GPU_SOC 1000mW"),
[]byte("11-14-2024 22:54:33 RAM 1024/4096MB GR3D_FREQ 40% tj@60.5C VDD_GPU_SOC 1200mW"),
[]byte("11-14-2024 22:54:33 RAM 1024/4096MB GR3D_FREQ 50% tj@70.5C VDD_GPU_SOC 1400mW"),
},
parser: func(gm *GPUManager) func([]byte) bool {
return gm.getJetsonParser()
},
expectedValues: map[string]expectedGPUValues{
"0": {
temperature: 70.5, // Last value
memoryUsed: 1024, // Last value
memoryTotal: 4096, // Last value
usage: 120.0, // Accumulated: 30 + 40 + 50
power: 3.6, // Accumulated: 1.0 + 1.2 + 1.4
count: 3,
avgUsage: 40.0, // 120 / 3
avgPower: 1.2, // 3.6 / 3
},
},
},
{
name: "NVIDIA GPU accumulation",
initialGPUData: map[string]*system.GPUData{
// NVIDIA parser will create the GPU data entries
},
dataSamples: [][]byte{
[]byte("0, NVIDIA GeForce RTX 3080, 50, 5000, 10000, 30, 200"),
[]byte("0, NVIDIA GeForce RTX 3080, 60, 6000, 10000, 40, 250"),
[]byte("0, NVIDIA GeForce RTX 3080, 70, 7000, 10000, 50, 300"),
},
parser: func(gm *GPUManager) func([]byte) bool {
return gm.parseNvidiaData
},
expectedValues: map[string]expectedGPUValues{
"0": {
temperature: 70.0, // Last value
memoryUsed: 7000.0 / 1.024, // Last value
memoryTotal: 10000.0 / 1.024, // Last value
usage: 120.0, // Accumulated: 30 + 40 + 50
power: 750.0, // Accumulated: 200 + 250 + 300
count: 3,
avgUsage: 40.0, // 120 / 3
avgPower: 250.0, // 750 / 3
},
},
},
{
name: "AMD GPU accumulation",
initialGPUData: map[string]*system.GPUData{
// AMD parser will create the GPU data entries
},
dataSamples: [][]byte{
[]byte(`{"card0": {"GUID": "34756", "Temperature (Sensor edge) (C)": "50.0", "Current Socket Graphics Package Power (W)": "100.0", "GPU use (%)": "30", "VRAM Total Memory (B)": "10737418240", "VRAM Total Used Memory (B)": "1073741824", "Card Series": "Radeon RX 6800"}}`),
[]byte(`{"card0": {"GUID": "34756", "Temperature (Sensor edge) (C)": "60.0", "Current Socket Graphics Package Power (W)": "150.0", "GPU use (%)": "40", "VRAM Total Memory (B)": "10737418240", "VRAM Total Used Memory (B)": "2147483648", "Card Series": "Radeon RX 6800"}}`),
[]byte(`{"card0": {"GUID": "34756", "Temperature (Sensor edge) (C)": "70.0", "Current Socket Graphics Package Power (W)": "200.0", "GPU use (%)": "50", "VRAM Total Memory (B)": "10737418240", "VRAM Total Used Memory (B)": "3221225472", "Card Series": "Radeon RX 6800"}}`),
},
parser: func(gm *GPUManager) func([]byte) bool {
return gm.parseAmdData
},
expectedValues: map[string]expectedGPUValues{
"34756": {
temperature: 70.0, // Last value
memoryUsed: 3221225472.0 / (1024 * 1024), // Last value
memoryTotal: 10737418240.0 / (1024 * 1024), // Last value
usage: 120.0, // Accumulated: 30 + 40 + 50
power: 450.0, // Accumulated: 100 + 150 + 200
count: 3,
avgUsage: 40.0, // 120 / 3
avgPower: 150.0, // 450 / 3
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create a new GPUManager for each test
gm := &GPUManager{
GpuDataMap: tt.initialGPUData,
}
// Get the parser function
parser := tt.parser(gm)
// Process each data sample
for i, sample := range tt.dataSamples {
valid := parser(sample)
assert.True(t, valid, "Sample %d should be valid", i)
}
// Check accumulated values
for id, expected := range tt.expectedValues {
gpu, exists := gm.GpuDataMap[id]
assert.True(t, exists, "GPU with ID %s should exist", id)
if !exists {
continue
}
assert.InDelta(t, expected.temperature, gpu.Temperature, 0.01, "Temperature should match")
assert.InDelta(t, expected.memoryUsed, gpu.MemoryUsed, 0.01, "Memory used should match")
assert.InDelta(t, expected.memoryTotal, gpu.MemoryTotal, 0.01, "Memory total should match")
assert.InDelta(t, expected.usage, gpu.Usage, 0.01, "Usage should match")
assert.InDelta(t, expected.power, gpu.Power, 0.01, "Power should match")
assert.Equal(t, expected.count, gpu.Count, "Count should match")
}
// Verify average calculation in GetCurrentData
result := gm.GetCurrentData()
for id, expected := range tt.expectedValues {
gpu, exists := result[id]
assert.True(t, exists, "GPU with ID %s should exist in GetCurrentData result", id)
if !exists {
continue
}
assert.InDelta(t, expected.temperature, gpu.Temperature, 0.01, "Temperature in GetCurrentData should match")
assert.InDelta(t, expected.avgUsage, gpu.Usage, 0.01, "Average usage in GetCurrentData should match")
assert.InDelta(t, expected.avgPower, gpu.Power, 0.01, "Average power in GetCurrentData should match")
}
// Verify that accumulators in the original map are reset
for id := range tt.expectedValues {
gpu, exists := gm.GpuDataMap[id]
assert.True(t, exists, "GPU with ID %s should still exist after GetCurrentData", id)
if !exists {
continue
}
assert.Equal(t, float64(0), gpu.Count, "Count should be reset for GPU ID %s", id)
assert.Equal(t, float64(0), gpu.Usage, "Usage should be reset for GPU ID %s", id)
assert.Equal(t, float64(0), gpu.Power, "Power should be reset for GPU ID %s", id)
}
})
}
}

43
agent/health/health.go Normal file
View File

@@ -0,0 +1,43 @@
// Package health provides functions to check and update the health of the agent.
// It uses a file in the temp directory to store the timestamp of the last connection attempt.
// If the timestamp is older than 90 seconds, the agent is considered unhealthy.
// NB: The agent must be started with the Start() method to be considered healthy.
package health
import (
"errors"
"log"
"os"
"path/filepath"
"time"
)
// healthFile is the path to the health file
var healthFile = filepath.Join(os.TempDir(), "beszel_health")
// Check checks if the agent is connected by checking the modification time of the health file
func Check() error {
fileInfo, err := os.Stat(healthFile)
if err != nil {
return err
}
if time.Since(fileInfo.ModTime()) > 91*time.Second {
log.Println("over 90 seconds since last connection")
return errors.New("unhealthy")
}
return nil
}
// Update updates the modification time of the health file
func Update() error {
file, err := os.Create(healthFile)
if err != nil {
return err
}
return file.Close()
}
// CleanUp removes the health file
func CleanUp() error {
return os.Remove(healthFile)
}

View File

@@ -0,0 +1,67 @@
//go:build testing
// +build testing
package health
import (
"os"
"path/filepath"
"testing"
"time"
"testing/synctest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestHealth(t *testing.T) {
// Override healthFile to use a temporary directory for this test.
originalHealthFile := healthFile
tmpDir := t.TempDir()
healthFile = filepath.Join(tmpDir, "beszel_health_test")
defer func() { healthFile = originalHealthFile }()
t.Run("check with no health file", func(t *testing.T) {
err := Check()
require.Error(t, err)
assert.True(t, os.IsNotExist(err), "expected a file-not-exist error, but got: %v", err)
})
t.Run("update and check", func(t *testing.T) {
err := Update()
require.NoError(t, err, "Update() failed")
err = Check()
assert.NoError(t, err, "Check() failed immediately after Update()")
})
// This test uses synctest to simulate time passing.
// NOTE: This test requires GOEXPERIMENT=synctest to run.
t.Run("check with simulated time", func(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
// Update the file to set the initial timestamp.
require.NoError(t, Update(), "Update() failed inside synctest")
// Set the mtime to the current fake time to align the file's timestamp with the simulated clock.
now := time.Now()
require.NoError(t, os.Chtimes(healthFile, now, now), "Chtimes failed")
// Wait a duration less than the threshold.
time.Sleep(89 * time.Second)
synctest.Wait()
// The check should still pass.
assert.NoError(t, Check(), "Check() failed after 89s")
// Wait for the total duration to exceed the threshold.
time.Sleep(5 * time.Second)
synctest.Wait()
// The check should now fail as unhealthy.
err := Check()
require.Error(t, err, "Check() should have failed after 91s")
assert.Equal(t, "unhealthy", err.Error(), "Check() returned wrong error")
})
})
}

80
agent/lhm/beszel_lhm.cs Normal file
View File

@@ -0,0 +1,80 @@
using System;
using System.Globalization;
using LibreHardwareMonitor.Hardware;
class Program
{
static void Main()
{
var computer = new Computer
{
IsCpuEnabled = true,
IsGpuEnabled = true,
IsMemoryEnabled = true,
IsMotherboardEnabled = true,
IsStorageEnabled = true,
// IsPsuEnabled = true,
// IsNetworkEnabled = true,
};
computer.Open();
var reader = Console.In;
var writer = Console.Out;
string line;
while ((line = reader.ReadLine()) != null)
{
if (line.Trim().Equals("getTemps", StringComparison.OrdinalIgnoreCase))
{
foreach (var hw in computer.Hardware)
{
// process main hardware sensors
ProcessSensors(hw, writer);
// process subhardware sensors
foreach (var subhardware in hw.SubHardware)
{
ProcessSensors(subhardware, writer);
}
}
// send empty line to signal end of sensor data
writer.WriteLine();
writer.Flush();
}
}
computer.Close();
}
static void ProcessSensors(IHardware hardware, System.IO.TextWriter writer)
{
var updated = false;
foreach (var sensor in hardware.Sensors)
{
var validTemp = sensor.SensorType == SensorType.Temperature && sensor.Value.HasValue;
if (!validTemp || sensor.Name.Contains("Distance"))
{
continue;
}
if (!updated)
{
hardware.Update();
updated = true;
}
var name = sensor.Name;
// if sensor.Name starts with "Temperature" replace with hardware.Identifier but retain the rest of the name.
// usually this is a number like Temperature 3
if (sensor.Name.StartsWith("Temperature"))
{
name = hardware.Identifier.ToString().Replace("/", "_").TrimStart('_') + sensor.Name.Substring(11);
}
// invariant culture assures the value is parsable as a float
var value = sensor.Value.Value.ToString("0.##", CultureInfo.InvariantCulture);
// write the name and value to the writer
writer.WriteLine($"{name}|{value}");
}
}
}

View File

@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net48</TargetFramework>
<Platforms>x64</Platforms>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="LibreHardwareMonitorLib" Version="0.9.4" />
</ItemGroup>
</Project>

140
agent/network.go Normal file
View File

@@ -0,0 +1,140 @@
package agent
import (
"fmt"
"log/slog"
"strings"
"time"
"github.com/henrygd/beszel/internal/entities/system"
psutilNet "github.com/shirou/gopsutil/v4/net"
)
func (a *Agent) updateNetworkStats(systemStats *system.Stats) {
// network stats
if len(a.netInterfaces) == 0 {
// if no network interfaces, initialize again
// this is a fix if agent started before network is online (#466)
// maybe refactor this in the future to not cache interface names at all so we
// don't miss an interface that's been added after agent started in any circumstance
a.initializeNetIoStats()
}
if systemStats.NetworkInterfaces == nil {
systemStats.NetworkInterfaces = make(map[string][4]uint64, 0)
}
if netIO, err := psutilNet.IOCounters(true); err == nil {
msElapsed := uint64(time.Since(a.netIoStats.Time).Milliseconds())
a.netIoStats.Time = time.Now()
totalBytesSent := uint64(0)
totalBytesRecv := uint64(0)
netInterfaceDeltaTracker.Cycle()
// 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
}
totalBytesSent += v.BytesSent
totalBytesRecv += v.BytesRecv
// track deltas for each network interface
netInterfaceDeltaTracker.Set(fmt.Sprintf("%sdown", v.Name), v.BytesRecv)
netInterfaceDeltaTracker.Set(fmt.Sprintf("%sup", v.Name), v.BytesSent)
var upDelta, downDelta uint64
if msElapsed > 0 {
upDelta = netInterfaceDeltaTracker.Delta(fmt.Sprintf("%sup", v.Name)) * 1000 / msElapsed
downDelta = netInterfaceDeltaTracker.Delta(fmt.Sprintf("%sdown", v.Name)) * 1000 / msElapsed
}
// add interface to systemStats
systemStats.NetworkInterfaces[v.Name] = [4]uint64{upDelta, downDelta, v.BytesSent, v.BytesRecv}
}
// add to systemStats
var bytesSentPerSecond, bytesRecvPerSecond uint64
if msElapsed > 0 {
bytesSentPerSecond = (totalBytesSent - a.netIoStats.BytesSent) * 1000 / msElapsed
bytesRecvPerSecond = (totalBytesRecv - a.netIoStats.BytesRecv) * 1000 / msElapsed
}
networkSentPs := bytesToMegabytes(float64(bytesSentPerSecond))
networkRecvPs := bytesToMegabytes(float64(bytesRecvPerSecond))
// 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
systemStats.Bandwidth[0], systemStats.Bandwidth[1] = bytesSentPerSecond, bytesRecvPerSecond
// update netIoStats
a.netIoStats.BytesSent = totalBytesSent
a.netIoStats.BytesRecv = totalBytesRecv
}
}
}
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 := GetEnv("NICS")
if nicsEnvExists {
nicsMap = make(map[string]struct{}, 0)
for nic := range strings.SplitSeq(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"),
strings.HasPrefix(v.Name, "bond"),
v.BytesRecv == 0,
v.BytesSent == 0:
return true
default:
return false
}
}

198
agent/sensors.go Normal file
View File

@@ -0,0 +1,198 @@
package agent
import (
"context"
"fmt"
"log/slog"
"path"
"runtime"
"strconv"
"strings"
"unicode/utf8"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/shirou/gopsutil/v4/common"
"github.com/shirou/gopsutil/v4/sensors"
)
type SensorConfig struct {
context context.Context
sensors map[string]struct{}
primarySensor string
isBlacklist bool
hasWildcards bool
skipCollection bool
}
func (a *Agent) newSensorConfig() *SensorConfig {
primarySensor, _ := GetEnv("PRIMARY_SENSOR")
sysSensors, _ := GetEnv("SYS_SENSORS")
sensorsEnvVal, sensorsSet := GetEnv("SENSORS")
skipCollection := sensorsSet && sensorsEnvVal == ""
return a.newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal, skipCollection)
}
// Matches sensors.TemperaturesWithContext to allow for panic recovery (gopsutil/issues/1832)
type getTempsFn func(ctx context.Context) ([]sensors.TemperatureStat, error)
// newSensorConfigWithEnv creates a SensorConfig with the provided environment variables
// sensorsSet indicates if the SENSORS environment variable was explicitly set (even to empty string)
func (a *Agent) newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal string, skipCollection bool) *SensorConfig {
config := &SensorConfig{
context: context.Background(),
primarySensor: primarySensor,
skipCollection: skipCollection,
sensors: make(map[string]struct{}),
}
// Set sensors context (allows overriding sys location for sensors)
if sysSensors != "" {
slog.Info("SYS_SENSORS", "path", sysSensors)
config.context = context.WithValue(config.context,
common.EnvKey, common.EnvMap{common.HostSysEnvKey: sysSensors},
)
}
// handle blacklist
if strings.HasPrefix(sensorsEnvVal, "-") {
config.isBlacklist = true
sensorsEnvVal = sensorsEnvVal[1:]
}
for sensor := range strings.SplitSeq(sensorsEnvVal, ",") {
sensor = strings.TrimSpace(sensor)
if sensor != "" {
config.sensors[sensor] = struct{}{}
if strings.Contains(sensor, "*") {
config.hasWildcards = true
}
}
}
return config
}
// updateTemperatures updates the agent with the latest sensor temperatures
func (a *Agent) updateTemperatures(systemStats *system.Stats) {
// skip if sensors whitelist is set to empty string
if a.sensorConfig.skipCollection {
slog.Debug("Skipping temperature collection")
return
}
// reset high temp
a.systemInfo.DashboardTemp = 0
temps, err := a.getTempsWithPanicRecovery(getSensorTemps)
if err != nil {
// retry once on panic (gopsutil/issues/1832)
temps, err = a.getTempsWithPanicRecovery(getSensorTemps)
if err != nil {
slog.Warn("Error updating temperatures", "err", err)
if len(systemStats.Temperatures) > 0 {
systemStats.Temperatures = make(map[string]float64)
}
return
}
}
slog.Debug("Temperature", "sensors", temps)
// return if no sensors
if len(temps) == 0 {
return
}
systemStats.Temperatures = make(map[string]float64, len(temps))
for i, sensor := range temps {
// check for malformed strings on darwin (gopsutil/issues/1832)
if runtime.GOOS == "darwin" && !utf8.ValidString(sensor.SensorKey) {
continue
}
// scale temperature
if sensor.Temperature != 0 && sensor.Temperature < 1 {
sensor.Temperature = scaleTemperature(sensor.Temperature)
}
// skip if temperature is unreasonable
if sensor.Temperature <= 0 || sensor.Temperature >= 200 {
continue
}
sensorName := sensor.SensorKey
if _, ok := systemStats.Temperatures[sensorName]; ok {
// if key already exists, append int to key
sensorName = sensorName + "_" + strconv.Itoa(i)
}
// skip if not in whitelist or blacklist
if !isValidSensor(sensorName, a.sensorConfig) {
continue
}
// set dashboard temperature
switch a.sensorConfig.primarySensor {
case "":
a.systemInfo.DashboardTemp = max(a.systemInfo.DashboardTemp, sensor.Temperature)
case sensorName:
a.systemInfo.DashboardTemp = sensor.Temperature
}
systemStats.Temperatures[sensorName] = twoDecimals(sensor.Temperature)
}
}
// getTempsWithPanicRecovery wraps sensors.TemperaturesWithContext to recover from panics (gopsutil/issues/1832)
func (a *Agent) getTempsWithPanicRecovery(getTemps getTempsFn) (temps []sensors.TemperatureStat, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("panic: %v", r)
}
}()
// get sensor data (error ignored intentionally as it may be only with one sensor)
temps, _ = getTemps(a.sensorConfig.context)
return
}
// isValidSensor checks if a sensor is valid based on the sensor name and the sensor config
func isValidSensor(sensorName string, config *SensorConfig) bool {
// if no sensors configured, everything is valid
if len(config.sensors) == 0 {
return true
}
// Exact match - return true if whitelist, false if blacklist
if _, exactMatch := config.sensors[sensorName]; exactMatch {
return !config.isBlacklist
}
// If no wildcards, return true if blacklist, false if whitelist
if !config.hasWildcards {
return config.isBlacklist
}
// Check for wildcard patterns
for pattern := range config.sensors {
if !strings.Contains(pattern, "*") {
continue
}
if match, _ := path.Match(pattern, sensorName); match {
return !config.isBlacklist
}
}
return config.isBlacklist
}
// scaleTemperature scales temperatures in fractional values to reasonable Celsius values
func scaleTemperature(temp float64) float64 {
if temp > 1 {
return temp
}
scaled100 := temp * 100
scaled1000 := temp * 1000
if scaled100 >= 15 && scaled100 <= 95 {
return scaled100
} else if scaled1000 >= 15 && scaled1000 <= 95 {
return scaled1000
}
return scaled100
}

9
agent/sensors_default.go Normal file
View File

@@ -0,0 +1,9 @@
//go:build !windows
package agent
import (
"github.com/shirou/gopsutil/v4/sensors"
)
var getSensorTemps = sensors.TemperaturesWithContext

554
agent/sensors_test.go Normal file
View File

@@ -0,0 +1,554 @@
//go:build testing
// +build testing
package agent
import (
"context"
"fmt"
"os"
"testing"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/shirou/gopsutil/v4/common"
"github.com/shirou/gopsutil/v4/sensors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestIsValidSensor(t *testing.T) {
tests := []struct {
name string
sensorName string
config *SensorConfig
expectedValid bool
}{
{
name: "Whitelist - sensor in list",
sensorName: "cpu_temp",
config: &SensorConfig{
sensors: map[string]struct{}{"cpu_temp": {}},
isBlacklist: false,
},
expectedValid: true,
},
{
name: "Whitelist - sensor not in list",
sensorName: "gpu_temp",
config: &SensorConfig{
sensors: map[string]struct{}{"cpu_temp": {}},
isBlacklist: false,
},
expectedValid: false,
},
{
name: "Blacklist - sensor in list",
sensorName: "cpu_temp",
config: &SensorConfig{
sensors: map[string]struct{}{"cpu_temp": {}},
isBlacklist: true,
},
expectedValid: false,
},
{
name: "Blacklist - sensor not in list",
sensorName: "gpu_temp",
config: &SensorConfig{
sensors: map[string]struct{}{"cpu_temp": {}},
isBlacklist: true,
},
expectedValid: true,
},
{
name: "Whitelist with wildcard - matching pattern",
sensorName: "core_0_temp",
config: &SensorConfig{
sensors: map[string]struct{}{"core_*_temp": {}},
isBlacklist: false,
hasWildcards: true,
},
expectedValid: true,
},
{
name: "Whitelist with wildcard - non-matching pattern",
sensorName: "gpu_temp",
config: &SensorConfig{
sensors: map[string]struct{}{"core_*_temp": {}},
isBlacklist: false,
hasWildcards: true,
},
expectedValid: false,
},
{
name: "Blacklist with wildcard - matching pattern",
sensorName: "core_0_temp",
config: &SensorConfig{
sensors: map[string]struct{}{"core_*_temp": {}},
isBlacklist: true,
hasWildcards: true,
},
expectedValid: false,
},
{
name: "Blacklist with wildcard - non-matching pattern",
sensorName: "gpu_temp",
config: &SensorConfig{
sensors: map[string]struct{}{"core_*_temp": {}},
isBlacklist: true,
hasWildcards: true,
},
expectedValid: true,
},
{
name: "No sensors configured",
sensorName: "any_temp",
config: &SensorConfig{
sensors: map[string]struct{}{},
isBlacklist: false,
hasWildcards: false,
skipCollection: false,
},
expectedValid: true,
},
{
name: "Mixed patterns in whitelist - exact match",
sensorName: "cpu_temp",
config: &SensorConfig{
sensors: map[string]struct{}{"cpu_temp": {}, "core_*_temp": {}},
isBlacklist: false,
hasWildcards: true,
},
expectedValid: true,
},
{
name: "Mixed patterns in whitelist - wildcard match",
sensorName: "core_1_temp",
config: &SensorConfig{
sensors: map[string]struct{}{"cpu_temp": {}, "core_*_temp": {}},
isBlacklist: false,
hasWildcards: true,
},
expectedValid: true,
},
{
name: "Mixed patterns in blacklist - exact match",
sensorName: "cpu_temp",
config: &SensorConfig{
sensors: map[string]struct{}{"cpu_temp": {}, "core_*_temp": {}},
isBlacklist: true,
hasWildcards: true,
},
expectedValid: false,
},
{
name: "Mixed patterns in blacklist - wildcard match",
sensorName: "core_1_temp",
config: &SensorConfig{
sensors: map[string]struct{}{"cpu_temp": {}, "core_*_temp": {}},
isBlacklist: true,
hasWildcards: true,
},
expectedValid: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := isValidSensor(tt.sensorName, tt.config)
assert.Equal(t, tt.expectedValid, result, "isValidSensor(%q, config) returned unexpected result", tt.sensorName)
})
}
}
func TestNewSensorConfigWithEnv(t *testing.T) {
agent := &Agent{}
tests := []struct {
name string
primarySensor string
sysSensors string
sensors string
skipCollection bool
expectedConfig *SensorConfig
}{
{
name: "Empty configuration",
primarySensor: "",
sysSensors: "",
sensors: "",
expectedConfig: &SensorConfig{
context: context.Background(),
primarySensor: "",
sensors: map[string]struct{}{},
isBlacklist: false,
hasWildcards: false,
skipCollection: false,
},
},
{
name: "Explicitly set to empty string",
primarySensor: "",
sysSensors: "",
sensors: "",
skipCollection: true,
expectedConfig: &SensorConfig{
context: context.Background(),
primarySensor: "",
sensors: map[string]struct{}{},
isBlacklist: false,
hasWildcards: false,
skipCollection: true,
},
},
{
name: "Primary sensor only - should create sensor map",
primarySensor: "cpu_temp",
sysSensors: "",
sensors: "",
expectedConfig: &SensorConfig{
context: context.Background(),
primarySensor: "cpu_temp",
sensors: map[string]struct{}{},
isBlacklist: false,
hasWildcards: false,
},
},
{
name: "Whitelist sensors",
primarySensor: "cpu_temp",
sysSensors: "",
sensors: "cpu_temp,gpu_temp",
expectedConfig: &SensorConfig{
context: context.Background(),
primarySensor: "cpu_temp",
sensors: map[string]struct{}{
"cpu_temp": {},
"gpu_temp": {},
},
isBlacklist: false,
hasWildcards: false,
},
},
{
name: "Blacklist sensors",
primarySensor: "cpu_temp",
sysSensors: "",
sensors: "-cpu_temp,gpu_temp",
expectedConfig: &SensorConfig{
context: context.Background(),
primarySensor: "cpu_temp",
sensors: map[string]struct{}{
"cpu_temp": {},
"gpu_temp": {},
},
isBlacklist: true,
hasWildcards: false,
},
},
{
name: "Sensors with wildcard",
primarySensor: "cpu_temp",
sysSensors: "",
sensors: "cpu_*,gpu_temp",
expectedConfig: &SensorConfig{
context: context.Background(),
primarySensor: "cpu_temp",
sensors: map[string]struct{}{
"cpu_*": {},
"gpu_temp": {},
},
isBlacklist: false,
hasWildcards: true,
},
},
{
name: "Sensors with whitespace",
primarySensor: "cpu_temp",
sysSensors: "",
sensors: "cpu_*, gpu_temp",
expectedConfig: &SensorConfig{
context: context.Background(),
primarySensor: "cpu_temp",
sensors: map[string]struct{}{
"cpu_*": {},
"gpu_temp": {},
},
isBlacklist: false,
hasWildcards: true,
},
},
{
name: "With SYS_SENSORS path",
primarySensor: "cpu_temp",
sysSensors: "/custom/path",
sensors: "cpu_temp",
expectedConfig: &SensorConfig{
primarySensor: "cpu_temp",
sensors: map[string]struct{}{
"cpu_temp": {},
},
isBlacklist: false,
hasWildcards: false,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := agent.newSensorConfigWithEnv(tt.primarySensor, tt.sysSensors, tt.sensors, tt.skipCollection)
// Check primary sensor
assert.Equal(t, tt.expectedConfig.primarySensor, result.primarySensor)
// Check sensor map
if tt.expectedConfig.sensors == nil {
assert.Nil(t, result.sensors)
} else {
assert.Equal(t, len(tt.expectedConfig.sensors), len(result.sensors))
for sensor := range tt.expectedConfig.sensors {
_, exists := result.sensors[sensor]
assert.True(t, exists, "Sensor %s should exist in the result", sensor)
}
}
// Check flags
assert.Equal(t, tt.expectedConfig.isBlacklist, result.isBlacklist)
assert.Equal(t, tt.expectedConfig.hasWildcards, result.hasWildcards)
// Check context
if tt.sysSensors != "" {
// Verify context contains correct values
envMap, ok := result.context.Value(common.EnvKey).(common.EnvMap)
require.True(t, ok, "Context should contain EnvMap")
sysPath, ok := envMap[common.HostSysEnvKey]
require.True(t, ok, "EnvMap should contain HostSysEnvKey")
assert.Equal(t, tt.sysSensors, sysPath)
}
})
}
}
func TestNewSensorConfig(t *testing.T) {
// Save original environment variables
originalPrimary, hasPrimary := os.LookupEnv("BESZEL_AGENT_PRIMARY_SENSOR")
originalSys, hasSys := os.LookupEnv("BESZEL_AGENT_SYS_SENSORS")
originalSensors, hasSensors := os.LookupEnv("BESZEL_AGENT_SENSORS")
// Restore environment variables after the test
defer func() {
// Clean up test environment variables
os.Unsetenv("BESZEL_AGENT_PRIMARY_SENSOR")
os.Unsetenv("BESZEL_AGENT_SYS_SENSORS")
os.Unsetenv("BESZEL_AGENT_SENSORS")
// Restore original values if they existed
if hasPrimary {
os.Setenv("BESZEL_AGENT_PRIMARY_SENSOR", originalPrimary)
}
if hasSys {
os.Setenv("BESZEL_AGENT_SYS_SENSORS", originalSys)
}
if hasSensors {
os.Setenv("BESZEL_AGENT_SENSORS", originalSensors)
}
}()
// Set test environment variables
os.Setenv("BESZEL_AGENT_PRIMARY_SENSOR", "test_primary")
os.Setenv("BESZEL_AGENT_SYS_SENSORS", "/test/path")
os.Setenv("BESZEL_AGENT_SENSORS", "test_sensor1,test_*,test_sensor3")
agent := &Agent{}
result := agent.newSensorConfig()
// Verify results
assert.Equal(t, "test_primary", result.primarySensor)
assert.NotNil(t, result.sensors)
assert.Equal(t, 3, len(result.sensors))
assert.True(t, result.hasWildcards)
assert.False(t, result.isBlacklist)
// Check that sys sensors path is in context
envMap, ok := result.context.Value(common.EnvKey).(common.EnvMap)
require.True(t, ok, "Context should contain EnvMap")
sysPath, ok := envMap[common.HostSysEnvKey]
require.True(t, ok, "EnvMap should contain HostSysEnvKey")
assert.Equal(t, "/test/path", sysPath)
}
func TestScaleTemperature(t *testing.T) {
tests := []struct {
name string
input float64
expected float64
desc string
}{
// Normal temperatures (no scaling needed)
{"normal_cpu_temp", 45.0, 45.0, "Normal CPU temperature"},
{"normal_room_temp", 25.0, 25.0, "Normal room temperature"},
{"high_cpu_temp", 85.0, 85.0, "High CPU temperature"},
// Zero temperature
{"zero_temp", 0.0, 0.0, "Zero temperature"},
// Fractional values that should use 100x scaling
{"fractional_45c", 0.45, 45.0, "0.45 should become 45°C (100x)"},
{"fractional_25c", 0.25, 25.0, "0.25 should become 25°C (100x)"},
{"fractional_60c", 0.60, 60.0, "0.60 should become 60°C (100x)"},
{"fractional_75c", 0.75, 75.0, "0.75 should become 75°C (100x)"},
{"fractional_30c", 0.30, 30.0, "0.30 should become 30°C (100x)"},
// Fractional values that should use 1000x scaling
{"millifractional_45c", 0.045, 45.0, "0.045 should become 45°C (1000x)"},
{"millifractional_25c", 0.025, 25.0, "0.025 should become 25°C (1000x)"},
{"millifractional_60c", 0.060, 60.0, "0.060 should become 60°C (1000x)"},
{"millifractional_75c", 0.075, 75.0, "0.075 should become 75°C (1000x)"},
{"millifractional_35c", 0.035, 35.0, "0.035 should become 35°C (1000x)"},
// Edge cases - values outside reasonable range
{"very_low_fractional", 0.01, 1.0, "0.01 should default to 100x scaling (1°C)"},
{"very_high_fractional", 0.99, 99.0, "0.99 should default to 100x scaling (99°C)"},
{"extremely_low", 0.001, 0.1, "0.001 should default to 100x scaling (0.1°C)"},
// Boundary cases around the reasonable range (15-95°C)
{"boundary_low_100x", 0.15, 15.0, "0.15 should use 100x scaling (15°C)"},
{"boundary_high_100x", 0.95, 95.0, "0.95 should use 100x scaling (95°C)"},
{"boundary_low_1000x", 0.015, 15.0, "0.015 should use 1000x scaling (15°C)"},
{"boundary_high_1000x", 0.095, 95.0, "0.095 should use 1000x scaling (95°C)"},
// Values just outside reasonable range
{"just_below_range_100x", 0.14, 14.0, "0.14 should default to 100x (14°C)"},
{"just_above_range_100x", 0.96, 96.0, "0.96 should default to 100x (96°C)"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := scaleTemperature(tt.input)
assert.InDelta(t, tt.expected, result, 0.001,
"scaleTemperature(%v) = %v, expected %v (%s)",
tt.input, result, tt.expected, tt.desc)
})
}
}
func TestScaleTemperatureLogic(t *testing.T) {
// Test the logic flow for ambiguous cases
t.Run("prefers_100x_when_both_valid", func(t *testing.T) {
// 0.5 could be 50°C (100x) or 500°C (1000x)
// Should prefer 100x since it's tried first and is in range
result := scaleTemperature(0.5)
expected := 50.0
assert.InDelta(t, expected, result, 0.001,
"scaleTemperature(0.5) = %v, expected %v (should prefer 100x scaling)",
result, expected)
})
t.Run("uses_1000x_when_100x_too_low", func(t *testing.T) {
// 0.05 -> 5°C (100x, too low) or 50°C (1000x, in range)
// Should use 1000x since 100x is below reasonable range
result := scaleTemperature(0.05)
expected := 50.0
assert.InDelta(t, expected, result, 0.001,
"scaleTemperature(0.05) = %v, expected %v (should use 1000x scaling)",
result, expected)
})
t.Run("defaults_to_100x_when_both_invalid", func(t *testing.T) {
// 0.005 -> 0.5°C (100x, too low) or 5°C (1000x, too low)
// Should default to 100x scaling
result := scaleTemperature(0.005)
expected := 0.5
assert.InDelta(t, expected, result, 0.001,
"scaleTemperature(0.005) = %v, expected %v (should default to 100x)",
result, expected)
})
}
func TestGetTempsWithPanicRecovery(t *testing.T) {
agent := &Agent{
systemInfo: system.Info{},
sensorConfig: &SensorConfig{
context: context.Background(),
},
}
tests := []struct {
name string
getTempsFn getTempsFn
expectError bool
errorMsg string
}{
{
name: "successful_function_call",
getTempsFn: func(ctx context.Context) ([]sensors.TemperatureStat, error) {
return []sensors.TemperatureStat{
{SensorKey: "test_sensor", Temperature: 45.0},
}, nil
},
expectError: false,
},
{
name: "function_returns_error",
getTempsFn: func(ctx context.Context) ([]sensors.TemperatureStat, error) {
return []sensors.TemperatureStat{
{SensorKey: "test_sensor", Temperature: 45.0},
}, fmt.Errorf("sensor error")
},
expectError: false, // getTempsWithPanicRecovery ignores errors from the function
},
{
name: "function_panics_with_string",
getTempsFn: func(ctx context.Context) ([]sensors.TemperatureStat, error) {
panic("test panic")
},
expectError: true,
errorMsg: "panic: test panic",
},
{
name: "function_panics_with_error",
getTempsFn: func(ctx context.Context) ([]sensors.TemperatureStat, error) {
panic(fmt.Errorf("panic error"))
},
expectError: true,
errorMsg: "panic:",
},
{
name: "function_panics_with_index_out_of_bounds",
getTempsFn: func(ctx context.Context) ([]sensors.TemperatureStat, error) {
slice := []int{1, 2, 3}
_ = slice[10] // out of bounds panic
return nil, nil
},
expectError: true,
errorMsg: "panic:",
},
{
name: "function_panics_with_any_conversion",
getTempsFn: func(ctx context.Context) ([]sensors.TemperatureStat, error) {
var i any = "string"
_ = i.(int) // type assertion panic
return nil, nil
},
expectError: true,
errorMsg: "panic:",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var temps []sensors.TemperatureStat
var err error
// The function should not panic, regardless of what the injected function does
assert.NotPanics(t, func() {
temps, err = agent.getTempsWithPanicRecovery(tt.getTempsFn)
}, "getTempsWithPanicRecovery should not panic")
if tt.expectError {
assert.Error(t, err, "Expected an error to be returned")
if tt.errorMsg != "" {
assert.Contains(t, err.Error(), tt.errorMsg,
"Error message should contain expected text")
}
assert.Nil(t, temps, "Temps should be nil when panic occurs")
} else {
assert.NoError(t, err, "Should not return error for successful calls")
}
})
}
}

286
agent/sensors_windows.go Normal file
View File

@@ -0,0 +1,286 @@
//go:build windows
//go:generate dotnet build -c Release lhm/beszel_lhm.csproj
package agent
import (
"bufio"
"context"
"embed"
"errors"
"fmt"
"io"
"log/slog"
"os"
"os/exec"
"path"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"github.com/shirou/gopsutil/v4/sensors"
)
// Note: This is always called from Agent.gatherStats() which holds Agent.Lock(),
// so no internal concurrency protection is needed.
// lhmProcess is a wrapper around the LHM .NET process.
type lhmProcess struct {
cmd *exec.Cmd
stdin io.WriteCloser
stdout io.ReadCloser
scanner *bufio.Scanner
isRunning bool
stoppedNoSensors bool
consecutiveNoSensors uint8
execPath string
tempDir string
}
//go:embed all:lhm/bin/Release/net48
var lhmFs embed.FS
var (
beszelLhm *lhmProcess
beszelLhmOnce sync.Once
useLHM = os.Getenv("LHM") == "true"
)
var errNoSensors = errors.New("no sensors found (try running as admin with LHM=true)")
// newlhmProcess copies the embedded LHM executable to a temporary directory and starts it.
func newlhmProcess() (*lhmProcess, error) {
destDir := filepath.Join(os.TempDir(), "beszel")
execPath := filepath.Join(destDir, "beszel_lhm.exe")
if err := os.MkdirAll(destDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create temp directory: %w", err)
}
// Only copy if executable doesn't exist
if _, err := os.Stat(execPath); os.IsNotExist(err) {
if err := copyEmbeddedDir(lhmFs, "lhm/bin/Release/net48", destDir); err != nil {
return nil, fmt.Errorf("failed to copy embedded directory: %w", err)
}
}
lhm := &lhmProcess{
execPath: execPath,
tempDir: destDir,
}
if err := lhm.startProcess(); err != nil {
return nil, fmt.Errorf("failed to start process: %w", err)
}
return lhm, nil
}
// startProcess starts the external LHM process
func (lhm *lhmProcess) startProcess() error {
// Clean up any existing process
lhm.cleanupProcess()
cmd := exec.Command(lhm.execPath)
stdin, err := cmd.StdinPipe()
if err != nil {
return err
}
stdout, err := cmd.StdoutPipe()
if err != nil {
stdin.Close()
return err
}
if err := cmd.Start(); err != nil {
stdin.Close()
stdout.Close()
return err
}
// Update process state
lhm.cmd = cmd
lhm.stdin = stdin
lhm.stdout = stdout
lhm.scanner = bufio.NewScanner(stdout)
lhm.isRunning = true
// Give process a moment to initialize
time.Sleep(100 * time.Millisecond)
return nil
}
// cleanupProcess terminates the process and closes resources but preserves files
func (lhm *lhmProcess) cleanupProcess() {
lhm.isRunning = false
if lhm.cmd != nil && lhm.cmd.Process != nil {
lhm.cmd.Process.Kill()
lhm.cmd.Wait()
}
if lhm.stdin != nil {
lhm.stdin.Close()
lhm.stdin = nil
}
if lhm.stdout != nil {
lhm.stdout.Close()
lhm.stdout = nil
}
lhm.cmd = nil
lhm.scanner = nil
lhm.stoppedNoSensors = false
lhm.consecutiveNoSensors = 0
}
func (lhm *lhmProcess) getTemps(ctx context.Context) (temps []sensors.TemperatureStat, err error) {
if !useLHM || lhm.stoppedNoSensors {
// Fall back to gopsutil if we can't get sensors from LHM
return sensors.TemperaturesWithContext(ctx)
}
// Start process if it's not running
if !lhm.isRunning || lhm.stdin == nil || lhm.scanner == nil {
err := lhm.startProcess()
if err != nil {
return temps, err
}
}
// Send command to process
_, err = fmt.Fprintln(lhm.stdin, "getTemps")
if err != nil {
lhm.isRunning = false
return temps, fmt.Errorf("failed to send command: %w", err)
}
// Read all sensor lines until we hit an empty line or EOF
for lhm.scanner.Scan() {
line := strings.TrimSpace(lhm.scanner.Text())
if line == "" {
break
}
parts := strings.Split(line, "|")
if len(parts) != 2 {
slog.Debug("Invalid sensor format", "line", line)
continue
}
name := strings.TrimSpace(parts[0])
valueStr := strings.TrimSpace(parts[1])
value, err := strconv.ParseFloat(valueStr, 64)
if err != nil {
slog.Debug("Failed to parse sensor", "err", err, "line", line)
continue
}
if name == "" || value <= 0 || value > 150 {
slog.Debug("Invalid sensor", "name", name, "val", value, "line", line)
continue
}
temps = append(temps, sensors.TemperatureStat{
SensorKey: name,
Temperature: value,
})
}
if err := lhm.scanner.Err(); err != nil {
lhm.isRunning = false
return temps, err
}
// Handle no sensors case
if len(temps) == 0 {
lhm.consecutiveNoSensors++
if lhm.consecutiveNoSensors >= 3 {
lhm.stoppedNoSensors = true
slog.Warn(errNoSensors.Error())
lhm.cleanup()
}
return sensors.TemperaturesWithContext(ctx)
}
lhm.consecutiveNoSensors = 0
return temps, nil
}
// getSensorTemps attempts to pull sensor temperatures from the embedded LHM process.
// NB: LibreHardwareMonitorLib requires admin privileges to access all available sensors.
func getSensorTemps(ctx context.Context) (temps []sensors.TemperatureStat, err error) {
defer func() {
if err != nil {
slog.Debug("Error reading sensors", "err", err)
}
}()
if !useLHM {
return sensors.TemperaturesWithContext(ctx)
}
// Initialize process once
beszelLhmOnce.Do(func() {
beszelLhm, err = newlhmProcess()
})
if err != nil {
return temps, fmt.Errorf("failed to initialize lhm: %w", err)
}
if beszelLhm == nil {
return temps, fmt.Errorf("lhm not available")
}
return beszelLhm.getTemps(ctx)
}
// cleanup terminates the process and closes resources
func (lhm *lhmProcess) cleanup() {
lhm.cleanupProcess()
if lhm.tempDir != "" {
os.RemoveAll(lhm.tempDir)
}
}
// copyEmbeddedDir copies the embedded directory to the destination path
func copyEmbeddedDir(fs embed.FS, srcPath, destPath string) error {
entries, err := fs.ReadDir(srcPath)
if err != nil {
return err
}
if err := os.MkdirAll(destPath, 0755); err != nil {
return err
}
for _, entry := range entries {
srcEntryPath := path.Join(srcPath, entry.Name())
destEntryPath := filepath.Join(destPath, entry.Name())
if entry.IsDir() {
if err := copyEmbeddedDir(fs, srcEntryPath, destEntryPath); err != nil {
return err
}
continue
}
data, err := fs.ReadFile(srcEntryPath)
if err != nil {
return err
}
if err := os.WriteFile(destEntryPath, data, 0755); err != nil {
return err
}
}
return nil
}

224
agent/server.go Normal file
View File

@@ -0,0 +1,224 @@
package agent
import (
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net"
"os"
"strings"
"time"
"github.com/henrygd/beszel"
"github.com/henrygd/beszel/internal/common"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/blang/semver"
"github.com/fxamacker/cbor/v2"
"github.com/gliderlabs/ssh"
gossh "golang.org/x/crypto/ssh"
)
// ServerOptions contains configuration options for starting the SSH server.
type ServerOptions struct {
Addr string // Network address to listen on (e.g., ":45876" or "/path/to/socket")
Network string // Network type ("tcp" or "unix")
Keys []gossh.PublicKey // SSH public keys for authentication
}
// hubVersions caches hub versions by session ID to avoid repeated parsing.
var hubVersions map[string]semver.Version
// StartServer starts the SSH server with the provided options.
// It configures the server with secure defaults, sets up authentication,
// and begins listening for connections. Returns an error if the server
// is already running or if there's an issue starting the server.
func (a *Agent) StartServer(opts ServerOptions) error {
if a.server != nil {
return errors.New("server already started")
}
slog.Info("Starting SSH server", "addr", opts.Addr, "network", opts.Network)
if opts.Network == "unix" {
// remove existing socket file if it exists
if err := os.Remove(opts.Addr); err != nil && !os.IsNotExist(err) {
return err
}
}
// start listening on the address
ln, err := net.Listen(opts.Network, opts.Addr)
if err != nil {
return err
}
defer ln.Close()
// base config (limit to allowed algorithms)
config := &gossh.ServerConfig{
ServerVersion: fmt.Sprintf("SSH-2.0-%s_%s", beszel.AppName, beszel.Version),
}
config.KeyExchanges = common.DefaultKeyExchanges
config.MACs = common.DefaultMACs
config.Ciphers = common.DefaultCiphers
// set default handler
ssh.Handle(a.handleSession)
a.server = &ssh.Server{
ServerConfigCallback: func(ctx ssh.Context) *gossh.ServerConfig {
return config
},
// check public key(s)
PublicKeyHandler: func(ctx ssh.Context, key ssh.PublicKey) bool {
remoteAddr := ctx.RemoteAddr()
for _, pubKey := range opts.Keys {
if ssh.KeysEqual(key, pubKey) {
slog.Info("SSH connected", "addr", remoteAddr)
return true
}
}
slog.Warn("Invalid SSH key", "addr", remoteAddr)
return false
},
// disable pty
PtyCallback: func(ctx ssh.Context, pty ssh.Pty) bool {
return false
},
// close idle connections after 70 seconds
IdleTimeout: 70 * time.Second,
}
// Start SSH server on the listener
return a.server.Serve(ln)
}
// getHubVersion retrieves and caches the hub version for a given session.
// It extracts the version from the SSH client version string and caches
// it to avoid repeated parsing. Returns a zero version if parsing fails.
func (a *Agent) getHubVersion(sessionId string, sessionCtx ssh.Context) semver.Version {
if hubVersions == nil {
hubVersions = make(map[string]semver.Version, 1)
}
hubVersion, ok := hubVersions[sessionId]
if ok {
return hubVersion
}
// Extract hub version from SSH client version
clientVersion := sessionCtx.Value(ssh.ContextKeyClientVersion)
if versionStr, ok := clientVersion.(string); ok {
hubVersion, _ = extractHubVersion(versionStr)
}
hubVersions[sessionId] = hubVersion
return hubVersion
}
// handleSession handles an incoming SSH session by gathering system statistics
// and sending them to the hub. It signals connection events, determines the
// appropriate encoding format based on hub version, and exits with appropriate
// status codes.
func (a *Agent) handleSession(s ssh.Session) {
a.connectionManager.eventChan <- SSHConnect
sessionCtx := s.Context()
sessionID := sessionCtx.SessionID()
hubVersion := a.getHubVersion(sessionID, sessionCtx)
stats := a.gatherStats(sessionID)
err := a.writeToSession(s, stats, hubVersion)
if err != nil {
slog.Error("Error encoding stats", "err", err, "stats", stats)
s.Exit(1)
} else {
s.Exit(0)
}
}
// writeToSession encodes and writes system statistics to the session.
// It chooses between CBOR and JSON encoding based on the hub version,
// using CBOR for newer versions and JSON for legacy compatibility.
func (a *Agent) writeToSession(w io.Writer, stats *system.CombinedData, hubVersion semver.Version) error {
if hubVersion.GTE(beszel.MinVersionCbor) {
return cbor.NewEncoder(w).Encode(stats)
}
return json.NewEncoder(w).Encode(stats)
}
// extractHubVersion extracts the beszel version from SSH client version string.
// Expected format: "SSH-2.0-beszel_X.Y.Z" or "beszel_X.Y.Z"
func extractHubVersion(versionString string) (semver.Version, error) {
_, after, _ := strings.Cut(versionString, "_")
return semver.Parse(after)
}
// ParseKeys parses a string containing SSH public keys in authorized_keys format.
// It returns a slice of ssh.PublicKey and an error if any key fails to parse.
func ParseKeys(input string) ([]gossh.PublicKey, error) {
var parsedKeys []gossh.PublicKey
for line := range strings.Lines(input) {
line = strings.TrimSpace(line)
// Skip empty lines or comments
if len(line) == 0 || strings.HasPrefix(line, "#") {
continue
}
// Parse the key
parsedKey, _, _, _, err := gossh.ParseAuthorizedKey([]byte(line))
if err != nil {
return nil, fmt.Errorf("failed to parse key: %s, error: %w", line, err)
}
parsedKeys = append(parsedKeys, parsedKey)
}
return parsedKeys, nil
}
// GetAddress determines the network address to listen on from various sources.
// It checks the provided address, then environment variables (LISTEN, PORT),
// and finally defaults to ":45876".
func GetAddress(addr string) string {
if addr == "" {
addr, _ = GetEnv("LISTEN")
}
if addr == "" {
// Legacy PORT environment variable support
addr, _ = GetEnv("PORT")
}
if addr == "" {
return ":45876"
}
// prefix with : if only port was provided
if GetNetwork(addr) != "unix" && !strings.Contains(addr, ":") {
addr = ":" + addr
}
return addr
}
// GetNetwork determines the network type based on the address format.
// It checks the NETWORK environment variable first, then infers from
// the address format: addresses starting with "/" are "unix", others are "tcp".
func GetNetwork(addr string) string {
if network, ok := GetEnv("NETWORK"); ok && network != "" {
return network
}
if strings.HasPrefix(addr, "/") {
return "unix"
}
return "tcp"
}
// StopServer stops the SSH server if it's running.
// It returns an error if the server is not running or if there's an error stopping it.
func (a *Agent) StopServer() error {
if a.server == nil {
return errors.New("SSH server not running")
}
slog.Info("Stopping SSH server")
_ = a.server.Close()
a.server = nil
a.connectionManager.eventChan <- SSHDisconnect
return nil
}

606
agent/server_test.go Normal file
View File

@@ -0,0 +1,606 @@
package agent
import (
"context"
"crypto/ed25519"
"encoding/json"
"fmt"
"net"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/henrygd/beszel/internal/entities/container"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/blang/semver"
"github.com/fxamacker/cbor/v2"
"github.com/gliderlabs/ssh"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
gossh "golang.org/x/crypto/ssh"
)
func TestStartServer(t *testing.T) {
// Generate a test key pair
pubKey, privKey, err := ed25519.GenerateKey(nil)
require.NoError(t, err)
signer, err := gossh.NewSignerFromKey(privKey)
require.NoError(t, err)
sshPubKey, err := gossh.NewPublicKey(pubKey)
require.NoError(t, err)
// Generate a different key pair for bad key test
badPubKey, badPrivKey, err := ed25519.GenerateKey(nil)
require.NoError(t, err)
badSigner, err := gossh.NewSignerFromKey(badPrivKey)
require.NoError(t, err)
sshBadPubKey, err := gossh.NewPublicKey(badPubKey)
require.NoError(t, err)
socketFile := filepath.Join(t.TempDir(), "beszel-test.sock")
tests := []struct {
name string
config ServerOptions
wantErr bool
errContains string
setup func() error
cleanup func() error
}{
{
name: "tcp port only",
config: ServerOptions{
Network: "tcp",
Addr: ":45987",
Keys: []gossh.PublicKey{sshPubKey},
},
},
{
name: "tcp with ipv4",
config: ServerOptions{
Network: "tcp4",
Addr: "127.0.0.1:45988",
Keys: []gossh.PublicKey{sshPubKey},
},
},
{
name: "tcp with ipv6",
config: ServerOptions{
Network: "tcp6",
Addr: "[::1]:45989",
Keys: []gossh.PublicKey{sshPubKey},
},
},
{
name: "unix socket",
config: ServerOptions{
Network: "unix",
Addr: socketFile,
Keys: []gossh.PublicKey{sshPubKey},
},
setup: func() error {
// Create a socket file that should be removed
f, err := os.Create(socketFile)
if err != nil {
return err
}
return f.Close()
},
cleanup: func() error {
return os.Remove(socketFile)
},
},
{
name: "bad key should fail",
config: ServerOptions{
Network: "tcp",
Addr: ":45987",
Keys: []gossh.PublicKey{sshBadPubKey},
},
wantErr: true,
errContains: "ssh: handshake failed",
},
{
name: "good key still good",
config: ServerOptions{
Network: "tcp",
Addr: ":45987",
Keys: []gossh.PublicKey{sshPubKey},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.setup != nil {
err := tt.setup()
require.NoError(t, err)
}
if tt.cleanup != nil {
defer tt.cleanup()
}
agent, err := NewAgent("")
require.NoError(t, err)
// Start server in a goroutine since it blocks
errChan := make(chan error, 1)
go func() {
errChan <- agent.StartServer(tt.config)
}()
// Add a short delay to allow the server to start
time.Sleep(100 * time.Millisecond)
// Try to connect to verify server is running
var client *gossh.Client
// Choose the appropriate signer based on the test case
testSigner := signer
if tt.name == "bad key should fail" {
testSigner = badSigner
}
sshClientConfig := &gossh.ClientConfig{
User: "a",
Auth: []gossh.AuthMethod{
gossh.PublicKeys(testSigner),
},
HostKeyCallback: gossh.InsecureIgnoreHostKey(),
Timeout: 4 * time.Second,
}
switch tt.config.Network {
case "unix":
client, err = gossh.Dial("unix", tt.config.Addr, sshClientConfig)
default:
if !strings.Contains(tt.config.Addr, ":") {
tt.config.Addr = ":" + tt.config.Addr
}
client, err = gossh.Dial("tcp", tt.config.Addr, sshClientConfig)
}
if tt.wantErr {
assert.Error(t, err)
if tt.errContains != "" {
assert.Contains(t, err.Error(), tt.errContains)
}
return
}
require.NoError(t, err)
require.NotNil(t, client)
client.Close()
})
}
}
/////////////////////////////////////////////////////////////////
//////////////////// ParseKeys Tests ////////////////////////////
/////////////////////////////////////////////////////////////////
// Helper function to generate a temporary file with content
func createTempFile(content string) (string, error) {
tmpFile, err := os.CreateTemp("", "ssh_keys_*.txt")
if err != nil {
return "", fmt.Errorf("failed to create temp file: %w", err)
}
defer tmpFile.Close()
if _, err := tmpFile.WriteString(content); err != nil {
return "", fmt.Errorf("failed to write to temp file: %w", err)
}
return tmpFile.Name(), nil
}
// Test case 1: String with a single SSH key
func TestParseSingleKeyFromString(t *testing.T) {
input := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKCBM91kukN7hbvFKtbpEeo2JXjCcNxXcdBH7V7ADMBo"
keys, err := ParseKeys(input)
if err != nil {
t.Fatalf("Expected no error, got: %v", err)
}
if len(keys) != 1 {
t.Fatalf("Expected 1 key, got %d keys", len(keys))
}
if keys[0].Type() != "ssh-ed25519" {
t.Fatalf("Expected key type 'ssh-ed25519', got '%s'", keys[0].Type())
}
}
// Test case 2: String with multiple SSH keys
func TestParseMultipleKeysFromString(t *testing.T) {
input := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKCBM91kukN7hbvFKtbpEeo2JXjCcNxXcdBH7V7ADMBo\nssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJDMtAOQfxDlCxe+A5lVbUY/DHxK1LAF2Z3AV0FYv36D \n #comment\n ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJDMtAOQfxDlCxe+A5lVbUY/DHxK1LAF2Z3AV0FYv36D"
keys, err := ParseKeys(input)
if err != nil {
t.Fatalf("Expected no error, got: %v", err)
}
if len(keys) != 3 {
t.Fatalf("Expected 3 keys, got %d keys", len(keys))
}
if keys[0].Type() != "ssh-ed25519" || keys[1].Type() != "ssh-ed25519" || keys[2].Type() != "ssh-ed25519" {
t.Fatalf("Unexpected key types: %s, %s, %s", keys[0].Type(), keys[1].Type(), keys[2].Type())
}
}
// Test case 3: File with a single SSH key
func TestParseSingleKeyFromFile(t *testing.T) {
content := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKCBM91kukN7hbvFKtbpEeo2JXjCcNxXcdBH7V7ADMBo"
filePath, err := createTempFile(content)
if err != nil {
t.Fatalf("Failed to create temp file: %v", err)
}
defer os.Remove(filePath) // Clean up the file after the test
// Read the file content
fileContent, err := os.ReadFile(filePath)
if err != nil {
t.Fatalf("Failed to read temp file: %v", err)
}
// Parse the keys
keys, err := ParseKeys(string(fileContent))
if err != nil {
t.Fatalf("Expected no error, got: %v", err)
}
if len(keys) != 1 {
t.Fatalf("Expected 1 key, got %d keys", len(keys))
}
if keys[0].Type() != "ssh-ed25519" {
t.Fatalf("Expected key type 'ssh-ed25519', got '%s'", keys[0].Type())
}
}
// Test case 4: File with multiple SSH keys
func TestParseMultipleKeysFromFile(t *testing.T) {
content := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKCBM91kukN7hbvFKtbpEeo2JXjCcNxXcdBH7V7ADMBo\nssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJDMtAOQfxDlCxe+A5lVbUY/DHxK1LAF2Z3AV0FYv36D \n #comment\n ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJDMtAOQfxDlCxe+A5lVbUY/DHxK1LAF2Z3AV0FYv36D"
filePath, err := createTempFile(content)
if err != nil {
t.Fatalf("Failed to create temp file: %v", err)
}
// defer os.Remove(filePath) // Clean up the file after the test
// Read the file content
fileContent, err := os.ReadFile(filePath)
if err != nil {
t.Fatalf("Failed to read temp file: %v", err)
}
// Parse the keys
keys, err := ParseKeys(string(fileContent))
if err != nil {
t.Fatalf("Expected no error, got: %v", err)
}
if len(keys) != 3 {
t.Fatalf("Expected 3 keys, got %d keys", len(keys))
}
if keys[0].Type() != "ssh-ed25519" || keys[1].Type() != "ssh-ed25519" || keys[2].Type() != "ssh-ed25519" {
t.Fatalf("Unexpected key types: %s, %s, %s", keys[0].Type(), keys[1].Type(), keys[2].Type())
}
}
// Test case 5: Invalid SSH key input
func TestParseInvalidKey(t *testing.T) {
input := "invalid-key-data"
_, err := ParseKeys(input)
if err == nil {
t.Fatalf("Expected an error for invalid key, got nil")
}
expectedErrMsg := "failed to parse key"
if !strings.Contains(err.Error(), expectedErrMsg) {
t.Fatalf("Expected error message to contain '%s', got: %v", expectedErrMsg, err)
}
}
/////////////////////////////////////////////////////////////////
//////////////////// Hub Version Tests //////////////////////////
/////////////////////////////////////////////////////////////////
func TestExtractHubVersion(t *testing.T) {
tests := []struct {
name string
clientVersion string
expectedVersion string
expectError bool
}{
{
name: "valid beszel client version with underscore",
clientVersion: "SSH-2.0-beszel_0.11.1",
expectedVersion: "0.11.1",
expectError: false,
},
{
name: "valid beszel client version with beta",
clientVersion: "SSH-2.0-beszel_1.0.0-beta",
expectedVersion: "1.0.0-beta",
expectError: false,
},
{
name: "valid beszel client version with rc",
clientVersion: "SSH-2.0-beszel_0.12.0-rc1",
expectedVersion: "0.12.0-rc1",
expectError: false,
},
{
name: "different SSH client",
clientVersion: "SSH-2.0-OpenSSH_8.0",
expectedVersion: "8.0",
expectError: true,
},
{
name: "malformed version string without underscore",
clientVersion: "SSH-2.0-beszel",
expectError: true,
},
{
name: "empty version string",
clientVersion: "",
expectError: true,
},
{
name: "version string with underscore but no version",
clientVersion: "beszel_",
expectedVersion: "",
expectError: true,
},
{
name: "version with patch and build metadata",
clientVersion: "SSH-2.0-beszel_1.2.3+build.123",
expectedVersion: "1.2.3+build.123",
expectError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := extractHubVersion(tt.clientVersion)
if tt.expectError {
assert.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tt.expectedVersion, result.String())
})
}
}
/////////////////////////////////////////////////////////////////
/////////////// Hub Version Detection Tests ////////////////////
/////////////////////////////////////////////////////////////////
func TestGetHubVersion(t *testing.T) {
agent, err := NewAgent("")
require.NoError(t, err)
// Mock SSH context that implements the ssh.Context interface
mockCtx := &mockSSHContext{
sessionID: "test-session-123",
clientVersion: "SSH-2.0-beszel_0.12.0",
}
// Test first call - should extract and cache version
version := agent.getHubVersion("test-session-123", mockCtx)
assert.Equal(t, "0.12.0", version.String())
// Test second call - should return cached version
mockCtx.clientVersion = "SSH-2.0-beszel_0.11.0" // Change version but should still return cached
version = agent.getHubVersion("test-session-123", mockCtx)
assert.Equal(t, "0.12.0", version.String()) // Should still be cached version
// Test different session - should extract new version
version = agent.getHubVersion("different-session", mockCtx)
assert.Equal(t, "0.11.0", version.String())
// Test with invalid version string (non-beszel client)
mockCtx.clientVersion = "SSH-2.0-OpenSSH_8.0"
version = agent.getHubVersion("invalid-session", mockCtx)
assert.Equal(t, "0.0.0", version.String()) // Should be empty version for non-beszel clients
// Test with no client version
mockCtx.clientVersion = ""
version = agent.getHubVersion("no-version-session", mockCtx)
assert.True(t, version.EQ(semver.Version{})) // Should be empty version
}
// mockSSHContext implements ssh.Context for testing
type mockSSHContext struct {
context.Context
sync.Mutex
sessionID string
clientVersion string
}
func (m *mockSSHContext) SessionID() string {
return m.sessionID
}
func (m *mockSSHContext) ClientVersion() string {
return m.clientVersion
}
func (m *mockSSHContext) ServerVersion() string {
return "SSH-2.0-beszel_test"
}
func (m *mockSSHContext) Value(key interface{}) interface{} {
if key == ssh.ContextKeyClientVersion {
return m.clientVersion
}
return nil
}
func (m *mockSSHContext) User() string { return "test-user" }
func (m *mockSSHContext) RemoteAddr() net.Addr { return nil }
func (m *mockSSHContext) LocalAddr() net.Addr { return nil }
func (m *mockSSHContext) Permissions() *ssh.Permissions { return nil }
func (m *mockSSHContext) SetValue(key, value interface{}) {}
/////////////////////////////////////////////////////////////////
/////////////// CBOR vs JSON Encoding Tests ////////////////////
/////////////////////////////////////////////////////////////////
// TestWriteToSessionEncoding tests that writeToSession actually encodes data in the correct format
func TestWriteToSessionEncoding(t *testing.T) {
tests := []struct {
name string
hubVersion string
expectedUsesCbor bool
}{
{
name: "old hub version should use JSON",
hubVersion: "0.11.1",
expectedUsesCbor: false,
},
{
name: "non-beta release should use CBOR",
hubVersion: "0.12.0",
expectedUsesCbor: true,
},
{
name: "even newer hub version should use CBOR",
hubVersion: "0.16.4",
expectedUsesCbor: true,
},
{
name: "beta version below release threshold should use JSON",
hubVersion: "0.12.0-beta0",
expectedUsesCbor: false,
},
// {
// name: "matching beta version should use CBOR",
// hubVersion: "0.12.0-beta2",
// expectedUsesCbor: true,
// },
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Reset the global hubVersions map to ensure clean state for each test
hubVersions = nil
agent, err := NewAgent("")
require.NoError(t, err)
// Parse the test version
version, err := semver.Parse(tt.hubVersion)
require.NoError(t, err)
// Create test data to encode
testData := createTestCombinedData()
var buf strings.Builder
err = agent.writeToSession(&buf, testData, version)
require.NoError(t, err)
encodedData := buf.String()
require.NotEmpty(t, encodedData)
// Verify the encoding format by attempting to decode
if tt.expectedUsesCbor {
var decodedCbor system.CombinedData
err = cbor.Unmarshal([]byte(encodedData), &decodedCbor)
assert.NoError(t, err, "Should be valid CBOR data")
var decodedJson system.CombinedData
err = json.Unmarshal([]byte(encodedData), &decodedJson)
assert.Error(t, err, "Should not be valid JSON data")
assert.Equal(t, testData.Info.Hostname, decodedCbor.Info.Hostname)
assert.Equal(t, testData.Stats.Cpu, decodedCbor.Stats.Cpu)
} else {
// Should be JSON - try to decode as JSON
var decodedJson system.CombinedData
err = json.Unmarshal([]byte(encodedData), &decodedJson)
assert.NoError(t, err, "Should be valid JSON data")
var decodedCbor system.CombinedData
err = cbor.Unmarshal([]byte(encodedData), &decodedCbor)
assert.Error(t, err, "Should not be valid CBOR data")
// Verify the decoded JSON data matches our test data
assert.Equal(t, testData.Info.Hostname, decodedJson.Info.Hostname)
assert.Equal(t, testData.Stats.Cpu, decodedJson.Stats.Cpu)
// Verify it looks like JSON (starts with '{' and contains readable field names)
assert.True(t, strings.HasPrefix(encodedData, "{"), "JSON should start with '{'")
assert.Contains(t, encodedData, `"info"`, "JSON should contain readable field names")
assert.Contains(t, encodedData, `"stats"`, "JSON should contain readable field names")
}
})
}
}
// Helper function to create test data for encoding tests
func createTestCombinedData() *system.CombinedData {
return &system.CombinedData{
Stats: system.Stats{
Cpu: 25.5,
Mem: 8589934592, // 8GB
MemUsed: 4294967296, // 4GB
MemPct: 50.0,
DiskTotal: 1099511627776, // 1TB
DiskUsed: 549755813888, // 512GB
DiskPct: 50.0,
},
Info: system.Info{
Hostname: "test-host",
Cores: 8,
CpuModel: "Test CPU Model",
Uptime: 3600,
AgentVersion: "0.12.0",
Os: system.Linux,
},
Containers: []*container.Stats{
{
Name: "test-container",
Cpu: 10.5,
Mem: 1073741824, // 1GB
},
},
}
}
func TestHubVersionCaching(t *testing.T) {
// Reset the global hubVersions map to ensure clean state
hubVersions = nil
agent, err := NewAgent("")
require.NoError(t, err)
ctx1 := &mockSSHContext{
sessionID: "session1",
clientVersion: "SSH-2.0-beszel_0.12.0",
}
ctx2 := &mockSSHContext{
sessionID: "session2",
clientVersion: "SSH-2.0-beszel_0.11.0",
}
// First calls should cache the versions
v1 := agent.getHubVersion("session1", ctx1)
v2 := agent.getHubVersion("session2", ctx2)
assert.Equal(t, "0.12.0", v1.String())
assert.Equal(t, "0.11.0", v2.String())
// Verify caching by changing context but keeping same session ID
ctx1.clientVersion = "SSH-2.0-beszel_0.10.0"
v1Cached := agent.getHubVersion("session1", ctx1)
assert.Equal(t, "0.12.0", v1Cached.String()) // Should still be cached version
// New session should get new version
ctx3 := &mockSSHContext{
sessionID: "session3",
clientVersion: "SSH-2.0-beszel_0.13.0",
}
v3 := agent.getHubVersion("session3", ctx3)
assert.Equal(t, "0.13.0", v3.String())
}

View File

@@ -1,8 +1,6 @@
package agent
import (
"beszel"
"beszel/internal/entities/system"
"bufio"
"fmt"
"log/slog"
@@ -11,19 +9,43 @@ import (
"strings"
"time"
"github.com/henrygd/beszel"
"github.com/henrygd/beszel/agent/battery"
"github.com/henrygd/beszel/agent/deltatracker"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/shirou/gopsutil/v4/cpu"
"github.com/shirou/gopsutil/v4/disk"
"github.com/shirou/gopsutil/v4/host"
"github.com/shirou/gopsutil/v4/load"
"github.com/shirou/gopsutil/v4/mem"
psutilNet "github.com/shirou/gopsutil/v4/net"
"github.com/shirou/gopsutil/v4/sensors"
)
var netInterfaceDeltaTracker = deltatracker.NewDeltaTracker[string, uint64]()
// 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()
platform, _, version, _ := host.PlatformInformation()
if platform == "darwin" {
a.systemInfo.KernelVersion = version
a.systemInfo.Os = system.Darwin
} else if strings.Contains(platform, "indows") {
a.systemInfo.KernelVersion = strings.Replace(platform, "Microsoft ", "", 1) + " " + version
a.systemInfo.Os = system.Windows
} else if platform == "freebsd" {
a.systemInfo.Os = system.Freebsd
a.systemInfo.KernelVersion = version
} else {
a.systemInfo.Os = system.Linux
}
if a.systemInfo.KernelVersion == "" {
a.systemInfo.KernelVersion, _ = host.KernelVersion()
}
// cpu model
if info, err := cpu.Info(); err == nil && len(info) > 0 {
@@ -41,16 +63,21 @@ func (a *Agent) initializeSystemInfo() {
}
// zfs
if _, err := getARCSize(); err == nil {
a.zfs = true
} else {
if _, err := getARCSize(); err != nil {
slog.Debug("Not monitoring ZFS ARC", "err", err)
} else {
a.zfs = true
}
}
// Returns current info, stats about the host system
func (a *Agent) getSystemStats() system.Stats {
systemStats := system.Stats{}
var systemStats system.Stats
// battery
if battery.HasReadableBattery() {
systemStats.Battery[0], systemStats.Battery[1], _ = battery.GetBatteryStats()
}
// cpu percent
cpuPct, err := cpu.Percent(0, false)
@@ -60,6 +87,16 @@ func (a *Agent) getSystemStats() system.Stats {
systemStats.Cpu = twoDecimals(cpuPct[0])
}
// load average
if avgstat, err := load.Avg(); err == nil {
systemStats.LoadAvg[0] = avgstat.Load1
systemStats.LoadAvg[1] = avgstat.Load5
systemStats.LoadAvg[2] = avgstat.Load15
slog.Debug("Load average", "5m", avgstat.Load5, "15m", avgstat.Load15)
} else {
slog.Error("Error getting load average", "err", err)
}
// memory
if v, err := mem.VirtualMemory(); err == nil {
// swap
@@ -138,102 +175,58 @@ func (a *Agent) getSystemStats() system.Stats {
}
// 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
}
}
a.updateNetworkStats(&systemStats)
// 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)
}
}
}
}
}
// temperatures
// TODO: maybe refactor to methods on systemStats
a.updateTemperatures(&systemStats)
// GPU data
if a.gpuManager != nil {
// reset high gpu percent
a.systemInfo.GpuPct = 0
// get current GPU data
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))
}
highestTemp := 0.0
for _, gpu := range gpuData {
if gpu.Temperature > 0 {
systemStats.Temperatures[gpu.Name] = gpu.Temperature
if a.sensorConfig.primarySensor == gpu.Name {
a.systemInfo.DashboardTemp = gpu.Temperature
}
if gpu.Temperature > highestTemp {
highestTemp = gpu.Temperature
}
}
// update high gpu percent for dashboard
a.systemInfo.GpuPct = max(a.systemInfo.GpuPct, gpu.Usage)
}
// use highest temp for dashboard temp if dashboard temp is unset
if a.systemInfo.DashboardTemp == 0 {
a.systemInfo.DashboardTemp = highestTemp
}
}
}
// update base system info
a.systemInfo.Cpu = systemStats.Cpu
a.systemInfo.LoadAvg = systemStats.LoadAvg
// TODO: remove these in future release in favor of load avg array
a.systemInfo.LoadAvg1 = systemStats.LoadAvg[0]
a.systemInfo.LoadAvg5 = systemStats.LoadAvg[1]
a.systemInfo.LoadAvg15 = systemStats.LoadAvg[2]
a.systemInfo.MemPct = systemStats.MemPct
a.systemInfo.DiskPct = systemStats.DiskPct
a.systemInfo.Uptime, _ = host.Uptime()
// TODO: in future release, remove MB bandwidth values in favor of bytes
a.systemInfo.Bandwidth = twoDecimals(systemStats.NetworkSent + systemStats.NetworkRecv)
a.systemInfo.BandwidthBytes = systemStats.Bandwidth[0] + systemStats.Bandwidth[1]
slog.Debug("sysinfo", "data", a.systemInfo)
return systemStats

165
agent/update.go Normal file
View File

@@ -0,0 +1,165 @@
package agent
import (
"fmt"
"log"
"os"
"os/exec"
"runtime"
"strings"
"github.com/henrygd/beszel/internal/ghupdate"
)
// restarter knows how to restart the beszel-agent service.
type restarter interface {
Restart() error
}
type systemdRestarter struct{ cmd string }
func (s *systemdRestarter) Restart() error {
// Only restart if the service is active
if err := exec.Command(s.cmd, "is-active", "beszel-agent.service").Run(); err != nil {
return nil
}
ghupdate.ColorPrint(ghupdate.ColorYellow, "Restarting beszel-agent.service via systemd…")
return exec.Command(s.cmd, "restart", "beszel-agent.service").Run()
}
type openRCRestarter struct{ cmd string }
func (o *openRCRestarter) Restart() error {
if err := exec.Command(o.cmd, "status", "beszel-agent").Run(); err != nil {
return nil
}
ghupdate.ColorPrint(ghupdate.ColorYellow, "Restarting beszel-agent via OpenRC…")
return exec.Command(o.cmd, "restart", "beszel-agent").Run()
}
type openWRTRestarter struct{ cmd string }
func (w *openWRTRestarter) Restart() error {
if err := exec.Command(w.cmd, "running", "beszel-agent").Run(); err != nil {
return nil
}
ghupdate.ColorPrint(ghupdate.ColorYellow, "Restarting beszel-agent via procd…")
return exec.Command(w.cmd, "restart", "beszel-agent").Run()
}
type freeBSDRestarter struct{ cmd string }
func (f *freeBSDRestarter) Restart() error {
if err := exec.Command(f.cmd, "beszel-agent", "status").Run(); err != nil {
return nil
}
ghupdate.ColorPrint(ghupdate.ColorYellow, "Restarting beszel-agent via FreeBSD rc…")
return exec.Command(f.cmd, "beszel-agent", "restart").Run()
}
func detectRestarter() restarter {
if path, err := exec.LookPath("systemctl"); err == nil {
return &systemdRestarter{cmd: path}
}
if path, err := exec.LookPath("rc-service"); err == nil {
return &openRCRestarter{cmd: path}
}
if path, err := exec.LookPath("service"); err == nil {
if runtime.GOOS == "freebsd" {
return &freeBSDRestarter{cmd: path}
}
return &openWRTRestarter{cmd: path}
}
return nil
}
// Update checks GitHub for a newer release of beszel-agent, applies it,
// fixes SELinux context if needed, and restarts the service.
func Update(useMirror bool) error {
exePath, _ := os.Executable()
dataDir, err := getDataDir()
if err != nil {
dataDir = os.TempDir()
}
updated, err := ghupdate.Update(ghupdate.Config{
ArchiveExecutable: "beszel-agent",
DataDir: dataDir,
UseMirror: useMirror,
})
if err != nil {
log.Fatal(err)
}
if !updated {
return nil
}
// make sure the file is executable
if err := os.Chmod(exePath, 0755); err != nil {
ghupdate.ColorPrintf(ghupdate.ColorYellow, "Warning: failed to set executable permissions: %v", err)
}
// set ownership to beszel:beszel if possible
if chownPath, err := exec.LookPath("chown"); err == nil {
if err := exec.Command(chownPath, "beszel:beszel", exePath).Run(); err != nil {
ghupdate.ColorPrintf(ghupdate.ColorYellow, "Warning: failed to set file ownership: %v", err)
}
}
// 6) Fix SELinux context if necessary
if err := handleSELinuxContext(exePath); err != nil {
ghupdate.ColorPrintf(ghupdate.ColorYellow, "Warning: SELinux context handling: %v", err)
}
// 7) Restart service if running under a recognised init system
if r := detectRestarter(); r != nil {
if err := r.Restart(); err != nil {
ghupdate.ColorPrintf(ghupdate.ColorYellow, "Warning: failed to restart service: %v", err)
ghupdate.ColorPrint(ghupdate.ColorYellow, "Please restart the service manually.")
} else {
ghupdate.ColorPrint(ghupdate.ColorGreen, "Service restarted successfully")
}
} else {
ghupdate.ColorPrint(ghupdate.ColorYellow, "No supported init system detected; please restart manually if needed.")
}
return nil
}
// handleSELinuxContext restores or applies the correct SELinux label to the binary.
func handleSELinuxContext(path string) error {
out, err := exec.Command("getenforce").Output()
if err != nil {
// SELinux not enabled or getenforce not available
return nil
}
state := strings.TrimSpace(string(out))
if state == "Disabled" {
return nil
}
ghupdate.ColorPrint(ghupdate.ColorYellow, "SELinux is enabled; applying context…")
var errs []string
// Try persistent context via semanage+restorecon
if semanagePath, err := exec.LookPath("semanage"); err == nil {
if err := exec.Command(semanagePath, "fcontext", "-a", "-t", "bin_t", path).Run(); err != nil {
errs = append(errs, "semanage fcontext failed: "+err.Error())
} else if restoreconPath, err := exec.LookPath("restorecon"); err == nil {
if err := exec.Command(restoreconPath, "-v", path).Run(); err != nil {
errs = append(errs, "restorecon failed: "+err.Error())
}
}
}
// Fallback to temporary context via chcon
if chconPath, err := exec.LookPath("chcon"); err == nil {
if err := exec.Command(chconPath, "-t", "bin_t", path).Run(); err != nil {
errs = append(errs, "chcon failed: "+err.Error())
}
}
if len(errs) > 0 {
return fmt.Errorf("SELinux context errors: %s", strings.Join(errs, "; "))
}
return nil
}

15
beszel.go Normal file
View File

@@ -0,0 +1,15 @@
// Package beszel provides core application constants and version information
// which are used throughout the application.
package beszel
import "github.com/blang/semver"
const (
// Version is the current version of the application.
Version = "0.12.9"
// AppName is the name of the application.
AppName = "beszel"
)
// MinVersionCbor is the minimum supported version for CBOR compatibility.
var MinVersionCbor = semver.MustParse("0.12.0")

View File

@@ -1,69 +0,0 @@
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:'

View File

@@ -1,67 +0,0 @@
# Default OS/ARCH values
OS ?= $(shell go env GOOS)
ARCH ?= $(shell go env GOARCH)
# Skip building the web UI if true
SKIP_WEB ?= false
.PHONY: tidy build-agent build-hub build clean lint dev-server dev-agent dev-hub dev generate-locales
.DEFAULT_GOAL := build
clean:
go clean
rm -rf ./build
lint:
golangci-lint run
tidy:
go mod tidy
build-web-ui:
@if command -v bun >/dev/null 2>&1; then \
bun install --cwd ./site && \
bun run --cwd ./site build; \
else \
npm install --prefix ./site && \
npm run --prefix ./site build; \
fi
build-agent: tidy
CGO_ENABLED=0 GOOS=$(OS) GOARCH=$(ARCH) go build -o ./build/beszel-agent_$(OS)_$(ARCH) -ldflags "-w -s" beszel/cmd/agent
build-hub: tidy $(if $(filter false,$(SKIP_WEB)),build-web-ui)
CGO_ENABLED=0 GOOS=$(OS) GOARCH=$(ARCH) go build -o ./build/beszel_$(OS)_$(ARCH) -ldflags "-w -s" beszel/cmd/hub
build: build-agent build-hub
generate-locales:
@if [ ! -f ./site/src/locales/en/en.ts ]; then \
echo "Generating locales..."; \
command -v bun >/dev/null 2>&1 && cd ./site && bun install && bun run sync || cd ./site && npm install && npm run sync; \
fi
dev-server: generate-locales
cd ./site
@if command -v bun >/dev/null 2>&1; then \
cd ./site && bun run dev; \
else \
cd ./site && npm run dev; \
fi
dev-hub:
mkdir -p ./site/dist && touch ./site/dist/index.html
@if command -v entr >/dev/null 2>&1; then \
find ./cmd/hub/*.go ./internal/{alerts,hub,records,users}/*.go | entr -r -s "cd ./cmd/hub && go run . serve"; \
else \
cd ./cmd/hub && go run . serve; \
fi
dev-agent:
@if command -v entr >/dev/null 2>&1; then \
find ./cmd/agent/*.go ./internal/agent/*.go | entr -r go run beszel/cmd/agent; \
else \
go run beszel/cmd/agent; \
fi
# KEY="..." make -j dev
dev: dev-server dev-hub dev-agent

View File

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

View File

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

View File

@@ -1,20 +0,0 @@
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,101 +0,0 @@
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.23.9
github.com/rhysd/go-github-selfupdate v1.2.3
github.com/shirou/gopsutil/v4 v4.24.11
github.com/spf13/cast v1.7.0
github.com/spf13/cobra v1.8.1
golang.org/x/crypto v0.31.0
golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67
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.6 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.7 // indirect
github.com/aws/aws-sdk-go-v2/config v1.28.6 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.17.47 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.21 // indirect
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.43 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.25 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.25 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.25 // 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.6 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.6 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.6 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.71.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.24.7 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.6 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.33.2 // 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.7 // 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.0 // 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.32.0 // indirect
golang.org/x/oauth2 v0.24.0 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/term v0.27.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.212.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20241216192217-9240e9c98484 // indirect
google.golang.org/grpc v1.69.0 // indirect
google.golang.org/protobuf v1.36.0 // indirect
modernc.org/gc/v3 v3.0.0-20241213165251-3bc300f6d0c9 // indirect
modernc.org/libc v1.61.4 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.8.0 // indirect
modernc.org/sqlite v1.34.2 // indirect
modernc.org/strutil v1.2.0 // indirect
modernc.org/token v1.1.0 // indirect
)

View File

@@ -1,433 +0,0 @@
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.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=
github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo=
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.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.6 h1:7BokKRgRPuGmKkFMhEg/jSul+tB9VvXhcViILtfG8b4=
github.com/aws/aws-sdk-go-v2 v1.32.6/go.mod h1:P5WJBrYqqbWVaOxgH0X/FYYD47/nooaPOZPlQdmiN2U=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.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.6 h1:D89IKtGrs/I3QXOLNTH93NJYtDhm8SYa9Q5CsPShmyo=
github.com/aws/aws-sdk-go-v2/config v1.28.6/go.mod h1:GDzxJ5wyyFSCoLkS+UhGB0dArhb9mI+Co4dHtoTxbko=
github.com/aws/aws-sdk-go-v2/credentials v1.17.47 h1:48bA+3/fCdi2yAwVt+3COvmatZ6jUDNkDTIsqDiMUdw=
github.com/aws/aws-sdk-go-v2/credentials v1.17.47/go.mod h1:+KdckOejLW3Ks3b0E3b5rHsr2f9yuORBum0WPnE5o5w=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.21 h1:AmoU1pziydclFT/xRV+xXE/Vb8fttJCLRPv8oAkprc0=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.21/go.mod h1:AjUdLYe4Tgs6kpH4Bv7uMZo7pottoyHMn4eTcIcneaY=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.43 h1:iLdpkYZ4cXIQMO7ud+cqMWR1xK5ESbt1rvN77tRi1BY=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.43/go.mod h1:OgbsKPAswXDd5kxnR4vZov69p3oYjbvUyIRBAAV0y9o=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.25 h1:s/fF4+yDQDoElYhfIVvSNyeCydfbuTKzhxSXDXCPasU=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.25/go.mod h1:IgPfDv5jqFIzQSNbUEMoitNooSMXjRSDkhXv8jiROvU=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.25 h1:ZntTCl5EsYnhN/IygQEUugpdwbhdkom9uHcbCftiGgA=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.25/go.mod h1:DBdPrgeocww+CSl1C8cEV8PN1mHMBhuCDLpXezyvWkE=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 h1:VaRN3TlFdd6KxX1x3ILT5ynH6HvKgqdiXoTxAF4HQcQ=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.25 h1:r67ps7oHCYnflpgDy2LZU0MAQtQbYIOqNNnqGO6xQkE=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.25/go.mod h1:GrGY+Q4fIokYLtjCVB/aFfCVL6hhGUFl8inD18fDalE=
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.6 h1:HCpPsWqmYQieU7SS6E9HXfdAMSud0pteVXieJmcpIRI=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.6/go.mod h1:ngUiVRCco++u+soRRVBIvBZxSMMvOVMXA4PJ36JLfSw=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.6 h1:50+XsN70RS7dwJ2CkVNXzj7U2L1HKP8nqTd3XWEXBN4=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.6/go.mod h1:WqgLmwY7so32kG01zD8CPTJWVWM+TzJoOVHwTg4aPug=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.6 h1:BbGDtTi0T1DYlmjBiCr/le3wzhA37O8QTC5/Ab8+EXk=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.6/go.mod h1:hLMJt7Q8ePgViKupeymbqI0la+t9/iYFBjxQCFwuAwI=
github.com/aws/aws-sdk-go-v2/service/s3 v1.71.0 h1:nyuzXooUNJexRT0Oy0UQY6AhOzxPxhtt4DcBIHyCnmw=
github.com/aws/aws-sdk-go-v2/service/s3 v1.71.0/go.mod h1:sT/iQz8JK3u/5gZkT+Hmr7GzVZehUMkRZpOaAwYXeGY=
github.com/aws/aws-sdk-go-v2/service/sso v1.24.7 h1:rLnYAfXQ3YAccocshIH5mzNNwZBkBo+bP6EhIxak6Hw=
github.com/aws/aws-sdk-go-v2/service/sso v1.24.7/go.mod h1:ZHtuQJ6t9A/+YDuxOLnbryAmITtr8UysSny3qcyvJTc=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.6 h1:JnhTZR3PiYDNKlXy50/pNeix9aGMo6lLpXwJ1mw8MD4=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.6/go.mod h1:URronUEGfXZN1VpdktPSD1EkAL9mfrV+2F4sjH38qOY=
github.com/aws/aws-sdk-go-v2/service/sts v1.33.2 h1:s4074ZO1Hk8qv65GqNXqDjmkf4HSQqJukaLuuW0TpDA=
github.com/aws/aws-sdk-go-v2/service/sts v1.33.2/go.mod h1:mVggCnIWoM09jP71Wh+ea7+5gAp53q+49wDFs1SW5z8=
github.com/aws/smithy-go v1.22.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/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.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.7 h1:SKFKl7kD0RiPdbht0s7hFtjl489WcQ1VyPW8ZzUMYCA=
github.com/gabriel-vasile/mimetype v1.4.7/go.mod h1:GDlAgAyIRT27BhFl53XNAFtfjzOkLaF35JdEG0P7LtU=
github.com/ganigeorgiev/fexpr v0.4.1 h1:hpUgbUEEWIZhSDBtf4M9aUNfQQ0BZkGRaMePy7Gcx5k=
github.com/ganigeorgiev/fexpr v0.4.1/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
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.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-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=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
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/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/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.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.0 h1:f+jMrjBPl+DL9nI4IQzLUxMq7XrAqFYB7hBPqMNIe8o=
github.com/googleapis/gax-go/v2 v2.14.0/go.mod h1:lhBCnjdLrWRaPvLWhmc8IS24m9mr07qSYnHncrgo+zk=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog=
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68=
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/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/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=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
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/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=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
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/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/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/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/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.23.9 h1:0P3BaMTUO8QzyamYqd/OpPM4L7zmu6HrmDGFQmX+eu4=
github.com/pocketbase/pocketbase v0.23.9/go.mod h1:8qIx1v60b+YES3e8H4J2QQF48J0uiydPhRi4ZHlKNjk=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/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=
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/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/shirou/gopsutil/v4 v4.24.11 h1:WaU9xqGFKvFfsUv94SXcUPD7rCkU0vr/asVdQOBZNj8=
github.com/shirou/gopsutil/v4 v4.24.11/go.mod h1:s4D/wg+ag4rG0WO7AiTj2BeYCRhym0vM7DHbZRxnIT8=
github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w=
github.com/spf13/cast v1.7.0/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=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
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.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.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/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.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.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-20241217172543-b2144cdd0a67 h1:1UoZQm6f0P/ZO0w1Ri+f+ifG/gXhegadRdwBIXEFWDo=
golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.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.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=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/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.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
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.32.0 h1:ZqPmj8Kzc+Y6e0+skZsuACbx+wzMgo5MQsJh9Qd6aYI=
golang.org/x/net v0.32.0/go.mod h1:CwU0IoeOlnQQWJ6ioyFrfRuomB8GKF6KbYXZVyeXNfs=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE=
golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-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.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.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-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.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.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.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=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
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.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-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.212.0 h1:BcRj3MJfHF3FYD29rk7u9kuu1SyfGqfHcA0hSwKqkHg=
google.golang.org/api v0.212.0/go.mod h1:gICpLlpp12/E8mycRMzgy3SQ9cFh2XnVJ6vJi/kQbvI=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
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-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-20241104194629-dd2ea8efbc28 h1:M0KvPgPmDZHPlbRbaNU1APr28TvwvvdUPlSv7PUvy8g=
google.golang.org/genproto/googleapis/api v0.0.0-20241104194629-dd2ea8efbc28/go.mod h1:dguCy7UOdZhTvLzDyt15+rOrawrpM4q7DD9dQ1P11P4=
google.golang.org/genproto/googleapis/rpc v0.0.0-20241216192217-9240e9c98484 h1:Z7FRVJPSMaHQxD0uXU8WdgFh8PseLM8Q8NzhnpMrBhQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20241216192217-9240e9c98484/go.mod h1:lcTa1sDdWEIHMWlITnIczmw5w60CF9ffkb8Z+DVmmjA=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.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.69.0 h1:quSiOM1GJPmPH5XtU+BCoVXcDVJJAzNcoyfC2cCjGkI=
google.golang.org/grpc v1.69.0/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.36.0 h1:mjIs9gYtt56AzC4ZaffQuh88TZurBGhIJMBZGSxNerQ=
google.golang.org/protobuf v1.36.0/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 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.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.23.1 h1:WqJoPL3x4cUufQVHkXpXX7ThFJ1C4ik80i2eXEXbhD8=
modernc.org/cc/v4 v4.23.1/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
modernc.org/ccgo/v4 v4.23.1 h1:N49a7JiWGWV7lkPE4yYcvjkBGZQi93/JabRYjdWmJXc=
modernc.org/ccgo/v4 v4.23.1/go.mod h1:JoIUegEIfutvoWV/BBfDFpPpfR2nc3U0jKucGcbmwDU=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/gc/v2 v2.5.0 h1:bJ9ChznK1L1mUtAQtxi0wi5AtAs5jQuw4PrPHO5pb6M=
modernc.org/gc/v2 v2.5.0/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
modernc.org/gc/v3 v3.0.0-20241213165251-3bc300f6d0c9 h1:ovz6yUKX71igz2yvk4NpiCL5fvdjZAI+DhuDEGx1xyU=
modernc.org/gc/v3 v3.0.0-20241213165251-3bc300f6d0c9/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
modernc.org/libc v1.61.4 h1:wVyqEx6tlltte9lPTjq0kDAdtdM9c4JH8rU6M1ZVawA=
modernc.org/libc v1.61.4/go.mod h1:VfXVuM/Shh5XsMNrh3C6OkfL78G3loa4ZC/Ljv9k7xc=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/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/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.34.2 h1:J9n76TPsfYYkFkZ9Uy1QphILYifiVEwwOT7yP5b++2Y=
modernc.org/sqlite v1.34.2/go.mod h1:dnR723UrTtjKpoHCAMN0Q/gZ9MT4r+iRvIBb9umWFkU=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

View File

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

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

View File

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

@@ -1,34 +0,0 @@
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)
s.Exit(1)
return
}
s.Exit(0)
}

View File

@@ -1,56 +0,0 @@
package agent
import (
"beszel"
"fmt"
"os"
"strings"
"github.com/blang/semver"
"github.com/rhysd/go-github-selfupdate/selfupdate"
)
// Update updates beszel-agent to the latest version
func Update() {
var latest *selfupdate.Release
var found bool
var err error
currentVersion := semver.MustParse(beszel.Version)
fmt.Println("beszel-agent", currentVersion)
fmt.Println("Checking for updates...")
updater, _ := selfupdate.NewUpdater(selfupdate.Config{
Filters: []string{"beszel-agent"},
})
latest, found, err = updater.DetectLatest("henrygd/beszel")
if err != nil {
fmt.Println("Error checking for updates:", err)
os.Exit(1)
}
if !found {
fmt.Println("No updates found")
os.Exit(0)
}
fmt.Println("Latest version:", latest.Version)
if latest.Version.LTE(currentVersion) {
fmt.Println("You are up to date")
return
}
var binaryPath string
fmt.Printf("Updating from %s to %s...\n", currentVersion, latest.Version)
binaryPath, err = os.Executable()
if err != nil {
fmt.Println("Error getting binary path:", err)
os.Exit(1)
}
err = selfupdate.UpdateTo(latest.AssetURL, binaryPath)
if err != nil {
fmt.Println("Please try rerunning with sudo. Error:", err)
os.Exit(1)
}
fmt.Printf("Successfully updated to %s\n\n%s\n", latest.Version, strings.TrimSpace(latest.ReleaseNotes))
}

View File

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

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

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

@@ -1,530 +0,0 @@
// 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() {
ticker := time.NewTicker(15 * time.Second)
for range ticker.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,57 +0,0 @@
package hub
import (
"beszel"
"fmt"
"os"
"strings"
"github.com/blang/semver"
"github.com/rhysd/go-github-selfupdate/selfupdate"
"github.com/spf13/cobra"
)
// Update updates beszel to the latest version
func Update(_ *cobra.Command, _ []string) {
var latest *selfupdate.Release
var found bool
var err error
currentVersion := semver.MustParse(beszel.Version)
fmt.Println("beszel", currentVersion)
fmt.Println("Checking for updates...")
updater, _ := selfupdate.NewUpdater(selfupdate.Config{
Filters: []string{"beszel_"},
})
latest, found, err = updater.DetectLatest("henrygd/beszel")
if err != nil {
fmt.Println("Error checking for updates:", err)
os.Exit(1)
}
if !found {
fmt.Println("No updates found")
os.Exit(0)
}
fmt.Println("Latest version:", latest.Version)
if latest.Version.LTE(currentVersion) {
fmt.Println("You are up to date")
return
}
var binaryPath string
fmt.Printf("Updating from %s to %s...\n", currentVersion, latest.Version)
binaryPath, err = os.Executable()
if err != nil {
fmt.Println("Error getting binary path:", err)
os.Exit(1)
}
err = selfupdate.UpdateTo(latest.AssetURL, binaryPath)
if err != nil {
fmt.Println("Please try rerunning with sudo. Error:", err)
os.Exit(1)
}
fmt.Printf("Successfully updated to %s\n\n%s\n", latest.Version, strings.TrimSpace(latest.ReleaseNotes))
}

View File

@@ -1,372 +0,0 @@
// 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 {
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
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,29 +0,0 @@
package migrations
import (
"github.com/pocketbase/pocketbase/core"
m "github.com/pocketbase/pocketbase/migrations"
"github.com/pocketbase/pocketbase/tools/security"
)
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
if err := app.Save(settings); err != nil {
return err
}
// create superuser
collection, _ := app.FindCollectionByNameOrId(core.CollectionNameSuperusers)
user := core.NewRecord(collection)
user.SetEmail(TempAdminEmail)
user.SetPassword(security.RandomString(12))
return app.Save(user)
}, nil)
}

Binary file not shown.

View File

@@ -1,13 +0,0 @@
<!DOCTYPE html>
<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>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -1,72 +0,0 @@
{
"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

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

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>

Before

Width:  |  Height:  |  Size: 257 B

View File

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

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

View File

@@ -1,248 +0,0 @@
import { pb } from "@/lib/stores"
import { alertInfo, cn } from "@/lib/utils"
import { Switch } from "@/components/ui/switch"
import { AlertInfo, AlertRecord, SystemRecord } from "@/types"
import { lazy, Suspense, useRef, useState } from "react"
import { toast } from "../ui/use-toast"
import { RecordOptions } from "pocketbase"
import { 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

@@ -1,143 +0,0 @@
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
import {
useYAxisWidth,
cn,
formatShortDate,
toFixedWithoutTrailingZeros,
decimalString,
chartMargin,
} from "@/lib/utils"
// import Spinner from '../spinner'
import { ChartData } from "@/types"
import { memo, useMemo } from "react"
import { t } from "@lingui/macro"
import { useLingui } from "@lingui/react"
/** [label, key, color, opacity] */
type DataKeys = [string, string, number, number]
const getNestedValue = (path: string, max = false, data: any): number | null => {
// fallback value (obj?.stats?.cpum ? 0 : null) should only come into play when viewing
// a max value which doesn't exist, or the value was zero and omitted from the stats object.
// so we check if cpum is present. if so, return 0 to make sure the zero value is displayed.
// if not, return null - there is no max data so do not display anything.
return `stats.${path}${max ? "m" : ""}`
.split(".")
.reduce((acc: any, key: string) => acc?.[key] ?? (data.stats?.cpum ? 0 : null), data)
}
export default memo(function AreaChartDefault({
maxToggled = false,
unit = " MB/s",
chartName,
chartData,
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

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

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

@@ -1,25 +0,0 @@
import { createRouter } from "@nanostores/router"
export const $router = createRouter(
{
home: "/",
server: "/system/:name",
settings: "/settings/:name?",
forgot_password: "/forgot-password",
},
{ links: false }
)
/** Navigate to url using router */
export const navigate = (urlString: string) => {
$router.open(urlString)
}
function onClick(e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) {
e.preventDefault()
$router.open(new URL((e.currentTarget as HTMLAnchorElement).href).pathname)
}
export const Link = (props: React.AnchorHTMLAttributes<HTMLAnchorElement>) => {
return <a onClick={onClick} {...props}></a>
}

View File

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

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

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

@@ -1,593 +0,0 @@
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,132 +0,0 @@
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"
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn("flex h-full w-full flex-col overflow-hidden bg-popover text-popover-foreground", className)}
{...props}
/>
))
Command.displayName = CommandPrimitive.displayName
interface CommandDialogProps extends DialogProps {}
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg">
<div className="sr-only">
<DialogTitle>Command</DialogTitle>
</div>
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<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",
className
)}
{...props}
/>
</div>
))
CommandInput.displayName = CommandPrimitive.Input.displayName
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
))
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} />)
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<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",
className
)}
{...props}
/>
))
CommandGroup.displayName = CommandPrimitive.Group.displayName
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} />
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default opacity-70 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:opacity-90 data-[disabled='true']:pointer-events-none data-[disabled='true']:opacity-50",
className
)}
{...props}
/>
))
CommandItem.displayName = CommandPrimitive.Item.displayName
const CommandShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return <span className={cn("ms-auto text-xs tracking-wide text-muted-foreground", className)} {...props} />
}
CommandShortcut.displayName = "CommandShortcut"
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

View File

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

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

@@ -1,24 +0,0 @@
import { Toast, ToastClose, ToastDescription, ToastProvider, ToastTitle, ToastViewport } from "@/components/ui/toast"
import { useToast } from "@/components/ui/use-toast"
export function Toaster() {
const { toasts } = useToast()
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && <ToastDescription>{description}</ToastDescription>}
</div>
{action}
<ToastClose />
</Toast>
)
})}
<ToastViewport />
</ToastProvider>
)
}

View File

@@ -1,28 +0,0 @@
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@@ -1,92 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 30 8% 98.5%;
--foreground: 30 0% 0%;
--card: 30 0% 100%;
--card-foreground: 240 6.67% 2.94%;
--popover: 30 0% 100%;
--popover-foreground: 240 10% 6.2%;
--primary: 240 5.88% 10%;
--primary-foreground: 30 0% 100%;
--secondary: 240 4.76% 95.88%;
--secondary-foreground: 240 5.88% 10%;
--muted: 26 6% 94%;
--muted-foreground: 24 2.79% 35.1%;
--accent: 20 23.08% 94%;
--accent-foreground: 240 5.88% 10%;
--destructive: 0 66% 53%;
--destructive-foreground: 0 0% 98.04%;
--border: 30 8.11% 85.49%;
--input: 30 4.29% 72.55%;
--ring: 30 3.97% 49.41%;
--radius: 0.8rem;
/* charts */
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
.dark {
color-scheme: dark;
--background: 220 5.5% 9%;
--foreground: 220 2% 97%;
--card: 220 5.5% 10.5%;
--card-foreground: 220 2% 97%;
--popover: 220 5.5% 9%;
--popover-foreground: 220 2% 97%;
--primary: 220 2% 96%;
--primary-foreground: 220 4% 10%;
--secondary: 220 4% 16%;
--secondary-foreground: 220 0% 98%;
--muted: 220 6% 16%;
--muted-foreground: 220 4% 67%;
--accent: 220 5% 15.5%;
--accent-foreground: 220 2% 98%;
--destructive: 0 62% 46%;
--destructive-foreground: 0 0% 97%;
--border: 220 3% 16%;
--input: 220 4% 22%;
--ring: 220 4% 80%;
--radius: 0.8rem;
}
}
/* Fonts */
@supports (font-variation-settings: normal) {
:root {
font-family: Inter, InterVariable, sans-serif;
}
}
@font-face {
font-family: InterVariable;
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url("/static/InterVariable.woff2?v=4.0") format("woff2");
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
.link {
@apply text-primary font-medium underline-offset-4 hover:underline;
}
}
.recharts-tooltip-wrapper {
z-index: 1;
}
.recharts-yAxis {
@apply tabular-nums;
}

View File

@@ -1,62 +0,0 @@
import { $direction } from "./stores"
import { i18n } from "@lingui/core"
import type { Messages } from "@lingui/core"
import languages from "@/lib/languages"
import { detect, fromUrl, fromStorage, fromNavigator } from "@lingui/detect-locale"
import { messages as enMessages } from "@/locales/en/en.ts"
// let locale = detect(fromUrl("lang"), fromStorage("lang"), fromNavigator(), "en")
let locale = detect(fromStorage("lang"), fromNavigator(), "en")
// log if dev
if (import.meta.env.DEV) {
console.log("detected locale", locale)
}
// activates locale
function activateLocale(locale: string, messages: Messages = enMessages) {
i18n.load(locale, messages)
i18n.activate(locale)
document.documentElement.lang = locale
localStorage.setItem("lang", locale)
$direction.set(locale.startsWith("ar") || locale.startsWith("fa") ? "rtl" : "ltr")
}
// dynamically loads translations for the given locale
export async function dynamicActivate(locale: string) {
if (locale == "en") {
activateLocale(locale)
} else {
try {
const { messages }: { messages: Messages } = await import(`../locales/${locale}/${locale}.ts`)
activateLocale(locale, messages)
} catch (error) {
console.error(`Error loading ${locale}`, error)
activateLocale("en")
}
}
}
// handle zh variants
if (locale?.startsWith("zh-")) {
// map zh variants to zh-CN
const zhVariantMap: Record<string, string> = {
"zh-CN": "zh-CN",
"zh-SG": "zh-CN",
"zh-MY": "zh-CN",
zh: "zh-CN",
"zh-Hans": "zh-CN",
"zh-HK": "zh-HK",
"zh-TW": "zh-HK",
"zh-MO": "zh-HK",
"zh-Hant": "zh-HK",
}
dynamicActivate(zhVariantMap[locale] || "zh-CN")
} else {
locale = (locale || "en").split("-")[0]
// use en if locale is not in languages
if (!languages.some((l) => l.lang === locale)) {
locale = "en"
}
dynamicActivate(locale)
}

View File

@@ -1,44 +0,0 @@
import PocketBase from "pocketbase"
import { atom, map, WritableAtom } from "nanostores"
import { AlertRecord, ChartTimes, SystemRecord, UserSettings } from "@/types"
/** PocketBase JS Client */
export const pb = new PocketBase("/")
/** Store if user is authenticated */
export const $authenticated = atom(pb.authStore.isValid)
/** List of system records */
export const $systems = atom([] as SystemRecord[])
/** List of alert records */
export const $alerts = atom([] as AlertRecord[])
/** SSH public key */
export const $publicKey = atom("")
/** Beszel hub version */
export const $hubVersion = atom("")
/** Chart time period */
export const $chartTime = atom("1h") as WritableAtom<ChartTimes>
/** User settings */
export const $userSettings = map<UserSettings>({
chartTime: "1h",
emails: [pb.authStore.record?.email || ""],
})
// update local storage on change
$userSettings.subscribe((value) => {
// console.log('user settings changed', value)
$chartTime.set(value.chartTime)
})
/** Container chart filter */
export const $containerFilter = atom("")
/** Fallback copy to clipboard dialog content */
export const $copyContent = atom("")
/** Direction for localization */
export const $direction = atom<"ltr" | "rtl">("ltr")

View File

@@ -1,319 +0,0 @@
import { toast } from "@/components/ui/use-toast"
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
import { $alerts, $copyContent, $systems, $userSettings, pb } from "./stores"
import { AlertInfo, AlertRecord, ChartTimeData, ChartTimes, SystemRecord } from "@/types"
import { RecordModel, RecordSubscription } from "pocketbase"
import { WritableAtom } from "nanostores"
import { timeDay, timeHour } from "d3-time"
import { useEffect, useState } from "react"
import { CpuIcon, HardDriveIcon, MemoryStickIcon, ServerIcon } from "lucide-react"
import { EthernetIcon, ThermometerIcon } from "@/components/ui/icons"
import { t } from "@lingui/macro"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
// export const cn = clsx
export async function copyToClipboard(content: string) {
const duration = 1500
try {
await navigator.clipboard.writeText(content)
toast({
duration,
description: t`Copied to clipboard`,
})
} catch (e: any) {
$copyContent.set(content)
}
}
const verifyAuth = () => {
pb.collection("users")
.authRefresh()
.catch(() => {
pb.authStore.clear()
toast({
title: t`Failed to authenticate`,
description: t`Please log in again`,
variant: "destructive",
})
})
}
export const updateSystemList = async () => {
const records = await pb
.collection<SystemRecord>("systems")
.getFullList({ sort: "+name", fields: "id,name,host,info,status" })
if (records.length) {
$systems.set(records)
} else {
verifyAuth()
}
}
export const updateAlerts = () => {
pb.collection("alerts")
.getFullList<AlertRecord>({ fields: "id,name,system,value,min,triggered", sort: "updated" })
.then((records) => {
$alerts.set(records)
})
}
const hourWithMinutesFormatter = new Intl.DateTimeFormat(undefined, {
hour: "numeric",
minute: "numeric",
})
export const hourWithMinutes = (timestamp: string) => {
return hourWithMinutesFormatter.format(new Date(timestamp))
}
const shortDateFormatter = new Intl.DateTimeFormat(undefined, {
day: "numeric",
month: "short",
hour: "numeric",
minute: "numeric",
})
export const formatShortDate = (timestamp: string) => {
return shortDateFormatter.format(new Date(timestamp))
}
const dayFormatter = new Intl.DateTimeFormat(undefined, {
day: "numeric",
month: "short",
})
export const formatDay = (timestamp: string) => {
return dayFormatter.format(new Date(timestamp))
}
export const updateFavicon = (newIcon: string) => {
;(document.querySelector("link[rel='icon']") as HTMLLinkElement).href = `/static/${newIcon}`
}
export const isAdmin = () => pb.authStore.record?.role === "admin"
export const isReadOnlyUser = () => pb.authStore.record?.role === "readonly"
/** Update systems / alerts list when records change */
export function updateRecordList<T extends RecordModel>(e: RecordSubscription<T>, $store: WritableAtom<T[]>) {
const curRecords = $store.get()
const newRecords = []
if (e.action === "delete") {
for (const server of curRecords) {
if (server.id !== e.record.id) {
newRecords.push(server)
}
}
} else {
let found = 0
for (const server of curRecords) {
if (server.id === e.record.id) {
found = newRecords.push(e.record)
} else {
newRecords.push(server)
}
}
if (!found) {
newRecords.push(e.record)
}
}
$store.set(newRecords)
}
export function getPbTimestamp(timeString: ChartTimes, d?: Date) {
d ||= chartTimeData[timeString].getOffset(new Date())
const year = d.getUTCFullYear()
const month = String(d.getUTCMonth() + 1).padStart(2, "0")
const day = String(d.getUTCDate()).padStart(2, "0")
const hours = String(d.getUTCHours()).padStart(2, "0")
const minutes = String(d.getUTCMinutes()).padStart(2, "0")
const seconds = String(d.getUTCSeconds()).padStart(2, "0")
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
export const chartTimeData: ChartTimeData = {
"1h": {
type: "1m",
expectedInterval: 60_000,
label: () => t`1 hour`,
// ticks: 12,
format: (timestamp: string) => hourWithMinutes(timestamp),
getOffset: (endTime: Date) => timeHour.offset(endTime, -1),
},
"12h": {
type: "10m",
expectedInterval: 60_000 * 10,
label: () => t`12 hours`,
ticks: 12,
format: (timestamp: string) => hourWithMinutes(timestamp),
getOffset: (endTime: Date) => timeHour.offset(endTime, -12),
},
"24h": {
type: "20m",
expectedInterval: 60_000 * 20,
label: () => t`24 hours`,
format: (timestamp: string) => hourWithMinutes(timestamp),
getOffset: (endTime: Date) => timeHour.offset(endTime, -24),
},
"1w": {
type: "120m",
expectedInterval: 60_000 * 120,
label: () => t`1 week`,
ticks: 7,
format: (timestamp: string) => formatDay(timestamp),
getOffset: (endTime: Date) => timeDay.offset(endTime, -7),
},
"30d": {
type: "480m",
expectedInterval: 60_000 * 480,
label: () => t`30 days`,
ticks: 30,
format: (timestamp: string) => formatDay(timestamp),
getOffset: (endTime: Date) => timeDay.offset(endTime, -30),
},
}
/** Sets the correct width of the y axis in recharts based on the longest label */
export function useYAxisWidth() {
const [yAxisWidth, setYAxisWidth] = useState(0)
let maxChars = 0
let timeout: Timer
function updateYAxisWidth(str: string) {
if (str.length > maxChars) {
maxChars = str.length
const div = document.createElement("div")
div.className = "text-xs tabular-nums tracking-tighter table sr-only"
div.innerHTML = str
clearTimeout(timeout)
timeout = setTimeout(() => {
document.body.appendChild(div)
const width = div.offsetWidth + 24
if (width > yAxisWidth) {
setYAxisWidth(div.offsetWidth + 24)
}
document.body.removeChild(div)
})
}
return str
}
return { yAxisWidth, updateYAxisWidth }
}
export function toFixedWithoutTrailingZeros(num: number, digits: number) {
return parseFloat(num.toFixed(digits)).toString()
}
export function toFixedFloat(num: number, digits: number) {
return parseFloat(num.toFixed(digits))
}
let decimalFormatters: Map<number, Intl.NumberFormat> = new Map()
/** Format number to x decimal places */
export function decimalString(num: number, digits = 2) {
let formatter = decimalFormatters.get(digits)
if (!formatter) {
formatter = new Intl.NumberFormat(undefined, {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
})
decimalFormatters.set(digits, formatter)
}
return formatter.format(num)
}
/** Get value from local storage */
function getStorageValue(key: string, defaultValue: any) {
const saved = localStorage?.getItem(key)
return saved ? JSON.parse(saved) : defaultValue
}
/** Hook to sync value in local storage */
export function useLocalStorage<T>(key: string, defaultValue: T) {
key = `besz-${key}`
const [value, setValue] = useState(() => {
return getStorageValue(key, defaultValue)
})
useEffect(() => {
localStorage?.setItem(key, JSON.stringify(value))
}, [key, value])
return [value, setValue]
}
export async function updateUserSettings() {
try {
const req = await pb.collection("user_settings").getFirstListItem("", { fields: "settings" })
$userSettings.set(req.settings)
return
} catch (e) {
console.log("get settings", e)
}
// create user settings if error fetching existing
try {
const createdSettings = await pb.collection("user_settings").create({ user: pb.authStore.record!.id })
$userSettings.set(createdSettings.settings)
} catch (e) {
console.log("create settings", e)
}
}
/**
* Get the value and unit of size (TB, GB, or MB) for a given size
* @param n size in gigabytes or megabytes
* @param isGigabytes boolean indicating if n represents gigabytes (true) or megabytes (false)
* @returns an object containing the value and unit of size
*/
export const getSizeAndUnit = (n: number, isGigabytes = true) => {
const sizeInGB = isGigabytes ? n : n / 1_000
if (sizeInGB >= 1_000) {
return { v: sizeInGB / 1_000, u: " TB" }
} else if (sizeInGB >= 1) {
return { v: sizeInGB, u: " GB" }
}
return { v: isGigabytes ? sizeInGB * 1_000 : n, u: " MB" }
}
export const chartMargin = { top: 12 }
export const alertInfo: Record<string, AlertInfo> = {
Status: {
name: () => t`Status`,
unit: "",
icon: ServerIcon,
desc: () => t`Triggers when status switches between up and down`,
single: true,
},
CPU: {
name: () => t`CPU Usage`,
unit: "%",
icon: CpuIcon,
desc: () => t`Triggers when CPU usage exceeds a threshold`,
},
Memory: {
name: () => t`Memory Usage`,
unit: "%",
icon: MemoryStickIcon,
desc: () => t`Triggers when memory usage exceeds a threshold`,
},
Disk: {
name: () => t`Disk Usage`,
unit: "%",
icon: HardDriveIcon,
desc: () => t`Triggers when usage of any disk exceeds a threshold`,
},
Bandwidth: {
name: () => t`Bandwidth`,
unit: " MB/s",
icon: EthernetIcon,
desc: () => t`Triggers when combined up/down exceeds a threshold`,
max: 125,
},
Temperature: {
name: () => t`Temperature`,
unit: "°C",
icon: ThermometerIcon,
desc: () => t`Triggers when any sensor exceeds a threshold`,
},
}

View File

@@ -1,831 +0,0 @@
msgid ""
msgstr ""
"POT-Creation-Date: 2024-11-01 11:30-0400\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: @lingui/cli\n"
"Language: ar\n"
"Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2024-12-17 21:36\n"
"Last-Translator: \n"
"Language-Team: Arabic\n"
"Plural-Forms: nplurals=6; plural=(n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5);\n"
"X-Crowdin-Project: beszel\n"
"X-Crowdin-Project-ID: 733311\n"
"X-Crowdin-Language: ar\n"
"X-Crowdin-File: /main/beszel/site/src/locales/en/en.po\n"
"X-Crowdin-File-ID: 16\n"
#: src/components/routes/system.tsx:250
msgid "{0, plural, one {# day} other {# days}}"
msgstr "{0, plural, one {# يوم} other {# أيام}}"
#: src/components/routes/system.tsx:248
msgid "{hours, plural, one {# hour} other {# hours}}"
msgstr "{hours, plural, one {# ساعة} other {# ساعات}}"
#: src/lib/utils.ts:139
msgid "1 hour"
msgstr "1 ساعة"
#: src/lib/utils.ts:162
msgid "1 week"
msgstr "1 أسبوع"
#: src/lib/utils.ts:147
msgid "12 hours"
msgstr "12 ساعة"
#: src/lib/utils.ts:155
msgid "24 hours"
msgstr "24 ساعة"
#: src/lib/utils.ts:170
msgid "30 days"
msgstr "30 يومًا"
#. Table column
#: src/components/systems-table/systems-table.tsx:227
#: src/components/systems-table/systems-table.tsx:313
#: src/components/systems-table/systems-table.tsx:455
#: src/components/systems-table/systems-table.tsx:465
msgid "Actions"
msgstr "إجراءات"
#: src/components/routes/home.tsx:61
msgid "Active Alerts"
msgstr "التنبيهات النشطة"
#: src/components/add-system.tsx:78
msgid "Add <0>System</0>"
msgstr "إضافة <0>نظام</0>"
#: src/components/add-system.tsx:87
msgid "Add New System"
msgstr "إضافة نظام جديد"
#: src/components/add-system.tsx:165
#: src/components/add-system.tsx:176
msgid "Add system"
msgstr "إضافة نظام"
#: src/components/routes/settings/notifications.tsx:156
msgid "Add URL"
msgstr "إضافة عنوان URL"
#: src/components/routes/settings/general.tsx:81
msgid "Adjust display options for charts."
msgstr "تعديل خيارات العرض للرسوم البيانية."
#: src/components/command-palette.tsx:132
#: src/components/command-palette.tsx:145
#: src/components/command-palette.tsx:159
#: src/components/command-palette.tsx:173
#: src/components/command-palette.tsx:188
msgid "Admin"
msgstr "مسؤول"
#: src/components/systems-table/systems-table.tsx:201
msgid "Agent"
msgstr "وكيل"
#: src/components/alerts/alert-button.tsx:32
#: src/components/alerts/alert-button.tsx:68
msgid "Alerts"
msgstr "التنبيهات"
#: src/components/alerts/alert-button.tsx:88
#: src/components/systems-table/systems-table.tsx:266
msgid "All Systems"
msgstr "جميع الأنظمة"
#: src/components/systems-table/systems-table.tsx:570
msgid "Are you sure you want to delete {name}?"
msgstr "هل أنت متأكد أنك تريد حذف {name}؟"
#: src/components/copy-to-clipboard.tsx:16
msgid "Automatic copy requires a secure context."
msgstr "النسخ التلقائي يتطلب سياقًا آمنًا."
#: src/components/routes/system.tsx:625
msgid "Average"
msgstr "متوسط"
#: src/components/routes/system.tsx:396
msgid "Average CPU utilization of containers"
msgstr "متوسط استخدام وحدة المعالجة المركزية للحاويات"
#: src/components/alerts/alerts-system.tsx:206
msgid "Average exceeds <0>{value}{0}</0>"
msgstr "المتوسط يتجاوز <0>{value}{0}</0>"
#: src/components/routes/system.tsx:497
msgid "Average power consumption of GPUs"
msgstr "متوسط ​​استهلاك طاقة GPUs"
#: src/components/routes/system.tsx:385
msgid "Average system-wide CPU utilization"
msgstr "متوسط استخدام وحدة المعالجة المركزية على مستوى النظام"
#: src/components/routes/system.tsx:515
msgid "Average utilization of {0}"
msgstr "متوسط ​​استخدام {0}"
#: src/components/command-palette.tsx:170
#: src/components/navbar.tsx:93
msgid "Backups"
msgstr "النسخ الاحتياطية"
#: src/components/routes/system.tsx:441
#: src/lib/utils.ts:307
msgid "Bandwidth"
msgstr "عرض النطاق الترددي"
#: src/components/login/auth-form.tsx:289
msgid "Beszel supports OpenID Connect and many OAuth2 authentication providers."
msgstr "يدعم Beszel OpenID Connect والعديد من مزودي المصادقة OAuth2."
#: src/components/routes/settings/notifications.tsx:127
msgid "Beszel uses <0>Shoutrrr</0> to integrate with popular notification services."
msgstr "يستخدم Beszel <0>Shoutrrr</0> للتكامل مع خدمات الإشعارات الشهيرة."
#: src/components/add-system.tsx:92
msgid "Binary"
msgstr "ثنائي"
#: src/components/charts/mem-chart.tsx:89
msgid "Cache / Buffers"
msgstr "ذاكرة التخزين المؤقت / المخازن المؤقتة"
#: src/components/systems-table/systems-table.tsx:581
msgid "Cancel"
msgstr "إلغاء"
#: src/components/routes/settings/config-yaml.tsx:68
msgid "Caution - potential data loss"
msgstr "تحذير - فقدان محتمل للبيانات"
#: src/components/routes/settings/general.tsx:36
msgid "Change general application options."
msgstr "تغيير خيارات التطبيق العامة."
#: src/components/routes/settings/general.tsx:78
msgid "Chart options"
msgstr "خيارات الرسم البياني"
#: src/components/login/forgot-pass-form.tsx:34
msgid "Check {email} for a reset link."
msgstr "تحقق من {email} للحصول على رابط إعادة التعيين."
#: src/components/routes/settings/layout.tsx:40
msgid "Check logs for more details."
msgstr "تحقق من السجلات لمزيد من التفاصيل."
#: src/components/routes/settings/notifications.tsx:183
msgid "Check your notification service"
msgstr "تحقق من خدمة الإشعارات الخاصة بك"
#: src/components/add-system.tsx:151
msgid "Click to copy"
msgstr "انقر للنسخ"
#: src/components/login/forgot-pass-form.tsx:83
#: src/components/login/forgot-pass-form.tsx:89
msgid "Command line instructions"
msgstr "تعليمات سطر الأوامر"
#: src/components/routes/settings/notifications.tsx:77
msgid "Configure how you receive alert notifications."
msgstr "قم بتكوين كيفية تلقي إشعارات التنبيه."
#: src/components/login/auth-form.tsx:165
#: src/components/login/auth-form.tsx:170
msgid "Confirm password"
msgstr "تأكيد كلمة المرور"
#: src/components/systems-table/systems-table.tsx:587
msgid "Continue"
msgstr "متابعة"
#: src/lib/utils.ts:25
msgid "Copied to clipboard"
msgstr "تم النسخ إلى الحافظة"
#: src/components/add-system.tsx:162
msgid "Copy"
msgstr "نسخ"
#: src/components/systems-table/systems-table.tsx:556
msgid "Copy host"
msgstr "نسخ المضيف"
#: src/components/add-system.tsx:173
msgid "Copy Linux command"
msgstr "نسخ أمر لينكس"
#: src/components/copy-to-clipboard.tsx:13
msgid "Copy text"
msgstr "نسخ النص"
#: src/components/systems-table/systems-table.tsx:157
msgid "CPU"
msgstr "المعالج"
#: src/components/charts/area-chart.tsx:56
#: src/components/routes/system.tsx:384
#: src/lib/utils.ts:289
msgid "CPU Usage"
msgstr "استخدام وحدة المعالجة المركزية"
#: src/components/login/auth-form.tsx:191
msgid "Create account"
msgstr "إنشاء حساب"
#. Dark theme
#: src/components/mode-toggle.tsx:21
msgid "Dark"
msgstr "داكن"
#: src/components/command-palette.tsx:81
#: src/components/routes/home.tsx:34
msgid "Dashboard"
msgstr "لوحة التحكم"
#: src/components/routes/settings/general.tsx:85
msgid "Default time period"
msgstr "الفترة الزمنية الافتراضية"
#: src/components/systems-table/systems-table.tsx:562
msgid "Delete"
msgstr "حذف"
#: src/components/systems-table/systems-table.tsx:173
msgid "Disk"
msgstr "القرص"
#: src/components/routes/system.tsx:431
msgid "Disk I/O"
msgstr "إدخال/إخراج القرص"
#: src/components/charts/disk-chart.tsx:79
#: src/components/routes/system.tsx:424
#: src/lib/utils.ts:301
msgid "Disk Usage"
msgstr "استخدام القرص"
#: src/components/routes/system.tsx:552
msgid "Disk usage of {extraFsName}"
msgstr "استخدام القرص لـ {extraFsName}"
#: src/components/routes/system.tsx:395
msgid "Docker CPU Usage"
msgstr "استخدام CPU لـ Docker"
#: src/components/routes/system.tsx:416
msgid "Docker Memory Usage"
msgstr "استخدام الذاكرة لـ Docker"
#: src/components/routes/system.tsx:457
msgid "Docker Network I/O"
msgstr "إدخال/إخراج الشبكة لـ Docker"
#: src/components/command-palette.tsx:124
msgid "Documentation"
msgstr "التوثيق"
#: src/components/login/auth-form.tsx:128
#: src/components/login/forgot-pass-form.tsx:53
msgid "Email"
msgstr "البريد الإلكتروني"
#: src/components/routes/settings/notifications.tsx:91
msgid "Email notifications"
msgstr "إشعارات البريد الإلكتروني"
#: src/components/login/login.tsx:38
msgid "Enter email address to reset password"
msgstr "أدخل عنوان البريد الإلكتروني لإعادة تعيين كلمة المرور"
#: src/components/routes/settings/notifications.tsx:111
msgid "Enter email address..."
msgstr "أدخل عنوان البريد الإلكتروني..."
#: src/components/login/auth-form.tsx:232
#: src/components/routes/settings/config-yaml.tsx:28
#: src/components/routes/settings/notifications.tsx:187
msgid "Error"
msgstr "خطأ"
#: src/components/routes/home.tsx:80
msgid "Exceeds {0}{1} in last {2, plural, one {# minute} other {# minutes}}"
msgstr "يتجاوز {0}{1} في آخر {2, plural, one {# دقيقة} other {# دقائق}}"
#: src/components/routes/settings/config-yaml.tsx:72
msgid "Existing systems not defined in <0>config.yml</0> will be deleted. Please make regular backups."
msgstr "سيتم حذف الأنظمة الحالية غير المعرفة في <0>config.yml</0>. يرجى عمل نسخ احتياطية بانتظام."
#: src/components/routes/settings/config-yaml.tsx:93
msgid "Export configuration"
msgstr "تصدير التكوين"
#: src/components/routes/settings/config-yaml.tsx:48
msgid "Export your current systems configuration."
msgstr "تصدير تكوين الأنظمة الحالية الخاصة بك."
#: src/lib/utils.ts:38
msgid "Failed to authenticate"
msgstr "فشل في المصادقة"
#: src/components/routes/settings/layout.tsx:39
#: src/components/routes/settings/notifications.tsx:62
msgid "Failed to save settings"
msgstr "فشل في حفظ الإعدادات"
#: src/components/routes/settings/notifications.tsx:188
msgid "Failed to send test notification"
msgstr "فشل في إرسال إشعار الاختبار"
#: src/components/alerts/alerts-system.tsx:24
msgid "Failed to update alert"
msgstr "فشل في تحديث التنبيه"
#: src/components/routes/system.tsx:596
#: src/components/systems-table/systems-table.tsx:273
msgid "Filter..."
msgstr "تصفية..."
#: src/components/alerts/alerts-system.tsx:227
msgid "For <0>{min}</0> {min, plural, one {minute} other {minutes}}"
msgstr "لمدة <0>{min}</0> {min, plural, one {دقيقة} other {دقائق}}"
#: src/components/login/auth-form.tsx:313
msgid "Forgot password?"
msgstr "هل نسيت كلمة المرور؟"
#. Context: General settings
#: src/components/routes/settings/general.tsx:33
#: src/components/routes/settings/layout.tsx:51
msgid "General"
msgstr "عام"
#: src/components/routes/system.tsx:496
msgid "GPU Power Draw"
msgstr "استهلاك طاقة GPU"
#: src/components/systems-table/systems-table.tsx:300
msgid "Grid"
msgstr "شبكة"
#: src/components/add-system.tsx:120
msgid "Host / IP"
msgstr "مضيف / IP"
#: src/components/login/forgot-pass-form.tsx:93
msgid "If you've lost the password to your admin account, you may reset it using the following command."
msgstr "إذا فقدت كلمة المرور لحساب المسؤول الخاص بك، يمكنك إعادة تعيينها باستخدام الأمر التالي."
#: src/components/login/auth-form.tsx:16
msgid "Invalid email address."
msgstr "عنوان البريد الإلكتروني غير صالح."
#. Linux kernel
#: src/components/routes/system.tsx:262
msgid "Kernel"
msgstr "كيرنل"
#: src/components/routes/settings/general.tsx:45
msgid "Language"
msgstr "اللغة"
#: src/components/systems-table/systems-table.tsx:286
msgid "Layout"
msgstr "التخطيط"
#. Light theme
#: src/components/mode-toggle.tsx:16
msgid "Light"
msgstr "فاتح"
#: src/components/navbar.tsx:104
msgid "Log Out"
msgstr "تسجيل الخروج"
#: src/components/login/login.tsx:19
msgid "Login"
msgstr "تسجيل الدخول"
#: src/components/login/auth-form.tsx:34
#: src/components/login/forgot-pass-form.tsx:15
msgid "Login attempt failed"
msgstr "فشل محاولة تسجيل الدخول"
#: src/components/command-palette.tsx:156
#: src/components/navbar.tsx:85
msgid "Logs"
msgstr "السجلات"
#: src/components/routes/settings/notifications.tsx:80
msgid "Looking instead for where to create alerts? Click the bell <0/> icons in the systems table."
msgstr "هل تبحث عن مكان لإنشاء التنبيهات؟ انقر على أيقونات الجرس <0/> في جدول الأنظمة."
#: src/components/routes/settings/layout.tsx:85
msgid "Manage display and notification preferences."
msgstr "إدارة تفضيلات العرض والإشعارات."
#. Chart select field. Please try to keep this short.
#: src/components/routes/system.tsx:628
msgid "Max 1 min"
msgstr "1 دقيقة كحد"
#: src/components/systems-table/systems-table.tsx:165
msgid "Memory"
msgstr "الذاكرة"
#: src/components/routes/system.tsx:406
#: src/lib/utils.ts:295
msgid "Memory Usage"
msgstr "استخدام الذاكرة"
#: src/components/routes/system.tsx:417
msgid "Memory usage of docker containers"
msgstr "استخدام الذاكرة لحاويات Docker"
#: src/components/add-system.tsx:116
msgid "Name"
msgstr "الاسم"
#: src/components/systems-table/systems-table.tsx:181
msgid "Net"
msgstr "الشبكة"
#: src/components/routes/system.tsx:458
msgid "Network traffic of docker containers"
msgstr "حركة مرور الشبكة لحاويات Docker"
#: src/components/routes/system.tsx:443
msgid "Network traffic of public interfaces"
msgstr "حركة مرور الشبكة للواجهات العامة"
#: src/components/command-palette.tsx:49
msgid "No results found."
msgstr "لم يتم العثور على نتائج."
#: src/components/systems-table/systems-table.tsx:421
#: src/components/systems-table/systems-table.tsx:494
msgid "No systems found."
msgstr "لم يتم العثور على أنظمة."
#: src/components/command-palette.tsx:110
#: src/components/routes/settings/layout.tsx:56
#: src/components/routes/settings/notifications.tsx:74
msgid "Notifications"
msgstr "الإشعارات"
#: src/components/login/auth-form.tsx:284
msgid "OAuth 2 / OIDC support"
msgstr "دعم OAuth 2 / OIDC"
#: src/components/routes/settings/config-yaml.tsx:61
msgid "On each restart, systems in the database will be updated to match the systems defined in the file."
msgstr "في كل إعادة تشغيل، سيتم تحديث الأنظمة في قاعدة البيانات لتتطابق مع الأنظمة المعرفة في الملف."
#: src/components/systems-table/systems-table.tsx:528
msgid "Open menu"
msgstr "فتح القائمة"
#: src/components/login/auth-form.tsx:203
msgid "Or continue with"
msgstr "أو المتابعة باستخدام"
#: src/components/alerts/alert-button.tsx:109
msgid "Overwrite existing alerts"
msgstr "الكتابة فوق التنبيهات الحالية"
#: src/components/command-palette.tsx:84
msgid "Page"
msgstr "صفحة"
#: src/components/command-palette.tsx:71
msgid "Pages / Settings"
msgstr "الصفحات / الإعدادات"
#: src/components/login/auth-form.tsx:147
#: src/components/login/auth-form.tsx:152
msgid "Password"
msgstr "كلمة المرور"
#: src/components/login/auth-form.tsx:17
msgid "Password must be at least 8 characters."
msgstr "كلمة المرور يجب أن تتكون من 8 أحرف على الأقل."
#: src/components/login/forgot-pass-form.tsx:33
msgid "Password reset request received"
msgstr "تم استلام طلب إعادة تعيين كلمة المرور"
#: src/components/systems-table/systems-table.tsx:550
msgid "Pause"
msgstr "إيقاف مؤقت"
#: src/components/routes/settings/notifications.tsx:95
msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered."
msgstr "يرجى <0>تكوين خادم SMTP</0> لضمان تسليم التنبيهات."
#: src/components/alerts/alerts-system.tsx:25
msgid "Please check logs for more details."
msgstr "يرجى التحقق من السجلات لمزيد من التفاصيل."
#: src/components/login/auth-form.tsx:35
#: src/components/login/forgot-pass-form.tsx:16
msgid "Please check your credentials and try again"
msgstr "يرجى التحقق من بيانات الاعتماد الخاصة بك والمحاولة مرة أخرى"
#: src/components/login/login.tsx:36
msgid "Please create an admin account"
msgstr "يرجى إنشاء حساب مسؤول"
#: src/components/login/auth-form.tsx:233
msgid "Please enable pop-ups for this site"
msgstr "يرجى تمكين النوافذ المنبثقة لهذا الموقع"
#: src/lib/utils.ts:39
msgid "Please log in again"
msgstr "يرجى تسجيل الدخول مرة أخرى"
#: src/components/login/auth-form.tsx:292
msgid "Please see <0>the documentation</0> for instructions."
msgstr "يرجى الاطلاع على <0>التوثيق</0> للحصول على التعليمات."
#: src/components/login/login.tsx:40
msgid "Please sign in to your account"
msgstr "يرجى تسجيل الدخول إلى حسابك"
#: src/components/add-system.tsx:124
msgid "Port"
msgstr "المنفذ"
#: src/components/routes/system.tsx:407
#: src/components/routes/system.tsx:523
msgid "Precise utilization at the recorded time"
msgstr "الاستخدام الدقيق في الوقت المسجل"
#: src/components/routes/settings/general.tsx:58
msgid "Preferred Language"
msgstr "اللغة المفضلة"
#. Use 'Key' if your language requires many more characters
#: src/components/add-system.tsx:128
msgid "Public Key"
msgstr "المفتاح العام"
#. Disk read
#: src/components/charts/area-chart.tsx:60
#: src/components/charts/area-chart.tsx:70
msgid "Read"
msgstr "قراءة"
#. Network bytes received (download)
#: src/components/charts/area-chart.tsx:65
msgid "Received"
msgstr "تم الاستلام"
#: src/components/login/forgot-pass-form.tsx:76
msgid "Reset Password"
msgstr "إعادة تعيين كلمة المرور"
#: src/components/systems-table/systems-table.tsx:545
msgid "Resume"
msgstr "استئناف"
#: src/components/routes/settings/notifications.tsx:117
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
msgstr "احفظ العنوان باستخدام مفتاح الإدخال أو الفاصلة. اتركه فارغًا لتعطيل إشعارات البريد الإلكتروني."
#: src/components/routes/settings/general.tsx:106
#: src/components/routes/settings/notifications.tsx:167
msgid "Save Settings"
msgstr "حفظ الإعدادات"
#: src/components/navbar.tsx:133
msgid "Search"
msgstr "بحث"
#: src/components/command-palette.tsx:46
msgid "Search for systems or settings..."
msgstr "البحث عن الأنظمة أو الإعدادات..."
#: src/components/alerts/alert-button.tsx:71
msgid "See <0>notification settings</0> to configure how you receive alerts."
msgstr "راجع <0>إعدادات الإشعارات</0> لتكوين كيفية تلقي التنبيهات."
#. Network bytes sent (upload)
#: src/components/charts/area-chart.tsx:64
msgid "Sent"
msgstr "تم الإرسال"
#: src/components/routes/settings/general.tsx:100
msgid "Sets the default time range for charts when a system is viewed."
msgstr "يحدد النطاق الزمني الافتراضي للرسوم البيانية عند عرض النظام."
#: src/components/command-palette.tsx:95
#: src/components/command-palette.tsx:98
#: src/components/command-palette.tsx:113
#: src/components/routes/settings/layout.tsx:71
#: src/components/routes/settings/layout.tsx:82
msgid "Settings"
msgstr "الإعدادات"
#: src/components/routes/settings/layout.tsx:33
msgid "Settings saved"
msgstr "تم حفظ الإعدادات"
#: src/components/login/auth-form.tsx:191
msgid "Sign in"
msgstr "تسجيل الدخول"
#: src/components/command-palette.tsx:185
msgid "SMTP settings"
msgstr "إعدادات SMTP"
#: src/components/systems-table/systems-table.tsx:308
msgid "Sort By"
msgstr "الترتيب حسب"
#: src/lib/utils.ts:282
msgid "Status"
msgstr "الحالة"
#: src/components/routes/system.tsx:473
msgid "Swap space used by the system"
msgstr "مساحة التبديل المستخدمة من قبل النظام"
#: src/components/routes/system.tsx:472
msgid "Swap Usage"
msgstr "استخدام التبديل"
#. System theme
#: src/components/mode-toggle.tsx:26
#: src/components/systems-table/systems-table.tsx:117
#: src/components/systems-table/systems-table.tsx:125
#: src/components/systems-table/systems-table.tsx:136
#: src/components/systems-table/systems-table.tsx:465
msgid "System"
msgstr "النظام"
#: src/components/navbar.tsx:77
msgid "Systems"
msgstr "الأنظمة"
#: src/components/routes/settings/config-yaml.tsx:55
msgid "Systems may be managed in a <0>config.yml</0> file inside your data directory."
msgstr "يمكن إدارة الأنظمة في ملف <0>config.yml</0> داخل دليل البيانات الخاص بك."
#: src/components/systems-table/systems-table.tsx:296
msgid "Table"
msgstr "جدول"
#: src/components/routes/system.tsx:484
#: src/lib/utils.ts:314
msgid "Temperature"
msgstr "درجة الحرارة"
#: src/components/routes/system.tsx:485
msgid "Temperatures of system sensors"
msgstr "درجات حرارة مستشعرات النظام"
#: src/components/routes/settings/notifications.tsx:211
msgid "Test <0>URL</0>"
msgstr "اختبار <0>URL</0>"
#: src/components/routes/settings/notifications.tsx:182
msgid "Test notification sent"
msgstr "تم إرسال إشعار الاختبار"
#: src/components/add-system.tsx:108
msgid "The agent must be running on the system to connect. Copy the installation command for the agent below."
msgstr "يجب أن يكون الوكيل قيد التشغيل على النظام للاتصال. انسخ أمر التثبيت للوكيل أدناه."
#: src/components/add-system.tsx:99
msgid "The agent must be running on the system to connect. Copy the<0>docker-compose.yml</0> for the agent below."
msgstr "يجب أن يكون الوكيل قيد التشغيل على النظام للاتصال. انسخ <0>docker-compose.yml</0> للوكيل أدناه."
#: src/components/login/forgot-pass-form.tsx:98
msgid "Then log into the backend and reset your user account password in the users table."
msgstr "ثم قم بتسجيل الدخول إلى الواجهة الخلفية وأعد تعيين كلمة مرور حساب المستخدم الخاص بك في جدول المستخدمين."
#: src/components/systems-table/systems-table.tsx:573
msgid "This action cannot be undone. This will permanently delete all current records for {name} from the database."
msgstr "لا يمكن التراجع عن هذا الإجراء. سيؤدي ذلك إلى حذف جميع السجلات الحالية لـ {name} من قاعدة البيانات بشكل دائم."
#: src/components/routes/system.tsx:564
msgid "Throughput of {extraFsName}"
msgstr "معدل نقل {extraFsName}"
#: src/components/routes/system.tsx:432
msgid "Throughput of root filesystem"
msgstr "معدل نقل نظام الملفات الجذر"
#: src/components/routes/settings/notifications.tsx:106
msgid "To email(s)"
msgstr "إلى البريد الإلكتروني"
#: src/components/routes/system.tsx:359
#: src/components/routes/system.tsx:372
msgid "Toggle grid"
msgstr "تبديل الشبكة"
#: src/components/mode-toggle.tsx:33
msgid "Toggle theme"
msgstr "تبديل السمة"
#: src/lib/utils.ts:317
msgid "Triggers when any sensor exceeds a threshold"
msgstr "يتم التفعيل عندما <20><>تجاوز أي مستشعر عتبة معينة"
#: src/lib/utils.ts:310
msgid "Triggers when combined up/down exceeds a threshold"
msgstr "يتم التفعيل عندما يتجاوز الجمع بين الصعود/الهبوط عتبة معينة"
#: src/lib/utils.ts:292
msgid "Triggers when CPU usage exceeds a threshold"
msgstr "يتم التفعيل عندما يتجاوز استخدام وحدة المعالجة المركزية عتبة معينة"
#: src/lib/utils.ts:298
msgid "Triggers when memory usage exceeds a threshold"
msgstr "يتم التفعيل عندما يتجاوز استخدام الذاكرة عتبة معينة"
#: src/lib/utils.ts:285
msgid "Triggers when status switches between up and down"
msgstr "يتم التفعيل عندما يتغير الحالة بين التشغيل والإيقاف"
#: src/lib/utils.ts:304
msgid "Triggers when usage of any disk exceeds a threshold"
msgstr "يتم التفعيل عندما يتجاوز استخدام أي قرص عتبة معينة"
#: src/components/systems-table/systems-table.tsx:269
msgid "Updated in real time. Click on a system to view information."
msgstr "محدث في الوقت الحقيقي. انقر على نظام لعرض المعلومات."
#: src/components/routes/system.tsx:261
msgid "Uptime"
msgstr "مدة التشغيل"
#: src/components/charts/area-chart.tsx:73
#: src/components/routes/system.tsx:514
#: src/components/routes/system.tsx:551
msgid "Usage"
msgstr "الاستخدام"
#: src/components/routes/system.tsx:424
msgid "Usage of root partition"
msgstr "استخدام القسم الجذر"
#: src/components/charts/area-chart.tsx:73
#: src/components/charts/mem-chart.tsx:65
#: src/components/charts/swap-chart.tsx:56
msgid "Used"
msgstr "مستخدم"
#: src/components/command-palette.tsx:142
#: src/components/navbar.tsx:69
msgid "Users"
msgstr "المستخدمون"
#: src/components/systems-table/systems-table.tsx:278
msgid "View"
msgstr "عرض"
#: src/components/systems-table/systems-table.tsx:342
msgid "Visible Fields"
msgstr "الأعمدة الظاهرة"
#: src/components/routes/system.tsx:662
msgid "Waiting for enough records to display"
msgstr "في انتظار وجود سجلات كافية للعرض"
#: src/components/routes/settings/general.tsx:48
msgid "Want to help us make our translations even better? Check out <0>Crowdin</0> for more details."
msgstr "هل تريد مساعدتنا في تحسين ترجماتنا؟ تحقق من <0>Crowdin</0> لمزيد من التفاصيل."
#: src/components/routes/settings/notifications.tsx:124
msgid "Webhook / Push notifications"
msgstr "إشعارات Webhook / Push"
#. Disk write
#: src/components/charts/area-chart.tsx:59
#: src/components/charts/area-chart.tsx:69
msgid "Write"
msgstr "كتابة"
#: src/components/routes/settings/layout.tsx:61
msgid "YAML Config"
msgstr "تكوين YAML"
#: src/components/routes/settings/config-yaml.tsx:45
msgid "YAML Configuration"
msgstr "تكوين YAML"
#: src/components/routes/settings/layout.tsx:34
msgid "Your user settings have been updated."
msgstr "تم تحديث إعدادات المستخدم الخاصة بك."

View File

@@ -1,831 +0,0 @@
msgid ""
msgstr ""
"POT-Creation-Date: 2024-11-01 11:30-0400\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: @lingui/cli\n"
"Language: cs\n"
"Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2024-12-17 21:36\n"
"Last-Translator: \n"
"Language-Team: Czech\n"
"Plural-Forms: nplurals=4; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 3;\n"
"X-Crowdin-Project: beszel\n"
"X-Crowdin-Project-ID: 733311\n"
"X-Crowdin-Language: cs\n"
"X-Crowdin-File: /main/beszel/site/src/locales/en/en.po\n"
"X-Crowdin-File-ID: 16\n"
#: src/components/routes/system.tsx:250
msgid "{0, plural, one {# day} other {# days}}"
msgstr "{0, plural, one {# den} few {# dny} other {# dní}}"
#: src/components/routes/system.tsx:248
msgid "{hours, plural, one {# hour} other {# hours}}"
msgstr "{hours, plural, one {# Hodina} few {# Hodiny} many {# Hodin} other {# Hodin}}"
#: src/lib/utils.ts:139
msgid "1 hour"
msgstr "1 hodina"
#: src/lib/utils.ts:162
msgid "1 week"
msgstr "1 týden"
#: src/lib/utils.ts:147
msgid "12 hours"
msgstr "12 hodin"
#: src/lib/utils.ts:155
msgid "24 hours"
msgstr "24 hodin"
#: src/lib/utils.ts:170
msgid "30 days"
msgstr "30 dní"
#. Table column
#: src/components/systems-table/systems-table.tsx:227
#: src/components/systems-table/systems-table.tsx:313
#: src/components/systems-table/systems-table.tsx:455
#: src/components/systems-table/systems-table.tsx:465
msgid "Actions"
msgstr "Akce"
#: src/components/routes/home.tsx:61
msgid "Active Alerts"
msgstr "Aktivní výstrahy"
#: src/components/add-system.tsx:78
msgid "Add <0>System</0>"
msgstr "Přidat <0>Systém</0>"
#: src/components/add-system.tsx:87
msgid "Add New System"
msgstr "Přidat nový systém"
#: src/components/add-system.tsx:165
#: src/components/add-system.tsx:176
msgid "Add system"
msgstr "Přidat systém"
#: src/components/routes/settings/notifications.tsx:156
msgid "Add URL"
msgstr "Přidat URL"
#: src/components/routes/settings/general.tsx:81
msgid "Adjust display options for charts."
msgstr "Upravit možnosti zobrazení pro grafy."
#: src/components/command-palette.tsx:132
#: src/components/command-palette.tsx:145
#: src/components/command-palette.tsx:159
#: src/components/command-palette.tsx:173
#: src/components/command-palette.tsx:188
msgid "Admin"
msgstr "Admin"
#: src/components/systems-table/systems-table.tsx:201
msgid "Agent"
msgstr "Agent"
#: src/components/alerts/alert-button.tsx:32
#: src/components/alerts/alert-button.tsx:68
msgid "Alerts"
msgstr "Výstrahy"
#: src/components/alerts/alert-button.tsx:88
#: src/components/systems-table/systems-table.tsx:266
msgid "All Systems"
msgstr "Všechny systémy"
#: src/components/systems-table/systems-table.tsx:570
msgid "Are you sure you want to delete {name}?"
msgstr "Opravdu chcete odstranit {name}?"
#: src/components/copy-to-clipboard.tsx:16
msgid "Automatic copy requires a secure context."
msgstr "Automatická kopie vyžaduje zabezpečený kontext."
#: src/components/routes/system.tsx:625
msgid "Average"
msgstr "Průměr"
#: src/components/routes/system.tsx:396
msgid "Average CPU utilization of containers"
msgstr "Průměrné využití CPU kontejnerů"
#: src/components/alerts/alerts-system.tsx:206
msgid "Average exceeds <0>{value}{0}</0>"
msgstr "Průměr je vyšší než <0>{value}{0}</0>"
#: src/components/routes/system.tsx:497
msgid "Average power consumption of GPUs"
msgstr "Průměrná spotřeba energie GPU"
#: src/components/routes/system.tsx:385
msgid "Average system-wide CPU utilization"
msgstr "Průměrné využití CPU v celém systému"
#: src/components/routes/system.tsx:515
msgid "Average utilization of {0}"
msgstr "Průměrné využití {0}"
#: src/components/command-palette.tsx:170
#: src/components/navbar.tsx:93
msgid "Backups"
msgstr "Zálohy"
#: src/components/routes/system.tsx:441
#: src/lib/utils.ts:307
msgid "Bandwidth"
msgstr "Přenos"
#: src/components/login/auth-form.tsx:289
msgid "Beszel supports OpenID Connect and many OAuth2 authentication providers."
msgstr "Beszel podporuje OpenID Connect a mnoho poskytovatelů OAuth2 ověřování."
#: src/components/routes/settings/notifications.tsx:127
msgid "Beszel uses <0>Shoutrrr</0> to integrate with popular notification services."
msgstr "Beszel používá <0>Shoutrrr</0> k integraci s populárními notifikačními službami."
#: src/components/add-system.tsx:92
msgid "Binary"
msgstr "Binary"
#: src/components/charts/mem-chart.tsx:89
msgid "Cache / Buffers"
msgstr "Cache / vyrovnávací paměť"
#: src/components/systems-table/systems-table.tsx:581
msgid "Cancel"
msgstr "Zrušit"
#: src/components/routes/settings/config-yaml.tsx:68
msgid "Caution - potential data loss"
msgstr "Upozornění - možná ztráta dat"
#: src/components/routes/settings/general.tsx:36
msgid "Change general application options."
msgstr "Změnit obecné nastavení aplikace."
#: src/components/routes/settings/general.tsx:78
msgid "Chart options"
msgstr "Možnosti grafu"
#: src/components/login/forgot-pass-form.tsx:34
msgid "Check {email} for a reset link."
msgstr "Zkontrolujte {email} pro odkaz na obnovení."
#: src/components/routes/settings/layout.tsx:40
msgid "Check logs for more details."
msgstr "Pro více informací zkontrolujte logy."
#: src/components/routes/settings/notifications.tsx:183
msgid "Check your notification service"
msgstr "Zkontrolujte službu upozornění"
#: src/components/add-system.tsx:151
msgid "Click to copy"
msgstr "Klikněte pro zkopírování"
#: src/components/login/forgot-pass-form.tsx:83
#: src/components/login/forgot-pass-form.tsx:89
msgid "Command line instructions"
msgstr "Instrukce příkazového řádku"
#: src/components/routes/settings/notifications.tsx:77
msgid "Configure how you receive alert notifications."
msgstr "Konfigurace způsobu přijímání upozornění."
#: src/components/login/auth-form.tsx:165
#: src/components/login/auth-form.tsx:170
msgid "Confirm password"
msgstr "Potvrdit heslo"
#: src/components/systems-table/systems-table.tsx:587
msgid "Continue"
msgstr "Pokračovat"
#: src/lib/utils.ts:25
msgid "Copied to clipboard"
msgstr "Zkopírováno do schránky"
#: src/components/add-system.tsx:162
msgid "Copy"
msgstr "Kopírovat"
#: src/components/systems-table/systems-table.tsx:556
msgid "Copy host"
msgstr "Kopírovat hostitele"
#: src/components/add-system.tsx:173
msgid "Copy Linux command"
msgstr "Kopírovat příkaz Linux"
#: src/components/copy-to-clipboard.tsx:13
msgid "Copy text"
msgstr "Kopírovat text"
#: src/components/systems-table/systems-table.tsx:157
msgid "CPU"
msgstr "Procesor"
#: src/components/charts/area-chart.tsx:56
#: src/components/routes/system.tsx:384
#: src/lib/utils.ts:289
msgid "CPU Usage"
msgstr "Využití procesoru"
#: src/components/login/auth-form.tsx:191
msgid "Create account"
msgstr "Vytvořit účet"
#. Dark theme
#: src/components/mode-toggle.tsx:21
msgid "Dark"
msgstr "Tmavý"
#: src/components/command-palette.tsx:81
#: src/components/routes/home.tsx:34
msgid "Dashboard"
msgstr "Přehled"
#: src/components/routes/settings/general.tsx:85
msgid "Default time period"
msgstr "Výchozí doba"
#: src/components/systems-table/systems-table.tsx:562
msgid "Delete"
msgstr "Odstranit"
#: src/components/systems-table/systems-table.tsx:173
msgid "Disk"
msgstr "Disk"
#: src/components/routes/system.tsx:431
msgid "Disk I/O"
msgstr "Disk I/O"
#: src/components/charts/disk-chart.tsx:79
#: src/components/routes/system.tsx:424
#: src/lib/utils.ts:301
msgid "Disk Usage"
msgstr "Využití disku"
#: src/components/routes/system.tsx:552
msgid "Disk usage of {extraFsName}"
msgstr "Využití disku {extraFsName}"
#: src/components/routes/system.tsx:395
msgid "Docker CPU Usage"
msgstr "Využití CPU Dockeru"
#: src/components/routes/system.tsx:416
msgid "Docker Memory Usage"
msgstr "Využití paměti Dockeru"
#: src/components/routes/system.tsx:457
msgid "Docker Network I/O"
msgstr "Síťové I/O Dockeru"
#: src/components/command-palette.tsx:124
msgid "Documentation"
msgstr "Dokumentace"
#: src/components/login/auth-form.tsx:128
#: src/components/login/forgot-pass-form.tsx:53
msgid "Email"
msgstr "Email"
#: src/components/routes/settings/notifications.tsx:91
msgid "Email notifications"
msgstr "Emailová upozornění"
#: src/components/login/login.tsx:38
msgid "Enter email address to reset password"
msgstr "Zadejte e-mailovou adresu pro obnovu hesla"
#: src/components/routes/settings/notifications.tsx:111
msgid "Enter email address..."
msgstr "Zadejte e-mailovou adresu..."
#: src/components/login/auth-form.tsx:232
#: src/components/routes/settings/config-yaml.tsx:28
#: src/components/routes/settings/notifications.tsx:187
msgid "Error"
msgstr "Chyba"
#: src/components/routes/home.tsx:80
msgid "Exceeds {0}{1} in last {2, plural, one {# minute} other {# minutes}}"
msgstr "Překračuje {0}{1} za {2, plural, one {poslední # minutu} few {poslední # minuty} other {posledních # minut}}"
#: src/components/routes/settings/config-yaml.tsx:72
msgid "Existing systems not defined in <0>config.yml</0> will be deleted. Please make regular backups."
msgstr "Stávající systémy, které nejsou definovány v <0>config.yml</0>, budou odstraněny. Provádějte pravidelné zálohování."
#: src/components/routes/settings/config-yaml.tsx:93
msgid "Export configuration"
msgstr "Exportovat konfiguraci"
#: src/components/routes/settings/config-yaml.tsx:48
msgid "Export your current systems configuration."
msgstr "Exportovat aktuální konfiguraci systémů."
#: src/lib/utils.ts:38
msgid "Failed to authenticate"
msgstr "Ověření se nezdařilo"
#: src/components/routes/settings/layout.tsx:39
#: src/components/routes/settings/notifications.tsx:62
msgid "Failed to save settings"
msgstr "Nepodařilo se uložit nastavení"
#: src/components/routes/settings/notifications.tsx:188
msgid "Failed to send test notification"
msgstr "Nepodařilo se odeslat testovací oznámení"
#: src/components/alerts/alerts-system.tsx:24
msgid "Failed to update alert"
msgstr "Nepodařilo se aktualizovat upozornění"
#: src/components/routes/system.tsx:596
#: src/components/systems-table/systems-table.tsx:273
msgid "Filter..."
msgstr "Filtr..."
#: src/components/alerts/alerts-system.tsx:227
msgid "For <0>{min}</0> {min, plural, one {minute} other {minutes}}"
msgstr "Za <0>{min}</0> {min, plural, one {minutu} few {minuty} other {minut}}"
#: src/components/login/auth-form.tsx:313
msgid "Forgot password?"
msgstr "Zapomněli jste heslo?"
#. Context: General settings
#: src/components/routes/settings/general.tsx:33
#: src/components/routes/settings/layout.tsx:51
msgid "General"
msgstr "Obecné"
#: src/components/routes/system.tsx:496
msgid "GPU Power Draw"
msgstr "Spotřeba energie GPU"
#: src/components/systems-table/systems-table.tsx:300
msgid "Grid"
msgstr "Mřížka"
#: src/components/add-system.tsx:120
msgid "Host / IP"
msgstr "Hostitel / IP"
#: src/components/login/forgot-pass-form.tsx:93
msgid "If you've lost the password to your admin account, you may reset it using the following command."
msgstr "Pokud jste ztratili heslo k vašemu účtu správce, můžete jej obnovit pomocí následujícího příkazu."
#: src/components/login/auth-form.tsx:16
msgid "Invalid email address."
msgstr "Neplatná e-mailová adresa."
#. Linux kernel
#: src/components/routes/system.tsx:262
msgid "Kernel"
msgstr "Kernel"
#: src/components/routes/settings/general.tsx:45
msgid "Language"
msgstr "Jazyk"
#: src/components/systems-table/systems-table.tsx:286
msgid "Layout"
msgstr "Rozvržení"
#. Light theme
#: src/components/mode-toggle.tsx:16
msgid "Light"
msgstr "Světlý"
#: src/components/navbar.tsx:104
msgid "Log Out"
msgstr "Odhlásit"
#: src/components/login/login.tsx:19
msgid "Login"
msgstr "Přihlásit"
#: src/components/login/auth-form.tsx:34
#: src/components/login/forgot-pass-form.tsx:15
msgid "Login attempt failed"
msgstr "Pokus o přihlášení selhal"
#: src/components/command-palette.tsx:156
#: src/components/navbar.tsx:85
msgid "Logs"
msgstr "Logy"
#: src/components/routes/settings/notifications.tsx:80
msgid "Looking instead for where to create alerts? Click the bell <0/> icons in the systems table."
msgstr "Hledáte místo kde vytvářet upozornění? Klikněte na ikonu zvonku <0/> v systémové tabulce."
#: src/components/routes/settings/layout.tsx:85
msgid "Manage display and notification preferences."
msgstr "Správa nastavení zobrazení a oznámení."
#. Chart select field. Please try to keep this short.
#: src/components/routes/system.tsx:628
msgid "Max 1 min"
msgstr "Max. 1 min"
#: src/components/systems-table/systems-table.tsx:165
msgid "Memory"
msgstr "Paměť"
#: src/components/routes/system.tsx:406
#: src/lib/utils.ts:295
msgid "Memory Usage"
msgstr "Využití paměti"
#: src/components/routes/system.tsx:417
msgid "Memory usage of docker containers"
msgstr "Využití paměti docker kontejnerů"
#: src/components/add-system.tsx:116
msgid "Name"
msgstr "Název"
#: src/components/systems-table/systems-table.tsx:181
msgid "Net"
msgstr "Síť"
#: src/components/routes/system.tsx:458
msgid "Network traffic of docker containers"
msgstr "Síťový provoz kontejnerů docker"
#: src/components/routes/system.tsx:443
msgid "Network traffic of public interfaces"
msgstr "Síťový provoz veřejných rozhraní"
#: src/components/command-palette.tsx:49
msgid "No results found."
msgstr "Nenalezeny žádné výskyty."
#: src/components/systems-table/systems-table.tsx:421
#: src/components/systems-table/systems-table.tsx:494
msgid "No systems found."
msgstr "Nenalezeny žádné systémy."
#: src/components/command-palette.tsx:110
#: src/components/routes/settings/layout.tsx:56
#: src/components/routes/settings/notifications.tsx:74
msgid "Notifications"
msgstr "Upozornění"
#: src/components/login/auth-form.tsx:284
msgid "OAuth 2 / OIDC support"
msgstr "Podpora OAuth 2 / OIDC"
#: src/components/routes/settings/config-yaml.tsx:61
msgid "On each restart, systems in the database will be updated to match the systems defined in the file."
msgstr "Při každém restartu budou systémy v databázi aktualizovány tak, aby odpovídaly systémům definovaným v souboru."
#: src/components/systems-table/systems-table.tsx:528
msgid "Open menu"
msgstr "Otevřít menu"
#: src/components/login/auth-form.tsx:203
msgid "Or continue with"
msgstr "Nebo pokračujte s"
#: src/components/alerts/alert-button.tsx:109
msgid "Overwrite existing alerts"
msgstr "Přepsat existující upozornění"
#: src/components/command-palette.tsx:84
msgid "Page"
msgstr "Stránka"
#: src/components/command-palette.tsx:71
msgid "Pages / Settings"
msgstr "Stránky / Nastavení"
#: src/components/login/auth-form.tsx:147
#: src/components/login/auth-form.tsx:152
msgid "Password"
msgstr "Heslo"
#: src/components/login/auth-form.tsx:17
msgid "Password must be at least 8 characters."
msgstr "Heslo musí obsahovat alespoň 8 znaků."
#: src/components/login/forgot-pass-form.tsx:33
msgid "Password reset request received"
msgstr "Žádost o obnovu hesla byla přijata"
#: src/components/systems-table/systems-table.tsx:550
msgid "Pause"
msgstr "Pozastavit"
#: src/components/routes/settings/notifications.tsx:95
msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered."
msgstr "<0>nakonfigurujte SMTP server</0> pro zajištění toho, aby byla upozornění doručena."
#: src/components/alerts/alerts-system.tsx:25
msgid "Please check logs for more details."
msgstr "Pro více informací zkontrolujte logy."
#: src/components/login/auth-form.tsx:35
#: src/components/login/forgot-pass-form.tsx:16
msgid "Please check your credentials and try again"
msgstr "Zkontrolujte prosím Vaše přihlašovací údaje a zkuste to znovu"
#: src/components/login/login.tsx:36
msgid "Please create an admin account"
msgstr "Vytvořte si prosím účet administrátora"
#: src/components/login/auth-form.tsx:233
msgid "Please enable pop-ups for this site"
msgstr "Prosím povolte vyskakovací okna pro tento web"
#: src/lib/utils.ts:39
msgid "Please log in again"
msgstr "Přihlaste se prosím znovu"
#: src/components/login/auth-form.tsx:292
msgid "Please see <0>the documentation</0> for instructions."
msgstr "Instrukce naleznete v <0>dokumentaci</0>."
#: src/components/login/login.tsx:40
msgid "Please sign in to your account"
msgstr "Přihlaste se prosím k vašemu účtu"
#: src/components/add-system.tsx:124
msgid "Port"
msgstr "Port"
#: src/components/routes/system.tsx:407
#: src/components/routes/system.tsx:523
msgid "Precise utilization at the recorded time"
msgstr "Přesné využití v zaznamenaném čase"
#: src/components/routes/settings/general.tsx:58
msgid "Preferred Language"
msgstr "Upřednostňovaný jazyk"
#. Use 'Key' if your language requires many more characters
#: src/components/add-system.tsx:128
msgid "Public Key"
msgstr "Veřejný klíč"
#. Disk read
#: src/components/charts/area-chart.tsx:60
#: src/components/charts/area-chart.tsx:70
msgid "Read"
msgstr "Číst"
#. Network bytes received (download)
#: src/components/charts/area-chart.tsx:65
msgid "Received"
msgstr "Přijato"
#: src/components/login/forgot-pass-form.tsx:76
msgid "Reset Password"
msgstr "Obnovit heslo"
#: src/components/systems-table/systems-table.tsx:545
msgid "Resume"
msgstr "Pokračovat"
#: src/components/routes/settings/notifications.tsx:117
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
msgstr "Adresu uložte pomocí klávesy enter nebo čárky. Pro deaktivaci e-mailových oznámení ponechte prázdné pole."
#: src/components/routes/settings/general.tsx:106
#: src/components/routes/settings/notifications.tsx:167
msgid "Save Settings"
msgstr "Uložit nastavení"
#: src/components/navbar.tsx:133
msgid "Search"
msgstr "Hledat"
#: src/components/command-palette.tsx:46
msgid "Search for systems or settings..."
msgstr "Hledat systémy nebo nastavení..."
#: src/components/alerts/alert-button.tsx:71
msgid "See <0>notification settings</0> to configure how you receive alerts."
msgstr "Podívejte se na <0>nastavení upozornění</0> pro nastavení toho, jak přijímáte upozornění."
#. Network bytes sent (upload)
#: src/components/charts/area-chart.tsx:64
msgid "Sent"
msgstr "Odeslat"
#: src/components/routes/settings/general.tsx:100
msgid "Sets the default time range for charts when a system is viewed."
msgstr "Nastaví výchozí časový rozsah grafů, když je systém zobrazen."
#: src/components/command-palette.tsx:95
#: src/components/command-palette.tsx:98
#: src/components/command-palette.tsx:113
#: src/components/routes/settings/layout.tsx:71
#: src/components/routes/settings/layout.tsx:82
msgid "Settings"
msgstr "Nastavení"
#: src/components/routes/settings/layout.tsx:33
msgid "Settings saved"
msgstr "Nastavení uloženo"
#: src/components/login/auth-form.tsx:191
msgid "Sign in"
msgstr "Přihlásit se"
#: src/components/command-palette.tsx:185
msgid "SMTP settings"
msgstr "Nastavení SMTP"
#: src/components/systems-table/systems-table.tsx:308
msgid "Sort By"
msgstr "Seřadit podle"
#: src/lib/utils.ts:282
msgid "Status"
msgstr "Stav"
#: src/components/routes/system.tsx:473
msgid "Swap space used by the system"
msgstr "Swap prostor využívaný systémem"
#: src/components/routes/system.tsx:472
msgid "Swap Usage"
msgstr "Swap využití"
#. System theme
#: src/components/mode-toggle.tsx:26
#: src/components/systems-table/systems-table.tsx:117
#: src/components/systems-table/systems-table.tsx:125
#: src/components/systems-table/systems-table.tsx:136
#: src/components/systems-table/systems-table.tsx:465
msgid "System"
msgstr "Systém"
#: src/components/navbar.tsx:77
msgid "Systems"
msgstr "Systémy"
#: src/components/routes/settings/config-yaml.tsx:55
msgid "Systems may be managed in a <0>config.yml</0> file inside your data directory."
msgstr "Systémy lze spravovat v souboru <0>config.yml</0> uvnitř datového adresáře."
#: src/components/systems-table/systems-table.tsx:296
msgid "Table"
msgstr "Tabulka"
#: src/components/routes/system.tsx:484
#: src/lib/utils.ts:314
msgid "Temperature"
msgstr "Teplota"
#: src/components/routes/system.tsx:485
msgid "Temperatures of system sensors"
msgstr "Teploty systémových senzorů"
#: src/components/routes/settings/notifications.tsx:211
msgid "Test <0>URL</0>"
msgstr "Test <0>URL</0>"
#: src/components/routes/settings/notifications.tsx:182
msgid "Test notification sent"
msgstr "Testovací oznámení odesláno"
#: src/components/add-system.tsx:108
msgid "The agent must be running on the system to connect. Copy the installation command for the agent below."
msgstr "Agent musí být v systému spuštěn, aby se mohl připojit. Zkopírujte níže uvedený instalační příkaz pro agenta."
#: src/components/add-system.tsx:99
msgid "The agent must be running on the system to connect. Copy the<0>docker-compose.yml</0> for the agent below."
msgstr "Agent musí být v systému spuštěn, aby se mohl připojit. Zkopírujte níže uvedený soubor<0>docker-compose.yml</0> pro agenta."
#: src/components/login/forgot-pass-form.tsx:98
msgid "Then log into the backend and reset your user account password in the users table."
msgstr "Poté se přihlaste do backendu a obnovte heslo k uživatelskému účtu v tabulce uživatelů."
#: src/components/systems-table/systems-table.tsx:573
msgid "This action cannot be undone. This will permanently delete all current records for {name} from the database."
msgstr "Tuto akci nelze vzít zpět. Tím se z databáze trvale odstraní všechny aktuální záznamy pro {name}."
#: src/components/routes/system.tsx:564
msgid "Throughput of {extraFsName}"
msgstr "Propustnost {extraFsName}"
#: src/components/routes/system.tsx:432
msgid "Throughput of root filesystem"
msgstr "Propustnost kořenového souborového systému"
#: src/components/routes/settings/notifications.tsx:106
msgid "To email(s)"
msgstr "Na email(y)"
#: src/components/routes/system.tsx:359
#: src/components/routes/system.tsx:372
msgid "Toggle grid"
msgstr "Přepnout mřížku"
#: src/components/mode-toggle.tsx:33
msgid "Toggle theme"
msgstr "Přepnout motiv"
#: src/lib/utils.ts:317
msgid "Triggers when any sensor exceeds a threshold"
msgstr "Spustí se, když některý senzor překročí prahovou hodnotu"
#: src/lib/utils.ts:310
msgid "Triggers when combined up/down exceeds a threshold"
msgstr "Spustí se, když kombinace up/down překročí prahovou hodnotu"
#: src/lib/utils.ts:292
msgid "Triggers when CPU usage exceeds a threshold"
msgstr "Spustí se, když využití procesoru překročí prahovou hodnotu"
#: src/lib/utils.ts:298
msgid "Triggers when memory usage exceeds a threshold"
msgstr "Spustí se, když využití paměti překročí prahovou hodnotu"
#: src/lib/utils.ts:285
msgid "Triggers when status switches between up and down"
msgstr "Spouští se, když se změní dostupnost"
#: src/lib/utils.ts:304
msgid "Triggers when usage of any disk exceeds a threshold"
msgstr "Spustí se, když využití disku překročí prahovou hodnotu"
#: src/components/systems-table/systems-table.tsx:269
msgid "Updated in real time. Click on a system to view information."
msgstr "Aktualizováno v reálném čase. Klepnutím na systém zobrazíte informace."
#: src/components/routes/system.tsx:261
msgid "Uptime"
msgstr "Doba provozu"
#: src/components/charts/area-chart.tsx:73
#: src/components/routes/system.tsx:514
#: src/components/routes/system.tsx:551
msgid "Usage"
msgstr "Využití"
#: src/components/routes/system.tsx:424
msgid "Usage of root partition"
msgstr "Využití kořenového oddílu"
#: src/components/charts/area-chart.tsx:73
#: src/components/charts/mem-chart.tsx:65
#: src/components/charts/swap-chart.tsx:56
msgid "Used"
msgstr "Využito"
#: src/components/command-palette.tsx:142
#: src/components/navbar.tsx:69
msgid "Users"
msgstr "Uživatelé"
#: src/components/systems-table/systems-table.tsx:278
msgid "View"
msgstr "Zobrazení"
#: src/components/systems-table/systems-table.tsx:342
msgid "Visible Fields"
msgstr "Viditelné sloupce"
#: src/components/routes/system.tsx:662
msgid "Waiting for enough records to display"
msgstr "Čeká se na dostatek záznamů k zobrazení"
#: src/components/routes/settings/general.tsx:48
msgid "Want to help us make our translations even better? Check out <0>Crowdin</0> for more details."
msgstr "Chcete nám pomoci s našimi překlady ještě lépe? Podívejte se na <0>Crowdin</0> pro více informací."
#: src/components/routes/settings/notifications.tsx:124
msgid "Webhook / Push notifications"
msgstr "Webhook / Push oznámení"
#. Disk write
#: src/components/charts/area-chart.tsx:59
#: src/components/charts/area-chart.tsx:69
msgid "Write"
msgstr "Psát"
#: src/components/routes/settings/layout.tsx:61
msgid "YAML Config"
msgstr "YAML konfigurace"
#: src/components/routes/settings/config-yaml.tsx:45
msgid "YAML Configuration"
msgstr "YAML konfigurace"
#: src/components/routes/settings/layout.tsx:34
msgid "Your user settings have been updated."
msgstr "Vaše uživatelská nastavení byla aktualizována."

View File

@@ -1,831 +0,0 @@
msgid ""
msgstr ""
"POT-Creation-Date: 2024-11-01 11:30-0400\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: @lingui/cli\n"
"Language: de\n"
"Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2024-12-17 21:36\n"
"Last-Translator: \n"
"Language-Team: German\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Crowdin-Project: beszel\n"
"X-Crowdin-Project-ID: 733311\n"
"X-Crowdin-Language: de\n"
"X-Crowdin-File: /main/beszel/site/src/locales/en/en.po\n"
"X-Crowdin-File-ID: 16\n"
#: src/components/routes/system.tsx:250
msgid "{0, plural, one {# day} other {# days}}"
msgstr "{0, plural, one {# Tag} other {# Tage}}"
#: src/components/routes/system.tsx:248
msgid "{hours, plural, one {# hour} other {# hours}}"
msgstr "{hours, plural, one {# Stunde} other {# Stunden}}"
#: src/lib/utils.ts:139
msgid "1 hour"
msgstr "1 Stunde"
#: src/lib/utils.ts:162
msgid "1 week"
msgstr "1 Woche"
#: src/lib/utils.ts:147
msgid "12 hours"
msgstr "12 Stunden"
#: src/lib/utils.ts:155
msgid "24 hours"
msgstr "24 Stunden"
#: src/lib/utils.ts:170
msgid "30 days"
msgstr "30 Tage"
#. Table column
#: src/components/systems-table/systems-table.tsx:227
#: src/components/systems-table/systems-table.tsx:313
#: src/components/systems-table/systems-table.tsx:455
#: src/components/systems-table/systems-table.tsx:465
msgid "Actions"
msgstr "Aktionen"
#: src/components/routes/home.tsx:61
msgid "Active Alerts"
msgstr "Aktive Warnungen"
#: src/components/add-system.tsx:78
msgid "Add <0>System</0>"
msgstr "<0>System</0> hinzufügen"
#: src/components/add-system.tsx:87
msgid "Add New System"
msgstr "Neues System hinzufügen"
#: src/components/add-system.tsx:165
#: src/components/add-system.tsx:176
msgid "Add system"
msgstr "System hinzufügen"
#: src/components/routes/settings/notifications.tsx:156
msgid "Add URL"
msgstr "URL hinzufügen"
#: src/components/routes/settings/general.tsx:81
msgid "Adjust display options for charts."
msgstr "Anzeigeoptionen für Diagramme anpassen."
#: src/components/command-palette.tsx:132
#: src/components/command-palette.tsx:145
#: src/components/command-palette.tsx:159
#: src/components/command-palette.tsx:173
#: src/components/command-palette.tsx:188
msgid "Admin"
msgstr "Admin"
#: src/components/systems-table/systems-table.tsx:201
msgid "Agent"
msgstr "Agent"
#: src/components/alerts/alert-button.tsx:32
#: src/components/alerts/alert-button.tsx:68
msgid "Alerts"
msgstr "Warnungen"
#: src/components/alerts/alert-button.tsx:88
#: src/components/systems-table/systems-table.tsx:266
msgid "All Systems"
msgstr "Alle Systeme"
#: src/components/systems-table/systems-table.tsx:570
msgid "Are you sure you want to delete {name}?"
msgstr "Möchten Sie {name} wirklich löschen?"
#: src/components/copy-to-clipboard.tsx:16
msgid "Automatic copy requires a secure context."
msgstr "Automatisches Kopieren erfordert einen sicheren Kontext."
#: src/components/routes/system.tsx:625
msgid "Average"
msgstr "Durchschnitt"
#: src/components/routes/system.tsx:396
msgid "Average CPU utilization of containers"
msgstr "Durchschnittliche CPU-Auslastung der Container"
#: src/components/alerts/alerts-system.tsx:206
msgid "Average exceeds <0>{value}{0}</0>"
msgstr "Durchschnitt überschreitet <0>{value}{0}</0>"
#: src/components/routes/system.tsx:497
msgid "Average power consumption of GPUs"
msgstr "Durchschnittlicher Stromverbrauch der GPUs"
#: src/components/routes/system.tsx:385
msgid "Average system-wide CPU utilization"
msgstr "Durchschnittliche systemweite CPU-Auslastung"
#: src/components/routes/system.tsx:515
msgid "Average utilization of {0}"
msgstr "Durchschnittliche Auslastung von {0}"
#: src/components/command-palette.tsx:170
#: src/components/navbar.tsx:93
msgid "Backups"
msgstr "Backups"
#: src/components/routes/system.tsx:441
#: src/lib/utils.ts:307
msgid "Bandwidth"
msgstr "Bandbreite"
#: src/components/login/auth-form.tsx:289
msgid "Beszel supports OpenID Connect and many OAuth2 authentication providers."
msgstr "Beszel unterstützt OpenID Connect und viele OAuth2-Authentifizierungsanbieter."
#: src/components/routes/settings/notifications.tsx:127
msgid "Beszel uses <0>Shoutrrr</0> to integrate with popular notification services."
msgstr "Beszel verwendet <0>Shoutrrr</0>, um sich mit beliebten Benachrichtigungsdiensten zu integrieren."
#: src/components/add-system.tsx:92
msgid "Binary"
msgstr "Binär"
#: src/components/charts/mem-chart.tsx:89
msgid "Cache / Buffers"
msgstr "Cache / Puffer"
#: src/components/systems-table/systems-table.tsx:581
msgid "Cancel"
msgstr "Abbrechen"
#: src/components/routes/settings/config-yaml.tsx:68
msgid "Caution - potential data loss"
msgstr "Vorsicht - potenzieller Datenverlust"
#: src/components/routes/settings/general.tsx:36
msgid "Change general application options."
msgstr "Allgemeine Anwendungsoptionen ändern."
#: src/components/routes/settings/general.tsx:78
msgid "Chart options"
msgstr "Diagrammoptionen"
#: src/components/login/forgot-pass-form.tsx:34
msgid "Check {email} for a reset link."
msgstr "Überprüfen Sie {email} auf einen Rücksetzlink."
#: src/components/routes/settings/layout.tsx:40
msgid "Check logs for more details."
msgstr "Überprüfen Sie die Protokolle für weitere Details."
#: src/components/routes/settings/notifications.tsx:183
msgid "Check your notification service"
msgstr "Überprüfen Sie Ihren Benachrichtigungsdienst"
#: src/components/add-system.tsx:151
msgid "Click to copy"
msgstr "Zum Kopieren klicken"
#: src/components/login/forgot-pass-form.tsx:83
#: src/components/login/forgot-pass-form.tsx:89
msgid "Command line instructions"
msgstr "Befehlszeilenanweisungen"
#: src/components/routes/settings/notifications.tsx:77
msgid "Configure how you receive alert notifications."
msgstr "Konfigurieren Sie, wie Sie Warnbenachrichtigungen erhalten."
#: src/components/login/auth-form.tsx:165
#: src/components/login/auth-form.tsx:170
msgid "Confirm password"
msgstr "Passwort bestätigen"
#: src/components/systems-table/systems-table.tsx:587
msgid "Continue"
msgstr "Fortfahren"
#: src/lib/utils.ts:25
msgid "Copied to clipboard"
msgstr "In die Zwischenablage kopiert"
#: src/components/add-system.tsx:162
msgid "Copy"
msgstr "Kopieren"
#: src/components/systems-table/systems-table.tsx:556
msgid "Copy host"
msgstr "Host kopieren"
#: src/components/add-system.tsx:173
msgid "Copy Linux command"
msgstr "Linux-Befehl kopieren"
#: src/components/copy-to-clipboard.tsx:13
msgid "Copy text"
msgstr "Text kopieren"
#: src/components/systems-table/systems-table.tsx:157
msgid "CPU"
msgstr "CPU"
#: src/components/charts/area-chart.tsx:56
#: src/components/routes/system.tsx:384
#: src/lib/utils.ts:289
msgid "CPU Usage"
msgstr "CPU-Auslastung"
#: src/components/login/auth-form.tsx:191
msgid "Create account"
msgstr "Konto erstellen"
#. Dark theme
#: src/components/mode-toggle.tsx:21
msgid "Dark"
msgstr "Dunkel"
#: src/components/command-palette.tsx:81
#: src/components/routes/home.tsx:34
msgid "Dashboard"
msgstr "Dashboard"
#: src/components/routes/settings/general.tsx:85
msgid "Default time period"
msgstr "Standardzeitraum"
#: src/components/systems-table/systems-table.tsx:562
msgid "Delete"
msgstr "Löschen"
#: src/components/systems-table/systems-table.tsx:173
msgid "Disk"
msgstr "Festplatte"
#: src/components/routes/system.tsx:431
msgid "Disk I/O"
msgstr "Festplatten-I/O"
#: src/components/charts/disk-chart.tsx:79
#: src/components/routes/system.tsx:424
#: src/lib/utils.ts:301
msgid "Disk Usage"
msgstr "Festplattennutzung"
#: src/components/routes/system.tsx:552
msgid "Disk usage of {extraFsName}"
msgstr "Festplattennutzung von {extraFsName}"
#: src/components/routes/system.tsx:395
msgid "Docker CPU Usage"
msgstr "Docker-CPU-Auslastung"
#: src/components/routes/system.tsx:416
msgid "Docker Memory Usage"
msgstr "Docker-Speichernutzung"
#: src/components/routes/system.tsx:457
msgid "Docker Network I/O"
msgstr "Docker-Netzwerk-I/O"
#: src/components/command-palette.tsx:124
msgid "Documentation"
msgstr "Dokumentation"
#: src/components/login/auth-form.tsx:128
#: src/components/login/forgot-pass-form.tsx:53
msgid "Email"
msgstr "E-Mail"
#: src/components/routes/settings/notifications.tsx:91
msgid "Email notifications"
msgstr "E-Mail-Benachrichtigungen"
#: src/components/login/login.tsx:38
msgid "Enter email address to reset password"
msgstr "E-Mail-Adresse eingeben, um das Passwort zurückzusetzen"
#: src/components/routes/settings/notifications.tsx:111
msgid "Enter email address..."
msgstr "E-Mail-Adresse eingeben..."
#: src/components/login/auth-form.tsx:232
#: src/components/routes/settings/config-yaml.tsx:28
#: src/components/routes/settings/notifications.tsx:187
msgid "Error"
msgstr "Fehler"
#: src/components/routes/home.tsx:80
msgid "Exceeds {0}{1} in last {2, plural, one {# minute} other {# minutes}}"
msgstr "Überschreitet {0}{1} in den letzten {2, plural, one {# Minute} other {# Minuten}}"
#: src/components/routes/settings/config-yaml.tsx:72
msgid "Existing systems not defined in <0>config.yml</0> will be deleted. Please make regular backups."
msgstr "Bestehende Systeme, die nicht in <0>config.yml</0> definiert sind, werden gelöscht. Bitte machen Sie regelmäßige Backups."
#: src/components/routes/settings/config-yaml.tsx:93
msgid "Export configuration"
msgstr "Konfiguration exportieren"
#: src/components/routes/settings/config-yaml.tsx:48
msgid "Export your current systems configuration."
msgstr "Exportieren Sie Ihre aktuelle Systemkonfiguration."
#: src/lib/utils.ts:38
msgid "Failed to authenticate"
msgstr "Authentifizierung fehlgeschlagen"
#: src/components/routes/settings/layout.tsx:39
#: src/components/routes/settings/notifications.tsx:62
msgid "Failed to save settings"
msgstr "Einstellungen konnten nicht gespeichert werden"
#: src/components/routes/settings/notifications.tsx:188
msgid "Failed to send test notification"
msgstr "Testbenachrichtigung konnte nicht gesendet werden"
#: src/components/alerts/alerts-system.tsx:24
msgid "Failed to update alert"
msgstr "Warnung konnte nicht aktualisiert werden"
#: src/components/routes/system.tsx:596
#: src/components/systems-table/systems-table.tsx:273
msgid "Filter..."
msgstr "Filter..."
#: src/components/alerts/alerts-system.tsx:227
msgid "For <0>{min}</0> {min, plural, one {minute} other {minutes}}"
msgstr "Für <0>{min}</0> {min, plural, one {Minute} other {Minuten}}"
#: src/components/login/auth-form.tsx:313
msgid "Forgot password?"
msgstr "Passwort vergessen?"
#. Context: General settings
#: src/components/routes/settings/general.tsx:33
#: src/components/routes/settings/layout.tsx:51
msgid "General"
msgstr "Allgemein"
#: src/components/routes/system.tsx:496
msgid "GPU Power Draw"
msgstr "GPU-Leistungsaufnahme"
#: src/components/systems-table/systems-table.tsx:300
msgid "Grid"
msgstr "Raster"
#: src/components/add-system.tsx:120
msgid "Host / IP"
msgstr "Host / IP"
#: src/components/login/forgot-pass-form.tsx:93
msgid "If you've lost the password to your admin account, you may reset it using the following command."
msgstr "Wenn Sie das Passwort für Ihr Administratorkonto verloren haben, können Sie es mit dem folgenden Befehl zurücksetzen."
#: src/components/login/auth-form.tsx:16
msgid "Invalid email address."
msgstr "Ungültige E-Mail-Adresse."
#. Linux kernel
#: src/components/routes/system.tsx:262
msgid "Kernel"
msgstr "Kernel"
#: src/components/routes/settings/general.tsx:45
msgid "Language"
msgstr "Sprache"
#: src/components/systems-table/systems-table.tsx:286
msgid "Layout"
msgstr ""
#. Light theme
#: src/components/mode-toggle.tsx:16
msgid "Light"
msgstr "Hell"
#: src/components/navbar.tsx:104
msgid "Log Out"
msgstr "Abmelden"
#: src/components/login/login.tsx:19
msgid "Login"
msgstr "Anmelden"
#: src/components/login/auth-form.tsx:34
#: src/components/login/forgot-pass-form.tsx:15
msgid "Login attempt failed"
msgstr "Anmeldeversuch fehlgeschlagen"
#: src/components/command-palette.tsx:156
#: src/components/navbar.tsx:85
msgid "Logs"
msgstr "Protokolle"
#: src/components/routes/settings/notifications.tsx:80
msgid "Looking instead for where to create alerts? Click the bell <0/> icons in the systems table."
msgstr "Suchen Sie stattdessen nach der Erstellung von Warnungen? Klicken Sie auf die Glocken-<0/>-Symbole in der Systemtabelle."
#: src/components/routes/settings/layout.tsx:85
msgid "Manage display and notification preferences."
msgstr "Anzeige- und Benachrichtigungseinstellungen verwalten."
#. Chart select field. Please try to keep this short.
#: src/components/routes/system.tsx:628
msgid "Max 1 min"
msgstr "Max 1 Min"
#: src/components/systems-table/systems-table.tsx:165
msgid "Memory"
msgstr "Speicher"
#: src/components/routes/system.tsx:406
#: src/lib/utils.ts:295
msgid "Memory Usage"
msgstr "Speichernutzung"
#: src/components/routes/system.tsx:417
msgid "Memory usage of docker containers"
msgstr "Speichernutzung der Docker-Container"
#: src/components/add-system.tsx:116
msgid "Name"
msgstr "Name"
#: src/components/systems-table/systems-table.tsx:181
msgid "Net"
msgstr "Netz"
#: src/components/routes/system.tsx:458
msgid "Network traffic of docker containers"
msgstr "Netzwerkverkehr der Docker-Container"
#: src/components/routes/system.tsx:443
msgid "Network traffic of public interfaces"
msgstr "Netzwerkverkehr der öffentlichen Schnittstellen"
#: src/components/command-palette.tsx:49
msgid "No results found."
msgstr "Keine Ergebnisse gefunden."
#: src/components/systems-table/systems-table.tsx:421
#: src/components/systems-table/systems-table.tsx:494
msgid "No systems found."
msgstr "Keine Systeme gefunden."
#: src/components/command-palette.tsx:110
#: src/components/routes/settings/layout.tsx:56
#: src/components/routes/settings/notifications.tsx:74
msgid "Notifications"
msgstr "Benachrichtigungen"
#: src/components/login/auth-form.tsx:284
msgid "OAuth 2 / OIDC support"
msgstr "OAuth 2 / OIDC-Unterstützung"
#: src/components/routes/settings/config-yaml.tsx:61
msgid "On each restart, systems in the database will be updated to match the systems defined in the file."
msgstr "Bei jedem Neustart werden die Systeme in der Datenbank aktualisiert, um den im Datei definierten Systemen zu entsprechen."
#: src/components/systems-table/systems-table.tsx:528
msgid "Open menu"
msgstr "Menü öffnen"
#: src/components/login/auth-form.tsx:203
msgid "Or continue with"
msgstr "Oder fortfahren mit"
#: src/components/alerts/alert-button.tsx:109
msgid "Overwrite existing alerts"
msgstr "Bestehende Warnungen überschreiben"
#: src/components/command-palette.tsx:84
msgid "Page"
msgstr "Seite"
#: src/components/command-palette.tsx:71
msgid "Pages / Settings"
msgstr "Seiten / Einstellungen"
#: src/components/login/auth-form.tsx:147
#: src/components/login/auth-form.tsx:152
msgid "Password"
msgstr "Passwort"
#: src/components/login/auth-form.tsx:17
msgid "Password must be at least 8 characters."
msgstr "Das Passwort muss mindestens 8 Zeichen haben."
#: src/components/login/forgot-pass-form.tsx:33
msgid "Password reset request received"
msgstr "Anfrage zum Zurücksetzen des Passworts erhalten"
#: src/components/systems-table/systems-table.tsx:550
msgid "Pause"
msgstr "Pause"
#: src/components/routes/settings/notifications.tsx:95
msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered."
msgstr "Bitte <0>konfigurieren Sie einen SMTP-Server</0>, um sicherzustellen, dass Warnungen zugestellt werden."
#: src/components/alerts/alerts-system.tsx:25
msgid "Please check logs for more details."
msgstr "Bitte überprüfen Sie die Protokolle für weitere Details."
#: src/components/login/auth-form.tsx:35
#: src/components/login/forgot-pass-form.tsx:16
msgid "Please check your credentials and try again"
msgstr "Bitte überprüfen Sie Ihre Anmeldedaten und versuchen Sie es erneut"
#: src/components/login/login.tsx:36
msgid "Please create an admin account"
msgstr "Bitte erstellen Sie ein Administratorkonto"
#: src/components/login/auth-form.tsx:233
msgid "Please enable pop-ups for this site"
msgstr "Bitte aktivieren Sie Pop-ups für diese Seite"
#: src/lib/utils.ts:39
msgid "Please log in again"
msgstr "Bitte melden Sie sich erneut an"
#: src/components/login/auth-form.tsx:292
msgid "Please see <0>the documentation</0> for instructions."
msgstr "Bitte sehen Sie sich <0>die Dokumentation</0> für Anweisungen an."
#: src/components/login/login.tsx:40
msgid "Please sign in to your account"
msgstr "Bitte melden Sie sich bei Ihrem Konto an"
#: src/components/add-system.tsx:124
msgid "Port"
msgstr "Port"
#: src/components/routes/system.tsx:407
#: src/components/routes/system.tsx:523
msgid "Precise utilization at the recorded time"
msgstr "Genaue Nutzung zum aufgezeichneten Zeitpunkt"
#: src/components/routes/settings/general.tsx:58
msgid "Preferred Language"
msgstr "Bevorzugte Sprache"
#. Use 'Key' if your language requires many more characters
#: src/components/add-system.tsx:128
msgid "Public Key"
msgstr "Schlüssel"
#. Disk read
#: src/components/charts/area-chart.tsx:60
#: src/components/charts/area-chart.tsx:70
msgid "Read"
msgstr "Lesen"
#. Network bytes received (download)
#: src/components/charts/area-chart.tsx:65
msgid "Received"
msgstr "Empfangen"
#: src/components/login/forgot-pass-form.tsx:76
msgid "Reset Password"
msgstr "Passwort zurücksetzen"
#: src/components/systems-table/systems-table.tsx:545
msgid "Resume"
msgstr "Fortsetzen"
#: src/components/routes/settings/notifications.tsx:117
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
msgstr "Adresse mit der Eingabetaste oder Komma speichern. Leer lassen, um E-Mail-Benachrichtigungen zu deaktivieren."
#: src/components/routes/settings/general.tsx:106
#: src/components/routes/settings/notifications.tsx:167
msgid "Save Settings"
msgstr "Einstellungen speichern"
#: src/components/navbar.tsx:133
msgid "Search"
msgstr "Suche"
#: src/components/command-palette.tsx:46
msgid "Search for systems or settings..."
msgstr "Nach Systemen oder Einstellungen suchen..."
#: src/components/alerts/alert-button.tsx:71
msgid "See <0>notification settings</0> to configure how you receive alerts."
msgstr "Siehe <0>Benachrichtigungseinstellungen</0>, um zu konfigurieren, wie Sie Warnungen erhalten."
#. Network bytes sent (upload)
#: src/components/charts/area-chart.tsx:64
msgid "Sent"
msgstr "Gesendet"
#: src/components/routes/settings/general.tsx:100
msgid "Sets the default time range for charts when a system is viewed."
msgstr "Legt den Standardzeitraum für Diagramme fest, wenn ein System angezeigt wird."
#: src/components/command-palette.tsx:95
#: src/components/command-palette.tsx:98
#: src/components/command-palette.tsx:113
#: src/components/routes/settings/layout.tsx:71
#: src/components/routes/settings/layout.tsx:82
msgid "Settings"
msgstr "Einstellungen"
#: src/components/routes/settings/layout.tsx:33
msgid "Settings saved"
msgstr "Einstellungen gespeichert"
#: src/components/login/auth-form.tsx:191
msgid "Sign in"
msgstr "Anmelden"
#: src/components/command-palette.tsx:185
msgid "SMTP settings"
msgstr "SMTP-Einstellungen"
#: src/components/systems-table/systems-table.tsx:308
msgid "Sort By"
msgstr "Sortieren nach"
#: src/lib/utils.ts:282
msgid "Status"
msgstr "Status"
#: src/components/routes/system.tsx:473
msgid "Swap space used by the system"
msgstr "Vom System genutzter Swap-Speicher"
#: src/components/routes/system.tsx:472
msgid "Swap Usage"
msgstr "Swap-Nutzung"
#. System theme
#: src/components/mode-toggle.tsx:26
#: src/components/systems-table/systems-table.tsx:117
#: src/components/systems-table/systems-table.tsx:125
#: src/components/systems-table/systems-table.tsx:136
#: src/components/systems-table/systems-table.tsx:465
msgid "System"
msgstr "System"
#: src/components/navbar.tsx:77
msgid "Systems"
msgstr "Systeme"
#: src/components/routes/settings/config-yaml.tsx:55
msgid "Systems may be managed in a <0>config.yml</0> file inside your data directory."
msgstr "Systeme können in einer <0>config.yml</0>-Datei in Ihrem Datenverzeichnis verwaltet werden."
#: src/components/systems-table/systems-table.tsx:296
msgid "Table"
msgstr "Tabelle"
#: src/components/routes/system.tsx:484
#: src/lib/utils.ts:314
msgid "Temperature"
msgstr "Temperatur"
#: src/components/routes/system.tsx:485
msgid "Temperatures of system sensors"
msgstr "Temperaturen der Systemsensoren"
#: src/components/routes/settings/notifications.tsx:211
msgid "Test <0>URL</0>"
msgstr "Test <0>URL</0>"
#: src/components/routes/settings/notifications.tsx:182
msgid "Test notification sent"
msgstr "Testbenachrichtigung gesendet"
#: src/components/add-system.tsx:108
msgid "The agent must be running on the system to connect. Copy the installation command for the agent below."
msgstr "Der Agent muss auf dem System laufen, um eine Verbindung herzustellen. Kopieren Sie den Installationsbefehl für den Agenten unten."
#: src/components/add-system.tsx:99
msgid "The agent must be running on the system to connect. Copy the<0>docker-compose.yml</0> for the agent below."
msgstr "Der Agent muss auf dem System laufen, um eine Verbindung herzustellen. Kopieren Sie die <0>docker-compose.yml</0> für den Agenten unten."
#: src/components/login/forgot-pass-form.tsx:98
msgid "Then log into the backend and reset your user account password in the users table."
msgstr "Melden Sie sich dann im Backend an und setzen Sie Ihr Benutzerkontopasswort in der Benutzertabelle zurück."
#: src/components/systems-table/systems-table.tsx:573
msgid "This action cannot be undone. This will permanently delete all current records for {name} from the database."
msgstr "Diese Aktion kann nicht rückgängig gemacht werden. Dadurch werden alle aktuellen Datensätze für {name} dauerhaft aus der Datenbank gelöscht."
#: src/components/routes/system.tsx:564
msgid "Throughput of {extraFsName}"
msgstr "Durchsatz von {extraFsName}"
#: src/components/routes/system.tsx:432
msgid "Throughput of root filesystem"
msgstr "Durchsatz des Root-Dateisystems"
#: src/components/routes/settings/notifications.tsx:106
msgid "To email(s)"
msgstr "An E-Mail(s)"
#: src/components/routes/system.tsx:359
#: src/components/routes/system.tsx:372
msgid "Toggle grid"
msgstr "Raster umschalten"
#: src/components/mode-toggle.tsx:33
msgid "Toggle theme"
msgstr "Thema umschalten"
#: src/lib/utils.ts:317
msgid "Triggers when any sensor exceeds a threshold"
msgstr "Löst aus, wenn ein Sensor einen Schwellenwert überschreitet"
#: src/lib/utils.ts:310
msgid "Triggers when combined up/down exceeds a threshold"
msgstr "Löst aus, wenn die kombinierte Auf-/Abwärtsbewegung einen Schwellenwert überschreitet"
#: src/lib/utils.ts:292
msgid "Triggers when CPU usage exceeds a threshold"
msgstr "Löst aus, wenn die CPU-Auslastung einen Schwellenwert überschreitet"
#: src/lib/utils.ts:298
msgid "Triggers when memory usage exceeds a threshold"
msgstr "Löst aus, wenn die Speichernutzung einen Schwellenwert überschreitet"
#: src/lib/utils.ts:285
msgid "Triggers when status switches between up and down"
msgstr "Löst aus, wenn der Status zwischen oben und unten wechselt"
#: src/lib/utils.ts:304
msgid "Triggers when usage of any disk exceeds a threshold"
msgstr "Löst aus, wenn die Nutzung einer Festplatte einen Schwellenwert überschreitet"
#: src/components/systems-table/systems-table.tsx:269
msgid "Updated in real time. Click on a system to view information."
msgstr "In Echtzeit aktualisiert. Klicken Sie auf ein System, um Informationen anzuzeigen."
#: src/components/routes/system.tsx:261
msgid "Uptime"
msgstr "Betriebszeit"
#: src/components/charts/area-chart.tsx:73
#: src/components/routes/system.tsx:514
#: src/components/routes/system.tsx:551
msgid "Usage"
msgstr "Nutzung"
#: src/components/routes/system.tsx:424
msgid "Usage of root partition"
msgstr "Nutzung der Root-Partition"
#: src/components/charts/area-chart.tsx:73
#: src/components/charts/mem-chart.tsx:65
#: src/components/charts/swap-chart.tsx:56
msgid "Used"
msgstr "Verwendet"
#: src/components/command-palette.tsx:142
#: src/components/navbar.tsx:69
msgid "Users"
msgstr "Benutzer"
#: src/components/systems-table/systems-table.tsx:278
msgid "View"
msgstr "Ansicht"
#: src/components/systems-table/systems-table.tsx:342
msgid "Visible Fields"
msgstr "Sichtbare Spalten"
#: src/components/routes/system.tsx:662
msgid "Waiting for enough records to display"
msgstr "Warten auf genügend Datensätze zur Anzeige"
#: src/components/routes/settings/general.tsx:48
msgid "Want to help us make our translations even better? Check out <0>Crowdin</0> for more details."
msgstr "Möchten Sie uns helfen, unsere Übersetzungen noch besser zu machen? Schauen Sie sich <0>Crowdin</0> für weitere Details an."
#: src/components/routes/settings/notifications.tsx:124
msgid "Webhook / Push notifications"
msgstr "Webhook / Push-Benachrichtigungen"
#. Disk write
#: src/components/charts/area-chart.tsx:59
#: src/components/charts/area-chart.tsx:69
msgid "Write"
msgstr "Schreiben"
#: src/components/routes/settings/layout.tsx:61
msgid "YAML Config"
msgstr "YAML-Konfiguration"
#: src/components/routes/settings/config-yaml.tsx:45
msgid "YAML Configuration"
msgstr "YAML-Konfiguration"
#: src/components/routes/settings/layout.tsx:34
msgid "Your user settings have been updated."
msgstr "Ihre Benutzereinstellungen wurden aktualisiert."

View File

@@ -1,826 +0,0 @@
msgid ""
msgstr ""
"POT-Creation-Date: 2024-11-01 11:30-0400\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: @lingui/cli\n"
"Language: en\n"
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: \n"
"Last-Translator: \n"
"Language-Team: \n"
"Plural-Forms: \n"
#: src/components/routes/system.tsx:250
msgid "{0, plural, one {# day} other {# days}}"
msgstr "{0, plural, one {# day} other {# days}}"
#: src/components/routes/system.tsx:248
msgid "{hours, plural, one {# hour} other {# hours}}"
msgstr "{hours, plural, one {# hour} other {# hours}}"
#: src/lib/utils.ts:139
msgid "1 hour"
msgstr "1 hour"
#: src/lib/utils.ts:162
msgid "1 week"
msgstr "1 week"
#: src/lib/utils.ts:147
msgid "12 hours"
msgstr "12 hours"
#: src/lib/utils.ts:155
msgid "24 hours"
msgstr "24 hours"
#: src/lib/utils.ts:170
msgid "30 days"
msgstr "30 days"
#. Table column
#: src/components/systems-table/systems-table.tsx:227
#: src/components/systems-table/systems-table.tsx:313
#: src/components/systems-table/systems-table.tsx:455
#: src/components/systems-table/systems-table.tsx:465
msgid "Actions"
msgstr "Actions"
#: src/components/routes/home.tsx:61
msgid "Active Alerts"
msgstr "Active Alerts"
#: src/components/add-system.tsx:78
msgid "Add <0>System</0>"
msgstr "Add <0>System</0>"
#: src/components/add-system.tsx:87
msgid "Add New System"
msgstr "Add New System"
#: src/components/add-system.tsx:165
#: src/components/add-system.tsx:176
msgid "Add system"
msgstr "Add system"
#: src/components/routes/settings/notifications.tsx:156
msgid "Add URL"
msgstr "Add URL"
#: src/components/routes/settings/general.tsx:81
msgid "Adjust display options for charts."
msgstr "Adjust display options for charts."
#: src/components/command-palette.tsx:132
#: src/components/command-palette.tsx:145
#: src/components/command-palette.tsx:159
#: src/components/command-palette.tsx:173
#: src/components/command-palette.tsx:188
msgid "Admin"
msgstr "Admin"
#: src/components/systems-table/systems-table.tsx:201
msgid "Agent"
msgstr "Agent"
#: src/components/alerts/alert-button.tsx:32
#: src/components/alerts/alert-button.tsx:68
msgid "Alerts"
msgstr "Alerts"
#: src/components/alerts/alert-button.tsx:88
#: src/components/systems-table/systems-table.tsx:266
msgid "All Systems"
msgstr "All Systems"
#: src/components/systems-table/systems-table.tsx:570
msgid "Are you sure you want to delete {name}?"
msgstr "Are you sure you want to delete {name}?"
#: src/components/copy-to-clipboard.tsx:16
msgid "Automatic copy requires a secure context."
msgstr "Automatic copy requires a secure context."
#: src/components/routes/system.tsx:625
msgid "Average"
msgstr "Average"
#: src/components/routes/system.tsx:396
msgid "Average CPU utilization of containers"
msgstr "Average CPU utilization of containers"
#: src/components/alerts/alerts-system.tsx:206
msgid "Average exceeds <0>{value}{0}</0>"
msgstr "Average exceeds <0>{value}{0}</0>"
#: src/components/routes/system.tsx:497
msgid "Average power consumption of GPUs"
msgstr "Average power consumption of GPUs"
#: src/components/routes/system.tsx:385
msgid "Average system-wide CPU utilization"
msgstr "Average system-wide CPU utilization"
#: src/components/routes/system.tsx:515
msgid "Average utilization of {0}"
msgstr "Average utilization of {0}"
#: src/components/command-palette.tsx:170
#: src/components/navbar.tsx:93
msgid "Backups"
msgstr "Backups"
#: src/components/routes/system.tsx:441
#: src/lib/utils.ts:307
msgid "Bandwidth"
msgstr "Bandwidth"
#: src/components/login/auth-form.tsx:289
msgid "Beszel supports OpenID Connect and many OAuth2 authentication providers."
msgstr "Beszel supports OpenID Connect and many OAuth2 authentication providers."
#: src/components/routes/settings/notifications.tsx:127
msgid "Beszel uses <0>Shoutrrr</0> to integrate with popular notification services."
msgstr "Beszel uses <0>Shoutrrr</0> to integrate with popular notification services."
#: src/components/add-system.tsx:92
msgid "Binary"
msgstr "Binary"
#: src/components/charts/mem-chart.tsx:89
msgid "Cache / Buffers"
msgstr "Cache / Buffers"
#: src/components/systems-table/systems-table.tsx:581
msgid "Cancel"
msgstr "Cancel"
#: src/components/routes/settings/config-yaml.tsx:68
msgid "Caution - potential data loss"
msgstr "Caution - potential data loss"
#: src/components/routes/settings/general.tsx:36
msgid "Change general application options."
msgstr "Change general application options."
#: src/components/routes/settings/general.tsx:78
msgid "Chart options"
msgstr "Chart options"
#: src/components/login/forgot-pass-form.tsx:34
msgid "Check {email} for a reset link."
msgstr "Check {email} for a reset link."
#: src/components/routes/settings/layout.tsx:40
msgid "Check logs for more details."
msgstr "Check logs for more details."
#: src/components/routes/settings/notifications.tsx:183
msgid "Check your notification service"
msgstr "Check your notification service"
#: src/components/add-system.tsx:151
msgid "Click to copy"
msgstr "Click to copy"
#: src/components/login/forgot-pass-form.tsx:83
#: src/components/login/forgot-pass-form.tsx:89
msgid "Command line instructions"
msgstr "Command line instructions"
#: src/components/routes/settings/notifications.tsx:77
msgid "Configure how you receive alert notifications."
msgstr "Configure how you receive alert notifications."
#: src/components/login/auth-form.tsx:165
#: src/components/login/auth-form.tsx:170
msgid "Confirm password"
msgstr "Confirm password"
#: src/components/systems-table/systems-table.tsx:587
msgid "Continue"
msgstr "Continue"
#: src/lib/utils.ts:25
msgid "Copied to clipboard"
msgstr "Copied to clipboard"
#: src/components/add-system.tsx:162
msgid "Copy"
msgstr "Copy"
#: src/components/systems-table/systems-table.tsx:556
msgid "Copy host"
msgstr "Copy host"
#: src/components/add-system.tsx:173
msgid "Copy Linux command"
msgstr "Copy Linux command"
#: src/components/copy-to-clipboard.tsx:13
msgid "Copy text"
msgstr "Copy text"
#: src/components/systems-table/systems-table.tsx:157
msgid "CPU"
msgstr "CPU"
#: src/components/charts/area-chart.tsx:56
#: src/components/routes/system.tsx:384
#: src/lib/utils.ts:289
msgid "CPU Usage"
msgstr "CPU Usage"
#: src/components/login/auth-form.tsx:191
msgid "Create account"
msgstr "Create account"
#. Dark theme
#: src/components/mode-toggle.tsx:21
msgid "Dark"
msgstr "Dark"
#: src/components/command-palette.tsx:81
#: src/components/routes/home.tsx:34
msgid "Dashboard"
msgstr "Dashboard"
#: src/components/routes/settings/general.tsx:85
msgid "Default time period"
msgstr "Default time period"
#: src/components/systems-table/systems-table.tsx:562
msgid "Delete"
msgstr "Delete"
#: src/components/systems-table/systems-table.tsx:173
msgid "Disk"
msgstr "Disk"
#: src/components/routes/system.tsx:431
msgid "Disk I/O"
msgstr "Disk I/O"
#: src/components/charts/disk-chart.tsx:79
#: src/components/routes/system.tsx:424
#: src/lib/utils.ts:301
msgid "Disk Usage"
msgstr "Disk Usage"
#: src/components/routes/system.tsx:552
msgid "Disk usage of {extraFsName}"
msgstr "Disk usage of {extraFsName}"
#: src/components/routes/system.tsx:395
msgid "Docker CPU Usage"
msgstr "Docker CPU Usage"
#: src/components/routes/system.tsx:416
msgid "Docker Memory Usage"
msgstr "Docker Memory Usage"
#: src/components/routes/system.tsx:457
msgid "Docker Network I/O"
msgstr "Docker Network I/O"
#: src/components/command-palette.tsx:124
msgid "Documentation"
msgstr "Documentation"
#: src/components/login/auth-form.tsx:128
#: src/components/login/forgot-pass-form.tsx:53
msgid "Email"
msgstr "Email"
#: src/components/routes/settings/notifications.tsx:91
msgid "Email notifications"
msgstr "Email notifications"
#: src/components/login/login.tsx:38
msgid "Enter email address to reset password"
msgstr "Enter email address to reset password"
#: src/components/routes/settings/notifications.tsx:111
msgid "Enter email address..."
msgstr "Enter email address..."
#: src/components/login/auth-form.tsx:232
#: src/components/routes/settings/config-yaml.tsx:28
#: src/components/routes/settings/notifications.tsx:187
msgid "Error"
msgstr "Error"
#: src/components/routes/home.tsx:80
msgid "Exceeds {0}{1} in last {2, plural, one {# minute} other {# minutes}}"
msgstr "Exceeds {0}{1} in last {2, plural, one {# minute} other {# minutes}}"
#: src/components/routes/settings/config-yaml.tsx:72
msgid "Existing systems not defined in <0>config.yml</0> will be deleted. Please make regular backups."
msgstr "Existing systems not defined in <0>config.yml</0> will be deleted. Please make regular backups."
#: src/components/routes/settings/config-yaml.tsx:93
msgid "Export configuration"
msgstr "Export configuration"
#: src/components/routes/settings/config-yaml.tsx:48
msgid "Export your current systems configuration."
msgstr "Export your current systems configuration."
#: src/lib/utils.ts:38
msgid "Failed to authenticate"
msgstr "Failed to authenticate"
#: src/components/routes/settings/layout.tsx:39
#: src/components/routes/settings/notifications.tsx:62
msgid "Failed to save settings"
msgstr "Failed to save settings"
#: src/components/routes/settings/notifications.tsx:188
msgid "Failed to send test notification"
msgstr "Failed to send test notification"
#: src/components/alerts/alerts-system.tsx:24
msgid "Failed to update alert"
msgstr "Failed to update alert"
#: src/components/routes/system.tsx:596
#: src/components/systems-table/systems-table.tsx:273
msgid "Filter..."
msgstr "Filter..."
#: src/components/alerts/alerts-system.tsx:227
msgid "For <0>{min}</0> {min, plural, one {minute} other {minutes}}"
msgstr "For <0>{min}</0> {min, plural, one {minute} other {minutes}}"
#: src/components/login/auth-form.tsx:313
msgid "Forgot password?"
msgstr "Forgot password?"
#. Context: General settings
#: src/components/routes/settings/general.tsx:33
#: src/components/routes/settings/layout.tsx:51
msgid "General"
msgstr "General"
#: src/components/routes/system.tsx:496
msgid "GPU Power Draw"
msgstr "GPU Power Draw"
#: src/components/systems-table/systems-table.tsx:300
msgid "Grid"
msgstr "Grid"
#: src/components/add-system.tsx:120
msgid "Host / IP"
msgstr "Host / IP"
#: src/components/login/forgot-pass-form.tsx:93
msgid "If you've lost the password to your admin account, you may reset it using the following command."
msgstr "If you've lost the password to your admin account, you may reset it using the following command."
#: src/components/login/auth-form.tsx:16
msgid "Invalid email address."
msgstr "Invalid email address."
#. Linux kernel
#: src/components/routes/system.tsx:262
msgid "Kernel"
msgstr "Kernel"
#: src/components/routes/settings/general.tsx:45
msgid "Language"
msgstr "Language"
#: src/components/systems-table/systems-table.tsx:286
msgid "Layout"
msgstr "Layout"
#. Light theme
#: src/components/mode-toggle.tsx:16
msgid "Light"
msgstr "Light"
#: src/components/navbar.tsx:104
msgid "Log Out"
msgstr "Log Out"
#: src/components/login/login.tsx:19
msgid "Login"
msgstr "Login"
#: src/components/login/auth-form.tsx:34
#: src/components/login/forgot-pass-form.tsx:15
msgid "Login attempt failed"
msgstr "Login attempt failed"
#: src/components/command-palette.tsx:156
#: src/components/navbar.tsx:85
msgid "Logs"
msgstr "Logs"
#: src/components/routes/settings/notifications.tsx:80
msgid "Looking instead for where to create alerts? Click the bell <0/> icons in the systems table."
msgstr "Looking instead for where to create alerts? Click the bell <0/> icons in the systems table."
#: src/components/routes/settings/layout.tsx:85
msgid "Manage display and notification preferences."
msgstr "Manage display and notification preferences."
#. Chart select field. Please try to keep this short.
#: src/components/routes/system.tsx:628
msgid "Max 1 min"
msgstr "Max 1 min"
#: src/components/systems-table/systems-table.tsx:165
msgid "Memory"
msgstr "Memory"
#: src/components/routes/system.tsx:406
#: src/lib/utils.ts:295
msgid "Memory Usage"
msgstr "Memory Usage"
#: src/components/routes/system.tsx:417
msgid "Memory usage of docker containers"
msgstr "Memory usage of docker containers"
#: src/components/add-system.tsx:116
msgid "Name"
msgstr "Name"
#: src/components/systems-table/systems-table.tsx:181
msgid "Net"
msgstr "Net"
#: src/components/routes/system.tsx:458
msgid "Network traffic of docker containers"
msgstr "Network traffic of docker containers"
#: src/components/routes/system.tsx:443
msgid "Network traffic of public interfaces"
msgstr "Network traffic of public interfaces"
#: src/components/command-palette.tsx:49
msgid "No results found."
msgstr "No results found."
#: src/components/systems-table/systems-table.tsx:421
#: src/components/systems-table/systems-table.tsx:494
msgid "No systems found."
msgstr "No systems found."
#: src/components/command-palette.tsx:110
#: src/components/routes/settings/layout.tsx:56
#: src/components/routes/settings/notifications.tsx:74
msgid "Notifications"
msgstr "Notifications"
#: src/components/login/auth-form.tsx:284
msgid "OAuth 2 / OIDC support"
msgstr "OAuth 2 / OIDC support"
#: src/components/routes/settings/config-yaml.tsx:61
msgid "On each restart, systems in the database will be updated to match the systems defined in the file."
msgstr "On each restart, systems in the database will be updated to match the systems defined in the file."
#: src/components/systems-table/systems-table.tsx:528
msgid "Open menu"
msgstr "Open menu"
#: src/components/login/auth-form.tsx:203
msgid "Or continue with"
msgstr "Or continue with"
#: src/components/alerts/alert-button.tsx:109
msgid "Overwrite existing alerts"
msgstr "Overwrite existing alerts"
#: src/components/command-palette.tsx:84
msgid "Page"
msgstr "Page"
#: src/components/command-palette.tsx:71
msgid "Pages / Settings"
msgstr "Pages / Settings"
#: src/components/login/auth-form.tsx:147
#: src/components/login/auth-form.tsx:152
msgid "Password"
msgstr "Password"
#: src/components/login/auth-form.tsx:17
msgid "Password must be at least 8 characters."
msgstr "Password must be at least 8 characters."
#: src/components/login/forgot-pass-form.tsx:33
msgid "Password reset request received"
msgstr "Password reset request received"
#: src/components/systems-table/systems-table.tsx:550
msgid "Pause"
msgstr "Pause"
#: src/components/routes/settings/notifications.tsx:95
msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered."
msgstr "Please <0>configure an SMTP server</0> to ensure alerts are delivered."
#: src/components/alerts/alerts-system.tsx:25
msgid "Please check logs for more details."
msgstr "Please check logs for more details."
#: src/components/login/auth-form.tsx:35
#: src/components/login/forgot-pass-form.tsx:16
msgid "Please check your credentials and try again"
msgstr "Please check your credentials and try again"
#: src/components/login/login.tsx:36
msgid "Please create an admin account"
msgstr "Please create an admin account"
#: src/components/login/auth-form.tsx:233
msgid "Please enable pop-ups for this site"
msgstr "Please enable pop-ups for this site"
#: src/lib/utils.ts:39
msgid "Please log in again"
msgstr "Please log in again"
#: src/components/login/auth-form.tsx:292
msgid "Please see <0>the documentation</0> for instructions."
msgstr "Please see <0>the documentation</0> for instructions."
#: src/components/login/login.tsx:40
msgid "Please sign in to your account"
msgstr "Please sign in to your account"
#: src/components/add-system.tsx:124
msgid "Port"
msgstr "Port"
#: src/components/routes/system.tsx:407
#: src/components/routes/system.tsx:523
msgid "Precise utilization at the recorded time"
msgstr "Precise utilization at the recorded time"
#: src/components/routes/settings/general.tsx:58
msgid "Preferred Language"
msgstr "Preferred Language"
#. Use 'Key' if your language requires many more characters
#: src/components/add-system.tsx:128
msgid "Public Key"
msgstr "Public Key"
#. Disk read
#: src/components/charts/area-chart.tsx:60
#: src/components/charts/area-chart.tsx:70
msgid "Read"
msgstr "Read"
#. Network bytes received (download)
#: src/components/charts/area-chart.tsx:65
msgid "Received"
msgstr "Received"
#: src/components/login/forgot-pass-form.tsx:76
msgid "Reset Password"
msgstr "Reset Password"
#: src/components/systems-table/systems-table.tsx:545
msgid "Resume"
msgstr "Resume"
#: src/components/routes/settings/notifications.tsx:117
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
msgstr "Save address using enter key or comma. Leave blank to disable email notifications."
#: src/components/routes/settings/general.tsx:106
#: src/components/routes/settings/notifications.tsx:167
msgid "Save Settings"
msgstr "Save Settings"
#: src/components/navbar.tsx:133
msgid "Search"
msgstr "Search"
#: src/components/command-palette.tsx:46
msgid "Search for systems or settings..."
msgstr "Search for systems or settings..."
#: src/components/alerts/alert-button.tsx:71
msgid "See <0>notification settings</0> to configure how you receive alerts."
msgstr "See <0>notification settings</0> to configure how you receive alerts."
#. Network bytes sent (upload)
#: src/components/charts/area-chart.tsx:64
msgid "Sent"
msgstr "Sent"
#: src/components/routes/settings/general.tsx:100
msgid "Sets the default time range for charts when a system is viewed."
msgstr "Sets the default time range for charts when a system is viewed."
#: src/components/command-palette.tsx:95
#: src/components/command-palette.tsx:98
#: src/components/command-palette.tsx:113
#: src/components/routes/settings/layout.tsx:71
#: src/components/routes/settings/layout.tsx:82
msgid "Settings"
msgstr "Settings"
#: src/components/routes/settings/layout.tsx:33
msgid "Settings saved"
msgstr "Settings saved"
#: src/components/login/auth-form.tsx:191
msgid "Sign in"
msgstr "Sign in"
#: src/components/command-palette.tsx:185
msgid "SMTP settings"
msgstr "SMTP settings"
#: src/components/systems-table/systems-table.tsx:308
msgid "Sort By"
msgstr "Sort By"
#: src/lib/utils.ts:282
msgid "Status"
msgstr "Status"
#: src/components/routes/system.tsx:473
msgid "Swap space used by the system"
msgstr "Swap space used by the system"
#: src/components/routes/system.tsx:472
msgid "Swap Usage"
msgstr "Swap Usage"
#. System theme
#: src/components/mode-toggle.tsx:26
#: src/components/systems-table/systems-table.tsx:117
#: src/components/systems-table/systems-table.tsx:125
#: src/components/systems-table/systems-table.tsx:136
#: src/components/systems-table/systems-table.tsx:465
msgid "System"
msgstr "System"
#: src/components/navbar.tsx:77
msgid "Systems"
msgstr "Systems"
#: src/components/routes/settings/config-yaml.tsx:55
msgid "Systems may be managed in a <0>config.yml</0> file inside your data directory."
msgstr "Systems may be managed in a <0>config.yml</0> file inside your data directory."
#: src/components/systems-table/systems-table.tsx:296
msgid "Table"
msgstr "Table"
#: src/components/routes/system.tsx:484
#: src/lib/utils.ts:314
msgid "Temperature"
msgstr "Temperature"
#: src/components/routes/system.tsx:485
msgid "Temperatures of system sensors"
msgstr "Temperatures of system sensors"
#: src/components/routes/settings/notifications.tsx:211
msgid "Test <0>URL</0>"
msgstr "Test <0>URL</0>"
#: src/components/routes/settings/notifications.tsx:182
msgid "Test notification sent"
msgstr "Test notification sent"
#: src/components/add-system.tsx:108
msgid "The agent must be running on the system to connect. Copy the installation command for the agent below."
msgstr "The agent must be running on the system to connect. Copy the installation command for the agent below."
#: src/components/add-system.tsx:99
msgid "The agent must be running on the system to connect. Copy the<0>docker-compose.yml</0> for the agent below."
msgstr "The agent must be running on the system to connect. Copy the<0>docker-compose.yml</0> for the agent below."
#: src/components/login/forgot-pass-form.tsx:98
msgid "Then log into the backend and reset your user account password in the users table."
msgstr "Then log into the backend and reset your user account password in the users table."
#: src/components/systems-table/systems-table.tsx:573
msgid "This action cannot be undone. This will permanently delete all current records for {name} from the database."
msgstr "This action cannot be undone. This will permanently delete all current records for {name} from the database."
#: src/components/routes/system.tsx:564
msgid "Throughput of {extraFsName}"
msgstr "Throughput of {extraFsName}"
#: src/components/routes/system.tsx:432
msgid "Throughput of root filesystem"
msgstr "Throughput of root filesystem"
#: src/components/routes/settings/notifications.tsx:106
msgid "To email(s)"
msgstr "To email(s)"
#: src/components/routes/system.tsx:359
#: src/components/routes/system.tsx:372
msgid "Toggle grid"
msgstr "Toggle grid"
#: src/components/mode-toggle.tsx:33
msgid "Toggle theme"
msgstr "Toggle theme"
#: src/lib/utils.ts:317
msgid "Triggers when any sensor exceeds a threshold"
msgstr "Triggers when any sensor exceeds a threshold"
#: src/lib/utils.ts:310
msgid "Triggers when combined up/down exceeds a threshold"
msgstr "Triggers when combined up/down exceeds a threshold"
#: src/lib/utils.ts:292
msgid "Triggers when CPU usage exceeds a threshold"
msgstr "Triggers when CPU usage exceeds a threshold"
#: src/lib/utils.ts:298
msgid "Triggers when memory usage exceeds a threshold"
msgstr "Triggers when memory usage exceeds a threshold"
#: src/lib/utils.ts:285
msgid "Triggers when status switches between up and down"
msgstr "Triggers when status switches between up and down"
#: src/lib/utils.ts:304
msgid "Triggers when usage of any disk exceeds a threshold"
msgstr "Triggers when usage of any disk exceeds a threshold"
#: src/components/systems-table/systems-table.tsx:269
msgid "Updated in real time. Click on a system to view information."
msgstr "Updated in real time. Click on a system to view information."
#: src/components/routes/system.tsx:261
msgid "Uptime"
msgstr "Uptime"
#: src/components/charts/area-chart.tsx:73
#: src/components/routes/system.tsx:514
#: src/components/routes/system.tsx:551
msgid "Usage"
msgstr "Usage"
#: src/components/routes/system.tsx:424
msgid "Usage of root partition"
msgstr "Usage of root partition"
#: src/components/charts/area-chart.tsx:73
#: src/components/charts/mem-chart.tsx:65
#: src/components/charts/swap-chart.tsx:56
msgid "Used"
msgstr "Used"
#: src/components/command-palette.tsx:142
#: src/components/navbar.tsx:69
msgid "Users"
msgstr "Users"
#: src/components/systems-table/systems-table.tsx:278
msgid "View"
msgstr "View"
#: src/components/systems-table/systems-table.tsx:342
msgid "Visible Fields"
msgstr "Visible Fields"
#: src/components/routes/system.tsx:662
msgid "Waiting for enough records to display"
msgstr "Waiting for enough records to display"
#: src/components/routes/settings/general.tsx:48
msgid "Want to help us make our translations even better? Check out <0>Crowdin</0> for more details."
msgstr "Want to help us make our translations even better? Check out <0>Crowdin</0> for more details."
#: src/components/routes/settings/notifications.tsx:124
msgid "Webhook / Push notifications"
msgstr "Webhook / Push notifications"
#. Disk write
#: src/components/charts/area-chart.tsx:59
#: src/components/charts/area-chart.tsx:69
msgid "Write"
msgstr "Write"
#: src/components/routes/settings/layout.tsx:61
msgid "YAML Config"
msgstr "YAML Config"
#: src/components/routes/settings/config-yaml.tsx:45
msgid "YAML Configuration"
msgstr "YAML Configuration"
#: src/components/routes/settings/layout.tsx:34
msgid "Your user settings have been updated."
msgstr "Your user settings have been updated."

View File

@@ -1,831 +0,0 @@
msgid ""
msgstr ""
"POT-Creation-Date: 2024-11-01 11:30-0400\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: @lingui/cli\n"
"Language: es\n"
"Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2024-12-17 21:36\n"
"Last-Translator: \n"
"Language-Team: Spanish\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Crowdin-Project: beszel\n"
"X-Crowdin-Project-ID: 733311\n"
"X-Crowdin-Language: es-ES\n"
"X-Crowdin-File: /main/beszel/site/src/locales/en/en.po\n"
"X-Crowdin-File-ID: 16\n"
#: src/components/routes/system.tsx:250
msgid "{0, plural, one {# day} other {# days}}"
msgstr "{0, plural, one {# día} other {# días}}"
#: src/components/routes/system.tsx:248
msgid "{hours, plural, one {# hour} other {# hours}}"
msgstr "{hours, plural, one {# hora} other {# horas}}"
#: src/lib/utils.ts:139
msgid "1 hour"
msgstr "1 hora"
#: src/lib/utils.ts:162
msgid "1 week"
msgstr "1 semana"
#: src/lib/utils.ts:147
msgid "12 hours"
msgstr "12 horas"
#: src/lib/utils.ts:155
msgid "24 hours"
msgstr "24 horas"
#: src/lib/utils.ts:170
msgid "30 days"
msgstr "30 días"
#. Table column
#: src/components/systems-table/systems-table.tsx:227
#: src/components/systems-table/systems-table.tsx:313
#: src/components/systems-table/systems-table.tsx:455
#: src/components/systems-table/systems-table.tsx:465
msgid "Actions"
msgstr "Acciones"
#: src/components/routes/home.tsx:61
msgid "Active Alerts"
msgstr "Alertas Activas"
#: src/components/add-system.tsx:78
msgid "Add <0>System</0>"
msgstr "Agregar <0>Sistema</0>"
#: src/components/add-system.tsx:87
msgid "Add New System"
msgstr "Agregar Nuevo Sistema"
#: src/components/add-system.tsx:165
#: src/components/add-system.tsx:176
msgid "Add system"
msgstr "Agregar sistema"
#: src/components/routes/settings/notifications.tsx:156
msgid "Add URL"
msgstr "Agregar URL"
#: src/components/routes/settings/general.tsx:81
msgid "Adjust display options for charts."
msgstr "Ajustar las opciones de visualización para los gráficos."
#: src/components/command-palette.tsx:132
#: src/components/command-palette.tsx:145
#: src/components/command-palette.tsx:159
#: src/components/command-palette.tsx:173
#: src/components/command-palette.tsx:188
msgid "Admin"
msgstr "Administrador"
#: src/components/systems-table/systems-table.tsx:201
msgid "Agent"
msgstr "Agente"
#: src/components/alerts/alert-button.tsx:32
#: src/components/alerts/alert-button.tsx:68
msgid "Alerts"
msgstr "Alertas"
#: src/components/alerts/alert-button.tsx:88
#: src/components/systems-table/systems-table.tsx:266
msgid "All Systems"
msgstr "Todos los Sistemas"
#: src/components/systems-table/systems-table.tsx:570
msgid "Are you sure you want to delete {name}?"
msgstr "¿Está seguro de que desea eliminar {name}?"
#: src/components/copy-to-clipboard.tsx:16
msgid "Automatic copy requires a secure context."
msgstr "La copia automática requiere un contexto seguro."
#: src/components/routes/system.tsx:625
msgid "Average"
msgstr "Promedio"
#: src/components/routes/system.tsx:396
msgid "Average CPU utilization of containers"
msgstr "Utilización promedio de CPU de los contenedores"
#: src/components/alerts/alerts-system.tsx:206
msgid "Average exceeds <0>{value}{0}</0>"
msgstr "El promedio excede <0>{value}{0}</0>"
#: src/components/routes/system.tsx:497
msgid "Average power consumption of GPUs"
msgstr ""
#: src/components/routes/system.tsx:385
msgid "Average system-wide CPU utilization"
msgstr "Utilización promedio de CPU del sistema"
#: src/components/routes/system.tsx:515
msgid "Average utilization of {0}"
msgstr ""
#: src/components/command-palette.tsx:170
#: src/components/navbar.tsx:93
msgid "Backups"
msgstr "Copias de Seguridad"
#: src/components/routes/system.tsx:441
#: src/lib/utils.ts:307
msgid "Bandwidth"
msgstr "Ancho de banda"
#: src/components/login/auth-form.tsx:289
msgid "Beszel supports OpenID Connect and many OAuth2 authentication providers."
msgstr "Beszel admite OpenID Connect y muchos proveedores de autenticación OAuth2."
#: src/components/routes/settings/notifications.tsx:127
msgid "Beszel uses <0>Shoutrrr</0> to integrate with popular notification services."
msgstr "Beszel utiliza <0>Shoutrrr</0> para integrarse con servicios populares de notificación."
#: src/components/add-system.tsx:92
msgid "Binary"
msgstr "Binario"
#: src/components/charts/mem-chart.tsx:89
msgid "Cache / Buffers"
msgstr "Caché / Buffers"
#: src/components/systems-table/systems-table.tsx:581
msgid "Cancel"
msgstr "Cancelar"
#: src/components/routes/settings/config-yaml.tsx:68
msgid "Caution - potential data loss"
msgstr "Precaución - posible pérdida de datos"
#: src/components/routes/settings/general.tsx:36
msgid "Change general application options."
msgstr "Cambiar las opciones generales de la aplicación."
#: src/components/routes/settings/general.tsx:78
msgid "Chart options"
msgstr "Opciones de Gráficos"
#: src/components/login/forgot-pass-form.tsx:34
msgid "Check {email} for a reset link."
msgstr "Revise {email} para un enlace de restablecimiento."
#: src/components/routes/settings/layout.tsx:40
msgid "Check logs for more details."
msgstr "Revise los registros para más detalles."
#: src/components/routes/settings/notifications.tsx:183
msgid "Check your notification service"
msgstr "Verifique su servicio de notificaciones"
#: src/components/add-system.tsx:151
msgid "Click to copy"
msgstr "Haga clic para copiar"
#: src/components/login/forgot-pass-form.tsx:83
#: src/components/login/forgot-pass-form.tsx:89
msgid "Command line instructions"
msgstr "Instrucciones de línea de comandos"
#: src/components/routes/settings/notifications.tsx:77
msgid "Configure how you receive alert notifications."
msgstr "Configure cómo recibe las notificaciones de alertas."
#: src/components/login/auth-form.tsx:165
#: src/components/login/auth-form.tsx:170
msgid "Confirm password"
msgstr "Confirmar contraseña"
#: src/components/systems-table/systems-table.tsx:587
msgid "Continue"
msgstr "Continuar"
#: src/lib/utils.ts:25
msgid "Copied to clipboard"
msgstr "Copiado al portapapeles"
#: src/components/add-system.tsx:162
msgid "Copy"
msgstr "Copiar"
#: src/components/systems-table/systems-table.tsx:556
msgid "Copy host"
msgstr "Copiar host"
#: src/components/add-system.tsx:173
msgid "Copy Linux command"
msgstr "Copiar comando de Linux"
#: src/components/copy-to-clipboard.tsx:13
msgid "Copy text"
msgstr "Copiar texto"
#: src/components/systems-table/systems-table.tsx:157
msgid "CPU"
msgstr "CPU"
#: src/components/charts/area-chart.tsx:56
#: src/components/routes/system.tsx:384
#: src/lib/utils.ts:289
msgid "CPU Usage"
msgstr "Uso de CPU"
#: src/components/login/auth-form.tsx:191
msgid "Create account"
msgstr "Crear cuenta"
#. Dark theme
#: src/components/mode-toggle.tsx:21
msgid "Dark"
msgstr "Oscuro"
#: src/components/command-palette.tsx:81
#: src/components/routes/home.tsx:34
msgid "Dashboard"
msgstr "Tablero"
#: src/components/routes/settings/general.tsx:85
msgid "Default time period"
msgstr "Período de tiempo predeterminado"
#: src/components/systems-table/systems-table.tsx:562
msgid "Delete"
msgstr "Eliminar"
#: src/components/systems-table/systems-table.tsx:173
msgid "Disk"
msgstr "Disco"
#: src/components/routes/system.tsx:431
msgid "Disk I/O"
msgstr "E/S de Disco"
#: src/components/charts/disk-chart.tsx:79
#: src/components/routes/system.tsx:424
#: src/lib/utils.ts:301
msgid "Disk Usage"
msgstr "Uso de Disco"
#: src/components/routes/system.tsx:552
msgid "Disk usage of {extraFsName}"
msgstr "Uso de disco de {extraFsName}"
#: src/components/routes/system.tsx:395
msgid "Docker CPU Usage"
msgstr "Uso de CPU de Docker"
#: src/components/routes/system.tsx:416
msgid "Docker Memory Usage"
msgstr "Uso de Memoria de Docker"
#: src/components/routes/system.tsx:457
msgid "Docker Network I/O"
msgstr "E/S de Red de Docker"
#: src/components/command-palette.tsx:124
msgid "Documentation"
msgstr "Documentación"
#: src/components/login/auth-form.tsx:128
#: src/components/login/forgot-pass-form.tsx:53
msgid "Email"
msgstr "Correo electrónico"
#: src/components/routes/settings/notifications.tsx:91
msgid "Email notifications"
msgstr "Notificaciones por correo"
#: src/components/login/login.tsx:38
msgid "Enter email address to reset password"
msgstr "Ingrese la dirección de correo electrónico para restablecer la contraseña"
#: src/components/routes/settings/notifications.tsx:111
msgid "Enter email address..."
msgstr "Ingrese dirección de correo..."
#: src/components/login/auth-form.tsx:232
#: src/components/routes/settings/config-yaml.tsx:28
#: src/components/routes/settings/notifications.tsx:187
msgid "Error"
msgstr "Error"
#: src/components/routes/home.tsx:80
msgid "Exceeds {0}{1} in last {2, plural, one {# minute} other {# minutes}}"
msgstr "Excede {0}{1} en el último {2, plural, one {# minuto} other {# minutos}}"
#: src/components/routes/settings/config-yaml.tsx:72
msgid "Existing systems not defined in <0>config.yml</0> will be deleted. Please make regular backups."
msgstr "Los sistemas existentes no definidos en <0>config.yml</0> serán eliminados. Por favor, haga copias de seguridad regularmente."
#: src/components/routes/settings/config-yaml.tsx:93
msgid "Export configuration"
msgstr "Exportar configuración"
#: src/components/routes/settings/config-yaml.tsx:48
msgid "Export your current systems configuration."
msgstr "Exporte la configuración actual de sus sistemas."
#: src/lib/utils.ts:38
msgid "Failed to authenticate"
msgstr "Error al autenticar"
#: src/components/routes/settings/layout.tsx:39
#: src/components/routes/settings/notifications.tsx:62
msgid "Failed to save settings"
msgstr "Error al guardar la configuración"
#: src/components/routes/settings/notifications.tsx:188
msgid "Failed to send test notification"
msgstr "Error al enviar la notificación de prueba"
#: src/components/alerts/alerts-system.tsx:24
msgid "Failed to update alert"
msgstr "Error al actualizar la alerta"
#: src/components/routes/system.tsx:596
#: src/components/systems-table/systems-table.tsx:273
msgid "Filter..."
msgstr "Filtrar..."
#: src/components/alerts/alerts-system.tsx:227
msgid "For <0>{min}</0> {min, plural, one {minute} other {minutes}}"
msgstr "Por <0>{min}</0> {min, plural, one {minuto} other {minutos}}"
#: src/components/login/auth-form.tsx:313
msgid "Forgot password?"
msgstr "¿Olvidó su contraseña?"
#. Context: General settings
#: src/components/routes/settings/general.tsx:33
#: src/components/routes/settings/layout.tsx:51
msgid "General"
msgstr "General"
#: src/components/routes/system.tsx:496
msgid "GPU Power Draw"
msgstr ""
#: src/components/systems-table/systems-table.tsx:300
msgid "Grid"
msgstr "Cuadrícula"
#: src/components/add-system.tsx:120
msgid "Host / IP"
msgstr "Host / IP"
#: src/components/login/forgot-pass-form.tsx:93
msgid "If you've lost the password to your admin account, you may reset it using the following command."
msgstr "Si ha perdido la contraseña de su cuenta de administrador, puede restablecerla usando el siguiente comando."
#: src/components/login/auth-form.tsx:16
msgid "Invalid email address."
msgstr "Dirección de correo electrónico no válida."
#. Linux kernel
#: src/components/routes/system.tsx:262
msgid "Kernel"
msgstr "Kernel"
#: src/components/routes/settings/general.tsx:45
msgid "Language"
msgstr "Idioma"
#: src/components/systems-table/systems-table.tsx:286
msgid "Layout"
msgstr "Diseño"
#. Light theme
#: src/components/mode-toggle.tsx:16
msgid "Light"
msgstr "Claro"
#: src/components/navbar.tsx:104
msgid "Log Out"
msgstr "Cerrar Sesión"
#: src/components/login/login.tsx:19
msgid "Login"
msgstr "Iniciar sesión"
#: src/components/login/auth-form.tsx:34
#: src/components/login/forgot-pass-form.tsx:15
msgid "Login attempt failed"
msgstr "Intento de inicio de sesión fallido"
#: src/components/command-palette.tsx:156
#: src/components/navbar.tsx:85
msgid "Logs"
msgstr "Registros"
#: src/components/routes/settings/notifications.tsx:80
msgid "Looking instead for where to create alerts? Click the bell <0/> icons in the systems table."
msgstr "¿Busca dónde crear alertas? Haga clic en los iconos de campana <0/> en la tabla de sistemas."
#: src/components/routes/settings/layout.tsx:85
msgid "Manage display and notification preferences."
msgstr "Administrar preferencias de visualización y notificaciones."
#. Chart select field. Please try to keep this short.
#: src/components/routes/system.tsx:628
msgid "Max 1 min"
msgstr "Máx 1 min"
#: src/components/systems-table/systems-table.tsx:165
msgid "Memory"
msgstr "Memoria"
#: src/components/routes/system.tsx:406
#: src/lib/utils.ts:295
msgid "Memory Usage"
msgstr "Uso de Memoria"
#: src/components/routes/system.tsx:417
msgid "Memory usage of docker containers"
msgstr "Uso de memoria de los contenedores de Docker"
#: src/components/add-system.tsx:116
msgid "Name"
msgstr "Nombre"
#: src/components/systems-table/systems-table.tsx:181
msgid "Net"
msgstr "Red"
#: src/components/routes/system.tsx:458
msgid "Network traffic of docker containers"
msgstr "Tráfico de red de los contenedores de Docker"
#: src/components/routes/system.tsx:443
msgid "Network traffic of public interfaces"
msgstr "Tráfico de red de interfaces públicas"
#: src/components/command-palette.tsx:49
msgid "No results found."
msgstr "No se encontraron resultados."
#: src/components/systems-table/systems-table.tsx:421
#: src/components/systems-table/systems-table.tsx:494
msgid "No systems found."
msgstr "No se encontraron sistemas."
#: src/components/command-palette.tsx:110
#: src/components/routes/settings/layout.tsx:56
#: src/components/routes/settings/notifications.tsx:74
msgid "Notifications"
msgstr "Notificaciones"
#: src/components/login/auth-form.tsx:284
msgid "OAuth 2 / OIDC support"
msgstr "Soporte para OAuth 2 / OIDC"
#: src/components/routes/settings/config-yaml.tsx:61
msgid "On each restart, systems in the database will be updated to match the systems defined in the file."
msgstr "En cada reinicio, los sistemas en la base de datos se actualizarán para coincidir con los sistemas definidos en el archivo."
#: src/components/systems-table/systems-table.tsx:528
msgid "Open menu"
msgstr "Abrir menú"
#: src/components/login/auth-form.tsx:203
msgid "Or continue with"
msgstr "O continuar con"
#: src/components/alerts/alert-button.tsx:109
msgid "Overwrite existing alerts"
msgstr "Sobrescribir alertas existentes"
#: src/components/command-palette.tsx:84
msgid "Page"
msgstr "Página"
#: src/components/command-palette.tsx:71
msgid "Pages / Settings"
msgstr "Páginas / Configuraciones"
#: src/components/login/auth-form.tsx:147
#: src/components/login/auth-form.tsx:152
msgid "Password"
msgstr "Contraseña"
#: src/components/login/auth-form.tsx:17
msgid "Password must be at least 8 characters."
msgstr "La contraseña debe tener al menos 8 caracteres."
#: src/components/login/forgot-pass-form.tsx:33
msgid "Password reset request received"
msgstr "Solicitud de restablecimiento de contraseña recibida"
#: src/components/systems-table/systems-table.tsx:550
msgid "Pause"
msgstr "Pausar"
#: src/components/routes/settings/notifications.tsx:95
msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered."
msgstr "Por favor, <0>configure un servidor SMTP</0> para asegurar que las alertas sean entregadas."
#: src/components/alerts/alerts-system.tsx:25
msgid "Please check logs for more details."
msgstr "Por favor, revise los registros para más detalles."
#: src/components/login/auth-form.tsx:35
#: src/components/login/forgot-pass-form.tsx:16
msgid "Please check your credentials and try again"
msgstr "Por favor, verifique sus credenciales e intente de nuevo"
#: src/components/login/login.tsx:36
msgid "Please create an admin account"
msgstr "Por favor, cree una cuenta de administrador"
#: src/components/login/auth-form.tsx:233
msgid "Please enable pop-ups for this site"
msgstr "Por favor, habilite las ventanas emergentes para este sitio"
#: src/lib/utils.ts:39
msgid "Please log in again"
msgstr "Por favor, inicie sesión de nuevo"
#: src/components/login/auth-form.tsx:292
msgid "Please see <0>the documentation</0> for instructions."
msgstr "Por favor, consulte <0>la documentación</0> para obtener instrucciones."
#: src/components/login/login.tsx:40
msgid "Please sign in to your account"
msgstr "Por favor, inicie sesión en su cuenta"
#: src/components/add-system.tsx:124
msgid "Port"
msgstr "Puerto"
#: src/components/routes/system.tsx:407
#: src/components/routes/system.tsx:523
msgid "Precise utilization at the recorded time"
msgstr "Utilización precisa en el momento registrado"
#: src/components/routes/settings/general.tsx:58
msgid "Preferred Language"
msgstr "Idioma Preferido"
#. Use 'Key' if your language requires many more characters
#: src/components/add-system.tsx:128
msgid "Public Key"
msgstr "Clave Pública"
#. Disk read
#: src/components/charts/area-chart.tsx:60
#: src/components/charts/area-chart.tsx:70
msgid "Read"
msgstr "Lectura"
#. Network bytes received (download)
#: src/components/charts/area-chart.tsx:65
msgid "Received"
msgstr "Recibido"
#: src/components/login/forgot-pass-form.tsx:76
msgid "Reset Password"
msgstr "Restablecer Contraseña"
#: src/components/systems-table/systems-table.tsx:545
msgid "Resume"
msgstr "Reanudar"
#: src/components/routes/settings/notifications.tsx:117
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
msgstr "Guarde la dirección usando la tecla enter o coma. Deje en blanco para desactivar las notificaciones por correo."
#: src/components/routes/settings/general.tsx:106
#: src/components/routes/settings/notifications.tsx:167
msgid "Save Settings"
msgstr "Guardar Configuración"
#: src/components/navbar.tsx:133
msgid "Search"
msgstr "Buscar"
#: src/components/command-palette.tsx:46
msgid "Search for systems or settings..."
msgstr "Buscar sistemas o configuraciones..."
#: src/components/alerts/alert-button.tsx:71
msgid "See <0>notification settings</0> to configure how you receive alerts."
msgstr "Consulte <0>configuración de notificaciones</0> para configurar cómo recibe alertas."
#. Network bytes sent (upload)
#: src/components/charts/area-chart.tsx:64
msgid "Sent"
msgstr "Enviado"
#: src/components/routes/settings/general.tsx:100
msgid "Sets the default time range for charts when a system is viewed."
msgstr "Establece el rango de tiempo predeterminado para los gráficos cuando se visualiza un sistema."
#: src/components/command-palette.tsx:95
#: src/components/command-palette.tsx:98
#: src/components/command-palette.tsx:113
#: src/components/routes/settings/layout.tsx:71
#: src/components/routes/settings/layout.tsx:82
msgid "Settings"
msgstr "Configuración"
#: src/components/routes/settings/layout.tsx:33
msgid "Settings saved"
msgstr "Configuración guardada"
#: src/components/login/auth-form.tsx:191
msgid "Sign in"
msgstr "Iniciar sesión"
#: src/components/command-palette.tsx:185
msgid "SMTP settings"
msgstr "Configuración SMTP"
#: src/components/systems-table/systems-table.tsx:308
msgid "Sort By"
msgstr "Ordenar por"
#: src/lib/utils.ts:282
msgid "Status"
msgstr "Estado"
#: src/components/routes/system.tsx:473
msgid "Swap space used by the system"
msgstr "Espacio de swap utilizado por el sistema"
#: src/components/routes/system.tsx:472
msgid "Swap Usage"
msgstr "Uso de Swap"
#. System theme
#: src/components/mode-toggle.tsx:26
#: src/components/systems-table/systems-table.tsx:117
#: src/components/systems-table/systems-table.tsx:125
#: src/components/systems-table/systems-table.tsx:136
#: src/components/systems-table/systems-table.tsx:465
msgid "System"
msgstr "Sistema"
#: src/components/navbar.tsx:77
msgid "Systems"
msgstr "Sistemas"
#: src/components/routes/settings/config-yaml.tsx:55
msgid "Systems may be managed in a <0>config.yml</0> file inside your data directory."
msgstr "Los sistemas pueden ser gestionados en un archivo <0>config.yml</0> dentro de su directorio de datos."
#: src/components/systems-table/systems-table.tsx:296
msgid "Table"
msgstr "Tabla"
#: src/components/routes/system.tsx:484
#: src/lib/utils.ts:314
msgid "Temperature"
msgstr "Temperatura"
#: src/components/routes/system.tsx:485
msgid "Temperatures of system sensors"
msgstr "Temperaturas de los sensores del sistema"
#: src/components/routes/settings/notifications.tsx:211
msgid "Test <0>URL</0>"
msgstr "Probar <0>URL</0>"
#: src/components/routes/settings/notifications.tsx:182
msgid "Test notification sent"
msgstr "Notificación de prueba enviada"
#: src/components/add-system.tsx:108
msgid "The agent must be running on the system to connect. Copy the installation command for the agent below."
msgstr "El agente debe estar ejecutándose en el sistema para conectarse. Copie el comando de instalación para el agente a continuación."
#: src/components/add-system.tsx:99
msgid "The agent must be running on the system to connect. Copy the<0>docker-compose.yml</0> for the agent below."
msgstr "El agente debe estar ejecutándose en el sistema para conectarse. Copie el <0>docker-compose.yml</0> para el agente a continuación."
#: src/components/login/forgot-pass-form.tsx:98
msgid "Then log into the backend and reset your user account password in the users table."
msgstr "Luego inicie sesión en el backend y restablezca la contraseña de su cuenta de usuario en la tabla de usuarios."
#: src/components/systems-table/systems-table.tsx:573
msgid "This action cannot be undone. This will permanently delete all current records for {name} from the database."
msgstr "Esta acción no se puede deshacer. Esto eliminará permanentemente todos los registros actuales de {name} de la base de datos."
#: src/components/routes/system.tsx:564
msgid "Throughput of {extraFsName}"
msgstr "Rendimiento de {extraFsName}"
#: src/components/routes/system.tsx:432
msgid "Throughput of root filesystem"
msgstr "Rendimiento del sistema de archivos raíz"
#: src/components/routes/settings/notifications.tsx:106
msgid "To email(s)"
msgstr "A correo(s)"
#: src/components/routes/system.tsx:359
#: src/components/routes/system.tsx:372
msgid "Toggle grid"
msgstr "Alternar cuadrícula"
#: src/components/mode-toggle.tsx:33
msgid "Toggle theme"
msgstr "Alternar tema"
#: src/lib/utils.ts:317
msgid "Triggers when any sensor exceeds a threshold"
msgstr "Se activa cuando cualquier sensor supera un umbral"
#: src/lib/utils.ts:310
msgid "Triggers when combined up/down exceeds a threshold"
msgstr "Se activa cuando la suma de subida/bajada supera un umbral"
#: src/lib/utils.ts:292
msgid "Triggers when CPU usage exceeds a threshold"
msgstr "Se activa cuando el uso de CPU supera un umbral"
#: src/lib/utils.ts:298
msgid "Triggers when memory usage exceeds a threshold"
msgstr "Se activa cuando el uso de memoria supera un umbral"
#: src/lib/utils.ts:285
msgid "Triggers when status switches between up and down"
msgstr "Se activa cuando el estado cambia entre activo e inactivo"
#: src/lib/utils.ts:304
msgid "Triggers when usage of any disk exceeds a threshold"
msgstr "Se activa cuando el uso de cualquier disco supera un umbral"
#: src/components/systems-table/systems-table.tsx:269
msgid "Updated in real time. Click on a system to view information."
msgstr "Actualizado en tiempo real. Haga clic en un sistema para ver la información."
#: src/components/routes/system.tsx:261
msgid "Uptime"
msgstr "Tiempo de actividad"
#: src/components/charts/area-chart.tsx:73
#: src/components/routes/system.tsx:514
#: src/components/routes/system.tsx:551
msgid "Usage"
msgstr "Uso"
#: src/components/routes/system.tsx:424
msgid "Usage of root partition"
msgstr "Uso de la partición raíz"
#: src/components/charts/area-chart.tsx:73
#: src/components/charts/mem-chart.tsx:65
#: src/components/charts/swap-chart.tsx:56
msgid "Used"
msgstr "Usado"
#: src/components/command-palette.tsx:142
#: src/components/navbar.tsx:69
msgid "Users"
msgstr "Usuarios"
#: src/components/systems-table/systems-table.tsx:278
msgid "View"
msgstr "Vista"
#: src/components/systems-table/systems-table.tsx:342
msgid "Visible Fields"
msgstr "Columnas visibles"
#: src/components/routes/system.tsx:662
msgid "Waiting for enough records to display"
msgstr "Esperando suficientes registros para mostrar"
#: src/components/routes/settings/general.tsx:48
msgid "Want to help us make our translations even better? Check out <0>Crowdin</0> for more details."
msgstr "¿Quieres ayudarnos a mejorar nuestras traducciones? Consulta <0>Crowdin</0> para más detalles."
#: src/components/routes/settings/notifications.tsx:124
msgid "Webhook / Push notifications"
msgstr "Notificaciones Webhook / Push"
#. Disk write
#: src/components/charts/area-chart.tsx:59
#: src/components/charts/area-chart.tsx:69
msgid "Write"
msgstr "Escritura"
#: src/components/routes/settings/layout.tsx:61
msgid "YAML Config"
msgstr "Configuración YAML"
#: src/components/routes/settings/config-yaml.tsx:45
msgid "YAML Configuration"
msgstr "Configuración YAML"
#: src/components/routes/settings/layout.tsx:34
msgid "Your user settings have been updated."
msgstr "Su configuración de usuario ha sido actualizada."

View File

@@ -1,831 +0,0 @@
msgid ""
msgstr ""
"POT-Creation-Date: 2024-11-01 11:30-0400\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: @lingui/cli\n"
"Language: fa\n"
"Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2024-12-17 21:36\n"
"Last-Translator: \n"
"Language-Team: Persian\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Crowdin-Project: beszel\n"
"X-Crowdin-Project-ID: 733311\n"
"X-Crowdin-Language: fa\n"
"X-Crowdin-File: /main/beszel/site/src/locales/en/en.po\n"
"X-Crowdin-File-ID: 16\n"
#: src/components/routes/system.tsx:250
msgid "{0, plural, one {# day} other {# days}}"
msgstr "{0, plural, one {# روز} other {# روز}}"
#: src/components/routes/system.tsx:248
msgid "{hours, plural, one {# hour} other {# hours}}"
msgstr "{hours, plural, one {# ساعت} other {# ساعت}}"
#: src/lib/utils.ts:139
msgid "1 hour"
msgstr "۱ ساعت"
#: src/lib/utils.ts:162
msgid "1 week"
msgstr "۱ هفته"
#: src/lib/utils.ts:147
msgid "12 hours"
msgstr "۱۲ ساعت"
#: src/lib/utils.ts:155
msgid "24 hours"
msgstr "۲۴ ساعت"
#: src/lib/utils.ts:170
msgid "30 days"
msgstr "۳۰ روز"
#. Table column
#: src/components/systems-table/systems-table.tsx:227
#: src/components/systems-table/systems-table.tsx:313
#: src/components/systems-table/systems-table.tsx:455
#: src/components/systems-table/systems-table.tsx:465
msgid "Actions"
msgstr "عملیات"
#: src/components/routes/home.tsx:61
msgid "Active Alerts"
msgstr " هشدارهای فعال"
#: src/components/add-system.tsx:78
msgid "Add <0>System</0>"
msgstr "افزودن <0>سیستم</0>"
#: src/components/add-system.tsx:87
msgid "Add New System"
msgstr "افزودن سیستم جدید"
#: src/components/add-system.tsx:165
#: src/components/add-system.tsx:176
msgid "Add system"
msgstr "افزودن سیستم"
#: src/components/routes/settings/notifications.tsx:156
msgid "Add URL"
msgstr "افزودن آدرس اینترنتی"
#: src/components/routes/settings/general.tsx:81
msgid "Adjust display options for charts."
msgstr "تنظیم گزینه‌های نمایش برای نمودارها."
#: src/components/command-palette.tsx:132
#: src/components/command-palette.tsx:145
#: src/components/command-palette.tsx:159
#: src/components/command-palette.tsx:173
#: src/components/command-palette.tsx:188
msgid "Admin"
msgstr "مدیر"
#: src/components/systems-table/systems-table.tsx:201
msgid "Agent"
msgstr "عامل"
#: src/components/alerts/alert-button.tsx:32
#: src/components/alerts/alert-button.tsx:68
msgid "Alerts"
msgstr "هشدارها"
#: src/components/alerts/alert-button.tsx:88
#: src/components/systems-table/systems-table.tsx:266
msgid "All Systems"
msgstr "همه سیستم‌ها"
#: src/components/systems-table/systems-table.tsx:570
msgid "Are you sure you want to delete {name}?"
msgstr "آیا مطمئن هستید که می‌خواهید {name} را حذف کنید؟"
#: src/components/copy-to-clipboard.tsx:16
msgid "Automatic copy requires a secure context."
msgstr "کپی خودکار نیاز به یک زمینه امن دارد."
#: src/components/routes/system.tsx:625
msgid "Average"
msgstr "میانگین"
#: src/components/routes/system.tsx:396
msgid "Average CPU utilization of containers"
msgstr "میانگین استفاده از CPU کانتینرها"
#: src/components/alerts/alerts-system.tsx:206
msgid "Average exceeds <0>{value}{0}</0>"
msgstr ""
#: src/components/routes/system.tsx:497
msgid "Average power consumption of GPUs"
msgstr "میانگین مصرف برق پردازنده‌های گرافیکی"
#: src/components/routes/system.tsx:385
msgid "Average system-wide CPU utilization"
msgstr "میانگین استفاده از CPU در کل سیستم"
#: src/components/routes/system.tsx:515
msgid "Average utilization of {0}"
msgstr "میانگین استفاده از {0}"
#: src/components/command-palette.tsx:170
#: src/components/navbar.tsx:93
msgid "Backups"
msgstr "پشتیبان‌گیری‌ها"
#: src/components/routes/system.tsx:441
#: src/lib/utils.ts:307
msgid "Bandwidth"
msgstr "پهنای باند"
#: src/components/login/auth-form.tsx:289
msgid "Beszel supports OpenID Connect and many OAuth2 authentication providers."
msgstr "بِزل از OpenID Connect و بسیاری از ارائه‌دهندگان احراز هویت OAuth2 پشتیبانی می‌کند."
#: src/components/routes/settings/notifications.tsx:127
msgid "Beszel uses <0>Shoutrrr</0> to integrate with popular notification services."
msgstr "بِزل از <0>Shoutrrr</0> برای ادغام با سرویس‌های اطلاع‌رسانی محبوب استفاده می‌کند."
#: src/components/add-system.tsx:92
msgid "Binary"
msgstr "دودویی"
#: src/components/charts/mem-chart.tsx:89
msgid "Cache / Buffers"
msgstr "حافظه پنهان / بافرها"
#: src/components/systems-table/systems-table.tsx:581
msgid "Cancel"
msgstr "لغو"
#: src/components/routes/settings/config-yaml.tsx:68
msgid "Caution - potential data loss"
msgstr "احتیاط - احتمال از دست رفتن داده‌ها"
#: src/components/routes/settings/general.tsx:36
msgid "Change general application options."
msgstr "تغییر گزینه‌های کلی برنامه."
#: src/components/routes/settings/general.tsx:78
msgid "Chart options"
msgstr "گزینه‌های نمودار"
#: src/components/login/forgot-pass-form.tsx:34
msgid "Check {email} for a reset link."
msgstr "ایمیل {email} خود را برای لینک بازنشانی بررسی کنید."
#: src/components/routes/settings/layout.tsx:40
msgid "Check logs for more details."
msgstr "برای جزئیات بیشتر، لاگ‌ها را بررسی کنید."
#: src/components/routes/settings/notifications.tsx:183
msgid "Check your notification service"
msgstr "سرویس اطلاع‌رسانی خود را بررسی کنید"
#: src/components/add-system.tsx:151
msgid "Click to copy"
msgstr "برای کپی کردن کلیک کنید"
#: src/components/login/forgot-pass-form.tsx:83
#: src/components/login/forgot-pass-form.tsx:89
msgid "Command line instructions"
msgstr "دستورالعمل‌های خط فرمان"
#: src/components/routes/settings/notifications.tsx:77
msgid "Configure how you receive alert notifications."
msgstr "نحوه دریافت هشدارهای اطلاع‌رسانی را پیکربندی کنید."
#: src/components/login/auth-form.tsx:165
#: src/components/login/auth-form.tsx:170
msgid "Confirm password"
msgstr "تأیید رمز عبور"
#: src/components/systems-table/systems-table.tsx:587
msgid "Continue"
msgstr "ادامه"
#: src/lib/utils.ts:25
msgid "Copied to clipboard"
msgstr "در کلیپ‌بورد کپی شد"
#: src/components/add-system.tsx:162
msgid "Copy"
msgstr "کپی"
#: src/components/systems-table/systems-table.tsx:556
msgid "Copy host"
msgstr "کپی میزبان"
#: src/components/add-system.tsx:173
msgid "Copy Linux command"
msgstr "کپی دستور لینوکس"
#: src/components/copy-to-clipboard.tsx:13
msgid "Copy text"
msgstr "کپی متن"
#: src/components/systems-table/systems-table.tsx:157
msgid "CPU"
msgstr "پردازنده"
#: src/components/charts/area-chart.tsx:56
#: src/components/routes/system.tsx:384
#: src/lib/utils.ts:289
msgid "CPU Usage"
msgstr "میزان استفاده از پردازنده"
#: src/components/login/auth-form.tsx:191
msgid "Create account"
msgstr "ایجاد حساب کاربری"
#. Dark theme
#: src/components/mode-toggle.tsx:21
msgid "Dark"
msgstr "تیره"
#: src/components/command-palette.tsx:81
#: src/components/routes/home.tsx:34
msgid "Dashboard"
msgstr "داشبورد"
#: src/components/routes/settings/general.tsx:85
msgid "Default time period"
msgstr "بازه زمانی پیش‌فرض"
#: src/components/systems-table/systems-table.tsx:562
msgid "Delete"
msgstr "حذف"
#: src/components/systems-table/systems-table.tsx:173
msgid "Disk"
msgstr "دیسک"
#: src/components/routes/system.tsx:431
msgid "Disk I/O"
msgstr "ورودی/خروجی دیسک"
#: src/components/charts/disk-chart.tsx:79
#: src/components/routes/system.tsx:424
#: src/lib/utils.ts:301
msgid "Disk Usage"
msgstr "میزان استفاده از دیسک"
#: src/components/routes/system.tsx:552
msgid "Disk usage of {extraFsName}"
msgstr "میزان استفاده از دیسک {extraFsName}"
#: src/components/routes/system.tsx:395
msgid "Docker CPU Usage"
msgstr "میزان استفاده از CPU داکر"
#: src/components/routes/system.tsx:416
msgid "Docker Memory Usage"
msgstr "میزان استفاده از حافظه داکر"
#: src/components/routes/system.tsx:457
msgid "Docker Network I/O"
msgstr "ورودی/خروجی شبکه داکر"
#: src/components/command-palette.tsx:124
msgid "Documentation"
msgstr "مستندات"
#: src/components/login/auth-form.tsx:128
#: src/components/login/forgot-pass-form.tsx:53
msgid "Email"
msgstr "ایمیل"
#: src/components/routes/settings/notifications.tsx:91
msgid "Email notifications"
msgstr "اعلان‌های ایمیلی"
#: src/components/login/login.tsx:38
msgid "Enter email address to reset password"
msgstr "آدرس ایمیل را برای بازنشانی رمز عبور وارد کنید"
#: src/components/routes/settings/notifications.tsx:111
msgid "Enter email address..."
msgstr "آدرس ایمیل را وارد کنید..."
#: src/components/login/auth-form.tsx:232
#: src/components/routes/settings/config-yaml.tsx:28
#: src/components/routes/settings/notifications.tsx:187
msgid "Error"
msgstr "خطا"
#: src/components/routes/home.tsx:80
msgid "Exceeds {0}{1} in last {2, plural, one {# minute} other {# minutes}}"
msgstr "در {2, plural, one {# دقیقه} other {# دقیقه}} گذشته از {0}{1} بیشتر است"
#: src/components/routes/settings/config-yaml.tsx:72
msgid "Existing systems not defined in <0>config.yml</0> will be deleted. Please make regular backups."
msgstr "سیستم‌های موجود که در <0>config.yml</0> تعریف نشده‌اند حذف خواهند شد. لطفاً به طور منظم پشتیبان‌گیری کنید."
#: src/components/routes/settings/config-yaml.tsx:93
msgid "Export configuration"
msgstr "خارج کردن پیکربندی"
#: src/components/routes/settings/config-yaml.tsx:48
msgid "Export your current systems configuration."
msgstr "پیکربندی سیستم‌های فعلی خود را خارج کنید."
#: src/lib/utils.ts:38
msgid "Failed to authenticate"
msgstr "احراز هویت ناموفق بود"
#: src/components/routes/settings/layout.tsx:39
#: src/components/routes/settings/notifications.tsx:62
msgid "Failed to save settings"
msgstr "ذخیره تنظیمات ناموفق بود"
#: src/components/routes/settings/notifications.tsx:188
msgid "Failed to send test notification"
msgstr "ارسال اعلان آزمایشی ناموفق بود"
#: src/components/alerts/alerts-system.tsx:24
msgid "Failed to update alert"
msgstr "به‌روزرسانی هشدار ناموفق بود"
#: src/components/routes/system.tsx:596
#: src/components/systems-table/systems-table.tsx:273
msgid "Filter..."
msgstr "فیلتر..."
#: src/components/alerts/alerts-system.tsx:227
msgid "For <0>{min}</0> {min, plural, one {minute} other {minutes}}"
msgstr "برای <0>{min}</0> {min, plural, one {دقیقه} other {دقیقه}}"
#: src/components/login/auth-form.tsx:313
msgid "Forgot password?"
msgstr "رمز عبور را فراموش کرده‌اید؟"
#. Context: General settings
#: src/components/routes/settings/general.tsx:33
#: src/components/routes/settings/layout.tsx:51
msgid "General"
msgstr "عمومی"
#: src/components/routes/system.tsx:496
msgid "GPU Power Draw"
msgstr "مصرف برق پردازنده گرافیکی"
#: src/components/systems-table/systems-table.tsx:300
msgid "Grid"
msgstr "جدول"
#: src/components/add-system.tsx:120
msgid "Host / IP"
msgstr "میزبان / IP"
#: src/components/login/forgot-pass-form.tsx:93
msgid "If you've lost the password to your admin account, you may reset it using the following command."
msgstr "اگر رمز عبور حساب مدیر خود را گم کرده‌اید، می‌توانید آن را با استفاده از دستور زیر بازنشانی کنید."
#: src/components/login/auth-form.tsx:16
msgid "Invalid email address."
msgstr "آدرس ایمیل نامعتبر است."
#. Linux kernel
#: src/components/routes/system.tsx:262
msgid "Kernel"
msgstr "هسته"
#: src/components/routes/settings/general.tsx:45
msgid "Language"
msgstr "زبان"
#: src/components/systems-table/systems-table.tsx:286
msgid "Layout"
msgstr "طرح‌بندی"
#. Light theme
#: src/components/mode-toggle.tsx:16
msgid "Light"
msgstr "روشن"
#: src/components/navbar.tsx:104
msgid "Log Out"
msgstr "خروج"
#: src/components/login/login.tsx:19
msgid "Login"
msgstr "ورود"
#: src/components/login/auth-form.tsx:34
#: src/components/login/forgot-pass-form.tsx:15
msgid "Login attempt failed"
msgstr "تلاش برای ورود ناموفق بود"
#: src/components/command-palette.tsx:156
#: src/components/navbar.tsx:85
msgid "Logs"
msgstr "لاگ‌ها"
#: src/components/routes/settings/notifications.tsx:80
msgid "Looking instead for where to create alerts? Click the bell <0/> icons in the systems table."
msgstr "به دنبال جایی برای ایجاد هشدار هستید؟ روی آیکون‌های زنگ <0/> در جدول سیستم‌ها کلیک کنید."
#: src/components/routes/settings/layout.tsx:85
msgid "Manage display and notification preferences."
msgstr "مدیریت تنظیمات نمایش و اعلان‌ها."
#. Chart select field. Please try to keep this short.
#: src/components/routes/system.tsx:628
msgid "Max 1 min"
msgstr "حداکثر ۱ دقیقه"
#: src/components/systems-table/systems-table.tsx:165
msgid "Memory"
msgstr "حافظه"
#: src/components/routes/system.tsx:406
#: src/lib/utils.ts:295
msgid "Memory Usage"
msgstr "میزان استفاده از حافظه"
#: src/components/routes/system.tsx:417
msgid "Memory usage of docker containers"
msgstr "میزان استفاده از حافظه کانتینرهای داکر"
#: src/components/add-system.tsx:116
msgid "Name"
msgstr "نام"
#: src/components/systems-table/systems-table.tsx:181
msgid "Net"
msgstr "شبکه"
#: src/components/routes/system.tsx:458
msgid "Network traffic of docker containers"
msgstr "ترافیک شبکه کانتینرهای داکر"
#: src/components/routes/system.tsx:443
msgid "Network traffic of public interfaces"
msgstr "ترافیک شبکه رابط‌های عمومی"
#: src/components/command-palette.tsx:49
msgid "No results found."
msgstr "هیچ نتیجه‌ای یافت نشد."
#: src/components/systems-table/systems-table.tsx:421
#: src/components/systems-table/systems-table.tsx:494
msgid "No systems found."
msgstr "هیچ سیستمی یافت نشد."
#: src/components/command-palette.tsx:110
#: src/components/routes/settings/layout.tsx:56
#: src/components/routes/settings/notifications.tsx:74
msgid "Notifications"
msgstr "اعلان‌ها"
#: src/components/login/auth-form.tsx:284
msgid "OAuth 2 / OIDC support"
msgstr "پشتیبانی از OAuth 2 / OIDC"
#: src/components/routes/settings/config-yaml.tsx:61
msgid "On each restart, systems in the database will be updated to match the systems defined in the file."
msgstr "در هر بار راه‌اندازی مجدد، سیستم‌های موجود در پایگاه داده با سیستم‌های تعریف شده در فایل مطابقت داده می‌شوند."
#: src/components/systems-table/systems-table.tsx:528
msgid "Open menu"
msgstr "باز کردن منو"
#: src/components/login/auth-form.tsx:203
msgid "Or continue with"
msgstr "یا ادامه با"
#: src/components/alerts/alert-button.tsx:109
msgid "Overwrite existing alerts"
msgstr "بازنویسی هشدارهای موجود"
#: src/components/command-palette.tsx:84
msgid "Page"
msgstr "صفحه"
#: src/components/command-palette.tsx:71
msgid "Pages / Settings"
msgstr "صفحات / تنظیمات"
#: src/components/login/auth-form.tsx:147
#: src/components/login/auth-form.tsx:152
msgid "Password"
msgstr "رمز عبور"
#: src/components/login/auth-form.tsx:17
msgid "Password must be at least 8 characters."
msgstr "رمز عبور باید حداقل ۸ کاراکتر باشد."
#: src/components/login/forgot-pass-form.tsx:33
msgid "Password reset request received"
msgstr "درخواست بازنشانی رمز عبور دریافت شد"
#: src/components/systems-table/systems-table.tsx:550
msgid "Pause"
msgstr "توقف"
#: src/components/routes/settings/notifications.tsx:95
msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered."
msgstr "لطفاً برای اطمینان از تحویل هشدارها، یک <0>سرور SMTP پیکربندی کنید</0>."
#: src/components/alerts/alerts-system.tsx:25
msgid "Please check logs for more details."
msgstr "لطفاً برای جزئیات بیشتر، لاگ‌ها را بررسی کنید."
#: src/components/login/auth-form.tsx:35
#: src/components/login/forgot-pass-form.tsx:16
msgid "Please check your credentials and try again"
msgstr "لطفاً اعتبارنامه‌های خود را بررسی کرده و دوباره تلاش کنید."
#: src/components/login/login.tsx:36
msgid "Please create an admin account"
msgstr "لطفاً یک حساب مدیر ایجاد کنید"
#: src/components/login/auth-form.tsx:233
msgid "Please enable pop-ups for this site"
msgstr "لطفاً پنجره‌های بازشو را برای این سایت فعال کنید"
#: src/lib/utils.ts:39
msgid "Please log in again"
msgstr "لطفاً دوباره وارد شوید"
#: src/components/login/auth-form.tsx:292
msgid "Please see <0>the documentation</0> for instructions."
msgstr "لطفاً برای دستورالعمل‌ها به <0>مستندات</0> مراجعه کنید."
#: src/components/login/login.tsx:40
msgid "Please sign in to your account"
msgstr "لطفاً به حساب کاربری خود وارد شوید"
#: src/components/add-system.tsx:124
msgid "Port"
msgstr "پورت"
#: src/components/routes/system.tsx:407
#: src/components/routes/system.tsx:523
msgid "Precise utilization at the recorded time"
msgstr "میزان دقیق استفاده در زمان ثبت شده"
#: src/components/routes/settings/general.tsx:58
msgid "Preferred Language"
msgstr "زبان ترجیحی"
#. Use 'Key' if your language requires many more characters
#: src/components/add-system.tsx:128
msgid "Public Key"
msgstr "کلید عمومی"
#. Disk read
#: src/components/charts/area-chart.tsx:60
#: src/components/charts/area-chart.tsx:70
msgid "Read"
msgstr "خواندن"
#. Network bytes received (download)
#: src/components/charts/area-chart.tsx:65
msgid "Received"
msgstr "دریافت شد"
#: src/components/login/forgot-pass-form.tsx:76
msgid "Reset Password"
msgstr "بازنشانی رمز عبور"
#: src/components/systems-table/systems-table.tsx:545
msgid "Resume"
msgstr "ادامه"
#: src/components/routes/settings/notifications.tsx:117
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
msgstr "آدرس را با استفاده از کلید Enter یا کاما ذخیره کنید. برای غیرفعال کردن اعلان‌های ایمیلی، خالی بگذارید."
#: src/components/routes/settings/general.tsx:106
#: src/components/routes/settings/notifications.tsx:167
msgid "Save Settings"
msgstr "ذخیره تنظیمات"
#: src/components/navbar.tsx:133
msgid "Search"
msgstr "جستجو"
#: src/components/command-palette.tsx:46
msgid "Search for systems or settings..."
msgstr "جستجو برای سیستم‌ها یا تنظیمات..."
#: src/components/alerts/alert-button.tsx:71
msgid "See <0>notification settings</0> to configure how you receive alerts."
msgstr "برای پیکربندی نحوه دریافت هشدارها، به <0>تنظیمات اعلان</0> مراجعه کنید."
#. Network bytes sent (upload)
#: src/components/charts/area-chart.tsx:64
msgid "Sent"
msgstr "ارسال شد"
#: src/components/routes/settings/general.tsx:100
msgid "Sets the default time range for charts when a system is viewed."
msgstr "بازه زمانی پیش‌فرض برای نمودارها هنگام مشاهده یک سیستم را تعیین می‌کند."
#: src/components/command-palette.tsx:95
#: src/components/command-palette.tsx:98
#: src/components/command-palette.tsx:113
#: src/components/routes/settings/layout.tsx:71
#: src/components/routes/settings/layout.tsx:82
msgid "Settings"
msgstr "تنظیمات"
#: src/components/routes/settings/layout.tsx:33
msgid "Settings saved"
msgstr "تنظیمات ذخیره شد"
#: src/components/login/auth-form.tsx:191
msgid "Sign in"
msgstr "ورود"
#: src/components/command-palette.tsx:185
msgid "SMTP settings"
msgstr "تنظیمات SMTP"
#: src/components/systems-table/systems-table.tsx:308
msgid "Sort By"
msgstr "مرتب‌سازی بر اساس"
#: src/lib/utils.ts:282
msgid "Status"
msgstr "وضعیت"
#: src/components/routes/system.tsx:473
msgid "Swap space used by the system"
msgstr "فضای Swap استفاده شده توسط سیستم"
#: src/components/routes/system.tsx:472
msgid "Swap Usage"
msgstr "میزان استفاده از Swap"
#. System theme
#: src/components/mode-toggle.tsx:26
#: src/components/systems-table/systems-table.tsx:117
#: src/components/systems-table/systems-table.tsx:125
#: src/components/systems-table/systems-table.tsx:136
#: src/components/systems-table/systems-table.tsx:465
msgid "System"
msgstr "سیستم"
#: src/components/navbar.tsx:77
msgid "Systems"
msgstr "سیستم‌ها"
#: src/components/routes/settings/config-yaml.tsx:55
msgid "Systems may be managed in a <0>config.yml</0> file inside your data directory."
msgstr "سیستم‌ها ممکن است در یک فایل <0>config.yml</0> درون دایرکتوری داده شما مدیریت شوند."
#: src/components/systems-table/systems-table.tsx:296
msgid "Table"
msgstr "جدول"
#: src/components/routes/system.tsx:484
#: src/lib/utils.ts:314
msgid "Temperature"
msgstr "دما"
#: src/components/routes/system.tsx:485
msgid "Temperatures of system sensors"
msgstr "دمای حسگرهای سیستم"
#: src/components/routes/settings/notifications.tsx:211
msgid "Test <0>URL</0>"
msgstr "تست <0>آدرس اینترنتی</0>"
#: src/components/routes/settings/notifications.tsx:182
msgid "Test notification sent"
msgstr "اعلان آزمایشی ارسال شد"
#: src/components/add-system.tsx:108
msgid "The agent must be running on the system to connect. Copy the installation command for the agent below."
msgstr "برای اتصال، عامل باید روی سیستم در حال اجرا باشد. دستور نصب عامل را از زیر کپی کنید."
#: src/components/add-system.tsx:99
msgid "The agent must be running on the system to connect. Copy the<0>docker-compose.yml</0> for the agent below."
msgstr "برای اتصال، عامل باید روی سیستم در حال اجرا باشد. <0>docker-compose.yml</0> مربوط به عامل را از زیر کپی کنید."
#: src/components/login/forgot-pass-form.tsx:98
msgid "Then log into the backend and reset your user account password in the users table."
msgstr "سپس وارد بخش پشتیبان شوید و رمز عبور حساب کاربری خود را در جدول کاربران بازنشانی کنید."
#: src/components/systems-table/systems-table.tsx:573
msgid "This action cannot be undone. This will permanently delete all current records for {name} from the database."
msgstr "این عمل قابل برگشت نیست. این کار تمام رکوردهای فعلی {name} را برای همیشه از پایگاه داده حذف خواهد کرد."
#: src/components/routes/system.tsx:564
msgid "Throughput of {extraFsName}"
msgstr "توان عملیاتی {extraFsName}"
#: src/components/routes/system.tsx:432
msgid "Throughput of root filesystem"
msgstr "توان عملیاتی سیستم فایل ریشه"
#: src/components/routes/settings/notifications.tsx:106
msgid "To email(s)"
msgstr "به ایمیل(ها)"
#: src/components/routes/system.tsx:359
#: src/components/routes/system.tsx:372
msgid "Toggle grid"
msgstr "تغییر نمایش جدول"
#: src/components/mode-toggle.tsx:33
msgid "Toggle theme"
msgstr "تغییر تم"
#: src/lib/utils.ts:317
msgid "Triggers when any sensor exceeds a threshold"
msgstr "هنگامی که هر حسگری از یک آستانه فراتر رود، فعال می‌شود"
#: src/lib/utils.ts:310
msgid "Triggers when combined up/down exceeds a threshold"
msgstr "هنگامی که مجموع بالا/پایین از یک آستانه فراتر رود، فعال می‌شود"
#: src/lib/utils.ts:292
msgid "Triggers when CPU usage exceeds a threshold"
msgstr "هنگامی که میزان استفاده از CPU از یک آستانه فراتر رود، فعال می‌شود"
#: src/lib/utils.ts:298
msgid "Triggers when memory usage exceeds a threshold"
msgstr "هنگامی که میزان استفاده از حافظه از یک آستانه فراتر رود، فعال می‌شود"
#: src/lib/utils.ts:285
msgid "Triggers when status switches between up and down"
msgstr "هنگامی که وضعیت بین بالا و پایین تغییر می‌کند، فعال می‌شود"
#: src/lib/utils.ts:304
msgid "Triggers when usage of any disk exceeds a threshold"
msgstr "هنگامی که استفاده از هر دیسکی از یک آستانه فراتر رود، فعال می‌شود"
#: src/components/systems-table/systems-table.tsx:269
msgid "Updated in real time. Click on a system to view information."
msgstr "به صورت لحظه‌ای به‌روزرسانی می‌شود. برای مشاهده اطلاعات، روی یک سیستم کلیک کنید."
#: src/components/routes/system.tsx:261
msgid "Uptime"
msgstr "آپتایم"
#: src/components/charts/area-chart.tsx:73
#: src/components/routes/system.tsx:514
#: src/components/routes/system.tsx:551
msgid "Usage"
msgstr "میزان استفاده"
#: src/components/routes/system.tsx:424
msgid "Usage of root partition"
msgstr "میزان استفاده از پارتیشن ریشه"
#: src/components/charts/area-chart.tsx:73
#: src/components/charts/mem-chart.tsx:65
#: src/components/charts/swap-chart.tsx:56
msgid "Used"
msgstr "استفاده شده"
#: src/components/command-palette.tsx:142
#: src/components/navbar.tsx:69
msgid "Users"
msgstr "کاربران"
#: src/components/systems-table/systems-table.tsx:278
msgid "View"
msgstr "مشاهده"
#: src/components/systems-table/systems-table.tsx:342
msgid "Visible Fields"
msgstr "فیلدهای قابل مشاهده"
#: src/components/routes/system.tsx:662
msgid "Waiting for enough records to display"
msgstr "در انتظار رکوردهای کافی برای نمایش"
#: src/components/routes/settings/general.tsx:48
msgid "Want to help us make our translations even better? Check out <0>Crowdin</0> for more details."
msgstr "می‌خواهید به ما کمک کنید تا ترجمه‌های خود را بهتر کنیم؟ برای جزئیات بیشتر به <0>Crowdin</0> مراجعه کنید."
#: src/components/routes/settings/notifications.tsx:124
msgid "Webhook / Push notifications"
msgstr "اعلان‌های Webhook / Push"
#. Disk write
#: src/components/charts/area-chart.tsx:59
#: src/components/charts/area-chart.tsx:69
msgid "Write"
msgstr "نوشتن"
#: src/components/routes/settings/layout.tsx:61
msgid "YAML Config"
msgstr "پیکربندی YAML"
#: src/components/routes/settings/config-yaml.tsx:45
msgid "YAML Configuration"
msgstr "پیکربندی YAML"
#: src/components/routes/settings/layout.tsx:34
msgid "Your user settings have been updated."
msgstr "تنظیمات کاربری شما به‌روزرسانی شد."

View File

@@ -1,831 +0,0 @@
msgid ""
msgstr ""
"POT-Creation-Date: 2024-11-01 11:30-0400\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: @lingui/cli\n"
"Language: fr\n"
"Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2024-12-17 21:36\n"
"Last-Translator: \n"
"Language-Team: French\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
"X-Crowdin-Project: beszel\n"
"X-Crowdin-Project-ID: 733311\n"
"X-Crowdin-Language: fr\n"
"X-Crowdin-File: /main/beszel/site/src/locales/en/en.po\n"
"X-Crowdin-File-ID: 16\n"
#: src/components/routes/system.tsx:250
msgid "{0, plural, one {# day} other {# days}}"
msgstr "{0, plural, one {# jour} other {# jours}}"
#: src/components/routes/system.tsx:248
msgid "{hours, plural, one {# hour} other {# hours}}"
msgstr "{hours, plural, one {# heure} other {# heures}}"
#: src/lib/utils.ts:139
msgid "1 hour"
msgstr "1 heure"
#: src/lib/utils.ts:162
msgid "1 week"
msgstr "1 semaine"
#: src/lib/utils.ts:147
msgid "12 hours"
msgstr "12 heures"
#: src/lib/utils.ts:155
msgid "24 hours"
msgstr "24 heures"
#: src/lib/utils.ts:170
msgid "30 days"
msgstr "30 jours"
#. Table column
#: src/components/systems-table/systems-table.tsx:227
#: src/components/systems-table/systems-table.tsx:313
#: src/components/systems-table/systems-table.tsx:455
#: src/components/systems-table/systems-table.tsx:465
msgid "Actions"
msgstr "Actions"
#: src/components/routes/home.tsx:61
msgid "Active Alerts"
msgstr "Alertes actives"
#: src/components/add-system.tsx:78
msgid "Add <0>System</0>"
msgstr "Ajouter <0>Système</0>"
#: src/components/add-system.tsx:87
msgid "Add New System"
msgstr "Ajouter un nouveau système"
#: src/components/add-system.tsx:165
#: src/components/add-system.tsx:176
msgid "Add system"
msgstr "Ajouter un système"
#: src/components/routes/settings/notifications.tsx:156
msgid "Add URL"
msgstr "Ajouter URL"
#: src/components/routes/settings/general.tsx:81
msgid "Adjust display options for charts."
msgstr "Ajuster les options d'affichage pour les graphiques."
#: src/components/command-palette.tsx:132
#: src/components/command-palette.tsx:145
#: src/components/command-palette.tsx:159
#: src/components/command-palette.tsx:173
#: src/components/command-palette.tsx:188
msgid "Admin"
msgstr "Admin"
#: src/components/systems-table/systems-table.tsx:201
msgid "Agent"
msgstr "Agent"
#: src/components/alerts/alert-button.tsx:32
#: src/components/alerts/alert-button.tsx:68
msgid "Alerts"
msgstr "Alertes"
#: src/components/alerts/alert-button.tsx:88
#: src/components/systems-table/systems-table.tsx:266
msgid "All Systems"
msgstr "Tous les systèmes"
#: src/components/systems-table/systems-table.tsx:570
msgid "Are you sure you want to delete {name}?"
msgstr "Êtes-vous sûr de vouloir supprimer {name} ?"
#: src/components/copy-to-clipboard.tsx:16
msgid "Automatic copy requires a secure context."
msgstr "La copie automatique nécessite un contexte sécurisé."
#: src/components/routes/system.tsx:625
msgid "Average"
msgstr "Moyenne"
#: src/components/routes/system.tsx:396
msgid "Average CPU utilization of containers"
msgstr "Utilisation moyenne du CPU des conteneurs"
#: src/components/alerts/alerts-system.tsx:206
msgid "Average exceeds <0>{value}{0}</0>"
msgstr "La moyenne dépasse <0>{value}{0}</0>"
#: src/components/routes/system.tsx:497
msgid "Average power consumption of GPUs"
msgstr ""
#: src/components/routes/system.tsx:385
msgid "Average system-wide CPU utilization"
msgstr "Utilisation moyenne du CPU à l'échelle du système"
#: src/components/routes/system.tsx:515
msgid "Average utilization of {0}"
msgstr ""
#: src/components/command-palette.tsx:170
#: src/components/navbar.tsx:93
msgid "Backups"
msgstr "Sauvegardes"
#: src/components/routes/system.tsx:441
#: src/lib/utils.ts:307
msgid "Bandwidth"
msgstr "Bande passante"
#: src/components/login/auth-form.tsx:289
msgid "Beszel supports OpenID Connect and many OAuth2 authentication providers."
msgstr "Beszel prend en charge OpenID Connect et de nombreux fournisseurs d'authentification OAuth2."
#: src/components/routes/settings/notifications.tsx:127
msgid "Beszel uses <0>Shoutrrr</0> to integrate with popular notification services."
msgstr "Beszel utilise <0>Shoutrrr</0> pour s'intégrer aux services de notification populaires."
#: src/components/add-system.tsx:92
msgid "Binary"
msgstr "Binaire"
#: src/components/charts/mem-chart.tsx:89
msgid "Cache / Buffers"
msgstr "Cache / Tampons"
#: src/components/systems-table/systems-table.tsx:581
msgid "Cancel"
msgstr "Annuler"
#: src/components/routes/settings/config-yaml.tsx:68
msgid "Caution - potential data loss"
msgstr "Attention - perte de données potentielle"
#: src/components/routes/settings/general.tsx:36
msgid "Change general application options."
msgstr "Modifier les options générales de l'application."
#: src/components/routes/settings/general.tsx:78
msgid "Chart options"
msgstr "Options de graphique"
#: src/components/login/forgot-pass-form.tsx:34
msgid "Check {email} for a reset link."
msgstr "Vérifiez {email} pour un lien de réinitialisation."
#: src/components/routes/settings/layout.tsx:40
msgid "Check logs for more details."
msgstr "Vérifiez les journaux pour plus de détails."
#: src/components/routes/settings/notifications.tsx:183
msgid "Check your notification service"
msgstr "Vérifiez votre service de notification"
#: src/components/add-system.tsx:151
msgid "Click to copy"
msgstr "Cliquez pour copier"
#: src/components/login/forgot-pass-form.tsx:83
#: src/components/login/forgot-pass-form.tsx:89
msgid "Command line instructions"
msgstr "Instructions en ligne de commande"
#: src/components/routes/settings/notifications.tsx:77
msgid "Configure how you receive alert notifications."
msgstr "Configurez comment vous recevez les notifications d'alerte."
#: src/components/login/auth-form.tsx:165
#: src/components/login/auth-form.tsx:170
msgid "Confirm password"
msgstr "Confirmer le mot de passe"
#: src/components/systems-table/systems-table.tsx:587
msgid "Continue"
msgstr "Continuer"
#: src/lib/utils.ts:25
msgid "Copied to clipboard"
msgstr "Copié dans le presse-papiers"
#: src/components/add-system.tsx:162
msgid "Copy"
msgstr "Copier"
#: src/components/systems-table/systems-table.tsx:556
msgid "Copy host"
msgstr "Copier l'hôte"
#: src/components/add-system.tsx:173
msgid "Copy Linux command"
msgstr "Copier la commande Linux"
#: src/components/copy-to-clipboard.tsx:13
msgid "Copy text"
msgstr "Copier le texte"
#: src/components/systems-table/systems-table.tsx:157
msgid "CPU"
msgstr "CPU"
#: src/components/charts/area-chart.tsx:56
#: src/components/routes/system.tsx:384
#: src/lib/utils.ts:289
msgid "CPU Usage"
msgstr "Utilisation du CPU"
#: src/components/login/auth-form.tsx:191
msgid "Create account"
msgstr "Créer un compte"
#. Dark theme
#: src/components/mode-toggle.tsx:21
msgid "Dark"
msgstr "Sombre"
#: src/components/command-palette.tsx:81
#: src/components/routes/home.tsx:34
msgid "Dashboard"
msgstr "Tableau de bord"
#: src/components/routes/settings/general.tsx:85
msgid "Default time period"
msgstr "Période par défaut"
#: src/components/systems-table/systems-table.tsx:562
msgid "Delete"
msgstr "Supprimer"
#: src/components/systems-table/systems-table.tsx:173
msgid "Disk"
msgstr "Disque"
#: src/components/routes/system.tsx:431
msgid "Disk I/O"
msgstr "Entrée/Sortie disque"
#: src/components/charts/disk-chart.tsx:79
#: src/components/routes/system.tsx:424
#: src/lib/utils.ts:301
msgid "Disk Usage"
msgstr "Utilisation du disque"
#: src/components/routes/system.tsx:552
msgid "Disk usage of {extraFsName}"
msgstr "Utilisation du disque de {extraFsName}"
#: src/components/routes/system.tsx:395
msgid "Docker CPU Usage"
msgstr "Utilisation du CPU Docker"
#: src/components/routes/system.tsx:416
msgid "Docker Memory Usage"
msgstr "Utilisation de la mémoire Docker"
#: src/components/routes/system.tsx:457
msgid "Docker Network I/O"
msgstr "Entrée/Sortie réseau Docker"
#: src/components/command-palette.tsx:124
msgid "Documentation"
msgstr "Documentation"
#: src/components/login/auth-form.tsx:128
#: src/components/login/forgot-pass-form.tsx:53
msgid "Email"
msgstr "Email"
#: src/components/routes/settings/notifications.tsx:91
msgid "Email notifications"
msgstr "Notifications par email"
#: src/components/login/login.tsx:38
msgid "Enter email address to reset password"
msgstr "Entrez l'adresse email pour réinitialiser le mot de passe"
#: src/components/routes/settings/notifications.tsx:111
msgid "Enter email address..."
msgstr "Entrez l'adresse email..."
#: src/components/login/auth-form.tsx:232
#: src/components/routes/settings/config-yaml.tsx:28
#: src/components/routes/settings/notifications.tsx:187
msgid "Error"
msgstr "Erreur"
#: src/components/routes/home.tsx:80
msgid "Exceeds {0}{1} in last {2, plural, one {# minute} other {# minutes}}"
msgstr "Dépasse {0}{1} dans {2, plural, one {la dernière # minute} other {les dernières # minutes}}"
#: src/components/routes/settings/config-yaml.tsx:72
msgid "Existing systems not defined in <0>config.yml</0> will be deleted. Please make regular backups."
msgstr "Les systèmes existants non définis dans <0>config.yml</0> seront supprimés. Veuillez faire des sauvegardes régulières."
#: src/components/routes/settings/config-yaml.tsx:93
msgid "Export configuration"
msgstr "Exporter la configuration"
#: src/components/routes/settings/config-yaml.tsx:48
msgid "Export your current systems configuration."
msgstr "Exportez la configuration actuelle de vos systèmes."
#: src/lib/utils.ts:38
msgid "Failed to authenticate"
msgstr "Échec de l'authentification"
#: src/components/routes/settings/layout.tsx:39
#: src/components/routes/settings/notifications.tsx:62
msgid "Failed to save settings"
msgstr "Échec de l'enregistrement des paramètres"
#: src/components/routes/settings/notifications.tsx:188
msgid "Failed to send test notification"
msgstr "Échec de l'envoi de la notification de test"
#: src/components/alerts/alerts-system.tsx:24
msgid "Failed to update alert"
msgstr "Échec de la mise à jour de l'alerte"
#: src/components/routes/system.tsx:596
#: src/components/systems-table/systems-table.tsx:273
msgid "Filter..."
msgstr "Filtrer..."
#: src/components/alerts/alerts-system.tsx:227
msgid "For <0>{min}</0> {min, plural, one {minute} other {minutes}}"
msgstr "Pour <0>{min}</0> {min, plural, one {minute} other {minutes}}"
#: src/components/login/auth-form.tsx:313
msgid "Forgot password?"
msgstr "Mot de passe oublié ?"
#. Context: General settings
#: src/components/routes/settings/general.tsx:33
#: src/components/routes/settings/layout.tsx:51
msgid "General"
msgstr "Général"
#: src/components/routes/system.tsx:496
msgid "GPU Power Draw"
msgstr ""
#: src/components/systems-table/systems-table.tsx:300
msgid "Grid"
msgstr "Grille"
#: src/components/add-system.tsx:120
msgid "Host / IP"
msgstr "Hôte / IP"
#: src/components/login/forgot-pass-form.tsx:93
msgid "If you've lost the password to your admin account, you may reset it using the following command."
msgstr "Si vous avez perdu le mot de passe de votre compte administrateur, vous pouvez le réinitialiser en utilisant la commande suivante."
#: src/components/login/auth-form.tsx:16
msgid "Invalid email address."
msgstr "Adresse email invalide."
#. Linux kernel
#: src/components/routes/system.tsx:262
msgid "Kernel"
msgstr "Noyau"
#: src/components/routes/settings/general.tsx:45
msgid "Language"
msgstr "Langue"
#: src/components/systems-table/systems-table.tsx:286
msgid "Layout"
msgstr "Disposition"
#. Light theme
#: src/components/mode-toggle.tsx:16
msgid "Light"
msgstr "Clair"
#: src/components/navbar.tsx:104
msgid "Log Out"
msgstr "Déconnexion"
#: src/components/login/login.tsx:19
msgid "Login"
msgstr "Connexion"
#: src/components/login/auth-form.tsx:34
#: src/components/login/forgot-pass-form.tsx:15
msgid "Login attempt failed"
msgstr "Échec de la tentative de connexion"
#: src/components/command-palette.tsx:156
#: src/components/navbar.tsx:85
msgid "Logs"
msgstr "Journaux"
#: src/components/routes/settings/notifications.tsx:80
msgid "Looking instead for where to create alerts? Click the bell <0/> icons in the systems table."
msgstr "Vous cherchez plutôt où créer des alertes ? Cliquez sur les icônes de cloche <0/> dans le tableau des systèmes."
#: src/components/routes/settings/layout.tsx:85
msgid "Manage display and notification preferences."
msgstr "Gérer les préférences d'affichage et de notification."
#. Chart select field. Please try to keep this short.
#: src/components/routes/system.tsx:628
msgid "Max 1 min"
msgstr "Max 1 min"
#: src/components/systems-table/systems-table.tsx:165
msgid "Memory"
msgstr "Mémoire"
#: src/components/routes/system.tsx:406
#: src/lib/utils.ts:295
msgid "Memory Usage"
msgstr "Utilisation de la mémoire"
#: src/components/routes/system.tsx:417
msgid "Memory usage of docker containers"
msgstr "Utilisation de la mémoire des conteneurs Docker"
#: src/components/add-system.tsx:116
msgid "Name"
msgstr "Nom"
#: src/components/systems-table/systems-table.tsx:181
msgid "Net"
msgstr "Net"
#: src/components/routes/system.tsx:458
msgid "Network traffic of docker containers"
msgstr "Trafic réseau des conteneurs Docker"
#: src/components/routes/system.tsx:443
msgid "Network traffic of public interfaces"
msgstr "Trafic réseau des interfaces publiques"
#: src/components/command-palette.tsx:49
msgid "No results found."
msgstr "Aucun résultat trouvé."
#: src/components/systems-table/systems-table.tsx:421
#: src/components/systems-table/systems-table.tsx:494
msgid "No systems found."
msgstr "Aucun système trouvé."
#: src/components/command-palette.tsx:110
#: src/components/routes/settings/layout.tsx:56
#: src/components/routes/settings/notifications.tsx:74
msgid "Notifications"
msgstr "Notifications"
#: src/components/login/auth-form.tsx:284
msgid "OAuth 2 / OIDC support"
msgstr "Support OAuth 2 / OIDC"
#: src/components/routes/settings/config-yaml.tsx:61
msgid "On each restart, systems in the database will be updated to match the systems defined in the file."
msgstr "À chaque redémarrage, les systèmes dans la base de données seront mis à jour pour correspondre aux systèmes définis dans le fichier."
#: src/components/systems-table/systems-table.tsx:528
msgid "Open menu"
msgstr "Ouvrir le menu"
#: src/components/login/auth-form.tsx:203
msgid "Or continue with"
msgstr "Ou continuer avec"
#: src/components/alerts/alert-button.tsx:109
msgid "Overwrite existing alerts"
msgstr "Écraser les alertes existantes"
#: src/components/command-palette.tsx:84
msgid "Page"
msgstr "Page"
#: src/components/command-palette.tsx:71
msgid "Pages / Settings"
msgstr "Pages / Paramètres"
#: src/components/login/auth-form.tsx:147
#: src/components/login/auth-form.tsx:152
msgid "Password"
msgstr "Mot de passe"
#: src/components/login/auth-form.tsx:17
msgid "Password must be at least 8 characters."
msgstr "Le mot de passe doit contenir au moins 8 caractères."
#: src/components/login/forgot-pass-form.tsx:33
msgid "Password reset request received"
msgstr "Demande de réinitialisation du mot de passe reçue"
#: src/components/systems-table/systems-table.tsx:550
msgid "Pause"
msgstr "Pause"
#: src/components/routes/settings/notifications.tsx:95
msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered."
msgstr "Veuillez <0>configurer un serveur SMTP</0> pour garantir la livraison des alertes."
#: src/components/alerts/alerts-system.tsx:25
msgid "Please check logs for more details."
msgstr "Veuillez vérifier les journaux pour plus de détails."
#: src/components/login/auth-form.tsx:35
#: src/components/login/forgot-pass-form.tsx:16
msgid "Please check your credentials and try again"
msgstr "Veuillez vérifier vos identifiants et réessayer"
#: src/components/login/login.tsx:36
msgid "Please create an admin account"
msgstr "Veuillez créer un compte administrateur"
#: src/components/login/auth-form.tsx:233
msgid "Please enable pop-ups for this site"
msgstr "Veuillez activer les pop-ups pour ce site"
#: src/lib/utils.ts:39
msgid "Please log in again"
msgstr "Veuillez vous reconnecter"
#: src/components/login/auth-form.tsx:292
msgid "Please see <0>the documentation</0> for instructions."
msgstr "Veuillez consulter <0>la documentation</0> pour les instructions."
#: src/components/login/login.tsx:40
msgid "Please sign in to your account"
msgstr "Veuillez vous connecter à votre compte"
#: src/components/add-system.tsx:124
msgid "Port"
msgstr "Port"
#: src/components/routes/system.tsx:407
#: src/components/routes/system.tsx:523
msgid "Precise utilization at the recorded time"
msgstr "Utilisation précise au moment enregistré"
#: src/components/routes/settings/general.tsx:58
msgid "Preferred Language"
msgstr "Langue préférée"
#. Use 'Key' if your language requires many more characters
#: src/components/add-system.tsx:128
msgid "Public Key"
msgstr "Clé publique"
#. Disk read
#: src/components/charts/area-chart.tsx:60
#: src/components/charts/area-chart.tsx:70
msgid "Read"
msgstr "Lecture"
#. Network bytes received (download)
#: src/components/charts/area-chart.tsx:65
msgid "Received"
msgstr "Reçu"
#: src/components/login/forgot-pass-form.tsx:76
msgid "Reset Password"
msgstr "Réinitialiser le mot de passe"
#: src/components/systems-table/systems-table.tsx:545
msgid "Resume"
msgstr "Reprendre"
#: src/components/routes/settings/notifications.tsx:117
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
msgstr "Enregistrez l'adresse en utilisant la touche Entrée ou la virgule. Laissez vide pour désactiver les notifications par email."
#: src/components/routes/settings/general.tsx:106
#: src/components/routes/settings/notifications.tsx:167
msgid "Save Settings"
msgstr "Enregistrer les paramètres"
#: src/components/navbar.tsx:133
msgid "Search"
msgstr "Recherche"
#: src/components/command-palette.tsx:46
msgid "Search for systems or settings..."
msgstr "Rechercher des systèmes ou des paramètres..."
#: src/components/alerts/alert-button.tsx:71
msgid "See <0>notification settings</0> to configure how you receive alerts."
msgstr "Voir les <0>paramètres de notification</0> pour configurer comment vous recevez les alertes."
#. Network bytes sent (upload)
#: src/components/charts/area-chart.tsx:64
msgid "Sent"
msgstr "Envoyé"
#: src/components/routes/settings/general.tsx:100
msgid "Sets the default time range for charts when a system is viewed."
msgstr "Définit la plage de temps par défaut pour les graphiques lorsqu'un système est consulté."
#: src/components/command-palette.tsx:95
#: src/components/command-palette.tsx:98
#: src/components/command-palette.tsx:113
#: src/components/routes/settings/layout.tsx:71
#: src/components/routes/settings/layout.tsx:82
msgid "Settings"
msgstr "Paramètres"
#: src/components/routes/settings/layout.tsx:33
msgid "Settings saved"
msgstr "Paramètres enregistrés"
#: src/components/login/auth-form.tsx:191
msgid "Sign in"
msgstr "Se connecter"
#: src/components/command-palette.tsx:185
msgid "SMTP settings"
msgstr "Paramètres SMTP"
#: src/components/systems-table/systems-table.tsx:308
msgid "Sort By"
msgstr "Trier par"
#: src/lib/utils.ts:282
msgid "Status"
msgstr "Statut"
#: src/components/routes/system.tsx:473
msgid "Swap space used by the system"
msgstr "Espace Swap utilisé par le système"
#: src/components/routes/system.tsx:472
msgid "Swap Usage"
msgstr "Utilisation du swap"
#. System theme
#: src/components/mode-toggle.tsx:26
#: src/components/systems-table/systems-table.tsx:117
#: src/components/systems-table/systems-table.tsx:125
#: src/components/systems-table/systems-table.tsx:136
#: src/components/systems-table/systems-table.tsx:465
msgid "System"
msgstr "Système"
#: src/components/navbar.tsx:77
msgid "Systems"
msgstr "Systèmes"
#: src/components/routes/settings/config-yaml.tsx:55
msgid "Systems may be managed in a <0>config.yml</0> file inside your data directory."
msgstr "Les systèmes peuvent être gérés dans un fichier <0>config.yml</0> à l'intérieur de votre répertoire de données."
#: src/components/systems-table/systems-table.tsx:296
msgid "Table"
msgstr "Tableau"
#: src/components/routes/system.tsx:484
#: src/lib/utils.ts:314
msgid "Temperature"
msgstr "Température"
#: src/components/routes/system.tsx:485
msgid "Temperatures of system sensors"
msgstr "Températures des capteurs du système"
#: src/components/routes/settings/notifications.tsx:211
msgid "Test <0>URL</0>"
msgstr "Tester <0>URL</0>"
#: src/components/routes/settings/notifications.tsx:182
msgid "Test notification sent"
msgstr "Notification de test envoyée"
#: src/components/add-system.tsx:108
msgid "The agent must be running on the system to connect. Copy the installation command for the agent below."
msgstr "L'agent doit être en cours d'exécution sur le système pour se connecter. Copiez la commande d'installation pour l'agent ci-dessous."
#: src/components/add-system.tsx:99
msgid "The agent must be running on the system to connect. Copy the<0>docker-compose.yml</0> for the agent below."
msgstr "L'agent doit être en cours d'exécution sur le système pour se connecter. Copiez le <0>docker-compose.yml</0> pour l'agent ci-dessous."
#: src/components/login/forgot-pass-form.tsx:98
msgid "Then log into the backend and reset your user account password in the users table."
msgstr "Ensuite, connectez-vous au backend et réinitialisez le mot de passe de votre compte utilisateur dans la table des utilisateurs."
#: src/components/systems-table/systems-table.tsx:573
msgid "This action cannot be undone. This will permanently delete all current records for {name} from the database."
msgstr "Cette action ne peut pas être annulée. Cela supprimera définitivement tous les enregistrements actuels pour {name} de la base de données."
#: src/components/routes/system.tsx:564
msgid "Throughput of {extraFsName}"
msgstr "Débit de {extraFsName}"
#: src/components/routes/system.tsx:432
msgid "Throughput of root filesystem"
msgstr "Débit du système de fichiers racine"
#: src/components/routes/settings/notifications.tsx:106
msgid "To email(s)"
msgstr "Aux email(s)"
#: src/components/routes/system.tsx:359
#: src/components/routes/system.tsx:372
msgid "Toggle grid"
msgstr "Basculer la grille"
#: src/components/mode-toggle.tsx:33
msgid "Toggle theme"
msgstr "Changer le thème"
#: src/lib/utils.ts:317
msgid "Triggers when any sensor exceeds a threshold"
msgstr "Déclenchement lorsque tout capteur dépasse un seuil"
#: src/lib/utils.ts:310
msgid "Triggers when combined up/down exceeds a threshold"
msgstr "Déclenchement lorsque le montant/descendant combinée dépasse un seuil"
#: src/lib/utils.ts:292
msgid "Triggers when CPU usage exceeds a threshold"
msgstr "Déclenchement lorsque l'utilisation du CPU dépasse un seuil"
#: src/lib/utils.ts:298
msgid "Triggers when memory usage exceeds a threshold"
msgstr "Déclenchement lorsque l'utilisation de la mémoire dépasse un seuil"
#: src/lib/utils.ts:285
msgid "Triggers when status switches between up and down"
msgstr "Déclenchement lorsque le statut passe d'opérationnel à indisponible"
#: src/lib/utils.ts:304
msgid "Triggers when usage of any disk exceeds a threshold"
msgstr "Déclenchement lorsque l'utilisation de tout disque dépasse un seuil"
#: src/components/systems-table/systems-table.tsx:269
msgid "Updated in real time. Click on a system to view information."
msgstr "Mis à jour en temps réel. Cliquez sur un système pour voir les informations."
#: src/components/routes/system.tsx:261
msgid "Uptime"
msgstr "Temps de fonctionnement"
#: src/components/charts/area-chart.tsx:73
#: src/components/routes/system.tsx:514
#: src/components/routes/system.tsx:551
msgid "Usage"
msgstr "Utilisation"
#: src/components/routes/system.tsx:424
msgid "Usage of root partition"
msgstr "Utilisation de la partition racine"
#: src/components/charts/area-chart.tsx:73
#: src/components/charts/mem-chart.tsx:65
#: src/components/charts/swap-chart.tsx:56
msgid "Used"
msgstr "Utilisé"
#: src/components/command-palette.tsx:142
#: src/components/navbar.tsx:69
msgid "Users"
msgstr "Utilisateurs"
#: src/components/systems-table/systems-table.tsx:278
msgid "View"
msgstr "Vue"
#: src/components/systems-table/systems-table.tsx:342
msgid "Visible Fields"
msgstr "Colonnes visibles"
#: src/components/routes/system.tsx:662
msgid "Waiting for enough records to display"
msgstr "En attente de suffisamment d'enregistrements à afficher"
#: src/components/routes/settings/general.tsx:48
msgid "Want to help us make our translations even better? Check out <0>Crowdin</0> for more details."
msgstr "Vous voulez nous aider à améliorer nos traductions ? Consultez <0>Crowdin</0> pour plus de détails."
#: src/components/routes/settings/notifications.tsx:124
msgid "Webhook / Push notifications"
msgstr "Notifications Webhook / Push"
#. Disk write
#: src/components/charts/area-chart.tsx:59
#: src/components/charts/area-chart.tsx:69
msgid "Write"
msgstr "Écriture"
#: src/components/routes/settings/layout.tsx:61
msgid "YAML Config"
msgstr "Configuration YAML"
#: src/components/routes/settings/config-yaml.tsx:45
msgid "YAML Configuration"
msgstr "Configuration YAML"
#: src/components/routes/settings/layout.tsx:34
msgid "Your user settings have been updated."
msgstr "Vos paramètres utilisateur ont été mis à jour."

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