Compare commits

...

232 Commits

Author SHA1 Message Date
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
200 changed files with 36390 additions and 15292 deletions

View File

@@ -1,8 +1,19 @@
name: 🐛 Bug report
description: Report a new bug or issue.
title: '[Bug]: '
labels: ['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: |
@@ -43,6 +54,39 @@ body:
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:
@@ -61,7 +105,6 @@ body:
id: install-method
attributes:
label: Installation method
default: 0
options:
- Docker
- Binary

View File

@@ -1,8 +1,19 @@
name: 🚀 Feature request
description: Request a new feature or change.
title: "[Feature]: "
labels: ["enhancement"]
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).
@@ -11,8 +22,55 @@ body:
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

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:
@@ -14,28 +14,48 @@ jobs:
include:
- image: henrygd/beszel
context: ./beszel
dockerfile: ./beszel/dockerfile_Hub
dockerfile: ./beszel/dockerfile_hub
registry: docker.io
username_secret: DOCKERHUB_USERNAME
password_secret: DOCKERHUB_TOKEN
- image: henrygd/beszel-agent
context: ./beszel
dockerfile: ./beszel/dockerfile_Agent
dockerfile: ./beszel/dockerfile_agent
registry: docker.io
username_secret: DOCKERHUB_USERNAME
password_secret: DOCKERHUB_TOKEN
- image: henrygd/beszel-agent-nvidia
context: ./beszel
dockerfile: ./beszel/dockerfile_agent_nvidia
platforms: linux/amd64
registry: docker.io
username_secret: DOCKERHUB_USERNAME
password_secret: DOCKERHUB_TOKEN
- image: ghcr.io/${{ github.repository }}/beszel
context: ./beszel
dockerfile: ./beszel/dockerfile_Hub
dockerfile: ./beszel/dockerfile_hub
registry: ghcr.io
username: ${{ github.actor }}
password_secret: GITHUB_TOKEN
- image: ghcr.io/${{ github.repository }}/beszel-agent
context: ./beszel
dockerfile: ./beszel/dockerfile_Agent
dockerfile: ./beszel/dockerfile_agent
registry: ghcr.io
username: ${{ github.actor }}
password_secret: GITHUB_TOKEN
- image: ghcr.io/${{ github.repository }}/beszel-agent-nvidia
context: ./beszel
dockerfile: ./beszel/dockerfile_agent_nvidia
platforms: linux/amd64
registry: ghcr.io
username: ${{ github.actor }}
password_secret: GITHUB_TOKEN
permissions:
contents: read
packages: write
@@ -65,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}}
@@ -84,9 +105,9 @@ jobs:
- 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
platforms: ${{ matrix.platforms || 'linux/amd64,linux/arm64,linux/arm/v7' }}
push: ${{ github.ref_type == 'tag' }}
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
@@ -29,7 +29,17 @@ jobs:
- 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 ./beszel/internal/agent/lhm/beszel_lhm.csproj
shell: bash
- name: GoReleaser beszel
uses: goreleaser/goreleaser-action@v6
@@ -39,4 +49,5 @@ jobs:
version: latest
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.TOKEN }}
GITHUB_TOKEN: ${{ secrets.TOKEN || secrets.GITHUB_TOKEN }}
WINGET_TOKEN: ${{ secrets.WINGET_TOKEN }}

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: Analysis
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.24.x
cached: false
- name: Get official govulncheck
run: go install golang.org/x/vuln/cmd/govulncheck@latest
shell: bash
- name: Run govulncheck
run: govulncheck -C ./beszel -show verbose ./...
shell: bash

3
.gitignore vendored
View File

@@ -17,3 +17,6 @@ beszel/build
beszel/site/src/locales/**/*.ts
*.bak
__debug_*
beszel/internal/agent/lhm/obj
beszel/internal/agent/lhm/bin
dockerfile_agent_dev

View File

@@ -29,6 +29,7 @@ builds:
- linux
- darwin
- freebsd
- openbsd
- windows
goarch:
- amd64
@@ -36,9 +37,13 @@ builds:
- arm
- mips64
- riscv64
- mipsle
- ppc64le
ignore:
- goos: freebsd
goarch: arm
- goos: openbsd
goarch: arm
- goos: windows
goarch: arm
- goos: darwin
@@ -47,8 +52,8 @@ builds:
goarch: riscv64
archives:
- id: beszel
format: tar.gz
- id: beszel-agent
formats: [tar.gz]
builds:
- beszel-agent
name_template: >-
@@ -57,10 +62,10 @@ archives:
{{- .Arch }}
format_overrides:
- goos: windows
format: zip
formats: [zip]
- id: beszel-agent
format: tar.gz
- id: beszel
formats: [tar.gz]
builds:
- beszel
name_template: >-
@@ -84,9 +89,6 @@ nfpms:
- beszel-agent
formats:
- deb
# don't think this is needed with CGO_ENABLED=0
# dependencies:
# - libc6
contents:
- src: ../supplemental/debian/beszel-agent.service
dst: lib/systemd/system/beszel-agent.service
@@ -111,6 +113,104 @@ nfpms:
# 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
# # 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: auto
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: auto
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
@@ -119,5 +219,5 @@ changelog:
sort: asc
filters:
exclude:
- '^docs:'
- '^test:'
- "^docs:"
- "^test:"

View File

@@ -4,6 +4,9 @@ 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 clean lint dev-server dev-agent dev-hub dev generate-locales
.DEFAULT_GOAL := build
@@ -30,11 +33,25 @@ build-web-ui:
npm run --prefix ./site build; \
fi
build-agent: tidy
GOOS=$(OS) GOARCH=$(ARCH) go build -o ./build/beszel-agent_$(OS)_$(ARCH) -ldflags "-w -s" beszel/cmd/agent
# 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 ./internal/agent/lhm/bin; \
dotnet build -c Release ./internal/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" beszel/cmd/agent
build-hub: tidy $(if $(filter false,$(SKIP_WEB)),build-web-ui)
GOOS=$(OS) GOARCH=$(ARCH) go build -o ./build/beszel_$(OS)_$(ARCH) -ldflags "-w -s" beszel/cmd/hub
GOOS=$(OS) GOARCH=$(ARCH) go build -o ./build/beszel_$(OS)_$(ARCH)$(EXE_EXT) -ldflags "-w -s" beszel/cmd/hub
build: build-agent build-hub
@@ -47,18 +64,18 @@ generate-locales:
dev-server: generate-locales
cd ./site
@if command -v bun >/dev/null 2>&1; then \
cd ./site && bun run dev; \
cd ./site && bun run dev --host 0.0.0.0; \
else \
cd ./site && npm run dev; \
cd ./site && npm run dev --host 0.0.0.0; \
fi
dev-hub: export ENV=dev
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"; \
find ./cmd/hub/*.go ./internal/{alerts,hub,records,users}/*.go | entr -r -s "cd ./cmd/hub && go run . serve --http 0.0.0.0:8090"; \
else \
cd ./cmd/hub && go run . serve; \
cd ./cmd/hub && go run . serve --http 0.0.0.0:8090; \
fi
dev-agent:
@@ -67,6 +84,15 @@ dev-agent:
else \
go run beszel/cmd/agent; \
fi
build-dotnet:
@if command -v dotnet >/dev/null 2>&1; then \
rm -rf ./internal/agent/lhm/bin; \
dotnet build -c Release ./internal/agent/lhm/beszel_lhm.csproj; \
else \
echo "dotnet not found"; \
fi
# KEY="..." make -j dev
dev: dev-server dev-hub dev-agent

View File

@@ -3,6 +3,7 @@ package main
import (
"beszel"
"beszel/internal/agent"
"beszel/internal/agent/health"
"flag"
"fmt"
"log"
@@ -18,39 +19,51 @@ type cmdOptions struct {
listen string // listen is the address or port to listen on.
}
// parseFlags parses the command line flags and populates the config struct.
func (opts *cmdOptions) parseFlags() {
// parse parses the command line flags and populates the config struct.
// It returns true if a subcommand was handled and the program should exit.
func (opts *cmdOptions) parse() bool {
flag.StringVar(&opts.key, "key", "", "Public key(s) for SSH authentication")
flag.StringVar(&opts.listen, "listen", "", "Address or port to listen on")
flag.Usage = func() {
fmt.Printf("Usage: %s [options] [subcommand]\n", os.Args[0])
fmt.Println("\nOptions:")
builder := strings.Builder{}
builder.WriteString("Usage: ")
builder.WriteString(os.Args[0])
builder.WriteString(" [command] [flags]\n")
builder.WriteString("\nCommands:\n")
builder.WriteString(" health Check if the agent is running\n")
builder.WriteString(" help Display this help message\n")
builder.WriteString(" update Update to the latest version\n")
builder.WriteString("\nFlags:\n")
fmt.Print(builder.String())
flag.PrintDefaults()
fmt.Println("\nSubcommands:")
fmt.Println(" version Display the version")
fmt.Println(" help Display this help message")
fmt.Println(" update Update the agent to the latest version")
}
}
// handleSubcommand handles subcommands such as version, help, and update.
// It returns true if a subcommand was handled, false otherwise.
func handleSubcommand() bool {
if len(os.Args) <= 1 {
return false
subcommand := ""
if len(os.Args) > 1 {
subcommand = os.Args[1]
}
switch os.Args[1] {
case "version", "-v":
switch subcommand {
case "-v", "version":
fmt.Println(beszel.AppName+"-agent", beszel.Version)
os.Exit(0)
return true
case "help":
flag.Usage()
os.Exit(0)
return true
case "update":
agent.Update()
os.Exit(0)
return true
case "health":
err := health.Check()
if err != nil {
log.Fatal(err)
}
fmt.Print("ok")
return true
}
flag.Parse()
return false
}
@@ -79,46 +92,18 @@ func (opts *cmdOptions) loadPublicKeys() ([]ssh.PublicKey, error) {
return agent.ParseKeys(string(pubKey))
}
// getAddress gets the address to listen on from the command line flag, environment variable, or default value.
func (opts *cmdOptions) getAddress() string {
// Try command line flag first
if opts.listen != "" {
return opts.listen
}
// Try environment variables
if addr, ok := agent.GetEnv("LISTEN"); ok && addr != "" {
return addr
}
// Legacy PORT environment variable support
if port, ok := agent.GetEnv("PORT"); ok && port != "" {
return port
}
return ":45876"
}
// getNetwork returns the network type to use for the server.
func (opts *cmdOptions) getNetwork() string {
if network, _ := agent.GetEnv("NETWORK"); network != "" {
return network
}
if strings.HasPrefix(opts.listen, "/") {
return "unix"
}
return "tcp"
return agent.GetAddress(opts.listen)
}
func main() {
var opts cmdOptions
opts.parseFlags()
subcommandHandled := opts.parse()
if handleSubcommand() {
if subcommandHandled {
return
}
flag.Parse()
opts.listen = opts.getAddress()
var serverConfig agent.ServerOptions
var err error
serverConfig.Keys, err = opts.loadPublicKeys()
@@ -126,11 +111,16 @@ func main() {
log.Fatal("Failed to load public keys:", err)
}
serverConfig.Addr = opts.listen
serverConfig.Network = opts.getNetwork()
addr := opts.getAddress()
serverConfig.Addr = addr
serverConfig.Network = agent.GetNetwork(addr)
agent := agent.NewAgent()
if err := agent.StartServer(serverConfig); err != nil {
log.Fatal("Failed to start server:", err)
a, err := agent.NewAgent()
if err != nil {
log.Fatal("Failed to create agent: ", err)
}
if err := a.Start(serverConfig); err != nil {
log.Fatal("Failed to start server: ", err)
}
}

View File

@@ -1,6 +1,7 @@
package main
import (
"beszel/internal/agent"
"crypto/ed25519"
"flag"
"os"
@@ -29,7 +30,7 @@ func TestGetAddress(t *testing.T) {
opts: cmdOptions{
listen: "8080",
},
expected: "8080",
expected: ":8080",
},
{
name: "use unix socket from flag",
@@ -52,7 +53,7 @@ func TestGetAddress(t *testing.T) {
envVars: map[string]string{
"PORT": "7070",
},
expected: "7070",
expected: ":7070",
},
{
name: "use unix socket from env var",
@@ -233,7 +234,7 @@ func TestGetNetwork(t *testing.T) {
for k, v := range tt.envVars {
t.Setenv(k, v)
}
network := tt.opts.getNetwork()
network := agent.GetNetwork(tt.opts.listen)
assert.Equal(t, tt.expected, network)
})
}
@@ -293,7 +294,7 @@ func TestParseFlags(t *testing.T) {
os.Args = tt.args
var opts cmdOptions
opts.parseFlags()
opts.parse()
flag.Parse()
assert.Equal(t, tt.expected, opts)

View File

@@ -4,7 +4,11 @@ import (
"beszel"
"beszel/internal/hub"
_ "beszel/migrations"
"fmt"
"log"
"net/http"
"os"
"time"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/plugins/migratecmd"
@@ -12,10 +16,21 @@ import (
)
func main() {
// handle health check first to prevent unneeded execution
if len(os.Args) > 3 && os.Args[1] == "health" {
url := os.Args[3]
if err := checkHealth(url); err != nil {
log.Fatal(err)
}
fmt.Print("ok")
return
}
baseApp := getBaseApp()
h := hub.NewHub(baseApp)
h.BootstrapHub()
h.Start()
if err := h.StartHub(); err != nil {
log.Fatal(err)
}
}
// getBaseApp creates a new PocketBase app with the default config
@@ -35,6 +50,8 @@ func getBaseApp() *pocketbase.PocketBase {
Short: "Update " + beszel.AppName + " to the latest version",
Run: hub.Update,
})
// add health command
baseApp.RootCmd.AddCommand(newHealthCmd())
// enable auto creation of migration files when making collection changes in the Admin UI
migratecmd.MustRegister(baseApp, baseApp.RootCmd, migratecmd.Config{
@@ -44,3 +61,39 @@ func getBaseApp() *pocketbase.PocketBase {
return baseApp
}
func newHealthCmd() *cobra.Command {
var baseURL string
healthCmd := &cobra.Command{
Use: "health",
Short: "Check health of running hub",
Run: func(cmd *cobra.Command, args []string) {
if err := checkHealth(baseURL); err != nil {
log.Fatal(err)
}
os.Exit(0)
},
}
healthCmd.Flags().StringVar(&baseURL, "url", "", "base URL")
healthCmd.MarkFlagRequired("url")
return healthCmd
}
// checkHealth checks the health of the hub.
func checkHealth(baseURL string) error {
client := &http.Client{
Timeout: time.Second * 3,
}
healthURL := baseURL + "/api/health"
resp, err := client.Get(healthURL)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("%s returned status %d", healthURL, resp.StatusCode)
}
return nil
}

26
beszel/dockerfile_agent Normal file
View File

@@ -0,0 +1,26 @@
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
RUN rm -rf /tmp/*
# --------------------------
# Final image: default scratch-based agent
# --------------------------
FROM scratch
COPY --from=builder /agent /agent
# this is so we don't need to create the /tmp directory in the scratch container
COPY --from=builder /tmp /tmp
ENTRYPOINT ["/agent"]

View File

@@ -12,9 +12,10 @@ COPY internal ./internal
ARG TARGETOS TARGETARCH
RUN CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /agent ./cmd/agent
# ? -------------------------
FROM scratch
# --------------------------
# Final image: GPU-enabled agent with nvidia-smi
# --------------------------
FROM nvidia/cuda:12.9.1-base-ubuntu22.04
COPY --from=builder /agent /agent
ENTRYPOINT ["/agent"]
ENTRYPOINT ["/agent"]

View File

@@ -1,92 +1,69 @@
module beszel
go 1.24.0
go 1.24.4
// lock shoutrrr to specific version to allow review before updating
replace github.com/nicholas-fedor/shoutrrr => github.com/nicholas-fedor/shoutrrr v0.8.8
require (
github.com/blang/semver v3.5.1+incompatible
github.com/containrrr/shoutrrr v0.8.0
github.com/distatus/battery v0.11.0
github.com/fxamacker/cbor/v2 v2.9.0
github.com/gliderlabs/ssh v0.3.8
github.com/goccy/go-json v0.10.5
github.com/google/uuid v1.6.0
github.com/lxzan/gws v1.8.9
github.com/nicholas-fedor/shoutrrr v0.8.17
github.com/pocketbase/dbx v1.11.0
github.com/pocketbase/pocketbase v0.25.9
github.com/rhysd/go-github-selfupdate v1.2.3
github.com/shirou/gopsutil/v4 v4.25.2
github.com/spf13/cast v1.7.1
github.com/pocketbase/pocketbase v0.29.3
github.com/shirou/gopsutil/v4 v4.25.7
github.com/spf13/cast v1.9.2
github.com/spf13/cobra v1.9.1
github.com/stretchr/testify v1.10.0
golang.org/x/crypto v0.35.0
golang.org/x/exp v0.0.0-20250228200357-dead58393ab7
github.com/stretchr/testify v1.11.0
golang.org/x/crypto v0.41.0
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b
gopkg.in/yaml.v3 v3.0.1
)
require (
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.36.3 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 // indirect
github.com/aws/aws-sdk-go-v2/config v1.29.8 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.17.61 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 // indirect
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.64 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.6.2 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.78.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.25.0 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.29.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.33.16 // indirect
github.com/aws/smithy-go v1.22.3 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/disintegration/imaging v1.6.2 // indirect
github.com/dolthub/maphash v0.1.0 // 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.2 // indirect
github.com/ebitengine/purego v0.8.4 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/ganigeorgiev/fexpr v0.4.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
github.com/ganigeorgiev/fexpr v0.5.0 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect
github.com/golang-jwt/jwt/v5 v5.2.1 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/google/go-github/v30 v30.1.0 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/gax-go/v2 v2.14.1 // indirect
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf // indirect
github.com/go-sql-driver/mysql v1.9.1 // indirect
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lufia/plan9stats v0.0.0-20250224150550-a661cff19cfb // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/lufia/plan9stats v0.0.0-20250821153705-5981dea3221d // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // 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.6 // indirect
github.com/tcnksm/go-gitconfig v0.1.2 // indirect
github.com/tklauser/go-sysconf v0.3.14 // indirect
github.com/tklauser/numcpus v0.9.0 // indirect
github.com/ulikunitz/xz v0.5.12 // indirect
github.com/spf13/pflag v1.0.7 // indirect
github.com/tklauser/go-sysconf v0.3.15 // indirect
github.com/tklauser/numcpus v0.10.0 // indirect
github.com/x448/float16 v0.8.4 // 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.24.0 // indirect
golang.org/x/net v0.35.0 // indirect
golang.org/x/oauth2 v0.27.0 // indirect
golang.org/x/sync v0.11.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
google.golang.org/api v0.223.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250227231956-55c901821b1e // indirect
google.golang.org/grpc v1.70.0 // indirect
google.golang.org/protobuf v1.36.5 // indirect
modernc.org/libc v1.61.13 // indirect
golang.org/x/image v0.30.0 // indirect
golang.org/x/net v0.43.0 // indirect
golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/text v0.28.0 // indirect
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect
howett.net/plist v1.0.1 // indirect
modernc.org/libc v1.66.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.8.2 // indirect
modernc.org/sqlite v1.35.0 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.38.2 // indirect
)

View File

@@ -1,393 +1,192 @@
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.15.0 h1:Ly0u4aA5vG/fsSsxu98qCQBemXtAtJf+95z9HK+cxps=
cloud.google.com/go/auth v0.15.0/go.mod h1:WJDGqZ1o9E9wKIL+IwStfyn/+s59zl4Bi+1KQNVXLZ8=
cloud.google.com/go/auth/oauth2adapt v0.2.7 h1:/Lc7xODdqcEw8IrZ9SvwnlLX6j9FHQM74z6cBk9Rw6M=
cloud.google.com/go/auth/oauth2adapt v0.2.7/go.mod h1:NTbTTzfvPl1Y3V1nPpOgl2w6d/FjO7NNUQaWSox6ZMc=
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/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
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.36.3 h1:mJoei2CxPutQVxaATCzDUjcZEjVRdpsiiXi2o38yqWM=
github.com/aws/aws-sdk-go-v2 v1.36.3/go.mod h1:LLXuLpgzEbD766Z5ECcRmi8AzSwfZItDtmABVkRLGzg=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 h1:zAybnyUQXIZ5mok5Jqwlf58/TFE7uvd3IAsa1aF9cXs=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10/go.mod h1:qqvMj6gHLR/EXWZw4ZbqlPbQUyenf4h82UQUlKc+l14=
github.com/aws/aws-sdk-go-v2/config v1.29.8 h1:RpwAfYcV2lr/yRc4lWhUM9JRPQqKgKWmou3LV7UfWP4=
github.com/aws/aws-sdk-go-v2/config v1.29.8/go.mod h1:t+G7Fq1OcO8cXTPPXzxQSnj/5Xzdc9jAAD3Xrn9/Mgo=
github.com/aws/aws-sdk-go-v2/credentials v1.17.61 h1:Hd/uX6Wo2iUW1JWII+rmyCD7MMhOe7ALwQXN6sKDd1o=
github.com/aws/aws-sdk-go-v2/credentials v1.17.61/go.mod h1:L7vaLkwHY1qgW0gG1zG0z/X0sQ5tpIY5iI13+j3qI80=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 h1:x793wxmUWVDhshP8WW2mlnXuFrO4cOd3HLBroh1paFw=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30/go.mod h1:Jpne2tDnYiFascUEs2AWHJL9Yp7A5ZVy3TNyxaAjD6M=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.64 h1:RTko0AQ0i1vWXDM97DkuW6zskgOxFxm4RqC0kmBJFkE=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.64/go.mod h1:ty968MpOa5CoQ/ALWNB8Gmfoehof2nRHDR/DZDPfimE=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 h1:ZK5jHhnrioRkUNOc+hOgQKlUL5JeC3S6JgLxtQ+Rm0Q=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34/go.mod h1:p4VfIceZokChbA9FzMbRGz5OV+lekcVtHlPKEO0gSZY=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 h1:SZwFm17ZUNNg5Np0ioo/gq8Mn6u9w19Mri8DnJ15Jf0=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34/go.mod h1:dFZsC0BLo346mvKQLWmoJxT+Sjp+qcVR1tRVHQGOH9Q=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34 h1:ZNTqv4nIdE/DiBfUUfXcLZ/Spcuz+RjeziUtNJackkM=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34/go.mod h1:zf7Vcd1ViW7cPqYWEHLHJkS50X0JS2IKz9Cgaj6ugrs=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 h1:eAh2A4b5IzM/lum78bZ590jy36+d/aFLgKF/4Vd1xPE=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3/go.mod h1:0yKJC/kb8sAnmlYa6Zs3QVYqaC8ug2AbnNChv5Ox3uA=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.6.2 h1:t/gZFyrijKuSU0elA5kRngP/oU3mc0I+Dvp8HwRE4c0=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.6.2/go.mod h1:iu6FSzgt+M2/x3Dk8zhycdIcHjEFb36IS8HVUVFoMg0=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 h1:dM9/92u2F1JbDaGooxTq18wmmFzbJRfXfVfy96/1CXM=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15/go.mod h1:SwFBy2vjtA0vZbjjaFtfN045boopadnoVPhu4Fv66vY=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15 h1:moLQUoVq91LiqT1nbvzDukyqAlCv89ZmwaHw/ZFlFZg=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15/go.mod h1:ZH34PJUc8ApjBIfgQCFvkWcUDBtl/WTD+uiYHjd8igA=
github.com/aws/aws-sdk-go-v2/service/s3 v1.78.0 h1:EBm8lXevBWe+kK9VOU/IBeOI189WPRwPUc3LvJK9GOs=
github.com/aws/aws-sdk-go-v2/service/s3 v1.78.0/go.mod h1:4qzsZSzB/KiX2EzDjs9D7A8rI/WGJxZceVJIHqtJjIU=
github.com/aws/aws-sdk-go-v2/service/sso v1.25.0 h1:2U9sF8nKy7UgyEeLiZTRg6ShBS22z8UnYpV6aRFL0is=
github.com/aws/aws-sdk-go-v2/service/sso v1.25.0/go.mod h1:qs4a9T5EMLl/Cajiw2TcbNt2UNo/Hqlyp+GiuG4CFDI=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.29.0 h1:wjAdc85cXdQR5uLx5FwWvGIHm4OPJhTyzUHU8craXtE=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.29.0/go.mod h1:MlYRNmYu/fGPoxBQVvBYr9nyr948aY/WLUvwBMBJubs=
github.com/aws/aws-sdk-go-v2/service/sts v1.33.16 h1:BHEK2Q/7CMRMCb3nySi/w8UbIcPhKvYP5s1xf8/izn0=
github.com/aws/aws-sdk-go-v2/service/sts v1.33.16/go.mod h1:cQnB8CUnxbMU82JvlqjKR2HBOm3fe9pWorWBza6MBJ4=
github.com/aws/smithy-go v1.22.3 h1:Z//5NuZCSW6R4PhQ93hShNbyBbn8BWCmCVCt+Q8Io5k=
github.com/aws/smithy-go v1.22.3/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI=
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.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
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/distatus/battery v0.11.0 h1:KJk89gz90Iq/wJtbjjM9yUzBXV+ASV/EG2WOOL7N8lc=
github.com/distatus/battery v0.11.0/go.mod h1:KmVkE8A8hpIX4T78QRdMktYpEp35QfOL8A8dwZBxq2k=
github.com/dolthub/maphash v0.1.0 h1:bsQ7JsF4FkkWyrP3oCnFJgrCUAFbFf3kOl4L/QxPDyQ=
github.com/dolthub/maphash v0.1.0/go.mod h1:gkg4Ch4CdCDu5h6PMriVLawB7koZ+5ijb9puGMV50a4=
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.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I=
github.com/ebitengine/purego v0.8.2/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/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw=
github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
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.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/ganigeorgiev/fexpr v0.4.1 h1:hpUgbUEEWIZhSDBtf4M9aUNfQQ0BZkGRaMePy7Gcx5k=
github.com/ganigeorgiev/fexpr v0.4.1/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
github.com/ganigeorgiev/fexpr v0.5.0 h1:XA9JxtTE/Xm+g/JFI6RfZEHSiQlk+1glLvRK1Lpv/Tk=
github.com/ganigeorgiev/fexpr v0.5.0/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.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
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/go-sql-driver/mysql v1.9.1 h1:FrjNGn/BsJQjVRuSa8CBrM5BWA9BWoXXat3KrtSb/iI=
github.com/go-sql-driver/mysql v1.9.1/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
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.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
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.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8=
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
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.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q=
github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA=
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/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/jarcoal/httpmock v1.4.0 h1:BvhqnH0JAYbNudL2GMJKgOHe2CtKlzJ/5rWKyp+hc2k=
github.com/jarcoal/httpmock v1.4.0/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
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-20250224150550-a661cff19cfb h1:YU0XAr3+rMpM8fP80KEesn32Qa9qkbquokvuwzWyYuA=
github.com/lufia/plan9stats v0.0.0-20250224150550-a661cff19cfb/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/lufia/plan9stats v0.0.0-20250821153705-5981dea3221d h1:vFzYZc8yji+9DmNRhpEbs8VBK4CgV/DPfGzeVJSSp/8=
github.com/lufia/plan9stats v0.0.0-20250821153705-5981dea3221d/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/lxzan/gws v1.8.9 h1:VU3SGUeWlQrEwfUSfokcZep8mdg/BrUF+y73YYshdBM=
github.com/lxzan/gws v1.8.9/go.mod h1:d9yHaR1eDTBHagQC6KY7ycUOaz5KWeqQtP3xu7aMK8Y=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
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/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/nicholas-fedor/shoutrrr v0.8.8 h1:F/oyoatWK5cbHPPgkjRZrA0262TP7KWuUQz9KskRtR8=
github.com/nicholas-fedor/shoutrrr v0.8.8/go.mod h1:T30Y+eoZFEjDk4HtOItcHQioZSOe3Z6a6aNfSz6jc5c=
github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus=
github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8=
github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y=
github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0=
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.25.9 h1:/PSJcy39vEGv4lsBG4HV0ZFLcFsTdK9oMkJbxVlVJSs=
github.com/pocketbase/pocketbase v0.25.9/go.mod h1:gOnPr+g/GS+iqKh5XYXycdRWVGhiHY4c1H4TGjU9DDw=
github.com/pocketbase/pocketbase v0.29.3 h1:Mj8o5awsbVJIdIoTuQNhfC2oL/c4aImQ3RyfFZlzFVg=
github.com/pocketbase/pocketbase v0.29.3/go.mod h1:oGpT67LObxCFK4V2fSL7J9YnPbBnnshOpJ5v3zcneww=
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.25.2 h1:NMscG3l2CqtWFS86kj3vP7soOczqrQYIEhO/pMvvQkk=
github.com/shirou/gopsutil/v4 v4.25.2/go.mod h1:34gBYJzyqCDT11b6bMHP0XCvWeU3J61XRT7a2EmCRTA=
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/shirou/gopsutil/v4 v4.25.7 h1:bNb2JuqKuAu3tRlPv5piSmBZyMfecwQ+t/ILq+1JqVM=
github.com/shirou/gopsutil/v4 v4.25.7/go.mod h1:XV/egmwJtd3ZQjBpJVY5kndsiOO4IRqy9TQnmm6VP7U=
github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE=
github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M=
github.com/spf13/pflag v1.0.7/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.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.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tcnksm/go-gitconfig v0.1.2 h1:iiDhRitByXAEyjgBqsKi9QU4o2TNtv9kPP3RgPgXBPw=
github.com/tcnksm/go-gitconfig v0.1.2/go.mod h1:/8EhP4H7oJZdIPyT+/UIsG87kTzrzM4UsLGSItWYCpE=
github.com/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/stretchr/testify v1.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8=
github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
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/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0 h1:rgMkmiGfix9vFJDcDi1PK8WEQP4FLQwLDfhp5ZLpFeE=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0/go.mod h1:ijPqXp5P6IRRByFVVg9DY8P5HkxkHE5ARIa+86aXPf4=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 h1:CV7UdSGJt/Ao6Gp4CXckLxVRRsRgDHoI8XjbL3PDl8s=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0/go.mod h1:FRmFuRJfag1IZ2dPkHnEoSFVgTVPUd2qf5Vi69hLb8I=
go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY=
go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI=
go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ=
go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE=
go.opentelemetry.io/otel/sdk v1.32.0 h1:RNxepc9vK59A8XsgZQouW8ue8Gkb4jpWtJm9ge5lEG4=
go.opentelemetry.io/otel/sdk v1.32.0/go.mod h1:LqgegDBjKMmb2GC6/PrTnteJG39I8/vJCAP9LlJXEjU=
go.opentelemetry.io/otel/sdk/metric v1.32.0 h1:rZvFnvmvawYb0alrYkjraqJq0Z4ZUJAiyYCU9snn1CU=
go.opentelemetry.io/otel/sdk/metric v1.32.0/go.mod h1:PWeZlq0zt9YkYAp3gjKZ0eicRYvOh1Gd+X99x6GHpCQ=
go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k=
go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE=
gocloud.dev v0.40.0 h1:f8LgP+4WDqOG/RXoUcyLpeIAGOcAbZrZbDQCUee10ng=
gocloud.dev v0.40.0/go.mod h1:drz+VyYNBvrMTW0KZiBAYEdl8lbNZx+OQ7oQvdrFmSQ=
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
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.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20250228200357-dead58393ab7 h1:aWwlzYV971S4BXRS9AmqwDLAD85ouC6X+pocatKY58c=
golang.org/x/exp v0.0.0-20250228200357-dead58393ab7/go.mod h1:BHOTPb3L19zxehTsLoJXVaTktb06DFgmdW6Wb9s8jqk=
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b h1:DXr+pvt3nC887026GRP39Ej11UATqWDmWuS99x26cD0=
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
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.23.0 h1:Zb7khfcRGKk+kqfxFaP5tZqCnDZMjC5VtUBs87Hr6QM=
golang.org/x/mod v0.23.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/image v0.30.0 h1:jD5RhkmVAnjqaCUXfbGBrn3lpxbknfN9w2UhHHU+5B4=
golang.org/x/image v0.30.0/go.mod h1:SAEUTxCCMWSrJcCy/4HwavEsfZZJlYxeHLc6tTiAe/c=
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
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.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M=
golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
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.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
golang.org/x/sync v0.11.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/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/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-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
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.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.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.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU=
golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
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.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4=
golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
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.30.0 h1:BgcpHewrV5AUp2G9MebG4XPFI1E2W41zU1SaqVA9vJY=
golang.org/x/tools v0.30.0/go.mod h1:c347cR/OJfw5TI+GfX7RUPNMdDRRbjvYTS0jPyvsVtY=
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.223.0 h1:JUTaWEriXmEy5AhvdMgksGGPEFsYfUKaPEYXd4c3Wvc=
google.golang.org/api v0.223.0/go.mod h1:C+RS7Z+dDwds2b+zoAk5hN/eSfsiCn0UDrYof/M4d2M=
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=
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
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-20241209162323-e6fa225c2576 h1:CkkIfIt50+lT6NHAVoRYEyAvQGFM7xEwXUUywFvEb3Q=
google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576/go.mod h1:1R3kvZ1dtP3+4p4d3G8uJ8rFk/fWlScl38vanWACI08=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250227231956-55c901821b1e h1:YA5lmSs3zc/5w+xsRcHqpETkaYyK63ivEPzNTcUUlSA=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250227231956-55c901821b1e/go.mod h1:LuRYeWDFV6WOn90g357N17oMCaxpgCnbi/44qJvDn2I=
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.70.0 h1:pWFv03aZoHzlRKHWicjsZytKAiYCtNS0dHbXnIdq7jQ=
google.golang.org/grpc v1.70.0/go.mod h1:ofIJqVKDXx/JiXrwr2IG4/zwdH9txy3IlF40RmcJSQw=
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.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
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.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
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.24.4 h1:TFkx1s6dCkQpd6dKurBNmpo+G8Zl4Sq/ztJ+2+DEsh0=
modernc.org/cc/v4 v4.24.4/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.23.16 h1:Z2N+kk38b7SfySC1ZkpGLN2vthNJP1+ZzGZIlH7uBxo=
modernc.org/ccgo/v4 v4.23.16/go.mod h1:nNma8goMTY7aQZQNTyN9AIoJfxav4nvTnvKThAeMDdo=
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.6.3 h1:aJVhcqAte49LF+mGveZ5KPlsp4tdGdAOT4sipJXADjw=
modernc.org/gc/v2 v2.6.3/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/libc v1.61.13 h1:3LRd6ZO1ezsFiX1y+bHd1ipyEHIJKvuprv0sLTBwLW8=
modernc.org/libc v1.61.13/go.mod h1:8F/uJWL/3nNil0Lgt1Dpz+GgkApWh04N3el3hxJcA6E=
howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM=
howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM=
modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM=
modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.8.2 h1:cL9L4bcoAObu4NkxOlKWBWtNHIsnnACGF/TbqQ6sbcI=
modernc.org/memory v1.8.2/go.mod h1:ZbjSvMO5NQ1A2i3bWeDiVMxIorXwdClKE/0SZ+BMotU=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.35.0 h1:yQps4fegMnZFdphtzlfQTCNBWtS0CZv48pRpW3RFHRw=
modernc.org/sqlite v1.35.0/go.mod h1:9cr2sicr7jIaWTBKQmAxQLfBv9LL0su4ZTEV+utt3ic=
modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek=
modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

View File

@@ -4,40 +4,58 @@ package agent
import (
"beszel"
"beszel/internal/entities/system"
"context"
"crypto/sha256"
"encoding/hex"
"log/slog"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/shirou/gopsutil/v4/common"
"github.com/gliderlabs/ssh"
"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
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
cache *SessionCache // Cache for system stats based on primary session ID
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
hasBattery bool // true if agent has access to battery stats
}
func NewAgent() *Agent {
agent := &Agent{
// 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.memCalc, _ = GetEnv("MEM_CALC")
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) {
@@ -53,30 +71,19 @@ func NewAgent() *Agent {
slog.Debug(beszel.Version)
// Set sensors context (allows overriding sys location for sensors)
if sysSensors, exists := GetEnv("SYS_SENSORS"); exists {
slog.Info("SYS_SENSORS", "path", sysSensors)
agent.sensorsContext = context.WithValue(agent.sensorsContext,
common.EnvKey, common.EnvMap{common.HostSysEnvKey: sysSensors},
)
} else {
agent.sensorsContext = context.Background()
}
// Set sensors whitelist
if sensors, exists := GetEnv("SENSORS"); exists {
agent.sensorsWhitelist = make(map[string]struct{})
for sensor := range strings.SplitSeq(sensors, ",") {
if sensor != "" {
agent.sensorsWhitelist[sensor] = struct{}{}
}
}
}
// initialize system info / docker manager
// 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
@@ -91,7 +98,7 @@ func NewAgent() *Agent {
slog.Debug("Stats", "data", agent.gatherStats(""))
}
return agent
return agent, nil
}
// GetEnv retrieves an environment variable with a "BESZEL_AGENT_" prefix, or falls back to the unprefixed key.
@@ -107,33 +114,70 @@ func (a *Agent) gatherStats(sessionID string) *system.CombinedData {
a.Lock()
defer a.Unlock()
cachedData, ok := a.cache.Get(sessionID)
if ok {
slog.Debug("Cached stats", "session", sessionID)
return cachedData
data, isCached := a.cache.Get(sessionID)
if isCached {
slog.Debug("Cached data", "session", sessionID)
return data
}
*cachedData = system.CombinedData{
*data = system.CombinedData{
Stats: a.getSystemStats(),
Info: a.systemInfo,
}
slog.Debug("System stats", "data", cachedData)
slog.Debug("System data", "data", data)
if containerStats, err := a.dockerManager.getDockerStats(); err == nil {
cachedData.Containers = containerStats
slog.Debug("Docker stats", "data", cachedData.Containers)
} else {
slog.Debug("Docker stats", "err", err)
}
cachedData.Stats.ExtraFs = make(map[string]*system.FsStats)
for name, stats := range a.fsStats {
if !stats.Root && stats.DiskTotal > 0 {
cachedData.Stats.ExtraFs[name] = stats
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)
}
}
slog.Debug("Extra filesystems", "data", cachedData.Stats.ExtraFs)
a.cache.Set(sessionID, cachedData)
return cachedData
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
}

View File

@@ -1,3 +1,6 @@
//go:build testing
// +build testing
package agent
import (

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
}

View File

@@ -0,0 +1,24 @@
package agent
import "github.com/distatus/battery"
// getBatteryStats returns the current battery percent and charge state
func getBatteryStats() (batteryPercent uint8, batteryState uint8, err error) {
batteries, err := battery.GetAll()
if err != nil {
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,265 @@
package agent
import (
"beszel"
"beszel/internal/common"
"crypto/tls"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"net/url"
"os"
"path"
"strings"
"time"
"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 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)
}

View File

@@ -0,0 +1,538 @@
//go:build testing
// +build testing
package agent
import (
"beszel"
"beszel/internal/common"
"crypto/ed25519"
"net/url"
"os"
"strings"
"testing"
"time"
"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")
})
}

View File

@@ -0,0 +1,220 @@
package agent
import (
"beszel/internal/agent/health"
"errors"
"log/slog"
"os"
"os/signal"
"syscall"
"time"
)
// 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")
}

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
}

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

@@ -5,6 +5,7 @@ import (
"log/slog"
"os"
"path/filepath"
"runtime"
"strings"
"time"
@@ -36,7 +37,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 {

View File

@@ -2,6 +2,7 @@ package agent
import (
"beszel/internal/entities/container"
"bytes"
"context"
"encoding/json"
"fmt"
@@ -26,6 +27,10 @@ type dockerManager struct {
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
@@ -62,13 +67,14 @@ func (dm *dockerManager) getDockerStats() ([]*container.Stats, error) {
if err != nil {
return nil, err
}
defer resp.Body.Close()
dm.apiContainerList = dm.apiContainerList[:0]
if err := json.NewDecoder(resp.Body).Decode(&dm.apiContainerList); err != nil {
if err := dm.decode(resp, &dm.apiContainerList); err != nil {
return nil, err
}
dm.isWindows = strings.Contains(resp.Header.Get("Server"), "windows")
containersLength := len(dm.apiContainerList)
// store valid ids to clean up old container ids from map
@@ -109,7 +115,8 @@ func (dm *dockerManager) getDockerStats() ([]*container.Stats, error) {
// retry failed containers separately so we can run them in parallel (docker 24 bug)
if len(failedContainers) > 0 {
slog.Debug("Retrying failed containers", "count", len(failedContainers))
for _, ctr := range failedContainers {
for i := range failedContainers {
ctr := failedContainers[i]
dm.queue()
go func() {
defer dm.dequeue()
@@ -162,31 +169,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
@@ -194,21 +215,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
}
@@ -224,14 +249,16 @@ func (dm *dockerManager) deleteContainerStatsSync(id string) {
func newDockerManager(a *Agent) *dockerManager {
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)
}
@@ -279,6 +306,7 @@ func newDockerManager(a *Agent) *dockerManager {
containerStatsMap: make(map[string]*container.Stats),
sem: make(chan struct{}, 5),
apiContainerList: []*container.ApiInfo{},
apiStats: &container.ApiStats{},
}
// If using podman, return client
@@ -297,9 +325,8 @@ func newDockerManager(a *Agent) *dockerManager {
if err != nil {
return manager
}
defer resp.Body.Close()
if err := json.NewDecoder(resp.Body).Decode(&versionInfo); err != nil {
if err := manager.decode(resp, &versionInfo); err != nil {
return manager
}
@@ -313,6 +340,22 @@ func newDockerManager(a *Agent) *dockerManager {
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
func getDockerHost() string {
scheme := "unix://"

View File

@@ -16,6 +16,28 @@ import (
"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
@@ -57,7 +79,7 @@ func (c *gpuCollector) start() {
break
}
slog.Warn(c.name+" failed, restarting", "err", err)
time.Sleep(time.Second * 5)
time.Sleep(retryWaitTime)
continue
}
}
@@ -76,7 +98,7 @@ func (c *gpuCollector) collect() error {
scanner := bufio.NewScanner(stdout)
if c.buf == nil {
c.buf = make([]byte, 0, 10*1024)
c.buf = make([]byte, 0, cmdBufferSize)
}
scanner.Buffer(c.buf, bufio.MaxScanTokenSize)
@@ -103,14 +125,13 @@ func (gm *GPUManager) getJetsonParser() func(output []byte) bool {
// 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()
// we get gpu name from the intitial run of nvidia-smi, so return if it hasn't been initialized
gpuData, ok := gm.GpuDataMap["0"]
if !ok {
return true
}
// Parse RAM usage
ramMatches := ramPattern.FindSubmatch(output)
if ramMatches != nil {
@@ -120,7 +141,8 @@ func (gm *GPUManager) getJetsonParser() func(output []byte) bool {
// Parse GR3D (GPU) usage
gr3dMatches := gr3dPattern.FindSubmatch(output)
if gr3dMatches != nil {
gpuData.Usage, _ = strconv.ParseFloat(string(gr3dMatches[1]), 64)
gr3dUsage, _ := strconv.ParseFloat(string(gr3dMatches[1]), 64)
gpuData.Usage += gr3dUsage
}
// Parse temperature
tempMatches := tempPattern.FindSubmatch(output)
@@ -131,7 +153,7 @@ func (gm *GPUManager) getJetsonParser() func(output []byte) bool {
powerMatches := powerPattern.FindSubmatch(output)
if powerMatches != nil {
power, _ := strconv.ParseFloat(string(powerMatches[2]), 64)
gpuData.Power = power / 1000
gpuData.Power += power / milliwattsInAWatt
}
gpuData.Count++
return true
@@ -161,18 +183,12 @@ func (gm *GPUManager) parseNvidiaData(output []byte) bool {
if _, ok := gm.GpuDataMap[id]; !ok {
name := strings.TrimPrefix(fields[1], "NVIDIA ")
gm.GpuDataMap[id] = &system.GPUData{Name: strings.TrimSuffix(name, " Laptop GPU")}
// check if tegrastats is active - if so we will only use nvidia-smi to get gpu name
// - nvidia-smi does not provide metrics for tegra / jetson devices
// this will end the nvidia-smi collector
if gm.tegrastats {
return false
}
}
// update gpu data
gpu := gm.GpuDataMap[id]
gpu.Temperature = temp
gpu.MemoryUsed = memoryUsage / 1.024
gpu.MemoryTotal = totalMemory / 1.024
gpu.MemoryUsed = memoryUsage / mebibytesInAMegabyte
gpu.MemoryTotal = totalMemory / mebibytesInAMegabyte
gpu.Usage += usage
gpu.Power += power
gpu.Count++
@@ -227,22 +243,28 @@ func (gm *GPUManager) GetCurrentData() map[string]system.GPUData {
// copy / reset the data
gpuData := make(map[string]system.GPUData, len(gm.GpuDataMap))
for id, gpu := range gm.GpuDataMap {
// sum the data
gpu.Temperature = twoDecimals(gpu.Temperature)
gpu.MemoryUsed = twoDecimals(gpu.MemoryUsed)
gpu.MemoryTotal = twoDecimals(gpu.MemoryTotal)
gpu.Usage = twoDecimals(gpu.Usage / gpu.Count)
gpu.Power = twoDecimals(gpu.Power / gpu.Count)
// reset the count
gpu.Count = 1
// dereference to avoid overwriting anything else
gpuCopy := *gpu
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 {
gpuCopy.Name = fmt.Sprintf("%s %s", gpu.Name, id)
gpuAvg.Name = fmt.Sprintf("%s %s", gpu.Name, id)
}
gpuData[id] = gpuCopy
gpuData[id] = gpuAvg
}
slog.Debug("GPU", "data", gpuData)
return gpuData
}
@@ -251,14 +273,15 @@ func (gm *GPUManager) GetCurrentData() map[string]system.GPUData {
// 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("nvidia-smi"); err == nil {
if _, err := exec.LookPath(nvidiaSmiCmd); err == nil {
gm.nvidiaSmi = true
}
if _, err := exec.LookPath("rocm-smi"); err == nil {
if _, err := exec.LookPath(rocmSmiCmd); err == nil {
gm.rocmSmi = true
}
if _, err := exec.LookPath("tegrastats"); err == nil {
if _, err := exec.LookPath(tegraStatsCmd); err == nil {
gm.tegrastats = true
gm.nvidiaSmi = false
}
if gm.nvidiaSmi || gm.rocmSmi || gm.tegrastats {
return nil
@@ -272,17 +295,19 @@ func (gm *GPUManager) startCollector(command string) {
name: command,
}
switch command {
case "nvidia-smi":
collector.cmdArgs = []string{"-l", "4",
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"}
"--format=csv,noheader,nounits",
}
collector.parse = gm.parseNvidiaData
go collector.start()
case "tegrastats":
collector.cmdArgs = []string{"--interval", "3000"}
case tegraStatsCmd:
collector.cmdArgs = []string{"--interval", tegraStatsInterval}
collector.parse = gm.getJetsonParser()
go collector.start()
case "rocm-smi":
case rocmSmiCmd:
collector.cmdArgs = []string{"--showid", "--showtemp", "--showuse", "--showpower", "--showproductname", "--showmeminfo", "vram", "--json"}
collector.parse = gm.parseAmdData
go func() {
@@ -290,12 +315,12 @@ func (gm *GPUManager) startCollector(command string) {
for {
if err := collector.collect(); err != nil {
failures++
if failures > 5 {
if failures > maxFailureRetries {
break
}
slog.Warn("Error collecting AMD GPU data", "err", err)
}
time.Sleep(4300 * time.Millisecond)
time.Sleep(rocmSmiInterval)
}
}()
}
@@ -310,13 +335,13 @@ func NewGPUManager() (*GPUManager, error) {
gm.GpuDataMap = make(map[string]*system.GPUData)
if gm.nvidiaSmi {
gm.startCollector("nvidia-smi")
gm.startCollector(nvidiaSmiCmd)
}
if gm.rocmSmi {
gm.startCollector("rocm-smi")
gm.startCollector(rocmSmiCmd)
}
if gm.tegrastats {
gm.startCollector("tegrastats")
gm.startCollector(tegraStatsCmd)
}
return &gm, nil

View File

@@ -1,3 +1,6 @@
//go:build testing
// +build testing
package agent
import (
@@ -43,6 +46,52 @@ func TestParseNvidiaData(t *testing.T) {
},
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: "",
@@ -202,14 +251,13 @@ func TestParseJetsonData(t *testing.T) {
tests := []struct {
name string
input string
gm *GPUManager
wantMetrics *system.GPUData
}{
{
name: "valid data",
input: "RAM 4300/30698MB GR3D_FREQ 45% tj@52.468C VDD_GPU_SOC 2171mW",
input: "11-14-2024 22:54:33 RAM 4300/30698MB GR3D_FREQ 45% tj@52.468C VDD_GPU_SOC 2171mW",
wantMetrics: &system.GPUData{
Name: "Jetson",
Name: "GPU",
MemoryUsed: 4300.0,
MemoryTotal: 30698.0,
Usage: 45.0,
@@ -219,10 +267,36 @@ func TestParseJetsonData(t *testing.T) {
},
},
{
name: "missing temperature",
input: "RAM 4300/30698MB GR3D_FREQ 45% VDD_GPU_SOC 2171mW",
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: "Jetson",
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,
@@ -230,32 +304,18 @@ func TestParseJetsonData(t *testing.T) {
Count: 1,
},
},
{
name: "no gpu defined by nvidia-smi",
input: "RAM 4300/30698MB GR3D_FREQ 45% VDD_GPU_SOC 2171mW",
gm: &GPUManager{
GpuDataMap: map[string]*system.GPUData{},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.gm != nil {
// should return if no gpu set by nvidia-smi
assert.Empty(t, tt.gm.GpuDataMap)
return
gm := &GPUManager{
GpuDataMap: make(map[string]*system.GPUData),
}
tt.gm = &GPUManager{
GpuDataMap: map[string]*system.GPUData{
"0": {Name: "Jetson"},
},
}
parser := tt.gm.getJetsonParser()
parser := gm.getJetsonParser()
valid := parser([]byte(tt.input))
assert.Equal(t, true, valid)
got := tt.gm.GpuDataMap["0"]
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)
@@ -271,44 +331,85 @@ func TestParseJetsonData(t *testing.T) {
}
func TestGetCurrentData(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,
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,
},
},
"1": {
Name: "GPU1",
Temperature: 60,
MemoryUsed: 3072,
MemoryTotal: 8192,
Usage: 30,
Power: 60,
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,
},
},
},
}
}
result := gm.GetCurrentData()
var result map[string]system.GPUData
assert.NotPanics(t, func() {
result = gm.GetCurrentData()
})
// Verify name disambiguation
assert.Equal(t, "GPU1 0", result["0"].Name)
assert.Equal(t, "GPU1 1", result["1"].Name)
// Check that usage and power are 0
assert.Equal(t, 0.0, result["0"].Usage)
assert.Equal(t, 0.0, result["0"].Power)
// Check averaged values
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 reset counts
assert.Equal(t, float64(1), gm.GpuDataMap["0"].Count)
assert.Equal(t, float64(1), gm.GpuDataMap["1"].Count)
// Verify reset count
assert.Equal(t, float64(0), gm.GpuDataMap["0"].Count)
})
}
func TestDetectGPUs(t *testing.T) {
@@ -381,7 +482,7 @@ echo "test"`
}
return nil
},
wantNvidiaSmi: true,
wantNvidiaSmi: false,
wantRocmSmi: true,
wantTegrastats: true,
wantErr: false,
@@ -486,7 +587,7 @@ echo '{"card0": {"Temperature (Sensor edge) (C)": "49.0", "Current Socket Graphi
setup: func(t *testing.T) error {
path := filepath.Join(dir, "tegrastats")
script := `#!/bin/sh
echo "RAM 1024/4096MB GR3D_FREQ 80% tj@70C VDD_GPU_SOC 1000mW"`
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
}
@@ -523,3 +624,170 @@ echo "RAM 1024/4096MB GR3D_FREQ 80% tj@70C VDD_GPU_SOC 1000mW"`
})
}
}
// 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)
}
})
}
}

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.Run(func() {
// 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")
})
})
}

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>

View File

@@ -17,7 +17,7 @@ func (a *Agent) initializeNetIoStats() {
nics, nicsEnvExists := GetEnv("NICS")
if nicsEnvExists {
nicsMap = make(map[string]struct{}, 0)
for _, nic := range strings.Split(nics, ",") {
for nic := range strings.SplitSeq(nics, ",") {
nicsMap[nic] = struct{}{}
}
}
@@ -57,6 +57,7 @@ func (a *Agent) skipNetworkInterface(v psutilNet.IOCountersStat) bool {
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

View File

@@ -0,0 +1,197 @@
package agent
import (
"beszel/internal/entities/system"
"context"
"fmt"
"log/slog"
"path"
"runtime"
"strconv"
"strings"
"unicode/utf8"
"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
}

View File

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

View File

@@ -0,0 +1,553 @@
//go:build testing
// +build testing
package agent
import (
"beszel/internal/entities/system"
"context"
"fmt"
"os"
"testing"
"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")
}
})
}
}

View File

@@ -0,0 +1,281 @@
//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
)
var errNoSensors = errors.New("no sensors found (try running as admin)")
// 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 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)
}
}()
// 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
}

View File

@@ -1,79 +1,163 @@
package agent
import (
"beszel"
"beszel/internal/common"
"beszel/internal/entities/system"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net"
"os"
"strings"
"time"
sshServer "github.com/gliderlabs/ssh"
"golang.org/x/crypto/ssh"
"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 string
Keys []ssh.PublicKey
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 {
sshServer.Handle(a.handleSession)
if a.server != nil {
return errors.New("server already started")
}
slog.Info("Starting SSH server", "addr", opts.Addr, "network", opts.Network)
switch opts.Network {
case "unix":
if opts.Network == "unix" {
// remove existing socket file if it exists
if err := os.Remove(opts.Addr); err != nil && !os.IsNotExist(err) {
return err
}
default:
// prefix with : if only port was provided
if !strings.Contains(opts.Addr, ":") {
opts.Addr = ":" + opts.Addr
}
}
// Listen on the address
// start listening on the address
ln, err := net.Listen(opts.Network, opts.Addr)
if err != nil {
return err
}
defer ln.Close()
// Start SSH server on the listener
err = sshServer.Serve(ln, nil, sshServer.NoPty(),
sshServer.PublicKeyAuth(func(ctx sshServer.Context, key sshServer.PublicKey) bool {
// 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 sshServer.KeysEqual(key, pubKey) {
if ssh.KeysEqual(key, pubKey) {
slog.Info("SSH connected", "addr", remoteAddr)
return true
}
}
slog.Warn("Invalid SSH key", "addr", remoteAddr)
return false
}),
)
if err != nil {
return err
},
// disable pty
PtyCallback: func(ctx ssh.Context, pty ssh.Pty) bool {
return false
},
// close idle connections after 70 seconds
IdleTimeout: 70 * time.Second,
}
return nil
// Start SSH server on the listener
return a.server.Serve(ln)
}
func (a *Agent) handleSession(s sshServer.Session) {
slog.Debug("New session", "client", s.RemoteAddr())
stats := a.gatherStats(s.Context().SessionID())
if err := json.NewEncoder(s).Encode(stats); err != nil {
// 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)
}
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) ([]ssh.PublicKey, error) {
var parsedKeys []ssh.PublicKey
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
@@ -81,7 +165,7 @@ func ParseKeys(input string) ([]ssh.PublicKey, error) {
continue
}
// Parse the key
parsedKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(line))
parsedKey, _, _, _, err := gossh.ParseAuthorizedKey([]byte(line))
if err != nil {
return nil, fmt.Errorf("failed to parse key: %s, error: %w", line, err)
}
@@ -89,3 +173,51 @@ func ParseKeys(input string) ([]ssh.PublicKey, error) {
}
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
}

View File

@@ -1,34 +1,43 @@
package agent
import (
"beszel/internal/entities/container"
"beszel/internal/entities/system"
"context"
"crypto/ed25519"
"encoding/json"
"fmt"
"net"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/blang/semver"
"github.com/fxamacker/cbor/v2"
"github.com/gliderlabs/ssh"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/ssh"
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 := ssh.NewSignerFromKey(privKey)
signer, err := gossh.NewSignerFromKey(privKey)
require.NoError(t, err)
sshPubKey, err := ssh.NewPublicKey(pubKey)
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 := ssh.NewSignerFromKey(badPrivKey)
badSigner, err := gossh.NewSignerFromKey(badPrivKey)
require.NoError(t, err)
sshBadPubKey, err := ssh.NewPublicKey(badPubKey)
sshBadPubKey, err := gossh.NewPublicKey(badPubKey)
require.NoError(t, err)
socketFile := filepath.Join(t.TempDir(), "beszel-test.sock")
@@ -45,8 +54,8 @@ func TestStartServer(t *testing.T) {
name: "tcp port only",
config: ServerOptions{
Network: "tcp",
Addr: "45987",
Keys: []ssh.PublicKey{sshPubKey},
Addr: ":45987",
Keys: []gossh.PublicKey{sshPubKey},
},
},
{
@@ -54,7 +63,7 @@ func TestStartServer(t *testing.T) {
config: ServerOptions{
Network: "tcp4",
Addr: "127.0.0.1:45988",
Keys: []ssh.PublicKey{sshPubKey},
Keys: []gossh.PublicKey{sshPubKey},
},
},
{
@@ -62,7 +71,7 @@ func TestStartServer(t *testing.T) {
config: ServerOptions{
Network: "tcp6",
Addr: "[::1]:45989",
Keys: []ssh.PublicKey{sshPubKey},
Keys: []gossh.PublicKey{sshPubKey},
},
},
{
@@ -70,7 +79,7 @@ func TestStartServer(t *testing.T) {
config: ServerOptions{
Network: "unix",
Addr: socketFile,
Keys: []ssh.PublicKey{sshPubKey},
Keys: []gossh.PublicKey{sshPubKey},
},
setup: func() error {
// Create a socket file that should be removed
@@ -88,8 +97,8 @@ func TestStartServer(t *testing.T) {
name: "bad key should fail",
config: ServerOptions{
Network: "tcp",
Addr: "45987",
Keys: []ssh.PublicKey{sshBadPubKey},
Addr: ":45987",
Keys: []gossh.PublicKey{sshBadPubKey},
},
wantErr: true,
errContains: "ssh: handshake failed",
@@ -98,8 +107,8 @@ func TestStartServer(t *testing.T) {
name: "good key still good",
config: ServerOptions{
Network: "tcp",
Addr: "45987",
Keys: []ssh.PublicKey{sshPubKey},
Addr: ":45987",
Keys: []gossh.PublicKey{sshPubKey},
},
},
}
@@ -115,7 +124,8 @@ func TestStartServer(t *testing.T) {
defer tt.cleanup()
}
agent := NewAgent()
agent, err := NewAgent("")
require.NoError(t, err)
// Start server in a goroutine since it blocks
errChan := make(chan error, 1)
@@ -127,8 +137,7 @@ func TestStartServer(t *testing.T) {
time.Sleep(100 * time.Millisecond)
// Try to connect to verify server is running
var client *ssh.Client
var err error
var client *gossh.Client
// Choose the appropriate signer based on the test case
testSigner := signer
@@ -136,23 +145,23 @@ func TestStartServer(t *testing.T) {
testSigner = badSigner
}
sshClientConfig := &ssh.ClientConfig{
sshClientConfig := &gossh.ClientConfig{
User: "a",
Auth: []ssh.AuthMethod{
ssh.PublicKeys(testSigner),
Auth: []gossh.AuthMethod{
gossh.PublicKeys(testSigner),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
HostKeyCallback: gossh.InsecureIgnoreHostKey(),
Timeout: 4 * time.Second,
}
switch tt.config.Network {
case "unix":
client, err = ssh.Dial("unix", tt.config.Addr, sshClientConfig)
client, err = gossh.Dial("unix", tt.config.Addr, sshClientConfig)
default:
if !strings.Contains(tt.config.Addr, ":") {
tt.config.Addr = ":" + tt.config.Addr
}
client, err = ssh.Dial("tcp", tt.config.Addr, sshClientConfig)
client, err = gossh.Dial("tcp", tt.config.Addr, sshClientConfig)
}
if tt.wantErr {
@@ -287,3 +296,310 @@ func TestParseInvalidKey(t *testing.T) {
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

@@ -14,16 +14,34 @@ import (
"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"
)
// 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,10 +59,17 @@ 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
}
// battery
if _, _, err := getBatteryStats(); err != nil {
slog.Debug("No battery detected", "err", err)
} else {
a.hasBattery = true
}
}
@@ -52,6 +77,11 @@ func (a *Agent) initializeSystemInfo() {
func (a *Agent) getSystemStats() system.Stats {
systemStats := system.Stats{}
// battery
if a.hasBattery {
systemStats.Battery[0], systemStats.Battery[1], _ = getBatteryStats()
}
// cpu percent
cpuPct, err := cpu.Percent(0, false)
if err != nil {
@@ -60,6 +90,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
@@ -146,24 +186,27 @@ func (a *Agent) getSystemStats() system.Stats {
a.initializeNetIoStats()
}
if netIO, err := psutilNet.IOCounters(true); err == nil {
secondsElapsed := time.Since(a.netIoStats.Time).Seconds()
msElapsed := uint64(time.Since(a.netIoStats.Time).Milliseconds())
a.netIoStats.Time = time.Now()
bytesSent := uint64(0)
bytesRecv := uint64(0)
totalBytesSent := uint64(0)
totalBytesRecv := 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
totalBytesSent += v.BytesSent
totalBytesRecv += 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)
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)
@@ -178,17 +221,16 @@ func (a *Agent) getSystemStats() system.Stats {
} else {
systemStats.NetworkSent = networkSentPs
systemStats.NetworkRecv = networkRecvPs
systemStats.Bandwidth[0], systemStats.Bandwidth[1] = bytesSentPerSecond, bytesRecvPerSecond
// update netIoStats
a.netIoStats.BytesSent = bytesSent
a.netIoStats.BytesRecv = bytesRecv
a.netIoStats.BytesSent = totalBytesSent
a.netIoStats.BytesRecv = totalBytesRecv
}
}
// temperatures (skip if sensors whitelist is set to empty string)
err = a.updateTemperatures(&systemStats)
if err != nil {
slog.Error("Error getting temperatures", "err", err)
}
// temperatures
// TODO: maybe refactor to methods on systemStats
a.updateTemperatures(&systemStats)
// GPU data
if a.gpuManager != nil {
@@ -202,81 +244,45 @@ func (a *Agent) getSystemStats() system.Stats {
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
}
func (a *Agent) updateTemperatures(systemStats *system.Stats) error {
// skip if sensors whitelist is set to empty string
if a.sensorsWhitelist != nil && len(a.sensorsWhitelist) == 0 {
slog.Debug("Skipping temperature collection")
return nil
}
primarySensor, primarySensorIsDefined := GetEnv("PRIMARY_SENSOR")
// reset high temp
a.systemInfo.DashboardTemp = 0
// get sensor data
temps, err := sensors.TemperaturesWithContext(a.sensorsContext)
if err != nil {
return err
}
slog.Debug("Temperature", "sensors", temps)
// return if no sensors
if len(temps) == 0 {
return nil
}
systemStats.Temperatures = make(map[string]float64, len(temps))
for i, sensor := range temps {
// 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
if a.sensorsWhitelist != nil {
if _, nameInWhitelist := a.sensorsWhitelist[sensorName]; !nameInWhitelist {
continue
}
}
// set dashboard temperature
if primarySensorIsDefined {
if sensorName == primarySensor {
a.systemInfo.DashboardTemp = sensor.Temperature
}
} else {
a.systemInfo.DashboardTemp = max(a.systemInfo.DashboardTemp, sensor.Temperature)
}
systemStats.Temperatures[sensorName] = twoDecimals(sensor.Temperature)
}
return nil
}
// Returns the size of the ZFS ARC memory cache in bytes
func getARCSize() (uint64, error) {
file, err := os.Open("/proc/spl/kstat/zfs/arcstats")

View File

@@ -1,56 +1,147 @@
package agent
import (
"beszel"
"beszel/internal/ghupdate"
"fmt"
"log"
"os"
"os/exec"
"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))
// 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()
}
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 {
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() error {
exePath, _ := os.Executable()
dataDir, err := getDataDir()
if err != nil {
dataDir = os.TempDir()
}
updated, err := ghupdate.Update(ghupdate.Config{
ArchiveExecutable: "beszel-agent",
DataDir: dataDir,
})
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.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
}

View File

@@ -8,15 +8,19 @@ import (
"sync"
"time"
"github.com/containrrr/shoutrrr"
"github.com/nicholas-fedor/shoutrrr"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/mailer"
)
type hubLike interface {
core.App
MakeLink(parts ...string) string
}
type AlertManager struct {
app core.App
hub hubLike
alertQueue chan alertTask
stopChan chan struct{}
pendingAlerts sync.Map
@@ -42,6 +46,7 @@ type SystemAlertStats struct {
NetSent float64 `json:"ns"`
NetRecv float64 `json:"nr"`
Temperatures map[string]float32 `json:"t"`
LoadAvg [3]float64 `json:"la"`
}
type SystemAlertData struct {
@@ -66,6 +71,7 @@ var supportsTitle = map[string]struct{}{
"gotify": {},
"ifttt": {},
"join": {},
"lark": {},
"matrix": {},
"ntfy": {},
"opsgenie": {},
@@ -78,19 +84,27 @@ var supportsTitle = map[string]struct{}{
}
// NewAlertManager creates a new AlertManager instance.
func NewAlertManager(app core.App) *AlertManager {
func NewAlertManager(app hubLike) *AlertManager {
am := &AlertManager{
app: app,
hub: app,
alertQueue: make(chan alertTask),
stopChan: make(chan struct{}),
}
am.bindEvents()
go am.startWorker()
return am
}
// Bind events to the alerts collection lifecycle
func (am *AlertManager) bindEvents() {
am.hub.OnRecordAfterUpdateSuccess("alerts").BindFunc(updateHistoryOnAlertUpdate)
am.hub.OnRecordAfterDeleteSuccess("alerts").BindFunc(resolveHistoryOnAlertDelete)
}
// SendAlert sends an alert to the user
func (am *AlertManager) SendAlert(data AlertMessageData) error {
// get user settings
record, err := am.app.FindFirstRecordByFilter(
record, err := am.hub.FindFirstRecordByFilter(
"user_settings", "user={:user}",
dbx.Params{"user": data.UserID},
)
@@ -103,12 +117,12 @@ func (am *AlertManager) SendAlert(data AlertMessageData) error {
Webhooks: []string{},
}
if err := record.UnmarshalJSONField("settings", &userAlertSettings); err != nil {
am.app.Logger().Error("Failed to unmarshal user settings", "err", err.Error())
am.hub.Logger().Error("Failed to unmarshal user settings", "err", err)
}
// 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())
am.hub.Logger().Error("Failed to send shoutrrr alert", "err", err)
}
}
// send alerts via email
@@ -124,15 +138,15 @@ func (am *AlertManager) SendAlert(data AlertMessageData) error {
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,
Address: am.hub.Settings().Meta.SenderAddress,
Name: am.hub.Settings().Meta.SenderName,
},
}
err = am.app.NewMailClient().Send(&message)
err = am.hub.NewMailClient().Send(&message)
if err != nil {
return err
}
am.app.Logger().Info("Sent email alert", "to", message.To, "subj", message.Subject)
am.hub.Logger().Info("Sent email alert", "to", message.To, "subj", message.Subject)
return nil
}
@@ -166,10 +180,12 @@ func (am *AlertManager) SendShoutrrrAlert(notificationUrl, title, message, link,
// Add link
if scheme == "ntfy" {
// if ntfy, add link to actions
queryParams.Add("Actions", fmt.Sprintf("view, %s, %s", linkText, link))
} else if scheme == "lark" {
queryParams.Add("link", link)
} else if scheme == "bark" {
queryParams.Add("url", link)
} else {
// else add link directly to the message
message += "\n\n" + link
}
@@ -180,25 +196,23 @@ func (am *AlertManager) SendShoutrrrAlert(notificationUrl, title, message, link,
err = shoutrrr.Send(parsedURL.String(), message)
if err == nil {
am.app.Logger().Info("Sent shoutrrr alert", "title", title)
am.hub.Logger().Info("Sent shoutrrr alert", "title", title)
} else {
am.app.Logger().Error("Error sending shoutrrr alert", "err", err.Error())
am.hub.Logger().Error("Error sending shoutrrr alert", "err", err)
return err
}
return nil
}
func (am *AlertManager) SendTestNotification(e *core.RequestEvent) error {
info, _ := e.RequestInfo()
if info.Auth == nil {
return apis.NewForbiddenError("Forbidden", nil)
var data struct {
URL string `json:"url"`
}
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 := e.BindBody(&data)
if err != nil || data.URL == "" {
return e.BadRequestError("URL is required", err)
}
err := am.SendShoutrrrAlert(url, "Test Alert", "This is a notification from Beszel.", am.app.Settings().Meta.AppURL, "View Beszel")
err = am.SendShoutrrrAlert(data.URL, "Test Alert", "This is a notification from Beszel.", am.hub.Settings().Meta.AppURL, "View Beszel")
if err != nil {
return e.JSON(200, map[string]string{"err": err.Error()})
}

View File

@@ -0,0 +1,119 @@
package alerts
import (
"database/sql"
"errors"
"net/http"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
)
// UpsertUserAlerts handles API request to create or update alerts for a user
// across multiple systems (POST /api/beszel/user-alerts)
func UpsertUserAlerts(e *core.RequestEvent) error {
userID := e.Auth.Id
reqData := struct {
Min uint8 `json:"min"`
Value float64 `json:"value"`
Name string `json:"name"`
Systems []string `json:"systems"`
Overwrite bool `json:"overwrite"`
}{}
err := e.BindBody(&reqData)
if err != nil || userID == "" || reqData.Name == "" || len(reqData.Systems) == 0 {
return e.BadRequestError("Bad data", err)
}
alertsCollection, err := e.App.FindCachedCollectionByNameOrId("alerts")
if err != nil {
return err
}
err = e.App.RunInTransaction(func(txApp core.App) error {
for _, systemId := range reqData.Systems {
// find existing matching alert
alertRecord, err := txApp.FindFirstRecordByFilter(alertsCollection,
"system={:system} && name={:name} && user={:user}",
dbx.Params{"system": systemId, "name": reqData.Name, "user": userID})
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}
// skip if alert already exists and overwrite is not set
if !reqData.Overwrite && alertRecord != nil {
continue
}
// create new alert if it doesn't exist
if alertRecord == nil {
alertRecord = core.NewRecord(alertsCollection)
alertRecord.Set("user", userID)
alertRecord.Set("system", systemId)
alertRecord.Set("name", reqData.Name)
}
alertRecord.Set("value", reqData.Value)
alertRecord.Set("min", reqData.Min)
if err := txApp.SaveNoValidate(alertRecord); err != nil {
return err
}
}
return nil
})
if err != nil {
return err
}
return e.JSON(http.StatusOK, map[string]any{"success": true})
}
// DeleteUserAlerts handles API request to delete alerts for a user across multiple systems
// (DELETE /api/beszel/user-alerts)
func DeleteUserAlerts(e *core.RequestEvent) error {
userID := e.Auth.Id
reqData := struct {
AlertName string `json:"name"`
Systems []string `json:"systems"`
}{}
err := e.BindBody(&reqData)
if err != nil || userID == "" || reqData.AlertName == "" || len(reqData.Systems) == 0 {
return e.BadRequestError("Bad data", err)
}
var numDeleted uint16
err = e.App.RunInTransaction(func(txApp core.App) error {
for _, systemId := range reqData.Systems {
// Find existing alert to delete
alertRecord, err := txApp.FindFirstRecordByFilter("alerts",
"system={:system} && name={:name} && user={:user}",
dbx.Params{"system": systemId, "name": reqData.AlertName, "user": userID})
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
// alert doesn't exist, continue to next system
continue
}
return err
}
if err := txApp.Delete(alertRecord); err != nil {
return err
}
numDeleted++
}
return nil
})
if err != nil {
return err
}
return e.JSON(http.StatusOK, map[string]any{"success": true, "count": numDeleted})
}

View File

@@ -0,0 +1,85 @@
package alerts
import (
"time"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
)
// On triggered alert record delete, set matching alert history record to resolved
func resolveHistoryOnAlertDelete(e *core.RecordEvent) error {
if !e.Record.GetBool("triggered") {
return e.Next()
}
_ = resolveAlertHistoryRecord(e.App, e.Record.Id)
return e.Next()
}
// On alert record update, update alert history record
func updateHistoryOnAlertUpdate(e *core.RecordEvent) error {
original := e.Record.Original()
new := e.Record
originalTriggered := original.GetBool("triggered")
newTriggered := new.GetBool("triggered")
// no need to update alert history if triggered state has not changed
if originalTriggered == newTriggered {
return e.Next()
}
// if new state is triggered, create new alert history record
if newTriggered {
_, _ = createAlertHistoryRecord(e.App, new)
return e.Next()
}
// if new state is not triggered, check for matching alert history record and set it to resolved
_ = resolveAlertHistoryRecord(e.App, new.Id)
return e.Next()
}
// resolveAlertHistoryRecord sets the resolved field to the current time
func resolveAlertHistoryRecord(app core.App, alertRecordID string) error {
alertHistoryRecords, err := app.FindRecordsByFilter(
"alerts_history",
"alert_id={:alert_id} && resolved=null",
"-created",
1,
0,
dbx.Params{"alert_id": alertRecordID},
)
if err != nil {
return err
}
if len(alertHistoryRecords) == 0 {
return nil
}
alertHistoryRecord := alertHistoryRecords[0] // there should be only one record
alertHistoryRecord.Set("resolved", time.Now().UTC())
err = app.Save(alertHistoryRecord)
if err != nil {
app.Logger().Error("Failed to resolve alert history", "err", err)
}
return err
}
// createAlertHistoryRecord creates a new alert history record
func createAlertHistoryRecord(app core.App, alertRecord *core.Record) (alertHistoryRecord *core.Record, err error) {
alertHistoryCollection, err := app.FindCachedCollectionByNameOrId("alerts_history")
if err != nil {
return nil, err
}
alertHistoryRecord = core.NewRecord(alertHistoryCollection)
alertHistoryRecord.Set("alert_id", alertRecord.Id)
alertHistoryRecord.Set("user", alertRecord.GetString("user"))
alertHistoryRecord.Set("system", alertRecord.GetString("system"))
alertHistoryRecord.Set("name", alertRecord.GetString("name"))
alertHistoryRecord.Set("value", alertRecord.GetFloat("value"))
err = app.Save(alertHistoryRecord)
if err != nil {
app.Logger().Error("Failed to save alert history", "err", err)
}
return alertHistoryRecord, err
}

View File

@@ -2,7 +2,6 @@ package alerts
import (
"fmt"
"net/url"
"strings"
"time"
@@ -87,7 +86,7 @@ func (am *AlertManager) HandleStatusAlerts(newStatus string, systemRecord *core.
// getSystemStatusAlerts retrieves all "Status" alert records for a given system ID.
func (am *AlertManager) getSystemStatusAlerts(systemID string) ([]*core.Record, error) {
alertRecords, err := am.app.FindAllRecords("alerts", dbx.HashExp{
alertRecords, err := am.hub.FindAllRecords("alerts", dbx.HashExp{
"system": systemID,
"name": "Status",
})
@@ -130,13 +129,21 @@ func (am *AlertManager) handleSystemUp(systemName string, alertRecords []*core.R
}
// No alert scheduled for this record, send "up" alert
if err := am.sendStatusAlert("up", systemName, alertRecord); err != nil {
am.app.Logger().Error("Failed to send alert", "err", err.Error())
am.hub.Logger().Error("Failed to send alert", "err", err)
}
}
}
// sendStatusAlert sends a status alert ("up" or "down") to the users associated with the alert records.
func (am *AlertManager) sendStatusAlert(alertStatus string, systemName string, alertRecord *core.Record) error {
switch alertStatus {
case "up":
alertRecord.Set("triggered", false)
case "down":
alertRecord.Set("triggered", true)
}
am.hub.Save(alertRecord)
var emoji string
if alertStatus == "up" {
emoji = "\u2705" // Green checkmark emoji
@@ -147,19 +154,19 @@ func (am *AlertManager) sendStatusAlert(alertStatus string, systemName string, a
title := fmt.Sprintf("Connection to %s is %s %v", systemName, alertStatus, emoji)
message := strings.TrimSuffix(title, emoji)
if errs := am.app.ExpandRecord(alertRecord, []string{"user"}, nil); len(errs) > 0 {
return errs["user"]
}
user := alertRecord.ExpandedOne("user")
if user == nil {
return nil
}
// if errs := am.hub.ExpandRecord(alertRecord, []string{"user"}, nil); len(errs) > 0 {
// return errs["user"]
// }
// user := alertRecord.ExpandedOne("user")
// if user == nil {
// return nil
// }
return am.SendAlert(AlertMessageData{
UserID: user.Id,
UserID: alertRecord.GetString("user"),
Title: title,
Message: message,
Link: am.app.Settings().Meta.AppURL + "/system/" + url.PathEscape(systemName),
Link: am.hub.MakeLink("system", systemName),
LinkText: "View " + systemName,
})
}

View File

@@ -2,12 +2,11 @@ package alerts
import (
"beszel/internal/entities/system"
"encoding/json"
"fmt"
"net/url"
"strings"
"time"
"github.com/goccy/go-json"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/types"
@@ -15,8 +14,8 @@ import (
)
func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *system.CombinedData) error {
alertRecords, err := am.app.FindAllRecords("alerts",
dbx.NewExp("system={:system}", dbx.Params{"system": systemRecord.Id}),
alertRecords, err := am.hub.FindAllRecords("alerts",
dbx.NewExp("system={:system} AND name!='Status'", dbx.Params{"system": systemRecord.Id}),
)
if err != nil || len(alertRecords) == 0 {
// log.Println("no alerts found for system")
@@ -55,6 +54,15 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
}
val = data.Info.DashboardTemp
unit = "°C"
case "LoadAvg1":
val = data.Info.LoadAvg[0]
unit = ""
case "LoadAvg5":
val = data.Info.LoadAvg[1]
unit = ""
case "LoadAvg15":
val = data.Info.LoadAvg[2]
unit = ""
}
triggered := alertRecord.GetBool("triggered")
@@ -101,7 +109,7 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
Created types.DateTime `db:"created"`
}{}
err = am.app.DB().
err = am.hub.DB().
Select("stats", "created").
From("system_stats").
Where(dbx.NewExp(
@@ -191,6 +199,12 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
}
alert.mapSums[key] += temp
}
case "LoadAvg1":
alert.val += stats.LoadAvg[0]
case "LoadAvg5":
alert.val += stats.LoadAvg[1]
case "LoadAvg15":
alert.val += stats.LoadAvg[2]
default:
continue
}
@@ -248,6 +262,10 @@ func (am *AlertManager) sendSystemAlert(alert SystemAlertData) {
if alert.name == "Disk" {
alert.name += " usage"
}
// format LoadAvg5 and LoadAvg15
if after, ok := strings.CutPrefix(alert.name, "LoadAvg"); ok {
alert.name = after + "m Load"
}
// make title alert name lowercase if not CPU
titleAlertName := alert.name
@@ -271,22 +289,15 @@ func (am *AlertManager) sendSystemAlert(alert SystemAlertData) {
body := fmt.Sprintf("%s averaged %.2f%s for the previous %v %s.", alert.descriptor, alert.val, alert.unit, alert.min, minutesLabel)
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())
if err := am.hub.Save(alert.alertRecord); err != nil {
// app.Logger().Error("failed to save alert record", "err", err)
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,
})
}
am.SendAlert(AlertMessageData{
UserID: alert.alertRecord.GetString("user"),
Title: subject,
Message: body,
Link: am.hub.MakeLink("system", systemName),
LinkText: "View " + systemName,
})
}

View File

@@ -0,0 +1,368 @@
//go:build testing
// +build testing
package alerts_test
import (
"bytes"
"encoding/json"
"io"
"net/http"
"strings"
"testing"
beszelTests "beszel/internal/tests"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
pbTests "github.com/pocketbase/pocketbase/tests"
"github.com/stretchr/testify/assert"
)
// marshal to json and return an io.Reader (for use in ApiScenario.Body)
func jsonReader(v any) io.Reader {
data, err := json.Marshal(v)
if err != nil {
panic(err)
}
return bytes.NewReader(data)
}
func TestUserAlertsApi(t *testing.T) {
hub, _ := beszelTests.NewTestHub(t.TempDir())
defer hub.Cleanup()
hub.StartHub()
user1, _ := beszelTests.CreateUser(hub, "alertstest@example.com", "password")
user1Token, _ := user1.NewAuthToken()
user2, _ := beszelTests.CreateUser(hub, "alertstest2@example.com", "password")
user2Token, _ := user2.NewAuthToken()
system1, _ := beszelTests.CreateRecord(hub, "systems", map[string]any{
"name": "system1",
"users": []string{user1.Id},
"host": "127.0.0.1",
})
system2, _ := beszelTests.CreateRecord(hub, "systems", map[string]any{
"name": "system2",
"users": []string{user1.Id, user2.Id},
"host": "127.0.0.2",
})
userRecords, _ := hub.CountRecords("users")
assert.EqualValues(t, 2, userRecords, "all users should be created")
systemRecords, _ := hub.CountRecords("systems")
assert.EqualValues(t, 2, systemRecords, "all systems should be created")
testAppFactory := func(t testing.TB) *pbTests.TestApp {
return hub.TestApp
}
scenarios := []beszelTests.ApiScenario{
{
Name: "GET not implemented - returns index",
Method: http.MethodGet,
URL: "/api/beszel/user-alerts",
ExpectedStatus: 200,
ExpectedContent: []string{"<html ", "globalThis.BESZEL"},
TestAppFactory: testAppFactory,
},
{
Name: "POST no auth",
Method: http.MethodPost,
URL: "/api/beszel/user-alerts",
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
},
{
Name: "POST no body",
Method: http.MethodPost,
URL: "/api/beszel/user-alerts",
Headers: map[string]string{
"Authorization": user1Token,
},
ExpectedStatus: 400,
ExpectedContent: []string{"Bad data"},
TestAppFactory: testAppFactory,
},
{
Name: "POST bad data",
Method: http.MethodPost,
URL: "/api/beszel/user-alerts",
Headers: map[string]string{
"Authorization": user1Token,
},
ExpectedStatus: 400,
ExpectedContent: []string{"Bad data"},
TestAppFactory: testAppFactory,
Body: jsonReader(map[string]any{
"invalidField": "this should cause validation error",
"threshold": "not a number",
}),
},
{
Name: "POST malformed JSON",
Method: http.MethodPost,
URL: "/api/beszel/user-alerts",
Headers: map[string]string{
"Authorization": user1Token,
},
ExpectedStatus: 400,
ExpectedContent: []string{"Bad data"},
TestAppFactory: testAppFactory,
Body: strings.NewReader(`{"alertType": "cpu", "threshold": 80, "enabled": true,}`),
},
{
Name: "POST valid alert data multiple systems",
Method: http.MethodPost,
URL: "/api/beszel/user-alerts",
Headers: map[string]string{
"Authorization": user1Token,
},
ExpectedStatus: 200,
ExpectedContent: []string{"\"success\":true"},
TestAppFactory: testAppFactory,
Body: jsonReader(map[string]any{
"name": "CPU",
"value": 69,
"min": 9,
"systems": []string{system1.Id, system2.Id},
"overwrite": false,
}),
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
// check total alerts
alerts, _ := app.CountRecords("alerts")
assert.EqualValues(t, 2, alerts, "should have 2 alerts")
// check alert has correct values
matchingAlerts, _ := app.CountRecords("alerts", dbx.HashExp{"name": "CPU", "user": user1.Id, "system": system1.Id, "value": 69, "min": 9})
assert.EqualValues(t, 1, matchingAlerts, "should have 1 alert")
},
},
{
Name: "POST valid alert data single system",
Method: http.MethodPost,
URL: "/api/beszel/user-alerts",
Headers: map[string]string{
"Authorization": user1Token,
},
ExpectedStatus: 200,
ExpectedContent: []string{"\"success\":true"},
TestAppFactory: testAppFactory,
Body: jsonReader(map[string]any{
"name": "Memory",
"systems": []string{system1.Id},
"value": 90,
"min": 10,
}),
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
user1Alerts, _ := app.CountRecords("alerts", dbx.HashExp{"user": user1.Id})
assert.EqualValues(t, 3, user1Alerts, "should have 3 alerts")
},
},
{
Name: "Overwrite: false, should not overwrite existing alert",
Method: http.MethodPost,
URL: "/api/beszel/user-alerts",
Headers: map[string]string{
"Authorization": user1Token,
},
ExpectedStatus: 200,
ExpectedContent: []string{"\"success\":true"},
TestAppFactory: testAppFactory,
Body: jsonReader(map[string]any{
"name": "CPU",
"value": 45,
"min": 5,
"systems": []string{system1.Id},
"overwrite": false,
}),
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
beszelTests.ClearCollection(t, app, "alerts")
beszelTests.CreateRecord(app, "alerts", map[string]any{
"name": "CPU",
"system": system1.Id,
"user": user1.Id,
"value": 80,
"min": 10,
})
},
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
alerts, _ := app.CountRecords("alerts")
assert.EqualValues(t, 1, alerts, "should have 1 alert")
alert, _ := app.FindFirstRecordByFilter("alerts", "name = 'CPU' && user = {:user}", dbx.Params{"user": user1.Id})
assert.EqualValues(t, 80, alert.Get("value"), "should have 80 as value")
},
},
{
Name: "Overwrite: true, should overwrite existing alert",
Method: http.MethodPost,
URL: "/api/beszel/user-alerts",
Headers: map[string]string{
"Authorization": user2Token,
},
ExpectedStatus: 200,
ExpectedContent: []string{"\"success\":true"},
TestAppFactory: testAppFactory,
Body: jsonReader(map[string]any{
"name": "CPU",
"value": 45,
"min": 5,
"systems": []string{system2.Id},
"overwrite": true,
}),
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
beszelTests.ClearCollection(t, app, "alerts")
beszelTests.CreateRecord(app, "alerts", map[string]any{
"name": "CPU",
"system": system2.Id,
"user": user2.Id,
"value": 80,
"min": 10,
})
},
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
alerts, _ := app.CountRecords("alerts")
assert.EqualValues(t, 1, alerts, "should have 1 alert")
alert, _ := app.FindFirstRecordByFilter("alerts", "name = 'CPU' && user = {:user}", dbx.Params{"user": user2.Id})
assert.EqualValues(t, 45, alert.Get("value"), "should have 45 as value")
},
},
{
Name: "DELETE no auth",
Method: http.MethodDelete,
URL: "/api/beszel/user-alerts",
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
Body: jsonReader(map[string]any{
"name": "CPU",
"systems": []string{system1.Id},
}),
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
beszelTests.ClearCollection(t, app, "alerts")
beszelTests.CreateRecord(app, "alerts", map[string]any{
"name": "CPU",
"system": system1.Id,
"user": user1.Id,
"value": 80,
"min": 10,
})
},
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
alerts, _ := app.CountRecords("alerts")
assert.EqualValues(t, 1, alerts, "should have 1 alert")
},
},
{
Name: "DELETE alert",
Method: http.MethodDelete,
URL: "/api/beszel/user-alerts",
Headers: map[string]string{
"Authorization": user1Token,
},
ExpectedStatus: 200,
ExpectedContent: []string{"\"count\":1", "\"success\":true"},
TestAppFactory: testAppFactory,
Body: jsonReader(map[string]any{
"name": "CPU",
"systems": []string{system1.Id},
}),
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
beszelTests.ClearCollection(t, app, "alerts")
beszelTests.CreateRecord(app, "alerts", map[string]any{
"name": "CPU",
"system": system1.Id,
"user": user1.Id,
"value": 80,
"min": 10,
})
},
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
alerts, _ := app.CountRecords("alerts")
assert.Zero(t, alerts, "should have 0 alerts")
},
},
{
Name: "DELETE alert multiple systems",
Method: http.MethodDelete,
URL: "/api/beszel/user-alerts",
Headers: map[string]string{
"Authorization": user1Token,
},
ExpectedStatus: 200,
ExpectedContent: []string{"\"count\":2", "\"success\":true"},
TestAppFactory: testAppFactory,
Body: jsonReader(map[string]any{
"name": "Memory",
"systems": []string{system1.Id, system2.Id},
}),
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
beszelTests.ClearCollection(t, app, "alerts")
for _, systemId := range []string{system1.Id, system2.Id} {
_, err := beszelTests.CreateRecord(app, "alerts", map[string]any{
"name": "Memory",
"system": systemId,
"user": user1.Id,
"value": 90,
"min": 10,
})
assert.NoError(t, err, "should create alert")
}
alerts, _ := app.CountRecords("alerts")
assert.EqualValues(t, 2, alerts, "should have 2 alerts")
},
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
alerts, _ := app.CountRecords("alerts")
assert.Zero(t, alerts, "should have 0 alerts")
},
},
{
Name: "User 2 should not be able to delete alert of user 1",
Method: http.MethodDelete,
URL: "/api/beszel/user-alerts",
Headers: map[string]string{
"Authorization": user2Token,
},
ExpectedStatus: 200,
ExpectedContent: []string{"\"count\":1", "\"success\":true"},
TestAppFactory: testAppFactory,
Body: jsonReader(map[string]any{
"name": "CPU",
"systems": []string{system2.Id},
}),
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
beszelTests.ClearCollection(t, app, "alerts")
for _, user := range []string{user1.Id, user2.Id} {
beszelTests.CreateRecord(app, "alerts", map[string]any{
"name": "CPU",
"system": system2.Id,
"user": user,
"value": 80,
"min": 10,
})
}
alerts, _ := app.CountRecords("alerts")
assert.EqualValues(t, 2, alerts, "should have 2 alerts")
user1AlertCount, _ := app.CountRecords("alerts", dbx.HashExp{"user": user1.Id})
assert.EqualValues(t, 1, user1AlertCount, "should have 1 alert")
user2AlertCount, _ := app.CountRecords("alerts", dbx.HashExp{"user": user2.Id})
assert.EqualValues(t, 1, user2AlertCount, "should have 1 alert")
},
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
user1AlertCount, _ := app.CountRecords("alerts", dbx.HashExp{"user": user1.Id})
assert.EqualValues(t, 1, user1AlertCount, "should have 1 alert")
user2AlertCount, _ := app.CountRecords("alerts", dbx.HashExp{"user": user2.Id})
assert.Zero(t, user2AlertCount, "should have 0 alerts")
},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}

View File

@@ -0,0 +1,10 @@
package common
var (
// Allowed ssh key exchanges
DefaultKeyExchanges = []string{"curve25519-sha256"}
// Allowed ssh macs
DefaultMACs = []string{"hmac-sha2-256-etm@openssh.com"}
// Allowed ssh ciphers
DefaultCiphers = []string{"chacha20-poly1305@openssh.com"}
)

View File

@@ -0,0 +1,32 @@
package common
type WebSocketAction = uint8
// Not implemented yet
// type AgentError = uint8
const (
// Request system data from agent
GetData WebSocketAction = iota
// Check the fingerprint of the agent
CheckFingerprint
)
// HubRequest defines the structure for requests sent from hub to agent.
type HubRequest[T any] struct {
Action WebSocketAction `cbor:"0,keyasint"`
Data T `cbor:"1,keyasint,omitempty,omitzero"`
// Error AgentError `cbor:"error,omitempty,omitzero"`
}
type FingerprintRequest struct {
Signature []byte `cbor:"0,keyasint"`
NeedSysInfo bool `cbor:"1,keyasint"` // For universal token system creation
}
type FingerprintResponse struct {
Fingerprint string `cbor:"0,keyasint"`
// Optional system info for universal token system creation
Hostname string `cbor:"1,keyasint,omitempty,omitzero"`
Port string `cbor:"2,keyasint,omitempty,omitzero"`
}

View File

@@ -27,38 +27,47 @@ type ApiInfo struct {
// Docker container resources from /containers/{id}/stats
type ApiStats struct {
// Common stats
// Read time.Time `json:"read"`
// PreRead time.Time `json:"preread"`
Read time.Time `json:"read"` // Time of stats generation
NumProcs uint32 `json:"num_procs,omitzero"` // Windows specific, not populated on Linux.
Networks map[string]NetworkStats
CPUStats CPUStats `json:"cpu_stats"`
MemoryStats MemoryStats `json:"memory_stats"`
}
// Linux specific stats, not populated on Windows.
// PidsStats PidsStats `json:"pids_stats,omitempty"`
// BlkioStats BlkioStats `json:"blkio_stats,omitempty"`
func (s *ApiStats) CalculateCpuPercentLinux(prevCpuContainer uint64, prevCpuSystem uint64) float64 {
cpuDelta := s.CPUStats.CPUUsage.TotalUsage - prevCpuContainer
systemDelta := s.CPUStats.SystemUsage - prevCpuSystem
// 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
// Avoid division by zero and handle first run case
if systemDelta == 0 || prevCpuContainer == 0 {
return 0.0
}
// Shared stats
CPUStats CPUStats `json:"cpu_stats,omitempty"`
// PreCPUStats CPUStats `json:"precpu_stats,omitempty"` // "Pre"="Previous"
MemoryStats MemoryStats `json:"memory_stats,omitempty"`
return float64(cpuDelta) / float64(systemDelta) * 100.0
}
// from: https://github.com/docker/cli/blob/master/cli/command/container/stats_helpers.go#L185
func (s *ApiStats) CalculateCpuPercentWindows(prevCpuUsage uint64, prevRead time.Time) float64 {
// Max number of 100ns intervals between the previous time read and now
possIntervals := uint64(s.Read.Sub(prevRead).Nanoseconds())
possIntervals /= 100 // Convert to number of 100ns intervals
possIntervals *= uint64(s.NumProcs) // Multiple by the number of processors
// Intervals used
intervalsUsed := s.CPUStats.CPUUsage.TotalUsage - prevCpuUsage
// Percentage avoiding divide-by-zero
if possIntervals > 0 {
return float64(intervalsUsed) / float64(possIntervals) * 100.0
}
return 0.00
}
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 {
@@ -66,42 +75,15 @@ type CPUUsage struct {
// 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"`
Stats MemoryStatsStats `json:"stats"`
// private working set (Windows only)
PrivateWorkingSet uint64 `json:"privateworkingset,omitempty"`
}
type MemoryStatsStats struct {
@@ -119,16 +101,18 @@ type NetworkStats struct {
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:"-"`
Name string `json:"n" cbor:"0,keyasint"`
Cpu float64 `json:"c" cbor:"1,keyasint"`
Mem float64 `json:"m" cbor:"2,keyasint"`
NetworkSent float64 `json:"ns" cbor:"3,keyasint"`
NetworkRecv float64 `json:"nr" cbor:"4,keyasint"`
// PrevCpu [2]uint64 `json:"-"`
CpuSystem uint64 `json:"-"`
CpuContainer uint64 `json:"-"`
PrevNet prevNetStats `json:"-"`
PrevReadTime time.Time `json:"-"`
}

View File

@@ -8,38 +8,46 @@ import (
)
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"`
Cpu float64 `json:"cpu" cbor:"0,keyasint"`
MaxCpu float64 `json:"cpum,omitempty" cbor:"1,keyasint,omitempty"`
Mem float64 `json:"m" cbor:"2,keyasint"`
MemUsed float64 `json:"mu" cbor:"3,keyasint"`
MemPct float64 `json:"mp" cbor:"4,keyasint"`
MemBuffCache float64 `json:"mb" cbor:"5,keyasint"`
MemZfsArc float64 `json:"mz,omitempty" cbor:"6,keyasint,omitempty"` // ZFS ARC memory
Swap float64 `json:"s,omitempty" cbor:"7,keyasint,omitempty"`
SwapUsed float64 `json:"su,omitempty" cbor:"8,keyasint,omitempty"`
DiskTotal float64 `json:"d" cbor:"9,keyasint"`
DiskUsed float64 `json:"du" cbor:"10,keyasint"`
DiskPct float64 `json:"dp" cbor:"11,keyasint"`
DiskReadPs float64 `json:"dr" cbor:"12,keyasint"`
DiskWritePs float64 `json:"dw" cbor:"13,keyasint"`
MaxDiskReadPs float64 `json:"drm,omitempty" cbor:"14,keyasint,omitempty"`
MaxDiskWritePs float64 `json:"dwm,omitempty" cbor:"15,keyasint,omitempty"`
NetworkSent float64 `json:"ns" cbor:"16,keyasint"`
NetworkRecv float64 `json:"nr" cbor:"17,keyasint"`
MaxNetworkSent float64 `json:"nsm,omitempty" cbor:"18,keyasint,omitempty"`
MaxNetworkRecv float64 `json:"nrm,omitempty" cbor:"19,keyasint,omitempty"`
Temperatures map[string]float64 `json:"t,omitempty" cbor:"20,keyasint,omitempty"`
ExtraFs map[string]*FsStats `json:"efs,omitempty" cbor:"21,keyasint,omitempty"`
GPUData map[string]GPUData `json:"g,omitempty" cbor:"22,keyasint,omitempty"`
LoadAvg1 float64 `json:"l1,omitempty" cbor:"23,keyasint,omitempty"`
LoadAvg5 float64 `json:"l5,omitempty" cbor:"24,keyasint,omitempty"`
LoadAvg15 float64 `json:"l15,omitempty" cbor:"25,keyasint,omitempty"`
Bandwidth [2]uint64 `json:"b,omitzero" cbor:"26,keyasint,omitzero"` // [sent bytes, recv bytes]
MaxBandwidth [2]uint64 `json:"bm,omitzero" cbor:"27,keyasint,omitzero"` // [sent bytes, recv bytes]
// TODO: remove other load fields in future release in favor of load avg array
LoadAvg [3]float64 `json:"la,omitempty" cbor:"28,keyasint"`
Battery [2]uint8 `json:"bat,omitzero" cbor:"29,keyasint,omitzero"` // [percent, charge state, current]
}
type GPUData struct {
Name string `json:"n"`
Name string `json:"n" cbor:"0,keyasint"`
Temperature float64 `json:"-"`
MemoryUsed float64 `json:"mu,omitempty"`
MemoryTotal float64 `json:"mt,omitempty"`
Usage float64 `json:"u"`
Power float64 `json:"p,omitempty"`
MemoryUsed float64 `json:"mu,omitempty" cbor:"1,keyasint,omitempty"`
MemoryTotal float64 `json:"mt,omitempty" cbor:"2,keyasint,omitempty"`
Usage float64 `json:"u" cbor:"3,keyasint"`
Power float64 `json:"p,omitempty" cbor:"4,keyasint,omitempty"`
Count float64 `json:"-"`
}
@@ -47,14 +55,14 @@ type FsStats struct {
Time time.Time `json:"-"`
Root bool `json:"-"`
Mountpoint string `json:"-"`
DiskTotal float64 `json:"d"`
DiskUsed float64 `json:"du"`
DiskTotal float64 `json:"d" cbor:"0,keyasint"`
DiskUsed float64 `json:"du" cbor:"1,keyasint"`
TotalRead uint64 `json:"-"`
TotalWrite uint64 `json:"-"`
DiskReadPs float64 `json:"r"`
DiskWritePs float64 `json:"w"`
MaxDiskReadPS float64 `json:"rm,omitempty"`
MaxDiskWritePS float64 `json:"wm,omitempty"`
DiskReadPs float64 `json:"r" cbor:"2,keyasint"`
DiskWritePs float64 `json:"w" cbor:"3,keyasint"`
MaxDiskReadPS float64 `json:"rm,omitempty" cbor:"4,keyasint,omitempty"`
MaxDiskWritePS float64 `json:"wm,omitempty" cbor:"5,keyasint,omitempty"`
}
type NetIoStats struct {
@@ -64,26 +72,42 @@ type NetIoStats struct {
Name string
}
type Os = uint8
const (
Linux Os = iota
Darwin
Windows
Freebsd
)
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"`
GpuPct float64 `json:"g,omitempty"`
DashboardTemp float64 `json:"dt,omitempty"`
Hostname string `json:"h" cbor:"0,keyasint"`
KernelVersion string `json:"k,omitempty" cbor:"1,keyasint,omitempty"`
Cores int `json:"c" cbor:"2,keyasint"`
Threads int `json:"t,omitempty" cbor:"3,keyasint,omitempty"`
CpuModel string `json:"m" cbor:"4,keyasint"`
Uptime uint64 `json:"u" cbor:"5,keyasint"`
Cpu float64 `json:"cpu" cbor:"6,keyasint"`
MemPct float64 `json:"mp" cbor:"7,keyasint"`
DiskPct float64 `json:"dp" cbor:"8,keyasint"`
Bandwidth float64 `json:"b" cbor:"9,keyasint"`
AgentVersion string `json:"v" cbor:"10,keyasint"`
Podman bool `json:"p,omitempty" cbor:"11,keyasint,omitempty"`
GpuPct float64 `json:"g,omitempty" cbor:"12,keyasint,omitempty"`
DashboardTemp float64 `json:"dt,omitempty" cbor:"13,keyasint,omitempty"`
Os Os `json:"os" cbor:"14,keyasint"`
LoadAvg1 float64 `json:"l1,omitempty" cbor:"15,keyasint,omitempty"`
LoadAvg5 float64 `json:"l5,omitempty" cbor:"16,keyasint,omitempty"`
LoadAvg15 float64 `json:"l15,omitempty" cbor:"17,keyasint,omitempty"`
BandwidthBytes uint64 `json:"bb" cbor:"18,keyasint"`
// TODO: remove load fields in future release in favor of load avg array
LoadAvg [3]float64 `json:"la,omitempty" cbor:"19,keyasint"`
}
// Final data structure to return to the hub
type CombinedData struct {
Stats Stats `json:"stats"`
Info Info `json:"info"`
Containers []*container.Stats `json:"container"`
Stats Stats `json:"stats" cbor:"0,keyasint"`
Info Info `json:"info" cbor:"1,keyasint"`
Containers []*container.Stats `json:"container" cbor:"2,keyasint"`
}

View File

@@ -0,0 +1,401 @@
// Package ghupdate implements a new command to self update the current
// executable with the latest GitHub release. This is based on PocketBase's
// ghupdate package with modifications.
package ghupdate
import (
"archive/tar"
"beszel"
"compress/gzip"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"path/filepath"
"runtime"
"strings"
"github.com/blang/semver"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/archive"
)
// Minimal color functions using ANSI escape codes
const (
colorReset = "\033[0m"
ColorYellow = "\033[33m"
colorGreen = "\033[32m"
colorCyan = "\033[36m"
colorGray = "\033[90m"
)
func ColorPrint(color, text string) {
fmt.Println(color + text + colorReset)
}
func ColorPrintf(color, format string, args ...interface{}) {
fmt.Printf(color+format+colorReset+"\n", args...)
}
// HttpClient is a base HTTP client interface (usually used for test purposes).
type HttpClient interface {
Do(req *http.Request) (*http.Response, error)
}
// Config defines the config options of the ghupdate plugin.
//
// NB! This plugin is considered experimental and its config options may change in the future.
type Config struct {
// Owner specifies the account owner of the repository (default to "pocketbase").
Owner string
// Repo specifies the name of the repository (default to "pocketbase").
Repo string
// ArchiveExecutable specifies the name of the executable file in the release archive
// (default to "pocketbase"; an additional ".exe" check is also performed as a fallback).
ArchiveExecutable string
// Optional context to use when fetching and downloading the latest release.
Context context.Context
// The HTTP client to use when fetching and downloading the latest release.
// Defaults to `http.DefaultClient`.
HttpClient HttpClient
// The data directory to use when fetching and downloading the latest release.
DataDir string
}
func Update(config Config) (updated bool, err error) {
p := &plugin{
currentVersion: beszel.Version,
config: config,
}
return p.update()
}
type plugin struct {
config Config
currentVersion string
}
func (p *plugin) update() (updated bool, err error) {
ColorPrint(ColorYellow, "Fetching release information...")
if p.config.DataDir == "" {
p.config.DataDir = os.TempDir()
}
if p.config.Owner == "" {
p.config.Owner = "henrygd"
}
if p.config.Repo == "" {
p.config.Repo = "beszel"
}
if p.config.Context == nil {
p.config.Context = context.Background()
}
if p.config.HttpClient == nil {
p.config.HttpClient = http.DefaultClient
}
var latest *release
latest, err = fetchLatestRelease(
p.config.Context,
p.config.HttpClient,
fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", p.config.Owner, p.config.Repo),
)
// if the first fetch fails, try the beszel.dev API (fallback for China)
if err != nil {
ColorPrint(ColorYellow, "Failed to fetch release. Trying beszel.dev mirror...")
latest, err = fetchLatestRelease(
p.config.Context,
p.config.HttpClient,
fmt.Sprintf("https://gh.beszel.dev/repos/%s/%s/releases/latest?api=true", p.config.Owner, p.config.Repo),
)
}
if err != nil {
return false, err
}
currentVersion := semver.MustParse(strings.TrimPrefix(p.currentVersion, "v"))
newVersion := semver.MustParse(strings.TrimPrefix(latest.Tag, "v"))
if newVersion.LTE(currentVersion) {
ColorPrintf(colorGreen, "You already have the latest version %s.", p.currentVersion)
return false, nil
}
suffix := archiveSuffix(p.config.ArchiveExecutable, runtime.GOOS, runtime.GOARCH)
asset, err := latest.findAssetBySuffix(suffix)
if err != nil {
return false, err
}
releaseDir := filepath.Join(p.config.DataDir, core.LocalTempDirName)
defer os.RemoveAll(releaseDir)
ColorPrintf(ColorYellow, "Downloading %s...", asset.Name)
// download the release asset
assetPath := filepath.Join(releaseDir, asset.Name)
if err := downloadFile(p.config.Context, p.config.HttpClient, asset.DownloadUrl, assetPath); err != nil {
return false, err
}
ColorPrintf(ColorYellow, "Extracting %s...", asset.Name)
extractDir := filepath.Join(releaseDir, "extracted_"+asset.Name)
defer os.RemoveAll(extractDir)
// Extract based on file extension
if strings.HasSuffix(asset.Name, ".tar.gz") {
if err := extractTarGz(assetPath, extractDir); err != nil {
return false, err
}
} else {
if err := archive.Extract(assetPath, extractDir); err != nil {
return false, err
}
}
ColorPrint(ColorYellow, "Replacing the executable...")
oldExec, err := os.Executable()
if err != nil {
return false, err
}
renamedOldExec := oldExec + ".old"
defer os.Remove(renamedOldExec)
newExec := filepath.Join(extractDir, p.config.ArchiveExecutable)
if _, err := os.Stat(newExec); err != nil {
// try again with an .exe extension
newExec = newExec + ".exe"
if _, fallbackErr := os.Stat(newExec); fallbackErr != nil {
return false, fmt.Errorf("the executable in the extracted path is missing or it is inaccessible: %v, %v", err, fallbackErr)
}
}
// rename the current executable
if err := os.Rename(oldExec, renamedOldExec); err != nil {
return false, fmt.Errorf("failed to rename the current executable: %w", err)
}
tryToRevertExecChanges := func() {
if revertErr := os.Rename(renamedOldExec, oldExec); revertErr != nil {
slog.Debug(
"Failed to revert executable",
slog.String("old", renamedOldExec),
slog.String("new", oldExec),
slog.String("error", revertErr.Error()),
)
}
}
// replace with the extracted binary
if err := os.Rename(newExec, oldExec); err != nil {
// If rename fails due to cross-device link, try copying instead
if isCrossDeviceError(err) {
if err := copyFile(newExec, oldExec); err != nil {
tryToRevertExecChanges()
return false, fmt.Errorf("failed replacing the executable: %w", err)
}
} else {
tryToRevertExecChanges()
return false, fmt.Errorf("failed replacing the executable: %w", err)
}
}
ColorPrint(colorGray, "---")
ColorPrint(colorGreen, "Update completed successfully! You can start the executable as usual.")
// print the release notes
if latest.Body != "" {
fmt.Print("\n")
ColorPrintf(colorCyan, "Here is a list with some of the %s changes:", latest.Tag)
// remove the update command note to avoid "stuttering"
// (@todo consider moving to a config option)
releaseNotes := strings.TrimSpace(strings.Replace(latest.Body, "> _To update the prebuilt executable you can run `./"+p.config.ArchiveExecutable+" update`._", "", 1))
ColorPrint(colorCyan, releaseNotes)
fmt.Print("\n")
}
return true, nil
}
func fetchLatestRelease(
ctx context.Context,
client HttpClient,
url string,
) (*release, error) {
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}
res, err := client.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
rawBody, err := io.ReadAll(res.Body)
if err != nil {
return nil, err
}
// http.Client doesn't treat non 2xx responses as error
if res.StatusCode >= 400 {
return nil, fmt.Errorf(
"(%d) failed to fetch latest releases:\n%s",
res.StatusCode,
string(rawBody),
)
}
result := &release{}
if err := json.Unmarshal(rawBody, result); err != nil {
return nil, err
}
return result, nil
}
func downloadFile(
ctx context.Context,
client HttpClient,
url string,
destPath string,
) error {
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return err
}
res, err := client.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
// http.Client doesn't treat non 2xx responses as error
if res.StatusCode >= 400 {
return fmt.Errorf("(%d) failed to send download file request", res.StatusCode)
}
// ensure that the dest parent dir(s) exist
if err := os.MkdirAll(filepath.Dir(destPath), os.ModePerm); err != nil {
return err
}
dest, err := os.Create(destPath)
if err != nil {
return err
}
defer dest.Close()
if _, err := io.Copy(dest, res.Body); err != nil {
return err
}
return nil
}
// isCrossDeviceError checks if the error is due to a cross-device link
func isCrossDeviceError(err error) bool {
return err != nil && (strings.Contains(err.Error(), "cross-device") ||
strings.Contains(err.Error(), "EXDEV"))
}
// copyFile copies a file from src to dst, preserving permissions
func copyFile(src, dst string) error {
sourceFile, err := os.Open(src)
if err != nil {
return err
}
defer sourceFile.Close()
destFile, err := os.Create(dst)
if err != nil {
return err
}
defer destFile.Close()
// Copy the file contents
if _, err := io.Copy(destFile, sourceFile); err != nil {
return err
}
// Preserve the original file permissions
sourceInfo, err := sourceFile.Stat()
if err != nil {
return err
}
return destFile.Chmod(sourceInfo.Mode())
}
func archiveSuffix(binaryName, goos, goarch string) string {
if goos == "windows" {
return fmt.Sprintf("%s_%s_%s.zip", binaryName, goos, goarch)
}
return fmt.Sprintf("%s_%s_%s.tar.gz", binaryName, goos, goarch)
}
func extractTarGz(srcPath, destDir string) error {
src, err := os.Open(srcPath)
if err != nil {
return err
}
defer src.Close()
gz, err := gzip.NewReader(src)
if err != nil {
return err
}
defer gz.Close()
tr := tar.NewReader(gz)
for {
header, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return err
}
if header.Typeflag == tar.TypeDir {
if err := os.MkdirAll(filepath.Join(destDir, header.Name), 0755); err != nil {
return err
}
continue
}
if err := os.MkdirAll(filepath.Dir(filepath.Join(destDir, header.Name)), 0755); err != nil {
return err
}
outFile, err := os.Create(filepath.Join(destDir, header.Name))
if err != nil {
return err
}
defer outFile.Close()
if _, err := io.Copy(outFile, tr); err != nil {
return err
}
}
return nil
}

View File

@@ -0,0 +1,23 @@
package ghupdate
import "testing"
func TestReleaseFindAssetBySuffix(t *testing.T) {
r := release{
Assets: []*releaseAsset{
{Name: "test1.zip", Id: 1},
{Name: "test2.zip", Id: 2},
{Name: "test22.zip", Id: 22},
{Name: "test3.zip", Id: 3},
},
}
asset, err := r.findAssetBySuffix("2.zip")
if err != nil {
t.Fatalf("Expected nil, got err: %v", err)
}
if asset.Id != 2 {
t.Fatalf("Expected asset with id %d, got %v", 2, asset)
}
}

View File

@@ -0,0 +1,36 @@
package ghupdate
import (
"errors"
"strings"
)
type releaseAsset struct {
Name string `json:"name"`
DownloadUrl string `json:"browser_download_url"`
Id int `json:"id"`
Size int `json:"size"`
}
type release struct {
Name string `json:"name"`
Tag string `json:"tag_name"`
Published string `json:"published_at"`
Url string `json:"html_url"`
Body string `json:"body"`
Assets []*releaseAsset `json:"assets"`
Id int `json:"id"`
}
// findAssetBySuffix returns the first available asset containing the specified suffix.
func (r *release) findAssetBySuffix(suffix string) (*releaseAsset, error) {
if suffix != "" {
for _, asset := range r.Assets {
if strings.HasSuffix(asset.Name, suffix) {
return asset, nil
}
}
}
return nil, errors.New("missing asset containing " + suffix)
}

View File

@@ -0,0 +1,320 @@
package hub
import (
"beszel/internal/common"
"beszel/internal/hub/expirymap"
"beszel/internal/hub/ws"
"errors"
"net"
"net/http"
"strings"
"sync"
"time"
"github.com/blang/semver"
"github.com/lxzan/gws"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
)
// agentConnectRequest holds information related to an agent's connection attempt.
type agentConnectRequest struct {
hub *Hub
req *http.Request
res http.ResponseWriter
token string
agentSemVer semver.Version
// isUniversalToken is true if the token is a universal token.
isUniversalToken bool
// userId is the user ID associated with the universal token.
userId string
}
// universalTokenMap stores active universal tokens and their associated user IDs.
var universalTokenMap tokenMap
type tokenMap struct {
store *expirymap.ExpiryMap[string]
once sync.Once
}
// getMap returns the expirymap, creating it if necessary.
func (tm *tokenMap) GetMap() *expirymap.ExpiryMap[string] {
tm.once.Do(func() {
tm.store = expirymap.New[string](time.Hour)
})
return tm.store
}
// handleAgentConnect is the HTTP handler for an agent's connection request.
func (h *Hub) handleAgentConnect(e *core.RequestEvent) error {
agentRequest := agentConnectRequest{req: e.Request, res: e.Response, hub: h}
_ = agentRequest.agentConnect()
return nil
}
// agentConnect validates agent credentials and upgrades the connection to a WebSocket.
func (acr *agentConnectRequest) agentConnect() (err error) {
var agentVersion string
acr.token, agentVersion, err = acr.validateAgentHeaders(acr.req.Header)
if err != nil {
return acr.sendResponseError(acr.res, http.StatusBadRequest, "")
}
// Check if token is an active universal token
acr.userId, acr.isUniversalToken = universalTokenMap.GetMap().GetOk(acr.token)
// Find matching fingerprint records for this token
fpRecords := getFingerprintRecordsByToken(acr.token, acr.hub)
if len(fpRecords) == 0 && !acr.isUniversalToken {
// Invalid token - no records found and not a universal token
return acr.sendResponseError(acr.res, http.StatusUnauthorized, "Invalid token")
}
// Validate agent version
acr.agentSemVer, err = semver.Parse(agentVersion)
if err != nil {
return acr.sendResponseError(acr.res, http.StatusUnauthorized, "Invalid agent version")
}
// Upgrade connection to WebSocket
conn, err := ws.GetUpgrader().Upgrade(acr.res, acr.req)
if err != nil {
return acr.sendResponseError(acr.res, http.StatusInternalServerError, "WebSocket upgrade failed")
}
go acr.verifyWsConn(conn, fpRecords)
return nil
}
// verifyWsConn verifies the WebSocket connection using the agent's fingerprint and
// SSH key signature, then adds the system to the system manager.
func (acr *agentConnectRequest) verifyWsConn(conn *gws.Conn, fpRecords []ws.FingerprintRecord) (err error) {
wsConn := ws.NewWsConnection(conn)
// must set wsConn in connection store before the read loop
conn.Session().Store("wsConn", wsConn)
// make sure connection is closed if there is an error
defer func() {
if err != nil {
wsConn.Close([]byte(err.Error()))
}
}()
go conn.ReadLoop()
signer, err := acr.hub.GetSSHKey("")
if err != nil {
return err
}
agentFingerprint, err := wsConn.GetFingerprint(acr.token, signer, acr.isUniversalToken)
if err != nil {
return err
}
// Find or create the appropriate system for this token and fingerprint
fpRecord, err := acr.findOrCreateSystemForToken(fpRecords, agentFingerprint)
if err != nil {
return err
}
return acr.hub.sm.AddWebSocketSystem(fpRecord.SystemId, acr.agentSemVer, wsConn)
}
// validateAgentHeaders extracts and validates the token and agent version from HTTP headers.
func (acr *agentConnectRequest) validateAgentHeaders(headers http.Header) (string, string, error) {
token := headers.Get("X-Token")
agentVersion := headers.Get("X-Beszel")
if agentVersion == "" || token == "" || len(token) > 64 {
return "", "", errors.New("")
}
return token, agentVersion, nil
}
// sendResponseError writes an HTTP error response.
func (acr *agentConnectRequest) sendResponseError(res http.ResponseWriter, code int, message string) error {
res.WriteHeader(code)
if message != "" {
res.Write([]byte(message))
}
return nil
}
// getFingerprintRecordsByToken retrieves all fingerprint records associated with a given token.
func getFingerprintRecordsByToken(token string, h *Hub) []ws.FingerprintRecord {
var records []ws.FingerprintRecord
// All will populate empty slice even on error
_ = h.DB().NewQuery("SELECT id, system, fingerprint, token FROM fingerprints WHERE token = {:token}").
Bind(dbx.Params{
"token": token,
}).
All(&records)
return records
}
// findOrCreateSystemForToken finds an existing system matching the token and fingerprint,
// or creates a new one for a universal token.
func (acr *agentConnectRequest) findOrCreateSystemForToken(fpRecords []ws.FingerprintRecord, agentFingerprint common.FingerprintResponse) (ws.FingerprintRecord, error) {
// No records - only valid for active universal tokens
if len(fpRecords) == 0 {
return acr.handleNoRecords(agentFingerprint)
}
// Single record - handle as regular token
if len(fpRecords) == 1 && !acr.isUniversalToken {
return acr.handleSingleRecord(fpRecords[0], agentFingerprint)
}
// Multiple records or universal token - look for matching fingerprint
return acr.handleMultipleRecordsOrUniversalToken(fpRecords, agentFingerprint)
}
// handleNoRecords handles the case where no fingerprint records are found for a token.
// A new system is created if the token is a valid universal token.
func (acr *agentConnectRequest) handleNoRecords(agentFingerprint common.FingerprintResponse) (ws.FingerprintRecord, error) {
var fpRecord ws.FingerprintRecord
if !acr.isUniversalToken || acr.userId == "" {
return fpRecord, errors.New("no matching fingerprints")
}
return acr.createNewSystemForUniversalToken(agentFingerprint)
}
// handleSingleRecord handles the case with a single fingerprint record. It validates
// the agent's fingerprint against the stored one, or sets it on first connect.
func (acr *agentConnectRequest) handleSingleRecord(fpRecord ws.FingerprintRecord, agentFingerprint common.FingerprintResponse) (ws.FingerprintRecord, error) {
// If no current fingerprint, update with new fingerprint (first time connecting)
if fpRecord.Fingerprint == "" {
if err := acr.hub.SetFingerprint(&fpRecord, agentFingerprint.Fingerprint); err != nil {
return fpRecord, err
}
// Update the record with the fingerprint that was set
fpRecord.Fingerprint = agentFingerprint.Fingerprint
return fpRecord, nil
}
// Abort if fingerprint exists but doesn't match (different machine)
if fpRecord.Fingerprint != agentFingerprint.Fingerprint {
return fpRecord, errors.New("fingerprint mismatch")
}
return fpRecord, nil
}
// handleMultipleRecordsOrUniversalToken finds a matching fingerprint from multiple records.
// If no match is found and the token is a universal token, a new system is created.
func (acr *agentConnectRequest) handleMultipleRecordsOrUniversalToken(fpRecords []ws.FingerprintRecord, agentFingerprint common.FingerprintResponse) (ws.FingerprintRecord, error) {
// Return existing record with matching fingerprint if found
for i := range fpRecords {
if fpRecords[i].Fingerprint == agentFingerprint.Fingerprint {
return fpRecords[i], nil
}
}
// No matching fingerprint record found, but it's
// an active universal token so create a new system
if acr.isUniversalToken {
return acr.createNewSystemForUniversalToken(agentFingerprint)
}
return ws.FingerprintRecord{}, errors.New("fingerprint mismatch")
}
// createNewSystemForUniversalToken creates a new system and fingerprint record for a universal token.
func (acr *agentConnectRequest) createNewSystemForUniversalToken(agentFingerprint common.FingerprintResponse) (ws.FingerprintRecord, error) {
var fpRecord ws.FingerprintRecord
if !acr.isUniversalToken || acr.userId == "" {
return fpRecord, errors.New("invalid token")
}
fpRecord.Token = acr.token
systemId, err := acr.createSystem(agentFingerprint)
if err != nil {
return fpRecord, err
}
fpRecord.SystemId = systemId
// Set the fingerprint for the new system
if err := acr.hub.SetFingerprint(&fpRecord, agentFingerprint.Fingerprint); err != nil {
return fpRecord, err
}
// Update the record with the fingerprint that was set
fpRecord.Fingerprint = agentFingerprint.Fingerprint
return fpRecord, nil
}
// createSystem creates a new system record in the database using details from the agent.
func (acr *agentConnectRequest) createSystem(agentFingerprint common.FingerprintResponse) (recordId string, err error) {
systemsCollection, err := acr.hub.FindCachedCollectionByNameOrId("systems")
if err != nil {
return "", err
}
remoteAddr := getRealIP(acr.req)
// separate port from address
if agentFingerprint.Hostname == "" {
agentFingerprint.Hostname = remoteAddr
}
if agentFingerprint.Port == "" {
agentFingerprint.Port = "45876"
}
// create new record
systemRecord := core.NewRecord(systemsCollection)
systemRecord.Set("name", agentFingerprint.Hostname)
systemRecord.Set("host", remoteAddr)
systemRecord.Set("port", agentFingerprint.Port)
systemRecord.Set("users", []string{acr.userId})
return systemRecord.Id, acr.hub.Save(systemRecord)
}
// SetFingerprint creates or updates a fingerprint record in the database.
func (h *Hub) SetFingerprint(fpRecord *ws.FingerprintRecord, fingerprint string) (err error) {
// // can't use raw query here because it doesn't trigger SSE
var record *core.Record
switch fpRecord.Id {
case "":
// create new record for universal token
collection, _ := h.FindCachedCollectionByNameOrId("fingerprints")
record = core.NewRecord(collection)
record.Set("system", fpRecord.SystemId)
default:
record, err = h.FindRecordById("fingerprints", fpRecord.Id)
}
if err != nil {
return err
}
record.Set("token", fpRecord.Token)
record.Set("fingerprint", fingerprint)
return h.SaveNoValidate(record)
}
// getRealIP extracts the client's real IP address from request headers,
// checking common proxy headers before falling back to the remote address.
func getRealIP(r *http.Request) string {
if ip := r.Header.Get("CF-Connecting-IP"); ip != "" {
return ip
}
if ip := r.Header.Get("X-Forwarded-For"); ip != "" {
// X-Forwarded-For can contain a comma-separated list: "client_ip, proxy1, proxy2"
// Take the first one
ips := strings.Split(ip, ",")
if len(ips) > 0 {
return strings.TrimSpace(ips[0])
}
}
// Fallback to RemoteAddr
ip, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return ip
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,5 @@
package hub
// Package config provides functions for syncing systems with the config.yml file
package config
import (
"beszel/internal/entities/system"
@@ -6,35 +7,36 @@ import (
"log"
"os"
"path/filepath"
"strconv"
"github.com/google/uuid"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/spf13/cast"
"gopkg.in/yaml.v3"
)
type Config struct {
Systems []SystemConfig `yaml:"systems"`
type config struct {
Systems []systemConfig `yaml:"systems"`
}
type SystemConfig struct {
type systemConfig struct {
Name string `yaml:"name"`
Host string `yaml:"host"`
Port uint16 `yaml:"port,omitempty"`
Token string `yaml:"token,omitempty"`
Users []string `yaml:"users"`
}
// Syncs systems with the config.yml file
func (h *Hub) syncSystemsWithConfig() error {
func SyncSystems(e *core.ServeEvent) error {
h := e.App
configPath := filepath.Join(h.DataDir(), "config.yml")
configData, err := os.ReadFile(configPath)
if err != nil {
return nil
}
var config Config
var config config
err = yaml.Unmarshal(configData, &config)
if err != nil {
return fmt.Errorf("failed to parse config.yml: %v", err)
@@ -89,16 +91,16 @@ func (h *Hub) syncSystemsWithConfig() error {
return err
}
// Create a map of existing systems for easy lookup
// Create a map of existing systems
existingSystemsMap := make(map[string]*core.Record)
for _, system := range existingSystems {
key := system.GetString("host") + ":" + system.GetString("port")
key := system.GetString("name") + system.GetString("host") + system.GetString("port")
existingSystemsMap[key] = system
}
// Process systems from config
for _, sysConfig := range config.Systems {
key := sysConfig.Host + ":" + strconv.Itoa(int(sysConfig.Port))
key := sysConfig.Name + sysConfig.Host + cast.ToString(sysConfig.Port)
if existingSystem, ok := existingSystemsMap[key]; ok {
// Update existing system
existingSystem.Set("name", sysConfig.Name)
@@ -107,6 +109,14 @@ func (h *Hub) syncSystemsWithConfig() error {
if err := h.Save(existingSystem); err != nil {
return err
}
// Only update token if one is specified in config, otherwise preserve existing token
if sysConfig.Token != "" {
if err := updateFingerprintToken(h, existingSystem.Id, sysConfig.Token); err != nil {
return err
}
}
delete(existingSystemsMap, key)
} else {
// Create new system
@@ -124,10 +134,21 @@ func (h *Hub) syncSystemsWithConfig() error {
if err := h.Save(newSystem); err != nil {
return fmt.Errorf("failed to create new system: %v", err)
}
// For new systems, generate token if not provided
token := sysConfig.Token
if token == "" {
token = uuid.New().String()
}
// Create fingerprint record for new system
if err := createFingerprintRecord(h, newSystem.Id, token); err != nil {
return err
}
}
}
// Delete systems not in config
// Delete systems not in config (and their fingerprint records will cascade delete)
for _, system := range existingSystemsMap {
if err := h.Delete(system); err != nil {
return err
@@ -139,7 +160,7 @@ func (h *Hub) syncSystemsWithConfig() error {
}
// Generates content for the config.yml file as a YAML string
func (h *Hub) generateConfigYAML() (string, error) {
func generateYAML(h core.App) (string, error) {
// Fetch all systems from the database
systems, err := h.FindRecordsByFilter("systems", "id != ''", "name", -1, 0)
if err != nil {
@@ -147,8 +168,8 @@ func (h *Hub) generateConfigYAML() (string, error) {
}
// Create a Config struct to hold the data
config := Config{
Systems: make([]SystemConfig, 0, len(systems)),
config := config{
Systems: make([]systemConfig, 0, len(systems)),
}
// Fetch all users at once
@@ -156,11 +177,29 @@ func (h *Hub) generateConfigYAML() (string, error) {
for _, system := range systems {
allUserIDs = append(allUserIDs, system.GetStringSlice("users")...)
}
userEmailMap, err := h.getUserEmailMap(allUserIDs)
userEmailMap, err := getUserEmailMap(h, allUserIDs)
if err != nil {
return "", err
}
// Fetch all fingerprint records to get tokens
type fingerprintData struct {
ID string `db:"id"`
System string `db:"system"`
Token string `db:"token"`
}
var fingerprints []fingerprintData
err = h.DB().NewQuery("SELECT id, system, token FROM fingerprints").All(&fingerprints)
if err != nil {
return "", err
}
// Create a map of system ID to token
systemTokenMap := make(map[string]string)
for _, fingerprint := range fingerprints {
systemTokenMap[fingerprint.System] = fingerprint.Token
}
// Populate the Config struct with system data
for _, system := range systems {
userIDs := system.GetStringSlice("users")
@@ -171,11 +210,12 @@ func (h *Hub) generateConfigYAML() (string, error) {
}
}
sysConfig := SystemConfig{
sysConfig := systemConfig{
Name: system.GetString("name"),
Host: system.GetString("host"),
Port: cast.ToUint16(system.Get("port")),
Users: userEmails,
Token: systemTokenMap[system.Id],
}
config.Systems = append(config.Systems, sysConfig)
}
@@ -187,13 +227,13 @@ func (h *Hub) generateConfigYAML() (string, error) {
}
// Add a header to the YAML
yamlData = append([]byte("# Values for port and users are optional.\n# Defaults are port 45876 and the first created user.\n\n"), yamlData...)
yamlData = append([]byte("# Values for port, users, and token are optional.\n# Defaults are port 45876, the first created user, and a generated UUID token.\n\n"), yamlData...)
return string(yamlData), nil
}
// New helper function to get a map of user IDs to emails
func (h *Hub) getUserEmailMap(userIDs []string) (map[string]string, error) {
func getUserEmailMap(h core.App, userIDs []string) (map[string]string, error) {
users, err := h.FindRecordsByIds("users", userIDs)
if err != nil {
return nil, err
@@ -207,13 +247,41 @@ func (h *Hub) getUserEmailMap(userIDs []string) (map[string]string, error) {
return userEmailMap, nil
}
// Returns the current config.yml file as a JSON object
func (h *Hub) getYamlConfig(e *core.RequestEvent) error {
info, _ := e.RequestInfo()
if info.Auth == nil || info.Auth.GetString("role") != "admin" {
return apis.NewForbiddenError("Forbidden", nil)
// Helper function to update or create fingerprint token for an existing system
func updateFingerprintToken(app core.App, systemID, token string) error {
// Try to find existing fingerprint record
fingerprint, err := app.FindFirstRecordByFilter("fingerprints", "system = {:system}", dbx.Params{"system": systemID})
if err != nil {
// If no fingerprint record exists, create one
return createFingerprintRecord(app, systemID, token)
}
configContent, err := h.generateConfigYAML()
// Update existing fingerprint record with new token (keep existing fingerprint)
fingerprint.Set("token", token)
return app.Save(fingerprint)
}
// Helper function to create a new fingerprint record for a system
func createFingerprintRecord(app core.App, systemID, token string) error {
fingerprintsCollection, err := app.FindCollectionByNameOrId("fingerprints")
if err != nil {
return fmt.Errorf("failed to find fingerprints collection: %v", err)
}
newFingerprint := core.NewRecord(fingerprintsCollection)
newFingerprint.Set("system", systemID)
newFingerprint.Set("token", token)
newFingerprint.Set("fingerprint", "") // Empty fingerprint, will be set on first connection
return app.Save(newFingerprint)
}
// Returns the current config.yml file as a JSON object
func GetYamlConfig(e *core.RequestEvent) error {
if e.Auth.GetString("role") != "admin" {
return e.ForbiddenError("Requires admin role", nil)
}
configContent, err := generateYAML(e.App)
if err != nil {
return err
}

View File

@@ -0,0 +1,245 @@
//go:build testing
// +build testing
package config_test
import (
"beszel/internal/hub/config"
"beszel/internal/tests"
"os"
"path/filepath"
"testing"
"github.com/pocketbase/pocketbase/core"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
)
// Config struct for testing (copied from config package since it's not exported)
type testConfig struct {
Systems []testSystemConfig `yaml:"systems"`
}
type testSystemConfig struct {
Name string `yaml:"name"`
Host string `yaml:"host"`
Port uint16 `yaml:"port,omitempty"`
Users []string `yaml:"users"`
Token string `yaml:"token,omitempty"`
}
// Helper function to create a test system for config tests
// func createConfigTestSystem(app core.App, name, host string, port uint16, userIDs []string) (*core.Record, error) {
// systemCollection, err := app.FindCollectionByNameOrId("systems")
// if err != nil {
// return nil, err
// }
// system := core.NewRecord(systemCollection)
// system.Set("name", name)
// system.Set("host", host)
// system.Set("port", port)
// system.Set("users", userIDs)
// system.Set("status", "pending")
// return system, app.Save(system)
// }
// Helper function to create a fingerprint record
func createConfigTestFingerprint(app core.App, systemID, token, fingerprint string) (*core.Record, error) {
fingerprintCollection, err := app.FindCollectionByNameOrId("fingerprints")
if err != nil {
return nil, err
}
fp := core.NewRecord(fingerprintCollection)
fp.Set("system", systemID)
fp.Set("token", token)
fp.Set("fingerprint", fingerprint)
return fp, app.Save(fp)
}
// TestConfigSyncWithTokens tests the config.SyncSystems function with various token scenarios
func TestConfigSyncWithTokens(t *testing.T) {
testHub, err := tests.NewTestHub()
require.NoError(t, err)
defer testHub.Cleanup()
// Create test user
user, err := tests.CreateUser(testHub.App, "admin@example.com", "testtesttest")
require.NoError(t, err)
testCases := []struct {
name string
setupFunc func() (string, *core.Record, *core.Record) // Returns: existing token, system record, fingerprint record
configYAML string
expectToken string // Expected token after sync
description string
}{
{
name: "new system with token in config",
setupFunc: func() (string, *core.Record, *core.Record) {
return "", nil, nil // No existing system
},
configYAML: `systems:
- name: "new-server"
host: "new.example.com"
port: 45876
users:
- "admin@example.com"
token: "explicit-token-123"`,
expectToken: "explicit-token-123",
description: "New system should use token from config",
},
{
name: "existing system without token in config (preserve existing)",
setupFunc: func() (string, *core.Record, *core.Record) {
// Create existing system and fingerprint
system, err := tests.CreateRecord(testHub.App, "systems", map[string]any{
"name": "preserve-server",
"host": "preserve.example.com",
"port": 45876,
"users": []string{user.Id},
})
require.NoError(t, err)
fingerprint, err := createConfigTestFingerprint(testHub.App, system.Id, "preserve-token-999", "preserve-fingerprint")
require.NoError(t, err)
return "preserve-token-999", system, fingerprint
},
configYAML: `systems:
- name: "preserve-server"
host: "preserve.example.com"
port: 45876
users:
- "admin@example.com"`,
expectToken: "preserve-token-999",
description: "Existing system should preserve original token when config doesn't specify one",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Setup test data
_, existingSystem, existingFingerprint := tc.setupFunc()
// Write config file
configPath := filepath.Join(testHub.DataDir(), "config.yml")
err := os.WriteFile(configPath, []byte(tc.configYAML), 0644)
require.NoError(t, err)
// Create serve event and sync
event := &core.ServeEvent{App: testHub.App}
err = config.SyncSystems(event)
require.NoError(t, err)
// Parse the config to get the system name for verification
var configData testConfig
err = yaml.Unmarshal([]byte(tc.configYAML), &configData)
require.NoError(t, err)
require.Len(t, configData.Systems, 1)
systemName := configData.Systems[0].Name
// Find the system after sync
systems, err := testHub.FindRecordsByFilter("systems", "name = {:name}", "", -1, 0, map[string]any{"name": systemName})
require.NoError(t, err)
require.Len(t, systems, 1)
system := systems[0]
// Find the fingerprint record
fingerprints, err := testHub.FindRecordsByFilter("fingerprints", "system = {:system}", "", -1, 0, map[string]any{"system": system.Id})
require.NoError(t, err)
require.Len(t, fingerprints, 1)
fingerprint := fingerprints[0]
// Verify token
actualToken := fingerprint.GetString("token")
if tc.expectToken == "" {
// For generated tokens, just verify it's not empty and is a valid UUID format
assert.NotEmpty(t, actualToken, tc.description)
assert.Len(t, actualToken, 36, "Generated token should be UUID format") // UUID length
} else {
assert.Equal(t, tc.expectToken, actualToken, tc.description)
}
// For existing systems, verify fingerprint is preserved
if existingFingerprint != nil {
actualFingerprint := fingerprint.GetString("fingerprint")
expectedFingerprint := existingFingerprint.GetString("fingerprint")
assert.Equal(t, expectedFingerprint, actualFingerprint, "Fingerprint should be preserved")
}
// Cleanup for next test
if existingSystem != nil {
testHub.Delete(existingSystem)
}
if existingFingerprint != nil {
testHub.Delete(existingFingerprint)
}
// Clean up the new records
testHub.Delete(system)
testHub.Delete(fingerprint)
})
}
}
// TestConfigMigrationScenario tests the specific migration scenario mentioned in the discussion
func TestConfigMigrationScenario(t *testing.T) {
testHub, err := tests.NewTestHub(t.TempDir())
require.NoError(t, err)
defer testHub.Cleanup()
// Create test user
user, err := tests.CreateUser(testHub.App, "admin@example.com", "testtesttest")
require.NoError(t, err)
// Simulate migration scenario: system exists with token from migration
existingSystem, err := tests.CreateRecord(testHub.App, "systems", map[string]any{
"name": "migrated-server",
"host": "migrated.example.com",
"port": 45876,
"users": []string{user.Id},
})
require.NoError(t, err)
migrationToken := "migration-generated-token-123"
existingFingerprint, err := createConfigTestFingerprint(testHub.App, existingSystem.Id, migrationToken, "existing-fingerprint-from-agent")
require.NoError(t, err)
// User exports config BEFORE this update (so no token field in YAML)
oldConfigYAML := `systems:
- name: "migrated-server"
host: "migrated.example.com"
port: 45876
users:
- "admin@example.com"`
// Write old config file and import
configPath := filepath.Join(testHub.DataDir(), "config.yml")
err = os.WriteFile(configPath, []byte(oldConfigYAML), 0644)
require.NoError(t, err)
event := &core.ServeEvent{App: testHub.App}
err = config.SyncSystems(event)
require.NoError(t, err)
// Verify the original token is preserved
updatedFingerprint, err := testHub.FindRecordById("fingerprints", existingFingerprint.Id)
require.NoError(t, err)
actualToken := updatedFingerprint.GetString("token")
assert.Equal(t, migrationToken, actualToken, "Migration token should be preserved when config doesn't specify a token")
// Verify fingerprint is also preserved
actualFingerprint := updatedFingerprint.GetString("fingerprint")
assert.Equal(t, "existing-fingerprint-from-agent", actualFingerprint, "Existing fingerprint should be preserved")
// Verify system still exists and is updated correctly
updatedSystem, err := testHub.FindRecordById("systems", existingSystem.Id)
require.NoError(t, err)
assert.Equal(t, "migrated-server", updatedSystem.GetString("name"))
assert.Equal(t, "migrated.example.com", updatedSystem.GetString("host"))
}

View File

@@ -0,0 +1,104 @@
package expirymap
import (
"reflect"
"time"
"github.com/pocketbase/pocketbase/tools/store"
)
type val[T any] struct {
value T
expires time.Time
}
type ExpiryMap[T any] struct {
store *store.Store[string, *val[T]]
cleanupInterval time.Duration
}
// New creates a new expiry map with custom cleanup interval
func New[T any](cleanupInterval time.Duration) *ExpiryMap[T] {
m := &ExpiryMap[T]{
store: store.New(map[string]*val[T]{}),
cleanupInterval: cleanupInterval,
}
m.startCleaner()
return m
}
// Set stores a value with the given TTL
func (m *ExpiryMap[T]) Set(key string, value T, ttl time.Duration) {
m.store.Set(key, &val[T]{
value: value,
expires: time.Now().Add(ttl),
})
}
// GetOk retrieves a value and checks if it exists and hasn't expired
// Performs lazy cleanup of expired entries on access
func (m *ExpiryMap[T]) GetOk(key string) (T, bool) {
value, ok := m.store.GetOk(key)
if !ok {
return *new(T), false
}
// Check if expired and perform lazy cleanup
if value.expires.Before(time.Now()) {
m.store.Remove(key)
return *new(T), false
}
return value.value, true
}
// GetByValue retrieves a value by value
func (m *ExpiryMap[T]) GetByValue(val T) (key string, value T, ok bool) {
for key, v := range m.store.GetAll() {
if reflect.DeepEqual(v.value, val) {
// check if expired
if v.expires.Before(time.Now()) {
m.store.Remove(key)
break
}
return key, v.value, true
}
}
return "", *new(T), false
}
// Remove explicitly removes a key
func (m *ExpiryMap[T]) Remove(key string) {
m.store.Remove(key)
}
// RemovebyValue removes a value by value
func (m *ExpiryMap[T]) RemovebyValue(value T) (T, bool) {
for key, val := range m.store.GetAll() {
if reflect.DeepEqual(val.value, value) {
m.store.Remove(key)
return val.value, true
}
}
return *new(T), false
}
// startCleaner runs the background cleanup process
func (m *ExpiryMap[T]) startCleaner() {
go func() {
tick := time.Tick(m.cleanupInterval)
for range tick {
m.cleanup()
}
}()
}
// cleanup removes all expired entries
func (m *ExpiryMap[T]) cleanup() {
now := time.Now()
for key, val := range m.store.GetAll() {
if val.expires.Before(now) {
m.store.Remove(key)
}
}
}

View File

@@ -0,0 +1,477 @@
//go:build testing
// +build testing
package expirymap
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Not using the following methods but are useful for testing
// TESTING: Has checks if a key exists and hasn't expired
func (m *ExpiryMap[T]) Has(key string) bool {
_, ok := m.GetOk(key)
return ok
}
// TESTING: Get retrieves a value, returns zero value if not found or expired
func (m *ExpiryMap[T]) Get(key string) T {
value, _ := m.GetOk(key)
return value
}
// TESTING: Len returns the number of non-expired entries
func (m *ExpiryMap[T]) Len() int {
count := 0
now := time.Now()
for _, val := range m.store.Values() {
if val.expires.After(now) {
count++
}
}
return count
}
func TestExpiryMap_BasicOperations(t *testing.T) {
em := New[string](time.Hour)
// Test Set and GetOk
em.Set("key1", "value1", time.Hour)
value, ok := em.GetOk("key1")
assert.True(t, ok)
assert.Equal(t, "value1", value)
// Test Get
value = em.Get("key1")
assert.Equal(t, "value1", value)
// Test Has
assert.True(t, em.Has("key1"))
assert.False(t, em.Has("nonexistent"))
// Test Remove
em.Remove("key1")
assert.False(t, em.Has("key1"))
}
func TestExpiryMap_Expiration(t *testing.T) {
em := New[string](time.Hour)
// Set a value with very short TTL
em.Set("shortlived", "value", time.Millisecond*10)
// Should exist immediately
assert.True(t, em.Has("shortlived"))
// Wait for expiration
time.Sleep(time.Millisecond * 20)
// Should be expired and automatically cleaned up on access
assert.False(t, em.Has("shortlived"))
value, ok := em.GetOk("shortlived")
assert.False(t, ok)
assert.Equal(t, "", value) // zero value for string
}
func TestExpiryMap_LazyCleanup(t *testing.T) {
em := New[int](time.Hour)
// Set multiple values with short TTL
em.Set("key1", 1, time.Millisecond*10)
em.Set("key2", 2, time.Millisecond*10)
em.Set("key3", 3, time.Hour) // This one won't expire
// Wait for expiration
time.Sleep(time.Millisecond * 20)
// Access expired keys should trigger lazy cleanup
_, ok := em.GetOk("key1")
assert.False(t, ok)
// Non-expired key should still exist
value, ok := em.GetOk("key3")
assert.True(t, ok)
assert.Equal(t, 3, value)
}
func TestExpiryMap_Len(t *testing.T) {
em := New[string](time.Hour)
// Initially empty
assert.Equal(t, 0, em.Len())
// Add some values
em.Set("key1", "value1", time.Hour)
em.Set("key2", "value2", time.Hour)
em.Set("key3", "value3", time.Millisecond*10) // Will expire soon
// Should count all initially
assert.Equal(t, 3, em.Len())
// Wait for one to expire
time.Sleep(time.Millisecond * 20)
// Len should reflect only non-expired entries
assert.Equal(t, 2, em.Len())
}
func TestExpiryMap_CustomInterval(t *testing.T) {
// Create with very short cleanup interval for testing
em := New[string](time.Millisecond * 50)
// Set a value that expires quickly
em.Set("test", "value", time.Millisecond*10)
// Should exist initially
assert.True(t, em.Has("test"))
// Wait for expiration + cleanup cycle
time.Sleep(time.Millisecond * 100)
// Should be cleaned up by background process
// Note: This test might be flaky due to timing, but demonstrates the concept
assert.False(t, em.Has("test"))
}
func TestExpiryMap_GenericTypes(t *testing.T) {
// Test with different types
t.Run("Int", func(t *testing.T) {
em := New[int](time.Hour)
em.Set("num", 42, time.Hour)
value, ok := em.GetOk("num")
assert.True(t, ok)
assert.Equal(t, 42, value)
})
t.Run("Struct", func(t *testing.T) {
type TestStruct struct {
Name string
Age int
}
em := New[TestStruct](time.Hour)
expected := TestStruct{Name: "John", Age: 30}
em.Set("person", expected, time.Hour)
value, ok := em.GetOk("person")
assert.True(t, ok)
assert.Equal(t, expected, value)
})
t.Run("Pointer", func(t *testing.T) {
em := New[*string](time.Hour)
str := "hello"
em.Set("ptr", &str, time.Hour)
value, ok := em.GetOk("ptr")
assert.True(t, ok)
require.NotNil(t, value)
assert.Equal(t, "hello", *value)
})
}
func TestExpiryMap_ZeroValues(t *testing.T) {
em := New[string](time.Hour)
// Test getting non-existent key returns zero value
value := em.Get("nonexistent")
assert.Equal(t, "", value)
// Test getting expired key returns zero value
em.Set("expired", "value", time.Millisecond*10)
time.Sleep(time.Millisecond * 20)
value = em.Get("expired")
assert.Equal(t, "", value)
}
func TestExpiryMap_Concurrent(t *testing.T) {
em := New[int](time.Hour)
// Simple concurrent access test
done := make(chan bool, 2)
// Writer goroutine
go func() {
for i := 0; i < 100; i++ {
em.Set("key", i, time.Hour)
time.Sleep(time.Microsecond)
}
done <- true
}()
// Reader goroutine
go func() {
for i := 0; i < 100; i++ {
_ = em.Get("key")
time.Sleep(time.Microsecond)
}
done <- true
}()
// Wait for both to complete
<-done
<-done
// Should not panic and should have some value
assert.True(t, em.Has("key"))
}
func TestExpiryMap_GetByValue(t *testing.T) {
em := New[string](time.Hour)
// Test getting by value when value exists
em.Set("key1", "value1", time.Hour)
em.Set("key2", "value2", time.Hour)
em.Set("key3", "value1", time.Hour) // Duplicate value - should return first match
// Test successful retrieval
key, value, ok := em.GetByValue("value1")
assert.True(t, ok)
assert.Equal(t, "value1", value)
assert.Contains(t, []string{"key1", "key3"}, key) // Should be one of the keys with this value
// Test retrieval of unique value
key, value, ok = em.GetByValue("value2")
assert.True(t, ok)
assert.Equal(t, "value2", value)
assert.Equal(t, "key2", key)
// Test getting non-existent value
key, value, ok = em.GetByValue("nonexistent")
assert.False(t, ok)
assert.Equal(t, "", value) // zero value for string
assert.Equal(t, "", key) // zero value for string
}
func TestExpiryMap_GetByValue_Expiration(t *testing.T) {
em := New[string](time.Hour)
// Set a value with short TTL
em.Set("shortkey", "shortvalue", time.Millisecond*10)
em.Set("longkey", "longvalue", time.Hour)
// Should find the short-lived value initially
key, value, ok := em.GetByValue("shortvalue")
assert.True(t, ok)
assert.Equal(t, "shortvalue", value)
assert.Equal(t, "shortkey", key)
// Wait for expiration
time.Sleep(time.Millisecond * 20)
// Should not find expired value and should trigger lazy cleanup
key, value, ok = em.GetByValue("shortvalue")
assert.False(t, ok)
assert.Equal(t, "", value)
assert.Equal(t, "", key)
// Should still find non-expired value
key, value, ok = em.GetByValue("longvalue")
assert.True(t, ok)
assert.Equal(t, "longvalue", value)
assert.Equal(t, "longkey", key)
}
func TestExpiryMap_GetByValue_GenericTypes(t *testing.T) {
t.Run("Int", func(t *testing.T) {
em := New[int](time.Hour)
em.Set("num1", 42, time.Hour)
em.Set("num2", 84, time.Hour)
key, value, ok := em.GetByValue(42)
assert.True(t, ok)
assert.Equal(t, 42, value)
assert.Equal(t, "num1", key)
key, value, ok = em.GetByValue(99)
assert.False(t, ok)
assert.Equal(t, 0, value)
assert.Equal(t, "", key)
})
t.Run("Struct", func(t *testing.T) {
type TestStruct struct {
Name string
Age int
}
em := New[TestStruct](time.Hour)
person1 := TestStruct{Name: "John", Age: 30}
person2 := TestStruct{Name: "Jane", Age: 25}
em.Set("person1", person1, time.Hour)
em.Set("person2", person2, time.Hour)
key, value, ok := em.GetByValue(person1)
assert.True(t, ok)
assert.Equal(t, person1, value)
assert.Equal(t, "person1", key)
nonexistent := TestStruct{Name: "Bob", Age: 40}
key, value, ok = em.GetByValue(nonexistent)
assert.False(t, ok)
assert.Equal(t, TestStruct{}, value)
assert.Equal(t, "", key)
})
}
func TestExpiryMap_RemoveValue(t *testing.T) {
em := New[string](time.Hour)
// Test removing existing value
em.Set("key1", "value1", time.Hour)
em.Set("key2", "value2", time.Hour)
em.Set("key3", "value1", time.Hour) // Duplicate value
// Remove by value should remove one instance
removedValue, ok := em.RemovebyValue("value1")
assert.True(t, ok)
assert.Equal(t, "value1", removedValue)
// Should still have the other instance or value2
assert.True(t, em.Has("key2")) // value2 should still exist
// Check if one of the duplicate values was removed
// At least one key with "value1" should be gone
key1Exists := em.Has("key1")
key3Exists := em.Has("key3")
assert.False(t, key1Exists && key3Exists) // Both shouldn't exist
assert.True(t, key1Exists || key3Exists) // At least one should be gone
// Test removing non-existent value
removedValue, ok = em.RemovebyValue("nonexistent")
assert.False(t, ok)
assert.Equal(t, "", removedValue) // zero value for string
}
func TestExpiryMap_RemoveValue_GenericTypes(t *testing.T) {
t.Run("Int", func(t *testing.T) {
em := New[int](time.Hour)
em.Set("num1", 42, time.Hour)
em.Set("num2", 84, time.Hour)
// Remove existing value
removedValue, ok := em.RemovebyValue(42)
assert.True(t, ok)
assert.Equal(t, 42, removedValue)
assert.False(t, em.Has("num1"))
assert.True(t, em.Has("num2"))
// Remove non-existent value
removedValue, ok = em.RemovebyValue(99)
assert.False(t, ok)
assert.Equal(t, 0, removedValue)
})
t.Run("Struct", func(t *testing.T) {
type TestStruct struct {
Name string
Age int
}
em := New[TestStruct](time.Hour)
person1 := TestStruct{Name: "John", Age: 30}
person2 := TestStruct{Name: "Jane", Age: 25}
em.Set("person1", person1, time.Hour)
em.Set("person2", person2, time.Hour)
// Remove existing struct
removedValue, ok := em.RemovebyValue(person1)
assert.True(t, ok)
assert.Equal(t, person1, removedValue)
assert.False(t, em.Has("person1"))
assert.True(t, em.Has("person2"))
// Remove non-existent struct
nonexistent := TestStruct{Name: "Bob", Age: 40}
removedValue, ok = em.RemovebyValue(nonexistent)
assert.False(t, ok)
assert.Equal(t, TestStruct{}, removedValue)
})
}
func TestExpiryMap_RemoveValue_WithExpiration(t *testing.T) {
em := New[string](time.Hour)
// Set values with different TTLs
em.Set("key1", "value1", time.Millisecond*10) // Will expire
em.Set("key2", "value2", time.Hour) // Won't expire
em.Set("key3", "value1", time.Hour) // Won't expire, duplicate value
// Wait for first value to expire
time.Sleep(time.Millisecond * 20)
// Try to remove the expired value - should remove one of the "value1" entries
removedValue, ok := em.RemovebyValue("value1")
assert.True(t, ok)
assert.Equal(t, "value1", removedValue)
// Should still have key2 (different value)
assert.True(t, em.Has("key2"))
// Should have removed one of the "value1" entries (either key1 or key3)
// But we can't predict which one due to map iteration order
key1Exists := em.Has("key1")
key3Exists := em.Has("key3")
// Exactly one of key1 or key3 should be gone
assert.False(t, key1Exists && key3Exists) // Both shouldn't exist
assert.True(t, key1Exists || key3Exists) // At least one should still exist
}
func TestExpiryMap_ValueOperations_Integration(t *testing.T) {
em := New[string](time.Hour)
// Test integration of GetByValue and RemoveValue
em.Set("key1", "shared", time.Hour)
em.Set("key2", "unique", time.Hour)
em.Set("key3", "shared", time.Hour)
// Find shared value
key, value, ok := em.GetByValue("shared")
assert.True(t, ok)
assert.Equal(t, "shared", value)
assert.Contains(t, []string{"key1", "key3"}, key)
// Remove shared value
removedValue, ok := em.RemovebyValue("shared")
assert.True(t, ok)
assert.Equal(t, "shared", removedValue)
// Should still be able to find the other shared value
key, value, ok = em.GetByValue("shared")
assert.True(t, ok)
assert.Equal(t, "shared", value)
assert.Contains(t, []string{"key1", "key3"}, key)
// Remove the other shared value
removedValue, ok = em.RemovebyValue("shared")
assert.True(t, ok)
assert.Equal(t, "shared", removedValue)
// Should not find shared value anymore
key, value, ok = em.GetByValue("shared")
assert.False(t, ok)
assert.Equal(t, "", value)
assert.Equal(t, "", key)
// Unique value should still exist
key, value, ok = em.GetByValue("unique")
assert.True(t, ok)
assert.Equal(t, "unique", value)
assert.Equal(t, "key2", key)
}

View File

@@ -4,6 +4,7 @@ package hub
import (
"beszel"
"beszel/internal/alerts"
"beszel/internal/hub/config"
"beszel/internal/hub/systems"
"beszel/internal/records"
"beszel/internal/users"
@@ -16,8 +17,11 @@ import (
"net/http/httputil"
"net/url"
"os"
"path"
"strings"
"time"
"github.com/google/uuid"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
@@ -31,6 +35,7 @@ type Hub struct {
rm *records.RecordManager
sm *systems.SystemManager
pubKey string
signer ssh.Signer
appURL string
}
@@ -56,50 +61,66 @@ func GetEnv(key string) (value string, exists bool) {
return os.LookupEnv(key)
}
func (h *Hub) BootstrapHub() (*Hub, error) {
if !h.App.IsBootstrapped() {
err := h.App.Bootstrap()
func (h *Hub) StartHub() error {
h.App.OnServe().BindFunc(func(e *core.ServeEvent) error {
// initialize settings / collections
if err := h.initialize(e); err != nil {
return err
}
// sync systems with config
if err := config.SyncSystems(e); err != nil {
return err
}
// register api routes
if err := h.registerApiRoutes(e); err != nil {
return err
}
// register cron jobs
if err := h.registerCronJobs(e); err != nil {
return err
}
// start server
if err := h.startServer(e); err != nil {
return err
}
// start system updates
if err := h.sm.Initialize(); err != nil {
return err
}
return e.Next()
})
// TODO: move to users package
// 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)
if pb, ok := h.App.(*pocketbase.PocketBase); ok {
// log.Println("Starting pocketbase")
err := pb.Start()
if err != nil {
return nil, err
return err
}
}
// initial setup
if err := h.initialize(); err != nil {
return nil, err
}
// serve web ui
h.OnServe().BindFunc(h.startServer)
// set up scheduled jobs
h.OnServe().BindFunc(h.registerCronJobs)
// custom api routes
h.OnServe().BindFunc(h.registerApiRoutes)
// TODO: move to users package
// handle default values for user / user_settings creation
h.OnRecordCreate("users").BindFunc(h.um.InitializeUserRole)
h.OnRecordCreate("user_settings").BindFunc(h.um.InitializeUserSettings)
// sync systems with config
h.syncSystemsWithConfig()
// start system updates
h.sm.Initialize()
return h, nil
return nil
}
// initialize sets up initial configuration (collections, settings, etc.)
func (h *Hub) initialize() error {
func (h *Hub) initialize(e *core.ServeEvent) error {
// set general settings
settings := h.Settings()
settings := e.App.Settings()
// batch requests (for global alerts)
settings.Batch.Enabled = true
// set URL if BASE_URL env is set
if h.appURL != "" {
settings.Meta.AppURL = h.appURL
}
if err := e.App.Save(settings); err != nil {
return err
}
// set auth settings
usersCollection, err := h.FindCollectionByNameOrId("users")
usersCollection, err := e.App.FindCollectionByNameOrId("users")
if err != nil {
return err
}
@@ -118,11 +139,11 @@ func (h *Hub) initialize() error {
} else {
usersCollection.CreateRule = nil
}
if err := h.Save(usersCollection); err != nil {
if err := e.App.Save(usersCollection); err != nil {
return err
}
// allow all users to access systems if SHARE_ALL_SYSTEMS is set
systemsCollection, err := h.FindCachedCollectionByNameOrId("systems")
systemsCollection, err := e.App.FindCachedCollectionByNameOrId("systems")
if err != nil {
return err
}
@@ -137,22 +158,15 @@ func (h *Hub) initialize() error {
systemsCollection.ViewRule = &systemsReadRule
systemsCollection.UpdateRule = &updateDeleteRule
systemsCollection.DeleteRule = &updateDeleteRule
if err := h.Save(systemsCollection); err != nil {
if err := e.App.Save(systemsCollection); err != nil {
return err
}
return nil
}
// Start starts the hub application / server
func (h *Hub) Start() error {
// Use type assertion to access the Start method
if pb, ok := h.App.(*pocketbase.PocketBase); ok {
return pb.Start()
}
return fmt.Errorf("unable to start: App is not *pocketbase.PocketBase")
}
// startServer sets up the server for Beszel
func (h *Hub) startServer(se *core.ServeEvent) error {
// TODO: exclude dev server from production binary
switch h.IsDev() {
case true:
proxy := httputil.NewSingleHostReverseProxy(&url.URL{
@@ -173,6 +187,8 @@ func (h *Hub) startServer(se *core.ServeEvent) error {
basePath := strings.TrimSuffix(parsedURL.Path, "/") + "/"
indexFile, _ := fs.ReadFile(site.DistDirFS, "index.html")
indexContent := strings.ReplaceAll(string(indexFile), "./", basePath)
indexContent = strings.Replace(indexContent, "{{V}}", beszel.Version, 1)
indexContent = strings.Replace(indexContent, "{{HUB_URL}}", h.appURL, 1)
// set up static asset serving
staticPaths := [2]string{"/static/", "/assets/"}
serveStatic := apis.Static(site.DistDirFS, false)
@@ -194,111 +210,142 @@ func (h *Hub) startServer(se *core.ServeEvent) error {
return e.HTML(http.StatusOK, indexContent)
})
}
return se.Next()
return nil
}
// registerCronJobs sets up all scheduled tasks
func (h *Hub) registerCronJobs(se *core.ServeEvent) error {
// delete old records once every hour
// registerCronJobs sets up scheduled tasks
func (h *Hub) registerCronJobs(_ *core.ServeEvent) error {
// delete old system_stats and alerts_history records once every hour
h.Cron().MustAdd("delete old records", "8 * * * *", h.rm.DeleteOldRecords)
// create longer records every 10 minutes
h.Cron().MustAdd("create longer records", "*/10 * * * *", h.rm.CreateLongerRecords)
return se.Next()
return nil
}
// custom api routes
func (h *Hub) registerApiRoutes(se *core.ServeEvent) error {
// returns public key and version
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})
})
// auth protected routes
apiAuth := se.Router.Group("/api/beszel")
apiAuth.Bind(apis.RequireAuth())
// auth optional routes
apiNoAuth := se.Router.Group("/api/beszel")
// create first user endpoint only needed if no users exist
if totalUsers, _ := se.App.CountRecords("users"); totalUsers == 0 {
apiNoAuth.POST("/create-user", h.um.CreateFirstUser)
}
// check if first time setup on login page
se.Router.GET("/api/beszel/first-run", func(e *core.RequestEvent) error {
total, err := h.CountRecords("users")
apiNoAuth.GET("/first-run", func(e *core.RequestEvent) error {
total, err := e.App.CountRecords("users")
return e.JSON(http.StatusOK, map[string]bool{"firstRun": err == nil && total == 0})
})
// get public key and version
apiAuth.GET("/getkey", func(e *core.RequestEvent) error {
return e.JSON(http.StatusOK, map[string]string{"key": h.pubKey, "v": beszel.Version})
})
// send test notification
se.Router.GET("/api/beszel/send-test-notification", h.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.CountRecords("users"); totalUsers == 0 {
se.Router.POST("/api/beszel/create-user", h.um.CreateFirstUser)
}
return se.Next()
apiAuth.POST("/test-notification", h.SendTestNotification)
// get config.yml content
apiAuth.GET("/config-yaml", config.GetYamlConfig)
// handle agent websocket connection
apiNoAuth.GET("/agent-connect", h.handleAgentConnect)
// get or create universal tokens
apiAuth.GET("/universal-token", h.getUniversalToken)
// update / delete user alerts
apiAuth.POST("/user-alerts", alerts.UpsertUserAlerts)
apiAuth.DELETE("/user-alerts", alerts.DeleteUserAlerts)
return nil
}
// generates key pair if it doesn't exist and returns private key bytes
func (h *Hub) GetSSHKey() ([]byte, error) {
dataDir := h.DataDir()
// check if the key pair already exists
existingKey, err := os.ReadFile(dataDir + "/id_ed25519")
if err == nil {
if pubKey, err := os.ReadFile(h.DataDir() + "/id_ed25519.pub"); err == nil {
h.pubKey = strings.TrimSuffix(string(pubKey), "\n")
// Handler for universal token API endpoint (create, read, delete)
func (h *Hub) getUniversalToken(e *core.RequestEvent) error {
tokenMap := universalTokenMap.GetMap()
userID := e.Auth.Id
query := e.Request.URL.Query()
token := query.Get("token")
if token == "" {
// return existing token if it exists
if token, _, ok := tokenMap.GetByValue(userID); ok {
return e.JSON(http.StatusOK, map[string]any{"token": token, "active": true})
}
// return existing private key
return existingKey, nil
// if no token is provided, generate a new one
token = uuid.New().String()
}
response := map[string]any{"token": token}
switch query.Get("enable") {
case "1":
tokenMap.Set(token, userID, time.Hour)
case "0":
tokenMap.RemovebyValue(userID)
}
_, response["active"] = tokenMap.GetOk(token)
return e.JSON(http.StatusOK, response)
}
// generates key pair if it doesn't exist and returns signer
func (h *Hub) GetSSHKey(dataDir string) (ssh.Signer, error) {
if h.signer != nil {
return h.signer, nil
}
if dataDir == "" {
dataDir = h.DataDir()
}
privateKeyPath := path.Join(dataDir, "id_ed25519")
// check if the key pair already exists
existingKey, err := os.ReadFile(privateKeyPath)
if err == nil {
private, err := ssh.ParsePrivateKey(existingKey)
if err != nil {
return nil, fmt.Errorf("failed to parse private key: %s", err)
}
pubKeyBytes := ssh.MarshalAuthorizedKey(private.PublicKey())
h.pubKey = strings.TrimSuffix(string(pubKeyBytes), "\n")
return private, nil
} else if !os.IsNotExist(err) {
// File exists but couldn't be read for some other reason
return nil, fmt.Errorf("failed to read %s: %w", privateKeyPath, err)
}
// Generate the Ed25519 key pair
pubKey, privKey, err := ed25519.GenerateKey(nil)
_, privKey, err := ed25519.GenerateKey(nil)
if err != nil {
// h.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.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.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.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)
privKeyPem, err := ssh.MarshalPrivateKey(privKey, "")
if err != nil {
return nil, err
}
pubKeyBytes := ssh.MarshalAuthorizedKey(publicKey)
if err := os.WriteFile(privateKeyPath, pem.EncodeToMemory(privKeyPem), 0600); err != nil {
return nil, fmt.Errorf("failed to write private key to %q: err: %w", privateKeyPath, err)
}
// These are fine to ignore the errors on, as we've literally just created a crypto.PublicKey | crypto.Signer
sshPrivate, _ := ssh.NewSignerFromSigner(privKey)
pubKeyBytes := ssh.MarshalAuthorizedKey(sshPrivate.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()
h.Logger().Info("ed25519 key pair generated successfully.")
h.Logger().Info("Saved to: " + privateKeyPath)
if _, err := publicFile.Write(pubKeyBytes); err != nil {
return nil, err
}
h.Logger().Info("ed25519 SSH key pair generated successfully.")
h.Logger().Info("Private key saved to: " + dataDir + "/id_ed25519")
h.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
return sshPrivate, err
}
// MakeLink formats a link with the app URL and path segments.
// Only path segments should be provided.
func (h *Hub) MakeLink(parts ...string) string {
base := strings.TrimSuffix(h.Settings().Meta.AppURL, "/")
for _, part := range parts {
if part == "" {
continue
}
base = fmt.Sprintf("%s/%s", base, url.PathEscape(part))
}
return base
}

View File

@@ -0,0 +1,603 @@
//go:build testing
// +build testing
package hub_test
import (
beszelTests "beszel/internal/tests"
"testing"
"bytes"
"crypto/ed25519"
"encoding/json"
"encoding/pem"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/pocketbase/pocketbase/core"
pbTests "github.com/pocketbase/pocketbase/tests"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/ssh"
)
// marshal to json and return an io.Reader (for use in ApiScenario.Body)
func jsonReader(v any) io.Reader {
data, err := json.Marshal(v)
if err != nil {
panic(err)
}
return bytes.NewReader(data)
}
func TestMakeLink(t *testing.T) {
hub, _ := beszelTests.NewTestHub(t.TempDir())
tests := []struct {
name string
appURL string
parts []string
expected string
}{
{
name: "no parts, no trailing slash in AppURL",
appURL: "http://localhost:8090",
parts: []string{},
expected: "http://localhost:8090",
},
{
name: "no parts, with trailing slash in AppURL",
appURL: "http://localhost:8090/",
parts: []string{},
expected: "http://localhost:8090", // TrimSuffix should handle the trailing slash
},
{
name: "one part",
appURL: "http://example.com",
parts: []string{"one"},
expected: "http://example.com/one",
},
{
name: "multiple parts",
appURL: "http://example.com",
parts: []string{"alpha", "beta", "gamma"},
expected: "http://example.com/alpha/beta/gamma",
},
{
name: "parts with spaces needing escaping",
appURL: "http://example.com",
parts: []string{"path with spaces", "another part"},
expected: "http://example.com/path%20with%20spaces/another%20part",
},
{
name: "parts with slashes needing escaping",
appURL: "http://example.com",
parts: []string{"a/b", "c"},
expected: "http://example.com/a%2Fb/c", // url.PathEscape escapes '/'
},
{
name: "AppURL with subpath, no trailing slash",
appURL: "http://localhost/sub",
parts: []string{"resource"},
expected: "http://localhost/sub/resource",
},
{
name: "AppURL with subpath, with trailing slash",
appURL: "http://localhost/sub/",
parts: []string{"item"},
expected: "http://localhost/sub/item",
},
{
name: "empty parts in the middle",
appURL: "http://localhost",
parts: []string{"first", "", "third"},
expected: "http://localhost/first/third",
},
{
name: "leading and trailing empty parts",
appURL: "http://localhost",
parts: []string{"", "path", ""},
expected: "http://localhost/path",
},
{
name: "parts with various special characters",
appURL: "https://test.dev/",
parts: []string{"p@th?", "key=value&"},
expected: "https://test.dev/p@th%3F/key=value&",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Store original app URL and restore it after the test
originalAppURL := hub.Settings().Meta.AppURL
hub.Settings().Meta.AppURL = tt.appURL
defer func() { hub.Settings().Meta.AppURL = originalAppURL }()
got := hub.MakeLink(tt.parts...)
assert.Equal(t, tt.expected, got, "MakeLink generated URL does not match expected")
})
}
}
func TestGetSSHKey(t *testing.T) {
hub, _ := beszelTests.NewTestHub(t.TempDir())
// Test Case 1: Key generation (no existing key)
t.Run("KeyGeneration", func(t *testing.T) {
tempDir := t.TempDir()
// Ensure pubKey is initially empty or different to ensure GetSSHKey sets it
hub.SetPubkey("")
signer, err := hub.GetSSHKey(tempDir)
assert.NoError(t, err, "GetSSHKey should not error when generating a new key")
assert.NotNil(t, signer, "GetSSHKey should return a non-nil signer")
// Check if private key file was created
privateKeyPath := filepath.Join(tempDir, "id_ed25519")
info, err := os.Stat(privateKeyPath)
assert.NoError(t, err, "Private key file should be created")
assert.False(t, info.IsDir(), "Private key path should be a file, not a directory")
// Check if h.pubKey was set
assert.NotEmpty(t, hub.GetPubkey(), "h.pubKey should be set after key generation")
assert.True(t, strings.HasPrefix(hub.GetPubkey(), "ssh-ed25519 "), "h.pubKey should start with 'ssh-ed25519 '")
// Verify the generated private key is parsable
keyData, err := os.ReadFile(privateKeyPath)
require.NoError(t, err)
_, err = ssh.ParsePrivateKey(keyData)
assert.NoError(t, err, "Generated private key should be parsable by ssh.ParsePrivateKey")
})
// Test Case 2: Existing key
t.Run("ExistingKey", func(t *testing.T) {
tempDir := t.TempDir()
// Manually create a valid key pair for the test
rawPubKey, rawPrivKey, err := ed25519.GenerateKey(nil)
require.NoError(t, err, "Failed to generate raw ed25519 key pair for pre-existing key test")
// Marshal the private key into OpenSSH PEM format
pemBlock, err := ssh.MarshalPrivateKey(rawPrivKey, "")
require.NoError(t, err, "Failed to marshal private key to PEM block for pre-existing key test")
privateKeyBytes := pem.EncodeToMemory(pemBlock)
require.NotNil(t, privateKeyBytes, "PEM encoded private key bytes should not be nil")
privateKeyPath := filepath.Join(tempDir, "id_ed25519")
err = os.WriteFile(privateKeyPath, privateKeyBytes, 0600)
require.NoError(t, err, "Failed to write pre-existing private key")
// Determine the expected public key string
sshPubKey, err := ssh.NewPublicKey(rawPubKey)
require.NoError(t, err)
expectedPubKeyStr := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(sshPubKey)))
// Reset h.pubKey to ensure it's set by GetSSHKey from the file
hub.SetPubkey("")
signer, err := hub.GetSSHKey(tempDir)
assert.NoError(t, err, "GetSSHKey should not error when reading an existing key")
assert.NotNil(t, signer, "GetSSHKey should return a non-nil signer for an existing key")
// Check if h.pubKey was set correctly to the public key from the file
assert.Equal(t, expectedPubKeyStr, hub.GetPubkey(), "h.pubKey should match the existing public key")
// Verify the signer's public key matches the original public key
signerPubKey := signer.PublicKey()
marshaledSignerPubKey := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(signerPubKey)))
assert.Equal(t, expectedPubKeyStr, marshaledSignerPubKey, "Signer's public key should match the existing public key")
})
// Test Case 3: Error cases
t.Run("ErrorCases", func(t *testing.T) {
tests := []struct {
name string
setupFunc func(dir string) error
errorCheck func(t *testing.T, err error)
}{
{
name: "CorruptedKey",
setupFunc: func(dir string) error {
return os.WriteFile(filepath.Join(dir, "id_ed25519"), []byte("this is not a valid SSH key"), 0600)
},
errorCheck: func(t *testing.T, err error) {
assert.Error(t, err)
assert.Contains(t, err.Error(), "ssh: no key found")
},
},
{
name: "PermissionDenied",
setupFunc: func(dir string) error {
// Create the key file
keyPath := filepath.Join(dir, "id_ed25519")
if err := os.WriteFile(keyPath, []byte("dummy content"), 0600); err != nil {
return err
}
// Make it read-only (can't be opened for writing in case a new key needs to be written)
return os.Chmod(keyPath, 0400)
},
errorCheck: func(t *testing.T, err error) {
// On read-only key, the parser will attempt to parse it and fail with "ssh: no key found"
assert.Error(t, err)
},
},
{
name: "EmptyFile",
setupFunc: func(dir string) error {
// Create an empty file
return os.WriteFile(filepath.Join(dir, "id_ed25519"), []byte{}, 0600)
},
errorCheck: func(t *testing.T, err error) {
assert.Error(t, err)
// The error from attempting to parse an empty file
assert.Contains(t, err.Error(), "ssh: no key found")
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
tempDir := t.TempDir()
// Setup the test case
err := tc.setupFunc(tempDir)
require.NoError(t, err, "Setup failed")
// Reset h.pubKey before each test case
hub.SetPubkey("")
// Attempt to get SSH key
_, err = hub.GetSSHKey(tempDir)
// Verify the error
tc.errorCheck(t, err)
// Check that pubKey was not set in error cases
assert.Empty(t, hub.GetPubkey(), "h.pubKey should not be set if there was an error")
})
}
})
}
func TestApiRoutesAuthentication(t *testing.T) {
hub, _ := beszelTests.NewTestHub(t.TempDir())
defer hub.Cleanup()
hub.StartHub()
// Create test user and get auth token
user, err := beszelTests.CreateUser(hub, "testuser@example.com", "password123")
require.NoError(t, err, "Failed to create test user")
adminUser, err := beszelTests.CreateRecord(hub, "users", map[string]any{
"email": "admin@example.com",
"password": "password123",
"role": "admin",
})
require.NoError(t, err, "Failed to create admin user")
adminUserToken, err := adminUser.NewAuthToken()
// superUser, err := beszelTests.CreateRecord(hub, core.CollectionNameSuperusers, map[string]any{
// "email": "superuser@example.com",
// "password": "password123",
// })
// require.NoError(t, err, "Failed to create superuser")
userToken, err := user.NewAuthToken()
require.NoError(t, err, "Failed to create auth token")
// Create test system for user-alerts endpoints
system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{
"name": "test-system",
"users": []string{user.Id},
"host": "127.0.0.1",
})
require.NoError(t, err, "Failed to create test system")
testAppFactory := func(t testing.TB) *pbTests.TestApp {
return hub.TestApp
}
scenarios := []beszelTests.ApiScenario{
// Auth Protected Routes - Should require authentication
{
Name: "POST /test-notification - no auth should fail",
Method: http.MethodPost,
URL: "/api/beszel/test-notification",
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
Body: jsonReader(map[string]any{
"url": "generic://127.0.0.1",
}),
},
{
Name: "POST /test-notification - with auth should succeed",
Method: http.MethodPost,
URL: "/api/beszel/test-notification",
TestAppFactory: testAppFactory,
Headers: map[string]string{
"Authorization": userToken,
},
Body: jsonReader(map[string]any{
"url": "generic://127.0.0.1",
}),
ExpectedStatus: 200,
ExpectedContent: []string{"sending message"},
},
{
Name: "GET /config-yaml - no auth should fail",
Method: http.MethodGet,
URL: "/api/beszel/config-yaml",
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /config-yaml - with user auth should fail",
Method: http.MethodGet,
URL: "/api/beszel/config-yaml",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 403,
ExpectedContent: []string{"Requires admin"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /config-yaml - with admin auth should succeed",
Method: http.MethodGet,
URL: "/api/beszel/config-yaml",
Headers: map[string]string{
"Authorization": adminUserToken,
},
ExpectedStatus: 200,
ExpectedContent: []string{"test-system"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /universal-token - no auth should fail",
Method: http.MethodGet,
URL: "/api/beszel/universal-token",
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /universal-token - with auth should succeed",
Method: http.MethodGet,
URL: "/api/beszel/universal-token",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 200,
ExpectedContent: []string{"active", "token"},
TestAppFactory: testAppFactory,
},
{
Name: "POST /user-alerts - no auth should fail",
Method: http.MethodPost,
URL: "/api/beszel/user-alerts",
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
Body: jsonReader(map[string]any{
"name": "CPU",
"value": 80,
"min": 10,
"systems": []string{system.Id},
}),
},
{
Name: "POST /user-alerts - with auth should succeed",
Method: http.MethodPost,
URL: "/api/beszel/user-alerts",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 200,
ExpectedContent: []string{"\"success\":true"},
TestAppFactory: testAppFactory,
Body: jsonReader(map[string]any{
"name": "CPU",
"value": 80,
"min": 10,
"systems": []string{system.Id},
}),
},
{
Name: "DELETE /user-alerts - no auth should fail",
Method: http.MethodDelete,
URL: "/api/beszel/user-alerts",
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
Body: jsonReader(map[string]any{
"name": "CPU",
"systems": []string{system.Id},
}),
},
{
Name: "DELETE /user-alerts - with auth should succeed",
Method: http.MethodDelete,
URL: "/api/beszel/user-alerts",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 200,
ExpectedContent: []string{"\"success\":true"},
TestAppFactory: testAppFactory,
Body: jsonReader(map[string]any{
"name": "CPU",
"systems": []string{system.Id},
}),
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
// Create an alert to delete
beszelTests.CreateRecord(app, "alerts", map[string]any{
"name": "CPU",
"system": system.Id,
"user": user.Id,
"value": 80,
"min": 10,
})
},
},
// Auth Optional Routes - Should work without authentication
{
Name: "GET /getkey - no auth should fail",
Method: http.MethodGet,
URL: "/api/beszel/getkey",
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /getkey - with auth should also succeed",
Method: http.MethodGet,
URL: "/api/beszel/getkey",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 200,
ExpectedContent: []string{"\"key\":", "\"v\":"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /first-run - no auth should succeed",
Method: http.MethodGet,
URL: "/api/beszel/first-run",
ExpectedStatus: 200,
ExpectedContent: []string{"\"firstRun\":false"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /first-run - with auth should also succeed",
Method: http.MethodGet,
URL: "/api/beszel/first-run",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 200,
ExpectedContent: []string{"\"firstRun\":false"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /agent-connect - no auth should succeed (websocket upgrade fails but route is accessible)",
Method: http.MethodGet,
URL: "/api/beszel/agent-connect",
ExpectedStatus: 400,
ExpectedContent: []string{},
TestAppFactory: testAppFactory,
},
{
Name: "POST /test-notification - invalid auth token should fail",
Method: http.MethodPost,
URL: "/api/beszel/test-notification",
Body: jsonReader(map[string]any{
"url": "generic://127.0.0.1",
}),
Headers: map[string]string{
"Authorization": "invalid-token",
},
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
},
{
Name: "POST /user-alerts - invalid auth token should fail",
Method: http.MethodPost,
URL: "/api/beszel/user-alerts",
Headers: map[string]string{
"Authorization": "invalid-token",
},
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
Body: jsonReader(map[string]any{
"name": "CPU",
"value": 80,
"min": 10,
"systems": []string{system.Id},
}),
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
func TestCreateUserEndpointAvailability(t *testing.T) {
t.Run("CreateUserEndpoint available when no users exist", func(t *testing.T) {
hub, _ := beszelTests.NewTestHub(t.TempDir())
defer hub.Cleanup()
// Ensure no users exist
userCount, err := hub.CountRecords("users")
require.NoError(t, err)
require.Zero(t, userCount, "Should start with no users")
hub.StartHub()
testAppFactory := func(t testing.TB) *pbTests.TestApp {
return hub.TestApp
}
scenario := beszelTests.ApiScenario{
Name: "POST /create-user - should be available when no users exist",
Method: http.MethodPost,
URL: "/api/beszel/create-user",
Body: jsonReader(map[string]any{
"email": "firstuser@example.com",
"password": "password123",
}),
ExpectedStatus: 200,
ExpectedContent: []string{"User created"},
TestAppFactory: testAppFactory,
}
scenario.Test(t)
// Verify user was created
userCount, err = hub.CountRecords("users")
require.NoError(t, err)
require.EqualValues(t, 1, userCount, "Should have created one user")
})
t.Run("CreateUserEndpoint not available when users exist", func(t *testing.T) {
hub, _ := beszelTests.NewTestHub(t.TempDir())
defer hub.Cleanup()
// Create a user first
_, err := beszelTests.CreateUser(hub, "existing@example.com", "password")
require.NoError(t, err)
hub.StartHub()
testAppFactory := func(t testing.TB) *pbTests.TestApp {
return hub.TestApp
}
scenario := beszelTests.ApiScenario{
Name: "POST /create-user - should not be available when users exist",
Method: http.MethodPost,
URL: "/api/beszel/create-user",
Body: jsonReader(map[string]any{
"email": "another@example.com",
"password": "password123",
}),
ExpectedStatus: 404,
ExpectedContent: []string{"wasn't found"},
TestAppFactory: testAppFactory,
}
scenario.Test(t)
})
}

View File

@@ -0,0 +1,21 @@
//go:build testing
// +build testing
package hub
import "beszel/internal/hub/systems"
// TESTING ONLY: GetSystemManager returns the system manager
func (h *Hub) GetSystemManager() *systems.SystemManager {
return h.sm
}
// TESTING ONLY: GetPubkey returns the public key
func (h *Hub) GetPubkey() string {
return h.pubKey
}
// TESTING ONLY: SetPubkey sets the public key
func (h *Hub) SetPubkey(pubkey string) {
h.pubKey = pubkey
}

View File

@@ -0,0 +1,387 @@
package systems
import (
"beszel"
"beszel/internal/entities/system"
"beszel/internal/hub/ws"
"context"
"encoding/json"
"errors"
"fmt"
"math/rand"
"net"
"strings"
"time"
"github.com/blang/semver"
"github.com/fxamacker/cbor/v2"
"github.com/pocketbase/pocketbase/core"
"golang.org/x/crypto/ssh"
)
type System struct {
Id string `db:"id"`
Host string `db:"host"`
Port string `db:"port"`
Status string `db:"status"`
manager *SystemManager // Manager that this system belongs to
client *ssh.Client // SSH client for fetching data
data *system.CombinedData // system data from agent
ctx context.Context // Context for stopping the updater
cancel context.CancelFunc // Stops and removes system from updater
WsConn *ws.WsConn // Handler for agent WebSocket connection
agentVersion semver.Version // Agent version
updateTicker *time.Ticker // Ticker for updating the system
}
func (sm *SystemManager) NewSystem(systemId string) *System {
system := &System{
Id: systemId,
data: &system.CombinedData{},
}
system.ctx, system.cancel = system.getContext()
return system
}
// StartUpdater starts the system updater.
// It first fetches the data from the agent then updates the records.
// If the data is not found or the system is down, it sets the system down.
func (sys *System) StartUpdater() {
// Channel that can be used to set the system down. Currently only used to
// allow a short delay for reconnection after websocket connection is closed.
var downChan chan struct{}
// Add random jitter to first WebSocket connection to prevent
// clustering if all agents are started at the same time.
// SSH connections during hub startup are already staggered.
var jitter <-chan time.Time
if sys.WsConn != nil {
jitter = getJitter()
// use the websocket connection's down channel to set the system down
downChan = sys.WsConn.DownChan
} else {
// if the system does not have a websocket connection, wait before updating
// to allow the agent to connect via websocket (makes sure fingerprint is set).
time.Sleep(11 * time.Second)
}
// update immediately if system is not paused (only for ws connections)
// we'll wait a minute before connecting via SSH to prioritize ws connections
if sys.Status != paused && sys.ctx.Err() == nil {
if err := sys.update(); err != nil {
_ = sys.setDown(err)
}
}
sys.updateTicker = time.NewTicker(time.Duration(interval) * time.Millisecond)
// Go 1.23+ will automatically stop the ticker when the system is garbage collected, however we seem to need this or testing/synctest will block even if calling runtime.GC()
defer sys.updateTicker.Stop()
for {
select {
case <-sys.ctx.Done():
return
case <-sys.updateTicker.C:
if err := sys.update(); err != nil {
_ = sys.setDown(err)
}
case <-downChan:
sys.WsConn = nil
downChan = nil
_ = sys.setDown(nil)
case <-jitter:
sys.updateTicker.Reset(time.Duration(interval) * time.Millisecond)
if err := sys.update(); err != nil {
_ = sys.setDown(err)
}
}
}
}
// update updates the system data and records.
func (sys *System) update() error {
if sys.Status == paused {
sys.handlePaused()
return nil
}
data, err := sys.fetchDataFromAgent()
if err == nil {
_, err = sys.createRecords(data)
}
return err
}
func (sys *System) handlePaused() {
if sys.WsConn == nil {
// if the system is paused and there's no websocket connection, remove the system
_ = sys.manager.RemoveSystem(sys.Id)
} else {
// Send a ping to the agent to keep the connection alive if the system is paused
if err := sys.WsConn.Ping(); err != nil {
sys.manager.hub.Logger().Warn("Failed to ping agent", "system", sys.Id, "err", err)
_ = sys.manager.RemoveSystem(sys.Id)
}
}
}
// createRecords updates the system record and adds system_stats and container_stats records
func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error) {
systemRecord, err := sys.getRecord()
if err != nil {
return nil, err
}
hub := sys.manager.hub
// add system_stats and container_stats records
systemStatsCollection, err := hub.FindCachedCollectionByNameOrId("system_stats")
if err != nil {
return nil, err
}
systemStatsRecord := core.NewRecord(systemStatsCollection)
systemStatsRecord.Set("system", systemRecord.Id)
systemStatsRecord.Set("stats", data.Stats)
systemStatsRecord.Set("type", "1m")
if err := hub.SaveNoValidate(systemStatsRecord); err != nil {
return nil, err
}
// add new container_stats record
if len(data.Containers) > 0 {
containerStatsCollection, err := hub.FindCachedCollectionByNameOrId("container_stats")
if err != nil {
return nil, err
}
containerStatsRecord := core.NewRecord(containerStatsCollection)
containerStatsRecord.Set("system", systemRecord.Id)
containerStatsRecord.Set("stats", data.Containers)
containerStatsRecord.Set("type", "1m")
if err := hub.SaveNoValidate(containerStatsRecord); err != nil {
return nil, err
}
}
// update system record (do this last because it triggers alerts and we need above records to be inserted first)
systemRecord.Set("status", up)
systemRecord.Set("info", data.Info)
if err := hub.SaveNoValidate(systemRecord); err != nil {
return nil, err
}
return systemRecord, nil
}
// getRecord retrieves the system record from the database.
// If the record is not found, it removes the system from the manager.
func (sys *System) getRecord() (*core.Record, error) {
record, err := sys.manager.hub.FindRecordById("systems", sys.Id)
if err != nil || record == nil {
_ = sys.manager.RemoveSystem(sys.Id)
return nil, err
}
return record, nil
}
// setDown marks a system as down in the database.
// It takes the original error that caused the system to go down and returns any error
// encountered during the process of updating the system status.
func (sys *System) setDown(originalError error) error {
if sys.Status == down || sys.Status == paused {
return nil
}
record, err := sys.getRecord()
if err != nil {
return err
}
if originalError != nil {
sys.manager.hub.Logger().Error("System down", "system", record.GetString("name"), "err", originalError)
}
record.Set("status", down)
return sys.manager.hub.SaveNoValidate(record)
}
func (sys *System) getContext() (context.Context, context.CancelFunc) {
if sys.ctx == nil {
sys.ctx, sys.cancel = context.WithCancel(context.Background())
}
return sys.ctx, sys.cancel
}
// fetchDataFromAgent attempts to fetch data from the agent,
// prioritizing WebSocket if available.
func (sys *System) fetchDataFromAgent() (*system.CombinedData, error) {
if sys.data == nil {
sys.data = &system.CombinedData{}
}
if sys.WsConn != nil && sys.WsConn.IsConnected() {
wsData, err := sys.fetchDataViaWebSocket()
if err == nil {
return wsData, nil
}
// close the WebSocket connection if error and try SSH
sys.closeWebSocketConnection()
}
sshData, err := sys.fetchDataViaSSH()
if err != nil {
return nil, err
}
return sshData, nil
}
func (sys *System) fetchDataViaWebSocket() (*system.CombinedData, error) {
if sys.WsConn == nil || !sys.WsConn.IsConnected() {
return nil, errors.New("no websocket connection")
}
err := sys.WsConn.RequestSystemData(sys.data)
if err != nil {
return nil, err
}
return sys.data, nil
}
// fetchDataViaSSH handles fetching data using SSH.
// This function encapsulates the original SSH logic.
// It updates sys.data directly upon successful fetch.
func (sys *System) fetchDataViaSSH() (*system.CombinedData, error) {
maxRetries := 1
for attempt := 0; attempt <= maxRetries; attempt++ {
if sys.client == nil || sys.Status == down {
if err := sys.createSSHClient(); err != nil {
return nil, err
}
}
session, err := sys.createSessionWithTimeout(4 * time.Second)
if err != nil {
if attempt >= maxRetries {
return nil, err
}
sys.manager.hub.Logger().Warn("Session closed. Retrying...", "host", sys.Host, "port", sys.Port, "err", err)
sys.closeSSHConnection()
// Reset format detection on connection failure - agent might have been upgraded
continue
}
defer session.Close()
stdout, err := session.StdoutPipe()
if err != nil {
return nil, err
}
if err := session.Shell(); err != nil {
return nil, err
}
*sys.data = system.CombinedData{}
if sys.agentVersion.GTE(beszel.MinVersionCbor) {
err = cbor.NewDecoder(stdout).Decode(sys.data)
} else {
err = json.NewDecoder(stdout).Decode(sys.data)
}
if err != nil {
sys.closeSSHConnection()
if attempt < maxRetries {
continue
}
return nil, err
}
// wait for the session to complete
if err := session.Wait(); err != nil {
return nil, err
}
return sys.data, nil
}
// this should never be reached due to the return in the loop
return nil, fmt.Errorf("failed to fetch data")
}
// createSSHClient creates a new SSH client for the system
func (s *System) createSSHClient() error {
if s.manager.sshConfig == nil {
if err := s.manager.createSSHClientConfig(); err != nil {
return err
}
}
network := "tcp"
host := s.Host
if strings.HasPrefix(host, "/") {
network = "unix"
} else {
host = net.JoinHostPort(host, s.Port)
}
var err error
s.client, err = ssh.Dial(network, host, s.manager.sshConfig)
if err != nil {
return err
}
s.agentVersion, _ = extractAgentVersion(string(s.client.Conn.ServerVersion()))
return nil
}
// createSessionWithTimeout creates a new SSH session with a timeout to avoid hanging
// in case of network issues
func (sys *System) createSessionWithTimeout(timeout time.Duration) (*ssh.Session, error) {
if sys.client == nil {
return nil, fmt.Errorf("client not initialized")
}
ctx, cancel := context.WithTimeout(sys.ctx, timeout)
defer cancel()
sessionChan := make(chan *ssh.Session, 1)
errChan := make(chan error, 1)
go func() {
if session, err := sys.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("timeout")
}
}
// closeSSHConnection closes the SSH connection but keeps the system in the manager
func (sys *System) closeSSHConnection() {
if sys.client != nil {
sys.client.Close()
sys.client = nil
}
}
// closeWebSocketConnection closes the WebSocket connection but keeps the system in the manager
// to allow updating via SSH. It will be removed if the WS connection is re-established.
// The system will be set as down a few seconds later if the connection is not re-established.
func (sys *System) closeWebSocketConnection() {
if sys.WsConn != nil {
sys.WsConn.Close(nil)
}
}
// extractAgentVersion extracts the beszel version from SSH server version string
func extractAgentVersion(versionString string) (semver.Version, error) {
_, after, _ := strings.Cut(versionString, "_")
return semver.Parse(after)
}
// getJitter returns a channel that will be triggered after a random delay
// between 40% and 90% of the interval.
// This is used to stagger the initial WebSocket connections to prevent clustering.
func getJitter() <-chan time.Time {
minPercent := 40
maxPercent := 90
jitterRange := maxPercent - minPercent
msDelay := (interval * minPercent / 100) + rand.Intn(interval*jitterRange/100)
return time.After(time.Duration(msDelay) * time.Millisecond)
}

View File

@@ -0,0 +1,346 @@
package systems
import (
"beszel"
"beszel/internal/common"
"beszel/internal/entities/system"
"beszel/internal/hub/ws"
"errors"
"fmt"
"time"
"github.com/blang/semver"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/store"
"golang.org/x/crypto/ssh"
)
// System status constants
const (
up string = "up" // System is online and responding
down string = "down" // System is offline or not responding
paused string = "paused" // System monitoring is paused
pending string = "pending" // System is waiting on initial connection result
// interval is the default update interval in milliseconds (60 seconds)
interval int = 60_000
// interval int = 10_000 // Debug interval for faster updates
// sessionTimeout is the maximum time to wait for SSH connections
sessionTimeout = 4 * time.Second
)
var (
// errSystemExists is returned when attempting to add a system that already exists
errSystemExists = errors.New("system exists")
)
// SystemManager manages a collection of monitored systems and their connections.
// It handles system lifecycle, status updates, and maintains both SSH and WebSocket connections.
type SystemManager struct {
hub hubLike // Hub interface for database and alert operations
systems *store.Store[string, *System] // Thread-safe store of active systems
sshConfig *ssh.ClientConfig // SSH client configuration for system connections
}
// hubLike defines the interface requirements for the hub dependency.
// It extends core.App with system-specific functionality.
type hubLike interface {
core.App
GetSSHKey(dataDir string) (ssh.Signer, error)
HandleSystemAlerts(systemRecord *core.Record, data *system.CombinedData) error
HandleStatusAlerts(status string, systemRecord *core.Record) error
}
// NewSystemManager creates a new SystemManager instance with the provided hub.
// The hub must implement the hubLike interface to provide database and alert functionality.
func NewSystemManager(hub hubLike) *SystemManager {
return &SystemManager{
systems: store.New(map[string]*System{}),
hub: hub,
}
}
// Initialize sets up the system manager by binding event hooks and starting existing systems.
// It configures SSH client settings and begins monitoring all non-paused systems from the database.
// Systems are started with staggered delays to prevent overwhelming the hub during startup.
func (sm *SystemManager) Initialize() error {
sm.bindEventHooks()
// Initialize SSH client configuration
err := sm.createSSHClientConfig()
if err != nil {
return err
}
// Load existing systems from database (excluding paused ones)
var systems []*System
err = sm.hub.DB().NewQuery("SELECT id, host, port, status FROM systems WHERE status != 'paused'").All(&systems)
if err != nil || len(systems) == 0 {
return err
}
// Start systems in background with staggered timing
go func() {
// Calculate staggered delay between system starts (max 2 seconds per system)
delta := interval / max(1, len(systems))
delta = min(delta, 2_000)
sleepTime := time.Duration(delta) * time.Millisecond
for _, system := range systems {
time.Sleep(sleepTime)
_ = sm.AddSystem(system)
}
}()
return nil
}
// bindEventHooks registers event handlers for system and fingerprint record changes.
// These hooks ensure the system manager stays synchronized with database changes.
func (sm *SystemManager) bindEventHooks() {
sm.hub.OnRecordCreate("systems").BindFunc(sm.onRecordCreate)
sm.hub.OnRecordAfterCreateSuccess("systems").BindFunc(sm.onRecordAfterCreateSuccess)
sm.hub.OnRecordUpdate("systems").BindFunc(sm.onRecordUpdate)
sm.hub.OnRecordAfterUpdateSuccess("systems").BindFunc(sm.onRecordAfterUpdateSuccess)
sm.hub.OnRecordAfterDeleteSuccess("systems").BindFunc(sm.onRecordAfterDeleteSuccess)
sm.hub.OnRecordAfterUpdateSuccess("fingerprints").BindFunc(sm.onTokenRotated)
}
// onTokenRotated handles fingerprint token rotation events.
// When a system's authentication token is rotated, any existing WebSocket connection
// must be closed to force re-authentication with the new token.
func (sm *SystemManager) onTokenRotated(e *core.RecordEvent) error {
systemID := e.Record.GetString("system")
system, ok := sm.systems.GetOk(systemID)
if !ok {
return e.Next()
}
// No need to close connection if not connected via websocket
if system.WsConn == nil {
return e.Next()
}
system.setDown(nil)
sm.RemoveSystem(systemID)
return e.Next()
}
// onRecordCreate is called before a new system record is committed to the database.
// It initializes the record with default values: empty info and pending status.
func (sm *SystemManager) onRecordCreate(e *core.RecordEvent) error {
e.Record.Set("info", system.Info{})
e.Record.Set("status", pending)
return e.Next()
}
// onRecordAfterCreateSuccess is called after a new system record is successfully created.
// It adds the new system to the manager to begin monitoring.
func (sm *SystemManager) onRecordAfterCreateSuccess(e *core.RecordEvent) error {
if err := sm.AddRecord(e.Record, nil); err != nil {
e.App.Logger().Error("Error adding record", "err", err)
}
return e.Next()
}
// onRecordUpdate is called before a system record is updated in the database.
// It clears system info when the status is changed to paused.
func (sm *SystemManager) onRecordUpdate(e *core.RecordEvent) error {
if e.Record.GetString("status") == paused {
e.Record.Set("info", system.Info{})
}
return e.Next()
}
// onRecordAfterUpdateSuccess handles system record updates after they're committed to the database.
// It manages system lifecycle based on status changes and triggers appropriate alerts.
// Status transitions are handled as follows:
// - paused: Closes SSH connection and deactivates alerts
// - pending: Starts monitoring (reuses WebSocket if available)
// - up: Triggers system alerts
// - down: Triggers status change alerts
func (sm *SystemManager) onRecordAfterUpdateSuccess(e *core.RecordEvent) error {
newStatus := e.Record.GetString("status")
prevStatus := pending
system, ok := sm.systems.GetOk(e.Record.Id)
if ok {
prevStatus = system.Status
system.Status = newStatus
}
switch newStatus {
case paused:
if ok {
// Pause monitoring but keep system in manager for potential resume
system.closeSSHConnection()
}
_ = deactivateAlerts(e.App, e.Record.Id)
return e.Next()
case pending:
// Resume monitoring, preferring existing WebSocket connection
if ok && system.WsConn != nil {
go system.update()
return e.Next()
}
// Start new monitoring session
if err := sm.AddRecord(e.Record, nil); err != nil {
e.App.Logger().Error("Error adding record", "err", err)
}
_ = deactivateAlerts(e.App, e.Record.Id)
return e.Next()
}
// Handle systems not in manager
if !ok {
return sm.AddRecord(e.Record, nil)
}
// Trigger system alerts when system comes online
if newStatus == up {
if err := sm.hub.HandleSystemAlerts(e.Record, system.data); err != nil {
e.App.Logger().Error("Error handling system alerts", "err", err)
}
}
// Trigger status change alerts for up/down transitions
if (newStatus == down && prevStatus == up) || (newStatus == up && prevStatus == down) {
if err := sm.hub.HandleStatusAlerts(newStatus, e.Record); err != nil {
e.App.Logger().Error("Error handling status alerts", "err", err)
}
}
return e.Next()
}
// onRecordAfterDeleteSuccess is called after a system record is successfully deleted.
// It removes the system from the manager and cleans up all associated resources.
func (sm *SystemManager) onRecordAfterDeleteSuccess(e *core.RecordEvent) error {
sm.RemoveSystem(e.Record.Id)
return e.Next()
}
// AddSystem adds a system to the manager and starts monitoring it.
// It validates required fields, initializes the system context, and starts the update goroutine.
// Returns error if a system with the same ID already exists.
func (sm *SystemManager) AddSystem(sys *System) error {
if sm.systems.Has(sys.Id) {
return errSystemExists
}
if sys.Id == "" || sys.Host == "" {
return errors.New("system missing required fields")
}
// Initialize system for monitoring
sys.manager = sm
sys.ctx, sys.cancel = sys.getContext()
sys.data = &system.CombinedData{}
sm.systems.Set(sys.Id, sys)
// Start monitoring in background
go sys.StartUpdater()
return nil
}
// RemoveSystem removes a system from the manager and cleans up all associated resources.
// It cancels the system's context, closes all connections, and removes it from the store.
// Returns an error if the system is not found.
func (sm *SystemManager) RemoveSystem(systemID string) error {
system, ok := sm.systems.GetOk(systemID)
if !ok {
return errors.New("system not found")
}
// Stop the update goroutine
if system.cancel != nil {
system.cancel()
}
// Clean up all connections
system.closeSSHConnection()
system.closeWebSocketConnection()
sm.systems.Remove(systemID)
return nil
}
// AddRecord creates a System instance from a database record and adds it to the manager.
// If a system with the same ID already exists, it's removed first to ensure clean state.
// If no system instance is provided, a new one is created.
// This method is typically called when systems are created or their status changes to pending.
func (sm *SystemManager) AddRecord(record *core.Record, system *System) (err error) {
// Remove existing system to ensure clean state
if sm.systems.Has(record.Id) {
_ = sm.RemoveSystem(record.Id)
}
// Create new system if none provided
if system == nil {
system = sm.NewSystem(record.Id)
}
// Populate system from record
system.Status = record.GetString("status")
system.Host = record.GetString("host")
system.Port = record.GetString("port")
return sm.AddSystem(system)
}
// AddWebSocketSystem creates and adds a system with an established WebSocket connection.
// This method is called when an agent connects via WebSocket with valid authentication.
// The system is immediately added to monitoring with the provided connection and version info.
func (sm *SystemManager) AddWebSocketSystem(systemId string, agentVersion semver.Version, wsConn *ws.WsConn) error {
systemRecord, err := sm.hub.FindRecordById("systems", systemId)
if err != nil {
return err
}
system := sm.NewSystem(systemId)
system.WsConn = wsConn
system.agentVersion = agentVersion
if err := sm.AddRecord(systemRecord, system); err != nil {
return err
}
return nil
}
// createSSHClientConfig initializes the SSH client configuration for connecting to an agent's server
func (sm *SystemManager) createSSHClientConfig() error {
privateKey, err := sm.hub.GetSSHKey("")
if err != nil {
return err
}
sm.sshConfig = &ssh.ClientConfig{
User: "u",
Auth: []ssh.AuthMethod{
ssh.PublicKeys(privateKey),
},
Config: ssh.Config{
Ciphers: common.DefaultCiphers,
KeyExchanges: common.DefaultKeyExchanges,
MACs: common.DefaultMACs,
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
ClientVersion: fmt.Sprintf("SSH-2.0-%s_%s", beszel.AppName, beszel.Version),
Timeout: sessionTimeout,
}
return nil
}
// deactivateAlerts finds all triggered alerts for a system and sets them to inactive.
// This is called when a system is paused or goes offline to prevent continued alerts.
func deactivateAlerts(app core.App, systemID string) error {
// Note: Direct SQL updates don't trigger SSE, so we use the PocketBase API
// _, err := app.DB().NewQuery(fmt.Sprintf("UPDATE alerts SET triggered = false WHERE system = '%s'", systemID)).Execute()
alerts, err := app.FindRecordsByFilter("alerts", fmt.Sprintf("system = '%s' && triggered = 1", systemID), "", -1, 0)
if err != nil {
return err
}
for _, alert := range alerts {
alert.Set("triggered", false)
if err := app.SaveNoValidate(alert); err != nil {
return err
}
}
return nil
}

View File

@@ -1,435 +0,0 @@
package systems
import (
"beszel/internal/entities/system"
"context"
"fmt"
"net"
"strings"
"time"
"github.com/goccy/go-json"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/store"
"golang.org/x/crypto/ssh"
)
const (
up string = "up"
down string = "down"
paused string = "paused"
pending string = "pending"
interval int = 60_000
sessionTimeout = 4 * time.Second
)
type SystemManager struct {
hub hubLike
systems *store.Store[string, *System]
sshConfig *ssh.ClientConfig
}
type System struct {
Id string `db:"id"`
Host string `db:"host"`
Port string `db:"port"`
Status string `db:"status"`
manager *SystemManager
client *ssh.Client
data *system.CombinedData
ctx context.Context
cancel context.CancelFunc
}
type hubLike interface {
core.App
GetSSHKey() ([]byte, error)
HandleSystemAlerts(systemRecord *core.Record, data *system.CombinedData) error
HandleStatusAlerts(status string, systemRecord *core.Record) error
}
func NewSystemManager(hub hubLike) *SystemManager {
return &SystemManager{
systems: store.New(map[string]*System{}),
hub: hub,
}
}
// Initialize initializes the system manager.
// It binds the event hooks and starts updating existing systems.
func (sm *SystemManager) Initialize() error {
sm.bindEventHooks()
// ssh setup
key, err := sm.hub.GetSSHKey()
if err != nil {
return err
}
if err := sm.createSSHClientConfig(key); err != nil {
return err
}
// start updating existing systems
var systems []*System
err = sm.hub.DB().NewQuery("SELECT id, host, port, status FROM systems WHERE status != 'paused'").All(&systems)
if err != nil || len(systems) == 0 {
return err
}
go func() {
// time between initial system updates
delta := interval / max(1, len(systems))
delta = min(delta, 2_000)
sleepTime := time.Duration(delta) * time.Millisecond
for _, system := range systems {
time.Sleep(sleepTime)
_ = sm.AddSystem(system)
}
}()
return nil
}
func (sm *SystemManager) bindEventHooks() {
sm.hub.OnRecordCreate("systems").BindFunc(sm.onRecordCreate)
sm.hub.OnRecordAfterCreateSuccess("systems").BindFunc(sm.onRecordAfterCreateSuccess)
sm.hub.OnRecordUpdate("systems").BindFunc(sm.onRecordUpdate)
sm.hub.OnRecordAfterUpdateSuccess("systems").BindFunc(sm.onRecordAfterUpdateSuccess)
sm.hub.OnRecordAfterDeleteSuccess("systems").BindFunc(sm.onRecordAfterDeleteSuccess)
}
// Runs before the record is committed to the database
func (sm *SystemManager) onRecordCreate(e *core.RecordEvent) error {
e.Record.Set("info", system.Info{})
e.Record.Set("status", pending)
return e.Next()
}
// Runs after the record is committed to the database
func (sm *SystemManager) onRecordAfterCreateSuccess(e *core.RecordEvent) error {
if err := sm.AddRecord(e.Record); err != nil {
e.App.Logger().Error("Error adding record", "err", err)
}
return e.Next()
}
// Runs before the record is updated
func (sm *SystemManager) onRecordUpdate(e *core.RecordEvent) error {
if e.Record.GetString("status") == paused {
e.Record.Set("info", system.Info{})
}
return e.Next()
}
// Runs after the record is updated
func (sm *SystemManager) onRecordAfterUpdateSuccess(e *core.RecordEvent) error {
newStatus := e.Record.GetString("status")
switch newStatus {
case paused:
sm.RemoveSystem(e.Record.Id)
return e.Next()
case pending:
if err := sm.AddRecord(e.Record); err != nil {
e.App.Logger().Error("Error adding record", "err", err)
}
return e.Next()
}
system, ok := sm.systems.GetOk(e.Record.Id)
if !ok {
return sm.AddRecord(e.Record)
}
prevStatus := system.Status
system.Status = newStatus
// system alerts if system is up
if system.Status == up {
if err := sm.hub.HandleSystemAlerts(e.Record, system.data); err != nil {
e.App.Logger().Error("Error handling system alerts", "err", err)
}
}
if (system.Status == down && prevStatus == up) || (system.Status == up && prevStatus == down) {
if err := sm.hub.HandleStatusAlerts(system.Status, e.Record); err != nil {
e.App.Logger().Error("Error handling status alerts", "err", err)
}
}
return e.Next()
}
// Runs after the record is deleted
func (sm *SystemManager) onRecordAfterDeleteSuccess(e *core.RecordEvent) error {
sm.RemoveSystem(e.Record.Id)
return e.Next()
}
// AddSystem adds a system to the manager
func (sm *SystemManager) AddSystem(sys *System) error {
if sm.systems.Has(sys.Id) {
return fmt.Errorf("system exists")
}
if sys.Id == "" || sys.Host == "" {
return fmt.Errorf("system is missing required fields")
}
sys.manager = sm
sys.ctx, sys.cancel = context.WithCancel(context.Background())
sys.data = &system.CombinedData{}
sm.systems.Set(sys.Id, sys)
go sys.StartUpdater()
return nil
}
// RemoveSystem removes a system from the manager
func (sm *SystemManager) RemoveSystem(systemID string) error {
system, ok := sm.systems.GetOk(systemID)
if !ok {
return fmt.Errorf("system not found")
}
// cancel the context to signal stop
if system.cancel != nil {
system.cancel()
}
system.resetSSHClient()
sm.systems.Remove(systemID)
return nil
}
// AddRecord adds a record to the system manager.
// It first removes any existing system with the same ID, then creates a new System
// instance from the record data and adds it to the manager.
// This function is typically called when a new system is created or when an existing
// system's status changes to pending.
func (sm *SystemManager) AddRecord(record *core.Record) (err error) {
_ = sm.RemoveSystem(record.Id)
system := &System{
Id: record.Id,
Status: record.GetString("status"),
Host: record.GetString("host"),
Port: record.GetString("port"),
}
return sm.AddSystem(system)
}
// StartUpdater starts the system updater.
// It first fetches the data from the agent then updates the records.
// If the data is not found or the system is down, it sets the system down.
func (sys *System) StartUpdater() {
if sys.data == nil {
sys.data = &system.CombinedData{}
}
if err := sys.update(); err != nil {
_ = sys.setDown(err)
}
c := time.Tick(time.Duration(interval) * time.Millisecond)
for {
select {
case <-sys.ctx.Done():
return
case <-c:
err := sys.update()
if err != nil {
_ = sys.setDown(err)
}
}
}
}
// update updates the system data and records.
// It first fetches the data from the agent then updates the records.
func (sys *System) update() error {
_, err := sys.fetchDataFromAgent()
if err == nil {
_, err = sys.createRecords()
}
return err
}
// createRecords updates the system record and adds system_stats and container_stats records
func (sys *System) createRecords() (*core.Record, error) {
systemRecord, err := sys.getRecord()
if err != nil {
return nil, err
}
hub := sys.manager.hub
// add system_stats and container_stats records
systemStats, err := hub.FindCachedCollectionByNameOrId("system_stats")
if err != nil {
return nil, err
}
systemStatsRecord := core.NewRecord(systemStats)
systemStatsRecord.Set("system", systemRecord.Id)
systemStatsRecord.Set("stats", sys.data.Stats)
systemStatsRecord.Set("type", "1m")
if err := hub.SaveNoValidate(systemStatsRecord); err != nil {
return nil, err
}
// add new container_stats record
if len(sys.data.Containers) > 0 {
containerStats, err := hub.FindCachedCollectionByNameOrId("container_stats")
if err != nil {
return nil, err
}
containerStatsRecord := core.NewRecord(containerStats)
containerStatsRecord.Set("system", systemRecord.Id)
containerStatsRecord.Set("stats", sys.data.Containers)
containerStatsRecord.Set("type", "1m")
if err := hub.SaveNoValidate(containerStatsRecord); err != nil {
return nil, err
}
}
// update system record (do this last because it triggers alerts and we need above records to be inserted first)
systemRecord.Set("status", up)
systemRecord.Set("info", sys.data.Info)
if err := hub.SaveNoValidate(systemRecord); err != nil {
return nil, err
}
return systemRecord, nil
}
// getRecord retrieves the system record from the database.
// If the record is not found or the system is paused, it removes the system from the manager.
func (sys *System) getRecord() (*core.Record, error) {
record, err := sys.manager.hub.FindRecordById("systems", sys.Id)
if err != nil || record == nil {
_ = sys.manager.RemoveSystem(sys.Id)
return nil, err
}
return record, nil
}
// setDown marks a system as down in the database.
// It takes the original error that caused the system to go down and returns any error
// encountered during the process of updating the system status.
func (sys *System) setDown(OriginalError error) error {
if sys.Status == down {
return nil
}
record, err := sys.getRecord()
if err != nil {
return err
}
sys.manager.hub.Logger().Error("System down", "system", record.GetString("name"), "err", OriginalError)
record.Set("status", down)
err = sys.manager.hub.SaveNoValidate(record)
if err != nil {
return err
}
return nil
}
// fetchDataFromAgent fetches the data from the agent.
// It first creates a new SSH client if it doesn't exist or the system is down.
// Then it creates a new SSH session and fetches the data from the agent.
// If the data is not found or the system is down, it sets the system down.
func (sys *System) fetchDataFromAgent() (*system.CombinedData, error) {
maxRetries := 1
for attempt := 0; attempt <= maxRetries; attempt++ {
if sys.client == nil || sys.Status == down {
if err := sys.createSSHClient(); err != nil {
return nil, err
}
}
session, err := sys.createSessionWithTimeout(4 * time.Second)
if err != nil {
if attempt >= maxRetries {
return nil, err
}
sys.manager.hub.Logger().Warn("Session closed. Retrying...", "host", sys.Host, "port", sys.Port, "err", err)
sys.resetSSHClient()
continue
}
defer session.Close()
stdout, err := session.StdoutPipe()
if err != nil {
return nil, err
}
if err := session.Shell(); err != nil {
return nil, err
}
// this is initialized in startUpdater, should never be nil
*sys.data = system.CombinedData{}
if err := json.NewDecoder(stdout).Decode(sys.data); err != nil {
return nil, err
}
// wait for the session to complete
if err := session.Wait(); err != nil {
return nil, err
}
return sys.data, nil
}
// this should never be reached due to the return in the loop
return nil, fmt.Errorf("failed to fetch data")
}
func (sm *SystemManager) createSSHClientConfig(key []byte) error {
signer, err := ssh.ParsePrivateKey(key)
if err != nil {
return err
}
sm.sshConfig = &ssh.ClientConfig{
User: "u",
Auth: []ssh.AuthMethod{
ssh.PublicKeys(signer),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Timeout: sessionTimeout,
}
return nil
}
// createSSHClient creates a new SSH client for the system
func (s *System) createSSHClient() error {
network := "tcp"
host := s.Host
if strings.HasPrefix(host, "/") {
network = "unix"
} else {
host = net.JoinHostPort(host, s.Port)
}
var err error
s.client, err = ssh.Dial(network, host, s.manager.sshConfig)
if err != nil {
return err
}
return nil
}
// createSessionWithTimeout creates a new SSH session with a timeout to avoid hanging
// in case of network issues
func (sys *System) createSessionWithTimeout(timeout time.Duration) (*ssh.Session, error) {
if sys.client == nil {
return nil, fmt.Errorf("client not initialized")
}
ctx, cancel := context.WithTimeout(sys.ctx, timeout)
defer cancel()
sessionChan := make(chan *ssh.Session, 1)
errChan := make(chan error, 1)
go func() {
if session, err := sys.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("timeout")
}
}
// resetSSHClient closes the SSH connection and resets the client to nil
func (sys *System) resetSSHClient() {
if sys.client != nil {
sys.client.Close()
}
sys.client = nil
}

View File

@@ -11,70 +11,133 @@ import (
"fmt"
"sync"
"testing"
"testing/synctest"
"time"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// createTestSystem creates a test system record with a unique host name
// and returns the created record and any error
func createTestSystem(t *testing.T, hub *tests.TestHub, options map[string]any) (*core.Record, error) {
collection, err := hub.FindCachedCollectionByNameOrId("systems")
if err != nil {
return nil, err
}
// get user record
var firstUser *core.Record
users, err := hub.FindAllRecords("users", dbx.NewExp("id != ''"))
if err != nil {
t.Fatal(err)
}
if len(users) > 0 {
firstUser = users[0]
}
// Generate a unique host name to ensure we're adding a new system
uniqueHost := fmt.Sprintf("test-host-%d.example.com", time.Now().UnixNano())
// Create the record
record := core.NewRecord(collection)
record.Set("name", uniqueHost)
record.Set("host", uniqueHost)
record.Set("port", "45876")
record.Set("status", "pending")
record.Set("users", []string{firstUser.Id})
// Apply any custom options
for key, value := range options {
record.Set(key, value)
}
// Save the record to the database
err = hub.Save(record)
if err != nil {
return nil, err
}
return record, nil
}
func TestSystemManagerIntegration(t *testing.T) {
// Create a test hub
hub, err := tests.NewTestHub()
func TestSystemManagerNew(t *testing.T) {
hub, err := tests.NewTestHub(t.TempDir())
if err != nil {
t.Fatal(err)
}
defer hub.Cleanup()
sm := hub.GetSystemManager()
// Create independent system manager
sm := systems.NewSystemManager(hub)
user, err := tests.CreateUser(hub, "test@test.com", "testtesttest")
require.NoError(t, err)
synctest.Run(func() {
sm.Initialize()
record, err := tests.CreateRecord(hub, "systems", map[string]any{
"name": "it-was-coney-island",
"host": "the-playground-of-the-world",
"port": "33914",
"users": []string{user.Id},
})
require.NoError(t, err)
assert.Equal(t, "pending", record.GetString("status"), "System status should be 'pending'")
assert.Equal(t, "pending", sm.GetSystemStatusFromStore(record.Id), "System status should be 'pending'")
// Verify the system host and port
host, port := sm.GetSystemHostPort(record.Id)
assert.Equal(t, record.GetString("host"), host, "System host should match")
assert.Equal(t, record.GetString("port"), port, "System port should match")
time.Sleep(13 * time.Second)
synctest.Wait()
assert.Equal(t, "pending", record.Fresh().GetString("status"), "System status should be 'pending'")
// Verify the system was added by checking if it exists
assert.True(t, sm.HasSystem(record.Id), "System should exist in the store")
time.Sleep(10 * time.Second)
synctest.Wait()
// system should be set to down after 15 seconds (no websocket connection)
assert.Equal(t, "down", sm.GetSystemStatusFromStore(record.Id), "System status should be 'down'")
// make sure the system is down in the db
record, err = hub.FindRecordById("systems", record.Id)
require.NoError(t, err)
assert.Equal(t, "down", record.GetString("status"), "System status should be 'down'")
assert.Equal(t, 1, sm.GetSystemCount(), "System count should be 1")
err = sm.RemoveSystem(record.Id)
assert.NoError(t, err)
assert.Equal(t, 0, sm.GetSystemCount(), "System count should be 0")
assert.False(t, sm.HasSystem(record.Id), "System should not exist in the store after removal")
// let's also make sure a system is removed from the store when the record is deleted
record, err = tests.CreateRecord(hub, "systems", map[string]any{
"name": "there-was-no-place-like-it",
"host": "in-the-whole-world",
"port": "33914",
"users": []string{user.Id},
})
require.NoError(t, err)
assert.True(t, sm.HasSystem(record.Id), "System should exist in the store after creation")
time.Sleep(8 * time.Second)
synctest.Wait()
assert.Equal(t, "pending", sm.GetSystemStatusFromStore(record.Id), "System status should be 'pending'")
sm.SetSystemStatusInDB(record.Id, "up")
time.Sleep(time.Second)
synctest.Wait()
assert.Equal(t, "up", sm.GetSystemStatusFromStore(record.Id), "System status should be 'up'")
// make sure the system switches to down after 11 seconds
sm.RemoveSystem(record.Id)
sm.AddRecord(record, nil)
assert.Equal(t, "pending", sm.GetSystemStatusFromStore(record.Id), "System status should be 'pending'")
time.Sleep(12 * time.Second)
synctest.Wait()
assert.Equal(t, "down", sm.GetSystemStatusFromStore(record.Id), "System status should be 'down'")
// sm.SetSystemStatusInDB(record.Id, "paused")
// time.Sleep(time.Second)
// synctest.Wait()
// assert.Equal(t, "paused", sm.GetSystemStatusFromStore(record.Id), "System status should be 'paused'")
// delete the record
err = hub.Delete(record)
require.NoError(t, err)
assert.False(t, sm.HasSystem(record.Id), "System should not exist in the store after deletion")
testOld(t, hub)
time.Sleep(time.Second)
synctest.Wait()
for _, systemId := range sm.GetAllSystemIDs() {
err = sm.RemoveSystem(systemId)
require.NoError(t, err)
assert.False(t, sm.HasSystem(systemId), "System should not exist in the store after deletion")
}
assert.Equal(t, 0, sm.GetSystemCount(), "System count should be 0")
// TODO: test with websocket client
})
}
func testOld(t *testing.T, hub *tests.TestHub) {
user, err := tests.CreateUser(hub, "test@testy.com", "testtesttest")
require.NoError(t, err)
sm := hub.GetSystemManager()
assert.NotNil(t, sm)
// Test initialization
sm.Initialize()
// error expected when creating a user with a duplicate email
_, err = tests.CreateUser(hub, "test@test.com", "testtesttest")
require.Error(t, err)
// Test collection existence. todo: move to hub package tests
t.Run("CollectionExistence", func(t *testing.T) {
@@ -92,81 +155,17 @@ func TestSystemManagerIntegration(t *testing.T) {
assert.NotNil(t, containerStats)
})
// Test adding a system record
t.Run("AddRecord", func(t *testing.T) {
var wg sync.WaitGroup
wg.Add(2)
// Get the count before adding the system
countBefore := sm.GetSystemCount()
// record should be pending on create
hub.OnRecordCreate("systems").BindFunc(func(e *core.RecordEvent) error {
record := e.Record
if record.GetString("name") == "welcometoarcoampm" {
assert.Equal(t, "pending", e.Record.GetString("status"), "System status should be 'pending'")
wg.Done()
}
return e.Next()
})
// record should be down on update
hub.OnRecordAfterUpdateSuccess("systems").BindFunc(func(e *core.RecordEvent) error {
record := e.Record
if record.GetString("name") == "welcometoarcoampm" {
assert.Equal(t, "down", e.Record.GetString("status"), "System status should be 'pending'")
wg.Done()
}
return e.Next()
})
// Create a test system with the first user assigned
record, err := createTestSystem(t, hub, map[string]any{
"name": "welcometoarcoampm",
"host": "localhost",
"port": "33914",
})
require.NoError(t, err)
wg.Wait()
// system should be down if grabbed from the store
assert.Equal(t, "down", sm.GetSystemStatusFromStore(record.Id), "System status should be 'down'")
// Check that the system count increased
countAfter := sm.GetSystemCount()
assert.Equal(t, countBefore+1, countAfter, "System count should increase after adding a system via event hook")
// Verify the system was added by checking if it exists
assert.True(t, sm.HasSystem(record.Id), "System should exist in the store")
// Verify the system host and port
host, port := sm.GetSystemHostPort(record.Id)
assert.Equal(t, record.Get("host"), host, "System host should match")
assert.Equal(t, record.Get("port"), port, "System port should match")
// Verify the system is in the list of all system IDs
ids := sm.GetAllSystemIDs()
assert.Contains(t, ids, record.Id, "System ID should be in the list of all system IDs")
// Verify the system was added by checking if removing it works
err = sm.RemoveSystem(record.Id)
assert.NoError(t, err, "System should exist and be removable")
// Verify the system no longer exists
assert.False(t, sm.HasSystem(record.Id), "System should not exist in the store after removal")
// Verify the system is not in the list of all system IDs
newIds := sm.GetAllSystemIDs()
assert.NotContains(t, newIds, record.Id, "System ID should not be in the list of all system IDs after removal")
})
t.Run("RemoveSystem", func(t *testing.T) {
// Get the count before adding the system
countBefore := sm.GetSystemCount()
// Create a test system record
record, err := createTestSystem(t, hub, map[string]any{})
record, err := tests.CreateRecord(hub, "systems", map[string]any{
"name": "i-even-got-lost-at-coney-island",
"host": "but-they-found-me",
"port": "33914",
"users": []string{user.Id},
})
require.NoError(t, err)
// Verify the system count increased
@@ -202,11 +201,16 @@ func TestSystemManagerIntegration(t *testing.T) {
t.Run("NewRecordPending", func(t *testing.T) {
// Create a test system
record, err := createTestSystem(t, hub, map[string]any{})
record, err := tests.CreateRecord(hub, "systems", map[string]any{
"name": "and-you-know",
"host": "i-feel-very-bad",
"port": "33914",
"users": []string{user.Id},
})
require.NoError(t, err)
// Add the record to the system manager
err = sm.AddRecord(record)
err = sm.AddRecord(record, nil)
require.NoError(t, err)
// Test filtering records by status - should be "pending" now
@@ -218,11 +222,16 @@ func TestSystemManagerIntegration(t *testing.T) {
t.Run("SystemStatusUpdate", func(t *testing.T) {
// Create a test system record
record, err := createTestSystem(t, hub, map[string]any{})
record, err := tests.CreateRecord(hub, "systems", map[string]any{
"name": "we-used-to-sleep-on-the-beach",
"host": "sleep-overnight-here",
"port": "33914",
"users": []string{user.Id},
})
require.NoError(t, err)
// Add the record to the system manager
err = sm.AddRecord(record)
err = sm.AddRecord(record, nil)
require.NoError(t, err)
// Test status changes
@@ -244,7 +253,12 @@ func TestSystemManagerIntegration(t *testing.T) {
t.Run("HandleSystemData", func(t *testing.T) {
// Create a test system record
record, err := createTestSystem(t, hub, map[string]any{})
record, err := tests.CreateRecord(hub, "systems", map[string]any{
"name": "things-changed-you-know",
"host": "they-dont-sleep-anymore-on-the-beach",
"port": "33914",
"users": []string{user.Id},
})
require.NoError(t, err)
// Create test system data
@@ -295,54 +309,14 @@ func TestSystemManagerIntegration(t *testing.T) {
assert.Error(t, err)
})
t.Run("DeleteRecord", func(t *testing.T) {
var wg sync.WaitGroup
wg.Add(2)
runs := 0
hub.OnRecordUpdate("systems").BindFunc(func(e *core.RecordEvent) error {
runs++
record := e.Record
if record.GetString("name") == "deadflagblues" {
if runs == 1 {
assert.Equal(t, "up", e.Record.GetString("status"), "System status should be 'up'")
wg.Done()
} else if runs == 2 {
assert.Equal(t, "paused", e.Record.GetString("status"), "System status should be 'paused'")
wg.Done()
}
}
return e.Next()
})
// Create a test system record
record, err := createTestSystem(t, hub, map[string]any{
"name": "deadflagblues",
})
require.NoError(t, err)
// Verify the system exists
assert.True(t, sm.HasSystem(record.Id), "System should exist in the store")
// set the status manually to up
sm.SetSystemStatusInDB(record.Id, "up")
// verify the status is up
assert.Equal(t, "up", sm.GetSystemStatusFromStore(record.Id), "System status should be 'up'")
// Set the status to "paused" which should cause it to be deleted from the store
sm.SetSystemStatusInDB(record.Id, "paused")
wg.Wait()
// Verify the system no longer exists
assert.False(t, sm.HasSystem(record.Id), "System should not exist in the store after deletion")
})
t.Run("ConcurrentOperations", func(t *testing.T) {
// Create a test system
record, err := createTestSystem(t, hub, map[string]any{})
record, err := tests.CreateRecord(hub, "systems", map[string]any{
"name": "jfkjahkfajs",
"host": "localhost",
"port": "33914",
"users": []string{user.Id},
})
require.NoError(t, err)
// Run concurrent operations
@@ -377,7 +351,12 @@ func TestSystemManagerIntegration(t *testing.T) {
t.Run("ContextCancellation", func(t *testing.T) {
// Create a test system record
record, err := createTestSystem(t, hub, map[string]any{})
record, err := tests.CreateRecord(hub, "systems", map[string]any{
"name": "lkhsdfsjf",
"host": "localhost",
"port": "33914",
"users": []string{user.Id},
})
require.NoError(t, err)
// Verify the system exists in the store
@@ -420,7 +399,7 @@ func TestSystemManagerIntegration(t *testing.T) {
assert.Error(t, err, "RemoveSystem should fail for non-existent system")
// Add the system back
err = sm.AddRecord(record)
err = sm.AddRecord(record, nil)
require.NoError(t, err, "AddRecord should succeed")
// Verify the system is back in the store

View File

@@ -9,17 +9,17 @@ import (
"fmt"
)
// GetSystemCount returns the number of systems in the store
// TESTING ONLY: GetSystemCount returns the number of systems in the store
func (sm *SystemManager) GetSystemCount() int {
return sm.systems.Length()
}
// HasSystem checks if a system with the given ID exists in the store
// TESTING ONLY: HasSystem checks if a system with the given ID exists in the store
func (sm *SystemManager) HasSystem(systemID string) bool {
return sm.systems.Has(systemID)
}
// GetSystemStatusFromStore returns the status of a system with the given ID
// TESTING ONLY: GetSystemStatusFromStore returns the status of a system with the given ID
// Returns an empty string if the system doesn't exist
func (sm *SystemManager) GetSystemStatusFromStore(systemID string) string {
sys, ok := sm.systems.GetOk(systemID)
@@ -29,7 +29,7 @@ func (sm *SystemManager) GetSystemStatusFromStore(systemID string) string {
return sys.Status
}
// GetSystemContextFromStore returns the context and cancel function for a system
// TESTING ONLY: GetSystemContextFromStore returns the context and cancel function for a system
func (sm *SystemManager) GetSystemContextFromStore(systemID string) (context.Context, context.CancelFunc, error) {
sys, ok := sm.systems.GetOk(systemID)
if !ok {
@@ -38,7 +38,7 @@ func (sm *SystemManager) GetSystemContextFromStore(systemID string) (context.Con
return sys.ctx, sys.cancel, nil
}
// GetSystemFromStore returns a store from the system
// TESTING ONLY: GetSystemFromStore returns a store from the system
func (sm *SystemManager) GetSystemFromStore(systemID string) (*System, error) {
sys, ok := sm.systems.GetOk(systemID)
if !ok {
@@ -47,7 +47,7 @@ func (sm *SystemManager) GetSystemFromStore(systemID string) (*System, error) {
return sys, nil
}
// GetAllSystemIDs returns a slice of all system IDs in the store
// TESTING ONLY: GetAllSystemIDs returns a slice of all system IDs in the store
func (sm *SystemManager) GetAllSystemIDs() []string {
data := sm.systems.GetAll()
ids := make([]string, 0, len(data))
@@ -57,7 +57,7 @@ func (sm *SystemManager) GetAllSystemIDs() []string {
return ids
}
// GetSystemData returns the combined data for a system with the given ID
// TESTING ONLY: GetSystemData returns the combined data for a system with the given ID
// Returns nil if the system doesn't exist
// This method is intended for testing
func (sm *SystemManager) GetSystemData(systemID string) *entities.CombinedData {
@@ -68,7 +68,7 @@ func (sm *SystemManager) GetSystemData(systemID string) *entities.CombinedData {
return sys.data
}
// GetSystemHostPort returns the host and port for a system with the given ID
// TESTING ONLY: GetSystemHostPort returns the host and port for a system with the given ID
// Returns empty strings if the system doesn't exist
func (sm *SystemManager) GetSystemHostPort(systemID string) (string, string) {
sys, ok := sm.systems.GetOk(systemID)
@@ -78,22 +78,7 @@ func (sm *SystemManager) GetSystemHostPort(systemID string) (string, string) {
return sys.Host, sys.Port
}
// DisableAutoUpdater disables the automatic updater for a system
// This is intended for testing
// Returns false if the system doesn't exist
// func (sm *SystemManager) DisableAutoUpdater(systemID string) bool {
// sys, ok := sm.systems.GetOk(systemID)
// if !ok {
// return false
// }
// if sys.cancel != nil {
// sys.cancel()
// sys.cancel = nil
// }
// return true
// }
// SetSystemStatusInDB sets the status of a system directly and updates the database record
// TESTING ONLY: SetSystemStatusInDB sets the status of a system directly and updates the database record
// This is intended for testing
// Returns false if the system doesn't exist
func (sm *SystemManager) SetSystemStatusInDB(systemID string, status string) bool {

View File

@@ -1,57 +1,73 @@
package hub
import (
"beszel"
"beszel/internal/ghupdate"
"fmt"
"log"
"os"
"strings"
"os/exec"
"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_"},
dataDir := os.TempDir()
// set dataDir to ./beszel_data if it exists
if _, err := os.Stat("./beszel_data"); err == nil {
dataDir = "./beszel_data"
}
updated, err := ghupdate.Update(ghupdate.Config{
ArchiveExecutable: "beszel",
DataDir: dataDir,
})
latest, found, err = updater.DetectLatest("henrygd/beszel")
if err != nil {
fmt.Println("Error checking for updates:", err)
os.Exit(1)
log.Fatal(err)
}
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")
if !updated {
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))
// Try to restart the service if it's running
restartService()
}
// restartService attempts to restart the beszel service
func restartService() {
// Check if we're running as a service by looking for systemd
if _, err := exec.LookPath("systemctl"); err == nil {
// Check if beszel service exists and is active
cmd := exec.Command("systemctl", "is-active", "beszel.service")
if err := cmd.Run(); err == nil {
fmt.Println("Restarting beszel service...")
restartCmd := exec.Command("systemctl", "restart", "beszel.service")
if err := restartCmd.Run(); err != nil {
fmt.Printf("Warning: Failed to restart service: %v\n", err)
fmt.Println("Please restart the service manually: sudo systemctl restart beszel")
} else {
fmt.Println("Service restarted successfully")
}
return
}
}
// Check for OpenRC (Alpine Linux)
if _, err := exec.LookPath("rc-service"); err == nil {
cmd := exec.Command("rc-service", "beszel", "status")
if err := cmd.Run(); err == nil {
fmt.Println("Restarting beszel service...")
restartCmd := exec.Command("rc-service", "beszel", "restart")
if err := restartCmd.Run(); err != nil {
fmt.Printf("Warning: Failed to restart service: %v\n", err)
fmt.Println("Please restart the service manually: sudo rc-service beszel restart")
} else {
fmt.Println("Service restarted successfully")
}
return
}
}
fmt.Println("Note: Service restart not attempted. If running as a service, restart manually.")
}

View File

@@ -0,0 +1,180 @@
package ws
import (
"beszel/internal/common"
"beszel/internal/entities/system"
"errors"
"time"
"weak"
"github.com/fxamacker/cbor/v2"
"github.com/lxzan/gws"
"golang.org/x/crypto/ssh"
)
const (
deadline = 70 * time.Second
)
// Handler implements the WebSocket event handler for agent connections.
type Handler struct {
gws.BuiltinEventHandler
}
// WsConn represents a WebSocket connection to an agent.
type WsConn struct {
conn *gws.Conn
responseChan chan *gws.Message
DownChan chan struct{}
}
// FingerprintRecord is fingerprints collection record data in the hub
type FingerprintRecord struct {
Id string `db:"id"`
SystemId string `db:"system"`
Fingerprint string `db:"fingerprint"`
Token string `db:"token"`
}
var upgrader *gws.Upgrader
// GetUpgrader returns a singleton WebSocket upgrader instance.
func GetUpgrader() *gws.Upgrader {
if upgrader != nil {
return upgrader
}
handler := &Handler{}
upgrader = gws.NewUpgrader(handler, &gws.ServerOption{})
return upgrader
}
// NewWsConnection creates a new WebSocket connection wrapper.
func NewWsConnection(conn *gws.Conn) *WsConn {
return &WsConn{
conn: conn,
responseChan: make(chan *gws.Message, 1),
DownChan: make(chan struct{}, 1),
}
}
// OnOpen sets a deadline for the WebSocket connection.
func (h *Handler) OnOpen(conn *gws.Conn) {
conn.SetDeadline(time.Now().Add(deadline))
}
// OnMessage routes incoming WebSocket messages to the response channel.
func (h *Handler) OnMessage(conn *gws.Conn, message *gws.Message) {
conn.SetDeadline(time.Now().Add(deadline))
if message.Opcode != gws.OpcodeBinary || message.Data.Len() == 0 {
return
}
wsConn, ok := conn.Session().Load("wsConn")
if !ok {
_ = conn.WriteClose(1000, nil)
return
}
select {
case wsConn.(*WsConn).responseChan <- message:
default:
// close if the connection is not expecting a response
wsConn.(*WsConn).Close(nil)
}
}
// OnClose handles WebSocket connection closures and triggers system down status after delay.
func (h *Handler) OnClose(conn *gws.Conn, err error) {
wsConn, ok := conn.Session().Load("wsConn")
if !ok {
return
}
wsConn.(*WsConn).conn = nil
// wait 5 seconds to allow reconnection before setting system down
// use a weak pointer to avoid keeping references if the system is removed
go func(downChan weak.Pointer[chan struct{}]) {
time.Sleep(5 * time.Second)
downChanValue := downChan.Value()
if downChanValue != nil {
*downChanValue <- struct{}{}
}
}(weak.Make(&wsConn.(*WsConn).DownChan))
}
// Close terminates the WebSocket connection gracefully.
func (ws *WsConn) Close(msg []byte) {
if ws.IsConnected() {
ws.conn.WriteClose(1000, msg)
}
}
// Ping sends a ping frame to keep the connection alive.
func (ws *WsConn) Ping() error {
ws.conn.SetDeadline(time.Now().Add(deadline))
return ws.conn.WritePing(nil)
}
// sendMessage encodes data to CBOR and sends it as a binary message to the agent.
func (ws *WsConn) sendMessage(data common.HubRequest[any]) error {
if ws.conn == nil {
return gws.ErrConnClosed
}
bytes, err := cbor.Marshal(data)
if err != nil {
return err
}
return ws.conn.WriteMessage(gws.OpcodeBinary, bytes)
}
// RequestSystemData requests system metrics from the agent and unmarshals the response.
func (ws *WsConn) RequestSystemData(data *system.CombinedData) error {
var message *gws.Message
ws.sendMessage(common.HubRequest[any]{
Action: common.GetData,
})
select {
case <-time.After(10 * time.Second):
ws.Close(nil)
return gws.ErrConnClosed
case message = <-ws.responseChan:
}
defer message.Close()
return cbor.Unmarshal(message.Data.Bytes(), data)
}
// GetFingerprint authenticates with the agent using SSH signature and returns the agent's fingerprint.
func (ws *WsConn) GetFingerprint(token string, signer ssh.Signer, needSysInfo bool) (common.FingerprintResponse, error) {
var clientFingerprint common.FingerprintResponse
challenge := []byte(token)
signature, err := signer.Sign(nil, challenge)
if err != nil {
return clientFingerprint, err
}
err = ws.sendMessage(common.HubRequest[any]{
Action: common.CheckFingerprint,
Data: common.FingerprintRequest{
Signature: signature.Blob,
NeedSysInfo: needSysInfo,
},
})
if err != nil {
return clientFingerprint, err
}
var message *gws.Message
select {
case message = <-ws.responseChan:
case <-time.After(10 * time.Second):
return clientFingerprint, errors.New("request expired")
}
defer message.Close()
err = cbor.Unmarshal(message.Data.Bytes(), &clientFingerprint)
return clientFingerprint, err
}
// IsConnected returns true if the WebSocket connection is active.
func (ws *WsConn) IsConnected() bool {
return ws.conn != nil
}

View File

@@ -0,0 +1,221 @@
//go:build testing
// +build testing
package ws
import (
"beszel/internal/common"
"crypto/ed25519"
"testing"
"time"
"github.com/fxamacker/cbor/v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/ssh"
)
// TestGetUpgrader tests the singleton upgrader
func TestGetUpgrader(t *testing.T) {
// Reset the global upgrader to test singleton behavior
upgrader = nil
// First call should create the upgrader
upgrader1 := GetUpgrader()
assert.NotNil(t, upgrader1, "Upgrader should not be nil")
// Second call should return the same instance
upgrader2 := GetUpgrader()
assert.Same(t, upgrader1, upgrader2, "Should return the same upgrader instance")
// Verify it's properly configured
assert.NotNil(t, upgrader1, "Upgrader should be configured")
}
// TestNewWsConnection tests WebSocket connection creation
func TestNewWsConnection(t *testing.T) {
// We can't easily mock gws.Conn, so we'll pass nil and test the structure
wsConn := NewWsConnection(nil)
assert.NotNil(t, wsConn, "WebSocket connection should not be nil")
assert.Nil(t, wsConn.conn, "Connection should be nil as passed")
assert.NotNil(t, wsConn.responseChan, "Response channel should be initialized")
assert.NotNil(t, wsConn.DownChan, "Down channel should be initialized")
assert.Equal(t, 1, cap(wsConn.responseChan), "Response channel should have capacity of 1")
assert.Equal(t, 1, cap(wsConn.DownChan), "Down channel should have capacity of 1")
}
// TestWsConn_IsConnected tests the connection status check
func TestWsConn_IsConnected(t *testing.T) {
// Test with nil connection
wsConn := NewWsConnection(nil)
assert.False(t, wsConn.IsConnected(), "Should not be connected when conn is nil")
}
// TestWsConn_Close tests the connection closing with nil connection
func TestWsConn_Close(t *testing.T) {
wsConn := NewWsConnection(nil)
// Should handle nil connection gracefully
assert.NotPanics(t, func() {
wsConn.Close([]byte("test message"))
}, "Should not panic when closing nil connection")
}
// TestWsConn_SendMessage_CBOR tests CBOR encoding in sendMessage
func TestWsConn_SendMessage_CBOR(t *testing.T) {
wsConn := NewWsConnection(nil)
testData := common.HubRequest[any]{
Action: common.GetData,
Data: "test data",
}
// This will fail because conn is nil, but we can test the CBOR encoding logic
// by checking that the function properly encodes to CBOR before failing
err := wsConn.sendMessage(testData)
assert.Error(t, err, "Should error with nil connection")
// Test CBOR encoding separately
bytes, err := cbor.Marshal(testData)
assert.NoError(t, err, "Should encode to CBOR successfully")
// Verify we can decode it back
var decodedData common.HubRequest[any]
err = cbor.Unmarshal(bytes, &decodedData)
assert.NoError(t, err, "Should decode from CBOR successfully")
assert.Equal(t, testData.Action, decodedData.Action, "Action should match")
}
// TestWsConn_GetFingerprint_SignatureGeneration tests signature creation logic
func TestWsConn_GetFingerprint_SignatureGeneration(t *testing.T) {
// Generate test key pair
_, privKey, err := ed25519.GenerateKey(nil)
require.NoError(t, err)
signer, err := ssh.NewSignerFromKey(privKey)
require.NoError(t, err)
token := "test-token"
// This will timeout since conn is nil, but we can verify the signature logic
// We can't test the full flow, but we can test that the signature is created properly
challenge := []byte(token)
signature, err := signer.Sign(nil, challenge)
assert.NoError(t, err, "Should create signature successfully")
assert.NotEmpty(t, signature.Blob, "Signature blob should not be empty")
assert.Equal(t, signer.PublicKey().Type(), signature.Format, "Signature format should match key type")
// Test the fingerprint request structure
fpRequest := common.FingerprintRequest{
Signature: signature.Blob,
NeedSysInfo: true,
}
// Test CBOR encoding of fingerprint request
fpData, err := cbor.Marshal(fpRequest)
assert.NoError(t, err, "Should encode fingerprint request to CBOR")
var decodedFpRequest common.FingerprintRequest
err = cbor.Unmarshal(fpData, &decodedFpRequest)
assert.NoError(t, err, "Should decode fingerprint request from CBOR")
assert.Equal(t, fpRequest.Signature, decodedFpRequest.Signature, "Signature should match")
assert.Equal(t, fpRequest.NeedSysInfo, decodedFpRequest.NeedSysInfo, "NeedSysInfo should match")
// Test the full hub request structure
hubRequest := common.HubRequest[any]{
Action: common.CheckFingerprint,
Data: fpRequest,
}
hubData, err := cbor.Marshal(hubRequest)
assert.NoError(t, err, "Should encode hub request to CBOR")
var decodedHubRequest common.HubRequest[cbor.RawMessage]
err = cbor.Unmarshal(hubData, &decodedHubRequest)
assert.NoError(t, err, "Should decode hub request from CBOR")
assert.Equal(t, common.CheckFingerprint, decodedHubRequest.Action, "Action should be CheckFingerprint")
}
// TestWsConn_RequestSystemData_RequestFormat tests system data request format
func TestWsConn_RequestSystemData_RequestFormat(t *testing.T) {
// Test the request format that would be sent
request := common.HubRequest[any]{
Action: common.GetData,
}
// Test CBOR encoding
data, err := cbor.Marshal(request)
assert.NoError(t, err, "Should encode request to CBOR")
// Test decoding
var decodedRequest common.HubRequest[any]
err = cbor.Unmarshal(data, &decodedRequest)
assert.NoError(t, err, "Should decode request from CBOR")
assert.Equal(t, common.GetData, decodedRequest.Action, "Should have GetData action")
}
// TestFingerprintRecord tests the FingerprintRecord struct
func TestFingerprintRecord(t *testing.T) {
record := FingerprintRecord{
Id: "test-id",
SystemId: "system-123",
Fingerprint: "test-fingerprint",
Token: "test-token",
}
assert.Equal(t, "test-id", record.Id)
assert.Equal(t, "system-123", record.SystemId)
assert.Equal(t, "test-fingerprint", record.Fingerprint)
assert.Equal(t, "test-token", record.Token)
}
// TestDeadlineConstant tests that the deadline constant is reasonable
func TestDeadlineConstant(t *testing.T) {
assert.Equal(t, 70*time.Second, deadline, "Deadline should be 70 seconds")
}
// TestCommonActions tests that the common actions are properly defined
func TestCommonActions(t *testing.T) {
// Test that the actions we use exist and have expected values
assert.Equal(t, common.WebSocketAction(0), common.GetData, "GetData should be action 0")
assert.Equal(t, common.WebSocketAction(1), common.CheckFingerprint, "CheckFingerprint should be action 1")
}
// TestHandler tests that we can create a Handler
func TestHandler(t *testing.T) {
handler := &Handler{}
assert.NotNil(t, handler, "Handler should be created successfully")
// The Handler embeds gws.BuiltinEventHandler, so it should have the embedded type
assert.NotNil(t, handler.BuiltinEventHandler, "Should have embedded BuiltinEventHandler")
}
// TestWsConnChannelBehavior tests channel behavior without WebSocket connections
func TestWsConnChannelBehavior(t *testing.T) {
wsConn := NewWsConnection(nil)
// Test that channels are properly initialized and can be used
select {
case wsConn.DownChan <- struct{}{}:
// Should be able to write to channel
default:
t.Error("Should be able to write to DownChan")
}
// Test reading from DownChan
select {
case <-wsConn.DownChan:
// Should be able to read from channel
case <-time.After(10 * time.Millisecond):
t.Error("Should be able to read from DownChan")
}
// Response channel should be empty initially
select {
case <-wsConn.responseChan:
t.Error("Response channel should be empty initially")
default:
// Expected - channel should be empty
}
}

View File

@@ -4,13 +4,13 @@ package records
import (
"beszel/internal/entities/container"
"beszel/internal/entities/system"
"encoding/json"
"fmt"
"log"
"math"
"strings"
"time"
"github.com/goccy/go-json"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
)
@@ -26,14 +26,26 @@ type LongerRecordData struct {
minShorterRecords int
}
type RecordStats []struct {
Stats []byte `db:"stats"`
type RecordIds []struct {
Id string `db:"id"`
}
func NewRecordManager(app core.App) *RecordManager {
return &RecordManager{app}
}
type StatsRecord struct {
Stats []byte `db:"stats"`
}
// global variables for reusing allocations
var statsRecord StatsRecord
var containerStats []container.Stats
var sumStats system.Stats
var tempStats system.Stats
var queryParams = make(dbx.Params, 1)
var containerSums = make(map[string]*container.Stats)
// Create longer records by averaging shorter records
func (rm *RecordManager) CreateLongerRecords() {
// start := time.Now()
@@ -76,11 +88,10 @@ func (rm *RecordManager) CreateLongerRecords() {
if err != nil {
return err
}
var systems []struct {
Id string `db:"id"`
}
var systems RecordIds
db := txApp.DB()
txApp.DB().NewQuery("SELECT id FROM systems WHERE status='up'").All(&systems)
db.NewQuery("SELECT id FROM systems WHERE status='up'").All(&systems)
// loop through all active systems, time periods, and collections
for _, system := range systems {
@@ -96,22 +107,23 @@ func (rm *RecordManager) CreateLongerRecords() {
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(
count, err := txApp.CountRecords(
collection.Id,
"system = {:system} && type = {:type} && created > {:created}",
dbx.Params{"type": recordData.longerType, "system": system.Id, "created": longerRecordPeriod},
dbx.NewExp(
"system = {:system} AND type = {:type} AND 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")
if err != nil || count > 0 {
continue
}
}
// get shorter records from the past x minutes
var stats RecordStats
var recordIds RecordIds
err := txApp.DB().
Select("stats").
Select("id").
From(collection.Name).
AndWhere(dbx.NewExp(
"system={:system} AND type={:type} AND created > {:created}",
@@ -121,10 +133,10 @@ func (rm *RecordManager) CreateLongerRecords() {
"created": shorterRecordPeriod,
},
)).
All(&stats)
All(&recordIds)
// continue if not enough shorter records
if err != nil || len(stats) < recordData.minShorterRecords {
if err != nil || len(recordIds) < recordData.minShorterRecords {
continue
}
// average the shorter records and create longer record
@@ -133,9 +145,10 @@ func (rm *RecordManager) CreateLongerRecords() {
longerRecord.Set("type", recordData.longerType)
switch collection.Name {
case "system_stats":
longerRecord.Set("stats", rm.AverageSystemStats(stats))
longerRecord.Set("stats", rm.AverageSystemStats(db, recordIds))
case "container_stats":
longerRecord.Set("stats", rm.AverageContainerStats(stats))
longerRecord.Set("stats", rm.AverageContainerStats(db, recordIds))
}
if err := txApp.SaveNoValidate(longerRecord); err != nil {
log.Println("failed to save longer record", "err", err)
@@ -147,24 +160,36 @@ func (rm *RecordManager) CreateLongerRecords() {
return nil
})
statsRecord.Stats = statsRecord.Stats[:0]
// 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{}
func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *system.Stats {
// Clear/reset global structs for reuse
sumStats = system.Stats{}
tempStats = system.Stats{}
sum := &sumStats
stats := &tempStats
// necessary because uint8 is not big enough for the sum
batterySum := 0
count := float64(len(records))
tempCount := float64(0)
// Temporary struct for unmarshaling
stats := &system.Stats{}
// Accumulate totals
for i := range records {
*stats = system.Stats{} // Reset tempStats for unmarshaling
if err := json.Unmarshal(records[i].Stats, stats); err != nil {
for _, record := range records {
id := record.Id
// clear global statsRecord for reuse
statsRecord.Stats = statsRecord.Stats[:0]
queryParams["id"] = id
db.NewQuery("SELECT stats FROM system_stats WHERE id = {:id}").Bind(queryParams).One(&statsRecord)
if err := json.Unmarshal(statsRecord.Stats, stats); err != nil {
continue
}
sum.Cpu += stats.Cpu
sum.Mem += stats.Mem
sum.MemUsed += stats.MemUsed
@@ -180,12 +205,21 @@ func (rm *RecordManager) AverageSystemStats(records RecordStats) *system.Stats {
sum.DiskWritePs += stats.DiskWritePs
sum.NetworkSent += stats.NetworkSent
sum.NetworkRecv += stats.NetworkRecv
sum.LoadAvg[0] += stats.LoadAvg[0]
sum.LoadAvg[1] += stats.LoadAvg[1]
sum.LoadAvg[2] += stats.LoadAvg[2]
sum.Bandwidth[0] += stats.Bandwidth[0]
sum.Bandwidth[1] += stats.Bandwidth[1]
batterySum += int(stats.Battery[0])
sum.Battery[1] = stats.Battery[1]
// 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)
sum.MaxBandwidth[0] = max(sum.MaxBandwidth[0], stats.MaxBandwidth[0], stats.Bandwidth[0])
sum.MaxBandwidth[1] = max(sum.MaxBandwidth[1], stats.MaxBandwidth[1], stats.Bandwidth[1])
// Accumulate temperatures
if stats.Temperatures != nil {
@@ -255,7 +289,12 @@ func (rm *RecordManager) AverageSystemStats(records RecordStats) *system.Stats {
sum.DiskWritePs = twoDecimals(sum.DiskWritePs / count)
sum.NetworkSent = twoDecimals(sum.NetworkSent / count)
sum.NetworkRecv = twoDecimals(sum.NetworkRecv / count)
sum.LoadAvg[0] = twoDecimals(sum.LoadAvg[0] / count)
sum.LoadAvg[1] = twoDecimals(sum.LoadAvg[1] / count)
sum.LoadAvg[2] = twoDecimals(sum.LoadAvg[2] / count)
sum.Bandwidth[0] = sum.Bandwidth[0] / uint64(count)
sum.Bandwidth[1] = sum.Bandwidth[1] / uint64(count)
sum.Battery[0] = uint8(batterySum / int(count))
// Average temperatures
if sum.Temperatures != nil && tempCount > 0 {
for key := range sum.Temperatures {
@@ -293,14 +332,24 @@ func (rm *RecordManager) AverageSystemStats(records RecordStats) *system.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)
func (rm *RecordManager) AverageContainerStats(db dbx.Builder, records RecordIds) []container.Stats {
// Clear global map for reuse
for k := range containerSums {
delete(containerSums, k)
}
sums := containerSums
count := float64(len(records))
containerStats := make([]container.Stats, 0, 50)
for i := range records {
// reset slice
id := records[i].Id
// clear global statsRecord and containerStats for reuse
statsRecord.Stats = statsRecord.Stats[:0]
containerStats = containerStats[:0]
if err := json.Unmarshal(records[i].Stats, &containerStats); err != nil {
queryParams["id"] = id
db.NewQuery("SELECT stats FROM container_stats WHERE id = {:id}").Bind(queryParams).One(&statsRecord)
if err := json.Unmarshal(statsRecord.Stats, &containerStats); err != nil {
return []container.Stats{}
}
for i := range containerStats {
@@ -328,12 +377,46 @@ func (rm *RecordManager) AverageContainerStats(records RecordStats) []container.
return result
}
// Deletes records older than what is displayed in the UI
// Delete old records
func (rm *RecordManager) DeleteOldRecords() {
// Define the collections to process
collections := []string{"system_stats", "container_stats"}
rm.app.RunInTransaction(func(txApp core.App) error {
err := deleteOldSystemStats(txApp)
if err != nil {
return err
}
err = deleteOldAlertsHistory(txApp, 200, 250)
if err != nil {
return err
}
return nil
})
}
// Define record types and their retention periods
// Delete old alerts history records
func deleteOldAlertsHistory(app core.App, countToKeep, countBeforeDeletion int) error {
db := app.DB()
var users []struct {
Id string `db:"user"`
}
err := db.NewQuery("SELECT user, COUNT(*) as count FROM alerts_history GROUP BY user HAVING count > {:countBeforeDeletion}").Bind(dbx.Params{"countBeforeDeletion": countBeforeDeletion}).All(&users)
if err != nil {
return err
}
for _, user := range users {
_, err = db.NewQuery("DELETE FROM alerts_history WHERE user = {:user} AND id NOT IN (SELECT id FROM alerts_history WHERE user = {:user} ORDER BY created DESC LIMIT {:countToKeep})").Bind(dbx.Params{"user": user.Id, "countToKeep": countToKeep}).Execute()
if err != nil {
return err
}
}
return nil
}
// Deletes system_stats records older than what is displayed in the UI
func deleteOldSystemStats(app core.App) error {
// Collections to process
collections := [2]string{"system_stats", "container_stats"}
// Record types and their retention periods
type RecordDeletionData struct {
recordType string
retention time.Duration
@@ -346,31 +429,28 @@ func (rm *RecordManager) DeleteOldRecords() {
{recordType: "480m", retention: 30 * 24 * time.Hour}, // 30 days
}
// Process each collection
now := time.Now().UTC()
for _, collection := range collections {
// Build the WHERE clause dynamically
// Build the WHERE clause
var conditionParts []string
var params dbx.Params = make(map[string]any)
for i, rd := range recordData {
for i := range recordData {
rd := recordData[i]
// Create parameterized condition for this record type
dateParam := fmt.Sprintf("date%d", i)
conditionParts = append(conditionParts, fmt.Sprintf("(type = '%s' AND created < {:%s})", rd.recordType, dateParam))
params[dateParam] = time.Now().UTC().Add(-rd.retention)
params[dateParam] = now.Add(-rd.retention)
}
// Combine conditions with OR
conditionStr := strings.Join(conditionParts, " OR ")
// Construct the full raw query
// Construct and execute the full raw query
rawQuery := fmt.Sprintf("DELETE FROM %s WHERE %s", collection, conditionStr)
// Execute the query with parameters
if _, err := rm.app.DB().NewQuery(rawQuery).Bind(params).Execute(); err != nil {
// return fmt.Errorf("failed to delete from %s: %v", collection, err)
rm.app.Logger().Error("failed to delete", "collection", collection, "error", err)
if _, err := app.DB().NewQuery(rawQuery).Bind(params).Execute(); err != nil {
return fmt.Errorf("failed to delete from %s: %v", collection, err)
}
}
return nil
}
/* Round float to two decimals */

View File

@@ -0,0 +1,381 @@
//go:build testing
// +build testing
package records_test
import (
"beszel/internal/records"
"beszel/internal/tests"
"fmt"
"testing"
"time"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestDeleteOldRecords tests the main DeleteOldRecords function
func TestDeleteOldRecords(t *testing.T) {
hub, err := tests.NewTestHub(t.TempDir())
require.NoError(t, err)
defer hub.Cleanup()
rm := records.NewRecordManager(hub)
// Create test user for alerts history
user, err := tests.CreateUser(hub, "test@example.com", "testtesttest")
require.NoError(t, err)
// Create test system
system, err := tests.CreateRecord(hub, "systems", map[string]any{
"name": "test-system",
"host": "localhost",
"port": "45876",
"status": "up",
"users": []string{user.Id},
})
require.NoError(t, err)
now := time.Now()
// Create old system_stats records that should be deleted
var record *core.Record
record, err = tests.CreateRecord(hub, "system_stats", map[string]any{
"system": system.Id,
"type": "1m",
"stats": `{"cpu": 50.0, "mem": 1024}`,
})
require.NoError(t, err)
// created is autodate field, so we need to set it manually
record.SetRaw("created", now.UTC().Add(-2*time.Hour).Format(types.DefaultDateLayout))
err = hub.SaveNoValidate(record)
require.NoError(t, err)
require.NotNil(t, record)
require.InDelta(t, record.GetDateTime("created").Time().UTC().Unix(), now.UTC().Add(-2*time.Hour).Unix(), 1)
require.Equal(t, record.Get("system"), system.Id)
require.Equal(t, record.Get("type"), "1m")
// Create recent system_stats record that should be kept
_, err = tests.CreateRecord(hub, "system_stats", map[string]any{
"system": system.Id,
"type": "1m",
"stats": `{"cpu": 30.0, "mem": 512}`,
"created": now.Add(-30 * time.Minute), // 30 minutes old, should be kept
})
require.NoError(t, err)
// Create many alerts history records to trigger deletion
for i := range 260 { // More than countBeforeDeletion (250)
_, err = tests.CreateRecord(hub, "alerts_history", map[string]any{
"user": user.Id,
"name": "CPU",
"value": i + 1,
"system": system.Id,
"created": now.Add(-time.Duration(i) * time.Minute),
})
require.NoError(t, err)
}
// Count records before deletion
systemStatsCountBefore, err := hub.CountRecords("system_stats")
require.NoError(t, err)
alertsCountBefore, err := hub.CountRecords("alerts_history")
require.NoError(t, err)
// Run deletion
rm.DeleteOldRecords()
// Count records after deletion
systemStatsCountAfter, err := hub.CountRecords("system_stats")
require.NoError(t, err)
alertsCountAfter, err := hub.CountRecords("alerts_history")
require.NoError(t, err)
// Verify old system stats were deleted
assert.Less(t, systemStatsCountAfter, systemStatsCountBefore, "Old system stats should be deleted")
// Verify alerts history was trimmed
assert.Less(t, alertsCountAfter, alertsCountBefore, "Excessive alerts history should be deleted")
assert.Equal(t, alertsCountAfter, int64(200), "Alerts count should be equal to countToKeep (200)")
}
// TestDeleteOldSystemStats tests the deleteOldSystemStats function
func TestDeleteOldSystemStats(t *testing.T) {
hub, err := tests.NewTestHub(t.TempDir())
require.NoError(t, err)
defer hub.Cleanup()
// Create test system
user, err := tests.CreateUser(hub, "test@example.com", "testtesttest")
require.NoError(t, err)
system, err := tests.CreateRecord(hub, "systems", map[string]any{
"name": "test-system",
"host": "localhost",
"port": "45876",
"status": "up",
"users": []string{user.Id},
})
require.NoError(t, err)
now := time.Now().UTC()
// Test data for different record types and their retention periods
testCases := []struct {
recordType string
retention time.Duration
shouldBeKept bool
ageFromNow time.Duration
description string
}{
{"1m", time.Hour, true, 30 * time.Minute, "1m record within 1 hour should be kept"},
{"1m", time.Hour, false, 2 * time.Hour, "1m record older than 1 hour should be deleted"},
{"10m", 12 * time.Hour, true, 6 * time.Hour, "10m record within 12 hours should be kept"},
{"10m", 12 * time.Hour, false, 24 * time.Hour, "10m record older than 12 hours should be deleted"},
{"20m", 24 * time.Hour, true, 12 * time.Hour, "20m record within 24 hours should be kept"},
{"20m", 24 * time.Hour, false, 48 * time.Hour, "20m record older than 24 hours should be deleted"},
{"120m", 7 * 24 * time.Hour, true, 3 * 24 * time.Hour, "120m record within 7 days should be kept"},
{"120m", 7 * 24 * time.Hour, false, 10 * 24 * time.Hour, "120m record older than 7 days should be deleted"},
{"480m", 30 * 24 * time.Hour, true, 15 * 24 * time.Hour, "480m record within 30 days should be kept"},
{"480m", 30 * 24 * time.Hour, false, 45 * 24 * time.Hour, "480m record older than 30 days should be deleted"},
}
// Create test records for both system_stats and container_stats
collections := []string{"system_stats", "container_stats"}
recordIds := make(map[string][]string)
for _, collection := range collections {
recordIds[collection] = make([]string, 0)
for i, tc := range testCases {
recordTime := now.Add(-tc.ageFromNow)
var stats string
if collection == "system_stats" {
stats = fmt.Sprintf(`{"cpu": %d.0, "mem": %d}`, i*10, i*100)
} else {
stats = fmt.Sprintf(`[{"name": "container%d", "cpu": %d.0, "mem": %d}]`, i, i*5, i*50)
}
record, err := tests.CreateRecord(hub, collection, map[string]any{
"system": system.Id,
"type": tc.recordType,
"stats": stats,
})
require.NoError(t, err)
record.SetRaw("created", recordTime.Format(types.DefaultDateLayout))
err = hub.SaveNoValidate(record)
require.NoError(t, err)
recordIds[collection] = append(recordIds[collection], record.Id)
}
}
// Run deletion
err = records.TestDeleteOldSystemStats(hub)
require.NoError(t, err)
// Verify results
for _, collection := range collections {
for i, tc := range testCases {
recordId := recordIds[collection][i]
// Try to find the record
_, err := hub.FindRecordById(collection, recordId)
if tc.shouldBeKept {
assert.NoError(t, err, "Record should exist: %s", tc.description)
} else {
assert.Error(t, err, "Record should be deleted: %s", tc.description)
}
}
}
}
// TestDeleteOldAlertsHistory tests the deleteOldAlertsHistory function
func TestDeleteOldAlertsHistory(t *testing.T) {
hub, err := tests.NewTestHub(t.TempDir())
require.NoError(t, err)
defer hub.Cleanup()
// Create test users
user1, err := tests.CreateUser(hub, "user1@example.com", "testtesttest")
require.NoError(t, err)
user2, err := tests.CreateUser(hub, "user2@example.com", "testtesttest")
require.NoError(t, err)
system, err := tests.CreateRecord(hub, "systems", map[string]any{
"name": "test-system",
"host": "localhost",
"port": "45876",
"status": "up",
"users": []string{user1.Id, user2.Id},
})
require.NoError(t, err)
now := time.Now().UTC()
testCases := []struct {
name string
user *core.Record
alertCount int
countToKeep int
countBeforeDeletion int
expectedAfterDeletion int
description string
}{
{
name: "User with few alerts (below threshold)",
user: user1,
alertCount: 100,
countToKeep: 50,
countBeforeDeletion: 150,
expectedAfterDeletion: 100, // No deletion because below threshold
description: "User with alerts below countBeforeDeletion should not have any deleted",
},
{
name: "User with many alerts (above threshold)",
user: user2,
alertCount: 300,
countToKeep: 100,
countBeforeDeletion: 200,
expectedAfterDeletion: 100, // Should be trimmed to countToKeep
description: "User with alerts above countBeforeDeletion should be trimmed to countToKeep",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Create alerts for this user
for i := 0; i < tc.alertCount; i++ {
_, err := tests.CreateRecord(hub, "alerts_history", map[string]any{
"user": tc.user.Id,
"name": "CPU",
"value": i + 1,
"system": system.Id,
"created": now.Add(-time.Duration(i) * time.Minute),
})
require.NoError(t, err)
}
// Count before deletion
countBefore, err := hub.CountRecords("alerts_history",
dbx.NewExp("user = {:user}", dbx.Params{"user": tc.user.Id}))
require.NoError(t, err)
assert.Equal(t, int64(tc.alertCount), countBefore, "Initial count should match")
// Run deletion
err = records.TestDeleteOldAlertsHistory(hub, tc.countToKeep, tc.countBeforeDeletion)
require.NoError(t, err)
// Count after deletion
countAfter, err := hub.CountRecords("alerts_history",
dbx.NewExp("user = {:user}", dbx.Params{"user": tc.user.Id}))
require.NoError(t, err)
assert.Equal(t, int64(tc.expectedAfterDeletion), countAfter, tc.description)
// If deletion occurred, verify the most recent records were kept
if tc.expectedAfterDeletion < tc.alertCount {
records, err := hub.FindRecordsByFilter("alerts_history",
"user = {:user}",
"-created", // Order by created DESC
tc.countToKeep,
0,
map[string]any{"user": tc.user.Id})
require.NoError(t, err)
assert.Len(t, records, tc.expectedAfterDeletion, "Should have exactly countToKeep records")
// Verify records are in descending order by created time
for i := 1; i < len(records); i++ {
prev := records[i-1].GetDateTime("created").Time()
curr := records[i].GetDateTime("created").Time()
assert.True(t, prev.After(curr) || prev.Equal(curr),
"Records should be ordered by created time (newest first)")
}
}
})
}
}
// TestDeleteOldAlertsHistoryEdgeCases tests edge cases for alerts history deletion
func TestDeleteOldAlertsHistoryEdgeCases(t *testing.T) {
hub, err := tests.NewTestHub(t.TempDir())
require.NoError(t, err)
defer hub.Cleanup()
t.Run("No users with excessive alerts", func(t *testing.T) {
// Create user with few alerts
user, err := tests.CreateUser(hub, "few@example.com", "testtesttest")
require.NoError(t, err)
system, err := tests.CreateRecord(hub, "systems", map[string]any{
"name": "test-system",
"host": "localhost",
"port": "45876",
"status": "up",
"users": []string{user.Id},
})
// Create only 5 alerts (well below threshold)
for i := range 5 {
_, err := tests.CreateRecord(hub, "alerts_history", map[string]any{
"user": user.Id,
"name": "CPU",
"value": i + 1,
"system": system.Id,
})
require.NoError(t, err)
}
// Should not error and should not delete anything
err = records.TestDeleteOldAlertsHistory(hub, 10, 20)
require.NoError(t, err)
count, err := hub.CountRecords("alerts_history")
require.NoError(t, err)
assert.Equal(t, int64(5), count, "All alerts should remain")
})
t.Run("Empty alerts_history table", func(t *testing.T) {
// Clear any existing alerts
_, err := hub.DB().NewQuery("DELETE FROM alerts_history").Execute()
require.NoError(t, err)
// Should not error with empty table
err = records.TestDeleteOldAlertsHistory(hub, 10, 20)
require.NoError(t, err)
})
}
// TestRecordManagerCreation tests RecordManager creation
func TestRecordManagerCreation(t *testing.T) {
hub, err := tests.NewTestHub(t.TempDir())
require.NoError(t, err)
defer hub.Cleanup()
rm := records.NewRecordManager(hub)
assert.NotNil(t, rm, "RecordManager should not be nil")
}
// TestTwoDecimals tests the twoDecimals helper function
func TestTwoDecimals(t *testing.T) {
testCases := []struct {
input float64
expected float64
}{
{1.234567, 1.23},
{1.235, 1.24}, // Should round up
{1.0, 1.0},
{0.0, 0.0},
{-1.234567, -1.23},
{-1.235, -1.23}, // Negative rounding
}
for _, tc := range testCases {
result := records.TestTwoDecimals(tc.input)
assert.InDelta(t, tc.expected, result, 0.02, "twoDecimals(%f) should equal %f", tc.input, tc.expected)
}
}

View File

@@ -0,0 +1,23 @@
//go:build testing
// +build testing
package records
import (
"github.com/pocketbase/pocketbase/core"
)
// TestDeleteOldSystemStats exposes deleteOldSystemStats for testing
func TestDeleteOldSystemStats(app core.App) error {
return deleteOldSystemStats(app)
}
// TestDeleteOldAlertsHistory exposes deleteOldAlertsHistory for testing
func TestDeleteOldAlertsHistory(app core.App, countToKeep, countBeforeDeletion int) error {
return deleteOldAlertsHistory(app, countToKeep, countBeforeDeletion)
}
// TestTwoDecimals exposes twoDecimals for testing
func TestTwoDecimals(value float64) float64 {
return twoDecimals(value)
}

View File

@@ -0,0 +1,309 @@
package tests
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"maps"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
pbtests "github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/hook"
)
// NOTE: This is a copy of https://github.com/pocketbase/pocketbase/blob/master/tests/api.go
// with the following changes:
// - Removed automatic cleanup of the test app in ApiScenario.Test (Aug 17 2025)
// ApiScenario defines a single api request test case/scenario.
type ApiScenario struct {
// Name is the test name.
Name string
// Method is the HTTP method of the test request to use.
Method string
// URL is the url/path of the endpoint you want to test.
URL string
// Body specifies the body to send with the request.
//
// For example:
//
// strings.NewReader(`{"title":"abc"}`)
Body io.Reader
// Headers specifies the headers to send with the request (e.g. "Authorization": "abc")
Headers map[string]string
// Delay adds a delay before checking the expectations usually
// to ensure that all fired non-awaited go routines have finished
Delay time.Duration
// Timeout specifies how long to wait before cancelling the request context.
//
// A zero or negative value means that there will be no timeout.
Timeout time.Duration
// expectations
// ---------------------------------------------------------------
// ExpectedStatus specifies the expected response HTTP status code.
ExpectedStatus int
// List of keywords that MUST exist in the response body.
//
// Either ExpectedContent or NotExpectedContent must be set if the response body is non-empty.
// Leave both fields empty if you want to ensure that the response didn't have any body (e.g. 204).
ExpectedContent []string
// List of keywords that MUST NOT exist in the response body.
//
// Either ExpectedContent or NotExpectedContent must be set if the response body is non-empty.
// Leave both fields empty if you want to ensure that the response didn't have any body (e.g. 204).
NotExpectedContent []string
// List of hook events to check whether they were fired or not.
//
// You can use the wildcard "*" event key if you want to ensure
// that no other hook events except those listed have been fired.
//
// For example:
//
// map[string]int{ "*": 0 } // no hook events were fired
// map[string]int{ "*": 0, "EventA": 2 } // no hook events, except EventA were fired
// map[string]int{ "EventA": 2, "EventB": 0 } // ensures that EventA was fired exactly 2 times and EventB exactly 0 times.
ExpectedEvents map[string]int
// test hooks
// ---------------------------------------------------------------
TestAppFactory func(t testing.TB) *pbtests.TestApp
BeforeTestFunc func(t testing.TB, app *pbtests.TestApp, e *core.ServeEvent)
AfterTestFunc func(t testing.TB, app *pbtests.TestApp, res *http.Response)
}
// Test executes the test scenario.
//
// Example:
//
// func TestListExample(t *testing.T) {
// scenario := tests.ApiScenario{
// Name: "list example collection",
// Method: http.MethodGet,
// URL: "/api/collections/example/records",
// ExpectedStatus: 200,
// ExpectedContent: []string{
// `"totalItems":3`,
// `"id":"0yxhwia2amd8gec"`,
// `"id":"achvryl401bhse3"`,
// `"id":"llvuca81nly1qls"`,
// },
// ExpectedEvents: map[string]int{
// "OnRecordsListRequest": 1,
// "OnRecordEnrich": 3,
// },
// }
//
// scenario.Test(t)
// }
func (scenario *ApiScenario) Test(t *testing.T) {
t.Run(scenario.normalizedName(), func(t *testing.T) {
scenario.test(t)
})
}
// Benchmark benchmarks the test scenario.
//
// Example:
//
// func BenchmarkListExample(b *testing.B) {
// scenario := tests.ApiScenario{
// Name: "list example collection",
// Method: http.MethodGet,
// URL: "/api/collections/example/records",
// ExpectedStatus: 200,
// ExpectedContent: []string{
// `"totalItems":3`,
// `"id":"0yxhwia2amd8gec"`,
// `"id":"achvryl401bhse3"`,
// `"id":"llvuca81nly1qls"`,
// },
// ExpectedEvents: map[string]int{
// "OnRecordsListRequest": 1,
// "OnRecordEnrich": 3,
// },
// }
//
// scenario.Benchmark(b)
// }
func (scenario *ApiScenario) Benchmark(b *testing.B) {
b.Run(scenario.normalizedName(), func(b *testing.B) {
for i := 0; i < b.N; i++ {
scenario.test(b)
}
})
}
func (scenario *ApiScenario) normalizedName() string {
var name = scenario.Name
if name == "" {
name = fmt.Sprintf("%s:%s", scenario.Method, scenario.URL)
}
return name
}
func (scenario *ApiScenario) test(t testing.TB) {
var testApp *pbtests.TestApp
if scenario.TestAppFactory != nil {
testApp = scenario.TestAppFactory(t)
if testApp == nil {
t.Fatal("TestAppFactory must return a non-nill app instance")
}
} else {
var testAppErr error
testApp, testAppErr = pbtests.NewTestApp()
if testAppErr != nil {
t.Fatalf("Failed to initialize the test app instance: %v", testAppErr)
}
}
// defer testApp.Cleanup()
baseRouter, err := apis.NewRouter(testApp)
if err != nil {
t.Fatal(err)
}
// manually trigger the serve event to ensure that custom app routes and middlewares are registered
serveEvent := new(core.ServeEvent)
serveEvent.App = testApp
serveEvent.Router = baseRouter
serveErr := testApp.OnServe().Trigger(serveEvent, func(e *core.ServeEvent) error {
if scenario.BeforeTestFunc != nil {
scenario.BeforeTestFunc(t, testApp, e)
}
// reset the event counters in case a hook was triggered from a before func (eg. db save)
testApp.ResetEventCalls()
// add middleware to timeout long-running requests (eg. keep-alive routes)
e.Router.Bind(&hook.Handler[*core.RequestEvent]{
Func: func(re *core.RequestEvent) error {
slowTimer := time.AfterFunc(3*time.Second, func() {
t.Logf("[WARN] Long running test %q", scenario.Name)
})
defer slowTimer.Stop()
if scenario.Timeout > 0 {
ctx, cancelFunc := context.WithTimeout(re.Request.Context(), scenario.Timeout)
defer cancelFunc()
re.Request = re.Request.Clone(ctx)
}
return re.Next()
},
Priority: -9999,
})
recorder := httptest.NewRecorder()
req := httptest.NewRequest(scenario.Method, scenario.URL, scenario.Body)
// set default header
req.Header.Set("content-type", "application/json")
// set scenario headers
for k, v := range scenario.Headers {
req.Header.Set(k, v)
}
// execute request
mux, err := e.Router.BuildMux()
if err != nil {
t.Fatalf("Failed to build router mux: %v", err)
}
mux.ServeHTTP(recorder, req)
res := recorder.Result()
if res.StatusCode != scenario.ExpectedStatus {
t.Errorf("Expected status code %d, got %d", scenario.ExpectedStatus, res.StatusCode)
}
if scenario.Delay > 0 {
time.Sleep(scenario.Delay)
}
if len(scenario.ExpectedContent) == 0 && len(scenario.NotExpectedContent) == 0 {
if len(recorder.Body.Bytes()) != 0 {
t.Errorf("Expected empty body, got \n%v", recorder.Body.String())
}
} else {
// normalize json response format
buffer := new(bytes.Buffer)
err := json.Compact(buffer, recorder.Body.Bytes())
var normalizedBody string
if err != nil {
// not a json...
normalizedBody = recorder.Body.String()
} else {
normalizedBody = buffer.String()
}
for _, item := range scenario.ExpectedContent {
if !strings.Contains(normalizedBody, item) {
t.Errorf("Cannot find %v in response body \n%v", item, normalizedBody)
break
}
}
for _, item := range scenario.NotExpectedContent {
if strings.Contains(normalizedBody, item) {
t.Errorf("Didn't expect %v in response body \n%v", item, normalizedBody)
break
}
}
}
remainingEvents := maps.Clone(testApp.EventCalls)
var noOtherEventsShouldRemain bool
for event, expectedNum := range scenario.ExpectedEvents {
if event == "*" && expectedNum <= 0 {
noOtherEventsShouldRemain = true
continue
}
actualNum := remainingEvents[event]
if actualNum != expectedNum {
t.Errorf("Expected event %s to be called %d, got %d", event, expectedNum, actualNum)
}
delete(remainingEvents, event)
}
if noOtherEventsShouldRemain && len(remainingEvents) > 0 {
t.Errorf("Missing expected remaining events:\n%#v\nAll triggered app events are:\n%#v", remainingEvents, testApp.EventCalls)
}
if scenario.AfterTestFunc != nil {
scenario.AfterTestFunc(t, testApp, res)
}
return nil
})
if serveErr != nil {
t.Fatalf("Failed to trigger app serve hook: %v", serveErr)
}
}

View File

@@ -1,11 +1,17 @@
//go:build testing
// +build testing
// Package tests provides helpers for testing the application.
package tests
import (
"beszel/internal/hub"
"fmt"
"testing"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tests"
"github.com/stretchr/testify/assert"
_ "github.com/pocketbase/pocketbase/migrations"
)
@@ -56,3 +62,37 @@ func NewTestHubWithConfig(config core.BaseAppConfig) (*TestHub, error) {
return t, nil
}
// Helper function to create a test user for config tests
func CreateUser(app core.App, email string, password string) (*core.Record, error) {
userCollection, err := app.FindCachedCollectionByNameOrId("users")
if err != nil {
return nil, err
}
user := core.NewRecord(userCollection)
user.Set("email", email)
user.Set("password", password)
return user, app.Save(user)
}
// Helper function to create a test record
func CreateRecord(app core.App, collectionName string, fields map[string]any) (*core.Record, error) {
collection, err := app.FindCachedCollectionByNameOrId(collectionName)
if err != nil {
return nil, err
}
record := core.NewRecord(collection)
record.Load(fields)
return record, app.Save(record)
}
func ClearCollection(t testing.TB, app core.App, collectionName string) error {
_, err := app.DB().NewQuery(fmt.Sprintf("DELETE from %s", collectionName)).Execute()
recordCount, err := app.CountRecords(collectionName)
assert.EqualValues(t, recordCount, 0, "should have 0 records after clearing")
return err
}

View File

@@ -6,6 +6,7 @@ import (
"log"
"net/http"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
)
@@ -13,13 +14,6 @@ type UserManager struct {
app core.App
}
type UserSettings struct {
ChartTime string `json:"chartTime"`
NotificationEmails []string `json:"emails"`
NotificationWebhooks []string `json:"webhooks"`
// Language string `json:"lang"`
}
func NewUserManager(app core.App) *UserManager {
return &UserManager{
app: app,
@@ -37,30 +31,26 @@ func (um *UserManager) InitializeUserRole(e *core.RecordEvent) error {
// Initialize user settings with defaults if not set
func (um *UserManager) InitializeUserSettings(e *core.RecordEvent) error {
record := e.Record
// intialize settings with defaults
settings := UserSettings{
// Language: "en",
ChartTime: "1h",
NotificationEmails: []string{},
NotificationWebhooks: []string{},
// intialize settings with defaults (zero values can be ignored)
settings := struct {
ChartTime string `json:"chartTime"`
Emails []string `json:"emails"`
}{
ChartTime: "1h",
}
record.UnmarshalJSONField("settings", &settings)
if len(settings.NotificationEmails) == 0 {
// get user email from auth record
if errs := um.app.ExpandRecord(record, []string{"user"}, nil); len(errs) == 0 {
// app.Logger().Error("failed to expand user relation", "errs", errs)
if user := record.ExpandedOne("user"); user != nil {
settings.NotificationEmails = []string{user.GetString("email")}
} else {
log.Println("Failed to get user email from auth record")
}
} else {
log.Println("failed to expand user relation", "errs", errs)
}
// get user email from auth record
var user struct {
Email string `db:"email"`
}
// if len(settings.NotificationWebhooks) == 0 {
// settings.NotificationWebhooks = []string{""}
// }
err := e.App.DB().NewQuery("SELECT email FROM users WHERE id = {:id}").Bind(dbx.Params{
"id": record.GetString("user"),
}).One(&user)
if err != nil {
log.Println("failed to get user email", "err", err)
return err
}
settings.Emails = []string{user.Email}
record.Set("settings", settings)
return e.Next()
}

View File

@@ -1,12 +1,14 @@
package migrations
import (
"github.com/google/uuid"
"github.com/pocketbase/pocketbase/core"
m "github.com/pocketbase/pocketbase/migrations"
)
func init() {
m.Register(func(app core.App) error {
// update collections
jsonData := `[
{
"id": "elngm8x1l60zi2v",
@@ -73,7 +75,10 @@ func init() {
"Memory",
"Disk",
"Temperature",
"Bandwidth"
"Bandwidth",
"LoadAvg1",
"LoadAvg5",
"LoadAvg15"
]
},
{
@@ -131,7 +136,125 @@ func init() {
}
],
"indexes": [
"CREATE INDEX ` + "`" + `idx_MnhEt21L5r` + "`" + ` ON ` + "`" + `alerts` + "`" + ` (` + "`" + `system` + "`" + `)"
"CREATE UNIQUE INDEX ` + "`" + `idx_MnhEt21L5r` + "`" + ` ON ` + "`" + `alerts` + "`" + ` (\n ` + "`" + `user` + "`" + `,\n ` + "`" + `system` + "`" + `,\n ` + "`" + `name` + "`" + `\n)"
],
"system": false
},
{
"id": "pbc_1697146157",
"listRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
"viewRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
"createRule": null,
"updateRule": null,
"deleteRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
"name": "alerts_history",
"type": "base",
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"hidden": false,
"id": "text3208210256",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"cascadeDelete": true,
"collectionId": "_pb_users_auth_",
"hidden": false,
"id": "relation2375276105",
"maxSelect": 1,
"minSelect": 0,
"name": "user",
"presentable": false,
"required": true,
"system": false,
"type": "relation"
},
{
"cascadeDelete": true,
"collectionId": "2hz5ncl8tizk5nx",
"hidden": false,
"id": "relation3377271179",
"maxSelect": 1,
"minSelect": 0,
"name": "system",
"presentable": false,
"required": true,
"system": false,
"type": "relation"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text2466471794",
"max": 0,
"min": 0,
"name": "alert_id",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text1579384326",
"max": 0,
"min": 0,
"name": "name",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": true,
"system": false,
"type": "text"
},
{
"hidden": false,
"id": "number494360628",
"max": null,
"min": null,
"name": "value",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "autodate2990389176",
"name": "created",
"onCreate": true,
"onUpdate": false,
"presentable": false,
"system": false,
"type": "autodate"
},
{
"hidden": false,
"id": "date2276568630",
"max": "",
"min": "",
"name": "resolved",
"presentable": false,
"required": false,
"system": false,
"type": "date"
}
],
"indexes": [
"CREATE INDEX ` + "`" + `idx_YdGnup5aqB` + "`" + ` ON ` + "`" + `alerts_history` + "`" + ` (` + "`" + `user` + "`" + `)",
"CREATE INDEX ` + "`" + `idx_taLet9VdME` + "`" + ` ON ` + "`" + `alerts_history` + "`" + ` (` + "`" + `created` + "`" + `)"
],
"system": false
},
@@ -225,6 +348,88 @@ func init() {
],
"system": false
},
{
"id": "pbc_3663931638",
"listRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id",
"viewRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id",
"createRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id && @request.auth.role != \"readonly\"",
"updateRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id && @request.auth.role != \"readonly\"",
"deleteRule": null,
"name": "fingerprints",
"type": "base",
"fields": [
{
"autogeneratePattern": "[a-z0-9]{9}",
"hidden": false,
"id": "text3208210256",
"max": 15,
"min": 9,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"cascadeDelete": true,
"collectionId": "2hz5ncl8tizk5nx",
"hidden": false,
"id": "relation3377271179",
"maxSelect": 1,
"minSelect": 0,
"name": "system",
"presentable": false,
"required": true,
"system": false,
"type": "relation"
},
{
"autogeneratePattern": "[a-zA-Z9-9]{20}",
"hidden": false,
"id": "text1597481275",
"max": 255,
"min": 9,
"name": "token",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": true,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text4228609354",
"max": 255,
"min": 9,
"name": "fingerprint",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"hidden": false,
"id": "autodate3332085495",
"name": "updated",
"onCreate": true,
"onUpdate": true,
"presentable": false,
"system": false,
"type": "autodate"
}
],
"indexes": [
"CREATE INDEX ` + "`" + `idx_p9qZlu26po` + "`" + ` ON ` + "`" + `fingerprints` + "`" + ` (` + "`" + `token` + "`" + `)",
"CREATE UNIQUE INDEX ` + "`" + `idx_ngboulGMYw` + "`" + ` ON ` + "`" + `fingerprints` + "`" + ` (` + "`" + `system` + "`" + `)"
],
"system": false
},
{
"id": "ej9oowivz8b2mht",
"listRule": "@request.auth.id != \"\"",
@@ -658,7 +863,37 @@ func init() {
}
]`
return app.ImportCollectionsByMarshaledJSON([]byte(jsonData), false)
err := app.ImportCollectionsByMarshaledJSON([]byte(jsonData), false)
if err != nil {
return err
}
// Get all systems that don't have fingerprint records
var systemIds []string
err = app.DB().NewQuery(`
SELECT s.id FROM systems s
LEFT JOIN fingerprints f ON s.id = f.system
WHERE f.system IS NULL
`).Column(&systemIds)
if err != nil {
return err
}
// Create fingerprint records with unique UUID tokens for each system
for _, systemId := range systemIds {
token := uuid.New().String()
_, err = app.DB().NewQuery(`
INSERT INTO fingerprints (system, token)
VALUES ({:system}, {:token})
`).Bind(map[string]any{
"system": systemId,
"token": token,
}).Execute()
if err != nil {
return err
}
}
return nil
}, func(app core.App) error {
return nil
})

View File

@@ -5,7 +5,7 @@ import (
m "github.com/pocketbase/pocketbase/migrations"
)
var (
const (
TempAdminEmail = "_@b.b"
)

Binary file not shown.

View File

@@ -4,9 +4,15 @@
<meta charset="UTF-8" />
<link rel="manifest" href="./static/manifest.json" />
<link rel="icon" type="image/svg+xml" href="./static/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0,maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<title>Beszel</title>
<script>window.BASE_PATH = "%BASE_URL%"</script>
<script>
globalThis.BESZEL = {
BASE_PATH: "%BASE_URL%",
HUB_VERSION: "{{V}}",
HUB_URL: "{{HUB_URL}}"
}
</script>
</head>
<body>
<div id="app"></div>

View File

@@ -1,6 +1,6 @@
import type { LinguiConfig } from "@lingui/conf"
import { defineConfig } from "@lingui/cli"
const config: LinguiConfig = {
export default defineConfig({
locales: [
"en",
"ar",
@@ -33,12 +33,13 @@ const config: LinguiConfig = {
],
sourceLocale: "en",
compileNamespace: "ts",
formatOptions: {
lineNumbers: false,
},
catalogs: [
{
path: "<rootDir>/src/locales/{locale}/{locale}",
include: ["src"],
},
],
}
export default config
})

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{
"name": "beszel",
"private": true,
"version": "0.0.0",
"version": "0.12.3",
"type": "module",
"scripts": {
"dev": "vite",
@@ -12,54 +12,54 @@
},
"dependencies": {
"@henrygd/queue": "^1.0.7",
"@lingui/detect-locale": "^4.14.1",
"@lingui/macro": "^4.14.1",
"@lingui/react": "^4.14.1",
"@henrygd/semaphore": "^0.0.2",
"@lingui/detect-locale": "^5.4.1",
"@lingui/macro": "^5.4.1",
"@lingui/react": "^5.4.1",
"@nanostores/react": "^0.7.3",
"@nanostores/router": "^0.11.0",
"@radix-ui/react-alert-dialog": "^1.1.6",
"@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-direction": "^1.1.0",
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-slider": "^1.2.3",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-switch": "^1.1.3",
"@radix-ui/react-tabs": "^1.1.3",
"@radix-ui/react-toast": "^1.2.6",
"@radix-ui/react-tooltip": "^1.1.8",
"@tanstack/react-table": "^8.21.2",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-direction": "^1.1.1",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-table": "^8.21.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.0.4",
"cmdk": "^1.1.1",
"d3-time": "^3.1.0",
"lucide-react": "^0.452.0",
"nanostores": "^0.11.4",
"pocketbase": "^0.25.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"recharts": "^2.15.1",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"valibot": "^0.42.0"
"pocketbase": "^0.26.2",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"recharts": "^2.15.4",
"tailwind-merge": "^3.3.1",
"valibot": "^0.42.1"
},
"devDependencies": {
"@lingui/cli": "^5.2.0",
"@lingui/swc-plugin": "^5.4.0",
"@lingui/vite-plugin": "^5.2.0",
"@types/bun": "^1.2.4",
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react-swc": "^3.8.0",
"autoprefixer": "^10.4.20",
"postcss": "^8.5.3",
"tailwindcss": "^3.4.17",
"tailwindcss-rtl": "^0.9.0",
"typescript": "^5.8.2",
"vite": "^6.2.0"
"@lingui/cli": "^5.4.1",
"@lingui/swc-plugin": "^5.6.1",
"@lingui/vite-plugin": "^5.4.1",
"@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/vite": "^4.1.12",
"@types/bun": "^1.2.20",
"@types/react": "^19.1.11",
"@types/react-dom": "^19.1.7",
"@vitejs/plugin-react-swc": "^4.0.1",
"tailwindcss": "^4.1.12",
"tw-animate-css": "^1.3.7",
"typescript": "^5.9.2",
"vite": "^7.1.3"
},
"overrides": {
"@nanostores/router": {
@@ -69,4 +69,4 @@
"optionalDependencies": {
"@esbuild/linux-arm64": "^0.21.5"
}
}
}

View File

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

View File

@@ -1,3 +1,5 @@
import { Trans } from "@lingui/react/macro"
import { t } from "@lingui/core/macro"
import { Button } from "@/components/ui/button"
import {
Dialog,
@@ -9,20 +11,28 @@ import {
DialogTrigger,
} from "@/components/ui/dialog"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { $publicKey, pb } from "@/lib/stores"
import { cn, copyToClipboard, isReadOnlyUser, useLocalStorage } from "@/lib/utils"
import { i18n } from "@lingui/core"
import { t, Trans } from "@lingui/macro"
import { cn, generateToken, isReadOnlyUser, tokenMap, useLocalStorage } from "@/lib/utils"
import { useStore } from "@nanostores/react"
import { ChevronDownIcon, Copy, PlusIcon } from "lucide-react"
import { memo, useRef, useState } from "react"
import { basePath, navigate } from "./router"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "./ui/dropdown-menu"
import { ChevronDownIcon, ExternalLinkIcon, PlusIcon } from "lucide-react"
import { memo, useEffect, useRef, useState } from "react"
import { $router, basePath, Link, navigate } from "./router"
import { SystemRecord } from "@/types"
import { SystemStatus } from "@/lib/enums"
import { AppleIcon, DockerIcon, TuxIcon, WindowsIcon } from "./ui/icons"
import { InputCopy } from "./ui/input-copy"
import { getPagePath } from "@nanostores/router"
import {
copyDockerCompose,
copyDockerRun,
copyLinuxCommand,
copyWindowsCommand,
DropdownItem,
InstallDropdown,
} from "./install-dropdowns"
import { DropdownMenu, DropdownMenuTrigger } from "./ui/dropdown-menu"
export function AddSystemButton({ className }: { className?: string }) {
const [open, setOpen] = useState(false)
@@ -49,36 +59,11 @@ export function AddSystemButton({ className }: { className?: string }) {
)
}
function copyDockerCompose(port = "45876", publicKey: 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:
LISTEN: ${port}
KEY: "${publicKey}"`)
}
function copyDockerRun(port = "45876", publicKey: string) {
copyToClipboard(
`docker run -d --name beszel-agent --network host --restart unless-stopped -v /var/run/docker.sock:/var/run/docker.sock:ro -e KEY="${publicKey}" -e LISTEN=${port} henrygd/beszel-agent:latest`
)
}
function copyInstallCommand(port = "45876", publicKey: 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)
}
/**
* Token to be used for the next system.
* Prevents token changing if user copies config, then closes dialog and opens again.
*/
let nextSystemToken: string | null = null
/**
* SystemDialog component for adding or editing a system.
@@ -86,12 +71,32 @@ function copyInstallCommand(port = "45876", publicKey: string) {
* @param {function} props.setOpen - Function to set the open state of the dialog.
* @param {SystemRecord} [props.system] - Optional system record for editing an existing system.
*/
export const SystemDialog = memo(({ setOpen, system }: { setOpen: (open: boolean) => void; system?: SystemRecord }) => {
export const SystemDialog = ({ setOpen, system }: { setOpen: (open: boolean) => void; system?: SystemRecord }) => {
const publicKey = useStore($publicKey)
const port = useRef<HTMLInputElement>(null)
const [hostValue, setHostValue] = useState(system?.host ?? "")
const isUnixSocket = hostValue.startsWith("/")
const [tab, setTab] = useLocalStorage("as-tab", "docker")
const [token, setToken] = useState(system?.token ?? "")
useEffect(() => {
;(async () => {
// if no system, generate a new token
if (!system) {
nextSystemToken ||= generateToken()
return setToken(nextSystemToken)
}
// if system exists,get the token from the fingerprint record
if (tokenMap.has(system.id)) {
return setToken(tokenMap.get(system.id)!)
}
const { token } = await pb.collection("fingerprints").getFirstListItem(`system = "${system.id}"`, {
fields: "token",
})
tokenMap.set(system.id, token)
setToken(token)
})()
}, [system?.id])
async function handleSubmit(e: SubmitEvent) {
e.preventDefault()
@@ -101,14 +106,20 @@ export const SystemDialog = memo(({ setOpen, system }: { setOpen: (open: boolean
try {
setOpen(false)
if (system) {
await pb.collection("systems").update(system.id, { ...data, status: "pending" })
await pb.collection("systems").update(system.id, { ...data, status: SystemStatus.Pending })
} else {
await pb.collection("systems").create(data)
const createdSystem = await pb.collection("systems").create(data)
await pb.collection("fingerprints").create({
system: createdSystem.id,
token,
})
// Reset the current token after successful system
// creation so next system gets a new token
nextSystemToken = null
}
navigate(basePath)
// console.log(record)
} catch (e) {
console.log(e)
console.error(e)
}
}
@@ -133,18 +144,35 @@ export const SystemDialog = memo(({ setOpen, system }: { setOpen: (open: boolean
</DialogHeader>
{/* Docker (set tab index to prevent auto focusing content in edit system dialog) */}
<TabsContent value="docker" tabIndex={-1}>
<DialogDescription className="mb-4 leading-normal w-0 min-w-full">
<DialogDescription className="mb-3 leading-relaxed w-0 min-w-full">
<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.
Copy the
<code className="bg-muted px-1 rounded-sm leading-3">docker-compose.yml</code> content for the agent
below, or register agents automatically with a{" "}
<Link
onClick={() => setOpen(false)}
href={getPagePath($router, "settings", { name: "tokens" })}
className="link"
>
universal token
</Link>
.
</Trans>
</DialogDescription>
</TabsContent>
{/* Binary */}
<TabsContent value="binary" tabIndex={-1}>
<DialogDescription className="mb-4 leading-normal w-0 min-w-full">
<DialogDescription className="mb-3 leading-relaxed w-0 min-w-full">
<Trans>
The agent must be running on the system to connect. Copy the installation command for the agent below.
Copy the installation command for the agent below, or register agents automatically with a{" "}
<Link
onClick={() => setOpen(false)}
href={getPagePath($router, "settings", { name: "tokens" })}
className="link"
>
universal token
</Link>
.
</Trans>
</DialogDescription>
</TabsContent>
@@ -180,51 +208,56 @@ export const SystemDialog = memo(({ setOpen, system }: { setOpen: (open: boolean
<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} 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>
<InputCopy value={publicKey} id="pkey" name="pkey" />
<Label htmlFor="tkn" className="xs:text-end whitespace-pre">
<Trans>Token</Trans>
</Label>
<InputCopy value={token} id="tkn" name="tkn" />
</div>
<DialogFooter className="flex justify-end gap-x-2 gap-y-3 flex-col mt-5">
{/* Docker */}
<TabsContent value="docker" className="contents">
<CopyButton
text={t`Copy` + " docker compose"}
onClick={() => copyDockerCompose(isUnixSocket ? hostValue : port.current?.value, publicKey)}
dropdownText={t`Copy` + " docker run"}
dropdownOnClick={() => copyDockerRun(isUnixSocket ? hostValue : port.current?.value, publicKey)}
text={t({ message: "Copy docker compose", context: "Button to copy docker compose file content" })}
onClick={async () =>
copyDockerCompose(isUnixSocket ? hostValue : port.current?.value, publicKey, token)
}
icon={<DockerIcon className="size-4 -me-0.5" />}
dropdownItems={[
{
text: t({ message: "Copy docker run", context: "Button to copy docker run command" }),
onClick: async () =>
copyDockerRun(isUnixSocket ? hostValue : port.current?.value, publicKey, token),
icons: [DockerIcon],
},
]}
/>
</TabsContent>
{/* Binary */}
<TabsContent value="binary" className="contents">
<CopyButton
text={t`Copy Linux command`}
onClick={() => copyInstallCommand(isUnixSocket ? hostValue : port.current?.value, publicKey)}
dropdownText={t`Manual setup instructions`}
dropdownUrl="https://beszel.dev/guide/agent-installation#binary"
icon={<TuxIcon className="size-4" />}
onClick={async () => copyLinuxCommand(isUnixSocket ? hostValue : port.current?.value, publicKey, token)}
dropdownItems={[
{
text: t({ message: "Homebrew command", context: "Button to copy install command" }),
onClick: async () =>
copyLinuxCommand(isUnixSocket ? hostValue : port.current?.value, publicKey, token, true),
icons: [AppleIcon, TuxIcon],
},
{
text: t({ message: "Windows command", context: "Button to copy install command" }),
onClick: async () =>
copyWindowsCommand(isUnixSocket ? hostValue : port.current?.value, publicKey, token),
icons: [WindowsIcon],
},
{
text: t`Manual setup instructions`,
url: "https://beszel.dev/guide/agent-installation#binary",
icons: [ExternalLinkIcon],
},
]}
/>
</TabsContent>
{/* Save */}
@@ -234,21 +267,25 @@ export const SystemDialog = memo(({ setOpen, system }: { setOpen: (open: boolean
</Tabs>
</DialogContent>
)
})
}
interface CopyButtonProps {
text: string
onClick: () => void
dropdownText: string
dropdownOnClick?: () => void
dropdownUrl?: string
dropdownItems: DropdownItem[]
icon?: React.ReactElement<any>
}
const CopyButton = memo((props: CopyButtonProps) => {
return (
<div className="flex gap-0 rounded-lg">
<Button type="button" variant="outline" onClick={props.onClick} className="rounded-e-none dark:border-e-0 grow">
{props.text}
<Button
type="button"
variant="outline"
onClick={props.onClick}
className="rounded-e-none dark:border-e-0 grow flex items-center gap-2"
>
{props.text} {props.icon}
</Button>
<div className="w-px h-full bg-muted"></div>
<DropdownMenu>
@@ -257,17 +294,7 @@ const CopyButton = memo((props: CopyButtonProps) => {
<ChevronDownIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{props.dropdownUrl ? (
<DropdownMenuItem asChild>
<a href={props.dropdownUrl} className="cursor-pointer" target="_blank" rel="noopener noreferrer">
{props.dropdownText}
</a>
</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={props.dropdownOnClick} className="cursor-pointer">{props.dropdownText}</DropdownMenuItem>
)}
</DropdownMenuContent>
<InstallDropdown items={props.dropdownItems} />
</DropdownMenu>
</div>
)

View File

@@ -0,0 +1,165 @@
import { ColumnDef } from "@tanstack/react-table"
import { AlertsHistoryRecord } from "@/types"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { formatShortDate, toFixedFloat, formatDuration, cn } from "@/lib/utils"
import { alertInfo } from "@/lib/alerts"
import { Trans } from "@lingui/react/macro"
import { t } from "@lingui/core/macro"
export const alertsHistoryColumns: ColumnDef<AlertsHistoryRecord>[] = [
{
accessorKey: "system",
enableSorting: true,
header: ({ column }) => (
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}>
<Trans>System</Trans>
</Button>
),
cell: ({ row }) => <span className="ps-2">{row.original.expand?.system?.name || row.original.system}</span>,
filterFn: (row, _, filterValue) => {
const display = row.original.expand?.system?.name || row.original.system || ""
return display.toLowerCase().includes(filterValue.toLowerCase())
},
},
{
// accessorKey: "name",
id: "name",
accessorFn: (record) => {
const name = record.name
const info = alertInfo[name]
return info?.name().replace("cpu", "CPU") || name
},
header: ({ column }) => (
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}>
<Trans>Name</Trans>
</Button>
),
cell: ({ getValue, row }) => {
let name = getValue() as string
const info = alertInfo[row.original.name]
const Icon = info?.icon
return (
<span className="flex items-center gap-2 ps-1 min-w-40">
{Icon && <Icon className="size-3.5" />}
{name}
</span>
)
},
},
{
accessorKey: "value",
enableSorting: false,
header: () => (
<Button variant="ghost">
<Trans>Value</Trans>
</Button>
),
cell({ row, getValue }) {
const name = row.original.name
if (name === "Status") {
return <span className="ps-2">{t`Down`}</span>
}
const value = getValue() as number
const unit = alertInfo[name]?.unit
return (
<span className="tabular-nums ps-2.5">
{toFixedFloat(value, value < 10 ? 2 : 1)}
{unit}
</span>
)
},
},
{
accessorKey: "state",
enableSorting: true,
sortingFn: (rowA, rowB) => (rowA.original.resolved ? 1 : 0) - (rowB.original.resolved ? 1 : 0),
header: ({ column }) => (
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}>
<Trans comment="Context: alert state (active or resolved)">State</Trans>
</Button>
),
cell: ({ row }) => {
const resolved = row.original.resolved
return (
<Badge
className={cn(
"capitalize pointer-events-none",
resolved
? "bg-green-100 text-green-800 border-green-200 dark:opacity-80"
: "bg-yellow-100 text-yellow-800 border-yellow-200"
)}
>
{/* {resolved ? <CircleCheckIcon className="size-3 me-0.5" /> : <CircleAlertIcon className="size-3 me-0.5" />} */}
{resolved ? <Trans>Resolved</Trans> : <Trans>Active</Trans>}
</Badge>
)
},
},
{
accessorKey: "created",
accessorFn: (record) => formatShortDate(record.created),
enableSorting: true,
invertSorting: true,
header: ({ column }) => (
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}>
<Trans comment="Context: date created">Created</Trans>
</Button>
),
cell: ({ getValue, row }) => (
<span className="ps-1 tabular-nums tracking-tight" title={`${row.original.created} UTC`}>
{getValue() as string}
</span>
),
},
{
accessorKey: "resolved",
enableSorting: true,
invertSorting: true,
header: ({ column }) => (
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}>
<Trans>Resolved</Trans>
</Button>
),
cell: ({ row, getValue }) => {
const resolved = getValue() as string | null
if (!resolved) {
return null
}
return (
<span className="ps-1 tabular-nums tracking-tight" title={`${row.original.resolved} UTC`}>
{formatShortDate(resolved)}
</span>
)
},
},
{
accessorKey: "duration",
invertSorting: true,
enableSorting: true,
sortingFn: (rowA, rowB) => {
const aCreated = new Date(rowA.original.created)
const bCreated = new Date(rowB.original.created)
const aResolved = rowA.original.resolved ? new Date(rowA.original.resolved) : null
const bResolved = rowB.original.resolved ? new Date(rowB.original.resolved) : null
const aDuration = aResolved ? aResolved.getTime() - aCreated.getTime() : null
const bDuration = bResolved ? bResolved.getTime() - bCreated.getTime() : null
if (!aDuration && bDuration) return -1
if (aDuration && !bDuration) return 1
return (aDuration || 0) - (bDuration || 0)
},
header: ({ column }) => (
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}>
<Trans>Duration</Trans>
</Button>
),
cell: ({ row }) => {
const duration = formatDuration(row.original.created, row.original.resolved)
if (!duration) {
return null
}
return <span className="ps-2">{duration}</span>
},
},
]

View File

@@ -1,120 +1,36 @@
import { memo, useState } from "react"
import { t } from "@lingui/core/macro"
import { memo, useMemo, 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 { $alerts } from "@/lib/stores"
import { BellIcon } from "lucide-react"
import { 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"
import { SystemRecord } from "@/types"
import { AlertDialogContent } from "./alerts-sheet"
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"
export default memo(function AlertsButton({ system }: { system: SystemRecord }) {
const alerts = useStore($alerts)
const [opened, setOpened] = useState(false)
const alerts = useStore($alerts)
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>
const hasSystemAlert = alerts[system.id]?.size > 0
return useMemo(
() => (
<Sheet>
<SheetTrigger 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": hasSystemAlert,
})}
/>
</Button>
</SheetTrigger>
<SheetContent className="max-h-full overflow-auto w-145 !max-w-full p-4 sm:p-6">
{opened && <AlertDialogContent system={system} />}
</SheetContent>
</Sheet>
),
[opened, hasSystemAlert]
)
})
function TheContent({
data: { system, alerts, systemAlerts },
}: {
data: { system: SystemRecord; alerts: AlertRecord[]; systemAlerts: AlertRecord[] }
}) {
const [overwriteExisting, setOverwriteExisting] = useState<boolean | "indeterminate">(false)
const systems = $systems.get()
const data = Object.keys(alertInfo).map((key) => {
const alert = alertInfo[key as keyof typeof alertInfo]
return {
key: key as keyof typeof alertInfo,
alert,
system,
}
})
return (
<>
<DialogHeader>
<DialogTitle className="text-xl">
<Trans>Alerts</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
See{" "}
<Link href="/settings/notifications" className="link">
notification settings
</Link>{" "}
to configure how you receive alerts.
</Trans>
</DialogDescription>
</DialogHeader>
<Tabs defaultValue="system">
<TabsList className="mb-1 -mt-0.5">
<TabsTrigger value="system">
<ServerIcon className="me-2 h-3.5 w-3.5" />
{system.name}
</TabsTrigger>
<TabsTrigger value="global">
<GlobeIcon className="me-1.5 h-3.5 w-3.5" />
<Trans>All Systems</Trans>
</TabsTrigger>
</TabsList>
<TabsContent value="system">
<div className="grid gap-3">
{data.map((d) => (
<SystemAlert key={d.key} system={system} data={d} systemAlerts={systemAlerts} />
))}
</div>
</TabsContent>
<TabsContent value="global">
<label
htmlFor="ovw"
className="mb-3 flex gap-2 items-center justify-center cursor-pointer border rounded-sm py-3 px-4 border-destructive text-destructive font-semibold text-sm"
>
<Checkbox
id="ovw"
className="text-destructive border-destructive data-[state=checked]:bg-destructive"
checked={overwriteExisting}
onCheckedChange={setOverwriteExisting}
/>
<Trans>Overwrite existing alerts</Trans>
</label>
<div className="grid gap-3">
{data.map((d) => (
<SystemAlertGlobal key={d.key} data={d} overwrite={overwriteExisting} alerts={alerts} systems={systems} />
))}
</div>
</TabsContent>
</Tabs>
</>
)
}

View File

@@ -0,0 +1,298 @@
import { t } from "@lingui/core/macro"
import { Trans, Plural } from "@lingui/react/macro"
import { $alerts, $systems, pb } from "@/lib/stores"
import { cn, debounce } from "@/lib/utils"
import { alertInfo } from "@/lib/alerts"
import { Switch } from "@/components/ui/switch"
import { AlertInfo, AlertRecord, SystemRecord } from "@/types"
import { lazy, memo, Suspense, useMemo, useState } from "react"
import { toast } from "@/components/ui/use-toast"
import { useStore } from "@nanostores/react"
import { getPagePath } from "@nanostores/router"
import { Checkbox } from "@/components/ui/checkbox"
import { DialogTitle, DialogDescription } from "@/components/ui/dialog"
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
import { ServerIcon, GlobeIcon } from "lucide-react"
import { $router, Link } from "@/components/router"
import { DialogHeader } from "@/components/ui/dialog"
const Slider = lazy(() => import("@/components/ui/slider"))
const endpoint = "/api/beszel/user-alerts"
const alertDebounce = 100
const alertKeys = Object.keys(alertInfo) as (keyof typeof alertInfo)[]
const failedUpdateToast = (error: unknown) => {
console.error(error)
toast({
title: t`Failed to update alert`,
description: t`Please check logs for more details.`,
variant: "destructive",
})
}
/** Create or update alerts for a given name and systems */
const upsertAlerts = debounce(
async ({ name, value, min, systems }: { name: string; value: number; min: number; systems: string[] }) => {
try {
await pb.send<{ success: boolean }>(endpoint, {
method: "POST",
// overwrite is always true because we've done filtering client side
body: { name, value, min, systems, overwrite: true },
})
} catch (error) {
failedUpdateToast(error)
}
},
alertDebounce
)
/** Delete alerts for a given name and systems */
const deleteAlerts = debounce(async ({ name, systems }: { name: string; systems: string[] }) => {
try {
await pb.send<{ success: boolean }>(endpoint, {
method: "DELETE",
body: { name, systems },
})
} catch (error) {
failedUpdateToast(error)
}
}, alertDebounce)
export const AlertDialogContent = memo(function AlertDialogContent({ system }: { system: SystemRecord }) {
const alerts = useStore($alerts)
const [overwriteExisting, setOverwriteExisting] = useState<boolean | "indeterminate">(false)
const [currentTab, setCurrentTab] = useState("system")
const systemAlerts = alerts[system.id] ?? new Map()
// We need to keep a copy of alerts when we switch to global tab. If we always compare to
// current alerts, it will only be updated when first checked, then won't be updated because
// after that it exists.
const alertsWhenGlobalSelected = useMemo(() => {
return currentTab === "global" ? structuredClone(alerts) : alerts
}, [currentTab])
return (
<>
<DialogHeader>
<DialogTitle className="text-xl">
<Trans>Alerts</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
See{" "}
<Link href={getPagePath($router, "settings", { name: "notifications" })} className="link">
notification settings
</Link>{" "}
to configure how you receive alerts.
</Trans>
</DialogDescription>
</DialogHeader>
<Tabs defaultValue="system" onValueChange={setCurrentTab}>
<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">
{alertKeys.map((name) => (
<AlertContent
key={name}
alertKey={name}
data={alertInfo[name as keyof typeof alertInfo]}
alert={systemAlerts.get(name)}
system={system}
/>
))}
</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">
{alertKeys.map((name) => (
<AlertContent
key={name}
alertKey={name}
system={system}
alert={systemAlerts.get(name)}
data={alertInfo[name as keyof typeof alertInfo]}
global={true}
overwriteExisting={!!overwriteExisting}
initialAlertsState={alertsWhenGlobalSelected}
/>
))}
</div>
</TabsContent>
</Tabs>
</>
)
})
export function AlertContent({
alertKey,
data: alertData,
system,
alert,
global = false,
overwriteExisting = false,
initialAlertsState = {},
}: {
alertKey: string
data: AlertInfo
system: SystemRecord
alert?: AlertRecord
global?: boolean
overwriteExisting?: boolean
initialAlertsState?: Record<string, Map<string, AlertRecord>>
}) {
const { name } = alertData
const singleDescription = alertData.singleDesc?.()
const [checked, setChecked] = useState(global ? false : !!alert)
const [min, setMin] = useState(alert?.min || 10)
const [value, setValue] = useState(alert?.value || (singleDescription ? 0 : alertData.start ?? 80))
const Icon = alertData.icon
/** Get system ids to update */
function getSystemIds(): string[] {
// if not global, update only the current system
if (!global) {
return [system.id]
}
// if global, update all systems when overwriteExisting is true
// update only systems without an existing alert when overwriteExisting is false
const allSystems = $systems.get()
const systemIds: string[] = []
for (const system of allSystems) {
if (overwriteExisting || !initialAlertsState[system.id]?.has(alertKey)) {
systemIds.push(system.id)
}
}
return systemIds
}
function sendUpsert(min: number, value: number) {
const systems = getSystemIds()
systems.length &&
upsertAlerts({
name: alertKey,
value,
min,
systems,
})
}
return (
<div className="rounded-lg border border-muted-foreground/15 hover:border-muted-foreground/20 transition-colors duration-100 group">
<label
htmlFor={`s${name}`}
className={cn("flex flex-row items-center justify-between gap-4 cursor-pointer p-4", {
"pb-0": checked,
})}
>
<div className="grid gap-1 select-none">
<p className="font-semibold flex gap-3 items-center">
<Icon className="h-4 w-4 opacity-85" /> {alertData.name()}
</p>
{!checked && <span className="block text-sm text-muted-foreground">{alertData.desc()}</span>}
</div>
<Switch
id={`s${name}`}
checked={checked}
onCheckedChange={(newChecked) => {
setChecked(newChecked)
if (newChecked) {
// if alert checked, create or update alert
sendUpsert(min, value)
} else {
// if unchecked, delete alert (unless global and overwriteExisting is false)
deleteAlerts({ name: alertKey, systems: getSystemIds() })
// when force deleting all alerts of a type, also remove them from initialAlertsState
if (overwriteExisting) {
for (const curAlerts of Object.values(initialAlertsState)) {
curAlerts.delete(alertKey)
}
}
}
}}
/>
</label>
{checked && (
<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" />}>
{!singleDescription && (
<div>
<p id={`v${name}`} className="text-sm block h-8">
<Trans>
Average exceeds{" "}
<strong className="text-foreground">
{value}
{alertData.unit}
</strong>
</Trans>
</p>
<div className="flex gap-3">
<Slider
aria-labelledby={`v${name}`}
defaultValue={[value]}
onValueCommit={(val) => sendUpsert(min, val[0])}
onValueChange={(val) => setValue(val[0])}
step={alertData.step ?? 1}
min={alertData.min ?? 1}
max={alertData.max ?? 99}
/>
</div>
</div>
)}
<div className={cn(singleDescription && "col-span-full lowercase")}>
<p id={`t${name}`} className="text-sm block h-8 first-letter:uppercase">
{singleDescription && (
<>
{singleDescription}
{` `}
</>
)}
<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${name}`}
defaultValue={[min]}
onValueCommit={(minVal) => sendUpsert(minVal[0], value)}
onValueChange={(val) => setMin(val[0])}
min={1}
max={60}
/>
</div>
</div>
</Suspense>
</div>
)}
</div>
)
}

View File

@@ -1,251 +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 singleDescription = data.alert.singleDesc?.()
const [checked, setChecked] = useState(data.checked || false)
const [min, setMin] = useState(data.min || 10)
const [value, setValue] = useState(data.val || (singleDescription ? 0 : 80))
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": checked,
})}
>
<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>
{!checked && <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>
{checked && (
<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" />}>
{!singleDescription && (
<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 className={cn(singleDescription && "col-span-full lowercase")}>
<p id={`t${key}`} className="text-sm block h-8 first-letter:uppercase">
{singleDescription && (
<>{singleDescription}{` `}</>
)}
<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 +1,93 @@
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"
import { useYAxisWidth, cn, formatShortDate, chartMargin } from "@/lib/utils"
import { ChartData, SystemStatsRecord } from "@/types"
import { useMemo } from "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 type DataPoint = {
label: string
dataKey: (data: SystemStatsRecord) => number | undefined
color: string
opacity: number
}
export default memo(function AreaChartDefault({
maxToggled = false,
unit = " MB/s",
chartName,
export default function AreaChartDefault({
chartData,
max,
maxToggled,
tickFormatter,
}: {
maxToggled?: boolean
unit?: string
chartName: string
contentFormatter,
dataPoints,
domain,
}: // logRender = false,
{
chartData: ChartData
max?: number
tickFormatter?: (value: number) => string
maxToggled?: boolean
tickFormatter: (value: number, index: number) => string
contentFormatter: ({ value, payload }: { value: number; payload: SystemStatsRecord }) => string
dataPoints?: DataPoint[]
domain?: [number, number]
// logRender?: boolean
}) {
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 useMemo(() => {
if (chartData.systemStats.length === 0) {
return null
}
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}
/>
)
// if (logRender) {
// console.log("Rendered at", new Date())
// }
return (
<div>
<ChartContainer
className={cn("h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity", {
"opacity-100": yAxisWidth,
})}
{/* <ChartLegend content={<ChartLegendContent />} /> */}
</AreaChart>
</ChartContainer>
</div>
)
})
>
<AreaChart accessibilityLayer data={chartData.systemStats} margin={chartMargin}>
<CartesianGrid vertical={false} />
<YAxis
direction="ltr"
orientation={chartData.orientation}
className="tracking-tighter"
width={yAxisWidth}
domain={domain ?? [0, max ?? "auto"]}
tickFormatter={(value, index) => updateYAxisWidth(tickFormatter(value, index))}
tickLine={false}
axisLine={false}
/>
{xAxis(chartData)}
<ChartTooltip
animationEasing="ease-out"
animationDuration={150}
content={
<ChartTooltipContent
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={contentFormatter}
/>
}
/>
{dataPoints?.map((dataPoint, i) => {
const color = `var(--chart-${dataPoint.color})`
return (
<Area
key={i}
dataKey={dataPoint.dataKey}
name={dataPoint.label}
type="monotoneX"
fill={color}
fillOpacity={dataPoint.opacity}
stroke={color}
isAnimationActive={false}
/>
)
})}
{/* <ChartLegend content={<ChartLegendContent />} /> */}
</AreaChart>
</ChartContainer>
</div>
)
}, [chartData.systemStats.at(-1), yAxisWidth, maxToggled])
}

View File

@@ -1,76 +1,64 @@
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
import { memo, useMemo } from "react"
import {
useYAxisWidth,
cn,
formatShortDate,
decimalString,
chartMargin,
toFixedFloat,
getSizeAndUnit,
toFixedWithoutTrailingZeros,
} from "@/lib/utils"
import { useYAxisWidth, cn, formatShortDate, chartMargin, toFixedFloat, formatBytes, decimalString } from "@/lib/utils"
// import Spinner from '../spinner'
import { useStore } from "@nanostores/react"
import { $containerFilter } from "@/lib/stores"
import { $containerFilter, $userSettings } from "@/lib/stores"
import { ChartData } from "@/types"
import { Separator } from "../ui/separator"
import { ChartType, Unit } from "@/lib/enums"
export default memo(function ContainerChart({
dataKey,
chartData,
chartName,
chartType,
unit = "%",
}: {
dataKey: string
chartData: ChartData
chartName: string
chartType: ChartType
unit?: string
}) {
const filter = useStore($containerFilter)
const userSettings = useStore($userSettings)
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
const { containerData } = chartData
const isNetChart = chartName === "net"
const isNetChart = chartType === ChartType.Network
const chartConfig = useMemo(() => {
let config = {} as Record<
string,
{
label: string
color: string
}
>
const totalUsage = {} as Record<string, number>
for (let stats of containerData) {
for (let key in stats) {
if (!key || key === "created") {
continue
}
if (!(key in totalUsage)) {
totalUsage[key] = 0
}
if (isNetChart) {
totalUsage[key] += (stats[key]?.nr ?? 0) + (stats[key]?.ns ?? 0)
} else {
// @ts-ignore
totalUsage[key] += stats[key]?.[dataKey] ?? 0
}
const config = {} as Record<string, { label: string; color: string }>
const totalUsage = new Map<string, number>()
// calculate total usage of each container
for (const stats of containerData) {
for (const key in stats) {
if (!key || key === "created") continue
const currentTotal = totalUsage.get(key) ?? 0
const increment = isNetChart
? (stats[key]?.nr ?? 0) + (stats[key]?.ns ?? 0)
: // @ts-ignore
stats[key]?.[dataKey] ?? 0
totalUsage.set(key, currentTotal + increment)
}
}
let keys = Object.keys(totalUsage)
keys.sort((a, b) => (totalUsage[a] > totalUsage[b] ? -1 : 1))
const length = keys.length
for (let i = 0; i < length; i++) {
const key = keys[i]
// Sort keys and generate colors based on usage
const sortedEntries = Array.from(totalUsage.entries()).sort(([, a], [, b]) => b - a)
const length = sortedEntries.length
sortedEntries.forEach(([key], i) => {
const hue = ((i * 360) / length) % 360
config[key] = {
label: key,
color: `hsl(${hue}, 60%, 55%)`,
}
}
})
return config satisfies ChartConfig
}, [chartData])
@@ -81,15 +69,16 @@ export default memo(function ContainerChart({
tickFormatter: (value: any) => string
}
// tick formatter
if (chartName === "cpu") {
if (chartType === ChartType.CPU) {
obj.tickFormatter = (value) => {
const val = toFixedWithoutTrailingZeros(value, 2) + unit
const val = toFixedFloat(value, 2) + unit
return updateYAxisWidth(val)
}
} else {
obj.tickFormatter = (value) => {
const { v, u } = getSizeAndUnit(value, false)
return updateYAxisWidth(`${toFixedFloat(v, 2)}${u}${isNetChart ? "/s" : ""}`)
const chartUnit = isNetChart ? userSettings.unitNet : Unit.Bytes
obj.tickFormatter = (val) => {
const { value, unit } = formatBytes(val, isNetChart, chartUnit, true)
return updateYAxisWidth(toFixedFloat(value, value >= 10 ? 0 : 1) + " " + unit)
}
}
// tooltip formatter
@@ -98,12 +87,14 @@ export default memo(function ContainerChart({
try {
const sent = item?.payload?.[key]?.ns ?? 0
const received = item?.payload?.[key]?.nr ?? 0
const { value: receivedValue, unit: receivedUnit } = formatBytes(received, true, userSettings.unitNet, true)
const { value: sentValue, unit: sentUnit } = formatBytes(sent, true, userSettings.unitNet, true)
return (
<span className="flex">
{decimalString(received)} MB/s
{decimalString(receivedValue)} {receivedUnit}
<span className="opacity-70 ms-0.5"> rx </span>
<Separator orientation="vertical" className="h-3 mx-1.5 bg-primary/40" />
{decimalString(sent)} MB/s
{decimalString(sentValue)} {sentUnit}
<span className="opacity-70 ms-0.5"> tx</span>
</span>
)
@@ -111,6 +102,11 @@ export default memo(function ContainerChart({
return null
}
}
} else if (chartType === ChartType.Memory) {
obj.toolTipFormatter = (item: any) => {
const { value, unit } = formatBytes(item.value, false, Unit.Bytes, true)
return decimalString(value) + " " + unit
}
} else {
obj.toolTipFormatter = (item: any) => decimalString(item.value) + unit
}
@@ -123,6 +119,8 @@ export default memo(function ContainerChart({
return obj
}, [])
const filterLower = filter?.toLowerCase()
// console.log('rendered at', new Date())
if (containerData.length === 0) {
@@ -157,13 +155,14 @@ export default memo(function ContainerChart({
<ChartTooltip
animationEasing="ease-out"
animationDuration={150}
truncate={true}
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
// @ts-ignore
itemSorter={(a, b) => b.value - a.value}
content={<ChartTooltipContent filter={filter} contentFormatter={toolTipFormatter} />}
/>
{Object.keys(chartConfig).map((key) => {
const filtered = filter && !key.includes(filter)
const filtered = filterLower && !key.toLowerCase().includes(filterLower)
let fillOpacity = filtered ? 0.05 : 0.4
let strokeOpacity = filtered ? 0.1 : 1
return (

View File

@@ -1,19 +1,10 @@
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
import {
useYAxisWidth,
cn,
formatShortDate,
decimalString,
toFixedFloat,
chartMargin,
getSizeAndUnit,
} from "@/lib/utils"
import { useYAxisWidth, cn, formatShortDate, decimalString, chartMargin, formatBytes, toFixedFloat } from "@/lib/utils"
import { ChartData } from "@/types"
import { memo } from "react"
import { t } from "@lingui/macro"
import { useLingui } from "@lingui/react"
import { useLingui } from "@lingui/react/macro"
import { Unit } from "@/lib/enums"
export default memo(function DiskChart({
dataKey,
@@ -25,7 +16,7 @@ export default memo(function DiskChart({
chartData: ChartData
}) {
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
const { _ } = useLingui()
const { t } = useLingui()
// round to nearest GB
if (diskSize >= 100) {
@@ -55,9 +46,9 @@ export default memo(function DiskChart({
minTickGap={6}
tickLine={false}
axisLine={false}
tickFormatter={(value) => {
const { v, u } = getSizeAndUnit(value)
return updateYAxisWidth(toFixedFloat(v, 2) + u)
tickFormatter={(val) => {
const { value, unit } = formatBytes(val * 1024, false, Unit.Bytes, true)
return updateYAxisWidth(toFixedFloat(value, value >= 10 ? 0 : 1) + " " + unit)
}}
/>
{xAxis(chartData)}
@@ -68,19 +59,19 @@ export default memo(function DiskChart({
<ChartTooltipContent
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={({ value }) => {
const { v, u } = getSizeAndUnit(value)
return decimalString(v) + u
const { value: convertedValue, unit } = formatBytes(value * 1024, false, Unit.Bytes, true)
return decimalString(convertedValue) + " " + unit
}}
/>
}
/>
<Area
dataKey={dataKey}
name={_(t`Disk Usage`)}
name={t`Disk Usage`}
type="monotoneX"
fill="hsl(var(--chart-4))"
fill="var(--chart-4)"
fillOpacity={0.4}
stroke="hsl(var(--chart-4))"
stroke="var(--chart-4)"
// animationDuration={1200}
isAnimationActive={false}
/>

View File

@@ -8,14 +8,7 @@ import {
ChartTooltipContent,
xAxis,
} from "@/components/ui/chart"
import {
useYAxisWidth,
cn,
formatShortDate,
toFixedWithoutTrailingZeros,
decimalString,
chartMargin,
} from "@/lib/utils"
import { useYAxisWidth, cn, formatShortDate, toFixedFloat, decimalString, chartMargin } from "@/lib/utils"
import { ChartData } from "@/types"
import { memo, useMemo } from "react"
@@ -72,7 +65,7 @@ export default memo(function GpuPowerChart({ chartData }: { chartData: ChartData
domain={[0, "auto"]}
width={yAxisWidth}
tickFormatter={(value) => {
const val = toFixedWithoutTrailingZeros(value, 2)
const val = toFixedFloat(value, 2)
return updateYAxisWidth(val + "W")
}}
tickLine={false}

View File

@@ -0,0 +1,96 @@
import { CartesianGrid, Line, LineChart, YAxis } from "recharts"
import {
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
xAxis,
} from "@/components/ui/chart"
import { useYAxisWidth, cn, formatShortDate, toFixedFloat, decimalString, chartMargin } from "@/lib/utils"
import { ChartData, SystemStats } from "@/types"
import { memo } from "react"
import { t } from "@lingui/core/macro"
export default memo(function LoadAverageChart({ chartData }: { chartData: ChartData }) {
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
const keys: { legacy: keyof SystemStats; color: string; label: string }[] = [
{
legacy: "l1",
color: "hsl(271, 81%, 60%)", // Purple
label: t({ message: `1 min`, comment: "Load average" }),
},
{
legacy: "l5",
color: "hsl(217, 91%, 60%)", // Blue
label: t({ message: `5 min`, comment: "Load average" }),
},
{
legacy: "l15",
color: "hsl(25, 95%, 53%)", // Orange
label: t({ message: `15 min`, comment: "Load average" }),
},
]
return (
<div>
<ChartContainer
className={cn("h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity", {
"opacity-100": yAxisWidth,
})}
>
<LineChart accessibilityLayer data={chartData.systemStats} margin={chartMargin}>
<CartesianGrid vertical={false} />
<YAxis
direction="ltr"
orientation={chartData.orientation}
className="tracking-tighter"
domain={[0, "auto"]}
width={yAxisWidth}
tickFormatter={(value) => {
return updateYAxisWidth(String(toFixedFloat(value, 2)))
}}
tickLine={false}
axisLine={false}
/>
{xAxis(chartData)}
<ChartTooltip
animationEasing="ease-out"
animationDuration={150}
// @ts-ignore
// itemSorter={(a, b) => b.value - a.value}
content={
<ChartTooltipContent
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={(item) => decimalString(item.value)}
/>
}
/>
{keys.map(({ legacy, color, label }, i) => {
const dataKey = (value: { stats: SystemStats }) => {
if (chartData.agentVersion.patch < 1) {
return value.stats?.[legacy]
}
return value.stats?.la?.[i] ?? value.stats?.[legacy]
}
return (
<Line
key={i}
dataKey={dataKey}
name={label}
type="monotoneX"
dot={false}
strokeWidth={1.5}
stroke={color}
isAnimationActive={false}
/>
)
})}
<ChartLegend content={<ChartLegendContent />} />
</LineChart>
</ChartContainer>
</div>
)
})

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