Compare commits

...

322 Commits

Author SHA1 Message Date
hank
a3198d05a5 New translations en.po (Dutch) 2026-02-06 08:42:46 -05:00
hank
ddaa2e6ea0 New translations en.po (Serbian (Cyrillic)) 2026-02-03 10:27:07 -05:00
hank
be7d4b129e New translations en.po (French) 2026-02-02 10:14:15 -05:00
hank
a2bcbc112f New translations en.po (Korean) 2026-02-02 07:39:57 -05:00
hank
cdb7738164 New translations en.po (Korean) 2026-02-02 05:04:51 -05:00
hank
3af3049816 New translations en.po (Indonesian) 2026-01-31 17:39:24 -05:00
hank
bd51580d74 New translations en.po (Chinese Traditional, Hong Kong) 2026-01-31 16:21:32 -05:00
hank
76a1145611 New translations en.po (Croatian) 2026-01-31 16:21:31 -05:00
hank
916503579d New translations en.po (Thai) 2026-01-31 16:21:30 -05:00
hank
412f8c6bf5 New translations en.po (Persian) 2026-01-31 16:21:30 -05:00
hank
444d080d1d New translations en.po (Indonesian) 2026-01-31 16:21:28 -05:00
hank
54e2e8cfd9 New translations en.po (Vietnamese) 2026-01-31 16:21:27 -05:00
hank
902657815c New translations en.po (Chinese Simplified) 2026-01-31 16:21:27 -05:00
hank
94c89a8fdf New translations en.po (Ukrainian) 2026-01-31 16:21:26 -05:00
hank
4c92c89fd0 New translations en.po (Turkish) 2026-01-31 16:21:25 -05:00
hank
9b42dc301a New translations en.po (Swedish) 2026-01-31 16:21:24 -05:00
hank
f05cce662b New translations en.po (Serbian (Cyrillic)) 2026-01-31 16:21:23 -05:00
hank
a3b2530fb4 New translations en.po (Slovenian) 2026-01-31 16:21:23 -05:00
hank
69d9b6c696 New translations en.po (Portuguese) 2026-01-31 16:21:22 -05:00
hank
5da4afb657 New translations en.po (Polish) 2026-01-31 16:21:21 -05:00
hank
eafb67ea04 New translations en.po (Korean) 2026-01-31 16:21:20 -05:00
hank
ce249b620c New translations en.po (Japanese) 2026-01-31 16:21:19 -05:00
hank
ce7b56bdd2 New translations en.po (Italian) 2026-01-31 16:21:18 -05:00
hank
9cad5a33e5 New translations en.po (Hebrew) 2026-01-31 16:21:17 -05:00
hank
b7b95b9bb0 New translations en.po (German) 2026-01-31 16:21:16 -05:00
hank
8f5632808a New translations en.po (Danish) 2026-01-31 16:21:15 -05:00
hank
002badbe4f New translations en.po (Czech) 2026-01-31 16:21:15 -05:00
hank
3b385aff85 New translations en.po (Bulgarian) 2026-01-31 16:21:14 -05:00
hank
49a2241033 New translations en.po (Arabic) 2026-01-31 16:21:13 -05:00
hank
8b1450fe32 New translations en.po (French) 2026-01-31 16:21:12 -05:00
hank
4964a3c55f New translations en.po (Romanian) 2026-01-31 16:21:11 -05:00
hank
af57c76fd0 New translations en.po (Spanish) 2026-01-31 16:21:10 -05:00
hank
e6fc6906d5 New translations en.po (Russian) 2026-01-31 16:21:09 -05:00
hank
1aef193b36 New translations en.po (Norwegian) 2026-01-31 16:21:08 -05:00
hank
7522d31ae1 New translations en.po (Hungarian) 2026-01-31 16:21:07 -05:00
hank
761da743b6 New translations en.po (Chinese Traditional) 2026-01-31 16:21:06 -05:00
hank
c779008340 New translations en.po (Dutch) 2026-01-31 16:21:06 -05:00
henrygd
3d8db53e52 fix container uptime sorting edge case (#1696) 2026-01-31 15:03:59 -05:00
Sven van Ginkel
5797f8a6ad Ignore alt key combinations when navigating systems with arrow keys (#1698) 2026-01-31 14:44:43 -05:00
henrygd
79ca31d770 improve container network stats granularity by using bytes instead of MB
Changes container network statistics to use raw byte values instead of converting to megabytes agent-side, providing more accurate measurements for low-bandwidth containers. Maintains backward compatibility with older agents/hubs through fallback logic.

- Agent now sends Bandwidth field as [sent_bytes, recv_bytes] array
- Deprecated NetworkSent/NetworkRecv fields still populated for compatibility
- Hub and frontend fall back to deprecated fields when Bandwidth is zero
- Record averaging correctly handles both old and new formats
- TODO markers added for cleanup in version 0.19+
2026-01-31 14:05:55 -05:00
Bart van der Braak
41f3705b6b update LibreHardwareMonitorLib to 0.9.5 (#1697)
fixes #1130

* add RuntimeIdentifier and AppendRuntimeIdentifierToOutputPath to beszel_lhm.csproj

* add more default sensor filters for LHM

---------

Co-authored-by: henrygd <hank@henrygd.me>
2026-01-30 19:23:56 -05:00
henrygd
20324763d2 remove stale systemd services from tracking after deletion (#1594) 2026-01-29 19:34:44 -05:00
henrygd
70f85f9590 fix SHARE_ALL_SYSTEMS for system_details, smart_devices, and systemd_services (#1660) 2026-01-29 19:28:27 -05:00
henrygd
c7f7f51c99 add experimental sysfs amd gpu collector (#737, #1569) 2026-01-29 18:35:57 -05:00
henrygd
6723ec8ea4 update honeypot field name and autofill ignores (#1011) 2026-01-28 18:16:30 -05:00
henrygd
afc19ebd3b write health_file to /dev/shm instead of /tmp if available (#1455) 2026-01-28 15:21:45 -05:00
Sven van Ginkel
c83d00ccaa Don't force lowercase text for active alerts (#1682) 2026-01-28 13:50:16 -05:00
Fahleen Arif
425c8d2bdf feat: Added tooltips for navbar buttons to clear meaning of each one (#1636)
* feat: Added tooltips for navbar buttons to clear meaning of each one.

* update tooltips and fix linter errors

---------

Co-authored-by: henrygd <hank@henrygd.me>
2026-01-28 13:39:15 -05:00
Sven van Ginkel
42da1e5a52 Bug: Apply SELinux context after binary replacement (#1678)
- Move SELinux context handling to internal/ghupdate for reuse
- Make chcon a true fallback (only runs if semanage/restorecon unavailable)
- Handle existing semanage rules with -m (modify) after -a (add) fails
- Apply SELinux handling to both agent and hub updates
- Add tests with proper skip behavior for SELinux systems

---------

Co-authored-by: henrygd <hank@henrygd.me>
2026-01-27 17:39:17 -05:00
Sven van Ginkel
afcae025ae Add icon button for mobile use (#1687) 2026-01-26 20:18:17 -05:00
Matthew Stern
1de36625a4 [Agent] feat: parse ATA device statistics for temperature and future metrics (#1689)
* feat: add ATA Device Statistics parsing and fall back for SMART temp reading

* simplify ata device statistics structs and fix smartctl args tests

* simplify ata device statistics lookup to use page number only

---------

Co-authored-by: henrygd <hank@henrygd.me>
2026-01-26 19:05:55 -05:00
Sven van Ginkel
a2b6c7f5e6 update goreleaser (#1677) 2026-01-25 17:15:28 -05:00
henrygd
799c7b077a support upgrades in agent install script (#1670) 2026-01-23 11:50:40 -05:00
henrygd
cb5f944de6 battery: ensure current charge doesn't exceed full capacity (#1668) 2026-01-22 13:01:21 -05:00
henrygd
23c4958145 increase smartctl --scan timeout to 10 seconds (#1465) 2026-01-21 19:09:57 -05:00
henrygd
edb2edc12c use name-only matching for unique SMART devices (#1655)
Fall back to name-only matching (previous behavior) when a device name
appears only once, preserving RAID composite key support added in #1655.
2026-01-21 18:25:03 -05:00
Julian Nadeau
648a979a81 Add SMART_DEVICES_SEPARATOR + allow drives with the same name to be added with different types (e.g. raid controllers) (#1655)
* Add SMART_DEVICES_SEPARATOR to override ,

* Allow composite keys in smart devices for raid controller support
2026-01-21 17:58:20 -05:00
Sven van Ginkel
988de6de7b chore: update workflows and templates (#1661)
* Update templates

* Add CodeOwners

* Apply Hanks Feedback

* Add note to make one issue per request

* update workflow
2026-01-21 15:36:23 -05:00
henrygd
031abbfcb3 ui: conditional title attribute and better CJK truncation
- Adds CJK support for system name truncation
- Change tooltip to title attribute and show only if system name is truncated
2026-01-16 18:17:45 -05:00
Fahleen Arif
b59fcc26e5 feat: add tooltip to system name in systems table for better accessibility (#1640) 2026-01-16 17:43:29 -05:00
Tamás Vince
acaa9381fe fix: update smartctlArgs call to use hasExistingData flag (#1645) 2026-01-16 15:30:52 -05:00
Loïc Tosser
8d9e9260e6 Change usermod to addgroup for docker access (#1641)
On Alpine Linux, the correct command to add a user to an existing group is addgroup <username> <groupname> rather than usermod -aG. The usermod command is part of the shadow package which is not installed by default on Alpine.
2026-01-14 16:45:23 -05:00
henrygd
0fc4a6daed update install-agent.sh to prefer glibc binary on linux glibc systems 2026-01-12 19:13:14 -05:00
henrygd
af0c1d3af7 release 0.18.2 2026-01-12 18:26:30 -05:00
henrygd
9ad3cd0ab9 fix: GPU ID collision between Intel and NVIDIA collectors (#1522)
- Prefix Intel GPU ID as i0 to avoid NVML/NVIDIA index IDs like 0
- Update frontend GPU engines chart to select a GPU by id instead of
assuming g[0]
- Adjust tests to use the new Intel GPU id
2026-01-12 17:27:35 -05:00
crimist
00def272b0 site: only hide GPU engine graph if entire usage is 0% (#1624) 2026-01-12 17:16:05 -05:00
henrygd
383913505f agent: fix tegrastats VDD_SYS_GPU parsing
- Parse VDD_SYS_GPU <mW>/<mW> correctly

- Add regression test for GPU@ temp + VDD_SYS_GPU power
2026-01-12 16:12:36 -05:00
Vascolas007
ca8cb78c29 Jetson tegrastats regex pre jetpack5 (#1631)
* feat:Adding regex catching groups for GPU temperature and power in pre jetpack 5
2026-01-12 16:11:22 -05:00
marmar76
8821fb5dd0 fix: some of indonesia translate (#1625)
Co-authored-by: Iskandar, Andreas (contracted) <Andreas.Iskandar@contracted.sampoerna.com>
2026-01-12 15:56:45 -05:00
henrygd
3279a6ca53 agent: add separate glibc build with NVML support (#1618)
purego requires dynamic linking, so split the agent builds:
- Default: static binary without NVML (works on musl/alpine)
- Glibc: dynamic binary with NVML support via purego

Changes:
- Add glibc build tag to conditionally include NVML code
- Add beszel-agent-linux-amd64-glibc build/archive in goreleaser
- Update ghupdate to use glibc binary on glibc systems
- Switch nvidia dockerfile to golang:bookworm with -tags glibc
2026-01-12 15:38:13 -05:00
henrygd
6a1a98d73f update build constraints to exclude nvml collector on arm64 (#1618) 2026-01-11 20:27:34 -05:00
henrygd
1f067aad5b release 0.18.1 2026-01-11 19:05:36 -05:00
henrygd
1388711105 fix(hub): prevent clearing all containers when single system update is empty (#1620) 2026-01-11 19:03:42 -05:00
henrygd
618e5b4cc1 fix purego build errors on non-supported architectures 2026-01-11 17:48:19 -05:00
henrygd
42c3ca5db5 release 0.18.0 2026-01-11 17:18:32 -05:00
henrygd
534791776b update translations 2026-01-11 17:09:43 -05:00
henrygd
0c6c53fc7d fix isSystemdAvailable in containers and update alpine to 3.23 2026-01-11 16:07:24 -05:00
henrygd
0dfd5ce07d update go deps and changelog 2026-01-11 15:06:58 -05:00
henrygd
2cd6d46f7c add option to make universal token permanent (#1097, 1614) 2026-01-11 15:03:33 -05:00
henrygd
c333a9fadd update translations 2026-01-11 13:50:11 -05:00
henrygd
ba3d1c66f0 refactor(auth): rename honeypot field to avoid autofill (#1011) 2026-01-09 15:12:34 -05:00
henrygd
7276e533ce update changelog and go deps 2026-01-09 13:23:05 -05:00
henrygd
8b84231042 refactor: update languages data structure 2026-01-09 12:19:43 -05:00
Natxo
77da744008 use origin country flags for Spanish and Portuguese languages (#1571) 2026-01-09 12:10:55 -05:00
henrygd
5da7a21119 agent: fix container logs decoding for raw streams (#1535) 2026-01-08 13:57:56 -05:00
henrygd
78d742c712 web: refactor gpu code for slighly better perf 2026-01-08 13:15:16 -05:00
crimist
1c97ea3e2c site: only hide GPU power graph if entire timescale is 0W 2026-01-05 19:42:10 -08:00
henrygd
3d970defe9 refactor: small comment / structure updates 2026-01-05 16:25:30 -05:00
Sven van Ginkel
6282794004 Add systemd check (#1550) 2026-01-05 15:59:17 -05:00
crimist
475c53a55d nvml: add rtd3 memory workaround, fix slog imports (#1587)
* NVML: only read memory usage if utilization > 0% to allow rtd3, #1522

* logging: /x/exp/slog -> log/slog everywhere, fixes log instance inconsistencies
2026-01-05 15:26:59 -05:00
henrygd
4547ff7b5d refactor: unify agent communication with Transport interface
- Introduce `Transport` interface to abstract WebSocket and SSH
communication
- Add generic `Data` field to `AgentResponse` for streamlined future
endpoints
- Maintain backward compatibility with legacy hubs and agents using
typed fields
- Unify fetch operations (SMART, systemd, containers) under a single
`request` method
- Improve `RequestManager` with deadline awareness and legacy response
support
- Refactor agent response routing into dedicated `agent/response.go`
- Update version to 0.18.0-beta.2
2026-01-05 13:13:55 -05:00
henrygd
e7b4be3dc5 fix(agent): update GPU average calculation tests to account for suspended state detection 2026-01-05 13:09:17 -05:00
henrygd
2bd85e04fc add experimental nvml gpu collector (#1522) 2025-12-21 17:10:42 -05:00
henrygd
f6ab5f2af1 refactor: rm diskinfo abstraction from smart-table.tsx 2025-12-21 12:25:12 -05:00
Sven van Ginkel
7d943633a3 fix capacity sorting in smart table (#1551) 2025-12-21 12:21:44 -05:00
henrygd
7fff3c999a update changelog and comments 2025-12-19 16:27:39 -05:00
henrygd
a9068a11a9 add SMART_INTERVAL env var with background smart data fetching 2025-12-19 16:14:31 -05:00
henrygd
d3d102516c refactor system.tsx: change null fallback for details 2025-12-19 00:45:15 -05:00
henrygd
32131439f9 fix systemd table visibility after moving os info to system_details
- also update bun.lock
2025-12-19 00:37:46 -05:00
Jesper Ek
d17685c540 fix non unique fingerprint (#1556) 2025-12-18 18:21:19 -05:00
henrygd
e59f8eee36 add system_details collection for infrequently updated data
- add includedetails flag to data requests for better efficiency
- integrate docker host info api for better os detection
- pull more OS details as well as cpu arch
- separate info bar component and refactor for new info
2025-12-18 17:26:05 -05:00
henrygd
35329abcbd agent install: improve freebsd checksum verification (#1526) 2025-12-12 15:32:55 -05:00
henrygd
ee7741c3ab add --url and --token command line args for agent (#1524) 2025-12-12 13:58:44 -05:00
David Davis
ab0803b2da Upgrade react to address CVE-2025-55182
More info: https://react.dev/blog/2025/12/03/critical-security-vulnerability-in-react-server-components
2025-12-12 07:42:29 -05:00
henrygd
96196a353c smart: fallback to nvme namespace path if base controller path fails (#1504) 2025-12-10 14:09:20 -05:00
henrygd
2a8796c38d update hub install script to support freebsd 2025-12-09 15:08:59 -05:00
Zero2A11
c8d4f7427d fix: When there is no client, LoaderCircle will always transfer (#1511) 2025-12-09 11:01:13 -05:00
henrygd
8d41a797d3 add battery charge to systems table 2025-12-08 18:20:51 -05:00
henrygd
570e1cbf40 pin alpine version to 3.22 2025-12-08 18:14:14 -05:00
henrygd
4c9b00a066 add low battery alert (#1507) 2025-12-08 15:19:46 -05:00
henrygd
7d1f8bb180 raise smartctl timeout to 15 seconds (#1465) 2025-12-05 14:59:11 -05:00
henrygd
3a6caeb06e add serbian 2025-12-05 14:21:50 -05:00
henrygd
9e67245e60 bump minimum go version to 1.25.5 2025-12-02 19:04:40 -05:00
henrygd
b7a95d5d76 release 0.17.0 2025-12-02 18:52:12 -05:00
henrygd
fe550c5901 refactor decodeDockerLogStream 2025-12-02 18:22:22 -05:00
henrygd
8aac0a571a update translations 2025-12-02 17:58:55 -05:00
henrygd
c506b8b0ad pull translations 2025-12-02 16:57:12 -05:00
henrygd
a6e84c207e update changelog 2025-11-30 16:24:05 -05:00
henrygd
249402eaed add hub builds for windows and freebsd 2025-11-30 15:25:31 -05:00
henrygd
394c476f2a strip ansi escape sequences from docker logs (#1478) 2025-11-30 14:36:00 -05:00
henrygd
86e8a141ea add DISK_USAGE_CACHE env var (#1426) 2025-11-30 14:21:00 -05:00
henrygd
53a7e06dcf upgrade go dependencies 2025-11-30 13:55:25 -05:00
henrygd
11edabd09f add alerts for smart failures 2025-11-30 13:52:31 -05:00
henrygd
41a3d9359f add dedicated S.M.A.R.T. page with persistent device storage
- Add /smart route to view SMART data across all systems
- Store SMART devices in new smart_devices collection
- Auto-fetch SMART data when system first comes online
- Add refresh/delete actions per device with realtime updates
- Add navbar and command palette entries
2025-11-30 13:32:37 -05:00
henrygd
5dfc5f247f update extra disk indicators in systems table to display max 3 states 2025-11-30 12:32:16 -05:00
henrygd
9804c8a31a update translation strings 2025-11-29 20:29:07 -05:00
Pavel Pikta
4d05bfdff0 feat: add crossorigin attribute to manifest link (#1457)
Signed-off-by: Pavel Pikta <pavel_pikta@epam.com>
2025-11-26 19:41:54 -05:00
henrygd
0388401a9e change layout of extra disks in all systems table (#1409) 2025-11-25 16:23:48 -05:00
henrygd
162c548010 quiet hours refactoring: change 'future' to 'inactive' 2025-11-24 19:12:35 -05:00
henrygd
888b4a57e5 add quiet hours to silence alerts during specific time periods (#265) 2025-11-24 17:35:28 -05:00
henrygd
26d367b188 add clear button to filter inputs in all systems and containers tables (#1447) 2025-11-19 17:58:58 -05:00
henrygd
ca4988951f add SKIP_SYSTEMD env var (#1448) 2025-11-19 17:21:30 -05:00
zjkal
c7a50dd74d fix: Fix issue where the Add System button is visible to read-only users (#1442)
移除按钮的hidden类并提前检查只读用户状态返回null
2025-11-19 16:38:37 -05:00
Frederik Ring
00fbf5c9c3 Font ligatures create unwanted artifacts in random ids (#1434) 2025-11-19 16:36:48 -05:00
henrygd
4bfe9dd5ad add missing systemd methods for nonlinux 2025-11-14 17:28:40 -05:00
henrygd
e159a75b79 update language files 2025-11-14 17:24:51 -05:00
henrygd
a69686125e release 0.16.1 2025-11-14 16:39:24 -05:00
henrygd
3eb025ded2 make sure distroless image gets :latest tag in workflow 2025-11-14 16:21:17 -05:00
henrygd
1d0e646094 update changelog and go deps 2025-11-14 16:05:36 -05:00
henrygd
32c8e047e3 update cpu / container axis datamax calculations 2025-11-14 15:45:18 -05:00
henrygd
3650482b09 refactor: move getRootMountPoint to disk.go 2025-11-14 14:06:46 -05:00
Arush Wadhawan
79adfd2c0d fix: detect and handle immutable filesystems like Fedora Silverblue (#1405) 2025-11-14 14:03:26 -05:00
Sven van Ginkel
779dcc62aa chore: update actions to lock issues and skip PRs (#1419) 2025-11-14 13:58:13 -05:00
henrygd
abe39c1a0a update bun.lockb to text-based bun.lock 2025-11-14 13:54:40 -05:00
henrygd
bd41ad813c change alert history pagination to use local storage instead of saving to user settings 2025-11-14 13:54:23 -05:00
Arush Wadhawan
77fe63fb63 feat: add alert history page size preference with persistence (#1404) 2025-11-14 13:37:46 -05:00
henrygd
f61ba202d8 remove matrix from list of notification services that support title param (#1406) 2025-11-14 13:27:23 -05:00
henrygd
e1067fa1a3 make layout width adjustable 2025-11-13 18:50:47 -05:00
henrygd
0a3eb898ae truncate device name in smart table (#1416) 2025-11-13 16:41:15 -05:00
evrial
6c33e9dc93 Set a dynamic upper domain on the YAxis for container chart (#1412) 2025-11-13 16:28:37 -05:00
henrygd
f8ed6ce705 refactor: fix nan when net value is undefined in systems table 2025-11-13 16:25:21 -05:00
henrygd
f64478b75e add SERVICE_PATTERNS env var (#1153) 2025-11-13 16:11:24 -05:00
henrygd
854a3697d7 add services column to all systems table 2025-11-13 15:09:48 -05:00
henrygd
b7915b9d0e release 0.16.0 2025-11-12 16:11:08 -05:00
henrygd
4443b606f6 update go deps 2025-11-12 16:08:21 -05:00
henrygd
6c20a98651 update translations 2025-11-12 15:29:30 -05:00
henrygd
b722ccc5bc show additional disk percentages in systems table (#1365) 2025-11-12 14:15:45 -05:00
hank
db0315394b New translations 2025-11-12 13:12:05 -05:00
henrygd
a7ef1235f4 specify latest tag for non-alpine agent image
also change capitalization for gpu alert
2025-11-11 16:18:54 -05:00
henrygd
f64a361c60 add EXCLUDE_SMART env var (#1392) 2025-11-11 16:05:00 -05:00
henrygd
aaa788bc2f add gpu usage alerts 2025-11-11 12:38:47 -05:00
henrygd
3eede6bead fix containers and systemd tables when using system name in URL 2025-11-10 17:43:11 -05:00
henrygd
02abfbcb54 change alert link to use system ID instead of name 2025-11-10 17:31:11 -05:00
henrygd
01d20562f0 add basic systemd service monitoring (#1153)
Co-authored-by: Shelby Tucker <shelby.tucker@gmail.com>
2025-11-10 17:04:51 -05:00
henrygd
cbe6ee6499 fix battery nil pointer error (#1353) 2025-11-10 15:16:50 -05:00
henrygd
9a61ea8356 improve perf of filter bar 2025-11-07 22:29:59 -05:00
henrygd
1af7ff600f embed smartctl in the windows binary (#1362) 2025-11-05 13:03:47 -05:00
henrygd
02d594cc82 release 0.15.4 2025-11-04 17:23:42 -05:00
henrygd
7d0b5c1c67 update language files 2025-11-04 17:18:57 -05:00
Thiago Alves Cavalcante
d3dc8a7af0 new Portuguese translations 2025-11-04 17:18:07 -05:00
henrygd
d67fefe7c5 new spanish translations by dtornerte 2025-11-04 17:17:02 -05:00
henrygd
4d364c5e4d update language files 2025-11-04 17:06:51 -05:00
henrygd
954400ea45 fix intel_gpu_top parsing when engine instance id is in column (#1230) 2025-11-04 16:02:20 -05:00
henrygd
04b6067e64 add a total line to the tooltip of charts with multiple values #1280
Co-authored-by: Titouan V <titouan.verhille@gmail.com>
2025-11-04 15:41:24 -05:00
henrygd
d77ee5554f add fallback paths for smartctl lookup (#1362, #1363) 2025-11-04 14:06:28 -05:00
henrygd
2e034bdead refactor containers table to fix clock issue causing no results (#1337) 2025-11-04 13:18:34 -05:00
henrygd
fc0947aa04 fix windows extra disk backslash issue (#1361) 2025-11-03 17:42:08 -05:00
henrygd
1d546a4091 update nvidia dockerfile to build latest smartmontools (#1335) 2025-11-02 17:13:47 -05:00
henrygd
f60b3bbbfb release 0.15.3 2025-11-01 16:04:02 -04:00
henrygd
8e99b9f1ad update shoutrrr and gopsutil deps 2025-11-01 14:31:41 -04:00
henrygd
fa5ed2bc11 update translations 2025-11-01 14:09:10 -04:00
henrygd
21d961ab97 sync language files 2025-11-01 13:50:53 -04:00
henrygd
aaa93b84d2 add hebrew + new cpu charts refactoring 2025-11-01 13:34:30 -04:00
henrygd
6a562ce03b add more cpu metrics (#1356)
- adds monitoring for cpu state time and per-core usage

Co-authored-by: Sven van Ginkel <svenvanginkel@icloud.com>
2025-11-01 12:57:58 -04:00
henrygd
3dbc48727e add INTEL_GPU_DEVICE env var (#1285) 2025-11-01 11:12:05 -04:00
henrygd
85ac2e5e9a update env var name to EXCLUDE_CONTAINERS #1352 2025-10-30 19:30:01 -04:00
Sven van Ginkel
af6bd4e505 [Feature] Add env var to exclude containers from being monitored (#1352) 2025-10-30 19:02:09 -04:00
Gabay
e54c4b3499 New translations en.po (Hebrew) 2025-10-30 16:50:14 -04:00
henrygd
078c88f825 add hebrew machine translations 2025-10-30 16:45:33 -04:00
henrygd
85169b6c5e improve parsing of edge case smart power on times (#1347) 2025-10-30 16:32:06 -04:00
henrygd
d0ff8ee2c0 fix disk i/o values in longer charts (#1355) 2025-10-30 14:17:56 -04:00
henrygd
e898768997 fix battery nil pointer error #1353 2025-10-30 12:52:33 -04:00
henrygd
0f5b504f23 release 0.15.2 2025-10-29 01:18:15 -04:00
henrygd
365d291393 improve smart device detection (#1345)
also fix virtual device filtering
2025-10-29 01:16:58 -04:00
henrygd
3dbab24c0f improve identification of smart drive types (#1345) 2025-10-28 22:37:47 -04:00
henrygd
1f67fb7c8d release 0.15.1 2025-10-28 19:30:36 -04:00
henrygd
219e09fc78 update language files 2025-10-28 18:41:39 -04:00
henrygd
cd9c2bd9ab update logs in smart.go
also change max execution time to 2 sec
2025-10-28 17:34:49 -04:00
henrygd
9f969d843c update changelog 2025-10-28 16:52:55 -04:00
henrygd
b22a6472fc missed staging this earlier :) 2025-10-28 16:44:34 -04:00
henrygd
d231ace28e fix SHARE_ALL_SYSTEMS not working for Containers
#1334
2025-10-28 16:25:29 -04:00
henrygd
473cb7f437 merge SMART_DEVICES with devices returned from smartctl scan 2025-10-28 15:38:47 -04:00
henrygd
783ed9f456 cache smartctl scan results for 10 min w/ force option
also add support for sntrealtek
2025-10-28 14:01:45 -04:00
henrygd
9a9a89ee50 handle when power on smart attribute is a string like 0h+0m+0.000s 2025-10-28 13:44:31 -04:00
henrygd
5122d0341d fix S.M.A.R.T. wrong disk is renderer in the DiskSheet table #1336 2025-10-28 12:55:38 -04:00
zjkal
81731689da A small translation error has been fixed (#1343) 2025-10-28 11:09:10 -04:00
henrygd
b3e9857448 add SMART_DEVICES env var (#373, #1335)
also iterate through parsers to try to find a match if type is not defined.
2025-10-27 15:26:29 -04:00
henrygd
2eda9eb0e3 add support for scsi and sntasmedia smart data (#373, #1335) 2025-10-27 14:39:12 -04:00
henrygd
82a5df5048 add secondsToString function 2025-10-27 14:14:17 -04:00
Sven van Ginkel
f11564a7ac Skip virtual disks (#1332) 2025-10-27 11:44:21 -04:00
Sven van Ginkel
9df4d29236 Add sorting to the smart table (#1333) 2025-10-27 11:43:23 -04:00
henrygd
1452817423 update readme 2025-10-26 14:09:14 -04:00
AuthorShin
c57e496f5e Added Container to Supported metrics list on readme.md (#1323) 2025-10-26 14:04:42 -04:00
henrygd
6287f7003c fix text contrast when container details disabled (#1324) 2025-10-26 11:41:21 -04:00
henrygd
37037b1f4e update changelog 2025-10-26 11:34:13 -04:00
henrygd
7cf123a99e fix: limit frame and total sizes when reading docker logs (#1322)
- Add per-frame size limit (1MB) to prevent single massive log entries
- Add total log size limit (5MB) for network transfer and browser rendering
- Gracefully truncate logs that exceed limits instead of consuming unbounded memory
2025-10-26 11:02:18 -04:00
henrygd
97394e775f release 0.15.0 2025-10-26 10:47:55 -04:00
henrygd
d5c381188b update go deps 2025-10-26 10:46:30 -04:00
henrygd
b107d12a62 smart support over ssh + change response code for smart failure 2025-10-26 10:33:34 -04:00
henrygd
e646f2c1fc fix inactive tab losing container table data 2025-10-26 10:32:49 -04:00
henrygd
b18528d24a update translations 2025-10-25 18:26:46 -04:00
henrygd
a6e64df399 update language files 2025-10-25 17:20:10 -04:00
Klaus Dandl
66ba21dd41 New German translations 2025-10-25 17:19:14 -04:00
Thor B.
1851e7a111 New Danish translations 2025-10-25 17:00:15 -04:00
henrygd
74b78e96b3 pre release refactoring + update changelog 2025-10-25 16:34:32 -04:00
henrygd
a9657f9c00 add CONTAINER_DETAILS env var (#1305) 2025-10-25 15:33:01 -04:00
henrygd
1dee63a0eb update HasReadableBattery to check all batteries 2025-10-25 14:06:25 -04:00
Nathan Heaps
d608cf0955 fix: send battery stats even if some batteries are missing stats (#1287)
Implement battery error reporting mechanism

Added error reporting for battery information retrieval.

Handles cases like bluetooth devices where `battery` finds it but it has incomplete information about that battery, which would have otherwise caused a null pointer error.

Related: #1254
2025-10-25 13:23:29 -04:00
henrygd
b9139a1f9b strip env vars from container detail (#1305) 2025-10-25 12:58:13 -04:00
henrygd
7f372c46db add alpine image + add smartmontools to intel / nvidia images 2025-10-25 11:59:57 -04:00
henrygd
40010ad9b9 small frontend refactoring / style updates 2025-10-25 11:58:28 -04:00
henrygd
5927f45a4a install-agent.sh: add 'beszel' user to disk group for device access 2025-10-25 11:56:26 -04:00
henrygd
962613df7c Add initial S.M.A.R.T. support
- Implement SmartManager for collecting SMART data from SATA and NVMe drives
- Add smartctl-based data collection with standby mode detection
- Support comprehensive SMART attributes parsing and storage
- Add hub API endpoint for fetching SMART data from agents
- Create SMART table UI with detailed disk information

Co-authored-by: geekifan <i@ifan.dev>
2025-10-24 18:54:56 -04:00
Sven van Ginkel
92b1f236e3 [Feature] Let y axis for temperature not start at 0 (#1307)
* Let y axis not start at 0

* use auto auto
2025-10-22 11:17:42 -04:00
henrygd
a911670a2d release 0.14.1 2025-10-20 17:44:31 -04:00
henrygd
b0cb0c2269 update language files 2025-10-20 17:31:07 -04:00
Teo
735d03577f New Croatian translations 2025-10-20 17:28:57 -04:00
Bruno
a33f88d822 New translations en.po (French) 2025-10-20 17:22:53 -04:00
henrygd
dfd1fc8fda add image name to containers table (#1302) 2025-10-20 17:11:26 -04:00
henrygd
1df08801a2 fix unfound filter values hiding containers chart (#1301) 2025-10-20 14:33:49 -04:00
henrygd
62f5f986bb add spacing for long temperature chart tooltip (#1299) 2025-10-20 14:32:27 -04:00
henrygd
a87b9af9d5 Add MFA_OTP env var to enable email-based one-time password for users and/or superusers 2025-10-20 14:29:56 -04:00
henrygd
03900e54cc correctly sort status column in containers table (#1294) 2025-10-19 11:18:32 -04:00
henrygd
f4abbd1a5b fix system link in container sheet when serving on subpath 2025-10-18 20:02:58 -04:00
henrygd
77ed90cb4a release 0.14.0 :) 2025-10-18 19:36:23 -04:00
henrygd
2fe3b1adb1 increase chart tooltip z-index 2025-10-18 19:20:16 -04:00
henrygd
f56093d0f0 hide container table if no containers 2025-10-18 18:52:59 -04:00
henrygd
77dba42f17 update language files 2025-10-18 18:51:09 -04:00
henrygd
e233a0b0dc new zh translations 2025-10-18 17:33:26 -04:00
derilevi
18e4c88875 new Hungarian translations 2025-10-18 17:32:34 -04:00
Rasko
904a6038cd new Norwegian translations 2025-10-18 17:30:24 -04:00
henrygd
ae55b86493 improve chart filtering logic to support multiple terms (#1274) 2025-10-18 17:10:08 -04:00
henrygd
5360f762e4 expand container monitoring functionality (#928)
- Add new /containers route with virtualized table showing all containers across systems
- Implement container stats collection (CPU, memory, network usage) with health status tracking
- Add container logs and info API endpoints with syntax highlighting using Shiki
- Create detailed container views with fullscreen logs/info dialogs and refresh functionality
- Add container table to individual system pages with lazy loading
- Implement container record storage with automatic cleanup and historical averaging
- Update navbar with container navigation icon
- Extract reusable ActiveAlerts component from home page
- Add FooterRepoLink component for consistent GitHub/version display
- Enhance filtering and search capabilities across container tables
2025-10-18 16:32:16 -04:00
henrygd
0d464787f2 logo color on hover 2025-10-18 15:48:59 -04:00
henrygd
24f72ef596 update collections snapshot 2025-10-18 15:47:12 -04:00
henrygd
2d8739052b likely fix for huge net traffic on interface reset (#1267) 2025-10-18 15:40:54 -04:00
henrygd
1e32d13650 make windows firewall rule opt-in with -ConfigureFirewall 2025-10-11 13:34:57 -04:00
hank
dbf3f94247 New translations by rodrigorm (Portuguese) 2025-10-09 14:21:48 -04:00
Roy W. Andersen
8a81c7bbac New translations en.po (Norwegian) 2025-10-09 14:15:17 -04:00
henrygd
d24150c78b release 0.13.2 2025-10-09 14:01:45 -04:00
henrygd
013da18789 expand check for bad container memory values (#1236) 2025-10-09 14:00:24 -04:00
henrygd
5b663621e4 rename favicon to break cache 2025-10-09 13:37:09 -04:00
henrygd
4056345216 add ability to set custom name for extra filesystems (#379) 2025-10-09 13:18:10 -04:00
henrygd
d00c0488c3 improve websocket agent reconnection after network interruptions (#1263) 2025-10-09 12:09:52 -04:00
henrygd
d352ce00fa allow a bit more latency in the one minute chart points (#1247) 2025-10-07 17:28:48 -04:00
henrygd
1623f5e751 refactor: async docker version check 2025-10-07 15:33:46 -04:00
Amanda Wee
612ad1238f Retry agent's attempt to get the Docker version (#1250) 2025-10-07 14:25:02 -04:00
henrygd
1ad4409609 update favicon to show down count in bubble 2025-10-07 14:13:41 -04:00
evrial
2a94e1d1ec OpenWRT - graceful service stop, restart and respawn if crashes (#1245) 2025-10-06 11:35:44 -04:00
henrygd
75b372437c add small end buffer to chart x axis 2025-10-05 21:18:16 -04:00
henrygd
b661d00159 release 0.13.1 2025-10-05 20:09:49 -04:00
henrygd
898dbf73c8 update agent dockerfile volume 2025-10-05 20:06:17 -04:00
Marrrrrrrrry
e099304948 Add VOLUME to preserve config across container recreations (#1235) 2025-10-05 20:05:00 -04:00
Maximilian Krause
b61b7a12dc New translations en.po (German) 2025-10-05 19:40:44 -04:00
henrygd
37769050e5 fix loading system with direct id url 2025-10-05 19:38:37 -04:00
henrygd
d81e137291 update system permalinks to use id instead of name (#1231)
maintains backward compatibility with old permalinks
2025-10-05 14:18:00 -04:00
henrygd
ae820d348e fix one minute chart on systems without docker (#1237) 2025-10-05 13:19:35 -04:00
henrygd
ddb298ac7c 0.13.0 release 2025-10-03 13:53:12 -04:00
henrygd
cca7b36039 add SYSTEM_NAME env var (#1184) 2025-10-03 13:44:10 -04:00
henrygd
adda381d9d update language files 2025-10-03 13:21:02 -04:00
zoixc
1630b1558f New translations en.po (Russian) 2025-10-03 13:08:58 -04:00
itssloplayz
733c10ff31 New translations en.po (Slovenian) 2025-10-03 13:08:00 -04:00
henrygd
ed3fd185d3 update pocketbase 2025-10-03 12:44:20 -04:00
henrygd
b1fd7e6695 fix intel engine delta tracking across cache keys
- plus a couple tiny lil refactors
2025-10-02 20:24:54 -04:00
henrygd
7d6230de74 add one minute chart + refactor rpc
- add one minute charts
- update disk io to use bytes
- update hub and agent connection interfaces / handlers to be more
flexible
- change agent cache to use cache time instead of session id
- refactor collection of metrics which require deltas to track
separately per cache time
2025-10-02 17:56:51 -04:00
henrygd
f9a39c6004 add noindex meta tag to html (#1218) 2025-09-30 19:16:15 -04:00
henrygd
f21a6d15fe update agent install script to use get.beszel.dev/latest-version (#1212) 2025-09-29 13:37:51 -04:00
Timothy Pillow
bf38716095 Modify GPU usage section in readme (#1216)
Updated GPU metrics to include Intel support and removed temperature. Synced section as currently written in https://beszel.dev/guide/what-is-beszel#supported-metrics
2025-09-29 12:27:20 -04:00
henrygd
45816e7de6 agent install script: refactor mirror handling (#1212)
- add --mirror flag
- use mirror url for api.github.com
- remove prompt confirmation for mirror usage
2025-09-28 13:49:41 -04:00
evrial
2a6946906e Fixed OpenWRT agent restarter logic (#1210)
* Fixed OpenWRT restarter logic

* Update update.go
2025-09-26 12:15:42 -04:00
henrygd
ca58ff66ba 0.12.12 release 2025-09-25 19:37:26 -04:00
henrygd
133d229361 add fallback cache/buff memory calculation when cache/buff isn't available (#1198) 2025-09-25 19:19:32 -04:00
henrygd
960cac4060 fix intel_gpu_top restart loop and add intel gpu pkg power (#1203) 2025-09-25 19:15:36 -04:00
henrygd
d83865cb4f remove NoNewPrivileges from systemd agent service
configuration (#1203)

Prevents service from running `intel_gpu_top`
2025-09-25 15:06:17 -04:00
henrygd
4b43d68da6 add SKIP_GPU=true (#1203) 2025-09-25 14:10:28 -04:00
henrygd
c790d76211 fix command arguments for OpenRC agent restart functionality (#1199) 2025-09-24 23:14:15 -04:00
henrygd
29b182fd7b 0.12.11 release :) 2025-09-24 18:08:54 -04:00
henrygd
fc78b959aa update colors for gpu power chart 2025-09-24 18:01:51 -04:00
henrygd
b8b3604aec update language files 2025-09-24 17:41:11 -04:00
henrygd
e45606fdec New Croatian translations by nikola.smis on Crowdin 2025-09-24 17:40:22 -04:00
aroxu
640afd82ad New Korean translations 2025-09-24 17:31:04 -04:00
henrygd
d025e51c67 make sure agent connection title works in grid layout 2025-09-24 17:15:17 -04:00
henrygd
f70c30345a fix sticky header z-index 2025-09-24 17:07:38 -04:00
henrygd
63bdac83a1 hide interfaces chart legend if interfaces.length > 15 2025-09-24 16:45:43 -04:00
Sven van Ginkel
65897a8df6 add cali to the default nics skip list (#1195) 2025-09-24 16:29:11 -04:00
henrygd
0dc9b3e273 add pattern matching and blacklist functionality to NICS env var. (#1190) 2025-09-24 16:27:37 -04:00
Sven van Ginkel
c1c0d8d672 Fix hub executable (#1193) 2025-09-24 15:13:24 -04:00
henrygd
1811ab64be add migration to fix bad cached mem values (#1196) 2025-09-24 15:07:11 -04:00
henrygd
5578520054 add title to agent connection type in all systems table 2025-09-24 14:18:20 -04:00
henrygd
7b128d09ac Update Intel GPU collector to parse plain text (-l) instead of JSON output (#1150) 2025-09-24 13:24:48 -04:00
henrygd
d295507c0b adjust calculation of cached memory (#1187, #1196) 2025-09-24 13:23:59 -04:00
henrygd
79fbbb7ad0 add ghcr.io image configuration for beszel-agent-intel in gh workflow 2025-09-23 20:16:19 -04:00
henrygd
e7325b23c4 simplify filter bar component 2025-09-23 20:15:42 -04:00
henrygd
c5eba6547a comments 2025-09-22 20:48:37 -04:00
henrygd
82e7c04b25 0.12.10 release :) 2025-09-22 18:32:30 -04:00
henrygd
a9ce16cfdd update language files 2025-09-22 18:28:39 -04:00
henrygd
2af8b6057f new Polish translations 2025-09-22 18:18:39 -04:00
henrygd
3fae4360a8 update changelog 2025-09-22 18:14:36 -04:00
henrygd
10073d85e1 update go deps 2025-09-22 18:13:50 -04:00
henrygd
e240ced018 add support for henrygd/beszel-agent-intel in docker workflow 2025-09-22 17:47:32 -04:00
henrygd
ae1e17f5ed add dockerfile for henrygd/beszel-agent-intel 2025-09-22 17:41:44 -04:00
henrygd
3abb7c213b initial support for one intel gpu with intel_gpu_top 2025-09-22 16:36:10 -04:00
221 changed files with 48036 additions and 4613 deletions

2
.github/CODEOWNERS vendored Normal file
View File

@@ -0,0 +1,2 @@
# Everything needs to be reviewed by Hank
* @henrygd

19
.github/DISCUSSION_TEMPLATE/ideas.yml vendored Normal file
View File

@@ -0,0 +1,19 @@
body:
- type: dropdown
id: component
attributes:
label: Component
description: Which part of Beszel is this about?
options:
- Hub
- Agent
- Hub & Agent
default: 0
validations:
required: true
- type: textarea
attributes:
label: Description
description: Please describe in detail what you want to share.
validations:
required: true

View File

@@ -1,19 +1,54 @@
body: body:
- type: markdown - type: checkboxes
id: terms
attributes: attributes:
value: | label: Welcome!
### Before opening a discussion: description: |
Thank you for reaching out to the Beszel community for support! To help us assist you better, please make sure to review the following points before submitting your request:
- Check the [common issues guide](https://beszel.dev/guide/common-issues). Please note:
- Search existing [issues](https://github.com/henrygd/beszel/issues) and [discussions](https://github.com/henrygd/beszel/discussions) (including closed). - For translation-related issues or requests, please use the [Crowdin project](https://crowdin.com/project/beszel).
**- Please do not submit support reqeusts that are specific to ZFS. We plan to add integration with ZFS utilities in the near future.**
options:
- label: I have read the [Documentation](https://beszel.dev/guide/getting-started)
required: true
- label: I have checked the [Common Issues Guide](https://beszel.dev/guide/common-issues) and my problem was not mentioned there.
required: true
- label: I have searched open and closed issues and discussions and my problem was not mentioned before.
required: true
- label: I have verified I am using the latest version available. You can check the latest release [here](https://github.com/henrygd/beszel/releases).
required: true
- type: dropdown
id: component
attributes:
label: Component
description: Which part of Beszel is this about?
options:
- Hub
- Agent
- Hub & Agent
default: 0
validations:
required: true
- type: textarea - type: textarea
id: description id: description
attributes: attributes:
label: Description label: Problem Description
description: A clear and concise description of the issue or question. If applicable, add screenshots to help explain your problem. description: |
How to write a good bug report?
- Respect the issue template as much as possible.
- The title should be short and descriptive.
- Explain the conditions which led you to report this issue: the context.
- The context should lead to something, a problem that youre facing.
- Remain clear and concise.
- Format your messages to help the reader focus on what matters and understand the structure of your message, use [Markdown syntax](https://help.github.com/articles/github-flavored-markdown)
validations: validations:
required: true required: true
- type: input - type: input
id: system id: system
attributes: attributes:
@@ -21,13 +56,15 @@ body:
placeholder: linux/amd64 (agent), freebsd/arm64 (hub) placeholder: linux/amd64 (agent), freebsd/arm64 (hub)
validations: validations:
required: true required: true
- type: input
id: version # - type: input
attributes: # id: version
label: Beszel version # attributes:
placeholder: 0.9.1 # label: Beszel version
validations: # placeholder: 0.9.1
required: true # validations:
# required: true
- type: dropdown - type: dropdown
id: install-method id: install-method
attributes: attributes:
@@ -41,18 +78,21 @@ body:
- Other (please describe above) - Other (please describe above)
validations: validations:
required: true required: true
- type: textarea - type: textarea
id: config id: config
attributes: attributes:
label: Configuration label: Configuration
description: Please provide any relevant service configuration description: Please provide any relevant service configuration
render: yaml render: yaml
- type: textarea - type: textarea
id: hub-logs id: hub-logs
attributes: attributes:
label: Hub Logs label: Hub Logs
description: Check the logs page in PocketBase (`/_/#/logs`) for relevant errors (copy JSON). description: Check the logs page in PocketBase (`/_/#/logs`) for relevant errors (copy JSON).
render: json render: json
- type: textarea - type: textarea
id: agent-logs id: agent-logs
attributes: attributes:

View File

@@ -1,8 +1,30 @@
name: 🐛 Bug report name: 🐛 Bug report
description: Report a new bug or issue. description: Use this template to report a bug or issue.
title: '[Bug]: ' title: '[Bug]: '
labels: ['bug', "needs confirmation"] labels: ['bug']
body: body:
- type: checkboxes
attributes:
label: Welcome!
description: |
The issue tracker is for reporting bugs and feature requests only. For end-user related support questions, please use the **[GitHub Discussions](https://github.com/henrygd/beszel/discussions/new?category=support)** instead
Please note:
- For translation-related issues or requests, please use the [Crowdin project](https://crowdin.com/project/beszel).
- To request a change or feature, use the [feature request form](https://github.com/henrygd/beszel/issues/new?template=feature_request.yml).
- Any issues that can be resolved by consulting the documentation or by reviewing existing open or closed issues will be closed.
**- Please do not submit bugs that are specific to ZFS. We plan to add integration with ZFS utilities in the near future.**
options:
- label: I have read the [Documentation](https://beszel.dev/guide/getting-started)
required: true
- label: I have checked the [Common Issues Guide](https://beszel.dev/guide/common-issues) and my problem was not mentioned there.
required: true
- label: I have searched open and closed issues and my problem was not mentioned before.
required: true
- label: I have verified I am using the latest version available. You can check the latest release [here](https://github.com/henrygd/beszel/releases).
required: true
- type: dropdown - type: dropdown
id: component id: component
attributes: attributes:
@@ -12,81 +34,53 @@ body:
- Hub - Hub
- Agent - Agent
- Hub & Agent - Hub & Agent
default: 0
validations: validations:
required: true required: true
- type: markdown
attributes:
value: |
### Thanks for taking the time to fill out this bug report!
- For more general support, please [start a support thread](https://github.com/henrygd/beszel/discussions/new?category=support).
- To request a change or feature, use the [feature request form](https://github.com/henrygd/beszel/issues/new?template=feature_request.yml).
- Please do not submit bugs that are specific to ZFS. We plan to add integration with ZFS utilities in the near future.
### Before submitting a bug report:
- Check the [common issues guide](https://beszel.dev/guide/common-issues).
- Search existing [issues](https://github.com/henrygd/beszel/issues) and [discussions](https://github.com/henrygd/beszel/discussions) (including closed).
- type: textarea - type: textarea
id: description id: description
attributes: attributes:
label: Description label: Problem Description
description: Explain the issue you experienced clearly and concisely. description: |
placeholder: I went to the coffee pot and it was empty. How to write a good bug report?
- Respect the issue template as much as possible.
- The title should be short and descriptive.
- Explain the conditions which led you to report this issue: the context.
- The context should lead to something, a problem that youre facing.
- Remain clear and concise.
- Format your messages to help the reader focus on what matters and understand the structure of your message, use [Markdown syntax](https://help.github.com/articles/github-flavored-markdown)
validations: validations:
required: true required: true
- type: textarea - type: textarea
id: expected-behavior id: expected-behavior
attributes: attributes:
label: Expected Behavior label: Expected Behavior
description: In a perfect world, what should have happened? description: |
In a perfect world, what should have happened?
**Important:** Be specific. Vague descriptions like "it should work" are not helpful.
placeholder: When I got to the coffee pot, it should have been full. placeholder: When I got to the coffee pot, it should have been full.
validations: validations:
required: true required: true
- type: textarea - type: textarea
id: steps-to-reproduce id: steps-to-reproduce
attributes: attributes:
label: Steps to Reproduce label: Steps to Reproduce
description: Describe how to reproduce the issue in repeatable steps. description: |
Provide detailed, numbered steps that someone else can follow to reproduce the issue.
**Important:** Vague descriptions like "it doesn't work" or "it's broken" will result in the issue being closed.
Include specific actions, URLs, button clicks, and any relevant data or configuration.
placeholder: | placeholder: |
1. Go to the coffee pot. 1. Go to the coffee pot.
2. Make more coffee. 2. Make more coffee.
3. Pour it into a cup. 3. Pour it into a cup.
4. Observe that the cup is empty instead of full.
validations: validations:
required: true 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 - type: input
id: system id: system
attributes: attributes:
@@ -94,6 +88,7 @@ body:
placeholder: linux/amd64 (agent), freebsd/arm64 (hub) placeholder: linux/amd64 (agent), freebsd/arm64 (hub)
validations: validations:
required: true required: true
- type: input - type: input
id: version id: version
attributes: attributes:
@@ -101,6 +96,7 @@ body:
placeholder: 0.9.1 placeholder: 0.9.1
validations: validations:
required: true required: true
- type: dropdown - type: dropdown
id: install-method id: install-method
attributes: attributes:
@@ -114,18 +110,21 @@ body:
- Other (please describe above) - Other (please describe above)
validations: validations:
required: true required: true
- type: textarea - type: textarea
id: config id: config
attributes: attributes:
label: Configuration label: Configuration
description: Please provide any relevant service configuration description: Please provide any relevant service configuration
render: yaml render: yaml
- type: textarea - type: textarea
id: hub-logs id: hub-logs
attributes: attributes:
label: Hub Logs label: Hub Logs
description: Check the logs page in PocketBase (`/_/#/logs`) for relevant errors (copy JSON). description: Check the logs page in PocketBase (`/_/#/logs`) for relevant errors (copy JSON).
render: json render: json
- type: textarea - type: textarea
id: agent-logs id: agent-logs
attributes: attributes:

View File

@@ -1,5 +1,8 @@
blank_issues_enabled: false blank_issues_enabled: false
contact_links: contact_links:
- name: 🗣️ Translations
url: https://crowdin.com/project/beszel
about: Please report translation issues and request new translations here.
- name: 💬 Support and questions - name: 💬 Support and questions
url: https://github.com/henrygd/beszel/discussions url: https://github.com/henrygd/beszel/discussions
about: Ask and answer questions here. about: Ask and answer questions here.

View File

@@ -1,8 +1,25 @@
name: 🚀 Feature request name: 🚀 Feature request
description: Request a new feature or change. description: Request a new feature or change.
title: "[Feature]: " title: "[Feature]: "
labels: ["enhancement", "needs review"] labels: ["enhancement"]
body: body:
- type: checkboxes
attributes:
label: Welcome!
description: |
The issue tracker is for reporting bugs and feature requests only. For end-user related support questions, please use the **[GitHub Discussions](https://github.com/henrygd/beszel/discussions)** instead
Please note:
- For **Bug reports**, use the [Bug Form](https://github.com/henrygd/beszel/issues/new?template=bug_report.yml).
- Any requests for new translations should be requested within the [crowdin project](https://crowdin.com/project/beszel).
- Create one issue per feature request. This helps us keep track of requests and prioritize them accordingly.
options:
- label: I have searched open and closed feature requests to make sure this or similar feature request does not already exist.
required: true
- label: This is a feature request, not a bug report or support question.
required: true
- type: dropdown - type: dropdown
id: component id: component
attributes: attributes:
@@ -12,16 +29,25 @@ body:
- Hub - Hub
- Agent - Agent
- Hub & Agent - Hub & Agent
default: 0
validations: validations:
required: true required: true
- type: markdown
attributes:
value: Before submitting, please search existing [issues](https://github.com/henrygd/beszel/issues) and [discussions](https://github.com/henrygd/beszel/discussions) (including closed).
- type: textarea - type: textarea
id: description
attributes: attributes:
label: Describe the feature you would like to see label: Description
description: |
Describe the solution or feature you'd like. Explain what problem this solves or what value it adds.
**Important:** Be specific and detailed. Vague requests like "make it better" will be closed.
placeholder: |
Example:
- What is the feature?
- What problem does it solve?
- How should it work?
validations: validations:
required: true required: true
- type: textarea - type: textarea
id: motivation id: motivation
attributes: attributes:
@@ -29,48 +55,3 @@ body:
description: Why do you want this feature? What problem does it solve? description: Why do you want this feature? What problem does it solve?
validations: validations:
required: true 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

View File

@@ -10,51 +10,141 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
fail-fast: false fail-fast: false
max-parallel: 5
matrix: matrix:
include: include:
# henrygd/beszel
- image: henrygd/beszel - image: henrygd/beszel
context: ./
dockerfile: ./internal/dockerfile_hub dockerfile: ./internal/dockerfile_hub
registry: docker.io registry: docker.io
username_secret: DOCKERHUB_USERNAME username_secret: DOCKERHUB_USERNAME
password_secret: DOCKERHUB_TOKEN password_secret: DOCKERHUB_TOKEN
tags: |
type=raw,value=edge
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
# henrygd/beszel-agent:alpine
- image: henrygd/beszel-agent - image: henrygd/beszel-agent
context: ./ dockerfile: ./internal/dockerfile_agent_alpine
dockerfile: ./internal/dockerfile_agent
registry: docker.io registry: docker.io
username_secret: DOCKERHUB_USERNAME username_secret: DOCKERHUB_USERNAME
password_secret: DOCKERHUB_TOKEN password_secret: DOCKERHUB_TOKEN
tags: |
type=raw,value=alpine
type=semver,pattern={{version}}-alpine
type=semver,pattern={{major}}.{{minor}}-alpine
type=semver,pattern={{major}}-alpine
# henrygd/beszel-agent-nvidia
- image: henrygd/beszel-agent-nvidia - image: henrygd/beszel-agent-nvidia
context: ./
dockerfile: ./internal/dockerfile_agent_nvidia dockerfile: ./internal/dockerfile_agent_nvidia
platforms: linux/amd64 platforms: linux/amd64
registry: docker.io registry: docker.io
username_secret: DOCKERHUB_USERNAME username_secret: DOCKERHUB_USERNAME
password_secret: DOCKERHUB_TOKEN password_secret: DOCKERHUB_TOKEN
tags: |
type=raw,value=edge
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
# henrygd/beszel-agent-intel
- image: henrygd/beszel-agent-intel
dockerfile: ./internal/dockerfile_agent_intel
platforms: linux/amd64
registry: docker.io
username_secret: DOCKERHUB_USERNAME
password_secret: DOCKERHUB_TOKEN
tags: |
type=raw,value=edge
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
# ghcr.io/henrygd/beszel
- image: ghcr.io/${{ github.repository }}/beszel - image: ghcr.io/${{ github.repository }}/beszel
context: ./
dockerfile: ./internal/dockerfile_hub dockerfile: ./internal/dockerfile_hub
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password_secret: GITHUB_TOKEN password_secret: GITHUB_TOKEN
tags: |
type=raw,value=edge
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
# ghcr.io/henrygd/beszel-agent
- image: ghcr.io/${{ github.repository }}/beszel-agent - image: ghcr.io/${{ github.repository }}/beszel-agent
context: ./
dockerfile: ./internal/dockerfile_agent dockerfile: ./internal/dockerfile_agent
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password_secret: GITHUB_TOKEN password_secret: GITHUB_TOKEN
tags: |
type=raw,value=edge
type=raw,value=latest
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
# ghcr.io/henrygd/beszel-agent-nvidia
- image: ghcr.io/${{ github.repository }}/beszel-agent-nvidia - image: ghcr.io/${{ github.repository }}/beszel-agent-nvidia
context: ./
dockerfile: ./internal/dockerfile_agent_nvidia dockerfile: ./internal/dockerfile_agent_nvidia
platforms: linux/amd64 platforms: linux/amd64
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password_secret: GITHUB_TOKEN password_secret: GITHUB_TOKEN
tags: |
type=raw,value=edge
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
# ghcr.io/henrygd/beszel-agent-intel
- image: ghcr.io/${{ github.repository }}/beszel-agent-intel
dockerfile: ./internal/dockerfile_agent_intel
platforms: linux/amd64
registry: ghcr.io
username: ${{ github.actor }}
password_secret: GITHUB_TOKEN
tags: |
type=raw,value=edge
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
# ghcr.io/henrygd/beszel-agent:alpine
- image: ghcr.io/${{ github.repository }}/beszel-agent
dockerfile: ./internal/dockerfile_agent_alpine
registry: ghcr.io
username: ${{ github.actor }}
password_secret: GITHUB_TOKEN
tags: |
type=raw,value=alpine
type=semver,pattern={{version}}-alpine
type=semver,pattern={{major}}.{{minor}}-alpine
type=semver,pattern={{major}}-alpine
# henrygd/beszel-agent (keep at bottom so it gets built after :alpine and gets the latest tag)
- image: henrygd/beszel-agent
dockerfile: ./internal/dockerfile_agent
registry: docker.io
username_secret: DOCKERHUB_USERNAME
password_secret: DOCKERHUB_TOKEN
tags: |
type=raw,value=edge
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
permissions: permissions:
contents: read contents: read
@@ -84,12 +174,7 @@ jobs:
uses: docker/metadata-action@v5 uses: docker/metadata-action@v5
with: with:
images: ${{ matrix.image }} images: ${{ matrix.image }}
tags: | tags: ${{ matrix.tags }}
type=raw,value=edge
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
# https://github.com/docker/login-action # https://github.com/docker/login-action
- name: Login to Docker Hub - name: Login to Docker Hub
@@ -107,7 +192,7 @@ jobs:
- name: Build and push Docker image - name: Build and push Docker image
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
with: with:
context: "${{ matrix.context }}" context: ./
file: ${{ matrix.dockerfile }} file: ${{ matrix.dockerfile }}
platforms: ${{ matrix.platforms || 'linux/amd64,linux/arm64,linux/arm/v7' }} platforms: ${{ matrix.platforms || 'linux/amd64,linux/arm64,linux/arm/v7' }}
push: ${{ github.ref_type == 'tag' && secrets[matrix.password_secret] != '' }} push: ${{ github.ref_type == 'tag' && secrets[matrix.password_secret] != '' }}

View File

@@ -10,12 +10,25 @@ permissions:
pull-requests: write pull-requests: write
jobs: jobs:
lock-inactive:
name: Lock Inactive Issues
runs-on: ubuntu-24.04
steps:
- uses: klaasnicolaas/action-inactivity-lock@v1.1.3
id: lock
with:
days-inactive-issues: 14
lock-reason-issues: ""
# Action can not skip PRs, set it to 100 years to cover it.
days-inactive-prs: 36524
lock-reason-prs: ""
close-stale: close-stale:
name: Close Stale Issues name: Close Stale Issues
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- name: Close Stale Issues - name: Close Stale Issues
uses: actions/stale@v9 uses: actions/stale@v10
with: with:
repo-token: ${{ secrets.GITHUB_TOKEN }} repo-token: ${{ secrets.GITHUB_TOKEN }}
@@ -32,11 +45,14 @@ jobs:
# Timing # Timing
days-before-issue-stale: 14 days-before-issue-stale: 14
days-before-issue-close: 7 days-before-issue-close: 7
# Action can not skip PRs, set it to 100 years to cover it.
days-before-pr-stale: 36524
# Labels # Labels
stale-issue-label: 'stale' stale-issue-label: 'stale'
remove-stale-when-updated: true remove-stale-when-updated: true
only-issue-labels: 'awaiting-requester' any-of-labels: 'awaiting-requester'
exempt-issue-labels: 'enhancement'
# Exemptions # Exemptions
exempt-assignees: true exempt-assignees: true

View File

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

@@ -5,6 +5,7 @@ project_name: beszel
before: before:
hooks: hooks:
- go mod tidy - go mod tidy
- go generate -run fetchsmartctl ./agent
builds: builds:
- id: beszel - id: beszel
@@ -15,10 +16,21 @@ builds:
goos: goos:
- linux - linux
- darwin - darwin
- windows
- freebsd
goarch: goarch:
- amd64 - amd64
- arm64 - arm64
- arm - arm
ignore:
- goos: windows
goarch: arm64
- goos: windows
goarch: arm
- goos: freebsd
goarch: arm64
- goos: freebsd
goarch: arm
- id: beszel-agent - id: beszel-agent
binary: beszel-agent binary: beszel-agent
@@ -64,6 +76,18 @@ builds:
- goos: windows - goos: windows
goarch: riscv64 goarch: riscv64
- id: beszel-agent-linux-amd64-glibc
binary: beszel-agent
main: internal/cmd/agent/agent.go
env:
- CGO_ENABLED=0
flags:
- -tags=glibc
goos:
- linux
goarch:
- amd64
archives: archives:
- id: beszel-agent - id: beszel-agent
formats: [tar.gz] formats: [tar.gz]
@@ -77,6 +101,15 @@ archives:
- goos: windows - goos: windows
formats: [zip] formats: [zip]
- id: beszel-agent-linux-amd64-glibc
formats: [tar.gz]
ids:
- beszel-agent-linux-amd64-glibc
name_template: >-
{{ .Binary }}_
{{- .Os }}_
{{- .Arch }}_glibc
- id: beszel - id: beszel
formats: [tar.gz] formats: [tar.gz]
ids: ids:
@@ -85,6 +118,9 @@ archives:
{{ .Binary }}_ {{ .Binary }}_
{{- .Os }}_ {{- .Os }}_
{{- .Arch }} {{- .Arch }}
format_overrides:
- goos: windows
formats: [zip]
nfpms: nfpms:
- id: beszel-agent - id: beszel-agent
@@ -122,9 +158,7 @@ nfpms:
- debconf - debconf
scripts: scripts:
templates: ./supplemental/debian/templates templates: ./supplemental/debian/templates
# Currently broken due to a bug in goreleaser config: ./supplemental/debian/config.sh
# https://github.com/goreleaser/goreleaser/issues/5487
#config: ./supplemental/debian/config.sh
scoops: scoops:
- ids: [beszel-agent] - ids: [beszel-agent]

View File

@@ -7,7 +7,7 @@ SKIP_WEB ?= false
# Set executable extension based on target OS # Set executable extension based on target OS
EXE_EXT := $(if $(filter windows,$(OS)),.exe,) EXE_EXT := $(if $(filter windows,$(OS)),.exe,)
.PHONY: tidy build-agent build-hub build-hub-dev build clean lint dev-server dev-agent dev-hub dev generate-locales .PHONY: tidy build-agent build-hub build-hub-dev build clean lint dev-server dev-agent dev-hub dev generate-locales fetch-smartctl-conditional
.DEFAULT_GOAL := build .DEFAULT_GOAL := build
clean: clean:
@@ -46,8 +46,14 @@ build-dotnet-conditional:
fi; \ fi; \
fi fi
# Download smartctl.exe at build time for Windows (skips if already present)
fetch-smartctl-conditional:
@if [ "$(OS)" = "windows" ]; then \
go generate -run fetchsmartctl ./agent; \
fi
# Update build-agent to include conditional .NET build # Update build-agent to include conditional .NET build
build-agent: tidy build-dotnet-conditional build-agent: tidy build-dotnet-conditional fetch-smartctl-conditional
GOOS=$(OS) GOARCH=$(ARCH) go build -o ./build/beszel-agent_$(OS)_$(ARCH)$(EXE_EXT) -ldflags "-w -s" ./internal/cmd/agent GOOS=$(OS) GOARCH=$(ARCH) go build -o ./build/beszel-agent_$(OS)_$(ARCH)$(EXE_EXT) -ldflags "-w -s" ./internal/cmd/agent
build-hub: tidy $(if $(filter false,$(SKIP_WEB)),build-web-ui) build-hub: tidy $(if $(filter false,$(SKIP_WEB)),build-web-ui)

View File

@@ -16,6 +16,8 @@ import (
"github.com/gliderlabs/ssh" "github.com/gliderlabs/ssh"
"github.com/henrygd/beszel" "github.com/henrygd/beszel"
"github.com/henrygd/beszel/agent/deltatracker"
"github.com/henrygd/beszel/internal/common"
"github.com/henrygd/beszel/internal/entities/system" "github.com/henrygd/beszel/internal/entities/system"
"github.com/shirou/gopsutil/v4/host" "github.com/shirou/gopsutil/v4/host"
gossh "golang.org/x/crypto/ssh" gossh "golang.org/x/crypto/ssh"
@@ -28,17 +30,25 @@ type Agent struct {
memCalc string // Memory calculation formula memCalc string // Memory calculation formula
fsNames []string // List of filesystem device names being monitored fsNames []string // List of filesystem device names being monitored
fsStats map[string]*system.FsStats // Keeps track of disk stats for each filesystem fsStats map[string]*system.FsStats // Keeps track of disk stats for each filesystem
diskPrev map[uint16]map[string]prevDisk // Previous disk I/O counters per cache interval
diskUsageCacheDuration time.Duration // How long to cache disk usage (to avoid waking sleeping disks)
lastDiskUsageUpdate time.Time // Last time disk usage was collected
netInterfaces map[string]struct{} // Stores all valid network interfaces netInterfaces map[string]struct{} // Stores all valid network interfaces
netIoStats system.NetIoStats // Keeps track of bandwidth usage netIoStats map[uint16]system.NetIoStats // Keeps track of bandwidth usage per cache interval
netInterfaceDeltaTrackers map[uint16]*deltatracker.DeltaTracker[string, uint64] // Per-cache-time NIC delta trackers
dockerManager *dockerManager // Manages Docker API requests dockerManager *dockerManager // Manages Docker API requests
sensorConfig *SensorConfig // Sensors config sensorConfig *SensorConfig // Sensors config
systemInfo system.Info // Host system info systemInfo system.Info // Host system info (dynamic)
systemDetails system.Details // Host system details (static, once-per-connection)
gpuManager *GPUManager // Manages GPU data gpuManager *GPUManager // Manages GPU data
cache *SessionCache // Cache for system stats based on primary session ID cache *systemDataCache // Cache for system stats based on cache time
connectionManager *ConnectionManager // Channel to signal connection events connectionManager *ConnectionManager // Channel to signal connection events
handlerRegistry *HandlerRegistry // Registry for routing incoming messages
server *ssh.Server // SSH server server *ssh.Server // SSH server
dataDir string // Directory for persisting data dataDir string // Directory for persisting data
keys []gossh.PublicKey // SSH public keys keys []gossh.PublicKey // SSH public keys
smartManager *SmartManager // Manages SMART data
systemdManager *systemdManager // Manages systemd services
} }
// NewAgent creates a new agent with the given data directory for persisting data. // NewAgent creates a new agent with the given data directory for persisting data.
@@ -46,9 +56,15 @@ type Agent struct {
func NewAgent(dataDir ...string) (agent *Agent, err error) { func NewAgent(dataDir ...string) (agent *Agent, err error) {
agent = &Agent{ agent = &Agent{
fsStats: make(map[string]*system.FsStats), fsStats: make(map[string]*system.FsStats),
cache: NewSessionCache(69 * time.Second), cache: NewSystemDataCache(),
} }
// Initialize disk I/O previous counters storage
agent.diskPrev = make(map[uint16]map[string]prevDisk)
// Initialize per-cache-time network tracking structures
agent.netIoStats = make(map[uint16]system.NetIoStats)
agent.netInterfaceDeltaTrackers = make(map[uint16]*deltatracker.DeltaTracker[string, uint64])
agent.dataDir, err = getDataDir(dataDir...) agent.dataDir, err = getDataDir(dataDir...)
if err != nil { if err != nil {
slog.Warn("Data directory not found") slog.Warn("Data directory not found")
@@ -58,6 +74,17 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
agent.memCalc, _ = GetEnv("MEM_CALC") agent.memCalc, _ = GetEnv("MEM_CALC")
agent.sensorConfig = agent.newSensorConfig() agent.sensorConfig = agent.newSensorConfig()
// Parse disk usage cache duration (e.g., "15m", "1h") to avoid waking sleeping disks
if diskUsageCache, exists := GetEnv("DISK_USAGE_CACHE"); exists {
if duration, err := time.ParseDuration(diskUsageCache); err == nil {
agent.diskUsageCacheDuration = duration
slog.Info("DISK_USAGE_CACHE", "duration", duration)
} else {
slog.Warn("Invalid DISK_USAGE_CACHE", "err", err)
}
}
// Set up slog with a log level determined by the LOG_LEVEL env var // Set up slog with a log level determined by the LOG_LEVEL env var
if logLevelStr, exists := GetEnv("LOG_LEVEL"); exists { if logLevelStr, exists := GetEnv("LOG_LEVEL"); exists {
switch strings.ToLower(logLevelStr) { switch strings.ToLower(logLevelStr) {
@@ -73,31 +100,53 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
slog.Debug(beszel.Version) slog.Debug(beszel.Version)
// initialize docker manager
agent.dockerManager = newDockerManager()
// initialize system info // initialize system info
agent.initializeSystemInfo() agent.refreshSystemDetails()
// SMART_INTERVAL env var to update smart data at this interval
if smartIntervalEnv, exists := GetEnv("SMART_INTERVAL"); exists {
if duration, err := time.ParseDuration(smartIntervalEnv); err == nil && duration > 0 {
agent.systemDetails.SmartInterval = duration
slog.Info("SMART_INTERVAL", "duration", duration)
} else {
slog.Warn("Invalid SMART_INTERVAL", "err", err)
}
}
// initialize connection manager // initialize connection manager
agent.connectionManager = newConnectionManager(agent) agent.connectionManager = newConnectionManager(agent)
// initialize handler registry
agent.handlerRegistry = NewHandlerRegistry()
// initialize disk info // initialize disk info
agent.initializeDiskInfo() agent.initializeDiskInfo()
// initialize net io stats // initialize net io stats
agent.initializeNetIoStats() agent.initializeNetIoStats()
// initialize docker manager agent.systemdManager, err = newSystemdManager()
agent.dockerManager = newDockerManager(agent) if err != nil {
slog.Debug("Systemd", "err", err)
}
agent.smartManager, err = NewSmartManager()
if err != nil {
slog.Debug("SMART", "err", err)
}
// initialize GPU manager // initialize GPU manager
if gm, err := NewGPUManager(); err != nil { agent.gpuManager, err = NewGPUManager()
if err != nil {
slog.Debug("GPU", "err", err) slog.Debug("GPU", "err", err)
} else {
agent.gpuManager = gm
} }
// if debugging, print stats // if debugging, print stats
if agent.debug { if agent.debug {
slog.Debug("Stats", "data", agent.gatherStats("")) slog.Debug("Stats", "data", agent.gatherStats(common.DataRequestOptions{CacheTimeMs: 60_000, IncludeDetails: true}))
} }
return agent, nil return agent, nil
@@ -112,24 +161,31 @@ func GetEnv(key string) (value string, exists bool) {
return os.LookupEnv(key) return os.LookupEnv(key)
} }
func (a *Agent) gatherStats(sessionID string) *system.CombinedData { func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedData {
a.Lock() a.Lock()
defer a.Unlock() defer a.Unlock()
data, isCached := a.cache.Get(sessionID) cacheTimeMs := options.CacheTimeMs
data, isCached := a.cache.Get(cacheTimeMs)
if isCached { if isCached {
slog.Debug("Cached data", "session", sessionID) slog.Debug("Cached data", "cacheTimeMs", cacheTimeMs)
return data return data
} }
*data = system.CombinedData{ *data = system.CombinedData{
Stats: a.getSystemStats(), Stats: a.getSystemStats(cacheTimeMs),
Info: a.systemInfo, Info: a.systemInfo,
} }
slog.Debug("System data", "data", data)
// Include static system details only when requested
if options.IncludeDetails {
data.Details = &a.systemDetails
}
// slog.Info("System data", "data", data, "cacheTimeMs", cacheTimeMs)
if a.dockerManager != nil { if a.dockerManager != nil {
if containerStats, err := a.dockerManager.getDockerStats(); err == nil { if containerStats, err := a.dockerManager.getDockerStats(cacheTimeMs); err == nil {
data.Containers = containerStats data.Containers = containerStats
slog.Debug("Containers", "data", data.Containers) slog.Debug("Containers", "data", data.Containers)
} else { } else {
@@ -137,15 +193,38 @@ func (a *Agent) gatherStats(sessionID string) *system.CombinedData {
} }
} }
// skip updating systemd services if cache time is not the default 60sec interval
if a.systemdManager != nil && cacheTimeMs == 60_000 {
totalCount := uint16(a.systemdManager.getServiceStatsCount())
if totalCount > 0 {
numFailed := a.systemdManager.getFailedServiceCount()
data.Info.Services = []uint16{totalCount, numFailed}
}
if a.systemdManager.hasFreshStats {
data.SystemdServices = a.systemdManager.getServiceStats(nil, false)
}
}
data.Stats.ExtraFs = make(map[string]*system.FsStats) data.Stats.ExtraFs = make(map[string]*system.FsStats)
data.Info.ExtraFsPct = make(map[string]float64)
for name, stats := range a.fsStats { for name, stats := range a.fsStats {
if !stats.Root && stats.DiskTotal > 0 { if !stats.Root && stats.DiskTotal > 0 {
data.Stats.ExtraFs[name] = stats // Use custom name if available, otherwise use device name
key := name
if stats.Name != "" {
key = stats.Name
}
data.Stats.ExtraFs[key] = stats
// Add percentages to Info struct for dashboard
if stats.DiskTotal > 0 {
pct := twoDecimals((stats.DiskUsed / stats.DiskTotal) * 100)
data.Info.ExtraFsPct[key] = pct
}
} }
} }
slog.Debug("Extra FS", "data", data.Stats.ExtraFs) slog.Debug("Extra FS", "data", data.Stats.ExtraFs)
a.cache.Set(sessionID, data) a.cache.Set(data, cacheTimeMs)
return data return data
} }
@@ -165,8 +244,9 @@ func (a *Agent) getFingerprint() string {
// if no fingerprint is found, generate one // if no fingerprint is found, generate one
fingerprint, err := host.HostID() fingerprint, err := host.HostID()
if err != nil || fingerprint == "" { // we ignore a commonly known "product_uuid" known not to be unique
fingerprint = a.systemInfo.Hostname + a.systemInfo.CpuModel if err != nil || fingerprint == "" || fingerprint == "03000200-0400-0500-0006-000700080009" {
fingerprint = a.systemDetails.Hostname + a.systemDetails.CpuModel
} }
// hash fingerprint // hash fingerprint

View File

@@ -1,37 +1,55 @@
package agent package agent
import ( import (
"sync"
"time" "time"
"github.com/henrygd/beszel/internal/entities/system" "github.com/henrygd/beszel/internal/entities/system"
) )
// Not thread safe since we only access from gatherStats which is already locked type systemDataCache struct {
type SessionCache struct { sync.RWMutex
cache map[uint16]*cacheNode
}
type cacheNode struct {
data *system.CombinedData data *system.CombinedData
lastUpdate time.Time lastUpdate time.Time
primarySession string
leaseTime time.Duration
} }
func NewSessionCache(leaseTime time.Duration) *SessionCache { // NewSystemDataCache creates a cache keyed by the polling interval in milliseconds.
return &SessionCache{ func NewSystemDataCache() *systemDataCache {
leaseTime: leaseTime, return &systemDataCache{
data: &system.CombinedData{}, cache: make(map[uint16]*cacheNode),
} }
} }
func (c *SessionCache) Get(sessionID string) (stats *system.CombinedData, isCached bool) { // Get returns cached combined data when the entry is still considered fresh.
if sessionID != c.primarySession && time.Since(c.lastUpdate) < c.leaseTime { func (c *systemDataCache) Get(cacheTimeMs uint16) (stats *system.CombinedData, isCached bool) {
return c.data, true c.RLock()
defer c.RUnlock()
node, ok := c.cache[cacheTimeMs]
if !ok {
return &system.CombinedData{}, false
} }
return c.data, false // allowedSkew := time.Second
// isFresh := time.Since(node.lastUpdate) < time.Duration(cacheTimeMs)*time.Millisecond-allowedSkew
// allow a 50% skew of the cache time
isFresh := time.Since(node.lastUpdate) < time.Duration(cacheTimeMs/2)*time.Millisecond
return node.data, isFresh
} }
func (c *SessionCache) Set(sessionID string, data *system.CombinedData) { // Set stores the latest combined data snapshot for the given interval.
if data != nil { func (c *systemDataCache) Set(data *system.CombinedData, cacheTimeMs uint16) {
*c.data = *data c.Lock()
defer c.Unlock()
node, ok := c.cache[cacheTimeMs]
if !ok {
node = &cacheNode{}
c.cache[cacheTimeMs] = node
} }
c.primarySession = sessionID node.data = data
c.lastUpdate = time.Now() node.lastUpdate = time.Now()
} }

View File

@@ -8,82 +8,239 @@ import (
"testing/synctest" "testing/synctest"
"time" "time"
"github.com/henrygd/beszel/internal/entities/container"
"github.com/henrygd/beszel/internal/entities/system" "github.com/henrygd/beszel/internal/entities/system"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func TestSessionCache_GetSet(t *testing.T) { func createTestCacheData() *system.CombinedData {
synctest.Test(t, func(t *testing.T) { return &system.CombinedData{
cache := NewSessionCache(69 * time.Second)
testData := &system.CombinedData{
Info: system.Info{
Hostname: "test-host",
Cores: 4,
},
Stats: system.Stats{ Stats: system.Stats{
Cpu: 50.0, Cpu: 50.5,
MemPct: 30.0, Mem: 8192,
DiskPct: 40.0, DiskTotal: 100000,
},
Info: system.Info{
AgentVersion: "0.12.0",
},
Containers: []*container.Stats{
{
Name: "test-container",
Cpu: 25.0,
},
},
}
}
func TestNewSystemDataCache(t *testing.T) {
cache := NewSystemDataCache()
require.NotNil(t, cache)
assert.NotNil(t, cache.cache)
assert.Empty(t, cache.cache)
}
func TestCacheGetSet(t *testing.T) {
cache := NewSystemDataCache()
data := createTestCacheData()
// Test setting data
cache.Set(data, 1000) // 1 second cache
// Test getting fresh data
retrieved, isCached := cache.Get(1000)
assert.True(t, isCached)
assert.Equal(t, data, retrieved)
// Test getting non-existent cache key
_, isCached = cache.Get(2000)
assert.False(t, isCached)
}
func TestCacheFreshness(t *testing.T) {
cache := NewSystemDataCache()
data := createTestCacheData()
testCases := []struct {
name string
cacheTimeMs uint16
sleepMs time.Duration
expectFresh bool
}{
{
name: "fresh data - well within cache time",
cacheTimeMs: 1000, // 1 second
sleepMs: 100, // 100ms
expectFresh: true,
},
{
name: "fresh data - at 50% of cache time boundary",
cacheTimeMs: 1000, // 1 second, 50% = 500ms
sleepMs: 499, // just under 500ms
expectFresh: true,
},
{
name: "stale data - exactly at 50% cache time",
cacheTimeMs: 1000, // 1 second, 50% = 500ms
sleepMs: 500, // exactly 500ms
expectFresh: false,
},
{
name: "stale data - well beyond cache time",
cacheTimeMs: 1000, // 1 second
sleepMs: 800, // 800ms
expectFresh: false,
},
{
name: "short cache time",
cacheTimeMs: 200, // 200ms, 50% = 100ms
sleepMs: 150, // 150ms > 100ms
expectFresh: false,
}, },
} }
// Test initial state - should not be cached for _, tc := range testCases {
data, isCached := cache.Get("session1") t.Run(tc.name, func(t *testing.T) {
assert.False(t, isCached, "Expected no cached data initially") synctest.Test(t, func(t *testing.T) {
assert.NotNil(t, data, "Expected data to be initialized") // Set data
// Set data for session1 cache.Set(data, tc.cacheTimeMs)
cache.Set("session1", testData)
time.Sleep(15 * time.Second) // Wait for the specified duration
if tc.sleepMs > 0 {
time.Sleep(tc.sleepMs * time.Millisecond)
}
// Get data for a different session - should be cached // Check freshness
data, isCached = cache.Get("session2") _, isCached := cache.Get(tc.cacheTimeMs)
assert.True(t, isCached, "Expected data to be cached for non-primary session") assert.Equal(t, tc.expectFresh, isCached)
require.NotNil(t, data, "Expected cached data to be returned") })
assert.Equal(t, "test-host", data.Info.Hostname, "Hostname should match test data") })
assert.Equal(t, 4, data.Info.Cores, "Cores should match test data") }
assert.Equal(t, 50.0, data.Stats.Cpu, "CPU should match test data") }
assert.Equal(t, 30.0, data.Stats.MemPct, "Memory percentage should match test data")
assert.Equal(t, 40.0, data.Stats.DiskPct, "Disk percentage should match test data")
time.Sleep(10 * time.Second) func TestCacheMultipleIntervals(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
cache := NewSystemDataCache()
data1 := createTestCacheData()
data2 := &system.CombinedData{
Stats: system.Stats{
Cpu: 75.0,
Mem: 16384,
},
Info: system.Info{
AgentVersion: "0.12.0",
},
Containers: []*container.Stats{},
}
// Get data for the primary session - should not be cached // Set data for different intervals
data, isCached = cache.Get("session1") cache.Set(data1, 500) // 500ms cache
assert.False(t, isCached, "Expected data not to be cached for primary session") cache.Set(data2, 1000) // 1000ms cache
require.NotNil(t, data, "Expected data to be returned even if not cached")
assert.Equal(t, "test-host", data.Info.Hostname, "Hostname should match test data")
// if not cached, agent will update the data
cache.Set("session1", testData)
time.Sleep(45 * time.Second) // Both should be fresh immediately
retrieved1, isCached1 := cache.Get(500)
assert.True(t, isCached1)
assert.Equal(t, data1, retrieved1)
// Get data for a different session - should still be cached retrieved2, isCached2 := cache.Get(1000)
_, isCached = cache.Get("session2") assert.True(t, isCached2)
assert.True(t, isCached, "Expected data to be cached for non-primary session") assert.Equal(t, data2, retrieved2)
// Wait for the lease to expire // Wait 300ms - 500ms cache should be stale (250ms threshold), 1000ms should still be fresh (500ms threshold)
time.Sleep(30 * time.Second) time.Sleep(300 * time.Millisecond)
// Get data for session2 - should not be cached _, isCached1 = cache.Get(500)
_, isCached = cache.Get("session2") assert.False(t, isCached1)
assert.False(t, isCached, "Expected data not to be cached after lease expiration")
_, isCached2 = cache.Get(1000)
assert.True(t, isCached2)
// Wait another 300ms (total 600ms) - now 1000ms cache should also be stale
time.Sleep(300 * time.Millisecond)
_, isCached2 = cache.Get(1000)
assert.False(t, isCached2)
}) })
} }
func TestSessionCache_NilData(t *testing.T) { func TestCacheOverwrite(t *testing.T) {
// Create a new SessionCache cache := NewSystemDataCache()
cache := NewSessionCache(30 * time.Second) data1 := createTestCacheData()
data2 := &system.CombinedData{
// Test setting nil data (should not panic) Stats: system.Stats{
assert.NotPanics(t, func() { Cpu: 90.0,
cache.Set("session1", nil) Mem: 32768,
}, "Setting nil data should not panic") },
Info: system.Info{
// Get data - should not be nil even though we set nil AgentVersion: "0.12.0",
data, _ := cache.Get("session2") },
assert.NotNil(t, data, "Expected data to not be nil after setting nil data") Containers: []*container.Stats{},
}
// Set initial data
cache.Set(data1, 1000)
retrieved, isCached := cache.Get(1000)
assert.True(t, isCached)
assert.Equal(t, data1, retrieved)
// Overwrite with new data
cache.Set(data2, 1000)
retrieved, isCached = cache.Get(1000)
assert.True(t, isCached)
assert.Equal(t, data2, retrieved)
assert.NotEqual(t, data1, retrieved)
}
func TestCacheMiss(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
cache := NewSystemDataCache()
// Test getting from empty cache
_, isCached := cache.Get(1000)
assert.False(t, isCached)
// Set data for one interval
data := createTestCacheData()
cache.Set(data, 1000)
// Test getting different interval
_, isCached = cache.Get(2000)
assert.False(t, isCached)
// Test getting after data has expired
time.Sleep(600 * time.Millisecond) // 600ms > 500ms (50% of 1000ms)
_, isCached = cache.Get(1000)
assert.False(t, isCached)
})
}
func TestCacheZeroInterval(t *testing.T) {
cache := NewSystemDataCache()
data := createTestCacheData()
// Set with zero interval - should allow immediate cache
cache.Set(data, 0)
// With 0 interval, 50% is 0, so it should never be considered fresh
// (time.Since(lastUpdate) >= 0, which is not < 0)
_, isCached := cache.Get(0)
assert.False(t, isCached)
}
func TestCacheLargeInterval(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
cache := NewSystemDataCache()
data := createTestCacheData()
// Test with maximum uint16 value
cache.Set(data, 65535) // ~65 seconds
// Should be fresh immediately
_, isCached := cache.Get(65535)
assert.True(t, isCached)
// Should still be fresh after a short time
time.Sleep(100 * time.Millisecond)
_, isCached = cache.Get(65535)
assert.True(t, isCached)
})
} }

View File

@@ -6,12 +6,15 @@ package battery
import ( import (
"errors" "errors"
"log/slog" "log/slog"
"math"
"github.com/distatus/battery" "github.com/distatus/battery"
) )
var systemHasBattery = false var (
var haveCheckedBattery = false systemHasBattery = false
haveCheckedBattery = false
)
// HasReadableBattery checks if the system has a battery and returns true if it does. // HasReadableBattery checks if the system has a battery and returns true if it does.
func HasReadableBattery() bool { func HasReadableBattery() bool {
@@ -19,8 +22,13 @@ func HasReadableBattery() bool {
return systemHasBattery return systemHasBattery
} }
haveCheckedBattery = true haveCheckedBattery = true
bat, err := battery.Get(0) batteries, err := battery.GetAll()
systemHasBattery = err == nil && bat != nil && bat.Design != 0 && bat.Full != 0 for _, bat := range batteries {
if bat != nil && (bat.Full > 0 || bat.Design > 0) {
systemHasBattery = true
break
}
}
if !systemHasBattery { if !systemHasBattery {
slog.Debug("No battery found", "err", err) slog.Debug("No battery found", "err", err)
} }
@@ -28,25 +36,49 @@ func HasReadableBattery() bool {
} }
// GetBatteryStats returns the current battery percent and charge state // GetBatteryStats returns the current battery percent and charge state
// percent = (current charge of all batteries) / (sum of designed/full capacity of all batteries)
func GetBatteryStats() (batteryPercent uint8, batteryState uint8, err error) { func GetBatteryStats() (batteryPercent uint8, batteryState uint8, err error) {
if !systemHasBattery { if !HasReadableBattery() {
return batteryPercent, batteryState, errors.ErrUnsupported return batteryPercent, batteryState, errors.ErrUnsupported
} }
batteries, err := battery.GetAll() batteries, err := battery.GetAll()
if err != nil || len(batteries) == 0 { // we'll handle errors later by skipping batteries with errors, rather
return batteryPercent, batteryState, err // than skipping everything because of the presence of some errors.
if len(batteries) == 0 {
return batteryPercent, batteryState, errors.New("no batteries")
} }
totalCapacity := float64(0) totalCapacity := float64(0)
totalCharge := float64(0) totalCharge := float64(0)
for _, bat := range batteries { errs, partialErrs := err.(battery.Errors)
if bat.Design != 0 {
totalCapacity += bat.Design batteryState = math.MaxUint8
} else {
for i, bat := range batteries {
if partialErrs && errs[i] != nil {
// if there were some errors, like missing data, skip it
continue
}
if bat == nil || bat.Full == 0 {
// skip batteries with no capacity. Charge is unlikely to ever be zero, but
// we can't guarantee that, so don't skip based on charge.
continue
}
totalCapacity += bat.Full totalCapacity += bat.Full
totalCharge += min(bat.Current, bat.Full)
if bat.State.Raw >= 0 {
batteryState = uint8(bat.State.Raw)
} }
totalCharge += bat.Current
} }
if totalCapacity == 0 || batteryState == math.MaxUint8 {
// for macs there's sometimes a ghost battery with 0 capacity
// https://github.com/distatus/battery/issues/34
// Instead of skipping over those batteries, we'll check for total 0 capacity
// and return an error. This also prevents a divide by zero.
return batteryPercent, batteryState, errors.New("no battery capacity")
}
batteryPercent = uint8(totalCharge / totalCapacity * 100) batteryPercent = uint8(totalCharge / totalCapacity * 100)
batteryState = uint8(batteries[0].State.Raw)
return batteryPercent, batteryState, nil return batteryPercent, batteryState, nil
} }

View File

@@ -142,7 +142,9 @@ func (client *WebSocketClient) OnOpen(conn *gws.Conn) {
// OnClose handles WebSocket connection closure. // OnClose handles WebSocket connection closure.
// It logs the closure reason and notifies the connection manager. // It logs the closure reason and notifies the connection manager.
func (client *WebSocketClient) OnClose(conn *gws.Conn, err error) { func (client *WebSocketClient) OnClose(conn *gws.Conn, err error) {
if err != nil {
slog.Warn("Connection closed", "err", strings.TrimPrefix(err.Error(), "gws: ")) slog.Warn("Connection closed", "err", strings.TrimPrefix(err.Error(), "gws: "))
}
client.agent.connectionManager.eventChan <- WebSocketDisconnect client.agent.connectionManager.eventChan <- WebSocketDisconnect
} }
@@ -156,11 +158,15 @@ func (client *WebSocketClient) OnMessage(conn *gws.Conn, message *gws.Message) {
return return
} }
if err := cbor.NewDecoder(message.Data).Decode(client.hubRequest); err != nil { var HubRequest common.HubRequest[cbor.RawMessage]
err := cbor.Unmarshal(message.Data.Bytes(), &HubRequest)
if err != nil {
slog.Error("Error parsing message", "err", err) slog.Error("Error parsing message", "err", err)
return return
} }
if err := client.handleHubRequest(client.hubRequest); err != nil {
if err := client.handleHubRequest(&HubRequest, HubRequest.Id); err != nil {
slog.Error("Error handling message", "err", err) slog.Error("Error handling message", "err", err)
} }
} }
@@ -173,7 +179,7 @@ func (client *WebSocketClient) OnPing(conn *gws.Conn, message []byte) {
} }
// handleAuthChallenge verifies the authenticity of the hub and returns the system's fingerprint. // handleAuthChallenge verifies the authenticity of the hub and returns the system's fingerprint.
func (client *WebSocketClient) handleAuthChallenge(msg *common.HubRequest[cbor.RawMessage]) (err error) { func (client *WebSocketClient) handleAuthChallenge(msg *common.HubRequest[cbor.RawMessage], requestID *uint32) (err error) {
var authRequest common.FingerprintRequest var authRequest common.FingerprintRequest
if err := cbor.Unmarshal(msg.Data, &authRequest); err != nil { if err := cbor.Unmarshal(msg.Data, &authRequest); err != nil {
return err return err
@@ -191,12 +197,13 @@ func (client *WebSocketClient) handleAuthChallenge(msg *common.HubRequest[cbor.R
} }
if authRequest.NeedSysInfo { if authRequest.NeedSysInfo {
response.Hostname = client.agent.systemInfo.Hostname response.Name, _ = GetEnv("SYSTEM_NAME")
response.Hostname = client.agent.systemDetails.Hostname
serverAddr := client.agent.connectionManager.serverOptions.Addr serverAddr := client.agent.connectionManager.serverOptions.Addr
_, response.Port, _ = net.SplitHostPort(serverAddr) _, response.Port, _ = net.SplitHostPort(serverAddr)
} }
return client.sendMessage(response) return client.sendResponse(response, requestID)
} }
// verifySignature verifies the signature of the token using the public keys. // verifySignature verifies the signature of the token using the public keys.
@@ -221,25 +228,17 @@ func (client *WebSocketClient) Close() {
} }
} }
// handleHubRequest routes the request to the appropriate handler. // handleHubRequest routes the request to the appropriate handler using the handler registry.
// It ensures the hub is verified before processing most requests. func (client *WebSocketClient) handleHubRequest(msg *common.HubRequest[cbor.RawMessage], requestID *uint32) error {
func (client *WebSocketClient) handleHubRequest(msg *common.HubRequest[cbor.RawMessage]) error { ctx := &HandlerContext{
if !client.hubVerified && msg.Action != common.CheckFingerprint { Client: client,
return errors.New("hub not verified") Agent: client.agent,
Request: msg,
RequestID: requestID,
HubVerified: client.hubVerified,
SendResponse: client.sendResponse,
} }
switch msg.Action { return client.agent.handlerRegistry.Handle(ctx)
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. // sendMessage encodes the given data to CBOR and sends it as a binary message over the WebSocket connection to the hub.
@@ -248,7 +247,25 @@ func (client *WebSocketClient) sendMessage(data any) error {
if err != nil { if err != nil {
return err return err
} }
return client.Conn.WriteMessage(gws.OpcodeBinary, bytes) err = client.Conn.WriteMessage(gws.OpcodeBinary, bytes)
if err != nil {
// If writing fails (e.g., broken pipe due to network issues),
// close the connection to trigger reconnection logic (#1263)
client.Close()
}
return err
}
// sendResponse sends a response with optional request ID.
// For ID-based requests, we must populate legacy typed fields for backward
// compatibility with older hubs (<= 0.17) that don't read the generic Data field.
func (client *WebSocketClient) sendResponse(data any, requestID *uint32) error {
if requestID != nil {
response := newAgentResponse(data, requestID)
return client.sendMessage(response)
}
// Legacy format - send data directly
return client.sendMessage(data)
} }
// getUserAgent returns one of two User-Agent strings based on current time. // getUserAgent returns one of two User-Agent strings based on current time.

View File

@@ -301,7 +301,7 @@ func TestWebSocketClient_HandleHubRequest(t *testing.T) {
Data: cbor.RawMessage{}, Data: cbor.RawMessage{},
} }
err := client.handleHubRequest(hubRequest) err := client.handleHubRequest(hubRequest, nil)
if tc.expectError { if tc.expectError {
assert.Error(t, err) assert.Error(t, err)

134
agent/cpu.go Normal file
View File

@@ -0,0 +1,134 @@
package agent
import (
"math"
"runtime"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/shirou/gopsutil/v4/cpu"
)
var lastCpuTimes = make(map[uint16]cpu.TimesStat)
var lastPerCoreCpuTimes = make(map[uint16][]cpu.TimesStat)
// init initializes the CPU monitoring by storing the initial CPU times
// for the default 60-second cache interval.
func init() {
if times, err := cpu.Times(false); err == nil {
lastCpuTimes[60000] = times[0]
}
if perCoreTimes, err := cpu.Times(true); err == nil {
lastPerCoreCpuTimes[60000] = perCoreTimes
}
}
// CpuMetrics contains detailed CPU usage breakdown
type CpuMetrics struct {
Total float64
User float64
System float64
Iowait float64
Steal float64
Idle float64
}
// getCpuMetrics calculates detailed CPU usage metrics using cached previous measurements.
// It returns percentages for total, user, system, iowait, and steal time.
func getCpuMetrics(cacheTimeMs uint16) (CpuMetrics, error) {
times, err := cpu.Times(false)
if err != nil || len(times) == 0 {
return CpuMetrics{}, err
}
// if cacheTimeMs is not in lastCpuTimes, use 60000 as fallback lastCpuTime
if _, ok := lastCpuTimes[cacheTimeMs]; !ok {
lastCpuTimes[cacheTimeMs] = lastCpuTimes[60000]
}
t1 := lastCpuTimes[cacheTimeMs]
t2 := times[0]
t1All, _ := getAllBusy(t1)
t2All, _ := getAllBusy(t2)
totalDelta := t2All - t1All
if totalDelta <= 0 {
return CpuMetrics{}, nil
}
metrics := CpuMetrics{
Total: calculateBusy(t1, t2),
User: clampPercent((t2.User - t1.User) / totalDelta * 100),
System: clampPercent((t2.System - t1.System) / totalDelta * 100),
Iowait: clampPercent((t2.Iowait - t1.Iowait) / totalDelta * 100),
Steal: clampPercent((t2.Steal - t1.Steal) / totalDelta * 100),
Idle: clampPercent((t2.Idle - t1.Idle) / totalDelta * 100),
}
lastCpuTimes[cacheTimeMs] = times[0]
return metrics, nil
}
// clampPercent ensures the percentage is between 0 and 100
func clampPercent(value float64) float64 {
return math.Min(100, math.Max(0, value))
}
// getPerCoreCpuUsage calculates per-core CPU busy usage as integer percentages (0-100).
// It uses cached previous measurements for the provided cache interval.
func getPerCoreCpuUsage(cacheTimeMs uint16) (system.Uint8Slice, error) {
perCoreTimes, err := cpu.Times(true)
if err != nil || len(perCoreTimes) == 0 {
return nil, err
}
// Initialize cache if needed
if _, ok := lastPerCoreCpuTimes[cacheTimeMs]; !ok {
lastPerCoreCpuTimes[cacheTimeMs] = lastPerCoreCpuTimes[60000]
}
lastTimes := lastPerCoreCpuTimes[cacheTimeMs]
// Limit to the number of cores available in both samples
length := len(perCoreTimes)
if len(lastTimes) < length {
length = len(lastTimes)
}
usage := make([]uint8, length)
for i := 0; i < length; i++ {
t1 := lastTimes[i]
t2 := perCoreTimes[i]
usage[i] = uint8(math.Round(calculateBusy(t1, t2)))
}
lastPerCoreCpuTimes[cacheTimeMs] = perCoreTimes
return usage, nil
}
// calculateBusy calculates the CPU busy percentage between two time points.
// It computes the ratio of busy time to total time elapsed between t1 and t2,
// returning a percentage clamped between 0 and 100.
func calculateBusy(t1, t2 cpu.TimesStat) float64 {
t1All, t1Busy := getAllBusy(t1)
t2All, t2Busy := getAllBusy(t2)
if t2All <= t1All || t2Busy <= t1Busy {
return 0
}
return clampPercent((t2Busy - t1Busy) / (t2All - t1All) * 100)
}
// getAllBusy calculates the total CPU time and busy CPU time from CPU times statistics.
// On Linux, it excludes guest and guest_nice time from the total to match kernel behavior.
// Returns total CPU time and busy CPU time (total minus idle and I/O wait time).
func getAllBusy(t cpu.TimesStat) (float64, float64) {
tot := t.Total()
if runtime.GOOS == "linux" {
tot -= t.Guest // Linux 2.6.24+
tot -= t.GuestNice // Linux 3.2.0+
}
busy := tot - t.Idle - t.Iowait
return tot, busy
}

View File

@@ -37,6 +37,16 @@ func (t *DeltaTracker[K, V]) Set(id K, value V) {
t.current[id] = value t.current[id] = value
} }
// Snapshot returns a copy of the current map.
// func (t *DeltaTracker[K, V]) Snapshot() map[K]V {
// t.RLock()
// defer t.RUnlock()
// copyMap := make(map[K]V, len(t.current))
// maps.Copy(copyMap, t.current)
// return copyMap
// }
// Deltas returns a map of all calculated deltas for the current interval. // Deltas returns a map of all calculated deltas for the current interval.
func (t *DeltaTracker[K, V]) Deltas() map[K]V { func (t *DeltaTracker[K, V]) Deltas() map[K]V {
t.RLock() t.RLock()
@@ -53,6 +63,15 @@ func (t *DeltaTracker[K, V]) Deltas() map[K]V {
return deltas return deltas
} }
// Previous returns the previously recorded value for the given key, if it exists.
func (t *DeltaTracker[K, V]) Previous(id K) (V, bool) {
t.RLock()
defer t.RUnlock()
value, ok := t.previous[id]
return value, ok
}
// Delta returns the delta for a single key. // Delta returns the delta for a single key.
// Returns 0 if the key doesn't exist or has no previous value. // Returns 0 if the key doesn't exist or has no previous value.
func (t *DeltaTracker[K, V]) Delta(id K) V { func (t *DeltaTracker[K, V]) Delta(id K) V {

View File

@@ -13,11 +13,25 @@ import (
"github.com/shirou/gopsutil/v4/disk" "github.com/shirou/gopsutil/v4/disk"
) )
// parseFilesystemEntry parses a filesystem entry in the format "device__customname"
// Returns the device/filesystem part and the custom name part
func parseFilesystemEntry(entry string) (device, customName string) {
entry = strings.TrimSpace(entry)
if parts := strings.SplitN(entry, "__", 2); len(parts) == 2 {
device = strings.TrimSpace(parts[0])
customName = strings.TrimSpace(parts[1])
} else {
device = entry
}
return device, customName
}
// Sets up the filesystems to monitor for disk usage and I/O. // Sets up the filesystems to monitor for disk usage and I/O.
func (a *Agent) initializeDiskInfo() { func (a *Agent) initializeDiskInfo() {
filesystem, _ := GetEnv("FILESYSTEM") filesystem, _ := GetEnv("FILESYSTEM")
efPath := "/extra-filesystems" efPath := "/extra-filesystems"
hasRoot := false hasRoot := false
isWindows := runtime.GOOS == "windows"
partitions, err := disk.Partitions(false) partitions, err := disk.Partitions(false)
if err != nil { if err != nil {
@@ -25,6 +39,13 @@ func (a *Agent) initializeDiskInfo() {
} }
slog.Debug("Disk", "partitions", partitions) slog.Debug("Disk", "partitions", partitions)
// trim trailing backslash for Windows devices (#1361)
if isWindows {
for i, p := range partitions {
partitions[i].Device = strings.TrimSuffix(p.Device, "\\")
}
}
// ioContext := context.WithValue(a.sensorsContext, // ioContext := context.WithValue(a.sensorsContext,
// common.EnvKey, common.EnvMap{common.HostProcEnvKey: "/tmp/testproc"}, // common.EnvKey, common.EnvMap{common.HostProcEnvKey: "/tmp/testproc"},
// ) // )
@@ -37,9 +58,9 @@ func (a *Agent) initializeDiskInfo() {
slog.Debug("Disk I/O", "diskstats", diskIoCounters) slog.Debug("Disk I/O", "diskstats", diskIoCounters)
// Helper function to add a filesystem to fsStats if it doesn't exist // Helper function to add a filesystem to fsStats if it doesn't exist
addFsStat := func(device, mountpoint string, root bool) { addFsStat := func(device, mountpoint string, root bool, customName ...string) {
var key string var key string
if runtime.GOOS == "windows" { if isWindows {
key = device key = device
} else { } else {
key = filepath.Base(device) key = filepath.Base(device)
@@ -66,10 +87,17 @@ func (a *Agent) initializeDiskInfo() {
} }
} }
} }
a.fsStats[key] = &system.FsStats{Root: root, Mountpoint: mountpoint} fsStats := &system.FsStats{Root: root, Mountpoint: mountpoint}
if len(customName) > 0 && customName[0] != "" {
fsStats.Name = customName[0]
}
a.fsStats[key] = fsStats
} }
} }
// Get the appropriate root mount point for this system
rootMountPoint := a.getRootMountPoint()
// Use FILESYSTEM env var to find root filesystem // Use FILESYSTEM env var to find root filesystem
if filesystem != "" { if filesystem != "" {
for _, p := range partitions { for _, p := range partitions {
@@ -86,11 +114,14 @@ func (a *Agent) initializeDiskInfo() {
// Add EXTRA_FILESYSTEMS env var values to fsStats // Add EXTRA_FILESYSTEMS env var values to fsStats
if extraFilesystems, exists := GetEnv("EXTRA_FILESYSTEMS"); exists { if extraFilesystems, exists := GetEnv("EXTRA_FILESYSTEMS"); exists {
for _, fs := range strings.Split(extraFilesystems, ",") { for _, fsEntry := range strings.Split(extraFilesystems, ",") {
// Parse custom name from format: device__customname
fs, customName := parseFilesystemEntry(fsEntry)
found := false found := false
for _, p := range partitions { for _, p := range partitions {
if strings.HasSuffix(p.Device, fs) || p.Mountpoint == fs { if strings.HasSuffix(p.Device, fs) || p.Mountpoint == fs {
addFsStat(p.Device, p.Mountpoint, false) addFsStat(p.Device, p.Mountpoint, false, customName)
found = true found = true
break break
} }
@@ -98,7 +129,7 @@ func (a *Agent) initializeDiskInfo() {
// if not in partitions, test if we can get disk usage // if not in partitions, test if we can get disk usage
if !found { if !found {
if _, err := disk.Usage(fs); err == nil { if _, err := disk.Usage(fs); err == nil {
addFsStat(filepath.Base(fs), fs, false) addFsStat(filepath.Base(fs), fs, false, customName)
} else { } else {
slog.Error("Invalid filesystem", "name", fs, "err", err) slog.Error("Invalid filesystem", "name", fs, "err", err)
} }
@@ -110,7 +141,7 @@ func (a *Agent) initializeDiskInfo() {
for _, p := range partitions { for _, p := range partitions {
// fmt.Println(p.Device, p.Mountpoint) // fmt.Println(p.Device, p.Mountpoint)
// Binary root fallback or docker root fallback // Binary root fallback or docker root fallback
if !hasRoot && (p.Mountpoint == "/" || (p.Mountpoint == "/etc/hosts" && strings.HasPrefix(p.Device, "/dev"))) { if !hasRoot && (p.Mountpoint == rootMountPoint || (p.Mountpoint == "/etc/hosts" && strings.HasPrefix(p.Device, "/dev"))) {
fs, match := findIoDevice(filepath.Base(p.Device), diskIoCounters, a.fsStats) fs, match := findIoDevice(filepath.Base(p.Device), diskIoCounters, a.fsStats)
if match { if match {
addFsStat(fs, p.Mountpoint, true) addFsStat(fs, p.Mountpoint, true)
@@ -120,7 +151,8 @@ func (a *Agent) initializeDiskInfo() {
// Check if device is in /extra-filesystems // Check if device is in /extra-filesystems
if strings.HasPrefix(p.Mountpoint, efPath) { if strings.HasPrefix(p.Mountpoint, efPath) {
addFsStat(p.Device, p.Mountpoint, false) device, customName := parseFilesystemEntry(p.Mountpoint)
addFsStat(device, p.Mountpoint, false, customName)
} }
} }
@@ -135,7 +167,8 @@ func (a *Agent) initializeDiskInfo() {
mountpoint := filepath.Join(efPath, folder.Name()) mountpoint := filepath.Join(efPath, folder.Name())
slog.Debug("/extra-filesystems", "mountpoint", mountpoint) slog.Debug("/extra-filesystems", "mountpoint", mountpoint)
if !existingMountpoints[mountpoint] { if !existingMountpoints[mountpoint] {
addFsStat(folder.Name(), mountpoint, false) device, customName := parseFilesystemEntry(folder.Name())
addFsStat(device, mountpoint, false, customName)
} }
} }
} }
@@ -144,8 +177,8 @@ func (a *Agent) initializeDiskInfo() {
// If no root filesystem set, use fallback // If no root filesystem set, use fallback
if !hasRoot { if !hasRoot {
rootDevice, _ := findIoDevice(filepath.Base(filesystem), diskIoCounters, a.fsStats) rootDevice, _ := findIoDevice(filepath.Base(filesystem), diskIoCounters, a.fsStats)
slog.Info("Root disk", "mountpoint", "/", "io", rootDevice) slog.Info("Root disk", "mountpoint", rootMountPoint, "io", rootDevice)
a.fsStats[rootDevice] = &system.FsStats{Root: true, Mountpoint: "/"} a.fsStats[rootDevice] = &system.FsStats{Root: true, Mountpoint: rootMountPoint}
} }
a.initializeDiskIoStats(diskIoCounters) a.initializeDiskIoStats(diskIoCounters)
@@ -189,3 +222,141 @@ func (a *Agent) initializeDiskIoStats(diskIoCounters map[string]disk.IOCountersS
a.fsNames = append(a.fsNames, device) a.fsNames = append(a.fsNames, device)
} }
} }
// Updates disk usage statistics for all monitored filesystems
func (a *Agent) updateDiskUsage(systemStats *system.Stats) {
// Check if we should skip extra filesystem collection to avoid waking sleeping disks.
// Root filesystem is always updated since it can't be sleeping while the agent runs.
// Always collect on first call (lastDiskUsageUpdate is zero) or if caching is disabled.
cacheExtraFs := a.diskUsageCacheDuration > 0 &&
!a.lastDiskUsageUpdate.IsZero() &&
time.Since(a.lastDiskUsageUpdate) < a.diskUsageCacheDuration
// disk usage
for _, stats := range a.fsStats {
// Skip non-root filesystems if caching is active
if cacheExtraFs && !stats.Root {
continue
}
if d, err := disk.Usage(stats.Mountpoint); err == nil {
stats.DiskTotal = bytesToGigabytes(d.Total)
stats.DiskUsed = bytesToGigabytes(d.Used)
if stats.Root {
systemStats.DiskTotal = bytesToGigabytes(d.Total)
systemStats.DiskUsed = bytesToGigabytes(d.Used)
systemStats.DiskPct = twoDecimals(d.UsedPercent)
}
} else {
// reset stats if error (likely unmounted)
slog.Error("Error getting disk stats", "name", stats.Mountpoint, "err", err)
stats.DiskTotal = 0
stats.DiskUsed = 0
stats.TotalRead = 0
stats.TotalWrite = 0
}
}
// Update the last disk usage update time when we've collected extra filesystems
if !cacheExtraFs {
a.lastDiskUsageUpdate = time.Now()
}
}
// Updates disk I/O statistics for all monitored filesystems
func (a *Agent) updateDiskIo(cacheTimeMs uint16, systemStats *system.Stats) {
// disk i/o (cache-aware per interval)
if ioCounters, err := disk.IOCounters(a.fsNames...); err == nil {
// Ensure map for this interval exists
if _, ok := a.diskPrev[cacheTimeMs]; !ok {
a.diskPrev[cacheTimeMs] = make(map[string]prevDisk)
}
now := time.Now()
for name, d := range ioCounters {
stats := a.fsStats[d.Name]
if stats == nil {
// skip devices not tracked
continue
}
// Previous snapshot for this interval and device
prev, hasPrev := a.diskPrev[cacheTimeMs][name]
if !hasPrev {
// Seed from agent-level fsStats if present, else seed from current
prev = prevDisk{readBytes: stats.TotalRead, writeBytes: stats.TotalWrite, at: stats.Time}
if prev.at.IsZero() {
prev = prevDisk{readBytes: d.ReadBytes, writeBytes: d.WriteBytes, at: now}
}
}
msElapsed := uint64(now.Sub(prev.at).Milliseconds())
if msElapsed < 100 {
// Avoid division by zero or clock issues; update snapshot and continue
a.diskPrev[cacheTimeMs][name] = prevDisk{readBytes: d.ReadBytes, writeBytes: d.WriteBytes, at: now}
continue
}
diskIORead := (d.ReadBytes - prev.readBytes) * 1000 / msElapsed
diskIOWrite := (d.WriteBytes - prev.writeBytes) * 1000 / msElapsed
readMbPerSecond := bytesToMegabytes(float64(diskIORead))
writeMbPerSecond := bytesToMegabytes(float64(diskIOWrite))
// validate values
if readMbPerSecond > 50_000 || writeMbPerSecond > 50_000 {
slog.Warn("Invalid disk I/O. Resetting.", "name", d.Name, "read", readMbPerSecond, "write", writeMbPerSecond)
// Reset interval snapshot and seed from current
a.diskPrev[cacheTimeMs][name] = prevDisk{readBytes: d.ReadBytes, writeBytes: d.WriteBytes, at: now}
// also refresh agent baseline to avoid future negatives
a.initializeDiskIoStats(ioCounters)
continue
}
// Update per-interval snapshot
a.diskPrev[cacheTimeMs][name] = prevDisk{readBytes: d.ReadBytes, writeBytes: d.WriteBytes, at: now}
// Update global fsStats baseline for cross-interval correctness
stats.Time = now
stats.TotalRead = d.ReadBytes
stats.TotalWrite = d.WriteBytes
stats.DiskReadPs = readMbPerSecond
stats.DiskWritePs = writeMbPerSecond
stats.DiskReadBytes = diskIORead
stats.DiskWriteBytes = diskIOWrite
if stats.Root {
systemStats.DiskReadPs = stats.DiskReadPs
systemStats.DiskWritePs = stats.DiskWritePs
systemStats.DiskIO[0] = diskIORead
systemStats.DiskIO[1] = diskIOWrite
}
}
}
}
// getRootMountPoint returns the appropriate root mount point for the system
// For immutable systems like Fedora Silverblue, it returns /sysroot instead of /
func (a *Agent) getRootMountPoint() string {
// 1. Check if /etc/os-release contains indicators of an immutable system
if osReleaseContent, err := os.ReadFile("/etc/os-release"); err == nil {
content := string(osReleaseContent)
if strings.Contains(content, "fedora") && strings.Contains(content, "silverblue") ||
strings.Contains(content, "coreos") ||
strings.Contains(content, "flatcar") ||
strings.Contains(content, "rhel-atomic") ||
strings.Contains(content, "centos-atomic") {
// Verify that /sysroot exists before returning it
if _, err := os.Stat("/sysroot"); err == nil {
return "/sysroot"
}
}
}
// 2. Check if /run/ostree is present (ostree-based systems like Silverblue)
if _, err := os.Stat("/run/ostree"); err == nil {
// Verify that /sysroot exists before returning it
if _, err := os.Stat("/sysroot"); err == nil {
return "/sysroot"
}
}
return "/"
}

319
agent/disk_test.go Normal file
View File

@@ -0,0 +1,319 @@
//go:build testing
// +build testing
package agent
import (
"os"
"strings"
"testing"
"time"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/shirou/gopsutil/v4/disk"
"github.com/stretchr/testify/assert"
)
func TestParseFilesystemEntry(t *testing.T) {
tests := []struct {
name string
input string
expectedFs string
expectedName string
}{
{
name: "simple device name",
input: "sda1",
expectedFs: "sda1",
expectedName: "",
},
{
name: "device with custom name",
input: "sda1__my-storage",
expectedFs: "sda1",
expectedName: "my-storage",
},
{
name: "full device path with custom name",
input: "/dev/sdb1__backup-drive",
expectedFs: "/dev/sdb1",
expectedName: "backup-drive",
},
{
name: "NVMe device with custom name",
input: "nvme0n1p2__fast-ssd",
expectedFs: "nvme0n1p2",
expectedName: "fast-ssd",
},
{
name: "whitespace trimmed",
input: " sda2__trimmed-name ",
expectedFs: "sda2",
expectedName: "trimmed-name",
},
{
name: "empty custom name",
input: "sda3__",
expectedFs: "sda3",
expectedName: "",
},
{
name: "empty device name",
input: "__just-custom",
expectedFs: "",
expectedName: "just-custom",
},
{
name: "multiple underscores in custom name",
input: "sda1__my_custom_drive",
expectedFs: "sda1",
expectedName: "my_custom_drive",
},
{
name: "custom name with spaces",
input: "sda1__My Storage Drive",
expectedFs: "sda1",
expectedName: "My Storage Drive",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fsEntry := strings.TrimSpace(tt.input)
var fs, customName string
if parts := strings.SplitN(fsEntry, "__", 2); len(parts) == 2 {
fs = strings.TrimSpace(parts[0])
customName = strings.TrimSpace(parts[1])
} else {
fs = fsEntry
}
assert.Equal(t, tt.expectedFs, fs)
assert.Equal(t, tt.expectedName, customName)
})
}
}
func TestInitializeDiskInfoWithCustomNames(t *testing.T) {
// Set up environment variables
oldEnv := os.Getenv("EXTRA_FILESYSTEMS")
defer func() {
if oldEnv != "" {
os.Setenv("EXTRA_FILESYSTEMS", oldEnv)
} else {
os.Unsetenv("EXTRA_FILESYSTEMS")
}
}()
// Test with custom names
os.Setenv("EXTRA_FILESYSTEMS", "sda1__my-storage,/dev/sdb1__backup-drive,nvme0n1p2")
// Mock disk partitions (we'll just test the parsing logic)
// Since the actual disk operations are system-dependent, we'll focus on the parsing
testCases := []struct {
envValue string
expectedFs []string
expectedNames map[string]string
}{
{
envValue: "sda1__my-storage,sdb1__backup-drive",
expectedFs: []string{"sda1", "sdb1"},
expectedNames: map[string]string{
"sda1": "my-storage",
"sdb1": "backup-drive",
},
},
{
envValue: "sda1,nvme0n1p2__fast-ssd",
expectedFs: []string{"sda1", "nvme0n1p2"},
expectedNames: map[string]string{
"nvme0n1p2": "fast-ssd",
},
},
}
for _, tc := range testCases {
t.Run("env_"+tc.envValue, func(t *testing.T) {
os.Setenv("EXTRA_FILESYSTEMS", tc.envValue)
// Create mock partitions that would match our test cases
partitions := []disk.PartitionStat{}
for _, fs := range tc.expectedFs {
if strings.HasPrefix(fs, "/dev/") {
partitions = append(partitions, disk.PartitionStat{
Device: fs,
Mountpoint: fs,
})
} else {
partitions = append(partitions, disk.PartitionStat{
Device: "/dev/" + fs,
Mountpoint: "/" + fs,
})
}
}
// Test the parsing logic by calling the relevant part
// We'll create a simplified version to test just the parsing
extraFilesystems := tc.envValue
for _, fsEntry := range strings.Split(extraFilesystems, ",") {
// Parse the entry
fsEntry = strings.TrimSpace(fsEntry)
var fs, customName string
if parts := strings.SplitN(fsEntry, "__", 2); len(parts) == 2 {
fs = strings.TrimSpace(parts[0])
customName = strings.TrimSpace(parts[1])
} else {
fs = fsEntry
}
// Verify the device is in our expected list
assert.Contains(t, tc.expectedFs, fs, "parsed device should be in expected list")
// Check if custom name should exist
if expectedName, exists := tc.expectedNames[fs]; exists {
assert.Equal(t, expectedName, customName, "custom name should match expected")
} else {
assert.Empty(t, customName, "custom name should be empty when not expected")
}
}
})
}
}
func TestFsStatsWithCustomNames(t *testing.T) {
// Test that FsStats properly stores custom names
fsStats := &system.FsStats{
Mountpoint: "/mnt/storage",
Name: "my-custom-storage",
DiskTotal: 100.0,
DiskUsed: 50.0,
}
assert.Equal(t, "my-custom-storage", fsStats.Name)
assert.Equal(t, "/mnt/storage", fsStats.Mountpoint)
assert.Equal(t, 100.0, fsStats.DiskTotal)
assert.Equal(t, 50.0, fsStats.DiskUsed)
}
func TestExtraFsKeyGeneration(t *testing.T) {
// Test the logic for generating ExtraFs keys with custom names
testCases := []struct {
name string
deviceName string
customName string
expectedKey string
}{
{
name: "with custom name",
deviceName: "sda1",
customName: "my-storage",
expectedKey: "my-storage",
},
{
name: "without custom name",
deviceName: "sda1",
customName: "",
expectedKey: "sda1",
},
{
name: "empty custom name falls back to device",
deviceName: "nvme0n1p2",
customName: "",
expectedKey: "nvme0n1p2",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Simulate the key generation logic from agent.go
key := tc.deviceName
if tc.customName != "" {
key = tc.customName
}
assert.Equal(t, tc.expectedKey, key)
})
}
}
func TestDiskUsageCaching(t *testing.T) {
t.Run("caching disabled updates all filesystems", func(t *testing.T) {
agent := &Agent{
fsStats: map[string]*system.FsStats{
"sda": {Root: true, Mountpoint: "/"},
"sdb": {Root: false, Mountpoint: "/mnt/storage"},
},
diskUsageCacheDuration: 0, // caching disabled
}
var stats system.Stats
agent.updateDiskUsage(&stats)
// Both should be updated (non-zero values from disk.Usage)
// Root stats should be populated in systemStats
assert.True(t, agent.lastDiskUsageUpdate.IsZero() || !agent.lastDiskUsageUpdate.IsZero(),
"lastDiskUsageUpdate should be set when caching is disabled")
})
t.Run("caching enabled always updates root filesystem", func(t *testing.T) {
agent := &Agent{
fsStats: map[string]*system.FsStats{
"sda": {Root: true, Mountpoint: "/", DiskTotal: 100, DiskUsed: 50},
"sdb": {Root: false, Mountpoint: "/mnt/storage", DiskTotal: 200, DiskUsed: 100},
},
diskUsageCacheDuration: 1 * time.Hour,
lastDiskUsageUpdate: time.Now(), // cache is fresh
}
// Store original extra fs values
originalExtraTotal := agent.fsStats["sdb"].DiskTotal
originalExtraUsed := agent.fsStats["sdb"].DiskUsed
var stats system.Stats
agent.updateDiskUsage(&stats)
// Root should be updated (systemStats populated from disk.Usage call)
// We can't easily check if disk.Usage was called, but we verify the flow works
// Extra filesystem should retain cached values (not reset)
assert.Equal(t, originalExtraTotal, agent.fsStats["sdb"].DiskTotal,
"extra filesystem DiskTotal should be unchanged when cached")
assert.Equal(t, originalExtraUsed, agent.fsStats["sdb"].DiskUsed,
"extra filesystem DiskUsed should be unchanged when cached")
})
t.Run("first call always updates all filesystems", func(t *testing.T) {
agent := &Agent{
fsStats: map[string]*system.FsStats{
"sda": {Root: true, Mountpoint: "/"},
"sdb": {Root: false, Mountpoint: "/mnt/storage"},
},
diskUsageCacheDuration: 1 * time.Hour,
// lastDiskUsageUpdate is zero (first call)
}
var stats system.Stats
agent.updateDiskUsage(&stats)
// After first call, lastDiskUsageUpdate should be set
assert.False(t, agent.lastDiskUsageUpdate.IsZero(),
"lastDiskUsageUpdate should be set after first call")
})
t.Run("expired cache updates extra filesystems", func(t *testing.T) {
agent := &Agent{
fsStats: map[string]*system.FsStats{
"sda": {Root: true, Mountpoint: "/"},
"sdb": {Root: false, Mountpoint: "/mnt/storage"},
},
diskUsageCacheDuration: 1 * time.Millisecond,
lastDiskUsageUpdate: time.Now().Add(-1 * time.Second), // cache expired
}
var stats system.Stats
agent.updateDiskUsage(&stats)
// lastDiskUsageUpdate should be refreshed since cache expired
assert.True(t, time.Since(agent.lastDiskUsageUpdate) < time.Second,
"lastDiskUsageUpdate should be refreshed when cache expires")
})
}

View File

@@ -3,28 +3,55 @@ package agent
import ( import (
"bytes" "bytes"
"context" "context"
"encoding/binary"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io"
"log/slog" "log/slog"
"net" "net"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
"path"
"regexp"
"strings" "strings"
"sync" "sync"
"time" "time"
"github.com/henrygd/beszel/agent/deltatracker"
"github.com/henrygd/beszel/internal/entities/container" "github.com/henrygd/beszel/internal/entities/container"
"github.com/blang/semver" "github.com/blang/semver"
) )
// ansiEscapePattern matches ANSI escape sequences (colors, cursor movement, etc.)
// This includes CSI sequences like \x1b[...m and simple escapes like \x1b[K
var ansiEscapePattern = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b[@-Z\\-_]`)
const (
// Docker API timeout in milliseconds
dockerTimeoutMs = 2100
// Maximum realistic network speed (5 GB/s) to detect bad deltas
maxNetworkSpeedBps uint64 = 5e9
// Maximum conceivable memory usage of a container (100TB) to detect bad memory stats
maxMemoryUsage uint64 = 100 * 1024 * 1024 * 1024 * 1024
// Number of log lines to request when fetching container logs
dockerLogsTail = 200
// Maximum size of a single log frame (1MB) to prevent memory exhaustion
// A single log line larger than 1MB is likely an error or misconfiguration
maxLogFrameSize = 1024 * 1024
// Maximum total log content size (5MB) to prevent memory exhaustion
// This provides a reasonable limit for network transfer and browser rendering
maxTotalLogSize = 5 * 1024 * 1024
)
type dockerManager struct { type dockerManager struct {
client *http.Client // Client to query Docker API client *http.Client // Client to query Docker API
wg sync.WaitGroup // WaitGroup to wait for all goroutines to finish wg sync.WaitGroup // WaitGroup to wait for all goroutines to finish
sem chan struct{} // Semaphore to limit concurrent container requests sem chan struct{} // Semaphore to limit concurrent container requests
containerStatsMutex sync.RWMutex // Mutex to prevent concurrent access to containerStatsMap containerStatsMutex sync.RWMutex // Mutex to prevent concurrent access to containerStatsMap
apiContainerList []*container.ApiInfo // List of containers from Docker API (no pointer) apiContainerList []*container.ApiInfo // List of containers from Docker API
containerStatsMap map[string]*container.Stats // Keeps track of container stats containerStatsMap map[string]*container.Stats // Keeps track of container stats
validIds map[string]struct{} // Map of valid container ids, used to prune invalid containers from containerStatsMap validIds map[string]struct{} // Map of valid container ids, used to prune invalid containers from containerStatsMap
goodDockerVersion bool // Whether docker version is at least 25.0.0 (one-shot works correctly) goodDockerVersion bool // Whether docker version is at least 25.0.0 (one-shot works correctly)
@@ -32,6 +59,19 @@ type dockerManager struct {
buf *bytes.Buffer // Buffer to store and read response bodies buf *bytes.Buffer // Buffer to store and read response bodies
decoder *json.Decoder // Reusable JSON decoder that reads from buf decoder *json.Decoder // Reusable JSON decoder that reads from buf
apiStats *container.ApiStats // Reusable API stats object apiStats *container.ApiStats // Reusable API stats object
excludeContainers []string // Patterns to exclude containers by name
usingPodman bool // Whether the Docker Engine API is running on Podman
// Cache-time-aware tracking for CPU stats (similar to cpu.go)
// Maps cache time intervals to container-specific CPU usage tracking
lastCpuContainer map[uint16]map[string]uint64 // cacheTimeMs -> containerId -> last cpu container usage
lastCpuSystem map[uint16]map[string]uint64 // cacheTimeMs -> containerId -> last cpu system usage
lastCpuReadTime map[uint16]map[string]time.Time // cacheTimeMs -> containerId -> last read time (Windows)
// Network delta trackers - one per cache time to avoid interference
// cacheTimeMs -> DeltaTracker for network bytes sent/received
networkSentTrackers map[uint16]*deltatracker.DeltaTracker[string, uint64]
networkRecvTrackers map[uint16]*deltatracker.DeltaTracker[string, uint64]
} }
// userAgentRoundTripper is a custom http.RoundTripper that adds a User-Agent header to all requests // userAgentRoundTripper is a custom http.RoundTripper that adds a User-Agent header to all requests
@@ -62,8 +102,21 @@ func (d *dockerManager) dequeue() {
} }
} }
// Returns stats for all running containers // shouldExcludeContainer checks if a container name matches any exclusion pattern
func (dm *dockerManager) getDockerStats() ([]*container.Stats, error) { func (dm *dockerManager) shouldExcludeContainer(name string) bool {
if len(dm.excludeContainers) == 0 {
return false
}
for _, pattern := range dm.excludeContainers {
if match, _ := path.Match(pattern, name); match {
return true
}
}
return false
}
// Returns stats for all running containers with cache-time-aware delta tracking
func (dm *dockerManager) getDockerStats(cacheTimeMs uint16) ([]*container.Stats, error) {
resp, err := dm.client.Get("http://localhost/containers/json") resp, err := dm.client.Get("http://localhost/containers/json")
if err != nil { if err != nil {
return nil, err return nil, err
@@ -87,9 +140,15 @@ func (dm *dockerManager) getDockerStats() ([]*container.Stats, error) {
var failedContainers []*container.ApiInfo var failedContainers []*container.ApiInfo
for i := range dm.apiContainerList { for _, ctr := range dm.apiContainerList {
ctr := dm.apiContainerList[i]
ctr.IdShort = ctr.Id[:12] ctr.IdShort = ctr.Id[:12]
// Skip this container if it matches the exclusion pattern
if dm.shouldExcludeContainer(ctr.Names[0][1:]) {
slog.Debug("Excluding container", "name", ctr.Names[0][1:])
continue
}
dm.validIds[ctr.IdShort] = struct{}{} dm.validIds[ctr.IdShort] = struct{}{}
// check if container is less than 1 minute old (possible restart) // check if container is less than 1 minute old (possible restart)
// note: can't use Created field because it's not updated on restart // note: can't use Created field because it's not updated on restart
@@ -98,9 +157,9 @@ func (dm *dockerManager) getDockerStats() ([]*container.Stats, error) {
dm.deleteContainerStatsSync(ctr.IdShort) dm.deleteContainerStatsSync(ctr.IdShort)
} }
dm.queue() dm.queue()
go func() { go func(ctr *container.ApiInfo) {
defer dm.dequeue() defer dm.dequeue()
err := dm.updateContainerStats(ctr) err := dm.updateContainerStats(ctr, cacheTimeMs)
// if error, delete from map and add to failed list to retry // if error, delete from map and add to failed list to retry
if err != nil { if err != nil {
dm.containerStatsMutex.Lock() dm.containerStatsMutex.Lock()
@@ -108,7 +167,7 @@ func (dm *dockerManager) getDockerStats() ([]*container.Stats, error) {
failedContainers = append(failedContainers, ctr) failedContainers = append(failedContainers, ctr)
dm.containerStatsMutex.Unlock() dm.containerStatsMutex.Unlock()
} }
}() }(ctr)
} }
dm.wg.Wait() dm.wg.Wait()
@@ -119,13 +178,12 @@ func (dm *dockerManager) getDockerStats() ([]*container.Stats, error) {
for i := range failedContainers { for i := range failedContainers {
ctr := failedContainers[i] ctr := failedContainers[i]
dm.queue() dm.queue()
go func() { go func(ctr *container.ApiInfo) {
defer dm.dequeue() defer dm.dequeue()
err = dm.updateContainerStats(ctr) if err2 := dm.updateContainerStats(ctr, cacheTimeMs); err2 != nil {
if err != nil { slog.Error("Error getting container stats", "err", err2)
slog.Error("Error getting container stats", "err", err)
} }
}() }(ctr)
} }
dm.wg.Wait() dm.wg.Wait()
} }
@@ -140,18 +198,193 @@ func (dm *dockerManager) getDockerStats() ([]*container.Stats, error) {
} }
} }
// prepare network trackers for next interval for this cache time
dm.cycleNetworkDeltasForCacheTime(cacheTimeMs)
return stats, nil return stats, nil
} }
// Updates stats for individual container // initializeCpuTracking initializes CPU tracking maps for a specific cache time interval
func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo) error { func (dm *dockerManager) initializeCpuTracking(cacheTimeMs uint16) {
// Initialize cache time maps if they don't exist
if dm.lastCpuContainer[cacheTimeMs] == nil {
dm.lastCpuContainer[cacheTimeMs] = make(map[string]uint64)
}
if dm.lastCpuSystem[cacheTimeMs] == nil {
dm.lastCpuSystem[cacheTimeMs] = make(map[string]uint64)
}
// Ensure the outer map exists before indexing
if dm.lastCpuReadTime == nil {
dm.lastCpuReadTime = make(map[uint16]map[string]time.Time)
}
if dm.lastCpuReadTime[cacheTimeMs] == nil {
dm.lastCpuReadTime[cacheTimeMs] = make(map[string]time.Time)
}
}
// getCpuPreviousValues returns previous CPU values for a container and cache time interval
func (dm *dockerManager) getCpuPreviousValues(cacheTimeMs uint16, containerId string) (uint64, uint64) {
return dm.lastCpuContainer[cacheTimeMs][containerId], dm.lastCpuSystem[cacheTimeMs][containerId]
}
// setCpuCurrentValues stores current CPU values for a container and cache time interval
func (dm *dockerManager) setCpuCurrentValues(cacheTimeMs uint16, containerId string, cpuContainer, cpuSystem uint64) {
dm.lastCpuContainer[cacheTimeMs][containerId] = cpuContainer
dm.lastCpuSystem[cacheTimeMs][containerId] = cpuSystem
}
// calculateMemoryUsage calculates memory usage from Docker API stats
func calculateMemoryUsage(apiStats *container.ApiStats, isWindows bool) (uint64, error) {
if isWindows {
return apiStats.MemoryStats.PrivateWorkingSet, nil
}
memCache := apiStats.MemoryStats.Stats.InactiveFile
if memCache == 0 {
memCache = apiStats.MemoryStats.Stats.Cache
}
usedDelta := apiStats.MemoryStats.Usage - memCache
if usedDelta <= 0 || usedDelta > maxMemoryUsage {
return 0, fmt.Errorf("bad memory stats")
}
return usedDelta, nil
}
// getNetworkTracker returns the DeltaTracker for a specific cache time, creating it if needed
func (dm *dockerManager) getNetworkTracker(cacheTimeMs uint16, isSent bool) *deltatracker.DeltaTracker[string, uint64] {
var trackers map[uint16]*deltatracker.DeltaTracker[string, uint64]
if isSent {
trackers = dm.networkSentTrackers
} else {
trackers = dm.networkRecvTrackers
}
if trackers[cacheTimeMs] == nil {
trackers[cacheTimeMs] = deltatracker.NewDeltaTracker[string, uint64]()
}
return trackers[cacheTimeMs]
}
// cycleNetworkDeltasForCacheTime cycles the network delta trackers for a specific cache time
func (dm *dockerManager) cycleNetworkDeltasForCacheTime(cacheTimeMs uint16) {
if dm.networkSentTrackers[cacheTimeMs] != nil {
dm.networkSentTrackers[cacheTimeMs].Cycle()
}
if dm.networkRecvTrackers[cacheTimeMs] != nil {
dm.networkRecvTrackers[cacheTimeMs].Cycle()
}
}
// calculateNetworkStats calculates network sent/receive deltas using DeltaTracker
func (dm *dockerManager) calculateNetworkStats(ctr *container.ApiInfo, apiStats *container.ApiStats, stats *container.Stats, initialized bool, name string, cacheTimeMs uint16) (uint64, uint64) {
var total_sent, total_recv uint64
for _, v := range apiStats.Networks {
total_sent += v.TxBytes
total_recv += v.RxBytes
}
// Get the DeltaTracker for this specific cache time
sentTracker := dm.getNetworkTracker(cacheTimeMs, true)
recvTracker := dm.getNetworkTracker(cacheTimeMs, false)
// Set current values in the cache-time-specific DeltaTracker
sentTracker.Set(ctr.IdShort, total_sent)
recvTracker.Set(ctr.IdShort, total_recv)
// Get deltas (bytes since last measurement)
sent_delta_raw := sentTracker.Delta(ctr.IdShort)
recv_delta_raw := recvTracker.Delta(ctr.IdShort)
// Calculate bytes per second independently for Tx and Rx if we have previous data
var sent_delta, recv_delta uint64
if initialized {
millisecondsElapsed := uint64(time.Since(stats.PrevReadTime).Milliseconds())
if millisecondsElapsed > 0 {
if sent_delta_raw > 0 {
sent_delta = sent_delta_raw * 1000 / millisecondsElapsed
if sent_delta > maxNetworkSpeedBps {
slog.Warn("Bad network delta", "container", name)
sent_delta = 0
}
}
if recv_delta_raw > 0 {
recv_delta = recv_delta_raw * 1000 / millisecondsElapsed
if recv_delta > maxNetworkSpeedBps {
slog.Warn("Bad network delta", "container", name)
recv_delta = 0
}
}
}
}
return sent_delta, recv_delta
}
// validateCpuPercentage checks if CPU percentage is within valid range
func validateCpuPercentage(cpuPct float64, containerName string) error {
if cpuPct > 100 {
return fmt.Errorf("%s cpu pct greater than 100: %+v", containerName, cpuPct)
}
return nil
}
// updateContainerStatsValues updates the final stats values
func updateContainerStatsValues(stats *container.Stats, cpuPct float64, usedMemory uint64, sent_delta, recv_delta uint64, readTime time.Time) {
stats.Cpu = twoDecimals(cpuPct)
stats.Mem = bytesToMegabytes(float64(usedMemory))
stats.Bandwidth = [2]uint64{sent_delta, recv_delta}
// TODO(0.19+): stop populating NetworkSent/NetworkRecv (deprecated in 0.18.3)
stats.NetworkSent = bytesToMegabytes(float64(sent_delta))
stats.NetworkRecv = bytesToMegabytes(float64(recv_delta))
stats.PrevReadTime = readTime
}
func parseDockerStatus(status string) (string, container.DockerHealth) {
trimmed := strings.TrimSpace(status)
if trimmed == "" {
return "", container.DockerHealthNone
}
// Remove "About " from status
trimmed = strings.Replace(trimmed, "About ", "", 1)
openIdx := strings.LastIndex(trimmed, "(")
if openIdx == -1 || !strings.HasSuffix(trimmed, ")") {
return trimmed, container.DockerHealthNone
}
statusText := strings.TrimSpace(trimmed[:openIdx])
if statusText == "" {
statusText = trimmed
}
healthText := strings.ToLower(strings.TrimSpace(strings.TrimSuffix(trimmed[openIdx+1:], ")")))
// Some Docker statuses include a "health:" prefix inside the parentheses.
// Strip it so it maps correctly to the known health states.
if colonIdx := strings.IndexRune(healthText, ':'); colonIdx != -1 {
prefix := strings.TrimSpace(healthText[:colonIdx])
if prefix == "health" || prefix == "health status" {
healthText = strings.TrimSpace(healthText[colonIdx+1:])
}
}
if health, ok := container.DockerHealthStrings[healthText]; ok {
return statusText, health
}
return trimmed, container.DockerHealthNone
}
// Updates stats for individual container with cache-time-aware delta tracking
func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo, cacheTimeMs uint16) error {
name := ctr.Names[0][1:] name := ctr.Names[0][1:]
resp, err := dm.client.Get("http://localhost/containers/" + ctr.IdShort + "/stats?stream=0&one-shot=1") resp, err := dm.client.Get(fmt.Sprintf("http://localhost/containers/%s/stats?stream=0&one-shot=1", ctr.IdShort))
if err != nil { if err != nil {
return err return err
} }
defer resp.Body.Close()
dm.containerStatsMutex.Lock() dm.containerStatsMutex.Lock()
defer dm.containerStatsMutex.Unlock() defer dm.containerStatsMutex.Unlock()
@@ -159,82 +392,76 @@ func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo) error {
// add empty values if they doesn't exist in map // add empty values if they doesn't exist in map
stats, initialized := dm.containerStatsMap[ctr.IdShort] stats, initialized := dm.containerStatsMap[ctr.IdShort]
if !initialized { if !initialized {
stats = &container.Stats{Name: name} stats = &container.Stats{Name: name, Id: ctr.IdShort, Image: ctr.Image}
dm.containerStatsMap[ctr.IdShort] = stats dm.containerStatsMap[ctr.IdShort] = stats
} }
stats.Id = ctr.IdShort
statusText, health := parseDockerStatus(ctr.Status)
stats.Status = statusText
stats.Health = health
// reset current stats // reset current stats
stats.Cpu = 0 stats.Cpu = 0
stats.Mem = 0 stats.Mem = 0
stats.Bandwidth = [2]uint64{0, 0}
// TODO(0.19+): stop populating NetworkSent/NetworkRecv (deprecated in 0.18.3)
stats.NetworkSent = 0 stats.NetworkSent = 0
stats.NetworkRecv = 0 stats.NetworkRecv = 0
// docker host container stats response
// res := dm.getApiStats()
// defer dm.putApiStats(res)
//
res := dm.apiStats res := dm.apiStats
res.Networks = nil res.Networks = nil
if err := dm.decode(resp, res); err != nil { if err := dm.decode(resp, res); err != nil {
return err return err
} }
// calculate cpu and memory stats // Initialize CPU tracking for this cache time interval
var usedMemory uint64 dm.initializeCpuTracking(cacheTimeMs)
// Get previous CPU values
prevCpuContainer, prevCpuSystem := dm.getCpuPreviousValues(cacheTimeMs, ctr.IdShort)
// Calculate CPU percentage based on platform
var cpuPct float64 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 { if dm.isWindows {
usedMemory = res.MemoryStats.PrivateWorkingSet prevRead := dm.lastCpuReadTime[cacheTimeMs][ctr.IdShort]
cpuPct = res.CalculateCpuPercentWindows(prevCpuContainer, stats.PrevReadTime) cpuPct = res.CalculateCpuPercentWindows(prevCpuContainer, prevRead)
} else { } 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) cpuPct = res.CalculateCpuPercentLinux(prevCpuContainer, prevCpuSystem)
} }
if cpuPct > 100 { // Calculate memory usage
return fmt.Errorf("%s cpu pct greater than 100: %+v", name, cpuPct) usedMemory, err := calculateMemoryUsage(res, dm.isWindows)
if err != nil {
return fmt.Errorf("%s - %w - see https://github.com/henrygd/beszel/issues/144", name, err)
} }
// network // Store current CPU stats for next calculation
currentCpuContainer := res.CPUStats.CPUUsage.TotalUsage
currentCpuSystem := res.CPUStats.SystemUsage
dm.setCpuCurrentValues(cacheTimeMs, ctr.IdShort, currentCpuContainer, currentCpuSystem)
// Validate CPU percentage
if err := validateCpuPercentage(cpuPct, name); err != nil {
return err
}
// Calculate network stats using DeltaTracker
sent_delta, recv_delta := dm.calculateNetworkStats(ctr, res, stats, initialized, name, cacheTimeMs)
// Store current network values for legacy compatibility
var total_sent, total_recv uint64 var total_sent, total_recv uint64
for _, v := range res.Networks { for _, v := range res.Networks {
total_sent += v.TxBytes total_sent += v.TxBytes
total_recv += v.RxBytes total_recv += v.RxBytes
} }
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, stats.PrevNet.Recv = total_sent, total_recv stats.PrevNet.Sent, stats.PrevNet.Recv = total_sent, total_recv
stats.Cpu = twoDecimals(cpuPct) // Update final stats values
stats.Mem = bytesToMegabytes(float64(usedMemory)) updateContainerStatsValues(stats, cpuPct, usedMemory, sent_delta, recv_delta, res.Read)
stats.NetworkSent = bytesToMegabytes(float64(sent_delta)) // store per-cache-time read time for Windows CPU percent calc
stats.NetworkRecv = bytesToMegabytes(float64(recv_delta)) dm.lastCpuReadTime[cacheTimeMs][ctr.IdShort] = res.Read
stats.PrevReadTime = res.Read
return nil return nil
} }
@@ -244,10 +471,19 @@ func (dm *dockerManager) deleteContainerStatsSync(id string) {
dm.containerStatsMutex.Lock() dm.containerStatsMutex.Lock()
defer dm.containerStatsMutex.Unlock() defer dm.containerStatsMutex.Unlock()
delete(dm.containerStatsMap, id) delete(dm.containerStatsMap, id)
for ct := range dm.lastCpuContainer {
delete(dm.lastCpuContainer[ct], id)
}
for ct := range dm.lastCpuSystem {
delete(dm.lastCpuSystem[ct], id)
}
for ct := range dm.lastCpuReadTime {
delete(dm.lastCpuReadTime[ct], id)
}
} }
// Creates a new http client for Docker or Podman API // Creates a new http client for Docker or Podman API
func newDockerManager(a *Agent) *dockerManager { func newDockerManager() *dockerManager {
dockerHost, exists := GetEnv("DOCKER_HOST") dockerHost, exists := GetEnv("DOCKER_HOST")
if exists { if exists {
// return nil if set to empty string // return nil if set to empty string
@@ -283,7 +519,7 @@ func newDockerManager(a *Agent) *dockerManager {
} }
// configurable timeout // configurable timeout
timeout := time.Millisecond * 2100 timeout := time.Millisecond * time.Duration(dockerTimeoutMs)
if t, set := GetEnv("DOCKER_TIMEOUT"); set { if t, set := GetEnv("DOCKER_TIMEOUT"); set {
timeout, err = time.ParseDuration(t) timeout, err = time.ParseDuration(t)
if err != nil { if err != nil {
@@ -299,6 +535,19 @@ func newDockerManager(a *Agent) *dockerManager {
userAgent: "Docker-Client/", userAgent: "Docker-Client/",
} }
// Read container exclusion patterns from environment variable
var excludeContainers []string
if excludeStr, set := GetEnv("EXCLUDE_CONTAINERS"); set && excludeStr != "" {
parts := strings.SplitSeq(excludeStr, ",")
for part := range parts {
trimmed := strings.TrimSpace(part)
if trimmed != "" {
excludeContainers = append(excludeContainers, trimmed)
}
}
slog.Info("EXCLUDE_CONTAINERS", "patterns", excludeContainers)
}
manager := &dockerManager{ manager := &dockerManager{
client: &http.Client{ client: &http.Client{
Timeout: timeout, Timeout: timeout,
@@ -308,37 +557,66 @@ func newDockerManager(a *Agent) *dockerManager {
sem: make(chan struct{}, 5), sem: make(chan struct{}, 5),
apiContainerList: []*container.ApiInfo{}, apiContainerList: []*container.ApiInfo{},
apiStats: &container.ApiStats{}, apiStats: &container.ApiStats{},
excludeContainers: excludeContainers,
// Initialize cache-time-aware tracking structures
lastCpuContainer: make(map[uint16]map[string]uint64),
lastCpuSystem: make(map[uint16]map[string]uint64),
lastCpuReadTime: make(map[uint16]map[string]time.Time),
networkSentTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
networkRecvTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
} }
// If using podman, return client // If using podman, return client
if strings.Contains(dockerHost, "podman") { if strings.Contains(dockerHost, "podman") {
a.systemInfo.Podman = true manager.usingPodman = true
manager.goodDockerVersion = true manager.goodDockerVersion = true
return manager return manager
} }
// Check docker version // this can take up to 5 seconds with retry, so run in goroutine
// (versions before 25.0.0 have a bug with one-shot which requires all requests to be made in one batch) go manager.checkDockerVersion()
// give version check a chance to complete before returning
time.Sleep(50 * time.Millisecond)
return manager
}
// checkDockerVersion checks Docker version and sets goodDockerVersion if at least 25.0.0.
// Versions before 25.0.0 have a bug with one-shot which requires all requests to be made in one batch.
func (dm *dockerManager) checkDockerVersion() {
var err error
var resp *http.Response
var versionInfo struct { var versionInfo struct {
Version string `json:"Version"` Version string `json:"Version"`
} }
resp, err := manager.client.Get("http://localhost/version") const versionMaxTries = 2
for i := 1; i <= versionMaxTries; i++ {
resp, err = dm.client.Get("http://localhost/version")
if err == nil {
break
}
if resp != nil {
resp.Body.Close()
}
if i < versionMaxTries {
slog.Debug("Failed to get Docker version; retrying", "attempt", i, "error", err)
time.Sleep(5 * time.Second)
}
}
if err != nil { if err != nil {
return manager return
} }
if err := dm.decode(resp, &versionInfo); err != nil {
if err := manager.decode(resp, &versionInfo); err != nil { return
return manager
} }
// if version > 24, one-shot works correctly and we can limit concurrent operations // if version > 24, one-shot works correctly and we can limit concurrent operations
if dockerVersion, err := semver.Parse(versionInfo.Version); err == nil && dockerVersion.Major > 24 { if dockerVersion, err := semver.Parse(versionInfo.Version); err == nil && dockerVersion.Major > 24 {
manager.goodDockerVersion = true dm.goodDockerVersion = true
} else { } else {
slog.Info(fmt.Sprintf("Docker %s is outdated. Upgrade if possible. See https://github.com/henrygd/beszel/issues/58", versionInfo.Version)) slog.Info(fmt.Sprintf("Docker %s is outdated. Upgrade if possible. See https://github.com/henrygd/beszel/issues/58", versionInfo.Version))
} }
return manager
} }
// Decodes Docker API JSON response using a reusable buffer and decoder. Not thread safe. // Decodes Docker API JSON response using a reusable buffer and decoder. Not thread safe.
@@ -368,3 +646,132 @@ func getDockerHost() string {
} }
return scheme + socks[0] return scheme + socks[0]
} }
// getContainerInfo fetches the inspection data for a container
func (dm *dockerManager) getContainerInfo(ctx context.Context, containerID string) ([]byte, error) {
endpoint := fmt.Sprintf("http://localhost/containers/%s/json", containerID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
resp, err := dm.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return nil, fmt.Errorf("container info request failed: %s: %s", resp.Status, strings.TrimSpace(string(body)))
}
// Remove sensitive environment variables from Config.Env
var containerInfo map[string]any
if err := json.NewDecoder(resp.Body).Decode(&containerInfo); err != nil {
return nil, err
}
if config, ok := containerInfo["Config"].(map[string]any); ok {
delete(config, "Env")
}
return json.Marshal(containerInfo)
}
// getLogs fetches the logs for a container
func (dm *dockerManager) getLogs(ctx context.Context, containerID string) (string, error) {
endpoint := fmt.Sprintf("http://localhost/containers/%s/logs?stdout=1&stderr=1&tail=%d", containerID, dockerLogsTail)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return "", err
}
resp, err := dm.client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return "", fmt.Errorf("logs request failed: %s: %s", resp.Status, strings.TrimSpace(string(body)))
}
var builder strings.Builder
multiplexed := resp.Header.Get("Content-Type") == "application/vnd.docker.multiplexed-stream"
if err := decodeDockerLogStream(resp.Body, &builder, multiplexed); err != nil {
return "", err
}
// Strip ANSI escape sequences from logs for clean display in web UI
logs := builder.String()
if strings.Contains(logs, "\x1b") {
logs = ansiEscapePattern.ReplaceAllString(logs, "")
}
return logs, nil
}
func decodeDockerLogStream(reader io.Reader, builder *strings.Builder, multiplexed bool) error {
if !multiplexed {
_, err := io.Copy(builder, io.LimitReader(reader, maxTotalLogSize))
return err
}
const headerSize = 8
var header [headerSize]byte
totalBytesRead := 0
for {
if _, err := io.ReadFull(reader, header[:]); err != nil {
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
return nil
}
return err
}
frameLen := binary.BigEndian.Uint32(header[4:])
if frameLen == 0 {
continue
}
// Prevent memory exhaustion from excessively large frames
if frameLen > maxLogFrameSize {
return fmt.Errorf("log frame size (%d) exceeds maximum (%d)", frameLen, maxLogFrameSize)
}
// Check if reading this frame would exceed total log size limit
if totalBytesRead+int(frameLen) > maxTotalLogSize {
// Read and discard remaining data to avoid blocking
_, _ = io.CopyN(io.Discard, reader, int64(frameLen))
slog.Debug("Truncating logs: limit reached", "read", totalBytesRead, "limit", maxTotalLogSize)
return nil
}
n, err := io.CopyN(builder, reader, int64(frameLen))
if err != nil {
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
return nil
}
return err
}
totalBytesRead += int(n)
}
}
// GetHostInfo fetches the system info from Docker
func (dm *dockerManager) GetHostInfo() (info container.HostInfo, err error) {
resp, err := dm.client.Get("http://localhost/info")
if err != nil {
return info, err
}
defer resp.Body.Close()
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
return info, err
}
return info, nil
}
func (dm *dockerManager) IsPodman() bool {
return dm.usingPodman
}

1248
agent/docker_test.go Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,8 @@ import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log/slog"
"maps"
"os/exec" "os/exec"
"regexp" "regexp"
"strconv" "strconv"
@@ -13,27 +15,23 @@ import (
"time" "time"
"github.com/henrygd/beszel/internal/entities/system" "github.com/henrygd/beszel/internal/entities/system"
"golang.org/x/exp/slog"
) )
const ( const (
// Commands // Commands
nvidiaSmiCmd string = "nvidia-smi" nvidiaSmiCmd string = "nvidia-smi"
rocmSmiCmd string = "rocm-smi" rocmSmiCmd string = "rocm-smi"
amdgpuCmd string = "amdgpu" // internal cmd for sysfs collection
tegraStatsCmd string = "tegrastats" tegraStatsCmd string = "tegrastats"
// Polling intervals // Polling intervals
nvidiaSmiInterval string = "4" // in seconds nvidiaSmiInterval string = "4" // in seconds
tegraStatsInterval string = "3700" // in milliseconds tegraStatsInterval string = "3700" // in milliseconds
rocmSmiInterval time.Duration = 4300 * time.Millisecond rocmSmiInterval time.Duration = 4300 * time.Millisecond
// Command retry and timeout constants // Command retry and timeout constants
retryWaitTime time.Duration = 5 * time.Second retryWaitTime time.Duration = 5 * time.Second
maxFailureRetries int = 5 maxFailureRetries int = 5
cmdBufferSize uint16 = 10 * 1024
// Unit Conversions // Unit Conversions
mebibytesInAMegabyte float64 = 1.024 // nvidia-smi reports memory in MiB mebibytesInAMegabyte float64 = 1.024 // nvidia-smi reports memory in MiB
milliwattsInAWatt float64 = 1000.0 // tegrastats reports power in mW milliwattsInAWatt float64 = 1000.0 // tegrastats reports power in mW
@@ -44,8 +42,26 @@ type GPUManager struct {
sync.Mutex sync.Mutex
nvidiaSmi bool nvidiaSmi bool
rocmSmi bool rocmSmi bool
amdgpu bool
tegrastats bool tegrastats bool
intelGpuStats bool
nvml bool
GpuDataMap map[string]*system.GPUData GpuDataMap map[string]*system.GPUData
// lastAvgData stores the last calculated averages for each GPU
// Used when a collection happens before new data arrives (Count == 0)
lastAvgData map[string]system.GPUData
// Per-cache-key tracking for delta calculations
// cacheKey -> gpuId -> snapshot of last count/usage/power values
lastSnapshots map[uint16]map[string]*gpuSnapshot
}
// gpuSnapshot stores the last observed incremental values for delta tracking
type gpuSnapshot struct {
count uint32
usage float64
power float64
powerPkg float64
engines map[string]float64
} }
// RocmSmiJson represents the JSON structure of rocm-smi output // RocmSmiJson represents the JSON structure of rocm-smi output
@@ -66,6 +82,7 @@ type gpuCollector struct {
cmdArgs []string cmdArgs []string
parse func([]byte) bool // returns true if valid data was found parse func([]byte) bool // returns true if valid data was found
buf []byte buf []byte
bufSize uint16
} }
var errNoValidData = fmt.Errorf("no valid GPU data found") // Error for missing data var errNoValidData = fmt.Errorf("no valid GPU data found") // Error for missing data
@@ -99,7 +116,7 @@ func (c *gpuCollector) collect() error {
scanner := bufio.NewScanner(stdout) scanner := bufio.NewScanner(stdout)
if c.buf == nil { if c.buf == nil {
c.buf = make([]byte, 0, cmdBufferSize) c.buf = make([]byte, 0, c.bufSize)
} }
scanner.Buffer(c.buf, bufio.MaxScanTokenSize) scanner.Buffer(c.buf, bufio.MaxScanTokenSize)
@@ -121,10 +138,10 @@ func (gm *GPUManager) getJetsonParser() func(output []byte) bool {
// use closure to avoid recompiling the regex // use closure to avoid recompiling the regex
ramPattern := regexp.MustCompile(`RAM (\d+)/(\d+)MB`) ramPattern := regexp.MustCompile(`RAM (\d+)/(\d+)MB`)
gr3dPattern := regexp.MustCompile(`GR3D_FREQ (\d+)%`) gr3dPattern := regexp.MustCompile(`GR3D_FREQ (\d+)%`)
tempPattern := regexp.MustCompile(`tj@(\d+\.?\d*)C`) tempPattern := regexp.MustCompile(`(?:tj|GPU)@(\d+\.?\d*)C`)
// Orin Nano / NX do not have GPU specific power monitor // Orin Nano / NX do not have GPU specific power monitor
// TODO: Maybe use VDD_IN for Nano / NX and add a total system power chart // 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`) powerPattern := regexp.MustCompile(`(GPU_SOC|CPU_GPU_CV)\s+(\d+)mW|VDD_SYS_GPU\s+(\d+)/\d+`)
// jetson devices have only one gpu so we'll just initialize here // jetson devices have only one gpu so we'll just initialize here
gpuData := &system.GPUData{Name: "GPU"} gpuData := &system.GPUData{Name: "GPU"}
@@ -153,7 +170,13 @@ func (gm *GPUManager) getJetsonParser() func(output []byte) bool {
// Parse power usage // Parse power usage
powerMatches := powerPattern.FindSubmatch(output) powerMatches := powerPattern.FindSubmatch(output)
if powerMatches != nil { if powerMatches != nil {
power, _ := strconv.ParseFloat(string(powerMatches[2]), 64) // powerMatches[2] is the "(GPU_SOC|CPU_GPU_CV) <N>mW" capture
// powerMatches[3] is the "VDD_SYS_GPU <N>/<N>" capture
powerStr := string(powerMatches[2])
if powerStr == "" {
powerStr = string(powerMatches[3])
}
power, _ := strconv.ParseFloat(powerStr, 64)
gpuData.Power += power / milliwattsInAWatt gpuData.Power += power / milliwattsInAWatt
} }
gpuData.Count++ gpuData.Count++
@@ -216,10 +239,11 @@ func (gm *GPUManager) parseAmdData(output []byte) bool {
totalMemory, _ := strconv.ParseFloat(v.MemoryTotal, 64) totalMemory, _ := strconv.ParseFloat(v.MemoryTotal, 64)
usage, _ := strconv.ParseFloat(v.Usage, 64) usage, _ := strconv.ParseFloat(v.Usage, 64)
if _, ok := gm.GpuDataMap[v.ID]; !ok { id := v.ID
gm.GpuDataMap[v.ID] = &system.GPUData{Name: v.Name} if _, ok := gm.GpuDataMap[id]; !ok {
gm.GpuDataMap[id] = &system.GPUData{Name: v.Name}
} }
gpu := gm.GpuDataMap[v.ID] gpu := gm.GpuDataMap[id]
gpu.Temperature, _ = strconv.ParseFloat(v.Temperature, 64) gpu.Temperature, _ = strconv.ParseFloat(v.Temperature, 64)
gpu.MemoryUsed = bytesToMegabytes(memoryUsage) gpu.MemoryUsed = bytesToMegabytes(memoryUsage)
gpu.MemoryTotal = bytesToMegabytes(totalMemory) gpu.MemoryTotal = bytesToMegabytes(totalMemory)
@@ -230,36 +254,21 @@ func (gm *GPUManager) parseAmdData(output []byte) bool {
return true return true
} }
// sums and resets the current GPU utilization data since the last update // GetCurrentData returns GPU utilization data averaged since the last call with this cacheKey
func (gm *GPUManager) GetCurrentData() map[string]system.GPUData { func (gm *GPUManager) GetCurrentData(cacheKey uint16) map[string]system.GPUData {
gm.Lock() gm.Lock()
defer gm.Unlock() defer gm.Unlock()
// check for GPUs with the same name gm.initializeSnapshots(cacheKey)
nameCounts := make(map[string]int) nameCounts := gm.countGPUNames()
for _, gpu := range gm.GpuDataMap {
nameCounts[gpu.Name]++
}
// copy / reset the data
gpuData := make(map[string]system.GPUData, len(gm.GpuDataMap)) gpuData := make(map[string]system.GPUData, len(gm.GpuDataMap))
for id, gpu := range gm.GpuDataMap { for id, gpu := range gm.GpuDataMap {
gpuAvg := *gpu gpuAvg := gm.calculateGPUAverage(id, gpu, cacheKey)
gm.updateInstantaneousValues(&gpuAvg, gpu)
gm.storeSnapshot(id, gpu, cacheKey)
gpuAvg.Temperature = twoDecimals(gpu.Temperature) // Append id to name if there are multiple GPUs with the same name
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 { if nameCounts[gpu.Name] > 1 {
gpuAvg.Name = fmt.Sprintf("%s %s", gpu.Name, id) gpuAvg.Name = fmt.Sprintf("%s %s", gpu.Name, id)
} }
@@ -269,6 +278,120 @@ func (gm *GPUManager) GetCurrentData() map[string]system.GPUData {
return gpuData return gpuData
} }
// initializeSnapshots ensures snapshot maps are initialized for the given cache key
func (gm *GPUManager) initializeSnapshots(cacheKey uint16) {
if gm.lastAvgData == nil {
gm.lastAvgData = make(map[string]system.GPUData)
}
if gm.lastSnapshots == nil {
gm.lastSnapshots = make(map[uint16]map[string]*gpuSnapshot)
}
if gm.lastSnapshots[cacheKey] == nil {
gm.lastSnapshots[cacheKey] = make(map[string]*gpuSnapshot)
}
}
// countGPUNames returns a map of GPU names to their occurrence count
func (gm *GPUManager) countGPUNames() map[string]int {
nameCounts := make(map[string]int)
for _, gpu := range gm.GpuDataMap {
nameCounts[gpu.Name]++
}
return nameCounts
}
// calculateGPUAverage computes the average GPU metrics since the last snapshot for this cache key
func (gm *GPUManager) calculateGPUAverage(id string, gpu *system.GPUData, cacheKey uint16) system.GPUData {
lastSnapshot := gm.lastSnapshots[cacheKey][id]
currentCount := uint32(gpu.Count)
deltaCount := gm.calculateDeltaCount(currentCount, lastSnapshot)
// If no new data arrived
if deltaCount == 0 {
// If GPU appears suspended (instantaneous values are 0), return zero values
// Otherwise return last known average for temporary collection gaps
if gpu.Temperature == 0 && gpu.MemoryUsed == 0 {
return system.GPUData{Name: gpu.Name}
}
return gm.lastAvgData[id] // zero value if not found
}
// Calculate new average
gpuAvg := *gpu
deltaUsage, deltaPower, deltaPowerPkg := gm.calculateDeltas(gpu, lastSnapshot)
gpuAvg.Power = twoDecimals(deltaPower / float64(deltaCount))
if gpu.Engines != nil {
// make fresh map for averaged engine metrics to avoid mutating
// the accumulator map stored in gm.GpuDataMap
gpuAvg.Engines = make(map[string]float64, len(gpu.Engines))
gpuAvg.Usage = gm.calculateIntelGPUUsage(&gpuAvg, gpu, lastSnapshot, deltaCount)
gpuAvg.PowerPkg = twoDecimals(deltaPowerPkg / float64(deltaCount))
} else {
gpuAvg.Usage = twoDecimals(deltaUsage / float64(deltaCount))
}
gm.lastAvgData[id] = gpuAvg
return gpuAvg
}
// calculateDeltaCount returns the change in count since the last snapshot
func (gm *GPUManager) calculateDeltaCount(currentCount uint32, lastSnapshot *gpuSnapshot) uint32 {
if lastSnapshot != nil {
return currentCount - lastSnapshot.count
}
return currentCount
}
// calculateDeltas computes the change in usage, power, and powerPkg since the last snapshot
func (gm *GPUManager) calculateDeltas(gpu *system.GPUData, lastSnapshot *gpuSnapshot) (deltaUsage, deltaPower, deltaPowerPkg float64) {
if lastSnapshot != nil {
return gpu.Usage - lastSnapshot.usage,
gpu.Power - lastSnapshot.power,
gpu.PowerPkg - lastSnapshot.powerPkg
}
return gpu.Usage, gpu.Power, gpu.PowerPkg
}
// calculateIntelGPUUsage computes Intel GPU usage from engine metrics and returns max engine usage
func (gm *GPUManager) calculateIntelGPUUsage(gpuAvg, gpu *system.GPUData, lastSnapshot *gpuSnapshot, deltaCount uint32) float64 {
maxEngineUsage := 0.0
for name, engine := range gpu.Engines {
var deltaEngine float64
if lastSnapshot != nil && lastSnapshot.engines != nil {
deltaEngine = engine - lastSnapshot.engines[name]
} else {
deltaEngine = engine
}
gpuAvg.Engines[name] = twoDecimals(deltaEngine / float64(deltaCount))
maxEngineUsage = max(maxEngineUsage, deltaEngine/float64(deltaCount))
}
return twoDecimals(maxEngineUsage)
}
// updateInstantaneousValues updates values that should reflect current state, not averages
func (gm *GPUManager) updateInstantaneousValues(gpuAvg *system.GPUData, gpu *system.GPUData) {
gpuAvg.Temperature = twoDecimals(gpu.Temperature)
gpuAvg.MemoryUsed = twoDecimals(gpu.MemoryUsed)
gpuAvg.MemoryTotal = twoDecimals(gpu.MemoryTotal)
}
// storeSnapshot saves the current GPU state for this cache key
func (gm *GPUManager) storeSnapshot(id string, gpu *system.GPUData, cacheKey uint16) {
snapshot := &gpuSnapshot{
count: uint32(gpu.Count),
usage: gpu.Usage,
power: gpu.Power,
powerPkg: gpu.PowerPkg,
}
if gpu.Engines != nil {
snapshot.engines = make(map[string]float64, len(gpu.Engines))
maps.Copy(snapshot.engines, gpu.Engines)
}
gm.lastSnapshots[cacheKey][id] = snapshot
}
// detectGPUs checks for the presence of GPU management tools (nvidia-smi, rocm-smi, tegrastats) // detectGPUs checks for the presence of GPU management tools (nvidia-smi, rocm-smi, tegrastats)
// in the system path. It sets the corresponding flags in the GPUManager struct if any of these // in the system path. It sets the corresponding flags in the GPUManager struct if any of these
// tools are found. If none of the tools are found, it returns an error indicating that no GPU // tools are found. If none of the tools are found, it returns an error indicating that no GPU
@@ -278,24 +401,49 @@ func (gm *GPUManager) detectGPUs() error {
gm.nvidiaSmi = true gm.nvidiaSmi = true
} }
if _, err := exec.LookPath(rocmSmiCmd); err == nil { if _, err := exec.LookPath(rocmSmiCmd); err == nil {
if val, _ := GetEnv("AMD_SYSFS"); val == "true" {
gm.amdgpu = true
} else {
gm.rocmSmi = true gm.rocmSmi = true
} }
} else if gm.hasAmdSysfs() {
gm.amdgpu = true
}
if _, err := exec.LookPath(tegraStatsCmd); err == nil { if _, err := exec.LookPath(tegraStatsCmd); err == nil {
gm.tegrastats = true gm.tegrastats = true
gm.nvidiaSmi = false gm.nvidiaSmi = false
} }
if gm.nvidiaSmi || gm.rocmSmi || gm.tegrastats { if _, err := exec.LookPath(intelGpuStatsCmd); err == nil {
gm.intelGpuStats = true
}
if gm.nvidiaSmi || gm.rocmSmi || gm.amdgpu || gm.tegrastats || gm.intelGpuStats || gm.nvml {
return nil return nil
} }
return fmt.Errorf("no GPU found - install nvidia-smi, rocm-smi, or tegrastats") return fmt.Errorf("no GPU found - install nvidia-smi, rocm-smi, or intel_gpu_top")
} }
// startCollector starts the appropriate GPU data collector based on the command // startCollector starts the appropriate GPU data collector based on the command
func (gm *GPUManager) startCollector(command string) { func (gm *GPUManager) startCollector(command string) {
collector := gpuCollector{ collector := gpuCollector{
name: command, name: command,
bufSize: 10 * 1024,
} }
switch command { switch command {
case intelGpuStatsCmd:
go func() {
failures := 0
for {
if err := gm.collectIntelStats(); err != nil {
failures++
if failures > maxFailureRetries {
break
}
slog.Warn("Error collecting Intel GPU data; see https://beszel.dev/guide/gpu", "err", err)
time.Sleep(retryWaitTime)
continue
}
}
}()
case nvidiaSmiCmd: case nvidiaSmiCmd:
collector.cmdArgs = []string{ collector.cmdArgs = []string{
"-l", nvidiaSmiInterval, "-l", nvidiaSmiInterval,
@@ -308,6 +456,12 @@ func (gm *GPUManager) startCollector(command string) {
collector.cmdArgs = []string{"--interval", tegraStatsInterval} collector.cmdArgs = []string{"--interval", tegraStatsInterval}
collector.parse = gm.getJetsonParser() collector.parse = gm.getJetsonParser()
go collector.start() go collector.start()
case amdgpuCmd:
go func() {
if err := gm.collectAmdStats(); err != nil {
slog.Warn("Error collecting AMD GPU data via sysfs", "err", err)
}
}()
case rocmSmiCmd: case rocmSmiCmd:
collector.cmdArgs = []string{"--showid", "--showtemp", "--showuse", "--showpower", "--showproductname", "--showmeminfo", "vram", "--json"} collector.cmdArgs = []string{"--showid", "--showtemp", "--showuse", "--showpower", "--showproductname", "--showmeminfo", "vram", "--json"}
collector.parse = gm.parseAmdData collector.parse = gm.parseAmdData
@@ -319,7 +473,7 @@ func (gm *GPUManager) startCollector(command string) {
if failures > maxFailureRetries { if failures > maxFailureRetries {
break break
} }
slog.Warn("Error collecting AMD GPU data", "err", err) slog.Warn("Error collecting AMD GPU data via rocm-smi", "err", err)
} }
time.Sleep(rocmSmiInterval) time.Sleep(rocmSmiInterval)
} }
@@ -329,6 +483,9 @@ func (gm *GPUManager) startCollector(command string) {
// NewGPUManager creates and initializes a new GPUManager // NewGPUManager creates and initializes a new GPUManager
func NewGPUManager() (*GPUManager, error) { func NewGPUManager() (*GPUManager, error) {
if skipGPU, _ := GetEnv("SKIP_GPU"); skipGPU == "true" {
return nil, nil
}
var gm GPUManager var gm GPUManager
if err := gm.detectGPUs(); err != nil { if err := gm.detectGPUs(); err != nil {
return nil, err return nil, err
@@ -336,14 +493,33 @@ func NewGPUManager() (*GPUManager, error) {
gm.GpuDataMap = make(map[string]*system.GPUData) gm.GpuDataMap = make(map[string]*system.GPUData)
if gm.nvidiaSmi { if gm.nvidiaSmi {
if nvml, _ := GetEnv("NVML"); nvml == "true" {
gm.nvml = true
gm.nvidiaSmi = false
collector := &nvmlCollector{gm: &gm}
if err := collector.init(); err == nil {
go collector.start()
} else {
slog.Warn("Failed to initialize NVML, falling back to nvidia-smi", "err", err)
gm.nvidiaSmi = true
gm.startCollector(nvidiaSmiCmd) gm.startCollector(nvidiaSmiCmd)
} }
} else {
gm.startCollector(nvidiaSmiCmd)
}
}
if gm.rocmSmi { if gm.rocmSmi {
gm.startCollector(rocmSmiCmd) gm.startCollector(rocmSmiCmd)
} }
if gm.amdgpu {
gm.startCollector(amdgpuCmd)
}
if gm.tegrastats { if gm.tegrastats {
gm.startCollector(tegraStatsCmd) gm.startCollector(tegraStatsCmd)
} }
if gm.intelGpuStats {
gm.startCollector(intelGpuStatsCmd)
}
return &gm, nil return &gm, nil
} }

184
agent/gpu_amd_linux.go Normal file
View File

@@ -0,0 +1,184 @@
//go:build linux
package agent
import (
"fmt"
"log/slog"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"github.com/henrygd/beszel/internal/entities/system"
)
// hasAmdSysfs returns true if any AMD GPU sysfs nodes are found
func (gm *GPUManager) hasAmdSysfs() bool {
cards, err := filepath.Glob("/sys/class/drm/card*/device/vendor")
if err != nil {
return false
}
for _, vendorPath := range cards {
vendor, err := os.ReadFile(vendorPath)
if err == nil && strings.TrimSpace(string(vendor)) == "0x1002" {
return true
}
}
return false
}
// collectAmdStats collects AMD GPU metrics directly from sysfs to avoid the overhead of rocm-smi
func (gm *GPUManager) collectAmdStats() error {
cards, err := filepath.Glob("/sys/class/drm/card*")
if err != nil {
return err
}
var amdGpuPaths []string
for _, card := range cards {
// Ignore symbolic links and non-main card directories
if strings.Contains(filepath.Base(card), "-") || !isAmdGpu(card) {
continue
}
amdGpuPaths = append(amdGpuPaths, card)
}
if len(amdGpuPaths) == 0 {
return errNoValidData
}
slog.Debug("Using sysfs for AMD GPU data collection")
failures := 0
for {
hasData := false
for _, cardPath := range amdGpuPaths {
if gm.updateAmdGpuData(cardPath) {
hasData = true
}
}
if !hasData {
failures++
if failures > maxFailureRetries {
return errNoValidData
}
slog.Warn("No AMD GPU data from sysfs", "failures", failures)
time.Sleep(retryWaitTime)
continue
}
failures = 0
time.Sleep(rocmSmiInterval)
}
}
func isAmdGpu(cardPath string) bool {
vendorPath := filepath.Join(cardPath, "device/vendor")
vendor, err := os.ReadFile(vendorPath)
if err != nil {
return false
}
return strings.TrimSpace(string(vendor)) == "0x1002"
}
// updateAmdGpuData reads GPU metrics from sysfs and updates the GPU data map.
// Returns true if at least some data was successfully read.
func (gm *GPUManager) updateAmdGpuData(cardPath string) bool {
devicePath := filepath.Join(cardPath, "device")
id := filepath.Base(cardPath)
// Read all sysfs values first (no lock needed - these can be slow)
usage, usageErr := readSysfsFloat(filepath.Join(devicePath, "gpu_busy_percent"))
memUsed, memUsedErr := readSysfsFloat(filepath.Join(devicePath, "mem_info_vram_used"))
memTotal, _ := readSysfsFloat(filepath.Join(devicePath, "mem_info_vram_total"))
var temp, power float64
hwmons, _ := filepath.Glob(filepath.Join(devicePath, "hwmon/hwmon*"))
for _, hwmonDir := range hwmons {
if t, err := readSysfsFloat(filepath.Join(hwmonDir, "temp1_input")); err == nil {
temp = t / 1000.0
}
if p, err := readSysfsFloat(filepath.Join(hwmonDir, "power1_average")); err == nil {
power += p / 1000000.0
} else if p, err := readSysfsFloat(filepath.Join(hwmonDir, "power1_input")); err == nil {
power += p / 1000000.0
}
}
// Check if we got any meaningful data
if usageErr != nil && memUsedErr != nil && temp == 0 {
return false
}
// Single lock to update all values atomically
gm.Lock()
defer gm.Unlock()
gpu, ok := gm.GpuDataMap[id]
if !ok {
gpu = &system.GPUData{Name: getAmdGpuName(devicePath)}
gm.GpuDataMap[id] = gpu
}
if usageErr == nil {
gpu.Usage += usage
}
gpu.MemoryUsed = bytesToMegabytes(memUsed)
gpu.MemoryTotal = bytesToMegabytes(memTotal)
gpu.Temperature = temp
gpu.Power += power
gpu.Count++
return true
}
func readSysfsFloat(path string) (float64, error) {
val, err := os.ReadFile(path)
if err != nil {
return 0, err
}
return strconv.ParseFloat(strings.TrimSpace(string(val)), 64)
}
// getAmdGpuName attempts to get a descriptive GPU name.
// First tries product_name (rarely available), then looks up the PCI device ID.
// Falls back to showing the raw device ID if not found in the lookup table.
func getAmdGpuName(devicePath string) string {
// Try product_name first (works for some enterprise GPUs)
if prod, err := os.ReadFile(filepath.Join(devicePath, "product_name")); err == nil {
return strings.TrimSpace(string(prod))
}
// Read PCI device ID and look it up
if deviceID, err := os.ReadFile(filepath.Join(devicePath, "device")); err == nil {
id := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(string(deviceID))), "0x")
if name, ok := getRadeonNames()[id]; ok {
return fmt.Sprintf("Radeon %s", name)
}
return fmt.Sprintf("AMD GPU (%s)", id)
}
return "AMD GPU"
}
// getRadeonNames returns the AMD GPU name lookup table
// Device IDs from https://pci-ids.ucw.cz/read/PC/1002
var getRadeonNames = sync.OnceValue(func() map[string]string {
return map[string]string{
"7550": "RX 9070",
"7590": "RX 9060 XT",
"7551": "AI PRO R9700",
"744c": "RX 7900",
"1681": "680M",
"7448": "PRO W7900",
"745e": "PRO W7800",
"7470": "PRO W7700",
"73e3": "PRO W6600",
"7422": "PRO W6400",
"7341": "PRO W5500",
}
})

View File

@@ -0,0 +1,15 @@
//go:build !linux
package agent
import (
"errors"
)
func (gm *GPUManager) hasAmdSysfs() bool {
return false
}
func (gm *GPUManager) collectAmdStats() error {
return errors.ErrUnsupported
}

207
agent/gpu_intel.go Normal file
View File

@@ -0,0 +1,207 @@
package agent
import (
"bufio"
"io"
"os/exec"
"strconv"
"strings"
"github.com/henrygd/beszel/internal/entities/system"
)
const (
intelGpuStatsCmd string = "intel_gpu_top"
intelGpuStatsInterval string = "3300" // in milliseconds
)
type intelGpuStats struct {
PowerGPU float64
PowerPkg float64
Engines map[string]float64
}
// updateIntelFromStats updates aggregated GPU data from a single intelGpuStats sample
func (gm *GPUManager) updateIntelFromStats(sample *intelGpuStats) bool {
gm.Lock()
defer gm.Unlock()
// only one gpu for now - cmd doesn't provide all by default
id := "i0" // prefix with i to avoid conflicts with nvidia card ids
gpuData, ok := gm.GpuDataMap[id]
if !ok {
gpuData = &system.GPUData{Name: "GPU", Engines: make(map[string]float64)}
gm.GpuDataMap[id] = gpuData
}
gpuData.Power += sample.PowerGPU
gpuData.PowerPkg += sample.PowerPkg
if gpuData.Engines == nil {
gpuData.Engines = make(map[string]float64, len(sample.Engines))
}
for name, engine := range sample.Engines {
gpuData.Engines[name] += engine
}
gpuData.Count++
return true
}
// collectIntelStats executes intel_gpu_top in text mode (-l) and parses the output
func (gm *GPUManager) collectIntelStats() (err error) {
// Build command arguments, optionally selecting a device via -d
args := []string{"-s", intelGpuStatsInterval, "-l"}
if dev, ok := GetEnv("INTEL_GPU_DEVICE"); ok && dev != "" {
args = append(args, "-d", dev)
}
cmd := exec.Command(intelGpuStatsCmd, args...)
// Avoid blocking if intel_gpu_top writes to stderr
cmd.Stderr = io.Discard
stdout, err := cmd.StdoutPipe()
if err != nil {
return err
}
if err := cmd.Start(); err != nil {
return err
}
// Ensure we always reap the child to avoid zombies on any return path and
// propagate a non-zero exit code if no other error was set.
defer func() {
// Best-effort close of the pipe (unblock the child if it writes)
_ = stdout.Close()
if cmd.ProcessState == nil || !cmd.ProcessState.Exited() {
_ = cmd.Process.Kill()
}
if waitErr := cmd.Wait(); err == nil && waitErr != nil {
err = waitErr
}
}()
scanner := bufio.NewScanner(stdout)
var header1 string
var engineNames []string
var friendlyNames []string
var preEngineCols int
var powerIndex int
var hadDataRow bool
// skip first data row because it sometimes has erroneous data
var skippedFirstDataRow bool
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
// first header line
if strings.HasPrefix(line, "Freq") {
header1 = line
continue
}
// second header line
if strings.HasPrefix(line, "req") {
engineNames, friendlyNames, powerIndex, preEngineCols = gm.parseIntelHeaders(header1, line)
continue
}
// Data row
if !skippedFirstDataRow {
skippedFirstDataRow = true
continue
}
sample, err := gm.parseIntelData(line, engineNames, friendlyNames, powerIndex, preEngineCols)
if err != nil {
return err
}
hadDataRow = true
gm.updateIntelFromStats(&sample)
}
if scanErr := scanner.Err(); scanErr != nil {
return scanErr
}
if !hadDataRow {
return errNoValidData
}
return nil
}
func (gm *GPUManager) parseIntelHeaders(header1 string, header2 string) (engineNames []string, friendlyNames []string, powerIndex int, preEngineCols int) {
// Build indexes
h1 := strings.Fields(header1)
h2 := strings.Fields(header2)
powerIndex = -1 // Initialize to -1, will be set to actual index if found
// Collect engine names from header1
for _, col := range h1 {
key := strings.TrimRightFunc(col, func(r rune) bool {
return (r >= '0' && r <= '9') || r == '/'
})
var friendly string
switch key {
case "RCS":
friendly = "Render/3D"
case "BCS":
friendly = "Blitter"
case "VCS":
friendly = "Video"
case "VECS":
friendly = "VideoEnhance"
case "CCS":
friendly = "Compute"
default:
continue
}
engineNames = append(engineNames, key)
friendlyNames = append(friendlyNames, friendly)
}
// find power gpu index among pre-engine columns
if n := len(engineNames); n > 0 {
preEngineCols = max(len(h2)-3*n, 0)
limit := min(len(h2), preEngineCols)
for i := range limit {
if strings.EqualFold(h2[i], "gpu") {
powerIndex = i
break
}
}
}
return engineNames, friendlyNames, powerIndex, preEngineCols
}
func (gm *GPUManager) parseIntelData(line string, engineNames []string, friendlyNames []string, powerIndex int, preEngineCols int) (sample intelGpuStats, err error) {
fields := strings.Fields(line)
if len(fields) == 0 {
return sample, errNoValidData
}
// Make sure row has enough columns for engines
if need := preEngineCols + 3*len(engineNames); len(fields) < need {
return sample, errNoValidData
}
if powerIndex >= 0 && powerIndex < len(fields) {
if v, perr := strconv.ParseFloat(fields[powerIndex], 64); perr == nil {
sample.PowerGPU = v
}
if v, perr := strconv.ParseFloat(fields[powerIndex+1], 64); perr == nil {
sample.PowerPkg = v
}
}
if len(engineNames) > 0 {
sample.Engines = make(map[string]float64, len(engineNames))
for k := range engineNames {
base := preEngineCols + 3*k
if base < len(fields) {
busy := 0.0
if v, e := strconv.ParseFloat(fields[base], 64); e == nil {
busy = v
}
cur := sample.Engines[friendlyNames[k]]
sample.Engines[friendlyNames[k]] = cur + busy
} else {
sample.Engines[friendlyNames[k]] = 0
}
}
}
return sample, nil
}

224
agent/gpu_nvml.go Normal file
View File

@@ -0,0 +1,224 @@
//go:build amd64 && (windows || (linux && glibc))
package agent
import (
"fmt"
"log/slog"
"strings"
"time"
"unsafe"
"github.com/ebitengine/purego"
"github.com/henrygd/beszel/internal/entities/system"
)
// NVML constants and types
const (
nvmlSuccess int = 0
)
type nvmlDevice uintptr
type nvmlReturn int
type nvmlMemoryV1 struct {
Total uint64
Free uint64
Used uint64
}
type nvmlMemoryV2 struct {
Version uint32
Total uint64
Reserved uint64
Free uint64
Used uint64
}
type nvmlUtilization struct {
Gpu uint32
Memory uint32
}
type nvmlPciInfo struct {
BusId [16]byte
Domain uint32
Bus uint32
Device uint32
PciDeviceId uint32
PciSubSystemId uint32
}
// NVML function signatures
var (
nvmlInit func() nvmlReturn
nvmlShutdown func() nvmlReturn
nvmlDeviceGetCount func(count *uint32) nvmlReturn
nvmlDeviceGetHandleByIndex func(index uint32, device *nvmlDevice) nvmlReturn
nvmlDeviceGetName func(device nvmlDevice, name *byte, length uint32) nvmlReturn
nvmlDeviceGetMemoryInfo func(device nvmlDevice, memory uintptr) nvmlReturn
nvmlDeviceGetUtilizationRates func(device nvmlDevice, utilization *nvmlUtilization) nvmlReturn
nvmlDeviceGetTemperature func(device nvmlDevice, sensorType int, temp *uint32) nvmlReturn
nvmlDeviceGetPowerUsage func(device nvmlDevice, power *uint32) nvmlReturn
nvmlDeviceGetPciInfo func(device nvmlDevice, pci *nvmlPciInfo) nvmlReturn
nvmlErrorString func(result nvmlReturn) string
)
type nvmlCollector struct {
gm *GPUManager
lib uintptr
devices []nvmlDevice
bdfs []string
isV2 bool
}
func (c *nvmlCollector) init() error {
slog.Debug("NVML: Initializing")
libPath := getNVMLPath()
lib, err := openLibrary(libPath)
if err != nil {
return fmt.Errorf("failed to load %s: %w", libPath, err)
}
c.lib = lib
purego.RegisterLibFunc(&nvmlInit, lib, "nvmlInit")
purego.RegisterLibFunc(&nvmlShutdown, lib, "nvmlShutdown")
purego.RegisterLibFunc(&nvmlDeviceGetCount, lib, "nvmlDeviceGetCount")
purego.RegisterLibFunc(&nvmlDeviceGetHandleByIndex, lib, "nvmlDeviceGetHandleByIndex")
purego.RegisterLibFunc(&nvmlDeviceGetName, lib, "nvmlDeviceGetName")
// Try to get v2 memory info, fallback to v1 if not available
if hasSymbol(lib, "nvmlDeviceGetMemoryInfo_v2") {
c.isV2 = true
purego.RegisterLibFunc(&nvmlDeviceGetMemoryInfo, lib, "nvmlDeviceGetMemoryInfo_v2")
} else {
purego.RegisterLibFunc(&nvmlDeviceGetMemoryInfo, lib, "nvmlDeviceGetMemoryInfo")
}
purego.RegisterLibFunc(&nvmlDeviceGetUtilizationRates, lib, "nvmlDeviceGetUtilizationRates")
purego.RegisterLibFunc(&nvmlDeviceGetTemperature, lib, "nvmlDeviceGetTemperature")
purego.RegisterLibFunc(&nvmlDeviceGetPowerUsage, lib, "nvmlDeviceGetPowerUsage")
purego.RegisterLibFunc(&nvmlDeviceGetPciInfo, lib, "nvmlDeviceGetPciInfo")
purego.RegisterLibFunc(&nvmlErrorString, lib, "nvmlErrorString")
if ret := nvmlInit(); ret != nvmlReturn(nvmlSuccess) {
return fmt.Errorf("nvmlInit failed: %v", ret)
}
var count uint32
if ret := nvmlDeviceGetCount(&count); ret != nvmlReturn(nvmlSuccess) {
return fmt.Errorf("nvmlDeviceGetCount failed: %v", ret)
}
for i := uint32(0); i < count; i++ {
var device nvmlDevice
if ret := nvmlDeviceGetHandleByIndex(i, &device); ret == nvmlReturn(nvmlSuccess) {
c.devices = append(c.devices, device)
// Get BDF for power state check
var pci nvmlPciInfo
if ret := nvmlDeviceGetPciInfo(device, &pci); ret == nvmlReturn(nvmlSuccess) {
busID := string(pci.BusId[:])
if idx := strings.Index(busID, "\x00"); idx != -1 {
busID = busID[:idx]
}
c.bdfs = append(c.bdfs, strings.ToLower(busID))
} else {
c.bdfs = append(c.bdfs, "")
}
}
}
return nil
}
func (c *nvmlCollector) start() {
defer nvmlShutdown()
ticker := time.Tick(3 * time.Second)
for range ticker {
c.collect()
}
}
func (c *nvmlCollector) collect() {
c.gm.Lock()
defer c.gm.Unlock()
for i, device := range c.devices {
id := fmt.Sprintf("%d", i)
bdf := c.bdfs[i]
// Update GPUDataMap
if _, ok := c.gm.GpuDataMap[id]; !ok {
var nameBuf [64]byte
if ret := nvmlDeviceGetName(device, &nameBuf[0], 64); ret != nvmlReturn(nvmlSuccess) {
continue
}
name := string(nameBuf[:strings.Index(string(nameBuf[:]), "\x00")])
name = strings.TrimPrefix(name, "NVIDIA ")
c.gm.GpuDataMap[id] = &system.GPUData{Name: strings.TrimSuffix(name, " Laptop GPU")}
}
gpu := c.gm.GpuDataMap[id]
if bdf != "" && !c.isGPUActive(bdf) {
slog.Debug("NVML: GPU is suspended, skipping", "bdf", bdf)
gpu.Temperature = 0
gpu.MemoryUsed = 0
continue
}
// Utilization
var utilization nvmlUtilization
if ret := nvmlDeviceGetUtilizationRates(device, &utilization); ret != nvmlReturn(nvmlSuccess) {
slog.Debug("NVML: Utilization failed (GPU likely suspended)", "bdf", bdf, "ret", ret)
gpu.Temperature = 0
gpu.MemoryUsed = 0
continue
}
slog.Debug("NVML: Collecting data for GPU", "bdf", bdf)
// Temperature
var temp uint32
nvmlDeviceGetTemperature(device, 0, &temp) // 0 is NVML_TEMPERATURE_GPU
// Memory: only poll if GPU is active to avoid leaving D3cold state (#1522)
if utilization.Gpu > 0 {
var usedMem, totalMem uint64
if c.isV2 {
var memory nvmlMemoryV2
memory.Version = 0x02000028 // (2 << 24) | 40 bytes
if ret := nvmlDeviceGetMemoryInfo(device, uintptr(unsafe.Pointer(&memory))); ret != nvmlReturn(nvmlSuccess) {
slog.Debug("NVML: MemoryInfo_v2 failed", "bdf", bdf, "ret", ret)
} else {
usedMem = memory.Used
totalMem = memory.Total
}
} else {
var memory nvmlMemoryV1
if ret := nvmlDeviceGetMemoryInfo(device, uintptr(unsafe.Pointer(&memory))); ret != nvmlReturn(nvmlSuccess) {
slog.Debug("NVML: MemoryInfo failed", "bdf", bdf, "ret", ret)
} else {
usedMem = memory.Used
totalMem = memory.Total
}
}
if totalMem > 0 {
gpu.MemoryUsed = float64(usedMem) / 1024 / 1024 / mebibytesInAMegabyte
gpu.MemoryTotal = float64(totalMem) / 1024 / 1024 / mebibytesInAMegabyte
}
} else {
slog.Debug("NVML: Skipping memory info (utilization=0)", "bdf", bdf)
}
// Power
var power uint32
nvmlDeviceGetPowerUsage(device, &power)
gpu.Temperature = float64(temp)
gpu.Usage += float64(utilization.Gpu)
gpu.Power += float64(power) / 1000.0
gpu.Count++
slog.Debug("NVML: Collected data", "gpu", gpu)
}
}

57
agent/gpu_nvml_linux.go Normal file
View File

@@ -0,0 +1,57 @@
//go:build glibc && linux && amd64
package agent
import (
"log/slog"
"os"
"path/filepath"
"strings"
"github.com/ebitengine/purego"
)
func openLibrary(name string) (uintptr, error) {
return purego.Dlopen(name, purego.RTLD_NOW|purego.RTLD_GLOBAL)
}
func getNVMLPath() string {
return "libnvidia-ml.so.1"
}
func hasSymbol(lib uintptr, symbol string) bool {
_, err := purego.Dlsym(lib, symbol)
return err == nil
}
func (c *nvmlCollector) isGPUActive(bdf string) bool {
// runtime_status
statusPath := filepath.Join("/sys/bus/pci/devices", bdf, "power/runtime_status")
status, err := os.ReadFile(statusPath)
if err != nil {
slog.Debug("NVML: Can't read runtime_status", "bdf", bdf, "err", err)
return true // Assume active if we can't read status
}
statusStr := strings.TrimSpace(string(status))
if statusStr != "active" && statusStr != "resuming" {
slog.Debug("NVML: GPU not active", "bdf", bdf, "status", statusStr)
return false
}
// power_state (D0 check)
// Find any drm card device power_state
pstatePathPattern := filepath.Join("/sys/bus/pci/devices", bdf, "drm/card*/device/power_state")
matches, _ := filepath.Glob(pstatePathPattern)
if len(matches) > 0 {
pstate, err := os.ReadFile(matches[0])
if err == nil {
pstateStr := strings.TrimSpace(string(pstate))
if pstateStr != "D0" {
slog.Debug("NVML: GPU not in D0 state", "bdf", bdf, "pstate", pstateStr)
return false
}
}
}
return true
}

View File

@@ -0,0 +1,33 @@
//go:build (!linux && !windows) || !amd64 || (linux && !glibc)
package agent
import "fmt"
type nvmlCollector struct {
gm *GPUManager
}
func (c *nvmlCollector) init() error {
return fmt.Errorf("nvml not supported on this platform")
}
func (c *nvmlCollector) start() {}
func (c *nvmlCollector) collect() {}
func openLibrary(name string) (uintptr, error) {
return 0, fmt.Errorf("nvml not supported on this platform")
}
func getNVMLPath() string {
return ""
}
func hasSymbol(lib uintptr, symbol string) bool {
return false
}
func (c *nvmlCollector) isGPUActive(bdf string) bool {
return true
}

25
agent/gpu_nvml_windows.go Normal file
View File

@@ -0,0 +1,25 @@
//go:build windows && amd64
package agent
import (
"golang.org/x/sys/windows"
)
func openLibrary(name string) (uintptr, error) {
handle, err := windows.LoadLibrary(name)
return uintptr(handle), err
}
func getNVMLPath() string {
return "nvml.dll"
}
func hasSymbol(lib uintptr, symbol string) bool {
_, err := windows.GetProcAddress(windows.Handle(lib), symbol)
return err == nil
}
func (c *nvmlCollector) isGPUActive(bdf string) bool {
return true
}

File diff suppressed because it is too large Load Diff

205
agent/handlers.go Normal file
View File

@@ -0,0 +1,205 @@
package agent
import (
"context"
"errors"
"fmt"
"github.com/fxamacker/cbor/v2"
"github.com/henrygd/beszel/internal/common"
"github.com/henrygd/beszel/internal/entities/smart"
"log/slog"
)
// HandlerContext provides context for request handlers
type HandlerContext struct {
Client *WebSocketClient
Agent *Agent
Request *common.HubRequest[cbor.RawMessage]
RequestID *uint32
HubVerified bool
// SendResponse abstracts how a handler sends responses (WS or SSH)
SendResponse func(data any, requestID *uint32) error
}
// RequestHandler defines the interface for handling specific websocket request types
type RequestHandler interface {
// Handle processes the request and returns an error if unsuccessful
Handle(hctx *HandlerContext) error
}
// Responder sends handler responses back to the hub (over WS or SSH)
type Responder interface {
SendResponse(data any, requestID *uint32) error
}
// HandlerRegistry manages the mapping between actions and their handlers
type HandlerRegistry struct {
handlers map[common.WebSocketAction]RequestHandler
}
// NewHandlerRegistry creates a new handler registry with default handlers
func NewHandlerRegistry() *HandlerRegistry {
registry := &HandlerRegistry{
handlers: make(map[common.WebSocketAction]RequestHandler),
}
registry.Register(common.GetData, &GetDataHandler{})
registry.Register(common.CheckFingerprint, &CheckFingerprintHandler{})
registry.Register(common.GetContainerLogs, &GetContainerLogsHandler{})
registry.Register(common.GetContainerInfo, &GetContainerInfoHandler{})
registry.Register(common.GetSmartData, &GetSmartDataHandler{})
registry.Register(common.GetSystemdInfo, &GetSystemdInfoHandler{})
return registry
}
// Register registers a handler for a specific action type
func (hr *HandlerRegistry) Register(action common.WebSocketAction, handler RequestHandler) {
hr.handlers[action] = handler
}
// Handle routes the request to the appropriate handler
func (hr *HandlerRegistry) Handle(hctx *HandlerContext) error {
handler, exists := hr.handlers[hctx.Request.Action]
if !exists {
return fmt.Errorf("unknown action: %d", hctx.Request.Action)
}
// Check verification requirement - default to requiring verification
if hctx.Request.Action != common.CheckFingerprint && !hctx.HubVerified {
return errors.New("hub not verified")
}
// Log handler execution for debugging
// slog.Debug("Executing handler", "action", hctx.Request.Action)
return handler.Handle(hctx)
}
// GetHandler returns the handler for a specific action
func (hr *HandlerRegistry) GetHandler(action common.WebSocketAction) (RequestHandler, bool) {
handler, exists := hr.handlers[action]
return handler, exists
}
////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////
// GetDataHandler handles system data requests
type GetDataHandler struct{}
func (h *GetDataHandler) Handle(hctx *HandlerContext) error {
var options common.DataRequestOptions
_ = cbor.Unmarshal(hctx.Request.Data, &options)
sysStats := hctx.Agent.gatherStats(options)
return hctx.SendResponse(sysStats, hctx.RequestID)
}
////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////
// CheckFingerprintHandler handles authentication challenges
type CheckFingerprintHandler struct{}
func (h *CheckFingerprintHandler) Handle(hctx *HandlerContext) error {
return hctx.Client.handleAuthChallenge(hctx.Request, hctx.RequestID)
}
////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////
// GetContainerLogsHandler handles container log requests
type GetContainerLogsHandler struct{}
func (h *GetContainerLogsHandler) Handle(hctx *HandlerContext) error {
if hctx.Agent.dockerManager == nil {
return hctx.SendResponse("", hctx.RequestID)
}
var req common.ContainerLogsRequest
if err := cbor.Unmarshal(hctx.Request.Data, &req); err != nil {
return err
}
ctx := context.Background()
logContent, err := hctx.Agent.dockerManager.getLogs(ctx, req.ContainerID)
if err != nil {
return err
}
return hctx.SendResponse(logContent, hctx.RequestID)
}
////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////
// GetContainerInfoHandler handles container info requests
type GetContainerInfoHandler struct{}
func (h *GetContainerInfoHandler) Handle(hctx *HandlerContext) error {
if hctx.Agent.dockerManager == nil {
return hctx.SendResponse("", hctx.RequestID)
}
var req common.ContainerInfoRequest
if err := cbor.Unmarshal(hctx.Request.Data, &req); err != nil {
return err
}
ctx := context.Background()
info, err := hctx.Agent.dockerManager.getContainerInfo(ctx, req.ContainerID)
if err != nil {
return err
}
return hctx.SendResponse(string(info), hctx.RequestID)
}
////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////
// GetSmartDataHandler handles SMART data requests
type GetSmartDataHandler struct{}
func (h *GetSmartDataHandler) Handle(hctx *HandlerContext) error {
if hctx.Agent.smartManager == nil {
// return empty map to indicate no data
return hctx.SendResponse(map[string]smart.SmartData{}, hctx.RequestID)
}
if err := hctx.Agent.smartManager.Refresh(false); err != nil {
slog.Debug("smart refresh failed", "err", err)
}
data := hctx.Agent.smartManager.GetCurrentData()
return hctx.SendResponse(data, hctx.RequestID)
}
////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////
// GetSystemdInfoHandler handles detailed systemd service info requests
type GetSystemdInfoHandler struct{}
func (h *GetSystemdInfoHandler) Handle(hctx *HandlerContext) error {
if hctx.Agent.systemdManager == nil {
return errors.ErrUnsupported
}
var req common.SystemdInfoRequest
if err := cbor.Unmarshal(hctx.Request.Data, &req); err != nil {
return err
}
if req.ServiceName == "" {
return errors.New("service name is required")
}
details, err := hctx.Agent.systemdManager.getServiceDetails(req.ServiceName)
if err != nil {
return err
}
return hctx.SendResponse(details, hctx.RequestID)
}

112
agent/handlers_test.go Normal file
View File

@@ -0,0 +1,112 @@
//go:build testing
// +build testing
package agent
import (
"testing"
"github.com/fxamacker/cbor/v2"
"github.com/henrygd/beszel/internal/common"
"github.com/stretchr/testify/assert"
)
// MockHandler for testing
type MockHandler struct {
requiresVerification bool
description string
handleFunc func(ctx *HandlerContext) error
}
func (m *MockHandler) Handle(ctx *HandlerContext) error {
if m.handleFunc != nil {
return m.handleFunc(ctx)
}
return nil
}
func (m *MockHandler) RequiresVerification() bool {
return m.requiresVerification
}
// TestHandlerRegistry tests the handler registry functionality
func TestHandlerRegistry(t *testing.T) {
t.Run("default registration", func(t *testing.T) {
registry := NewHandlerRegistry()
// Check default handlers are registered
getDataHandler, exists := registry.GetHandler(common.GetData)
assert.True(t, exists)
assert.IsType(t, &GetDataHandler{}, getDataHandler)
fingerprintHandler, exists := registry.GetHandler(common.CheckFingerprint)
assert.True(t, exists)
assert.IsType(t, &CheckFingerprintHandler{}, fingerprintHandler)
})
t.Run("custom handler registration", func(t *testing.T) {
registry := NewHandlerRegistry()
mockHandler := &MockHandler{
requiresVerification: true,
description: "Test handler",
}
// Register a custom handler for a mock action
const mockAction common.WebSocketAction = 99
registry.Register(mockAction, mockHandler)
// Verify registration
handler, exists := registry.GetHandler(mockAction)
assert.True(t, exists)
assert.Equal(t, mockHandler, handler)
})
t.Run("unknown action", func(t *testing.T) {
registry := NewHandlerRegistry()
ctx := &HandlerContext{
Request: &common.HubRequest[cbor.RawMessage]{
Action: common.WebSocketAction(255), // Unknown action
},
HubVerified: true,
}
err := registry.Handle(ctx)
assert.Error(t, err)
assert.Contains(t, err.Error(), "unknown action: 255")
})
t.Run("verification required", func(t *testing.T) {
registry := NewHandlerRegistry()
ctx := &HandlerContext{
Request: &common.HubRequest[cbor.RawMessage]{
Action: common.GetData, // Requires verification
},
HubVerified: false, // Not verified
}
err := registry.Handle(ctx)
assert.Error(t, err)
assert.Contains(t, err.Error(), "hub not verified")
})
}
// TestCheckFingerprintHandler tests the CheckFingerprint handler
func TestCheckFingerprintHandler(t *testing.T) {
handler := &CheckFingerprintHandler{}
t.Run("handle with invalid data", func(t *testing.T) {
client := &WebSocketClient{}
ctx := &HandlerContext{
Client: client,
HubVerified: false,
Request: &common.HubRequest[cbor.RawMessage]{
Action: common.CheckFingerprint,
Data: cbor.RawMessage{}, // Empty/invalid data
},
}
// Should fail to decode the fingerprint request
err := handler.Handle(ctx)
assert.Error(t, err)
})
}

View File

@@ -9,11 +9,31 @@ import (
"log" "log"
"os" "os"
"path/filepath" "path/filepath"
"runtime"
"time" "time"
) )
// healthFile is the path to the health file // healthFile is the path to the health file
var healthFile = filepath.Join(os.TempDir(), "beszel_health") var healthFile = getHealthFilePath()
func getHealthFilePath() string {
filename := "beszel_health"
if runtime.GOOS == "linux" {
fullPath := filepath.Join("/dev/shm", filename)
if err := updateHealthFile(fullPath); err == nil {
return fullPath
}
}
return filepath.Join(os.TempDir(), filename)
}
func updateHealthFile(path string) error {
file, err := os.Create(path)
if err != nil {
return err
}
return file.Close()
}
// Check checks if the agent is connected by checking the modification time of the health file // Check checks if the agent is connected by checking the modification time of the health file
func Check() error { func Check() error {
@@ -30,11 +50,7 @@ func Check() error {
// Update updates the modification time of the health file // Update updates the modification time of the health file
func Update() error { func Update() error {
file, err := os.Create(healthFile) return updateHealthFile(healthFile)
if err != nil {
return err
}
return file.Close()
} }
// CleanUp removes the health file // CleanUp removes the health file

View File

@@ -52,7 +52,12 @@ class Program
foreach (var sensor in hardware.Sensors) foreach (var sensor in hardware.Sensors)
{ {
var validTemp = sensor.SensorType == SensorType.Temperature && sensor.Value.HasValue; var validTemp = sensor.SensorType == SensorType.Temperature && sensor.Value.HasValue;
if (!validTemp || sensor.Name.Contains("Distance")) if (!validTemp ||
sensor.Name.IndexOf("Distance", StringComparison.OrdinalIgnoreCase) >= 0 ||
sensor.Name.IndexOf("Limit", StringComparison.OrdinalIgnoreCase) >= 0 ||
sensor.Name.IndexOf("Critical", StringComparison.OrdinalIgnoreCase) >= 0 ||
sensor.Name.IndexOf("Warning", StringComparison.OrdinalIgnoreCase) >= 0 ||
sensor.Name.IndexOf("Resolution", StringComparison.OrdinalIgnoreCase) >= 0)
{ {
continue; continue;
} }

View File

@@ -3,9 +3,11 @@
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
<TargetFramework>net48</TargetFramework> <TargetFramework>net48</TargetFramework>
<Platforms>x64</Platforms> <Platforms>x64</Platforms>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="LibreHardwareMonitorLib" Version="0.9.4" /> <PackageReference Include="LibreHardwareMonitorLib" Version="0.9.5" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -3,6 +3,7 @@ package agent
import ( import (
"fmt" "fmt"
"log/slog" "log/slog"
"path"
"strings" "strings"
"time" "time"
@@ -11,10 +12,116 @@ import (
psutilNet "github.com/shirou/gopsutil/v4/net" psutilNet "github.com/shirou/gopsutil/v4/net"
) )
var netInterfaceDeltaTracker = deltatracker.NewDeltaTracker[string, uint64]() // NicConfig controls inclusion/exclusion of network interfaces via the NICS env var
//
// Behavior mirrors SensorConfig's matching logic:
// - Leading '-' means blacklist mode; otherwise whitelist mode
// - Supports '*' wildcards using path.Match
// - In whitelist mode with an empty list, no NICs are selected
// - In blacklist mode with an empty list, all NICs are selected
type NicConfig struct {
nics map[string]struct{}
isBlacklist bool
hasWildcards bool
}
func (a *Agent) updateNetworkStats(systemStats *system.Stats) { func newNicConfig(nicsEnvVal string) *NicConfig {
cfg := &NicConfig{
nics: make(map[string]struct{}),
}
if strings.HasPrefix(nicsEnvVal, "-") {
cfg.isBlacklist = true
nicsEnvVal = nicsEnvVal[1:]
}
for nic := range strings.SplitSeq(nicsEnvVal, ",") {
nic = strings.TrimSpace(nic)
if nic != "" {
cfg.nics[nic] = struct{}{}
if strings.Contains(nic, "*") {
cfg.hasWildcards = true
}
}
}
return cfg
}
// isValidNic determines if a NIC should be included based on NicConfig rules
func isValidNic(nicName string, cfg *NicConfig) bool {
// Empty list behavior differs by mode: blacklist: allow all; whitelist: allow none
if len(cfg.nics) == 0 {
return cfg.isBlacklist
}
// Exact match: return true if whitelist, false if blacklist
if _, exactMatch := cfg.nics[nicName]; exactMatch {
return !cfg.isBlacklist
}
// If no wildcards, return true if blacklist, false if whitelist
if !cfg.hasWildcards {
return cfg.isBlacklist
}
// Check for wildcard patterns
for pattern := range cfg.nics {
if !strings.Contains(pattern, "*") {
continue
}
if match, _ := path.Match(pattern, nicName); match {
return !cfg.isBlacklist
}
}
return cfg.isBlacklist
}
func (a *Agent) updateNetworkStats(cacheTimeMs uint16, systemStats *system.Stats) {
// network stats // network stats
a.ensureNetInterfacesInitialized()
a.ensureNetworkInterfacesMap(systemStats)
if netIO, err := psutilNet.IOCounters(true); err == nil {
nis, msElapsed := a.loadAndTickNetBaseline(cacheTimeMs)
totalBytesSent, totalBytesRecv := a.sumAndTrackPerNicDeltas(cacheTimeMs, msElapsed, netIO, systemStats)
bytesSentPerSecond, bytesRecvPerSecond := a.computeBytesPerSecond(msElapsed, totalBytesSent, totalBytesRecv, nis)
a.applyNetworkTotals(cacheTimeMs, netIO, systemStats, nis, totalBytesSent, totalBytesRecv, bytesSentPerSecond, bytesRecvPerSecond)
}
}
func (a *Agent) initializeNetIoStats() {
// reset valid network interfaces
a.netInterfaces = make(map[string]struct{}, 0)
// parse NICS env var for whitelist / blacklist
nicsEnvVal, nicsEnvExists := GetEnv("NICS")
var nicCfg *NicConfig
if nicsEnvExists {
nicCfg = newNicConfig(nicsEnvVal)
}
// get current network I/O stats and record valid interfaces
if netIO, err := psutilNet.IOCounters(true); err == nil {
for _, v := range netIO {
if nicsEnvExists && !isValidNic(v.Name, nicCfg) {
continue
}
if a.skipNetworkInterface(v) {
continue
}
slog.Info("Detected network interface", "name", v.Name, "sent", v.BytesSent, "recv", v.BytesRecv)
// store as a valid network interface
a.netInterfaces[v.Name] = struct{}{}
}
}
// Reset per-cache-time trackers and baselines so they will reinitialize on next use
a.netInterfaceDeltaTrackers = make(map[uint16]*deltatracker.DeltaTracker[string, uint64])
a.netIoStats = make(map[uint16]system.NetIoStats)
}
// ensureNetInterfacesInitialized re-initializes NICs if none are currently tracked
func (a *Agent) ensureNetInterfacesInitialized() {
if len(a.netInterfaces) == 0 { if len(a.netInterfaces) == 0 {
// if no network interfaces, initialize again // if no network interfaces, initialize again
// this is a fix if agent started before network is online (#466) // this is a fix if agent started before network is online (#466)
@@ -22,48 +129,94 @@ func (a *Agent) updateNetworkStats(systemStats *system.Stats) {
// don't miss an interface that's been added after agent started in any circumstance // don't miss an interface that's been added after agent started in any circumstance
a.initializeNetIoStats() a.initializeNetIoStats()
} }
}
// ensureNetworkInterfacesMap ensures systemStats.NetworkInterfaces map exists
func (a *Agent) ensureNetworkInterfacesMap(systemStats *system.Stats) {
if systemStats.NetworkInterfaces == nil { if systemStats.NetworkInterfaces == nil {
systemStats.NetworkInterfaces = make(map[string][4]uint64, 0) systemStats.NetworkInterfaces = make(map[string][4]uint64, 0)
} }
}
// loadAndTickNetBaseline returns the NetIoStats baseline and milliseconds elapsed, updating time
func (a *Agent) loadAndTickNetBaseline(cacheTimeMs uint16) (netIoStat system.NetIoStats, msElapsed uint64) {
netIoStat = a.netIoStats[cacheTimeMs]
if netIoStat.Time.IsZero() {
netIoStat.Time = time.Now()
msElapsed = 0
} else {
msElapsed = uint64(time.Since(netIoStat.Time).Milliseconds())
netIoStat.Time = time.Now()
}
return netIoStat, msElapsed
}
// sumAndTrackPerNicDeltas accumulates totals and records per-NIC up/down deltas into systemStats
func (a *Agent) sumAndTrackPerNicDeltas(cacheTimeMs uint16, msElapsed uint64, netIO []psutilNet.IOCountersStat, systemStats *system.Stats) (totalBytesSent, totalBytesRecv uint64) {
tracker := a.netInterfaceDeltaTrackers[cacheTimeMs]
if tracker == nil {
tracker = deltatracker.NewDeltaTracker[string, uint64]()
a.netInterfaceDeltaTrackers[cacheTimeMs] = tracker
}
tracker.Cycle()
if netIO, err := psutilNet.IOCounters(true); err == nil {
msElapsed := uint64(time.Since(a.netIoStats.Time).Milliseconds())
a.netIoStats.Time = time.Now()
totalBytesSent := uint64(0)
totalBytesRecv := uint64(0)
netInterfaceDeltaTracker.Cycle()
// sum all bytes sent and received
for _, v := range netIO { for _, v := range netIO {
// skip if not in valid network interfaces list
if _, exists := a.netInterfaces[v.Name]; !exists { if _, exists := a.netInterfaces[v.Name]; !exists {
continue continue
} }
totalBytesSent += v.BytesSent totalBytesSent += v.BytesSent
totalBytesRecv += v.BytesRecv totalBytesRecv += v.BytesRecv
// track deltas for each network interface
var upDelta, downDelta uint64 var upDelta, downDelta uint64
upKey, downKey := fmt.Sprintf("%sup", v.Name), fmt.Sprintf("%sdown", v.Name) upKey, downKey := fmt.Sprintf("%sup", v.Name), fmt.Sprintf("%sdown", v.Name)
netInterfaceDeltaTracker.Set(upKey, v.BytesSent) tracker.Set(upKey, v.BytesSent)
netInterfaceDeltaTracker.Set(downKey, v.BytesRecv) tracker.Set(downKey, v.BytesRecv)
if msElapsed > 0 { if msElapsed > 0 {
upDelta = netInterfaceDeltaTracker.Delta(upKey) * 1000 / msElapsed if prevVal, ok := tracker.Previous(upKey); ok {
downDelta = netInterfaceDeltaTracker.Delta(downKey) * 1000 / msElapsed var deltaBytes uint64
if v.BytesSent >= prevVal {
deltaBytes = v.BytesSent - prevVal
} else {
deltaBytes = v.BytesSent
}
upDelta = deltaBytes * 1000 / msElapsed
}
if prevVal, ok := tracker.Previous(downKey); ok {
var deltaBytes uint64
if v.BytesRecv >= prevVal {
deltaBytes = v.BytesRecv - prevVal
} else {
deltaBytes = v.BytesRecv
}
downDelta = deltaBytes * 1000 / msElapsed
}
} }
// add interface to systemStats
systemStats.NetworkInterfaces[v.Name] = [4]uint64{upDelta, downDelta, v.BytesSent, v.BytesRecv} systemStats.NetworkInterfaces[v.Name] = [4]uint64{upDelta, downDelta, v.BytesSent, v.BytesRecv}
} }
// add to systemStats return totalBytesSent, totalBytesRecv
var bytesSentPerSecond, bytesRecvPerSecond uint64
if msElapsed > 0 {
bytesSentPerSecond = (totalBytesSent - a.netIoStats.BytesSent) * 1000 / msElapsed
bytesRecvPerSecond = (totalBytesRecv - a.netIoStats.BytesRecv) * 1000 / msElapsed
} }
// computeBytesPerSecond calculates per-second totals from elapsed time and totals
func (a *Agent) computeBytesPerSecond(msElapsed, totalBytesSent, totalBytesRecv uint64, nis system.NetIoStats) (bytesSentPerSecond, bytesRecvPerSecond uint64) {
if msElapsed > 0 {
bytesSentPerSecond = (totalBytesSent - nis.BytesSent) * 1000 / msElapsed
bytesRecvPerSecond = (totalBytesRecv - nis.BytesRecv) * 1000 / msElapsed
}
return bytesSentPerSecond, bytesRecvPerSecond
}
// applyNetworkTotals validates and writes computed network stats, or resets on anomaly
func (a *Agent) applyNetworkTotals(
cacheTimeMs uint16,
netIO []psutilNet.IOCountersStat,
systemStats *system.Stats,
nis system.NetIoStats,
totalBytesSent, totalBytesRecv uint64,
bytesSentPerSecond, bytesRecvPerSecond uint64,
) {
networkSentPs := bytesToMegabytes(float64(bytesSentPerSecond)) networkSentPs := bytesToMegabytes(float64(bytesSentPerSecond))
networkRecvPs := bytesToMegabytes(float64(bytesRecvPerSecond)) networkRecvPs := bytesToMegabytes(float64(bytesRecvPerSecond))
// add check for issue (#150) where sent is a massive number
if networkSentPs > 10_000 || networkRecvPs > 10_000 { if networkSentPs > 10_000 || networkRecvPs > 10_000 {
slog.Warn("Invalid net stats. Resetting.", "sent", networkSentPs, "recv", networkRecvPs) slog.Warn("Invalid net stats. Resetting.", "sent", networkSentPs, "recv", networkRecvPs)
for _, v := range netIO { for _, v := range netIO {
@@ -72,60 +225,21 @@ func (a *Agent) updateNetworkStats(systemStats *system.Stats) {
} }
slog.Info(v.Name, "recv", v.BytesRecv, "sent", v.BytesSent) slog.Info(v.Name, "recv", v.BytesRecv, "sent", v.BytesSent)
} }
// reset network I/O stats
a.initializeNetIoStats() a.initializeNetIoStats()
} else { delete(a.netIoStats, cacheTimeMs)
delete(a.netInterfaceDeltaTrackers, cacheTimeMs)
systemStats.NetworkSent = 0
systemStats.NetworkRecv = 0
systemStats.Bandwidth[0], systemStats.Bandwidth[1] = 0, 0
return
}
systemStats.NetworkSent = networkSentPs systemStats.NetworkSent = networkSentPs
systemStats.NetworkRecv = networkRecvPs systemStats.NetworkRecv = networkRecvPs
systemStats.Bandwidth[0], systemStats.Bandwidth[1] = bytesSentPerSecond, bytesRecvPerSecond systemStats.Bandwidth[0], systemStats.Bandwidth[1] = bytesSentPerSecond, bytesRecvPerSecond
// update netIoStats nis.BytesSent = totalBytesSent
a.netIoStats.BytesSent = totalBytesSent nis.BytesRecv = totalBytesRecv
a.netIoStats.BytesRecv = totalBytesRecv a.netIoStats[cacheTimeMs] = nis
}
}
}
func (a *Agent) initializeNetIoStats() {
// reset valid network interfaces
a.netInterfaces = make(map[string]struct{}, 0)
// map of network interface names passed in via NICS env var
var nicsMap map[string]struct{}
nics, nicsEnvExists := GetEnv("NICS")
if nicsEnvExists {
nicsMap = make(map[string]struct{}, 0)
for nic := range strings.SplitSeq(nics, ",") {
nicsMap[nic] = struct{}{}
}
}
// reset network I/O stats
a.netIoStats.BytesSent = 0
a.netIoStats.BytesRecv = 0
// get intial network I/O stats
if netIO, err := psutilNet.IOCounters(true); err == nil {
a.netIoStats.Time = time.Now()
for _, v := range netIO {
switch {
// skip if nics exists and the interface is not in the list
case nicsEnvExists:
if _, nameInNics := nicsMap[v.Name]; !nameInNics {
continue
}
// otherwise run the interface name through the skipNetworkInterface function
default:
if a.skipNetworkInterface(v) {
continue
}
}
slog.Info("Detected network interface", "name", v.Name, "sent", v.BytesSent, "recv", v.BytesRecv)
a.netIoStats.BytesSent += v.BytesSent
a.netIoStats.BytesRecv += v.BytesRecv
// store as a valid network interface
a.netInterfaces[v.Name] = struct{}{}
}
}
} }
func (a *Agent) skipNetworkInterface(v psutilNet.IOCountersStat) bool { func (a *Agent) skipNetworkInterface(v psutilNet.IOCountersStat) bool {
@@ -135,6 +249,7 @@ func (a *Agent) skipNetworkInterface(v psutilNet.IOCountersStat) bool {
strings.HasPrefix(v.Name, "br-"), strings.HasPrefix(v.Name, "br-"),
strings.HasPrefix(v.Name, "veth"), strings.HasPrefix(v.Name, "veth"),
strings.HasPrefix(v.Name, "bond"), strings.HasPrefix(v.Name, "bond"),
strings.HasPrefix(v.Name, "cali"),
v.BytesRecv == 0, v.BytesRecv == 0,
v.BytesSent == 0: v.BytesSent == 0:
return true return true

502
agent/network_test.go Normal file
View File

@@ -0,0 +1,502 @@
//go:build testing
package agent
import (
"testing"
"time"
"github.com/henrygd/beszel/agent/deltatracker"
"github.com/henrygd/beszel/internal/entities/system"
psutilNet "github.com/shirou/gopsutil/v4/net"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestIsValidNic(t *testing.T) {
tests := []struct {
name string
nicName string
config *NicConfig
expectedValid bool
}{
{
name: "Whitelist - NIC in list",
nicName: "eth0",
config: &NicConfig{
nics: map[string]struct{}{"eth0": {}},
isBlacklist: false,
},
expectedValid: true,
},
{
name: "Whitelist - NIC not in list",
nicName: "wlan0",
config: &NicConfig{
nics: map[string]struct{}{"eth0": {}},
isBlacklist: false,
},
expectedValid: false,
},
{
name: "Blacklist - NIC in list",
nicName: "eth0",
config: &NicConfig{
nics: map[string]struct{}{"eth0": {}},
isBlacklist: true,
},
expectedValid: false,
},
{
name: "Blacklist - NIC not in list",
nicName: "wlan0",
config: &NicConfig{
nics: map[string]struct{}{"eth0": {}},
isBlacklist: true,
},
expectedValid: true,
},
{
name: "Whitelist with wildcard - matching pattern",
nicName: "eth1",
config: &NicConfig{
nics: map[string]struct{}{"eth*": {}},
isBlacklist: false,
hasWildcards: true,
},
expectedValid: true,
},
{
name: "Whitelist with wildcard - non-matching pattern",
nicName: "wlan0",
config: &NicConfig{
nics: map[string]struct{}{"eth*": {}},
isBlacklist: false,
hasWildcards: true,
},
expectedValid: false,
},
{
name: "Blacklist with wildcard - matching pattern",
nicName: "eth1",
config: &NicConfig{
nics: map[string]struct{}{"eth*": {}},
isBlacklist: true,
hasWildcards: true,
},
expectedValid: false,
},
{
name: "Blacklist with wildcard - non-matching pattern",
nicName: "wlan0",
config: &NicConfig{
nics: map[string]struct{}{"eth*": {}},
isBlacklist: true,
hasWildcards: true,
},
expectedValid: true,
},
{
name: "Empty whitelist config - no NICs allowed",
nicName: "eth0",
config: &NicConfig{
nics: map[string]struct{}{},
isBlacklist: false,
},
expectedValid: false,
},
{
name: "Empty blacklist config - all NICs allowed",
nicName: "eth0",
config: &NicConfig{
nics: map[string]struct{}{},
isBlacklist: true,
},
expectedValid: true,
},
{
name: "Multiple patterns - exact match",
nicName: "eth0",
config: &NicConfig{
nics: map[string]struct{}{"eth0": {}, "wlan*": {}},
isBlacklist: false,
},
expectedValid: true,
},
{
name: "Multiple patterns - wildcard match",
nicName: "wlan1",
config: &NicConfig{
nics: map[string]struct{}{"eth0": {}, "wlan*": {}},
isBlacklist: false,
hasWildcards: true,
},
expectedValid: true,
},
{
name: "Multiple patterns - no match",
nicName: "bond0",
config: &NicConfig{
nics: map[string]struct{}{"eth0": {}, "wlan*": {}},
isBlacklist: false,
hasWildcards: true,
},
expectedValid: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := isValidNic(tt.nicName, tt.config)
assert.Equal(t, tt.expectedValid, result)
})
}
}
func TestNewNicConfig(t *testing.T) {
tests := []struct {
name string
nicsEnvVal string
expectedCfg *NicConfig
}{
{
name: "Empty string",
nicsEnvVal: "",
expectedCfg: &NicConfig{
nics: map[string]struct{}{},
isBlacklist: false,
hasWildcards: false,
},
},
{
name: "Single NIC whitelist",
nicsEnvVal: "eth0",
expectedCfg: &NicConfig{
nics: map[string]struct{}{"eth0": {}},
isBlacklist: false,
hasWildcards: false,
},
},
{
name: "Multiple NICs whitelist",
nicsEnvVal: "eth0,wlan0",
expectedCfg: &NicConfig{
nics: map[string]struct{}{"eth0": {}, "wlan0": {}},
isBlacklist: false,
hasWildcards: false,
},
},
{
name: "Blacklist mode",
nicsEnvVal: "-eth0,wlan0",
expectedCfg: &NicConfig{
nics: map[string]struct{}{"eth0": {}, "wlan0": {}},
isBlacklist: true,
hasWildcards: false,
},
},
{
name: "With wildcards",
nicsEnvVal: "eth*,wlan0",
expectedCfg: &NicConfig{
nics: map[string]struct{}{"eth*": {}, "wlan0": {}},
isBlacklist: false,
hasWildcards: true,
},
},
{
name: "Blacklist with wildcards",
nicsEnvVal: "-eth*,wlan0",
expectedCfg: &NicConfig{
nics: map[string]struct{}{"eth*": {}, "wlan0": {}},
isBlacklist: true,
hasWildcards: true,
},
},
{
name: "With whitespace",
nicsEnvVal: "eth0, wlan0 , eth1",
expectedCfg: &NicConfig{
nics: map[string]struct{}{"eth0": {}, "wlan0": {}, "eth1": {}},
isBlacklist: false,
hasWildcards: false,
},
},
{
name: "Only wildcards",
nicsEnvVal: "eth*,wlan*",
expectedCfg: &NicConfig{
nics: map[string]struct{}{"eth*": {}, "wlan*": {}},
isBlacklist: false,
hasWildcards: true,
},
},
{
name: "Leading dash only",
nicsEnvVal: "-",
expectedCfg: &NicConfig{
nics: map[string]struct{}{},
isBlacklist: true,
hasWildcards: false,
},
},
{
name: "Mixed exact and wildcard",
nicsEnvVal: "eth0,br-*",
expectedCfg: &NicConfig{
nics: map[string]struct{}{"eth0": {}, "br-*": {}},
isBlacklist: false,
hasWildcards: true,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := newNicConfig(tt.nicsEnvVal)
require.NotNil(t, cfg)
assert.Equal(t, tt.expectedCfg.isBlacklist, cfg.isBlacklist)
assert.Equal(t, tt.expectedCfg.hasWildcards, cfg.hasWildcards)
assert.Equal(t, tt.expectedCfg.nics, cfg.nics)
})
}
}
func TestEnsureNetworkInterfacesMap(t *testing.T) {
var a Agent
var stats system.Stats
// Initially nil
assert.Nil(t, stats.NetworkInterfaces)
// Ensure map is created
a.ensureNetworkInterfacesMap(&stats)
assert.NotNil(t, stats.NetworkInterfaces)
// Idempotent
a.ensureNetworkInterfacesMap(&stats)
assert.NotNil(t, stats.NetworkInterfaces)
}
func TestLoadAndTickNetBaseline(t *testing.T) {
a := &Agent{netIoStats: make(map[uint16]system.NetIoStats)}
// First call initializes time and returns 0 elapsed
ni, elapsed := a.loadAndTickNetBaseline(100)
assert.Equal(t, uint64(0), elapsed)
assert.False(t, ni.Time.IsZero())
// Store back what loadAndTick returns to mimic updateNetworkStats behavior
a.netIoStats[100] = ni
time.Sleep(2 * time.Millisecond)
// Next call should produce >= 0 elapsed and update time
ni2, elapsed2 := a.loadAndTickNetBaseline(100)
assert.True(t, elapsed2 > 0)
assert.False(t, ni2.Time.IsZero())
}
func TestComputeBytesPerSecond(t *testing.T) {
a := &Agent{}
// No elapsed -> zero rate
bytesUp, bytesDown := a.computeBytesPerSecond(0, 2000, 3000, system.NetIoStats{BytesSent: 1000, BytesRecv: 1000})
assert.Equal(t, uint64(0), bytesUp)
assert.Equal(t, uint64(0), bytesDown)
// With elapsed -> per-second calculation
bytesUp, bytesDown = a.computeBytesPerSecond(500, 6000, 11000, system.NetIoStats{BytesSent: 1000, BytesRecv: 1000})
// (6000-1000)*1000/500 = 10000; (11000-1000)*1000/500 = 20000
assert.Equal(t, uint64(10000), bytesUp)
assert.Equal(t, uint64(20000), bytesDown)
}
func TestSumAndTrackPerNicDeltas(t *testing.T) {
a := &Agent{
netInterfaces: map[string]struct{}{"eth0": {}, "wlan0": {}},
netInterfaceDeltaTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
}
// Two samples for same cache interval to verify delta behavior
cache := uint16(42)
net1 := []psutilNet.IOCountersStat{{Name: "eth0", BytesSent: 1000, BytesRecv: 2000}}
stats1 := &system.Stats{}
a.ensureNetworkInterfacesMap(stats1)
tx1, rx1 := a.sumAndTrackPerNicDeltas(cache, 0, net1, stats1)
assert.Equal(t, uint64(1000), tx1)
assert.Equal(t, uint64(2000), rx1)
// Second cycle with elapsed, larger counters -> deltas computed inside
net2 := []psutilNet.IOCountersStat{{Name: "eth0", BytesSent: 4000, BytesRecv: 9000}}
stats := &system.Stats{}
a.ensureNetworkInterfacesMap(stats)
tx2, rx2 := a.sumAndTrackPerNicDeltas(cache, 1000, net2, stats)
assert.Equal(t, uint64(4000), tx2)
assert.Equal(t, uint64(9000), rx2)
// Up/Down deltas per second should be (4000-1000)/1s = 3000 and (9000-2000)/1s = 7000
ni, ok := stats.NetworkInterfaces["eth0"]
assert.True(t, ok)
assert.Equal(t, uint64(3000), ni[0])
assert.Equal(t, uint64(7000), ni[1])
}
func TestSumAndTrackPerNicDeltasHandlesCounterReset(t *testing.T) {
a := &Agent{
netInterfaces: map[string]struct{}{"eth0": {}},
netInterfaceDeltaTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
}
cache := uint16(77)
// First interval establishes baseline values
initial := []psutilNet.IOCountersStat{{Name: "eth0", BytesSent: 4_000, BytesRecv: 6_000}}
statsInitial := &system.Stats{}
a.ensureNetworkInterfacesMap(statsInitial)
_, _ = a.sumAndTrackPerNicDeltas(cache, 0, initial, statsInitial)
// Second interval increments counters normally so previous snapshot gets populated
increment := []psutilNet.IOCountersStat{{Name: "eth0", BytesSent: 9_000, BytesRecv: 11_000}}
statsIncrement := &system.Stats{}
a.ensureNetworkInterfacesMap(statsIncrement)
_, _ = a.sumAndTrackPerNicDeltas(cache, 1_000, increment, statsIncrement)
niIncrement, ok := statsIncrement.NetworkInterfaces["eth0"]
require.True(t, ok)
assert.Equal(t, uint64(5_000), niIncrement[0])
assert.Equal(t, uint64(5_000), niIncrement[1])
// Third interval simulates counter reset (values drop below previous totals)
reset := []psutilNet.IOCountersStat{{Name: "eth0", BytesSent: 1_200, BytesRecv: 1_500}}
statsReset := &system.Stats{}
a.ensureNetworkInterfacesMap(statsReset)
_, _ = a.sumAndTrackPerNicDeltas(cache, 1_000, reset, statsReset)
niReset, ok := statsReset.NetworkInterfaces["eth0"]
require.True(t, ok)
assert.Equal(t, uint64(1_200), niReset[0], "upload delta should match new counter value after reset")
assert.Equal(t, uint64(1_500), niReset[1], "download delta should match new counter value after reset")
}
func TestApplyNetworkTotals(t *testing.T) {
tests := []struct {
name string
bytesSentPerSecond uint64
bytesRecvPerSecond uint64
totalBytesSent uint64
totalBytesRecv uint64
expectReset bool
expectedNetworkSent float64
expectedNetworkRecv float64
expectedBandwidthSent uint64
expectedBandwidthRecv uint64
}{
{
name: "Valid network stats - normal values",
bytesSentPerSecond: 1000000, // 1 MB/s
bytesRecvPerSecond: 2000000, // 2 MB/s
totalBytesSent: 10000000,
totalBytesRecv: 20000000,
expectReset: false,
expectedNetworkSent: 0.95, // ~1 MB/s rounded to 2 decimals
expectedNetworkRecv: 1.91, // ~2 MB/s rounded to 2 decimals
expectedBandwidthSent: 1000000,
expectedBandwidthRecv: 2000000,
},
{
name: "Invalid network stats - sent exceeds threshold",
bytesSentPerSecond: 11000000000, // ~10.5 GB/s > 10 GB/s threshold
bytesRecvPerSecond: 1000000, // 1 MB/s
totalBytesSent: 10000000,
totalBytesRecv: 20000000,
expectReset: true,
},
{
name: "Invalid network stats - recv exceeds threshold",
bytesSentPerSecond: 1000000, // 1 MB/s
bytesRecvPerSecond: 11000000000, // ~10.5 GB/s > 10 GB/s threshold
totalBytesSent: 10000000,
totalBytesRecv: 20000000,
expectReset: true,
},
{
name: "Invalid network stats - both exceed threshold",
bytesSentPerSecond: 12000000000, // ~11.4 GB/s
bytesRecvPerSecond: 13000000000, // ~12.4 GB/s
totalBytesSent: 10000000,
totalBytesRecv: 20000000,
expectReset: true,
},
{
name: "Valid network stats - at threshold boundary",
bytesSentPerSecond: 10485750000, // ~9999.99 MB/s (rounds to 9999.99)
bytesRecvPerSecond: 10485750000, // ~9999.99 MB/s (rounds to 9999.99)
totalBytesSent: 10000000,
totalBytesRecv: 20000000,
expectReset: false,
expectedNetworkSent: 9999.99,
expectedNetworkRecv: 9999.99,
expectedBandwidthSent: 10485750000,
expectedBandwidthRecv: 10485750000,
},
{
name: "Zero values",
bytesSentPerSecond: 0,
bytesRecvPerSecond: 0,
totalBytesSent: 0,
totalBytesRecv: 0,
expectReset: false,
expectedNetworkSent: 0.0,
expectedNetworkRecv: 0.0,
expectedBandwidthSent: 0,
expectedBandwidthRecv: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Setup agent with initialized maps
a := &Agent{
netInterfaces: make(map[string]struct{}),
netIoStats: make(map[uint16]system.NetIoStats),
netInterfaceDeltaTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
}
cacheTimeMs := uint16(100)
netIO := []psutilNet.IOCountersStat{
{Name: "eth0", BytesSent: 1000, BytesRecv: 2000},
}
systemStats := &system.Stats{}
nis := system.NetIoStats{}
a.applyNetworkTotals(
cacheTimeMs,
netIO,
systemStats,
nis,
tt.totalBytesSent,
tt.totalBytesRecv,
tt.bytesSentPerSecond,
tt.bytesRecvPerSecond,
)
if tt.expectReset {
// Should have reset network tracking state - maps cleared and stats zeroed
assert.NotContains(t, a.netIoStats, cacheTimeMs, "cache entry should be cleared after reset")
assert.NotContains(t, a.netInterfaceDeltaTrackers, cacheTimeMs, "tracker should be cleared on reset")
assert.Zero(t, systemStats.NetworkSent)
assert.Zero(t, systemStats.NetworkRecv)
assert.Zero(t, systemStats.Bandwidth[0])
assert.Zero(t, systemStats.Bandwidth[1])
} else {
// Should have applied stats
assert.Equal(t, tt.expectedNetworkSent, systemStats.NetworkSent)
assert.Equal(t, tt.expectedNetworkRecv, systemStats.NetworkRecv)
assert.Equal(t, tt.expectedBandwidthSent, systemStats.Bandwidth[0])
assert.Equal(t, tt.expectedBandwidthRecv, systemStats.Bandwidth[1])
// Should have updated NetIoStats
updatedNis := a.netIoStats[cacheTimeMs]
assert.Equal(t, tt.totalBytesSent, updatedNis.BytesSent)
assert.Equal(t, tt.totalBytesRecv, updatedNis.BytesRecv)
}
})
}
}

31
agent/response.go Normal file
View File

@@ -0,0 +1,31 @@
package agent
import (
"github.com/fxamacker/cbor/v2"
"github.com/henrygd/beszel/internal/common"
"github.com/henrygd/beszel/internal/entities/smart"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/henrygd/beszel/internal/entities/systemd"
)
// newAgentResponse creates an AgentResponse using legacy typed fields.
// This maintains backward compatibility with <= 0.17 hubs that expect specific fields.
func newAgentResponse(data any, requestID *uint32) common.AgentResponse {
response := common.AgentResponse{Id: requestID}
switch v := data.(type) {
case *system.CombinedData:
response.SystemData = v
case *common.FingerprintResponse:
response.Fingerprint = v
case string:
response.String = &v
case map[string]smart.SmartData:
response.SmartData = v
case systemd.ServiceDetails:
response.ServiceInfo = v
default:
// For unknown types, use the generic Data field
response.Data, _ = cbor.Marshal(data)
}
return response
}

View File

@@ -127,15 +127,70 @@ func (a *Agent) handleSession(s ssh.Session) {
hubVersion := a.getHubVersion(sessionID, sessionCtx) hubVersion := a.getHubVersion(sessionID, sessionCtx)
stats := a.gatherStats(sessionID) // Legacy one-shot behavior for older hubs
if hubVersion.LT(beszel.MinVersionAgentResponse) {
err := a.writeToSession(s, stats, hubVersion) if err := a.handleLegacyStats(s, hubVersion); err != nil {
if err != nil { slog.Error("Error encoding stats", "err", err)
slog.Error("Error encoding stats", "err", err, "stats", stats)
s.Exit(1) s.Exit(1)
} else { return
}
}
var req common.HubRequest[cbor.RawMessage]
if err := cbor.NewDecoder(s).Decode(&req); err != nil {
// Fallback to legacy one-shot if the first decode fails
if err2 := a.handleLegacyStats(s, hubVersion); err2 != nil {
slog.Error("Error encoding stats (fallback)", "err", err2)
s.Exit(1)
return
}
s.Exit(0)
return
}
if err := a.handleSSHRequest(s, &req); err != nil {
slog.Error("SSH request handling failed", "err", err)
s.Exit(1)
return
}
s.Exit(0) s.Exit(0)
} }
// handleSSHRequest builds a handler context and dispatches to the shared registry
func (a *Agent) handleSSHRequest(w io.Writer, req *common.HubRequest[cbor.RawMessage]) error {
// SSH does not support fingerprint auth action
if req.Action == common.CheckFingerprint {
return cbor.NewEncoder(w).Encode(common.AgentResponse{Error: "unsupported action"})
}
// responder that writes AgentResponse to stdout
// Uses legacy typed fields for backward compatibility with <= 0.17
sshResponder := func(data any, requestID *uint32) error {
response := newAgentResponse(data, requestID)
return cbor.NewEncoder(w).Encode(response)
}
ctx := &HandlerContext{
Client: nil,
Agent: a,
Request: req,
RequestID: nil,
HubVerified: true,
SendResponse: sshResponder,
}
if handler, ok := a.handlerRegistry.GetHandler(req.Action); ok {
if err := handler.Handle(ctx); err != nil {
return cbor.NewEncoder(w).Encode(common.AgentResponse{Error: err.Error()})
}
return nil
}
return cbor.NewEncoder(w).Encode(common.AgentResponse{Error: fmt.Sprintf("unknown action: %d", req.Action)})
}
// handleLegacyStats serves the legacy one-shot stats payload for older hubs
func (a *Agent) handleLegacyStats(w io.Writer, hubVersion semver.Version) error {
stats := a.gatherStats(common.DataRequestOptions{CacheTimeMs: 60_000})
return a.writeToSession(w, stats, hubVersion)
} }
// writeToSession encodes and writes system statistics to the session. // writeToSession encodes and writes system statistics to the session.

View File

@@ -513,7 +513,7 @@ func TestWriteToSessionEncoding(t *testing.T) {
err = json.Unmarshal([]byte(encodedData), &decodedJson) err = json.Unmarshal([]byte(encodedData), &decodedJson)
assert.Error(t, err, "Should not be valid JSON data") assert.Error(t, err, "Should not be valid JSON data")
assert.Equal(t, testData.Info.Hostname, decodedCbor.Info.Hostname) assert.Equal(t, testData.Details.Hostname, decodedCbor.Details.Hostname)
assert.Equal(t, testData.Stats.Cpu, decodedCbor.Stats.Cpu) assert.Equal(t, testData.Stats.Cpu, decodedCbor.Stats.Cpu)
} else { } else {
// Should be JSON - try to decode as JSON // Should be JSON - try to decode as JSON
@@ -526,7 +526,7 @@ func TestWriteToSessionEncoding(t *testing.T) {
assert.Error(t, err, "Should not be valid CBOR data") assert.Error(t, err, "Should not be valid CBOR data")
// Verify the decoded JSON data matches our test data // Verify the decoded JSON data matches our test data
assert.Equal(t, testData.Info.Hostname, decodedJson.Info.Hostname) assert.Equal(t, testData.Details.Hostname, decodedJson.Details.Hostname)
assert.Equal(t, testData.Stats.Cpu, decodedJson.Stats.Cpu) assert.Equal(t, testData.Stats.Cpu, decodedJson.Stats.Cpu)
// Verify it looks like JSON (starts with '{' and contains readable field names) // Verify it looks like JSON (starts with '{' and contains readable field names)
@@ -550,13 +550,12 @@ func createTestCombinedData() *system.CombinedData {
DiskUsed: 549755813888, // 512GB DiskUsed: 549755813888, // 512GB
DiskPct: 50.0, DiskPct: 50.0,
}, },
Info: system.Info{ Details: &system.Details{
Hostname: "test-host", Hostname: "test-host",
Cores: 8, },
CpuModel: "Test CPU Model", Info: system.Info{
Uptime: 3600, Uptime: 3600,
AgentVersion: "0.12.0", AgentVersion: "0.12.0",
Os: system.Linux,
}, },
Containers: []*container.Stats{ Containers: []*container.Stats{
{ {

1134
agent/smart.go Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,9 @@
//go:build !windows
package agent
import "errors"
func ensureEmbeddedSmartctl() (string, error) {
return "", errors.ErrUnsupported
}

954
agent/smart_test.go Normal file
View File

@@ -0,0 +1,954 @@
//go:build testing
// +build testing
package agent
import (
"errors"
"os"
"path/filepath"
"testing"
"github.com/henrygd/beszel/internal/entities/smart"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseSmartForScsi(t *testing.T) {
fixturePath := filepath.Join("test-data", "smart", "scsi.json")
data, err := os.ReadFile(fixturePath)
if err != nil {
t.Fatalf("failed reading fixture: %v", err)
}
sm := &SmartManager{
SmartDataMap: make(map[string]*smart.SmartData),
}
hasData, exitStatus := sm.parseSmartForScsi(data)
if !hasData {
t.Fatalf("expected SCSI data to parse successfully")
}
if exitStatus != 0 {
t.Fatalf("expected exit status 0, got %d", exitStatus)
}
deviceData, ok := sm.SmartDataMap["9YHSDH9B"]
if !ok {
t.Fatalf("expected smart data entry for serial 9YHSDH9B")
}
assert.Equal(t, deviceData.ModelName, "YADRO WUH721414AL4204")
assert.Equal(t, deviceData.SerialNumber, "9YHSDH9B")
assert.Equal(t, deviceData.FirmwareVersion, "C240")
assert.Equal(t, deviceData.DiskName, "/dev/sde")
assert.Equal(t, deviceData.DiskType, "scsi")
assert.EqualValues(t, deviceData.Temperature, 34)
assert.Equal(t, deviceData.SmartStatus, "PASSED")
assert.EqualValues(t, deviceData.Capacity, 14000519643136)
if len(deviceData.Attributes) == 0 {
t.Fatalf("expected attributes to be populated")
}
assertAttrValue(t, deviceData.Attributes, "PowerOnHours", 458)
assertAttrValue(t, deviceData.Attributes, "PowerOnMinutes", 25)
assertAttrValue(t, deviceData.Attributes, "GrownDefectList", 0)
assertAttrValue(t, deviceData.Attributes, "StartStopCycles", 2)
assertAttrValue(t, deviceData.Attributes, "LoadUnloadCycles", 418)
assertAttrValue(t, deviceData.Attributes, "ReadGigabytesProcessed", 3641)
assertAttrValue(t, deviceData.Attributes, "WriteGigabytesProcessed", 2124590)
assertAttrValue(t, deviceData.Attributes, "VerifyGigabytesProcessed", 0)
}
func TestParseSmartForSata(t *testing.T) {
fixturePath := filepath.Join("test-data", "smart", "sda.json")
data, err := os.ReadFile(fixturePath)
require.NoError(t, err)
sm := &SmartManager{
SmartDataMap: make(map[string]*smart.SmartData),
}
hasData, exitStatus := sm.parseSmartForSata(data)
require.True(t, hasData)
assert.Equal(t, 64, exitStatus)
deviceData, ok := sm.SmartDataMap["9C40918040082"]
require.True(t, ok, "expected smart data entry for serial 9C40918040082")
assert.Equal(t, "P3-2TB", deviceData.ModelName)
assert.Equal(t, "X0104A0", deviceData.FirmwareVersion)
assert.Equal(t, "/dev/sda", deviceData.DiskName)
assert.Equal(t, "sat", deviceData.DiskType)
assert.Equal(t, uint8(31), deviceData.Temperature)
assert.Equal(t, "PASSED", deviceData.SmartStatus)
assert.Equal(t, uint64(2048408248320), deviceData.Capacity)
if assert.NotEmpty(t, deviceData.Attributes) {
assertAttrValue(t, deviceData.Attributes, "Temperature_Celsius", 31)
}
}
func TestParseSmartForSataDeviceStatisticsTemperature(t *testing.T) {
jsonPayload := []byte(`{
"smartctl": {"exit_status": 0},
"device": {"name": "/dev/sdb", "type": "sat"},
"model_name": "SanDisk SSD U110 16GB",
"serial_number": "DEVSTAT123",
"firmware_version": "U21B001",
"user_capacity": {"bytes": 16013942784},
"smart_status": {"passed": true},
"ata_smart_attributes": {"table": []},
"ata_device_statistics": {
"pages": [
{
"number": 5,
"name": "Temperature Statistics",
"table": [
{"name": "Current Temperature", "value": 22, "flags": {"valid": true}}
]
}
]
}
}`)
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
hasData, exitStatus := sm.parseSmartForSata(jsonPayload)
require.True(t, hasData)
assert.Equal(t, 0, exitStatus)
deviceData, ok := sm.SmartDataMap["DEVSTAT123"]
require.True(t, ok, "expected smart data entry for serial DEVSTAT123")
assert.Equal(t, uint8(22), deviceData.Temperature)
}
func TestParseSmartForSataParentheticalRawValue(t *testing.T) {
jsonPayload := []byte(`{
"smartctl": {"exit_status": 0},
"device": {"name": "/dev/sdz", "type": "sat"},
"model_name": "Example",
"serial_number": "PARENTHESES123",
"firmware_version": "1.0",
"user_capacity": {"bytes": 1024},
"smart_status": {"passed": true},
"temperature": {"current": 25},
"ata_smart_attributes": {
"table": [
{
"id": 9,
"name": "Power_On_Hours",
"value": 93,
"worst": 55,
"thresh": 0,
"when_failed": "",
"raw": {
"value": 57891864217128,
"string": "39925 (212 206 0)"
}
}
]
}
}`)
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
hasData, exitStatus := sm.parseSmartForSata(jsonPayload)
require.True(t, hasData)
assert.Equal(t, 0, exitStatus)
data, ok := sm.SmartDataMap["PARENTHESES123"]
require.True(t, ok)
require.Len(t, data.Attributes, 1)
attr := data.Attributes[0]
assert.Equal(t, uint64(39925), attr.RawValue)
assert.Equal(t, "39925 (212 206 0)", attr.RawString)
}
func TestParseSmartForNvme(t *testing.T) {
fixturePath := filepath.Join("test-data", "smart", "nvme0.json")
data, err := os.ReadFile(fixturePath)
require.NoError(t, err)
sm := &SmartManager{
SmartDataMap: make(map[string]*smart.SmartData),
}
hasData, exitStatus := sm.parseSmartForNvme(data)
require.True(t, hasData)
assert.Equal(t, 0, exitStatus)
deviceData, ok := sm.SmartDataMap["2024031600129"]
require.True(t, ok, "expected smart data entry for serial 2024031600129")
assert.Equal(t, "PELADN 512GB", deviceData.ModelName)
assert.Equal(t, "VC2S038E", deviceData.FirmwareVersion)
assert.Equal(t, "/dev/nvme0", deviceData.DiskName)
assert.Equal(t, "nvme", deviceData.DiskType)
assert.Equal(t, uint8(61), deviceData.Temperature)
assert.Equal(t, "PASSED", deviceData.SmartStatus)
assert.Equal(t, uint64(512110190592), deviceData.Capacity)
if assert.NotEmpty(t, deviceData.Attributes) {
assertAttrValue(t, deviceData.Attributes, "PercentageUsed", 0)
assertAttrValue(t, deviceData.Attributes, "DataUnitsWritten", 16040567)
}
}
func TestHasDataForDevice(t *testing.T) {
sm := &SmartManager{
SmartDataMap: map[string]*smart.SmartData{
"serial-1": {DiskName: "/dev/sda"},
"serial-2": nil,
},
}
assert.True(t, sm.hasDataForDevice("/dev/sda"))
assert.False(t, sm.hasDataForDevice("/dev/sdb"))
}
func TestDevicesSnapshotReturnsCopy(t *testing.T) {
originalDevice := &DeviceInfo{Name: "/dev/sda"}
sm := &SmartManager{
SmartDevices: []*DeviceInfo{
originalDevice,
{Name: "/dev/sdb"},
},
}
snapshot := sm.devicesSnapshot()
require.Len(t, snapshot, 2)
sm.SmartDevices[0] = &DeviceInfo{Name: "/dev/sdz"}
assert.Equal(t, "/dev/sda", snapshot[0].Name)
snapshot[1] = &DeviceInfo{Name: "/dev/nvme0"}
assert.Equal(t, "/dev/sdb", sm.SmartDevices[1].Name)
sm.SmartDevices = append(sm.SmartDevices, &DeviceInfo{Name: "/dev/nvme1"})
assert.Len(t, snapshot, 2)
}
func TestScanDevicesWithEnvOverrideAndSeparator(t *testing.T) {
t.Setenv("SMART_DEVICES_SEPARATOR", "|")
t.Setenv("SMART_DEVICES", "/dev/sda:jmb39x-q,0|/dev/nvme0:nvme")
sm := &SmartManager{
SmartDataMap: make(map[string]*smart.SmartData),
}
err := sm.ScanDevices(true)
require.NoError(t, err)
require.Len(t, sm.SmartDevices, 2)
assert.Equal(t, "/dev/sda", sm.SmartDevices[0].Name)
assert.Equal(t, "jmb39x-q,0", sm.SmartDevices[0].Type)
assert.Equal(t, "/dev/nvme0", sm.SmartDevices[1].Name)
assert.Equal(t, "nvme", sm.SmartDevices[1].Type)
}
func TestScanDevicesWithEnvOverride(t *testing.T) {
t.Setenv("SMART_DEVICES", "/dev/sda:sat, /dev/nvme0:nvme")
sm := &SmartManager{
SmartDataMap: make(map[string]*smart.SmartData),
}
err := sm.ScanDevices(true)
require.NoError(t, err)
require.Len(t, sm.SmartDevices, 2)
assert.Equal(t, "/dev/sda", sm.SmartDevices[0].Name)
assert.Equal(t, "sat", sm.SmartDevices[0].Type)
assert.Equal(t, "/dev/nvme0", sm.SmartDevices[1].Name)
assert.Equal(t, "nvme", sm.SmartDevices[1].Type)
}
func TestScanDevicesWithEnvOverrideInvalid(t *testing.T) {
t.Setenv("SMART_DEVICES", ":sat")
sm := &SmartManager{
SmartDataMap: make(map[string]*smart.SmartData),
}
err := sm.ScanDevices(true)
require.Error(t, err)
}
func TestScanDevicesWithEnvOverrideEmpty(t *testing.T) {
t.Setenv("SMART_DEVICES", " ")
sm := &SmartManager{
SmartDataMap: make(map[string]*smart.SmartData),
}
err := sm.ScanDevices(true)
assert.ErrorIs(t, err, errNoValidSmartData)
assert.Empty(t, sm.SmartDevices)
}
func TestSmartctlArgsWithoutType(t *testing.T) {
device := &DeviceInfo{Name: "/dev/sda"}
sm := &SmartManager{}
args := sm.smartctlArgs(device, true)
assert.Equal(t, []string{"-a", "--json=c", "-n", "standby", "/dev/sda"}, args)
}
func TestSmartctlArgs(t *testing.T) {
sm := &SmartManager{}
sataDevice := &DeviceInfo{Name: "/dev/sda", Type: "sat"}
assert.Equal(t,
[]string{"-d", "sat", "-a", "--json=c", "-l", "devstat", "-n", "standby", "/dev/sda"},
sm.smartctlArgs(sataDevice, true),
)
assert.Equal(t,
[]string{"-d", "sat", "-a", "--json=c", "-l", "devstat", "/dev/sda"},
sm.smartctlArgs(sataDevice, false),
)
nvmeDevice := &DeviceInfo{Name: "/dev/nvme0", Type: "nvme"}
assert.Equal(t,
[]string{"-d", "nvme", "-a", "--json=c", "-n", "standby", "/dev/nvme0"},
sm.smartctlArgs(nvmeDevice, true),
)
assert.Equal(t,
[]string{"-a", "--json=c", "-n", "standby"},
sm.smartctlArgs(nil, true),
)
}
func TestResolveRefreshError(t *testing.T) {
scanErr := errors.New("scan failed")
collectErr := errors.New("collect failed")
tests := []struct {
name string
devices []*DeviceInfo
data map[string]*smart.SmartData
scanErr error
collectErr error
expectedErr error
expectNoErr bool
}{
{
name: "no devices returns scan error",
devices: nil,
data: make(map[string]*smart.SmartData),
scanErr: scanErr,
expectedErr: scanErr,
},
{
name: "has data ignores errors",
devices: []*DeviceInfo{{Name: "/dev/sda"}},
data: map[string]*smart.SmartData{"serial": {}},
scanErr: scanErr,
collectErr: collectErr,
expectNoErr: true,
},
{
name: "collect error preferred",
devices: []*DeviceInfo{{Name: "/dev/sda"}},
data: make(map[string]*smart.SmartData),
collectErr: collectErr,
expectedErr: collectErr,
},
{
name: "scan error returned when no data",
devices: []*DeviceInfo{{Name: "/dev/sda"}},
data: make(map[string]*smart.SmartData),
scanErr: scanErr,
expectedErr: scanErr,
},
{
name: "no errors returns sentinel",
devices: []*DeviceInfo{{Name: "/dev/sda"}},
data: make(map[string]*smart.SmartData),
expectedErr: errNoValidSmartData,
},
{
name: "no devices collect error",
devices: nil,
data: make(map[string]*smart.SmartData),
collectErr: collectErr,
expectedErr: collectErr,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
sm := &SmartManager{
SmartDevices: tt.devices,
SmartDataMap: tt.data,
}
err := sm.resolveRefreshError(tt.scanErr, tt.collectErr)
if tt.expectNoErr {
assert.NoError(t, err)
return
}
if tt.expectedErr == nil {
assert.NoError(t, err)
} else {
assert.Equal(t, tt.expectedErr, err)
}
})
}
}
func TestParseScan(t *testing.T) {
sm := &SmartManager{
SmartDataMap: map[string]*smart.SmartData{
"serial-active": {DiskName: "/dev/sda"},
"serial-stale": {DiskName: "/dev/sdb"},
},
}
scanJSON := []byte(`{
"devices": [
{"name": "/dev/sda", "type": "sat", "info_name": "/dev/sda [SAT]", "protocol": "ATA"},
{"name": "/dev/nvme0", "type": "nvme", "info_name": "/dev/nvme0", "protocol": "NVMe"}
]
}`)
devices, hasData := sm.parseScan(scanJSON)
assert.True(t, hasData)
sm.updateSmartDevices(devices)
require.Len(t, sm.SmartDevices, 2)
assert.Equal(t, "/dev/sda", sm.SmartDevices[0].Name)
assert.Equal(t, "sat", sm.SmartDevices[0].Type)
assert.Equal(t, "/dev/nvme0", sm.SmartDevices[1].Name)
assert.Equal(t, "nvme", sm.SmartDevices[1].Type)
_, activeExists := sm.SmartDataMap["serial-active"]
assert.True(t, activeExists, "active smart data should be preserved when device path remains")
_, staleExists := sm.SmartDataMap["serial-stale"]
assert.False(t, staleExists, "stale smart data entry should be removed when device path disappears")
}
func TestMergeDeviceListsPrefersConfigured(t *testing.T) {
scanned := []*DeviceInfo{
{Name: "/dev/sda", Type: "sat", InfoName: "scan-info", Protocol: "ATA"},
{Name: "/dev/nvme0", Type: "nvme"},
}
configured := []*DeviceInfo{
{Name: "/dev/sda", Type: "sat-override"},
{Name: "/dev/sdb", Type: "sat"},
}
merged := mergeDeviceLists(nil, scanned, configured)
require.Len(t, merged, 3)
byName := make(map[string]*DeviceInfo, len(merged))
for _, dev := range merged {
byName[dev.Name] = dev
}
require.Contains(t, byName, "/dev/sda")
assert.Equal(t, "sat-override", byName["/dev/sda"].Type, "configured type should override scanned type")
assert.Equal(t, "scan-info", byName["/dev/sda"].InfoName, "scan metadata should be preserved when config does not provide it")
require.Contains(t, byName, "/dev/nvme0")
assert.Equal(t, "nvme", byName["/dev/nvme0"].Type)
require.Contains(t, byName, "/dev/sdb")
assert.Equal(t, "sat", byName["/dev/sdb"].Type)
}
func TestMergeDeviceListsPreservesVerification(t *testing.T) {
existing := []*DeviceInfo{
{Name: "/dev/sda", Type: "sat+megaraid", parserType: "sat", typeVerified: true},
}
scanned := []*DeviceInfo{
{Name: "/dev/sda", Type: "nvme"},
}
merged := mergeDeviceLists(existing, scanned, nil)
require.Len(t, merged, 1)
device := merged[0]
assert.True(t, device.typeVerified)
assert.Equal(t, "sat", device.parserType)
assert.Equal(t, "sat+megaraid", device.Type)
}
func TestMergeDeviceListsUpdatesTypeWhenUnverified(t *testing.T) {
existing := []*DeviceInfo{
{Name: "/dev/sda", Type: "sat", parserType: "sat", typeVerified: false},
}
scanned := []*DeviceInfo{
{Name: "/dev/sda", Type: "nvme"},
}
merged := mergeDeviceLists(existing, scanned, nil)
require.Len(t, merged, 1)
device := merged[0]
assert.False(t, device.typeVerified)
assert.Equal(t, "nvme", device.Type)
assert.Equal(t, "", device.parserType)
}
func TestMergeDeviceListsHandlesDevicesWithSameNameAndDifferentTypes(t *testing.T) {
// There are use cases where the same device name is re-used,
// for example, a RAID controller with multiple drives.
scanned := []*DeviceInfo{
{Name: "/dev/sda", Type: "megaraid,0"},
{Name: "/dev/sda", Type: "megaraid,1"},
{Name: "/dev/sda", Type: "megaraid,2"},
}
merged := mergeDeviceLists(nil, scanned, nil)
require.Len(t, merged, 3, "should have 3 separate devices for RAID controller")
byKey := make(map[string]*DeviceInfo, len(merged))
for _, dev := range merged {
key := dev.Name + "|" + dev.Type
byKey[key] = dev
}
assert.Contains(t, byKey, "/dev/sda|megaraid,0")
assert.Contains(t, byKey, "/dev/sda|megaraid,1")
assert.Contains(t, byKey, "/dev/sda|megaraid,2")
}
func TestMergeDeviceListsHandlesMixedRAIDAndRegular(t *testing.T) {
// Test mixing RAID drives with regular devices
scanned := []*DeviceInfo{
{Name: "/dev/sda", Type: "megaraid,0"},
{Name: "/dev/sda", Type: "megaraid,1"},
{Name: "/dev/sdb", Type: "sat"},
{Name: "/dev/nvme0", Type: "nvme"},
}
merged := mergeDeviceLists(nil, scanned, nil)
require.Len(t, merged, 4, "should have 4 separate devices")
byKey := make(map[string]*DeviceInfo, len(merged))
for _, dev := range merged {
key := dev.Name + "|" + dev.Type
byKey[key] = dev
}
assert.Contains(t, byKey, "/dev/sda|megaraid,0")
assert.Contains(t, byKey, "/dev/sda|megaraid,1")
assert.Contains(t, byKey, "/dev/sdb|sat")
assert.Contains(t, byKey, "/dev/nvme0|nvme")
}
func TestUpdateSmartDevicesPreservesRAIDDrives(t *testing.T) {
// Test that updateSmartDevices correctly validates RAID drives using composite keys
sm := &SmartManager{
SmartDevices: []*DeviceInfo{
{Name: "/dev/sda", Type: "megaraid,0"},
{Name: "/dev/sda", Type: "megaraid,1"},
},
SmartDataMap: map[string]*smart.SmartData{
"serial-0": {
DiskName: "/dev/sda",
DiskType: "megaraid,0",
SerialNumber: "serial-0",
},
"serial-1": {
DiskName: "/dev/sda",
DiskType: "megaraid,1",
SerialNumber: "serial-1",
},
"serial-stale": {
DiskName: "/dev/sda",
DiskType: "megaraid,2",
SerialNumber: "serial-stale",
},
},
}
sm.updateSmartDevices(sm.SmartDevices)
// serial-0 and serial-1 should be preserved (matching devices exist)
assert.Contains(t, sm.SmartDataMap, "serial-0")
assert.Contains(t, sm.SmartDataMap, "serial-1")
// serial-stale should be removed (no matching device)
assert.NotContains(t, sm.SmartDataMap, "serial-stale")
}
func TestParseSmartOutputMarksVerified(t *testing.T) {
fixturePath := filepath.Join("test-data", "smart", "nvme0.json")
data, err := os.ReadFile(fixturePath)
require.NoError(t, err)
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
device := &DeviceInfo{Name: "/dev/nvme0"}
require.True(t, sm.parseSmartOutput(device, data))
assert.Equal(t, "nvme", device.Type)
assert.Equal(t, "nvme", device.parserType)
assert.True(t, device.typeVerified)
}
func TestParseSmartOutputKeepsCustomType(t *testing.T) {
fixturePath := filepath.Join("test-data", "smart", "sda.json")
data, err := os.ReadFile(fixturePath)
require.NoError(t, err)
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
device := &DeviceInfo{Name: "/dev/sda", Type: "sat+megaraid"}
require.True(t, sm.parseSmartOutput(device, data))
assert.Equal(t, "sat+megaraid", device.Type)
assert.Equal(t, "sat", device.parserType)
assert.True(t, device.typeVerified)
}
func TestParseSmartOutputResetsVerificationOnFailure(t *testing.T) {
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
device := &DeviceInfo{Name: "/dev/sda", Type: "sat", parserType: "sat", typeVerified: true}
assert.False(t, sm.parseSmartOutput(device, []byte("not json")))
assert.False(t, device.typeVerified)
assert.Equal(t, "sat", device.parserType)
}
func assertAttrValue(t *testing.T, attributes []*smart.SmartAttribute, name string, expected uint64) {
t.Helper()
attr := findAttr(attributes, name)
if attr == nil {
t.Fatalf("expected attribute %s to be present", name)
}
if attr.RawValue != expected {
t.Fatalf("unexpected attribute %s value: got %d, want %d", name, attr.RawValue, expected)
}
}
func findAttr(attributes []*smart.SmartAttribute, name string) *smart.SmartAttribute {
for _, attr := range attributes {
if attr != nil && attr.Name == name {
return attr
}
}
return nil
}
func TestIsVirtualDevice(t *testing.T) {
sm := &SmartManager{}
tests := []struct {
name string
vendor string
product string
model string
expected bool
}{
{"regular drive", "SEAGATE", "ST1000DM003", "ST1000DM003-1CH162", false},
{"qemu virtual", "QEMU", "QEMU HARDDISK", "QEMU HARDDISK", true},
{"virtualbox virtual", "VBOX", "HARDDISK", "VBOX HARDDISK", true},
{"vmware virtual", "VMWARE", "Virtual disk", "VMWARE Virtual disk", true},
{"virtual in model", "ATA", "VIRTUAL", "VIRTUAL DISK", true},
{"iet virtual", "IET", "VIRTUAL-DISK", "VIRTUAL-DISK", true},
{"hyper-v virtual", "MSFT", "VIRTUAL HD", "VIRTUAL HD", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
data := &smart.SmartInfoForSata{
ScsiVendor: tt.vendor,
ScsiProduct: tt.product,
ModelName: tt.model,
}
result := sm.isVirtualDevice(data)
assert.Equal(t, tt.expected, result)
})
}
}
func TestIsVirtualDeviceNvme(t *testing.T) {
sm := &SmartManager{}
tests := []struct {
name string
model string
expected bool
}{
{"regular nvme", "Samsung SSD 970 EVO Plus 1TB", false},
{"qemu virtual", "QEMU NVMe Ctrl", true},
{"virtualbox virtual", "VBOX NVMe", true},
{"vmware virtual", "VMWARE NVMe", true},
{"virtual in model", "Virtual NVMe Device", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
data := &smart.SmartInfoForNvme{
ModelName: tt.model,
}
result := sm.isVirtualDeviceNvme(data)
assert.Equal(t, tt.expected, result)
})
}
}
func TestIsVirtualDeviceScsi(t *testing.T) {
sm := &SmartManager{}
tests := []struct {
name string
vendor string
product string
model string
expected bool
}{
{"regular scsi", "SEAGATE", "ST1000DM003", "ST1000DM003-1CH162", false},
{"qemu virtual", "QEMU", "QEMU HARDDISK", "QEMU HARDDISK", true},
{"virtualbox virtual", "VBOX", "HARDDISK", "VBOX HARDDISK", true},
{"vmware virtual", "VMWARE", "Virtual disk", "VMWARE Virtual disk", true},
{"virtual in model", "ATA", "VIRTUAL", "VIRTUAL DISK", true},
{"iet virtual", "IET", "VIRTUAL-DISK", "VIRTUAL-DISK", true},
{"hyper-v virtual", "MSFT", "VIRTUAL HD", "VIRTUAL HD", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
data := &smart.SmartInfoForScsi{
ScsiVendor: tt.vendor,
ScsiProduct: tt.product,
ScsiModelName: tt.model,
}
result := sm.isVirtualDeviceScsi(data)
assert.Equal(t, tt.expected, result)
})
}
}
func TestRefreshExcludedDevices(t *testing.T) {
tests := []struct {
name string
envValue string
expectedDevs map[string]struct{}
}{
{
name: "empty env",
envValue: "",
expectedDevs: map[string]struct{}{},
},
{
name: "single device",
envValue: "/dev/sda",
expectedDevs: map[string]struct{}{
"/dev/sda": {},
},
},
{
name: "multiple devices",
envValue: "/dev/sda,/dev/sdb,/dev/nvme0",
expectedDevs: map[string]struct{}{
"/dev/sda": {},
"/dev/sdb": {},
"/dev/nvme0": {},
},
},
{
name: "devices with whitespace",
envValue: " /dev/sda , /dev/sdb , /dev/nvme0 ",
expectedDevs: map[string]struct{}{
"/dev/sda": {},
"/dev/sdb": {},
"/dev/nvme0": {},
},
},
{
name: "duplicate devices",
envValue: "/dev/sda,/dev/sdb,/dev/sda",
expectedDevs: map[string]struct{}{
"/dev/sda": {},
"/dev/sdb": {},
},
},
{
name: "empty entries and whitespace",
envValue: "/dev/sda,, /dev/sdb , , ",
expectedDevs: map[string]struct{}{
"/dev/sda": {},
"/dev/sdb": {},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.envValue != "" {
t.Setenv("EXCLUDE_SMART", tt.envValue)
} else {
// Ensure env var is not set for empty test
os.Unsetenv("EXCLUDE_SMART")
}
sm := &SmartManager{}
sm.refreshExcludedDevices()
assert.Equal(t, tt.expectedDevs, sm.excludedDevices)
})
}
}
func TestIsExcludedDevice(t *testing.T) {
sm := &SmartManager{
excludedDevices: map[string]struct{}{
"/dev/sda": {},
"/dev/nvme0": {},
},
}
tests := []struct {
name string
deviceName string
expectedBool bool
}{
{"excluded device sda", "/dev/sda", true},
{"excluded device nvme0", "/dev/nvme0", true},
{"non-excluded device sdb", "/dev/sdb", false},
{"non-excluded device nvme1", "/dev/nvme1", false},
{"empty device name", "", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := sm.isExcludedDevice(tt.deviceName)
assert.Equal(t, tt.expectedBool, result)
})
}
}
func TestFilterExcludedDevices(t *testing.T) {
tests := []struct {
name string
excludedDevs map[string]struct{}
inputDevices []*DeviceInfo
expectedDevs []*DeviceInfo
expectedLength int
}{
{
name: "no exclusions",
excludedDevs: map[string]struct{}{},
inputDevices: []*DeviceInfo{
{Name: "/dev/sda"},
{Name: "/dev/sdb"},
{Name: "/dev/nvme0"},
},
expectedDevs: []*DeviceInfo{
{Name: "/dev/sda"},
{Name: "/dev/sdb"},
{Name: "/dev/nvme0"},
},
expectedLength: 3,
},
{
name: "some devices excluded",
excludedDevs: map[string]struct{}{
"/dev/sda": {},
"/dev/nvme0": {},
},
inputDevices: []*DeviceInfo{
{Name: "/dev/sda"},
{Name: "/dev/sdb"},
{Name: "/dev/nvme0"},
{Name: "/dev/nvme1"},
},
expectedDevs: []*DeviceInfo{
{Name: "/dev/sdb"},
{Name: "/dev/nvme1"},
},
expectedLength: 2,
},
{
name: "all devices excluded",
excludedDevs: map[string]struct{}{
"/dev/sda": {},
"/dev/sdb": {},
},
inputDevices: []*DeviceInfo{
{Name: "/dev/sda"},
{Name: "/dev/sdb"},
},
expectedDevs: []*DeviceInfo{},
expectedLength: 0,
},
{
name: "nil devices",
excludedDevs: map[string]struct{}{},
inputDevices: nil,
expectedDevs: []*DeviceInfo{},
expectedLength: 0,
},
{
name: "filter nil and empty name devices",
excludedDevs: map[string]struct{}{
"/dev/sda": {},
},
inputDevices: []*DeviceInfo{
{Name: "/dev/sda"},
nil,
{Name: ""},
{Name: "/dev/sdb"},
},
expectedDevs: []*DeviceInfo{
{Name: "/dev/sdb"},
},
expectedLength: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
sm := &SmartManager{
excludedDevices: tt.excludedDevs,
}
result := sm.filterExcludedDevices(tt.inputDevices)
assert.Len(t, result, tt.expectedLength)
assert.Equal(t, tt.expectedDevs, result)
})
}
}
func TestIsNvmeControllerPath(t *testing.T) {
tests := []struct {
path string
expected bool
}{
// Controller paths (should return true)
{"/dev/nvme0", true},
{"/dev/nvme1", true},
{"/dev/nvme10", true},
{"nvme0", true},
// Namespace paths (should return false)
{"/dev/nvme0n1", false},
{"/dev/nvme1n1", false},
{"/dev/nvme0n1p1", false},
{"nvme0n1", false},
// Non-NVMe paths (should return false)
{"/dev/sda", false},
{"/dev/sda1", false},
{"/dev/hda", false},
{"", false},
{"/dev/nvme", false},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
result := isNvmeControllerPath(tt.path)
assert.Equal(t, tt.expected, result, "path: %s", tt.path)
})
}
}

40
agent/smart_windows.go Normal file
View File

@@ -0,0 +1,40 @@
//go:build windows
package agent
import (
_ "embed"
"fmt"
"os"
"path/filepath"
"sync"
)
//go:embed smartmontools/smartctl.exe
var embeddedSmartctl []byte
var (
smartctlOnce sync.Once
smartctlPath string
smartctlErr error
)
func ensureEmbeddedSmartctl() (string, error) {
smartctlOnce.Do(func() {
destDir := filepath.Join(os.TempDir(), "beszel", "smartmontools")
if err := os.MkdirAll(destDir, 0o755); err != nil {
smartctlErr = fmt.Errorf("failed to create smartctl directory: %w", err)
return
}
destPath := filepath.Join(destDir, "smartctl.exe")
if err := os.WriteFile(destPath, embeddedSmartctl, 0o755); err != nil {
smartctlErr = fmt.Errorf("failed to write embedded smartctl: %w", err)
return
}
smartctlPath = destPath
})
return smartctlPath, smartctlErr
}

View File

@@ -2,60 +2,107 @@ package agent
import ( import (
"bufio" "bufio"
"errors"
"fmt" "fmt"
"log/slog" "log/slog"
"os" "os"
"runtime"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"github.com/henrygd/beszel" "github.com/henrygd/beszel"
"github.com/henrygd/beszel/agent/battery" "github.com/henrygd/beszel/agent/battery"
"github.com/henrygd/beszel/internal/entities/container"
"github.com/henrygd/beszel/internal/entities/system" "github.com/henrygd/beszel/internal/entities/system"
"github.com/shirou/gopsutil/v4/cpu" "github.com/shirou/gopsutil/v4/cpu"
"github.com/shirou/gopsutil/v4/disk"
"github.com/shirou/gopsutil/v4/host" "github.com/shirou/gopsutil/v4/host"
"github.com/shirou/gopsutil/v4/load" "github.com/shirou/gopsutil/v4/load"
"github.com/shirou/gopsutil/v4/mem" "github.com/shirou/gopsutil/v4/mem"
) )
// prevDisk stores previous per-device disk counters for a given cache interval
type prevDisk struct {
readBytes uint64
writeBytes uint64
at time.Time
}
// Sets initial / non-changing values about the host system // Sets initial / non-changing values about the host system
func (a *Agent) initializeSystemInfo() { func (a *Agent) refreshSystemDetails() {
a.systemInfo.AgentVersion = beszel.Version a.systemInfo.AgentVersion = beszel.Version
a.systemInfo.Hostname, _ = os.Hostname()
// get host info from Docker if available
var hostInfo container.HostInfo
if a.dockerManager != nil {
a.systemDetails.Podman = a.dockerManager.IsPodman()
hostInfo, _ = a.dockerManager.GetHostInfo()
}
a.systemDetails.Hostname, _ = os.Hostname()
if arch, err := host.KernelArch(); err == nil {
a.systemDetails.Arch = arch
} else {
a.systemDetails.Arch = runtime.GOARCH
}
platform, _, version, _ := host.PlatformInformation() platform, _, version, _ := host.PlatformInformation()
if platform == "darwin" { if platform == "darwin" {
a.systemInfo.KernelVersion = version a.systemDetails.Os = system.Darwin
a.systemInfo.Os = system.Darwin a.systemDetails.OsName = fmt.Sprintf("macOS %s", version)
} else if strings.Contains(platform, "indows") { } else if strings.Contains(platform, "indows") {
a.systemInfo.KernelVersion = fmt.Sprintf("%s %s", strings.Replace(platform, "Microsoft ", "", 1), version) a.systemDetails.Os = system.Windows
a.systemInfo.Os = system.Windows a.systemDetails.OsName = strings.Replace(platform, "Microsoft ", "", 1)
a.systemDetails.Kernel = version
} else if platform == "freebsd" { } else if platform == "freebsd" {
a.systemInfo.Os = system.Freebsd a.systemDetails.Os = system.Freebsd
a.systemInfo.KernelVersion = version a.systemDetails.Kernel, _ = host.KernelVersion()
if prettyName, err := getOsPrettyName(); err == nil {
a.systemDetails.OsName = prettyName
} else { } else {
a.systemInfo.Os = system.Linux a.systemDetails.OsName = "FreeBSD"
}
} else {
a.systemDetails.Os = system.Linux
a.systemDetails.OsName = hostInfo.OperatingSystem
if a.systemDetails.OsName == "" {
if prettyName, err := getOsPrettyName(); err == nil {
a.systemDetails.OsName = prettyName
} else {
a.systemDetails.OsName = platform
}
}
a.systemDetails.Kernel = hostInfo.KernelVersion
if a.systemDetails.Kernel == "" {
a.systemDetails.Kernel, _ = host.KernelVersion()
} }
if a.systemInfo.KernelVersion == "" {
a.systemInfo.KernelVersion, _ = host.KernelVersion()
} }
// cpu model // cpu model
if info, err := cpu.Info(); err == nil && len(info) > 0 { if info, err := cpu.Info(); err == nil && len(info) > 0 {
a.systemInfo.CpuModel = info[0].ModelName a.systemDetails.CpuModel = info[0].ModelName
} }
// cores / threads // cores / threads
a.systemInfo.Cores, _ = cpu.Counts(false) cores, _ := cpu.Counts(false)
if threads, err := cpu.Counts(true); err == nil { threads := hostInfo.NCPU
if threads > 0 && threads < a.systemInfo.Cores { if threads == 0 {
// in lxc logical cores reflects container limits, so use that as cores if lower threads, _ = cpu.Counts(true)
a.systemInfo.Cores = threads }
} else { // in lxc, logical cores reflects container limits, so use that as cores if lower
a.systemInfo.Threads = threads if threads > 0 && threads < cores {
cores = threads
}
a.systemDetails.Cores = cores
a.systemDetails.Threads = threads
// total memory
a.systemDetails.MemoryTotal = hostInfo.MemTotal
if a.systemDetails.MemoryTotal == 0 {
if v, err := mem.VirtualMemory(); err == nil {
a.systemDetails.MemoryTotal = v.Total
} }
} }
@@ -68,20 +115,33 @@ func (a *Agent) initializeSystemInfo() {
} }
// Returns current info, stats about the host system // Returns current info, stats about the host system
func (a *Agent) getSystemStats() system.Stats { func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
var systemStats system.Stats var systemStats system.Stats
// battery // battery
if battery.HasReadableBattery() { if batteryPercent, batteryState, err := battery.GetBatteryStats(); err == nil {
systemStats.Battery[0], systemStats.Battery[1], _ = battery.GetBatteryStats() systemStats.Battery[0] = batteryPercent
systemStats.Battery[1] = batteryState
} }
// cpu percent // cpu metrics
cpuPct, err := cpu.Percent(0, false) cpuMetrics, err := getCpuMetrics(cacheTimeMs)
if err != nil { if err == nil {
slog.Error("Error getting cpu percent", "err", err) systemStats.Cpu = twoDecimals(cpuMetrics.Total)
} else if len(cpuPct) > 0 { systemStats.CpuBreakdown = []float64{
systemStats.Cpu = twoDecimals(cpuPct[0]) twoDecimals(cpuMetrics.User),
twoDecimals(cpuMetrics.System),
twoDecimals(cpuMetrics.Iowait),
twoDecimals(cpuMetrics.Steal),
twoDecimals(cpuMetrics.Idle),
}
} else {
slog.Error("Error getting cpu metrics", "err", err)
}
// per-core cpu usage
if perCoreUsage, err := getPerCoreCpuUsage(cacheTimeMs); err == nil {
systemStats.CpuCoresUsage = perCoreUsage
} }
// load average // load average
@@ -100,14 +160,22 @@ func (a *Agent) getSystemStats() system.Stats {
systemStats.Swap = bytesToGigabytes(v.SwapTotal) systemStats.Swap = bytesToGigabytes(v.SwapTotal)
systemStats.SwapUsed = bytesToGigabytes(v.SwapTotal - v.SwapFree - v.SwapCached) systemStats.SwapUsed = bytesToGigabytes(v.SwapTotal - v.SwapFree - v.SwapCached)
// cache + buffers value for default mem calculation // cache + buffers value for default mem calculation
cacheBuff := v.Total - v.Free - v.Used
// htop memory calculation overrides
if a.memCalc == "htop" {
// note: gopsutil automatically adds SReclaimable to v.Cached // note: gopsutil automatically adds SReclaimable to v.Cached
cacheBuff = v.Cached + v.Buffers - v.Shared cacheBuff := v.Cached + v.Buffers - v.Shared
if cacheBuff <= 0 {
cacheBuff = max(v.Total-v.Free-v.Used, 0)
}
// htop memory calculation overrides (likely outdated as of mid 2025)
if a.memCalc == "htop" {
// cacheBuff = v.Cached + v.Buffers - v.Shared
v.Used = v.Total - (v.Free + cacheBuff) v.Used = v.Total - (v.Free + cacheBuff)
v.UsedPercent = float64(v.Used) / float64(v.Total) * 100.0 v.UsedPercent = float64(v.Used) / float64(v.Total) * 100.0
} }
// if a.memCalc == "legacy" {
// v.Used = v.Total - v.Free - v.Buffers - v.Cached
// cacheBuff = v.Total - v.Free - v.Used
// v.UsedPercent = float64(v.Used) / float64(v.Total) * 100.0
// }
// subtract ZFS ARC size from used memory and add as its own category // subtract ZFS ARC size from used memory and add as its own category
if a.zfs { if a.zfs {
if arcSize, _ := getARCSize(); arcSize > 0 && arcSize < v.Used { if arcSize, _ := getARCSize(); arcSize > 0 && arcSize < v.Used {
@@ -123,56 +191,13 @@ func (a *Agent) getSystemStats() system.Stats {
} }
// disk usage // disk usage
for _, stats := range a.fsStats { a.updateDiskUsage(&systemStats)
if d, err := disk.Usage(stats.Mountpoint); err == nil {
stats.DiskTotal = bytesToGigabytes(d.Total)
stats.DiskUsed = bytesToGigabytes(d.Used)
if stats.Root {
systemStats.DiskTotal = bytesToGigabytes(d.Total)
systemStats.DiskUsed = bytesToGigabytes(d.Used)
systemStats.DiskPct = twoDecimals(d.UsedPercent)
}
} else {
// reset stats if error (likely unmounted)
slog.Error("Error getting disk stats", "name", stats.Mountpoint, "err", err)
stats.DiskTotal = 0
stats.DiskUsed = 0
stats.TotalRead = 0
stats.TotalWrite = 0
}
}
// disk i/o // disk i/o (cache-aware per interval)
if ioCounters, err := disk.IOCounters(a.fsNames...); err == nil { a.updateDiskIo(cacheTimeMs, &systemStats)
for _, d := range ioCounters {
stats := a.fsStats[d.Name]
if stats == nil {
continue
}
secondsElapsed := time.Since(stats.Time).Seconds()
readPerSecond := bytesToMegabytes(float64(d.ReadBytes-stats.TotalRead) / secondsElapsed)
writePerSecond := bytesToMegabytes(float64(d.WriteBytes-stats.TotalWrite) / secondsElapsed)
// check for invalid values and reset stats if so
if readPerSecond < 0 || writePerSecond < 0 || readPerSecond > 50_000 || writePerSecond > 50_000 {
slog.Warn("Invalid disk I/O. Resetting.", "name", d.Name, "read", readPerSecond, "write", writePerSecond)
a.initializeDiskIoStats(ioCounters)
break
}
stats.Time = time.Now()
stats.DiskReadPs = readPerSecond
stats.DiskWritePs = writePerSecond
stats.TotalRead = d.ReadBytes
stats.TotalWrite = d.WriteBytes
// if root filesystem, update system stats
if stats.Root {
systemStats.DiskReadPs = stats.DiskReadPs
systemStats.DiskWritePs = stats.DiskWritePs
}
}
}
// network stats // network stats (per cache interval)
a.updateNetworkStats(&systemStats) a.updateNetworkStats(cacheTimeMs, &systemStats)
// temperatures // temperatures
// TODO: maybe refactor to methods on systemStats // TODO: maybe refactor to methods on systemStats
@@ -183,7 +208,7 @@ func (a *Agent) getSystemStats() system.Stats {
// reset high gpu percent // reset high gpu percent
a.systemInfo.GpuPct = 0 a.systemInfo.GpuPct = 0
// get current GPU data // get current GPU data
if gpuData := a.gpuManager.GetCurrentData(); len(gpuData) > 0 { if gpuData := a.gpuManager.GetCurrentData(cacheTimeMs); len(gpuData) > 0 {
systemStats.GPUData = gpuData systemStats.GPUData = gpuData
// add temperatures // add temperatures
@@ -211,21 +236,16 @@ func (a *Agent) getSystemStats() system.Stats {
} }
} }
// update base system info // update system info
a.systemInfo.ConnectionType = a.connectionManager.ConnectionType a.systemInfo.ConnectionType = a.connectionManager.ConnectionType
a.systemInfo.Cpu = systemStats.Cpu a.systemInfo.Cpu = systemStats.Cpu
a.systemInfo.LoadAvg = systemStats.LoadAvg 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.MemPct = systemStats.MemPct
a.systemInfo.DiskPct = systemStats.DiskPct a.systemInfo.DiskPct = systemStats.DiskPct
a.systemInfo.Battery = systemStats.Battery
a.systemInfo.Uptime, _ = host.Uptime() 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] a.systemInfo.BandwidthBytes = systemStats.Bandwidth[0] + systemStats.Bandwidth[1]
slog.Debug("sysinfo", "data", a.systemInfo) a.systemInfo.Threads = a.systemDetails.Threads
return systemStats return systemStats
} }
@@ -255,3 +275,24 @@ func getARCSize() (uint64, error) {
return 0, fmt.Errorf("failed to parse size field") return 0, fmt.Errorf("failed to parse size field")
} }
// getOsPrettyName attempts to get the pretty OS name from /etc/os-release on Linux systems
func getOsPrettyName() (string, error) {
file, err := os.Open("/etc/os-release")
if err != nil {
return "", err
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
if after, ok := strings.CutPrefix(line, "PRETTY_NAME="); ok {
value := after
value = strings.Trim(value, `"`)
return value, nil
}
}
return "", errors.New("pretty name not found")
}

313
agent/systemd.go Normal file
View File

@@ -0,0 +1,313 @@
//go:build linux
package agent
import (
"context"
"errors"
"log/slog"
"maps"
"math"
"os"
"strconv"
"strings"
"sync"
"time"
"github.com/coreos/go-systemd/v22/dbus"
"github.com/henrygd/beszel/internal/entities/systemd"
)
var errNoActiveTime = errors.New("no active time")
// systemdManager manages the collection of systemd service statistics.
type systemdManager struct {
sync.Mutex
serviceStatsMap map[string]*systemd.Service
isRunning bool
hasFreshStats bool
patterns []string
}
// isSystemdAvailable checks if systemd is used on the system to avoid unnecessary connection attempts (#1548)
func isSystemdAvailable() bool {
paths := []string{
"/run/systemd/system",
"/run/dbus/system_bus_socket",
"/var/run/dbus/system_bus_socket",
}
for _, path := range paths {
if _, err := os.Stat(path); err == nil {
return true
}
}
if data, err := os.ReadFile("/proc/1/comm"); err == nil {
return strings.TrimSpace(string(data)) == "systemd"
}
return false
}
// newSystemdManager creates a new systemdManager.
func newSystemdManager() (*systemdManager, error) {
if skipSystemd, _ := GetEnv("SKIP_SYSTEMD"); skipSystemd == "true" {
return nil, nil
}
// Check if systemd is available on the system before attempting connection
if !isSystemdAvailable() {
slog.Debug("Systemd not available")
return nil, nil
}
conn, err := dbus.NewSystemConnectionContext(context.Background())
if err != nil {
slog.Debug("Error connecting to systemd", "err", err, "ref", "https://beszel.dev/guide/systemd")
return nil, err
}
manager := &systemdManager{
serviceStatsMap: make(map[string]*systemd.Service),
patterns: getServicePatterns(),
}
manager.startWorker(conn)
return manager, nil
}
func (sm *systemdManager) startWorker(conn *dbus.Conn) {
if sm.isRunning {
return
}
sm.isRunning = true
// prime the service stats map with the current services
_ = sm.getServiceStats(conn, true)
// update the services every 10 minutes
go func() {
for {
time.Sleep(time.Minute * 10)
_ = sm.getServiceStats(nil, true)
}
}()
}
// getServiceStatsCount returns the number of systemd services.
func (sm *systemdManager) getServiceStatsCount() int {
return len(sm.serviceStatsMap)
}
// getFailedServiceCount returns the number of systemd services in a failed state.
func (sm *systemdManager) getFailedServiceCount() uint16 {
sm.Lock()
defer sm.Unlock()
count := uint16(0)
for _, service := range sm.serviceStatsMap {
if service.State == systemd.StatusFailed {
count++
}
}
return count
}
// getServiceStats collects statistics for all running systemd services.
func (sm *systemdManager) getServiceStats(conn *dbus.Conn, refresh bool) []*systemd.Service {
// start := time.Now()
// defer func() {
// slog.Info("systemdManager.getServiceStats", "duration", time.Since(start))
// }()
var services []*systemd.Service
var err error
if !refresh {
// return nil
sm.Lock()
defer sm.Unlock()
for _, service := range sm.serviceStatsMap {
services = append(services, service)
}
sm.hasFreshStats = false
return services
}
if conn == nil || !conn.Connected() {
conn, err = dbus.NewSystemConnectionContext(context.Background())
if err != nil {
return nil
}
defer conn.Close()
}
units, err := conn.ListUnitsByPatternsContext(context.Background(), []string{"loaded"}, sm.patterns)
if err != nil {
slog.Error("Error listing systemd service units", "err", err)
return nil
}
// Track which units are currently present to remove stale entries
currentUnits := make(map[string]struct{}, len(units))
for _, unit := range units {
currentUnits[unit.Name] = struct{}{}
service, err := sm.updateServiceStats(conn, unit)
if err != nil {
continue
}
services = append(services, service)
}
// Remove services that no longer exist in systemd
sm.Lock()
for unitName := range sm.serviceStatsMap {
if _, exists := currentUnits[unitName]; !exists {
delete(sm.serviceStatsMap, unitName)
}
}
sm.Unlock()
sm.hasFreshStats = true
return services
}
// updateServiceStats updates the statistics for a single systemd service.
func (sm *systemdManager) updateServiceStats(conn *dbus.Conn, unit dbus.UnitStatus) (*systemd.Service, error) {
sm.Lock()
defer sm.Unlock()
ctx := context.Background()
// if service has never been active (no active since time), skip it
if activeEnterTsProp, err := conn.GetUnitTypePropertyContext(ctx, unit.Name, "Unit", "ActiveEnterTimestamp"); err == nil {
if ts, ok := activeEnterTsProp.Value.Value().(uint64); !ok || ts == 0 || ts == math.MaxUint64 {
return nil, errNoActiveTime
}
} else {
return nil, err
}
service, serviceExists := sm.serviceStatsMap[unit.Name]
if !serviceExists {
service = &systemd.Service{Name: unescapeServiceName(strings.TrimSuffix(unit.Name, ".service"))}
sm.serviceStatsMap[unit.Name] = service
}
memPeak := service.MemPeak
if memPeakProp, err := conn.GetUnitTypePropertyContext(ctx, unit.Name, "Service", "MemoryPeak"); err == nil {
// If memPeak is MaxUint64 the api is saying it's not available
if v, ok := memPeakProp.Value.Value().(uint64); ok && v != math.MaxUint64 {
memPeak = v
}
}
var memUsage uint64
if memProp, err := conn.GetUnitTypePropertyContext(ctx, unit.Name, "Service", "MemoryCurrent"); err == nil {
// If memUsage is MaxUint64 the api is saying it's not available
if v, ok := memProp.Value.Value().(uint64); ok && v != math.MaxUint64 {
memUsage = v
}
}
service.State = systemd.ParseServiceStatus(unit.ActiveState)
service.Sub = systemd.ParseServiceSubState(unit.SubState)
// some systems always return 0 for mem peak, so we should update the peak if the current usage is greater
if memUsage > memPeak {
memPeak = memUsage
}
var cpuUsage uint64
if cpuProp, err := conn.GetUnitTypePropertyContext(ctx, unit.Name, "Service", "CPUUsageNSec"); err == nil {
if v, ok := cpuProp.Value.Value().(uint64); ok {
cpuUsage = v
}
}
service.Mem = memUsage
if memPeak > service.MemPeak {
service.MemPeak = memPeak
}
service.UpdateCPUPercent(cpuUsage)
return service, nil
}
// getServiceDetails collects extended information for a specific systemd service.
func (sm *systemdManager) getServiceDetails(serviceName string) (systemd.ServiceDetails, error) {
conn, err := dbus.NewSystemConnectionContext(context.Background())
if err != nil {
return nil, err
}
defer conn.Close()
unitName := serviceName
if !strings.HasSuffix(unitName, ".service") {
unitName += ".service"
}
ctx := context.Background()
props, err := conn.GetUnitPropertiesContext(ctx, unitName)
if err != nil {
return nil, err
}
// Start with all unit properties
details := make(systemd.ServiceDetails)
maps.Copy(details, props)
// // Add service-specific properties
servicePropNames := []string{
"MainPID", "ExecMainPID", "TasksCurrent", "TasksMax",
"MemoryCurrent", "MemoryPeak", "MemoryLimit", "CPUUsageNSec",
"NRestarts", "ExecMainStartTimestampRealtime", "Result",
}
for _, propName := range servicePropNames {
if variant, err := conn.GetUnitTypePropertyContext(ctx, unitName, "Service", propName); err == nil {
value := variant.Value.Value()
// Check if the value is MaxUint64, which indicates unlimited/infinite
if uint64Value, ok := value.(uint64); ok && uint64Value == math.MaxUint64 {
// Set to nil to indicate unlimited - frontend will handle this appropriately
details[propName] = nil
} else {
details[propName] = value
}
}
}
return details, nil
}
// unescapeServiceName unescapes systemd service names that contain C-style escape sequences like \x2d
func unescapeServiceName(name string) string {
if !strings.Contains(name, "\\x") {
return name
}
unescaped, err := strconv.Unquote("\"" + name + "\"")
if err != nil {
return name
}
return unescaped
}
// getServicePatterns returns the list of service patterns to match.
// It reads from the SERVICE_PATTERNS environment variable if set,
// otherwise defaults to "*service".
func getServicePatterns() []string {
patterns := []string{}
if envPatterns, _ := GetEnv("SERVICE_PATTERNS"); envPatterns != "" {
for pattern := range strings.SplitSeq(envPatterns, ",") {
pattern = strings.TrimSpace(pattern)
if pattern == "" {
continue
}
if !strings.HasSuffix(pattern, ".service") {
pattern += ".service"
}
patterns = append(patterns, pattern)
}
}
if len(patterns) == 0 {
patterns = []string{"*.service"}
}
return patterns
}

38
agent/systemd_nonlinux.go Normal file
View File

@@ -0,0 +1,38 @@
//go:build !linux
package agent
import (
"errors"
"github.com/henrygd/beszel/internal/entities/systemd"
)
// systemdManager manages the collection of systemd service statistics.
type systemdManager struct {
hasFreshStats bool
}
// newSystemdManager creates a new systemdManager.
func newSystemdManager() (*systemdManager, error) {
return &systemdManager{}, nil
}
// getServiceStats returns nil for non-linux systems.
func (sm *systemdManager) getServiceStats(conn any, refresh bool) []*systemd.Service {
return nil
}
// getServiceStatsCount returns 0 for non-linux systems.
func (sm *systemdManager) getServiceStatsCount() int {
return 0
}
// getFailedServiceCount returns 0 for non-linux systems.
func (sm *systemdManager) getFailedServiceCount() uint16 {
return 0
}
func (sm *systemdManager) getServiceDetails(string) (systemd.ServiceDetails, error) {
return nil, errors.New("systemd manager unavailable")
}

View File

@@ -0,0 +1,53 @@
//go:build !linux && testing
package agent
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestNewSystemdManager(t *testing.T) {
manager, err := newSystemdManager()
assert.NoError(t, err)
assert.NotNil(t, manager)
}
func TestSystemdManagerGetServiceStats(t *testing.T) {
manager, err := newSystemdManager()
assert.NoError(t, err)
// Test with refresh = true
result := manager.getServiceStats("any-service", true)
assert.Nil(t, result)
// Test with refresh = false
result = manager.getServiceStats("any-service", false)
assert.Nil(t, result)
}
func TestSystemdManagerGetServiceDetails(t *testing.T) {
manager, err := newSystemdManager()
assert.NoError(t, err)
result, err := manager.getServiceDetails("any-service")
assert.Error(t, err)
assert.Equal(t, "systemd manager unavailable", err.Error())
assert.Nil(t, result)
// Test with empty service name
result, err = manager.getServiceDetails("")
assert.Error(t, err)
assert.Equal(t, "systemd manager unavailable", err.Error())
assert.Nil(t, result)
}
func TestSystemdManagerFields(t *testing.T) {
manager, err := newSystemdManager()
assert.NoError(t, err)
// The non-linux manager should be a simple struct with no special fields
// We can't test private fields directly, but we can test the methods work
assert.NotNil(t, manager)
}

188
agent/systemd_test.go Normal file
View File

@@ -0,0 +1,188 @@
//go:build linux && testing
package agent
import (
"os"
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func TestUnescapeServiceName(t *testing.T) {
tests := []struct {
input string
expected string
}{
{"nginx.service", "nginx.service"}, // No escaping needed
{"test\\x2dwith\\x2ddashes.service", "test-with-dashes.service"}, // \x2d is dash
{"service\\x20with\\x20spaces.service", "service with spaces.service"}, // \x20 is space
{"mixed\\x2dand\\x2dnormal", "mixed-and-normal"}, // Mixed escaped and normal
{"no-escape-here", "no-escape-here"}, // No escape sequences
{"", ""}, // Empty string
{"\\x2d\\x2d", "--"}, // Multiple escapes
}
for _, test := range tests {
t.Run(test.input, func(t *testing.T) {
result := unescapeServiceName(test.input)
assert.Equal(t, test.expected, result)
})
}
}
func TestUnescapeServiceNameInvalid(t *testing.T) {
// Test invalid escape sequences - should return original string
invalidInputs := []string{
"invalid\\x", // Incomplete escape
"invalid\\xZZ", // Invalid hex
"invalid\\x2", // Incomplete hex
"invalid\\xyz", // Not a valid escape
}
for _, input := range invalidInputs {
t.Run(input, func(t *testing.T) {
result := unescapeServiceName(input)
assert.Equal(t, input, result, "Invalid escape sequences should return original string")
})
}
}
func TestIsSystemdAvailable(t *testing.T) {
// Note: This test's result will vary based on the actual system running the tests
// On systems with systemd, it should return true
// On systems without systemd, it should return false
result := isSystemdAvailable()
// Check if either the /run/systemd/system directory exists or PID 1 is systemd
runSystemdExists := false
if _, err := os.Stat("/run/systemd/system"); err == nil {
runSystemdExists = true
}
pid1IsSystemd := false
if data, err := os.ReadFile("/proc/1/comm"); err == nil {
pid1IsSystemd = strings.TrimSpace(string(data)) == "systemd"
}
expected := runSystemdExists || pid1IsSystemd
assert.Equal(t, expected, result, "isSystemdAvailable should correctly detect systemd presence")
// Log the result for informational purposes
if result {
t.Log("Systemd is available on this system")
} else {
t.Log("Systemd is not available on this system")
}
}
func TestGetServicePatterns(t *testing.T) {
tests := []struct {
name string
prefixedEnv string
unprefixedEnv string
expected []string
cleanupEnvVars bool
}{
{
name: "default when no env var set",
prefixedEnv: "",
unprefixedEnv: "",
expected: []string{"*.service"},
cleanupEnvVars: true,
},
{
name: "single pattern with prefixed env",
prefixedEnv: "nginx",
unprefixedEnv: "",
expected: []string{"nginx.service"},
cleanupEnvVars: true,
},
{
name: "single pattern with unprefixed env",
prefixedEnv: "",
unprefixedEnv: "nginx",
expected: []string{"nginx.service"},
cleanupEnvVars: true,
},
{
name: "prefixed env takes precedence",
prefixedEnv: "nginx",
unprefixedEnv: "apache",
expected: []string{"nginx.service"},
cleanupEnvVars: true,
},
{
name: "multiple patterns",
prefixedEnv: "nginx,apache,postgresql",
unprefixedEnv: "",
expected: []string{"nginx.service", "apache.service", "postgresql.service"},
cleanupEnvVars: true,
},
{
name: "patterns with .service suffix",
prefixedEnv: "nginx.service,apache.service",
unprefixedEnv: "",
expected: []string{"nginx.service", "apache.service"},
cleanupEnvVars: true,
},
{
name: "mixed patterns with and without suffix",
prefixedEnv: "nginx.service,apache,postgresql.service",
unprefixedEnv: "",
expected: []string{"nginx.service", "apache.service", "postgresql.service"},
cleanupEnvVars: true,
},
{
name: "patterns with whitespace",
prefixedEnv: " nginx , apache , postgresql ",
unprefixedEnv: "",
expected: []string{"nginx.service", "apache.service", "postgresql.service"},
cleanupEnvVars: true,
},
{
name: "empty patterns are skipped",
prefixedEnv: "nginx,,apache, ,postgresql",
unprefixedEnv: "",
expected: []string{"nginx.service", "apache.service", "postgresql.service"},
cleanupEnvVars: true,
},
{
name: "wildcard pattern",
prefixedEnv: "*nginx*,*apache*",
unprefixedEnv: "",
expected: []string{"*nginx*.service", "*apache*.service"},
cleanupEnvVars: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Clean up any existing env vars
os.Unsetenv("BESZEL_AGENT_SERVICE_PATTERNS")
os.Unsetenv("SERVICE_PATTERNS")
// Set up environment variables
if tt.prefixedEnv != "" {
os.Setenv("BESZEL_AGENT_SERVICE_PATTERNS", tt.prefixedEnv)
}
if tt.unprefixedEnv != "" {
os.Setenv("SERVICE_PATTERNS", tt.unprefixedEnv)
}
// Run the function
result := getServicePatterns()
// Verify results
assert.Equal(t, tt.expected, result, "Patterns should match expected values")
// Cleanup
if tt.cleanupEnvVars {
os.Unsetenv("BESZEL_AGENT_SERVICE_PATTERNS")
os.Unsetenv("SERVICE_PATTERNS")
}
})
}
}

View File

@@ -0,0 +1,24 @@
{
"cpu_stats": {
"cpu_usage": {
"total_usage": 312055276000
},
"system_cpu_usage": 1366399830000000
},
"memory_stats": {
"usage": 507400192,
"stats": {
"inactive_file": 165130240
}
},
"networks": {
"eth0": {
"tx_bytes": 20376558,
"rx_bytes": 537029455
},
"eth1": {
"tx_bytes": 2003766,
"rx_bytes": 6241
}
}
}

View File

@@ -0,0 +1,24 @@
{
"cpu_stats": {
"cpu_usage": {
"total_usage": 314891801000
},
"system_cpu_usage": 1368474900000000
},
"memory_stats": {
"usage": 507400192,
"stats": {
"inactive_file": 165130240
}
},
"networks": {
"eth0": {
"tx_bytes": 20376558,
"rx_bytes": 537029455
},
"eth1": {
"tx_bytes": 2003766,
"rx_bytes": 6241
}
}
}

View File

@@ -0,0 +1,272 @@
{
"json_format_version": [
1,
0
],
"smartctl": {
"version": [
7,
5
],
"pre_release": false,
"svn_revision": "5714",
"platform_info": "x86_64-linux-6.17.1-2-cachyos",
"build_info": "(local build)",
"argv": [
"smartctl",
"-aj",
"/dev/nvme0"
],
"exit_status": 0
},
"local_time": {
"time_t": 1761507494,
"asctime": "Sun Oct 26 15:38:14 2025 EDT"
},
"device": {
"name": "/dev/nvme0",
"info_name": "/dev/nvme0",
"type": "nvme",
"protocol": "NVMe"
},
"model_name": "PELADN 512GB",
"serial_number": "2024031600129",
"firmware_version": "VC2S038E",
"nvme_pci_vendor": {
"id": 4332,
"subsystem_id": 4332
},
"nvme_ieee_oui_identifier": 57420,
"nvme_controller_id": 1,
"nvme_version": {
"string": "1.4",
"value": 66560
},
"nvme_number_of_namespaces": 1,
"nvme_namespaces": [
{
"id": 1,
"size": {
"blocks": 1000215216,
"bytes": 512110190592
},
"capacity": {
"blocks": 1000215216,
"bytes": 512110190592
},
"utilization": {
"blocks": 1000215216,
"bytes": 512110190592
},
"formatted_lba_size": 512,
"eui64": {
"oui": 57420,
"ext_id": 112094110470
},
"features": {
"value": 0,
"thin_provisioning": false,
"na_fields": false,
"dealloc_or_unwritten_block_error": false,
"uid_reuse": false,
"np_fields": false,
"other": 0
},
"lba_formats": [
{
"formatted": true,
"data_bytes": 512,
"metadata_bytes": 0,
"relative_performance": 0
}
]
}
],
"user_capacity": {
"blocks": 1000215216,
"bytes": 512110190592
},
"logical_block_size": 512,
"smart_support": {
"available": true,
"enabled": true
},
"nvme_firmware_update_capabilities": {
"value": 2,
"slots": 1,
"first_slot_is_read_only": false,
"activiation_without_reset": false,
"multiple_update_detection": false,
"other": 0
},
"nvme_optional_admin_commands": {
"value": 23,
"security_send_receive": true,
"format_nvm": true,
"firmware_download": true,
"namespace_management": false,
"self_test": true,
"directives": false,
"mi_send_receive": false,
"virtualization_management": false,
"doorbell_buffer_config": false,
"get_lba_status": false,
"command_and_feature_lockdown": false,
"other": 0
},
"nvme_optional_nvm_commands": {
"value": 94,
"compare": false,
"write_uncorrectable": true,
"dataset_management": true,
"write_zeroes": true,
"save_select_feature_nonzero": true,
"reservations": false,
"timestamp": true,
"verify": false,
"copy": false,
"other": 0
},
"nvme_log_page_attributes": {
"value": 2,
"smart_health_per_namespace": false,
"commands_effects_log": true,
"extended_get_log_page_cmd": false,
"telemetry_log": false,
"persistent_event_log": false,
"supported_log_pages_log": false,
"telemetry_data_area_4": false,
"other": 0
},
"nvme_maximum_data_transfer_pages": 32,
"nvme_composite_temperature_threshold": {
"warning": 100,
"critical": 110
},
"temperature": {
"op_limit_max": 100,
"critical_limit_max": 110,
"current": 61
},
"nvme_power_states": [
{
"non_operational_state": false,
"relative_read_latency": 0,
"relative_read_throughput": 0,
"relative_write_latency": 0,
"relative_write_throughput": 0,
"entry_latency_us": 230000,
"exit_latency_us": 50000,
"max_power": {
"value": 800,
"scale": 2,
"units_per_watt": 100
}
},
{
"non_operational_state": false,
"relative_read_latency": 1,
"relative_read_throughput": 1,
"relative_write_latency": 1,
"relative_write_throughput": 1,
"entry_latency_us": 4000,
"exit_latency_us": 50000,
"max_power": {
"value": 400,
"scale": 2,
"units_per_watt": 100
}
},
{
"non_operational_state": false,
"relative_read_latency": 2,
"relative_read_throughput": 2,
"relative_write_latency": 2,
"relative_write_throughput": 2,
"entry_latency_us": 4000,
"exit_latency_us": 250000,
"max_power": {
"value": 300,
"scale": 2,
"units_per_watt": 100
}
},
{
"non_operational_state": true,
"relative_read_latency": 3,
"relative_read_throughput": 3,
"relative_write_latency": 3,
"relative_write_throughput": 3,
"entry_latency_us": 5000,
"exit_latency_us": 10000,
"max_power": {
"value": 300,
"scale": 1,
"units_per_watt": 10000
}
},
{
"non_operational_state": true,
"relative_read_latency": 4,
"relative_read_throughput": 4,
"relative_write_latency": 4,
"relative_write_throughput": 4,
"entry_latency_us": 54000,
"exit_latency_us": 45000,
"max_power": {
"value": 50,
"scale": 1,
"units_per_watt": 10000
}
}
],
"smart_status": {
"passed": true,
"nvme": {
"value": 0
}
},
"nvme_smart_health_information_log": {
"nsid": -1,
"critical_warning": 0,
"temperature": 61,
"available_spare": 100,
"available_spare_threshold": 32,
"percentage_used": 0,
"data_units_read": 6573104,
"data_units_written": 16040567,
"host_reads": 63241130,
"host_writes": 253050006,
"controller_busy_time": 0,
"power_cycles": 430,
"power_on_hours": 4399,
"unsafe_shutdowns": 44,
"media_errors": 0,
"num_err_log_entries": 0,
"warning_temp_time": 0,
"critical_comp_time": 0
},
"spare_available": {
"current_percent": 100,
"threshold_percent": 32
},
"endurance_used": {
"current_percent": 0
},
"power_cycle_count": 430,
"power_on_time": {
"hours": 4399
},
"nvme_error_information_log": {
"size": 8,
"read": 8,
"unread": 0
},
"nvme_self_test_log": {
"nsid": -1,
"current_self_test_operation": {
"value": 0,
"string": "No self-test in progress"
}
}
}

View File

@@ -0,0 +1,36 @@
{
"json_format_version": [
1,
0
],
"smartctl": {
"version": [
7,
5
],
"pre_release": false,
"svn_revision": "5714",
"platform_info": "x86_64-linux-6.17.1-2-cachyos",
"build_info": "(local build)",
"argv": [
"smartctl",
"--scan",
"-j"
],
"exit_status": 0
},
"devices": [
{
"name": "/dev/sda",
"info_name": "/dev/sda [SAT]",
"type": "sat",
"protocol": "ATA"
},
{
"name": "/dev/nvme0",
"info_name": "/dev/nvme0",
"type": "nvme",
"protocol": "NVMe"
}
]
}

View File

@@ -0,0 +1,125 @@
{
"json_format_version": [
1,
0
],
"smartctl": {
"version": [
7,
3
],
"svn_revision": "5338",
"platform_info": "x86_64-linux-6.12.43+deb12-amd64",
"build_info": "(local build)",
"argv": [
"smartctl",
"-aj",
"/dev/sde"
],
"exit_status": 0
},
"local_time": {
"time_t": 1761502142,
"asctime": "Sun Oct 21 21:09:02 2025 MSK"
},
"device": {
"name": "/dev/sde",
"info_name": "/dev/sde",
"type": "scsi",
"protocol": "SCSI"
},
"scsi_vendor": "YADRO",
"scsi_product": "WUH721414AL4204",
"scsi_model_name": "YADRO WUH721414AL4204",
"scsi_revision": "C240",
"scsi_version": "SPC-4",
"user_capacity": {
"blocks": 3418095616,
"bytes": 14000519643136
},
"logical_block_size": 4096,
"scsi_lb_provisioning": {
"name": "fully provisioned",
"value": 0,
"management_enabled": {
"name": "LBPME",
"value": 0
},
"read_zeros": {
"name": "LBPRZ",
"value": 0
}
},
"rotation_rate": 7200,
"form_factor": {
"scsi_value": 2,
"name": "3.5 inches"
},
"logical_unit_id": "0x5000cca29063dc00",
"serial_number": "9YHSDH9B",
"device_type": {
"scsi_terminology": "Peripheral Device Type [PDT]",
"scsi_value": 0,
"name": "disk"
},
"scsi_transport_protocol": {
"name": "SAS (SPL-4)",
"value": 6
},
"smart_support": {
"available": true,
"enabled": true
},
"temperature_warning": {
"enabled": true
},
"smart_status": {
"passed": true
},
"temperature": {
"current": 34,
"drive_trip": 85
},
"power_on_time": {
"hours": 458,
"minutes": 25
},
"scsi_start_stop_cycle_counter": {
"year_of_manufacture": "2022",
"week_of_manufacture": "41",
"specified_cycle_count_over_device_lifetime": 50000,
"accumulated_start_stop_cycles": 2,
"specified_load_unload_count_over_device_lifetime": 600000,
"accumulated_load_unload_cycles": 418
},
"scsi_grown_defect_list": 0,
"scsi_error_counter_log": {
"read": {
"errors_corrected_by_eccfast": 0,
"errors_corrected_by_eccdelayed": 0,
"errors_corrected_by_rereads_rewrites": 0,
"total_errors_corrected": 0,
"correction_algorithm_invocations": 346,
"gigabytes_processed": "3,641",
"total_uncorrected_errors": 0
},
"write": {
"errors_corrected_by_eccfast": 0,
"errors_corrected_by_eccdelayed": 0,
"errors_corrected_by_rereads_rewrites": 0,
"total_errors_corrected": 0,
"correction_algorithm_invocations": 4052,
"gigabytes_processed": "2124,590",
"total_uncorrected_errors": 0
},
"verify": {
"errors_corrected_by_eccfast": 0,
"errors_corrected_by_eccdelayed": 0,
"errors_corrected_by_rereads_rewrites": 0,
"total_errors_corrected": 0,
"correction_algorithm_invocations": 223,
"gigabytes_processed": "0,000",
"total_uncorrected_errors": 0
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,17 @@
{
"ID": "7TRN:IPZB:QYBB:VPBQ:UMPP:KARE:6ZNR:XE6T:7EWV:PKF4:ZOJD:TPYS",
"Containers": 14,
"ContainersRunning": 3,
"ContainersPaused": 1,
"ContainersStopped": 10,
"Images": 508,
"Driver": "overlay2",
"KernelVersion": "6.8.0-31-generic",
"OperatingSystem": "Ubuntu 24.04 LTS",
"OSVersion": "24.04",
"OSType": "linux",
"Architecture": "x86_64",
"NCPU": 4,
"MemTotal": 2095882240,
"ServerVersion": "27.0.1"
}

View File

@@ -0,0 +1,130 @@
package main
import (
"crypto/sha1"
"crypto/sha256"
"encoding/hex"
"flag"
"fmt"
"hash"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
// Download smartctl.exe from the given URL and save it to the given destination.
// This is used to embed smartctl.exe in the Windows build.
func main() {
url := flag.String("url", "", "URL to download smartctl.exe from (required)")
out := flag.String("out", "", "Destination path for smartctl.exe (required)")
sha := flag.String("sha", "", "Optional SHA1/SHA256 checksum for integrity validation")
force := flag.Bool("force", false, "Force re-download even if destination exists")
flag.Parse()
if *url == "" || *out == "" {
fatalf("-url and -out are required")
}
if !*force {
if info, err := os.Stat(*out); err == nil && info.Size() > 0 {
fmt.Println("smartctl.exe already present, skipping download")
return
}
}
if err := downloadFile(*url, *out, *sha); err != nil {
fatalf("download failed: %v", err)
}
}
func downloadFile(url, dest, shaHex string) error {
// Prepare destination
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return fmt.Errorf("create dir: %w", err)
}
// HTTP client
client := &http.Client{Timeout: 60 * time.Second}
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return fmt.Errorf("new request: %w", err)
}
req.Header.Set("User-Agent", "beszel-fetchsmartctl/1.0")
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("http get: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("unexpected HTTP status: %s", resp.Status)
}
tmp := dest + ".tmp"
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
if err != nil {
return fmt.Errorf("open tmp: %w", err)
}
// Determine hash algorithm based on length (SHA1=40, SHA256=64)
var hasher hash.Hash
if shaHex := strings.TrimSpace(shaHex); shaHex != "" {
cleanSha := strings.ToLower(strings.ReplaceAll(shaHex, " ", ""))
switch len(cleanSha) {
case 40:
hasher = sha1.New()
case 64:
hasher = sha256.New()
default:
f.Close()
os.Remove(tmp)
return fmt.Errorf("unsupported hash length: %d (expected 40 for SHA1 or 64 for SHA256)", len(cleanSha))
}
}
var mw io.Writer = f
if hasher != nil {
mw = io.MultiWriter(f, hasher)
}
if _, err := io.Copy(mw, resp.Body); err != nil {
f.Close()
os.Remove(tmp)
return fmt.Errorf("write tmp: %w", err)
}
if err := f.Close(); err != nil {
os.Remove(tmp)
return fmt.Errorf("close tmp: %w", err)
}
if hasher != nil && shaHex != "" {
cleanSha := strings.ToLower(strings.ReplaceAll(strings.TrimSpace(shaHex), " ", ""))
got := strings.ToLower(hex.EncodeToString(hasher.Sum(nil)))
if got != cleanSha {
os.Remove(tmp)
return fmt.Errorf("hash mismatch: got %s want %s", got, cleanSha)
}
}
// Make executable and move into place
if err := os.Chmod(tmp, 0o755); err != nil {
os.Remove(tmp)
return fmt.Errorf("chmod: %w", err)
}
if err := os.Rename(tmp, dest); err != nil {
os.Remove(tmp)
return fmt.Errorf("rename: %w", err)
}
fmt.Println("smartctl.exe downloaded to", dest)
return nil
}
func fatalf(format string, a ...any) {
fmt.Fprintf(os.Stderr, format+"\n", a...)
os.Exit(1)
}

View File

@@ -1,12 +1,10 @@
package agent package agent
import ( import (
"fmt"
"log" "log"
"os" "os"
"os/exec" "os/exec"
"runtime" "runtime"
"strings"
"github.com/henrygd/beszel/internal/ghupdate" "github.com/henrygd/beszel/internal/ghupdate"
) )
@@ -30,21 +28,22 @@ func (s *systemdRestarter) Restart() error {
type openRCRestarter struct{ cmd string } type openRCRestarter struct{ cmd string }
func (o *openRCRestarter) Restart() error { func (o *openRCRestarter) Restart() error {
if err := exec.Command(o.cmd, "status", "beszel-agent").Run(); err != nil { if err := exec.Command(o.cmd, "beszel-agent", "status").Run(); err != nil {
return nil return nil
} }
ghupdate.ColorPrint(ghupdate.ColorYellow, "Restarting beszel-agent via OpenRC…") ghupdate.ColorPrint(ghupdate.ColorYellow, "Restarting beszel-agent via OpenRC…")
return exec.Command(o.cmd, "restart", "beszel-agent").Run() return exec.Command(o.cmd, "beszel-agent", "restart").Run()
} }
type openWRTRestarter struct{ cmd string } type openWRTRestarter struct{ cmd string }
func (w *openWRTRestarter) Restart() error { func (w *openWRTRestarter) Restart() error {
if err := exec.Command(w.cmd, "running", "beszel-agent").Run(); err != nil { // https://openwrt.org/docs/guide-user/base-system/managing_services?s[]=service
if err := exec.Command("/etc/init.d/beszel-agent", "running").Run(); err != nil {
return nil return nil
} }
ghupdate.ColorPrint(ghupdate.ColorYellow, "Restarting beszel-agent via procd…") ghupdate.ColorPrint(ghupdate.ColorYellow, "Restarting beszel-agent via procd…")
return exec.Command(w.cmd, "restart", "beszel-agent").Run() return exec.Command("/etc/init.d/beszel-agent", "restart").Run()
} }
type freeBSDRestarter struct{ cmd string } type freeBSDRestarter struct{ cmd string }
@@ -64,11 +63,13 @@ func detectRestarter() restarter {
if path, err := exec.LookPath("rc-service"); err == nil { if path, err := exec.LookPath("rc-service"); err == nil {
return &openRCRestarter{cmd: path} return &openRCRestarter{cmd: path}
} }
if path, err := exec.LookPath("procd"); err == nil {
return &openWRTRestarter{cmd: path}
}
if path, err := exec.LookPath("service"); err == nil { if path, err := exec.LookPath("service"); err == nil {
if runtime.GOOS == "freebsd" { if runtime.GOOS == "freebsd" {
return &freeBSDRestarter{cmd: path} return &freeBSDRestarter{cmd: path}
} }
return &openWRTRestarter{cmd: path}
} }
return nil return nil
} }
@@ -105,12 +106,12 @@ func Update(useMirror bool) error {
} }
} }
// 6) Fix SELinux context if necessary // Fix SELinux context if necessary
if err := handleSELinuxContext(exePath); err != nil { if err := ghupdate.HandleSELinuxContext(exePath); err != nil {
ghupdate.ColorPrintf(ghupdate.ColorYellow, "Warning: SELinux context handling: %v", err) ghupdate.ColorPrintf(ghupdate.ColorYellow, "Warning: SELinux context handling: %v", err)
} }
// 7) Restart service if running under a recognised init system // Restart service if running under a recognised init system
if r := detectRestarter(); r != nil { if r := detectRestarter(); r != nil {
if err := r.Restart(); err != nil { if err := r.Restart(); err != nil {
ghupdate.ColorPrintf(ghupdate.ColorYellow, "Warning: failed to restart service: %v", err) ghupdate.ColorPrintf(ghupdate.ColorYellow, "Warning: failed to restart service: %v", err)
@@ -125,41 +126,3 @@ func Update(useMirror bool) error {
return nil 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

@@ -6,10 +6,13 @@ import "github.com/blang/semver"
const ( const (
// Version is the current version of the application. // Version is the current version of the application.
Version = "0.12.9" Version = "0.18.2"
// AppName is the name of the application. // AppName is the name of the application.
AppName = "beszel" AppName = "beszel"
) )
// MinVersionCbor is the minimum supported version for CBOR compatibility. // MinVersionCbor is the minimum supported version for CBOR compatibility.
var MinVersionCbor = semver.MustParse("0.12.0") var MinVersionCbor = semver.MustParse("0.12.0")
// MinVersionAgentResponse is the minimum supported version for AgentResponse compatibility.
var MinVersionAgentResponse = semver.MustParse("0.13.0")

57
go.mod
View File

@@ -1,27 +1,27 @@
module github.com/henrygd/beszel module github.com/henrygd/beszel
go 1.25.1 go 1.25.5
// 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 ( require (
github.com/blang/semver v3.5.1+incompatible github.com/blang/semver v3.5.1+incompatible
github.com/coreos/go-systemd/v22 v22.6.0
github.com/distatus/battery v0.11.0 github.com/distatus/battery v0.11.0
github.com/ebitengine/purego v0.9.1
github.com/fxamacker/cbor/v2 v2.9.0 github.com/fxamacker/cbor/v2 v2.9.0
github.com/gliderlabs/ssh v0.3.8 github.com/gliderlabs/ssh v0.3.8
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/lxzan/gws v1.8.9 github.com/lxzan/gws v1.8.9
github.com/nicholas-fedor/shoutrrr v0.8.17 github.com/nicholas-fedor/shoutrrr v0.13.1
github.com/pocketbase/dbx v1.11.0 github.com/pocketbase/dbx v1.11.0
github.com/pocketbase/pocketbase v0.29.3 github.com/pocketbase/pocketbase v0.35.1
github.com/shirou/gopsutil/v4 v4.25.6 github.com/shirou/gopsutil/v4 v4.25.12
github.com/spf13/cast v1.9.2 github.com/spf13/cast v1.10.0
github.com/spf13/cobra v1.9.1 github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.7 github.com/spf13/pflag v1.0.10
github.com/stretchr/testify v1.11.0 github.com/stretchr/testify v1.11.1
golang.org/x/crypto v0.41.0 golang.org/x/crypto v0.46.0
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93
golang.org/x/sys v0.40.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
@@ -33,37 +33,36 @@ require (
github.com/dolthub/maphash v0.1.0 // indirect github.com/dolthub/maphash v0.1.0 // indirect
github.com/domodwyer/mailyak/v3 v3.6.2 // indirect github.com/domodwyer/mailyak/v3 v3.6.2 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ebitengine/purego v0.8.4 // indirect
github.com/fatih/color v1.18.0 // indirect github.com/fatih/color v1.18.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.9 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/ganigeorgiev/fexpr v0.5.0 // indirect github.com/ganigeorgiev/fexpr v0.5.0 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect
github.com/go-sql-driver/mysql v1.9.1 // indirect github.com/go-sql-driver/mysql v1.9.1 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/compress v1.18.2 // indirect
github.com/lufia/plan9stats v0.0.0-20250821153705-5981dea3221d // indirect github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/tklauser/go-sysconf v0.3.15 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect
github.com/tklauser/numcpus v0.10.0 // indirect github.com/tklauser/numcpus v0.11.0 // indirect
github.com/x448/float16 v0.8.4 // indirect github.com/x448/float16 v0.8.4 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect
golang.org/x/image v0.30.0 // indirect golang.org/x/image v0.34.0 // indirect
golang.org/x/net v0.43.0 // indirect golang.org/x/net v0.48.0 // indirect
golang.org/x/oauth2 v0.30.0 // indirect golang.org/x/oauth2 v0.34.0 // indirect
golang.org/x/sync v0.16.0 // indirect golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.35.0 // indirect golang.org/x/term v0.39.0 // indirect
golang.org/x/text v0.28.0 // indirect golang.org/x/text v0.33.0 // indirect
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect
howett.net/plist v1.0.1 // indirect howett.net/plist v1.0.1 // indirect
modernc.org/libc v1.66.3 // indirect modernc.org/libc v1.66.10 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.38.2 // indirect modernc.org/sqlite v1.43.0 // indirect
) )

166
go.sum
View File

@@ -1,5 +1,7 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= 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/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-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=
@@ -7,6 +9,8 @@ github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3d
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ=
github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
github.com/coreos/go-systemd/v22 v22.6.0 h1:aGVa/v8B7hpb0TKl0MWoAavPDmHvobFe5R5zn0bCJWo=
github.com/coreos/go-systemd/v22 v22.6.0/go.mod h1:iG+pp635Fo7ZmV/j14KUcmEyWF+0X7Lua8rrTWzYgWU=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 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.0/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 h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
@@ -21,22 +25,22 @@ github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCO
github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c= github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw= github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= 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/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= 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/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.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/ganigeorgiev/fexpr v0.5.0 h1:XA9JxtTE/Xm+g/JFI6RfZEHSiQlk+1glLvRK1Lpv/Tk= 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/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 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= 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.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= 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 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
@@ -47,49 +51,53 @@ github.com/go-sql-driver/mysql v1.9.1 h1:FrjNGn/BsJQjVRuSa8CBrM5BWA9BWoXXat3KrtS
github.com/go-sql-driver/mysql v1.9.1/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= 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 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= 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-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.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 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-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= github.com/google/pprof v0.0.0-20251114195745-4902fdda35c8 h1:3DsUAV+VNEQa2CUVLxCY3f87278uWfIDhJnbdvDjvmE=
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/pprof v0.0.0-20251114195745-4902fdda35c8/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 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/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jarcoal/httpmock v1.4.0 h1:BvhqnH0JAYbNudL2GMJKgOHe2CtKlzJ/5rWKyp+hc2k= github.com/jarcoal/httpmock v1.4.1 h1:0Ju+VCFuARfFlhVXFc2HxlcQkfB+Xq12/EotHko+x2A=
github.com/jarcoal/httpmock v1.4.0/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0= github.com/jarcoal/httpmock v1.4.1/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= 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.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lufia/plan9stats v0.0.0-20250821153705-5981dea3221d h1:vFzYZc8yji+9DmNRhpEbs8VBK4CgV/DPfGzeVJSSp/8= github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 h1:PwQumkgq4/acIiZhtifTV5OUqqiP82UAl0h87xj/l9k=
github.com/lufia/plan9stats v0.0.0-20250821153705-5981dea3221d/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/lxzan/gws v1.8.9 h1:VU3SGUeWlQrEwfUSfokcZep8mdg/BrUF+y73YYshdBM= github.com/lxzan/gws v1.8.9 h1:VU3SGUeWlQrEwfUSfokcZep8mdg/BrUF+y73YYshdBM=
github.com/lxzan/gws v1.8.9/go.mod h1:d9yHaR1eDTBHagQC6KY7ycUOaz5KWeqQtP3xu7aMK8Y= 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 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nicholas-fedor/shoutrrr v0.8.8 h1:F/oyoatWK5cbHPPgkjRZrA0262TP7KWuUQz9KskRtR8= github.com/nicholas-fedor/shoutrrr v0.13.1 h1:llEoHNbnMM4GfQ9+2Ns3n6ssvNfi3NPWluM0AQiicoY=
github.com/nicholas-fedor/shoutrrr v0.8.8/go.mod h1:T30Y+eoZFEjDk4HtOItcHQioZSOe3Z6a6aNfSz6jc5c= github.com/nicholas-fedor/shoutrrr v0.13.1/go.mod h1:kU4cFJpEAtTzl3iV0l+XUXmM90OlC5T01b7roM4/pYM=
github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus= github.com/onsi/ginkgo/v2 v2.27.3 h1:ICsZJ8JoYafeXFFlFAG75a7CxMsJHwgKwtO+82SE9L8=
github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8= github.com/onsi/ginkgo/v2 v2.27.3/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y= github.com/onsi/gomega v1.38.3 h1:eTX+W6dobAYfFeGC2PV6RwXRu/MyT+cQguijutvkpSM=
github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0= github.com/onsi/gomega v1.38.3/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pocketbase/dbx v1.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU= github.com/pocketbase/dbx v1.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU=
github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs= github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
github.com/pocketbase/pocketbase v0.29.3 h1:Mj8o5awsbVJIdIoTuQNhfC2oL/c4aImQ3RyfFZlzFVg= github.com/pocketbase/pocketbase v0.35.1 h1:Cd5ivUThTw29myY/tYa2cb0elkScBMseG6fExZsIQB8=
github.com/pocketbase/pocketbase v0.29.3/go.mod h1:oGpT67LObxCFK4V2fSL7J9YnPbBnnshOpJ5v3zcneww= github.com/pocketbase/pocketbase v0.35.1/go.mod h1:yQnh1o1Aq6wVuqcmZbRbDmIhc31AME/F5pnPR0Bdtmg=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
@@ -97,86 +105,90 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qq
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/shirou/gopsutil/v4 v4.25.6 h1:kLysI2JsKorfaFPcYmcJqbzROzsBWEOAtw6A7dIfqXs= github.com/shirou/gopsutil/v4 v4.25.12 h1:e7PvW/0RmJ8p8vPGJH4jvNkOyLmbkXgXW4m6ZPic6CY=
github.com/shirou/gopsutil/v4 v4.25.6/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c= github.com/shirou/gopsutil/v4 v4.25.12/go.mod h1:EivAfP5x2EhLp2ovdpKSozecVXn1TmuG7SMzs/Wh4PU=
github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE= github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4= github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4= github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso= github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ= github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= 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/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 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b h1:DXr+pvt3nC887026GRP39Ej11UATqWDmWuS99x26cD0= golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0=
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4= golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.30.0 h1:jD5RhkmVAnjqaCUXfbGBrn3lpxbknfN9w2UhHHU+5B4= golang.org/x/image v0.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8=
golang.org/x/image v0.30.0/go.mod h1:SAEUTxCCMWSrJcCy/4HwavEsfZZJlYxeHLc6tTiAe/c= golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU=
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/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.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg= 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.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM= howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM=
howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= 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.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU= modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE= modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM= modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ= modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8= modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
modernc.org/libc v1.67.4 h1:zZGmCMUVPORtKv95c2ReQN5VDjvkoRm9GWPTEPuvlWg=
modernc.org/libc v1.67.4/go.mod h1:QvvnnJ5P7aitu0ReNpVIEyesuhmDLQ8kaEoyMjIFZJA=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
@@ -185,8 +197,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= 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 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek= modernc.org/sqlite v1.43.0 h1:8YqiFx3G1VhHTXO2Q00bl1Wz9KhS9Q5okwfp9Y97VnA=
modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E= modernc.org/sqlite v1.43.0/go.mod h1:+VkC6v3pLOAE0A0uVucQEcbVW0I5nHCeDaBf+DpsQT8=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

View File

@@ -28,6 +28,7 @@ type AlertManager struct {
type AlertMessageData struct { type AlertMessageData struct {
UserID string UserID string
SystemID string
Title string Title string
Message string Message string
Link string Link string
@@ -45,8 +46,14 @@ type SystemAlertStats struct {
Disk float64 `json:"dp"` Disk float64 `json:"dp"`
NetSent float64 `json:"ns"` NetSent float64 `json:"ns"`
NetRecv float64 `json:"nr"` NetRecv float64 `json:"nr"`
GPU map[string]SystemAlertGPUData `json:"g"`
Temperatures map[string]float32 `json:"t"` Temperatures map[string]float32 `json:"t"`
LoadAvg [3]float64 `json:"la"` LoadAvg [3]float64 `json:"la"`
Battery [2]uint8 `json:"bat"`
}
type SystemAlertGPUData struct {
Usage float64 `json:"u"`
} }
type SystemAlertData struct { type SystemAlertData struct {
@@ -72,7 +79,6 @@ var supportsTitle = map[string]struct{}{
"ifttt": {}, "ifttt": {},
"join": {}, "join": {},
"lark": {}, "lark": {},
"matrix": {},
"ntfy": {}, "ntfy": {},
"opsgenie": {}, "opsgenie": {},
"pushbullet": {}, "pushbullet": {},
@@ -99,10 +105,84 @@ func NewAlertManager(app hubLike) *AlertManager {
func (am *AlertManager) bindEvents() { func (am *AlertManager) bindEvents() {
am.hub.OnRecordAfterUpdateSuccess("alerts").BindFunc(updateHistoryOnAlertUpdate) am.hub.OnRecordAfterUpdateSuccess("alerts").BindFunc(updateHistoryOnAlertUpdate)
am.hub.OnRecordAfterDeleteSuccess("alerts").BindFunc(resolveHistoryOnAlertDelete) am.hub.OnRecordAfterDeleteSuccess("alerts").BindFunc(resolveHistoryOnAlertDelete)
am.hub.OnRecordAfterUpdateSuccess("smart_devices").BindFunc(am.handleSmartDeviceAlert)
}
// IsNotificationSilenced checks if a notification should be silenced based on configured quiet hours
func (am *AlertManager) IsNotificationSilenced(userID, systemID string) bool {
// Query for quiet hours windows that match this user and system
// Include both global windows (system is null/empty) and system-specific windows
var filter string
var params dbx.Params
if systemID == "" {
// If no systemID provided, only check global windows
filter = "user={:user} AND system=''"
params = dbx.Params{"user": userID}
} else {
// Check both global and system-specific windows
filter = "user={:user} AND (system='' OR system={:system})"
params = dbx.Params{
"user": userID,
"system": systemID,
}
}
quietHourWindows, err := am.hub.FindAllRecords("quiet_hours", dbx.NewExp(filter, params))
if err != nil || len(quietHourWindows) == 0 {
return false
}
now := time.Now().UTC()
for _, window := range quietHourWindows {
windowType := window.GetString("type")
start := window.GetDateTime("start").Time()
end := window.GetDateTime("end").Time()
if windowType == "daily" {
// For daily recurring windows, extract just the time portion and compare
// The start/end are stored as full datetime but we only care about HH:MM
startHour, startMin, _ := start.Clock()
endHour, endMin, _ := end.Clock()
nowHour, nowMin, _ := now.Clock()
// Convert to minutes since midnight for easier comparison
startMinutes := startHour*60 + startMin
endMinutes := endHour*60 + endMin
nowMinutes := nowHour*60 + nowMin
// Handle case where window crosses midnight
if endMinutes < startMinutes {
// Window crosses midnight (e.g., 23:00 - 01:00)
if nowMinutes >= startMinutes || nowMinutes < endMinutes {
return true
}
} else {
// Normal case (e.g., 09:00 - 17:00)
if nowMinutes >= startMinutes && nowMinutes < endMinutes {
return true
}
}
} else {
// One-time window: check if current time is within the date range
if (now.After(start) || now.Equal(start)) && now.Before(end) {
return true
}
}
}
return false
} }
// SendAlert sends an alert to the user // SendAlert sends an alert to the user
func (am *AlertManager) SendAlert(data AlertMessageData) error { func (am *AlertManager) SendAlert(data AlertMessageData) error {
// Check if alert is silenced
if am.IsNotificationSilenced(data.UserID, data.SystemID) {
am.hub.Logger().Info("Notification silenced", "user", data.UserID, "system", data.SystemID, "title", data.Title)
return nil
}
// get user settings // get user settings
record, err := am.hub.FindFirstRecordByFilter( record, err := am.hub.FindFirstRecordByFilter(
"user_settings", "user={:user}", "user_settings", "user={:user}",

View File

@@ -0,0 +1,387 @@
//go:build testing
// +build testing
package alerts_test
import (
"encoding/json"
"testing"
"time"
"github.com/henrygd/beszel/internal/entities/system"
beszelTests "github.com/henrygd/beszel/internal/tests"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/tools/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestBatteryAlertLogic tests that battery alerts trigger when value drops BELOW threshold
// (opposite of other alerts like CPU, Memory, etc. which trigger when exceeding threshold)
func TestBatteryAlertLogic(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup()
// Create a system
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
require.NoError(t, err)
systemRecord := systems[0]
// Create a battery alert with threshold of 20% and min of 1 minute (immediate trigger)
batteryAlert, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{
"name": "Battery",
"system": systemRecord.Id,
"user": user.Id,
"value": 20, // threshold: 20%
"min": 1, // 1 minute (immediate trigger for testing)
})
require.NoError(t, err)
// Verify alert is not triggered initially
assert.False(t, batteryAlert.GetBool("triggered"), "Alert should not be triggered initially")
// Create system stats with battery at 50% (above threshold - should NOT trigger)
statsHigh := system.Stats{
Cpu: 10,
MemPct: 30,
DiskPct: 40,
Battery: [2]uint8{50, 1}, // 50% battery, discharging
}
statsHighJSON, _ := json.Marshal(statsHigh)
_, err = beszelTests.CreateRecord(hub, "system_stats", map[string]any{
"system": systemRecord.Id,
"type": "1m",
"stats": string(statsHighJSON),
})
require.NoError(t, err)
// Create CombinedData for the alert handler
combinedDataHigh := &system.CombinedData{
Stats: statsHigh,
Info: system.Info{
AgentVersion: "0.12.0",
Cpu: 10,
MemPct: 30,
DiskPct: 40,
},
}
// Simulate system update time
systemRecord.Set("updated", time.Now().UTC())
err = hub.SaveNoValidate(systemRecord)
require.NoError(t, err)
// Handle system alerts with high battery
am := hub.GetAlertManager()
err = am.HandleSystemAlerts(systemRecord, combinedDataHigh)
require.NoError(t, err)
// Verify alert is still NOT triggered (battery 50% is above threshold 20%)
batteryAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": batteryAlert.Id})
require.NoError(t, err)
assert.False(t, batteryAlert.GetBool("triggered"), "Alert should NOT be triggered when battery (50%%) is above threshold (20%%)")
// Now create stats with battery at 15% (below threshold - should trigger)
statsLow := system.Stats{
Cpu: 10,
MemPct: 30,
DiskPct: 40,
Battery: [2]uint8{15, 1}, // 15% battery, discharging
}
statsLowJSON, _ := json.Marshal(statsLow)
_, err = beszelTests.CreateRecord(hub, "system_stats", map[string]any{
"system": systemRecord.Id,
"type": "1m",
"stats": string(statsLowJSON),
})
require.NoError(t, err)
combinedDataLow := &system.CombinedData{
Stats: statsLow,
Info: system.Info{
AgentVersion: "0.12.0",
Cpu: 10,
MemPct: 30,
DiskPct: 40,
},
}
// Update system timestamp
systemRecord.Set("updated", time.Now().UTC())
err = hub.SaveNoValidate(systemRecord)
require.NoError(t, err)
// Handle system alerts with low battery
err = am.HandleSystemAlerts(systemRecord, combinedDataLow)
require.NoError(t, err)
// Wait for the alert to be processed
time.Sleep(20 * time.Millisecond)
// Verify alert IS triggered (battery 15% is below threshold 20%)
batteryAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": batteryAlert.Id})
require.NoError(t, err)
assert.True(t, batteryAlert.GetBool("triggered"), "Alert SHOULD be triggered when battery (15%%) drops below threshold (20%%)")
// Now test resolution: battery goes back above threshold
statsRecovered := system.Stats{
Cpu: 10,
MemPct: 30,
DiskPct: 40,
Battery: [2]uint8{25, 1}, // 25% battery, discharging
}
statsRecoveredJSON, _ := json.Marshal(statsRecovered)
_, err = beszelTests.CreateRecord(hub, "system_stats", map[string]any{
"system": systemRecord.Id,
"type": "1m",
"stats": string(statsRecoveredJSON),
})
require.NoError(t, err)
combinedDataRecovered := &system.CombinedData{
Stats: statsRecovered,
Info: system.Info{
AgentVersion: "0.12.0",
Cpu: 10,
MemPct: 30,
DiskPct: 40,
},
}
// Update system timestamp
systemRecord.Set("updated", time.Now().UTC())
err = hub.SaveNoValidate(systemRecord)
require.NoError(t, err)
// Handle system alerts with recovered battery
err = am.HandleSystemAlerts(systemRecord, combinedDataRecovered)
require.NoError(t, err)
// Wait for the alert to be processed
time.Sleep(20 * time.Millisecond)
// Verify alert is now resolved (battery 25% is above threshold 20%)
batteryAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": batteryAlert.Id})
require.NoError(t, err)
assert.False(t, batteryAlert.GetBool("triggered"), "Alert should be resolved when battery (25%%) goes above threshold (20%%)")
}
// TestBatteryAlertNoBattery verifies that systems without battery data don't trigger alerts
func TestBatteryAlertNoBattery(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup()
// Create a system
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
require.NoError(t, err)
systemRecord := systems[0]
// Create a battery alert
batteryAlert, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{
"name": "Battery",
"system": systemRecord.Id,
"user": user.Id,
"value": 20,
"min": 1,
})
require.NoError(t, err)
// Create stats with NO battery data (Battery[0] = 0)
statsNoBattery := system.Stats{
Cpu: 10,
MemPct: 30,
DiskPct: 40,
Battery: [2]uint8{0, 0}, // No battery
}
combinedData := &system.CombinedData{
Stats: statsNoBattery,
Info: system.Info{
AgentVersion: "0.12.0",
Cpu: 10,
MemPct: 30,
DiskPct: 40,
},
}
// Simulate system update time
systemRecord.Set("updated", time.Now().UTC())
err = hub.SaveNoValidate(systemRecord)
require.NoError(t, err)
// Handle system alerts
am := hub.GetAlertManager()
err = am.HandleSystemAlerts(systemRecord, combinedData)
require.NoError(t, err)
// Wait a moment for processing
time.Sleep(20 * time.Millisecond)
// Verify alert is NOT triggered (no battery data should skip the alert)
batteryAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": batteryAlert.Id})
require.NoError(t, err)
assert.False(t, batteryAlert.GetBool("triggered"), "Alert should NOT be triggered when system has no battery")
}
// TestBatteryAlertAveragedSamples tests battery alerts with min > 1 (averaging multiple samples)
// This ensures the inverted threshold logic works correctly across averaged time windows
func TestBatteryAlertAveragedSamples(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup()
// Create a system
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
require.NoError(t, err)
systemRecord := systems[0]
// Create a battery alert with threshold of 25% and min of 2 minutes (requires averaging)
batteryAlert, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{
"name": "Battery",
"system": systemRecord.Id,
"user": user.Id,
"value": 25, // threshold: 25%
"min": 2, // 2 minutes - requires averaging
})
require.NoError(t, err)
// Verify alert is not triggered initially
assert.False(t, batteryAlert.GetBool("triggered"), "Alert should not be triggered initially")
am := hub.GetAlertManager()
now := time.Now().UTC()
// Create system_stats records with low battery (below threshold)
// The alert has min=2 minutes, so alert.time = now - 2 minutes
// For the alert to be valid, alert.time must be AFTER the oldest record's created time
// So we need records older than (now - 2 min), plus records within the window
// Records at: now-3min (oldest, before window), now-90s, now-60s, now-30s
recordTimes := []time.Duration{
-180 * time.Second, // 3 min ago - this makes the oldest record before alert.time
-90 * time.Second,
-60 * time.Second,
-30 * time.Second,
}
for _, offset := range recordTimes {
statsLow := system.Stats{
Cpu: 10,
MemPct: 30,
DiskPct: 40,
Battery: [2]uint8{15, 1}, // 15% battery (below 25% threshold)
}
statsLowJSON, _ := json.Marshal(statsLow)
recordTime := now.Add(offset)
record, err := beszelTests.CreateRecord(hub, "system_stats", map[string]any{
"system": systemRecord.Id,
"type": "1m",
"stats": string(statsLowJSON),
})
require.NoError(t, err)
// Update created time to simulate historical records - use SetRaw with formatted string
record.SetRaw("created", recordTime.Format(types.DefaultDateLayout))
err = hub.SaveNoValidate(record)
require.NoError(t, err)
}
// Create combined data with low battery
combinedDataLow := &system.CombinedData{
Stats: system.Stats{
Cpu: 10,
MemPct: 30,
DiskPct: 40,
Battery: [2]uint8{15, 1},
},
Info: system.Info{
AgentVersion: "0.12.0",
Cpu: 10,
MemPct: 30,
DiskPct: 40,
},
}
// Update system timestamp
systemRecord.Set("updated", now)
err = hub.SaveNoValidate(systemRecord)
require.NoError(t, err)
// Handle system alerts - should trigger because average battery is below threshold
err = am.HandleSystemAlerts(systemRecord, combinedDataLow)
require.NoError(t, err)
// Wait for alert processing
time.Sleep(20 * time.Millisecond)
// Verify alert IS triggered (average battery 15% is below threshold 25%)
batteryAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": batteryAlert.Id})
require.NoError(t, err)
assert.True(t, batteryAlert.GetBool("triggered"),
"Alert SHOULD be triggered when average battery (15%%) is below threshold (25%%) over min period")
// Now add records with high battery to test resolution
// Use a new time window 2 minutes later
newNow := now.Add(2 * time.Minute)
// Records need to span before the alert time window (newNow - 2 min)
recordTimesHigh := []time.Duration{
-180 * time.Second, // 3 min before newNow - makes oldest record before alert.time
-90 * time.Second,
-60 * time.Second,
-30 * time.Second,
}
for _, offset := range recordTimesHigh {
statsHigh := system.Stats{
Cpu: 10,
MemPct: 30,
DiskPct: 40,
Battery: [2]uint8{50, 1}, // 50% battery (above 25% threshold)
}
statsHighJSON, _ := json.Marshal(statsHigh)
recordTime := newNow.Add(offset)
record, err := beszelTests.CreateRecord(hub, "system_stats", map[string]any{
"system": systemRecord.Id,
"type": "1m",
"stats": string(statsHighJSON),
})
require.NoError(t, err)
record.SetRaw("created", recordTime.Format(types.DefaultDateLayout))
err = hub.SaveNoValidate(record)
require.NoError(t, err)
}
// Create combined data with high battery
combinedDataHigh := &system.CombinedData{
Stats: system.Stats{
Cpu: 10,
MemPct: 30,
DiskPct: 40,
Battery: [2]uint8{50, 1},
},
Info: system.Info{
AgentVersion: "0.12.0",
Cpu: 10,
MemPct: 30,
DiskPct: 40,
},
}
// Update system timestamp to the new time window
systemRecord.Set("updated", newNow)
err = hub.SaveNoValidate(systemRecord)
require.NoError(t, err)
// Handle system alerts - should resolve because average battery is now above threshold
err = am.HandleSystemAlerts(systemRecord, combinedDataHigh)
require.NoError(t, err)
// Wait for alert processing
time.Sleep(20 * time.Millisecond)
// Verify alert is resolved (average battery 50% is above threshold 25%)
batteryAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": batteryAlert.Id})
require.NoError(t, err)
assert.False(t, batteryAlert.GetBool("triggered"),
"Alert should be resolved when average battery (50%%) is above threshold (25%%) over min period")
}

View File

@@ -0,0 +1,426 @@
//go:build testing
// +build testing
package alerts_test
import (
"testing"
"testing/synctest"
"time"
"github.com/henrygd/beszel/internal/alerts"
beszelTests "github.com/henrygd/beszel/internal/tests"
"github.com/pocketbase/dbx"
"github.com/stretchr/testify/assert"
)
func TestAlertSilencedOneTime(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup()
// Create a system
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
assert.NoError(t, err)
system := systems[0]
// Create an alert
alert, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{
"name": "CPU",
"system": system.Id,
"user": user.Id,
"value": 80,
"min": 1,
})
assert.NoError(t, err)
// Create a one-time quiet hours window (current time - 1 hour to current time + 1 hour)
now := time.Now().UTC()
startTime := now.Add(-1 * time.Hour)
endTime := now.Add(1 * time.Hour)
_, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{
"user": user.Id,
"system": system.Id,
"type": "one-time",
"start": startTime,
"end": endTime,
})
assert.NoError(t, err)
// Get alert manager
am := alerts.NewAlertManager(hub)
defer am.StopWorker()
// Test that alert is silenced
silenced := am.IsNotificationSilenced(user.Id, system.Id)
assert.True(t, silenced, "Alert should be silenced during active one-time window")
// Create a window that has already ended
pastStart := now.Add(-3 * time.Hour)
pastEnd := now.Add(-2 * time.Hour)
_, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{
"user": user.Id,
"system": system.Id,
"type": "one-time",
"start": pastStart,
"end": pastEnd,
})
assert.NoError(t, err)
// Should still be silenced because of the first window
silenced = am.IsNotificationSilenced(user.Id, system.Id)
assert.True(t, silenced, "Alert should still be silenced (past window doesn't affect active window)")
// Clear all windows and create a future window
_, err = hub.DB().NewQuery("DELETE FROM quiet_hours").Execute()
assert.NoError(t, err)
futureStart := now.Add(2 * time.Hour)
futureEnd := now.Add(3 * time.Hour)
_, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{
"user": user.Id,
"system": system.Id,
"type": "one-time",
"start": futureStart,
"end": futureEnd,
})
assert.NoError(t, err)
// Alert should NOT be silenced (window hasn't started yet)
silenced = am.IsNotificationSilenced(user.Id, system.Id)
assert.False(t, silenced, "Alert should not be silenced (window hasn't started)")
_ = alert
}
func TestAlertSilencedDaily(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup()
// Create a system
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
assert.NoError(t, err)
system := systems[0]
// Get alert manager
am := alerts.NewAlertManager(hub)
defer am.StopWorker()
// Get current hour and create a window that includes current time
now := time.Now().UTC()
currentHour := now.Hour()
currentMin := now.Minute()
// Create a window from 1 hour ago to 1 hour from now
startHour := (currentHour - 1 + 24) % 24
endHour := (currentHour + 1) % 24
// Create times with just the hours/minutes we want (date doesn't matter for daily)
startTime := time.Date(2000, 1, 1, startHour, currentMin, 0, 0, time.UTC)
endTime := time.Date(2000, 1, 1, endHour, currentMin, 0, 0, time.UTC)
_, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{
"user": user.Id,
"system": system.Id,
"type": "daily",
"start": startTime,
"end": endTime,
})
assert.NoError(t, err)
// Alert should be silenced (current time is within the daily window)
silenced := am.IsNotificationSilenced(user.Id, system.Id)
assert.True(t, silenced, "Alert should be silenced during active daily window")
// Clear windows and create one that doesn't include current time
_, err = hub.DB().NewQuery("DELETE FROM quiet_hours").Execute()
assert.NoError(t, err)
// Create a window from 6-12 hours from now
futureStartHour := (currentHour + 6) % 24
futureEndHour := (currentHour + 12) % 24
startTime = time.Date(2000, 1, 1, futureStartHour, 0, 0, 0, time.UTC)
endTime = time.Date(2000, 1, 1, futureEndHour, 0, 0, 0, time.UTC)
_, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{
"user": user.Id,
"system": system.Id,
"type": "daily",
"start": startTime,
"end": endTime,
})
assert.NoError(t, err)
// Alert should NOT be silenced
silenced = am.IsNotificationSilenced(user.Id, system.Id)
assert.False(t, silenced, "Alert should not be silenced (outside daily window)")
}
func TestAlertSilencedDailyMidnightCrossing(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup()
// Create a system
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
assert.NoError(t, err)
system := systems[0]
// Get alert manager
am := alerts.NewAlertManager(hub)
defer am.StopWorker()
// Create a window that crosses midnight: 22:00 - 02:00
startTime := time.Date(2000, 1, 1, 22, 0, 0, 0, time.UTC)
endTime := time.Date(2000, 1, 1, 2, 0, 0, 0, time.UTC)
_, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{
"user": user.Id,
"system": system.Id,
"type": "daily",
"start": startTime,
"end": endTime,
})
assert.NoError(t, err)
// Test with a time at 23:00 (should be silenced)
// We can't control the actual current time, but we can verify the logic
// by checking if the window was created correctly
windows, err := hub.FindAllRecords("quiet_hours", dbx.HashExp{
"user": user.Id,
"system": system.Id,
})
assert.NoError(t, err)
assert.Len(t, windows, 1, "Should have created 1 window")
window := windows[0]
assert.Equal(t, "daily", window.GetString("type"))
assert.Equal(t, 22, window.GetDateTime("start").Time().Hour())
assert.Equal(t, 2, window.GetDateTime("end").Time().Hour())
}
func TestAlertSilencedGlobal(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup()
// Create multiple systems
systems, err := beszelTests.CreateSystems(hub, 3, user.Id, "up")
assert.NoError(t, err)
// Get alert manager
am := alerts.NewAlertManager(hub)
defer am.StopWorker()
// Create a global quiet hours window (no system specified)
now := time.Now().UTC()
startTime := now.Add(-1 * time.Hour)
endTime := now.Add(1 * time.Hour)
_, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{
"user": user.Id,
"type": "one-time",
"start": startTime,
"end": endTime,
// system field is empty/null for global windows
})
assert.NoError(t, err)
// All systems should be silenced
for _, system := range systems {
silenced := am.IsNotificationSilenced(user.Id, system.Id)
assert.True(t, silenced, "Alert should be silenced for system %s (global window)", system.Id)
}
// Even with a systemID that doesn't exist, should be silenced
silenced := am.IsNotificationSilenced(user.Id, "nonexistent-system")
assert.True(t, silenced, "Alert should be silenced for any system (global window)")
}
func TestAlertSilencedSystemSpecific(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup()
// Create multiple systems
systems, err := beszelTests.CreateSystems(hub, 2, user.Id, "up")
assert.NoError(t, err)
system1 := systems[0]
system2 := systems[1]
// Get alert manager
am := alerts.NewAlertManager(hub)
defer am.StopWorker()
// Create a system-specific quiet hours window for system1 only
now := time.Now().UTC()
startTime := now.Add(-1 * time.Hour)
endTime := now.Add(1 * time.Hour)
_, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{
"user": user.Id,
"system": system1.Id,
"type": "one-time",
"start": startTime,
"end": endTime,
})
assert.NoError(t, err)
// System1 should be silenced
silenced := am.IsNotificationSilenced(user.Id, system1.Id)
assert.True(t, silenced, "Alert should be silenced for system1")
// System2 should NOT be silenced
silenced = am.IsNotificationSilenced(user.Id, system2.Id)
assert.False(t, silenced, "Alert should not be silenced for system2")
}
func TestAlertSilencedMultiUser(t *testing.T) {
hub, _ := beszelTests.GetHubWithUser(t)
defer hub.Cleanup()
// Create two users
user1, err := beszelTests.CreateUser(hub, "user1@example.com", "password")
assert.NoError(t, err)
user2, err := beszelTests.CreateUser(hub, "user2@example.com", "password")
assert.NoError(t, err)
// Create a system accessible to both users
system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{
"name": "shared-system",
"users": []string{user1.Id, user2.Id},
"host": "127.0.0.1",
})
assert.NoError(t, err)
// Get alert manager
am := alerts.NewAlertManager(hub)
defer am.StopWorker()
// Create a quiet hours window for user1 only
now := time.Now().UTC()
startTime := now.Add(-1 * time.Hour)
endTime := now.Add(1 * time.Hour)
_, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{
"user": user1.Id,
"system": system.Id,
"type": "one-time",
"start": startTime,
"end": endTime,
})
assert.NoError(t, err)
// User1 should be silenced
silenced := am.IsNotificationSilenced(user1.Id, system.Id)
assert.True(t, silenced, "Alert should be silenced for user1")
// User2 should NOT be silenced
silenced = am.IsNotificationSilenced(user2.Id, system.Id)
assert.False(t, silenced, "Alert should not be silenced for user2")
}
func TestAlertSilencedWithActualAlert(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup()
// Create a system
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
assert.NoError(t, err)
system := systems[0]
// Create a status alert
_, err = beszelTests.CreateRecord(hub, "alerts", map[string]any{
"name": "Status",
"system": system.Id,
"user": user.Id,
"min": 1,
})
assert.NoError(t, err)
// Create user settings with email
userSettings, err := hub.FindFirstRecordByFilter("user_settings", "user={:user}", dbx.Params{"user": user.Id})
if err != nil || userSettings == nil {
userSettings, err = beszelTests.CreateRecord(hub, "user_settings", map[string]any{
"user": user.Id,
"settings": map[string]any{
"emails": []string{"test@example.com"},
},
})
assert.NoError(t, err)
}
// Create a quiet hours window
now := time.Now().UTC()
startTime := now.Add(-1 * time.Hour)
endTime := now.Add(1 * time.Hour)
_, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{
"user": user.Id,
"system": system.Id,
"type": "one-time",
"start": startTime,
"end": endTime,
})
assert.NoError(t, err)
// Get initial email count
initialEmailCount := hub.TestMailer.TotalSend()
// Trigger an alert by setting system to down
system.Set("status", "down")
err = hub.SaveNoValidate(system)
assert.NoError(t, err)
// Wait for the alert to be processed (1 minute + buffer)
time.Sleep(time.Second * 75)
synctest.Wait()
// Check that no email was sent (because alert is silenced)
finalEmailCount := hub.TestMailer.TotalSend()
assert.Equal(t, initialEmailCount, finalEmailCount, "No emails should be sent when alert is silenced")
// Clear quiet hours windows
_, err = hub.DB().NewQuery("DELETE FROM quiet_hours").Execute()
assert.NoError(t, err)
// Reset system to up, then down again
system.Set("status", "up")
err = hub.SaveNoValidate(system)
assert.NoError(t, err)
time.Sleep(100 * time.Millisecond)
system.Set("status", "down")
err = hub.SaveNoValidate(system)
assert.NoError(t, err)
// Wait for the alert to be processed
time.Sleep(time.Second * 75)
synctest.Wait()
// Now an email should be sent
newEmailCount := hub.TestMailer.TotalSend()
assert.Greater(t, newEmailCount, finalEmailCount, "Email should be sent when not silenced")
})
}
func TestAlertSilencedNoWindows(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup()
// Create a system
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
assert.NoError(t, err)
system := systems[0]
// Get alert manager
am := alerts.NewAlertManager(hub)
defer am.StopWorker()
// Without any quiet hours windows, alert should NOT be silenced
silenced := am.IsNotificationSilenced(user.Id, system.Id)
assert.False(t, silenced, "Alert should not be silenced when no windows exist")
}

View File

@@ -0,0 +1,67 @@
package alerts
import (
"fmt"
"github.com/pocketbase/pocketbase/core"
)
// handleSmartDeviceAlert sends alerts when a SMART device state changes from PASSED to FAILED.
// This is automatic and does not require user opt-in.
func (am *AlertManager) handleSmartDeviceAlert(e *core.RecordEvent) error {
oldState := e.Record.Original().GetString("state")
newState := e.Record.GetString("state")
// Only alert when transitioning from PASSED to FAILED
if oldState != "PASSED" || newState != "FAILED" {
return e.Next()
}
systemID := e.Record.GetString("system")
if systemID == "" {
return e.Next()
}
// Fetch the system record to get the name and users
systemRecord, err := e.App.FindRecordById("systems", systemID)
if err != nil {
e.App.Logger().Error("Failed to find system for SMART alert", "err", err, "systemID", systemID)
return e.Next()
}
systemName := systemRecord.GetString("name")
deviceName := e.Record.GetString("name")
model := e.Record.GetString("model")
// Build alert message
title := fmt.Sprintf("SMART failure on %s: %s \U0001F534", systemName, deviceName)
var message string
if model != "" {
message = fmt.Sprintf("Disk %s (%s) SMART status changed to FAILED", deviceName, model)
} else {
message = fmt.Sprintf("Disk %s SMART status changed to FAILED", deviceName)
}
// Get users associated with the system
userIDs := systemRecord.GetStringSlice("users")
if len(userIDs) == 0 {
return e.Next()
}
// Send alert to each user
for _, userID := range userIDs {
if err := am.SendAlert(AlertMessageData{
UserID: userID,
SystemID: systemID,
Title: title,
Message: message,
Link: am.hub.MakeLink("system", systemID),
LinkText: "View " + systemName,
}); err != nil {
e.App.Logger().Error("Failed to send SMART alert", "err", err, "userID", userID)
}
}
return e.Next()
}

View File

@@ -0,0 +1,196 @@
//go:build testing
// +build testing
package alerts_test
import (
"testing"
"time"
beszelTests "github.com/henrygd/beszel/internal/tests"
"github.com/stretchr/testify/assert"
)
func TestSmartDeviceAlert(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup()
// Create a system for the user
system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{
"name": "test-system",
"users": []string{user.Id},
"host": "127.0.0.1",
})
assert.NoError(t, err)
// Create a smart_device with state PASSED
smartDevice, err := beszelTests.CreateRecord(hub, "smart_devices", map[string]any{
"system": system.Id,
"name": "/dev/sda",
"model": "Samsung SSD 970 EVO",
"state": "PASSED",
})
assert.NoError(t, err)
// Verify no emails sent initially
assert.Zero(t, hub.TestMailer.TotalSend(), "should have 0 emails sent initially")
// Re-fetch the record so PocketBase can properly track original values
smartDevice, err = hub.FindRecordById("smart_devices", smartDevice.Id)
assert.NoError(t, err)
// Update the smart device state to FAILED
smartDevice.Set("state", "FAILED")
err = hub.Save(smartDevice)
assert.NoError(t, err)
// Wait for the alert to be processed
time.Sleep(50 * time.Millisecond)
// Verify that an email was sent
assert.EqualValues(t, 1, hub.TestMailer.TotalSend(), "should have 1 email sent after state changed to FAILED")
// Check the email content
lastMessage := hub.TestMailer.LastMessage()
assert.Contains(t, lastMessage.Subject, "SMART failure on test-system")
assert.Contains(t, lastMessage.Subject, "/dev/sda")
assert.Contains(t, lastMessage.Text, "Samsung SSD 970 EVO")
assert.Contains(t, lastMessage.Text, "FAILED")
}
func TestSmartDeviceAlertNoAlertOnNonPassedToFailed(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup()
// Create a system for the user
system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{
"name": "test-system",
"users": []string{user.Id},
"host": "127.0.0.1",
})
assert.NoError(t, err)
// Create a smart_device with state UNKNOWN
smartDevice, err := beszelTests.CreateRecord(hub, "smart_devices", map[string]any{
"system": system.Id,
"name": "/dev/sda",
"model": "Samsung SSD 970 EVO",
"state": "UNKNOWN",
})
assert.NoError(t, err)
// Re-fetch the record so PocketBase can properly track original values
smartDevice, err = hub.FindRecordById("smart_devices", smartDevice.Id)
assert.NoError(t, err)
// Update the state from UNKNOWN to FAILED - should NOT trigger alert
smartDevice.Set("state", "FAILED")
err = hub.Save(smartDevice)
assert.NoError(t, err)
time.Sleep(50 * time.Millisecond)
// Verify no email was sent (only PASSED -> FAILED triggers alert)
assert.Zero(t, hub.TestMailer.TotalSend(), "should have 0 emails when changing from UNKNOWN to FAILED")
// Re-fetch the record again
smartDevice, err = hub.FindRecordById("smart_devices", smartDevice.Id)
assert.NoError(t, err)
// Update state from FAILED to PASSED - should NOT trigger alert
smartDevice.Set("state", "PASSED")
err = hub.Save(smartDevice)
assert.NoError(t, err)
time.Sleep(50 * time.Millisecond)
// Verify no email was sent
assert.Zero(t, hub.TestMailer.TotalSend(), "should have 0 emails when changing from FAILED to PASSED")
}
func TestSmartDeviceAlertMultipleUsers(t *testing.T) {
hub, user1 := beszelTests.GetHubWithUser(t)
defer hub.Cleanup()
// Create a second user
user2, err := beszelTests.CreateUser(hub, "test2@example.com", "password")
assert.NoError(t, err)
// Create user settings for the second user
_, err = beszelTests.CreateRecord(hub, "user_settings", map[string]any{
"user": user2.Id,
"settings": `{"emails":["test2@example.com"],"webhooks":[]}`,
})
assert.NoError(t, err)
// Create a system with both users
system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{
"name": "shared-system",
"users": []string{user1.Id, user2.Id},
"host": "127.0.0.1",
})
assert.NoError(t, err)
// Create a smart_device with state PASSED
smartDevice, err := beszelTests.CreateRecord(hub, "smart_devices", map[string]any{
"system": system.Id,
"name": "/dev/nvme0n1",
"model": "WD Black SN850",
"state": "PASSED",
})
assert.NoError(t, err)
// Re-fetch the record so PocketBase can properly track original values
smartDevice, err = hub.FindRecordById("smart_devices", smartDevice.Id)
assert.NoError(t, err)
// Update the smart device state to FAILED
smartDevice.Set("state", "FAILED")
err = hub.Save(smartDevice)
assert.NoError(t, err)
time.Sleep(50 * time.Millisecond)
// Verify that two emails were sent (one for each user)
assert.EqualValues(t, 2, hub.TestMailer.TotalSend(), "should have 2 emails sent for 2 users")
}
func TestSmartDeviceAlertWithoutModel(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup()
// Create a system for the user
system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{
"name": "test-system",
"users": []string{user.Id},
"host": "127.0.0.1",
})
assert.NoError(t, err)
// Create a smart_device with state PASSED but no model
smartDevice, err := beszelTests.CreateRecord(hub, "smart_devices", map[string]any{
"system": system.Id,
"name": "/dev/sdb",
"state": "PASSED",
})
assert.NoError(t, err)
// Re-fetch the record so PocketBase can properly track original values
smartDevice, err = hub.FindRecordById("smart_devices", smartDevice.Id)
assert.NoError(t, err)
// Update the smart device state to FAILED
smartDevice.Set("state", "FAILED")
err = hub.Save(smartDevice)
assert.NoError(t, err)
time.Sleep(50 * time.Millisecond)
// Verify that an email was sent
assert.EqualValues(t, 1, hub.TestMailer.TotalSend(), "should have 1 email sent")
// Check that the email doesn't have empty parentheses for missing model
lastMessage := hub.TestMailer.LastMessage()
assert.NotContains(t, lastMessage.Text, "()", "should not have empty parentheses for missing model")
assert.Contains(t, lastMessage.Text, "/dev/sdb")
}

View File

@@ -161,19 +161,15 @@ func (am *AlertManager) sendStatusAlert(alertStatus string, systemName string, a
title := fmt.Sprintf("Connection to %s is %s %v", systemName, alertStatus, emoji) title := fmt.Sprintf("Connection to %s is %s %v", systemName, alertStatus, emoji)
message := strings.TrimSuffix(title, emoji) message := strings.TrimSuffix(title, emoji)
// if errs := am.hub.ExpandRecord(alertRecord, []string{"user"}, nil); len(errs) > 0 { // Get system ID for the link
// return errs["user"] systemID := alertRecord.GetString("system")
// }
// user := alertRecord.ExpandedOne("user")
// if user == nil {
// return nil
// }
return am.SendAlert(AlertMessageData{ return am.SendAlert(AlertMessageData{
UserID: alertRecord.GetString("user"), UserID: alertRecord.GetString("user"),
SystemID: systemID,
Title: title, Title: title,
Message: message, Message: message,
Link: am.hub.MakeLink("system", systemName), Link: am.hub.MakeLink("system", systemID),
LinkText: "View " + systemName, LinkText: "View " + systemName,
}) })
} }

View File

@@ -64,18 +64,33 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
case "LoadAvg15": case "LoadAvg15":
val = data.Info.LoadAvg[2] val = data.Info.LoadAvg[2]
unit = "" unit = ""
case "GPU":
val = data.Info.GpuPct
case "Battery":
if data.Stats.Battery[0] == 0 {
continue
}
val = float64(data.Stats.Battery[0])
} }
triggered := alertRecord.GetBool("triggered") triggered := alertRecord.GetBool("triggered")
threshold := alertRecord.GetFloat("value") threshold := alertRecord.GetFloat("value")
// Battery alert has inverted logic: trigger when value is BELOW threshold
lowAlert := isLowAlert(name)
// CONTINUE // CONTINUE
// IF alert is not triggered and curValue is less than threshold // For normal alerts: IF not triggered and curValue <= threshold, OR triggered and curValue > threshold
// OR alert is triggered and curValue is greater than threshold // For low alerts (Battery): IF not triggered and curValue >= threshold, OR triggered and curValue < threshold
if (!triggered && val <= threshold) || (triggered && val > threshold) { if lowAlert {
// log.Printf("Skipping alert %s: val %f | threshold %f | triggered %v\n", name, val, threshold, triggered) if (!triggered && val >= threshold) || (triggered && val < threshold) {
continue continue
} }
} else {
if (!triggered && val <= threshold) || (triggered && val > threshold) {
continue
}
}
min := max(1, cast.ToUint8(alertRecord.Get("min"))) min := max(1, cast.ToUint8(alertRecord.Get("min")))
@@ -92,7 +107,11 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
// send alert immediately if min is 1 - no need to sum up values. // send alert immediately if min is 1 - no need to sum up values.
if min == 1 { if min == 1 {
if lowAlert {
alert.triggered = val < threshold
} else {
alert.triggered = val > threshold alert.triggered = val > threshold
}
go am.sendSystemAlert(alert) go am.sendSystemAlert(alert)
continue continue
} }
@@ -206,6 +225,19 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
alert.val += stats.LoadAvg[1] alert.val += stats.LoadAvg[1]
case "LoadAvg15": case "LoadAvg15":
alert.val += stats.LoadAvg[2] alert.val += stats.LoadAvg[2]
case "GPU":
if len(stats.GPU) == 0 {
continue
}
maxUsage := 0.0
for _, gpu := range stats.GPU {
if gpu.Usage > maxUsage {
maxUsage = gpu.Usage
}
}
alert.val += maxUsage
case "Battery":
alert.val += float64(stats.Battery[0])
default: default:
continue continue
} }
@@ -243,6 +275,17 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
// log.Printf("%s: val %f | count %d | min-count %f | threshold %f\n", alert.name, alert.val, alert.count, minCount, alert.threshold) // log.Printf("%s: val %f | count %d | min-count %f | threshold %f\n", alert.name, alert.val, alert.count, minCount, alert.threshold)
// pass through alert if count is greater than or equal to minCount // pass through alert if count is greater than or equal to minCount
if float32(alert.count) >= minCount { if float32(alert.count) >= minCount {
// Battery alert has inverted logic: trigger when value is BELOW threshold
lowAlert := isLowAlert(alert.name)
if lowAlert {
if !alert.triggered && alert.val < alert.threshold {
alert.triggered = true
go am.sendSystemAlert(alert)
} else if alert.triggered && alert.val >= alert.threshold {
alert.triggered = false
go am.sendSystemAlert(alert)
}
} else {
if !alert.triggered && alert.val > alert.threshold { if !alert.triggered && alert.val > alert.threshold {
alert.triggered = true alert.triggered = true
go am.sendSystemAlert(alert) go am.sendSystemAlert(alert)
@@ -252,6 +295,7 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
} }
} }
} }
}
return nil return nil
} }
@@ -268,18 +312,27 @@ func (am *AlertManager) sendSystemAlert(alert SystemAlertData) {
alert.name = after + "m Load" alert.name = after + "m Load"
} }
// make title alert name lowercase if not CPU // make title alert name lowercase if not CPU or GPU
titleAlertName := alert.name titleAlertName := alert.name
if titleAlertName != "CPU" { if titleAlertName != "CPU" && titleAlertName != "GPU" {
titleAlertName = strings.ToLower(titleAlertName) titleAlertName = strings.ToLower(titleAlertName)
} }
var subject string var subject string
lowAlert := isLowAlert(alert.name)
if alert.triggered { if alert.triggered {
if lowAlert {
subject = fmt.Sprintf("%s %s below threshold", systemName, titleAlertName)
} else {
subject = fmt.Sprintf("%s %s above threshold", systemName, titleAlertName)
}
} else {
if lowAlert {
subject = fmt.Sprintf("%s %s above threshold", systemName, titleAlertName) subject = fmt.Sprintf("%s %s above threshold", systemName, titleAlertName)
} else { } else {
subject = fmt.Sprintf("%s %s below threshold", systemName, titleAlertName) subject = fmt.Sprintf("%s %s below threshold", systemName, titleAlertName)
} }
}
minutesLabel := "minute" minutesLabel := "minute"
if alert.min > 1 { if alert.min > 1 {
minutesLabel += "s" minutesLabel += "s"
@@ -296,9 +349,14 @@ func (am *AlertManager) sendSystemAlert(alert SystemAlertData) {
} }
am.SendAlert(AlertMessageData{ am.SendAlert(AlertMessageData{
UserID: alert.alertRecord.GetString("user"), UserID: alert.alertRecord.GetString("user"),
SystemID: alert.systemRecord.Id,
Title: subject, Title: subject,
Message: body, Message: body,
Link: am.hub.MakeLink("system", systemName), Link: am.hub.MakeLink("system", alert.systemRecord.Id),
LinkText: "View " + systemName, LinkText: "View " + systemName,
}) })
} }
func isLowAlert(name string) bool {
return name == "Battery"
}

View File

@@ -17,9 +17,8 @@ import (
type cmdOptions struct { type cmdOptions struct {
key string // key is the public key(s) for SSH authentication. key string // key is the public key(s) for SSH authentication.
listen string // listen is the address or port to listen on. listen string // listen is the address or port to listen on.
// TODO: add hubURL and token hubURL string // hubURL is the URL of the Beszel hub.
// hubURL string // hubURL is the URL of the hub to use. token string // token is the token to use for authentication.
// token string // token is the token to use for authentication.
} }
// parse parses the command line flags and populates the config struct. // parse parses the command line flags and populates the config struct.
@@ -47,13 +46,13 @@ func (opts *cmdOptions) parse() bool {
// pflag.CommandLine.ParseErrorsWhitelist.UnknownFlags = true // pflag.CommandLine.ParseErrorsWhitelist.UnknownFlags = true
pflag.StringVarP(&opts.key, "key", "k", "", "Public key(s) for SSH authentication") pflag.StringVarP(&opts.key, "key", "k", "", "Public key(s) for SSH authentication")
pflag.StringVarP(&opts.listen, "listen", "l", "", "Address or port to listen on") pflag.StringVarP(&opts.listen, "listen", "l", "", "Address or port to listen on")
// pflag.StringVarP(&opts.hubURL, "hub-url", "u", "", "URL of the hub to use") pflag.StringVarP(&opts.hubURL, "url", "u", "", "URL of the Beszel hub")
// pflag.StringVarP(&opts.token, "token", "t", "", "Token to use for authentication") pflag.StringVarP(&opts.token, "token", "t", "", "Token to use for authentication")
chinaMirrors := pflag.BoolP("china-mirrors", "c", false, "Use mirror for update (gh.beszel.dev) instead of GitHub") chinaMirrors := pflag.BoolP("china-mirrors", "c", false, "Use mirror for update (gh.beszel.dev) instead of GitHub")
help := pflag.BoolP("help", "h", false, "Show this help message") help := pflag.BoolP("help", "h", false, "Show this help message")
// Convert old single-dash long flags to double-dash for backward compatibility // Convert old single-dash long flags to double-dash for backward compatibility
flagsToConvert := []string{"key", "listen"} flagsToConvert := []string{"key", "listen", "url", "token"}
for i, arg := range os.Args { for i, arg := range os.Args {
for _, flag := range flagsToConvert { for _, flag := range flagsToConvert {
singleDash := "-" + flag singleDash := "-" + flag
@@ -95,6 +94,13 @@ func (opts *cmdOptions) parse() bool {
return true return true
} }
// Set environment variables from CLI flags (if provided)
if opts.hubURL != "" {
os.Setenv("HUB_URL", opts.hubURL)
}
if opts.token != "" {
os.Setenv("TOKEN", opts.token)
}
return false return false
} }

View File

@@ -1,22 +1,48 @@
package common package common
type WebSocketAction = uint8 import (
"github.com/fxamacker/cbor/v2"
"github.com/henrygd/beszel/internal/entities/smart"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/henrygd/beszel/internal/entities/systemd"
)
// Not implemented yet type WebSocketAction = uint8
// type AgentError = uint8
const ( const (
// Request system data from agent // Request system data from agent
GetData WebSocketAction = iota GetData WebSocketAction = iota
// Check the fingerprint of the agent // Check the fingerprint of the agent
CheckFingerprint CheckFingerprint
// Request container logs from agent
GetContainerLogs
// Request container info from agent
GetContainerInfo
// Request SMART data from agent
GetSmartData
// Request detailed systemd service info from agent
GetSystemdInfo
// Add new actions here...
) )
// HubRequest defines the structure for requests sent from hub to agent. // HubRequest defines the structure for requests sent from hub to agent.
type HubRequest[T any] struct { type HubRequest[T any] struct {
Action WebSocketAction `cbor:"0,keyasint"` Action WebSocketAction `cbor:"0,keyasint"`
Data T `cbor:"1,keyasint,omitempty,omitzero"` Data T `cbor:"1,keyasint,omitempty,omitzero"`
// Error AgentError `cbor:"error,omitempty,omitzero"` Id *uint32 `cbor:"2,keyasint,omitempty"`
}
// AgentResponse defines the structure for responses sent from agent to hub.
type AgentResponse struct {
Id *uint32 `cbor:"0,keyasint,omitempty"`
SystemData *system.CombinedData `cbor:"1,keyasint,omitempty,omitzero"` // Legacy (<= 0.17)
Fingerprint *FingerprintResponse `cbor:"2,keyasint,omitempty,omitzero"` // Legacy (<= 0.17)
Error string `cbor:"3,keyasint,omitempty,omitzero"`
String *string `cbor:"4,keyasint,omitempty,omitzero"` // Legacy (<= 0.17)
SmartData map[string]smart.SmartData `cbor:"5,keyasint,omitempty,omitzero"` // Legacy (<= 0.17)
ServiceInfo systemd.ServiceDetails `cbor:"6,keyasint,omitempty,omitzero"` // Legacy (<= 0.17)
// Data is the generic response payload for new endpoints (0.18+)
Data cbor.RawMessage `cbor:"7,keyasint,omitempty,omitzero"`
} }
type FingerprintRequest struct { type FingerprintRequest struct {
@@ -27,6 +53,24 @@ type FingerprintRequest struct {
type FingerprintResponse struct { type FingerprintResponse struct {
Fingerprint string `cbor:"0,keyasint"` Fingerprint string `cbor:"0,keyasint"`
// Optional system info for universal token system creation // Optional system info for universal token system creation
Hostname string `cbor:"1,keyasint,omitempty,omitzero"` Hostname string `cbor:"1,keyasint,omitzero"`
Port string `cbor:"2,keyasint,omitempty,omitzero"` Port string `cbor:"2,keyasint,omitzero"`
Name string `cbor:"3,keyasint,omitzero"`
}
type DataRequestOptions struct {
CacheTimeMs uint16 `cbor:"0,keyasint"`
IncludeDetails bool `cbor:"1,keyasint"`
}
type ContainerLogsRequest struct {
ContainerID string `cbor:"0,keyasint"`
}
type ContainerInfoRequest struct {
ContainerID string `cbor:"0,keyasint"`
}
type SystemdInfoRequest struct {
ServiceName string `cbor:"0,keyasint"`
} }

View File

@@ -23,4 +23,7 @@ COPY --from=builder /agent /agent
# this is so we don't need to create the /tmp directory in the scratch container # this is so we don't need to create the /tmp directory in the scratch container
COPY --from=builder /tmp /tmp COPY --from=builder /tmp /tmp
# Ensure data persistence across container recreations
VOLUME ["/var/lib/beszel-agent"]
ENTRYPOINT ["/agent"] ENTRYPOINT ["/agent"]

View File

@@ -0,0 +1,28 @@
FROM --platform=$BUILDPLATFORM golang:alpine AS builder
WORKDIR /app
COPY ../go.mod ../go.sum ./
RUN go mod download
# Copy source files
COPY . ./
# Build
ARG TARGETOS TARGETARCH
RUN CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /agent ./internal/cmd/agent
RUN rm -rf /tmp/*
# --------------------------
# Final image: default scratch-based agent
# --------------------------
FROM alpine:3.23
COPY --from=builder /agent /agent
RUN apk add --no-cache smartmontools
# Ensure data persistence across container recreations
VOLUME ["/var/lib/beszel-agent"]
ENTRYPOINT ["/agent"]

View File

@@ -0,0 +1,28 @@
FROM --platform=$BUILDPLATFORM golang:alpine AS builder
WORKDIR /app
COPY ../go.mod ../go.sum ./
RUN go mod download
# Copy source files
COPY . ./
# Build
ARG TARGETOS TARGETARCH
RUN CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /agent ./internal/cmd/agent
# --------------------------
# Final image
# Note: must cap_add: [CAP_PERFMON] and mount /dev/dri/ as volume
# --------------------------
FROM alpine:3.23
COPY --from=builder /agent /agent
RUN apk add --no-cache -X https://dl-cdn.alpinelinux.org/alpine/edge/testing igt-gpu-tools smartmontools
# Ensure data persistence across container recreations
VOLUME ["/var/lib/beszel-agent"]
ENTRYPOINT ["/agent"]

View File

@@ -1,8 +1,7 @@
FROM --platform=$BUILDPLATFORM golang:alpine AS builder FROM --platform=$BUILDPLATFORM golang:bookworm AS builder
WORKDIR /app WORKDIR /app
COPY ../go.mod ../go.sum ./ COPY ../go.mod ../go.sum ./
RUN go mod download RUN go mod download
@@ -11,9 +10,26 @@ COPY . ./
# Build # Build
ARG TARGETOS TARGETARCH ARG TARGETOS TARGETARCH
RUN CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /agent ./internal/cmd/agent RUN CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -tags glibc -ldflags "-w -s" -o /agent ./internal/cmd/agent
RUN rm -rf /tmp/* # --------------------------
# Smartmontools builder stage
# --------------------------
FROM nvidia/cuda:12.2.2-base-ubuntu22.04 AS smartmontools-builder
RUN apt-get update && apt-get install -y \
wget \
build-essential \
&& wget https://downloads.sourceforge.net/project/smartmontools/smartmontools/7.5/smartmontools-7.5.tar.gz \
&& tar zxvf smartmontools-7.5.tar.gz \
&& cd smartmontools-7.5 \
&& ./configure --prefix=/usr --sysconfdir=/etc \
&& make \
&& make install \
&& rm -rf /smartmontools-7.5* \
&& apt-get remove -y wget build-essential \
&& apt-get autoremove -y \
&& rm -rf /var/lib/apt/lists/*
# -------------------------- # --------------------------
# Final image: GPU-enabled agent with nvidia-smi # Final image: GPU-enabled agent with nvidia-smi
@@ -21,7 +37,10 @@ RUN rm -rf /tmp/*
FROM nvidia/cuda:12.2.2-base-ubuntu22.04 FROM nvidia/cuda:12.2.2-base-ubuntu22.04
COPY --from=builder /agent /agent COPY --from=builder /agent /agent
# this is so we don't need to create the /tmp directory in the scratch container # Copy smartmontools binaries and config files
COPY --from=builder /tmp /tmp COPY --from=smartmontools-builder /usr/sbin/smartctl /usr/sbin/smartctl
# Ensure data persistence across container recreations
VOLUME ["/var/lib/beszel-agent"]
ENTRYPOINT ["/agent"] ENTRYPOINT ["/agent"]

View File

@@ -25,6 +25,9 @@ FROM scratch
COPY --from=builder /beszel / COPY --from=builder /beszel /
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
# Ensure data persistence across container recreations
VOLUME ["/beszel_data"]
EXPOSE 8090 EXPOSE 8090
ENTRYPOINT [ "/beszel" ] ENTRYPOINT [ "/beszel" ]

View File

@@ -8,7 +8,8 @@ type ApiInfo struct {
IdShort string IdShort string
Names []string Names []string
Status string Status string
// Image string State string
Image string
// ImageID string // ImageID string
// Command string // Command string
// Created int64 // Created int64
@@ -16,7 +17,6 @@ type ApiInfo struct {
// SizeRw int64 `json:",omitempty"` // SizeRw int64 `json:",omitempty"`
// SizeRootFs int64 `json:",omitempty"` // SizeRootFs int64 `json:",omitempty"`
// Labels map[string]string // Labels map[string]string
// State string
// HostConfig struct { // HostConfig struct {
// NetworkMode string `json:",omitempty"` // NetworkMode string `json:",omitempty"`
// Annotations map[string]string `json:",omitempty"` // Annotations map[string]string `json:",omitempty"`
@@ -34,6 +34,14 @@ type ApiStats struct {
MemoryStats MemoryStats `json:"memory_stats"` MemoryStats MemoryStats `json:"memory_stats"`
} }
// Docker system info from /info API endpoint
type HostInfo struct {
OperatingSystem string `json:"OperatingSystem"`
KernelVersion string `json:"KernelVersion"`
NCPU int `json:"NCPU"`
MemTotal uint64 `json:"MemTotal"`
}
func (s *ApiStats) CalculateCpuPercentLinux(prevCpuContainer uint64, prevCpuSystem uint64) float64 { func (s *ApiStats) CalculateCpuPercentLinux(prevCpuContainer uint64, prevCpuSystem uint64) float64 {
cpuDelta := s.CPUStats.CPUUsage.TotalUsage - prevCpuContainer cpuDelta := s.CPUStats.CPUUsage.TotalUsage - prevCpuContainer
systemDelta := s.CPUStats.SystemUsage - prevCpuSystem systemDelta := s.CPUStats.SystemUsage - prevCpuSystem
@@ -103,13 +111,35 @@ type prevNetStats struct {
Recv uint64 Recv uint64
} }
type DockerHealth = uint8
const (
DockerHealthNone DockerHealth = iota
DockerHealthStarting
DockerHealthHealthy
DockerHealthUnhealthy
)
var DockerHealthStrings = map[string]DockerHealth{
"none": DockerHealthNone,
"starting": DockerHealthStarting,
"healthy": DockerHealthHealthy,
"unhealthy": DockerHealthUnhealthy,
}
// Docker container stats // Docker container stats
type Stats struct { type Stats struct {
Name string `json:"n" cbor:"0,keyasint"` Name string `json:"n" cbor:"0,keyasint"`
Cpu float64 `json:"c" cbor:"1,keyasint"` Cpu float64 `json:"c" cbor:"1,keyasint"`
Mem float64 `json:"m" cbor:"2,keyasint"` Mem float64 `json:"m" cbor:"2,keyasint"`
NetworkSent float64 `json:"ns" cbor:"3,keyasint"` NetworkSent float64 `json:"ns,omitzero" cbor:"3,keyasint,omitzero"` // deprecated 0.18.3 (MB) - keep field for old agents/records
NetworkRecv float64 `json:"nr" cbor:"4,keyasint"` NetworkRecv float64 `json:"nr,omitzero" cbor:"4,keyasint,omitzero"` // deprecated 0.18.3 (MB) - keep field for old agents/records
Bandwidth [2]uint64 `json:"b,omitzero" cbor:"9,keyasint,omitzero"` // [sent bytes, recv bytes]
Health DockerHealth `json:"-" cbor:"5,keyasint"`
Status string `json:"-" cbor:"6,keyasint"`
Id string `json:"-" cbor:"7,keyasint"`
Image string `json:"-" cbor:"8,keyasint"`
// PrevCpu [2]uint64 `json:"-"` // PrevCpu [2]uint64 `json:"-"`
CpuSystem uint64 `json:"-"` CpuSystem uint64 `json:"-"`
CpuContainer uint64 `json:"-"` CpuContainer uint64 `json:"-"`

View File

@@ -0,0 +1,543 @@
package smart
import (
"encoding/json"
"strconv"
"strings"
)
// Common types
type VersionInfo [2]int
type SmartctlInfo struct {
Version VersionInfo `json:"version"`
SvnRevision string `json:"svn_revision"`
PlatformInfo string `json:"platform_info"`
BuildInfo string `json:"build_info"`
Argv []string `json:"argv"`
ExitStatus int `json:"exit_status"`
}
type DeviceInfo struct {
Name string `json:"name"`
InfoName string `json:"info_name"`
Type string `json:"type"`
Protocol string `json:"protocol"`
}
type UserCapacity struct {
Blocks uint64 `json:"blocks"`
Bytes uint64 `json:"bytes"`
}
// type LocalTime struct {
// TimeT int64 `json:"time_t"`
// Asctime string `json:"asctime"`
// }
// type WwnInfo struct {
// Naa int `json:"naa"`
// Oui int `json:"oui"`
// ID int `json:"id"`
// }
// type FormFactorInfo struct {
// AtaValue int `json:"ata_value"`
// Name string `json:"name"`
// }
// type TrimInfo struct {
// Supported bool `json:"supported"`
// }
// type AtaVersionInfo struct {
// String string `json:"string"`
// MajorValue int `json:"major_value"`
// MinorValue int `json:"minor_value"`
// }
// type VersionStringInfo struct {
// String string `json:"string"`
// Value int `json:"value"`
// }
// type SpeedInfo struct {
// SataValue int `json:"sata_value"`
// String string `json:"string"`
// UnitsPerSecond int `json:"units_per_second"`
// BitsPerUnit int `json:"bits_per_unit"`
// }
// type InterfaceSpeedInfo struct {
// Max SpeedInfo `json:"max"`
// Current SpeedInfo `json:"current"`
// }
type SmartStatusInfo struct {
Passed bool `json:"passed"`
}
type StatusInfo struct {
Value int `json:"value"`
String string `json:"string"`
Passed bool `json:"passed"`
}
type PollingMinutes struct {
Short int `json:"short"`
Extended int `json:"extended"`
}
type CapabilitiesInfo struct {
Values []int `json:"values"`
ExecOfflineImmediateSupported bool `json:"exec_offline_immediate_supported"`
OfflineIsAbortedUponNewCmd bool `json:"offline_is_aborted_upon_new_cmd"`
OfflineSurfaceScanSupported bool `json:"offline_surface_scan_supported"`
SelfTestsSupported bool `json:"self_tests_supported"`
ConveyanceSelfTestSupported bool `json:"conveyance_self_test_supported"`
SelectiveSelfTestSupported bool `json:"selective_self_test_supported"`
AttributeAutosaveEnabled bool `json:"attribute_autosave_enabled"`
ErrorLoggingSupported bool `json:"error_logging_supported"`
GpLoggingSupported bool `json:"gp_logging_supported"`
}
// type AtaSmartData struct {
// OfflineDataCollection OfflineDataCollectionInfo `json:"offline_data_collection"`
// SelfTest SelfTestInfo `json:"self_test"`
// Capabilities CapabilitiesInfo `json:"capabilities"`
// }
// type OfflineDataCollectionInfo struct {
// Status StatusInfo `json:"status"`
// CompletionSeconds int `json:"completion_seconds"`
// }
// type SelfTestInfo struct {
// Status StatusInfo `json:"status"`
// PollingMinutes PollingMinutes `json:"polling_minutes"`
// }
// type AtaSctCapabilities struct {
// Value int `json:"value"`
// ErrorRecoveryControlSupported bool `json:"error_recovery_control_supported"`
// FeatureControlSupported bool `json:"feature_control_supported"`
// DataTableSupported bool `json:"data_table_supported"`
// }
type SummaryInfo struct {
Revision int `json:"revision"`
Count int `json:"count"`
}
type AtaSmartAttributes struct {
Table []AtaSmartAttribute `json:"table"`
}
type AtaDeviceStatistics struct {
Pages []AtaDeviceStatisticsPage `json:"pages"`
}
type AtaDeviceStatisticsPage struct {
Number uint8 `json:"number"`
Table []AtaDeviceStatisticsEntry `json:"table"`
}
type AtaDeviceStatisticsEntry struct {
Name string `json:"name"`
Value *uint64 `json:"value,omitempty"`
}
type AtaSmartAttribute struct {
ID uint16 `json:"id"`
Name string `json:"name"`
Value uint16 `json:"value"`
Worst uint16 `json:"worst"`
Thresh uint16 `json:"thresh"`
WhenFailed string `json:"when_failed"`
// Flags AttributeFlags `json:"flags"`
Raw RawValue `json:"raw"`
}
// type AttributeFlags struct {
// Value int `json:"value"`
// String string `json:"string"`
// Prefailure bool `json:"prefailure"`
// UpdatedOnline bool `json:"updated_online"`
// Performance bool `json:"performance"`
// ErrorRate bool `json:"error_rate"`
// EventCount bool `json:"event_count"`
// AutoKeep bool `json:"auto_keep"`
// }
type RawValue struct {
Value SmartRawValue `json:"value"`
String string `json:"string"`
}
func (r *RawValue) UnmarshalJSON(data []byte) error {
var tmp struct {
Value json.RawMessage `json:"value"`
String string `json:"string"`
}
if err := json.Unmarshal(data, &tmp); err != nil {
return err
}
if len(tmp.Value) > 0 {
if err := r.Value.UnmarshalJSON(tmp.Value); err != nil {
return err
}
} else {
r.Value = 0
}
r.String = tmp.String
if parsed, ok := ParseSmartRawValueString(tmp.String); ok {
r.Value = SmartRawValue(parsed)
}
return nil
}
type SmartRawValue uint64
// handles when drives report strings like "0h+0m+0.000s" or "7344 (253d 8h)" for power on hours
func (v *SmartRawValue) UnmarshalJSON(data []byte) error {
trimmed := strings.TrimSpace(string(data))
if len(trimmed) == 0 || trimmed == "null" {
*v = 0
return nil
}
if trimmed[0] == '"' {
valueStr, err := strconv.Unquote(trimmed)
if err != nil {
return err
}
parsed, ok := ParseSmartRawValueString(valueStr)
if ok {
*v = SmartRawValue(parsed)
return nil
}
*v = 0
return nil
}
if parsed, err := strconv.ParseUint(trimmed, 0, 64); err == nil {
*v = SmartRawValue(parsed)
return nil
}
if parsed, ok := ParseSmartRawValueString(trimmed); ok {
*v = SmartRawValue(parsed)
return nil
}
*v = 0
return nil
}
// ParseSmartRawValueString attempts to extract a numeric value from the raw value
// strings emitted by smartctl, which sometimes include human-friendly annotations
// like "7344 (253d 8h)" or "0h+0m+0.000s". It returns the parsed value and a
// boolean indicating success.
func ParseSmartRawValueString(value string) (uint64, bool) {
value = strings.TrimSpace(value)
if value == "" {
return 0, false
}
if parsed, err := strconv.ParseUint(value, 0, 64); err == nil {
return parsed, true
}
if idx := strings.IndexRune(value, 'h'); idx > 0 {
hoursPart := strings.TrimSpace(value[:idx])
if hoursPart != "" {
if parsed, err := strconv.ParseFloat(hoursPart, 64); err == nil {
return uint64(parsed), true
}
}
}
for i := 0; i < len(value); i++ {
if value[i] < '0' || value[i] > '9' {
continue
}
end := i + 1
for end < len(value) && value[end] >= '0' && value[end] <= '9' {
end++
}
digits := value[i:end]
if parsed, err := strconv.ParseUint(digits, 10, 64); err == nil {
return parsed, true
}
i = end
}
return 0, false
}
// type PowerOnTimeInfo struct {
// Hours uint32 `json:"hours"`
// }
type TemperatureInfo struct {
Current uint8 `json:"current"`
}
type TemperatureInfoScsi struct {
Current uint8 `json:"current"`
DriveTrip uint8 `json:"drive_trip"`
}
// type SelectiveSelfTestTable struct {
// LbaMin int `json:"lba_min"`
// LbaMax int `json:"lba_max"`
// Status StatusInfo `json:"status"`
// }
// type SelectiveSelfTestFlags struct {
// Value int `json:"value"`
// RemainderScanEnabled bool `json:"remainder_scan_enabled"`
// }
// type AtaSmartSelectiveSelfTestLog struct {
// Revision int `json:"revision"`
// Table []SelectiveSelfTestTable `json:"table"`
// Flags SelectiveSelfTestFlags `json:"flags"`
// PowerUpScanResumeMinutes int `json:"power_up_scan_resume_minutes"`
// }
// BaseSmartInfo contains common fields shared between SATA and NVMe drives
// type BaseSmartInfo struct {
// Device DeviceInfo `json:"device"`
// ModelName string `json:"model_name"`
// SerialNumber string `json:"serial_number"`
// FirmwareVersion string `json:"firmware_version"`
// UserCapacity UserCapacity `json:"user_capacity"`
// LogicalBlockSize int `json:"logical_block_size"`
// LocalTime LocalTime `json:"local_time"`
// }
type SmartctlInfoLegacy struct {
Version VersionInfo `json:"version"`
SvnRevision string `json:"svn_revision"`
PlatformInfo string `json:"platform_info"`
BuildInfo string `json:"build_info"`
Argv []string `json:"argv"`
ExitStatus int `json:"exit_status"`
}
type SmartInfoForSata struct {
// JSONFormatVersion VersionInfo `json:"json_format_version"`
Smartctl SmartctlInfoLegacy `json:"smartctl"`
Device DeviceInfo `json:"device"`
// ModelFamily string `json:"model_family"`
ModelName string `json:"model_name"`
SerialNumber string `json:"serial_number"`
// Wwn WwnInfo `json:"wwn"`
FirmwareVersion string `json:"firmware_version"`
UserCapacity UserCapacity `json:"user_capacity"`
ScsiVendor string `json:"scsi_vendor"`
ScsiProduct string `json:"scsi_product"`
// LogicalBlockSize int `json:"logical_block_size"`
// PhysicalBlockSize int `json:"physical_block_size"`
// RotationRate int `json:"rotation_rate"`
// FormFactor FormFactorInfo `json:"form_factor"`
// Trim TrimInfo `json:"trim"`
// InSmartctlDatabase bool `json:"in_smartctl_database"`
// AtaVersion AtaVersionInfo `json:"ata_version"`
// SataVersion VersionStringInfo `json:"sata_version"`
// InterfaceSpeed InterfaceSpeedInfo `json:"interface_speed"`
// LocalTime LocalTime `json:"local_time"`
SmartStatus SmartStatusInfo `json:"smart_status"`
// AtaSmartData AtaSmartData `json:"ata_smart_data"`
// AtaSctCapabilities AtaSctCapabilities `json:"ata_sct_capabilities"`
AtaSmartAttributes AtaSmartAttributes `json:"ata_smart_attributes"`
AtaDeviceStatistics AtaDeviceStatistics `json:"ata_device_statistics"`
// PowerOnTime PowerOnTimeInfo `json:"power_on_time"`
// PowerCycleCount uint16 `json:"power_cycle_count"`
Temperature TemperatureInfo `json:"temperature"`
// AtaSmartErrorLog AtaSmartErrorLog `json:"ata_smart_error_log"`
// AtaSmartSelfTestLog AtaSmartSelfTestLog `json:"ata_smart_self_test_log"`
// AtaSmartSelectiveSelfTestLog AtaSmartSelectiveSelfTestLog `json:"ata_smart_selective_self_test_log"`
}
type ScsiErrorCounter struct {
ErrorsCorrectedByECCFast uint64 `json:"errors_corrected_by_eccfast"`
ErrorsCorrectedByECCDelayed uint64 `json:"errors_corrected_by_eccdelayed"`
ErrorsCorrectedByRereadsRewrites uint64 `json:"errors_corrected_by_rereads_rewrites"`
TotalErrorsCorrected uint64 `json:"total_errors_corrected"`
CorrectionAlgorithmInvocations uint64 `json:"correction_algorithm_invocations"`
GigabytesProcessed string `json:"gigabytes_processed"`
TotalUncorrectedErrors uint64 `json:"total_uncorrected_errors"`
}
type ScsiErrorCounterLog struct {
Read ScsiErrorCounter `json:"read"`
Write ScsiErrorCounter `json:"write"`
Verify ScsiErrorCounter `json:"verify"`
}
type ScsiStartStopCycleCounter struct {
YearOfManufacture string `json:"year_of_manufacture"`
WeekOfManufacture string `json:"week_of_manufacture"`
SpecifiedCycleCountOverDeviceLifetime uint64 `json:"specified_cycle_count_over_device_lifetime"`
AccumulatedStartStopCycles uint64 `json:"accumulated_start_stop_cycles"`
SpecifiedLoadUnloadCountOverDeviceLifetime uint64 `json:"specified_load_unload_count_over_device_lifetime"`
AccumulatedLoadUnloadCycles uint64 `json:"accumulated_load_unload_cycles"`
}
type PowerOnTimeScsi struct {
Hours uint64 `json:"hours"`
Minutes uint64 `json:"minutes"`
}
type SmartInfoForScsi struct {
Smartctl SmartctlInfoLegacy `json:"smartctl"`
Device DeviceInfo `json:"device"`
ScsiVendor string `json:"scsi_vendor"`
ScsiProduct string `json:"scsi_product"`
ScsiModelName string `json:"scsi_model_name"`
ScsiRevision string `json:"scsi_revision"`
ScsiVersion string `json:"scsi_version"`
SerialNumber string `json:"serial_number"`
UserCapacity UserCapacity `json:"user_capacity"`
Temperature TemperatureInfoScsi `json:"temperature"`
SmartStatus SmartStatusInfo `json:"smart_status"`
PowerOnTime PowerOnTimeScsi `json:"power_on_time"`
ScsiStartStopCycleCounter ScsiStartStopCycleCounter `json:"scsi_start_stop_cycle_counter"`
ScsiGrownDefectList uint64 `json:"scsi_grown_defect_list"`
ScsiErrorCounterLog ScsiErrorCounterLog `json:"scsi_error_counter_log"`
}
// type AtaSmartErrorLog struct {
// Summary SummaryInfo `json:"summary"`
// }
// type AtaSmartSelfTestLog struct {
// Standard SummaryInfo `json:"standard"`
// }
type SmartctlInfoNvme struct {
Version VersionInfo `json:"version"`
SVNRevision string `json:"svn_revision"`
PlatformInfo string `json:"platform_info"`
BuildInfo string `json:"build_info"`
Argv []string `json:"argv"`
ExitStatus int `json:"exit_status"`
}
// type NVMePCIVendor struct {
// ID int `json:"id"`
// SubsystemID int `json:"subsystem_id"`
// }
// type SizeCapacityInfo struct {
// Blocks uint64 `json:"blocks"`
// Bytes uint64 `json:"bytes"`
// }
// type EUI64Info struct {
// OUI int `json:"oui"`
// ExtID int `json:"ext_id"`
// }
// type NVMeNamespace struct {
// ID uint32 `json:"id"`
// Size SizeCapacityInfo `json:"size"`
// Capacity SizeCapacityInfo `json:"capacity"`
// Utilization SizeCapacityInfo `json:"utilization"`
// FormattedLBASize uint32 `json:"formatted_lba_size"`
// EUI64 EUI64Info `json:"eui64"`
// }
type SmartStatusInfoNvme struct {
Passed bool `json:"passed"`
NVMe SmartStatusNVMe `json:"nvme"`
}
type SmartStatusNVMe struct {
Value int `json:"value"`
}
type NVMeSmartHealthInformationLog struct {
CriticalWarning uint `json:"critical_warning"`
Temperature uint8 `json:"temperature"`
AvailableSpare uint `json:"available_spare"`
AvailableSpareThreshold uint `json:"available_spare_threshold"`
PercentageUsed uint8 `json:"percentage_used"`
DataUnitsRead uint64 `json:"data_units_read"`
DataUnitsWritten uint64 `json:"data_units_written"`
HostReads uint `json:"host_reads"`
HostWrites uint `json:"host_writes"`
ControllerBusyTime uint `json:"controller_busy_time"`
PowerCycles uint16 `json:"power_cycles"`
PowerOnHours uint32 `json:"power_on_hours"`
UnsafeShutdowns uint16 `json:"unsafe_shutdowns"`
MediaErrors uint `json:"media_errors"`
NumErrLogEntries uint `json:"num_err_log_entries"`
WarningTempTime uint `json:"warning_temp_time"`
CriticalCompTime uint `json:"critical_comp_time"`
TemperatureSensors []uint8 `json:"temperature_sensors"`
}
type SmartInfoForNvme struct {
// JSONFormatVersion VersionInfo `json:"json_format_version"`
Smartctl SmartctlInfoNvme `json:"smartctl"`
Device DeviceInfo `json:"device"`
ModelName string `json:"model_name"`
SerialNumber string `json:"serial_number"`
FirmwareVersion string `json:"firmware_version"`
// NVMePCIVendor NVMePCIVendor `json:"nvme_pci_vendor"`
// NVMeIEEEOUIIdentifier uint32 `json:"nvme_ieee_oui_identifier"`
// NVMeTotalCapacity uint64 `json:"nvme_total_capacity"`
// NVMeUnallocatedCapacity uint64 `json:"nvme_unallocated_capacity"`
// NVMeControllerID uint16 `json:"nvme_controller_id"`
// NVMeVersion VersionStringInfo `json:"nvme_version"`
// NVMeNumberOfNamespaces uint8 `json:"nvme_number_of_namespaces"`
// NVMeNamespaces []NVMeNamespace `json:"nvme_namespaces"`
UserCapacity UserCapacity `json:"user_capacity"`
// LogicalBlockSize int `json:"logical_block_size"`
// LocalTime LocalTime `json:"local_time"`
SmartStatus SmartStatusInfoNvme `json:"smart_status"`
NVMeSmartHealthInformationLog NVMeSmartHealthInformationLog `json:"nvme_smart_health_information_log"`
Temperature TemperatureInfoNvme `json:"temperature"`
PowerCycleCount uint16 `json:"power_cycle_count"`
PowerOnTime PowerOnTimeInfoNvme `json:"power_on_time"`
}
type TemperatureInfoNvme struct {
Current int `json:"current"`
}
type PowerOnTimeInfoNvme struct {
Hours int `json:"hours"`
}
type SmartData struct {
// ModelFamily string `json:"mf,omitempty" cbor:"0,keyasint,omitempty"`
ModelName string `json:"mn,omitempty" cbor:"1,keyasint,omitempty"`
SerialNumber string `json:"sn,omitempty" cbor:"2,keyasint,omitempty"`
FirmwareVersion string `json:"fv,omitempty" cbor:"3,keyasint,omitempty"`
Capacity uint64 `json:"c,omitempty" cbor:"4,keyasint,omitempty"`
SmartStatus string `json:"s,omitempty" cbor:"5,keyasint,omitempty"`
DiskName string `json:"dn,omitempty" cbor:"6,keyasint,omitempty"`
DiskType string `json:"dt,omitempty" cbor:"7,keyasint,omitempty"`
Temperature uint8 `json:"t,omitempty" cbor:"8,keyasint,omitempty"`
Attributes []*SmartAttribute `json:"a,omitempty" cbor:"9,keyasint,omitempty"`
}
type SmartAttribute struct {
ID uint16 `json:"id,omitempty" cbor:"0,keyasint,omitempty"`
Name string `json:"n" cbor:"1,keyasint"`
Value uint16 `json:"v,omitempty" cbor:"2,keyasint,omitempty"`
Worst uint16 `json:"w,omitempty" cbor:"3,keyasint,omitempty"`
Threshold uint16 `json:"t,omitempty" cbor:"4,keyasint,omitempty"`
RawValue uint64 `json:"rv" cbor:"5,keyasint"`
RawString string `json:"rs,omitempty" cbor:"6,keyasint,omitempty"`
WhenFailed string `json:"wf,omitempty" cbor:"7,keyasint,omitempty"`
}

View File

@@ -0,0 +1,62 @@
package smart
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
)
func TestSmartRawValueUnmarshalDuration(t *testing.T) {
input := []byte(`{"value":"62312h+33m+50.907s","string":"62312h+33m+50.907s"}`)
var raw RawValue
err := json.Unmarshal(input, &raw)
assert.NoError(t, err)
assert.EqualValues(t, 62312, raw.Value)
}
func TestSmartRawValueUnmarshalNumericString(t *testing.T) {
input := []byte(`{"value":"7344","string":"7344"}`)
var raw RawValue
err := json.Unmarshal(input, &raw)
assert.NoError(t, err)
assert.EqualValues(t, 7344, raw.Value)
}
func TestSmartRawValueUnmarshalParenthetical(t *testing.T) {
input := []byte(`{"value":"39925 (212 206 0)","string":"39925 (212 206 0)"}`)
var raw RawValue
err := json.Unmarshal(input, &raw)
assert.NoError(t, err)
assert.EqualValues(t, 39925, raw.Value)
}
func TestSmartRawValueUnmarshalDurationWithFractions(t *testing.T) {
input := []byte(`{"value":"2748h+31m+49.560s","string":"2748h+31m+49.560s"}`)
var raw RawValue
err := json.Unmarshal(input, &raw)
assert.NoError(t, err)
assert.EqualValues(t, 2748, raw.Value)
}
func TestSmartRawValueUnmarshalParentheticalRawValue(t *testing.T) {
input := []byte(`{"value":57891864217128,"string":"39925 (212 206 0)"}`)
var raw RawValue
err := json.Unmarshal(input, &raw)
assert.NoError(t, err)
assert.EqualValues(t, 39925, raw.Value)
}
func TestSmartRawValueUnmarshalDurationRawValue(t *testing.T) {
input := []byte(`{"value":57891864217128,"string":"2748h+31m+49.560s"}`)
var raw RawValue
err := json.Unmarshal(input, &raw)
assert.NoError(t, err)
assert.EqualValues(t, 2748, raw.Value)
}

View File

@@ -3,9 +3,11 @@ package system
// TODO: this is confusing, make common package with common/types common/helpers etc // TODO: this is confusing, make common package with common/types common/helpers etc
import ( import (
"encoding/json"
"time" "time"
"github.com/henrygd/beszel/internal/entities/container" "github.com/henrygd/beszel/internal/entities/container"
"github.com/henrygd/beszel/internal/entities/systemd"
) )
type Stats struct { type Stats struct {
@@ -25,8 +27,8 @@ type Stats struct {
DiskWritePs float64 `json:"dw" cbor:"13,keyasint"` DiskWritePs float64 `json:"dw" cbor:"13,keyasint"`
MaxDiskReadPs float64 `json:"drm,omitempty" cbor:"14,keyasint,omitempty"` MaxDiskReadPs float64 `json:"drm,omitempty" cbor:"14,keyasint,omitempty"`
MaxDiskWritePs float64 `json:"dwm,omitempty" cbor:"15,keyasint,omitempty"` MaxDiskWritePs float64 `json:"dwm,omitempty" cbor:"15,keyasint,omitempty"`
NetworkSent float64 `json:"ns" cbor:"16,keyasint"` NetworkSent float64 `json:"ns,omitzero" cbor:"16,keyasint,omitzero"`
NetworkRecv float64 `json:"nr" cbor:"17,keyasint"` NetworkRecv float64 `json:"nr,omitzero" cbor:"17,keyasint,omitzero"`
MaxNetworkSent float64 `json:"nsm,omitempty" cbor:"18,keyasint,omitempty"` MaxNetworkSent float64 `json:"nsm,omitempty" cbor:"18,keyasint,omitempty"`
MaxNetworkRecv float64 `json:"nrm,omitempty" cbor:"19,keyasint,omitempty"` MaxNetworkRecv float64 `json:"nrm,omitempty" cbor:"19,keyasint,omitempty"`
Temperatures map[string]float64 `json:"t,omitempty" cbor:"20,keyasint,omitempty"` Temperatures map[string]float64 `json:"t,omitempty" cbor:"20,keyasint,omitempty"`
@@ -42,22 +44,46 @@ type Stats struct {
Battery [2]uint8 `json:"bat,omitzero" cbor:"29,keyasint,omitzero"` // [percent, charge state, current] Battery [2]uint8 `json:"bat,omitzero" cbor:"29,keyasint,omitzero"` // [percent, charge state, current]
MaxMem float64 `json:"mm,omitempty" cbor:"30,keyasint,omitempty"` MaxMem float64 `json:"mm,omitempty" cbor:"30,keyasint,omitempty"`
NetworkInterfaces map[string][4]uint64 `json:"ni,omitempty" cbor:"31,keyasint,omitempty"` // [upload bytes, download bytes, total upload, total download] NetworkInterfaces map[string][4]uint64 `json:"ni,omitempty" cbor:"31,keyasint,omitempty"` // [upload bytes, download bytes, total upload, total download]
DiskIO [2]uint64 `json:"dio,omitzero" cbor:"32,keyasint,omitzero"` // [read bytes, write bytes]
MaxDiskIO [2]uint64 `json:"diom,omitzero" cbor:"-"` // [max read bytes, max write bytes]
CpuBreakdown []float64 `json:"cpub,omitempty" cbor:"33,keyasint,omitempty"` // [user, system, iowait, steal, idle]
CpuCoresUsage Uint8Slice `json:"cpus,omitempty" cbor:"34,keyasint,omitempty"` // per-core busy usage [CPU0..]
}
// Uint8Slice wraps []uint8 to customize JSON encoding while keeping CBOR efficient.
// JSON: encodes as array of numbers (avoids base64 string).
// CBOR: falls back to default handling for []uint8 (byte string), keeping payload small.
type Uint8Slice []uint8
func (s Uint8Slice) MarshalJSON() ([]byte, error) {
if s == nil {
return []byte("null"), nil
}
// Convert to wider ints to force array-of-numbers encoding.
arr := make([]uint16, len(s))
for i, v := range s {
arr[i] = uint16(v)
}
return json.Marshal(arr)
} }
type GPUData struct { type GPUData struct {
Name string `json:"n" cbor:"0,keyasint"` Name string `json:"n" cbor:"0,keyasint"`
Temperature float64 `json:"-"` Temperature float64 `json:"-"`
MemoryUsed float64 `json:"mu,omitempty" cbor:"1,keyasint,omitempty"` MemoryUsed float64 `json:"mu,omitempty,omitzero" cbor:"1,keyasint,omitempty,omitzero"`
MemoryTotal float64 `json:"mt,omitempty" cbor:"2,keyasint,omitempty"` MemoryTotal float64 `json:"mt,omitempty,omitzero" cbor:"2,keyasint,omitempty,omitzero"`
Usage float64 `json:"u" cbor:"3,keyasint"` Usage float64 `json:"u" cbor:"3,keyasint,omitempty"`
Power float64 `json:"p,omitempty" cbor:"4,keyasint,omitempty"` Power float64 `json:"p,omitempty" cbor:"4,keyasint,omitempty"`
Count float64 `json:"-"` Count float64 `json:"-"`
Engines map[string]float64 `json:"e,omitempty" cbor:"5,keyasint,omitempty"`
PowerPkg float64 `json:"pp,omitempty" cbor:"6,keyasint,omitempty"`
} }
type FsStats struct { type FsStats struct {
Time time.Time `json:"-"` Time time.Time `json:"-"`
Root bool `json:"-"` Root bool `json:"-"`
Mountpoint string `json:"-"` Mountpoint string `json:"-"`
Name string `json:"-"`
DiskTotal float64 `json:"d" cbor:"0,keyasint"` DiskTotal float64 `json:"d" cbor:"0,keyasint"`
DiskUsed float64 `json:"du" cbor:"1,keyasint"` DiskUsed float64 `json:"du" cbor:"1,keyasint"`
TotalRead uint64 `json:"-"` TotalRead uint64 `json:"-"`
@@ -66,6 +92,11 @@ type FsStats struct {
DiskWritePs float64 `json:"w" cbor:"3,keyasint"` DiskWritePs float64 `json:"w" cbor:"3,keyasint"`
MaxDiskReadPS float64 `json:"rm,omitempty" cbor:"4,keyasint,omitempty"` MaxDiskReadPS float64 `json:"rm,omitempty" cbor:"4,keyasint,omitempty"`
MaxDiskWritePS float64 `json:"wm,omitempty" cbor:"5,keyasint,omitempty"` MaxDiskWritePS float64 `json:"wm,omitempty" cbor:"5,keyasint,omitempty"`
// TODO: remove DiskReadPs and DiskWritePs in future release in favor of DiskReadBytes and DiskWriteBytes
DiskReadBytes uint64 `json:"rb" cbor:"6,keyasint,omitempty"`
DiskWriteBytes uint64 `json:"wb" cbor:"7,keyasint,omitempty"`
MaxDiskReadBytes uint64 `json:"rbm,omitempty" cbor:"-"`
MaxDiskWriteBytes uint64 `json:"wbm,omitempty" cbor:"-"`
} }
type NetIoStats struct { type NetIoStats struct {
@@ -92,29 +123,49 @@ const (
ConnectionTypeWebSocket ConnectionTypeWebSocket
) )
// Core system data that is needed in All Systems table
type Info struct { type Info struct {
Hostname string `json:"h" cbor:"0,keyasint"` Hostname string `json:"h,omitempty" cbor:"0,keyasint,omitempty"` // deprecated - moved to Details struct
KernelVersion string `json:"k,omitempty" cbor:"1,keyasint,omitempty"` KernelVersion string `json:"k,omitempty" cbor:"1,keyasint,omitempty"` // deprecated - moved to Details struct
Cores int `json:"c" cbor:"2,keyasint"` Cores int `json:"c,omitzero" cbor:"2,keyasint,omitzero"` // deprecated - moved to Details struct
// Threads is needed in Info struct to calculate load average thresholds
Threads int `json:"t,omitempty" cbor:"3,keyasint,omitempty"` Threads int `json:"t,omitempty" cbor:"3,keyasint,omitempty"`
CpuModel string `json:"m" cbor:"4,keyasint"` CpuModel string `json:"m,omitempty" cbor:"4,keyasint,omitempty"` // deprecated - moved to Details struct
Uptime uint64 `json:"u" cbor:"5,keyasint"` Uptime uint64 `json:"u" cbor:"5,keyasint"`
Cpu float64 `json:"cpu" cbor:"6,keyasint"` Cpu float64 `json:"cpu" cbor:"6,keyasint"`
MemPct float64 `json:"mp" cbor:"7,keyasint"` MemPct float64 `json:"mp" cbor:"7,keyasint"`
DiskPct float64 `json:"dp" cbor:"8,keyasint"` DiskPct float64 `json:"dp" cbor:"8,keyasint"`
Bandwidth float64 `json:"b" cbor:"9,keyasint"` Bandwidth float64 `json:"b" cbor:"9,keyasint"`
AgentVersion string `json:"v" cbor:"10,keyasint"` AgentVersion string `json:"v" cbor:"10,keyasint"`
Podman bool `json:"p,omitempty" cbor:"11,keyasint,omitempty"` Podman bool `json:"p,omitempty" cbor:"11,keyasint,omitempty"` // deprecated - moved to Details struct
GpuPct float64 `json:"g,omitempty" cbor:"12,keyasint,omitempty"` GpuPct float64 `json:"g,omitempty" cbor:"12,keyasint,omitempty"`
DashboardTemp float64 `json:"dt,omitempty" cbor:"13,keyasint,omitempty"` DashboardTemp float64 `json:"dt,omitempty" cbor:"13,keyasint,omitempty"`
Os Os `json:"os" cbor:"14,keyasint"` Os Os `json:"os,omitempty" cbor:"14,keyasint,omitempty"` // deprecated - moved to Details struct
LoadAvg1 float64 `json:"l1,omitempty" cbor:"15,keyasint,omitempty"` LoadAvg1 float64 `json:"l1,omitempty" cbor:"15,keyasint,omitempty"` // deprecated - use `la` array instead
LoadAvg5 float64 `json:"l5,omitempty" cbor:"16,keyasint,omitempty"` LoadAvg5 float64 `json:"l5,omitempty" cbor:"16,keyasint,omitempty"` // deprecated - use `la` array instead
LoadAvg15 float64 `json:"l15,omitempty" cbor:"17,keyasint,omitempty"` LoadAvg15 float64 `json:"l15,omitempty" cbor:"17,keyasint,omitempty"` // deprecated - use `la` array instead
BandwidthBytes uint64 `json:"bb" cbor:"18,keyasint"` 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"` LoadAvg [3]float64 `json:"la,omitempty" cbor:"19,keyasint"`
ConnectionType ConnectionType `json:"ct,omitempty" cbor:"20,keyasint,omitempty,omitzero"` ConnectionType ConnectionType `json:"ct,omitempty" cbor:"20,keyasint,omitempty,omitzero"`
ExtraFsPct map[string]float64 `json:"efs,omitempty" cbor:"21,keyasint,omitempty"`
Services []uint16 `json:"sv,omitempty" cbor:"22,keyasint,omitempty"` // [totalServices, numFailedServices]
Battery [2]uint8 `json:"bat,omitzero" cbor:"23,keyasint,omitzero"` // [percent, charge state]
}
// Data that does not change during process lifetime and is not needed in All Systems table
type Details struct {
Hostname string `cbor:"0,keyasint"`
Kernel string `cbor:"1,keyasint,omitempty"`
Cores int `cbor:"2,keyasint"`
Threads int `cbor:"3,keyasint"`
CpuModel string `cbor:"4,keyasint"`
Os Os `cbor:"5,keyasint"`
OsName string `cbor:"6,keyasint"`
Arch string `cbor:"7,keyasint"`
Podman bool `cbor:"8,keyasint,omitempty"`
MemoryTotal uint64 `cbor:"9,keyasint"`
SmartInterval time.Duration `cbor:"10,keyasint,omitempty"`
} }
// Final data structure to return to the hub // Final data structure to return to the hub
@@ -122,4 +173,6 @@ type CombinedData struct {
Stats Stats `json:"stats" cbor:"0,keyasint"` Stats Stats `json:"stats" cbor:"0,keyasint"`
Info Info `json:"info" cbor:"1,keyasint"` Info Info `json:"info" cbor:"1,keyasint"`
Containers []*container.Stats `json:"container" cbor:"2,keyasint"` Containers []*container.Stats `json:"container" cbor:"2,keyasint"`
SystemdServices []*systemd.Service `json:"systemd,omitempty" cbor:"3,keyasint,omitempty"`
Details *Details `cbor:"4,keyasint,omitempty"`
} }

View File

@@ -0,0 +1,127 @@
package systemd
import (
"math"
"runtime"
"time"
)
// ServiceState represents the status of a systemd service
type ServiceState uint8
const (
StatusActive ServiceState = iota
StatusInactive
StatusFailed
StatusActivating
StatusDeactivating
StatusReloading
)
// ServiceSubState represents the sub status of a systemd service
type ServiceSubState uint8
const (
SubStateDead ServiceSubState = iota
SubStateRunning
SubStateExited
SubStateFailed
SubStateUnknown
)
// ParseServiceStatus converts a string status to a ServiceStatus enum value
func ParseServiceStatus(status string) ServiceState {
switch status {
case "active":
return StatusActive
case "inactive":
return StatusInactive
case "failed":
return StatusFailed
case "activating":
return StatusActivating
case "deactivating":
return StatusDeactivating
case "reloading":
return StatusReloading
default:
return StatusInactive
}
}
// ParseServiceSubState converts a string sub status to a ServiceSubState enum value
func ParseServiceSubState(subState string) ServiceSubState {
switch subState {
case "dead":
return SubStateDead
case "running":
return SubStateRunning
case "exited":
return SubStateExited
case "failed":
return SubStateFailed
default:
return SubStateUnknown
}
}
// Service represents a single systemd service with its stats.
type Service struct {
Name string `json:"n" cbor:"0,keyasint"`
State ServiceState `json:"s" cbor:"1,keyasint"`
Cpu float64 `json:"c" cbor:"2,keyasint"`
Mem uint64 `json:"m" cbor:"3,keyasint"`
MemPeak uint64 `json:"mp" cbor:"4,keyasint"`
Sub ServiceSubState `json:"ss" cbor:"5,keyasint"`
CpuPeak float64 `json:"cp" cbor:"6,keyasint"`
PrevCpuUsage uint64 `json:"-"`
PrevReadTime time.Time `json:"-"`
}
// UpdateCPUPercent calculates the CPU usage percentage for the service.
func (s *Service) UpdateCPUPercent(cpuUsage uint64) {
now := time.Now()
if s.PrevReadTime.IsZero() || cpuUsage < s.PrevCpuUsage {
s.Cpu = 0
s.PrevCpuUsage = cpuUsage
s.PrevReadTime = now
return
}
duration := now.Sub(s.PrevReadTime).Nanoseconds()
if duration <= 0 {
s.PrevCpuUsage = cpuUsage
s.PrevReadTime = now
return
}
coreCount := int64(runtime.NumCPU())
duration *= coreCount
usageDelta := cpuUsage - s.PrevCpuUsage
cpuPercent := float64(usageDelta) / float64(duration)
s.Cpu = twoDecimals(cpuPercent * 100)
if s.Cpu > s.CpuPeak {
s.CpuPeak = s.Cpu
}
s.PrevCpuUsage = cpuUsage
s.PrevReadTime = now
}
func twoDecimals(value float64) float64 {
return math.Round(value*100) / 100
}
// ServiceDependency represents a unit that the service depends on.
type ServiceDependency struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
ActiveState string `json:"activeState,omitempty"`
SubState string `json:"subState,omitempty"`
}
// ServiceDetails contains extended information about a systemd service.
type ServiceDetails map[string]any

View File

@@ -0,0 +1,113 @@
//go:build testing
package systemd_test
import (
"testing"
"time"
"github.com/henrygd/beszel/internal/entities/systemd"
"github.com/stretchr/testify/assert"
)
func TestParseServiceStatus(t *testing.T) {
tests := []struct {
input string
expected systemd.ServiceState
}{
{"active", systemd.StatusActive},
{"inactive", systemd.StatusInactive},
{"failed", systemd.StatusFailed},
{"activating", systemd.StatusActivating},
{"deactivating", systemd.StatusDeactivating},
{"reloading", systemd.StatusReloading},
{"unknown", systemd.StatusInactive}, // default case
{"", systemd.StatusInactive}, // default case
}
for _, test := range tests {
t.Run(test.input, func(t *testing.T) {
result := systemd.ParseServiceStatus(test.input)
assert.Equal(t, test.expected, result)
})
}
}
func TestParseServiceSubState(t *testing.T) {
tests := []struct {
input string
expected systemd.ServiceSubState
}{
{"dead", systemd.SubStateDead},
{"running", systemd.SubStateRunning},
{"exited", systemd.SubStateExited},
{"failed", systemd.SubStateFailed},
{"unknown", systemd.SubStateUnknown},
{"other", systemd.SubStateUnknown}, // default case
{"", systemd.SubStateUnknown}, // default case
}
for _, test := range tests {
t.Run(test.input, func(t *testing.T) {
result := systemd.ParseServiceSubState(test.input)
assert.Equal(t, test.expected, result)
})
}
}
func TestServiceUpdateCPUPercent(t *testing.T) {
t.Run("initial call sets CPU to 0", func(t *testing.T) {
service := &systemd.Service{}
service.UpdateCPUPercent(1000)
assert.Equal(t, 0.0, service.Cpu)
assert.Equal(t, uint64(1000), service.PrevCpuUsage)
assert.False(t, service.PrevReadTime.IsZero())
})
t.Run("subsequent call calculates CPU percentage", func(t *testing.T) {
service := &systemd.Service{}
service.PrevCpuUsage = 1000
service.PrevReadTime = time.Now().Add(-time.Second)
service.UpdateCPUPercent(8000000000) // 8 seconds of CPU time
// CPU usage should be positive and reasonable
assert.Greater(t, service.Cpu, 0.0, "CPU usage should be positive")
assert.LessOrEqual(t, service.Cpu, 100.0, "CPU usage should not exceed 100%")
assert.Equal(t, uint64(8000000000), service.PrevCpuUsage)
assert.Greater(t, service.CpuPeak, 0.0, "CPU peak should be set")
})
t.Run("CPU peak updates only when higher", func(t *testing.T) {
service := &systemd.Service{}
service.PrevCpuUsage = 1000
service.PrevReadTime = time.Now().Add(-time.Second)
service.UpdateCPUPercent(8000000000) // Set initial peak to ~50%
initialPeak := service.CpuPeak
// Now try with much lower CPU usage - should not update peak
service.PrevReadTime = time.Now().Add(-time.Second)
service.UpdateCPUPercent(1000000) // Much lower usage
assert.Equal(t, initialPeak, service.CpuPeak, "Peak should not update for lower CPU usage")
})
t.Run("handles zero duration", func(t *testing.T) {
service := &systemd.Service{}
service.PrevCpuUsage = 1000
now := time.Now()
service.PrevReadTime = now
// Mock time.Now() to return the same time to ensure zero duration
// Since we can't mock time in Go easily, we'll check the logic manually
// The zero duration case happens when duration <= 0
assert.Equal(t, 0.0, service.Cpu, "CPU should start at 0")
})
t.Run("handles CPU usage wraparound", func(t *testing.T) {
service := &systemd.Service{}
// Simulate wraparound where new usage is less than previous
service.PrevCpuUsage = 1000
service.PrevReadTime = time.Now().Add(-time.Second)
service.UpdateCPUPercent(500) // Less than previous, should reset
assert.Equal(t, 0.0, service.Cpu)
})
}

View File

@@ -11,6 +11,7 @@ import (
"log/slog" "log/slog"
"net/http" "net/http"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"runtime" "runtime"
"strings" "strings"
@@ -345,5 +346,32 @@ func archiveSuffix(binaryName, goos, goarch string) string {
if goos == "windows" { if goos == "windows" {
return fmt.Sprintf("%s_%s_%s.zip", binaryName, goos, goarch) return fmt.Sprintf("%s_%s_%s.zip", binaryName, goos, goarch)
} }
// Use glibc build for agent on glibc systems (includes NVML support via purego)
if binaryName == "beszel-agent" && goos == "linux" && goarch == "amd64" && isGlibc() {
return fmt.Sprintf("%s_%s_%s_glibc.tar.gz", binaryName, goos, goarch)
}
return fmt.Sprintf("%s_%s_%s.tar.gz", binaryName, goos, goarch) return fmt.Sprintf("%s_%s_%s.tar.gz", binaryName, goos, goarch)
} }
func isGlibc() bool {
for _, path := range []string{
"/lib64/ld-linux-x86-64.so.2", // common on many distros
"/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2", // Debian/Ubuntu
"/lib/ld-linux-x86-64.so.2", // alternate
} {
if _, err := os.Stat(path); err == nil {
return true
}
}
// Fallback to ldd output when present (musl ldd reports musl, glibc reports GNU libc/glibc).
if lddPath, err := exec.LookPath("ldd"); err == nil {
out, err := exec.Command(lddPath, "--version").CombinedOutput()
if err == nil {
s := strings.ToLower(string(out))
if strings.Contains(s, "gnu libc") || strings.Contains(s, "glibc") {
return true
}
}
}
return false
}

View File

@@ -0,0 +1,66 @@
package ghupdate
import (
"fmt"
"os/exec"
"strings"
)
// 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
}
ColorPrint(ColorYellow, "SELinux is enabled; applying context…")
// Try persistent context via semanage+restorecon
if success := trySemanageRestorecon(path); success {
return nil
}
// 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 {
return fmt.Errorf("chcon failed: %w", err)
}
return nil
}
return fmt.Errorf("no SELinux tools available (semanage/restorecon or chcon)")
}
// trySemanageRestorecon attempts to set persistent SELinux context using semanage and restorecon.
// Returns true if successful, false otherwise.
func trySemanageRestorecon(path string) bool {
semanagePath, err := exec.LookPath("semanage")
if err != nil {
return false
}
restoreconPath, err := exec.LookPath("restorecon")
if err != nil {
return false
}
// Try to add the fcontext rule; if it already exists, try to modify it
if err := exec.Command(semanagePath, "fcontext", "-a", "-t", "bin_t", path).Run(); err != nil {
// Rule may already exist, try modify instead
if err := exec.Command(semanagePath, "fcontext", "-m", "-t", "bin_t", path).Run(); err != nil {
return false
}
}
// Apply the context with restorecon
if err := exec.Command(restoreconPath, "-v", path).Run(); err != nil {
return false
}
return true
}

View File

@@ -0,0 +1,53 @@
package ghupdate
import (
"os"
"os/exec"
"path/filepath"
"testing"
)
func TestHandleSELinuxContext_NoSELinux(t *testing.T) {
// Skip on SELinux systems - this test is for non-SELinux behavior
if _, err := exec.LookPath("getenforce"); err == nil {
t.Skip("skipping on SELinux-enabled system")
}
// On systems without SELinux, getenforce will fail and the function
// should return nil without error
tempFile := filepath.Join(t.TempDir(), "test-binary")
if err := os.WriteFile(tempFile, []byte("test"), 0755); err != nil {
t.Fatalf("failed to create temp file: %v", err)
}
err := HandleSELinuxContext(tempFile)
if err != nil {
t.Errorf("HandleSELinuxContext() on non-SELinux system returned error: %v", err)
}
}
func TestHandleSELinuxContext_InvalidPath(t *testing.T) {
// Skip on SELinux systems - this test is for non-SELinux behavior
if _, err := exec.LookPath("getenforce"); err == nil {
t.Skip("skipping on SELinux-enabled system")
}
// On non-SELinux systems, getenforce fails early so even invalid paths succeed
err := HandleSELinuxContext("/nonexistent/path/binary")
if err != nil {
t.Errorf("HandleSELinuxContext() with invalid path on non-SELinux system returned error: %v", err)
}
}
func TestTrySemanageRestorecon_NoTools(t *testing.T) {
// Skip if semanage is available (we don't want to modify system SELinux policy)
if _, err := exec.LookPath("semanage"); err == nil {
t.Skip("skipping on system with semanage available")
}
// Should return false when semanage is not available
result := trySemanageRestorecon("/some/path")
if result {
t.Error("trySemanageRestorecon() returned true when semanage is not available")
}
}

View File

@@ -1,6 +1,7 @@
package hub package hub
import ( import (
"context"
"errors" "errors"
"net" "net"
"net/http" "net/http"
@@ -65,6 +66,15 @@ func (acr *agentConnectRequest) agentConnect() (err error) {
// Check if token is an active universal token // Check if token is an active universal token
acr.userId, acr.isUniversalToken = universalTokenMap.GetMap().GetOk(acr.token) acr.userId, acr.isUniversalToken = universalTokenMap.GetMap().GetOk(acr.token)
if !acr.isUniversalToken {
// Fallback: check for a permanent universal token stored in the DB
if rec, err := acr.hub.FindFirstRecordByFilter("universal_tokens", "token = {:token}", dbx.Params{"token": acr.token}); err == nil {
if userID := rec.GetString("user"); userID != "" {
acr.userId = userID
acr.isUniversalToken = true
}
}
}
// Find matching fingerprint records for this token // Find matching fingerprint records for this token
fpRecords := getFingerprintRecordsByToken(acr.token, acr.hub) fpRecords := getFingerprintRecordsByToken(acr.token, acr.hub)
@@ -93,7 +103,7 @@ func (acr *agentConnectRequest) agentConnect() (err error) {
// verifyWsConn verifies the WebSocket connection using the agent's fingerprint and // verifyWsConn verifies the WebSocket connection using the agent's fingerprint and
// SSH key signature, then adds the system to the system manager. // SSH key signature, then adds the system to the system manager.
func (acr *agentConnectRequest) verifyWsConn(conn *gws.Conn, fpRecords []ws.FingerprintRecord) (err error) { func (acr *agentConnectRequest) verifyWsConn(conn *gws.Conn, fpRecords []ws.FingerprintRecord) (err error) {
wsConn := ws.NewWsConnection(conn) wsConn := ws.NewWsConnection(conn, acr.agentSemVer)
// must set wsConn in connection store before the read loop // must set wsConn in connection store before the read loop
conn.Session().Store("wsConn", wsConn) conn.Session().Store("wsConn", wsConn)
@@ -112,7 +122,7 @@ func (acr *agentConnectRequest) verifyWsConn(conn *gws.Conn, fpRecords []ws.Fing
return err return err
} }
agentFingerprint, err := wsConn.GetFingerprint(acr.token, signer, acr.isUniversalToken) agentFingerprint, err := wsConn.GetFingerprint(context.Background(), acr.token, signer, acr.isUniversalToken)
if err != nil { if err != nil {
return err return err
} }
@@ -267,9 +277,12 @@ func (acr *agentConnectRequest) createSystem(agentFingerprint common.Fingerprint
if agentFingerprint.Port == "" { if agentFingerprint.Port == "" {
agentFingerprint.Port = "45876" agentFingerprint.Port = "45876"
} }
if agentFingerprint.Name == "" {
agentFingerprint.Name = agentFingerprint.Hostname
}
// create new record // create new record
systemRecord := core.NewRecord(systemsCollection) systemRecord := core.NewRecord(systemsCollection)
systemRecord.Set("name", agentFingerprint.Hostname) systemRecord.Set("name", agentFingerprint.Name)
systemRecord.Set("host", remoteAddr) systemRecord.Set("host", remoteAddr)
systemRecord.Set("port", agentFingerprint.Port) systemRecord.Set("port", agentFingerprint.Port)
systemRecord.Set("users", []string{acr.userId}) systemRecord.Set("users", []string{acr.userId})

View File

@@ -1169,6 +1169,106 @@ func TestMultipleSystemsWithSameUniversalToken(t *testing.T) {
} }
} }
// TestPermanentUniversalTokenFromDB verifies that a universal token persisted in the DB
// (universal_tokens collection) is accepted for agent self-registration even if it is not
// present in the in-memory universalTokenMap.
func TestPermanentUniversalTokenFromDB(t *testing.T) {
// Create hub and test app
hub, testApp, err := createTestHub(t)
require.NoError(t, err)
defer testApp.Cleanup()
// Get the hub's SSH key
hubSigner, err := hub.GetSSHKey("")
require.NoError(t, err)
goodPubKey := hubSigner.PublicKey()
// Create test user
userRecord, err := createTestUser(testApp)
require.NoError(t, err)
// Create a permanent universal token record in the DB (do NOT add it to universalTokenMap)
universalToken := "db-universal-token-123"
_, err = createTestRecord(testApp, "universal_tokens", map[string]any{
"user": userRecord.Id,
"token": universalToken,
})
require.NoError(t, err)
// Create HTTP server with the actual API route
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/beszel/agent-connect" {
acr := &agentConnectRequest{
hub: hub,
req: r,
res: w,
}
acr.agentConnect()
} else {
http.NotFound(w, r)
}
}))
defer ts.Close()
// Create and configure agent
agentDataDir := t.TempDir()
err = os.WriteFile(filepath.Join(agentDataDir, "fingerprint"), []byte("db-token-system-fingerprint"), 0644)
require.NoError(t, err)
testAgent, err := agent.NewAgent(agentDataDir)
require.NoError(t, err)
// Set up environment variables for the agent
os.Setenv("BESZEL_AGENT_HUB_URL", ts.URL)
os.Setenv("BESZEL_AGENT_TOKEN", universalToken)
defer func() {
os.Unsetenv("BESZEL_AGENT_HUB_URL")
os.Unsetenv("BESZEL_AGENT_TOKEN")
}()
// Start agent in background
done := make(chan error, 1)
go func() {
serverOptions := agent.ServerOptions{
Network: "tcp",
Addr: "127.0.0.1:46050",
Keys: []ssh.PublicKey{goodPubKey},
}
done <- testAgent.Start(serverOptions)
}()
// Wait for connection result
maxWait := 2 * time.Second
time.Sleep(20 * time.Millisecond)
checkInterval := 20 * time.Millisecond
timeout := time.After(maxWait)
ticker := time.Tick(checkInterval)
connectionManager := testAgent.GetConnectionManager()
for {
select {
case <-timeout:
t.Fatalf("Expected connection to succeed but timed out - agent state: %d", connectionManager.State)
case <-ticker:
if connectionManager.State == agent.WebSocketConnected {
// Success
goto verify
}
case err := <-done:
// If Start returns early, treat it as failure
if err != nil {
t.Fatalf("Agent failed to start/connect: %v", err)
}
}
}
verify:
// Verify that a system was created for the user (self-registration path)
systemsAfter, err := testApp.FindRecordsByFilter("systems", "users ~ {:userId}", "", -1, 0, map[string]any{"userId": userRecord.Id})
require.NoError(t, err)
require.NotEmpty(t, systemsAfter, "Expected a system to be created for DB-backed universal token")
}
// TestFindOrCreateSystemForToken tests the findOrCreateSystemForToken function // TestFindOrCreateSystemForToken tests the findOrCreateSystemForToken function
func TestFindOrCreateSystemForToken(t *testing.T) { func TestFindOrCreateSystemForToken(t *testing.T) {
hub, testApp, err := createTestHub(t) hub, testApp, err := createTestHub(t)

View File

@@ -415,7 +415,11 @@ func TestExpiryMap_RemoveValue_WithExpiration(t *testing.T) {
// Wait for first value to expire // Wait for first value to expire
time.Sleep(time.Millisecond * 20) time.Sleep(time.Millisecond * 20)
// Try to remove the expired value - should remove one of the "value1" entries // Trigger lazy cleanup of the expired key
_, ok := em.GetOk("key1")
assert.False(t, ok)
// Try to remove the remaining "value1" entry (key3)
removedValue, ok := em.RemovebyValue("value1") removedValue, ok := em.RemovebyValue("value1")
assert.True(t, ok) assert.True(t, ok)
assert.Equal(t, "value1", removedValue) assert.Equal(t, "value1", removedValue)
@@ -423,14 +427,9 @@ func TestExpiryMap_RemoveValue_WithExpiration(t *testing.T) {
// Should still have key2 (different value) // Should still have key2 (different value)
assert.True(t, em.Has("key2")) assert.True(t, em.Has("key2"))
// Should have removed one of the "value1" entries (either key1 or key3) // key1 should be gone due to expiration and key3 should be removed by value.
// But we can't predict which one due to map iteration order assert.False(t, em.Has("key1"))
key1Exists := em.Has("key1") assert.False(t, em.Has("key3"))
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) { func TestExpiryMap_ValueOperations_Integration(t *testing.T) {

View File

@@ -20,6 +20,7 @@ import (
"github.com/henrygd/beszel/internal/users" "github.com/henrygd/beszel/internal/users"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase" "github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/apis" "github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
@@ -120,18 +121,27 @@ func (h *Hub) initialize(e *core.ServeEvent) error {
return err return err
} }
// set auth settings // set auth settings
usersCollection, err := e.App.FindCollectionByNameOrId("users") if err := setCollectionAuthSettings(e.App); err != nil {
return err
}
return nil
}
// setCollectionAuthSettings sets up default authentication settings for the app
func setCollectionAuthSettings(app core.App) error {
usersCollection, err := app.FindCollectionByNameOrId("users")
if err != nil { if err != nil {
return err return err
} }
superusersCollection, err := app.FindCollectionByNameOrId(core.CollectionNameSuperusers)
if err != nil {
return err
}
// disable email auth if DISABLE_PASSWORD_AUTH env var is set // disable email auth if DISABLE_PASSWORD_AUTH env var is set
disablePasswordAuth, _ := GetEnv("DISABLE_PASSWORD_AUTH") disablePasswordAuth, _ := GetEnv("DISABLE_PASSWORD_AUTH")
usersCollection.PasswordAuth.Enabled = disablePasswordAuth != "true" usersCollection.PasswordAuth.Enabled = disablePasswordAuth != "true"
usersCollection.PasswordAuth.IdentityFields = []string{"email"} usersCollection.PasswordAuth.IdentityFields = []string{"email"}
// disable oauth if no providers are configured (todo: remove this in post 0.9.0 release)
if usersCollection.OAuth2.Enabled {
usersCollection.OAuth2.Enabled = len(usersCollection.OAuth2.Providers) > 0
}
// allow oauth user creation if USER_CREATION is set // allow oauth user creation if USER_CREATION is set
if userCreation, _ := GetEnv("USER_CREATION"); userCreation == "true" { if userCreation, _ := GetEnv("USER_CREATION"); userCreation == "true" {
cr := "@request.context = 'oauth2'" cr := "@request.context = 'oauth2'"
@@ -139,28 +149,78 @@ func (h *Hub) initialize(e *core.ServeEvent) error {
} else { } else {
usersCollection.CreateRule = nil usersCollection.CreateRule = nil
} }
if err := e.App.Save(usersCollection); err != nil {
// enable mfaOtp mfa if MFA_OTP env var is set
mfaOtp, _ := GetEnv("MFA_OTP")
usersCollection.OTP.Length = 6
superusersCollection.OTP.Length = 6
usersCollection.OTP.Enabled = mfaOtp == "true"
usersCollection.MFA.Enabled = mfaOtp == "true"
superusersCollection.OTP.Enabled = mfaOtp == "true" || mfaOtp == "superusers"
superusersCollection.MFA.Enabled = mfaOtp == "true" || mfaOtp == "superusers"
if err := app.Save(superusersCollection); err != nil {
return err return err
} }
if err := app.Save(usersCollection); err != nil {
return err
}
shareAllSystems, _ := GetEnv("SHARE_ALL_SYSTEMS")
// allow all users to access systems if SHARE_ALL_SYSTEMS is set // allow all users to access systems if SHARE_ALL_SYSTEMS is set
systemsCollection, err := e.App.FindCachedCollectionByNameOrId("systems") systemsCollection, err := app.FindCollectionByNameOrId("systems")
if err != nil { if err != nil {
return err return err
} }
shareAllSystems, _ := GetEnv("SHARE_ALL_SYSTEMS") var systemsReadRule string
systemsReadRule := "@request.auth.id != \"\"" if shareAllSystems == "true" {
if shareAllSystems != "true" { systemsReadRule = "@request.auth.id != \"\""
// default is to only show systems that the user id is assigned to } else {
systemsReadRule += " && users.id ?= @request.auth.id" systemsReadRule = "@request.auth.id != \"\" && users.id ?= @request.auth.id"
} }
updateDeleteRule := systemsReadRule + " && @request.auth.role != \"readonly\"" updateDeleteRule := systemsReadRule + " && @request.auth.role != \"readonly\""
systemsCollection.ListRule = &systemsReadRule systemsCollection.ListRule = &systemsReadRule
systemsCollection.ViewRule = &systemsReadRule systemsCollection.ViewRule = &systemsReadRule
systemsCollection.UpdateRule = &updateDeleteRule systemsCollection.UpdateRule = &updateDeleteRule
systemsCollection.DeleteRule = &updateDeleteRule systemsCollection.DeleteRule = &updateDeleteRule
if err := e.App.Save(systemsCollection); err != nil { if err := app.Save(systemsCollection); err != nil {
return err return err
} }
// allow all users to access all containers if SHARE_ALL_SYSTEMS is set
containersCollection, err := app.FindCollectionByNameOrId("containers")
if err != nil {
return err
}
containersListRule := strings.Replace(systemsReadRule, "users.id", "system.users.id", 1)
containersCollection.ListRule = &containersListRule
if err := app.Save(containersCollection); err != nil {
return err
}
// allow all users to access system-related collections if SHARE_ALL_SYSTEMS is set
// these collections all have a "system" relation field
systemRelatedCollections := []string{"system_details", "smart_devices", "systemd_services"}
for _, collectionName := range systemRelatedCollections {
collection, err := app.FindCollectionByNameOrId(collectionName)
if err != nil {
return err
}
collection.ListRule = &containersListRule
// set viewRule for collections that need it (system_details, smart_devices)
if collection.ViewRule != nil {
collection.ViewRule = &containersListRule
}
// set deleteRule for smart_devices (allows user to dismiss disk warnings)
if collectionName == "smart_devices" {
deleteRule := containersListRule + " && @request.auth.role != \"readonly\""
collection.DeleteRule = &deleteRule
}
if err := app.Save(collection); err != nil {
return err
}
}
return nil return nil
} }
@@ -236,7 +296,17 @@ func (h *Hub) registerApiRoutes(se *core.ServeEvent) error {
// update / delete user alerts // update / delete user alerts
apiAuth.POST("/user-alerts", alerts.UpsertUserAlerts) apiAuth.POST("/user-alerts", alerts.UpsertUserAlerts)
apiAuth.DELETE("/user-alerts", alerts.DeleteUserAlerts) apiAuth.DELETE("/user-alerts", alerts.DeleteUserAlerts)
// refresh SMART devices for a system
apiAuth.POST("/smart/refresh", h.refreshSmartData)
// get systemd service details
apiAuth.GET("/systemd/info", h.getSystemdInfo)
// /containers routes
if enabled, _ := GetEnv("CONTAINER_DETAILS"); enabled != "false" {
// get container logs
apiAuth.GET("/containers/logs", h.getContainerLogs)
// get container info
apiAuth.GET("/containers/info", h.getContainerInfo)
}
return nil return nil
} }
@@ -246,27 +316,170 @@ func (h *Hub) getUniversalToken(e *core.RequestEvent) error {
userID := e.Auth.Id userID := e.Auth.Id
query := e.Request.URL.Query() query := e.Request.URL.Query()
token := query.Get("token") token := query.Get("token")
enable := query.Get("enable")
permanent := query.Get("permanent")
// helper for deleting any existing permanent token record for this user
deletePermanent := func() error {
rec, err := h.FindFirstRecordByFilter("universal_tokens", "user = {:user}", dbx.Params{"user": userID})
if err != nil {
return nil // no record
}
return h.Delete(rec)
}
// helper for upserting a permanent token record for this user
upsertPermanent := func(token string) error {
rec, err := h.FindFirstRecordByFilter("universal_tokens", "user = {:user}", dbx.Params{"user": userID})
if err == nil {
rec.Set("token", token)
return h.Save(rec)
}
col, err := h.FindCachedCollectionByNameOrId("universal_tokens")
if err != nil {
return err
}
newRec := core.NewRecord(col)
newRec.Set("user", userID)
newRec.Set("token", token)
return h.Save(newRec)
}
// Disable universal tokens (both ephemeral and permanent)
if enable == "0" {
tokenMap.RemovebyValue(userID)
_ = deletePermanent()
return e.JSON(http.StatusOK, map[string]any{"token": token, "active": false, "permanent": false})
}
// Enable universal token (ephemeral or permanent)
if enable == "1" {
if token == "" {
token = uuid.New().String()
}
if permanent == "1" {
// make token permanent (persist across restarts)
tokenMap.RemovebyValue(userID)
if err := upsertPermanent(token); err != nil {
return err
}
return e.JSON(http.StatusOK, map[string]any{"token": token, "active": true, "permanent": true})
}
// default: ephemeral mode (1 hour)
_ = deletePermanent()
tokenMap.Set(token, userID, time.Hour)
return e.JSON(http.StatusOK, map[string]any{"token": token, "active": true, "permanent": false})
}
// Read current state
// Prefer permanent token if it exists.
if rec, err := h.FindFirstRecordByFilter("universal_tokens", "user = {:user}", dbx.Params{"user": userID}); err == nil {
dbToken := rec.GetString("token")
// If no token was provided, or the caller is asking about their permanent token, return it.
if token == "" || token == dbToken {
return e.JSON(http.StatusOK, map[string]any{"token": dbToken, "active": true, "permanent": true})
}
// Token doesn't match their permanent token (avoid leaking other info)
return e.JSON(http.StatusOK, map[string]any{"token": token, "active": false, "permanent": false})
}
// No permanent token; fall back to ephemeral token map.
if token == "" { if token == "" {
// return existing token if it exists // return existing token if it exists
if token, _, ok := tokenMap.GetByValue(userID); ok { if token, _, ok := tokenMap.GetByValue(userID); ok {
return e.JSON(http.StatusOK, map[string]any{"token": token, "active": true}) return e.JSON(http.StatusOK, map[string]any{"token": token, "active": true, "permanent": false})
} }
// if no token is provided, generate a new one // if no token is provided, generate a new one
token = uuid.New().String() token = uuid.New().String()
} }
response := map[string]any{"token": token}
switch query.Get("enable") { // Token is considered active only if it belongs to the current user.
case "1": activeUser, ok := tokenMap.GetOk(token)
tokenMap.Set(token, userID, time.Hour) active := ok && activeUser == userID
case "0": response := map[string]any{"token": token, "active": active, "permanent": false}
tokenMap.RemovebyValue(userID)
}
_, response["active"] = tokenMap.GetOk(token)
return e.JSON(http.StatusOK, response) return e.JSON(http.StatusOK, response)
} }
// containerRequestHandler handles both container logs and info requests
func (h *Hub) containerRequestHandler(e *core.RequestEvent, fetchFunc func(*systems.System, string) (string, error), responseKey string) error {
systemID := e.Request.URL.Query().Get("system")
containerID := e.Request.URL.Query().Get("container")
if systemID == "" || containerID == "" {
return e.JSON(http.StatusBadRequest, map[string]string{"error": "system and container parameters are required"})
}
system, err := h.sm.GetSystem(systemID)
if err != nil {
return e.JSON(http.StatusNotFound, map[string]string{"error": "system not found"})
}
data, err := fetchFunc(system, containerID)
if err != nil {
return e.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
}
return e.JSON(http.StatusOK, map[string]string{responseKey: data})
}
// getContainerLogs handles GET /api/beszel/containers/logs requests
func (h *Hub) getContainerLogs(e *core.RequestEvent) error {
return h.containerRequestHandler(e, func(system *systems.System, containerID string) (string, error) {
return system.FetchContainerLogsFromAgent(containerID)
}, "logs")
}
func (h *Hub) getContainerInfo(e *core.RequestEvent) error {
return h.containerRequestHandler(e, func(system *systems.System, containerID string) (string, error) {
return system.FetchContainerInfoFromAgent(containerID)
}, "info")
}
// getSystemdInfo handles GET /api/beszel/systemd/info requests
func (h *Hub) getSystemdInfo(e *core.RequestEvent) error {
query := e.Request.URL.Query()
systemID := query.Get("system")
serviceName := query.Get("service")
if systemID == "" || serviceName == "" {
return e.JSON(http.StatusBadRequest, map[string]string{"error": "system and service parameters are required"})
}
system, err := h.sm.GetSystem(systemID)
if err != nil {
return e.JSON(http.StatusNotFound, map[string]string{"error": "system not found"})
}
details, err := system.FetchSystemdInfoFromAgent(serviceName)
if err != nil {
return e.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
}
e.Response.Header().Set("Cache-Control", "public, max-age=60")
return e.JSON(http.StatusOK, map[string]any{"details": details})
}
// refreshSmartData handles POST /api/beszel/smart/refresh requests
// Fetches fresh SMART data from the agent and updates the collection
func (h *Hub) refreshSmartData(e *core.RequestEvent) error {
systemID := e.Request.URL.Query().Get("system")
if systemID == "" {
return e.JSON(http.StatusBadRequest, map[string]string{"error": "system parameter is required"})
}
system, err := h.sm.GetSystem(systemID)
if err != nil {
return e.JSON(http.StatusNotFound, map[string]string{"error": "system not found"})
}
// Fetch and save SMART devices
if err := system.FetchAndSaveSmartDevices(); err != nil {
return e.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return e.JSON(http.StatusOK, map[string]string{"status": "ok"})
}
// generates key pair if it doesn't exist and returns signer // generates key pair if it doesn't exist and returns signer
func (h *Hub) GetSSHKey(dataDir string) (ssh.Signer, error) { func (h *Hub) GetSSHKey(dataDir string) (ssh.Signer, error) {
if h.signer != nil { if h.signer != nil {

View File

@@ -378,7 +378,18 @@ func TestApiRoutesAuthentication(t *testing.T) {
"Authorization": userToken, "Authorization": userToken,
}, },
ExpectedStatus: 200, ExpectedStatus: 200,
ExpectedContent: []string{"active", "token"}, ExpectedContent: []string{"active", "token", "permanent"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /universal-token - enable permanent should succeed",
Method: http.MethodGet,
URL: "/api/beszel/universal-token?enable=1&permanent=1&token=permanent-token-123",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 200,
ExpectedContent: []string{"\"permanent\":true", "permanent-token-123"},
TestAppFactory: testAppFactory, TestAppFactory: testAppFactory,
}, },
{ {
@@ -449,6 +460,47 @@ func TestApiRoutesAuthentication(t *testing.T) {
}) })
}, },
}, },
{
Name: "GET /containers/logs - no auth should fail",
Method: http.MethodGet,
URL: "/api/beszel/containers/logs?system=test-system&container=test-container",
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /containers/logs - with auth but missing system param should fail",
Method: http.MethodGet,
URL: "/api/beszel/containers/logs?container=test-container",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 400,
ExpectedContent: []string{"system and container parameters are required"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /containers/logs - with auth but missing container param should fail",
Method: http.MethodGet,
URL: "/api/beszel/containers/logs?system=test-system",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 400,
ExpectedContent: []string{"system and container parameters are required"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /containers/logs - with auth but invalid system should fail",
Method: http.MethodGet,
URL: "/api/beszel/containers/logs?system=invalid-system&container=test-container",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 404,
ExpectedContent: []string{"system not found"},
TestAppFactory: testAppFactory,
},
// Auth Optional Routes - Should work without authentication // Auth Optional Routes - Should work without authentication
{ {

View File

@@ -5,19 +5,28 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"hash/fnv"
"math/rand" "math/rand"
"net" "net"
"strings" "strings"
"sync/atomic"
"time" "time"
"github.com/henrygd/beszel/internal/common"
"github.com/henrygd/beszel/internal/hub/transport"
"github.com/henrygd/beszel/internal/hub/ws" "github.com/henrygd/beszel/internal/hub/ws"
"github.com/henrygd/beszel/internal/entities/container"
"github.com/henrygd/beszel/internal/entities/smart"
"github.com/henrygd/beszel/internal/entities/system" "github.com/henrygd/beszel/internal/entities/system"
"github.com/henrygd/beszel/internal/entities/systemd"
"github.com/henrygd/beszel" "github.com/henrygd/beszel"
"github.com/blang/semver" "github.com/blang/semver"
"github.com/fxamacker/cbor/v2" "github.com/fxamacker/cbor/v2"
"github.com/lxzan/gws"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
) )
@@ -29,12 +38,17 @@ type System struct {
Status string `db:"status"` Status string `db:"status"`
manager *SystemManager // Manager that this system belongs to manager *SystemManager // Manager that this system belongs to
client *ssh.Client // SSH client for fetching data client *ssh.Client // SSH client for fetching data
sshTransport *transport.SSHTransport // SSH transport for requests
data *system.CombinedData // system data from agent data *system.CombinedData // system data from agent
ctx context.Context // Context for stopping the updater ctx context.Context // Context for stopping the updater
cancel context.CancelFunc // Stops and removes system from updater cancel context.CancelFunc // Stops and removes system from updater
WsConn *ws.WsConn // Handler for agent WebSocket connection WsConn *ws.WsConn // Handler for agent WebSocket connection
agentVersion semver.Version // Agent version agentVersion semver.Version // Agent version
updateTicker *time.Ticker // Ticker for updating the system updateTicker *time.Ticker // Ticker for updating the system
detailsFetched atomic.Bool // True if static system details have been fetched and saved
smartFetching atomic.Bool // True if SMART devices are currently being fetched
smartInterval time.Duration // Interval for periodic SMART data updates
lastSmartFetch atomic.Int64 // Unix milliseconds of last SMART data fetch
} }
func (sm *SystemManager) NewSystem(systemId string) *System { func (sm *SystemManager) NewSystem(systemId string) *System {
@@ -107,10 +121,37 @@ func (sys *System) update() error {
sys.handlePaused() sys.handlePaused()
return nil return nil
} }
data, err := sys.fetchDataFromAgent() options := common.DataRequestOptions{
if err == nil { CacheTimeMs: uint16(interval),
_, err = sys.createRecords(data)
} }
// fetch system details if not already fetched
if !sys.detailsFetched.Load() {
options.IncludeDetails = true
}
data, err := sys.fetchDataFromAgent(options)
if err != nil {
return err
}
// create system records
_, err = sys.createRecords(data)
// Fetch and save SMART devices when system first comes online or at intervals
if backgroundSmartFetchEnabled() {
if sys.smartInterval <= 0 {
sys.smartInterval = time.Hour
}
lastFetch := sys.lastSmartFetch.Load()
if time.Since(time.UnixMilli(lastFetch)) >= sys.smartInterval && sys.smartFetching.CompareAndSwap(false, true) {
go func() {
defer sys.smartFetching.Store(false)
sys.lastSmartFetch.Store(time.Now().UnixMilli())
_ = sys.FetchAndSaveSmartDevices()
}()
}
}
return err return err
} }
@@ -134,41 +175,160 @@ func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error
return nil, err return nil, err
} }
hub := sys.manager.hub hub := sys.manager.hub
// add system_stats and container_stats records err = hub.RunInTransaction(func(txApp core.App) error {
systemStatsCollection, err := hub.FindCachedCollectionByNameOrId("system_stats") // add system_stats record
systemStatsCollection, err := txApp.FindCachedCollectionByNameOrId("system_stats")
if err != nil { if err != nil {
return nil, err return err
} }
systemStatsRecord := core.NewRecord(systemStatsCollection) systemStatsRecord := core.NewRecord(systemStatsCollection)
systemStatsRecord.Set("system", systemRecord.Id) systemStatsRecord.Set("system", systemRecord.Id)
systemStatsRecord.Set("stats", data.Stats) systemStatsRecord.Set("stats", data.Stats)
systemStatsRecord.Set("type", "1m") systemStatsRecord.Set("type", "1m")
if err := hub.SaveNoValidate(systemStatsRecord); err != nil { if err := txApp.SaveNoValidate(systemStatsRecord); err != nil {
return nil, err return err
} }
// add new container_stats record
// add containers and container_stats records
if len(data.Containers) > 0 { if len(data.Containers) > 0 {
containerStatsCollection, err := hub.FindCachedCollectionByNameOrId("container_stats") if data.Containers[0].Id != "" {
if err := createContainerRecords(txApp, data.Containers, sys.Id); err != nil {
return err
}
}
containerStatsCollection, err := txApp.FindCachedCollectionByNameOrId("container_stats")
if err != nil { if err != nil {
return nil, err return err
} }
containerStatsRecord := core.NewRecord(containerStatsCollection) containerStatsRecord := core.NewRecord(containerStatsCollection)
containerStatsRecord.Set("system", systemRecord.Id) containerStatsRecord.Set("system", systemRecord.Id)
containerStatsRecord.Set("stats", data.Containers) containerStatsRecord.Set("stats", data.Containers)
containerStatsRecord.Set("type", "1m") containerStatsRecord.Set("type", "1m")
if err := hub.SaveNoValidate(containerStatsRecord); err != nil { if err := txApp.SaveNoValidate(containerStatsRecord); err != nil {
return nil, err return err
} }
} }
// add new systemd_stats record
if len(data.SystemdServices) > 0 {
if err := createSystemdStatsRecords(txApp, data.SystemdServices, sys.Id); err != nil {
return err
}
}
// add system details record
if data.Details != nil {
if err := createSystemDetailsRecord(txApp, data.Details, sys.Id); err != nil {
return err
}
sys.detailsFetched.Store(true)
// update smart interval if it's set on the agent side
if data.Details.SmartInterval > 0 {
sys.smartInterval = data.Details.SmartInterval
}
}
// update system record (do this last because it triggers alerts and we need above records to be inserted first) // 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("status", up)
systemRecord.Set("info", data.Info) systemRecord.Set("info", data.Info)
if err := hub.SaveNoValidate(systemRecord); err != nil { if err := txApp.SaveNoValidate(systemRecord); err != nil {
return nil, err return err
} }
return systemRecord, nil return nil
})
return systemRecord, err
}
func createSystemDetailsRecord(app core.App, data *system.Details, systemId string) error {
collectionName := "system_details"
params := dbx.Params{
"id": systemId,
"system": systemId,
"hostname": data.Hostname,
"kernel": data.Kernel,
"cores": data.Cores,
"threads": data.Threads,
"cpu": data.CpuModel,
"os": data.Os,
"os_name": data.OsName,
"arch": data.Arch,
"memory": data.MemoryTotal,
"podman": data.Podman,
"updated": time.Now().UTC(),
}
result, err := app.DB().Update(collectionName, params, dbx.HashExp{"id": systemId}).Execute()
rowsAffected, _ := result.RowsAffected()
if err != nil || rowsAffected == 0 {
_, err = app.DB().Insert(collectionName, params).Execute()
}
return err
}
func createSystemdStatsRecords(app core.App, data []*systemd.Service, systemId string) error {
if len(data) == 0 {
return nil
}
// shared params for all records
params := dbx.Params{
"system": systemId,
"updated": time.Now().UTC().UnixMilli(),
}
valueStrings := make([]string, 0, len(data))
for i, service := range data {
suffix := fmt.Sprintf("%d", i)
valueStrings = append(valueStrings, fmt.Sprintf("({:id%[1]s}, {:system}, {:name%[1]s}, {:state%[1]s}, {:sub%[1]s}, {:cpu%[1]s}, {:cpuPeak%[1]s}, {:memory%[1]s}, {:memPeak%[1]s}, {:updated})", suffix))
params["id"+suffix] = makeStableHashId(systemId, service.Name)
params["name"+suffix] = service.Name
params["state"+suffix] = service.State
params["sub"+suffix] = service.Sub
params["cpu"+suffix] = service.Cpu
params["cpuPeak"+suffix] = service.CpuPeak
params["memory"+suffix] = service.Mem
params["memPeak"+suffix] = service.MemPeak
}
queryString := fmt.Sprintf(
"INSERT INTO systemd_services (id, system, name, state, sub, cpu, cpuPeak, memory, memPeak, updated) VALUES %s ON CONFLICT(id) DO UPDATE SET system = excluded.system, name = excluded.name, state = excluded.state, sub = excluded.sub, cpu = excluded.cpu, cpuPeak = excluded.cpuPeak, memory = excluded.memory, memPeak = excluded.memPeak, updated = excluded.updated",
strings.Join(valueStrings, ","),
)
_, err := app.DB().NewQuery(queryString).Bind(params).Execute()
return err
}
// createContainerRecords creates container records
func createContainerRecords(app core.App, data []*container.Stats, systemId string) error {
if len(data) == 0 {
return nil
}
// shared params for all records
params := dbx.Params{
"system": systemId,
"updated": time.Now().UTC().UnixMilli(),
}
valueStrings := make([]string, 0, len(data))
for i, container := range data {
suffix := fmt.Sprintf("%d", i)
valueStrings = append(valueStrings, fmt.Sprintf("({:id%[1]s}, {:system}, {:name%[1]s}, {:image%[1]s}, {:status%[1]s}, {:health%[1]s}, {:cpu%[1]s}, {:memory%[1]s}, {:net%[1]s}, {:updated})", suffix))
params["id"+suffix] = container.Id
params["name"+suffix] = container.Name
params["image"+suffix] = container.Image
params["status"+suffix] = container.Status
params["health"+suffix] = container.Health
params["cpu"+suffix] = container.Cpu
params["memory"+suffix] = container.Mem
netBytes := container.Bandwidth[0] + container.Bandwidth[1]
if netBytes == 0 {
netBytes = uint64((container.NetworkSent + container.NetworkRecv) * 1024 * 1024)
}
params["net"+suffix] = netBytes
}
queryString := fmt.Sprintf(
"INSERT INTO containers (id, system, name, image, status, health, cpu, memory, net, updated) VALUES %s ON CONFLICT(id) DO UPDATE SET system = excluded.system, name = excluded.name, image = excluded.image, status = excluded.status, health = excluded.health, cpu = excluded.cpu, memory = excluded.memory, net = excluded.net, updated = excluded.updated",
strings.Join(valueStrings, ","),
)
_, err := app.DB().NewQuery(queryString).Bind(params).Execute()
return err
} }
// getRecord retrieves the system record from the database. // getRecord retrieves the system record from the database.
@@ -207,15 +367,85 @@ func (sys *System) getContext() (context.Context, context.CancelFunc) {
return sys.ctx, sys.cancel return sys.ctx, sys.cancel
} }
// fetchDataFromAgent attempts to fetch data from the agent, // request sends a request to the agent, trying WebSocket first, then SSH.
// prioritizing WebSocket if available. // This is the unified request method that uses the transport abstraction.
func (sys *System) fetchDataFromAgent() (*system.CombinedData, error) { func (sys *System) request(ctx context.Context, action common.WebSocketAction, req any, dest any) error {
// Try WebSocket first
if sys.WsConn != nil && sys.WsConn.IsConnected() {
wsTransport := transport.NewWebSocketTransport(sys.WsConn)
if err := wsTransport.Request(ctx, action, req, dest); err == nil {
return nil
} else if !shouldFallbackToSSH(err) {
return err
} else if shouldCloseWebSocket(err) {
sys.closeWebSocketConnection()
}
}
// Fall back to SSH if WebSocket fails
if err := sys.ensureSSHTransport(); err != nil {
return err
}
err := sys.sshTransport.RequestWithRetry(ctx, action, req, dest, 1)
// Keep legacy SSH client/version fields in sync for other code paths.
if sys.sshTransport != nil {
sys.client = sys.sshTransport.GetClient()
sys.agentVersion = sys.sshTransport.GetAgentVersion()
}
return err
}
func shouldFallbackToSSH(err error) bool {
if err == nil {
return false
}
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
return true
}
if errors.Is(err, gws.ErrConnClosed) {
return true
}
return errors.Is(err, transport.ErrWebSocketNotConnected)
}
func shouldCloseWebSocket(err error) bool {
if err == nil {
return false
}
return errors.Is(err, gws.ErrConnClosed) || errors.Is(err, transport.ErrWebSocketNotConnected)
}
// ensureSSHTransport ensures the SSH transport is initialized and connected.
func (sys *System) ensureSSHTransport() error {
if sys.sshTransport == nil {
if sys.manager.sshConfig == nil {
if err := sys.manager.createSSHClientConfig(); err != nil {
return err
}
}
sys.sshTransport = transport.NewSSHTransport(transport.SSHTransportConfig{
Host: sys.Host,
Port: sys.Port,
Config: sys.manager.sshConfig,
Timeout: 4 * time.Second,
})
}
// Sync client state with transport
if sys.client != nil {
sys.sshTransport.SetClient(sys.client)
sys.sshTransport.SetAgentVersion(sys.agentVersion)
}
return nil
}
// fetchDataFromAgent attempts to fetch data from the agent, prioritizing WebSocket if available.
func (sys *System) fetchDataFromAgent(options common.DataRequestOptions) (*system.CombinedData, error) {
if sys.data == nil { if sys.data == nil {
sys.data = &system.CombinedData{} sys.data = &system.CombinedData{}
} }
if sys.WsConn != nil && sys.WsConn.IsConnected() { if sys.WsConn != nil && sys.WsConn.IsConnected() {
wsData, err := sys.fetchDataViaWebSocket() wsData, err := sys.fetchDataViaWebSocket(options)
if err == nil { if err == nil {
return wsData, nil return wsData, nil
} }
@@ -223,82 +453,164 @@ func (sys *System) fetchDataFromAgent() (*system.CombinedData, error) {
sys.closeWebSocketConnection() sys.closeWebSocketConnection()
} }
sshData, err := sys.fetchDataViaSSH() sshData, err := sys.fetchDataViaSSH(options)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return sshData, nil return sshData, nil
} }
func (sys *System) fetchDataViaWebSocket() (*system.CombinedData, error) { func (sys *System) fetchDataViaWebSocket(options common.DataRequestOptions) (*system.CombinedData, error) {
if sys.WsConn == nil || !sys.WsConn.IsConnected() { if sys.WsConn == nil || !sys.WsConn.IsConnected() {
return nil, errors.New("no websocket connection") return nil, errors.New("no websocket connection")
} }
err := sys.WsConn.RequestSystemData(sys.data) wsTransport := transport.NewWebSocketTransport(sys.WsConn)
err := wsTransport.Request(context.Background(), common.GetData, options, sys.data)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return sys.data, nil return sys.data, nil
} }
// FetchContainerInfoFromAgent fetches container info from the agent
func (sys *System) FetchContainerInfoFromAgent(containerID string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var result string
err := sys.request(ctx, common.GetContainerInfo, common.ContainerInfoRequest{ContainerID: containerID}, &result)
return result, err
}
// FetchContainerLogsFromAgent fetches container logs from the agent
func (sys *System) FetchContainerLogsFromAgent(containerID string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var result string
err := sys.request(ctx, common.GetContainerLogs, common.ContainerLogsRequest{ContainerID: containerID}, &result)
return result, err
}
// FetchSystemdInfoFromAgent fetches detailed systemd service information from the agent
func (sys *System) FetchSystemdInfoFromAgent(serviceName string) (systemd.ServiceDetails, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var result systemd.ServiceDetails
err := sys.request(ctx, common.GetSystemdInfo, common.SystemdInfoRequest{ServiceName: serviceName}, &result)
return result, err
}
// FetchSmartDataFromAgent fetches SMART data from the agent
func (sys *System) FetchSmartDataFromAgent() (map[string]smart.SmartData, error) {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
var result map[string]smart.SmartData
err := sys.request(ctx, common.GetSmartData, nil, &result)
return result, err
}
func makeStableHashId(strings ...string) string {
hash := fnv.New32a()
for _, str := range strings {
hash.Write([]byte(str))
}
return fmt.Sprintf("%x", hash.Sum32())
}
// fetchDataViaSSH handles fetching data using SSH. // fetchDataViaSSH handles fetching data using SSH.
// This function encapsulates the original SSH logic. // This function encapsulates the original SSH logic.
// It updates sys.data directly upon successful fetch. // It updates sys.data directly upon successful fetch.
func (sys *System) fetchDataViaSSH() (*system.CombinedData, error) { func (sys *System) fetchDataViaSSH(options common.DataRequestOptions) (*system.CombinedData, error) {
maxRetries := 1 err := sys.runSSHOperation(4*time.Second, 1, func(session *ssh.Session) (bool, error) {
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() stdout, err := session.StdoutPipe()
if err != nil { if err != nil {
return nil, err return false, err
} }
stdin, stdinErr := session.StdinPipe()
if err := session.Shell(); err != nil { if err := session.Shell(); err != nil {
return nil, err return false, err
} }
*sys.data = system.CombinedData{} *sys.data = system.CombinedData{}
if sys.agentVersion.GTE(beszel.MinVersionCbor) { if sys.agentVersion.GTE(beszel.MinVersionAgentResponse) && stdinErr == nil {
err = cbor.NewDecoder(stdout).Decode(sys.data) req := common.HubRequest[any]{Action: common.GetData, Data: options}
} else { _ = cbor.NewEncoder(stdin).Encode(req)
err = json.NewDecoder(stdout).Decode(sys.data) _ = stdin.Close()
}
if err != nil { var resp common.AgentResponse
sys.closeSSHConnection() if decErr := cbor.NewDecoder(stdout).Decode(&resp); decErr == nil && resp.SystemData != nil {
if attempt < maxRetries { *sys.data = *resp.SystemData
continue
}
return nil, err
}
// wait for the session to complete
if err := session.Wait(); err != nil { if err := session.Wait(); err != nil {
return false, err
}
return false, nil
}
}
var decodeErr error
if sys.agentVersion.GTE(beszel.MinVersionCbor) {
decodeErr = cbor.NewDecoder(stdout).Decode(sys.data)
} else {
decodeErr = json.NewDecoder(stdout).Decode(sys.data)
}
if decodeErr != nil {
return true, decodeErr
}
if err := session.Wait(); err != nil {
return false, err
}
return false, nil
})
if err != nil {
return nil, err return nil, err
} }
return sys.data, nil return sys.data, nil
} }
// this should never be reached due to the return in the loop // runSSHOperation establishes an SSH session and executes the provided operation.
return nil, fmt.Errorf("failed to fetch data") // The operation can request a retry by returning true as the first return value.
func (sys *System) runSSHOperation(timeout time.Duration, retries int, operation func(*ssh.Session) (bool, error)) error {
for attempt := 0; attempt <= retries; attempt++ {
if sys.client == nil || sys.Status == down {
if err := sys.createSSHClient(); err != nil {
return err
}
}
session, err := sys.createSessionWithTimeout(timeout)
if err != nil {
if attempt >= retries {
return err
}
sys.manager.hub.Logger().Warn("Session closed. Retrying...", "host", sys.Host, "port", sys.Port, "err", err)
sys.closeSSHConnection()
continue
}
retry, opErr := func() (bool, error) {
defer session.Close()
return operation(session)
}()
if opErr == nil {
return nil
}
if retry {
sys.closeSSHConnection()
if attempt < retries {
continue
}
}
return opErr
}
return fmt.Errorf("ssh operation failed")
} }
// createSSHClient creates a new SSH client for the system // createSSHClient creates a new SSH client for the system
@@ -357,6 +669,9 @@ func (sys *System) createSessionWithTimeout(timeout time.Duration) (*ssh.Session
// closeSSHConnection closes the SSH connection but keeps the system in the manager // closeSSHConnection closes the SSH connection but keeps the system in the manager
func (sys *System) closeSSHConnection() { func (sys *System) closeSSHConnection() {
if sys.sshTransport != nil {
sys.sshTransport.Close()
}
if sys.client != nil { if sys.client != nil {
sys.client.Close() sys.client.Close()
sys.client = nil sys.client = nil
@@ -379,11 +694,11 @@ func extractAgentVersion(versionString string) (semver.Version, error) {
} }
// getJitter returns a channel that will be triggered after a random delay // getJitter returns a channel that will be triggered after a random delay
// between 40% and 90% of the interval. // between 51% and 95% of the interval.
// This is used to stagger the initial WebSocket connections to prevent clustering. // This is used to stagger the initial WebSocket connections to prevent clustering.
func getJitter() <-chan time.Time { func getJitter() <-chan time.Time {
minPercent := 40 minPercent := 51
maxPercent := 90 maxPercent := 95
jitterRange := maxPercent - minPercent jitterRange := maxPercent - minPercent
msDelay := (interval * minPercent / 100) + rand.Intn(interval*jitterRange/100) msDelay := (interval * minPercent / 100) + rand.Intn(interval*jitterRange/100)
return time.After(time.Duration(msDelay) * time.Millisecond) return time.After(time.Duration(msDelay) * time.Millisecond)

View File

@@ -63,6 +63,15 @@ func NewSystemManager(hub hubLike) *SystemManager {
} }
} }
// GetSystem returns a system by ID from the store
func (sm *SystemManager) GetSystem(systemID string) (*System, error) {
sys, ok := sm.systems.GetOk(systemID)
if !ok {
return nil, fmt.Errorf("system not found")
}
return sys, nil
}
// Initialize sets up the system manager by binding event hooks and starting existing systems. // 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. // 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. // Systems are started with staggered delays to prevent overwhelming the hub during startup.
@@ -106,6 +115,8 @@ func (sm *SystemManager) bindEventHooks() {
sm.hub.OnRecordAfterUpdateSuccess("systems").BindFunc(sm.onRecordAfterUpdateSuccess) sm.hub.OnRecordAfterUpdateSuccess("systems").BindFunc(sm.onRecordAfterUpdateSuccess)
sm.hub.OnRecordAfterDeleteSuccess("systems").BindFunc(sm.onRecordAfterDeleteSuccess) sm.hub.OnRecordAfterDeleteSuccess("systems").BindFunc(sm.onRecordAfterDeleteSuccess)
sm.hub.OnRecordAfterUpdateSuccess("fingerprints").BindFunc(sm.onTokenRotated) sm.hub.OnRecordAfterUpdateSuccess("fingerprints").BindFunc(sm.onTokenRotated)
sm.hub.OnRealtimeSubscribeRequest().BindFunc(sm.onRealtimeSubscribeRequest)
sm.hub.OnRealtimeConnectRequest().BindFunc(sm.onRealtimeConnectRequest)
} }
// onTokenRotated handles fingerprint token rotation events. // onTokenRotated handles fingerprint token rotation events.

View File

@@ -0,0 +1,188 @@
package systems
import (
"encoding/json"
"strings"
"sync"
"time"
"github.com/henrygd/beszel/internal/common"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/subscriptions"
)
type subscriptionInfo struct {
subscription string
connectedClients uint8
}
var (
activeSubscriptions = make(map[string]*subscriptionInfo)
workerRunning bool
realtimeTicker *time.Ticker
tickerStopChan chan struct{}
realtimeMutex sync.Mutex
)
// onRealtimeConnectRequest handles client connection events for realtime subscriptions.
// It cleans up existing subscriptions when a client connects.
func (sm *SystemManager) onRealtimeConnectRequest(e *core.RealtimeConnectRequestEvent) error {
// after e.Next() is the client disconnection
e.Next()
subscriptions := e.Client.Subscriptions()
for k := range subscriptions {
sm.removeRealtimeSubscription(k, subscriptions[k])
}
return nil
}
// onRealtimeSubscribeRequest handles client subscription events for realtime metrics.
// It tracks new subscriptions and unsubscriptions to manage the realtime worker lifecycle.
func (sm *SystemManager) onRealtimeSubscribeRequest(e *core.RealtimeSubscribeRequestEvent) error {
oldSubs := e.Client.Subscriptions()
// after e.Next() is the result of the subscribe request
err := e.Next()
newSubs := e.Client.Subscriptions()
// handle new subscriptions
for k, options := range newSubs {
if _, ok := oldSubs[k]; !ok {
if strings.HasPrefix(k, "rt_metrics") {
systemId := options.Query["system"]
if _, ok := activeSubscriptions[systemId]; !ok {
activeSubscriptions[systemId] = &subscriptionInfo{
subscription: k,
}
}
activeSubscriptions[systemId].connectedClients += 1
sm.onRealtimeSubscriptionAdded()
}
}
}
// handle unsubscriptions
for k := range oldSubs {
if _, ok := newSubs[k]; !ok {
sm.removeRealtimeSubscription(k, oldSubs[k])
}
}
return err
}
// onRealtimeSubscriptionAdded initializes or starts the realtime worker when the first subscription is added.
// It ensures only one worker runs at a time and creates the ticker for periodic data fetching.
func (sm *SystemManager) onRealtimeSubscriptionAdded() {
realtimeMutex.Lock()
defer realtimeMutex.Unlock()
// Start the worker if it's not already running
if !workerRunning {
workerRunning = true
// Create a new stop channel for this worker instance
tickerStopChan = make(chan struct{})
go sm.startRealtimeWorker()
}
// If no ticker exists, create one
if realtimeTicker == nil {
realtimeTicker = time.NewTicker(1 * time.Second)
}
}
// checkSubscriptions stops the realtime worker when there are no active subscriptions.
// This prevents unnecessary resource usage when no clients are listening for realtime data.
func (sm *SystemManager) checkSubscriptions() {
if !workerRunning || len(activeSubscriptions) > 0 {
return
}
realtimeMutex.Lock()
defer realtimeMutex.Unlock()
// Signal the worker to stop
if tickerStopChan != nil {
select {
case tickerStopChan <- struct{}{}:
default:
}
}
if realtimeTicker != nil {
realtimeTicker.Stop()
realtimeTicker = nil
}
// Mark worker as stopped (will be reset when next subscription comes in)
workerRunning = false
}
// removeRealtimeSubscription removes a realtime subscription and checks if the worker should be stopped.
// It only processes subscriptions with the "rt_metrics" prefix and triggers cleanup when subscriptions are removed.
func (sm *SystemManager) removeRealtimeSubscription(subscription string, options subscriptions.SubscriptionOptions) {
if strings.HasPrefix(subscription, "rt_metrics") {
systemId := options.Query["system"]
if info, ok := activeSubscriptions[systemId]; ok {
info.connectedClients -= 1
if info.connectedClients <= 0 {
delete(activeSubscriptions, systemId)
}
}
sm.checkSubscriptions()
}
}
// startRealtimeWorker runs the main loop for fetching realtime data from agents.
// It continuously fetches system data and broadcasts it to subscribed clients via WebSocket.
func (sm *SystemManager) startRealtimeWorker() {
sm.fetchRealtimeDataAndNotify()
for {
select {
case <-tickerStopChan:
return
case <-realtimeTicker.C:
// Check if ticker is still valid (might have been stopped)
if realtimeTicker == nil || len(activeSubscriptions) == 0 {
return
}
// slog.Debug("activeSubscriptions", "count", len(activeSubscriptions))
sm.fetchRealtimeDataAndNotify()
}
}
}
// fetchRealtimeDataAndNotify fetches realtime data for all active subscriptions and notifies the clients.
func (sm *SystemManager) fetchRealtimeDataAndNotify() {
for systemId, info := range activeSubscriptions {
system, err := sm.GetSystem(systemId)
if err != nil {
continue
}
go func() {
data, err := system.fetchDataFromAgent(common.DataRequestOptions{CacheTimeMs: 1000})
if err != nil {
return
}
bytes, err := json.Marshal(data)
if err == nil {
notify(sm.hub, info.subscription, bytes)
}
}()
}
}
// notify broadcasts realtime data to all clients subscribed to a specific subscription.
// It iterates through all connected clients and sends the data only to those with matching subscriptions.
func notify(app core.App, subscription string, data []byte) error {
message := subscriptions.Message{
Name: subscription,
Data: data,
}
for _, client := range app.SubscriptionsBroker().Clients() {
if !client.HasSubscription(subscription) {
continue
}
client.Send(message)
}
return nil
}

View File

@@ -0,0 +1,92 @@
package systems
import (
"database/sql"
"errors"
"strings"
"github.com/henrygd/beszel/internal/entities/smart"
"github.com/pocketbase/pocketbase/core"
)
// FetchAndSaveSmartDevices fetches SMART data from the agent and saves it to the database
func (sys *System) FetchAndSaveSmartDevices() error {
smartData, err := sys.FetchSmartDataFromAgent()
if err != nil || len(smartData) == 0 {
return err
}
return sys.saveSmartDevices(smartData)
}
// saveSmartDevices saves SMART device data to the smart_devices collection
func (sys *System) saveSmartDevices(smartData map[string]smart.SmartData) error {
if len(smartData) == 0 {
return nil
}
hub := sys.manager.hub
collection, err := hub.FindCachedCollectionByNameOrId("smart_devices")
if err != nil {
return err
}
for deviceKey, device := range smartData {
if err := sys.upsertSmartDeviceRecord(collection, deviceKey, device); err != nil {
return err
}
}
return nil
}
func (sys *System) upsertSmartDeviceRecord(collection *core.Collection, deviceKey string, device smart.SmartData) error {
hub := sys.manager.hub
recordID := makeStableHashId(sys.Id, deviceKey)
record, err := hub.FindRecordById(collection, recordID)
if err != nil {
if !errors.Is(err, sql.ErrNoRows) {
return err
}
record = core.NewRecord(collection)
record.Set("id", recordID)
}
name := device.DiskName
if name == "" {
name = deviceKey
}
powerOnHours, powerCycles := extractPowerMetrics(device.Attributes)
record.Set("system", sys.Id)
record.Set("name", name)
record.Set("model", device.ModelName)
record.Set("state", device.SmartStatus)
record.Set("capacity", device.Capacity)
record.Set("temp", device.Temperature)
record.Set("firmware", device.FirmwareVersion)
record.Set("serial", device.SerialNumber)
record.Set("type", device.DiskType)
record.Set("hours", powerOnHours)
record.Set("cycles", powerCycles)
record.Set("attributes", device.Attributes)
return hub.SaveNoValidate(record)
}
// extractPowerMetrics extracts power on hours and power cycles from SMART attributes
func extractPowerMetrics(attributes []*smart.SmartAttribute) (powerOnHours, powerCycles uint64) {
for _, attr := range attributes {
nameLower := strings.ToLower(attr.Name)
if powerOnHours == 0 && (strings.Contains(nameLower, "poweronhours") || strings.Contains(nameLower, "power_on_hours")) {
powerOnHours = attr.RawValue
}
if powerCycles == 0 && ((strings.Contains(nameLower, "power") && strings.Contains(nameLower, "cycle")) || strings.Contains(nameLower, "startstopcycles")) {
powerCycles = attr.RawValue
}
if powerOnHours > 0 && powerCycles > 0 {
break
}
}
return
}

View File

@@ -0,0 +1,75 @@
//go:build testing
package systems
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestGetSystemdServiceId(t *testing.T) {
t.Run("deterministic output", func(t *testing.T) {
systemId := "sys-123"
serviceName := "nginx.service"
// Call multiple times and ensure same result
id1 := makeStableHashId(systemId, serviceName)
id2 := makeStableHashId(systemId, serviceName)
id3 := makeStableHashId(systemId, serviceName)
assert.Equal(t, id1, id2)
assert.Equal(t, id2, id3)
assert.NotEmpty(t, id1)
})
t.Run("different inputs produce different ids", func(t *testing.T) {
systemId1 := "sys-123"
systemId2 := "sys-456"
serviceName1 := "nginx.service"
serviceName2 := "apache.service"
id1 := makeStableHashId(systemId1, serviceName1)
id2 := makeStableHashId(systemId2, serviceName1)
id3 := makeStableHashId(systemId1, serviceName2)
id4 := makeStableHashId(systemId2, serviceName2)
// All IDs should be different
assert.NotEqual(t, id1, id2)
assert.NotEqual(t, id1, id3)
assert.NotEqual(t, id1, id4)
assert.NotEqual(t, id2, id3)
assert.NotEqual(t, id2, id4)
assert.NotEqual(t, id3, id4)
})
t.Run("consistent length", func(t *testing.T) {
testCases := []struct {
systemId string
serviceName string
}{
{"short", "short.service"},
{"very-long-system-id-that-might-be-used-in-practice", "very-long-service-name.service"},
{"", "empty-system.service"},
{"empty-service", ""},
{"", ""},
}
for _, tc := range testCases {
id := makeStableHashId(tc.systemId, tc.serviceName)
// FNV-32 produces 8 hex characters
assert.Len(t, id, 8, "ID should be 8 characters for systemId='%s', serviceName='%s'", tc.systemId, tc.serviceName)
}
})
t.Run("hexadecimal output", func(t *testing.T) {
id := makeStableHashId("test-system", "test-service")
assert.NotEmpty(t, id)
// Should only contain hexadecimal characters
for _, char := range id {
assert.True(t, (char >= '0' && char <= '9') || (char >= 'a' && char <= 'f'),
"ID should only contain hexadecimal characters, got: %s", id)
}
})
}

View File

@@ -0,0 +1,10 @@
//go:build !testing
// +build !testing
package systems
// Background SMART fetching is enabled in production but disabled for tests (systems_test_helpers.go).
//
// The hub integration tests create/replace systems and clean up the test apps quickly.
// Background SMART fetching can outlive teardown and crash in PocketBase internals (nil DB).
func backgroundSmartFetchEnabled() bool { return true }

View File

@@ -266,12 +266,14 @@ func testOld(t *testing.T, hub *tests.TestHub) {
// Create test system data // Create test system data
testData := &system.CombinedData{ testData := &system.CombinedData{
Info: system.Info{ Details: &system.Details{
Hostname: "data-test.example.com", Hostname: "data-test.example.com",
KernelVersion: "5.15.0-generic", Kernel: "5.15.0-generic",
Cores: 4, Cores: 4,
Threads: 8, Threads: 8,
CpuModel: "Test CPU", CpuModel: "Test CPU",
},
Info: system.Info{
Uptime: 3600, Uptime: 3600,
Cpu: 25.5, Cpu: 25.5,
MemPct: 40.2, MemPct: 40.2,

View File

@@ -10,6 +10,13 @@ import (
entities "github.com/henrygd/beszel/internal/entities/system" entities "github.com/henrygd/beszel/internal/entities/system"
) )
// The hub integration tests create/replace systems and cleanup the test apps quickly.
// Background SMART fetching can outlive teardown and crash in PocketBase internals (nil DB).
//
// We keep the explicit SMART refresh endpoint / method available, but disable
// the automatic background fetch during tests.
func backgroundSmartFetchEnabled() bool { return false }
// TESTING ONLY: 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 { func (sm *SystemManager) GetSystemCount() int {
return sm.systems.Length() return sm.systems.Length()

View File

@@ -0,0 +1,227 @@
package transport
import (
"context"
"errors"
"fmt"
"io"
"net"
"strings"
"time"
"github.com/blang/semver"
"github.com/fxamacker/cbor/v2"
"github.com/henrygd/beszel/internal/common"
"golang.org/x/crypto/ssh"
)
// SSHTransport implements Transport over SSH connections.
type SSHTransport struct {
client *ssh.Client
config *ssh.ClientConfig
host string
port string
agentVersion semver.Version
timeout time.Duration
}
// SSHTransportConfig holds configuration for creating an SSH transport.
type SSHTransportConfig struct {
Host string
Port string
Config *ssh.ClientConfig
AgentVersion semver.Version
Timeout time.Duration
}
// NewSSHTransport creates a new SSH transport with the given configuration.
func NewSSHTransport(cfg SSHTransportConfig) *SSHTransport {
timeout := cfg.Timeout
if timeout == 0 {
timeout = 4 * time.Second
}
return &SSHTransport{
config: cfg.Config,
host: cfg.Host,
port: cfg.Port,
agentVersion: cfg.AgentVersion,
timeout: timeout,
}
}
// SetClient sets the SSH client for reuse across requests.
func (t *SSHTransport) SetClient(client *ssh.Client) {
t.client = client
}
// SetAgentVersion sets the agent version (extracted from SSH handshake).
func (t *SSHTransport) SetAgentVersion(version semver.Version) {
t.agentVersion = version
}
// GetClient returns the current SSH client (for connection management).
func (t *SSHTransport) GetClient() *ssh.Client {
return t.client
}
// GetAgentVersion returns the agent version.
func (t *SSHTransport) GetAgentVersion() semver.Version {
return t.agentVersion
}
// Request sends a request to the agent via SSH and unmarshals the response.
func (t *SSHTransport) Request(ctx context.Context, action common.WebSocketAction, req any, dest any) error {
if t.client == nil {
if err := t.connect(); err != nil {
return err
}
}
session, err := t.createSessionWithTimeout(ctx)
if err != nil {
return err
}
defer session.Close()
stdout, err := session.StdoutPipe()
if err != nil {
return err
}
stdin, err := session.StdinPipe()
if err != nil {
return err
}
if err := session.Shell(); err != nil {
return err
}
// Send request
hubReq := common.HubRequest[any]{Action: action, Data: req}
if err := cbor.NewEncoder(stdin).Encode(hubReq); err != nil {
return fmt.Errorf("failed to encode request: %w", err)
}
stdin.Close()
// Read response
var resp common.AgentResponse
if err := cbor.NewDecoder(stdout).Decode(&resp); err != nil {
return fmt.Errorf("failed to decode response: %w", err)
}
if resp.Error != "" {
return errors.New(resp.Error)
}
if err := session.Wait(); err != nil {
return err
}
return UnmarshalResponse(resp, action, dest)
}
// IsConnected returns true if the SSH connection is active.
func (t *SSHTransport) IsConnected() bool {
return t.client != nil
}
// Close terminates the SSH connection.
func (t *SSHTransport) Close() {
if t.client != nil {
t.client.Close()
t.client = nil
}
}
// connect establishes a new SSH connection.
func (t *SSHTransport) connect() error {
if t.config == nil {
return errors.New("SSH config not set")
}
network := "tcp"
host := t.host
if strings.HasPrefix(host, "/") {
network = "unix"
} else {
host = net.JoinHostPort(host, t.port)
}
client, err := ssh.Dial(network, host, t.config)
if err != nil {
return err
}
t.client = client
// Extract agent version from server version string
t.agentVersion, _ = extractAgentVersion(string(client.Conn.ServerVersion()))
return nil
}
// createSessionWithTimeout creates a new SSH session with a timeout.
func (t *SSHTransport) createSessionWithTimeout(ctx context.Context) (*ssh.Session, error) {
if t.client == nil {
return nil, errors.New("client not initialized")
}
ctx, cancel := context.WithTimeout(ctx, t.timeout)
defer cancel()
sessionChan := make(chan *ssh.Session, 1)
errChan := make(chan error, 1)
go func() {
session, err := t.client.NewSession()
if 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, errors.New("timeout creating session")
}
}
// 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)
}
// RequestWithRetry sends a request with automatic retry on connection failures.
func (t *SSHTransport) RequestWithRetry(ctx context.Context, action common.WebSocketAction, req any, dest any, retries int) error {
var lastErr error
for attempt := 0; attempt <= retries; attempt++ {
err := t.Request(ctx, action, req, dest)
if err == nil {
return nil
}
lastErr = err
// Check if it's a connection error that warrants a retry
if isConnectionError(err) && attempt < retries {
t.Close()
continue
}
return err
}
return lastErr
}
// isConnectionError checks if an error indicates a connection problem.
func isConnectionError(err error) bool {
if err == nil {
return false
}
errStr := err.Error()
return strings.Contains(errStr, "connection") ||
strings.Contains(errStr, "EOF") ||
strings.Contains(errStr, "closed") ||
errors.Is(err, io.EOF)
}

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