mirror of
https://github.com/henrygd/beszel.git
synced 2026-04-18 02:41:50 +02:00
Compare commits
411 Commits
f21a6d15fe
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a71617e058 | ||
|
|
e5507fa106 | ||
|
|
a024c3cfd0 | ||
|
|
07466804e7 | ||
|
|
981c788d6f | ||
|
|
f5576759de | ||
|
|
be0b708064 | ||
|
|
ab3a3de46c | ||
|
|
1556e53926 | ||
|
|
e3ade3aeb8 | ||
|
|
b013f06956 | ||
|
|
3793b27958 | ||
|
|
5b02158228 | ||
|
|
0ae8c42ae0 | ||
|
|
ea80f3c5a2 | ||
|
|
c3dffff5e4 | ||
|
|
06fdd0e7a8 | ||
|
|
6e3fd90834 | ||
|
|
5ab82183fa | ||
|
|
a68e02ca84 | ||
|
|
0f2e16c63c | ||
|
|
c4009f2b43 | ||
|
|
ef0c1420d1 | ||
|
|
eb9a8e1ef9 | ||
|
|
6b5e6ffa9a | ||
|
|
d656036d3b | ||
|
|
80b73c7faf | ||
|
|
afe9eb7a70 | ||
|
|
7f565a3086 | ||
|
|
77862d4cb1 | ||
|
|
e158a9001b | ||
|
|
f670e868e4 | ||
|
|
0fff699bf6 | ||
|
|
ba10da1b9f | ||
|
|
7f4f14b505 | ||
|
|
2fda4ff264 | ||
|
|
20b0b40ec8 | ||
|
|
d548a012b4 | ||
|
|
ce5d1217dd | ||
|
|
cef09d7cb1 | ||
|
|
f6440acb43 | ||
|
|
5463a38f0f | ||
|
|
80135fdad3 | ||
|
|
5db4eb4346 | ||
|
|
f6c5e2928a | ||
|
|
6a207c33fa | ||
|
|
9f19afccde | ||
|
|
f25f2469e3 | ||
|
|
5bd43ed461 | ||
|
|
afdc3f7779 | ||
|
|
a227c77526 | ||
|
|
8202d746af | ||
|
|
9840b99327 | ||
|
|
f7b5a505e8 | ||
|
|
3cb32ac046 | ||
|
|
e610d9bfc8 | ||
|
|
b53fdbe0ef | ||
|
|
c7261b56f1 | ||
|
|
3f4c3d51b6 | ||
|
|
ad21cab457 | ||
|
|
f04684b30a | ||
|
|
4d4e4fba9b | ||
|
|
62587919f4 | ||
|
|
35528332fd | ||
|
|
e3e453140e | ||
|
|
7a64da9f65 | ||
|
|
8e71c8ad97 | ||
|
|
97f3b8c61f | ||
|
|
0b0b5d16d7 | ||
|
|
b2fd50211e | ||
|
|
c159eaacd1 | ||
|
|
441bdd2ec5 | ||
|
|
ff36138229 | ||
|
|
be70840609 | ||
|
|
565162ef5f | ||
|
|
adbfe7cfb7 | ||
|
|
1ff7762c80 | ||
|
|
0ab8a606e0 | ||
|
|
e4e0affbc1 | ||
|
|
c3a0e645ee | ||
|
|
c6c3950fb0 | ||
|
|
48ddc96a0d | ||
|
|
704cb86de8 | ||
|
|
2854ce882f | ||
|
|
ed50367f70 | ||
|
|
4ebe869591 | ||
|
|
c9bbbe91f2 | ||
|
|
5bfe4f6970 | ||
|
|
380d2b1091 | ||
|
|
a7f99e7a8c | ||
|
|
bd94a9d142 | ||
|
|
8e2316f845 | ||
|
|
0d3dfcb207 | ||
|
|
b386ce5190 | ||
|
|
e527534016 | ||
|
|
ec7ad632a9 | ||
|
|
963fce5a33 | ||
|
|
d38c0da06d | ||
|
|
cae6ac4626 | ||
|
|
6b1ff264f2 | ||
|
|
35d0e792ad | ||
|
|
654cd06b19 | ||
|
|
5e1b028130 | ||
|
|
638e7dc12a | ||
|
|
73c262455d | ||
|
|
0c4d2edd45 | ||
|
|
8f23fff1c9 | ||
|
|
02c1a0c13d | ||
|
|
69fdcb36ab | ||
|
|
b91eb6de40 | ||
|
|
ec69f6c6e0 | ||
|
|
a86cb91e07 | ||
|
|
004841717a | ||
|
|
096296ba7b | ||
|
|
b012df5669 | ||
|
|
12545b4b6d | ||
|
|
9e2296452b | ||
|
|
ac79860d4a | ||
|
|
e13a99fdac | ||
|
|
4cfb2a86ad | ||
|
|
191f25f6e0 | ||
|
|
aa8b3711d7 | ||
|
|
1fb0b25988 | ||
|
|
04600d83cc | ||
|
|
5d8906c9b2 | ||
|
|
daac287b9d | ||
|
|
d526ea61a9 | ||
|
|
79616e1662 | ||
|
|
01e8bdf040 | ||
|
|
1e3a44e05d | ||
|
|
311095cfdd | ||
|
|
4869c834bb | ||
|
|
e1c1e97f0a | ||
|
|
f6b2824ccc | ||
|
|
f17ffc21b8 | ||
|
|
f792f9b102 | ||
|
|
1def7d8d3a | ||
|
|
ef92b254bf | ||
|
|
10d853c004 | ||
|
|
cdfd116da0 | ||
|
|
283fa9d5c2 | ||
|
|
7d6c0caafc | ||
|
|
04d54a3efc | ||
|
|
14ecb1b069 | ||
|
|
1f1a448aef | ||
|
|
e816ea143a | ||
|
|
2230097dc7 | ||
|
|
25c77c5664 | ||
|
|
dba3519b2c | ||
|
|
48c35aa54d | ||
|
|
6b7845b03e | ||
|
|
221be1da58 | ||
|
|
8347afd68e | ||
|
|
2a3885a52e | ||
|
|
5452e50080 | ||
|
|
028f7bafb2 | ||
|
|
0f6142e27e | ||
|
|
8c37b93a4b | ||
|
|
201d16af05 | ||
|
|
db007176fd | ||
|
|
83fb67132b | ||
|
|
a04837f4d5 | ||
|
|
3d8db53e52 | ||
|
|
5797f8a6ad | ||
|
|
79ca31d770 | ||
|
|
41f3705b6b | ||
|
|
20324763d2 | ||
|
|
70f85f9590 | ||
|
|
c7f7f51c99 | ||
|
|
6723ec8ea4 | ||
|
|
afc19ebd3b | ||
|
|
c83d00ccaa | ||
|
|
425c8d2bdf | ||
|
|
42da1e5a52 | ||
|
|
afcae025ae | ||
|
|
1de36625a4 | ||
|
|
a2b6c7f5e6 | ||
|
|
799c7b077a | ||
|
|
cb5f944de6 | ||
|
|
23c4958145 | ||
|
|
edb2edc12c | ||
|
|
648a979a81 | ||
|
|
988de6de7b | ||
|
|
031abbfcb3 | ||
|
|
b59fcc26e5 | ||
|
|
acaa9381fe | ||
|
|
8d9e9260e6 | ||
|
|
0fc4a6daed | ||
|
|
af0c1d3af7 | ||
|
|
9ad3cd0ab9 | ||
|
|
00def272b0 | ||
|
|
383913505f | ||
|
|
ca8cb78c29 | ||
|
|
8821fb5dd0 | ||
|
|
3279a6ca53 | ||
|
|
6a1a98d73f | ||
|
|
1f067aad5b | ||
|
|
1388711105 | ||
|
|
618e5b4cc1 | ||
|
|
42c3ca5db5 | ||
|
|
534791776b | ||
|
|
0c6c53fc7d | ||
|
|
0dfd5ce07d | ||
|
|
2cd6d46f7c | ||
|
|
c333a9fadd | ||
|
|
ba3d1c66f0 | ||
|
|
7276e533ce | ||
|
|
8b84231042 | ||
|
|
77da744008 | ||
|
|
5da7a21119 | ||
|
|
78d742c712 | ||
|
|
1c97ea3e2c | ||
|
|
3d970defe9 | ||
|
|
6282794004 | ||
|
|
475c53a55d | ||
|
|
4547ff7b5d | ||
|
|
e7b4be3dc5 | ||
|
|
2bd85e04fc | ||
|
|
f6ab5f2af1 | ||
|
|
7d943633a3 | ||
|
|
7fff3c999a | ||
|
|
a9068a11a9 | ||
|
|
d3d102516c | ||
|
|
32131439f9 | ||
|
|
d17685c540 | ||
|
|
e59f8eee36 | ||
|
|
35329abcbd | ||
|
|
ee7741c3ab | ||
|
|
ab0803b2da | ||
|
|
96196a353c | ||
|
|
2a8796c38d | ||
|
|
c8d4f7427d | ||
|
|
8d41a797d3 | ||
|
|
570e1cbf40 | ||
|
|
4c9b00a066 | ||
|
|
7d1f8bb180 | ||
|
|
3a6caeb06e | ||
|
|
9e67245e60 | ||
|
|
b7a95d5d76 | ||
|
|
fe550c5901 | ||
|
|
8aac0a571a | ||
|
|
c506b8b0ad | ||
|
|
a6e84c207e | ||
|
|
249402eaed | ||
|
|
394c476f2a | ||
|
|
86e8a141ea | ||
|
|
53a7e06dcf | ||
|
|
11edabd09f | ||
|
|
41a3d9359f | ||
|
|
5dfc5f247f | ||
|
|
9804c8a31a | ||
|
|
4d05bfdff0 | ||
|
|
0388401a9e | ||
|
|
162c548010 | ||
|
|
888b4a57e5 | ||
|
|
26d367b188 | ||
|
|
ca4988951f | ||
|
|
c7a50dd74d | ||
|
|
00fbf5c9c3 | ||
|
|
4bfe9dd5ad | ||
|
|
e159a75b79 | ||
|
|
a69686125e | ||
|
|
3eb025ded2 | ||
|
|
1d0e646094 | ||
|
|
32c8e047e3 | ||
|
|
3650482b09 | ||
|
|
79adfd2c0d | ||
|
|
779dcc62aa | ||
|
|
abe39c1a0a | ||
|
|
bd41ad813c | ||
|
|
77fe63fb63 | ||
|
|
f61ba202d8 | ||
|
|
e1067fa1a3 | ||
|
|
0a3eb898ae | ||
|
|
6c33e9dc93 | ||
|
|
f8ed6ce705 | ||
|
|
f64478b75e | ||
|
|
854a3697d7 | ||
|
|
b7915b9d0e | ||
|
|
4443b606f6 | ||
|
|
6c20a98651 | ||
|
|
b722ccc5bc | ||
|
|
db0315394b | ||
|
|
a7ef1235f4 | ||
|
|
f64a361c60 | ||
|
|
aaa788bc2f | ||
|
|
3eede6bead | ||
|
|
02abfbcb54 | ||
|
|
01d20562f0 | ||
|
|
cbe6ee6499 | ||
|
|
9a61ea8356 | ||
|
|
1af7ff600f | ||
|
|
02d594cc82 | ||
|
|
7d0b5c1c67 | ||
|
|
d3dc8a7af0 | ||
|
|
d67fefe7c5 | ||
|
|
4d364c5e4d | ||
|
|
954400ea45 | ||
|
|
04b6067e64 | ||
|
|
d77ee5554f | ||
|
|
2e034bdead | ||
|
|
fc0947aa04 | ||
|
|
1d546a4091 | ||
|
|
f60b3bbbfb | ||
|
|
8e99b9f1ad | ||
|
|
fa5ed2bc11 | ||
|
|
21d961ab97 | ||
|
|
aaa93b84d2 | ||
|
|
6a562ce03b | ||
|
|
3dbc48727e | ||
|
|
85ac2e5e9a | ||
|
|
af6bd4e505 | ||
|
|
e54c4b3499 | ||
|
|
078c88f825 | ||
|
|
85169b6c5e | ||
|
|
d0ff8ee2c0 | ||
|
|
e898768997 | ||
|
|
0f5b504f23 | ||
|
|
365d291393 | ||
|
|
3dbab24c0f | ||
|
|
1f67fb7c8d | ||
|
|
219e09fc78 | ||
|
|
cd9c2bd9ab | ||
|
|
9f969d843c | ||
|
|
b22a6472fc | ||
|
|
d231ace28e | ||
|
|
473cb7f437 | ||
|
|
783ed9f456 | ||
|
|
9a9a89ee50 | ||
|
|
5122d0341d | ||
|
|
81731689da | ||
|
|
b3e9857448 | ||
|
|
2eda9eb0e3 | ||
|
|
82a5df5048 | ||
|
|
f11564a7ac | ||
|
|
9df4d29236 | ||
|
|
1452817423 | ||
|
|
c57e496f5e | ||
|
|
6287f7003c | ||
|
|
37037b1f4e | ||
|
|
7cf123a99e | ||
|
|
97394e775f | ||
|
|
d5c381188b | ||
|
|
b107d12a62 | ||
|
|
e646f2c1fc | ||
|
|
b18528d24a | ||
|
|
a6e64df399 | ||
|
|
66ba21dd41 | ||
|
|
1851e7a111 | ||
|
|
74b78e96b3 | ||
|
|
a9657f9c00 | ||
|
|
1dee63a0eb | ||
|
|
d608cf0955 | ||
|
|
b9139a1f9b | ||
|
|
7f372c46db | ||
|
|
40010ad9b9 | ||
|
|
5927f45a4a | ||
|
|
962613df7c | ||
|
|
92b1f236e3 | ||
|
|
a911670a2d | ||
|
|
b0cb0c2269 | ||
|
|
735d03577f | ||
|
|
a33f88d822 | ||
|
|
dfd1fc8fda | ||
|
|
1df08801a2 | ||
|
|
62f5f986bb | ||
|
|
a87b9af9d5 | ||
|
|
03900e54cc | ||
|
|
f4abbd1a5b | ||
|
|
77ed90cb4a | ||
|
|
2fe3b1adb1 | ||
|
|
f56093d0f0 | ||
|
|
77dba42f17 | ||
|
|
e233a0b0dc | ||
|
|
18e4c88875 | ||
|
|
904a6038cd | ||
|
|
ae55b86493 | ||
|
|
5360f762e4 | ||
|
|
0d464787f2 | ||
|
|
24f72ef596 | ||
|
|
2d8739052b | ||
|
|
1e32d13650 | ||
|
|
dbf3f94247 | ||
|
|
8a81c7bbac | ||
|
|
d24150c78b | ||
|
|
013da18789 | ||
|
|
5b663621e4 | ||
|
|
4056345216 | ||
|
|
d00c0488c3 | ||
|
|
d352ce00fa | ||
|
|
1623f5e751 | ||
|
|
612ad1238f | ||
|
|
1ad4409609 | ||
|
|
2a94e1d1ec | ||
|
|
75b372437c | ||
|
|
b661d00159 | ||
|
|
898dbf73c8 | ||
|
|
e099304948 | ||
|
|
b61b7a12dc | ||
|
|
37769050e5 | ||
|
|
d81e137291 | ||
|
|
ae820d348e | ||
|
|
ddb298ac7c | ||
|
|
cca7b36039 | ||
|
|
adda381d9d | ||
|
|
1630b1558f | ||
|
|
733c10ff31 | ||
|
|
ed3fd185d3 | ||
|
|
b1fd7e6695 | ||
|
|
7d6230de74 | ||
|
|
f9a39c6004 |
2
.github/CODEOWNERS
vendored
Normal file
2
.github/CODEOWNERS
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# Everything needs to be reviewed by Hank
|
||||||
|
* @henrygd
|
||||||
19
.github/DISCUSSION_TEMPLATE/ideas.yml
vendored
Normal file
19
.github/DISCUSSION_TEMPLATE/ideas.yml
vendored
Normal 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
|
||||||
68
.github/DISCUSSION_TEMPLATE/support.yml
vendored
68
.github/DISCUSSION_TEMPLATE/support.yml
vendored
@@ -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 you’re 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:
|
||||||
|
|||||||
103
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
103
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -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 you’re 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:
|
||||||
|
|||||||
3
.github/ISSUE_TEMPLATE/config.yml
vendored
3
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -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.
|
||||||
|
|||||||
81
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
81
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@@ -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
|
|
||||||
101
.github/workflows/docker-images.yml
vendored
101
.github/workflows/docker-images.yml
vendored
@@ -10,67 +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
|
- image: henrygd/beszel-agent-intel
|
||||||
context: ./
|
|
||||||
dockerfile: ./internal/dockerfile_agent_intel
|
dockerfile: ./internal/dockerfile_agent_intel
|
||||||
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' }}
|
||||||
|
|
||||||
|
# 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
|
- image: ghcr.io/${{ github.repository }}/beszel-agent-intel
|
||||||
context: ./
|
|
||||||
dockerfile: ./internal/dockerfile_agent_intel
|
dockerfile: ./internal/dockerfile_agent_intel
|
||||||
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: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
|
||||||
@@ -100,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
|
||||||
@@ -123,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] != '' }}
|
||||||
|
|||||||
25
.github/workflows/inactivity-actions.yml
vendored
25
.github/workflows/inactivity-actions.yml
vendored
@@ -6,16 +6,30 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
|
actions: write
|
||||||
issues: write
|
issues: write
|
||||||
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,12 +46,19 @@ 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
|
||||||
|
|
||||||
|
# Max issues to process before early exit. Next run resumes from cache. GH API limit: 5000.
|
||||||
|
operations-per-run: 1500
|
||||||
|
|
||||||
# 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
|
||||||
|
|
||||||
exempt-milestones: true
|
exempt-milestones: true
|
||||||
82
.github/workflows/label-from-dropdown.yml
vendored
82
.github/workflows/label-from-dropdown.yml
vendored
@@ -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
|
|
||||||
});
|
|
||||||
}
|
|
||||||
6
.github/workflows/vulncheck.yml
vendored
6
.github/workflows/vulncheck.yml
vendored
@@ -19,11 +19,11 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Check out code into the Go module directory
|
- name: Check out code into the Go module directory
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version: 1.25.x
|
go-version: 1.26.x
|
||||||
# cached: false
|
# cached: false
|
||||||
- name: Get official govulncheck
|
- name: Get official govulncheck
|
||||||
run: go install golang.org/x/vuln/cmd/govulncheck@latest
|
run: go install golang.org/x/vuln/cmd/govulncheck@latest
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -10,6 +10,7 @@ dist
|
|||||||
*.exe
|
*.exe
|
||||||
internal/cmd/hub/hub
|
internal/cmd/hub/hub
|
||||||
internal/cmd/agent/agent
|
internal/cmd/agent/agent
|
||||||
|
agent.test
|
||||||
node_modules
|
node_modules
|
||||||
build
|
build
|
||||||
*timestamp*
|
*timestamp*
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
51
Makefile
51
Makefile
@@ -3,11 +3,45 @@ OS ?= $(shell go env GOOS)
|
|||||||
ARCH ?= $(shell go env GOARCH)
|
ARCH ?= $(shell go env GOARCH)
|
||||||
# Skip building the web UI if true
|
# Skip building the web UI if true
|
||||||
SKIP_WEB ?= false
|
SKIP_WEB ?= false
|
||||||
|
# Controls NVML/glibc agent build tag behavior:
|
||||||
|
# - auto (default): enable on linux/amd64 glibc hosts
|
||||||
|
# - true: always enable
|
||||||
|
# - false: always disable
|
||||||
|
NVML ?= auto
|
||||||
|
|
||||||
|
# Detect glibc host for local linux/amd64 builds.
|
||||||
|
HOST_GLIBC := $(shell \
|
||||||
|
if [ "$(OS)" = "linux" ] && [ "$(ARCH)" = "amd64" ]; then \
|
||||||
|
for p in /lib64/ld-linux-x86-64.so.2 /lib/x86_64-linux-gnu/ld-linux-x86-64.so.2 /lib/ld-linux-x86-64.so.2; do \
|
||||||
|
[ -e "$$p" ] && { echo true; exit 0; }; \
|
||||||
|
done; \
|
||||||
|
if command -v ldd >/dev/null 2>&1; then \
|
||||||
|
if ldd --version 2>&1 | tr '[:upper:]' '[:lower:]' | awk '/gnu libc|glibc/{found=1} END{exit !found}'; then \
|
||||||
|
echo true; \
|
||||||
|
else \
|
||||||
|
echo false; \
|
||||||
|
fi; \
|
||||||
|
else \
|
||||||
|
echo false; \
|
||||||
|
fi; \
|
||||||
|
else \
|
||||||
|
echo false; \
|
||||||
|
fi)
|
||||||
|
|
||||||
|
# Enable glibc build tag for NVML on supported Linux builds.
|
||||||
|
AGENT_GO_TAGS :=
|
||||||
|
ifeq ($(NVML),true)
|
||||||
|
AGENT_GO_TAGS := -tags glibc
|
||||||
|
else ifeq ($(NVML),auto)
|
||||||
|
ifeq ($(HOST_GLIBC),true)
|
||||||
|
AGENT_GO_TAGS := -tags glibc
|
||||||
|
endif
|
||||||
|
endif
|
||||||
|
|
||||||
# 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:
|
||||||
@@ -17,7 +51,6 @@ clean:
|
|||||||
lint:
|
lint:
|
||||||
golangci-lint run
|
golangci-lint run
|
||||||
|
|
||||||
test: export GOEXPERIMENT=synctest
|
|
||||||
test:
|
test:
|
||||||
go test -tags=testing ./...
|
go test -tags=testing ./...
|
||||||
|
|
||||||
@@ -46,9 +79,15 @@ 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 $(AGENT_GO_TAGS) -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)
|
||||||
GOOS=$(OS) GOARCH=$(ARCH) go build -o ./build/beszel_$(OS)_$(ARCH)$(EXE_EXT) -ldflags "-w -s" ./internal/cmd/hub
|
GOOS=$(OS) GOARCH=$(ARCH) go build -o ./build/beszel_$(OS)_$(ARCH)$(EXE_EXT) -ldflags "-w -s" ./internal/cmd/hub
|
||||||
@@ -84,9 +123,9 @@ dev-hub:
|
|||||||
|
|
||||||
dev-agent:
|
dev-agent:
|
||||||
@if command -v entr >/dev/null 2>&1; then \
|
@if command -v entr >/dev/null 2>&1; then \
|
||||||
find ./internal/cmd/agent/*.go ./agent/*.go | entr -r go run github.com/henrygd/beszel/internal/cmd/agent; \
|
find ./internal/cmd/agent/*.go ./agent/*.go | entr -r go run $(AGENT_GO_TAGS) github.com/henrygd/beszel/internal/cmd/agent; \
|
||||||
else \
|
else \
|
||||||
go run github.com/henrygd/beszel/internal/cmd/agent; \
|
go run $(AGENT_GO_TAGS) github.com/henrygd/beszel/internal/cmd/agent; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
build-dotnet:
|
build-dotnet:
|
||||||
|
|||||||
168
agent/agent.go
168
agent/agent.go
@@ -5,22 +5,22 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"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/agent/utils"
|
||||||
|
"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"
|
|
||||||
gossh "golang.org/x/crypto/ssh"
|
gossh "golang.org/x/crypto/ssh"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const defaultDataCacheTimeMs uint16 = 60_000
|
||||||
|
|
||||||
type Agent struct {
|
type Agent struct {
|
||||||
sync.Mutex // Used to lock agent while collecting data
|
sync.Mutex // Used to lock agent while collecting data
|
||||||
debug bool // true if LOG_LEVEL is set to debug
|
debug bool // true if LOG_LEVEL is set to debug
|
||||||
@@ -28,17 +28,26 @@ 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)
|
||||||
|
detailsDirty bool // Whether system details have changed and need to be resent
|
||||||
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,20 +55,37 @@ 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(),
|
||||||
}
|
}
|
||||||
|
|
||||||
agent.dataDir, err = getDataDir(dataDir...)
|
// 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...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("Data directory not found")
|
slog.Warn("Data directory not found")
|
||||||
} else {
|
} else {
|
||||||
slog.Info("Data directory", "path", agent.dataDir)
|
slog.Info("Data directory", "path", agent.dataDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
agent.memCalc, _ = GetEnv("MEM_CALC")
|
agent.memCalc, _ = utils.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 := utils.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 := utils.GetEnv("LOG_LEVEL"); exists {
|
||||||
switch strings.ToLower(logLevelStr) {
|
switch strings.ToLower(logLevelStr) {
|
||||||
case "debug":
|
case "debug":
|
||||||
agent.debug = true
|
agent.debug = true
|
||||||
@@ -73,63 +99,78 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
|
|||||||
|
|
||||||
slog.Debug(beszel.Version)
|
slog.Debug(beszel.Version)
|
||||||
|
|
||||||
|
// initialize docker manager
|
||||||
|
agent.dockerManager = newDockerManager(agent)
|
||||||
|
|
||||||
// initialize system info
|
// initialize system info
|
||||||
agent.initializeSystemInfo()
|
agent.refreshSystemDetails()
|
||||||
|
|
||||||
|
// SMART_INTERVAL env var to update smart data at this interval
|
||||||
|
if smartIntervalEnv, exists := utils.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: defaultDataCacheTimeMs, IncludeDetails: true}))
|
||||||
}
|
}
|
||||||
|
|
||||||
return agent, nil
|
return agent, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetEnv retrieves an environment variable with a "BESZEL_AGENT_" prefix, or falls back to the unprefixed key.
|
func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedData {
|
||||||
func GetEnv(key string) (value string, exists bool) {
|
|
||||||
if value, exists = os.LookupEnv("BESZEL_AGENT_" + key); exists {
|
|
||||||
return value, exists
|
|
||||||
}
|
|
||||||
// Fallback to the old unprefixed key
|
|
||||||
return os.LookupEnv(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *Agent) gatherStats(sessionID string) *system.CombinedData {
|
|
||||||
a.Lock()
|
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)
|
|
||||||
|
// 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,49 +178,48 @@ 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 == defaultDataCacheTimeMs {
|
||||||
|
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 := utils.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 a.attachSystemDetails(data, cacheTimeMs, options.IncludeDetails)
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartAgent initializes and starts the agent with optional WebSocket connection
|
// Start initializes and starts the agent with optional WebSocket connection
|
||||||
func (a *Agent) Start(serverOptions ServerOptions) error {
|
func (a *Agent) Start(serverOptions ServerOptions) error {
|
||||||
a.keys = serverOptions.Keys
|
a.keys = serverOptions.Keys
|
||||||
return a.connectionManager.Start(serverOptions)
|
return a.connectionManager.Start(serverOptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Agent) getFingerprint() string {
|
func (a *Agent) getFingerprint() string {
|
||||||
// first look for a fingerprint in the data directory
|
return GetFingerprint(a.dataDir, a.systemDetails.Hostname, a.systemDetails.CpuModel)
|
||||||
if a.dataDir != "" {
|
|
||||||
if fp, err := os.ReadFile(filepath.Join(a.dataDir, "fingerprint")); err == nil {
|
|
||||||
return string(fp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// if no fingerprint is found, generate one
|
|
||||||
fingerprint, err := host.HostID()
|
|
||||||
if err != nil || fingerprint == "" {
|
|
||||||
fingerprint = a.systemInfo.Hostname + a.systemInfo.CpuModel
|
|
||||||
}
|
|
||||||
|
|
||||||
// hash fingerprint
|
|
||||||
sum := sha256.Sum256([]byte(fingerprint))
|
|
||||||
fingerprint = hex.EncodeToString(sum[:24])
|
|
||||||
|
|
||||||
// save fingerprint to data directory
|
|
||||||
if a.dataDir != "" {
|
|
||||||
err = os.WriteFile(filepath.Join(a.dataDir, "fingerprint"), []byte(fingerprint), 0644)
|
|
||||||
if err != nil {
|
|
||||||
slog.Warn("Failed to save fingerprint", "err", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return fingerprint
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
//go:build testing
|
//go:build testing
|
||||||
// +build testing
|
|
||||||
|
|
||||||
package agent
|
package agent
|
||||||
|
|
||||||
@@ -8,82 +7,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{
|
||||||
|
Stats: system.Stats{
|
||||||
|
Cpu: 90.0,
|
||||||
|
Mem: 32768,
|
||||||
|
},
|
||||||
|
Info: system.Info{
|
||||||
|
AgentVersion: "0.12.0",
|
||||||
|
},
|
||||||
|
Containers: []*container.Stats{},
|
||||||
|
}
|
||||||
|
|
||||||
// Test setting nil data (should not panic)
|
// Set initial data
|
||||||
assert.NotPanics(t, func() {
|
cache.Set(data1, 1000)
|
||||||
cache.Set("session1", nil)
|
retrieved, isCached := cache.Get(1000)
|
||||||
}, "Setting nil data should not panic")
|
assert.True(t, isCached)
|
||||||
|
assert.Equal(t, data1, retrieved)
|
||||||
|
|
||||||
// Get data - should not be nil even though we set nil
|
// Overwrite with new data
|
||||||
data, _ := cache.Get("session2")
|
cache.Set(data2, 1000)
|
||||||
assert.NotNil(t, data, "Expected data to not be nil after setting nil data")
|
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)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
//go:build testing
|
//go:build testing
|
||||||
// +build testing
|
|
||||||
|
|
||||||
package agent
|
package agent
|
||||||
|
|
||||||
|
|||||||
@@ -1,52 +1,11 @@
|
|||||||
//go:build !freebsd
|
// Package battery provides functions to check if the system has a battery and return the charge state and percentage.
|
||||||
|
|
||||||
// Package battery provides functions to check if the system has a battery and to get the battery stats.
|
|
||||||
package battery
|
package battery
|
||||||
|
|
||||||
import (
|
const (
|
||||||
"errors"
|
stateUnknown uint8 = iota
|
||||||
"log/slog"
|
stateEmpty
|
||||||
|
stateFull
|
||||||
"github.com/distatus/battery"
|
stateCharging
|
||||||
|
stateDischarging
|
||||||
|
stateIdle
|
||||||
)
|
)
|
||||||
|
|
||||||
var systemHasBattery = false
|
|
||||||
var haveCheckedBattery = false
|
|
||||||
|
|
||||||
// HasReadableBattery checks if the system has a battery and returns true if it does.
|
|
||||||
func HasReadableBattery() bool {
|
|
||||||
if haveCheckedBattery {
|
|
||||||
return systemHasBattery
|
|
||||||
}
|
|
||||||
haveCheckedBattery = true
|
|
||||||
bat, err := battery.Get(0)
|
|
||||||
systemHasBattery = err == nil && bat != nil && bat.Design != 0 && bat.Full != 0
|
|
||||||
if !systemHasBattery {
|
|
||||||
slog.Debug("No battery found", "err", err)
|
|
||||||
}
|
|
||||||
return systemHasBattery
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetBatteryStats returns the current battery percent and charge state
|
|
||||||
func GetBatteryStats() (batteryPercent uint8, batteryState uint8, err error) {
|
|
||||||
if !systemHasBattery {
|
|
||||||
return batteryPercent, batteryState, errors.ErrUnsupported
|
|
||||||
}
|
|
||||||
batteries, err := battery.GetAll()
|
|
||||||
if err != nil || len(batteries) == 0 {
|
|
||||||
return batteryPercent, batteryState, err
|
|
||||||
}
|
|
||||||
totalCapacity := float64(0)
|
|
||||||
totalCharge := float64(0)
|
|
||||||
for _, bat := range batteries {
|
|
||||||
if bat.Design != 0 {
|
|
||||||
totalCapacity += bat.Design
|
|
||||||
} else {
|
|
||||||
totalCapacity += bat.Full
|
|
||||||
}
|
|
||||||
totalCharge += bat.Current
|
|
||||||
}
|
|
||||||
batteryPercent = uint8(totalCharge / totalCapacity * 100)
|
|
||||||
batteryState = uint8(batteries[0].State.Raw)
|
|
||||||
return batteryPercent, batteryState, nil
|
|
||||||
}
|
|
||||||
|
|||||||
96
agent/battery/battery_darwin.go
Normal file
96
agent/battery/battery_darwin.go
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
//go:build darwin
|
||||||
|
|
||||||
|
package battery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"math"
|
||||||
|
"os/exec"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"howett.net/plist"
|
||||||
|
)
|
||||||
|
|
||||||
|
type macBattery struct {
|
||||||
|
CurrentCapacity int `plist:"CurrentCapacity"`
|
||||||
|
MaxCapacity int `plist:"MaxCapacity"`
|
||||||
|
FullyCharged bool `plist:"FullyCharged"`
|
||||||
|
IsCharging bool `plist:"IsCharging"`
|
||||||
|
ExternalConnected bool `plist:"ExternalConnected"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func readMacBatteries() ([]macBattery, error) {
|
||||||
|
out, err := exec.Command("ioreg", "-n", "AppleSmartBattery", "-r", "-a").Output()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var batteries []macBattery
|
||||||
|
if _, err := plist.Unmarshal(out, &batteries); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return batteries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasReadableBattery checks if the system has a battery and returns true if it does.
|
||||||
|
var HasReadableBattery = sync.OnceValue(func() bool {
|
||||||
|
systemHasBattery := false
|
||||||
|
batteries, err := readMacBatteries()
|
||||||
|
slog.Debug("Batteries", "batteries", batteries, "err", err)
|
||||||
|
for _, bat := range batteries {
|
||||||
|
if bat.MaxCapacity > 0 {
|
||||||
|
systemHasBattery = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return systemHasBattery
|
||||||
|
})
|
||||||
|
|
||||||
|
// GetBatteryStats returns the current battery percent and charge state.
|
||||||
|
// Uses CurrentCapacity/MaxCapacity to match the value macOS displays.
|
||||||
|
func GetBatteryStats() (batteryPercent uint8, batteryState uint8, err error) {
|
||||||
|
if !HasReadableBattery() {
|
||||||
|
return batteryPercent, batteryState, errors.ErrUnsupported
|
||||||
|
}
|
||||||
|
batteries, err := readMacBatteries()
|
||||||
|
if len(batteries) == 0 {
|
||||||
|
return batteryPercent, batteryState, errors.New("no batteries")
|
||||||
|
}
|
||||||
|
|
||||||
|
totalCapacity := 0
|
||||||
|
totalCharge := 0
|
||||||
|
batteryState = math.MaxUint8
|
||||||
|
|
||||||
|
for _, bat := range batteries {
|
||||||
|
if bat.MaxCapacity == 0 {
|
||||||
|
// skip ghost batteries with 0 capacity
|
||||||
|
// https://github.com/distatus/battery/issues/34
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
totalCapacity += bat.MaxCapacity
|
||||||
|
totalCharge += min(bat.CurrentCapacity, bat.MaxCapacity)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case !bat.ExternalConnected:
|
||||||
|
batteryState = stateDischarging
|
||||||
|
case bat.IsCharging:
|
||||||
|
batteryState = stateCharging
|
||||||
|
case bat.CurrentCapacity == 0:
|
||||||
|
batteryState = stateEmpty
|
||||||
|
case !bat.FullyCharged:
|
||||||
|
batteryState = stateIdle
|
||||||
|
default:
|
||||||
|
batteryState = stateFull
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if totalCapacity == 0 || batteryState == math.MaxUint8 {
|
||||||
|
return batteryPercent, batteryState, errors.New("no battery capacity")
|
||||||
|
}
|
||||||
|
|
||||||
|
batteryPercent = uint8(float64(totalCharge) / float64(totalCapacity) * 100)
|
||||||
|
return batteryPercent, batteryState, nil
|
||||||
|
}
|
||||||
120
agent/battery/battery_linux.go
Normal file
120
agent/battery/battery_linux.go
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package battery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"math"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/agent/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// getBatteryPaths returns the paths of all batteries in /sys/class/power_supply
|
||||||
|
var getBatteryPaths func() ([]string, error)
|
||||||
|
|
||||||
|
// HasReadableBattery checks if the system has a battery and returns true if it does.
|
||||||
|
var HasReadableBattery func() bool
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
resetBatteryState("/sys/class/power_supply")
|
||||||
|
}
|
||||||
|
|
||||||
|
// resetBatteryState resets the sync.Once functions to a fresh state.
|
||||||
|
// Tests call this after swapping sysfsPowerSupply so the new path is picked up.
|
||||||
|
func resetBatteryState(sysfsPowerSupplyPath string) {
|
||||||
|
getBatteryPaths = sync.OnceValues(func() ([]string, error) {
|
||||||
|
entries, err := os.ReadDir(sysfsPowerSupplyPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var paths []string
|
||||||
|
for _, e := range entries {
|
||||||
|
path := filepath.Join(sysfsPowerSupplyPath, e.Name())
|
||||||
|
if utils.ReadStringFile(filepath.Join(path, "type")) == "Battery" {
|
||||||
|
paths = append(paths, path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return paths, nil
|
||||||
|
})
|
||||||
|
HasReadableBattery = sync.OnceValue(func() bool {
|
||||||
|
systemHasBattery := false
|
||||||
|
paths, err := getBatteryPaths()
|
||||||
|
for _, path := range paths {
|
||||||
|
if _, ok := utils.ReadStringFileOK(filepath.Join(path, "capacity")); ok {
|
||||||
|
systemHasBattery = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !systemHasBattery {
|
||||||
|
slog.Debug("No battery found", "err", err)
|
||||||
|
}
|
||||||
|
return systemHasBattery
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSysfsState(status string) uint8 {
|
||||||
|
switch status {
|
||||||
|
case "Empty":
|
||||||
|
return stateEmpty
|
||||||
|
case "Full":
|
||||||
|
return stateFull
|
||||||
|
case "Charging":
|
||||||
|
return stateCharging
|
||||||
|
case "Discharging":
|
||||||
|
return stateDischarging
|
||||||
|
case "Not charging":
|
||||||
|
return stateIdle
|
||||||
|
default:
|
||||||
|
return stateUnknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBatteryStats returns the current battery percent and charge state.
|
||||||
|
// Reads /sys/class/power_supply/*/capacity directly so the kernel-reported
|
||||||
|
// value is used, which is always 0-100 and matches what the OS displays.
|
||||||
|
func GetBatteryStats() (batteryPercent uint8, batteryState uint8, err error) {
|
||||||
|
if !HasReadableBattery() {
|
||||||
|
return batteryPercent, batteryState, errors.ErrUnsupported
|
||||||
|
}
|
||||||
|
paths, err := getBatteryPaths()
|
||||||
|
if err != nil {
|
||||||
|
return batteryPercent, batteryState, err
|
||||||
|
}
|
||||||
|
if len(paths) == 0 {
|
||||||
|
return batteryPercent, batteryState, errors.New("no batteries")
|
||||||
|
}
|
||||||
|
|
||||||
|
batteryState = math.MaxUint8
|
||||||
|
totalPercent := 0
|
||||||
|
count := 0
|
||||||
|
|
||||||
|
for _, path := range paths {
|
||||||
|
capStr, ok := utils.ReadStringFileOK(filepath.Join(path, "capacity"))
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cap, parseErr := strconv.Atoi(capStr)
|
||||||
|
if parseErr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
totalPercent += cap
|
||||||
|
count++
|
||||||
|
|
||||||
|
state := parseSysfsState(utils.ReadStringFile(filepath.Join(path, "status")))
|
||||||
|
if state != stateUnknown {
|
||||||
|
batteryState = state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if count == 0 || batteryState == math.MaxUint8 {
|
||||||
|
return batteryPercent, batteryState, errors.New("no battery capacity")
|
||||||
|
}
|
||||||
|
|
||||||
|
batteryPercent = uint8(totalPercent / count)
|
||||||
|
return batteryPercent, batteryState, nil
|
||||||
|
}
|
||||||
201
agent/battery/battery_linux_test.go
Normal file
201
agent/battery/battery_linux_test.go
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
//go:build testing && linux
|
||||||
|
|
||||||
|
package battery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// setupFakeSysfs creates a temporary sysfs-like tree under t.TempDir(),
|
||||||
|
// swaps sysfsPowerSupply, resets the sync.Once caches, and restores
|
||||||
|
// everything on cleanup. Returns a helper to create battery directories.
|
||||||
|
func setupFakeSysfs(t *testing.T) (tmpDir string, addBattery func(name, capacity, status string)) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
tmp := t.TempDir()
|
||||||
|
resetBatteryState(tmp)
|
||||||
|
|
||||||
|
write := func(path, content string) {
|
||||||
|
t.Helper()
|
||||||
|
dir := filepath.Dir(path)
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addBattery = func(name, capacity, status string) {
|
||||||
|
t.Helper()
|
||||||
|
batDir := filepath.Join(tmp, name)
|
||||||
|
write(filepath.Join(batDir, "type"), "Battery")
|
||||||
|
write(filepath.Join(batDir, "capacity"), capacity)
|
||||||
|
write(filepath.Join(batDir, "status"), status)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tmp, addBattery
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseSysfsState(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
want uint8
|
||||||
|
}{
|
||||||
|
{"Empty", stateEmpty},
|
||||||
|
{"Full", stateFull},
|
||||||
|
{"Charging", stateCharging},
|
||||||
|
{"Discharging", stateDischarging},
|
||||||
|
{"Not charging", stateIdle},
|
||||||
|
{"", stateUnknown},
|
||||||
|
{"SomethingElse", stateUnknown},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
assert.Equal(t, tt.want, parseSysfsState(tt.input), "parseSysfsState(%q)", tt.input)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetBatteryStats_SingleBattery(t *testing.T) {
|
||||||
|
_, addBattery := setupFakeSysfs(t)
|
||||||
|
addBattery("BAT0", "72", "Discharging")
|
||||||
|
|
||||||
|
pct, state, err := GetBatteryStats()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, uint8(72), pct)
|
||||||
|
assert.Equal(t, stateDischarging, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetBatteryStats_MultipleBatteries(t *testing.T) {
|
||||||
|
_, addBattery := setupFakeSysfs(t)
|
||||||
|
addBattery("BAT0", "80", "Charging")
|
||||||
|
addBattery("BAT1", "40", "Charging")
|
||||||
|
|
||||||
|
pct, state, err := GetBatteryStats()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
// average of 80 and 40 = 60
|
||||||
|
assert.EqualValues(t, 60, pct)
|
||||||
|
assert.Equal(t, stateCharging, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetBatteryStats_FullBattery(t *testing.T) {
|
||||||
|
_, addBattery := setupFakeSysfs(t)
|
||||||
|
addBattery("BAT0", "100", "Full")
|
||||||
|
|
||||||
|
pct, state, err := GetBatteryStats()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, uint8(100), pct)
|
||||||
|
assert.Equal(t, stateFull, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetBatteryStats_EmptyBattery(t *testing.T) {
|
||||||
|
_, addBattery := setupFakeSysfs(t)
|
||||||
|
addBattery("BAT0", "0", "Empty")
|
||||||
|
|
||||||
|
pct, state, err := GetBatteryStats()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, uint8(0), pct)
|
||||||
|
assert.Equal(t, stateEmpty, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetBatteryStats_NotCharging(t *testing.T) {
|
||||||
|
_, addBattery := setupFakeSysfs(t)
|
||||||
|
addBattery("BAT0", "80", "Not charging")
|
||||||
|
|
||||||
|
pct, state, err := GetBatteryStats()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, uint8(80), pct)
|
||||||
|
assert.Equal(t, stateIdle, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetBatteryStats_NoBatteries(t *testing.T) {
|
||||||
|
setupFakeSysfs(t) // empty directory, no batteries
|
||||||
|
|
||||||
|
_, _, err := GetBatteryStats()
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetBatteryStats_NonBatterySupplyIgnored(t *testing.T) {
|
||||||
|
tmp, addBattery := setupFakeSysfs(t)
|
||||||
|
|
||||||
|
// Add a real battery
|
||||||
|
addBattery("BAT0", "55", "Charging")
|
||||||
|
|
||||||
|
// Add an AC adapter (type != Battery) - should be ignored
|
||||||
|
acDir := filepath.Join(tmp, "AC0")
|
||||||
|
if err := os.MkdirAll(acDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(acDir, "type"), []byte("Mains"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pct, state, err := GetBatteryStats()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, uint8(55), pct)
|
||||||
|
assert.Equal(t, stateCharging, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetBatteryStats_InvalidCapacitySkipped(t *testing.T) {
|
||||||
|
tmp, addBattery := setupFakeSysfs(t)
|
||||||
|
|
||||||
|
// One battery with valid capacity
|
||||||
|
addBattery("BAT0", "90", "Discharging")
|
||||||
|
|
||||||
|
// Another with invalid capacity text
|
||||||
|
badDir := filepath.Join(tmp, "BAT1")
|
||||||
|
if err := os.MkdirAll(badDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(badDir, "type"), []byte("Battery"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(badDir, "capacity"), []byte("not-a-number"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(badDir, "status"), []byte("Discharging"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pct, _, err := GetBatteryStats()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
// Only BAT0 counted
|
||||||
|
assert.Equal(t, uint8(90), pct)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetBatteryStats_UnknownStatusOnly(t *testing.T) {
|
||||||
|
_, addBattery := setupFakeSysfs(t)
|
||||||
|
addBattery("BAT0", "50", "SomethingWeird")
|
||||||
|
|
||||||
|
_, _, err := GetBatteryStats()
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHasReadableBattery_True(t *testing.T) {
|
||||||
|
_, addBattery := setupFakeSysfs(t)
|
||||||
|
addBattery("BAT0", "50", "Charging")
|
||||||
|
|
||||||
|
assert.True(t, HasReadableBattery())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHasReadableBattery_False(t *testing.T) {
|
||||||
|
setupFakeSysfs(t) // no batteries
|
||||||
|
|
||||||
|
assert.False(t, HasReadableBattery())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHasReadableBattery_NoCapacityFile(t *testing.T) {
|
||||||
|
tmp, _ := setupFakeSysfs(t)
|
||||||
|
|
||||||
|
// Battery dir with type file but no capacity file
|
||||||
|
batDir := filepath.Join(tmp, "BAT0")
|
||||||
|
err := os.MkdirAll(batDir, 0o755)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
err = os.WriteFile(filepath.Join(batDir, "type"), []byte("Battery"), 0o644)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
assert.False(t, HasReadableBattery())
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
//go:build freebsd
|
//go:build !darwin && !linux && !windows
|
||||||
|
|
||||||
package battery
|
package battery
|
||||||
|
|
||||||
298
agent/battery/battery_windows.go
Normal file
298
agent/battery/battery_windows.go
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
// Most of the Windows battery code is based on
|
||||||
|
// distatus/battery by Karol 'Kenji Takahashi' Woźniak
|
||||||
|
|
||||||
|
package battery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"math"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
|
)
|
||||||
|
|
||||||
|
type batteryQueryInformation struct {
|
||||||
|
BatteryTag uint32
|
||||||
|
InformationLevel int32
|
||||||
|
AtRate int32
|
||||||
|
}
|
||||||
|
|
||||||
|
type batteryInformation struct {
|
||||||
|
Capabilities uint32
|
||||||
|
Technology uint8
|
||||||
|
Reserved [3]uint8
|
||||||
|
Chemistry [4]uint8
|
||||||
|
DesignedCapacity uint32
|
||||||
|
FullChargedCapacity uint32
|
||||||
|
DefaultAlert1 uint32
|
||||||
|
DefaultAlert2 uint32
|
||||||
|
CriticalBias uint32
|
||||||
|
CycleCount uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
type batteryWaitStatus struct {
|
||||||
|
BatteryTag uint32
|
||||||
|
Timeout uint32
|
||||||
|
PowerState uint32
|
||||||
|
LowCapacity uint32
|
||||||
|
HighCapacity uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
type batteryStatus struct {
|
||||||
|
PowerState uint32
|
||||||
|
Capacity uint32
|
||||||
|
Voltage uint32
|
||||||
|
Rate int32
|
||||||
|
}
|
||||||
|
|
||||||
|
type winGUID struct {
|
||||||
|
Data1 uint32
|
||||||
|
Data2 uint16
|
||||||
|
Data3 uint16
|
||||||
|
Data4 [8]byte
|
||||||
|
}
|
||||||
|
|
||||||
|
type spDeviceInterfaceData struct {
|
||||||
|
cbSize uint32
|
||||||
|
InterfaceClassGuid winGUID
|
||||||
|
Flags uint32
|
||||||
|
Reserved uint
|
||||||
|
}
|
||||||
|
|
||||||
|
var guidDeviceBattery = winGUID{
|
||||||
|
0x72631e54,
|
||||||
|
0x78A4,
|
||||||
|
0x11d0,
|
||||||
|
[8]byte{0xbc, 0xf7, 0x00, 0xaa, 0x00, 0xb7, 0xb3, 0x2a},
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
setupapi = &windows.LazyDLL{Name: "setupapi.dll", System: true}
|
||||||
|
setupDiGetClassDevsW = setupapi.NewProc("SetupDiGetClassDevsW")
|
||||||
|
setupDiEnumDeviceInterfaces = setupapi.NewProc("SetupDiEnumDeviceInterfaces")
|
||||||
|
setupDiGetDeviceInterfaceDetailW = setupapi.NewProc("SetupDiGetDeviceInterfaceDetailW")
|
||||||
|
setupDiDestroyDeviceInfoList = setupapi.NewProc("SetupDiDestroyDeviceInfoList")
|
||||||
|
)
|
||||||
|
|
||||||
|
// winBatteryGet reads one battery by index. Returns (fullCapacity, currentCapacity, state, error).
|
||||||
|
// Returns error == errNotFound when there are no more batteries.
|
||||||
|
var errNotFound = errors.New("no more batteries")
|
||||||
|
|
||||||
|
func setupDiSetup(proc *windows.LazyProc, nargs, a1, a2, a3, a4, a5, a6 uintptr) (uintptr, error) {
|
||||||
|
_ = nargs
|
||||||
|
r1, _, errno := syscall.SyscallN(proc.Addr(), a1, a2, a3, a4, a5, a6)
|
||||||
|
if windows.Handle(r1) == windows.InvalidHandle {
|
||||||
|
if errno != 0 {
|
||||||
|
return 0, error(errno)
|
||||||
|
}
|
||||||
|
return 0, syscall.EINVAL
|
||||||
|
}
|
||||||
|
return r1, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupDiCall(proc *windows.LazyProc, nargs, a1, a2, a3, a4, a5, a6 uintptr) syscall.Errno {
|
||||||
|
_ = nargs
|
||||||
|
r1, _, errno := syscall.SyscallN(proc.Addr(), a1, a2, a3, a4, a5, a6)
|
||||||
|
if r1 == 0 {
|
||||||
|
if errno != 0 {
|
||||||
|
return errno
|
||||||
|
}
|
||||||
|
return syscall.EINVAL
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func readWinBatteryState(powerState uint32) uint8 {
|
||||||
|
switch {
|
||||||
|
case powerState&0x00000004 != 0:
|
||||||
|
return stateCharging
|
||||||
|
case powerState&0x00000008 != 0:
|
||||||
|
return stateEmpty
|
||||||
|
case powerState&0x00000002 != 0:
|
||||||
|
return stateDischarging
|
||||||
|
case powerState&0x00000001 != 0:
|
||||||
|
return stateFull
|
||||||
|
default:
|
||||||
|
return stateUnknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func winBatteryGet(idx int) (full, current uint32, state uint8, err error) {
|
||||||
|
hdev, err := setupDiSetup(
|
||||||
|
setupDiGetClassDevsW,
|
||||||
|
4,
|
||||||
|
uintptr(unsafe.Pointer(&guidDeviceBattery)),
|
||||||
|
0, 0,
|
||||||
|
2|16, // DIGCF_PRESENT|DIGCF_DEVICEINTERFACE
|
||||||
|
0, 0,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, stateUnknown, err
|
||||||
|
}
|
||||||
|
defer syscall.SyscallN(setupDiDestroyDeviceInfoList.Addr(), hdev)
|
||||||
|
|
||||||
|
var did spDeviceInterfaceData
|
||||||
|
did.cbSize = uint32(unsafe.Sizeof(did))
|
||||||
|
errno := setupDiCall(
|
||||||
|
setupDiEnumDeviceInterfaces,
|
||||||
|
5,
|
||||||
|
hdev, 0,
|
||||||
|
uintptr(unsafe.Pointer(&guidDeviceBattery)),
|
||||||
|
uintptr(idx),
|
||||||
|
uintptr(unsafe.Pointer(&did)),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
if errno == 259 { // ERROR_NO_MORE_ITEMS
|
||||||
|
return 0, 0, stateUnknown, errNotFound
|
||||||
|
}
|
||||||
|
if errno != 0 {
|
||||||
|
return 0, 0, stateUnknown, errno
|
||||||
|
}
|
||||||
|
|
||||||
|
var cbRequired uint32
|
||||||
|
errno = setupDiCall(
|
||||||
|
setupDiGetDeviceInterfaceDetailW,
|
||||||
|
6,
|
||||||
|
hdev,
|
||||||
|
uintptr(unsafe.Pointer(&did)),
|
||||||
|
0, 0,
|
||||||
|
uintptr(unsafe.Pointer(&cbRequired)),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
if errno != 0 && errno != 122 { // ERROR_INSUFFICIENT_BUFFER
|
||||||
|
return 0, 0, stateUnknown, errno
|
||||||
|
}
|
||||||
|
didd := make([]uint16, cbRequired/2)
|
||||||
|
cbSize := (*uint32)(unsafe.Pointer(&didd[0]))
|
||||||
|
if unsafe.Sizeof(uint(0)) == 8 {
|
||||||
|
*cbSize = 8
|
||||||
|
} else {
|
||||||
|
*cbSize = 6
|
||||||
|
}
|
||||||
|
errno = setupDiCall(
|
||||||
|
setupDiGetDeviceInterfaceDetailW,
|
||||||
|
6,
|
||||||
|
hdev,
|
||||||
|
uintptr(unsafe.Pointer(&did)),
|
||||||
|
uintptr(unsafe.Pointer(&didd[0])),
|
||||||
|
uintptr(cbRequired),
|
||||||
|
uintptr(unsafe.Pointer(&cbRequired)),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
if errno != 0 {
|
||||||
|
return 0, 0, stateUnknown, errno
|
||||||
|
}
|
||||||
|
devicePath := &didd[2:][0]
|
||||||
|
|
||||||
|
handle, err := windows.CreateFile(
|
||||||
|
devicePath,
|
||||||
|
windows.GENERIC_READ|windows.GENERIC_WRITE,
|
||||||
|
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE,
|
||||||
|
nil,
|
||||||
|
windows.OPEN_EXISTING,
|
||||||
|
windows.FILE_ATTRIBUTE_NORMAL,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, stateUnknown, err
|
||||||
|
}
|
||||||
|
defer windows.CloseHandle(handle)
|
||||||
|
|
||||||
|
var dwOut uint32
|
||||||
|
var dwWait uint32
|
||||||
|
var bqi batteryQueryInformation
|
||||||
|
err = windows.DeviceIoControl(
|
||||||
|
handle,
|
||||||
|
2703424, // IOCTL_BATTERY_QUERY_TAG
|
||||||
|
(*byte)(unsafe.Pointer(&dwWait)),
|
||||||
|
uint32(unsafe.Sizeof(dwWait)),
|
||||||
|
(*byte)(unsafe.Pointer(&bqi.BatteryTag)),
|
||||||
|
uint32(unsafe.Sizeof(bqi.BatteryTag)),
|
||||||
|
&dwOut, nil,
|
||||||
|
)
|
||||||
|
if err != nil || bqi.BatteryTag == 0 {
|
||||||
|
return 0, 0, stateUnknown, errors.New("battery tag not returned")
|
||||||
|
}
|
||||||
|
|
||||||
|
var bi batteryInformation
|
||||||
|
if err = windows.DeviceIoControl(
|
||||||
|
handle,
|
||||||
|
2703428, // IOCTL_BATTERY_QUERY_INFORMATION
|
||||||
|
(*byte)(unsafe.Pointer(&bqi)),
|
||||||
|
uint32(unsafe.Sizeof(bqi)),
|
||||||
|
(*byte)(unsafe.Pointer(&bi)),
|
||||||
|
uint32(unsafe.Sizeof(bi)),
|
||||||
|
&dwOut, nil,
|
||||||
|
); err != nil {
|
||||||
|
return 0, 0, stateUnknown, err
|
||||||
|
}
|
||||||
|
|
||||||
|
bws := batteryWaitStatus{BatteryTag: bqi.BatteryTag}
|
||||||
|
var bs batteryStatus
|
||||||
|
if err = windows.DeviceIoControl(
|
||||||
|
handle,
|
||||||
|
2703436, // IOCTL_BATTERY_QUERY_STATUS
|
||||||
|
(*byte)(unsafe.Pointer(&bws)),
|
||||||
|
uint32(unsafe.Sizeof(bws)),
|
||||||
|
(*byte)(unsafe.Pointer(&bs)),
|
||||||
|
uint32(unsafe.Sizeof(bs)),
|
||||||
|
&dwOut, nil,
|
||||||
|
); err != nil {
|
||||||
|
return 0, 0, stateUnknown, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if bs.Capacity == 0xffffffff { // BATTERY_UNKNOWN_CAPACITY
|
||||||
|
return 0, 0, stateUnknown, errors.New("battery capacity unknown")
|
||||||
|
}
|
||||||
|
|
||||||
|
return bi.FullChargedCapacity, bs.Capacity, readWinBatteryState(bs.PowerState), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasReadableBattery checks if the system has a battery and returns true if it does.
|
||||||
|
var HasReadableBattery = sync.OnceValue(func() bool {
|
||||||
|
systemHasBattery := false
|
||||||
|
full, _, _, err := winBatteryGet(0)
|
||||||
|
if err == nil && full > 0 {
|
||||||
|
systemHasBattery = true
|
||||||
|
}
|
||||||
|
if !systemHasBattery {
|
||||||
|
slog.Debug("No battery found", "err", err)
|
||||||
|
}
|
||||||
|
return systemHasBattery
|
||||||
|
})
|
||||||
|
|
||||||
|
// GetBatteryStats returns the current battery percent and charge state.
|
||||||
|
func GetBatteryStats() (batteryPercent uint8, batteryState uint8, err error) {
|
||||||
|
if !HasReadableBattery() {
|
||||||
|
return batteryPercent, batteryState, errors.ErrUnsupported
|
||||||
|
}
|
||||||
|
|
||||||
|
totalFull := uint32(0)
|
||||||
|
totalCurrent := uint32(0)
|
||||||
|
batteryState = math.MaxUint8
|
||||||
|
|
||||||
|
for i := 0; ; i++ {
|
||||||
|
full, current, state, bErr := winBatteryGet(i)
|
||||||
|
if errors.Is(bErr, errNotFound) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if bErr != nil || full == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
totalFull += full
|
||||||
|
totalCurrent += min(current, full)
|
||||||
|
batteryState = state
|
||||||
|
}
|
||||||
|
|
||||||
|
if totalFull == 0 || batteryState == math.MaxUint8 {
|
||||||
|
return batteryPercent, batteryState, errors.New("no battery capacity")
|
||||||
|
}
|
||||||
|
|
||||||
|
batteryPercent = uint8(float64(totalCurrent) / float64(totalFull) * 100)
|
||||||
|
return batteryPercent, batteryState, nil
|
||||||
|
}
|
||||||
@@ -14,11 +14,13 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel"
|
"github.com/henrygd/beszel"
|
||||||
|
"github.com/henrygd/beszel/agent/utils"
|
||||||
"github.com/henrygd/beszel/internal/common"
|
"github.com/henrygd/beszel/internal/common"
|
||||||
|
|
||||||
"github.com/fxamacker/cbor/v2"
|
"github.com/fxamacker/cbor/v2"
|
||||||
"github.com/lxzan/gws"
|
"github.com/lxzan/gws"
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
|
"golang.org/x/net/proxy"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -43,7 +45,7 @@ type WebSocketClient struct {
|
|||||||
// newWebSocketClient creates a new WebSocket client for the given agent.
|
// newWebSocketClient creates a new WebSocket client for the given agent.
|
||||||
// It reads configuration from environment variables and validates the hub URL.
|
// It reads configuration from environment variables and validates the hub URL.
|
||||||
func newWebSocketClient(agent *Agent) (client *WebSocketClient, err error) {
|
func newWebSocketClient(agent *Agent) (client *WebSocketClient, err error) {
|
||||||
hubURLStr, exists := GetEnv("HUB_URL")
|
hubURLStr, exists := utils.GetEnv("HUB_URL")
|
||||||
if !exists {
|
if !exists {
|
||||||
return nil, errors.New("HUB_URL environment variable not set")
|
return nil, errors.New("HUB_URL environment variable not set")
|
||||||
}
|
}
|
||||||
@@ -72,12 +74,12 @@ func newWebSocketClient(agent *Agent) (client *WebSocketClient, err error) {
|
|||||||
// If neither is set, it returns an error.
|
// If neither is set, it returns an error.
|
||||||
func getToken() (string, error) {
|
func getToken() (string, error) {
|
||||||
// get token from env var
|
// get token from env var
|
||||||
token, _ := GetEnv("TOKEN")
|
token, _ := utils.GetEnv("TOKEN")
|
||||||
if token != "" {
|
if token != "" {
|
||||||
return token, nil
|
return token, nil
|
||||||
}
|
}
|
||||||
// get token from file
|
// get token from file
|
||||||
tokenFile, _ := GetEnv("TOKEN_FILE")
|
tokenFile, _ := utils.GetEnv("TOKEN_FILE")
|
||||||
if tokenFile == "" {
|
if tokenFile == "" {
|
||||||
return "", errors.New("must set TOKEN or TOKEN_FILE")
|
return "", errors.New("must set TOKEN or TOKEN_FILE")
|
||||||
}
|
}
|
||||||
@@ -103,6 +105,11 @@ func (client *WebSocketClient) getOptions() *gws.ClientOption {
|
|||||||
}
|
}
|
||||||
client.hubURL.Path = path.Join(client.hubURL.Path, "api/beszel/agent-connect")
|
client.hubURL.Path = path.Join(client.hubURL.Path, "api/beszel/agent-connect")
|
||||||
|
|
||||||
|
// make sure BESZEL_AGENT_ALL_PROXY works (GWS only checks ALL_PROXY)
|
||||||
|
if val := os.Getenv("BESZEL_AGENT_ALL_PROXY"); val != "" {
|
||||||
|
os.Setenv("ALL_PROXY", val)
|
||||||
|
}
|
||||||
|
|
||||||
client.options = &gws.ClientOption{
|
client.options = &gws.ClientOption{
|
||||||
Addr: client.hubURL.String(),
|
Addr: client.hubURL.String(),
|
||||||
TlsConfig: &tls.Config{InsecureSkipVerify: true},
|
TlsConfig: &tls.Config{InsecureSkipVerify: true},
|
||||||
@@ -111,6 +118,9 @@ func (client *WebSocketClient) getOptions() *gws.ClientOption {
|
|||||||
"X-Token": []string{client.token},
|
"X-Token": []string{client.token},
|
||||||
"X-Beszel": []string{beszel.Version},
|
"X-Beszel": []string{beszel.Version},
|
||||||
},
|
},
|
||||||
|
NewDialer: func() (gws.Dialer, error) {
|
||||||
|
return proxy.FromEnvironment(), nil
|
||||||
|
},
|
||||||
}
|
}
|
||||||
return client.options
|
return client.options
|
||||||
}
|
}
|
||||||
@@ -142,7 +152,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 +168,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 +189,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 +207,13 @@ func (client *WebSocketClient) handleAuthChallenge(msg *common.HubRequest[cbor.R
|
|||||||
}
|
}
|
||||||
|
|
||||||
if authRequest.NeedSysInfo {
|
if authRequest.NeedSysInfo {
|
||||||
response.Hostname = client.agent.systemInfo.Hostname
|
response.Name, _ = utils.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 +238,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 +257,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.
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
//go:build testing
|
//go:build testing
|
||||||
// +build testing
|
|
||||||
|
|
||||||
package agent
|
package agent
|
||||||
|
|
||||||
@@ -71,19 +70,11 @@ func TestNewWebSocketClient(t *testing.T) {
|
|||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
// Set up environment
|
// Set up environment
|
||||||
if tc.hubURL != "" {
|
if tc.hubURL != "" {
|
||||||
os.Setenv("BESZEL_AGENT_HUB_URL", tc.hubURL)
|
t.Setenv("BESZEL_AGENT_HUB_URL", tc.hubURL)
|
||||||
} else {
|
|
||||||
os.Unsetenv("BESZEL_AGENT_HUB_URL")
|
|
||||||
}
|
}
|
||||||
if tc.token != "" {
|
if tc.token != "" {
|
||||||
os.Setenv("BESZEL_AGENT_TOKEN", tc.token)
|
t.Setenv("BESZEL_AGENT_TOKEN", tc.token)
|
||||||
} else {
|
|
||||||
os.Unsetenv("BESZEL_AGENT_TOKEN")
|
|
||||||
}
|
}
|
||||||
defer func() {
|
|
||||||
os.Unsetenv("BESZEL_AGENT_HUB_URL")
|
|
||||||
os.Unsetenv("BESZEL_AGENT_TOKEN")
|
|
||||||
}()
|
|
||||||
|
|
||||||
client, err := newWebSocketClient(agent)
|
client, err := newWebSocketClient(agent)
|
||||||
|
|
||||||
@@ -139,12 +130,8 @@ func TestWebSocketClient_GetOptions(t *testing.T) {
|
|||||||
for _, tc := range testCases {
|
for _, tc := range testCases {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
// Set up environment
|
// Set up environment
|
||||||
os.Setenv("BESZEL_AGENT_HUB_URL", tc.inputURL)
|
t.Setenv("BESZEL_AGENT_HUB_URL", tc.inputURL)
|
||||||
os.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
t.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
||||||
defer func() {
|
|
||||||
os.Unsetenv("BESZEL_AGENT_HUB_URL")
|
|
||||||
os.Unsetenv("BESZEL_AGENT_TOKEN")
|
|
||||||
}()
|
|
||||||
|
|
||||||
client, err := newWebSocketClient(agent)
|
client, err := newWebSocketClient(agent)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -186,12 +173,8 @@ func TestWebSocketClient_VerifySignature(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Set up environment
|
// Set up environment
|
||||||
os.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080")
|
t.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080")
|
||||||
os.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
t.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
||||||
defer func() {
|
|
||||||
os.Unsetenv("BESZEL_AGENT_HUB_URL")
|
|
||||||
os.Unsetenv("BESZEL_AGENT_TOKEN")
|
|
||||||
}()
|
|
||||||
|
|
||||||
client, err := newWebSocketClient(agent)
|
client, err := newWebSocketClient(agent)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -259,12 +242,8 @@ func TestWebSocketClient_HandleHubRequest(t *testing.T) {
|
|||||||
agent := createTestAgent(t)
|
agent := createTestAgent(t)
|
||||||
|
|
||||||
// Set up environment
|
// Set up environment
|
||||||
os.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080")
|
t.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080")
|
||||||
os.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
t.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
||||||
defer func() {
|
|
||||||
os.Unsetenv("BESZEL_AGENT_HUB_URL")
|
|
||||||
os.Unsetenv("BESZEL_AGENT_TOKEN")
|
|
||||||
}()
|
|
||||||
|
|
||||||
client, err := newWebSocketClient(agent)
|
client, err := newWebSocketClient(agent)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -301,7 +280,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)
|
||||||
@@ -351,13 +330,8 @@ func TestGetUserAgent(t *testing.T) {
|
|||||||
func TestWebSocketClient_Close(t *testing.T) {
|
func TestWebSocketClient_Close(t *testing.T) {
|
||||||
agent := createTestAgent(t)
|
agent := createTestAgent(t)
|
||||||
|
|
||||||
// Set up environment
|
t.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080")
|
||||||
os.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080")
|
t.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
||||||
os.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
|
||||||
defer func() {
|
|
||||||
os.Unsetenv("BESZEL_AGENT_HUB_URL")
|
|
||||||
os.Unsetenv("BESZEL_AGENT_TOKEN")
|
|
||||||
}()
|
|
||||||
|
|
||||||
client, err := newWebSocketClient(agent)
|
client, err := newWebSocketClient(agent)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -372,13 +346,8 @@ func TestWebSocketClient_Close(t *testing.T) {
|
|||||||
func TestWebSocketClient_ConnectRateLimit(t *testing.T) {
|
func TestWebSocketClient_ConnectRateLimit(t *testing.T) {
|
||||||
agent := createTestAgent(t)
|
agent := createTestAgent(t)
|
||||||
|
|
||||||
// Set up environment
|
t.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080")
|
||||||
os.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080")
|
t.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
||||||
os.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
|
||||||
defer func() {
|
|
||||||
os.Unsetenv("BESZEL_AGENT_HUB_URL")
|
|
||||||
os.Unsetenv("BESZEL_AGENT_TOKEN")
|
|
||||||
}()
|
|
||||||
|
|
||||||
client, err := newWebSocketClient(agent)
|
client, err := newWebSocketClient(agent)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -394,20 +363,10 @@ func TestWebSocketClient_ConnectRateLimit(t *testing.T) {
|
|||||||
|
|
||||||
// TestGetToken tests the getToken function with various scenarios
|
// TestGetToken tests the getToken function with various scenarios
|
||||||
func TestGetToken(t *testing.T) {
|
func TestGetToken(t *testing.T) {
|
||||||
unsetEnvVars := func() {
|
|
||||||
os.Unsetenv("BESZEL_AGENT_TOKEN")
|
|
||||||
os.Unsetenv("TOKEN")
|
|
||||||
os.Unsetenv("BESZEL_AGENT_TOKEN_FILE")
|
|
||||||
os.Unsetenv("TOKEN_FILE")
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Run("token from TOKEN environment variable", func(t *testing.T) {
|
t.Run("token from TOKEN environment variable", func(t *testing.T) {
|
||||||
unsetEnvVars()
|
|
||||||
|
|
||||||
// Set TOKEN env var
|
// Set TOKEN env var
|
||||||
expectedToken := "test-token-from-env"
|
expectedToken := "test-token-from-env"
|
||||||
os.Setenv("TOKEN", expectedToken)
|
t.Setenv("TOKEN", expectedToken)
|
||||||
defer os.Unsetenv("TOKEN")
|
|
||||||
|
|
||||||
token, err := getToken()
|
token, err := getToken()
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
@@ -415,12 +374,9 @@ func TestGetToken(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("token from BESZEL_AGENT_TOKEN environment variable", func(t *testing.T) {
|
t.Run("token from BESZEL_AGENT_TOKEN environment variable", func(t *testing.T) {
|
||||||
unsetEnvVars()
|
|
||||||
|
|
||||||
// Set BESZEL_AGENT_TOKEN env var (should take precedence)
|
// Set BESZEL_AGENT_TOKEN env var (should take precedence)
|
||||||
expectedToken := "test-token-from-beszel-env"
|
expectedToken := "test-token-from-beszel-env"
|
||||||
os.Setenv("BESZEL_AGENT_TOKEN", expectedToken)
|
t.Setenv("BESZEL_AGENT_TOKEN", expectedToken)
|
||||||
defer os.Unsetenv("BESZEL_AGENT_TOKEN")
|
|
||||||
|
|
||||||
token, err := getToken()
|
token, err := getToken()
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
@@ -428,8 +384,6 @@ func TestGetToken(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("token from TOKEN_FILE", func(t *testing.T) {
|
t.Run("token from TOKEN_FILE", func(t *testing.T) {
|
||||||
unsetEnvVars()
|
|
||||||
|
|
||||||
// Create a temporary token file
|
// Create a temporary token file
|
||||||
expectedToken := "test-token-from-file"
|
expectedToken := "test-token-from-file"
|
||||||
tokenFile, err := os.CreateTemp("", "token-test-*.txt")
|
tokenFile, err := os.CreateTemp("", "token-test-*.txt")
|
||||||
@@ -441,8 +395,7 @@ func TestGetToken(t *testing.T) {
|
|||||||
tokenFile.Close()
|
tokenFile.Close()
|
||||||
|
|
||||||
// Set TOKEN_FILE env var
|
// Set TOKEN_FILE env var
|
||||||
os.Setenv("TOKEN_FILE", tokenFile.Name())
|
t.Setenv("TOKEN_FILE", tokenFile.Name())
|
||||||
defer os.Unsetenv("TOKEN_FILE")
|
|
||||||
|
|
||||||
token, err := getToken()
|
token, err := getToken()
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
@@ -450,8 +403,6 @@ func TestGetToken(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("token from BESZEL_AGENT_TOKEN_FILE", func(t *testing.T) {
|
t.Run("token from BESZEL_AGENT_TOKEN_FILE", func(t *testing.T) {
|
||||||
unsetEnvVars()
|
|
||||||
|
|
||||||
// Create a temporary token file
|
// Create a temporary token file
|
||||||
expectedToken := "test-token-from-beszel-file"
|
expectedToken := "test-token-from-beszel-file"
|
||||||
tokenFile, err := os.CreateTemp("", "token-test-*.txt")
|
tokenFile, err := os.CreateTemp("", "token-test-*.txt")
|
||||||
@@ -463,8 +414,7 @@ func TestGetToken(t *testing.T) {
|
|||||||
tokenFile.Close()
|
tokenFile.Close()
|
||||||
|
|
||||||
// Set BESZEL_AGENT_TOKEN_FILE env var (should take precedence)
|
// Set BESZEL_AGENT_TOKEN_FILE env var (should take precedence)
|
||||||
os.Setenv("BESZEL_AGENT_TOKEN_FILE", tokenFile.Name())
|
t.Setenv("BESZEL_AGENT_TOKEN_FILE", tokenFile.Name())
|
||||||
defer os.Unsetenv("BESZEL_AGENT_TOKEN_FILE")
|
|
||||||
|
|
||||||
token, err := getToken()
|
token, err := getToken()
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
@@ -472,8 +422,6 @@ func TestGetToken(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("TOKEN takes precedence over TOKEN_FILE", func(t *testing.T) {
|
t.Run("TOKEN takes precedence over TOKEN_FILE", func(t *testing.T) {
|
||||||
unsetEnvVars()
|
|
||||||
|
|
||||||
// Create a temporary token file
|
// Create a temporary token file
|
||||||
fileToken := "token-from-file"
|
fileToken := "token-from-file"
|
||||||
tokenFile, err := os.CreateTemp("", "token-test-*.txt")
|
tokenFile, err := os.CreateTemp("", "token-test-*.txt")
|
||||||
@@ -486,12 +434,8 @@ func TestGetToken(t *testing.T) {
|
|||||||
|
|
||||||
// Set both TOKEN and TOKEN_FILE
|
// Set both TOKEN and TOKEN_FILE
|
||||||
envToken := "token-from-env"
|
envToken := "token-from-env"
|
||||||
os.Setenv("TOKEN", envToken)
|
t.Setenv("TOKEN", envToken)
|
||||||
os.Setenv("TOKEN_FILE", tokenFile.Name())
|
t.Setenv("TOKEN_FILE", tokenFile.Name())
|
||||||
defer func() {
|
|
||||||
os.Unsetenv("TOKEN")
|
|
||||||
os.Unsetenv("TOKEN_FILE")
|
|
||||||
}()
|
|
||||||
|
|
||||||
token, err := getToken()
|
token, err := getToken()
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
@@ -499,7 +443,10 @@ func TestGetToken(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("error when neither TOKEN nor TOKEN_FILE is set", func(t *testing.T) {
|
t.Run("error when neither TOKEN nor TOKEN_FILE is set", func(t *testing.T) {
|
||||||
unsetEnvVars()
|
t.Setenv("BESZEL_AGENT_TOKEN", "")
|
||||||
|
t.Setenv("TOKEN", "")
|
||||||
|
t.Setenv("BESZEL_AGENT_TOKEN_FILE", "")
|
||||||
|
t.Setenv("TOKEN_FILE", "")
|
||||||
|
|
||||||
token, err := getToken()
|
token, err := getToken()
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
@@ -508,11 +455,8 @@ func TestGetToken(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("error when TOKEN_FILE points to non-existent file", func(t *testing.T) {
|
t.Run("error when TOKEN_FILE points to non-existent file", func(t *testing.T) {
|
||||||
unsetEnvVars()
|
|
||||||
|
|
||||||
// Set TOKEN_FILE to a non-existent file
|
// Set TOKEN_FILE to a non-existent file
|
||||||
os.Setenv("TOKEN_FILE", "/non/existent/file.txt")
|
t.Setenv("TOKEN_FILE", "/non/existent/file.txt")
|
||||||
defer os.Unsetenv("TOKEN_FILE")
|
|
||||||
|
|
||||||
token, err := getToken()
|
token, err := getToken()
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
@@ -521,8 +465,6 @@ func TestGetToken(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("handles empty token file", func(t *testing.T) {
|
t.Run("handles empty token file", func(t *testing.T) {
|
||||||
unsetEnvVars()
|
|
||||||
|
|
||||||
// Create an empty token file
|
// Create an empty token file
|
||||||
tokenFile, err := os.CreateTemp("", "token-test-*.txt")
|
tokenFile, err := os.CreateTemp("", "token-test-*.txt")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -530,8 +472,7 @@ func TestGetToken(t *testing.T) {
|
|||||||
tokenFile.Close()
|
tokenFile.Close()
|
||||||
|
|
||||||
// Set TOKEN_FILE env var
|
// Set TOKEN_FILE env var
|
||||||
os.Setenv("TOKEN_FILE", tokenFile.Name())
|
t.Setenv("TOKEN_FILE", tokenFile.Name())
|
||||||
defer os.Unsetenv("TOKEN_FILE")
|
|
||||||
|
|
||||||
token, err := getToken()
|
token, err := getToken()
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
@@ -539,8 +480,6 @@ func TestGetToken(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("strips whitespace from TOKEN_FILE", func(t *testing.T) {
|
t.Run("strips whitespace from TOKEN_FILE", func(t *testing.T) {
|
||||||
unsetEnvVars()
|
|
||||||
|
|
||||||
tokenWithWhitespace := " test-token-with-whitespace \n\t"
|
tokenWithWhitespace := " test-token-with-whitespace \n\t"
|
||||||
expectedToken := "test-token-with-whitespace"
|
expectedToken := "test-token-with-whitespace"
|
||||||
tokenFile, err := os.CreateTemp("", "token-test-*.txt")
|
tokenFile, err := os.CreateTemp("", "token-test-*.txt")
|
||||||
@@ -551,8 +490,7 @@ func TestGetToken(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
tokenFile.Close()
|
tokenFile.Close()
|
||||||
|
|
||||||
os.Setenv("TOKEN_FILE", tokenFile.Name())
|
t.Setenv("TOKEN_FILE", tokenFile.Name())
|
||||||
defer os.Unsetenv("TOKEN_FILE")
|
|
||||||
|
|
||||||
token, err := getToken()
|
token, err := getToken()
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/agent/health"
|
"github.com/henrygd/beszel/agent/health"
|
||||||
|
"github.com/henrygd/beszel/agent/utils"
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -91,8 +95,8 @@ func (c *ConnectionManager) Start(serverOptions ServerOptions) error {
|
|||||||
c.eventChan = make(chan ConnectionEvent, 1)
|
c.eventChan = make(chan ConnectionEvent, 1)
|
||||||
|
|
||||||
// signal handling for shutdown
|
// signal handling for shutdown
|
||||||
sigChan := make(chan os.Signal, 1)
|
sigCtx, stopSignals := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
defer stopSignals()
|
||||||
|
|
||||||
c.startWsTicker()
|
c.startWsTicker()
|
||||||
c.connect()
|
c.connect()
|
||||||
@@ -109,13 +113,36 @@ func (c *ConnectionManager) Start(serverOptions ServerOptions) error {
|
|||||||
_ = c.startWebSocketConnection()
|
_ = c.startWebSocketConnection()
|
||||||
case <-healthTicker:
|
case <-healthTicker:
|
||||||
_ = health.Update()
|
_ = health.Update()
|
||||||
case <-sigChan:
|
case <-sigCtx.Done():
|
||||||
slog.Info("Shutting down")
|
slog.Info("Shutting down", "cause", context.Cause(sigCtx))
|
||||||
|
return c.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// stop does not stop the connection manager itself, just any active connections. The manager will attempt to reconnect after stopping, so this should only be called immediately before shutting down the entire agent.
|
||||||
|
//
|
||||||
|
// If we need or want to expose a graceful Stop method in the future, do something like this to actually stop the manager:
|
||||||
|
//
|
||||||
|
// func (c *ConnectionManager) Start(serverOptions ServerOptions) error {
|
||||||
|
// ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
// c.cancel = cancel
|
||||||
|
//
|
||||||
|
// for {
|
||||||
|
// select {
|
||||||
|
// case <-ctx.Done():
|
||||||
|
// return c.stop()
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// func (c *ConnectionManager) Stop() {
|
||||||
|
// c.cancel()
|
||||||
|
// }
|
||||||
|
func (c *ConnectionManager) stop() error {
|
||||||
_ = c.agent.StopServer()
|
_ = c.agent.StopServer()
|
||||||
c.closeWebSocket()
|
c.closeWebSocket()
|
||||||
return health.CleanUp()
|
return health.CleanUp()
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleEvent processes connection events and updates the connection state accordingly.
|
// handleEvent processes connection events and updates the connection state accordingly.
|
||||||
@@ -185,10 +212,17 @@ func (c *ConnectionManager) connect() {
|
|||||||
|
|
||||||
// Try WebSocket first, if it fails, start SSH server
|
// Try WebSocket first, if it fails, start SSH server
|
||||||
err := c.startWebSocketConnection()
|
err := c.startWebSocketConnection()
|
||||||
if err != nil && c.State == Disconnected {
|
if err != nil {
|
||||||
|
if shouldExitOnErr(err) {
|
||||||
|
time.Sleep(2 * time.Second) // prevent tight restart loop
|
||||||
|
_ = c.stop()
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if c.State == Disconnected {
|
||||||
c.startSSHServer()
|
c.startSSHServer()
|
||||||
c.startWsTicker()
|
c.startWsTicker()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// startWebSocketConnection attempts to establish a WebSocket connection to the hub.
|
// startWebSocketConnection attempts to establish a WebSocket connection to the hub.
|
||||||
@@ -224,3 +258,14 @@ func (c *ConnectionManager) closeWebSocket() {
|
|||||||
c.wsClient.Close()
|
c.wsClient.Close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// shouldExitOnErr checks if the error is a DNS resolution failure and if the
|
||||||
|
// EXIT_ON_DNS_ERROR env var is set. https://github.com/henrygd/beszel/issues/1924.
|
||||||
|
func shouldExitOnErr(err error) bool {
|
||||||
|
if val, _ := utils.GetEnv("EXIT_ON_DNS_ERROR"); val == "true" {
|
||||||
|
if opErr, ok := errors.AsType[*net.OpError](err); ok {
|
||||||
|
return strings.Contains(opErr.Err.Error(), "lookup")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
//go:build testing
|
//go:build testing
|
||||||
// +build testing
|
|
||||||
|
|
||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/ed25519"
|
"crypto/ed25519"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -184,10 +183,6 @@ func TestConnectionManager_TickerManagement(t *testing.T) {
|
|||||||
|
|
||||||
// TestConnectionManager_WebSocketConnectionFlow tests WebSocket connection logic
|
// TestConnectionManager_WebSocketConnectionFlow tests WebSocket connection logic
|
||||||
func TestConnectionManager_WebSocketConnectionFlow(t *testing.T) {
|
func TestConnectionManager_WebSocketConnectionFlow(t *testing.T) {
|
||||||
if testing.Short() {
|
|
||||||
t.Skip("Skipping WebSocket connection test in short mode")
|
|
||||||
}
|
|
||||||
|
|
||||||
agent := createTestAgent(t)
|
agent := createTestAgent(t)
|
||||||
cm := agent.connectionManager
|
cm := agent.connectionManager
|
||||||
|
|
||||||
@@ -197,19 +192,18 @@ func TestConnectionManager_WebSocketConnectionFlow(t *testing.T) {
|
|||||||
assert.Equal(t, Disconnected, cm.State, "State should remain Disconnected after failed connection")
|
assert.Equal(t, Disconnected, cm.State, "State should remain Disconnected after failed connection")
|
||||||
|
|
||||||
// Test with invalid URL
|
// Test with invalid URL
|
||||||
os.Setenv("BESZEL_AGENT_HUB_URL", "invalid-url")
|
t.Setenv("BESZEL_AGENT_HUB_URL", "1,33%")
|
||||||
os.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
t.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
||||||
defer func() {
|
|
||||||
os.Unsetenv("BESZEL_AGENT_HUB_URL")
|
|
||||||
os.Unsetenv("BESZEL_AGENT_TOKEN")
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Test with missing token
|
|
||||||
os.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080")
|
|
||||||
os.Unsetenv("BESZEL_AGENT_TOKEN")
|
|
||||||
|
|
||||||
_, err2 := newWebSocketClient(agent)
|
_, err2 := newWebSocketClient(agent)
|
||||||
assert.Error(t, err2, "WebSocket client creation should fail without token")
|
assert.Error(t, err2, "WebSocket client creation should fail with invalid URL")
|
||||||
|
|
||||||
|
// Test with missing token
|
||||||
|
t.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080")
|
||||||
|
t.Setenv("BESZEL_AGENT_TOKEN", "")
|
||||||
|
|
||||||
|
_, err3 := newWebSocketClient(agent)
|
||||||
|
assert.Error(t, err3, "WebSocket client creation should fail without token")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestConnectionManager_ReconnectionLogic tests reconnection prevention logic
|
// TestConnectionManager_ReconnectionLogic tests reconnection prevention logic
|
||||||
@@ -235,12 +229,8 @@ func TestConnectionManager_ConnectWithRateLimit(t *testing.T) {
|
|||||||
cm := agent.connectionManager
|
cm := agent.connectionManager
|
||||||
|
|
||||||
// Set up environment for WebSocket client creation
|
// Set up environment for WebSocket client creation
|
||||||
os.Setenv("BESZEL_AGENT_HUB_URL", "ws://localhost:8080")
|
t.Setenv("BESZEL_AGENT_HUB_URL", "ws://localhost:8080")
|
||||||
os.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
t.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
||||||
defer func() {
|
|
||||||
os.Unsetenv("BESZEL_AGENT_HUB_URL")
|
|
||||||
os.Unsetenv("BESZEL_AGENT_TOKEN")
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Create WebSocket client
|
// Create WebSocket client
|
||||||
wsClient, err := newWebSocketClient(agent)
|
wsClient, err := newWebSocketClient(agent)
|
||||||
@@ -286,12 +276,8 @@ func TestConnectionManager_CloseWebSocket(t *testing.T) {
|
|||||||
}, "Should not panic when closing nil WebSocket client")
|
}, "Should not panic when closing nil WebSocket client")
|
||||||
|
|
||||||
// Set up environment and create WebSocket client
|
// Set up environment and create WebSocket client
|
||||||
os.Setenv("BESZEL_AGENT_HUB_URL", "ws://localhost:8080")
|
t.Setenv("BESZEL_AGENT_HUB_URL", "ws://localhost:8080")
|
||||||
os.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
t.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
||||||
defer func() {
|
|
||||||
os.Unsetenv("BESZEL_AGENT_HUB_URL")
|
|
||||||
os.Unsetenv("BESZEL_AGENT_TOKEN")
|
|
||||||
}()
|
|
||||||
|
|
||||||
wsClient, err := newWebSocketClient(agent)
|
wsClient, err := newWebSocketClient(agent)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -313,3 +299,65 @@ func TestConnectionManager_ConnectFlow(t *testing.T) {
|
|||||||
cm.connect()
|
cm.connect()
|
||||||
}, "Connect should not panic without WebSocket client")
|
}, "Connect should not panic without WebSocket client")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestShouldExitOnErr(t *testing.T) {
|
||||||
|
createDialErr := func(msg string) error {
|
||||||
|
return &net.OpError{
|
||||||
|
Op: "dial",
|
||||||
|
Net: "tcp",
|
||||||
|
Err: errors.New(msg),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
envValue string
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no env var",
|
||||||
|
err: createDialErr("lookup lkahsdfasdf: no such host"),
|
||||||
|
envValue: "",
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "env var false",
|
||||||
|
err: createDialErr("lookup lkahsdfasdf: no such host"),
|
||||||
|
envValue: "false",
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "env var true, matching error",
|
||||||
|
err: createDialErr("lookup lkahsdfasdf: no such host"),
|
||||||
|
envValue: "true",
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "env var true, matching error with extra context",
|
||||||
|
err: createDialErr("lookup beszel.server.lan on [::1]:53: read udp [::1]:44557->[::1]:53: read: connection refused"),
|
||||||
|
envValue: "true",
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "env var true, non-matching error",
|
||||||
|
err: errors.New("connection refused"),
|
||||||
|
envValue: "true",
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "env var true, dial but not lookup",
|
||||||
|
err: createDialErr("connection timeout"),
|
||||||
|
envValue: "true",
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Setenv("EXIT_ON_DNS_ERROR", tt.envValue)
|
||||||
|
result := shouldExitOnErr(tt.err)
|
||||||
|
assert.Equal(t, tt.expected, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
131
agent/cpu.go
Normal file
131
agent/cpu.go
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
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 && len(times) > 0 {
|
||||||
|
lastCpuTimes[60000] = times[0]
|
||||||
|
}
|
||||||
|
if perCoreTimes, err := cpu.Times(true); err == nil && len(perCoreTimes) > 0 {
|
||||||
|
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 := min(len(lastTimes), len(perCoreTimes))
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -6,17 +6,19 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/agent/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
// getDataDir returns the path to the data directory for the agent and an error
|
// GetDataDir returns the path to the data directory for the agent and an error
|
||||||
// if the directory is not valid. Attempts to find the optimal data directory if
|
// if the directory is not valid. Attempts to find the optimal data directory if
|
||||||
// no data directories are provided.
|
// no data directories are provided.
|
||||||
func getDataDir(dataDirs ...string) (string, error) {
|
func GetDataDir(dataDirs ...string) (string, error) {
|
||||||
if len(dataDirs) > 0 {
|
if len(dataDirs) > 0 {
|
||||||
return testDataDirs(dataDirs)
|
return testDataDirs(dataDirs)
|
||||||
}
|
}
|
||||||
|
|
||||||
dataDir, _ := GetEnv("DATA_DIR")
|
dataDir, _ := utils.GetEnv("DATA_DIR")
|
||||||
if dataDir != "" {
|
if dataDir != "" {
|
||||||
dataDirs = append(dataDirs, dataDir)
|
dataDirs = append(dataDirs, dataDir)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
//go:build testing
|
//go:build testing
|
||||||
// +build testing
|
|
||||||
|
|
||||||
package agent
|
package agent
|
||||||
|
|
||||||
@@ -17,7 +16,7 @@ func TestGetDataDir(t *testing.T) {
|
|||||||
// Test with explicit dataDir parameter
|
// Test with explicit dataDir parameter
|
||||||
t.Run("explicit data dir", func(t *testing.T) {
|
t.Run("explicit data dir", func(t *testing.T) {
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
result, err := getDataDir(tempDir)
|
result, err := GetDataDir(tempDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, tempDir, result)
|
assert.Equal(t, tempDir, result)
|
||||||
})
|
})
|
||||||
@@ -26,7 +25,7 @@ func TestGetDataDir(t *testing.T) {
|
|||||||
t.Run("explicit data dir - create new", func(t *testing.T) {
|
t.Run("explicit data dir - create new", func(t *testing.T) {
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
newDir := filepath.Join(tempDir, "new-data-dir")
|
newDir := filepath.Join(tempDir, "new-data-dir")
|
||||||
result, err := getDataDir(newDir)
|
result, err := GetDataDir(newDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, newDir, result)
|
assert.Equal(t, newDir, result)
|
||||||
|
|
||||||
@@ -40,19 +39,9 @@ func TestGetDataDir(t *testing.T) {
|
|||||||
t.Run("DATA_DIR environment variable", func(t *testing.T) {
|
t.Run("DATA_DIR environment variable", func(t *testing.T) {
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
// Set environment variable
|
t.Setenv("BESZEL_AGENT_DATA_DIR", tempDir)
|
||||||
oldValue := os.Getenv("DATA_DIR")
|
|
||||||
defer func() {
|
|
||||||
if oldValue == "" {
|
|
||||||
os.Unsetenv("BESZEL_AGENT_DATA_DIR")
|
|
||||||
} else {
|
|
||||||
os.Setenv("BESZEL_AGENT_DATA_DIR", oldValue)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
os.Setenv("BESZEL_AGENT_DATA_DIR", tempDir)
|
result, err := GetDataDir()
|
||||||
|
|
||||||
result, err := getDataDir()
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, tempDir, result)
|
assert.Equal(t, tempDir, result)
|
||||||
})
|
})
|
||||||
@@ -60,26 +49,15 @@ func TestGetDataDir(t *testing.T) {
|
|||||||
// Test with invalid explicit dataDir
|
// Test with invalid explicit dataDir
|
||||||
t.Run("invalid explicit data dir", func(t *testing.T) {
|
t.Run("invalid explicit data dir", func(t *testing.T) {
|
||||||
invalidPath := "/invalid/path/that/cannot/be/created"
|
invalidPath := "/invalid/path/that/cannot/be/created"
|
||||||
_, err := getDataDir(invalidPath)
|
_, err := GetDataDir(invalidPath)
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Test fallback behavior (empty dataDir, no env var)
|
// Test fallback behavior (empty dataDir, no env var)
|
||||||
t.Run("fallback to default directories", func(t *testing.T) {
|
t.Run("fallback to default directories", func(t *testing.T) {
|
||||||
// Clear DATA_DIR environment variable
|
|
||||||
oldValue := os.Getenv("DATA_DIR")
|
|
||||||
defer func() {
|
|
||||||
if oldValue == "" {
|
|
||||||
os.Unsetenv("DATA_DIR")
|
|
||||||
} else {
|
|
||||||
os.Setenv("DATA_DIR", oldValue)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
os.Unsetenv("DATA_DIR")
|
|
||||||
|
|
||||||
// This will try platform-specific defaults, which may or may not work
|
// This will try platform-specific defaults, which may or may not work
|
||||||
// We're mainly testing that it doesn't panic and returns some result
|
// We're mainly testing that it doesn't panic and returns some result
|
||||||
result, err := getDataDir()
|
result, err := GetDataDir()
|
||||||
// We don't assert success/failure here since it depends on system permissions
|
// We don't assert success/failure here since it depends on system permissions
|
||||||
// Just verify we get a string result if no error
|
// Just verify we get a string result if no error
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
778
agent/disk.go
778
agent/disk.go
@@ -1,6 +1,7 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -8,172 +9,531 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/agent/utils"
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
|
||||||
"github.com/shirou/gopsutil/v4/disk"
|
"github.com/shirou/gopsutil/v4/disk"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// fsRegistrationContext holds the shared lookup state needed to resolve a
|
||||||
|
// filesystem into the tracked fsStats key and metadata.
|
||||||
|
type fsRegistrationContext struct {
|
||||||
|
filesystem string // value of optional FILESYSTEM env var
|
||||||
|
isWindows bool
|
||||||
|
efPath string // path to extra filesystems (default "/extra-filesystems")
|
||||||
|
diskIoCounters map[string]disk.IOCountersStat
|
||||||
|
}
|
||||||
|
|
||||||
|
// diskDiscovery groups the transient state for a single initializeDiskInfo run so
|
||||||
|
// helper methods can share the same partitions, mount paths, and lookup functions
|
||||||
|
type diskDiscovery struct {
|
||||||
|
agent *Agent
|
||||||
|
rootMountPoint string
|
||||||
|
partitions []disk.PartitionStat
|
||||||
|
usageFn func(string) (*disk.UsageStat, error)
|
||||||
|
ctx fsRegistrationContext
|
||||||
|
}
|
||||||
|
|
||||||
|
// prevDisk stores previous per-device disk counters for a given cache interval
|
||||||
|
type prevDisk struct {
|
||||||
|
readBytes uint64
|
||||||
|
writeBytes uint64
|
||||||
|
readTime uint64 // cumulative ms spent on reads (from ReadTime)
|
||||||
|
writeTime uint64 // cumulative ms spent on writes (from WriteTime)
|
||||||
|
ioTime uint64 // cumulative ms spent doing I/O (from IoTime)
|
||||||
|
weightedIO uint64 // cumulative weighted ms (queue-depth × ms, from WeightedIO)
|
||||||
|
readCount uint64 // cumulative read operation count
|
||||||
|
writeCount uint64 // cumulative write operation count
|
||||||
|
at time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// prevDiskFromCounter creates a prevDisk snapshot from a disk.IOCountersStat at time t.
|
||||||
|
func prevDiskFromCounter(d disk.IOCountersStat, t time.Time) prevDisk {
|
||||||
|
return prevDisk{
|
||||||
|
readBytes: d.ReadBytes,
|
||||||
|
writeBytes: d.WriteBytes,
|
||||||
|
readTime: d.ReadTime,
|
||||||
|
writeTime: d.WriteTime,
|
||||||
|
ioTime: d.IoTime,
|
||||||
|
weightedIO: d.WeightedIO,
|
||||||
|
readCount: d.ReadCount,
|
||||||
|
writeCount: d.WriteCount,
|
||||||
|
at: t,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// extraFilesystemPartitionInfo derives the I/O device and optional display name
|
||||||
|
// for a mounted /extra-filesystems partition. Prefer the partition device reported
|
||||||
|
// by the system and only use the folder name for custom naming metadata.
|
||||||
|
func extraFilesystemPartitionInfo(p disk.PartitionStat) (device, customName string) {
|
||||||
|
device = strings.TrimSpace(p.Device)
|
||||||
|
folderDevice, customName := parseFilesystemEntry(filepath.Base(p.Mountpoint))
|
||||||
|
if device == "" {
|
||||||
|
device = folderDevice
|
||||||
|
}
|
||||||
|
return device, customName
|
||||||
|
}
|
||||||
|
|
||||||
|
func isDockerSpecialMountpoint(mountpoint string) bool {
|
||||||
|
switch mountpoint {
|
||||||
|
case "/etc/hosts", "/etc/resolv.conf", "/etc/hostname":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerFilesystemStats resolves the tracked key and stats payload for a
|
||||||
|
// filesystem before it is inserted into fsStats.
|
||||||
|
func registerFilesystemStats(existing map[string]*system.FsStats, device, mountpoint string, root bool, customName string, ctx fsRegistrationContext) (string, *system.FsStats, bool) {
|
||||||
|
key := device
|
||||||
|
if !ctx.isWindows {
|
||||||
|
key = filepath.Base(device)
|
||||||
|
}
|
||||||
|
|
||||||
|
if root {
|
||||||
|
// Try to map root device to a diskIoCounters entry. First checks for an
|
||||||
|
// exact key match, then uses findIoDevice for normalized / prefix-based
|
||||||
|
// matching (e.g. nda0p2 -> nda0), and finally falls back to FILESYSTEM.
|
||||||
|
if _, ioMatch := ctx.diskIoCounters[key]; !ioMatch {
|
||||||
|
if matchedKey, match := findIoDevice(key, ctx.diskIoCounters); match {
|
||||||
|
key = matchedKey
|
||||||
|
} else if ctx.filesystem != "" {
|
||||||
|
if matchedKey, match := findIoDevice(ctx.filesystem, ctx.diskIoCounters); match {
|
||||||
|
key = matchedKey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, ioMatch = ctx.diskIoCounters[key]; !ioMatch {
|
||||||
|
slog.Warn("Root I/O unmapped; set FILESYSTEM", "device", device, "mountpoint", mountpoint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Check if non-root has diskstats and prefer the folder device for
|
||||||
|
// /extra-filesystems mounts when the discovered partition device is a
|
||||||
|
// mapper path (e.g. luks UUID) that obscures the underlying block device.
|
||||||
|
if _, ioMatch := ctx.diskIoCounters[key]; !ioMatch {
|
||||||
|
if strings.HasPrefix(mountpoint, ctx.efPath) {
|
||||||
|
folderDevice, _ := parseFilesystemEntry(filepath.Base(mountpoint))
|
||||||
|
if folderDevice != "" {
|
||||||
|
if matchedKey, match := findIoDevice(folderDevice, ctx.diskIoCounters); match {
|
||||||
|
key = matchedKey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, ioMatch = ctx.diskIoCounters[key]; !ioMatch {
|
||||||
|
if matchedKey, match := findIoDevice(key, ctx.diskIoCounters); match {
|
||||||
|
key = matchedKey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, exists := existing[key]; exists {
|
||||||
|
return "", nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
fsStats := &system.FsStats{Root: root, Mountpoint: mountpoint}
|
||||||
|
if customName != "" {
|
||||||
|
fsStats.Name = customName
|
||||||
|
}
|
||||||
|
return key, fsStats, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// addFsStat inserts a discovered filesystem if it resolves to a new tracking
|
||||||
|
// key. The key selection itself lives in buildFsStatRegistration so that logic
|
||||||
|
// can stay directly unit-tested.
|
||||||
|
func (d *diskDiscovery) addFsStat(device, mountpoint string, root bool, customName string) {
|
||||||
|
key, fsStats, ok := registerFilesystemStats(d.agent.fsStats, device, mountpoint, root, customName, d.ctx)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d.agent.fsStats[key] = fsStats
|
||||||
|
name := key
|
||||||
|
if customName != "" {
|
||||||
|
name = customName
|
||||||
|
}
|
||||||
|
slog.Info("Detected disk", "name", name, "device", device, "mount", mountpoint, "io", key, "root", root)
|
||||||
|
}
|
||||||
|
|
||||||
|
// addConfiguredRootFs resolves FILESYSTEM against partitions first, then falls
|
||||||
|
// back to direct diskstats matching for setups like ZFS where partitions do not
|
||||||
|
// expose the physical device name.
|
||||||
|
func (d *diskDiscovery) addConfiguredRootFs() bool {
|
||||||
|
if d.ctx.filesystem == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, p := range d.partitions {
|
||||||
|
if filesystemMatchesPartitionSetting(d.ctx.filesystem, p) {
|
||||||
|
d.addFsStat(p.Device, p.Mountpoint, true, "")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FILESYSTEM may name a physical disk absent from partitions (e.g. ZFS lists
|
||||||
|
// dataset paths like zroot/ROOT/default, not block devices).
|
||||||
|
if ioKey, match := findIoDevice(d.ctx.filesystem, d.ctx.diskIoCounters); match {
|
||||||
|
d.agent.fsStats[ioKey] = &system.FsStats{Root: true, Mountpoint: d.rootMountPoint}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Warn("Partition details not found", "filesystem", d.ctx.filesystem)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func isRootFallbackPartition(p disk.PartitionStat, rootMountPoint string) bool {
|
||||||
|
return p.Mountpoint == rootMountPoint ||
|
||||||
|
(isDockerSpecialMountpoint(p.Mountpoint) && strings.HasPrefix(p.Device, "/dev"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// addPartitionRootFs handles the non-configured root fallback path when a
|
||||||
|
// partition looks like the active root mount but still needs translating to an
|
||||||
|
// I/O device key.
|
||||||
|
func (d *diskDiscovery) addPartitionRootFs(device, mountpoint string) bool {
|
||||||
|
fs, match := findIoDevice(filepath.Base(device), d.ctx.diskIoCounters)
|
||||||
|
if !match {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// The resolved I/O device is already known here, so use it directly to avoid
|
||||||
|
// a second fallback search inside buildFsStatRegistration.
|
||||||
|
d.addFsStat(fs, mountpoint, true, "")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// addLastResortRootFs is only used when neither FILESYSTEM nor partition-based
|
||||||
|
// heuristics can identify root, so it picks the busiest I/O device as a final
|
||||||
|
// fallback and preserves the root mountpoint for usage collection.
|
||||||
|
func (d *diskDiscovery) addLastResortRootFs() {
|
||||||
|
rootKey := mostActiveIoDevice(d.ctx.diskIoCounters)
|
||||||
|
if rootKey != "" {
|
||||||
|
slog.Warn("Using most active device for root I/O; set FILESYSTEM to override", "device", rootKey)
|
||||||
|
} else {
|
||||||
|
rootKey = filepath.Base(d.rootMountPoint)
|
||||||
|
if _, exists := d.agent.fsStats[rootKey]; exists {
|
||||||
|
rootKey = "root"
|
||||||
|
}
|
||||||
|
slog.Warn("Root I/O device not detected; set FILESYSTEM to override")
|
||||||
|
}
|
||||||
|
d.agent.fsStats[rootKey] = &system.FsStats{Root: true, Mountpoint: d.rootMountPoint}
|
||||||
|
}
|
||||||
|
|
||||||
|
// findPartitionByFilesystemSetting matches an EXTRA_FILESYSTEMS entry against a
|
||||||
|
// discovered partition either by mountpoint or by device suffix.
|
||||||
|
func findPartitionByFilesystemSetting(filesystem string, partitions []disk.PartitionStat) (disk.PartitionStat, bool) {
|
||||||
|
for _, p := range partitions {
|
||||||
|
if strings.HasSuffix(p.Device, filesystem) || p.Mountpoint == filesystem {
|
||||||
|
return p, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return disk.PartitionStat{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// addConfiguredExtraFsEntry resolves one EXTRA_FILESYSTEMS entry, preferring a
|
||||||
|
// discovered partition and falling back to any path that disk.Usage accepts.
|
||||||
|
func (d *diskDiscovery) addConfiguredExtraFsEntry(filesystem, customName string) {
|
||||||
|
if p, found := findPartitionByFilesystemSetting(filesystem, d.partitions); found {
|
||||||
|
d.addFsStat(p.Device, p.Mountpoint, false, customName)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := d.usageFn(filesystem); err == nil {
|
||||||
|
d.addFsStat(filepath.Base(filesystem), filesystem, false, customName)
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
slog.Error("Invalid filesystem", "name", filesystem, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// addConfiguredExtraFilesystems parses and registers the comma-separated
|
||||||
|
// EXTRA_FILESYSTEMS env var entries.
|
||||||
|
func (d *diskDiscovery) addConfiguredExtraFilesystems(extraFilesystems string) {
|
||||||
|
for fsEntry := range strings.SplitSeq(extraFilesystems, ",") {
|
||||||
|
filesystem, customName := parseFilesystemEntry(fsEntry)
|
||||||
|
d.addConfiguredExtraFsEntry(filesystem, customName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// addPartitionExtraFs registers partitions mounted under /extra-filesystems so
|
||||||
|
// their display names can come from the folder name while their I/O keys still
|
||||||
|
// prefer the underlying partition device. Only direct children are matched to
|
||||||
|
// avoid registering nested virtual mounts (e.g. /proc, /sys) that are returned by
|
||||||
|
// disk.Partitions(true) when the host root is bind-mounted in /extra-filesystems.
|
||||||
|
func (d *diskDiscovery) addPartitionExtraFs(p disk.PartitionStat) {
|
||||||
|
if filepath.Dir(p.Mountpoint) != d.ctx.efPath {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
device, customName := extraFilesystemPartitionInfo(p)
|
||||||
|
d.addFsStat(device, p.Mountpoint, false, customName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// addExtraFilesystemFolders handles bare directories under /extra-filesystems
|
||||||
|
// that may not appear in partition discovery, while skipping mountpoints that
|
||||||
|
// were already registered from higher-fidelity sources.
|
||||||
|
func (d *diskDiscovery) addExtraFilesystemFolders(folderNames []string) {
|
||||||
|
existingMountpoints := make(map[string]bool, len(d.agent.fsStats))
|
||||||
|
for _, stats := range d.agent.fsStats {
|
||||||
|
existingMountpoints[stats.Mountpoint] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, folderName := range folderNames {
|
||||||
|
mountpoint := filepath.Join(d.ctx.efPath, folderName)
|
||||||
|
slog.Debug("/extra-filesystems", "mountpoint", mountpoint)
|
||||||
|
if existingMountpoints[mountpoint] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
device, customName := parseFilesystemEntry(folderName)
|
||||||
|
d.addFsStat(device, mountpoint, false, 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, _ := utils.GetEnv("FILESYSTEM")
|
||||||
efPath := "/extra-filesystems"
|
|
||||||
hasRoot := false
|
hasRoot := false
|
||||||
|
isWindows := runtime.GOOS == "windows"
|
||||||
|
|
||||||
partitions, err := disk.Partitions(false)
|
partitions, err := disk.PartitionsWithContext(context.Background(), true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Error getting disk partitions", "err", err)
|
slog.Error("Error getting disk partitions", "err", err)
|
||||||
}
|
}
|
||||||
slog.Debug("Disk", "partitions", partitions)
|
slog.Debug("Disk", "partitions", partitions)
|
||||||
|
|
||||||
// ioContext := context.WithValue(a.sensorsContext,
|
// trim trailing backslash for Windows devices (#1361)
|
||||||
// common.EnvKey, common.EnvMap{common.HostProcEnvKey: "/tmp/testproc"},
|
if isWindows {
|
||||||
// )
|
for i, p := range partitions {
|
||||||
// diskIoCounters, err := disk.IOCountersWithContext(ioContext)
|
partitions[i].Device = strings.TrimSuffix(p.Device, "\\")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
diskIoCounters, err := disk.IOCounters()
|
diskIoCounters, err := disk.IOCounters()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Error getting diskstats", "err", err)
|
slog.Error("Error getting diskstats", "err", err)
|
||||||
}
|
}
|
||||||
slog.Debug("Disk I/O", "diskstats", diskIoCounters)
|
slog.Debug("Disk I/O", "diskstats", diskIoCounters)
|
||||||
|
ctx := fsRegistrationContext{
|
||||||
// Helper function to add a filesystem to fsStats if it doesn't exist
|
filesystem: filesystem,
|
||||||
addFsStat := func(device, mountpoint string, root bool) {
|
isWindows: isWindows,
|
||||||
var key string
|
diskIoCounters: diskIoCounters,
|
||||||
if runtime.GOOS == "windows" {
|
efPath: "/extra-filesystems",
|
||||||
key = device
|
|
||||||
} else {
|
|
||||||
key = filepath.Base(device)
|
|
||||||
}
|
|
||||||
var ioMatch bool
|
|
||||||
if _, exists := a.fsStats[key]; !exists {
|
|
||||||
if root {
|
|
||||||
slog.Info("Detected root device", "name", key)
|
|
||||||
// Check if root device is in /proc/diskstats, use fallback if not
|
|
||||||
if _, ioMatch = diskIoCounters[key]; !ioMatch {
|
|
||||||
key, ioMatch = findIoDevice(filesystem, diskIoCounters, a.fsStats)
|
|
||||||
if !ioMatch {
|
|
||||||
slog.Info("Using I/O fallback", "device", device, "mountpoint", mountpoint, "fallback", key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Check if non-root has diskstats and fall back to folder name if not
|
|
||||||
// Scenario: device is encrypted and named luks-2bcb02be-999d-4417-8d18-5c61e660fb6e - not in /proc/diskstats.
|
|
||||||
// However, the device can be specified by mounting folder from luks device at /extra-filesystems/sda1
|
|
||||||
if _, ioMatch = diskIoCounters[key]; !ioMatch {
|
|
||||||
efBase := filepath.Base(mountpoint)
|
|
||||||
if _, ioMatch = diskIoCounters[efBase]; ioMatch {
|
|
||||||
key = efBase
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
a.fsStats[key] = &system.FsStats{Root: root, Mountpoint: mountpoint}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use FILESYSTEM env var to find root filesystem
|
// Get the appropriate root mount point for this system
|
||||||
if filesystem != "" {
|
discovery := diskDiscovery{
|
||||||
for _, p := range partitions {
|
agent: a,
|
||||||
if strings.HasSuffix(p.Device, filesystem) || p.Mountpoint == filesystem {
|
rootMountPoint: a.getRootMountPoint(),
|
||||||
addFsStat(p.Device, p.Mountpoint, true)
|
partitions: partitions,
|
||||||
hasRoot = true
|
usageFn: disk.Usage,
|
||||||
break
|
ctx: ctx,
|
||||||
}
|
|
||||||
}
|
|
||||||
if !hasRoot {
|
|
||||||
slog.Warn("Partition details not found", "filesystem", filesystem)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hasRoot = discovery.addConfiguredRootFs()
|
||||||
|
|
||||||
// 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 := utils.GetEnv("EXTRA_FILESYSTEMS"); exists {
|
||||||
for _, fs := range strings.Split(extraFilesystems, ",") {
|
discovery.addConfiguredExtraFilesystems(extraFilesystems)
|
||||||
found := false
|
|
||||||
for _, p := range partitions {
|
|
||||||
if strings.HasSuffix(p.Device, fs) || p.Mountpoint == fs {
|
|
||||||
addFsStat(p.Device, p.Mountpoint, false)
|
|
||||||
found = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// if not in partitions, test if we can get disk usage
|
|
||||||
if !found {
|
|
||||||
if _, err := disk.Usage(fs); err == nil {
|
|
||||||
addFsStat(filepath.Base(fs), fs, false)
|
|
||||||
} else {
|
|
||||||
slog.Error("Invalid filesystem", "name", fs, "err", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process partitions for various mount points
|
// Process partitions for various mount points
|
||||||
for _, p := range partitions {
|
for _, p := range partitions {
|
||||||
// fmt.Println(p.Device, p.Mountpoint)
|
if !hasRoot && isRootFallbackPartition(p, discovery.rootMountPoint) {
|
||||||
// Binary root fallback or docker root fallback
|
hasRoot = discovery.addPartitionRootFs(p.Device, p.Mountpoint)
|
||||||
if !hasRoot && (p.Mountpoint == "/" || (p.Mountpoint == "/etc/hosts" && strings.HasPrefix(p.Device, "/dev"))) {
|
|
||||||
fs, match := findIoDevice(filepath.Base(p.Device), diskIoCounters, a.fsStats)
|
|
||||||
if match {
|
|
||||||
addFsStat(fs, p.Mountpoint, true)
|
|
||||||
hasRoot = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if device is in /extra-filesystems
|
|
||||||
if strings.HasPrefix(p.Mountpoint, efPath) {
|
|
||||||
addFsStat(p.Device, p.Mountpoint, false)
|
|
||||||
}
|
}
|
||||||
|
discovery.addPartitionExtraFs(p)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check all folders in /extra-filesystems and add them if not already present
|
// Check all folders in /extra-filesystems and add them if not already present
|
||||||
if folders, err := os.ReadDir(efPath); err == nil {
|
if folders, err := os.ReadDir(discovery.ctx.efPath); err == nil {
|
||||||
existingMountpoints := make(map[string]bool)
|
folderNames := make([]string, 0, len(folders))
|
||||||
for _, stats := range a.fsStats {
|
|
||||||
existingMountpoints[stats.Mountpoint] = true
|
|
||||||
}
|
|
||||||
for _, folder := range folders {
|
for _, folder := range folders {
|
||||||
if folder.IsDir() {
|
if folder.IsDir() {
|
||||||
mountpoint := filepath.Join(efPath, folder.Name())
|
folderNames = append(folderNames, folder.Name())
|
||||||
slog.Debug("/extra-filesystems", "mountpoint", mountpoint)
|
|
||||||
if !existingMountpoints[mountpoint] {
|
|
||||||
addFsStat(folder.Name(), mountpoint, false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
discovery.addExtraFilesystemFolders(folderNames)
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no root filesystem set, use fallback
|
// If no root filesystem set, try the most active I/O device as a last
|
||||||
|
// resort (e.g. ZFS where dataset names are unrelated to disk names).
|
||||||
if !hasRoot {
|
if !hasRoot {
|
||||||
rootDevice, _ := findIoDevice(filepath.Base(filesystem), diskIoCounters, a.fsStats)
|
discovery.addLastResortRootFs()
|
||||||
slog.Info("Root disk", "mountpoint", "/", "io", rootDevice)
|
|
||||||
a.fsStats[rootDevice] = &system.FsStats{Root: true, Mountpoint: "/"}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a.pruneDuplicateRootExtraFilesystems()
|
||||||
a.initializeDiskIoStats(diskIoCounters)
|
a.initializeDiskIoStats(diskIoCounters)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns matching device from /proc/diskstats,
|
// Removes extra filesystems that mirror root usage (https://github.com/henrygd/beszel/issues/1428).
|
||||||
// or the device with the most reads if no match is found.
|
func (a *Agent) pruneDuplicateRootExtraFilesystems() {
|
||||||
// bool is true if a match was found.
|
var rootMountpoint string
|
||||||
func findIoDevice(filesystem string, diskIoCounters map[string]disk.IOCountersStat, fsStats map[string]*system.FsStats) (string, bool) {
|
for _, stats := range a.fsStats {
|
||||||
var maxReadBytes uint64
|
if stats != nil && stats.Root {
|
||||||
maxReadDevice := "/"
|
rootMountpoint = stats.Mountpoint
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rootMountpoint == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rootUsage, err := disk.Usage(rootMountpoint)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for name, stats := range a.fsStats {
|
||||||
|
if stats == nil || stats.Root {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
extraUsage, err := disk.Usage(stats.Mountpoint)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if hasSameDiskUsage(rootUsage, extraUsage) {
|
||||||
|
slog.Info("Ignoring duplicate FS", "name", name, "mount", stats.Mountpoint)
|
||||||
|
delete(a.fsStats, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasSameDiskUsage compares root/extra usage with a small byte tolerance.
|
||||||
|
func hasSameDiskUsage(a, b *disk.UsageStat) bool {
|
||||||
|
if a == nil || b == nil || a.Total == 0 || b.Total == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Allow minor drift between sequential disk usage calls.
|
||||||
|
const toleranceBytes uint64 = 16 * 1024 * 1024
|
||||||
|
return withinUsageTolerance(a.Total, b.Total, toleranceBytes) &&
|
||||||
|
withinUsageTolerance(a.Used, b.Used, toleranceBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// withinUsageTolerance reports whether two byte values differ by at most tolerance.
|
||||||
|
func withinUsageTolerance(a, b, tolerance uint64) bool {
|
||||||
|
if a >= b {
|
||||||
|
return a-b <= tolerance
|
||||||
|
}
|
||||||
|
return b-a <= tolerance
|
||||||
|
}
|
||||||
|
|
||||||
|
type ioMatchCandidate struct {
|
||||||
|
name string
|
||||||
|
bytes uint64
|
||||||
|
ops uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
// findIoDevice prefers exact device/label matches, then falls back to a
|
||||||
|
// prefix-related candidate with the highest recent activity.
|
||||||
|
func findIoDevice(filesystem string, diskIoCounters map[string]disk.IOCountersStat) (string, bool) {
|
||||||
|
filesystem = normalizeDeviceName(filesystem)
|
||||||
|
if filesystem == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates := []ioMatchCandidate{}
|
||||||
|
|
||||||
for _, d := range diskIoCounters {
|
for _, d := range diskIoCounters {
|
||||||
if d.Name == filesystem || (d.Label != "" && d.Label == filesystem) {
|
if normalizeDeviceName(d.Name) == filesystem || (d.Label != "" && normalizeDeviceName(d.Label) == filesystem) {
|
||||||
return d.Name, true
|
return d.Name, true
|
||||||
}
|
}
|
||||||
if d.ReadBytes > maxReadBytes {
|
if prefixRelated(normalizeDeviceName(d.Name), filesystem) ||
|
||||||
// don't use if device already exists in fsStats
|
(d.Label != "" && prefixRelated(normalizeDeviceName(d.Label), filesystem)) {
|
||||||
if _, exists := fsStats[d.Name]; !exists {
|
candidates = append(candidates, ioMatchCandidate{
|
||||||
maxReadBytes = d.ReadBytes
|
name: d.Name,
|
||||||
maxReadDevice = d.Name
|
bytes: d.ReadBytes + d.WriteBytes,
|
||||||
|
ops: d.ReadCount + d.WriteCount,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
return "", false
|
||||||
}
|
}
|
||||||
return maxReadDevice, false
|
|
||||||
|
best := candidates[0]
|
||||||
|
for _, c := range candidates[1:] {
|
||||||
|
if c.bytes > best.bytes ||
|
||||||
|
(c.bytes == best.bytes && c.ops > best.ops) ||
|
||||||
|
(c.bytes == best.bytes && c.ops == best.ops && c.name < best.name) {
|
||||||
|
best = c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("Using disk I/O fallback", "requested", filesystem, "selected", best.name)
|
||||||
|
return best.name, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// mostActiveIoDevice returns the device with the highest I/O activity,
|
||||||
|
// or "" if diskIoCounters is empty.
|
||||||
|
func mostActiveIoDevice(diskIoCounters map[string]disk.IOCountersStat) string {
|
||||||
|
var best ioMatchCandidate
|
||||||
|
for _, d := range diskIoCounters {
|
||||||
|
c := ioMatchCandidate{
|
||||||
|
name: d.Name,
|
||||||
|
bytes: d.ReadBytes + d.WriteBytes,
|
||||||
|
ops: d.ReadCount + d.WriteCount,
|
||||||
|
}
|
||||||
|
if best.name == "" || c.bytes > best.bytes ||
|
||||||
|
(c.bytes == best.bytes && c.ops > best.ops) ||
|
||||||
|
(c.bytes == best.bytes && c.ops == best.ops && c.name < best.name) {
|
||||||
|
best = c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best.name
|
||||||
|
}
|
||||||
|
|
||||||
|
// prefixRelated reports whether either identifier is a prefix of the other.
|
||||||
|
func prefixRelated(a, b string) bool {
|
||||||
|
if a == "" || b == "" || a == b {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.HasPrefix(a, b) || strings.HasPrefix(b, a)
|
||||||
|
}
|
||||||
|
|
||||||
|
// filesystemMatchesPartitionSetting checks whether a FILESYSTEM env var value
|
||||||
|
// matches a partition by mountpoint, exact device name, or prefix relationship
|
||||||
|
// (e.g. FILESYSTEM=ada0 matches partition /dev/ada0p2).
|
||||||
|
func filesystemMatchesPartitionSetting(filesystem string, p disk.PartitionStat) bool {
|
||||||
|
filesystem = strings.TrimSpace(filesystem)
|
||||||
|
if filesystem == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if p.Mountpoint == filesystem {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
fsName := normalizeDeviceName(filesystem)
|
||||||
|
partName := normalizeDeviceName(p.Device)
|
||||||
|
if fsName == "" || partName == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if fsName == partName {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return prefixRelated(partName, fsName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeDeviceName canonicalizes device strings for comparisons.
|
||||||
|
func normalizeDeviceName(value string) string {
|
||||||
|
name := filepath.Base(strings.TrimSpace(value))
|
||||||
|
if name == "." {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return name
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sets start values for disk I/O stats.
|
// Sets start values for disk I/O stats.
|
||||||
func (a *Agent) initializeDiskIoStats(diskIoCounters map[string]disk.IOCountersStat) {
|
func (a *Agent) initializeDiskIoStats(diskIoCounters map[string]disk.IOCountersStat) {
|
||||||
|
a.fsNames = a.fsNames[:0]
|
||||||
|
now := time.Now()
|
||||||
for device, stats := range a.fsStats {
|
for device, stats := range a.fsStats {
|
||||||
// skip if not in diskIoCounters
|
// skip if not in diskIoCounters
|
||||||
d, exists := diskIoCounters[device]
|
d, exists := diskIoCounters[device]
|
||||||
@@ -182,10 +542,204 @@ func (a *Agent) initializeDiskIoStats(diskIoCounters map[string]disk.IOCountersS
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// populate initial values
|
// populate initial values
|
||||||
stats.Time = time.Now()
|
stats.Time = now
|
||||||
stats.TotalRead = d.ReadBytes
|
stats.TotalRead = d.ReadBytes
|
||||||
stats.TotalWrite = d.WriteBytes
|
stats.TotalWrite = d.WriteBytes
|
||||||
// add to list of valid io device names
|
// add to list of valid io device names
|
||||||
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 = utils.BytesToGigabytes(d.Total)
|
||||||
|
stats.DiskUsed = utils.BytesToGigabytes(d.Used)
|
||||||
|
if stats.Root {
|
||||||
|
systemStats.DiskTotal = utils.BytesToGigabytes(d.Total)
|
||||||
|
systemStats.DiskUsed = utils.BytesToGigabytes(d.Used)
|
||||||
|
systemStats.DiskPct = utils.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,
|
||||||
|
readTime: d.ReadTime,
|
||||||
|
writeTime: d.WriteTime,
|
||||||
|
ioTime: d.IoTime,
|
||||||
|
weightedIO: d.WeightedIO,
|
||||||
|
readCount: d.ReadCount,
|
||||||
|
writeCount: d.WriteCount,
|
||||||
|
at: stats.Time,
|
||||||
|
}
|
||||||
|
if prev.at.IsZero() {
|
||||||
|
prev = prevDiskFromCounter(d, now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
msElapsed := uint64(now.Sub(prev.at).Milliseconds())
|
||||||
|
|
||||||
|
// Update per-interval snapshot
|
||||||
|
a.diskPrev[cacheTimeMs][name] = prevDiskFromCounter(d, now)
|
||||||
|
|
||||||
|
// Avoid division by zero or clock issues
|
||||||
|
if msElapsed < 100 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
diskIORead := (d.ReadBytes - prev.readBytes) * 1000 / msElapsed
|
||||||
|
diskIOWrite := (d.WriteBytes - prev.writeBytes) * 1000 / msElapsed
|
||||||
|
readMbPerSecond := utils.BytesToMegabytes(float64(diskIORead))
|
||||||
|
writeMbPerSecond := utils.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)
|
||||||
|
// also refresh agent baseline to avoid future negatives
|
||||||
|
a.initializeDiskIoStats(ioCounters)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// These properties are calculated differently on different platforms,
|
||||||
|
// but generally represent cumulative time spent doing reads/writes on the device.
|
||||||
|
// This can surpass 100% if there are multiple concurrent I/O operations.
|
||||||
|
// Linux kernel docs:
|
||||||
|
// This is the total number of milliseconds spent by all reads (as
|
||||||
|
// measured from __make_request() to end_that_request_last()).
|
||||||
|
// https://www.kernel.org/doc/Documentation/iostats.txt (fields 4, 8)
|
||||||
|
diskReadTime := utils.TwoDecimals(float64(d.ReadTime-prev.readTime) / float64(msElapsed) * 100)
|
||||||
|
diskWriteTime := utils.TwoDecimals(float64(d.WriteTime-prev.writeTime) / float64(msElapsed) * 100)
|
||||||
|
|
||||||
|
// I/O utilization %: fraction of wall time the device had any I/O in progress (0-100).
|
||||||
|
diskIoUtilPct := utils.TwoDecimals(float64(d.IoTime-prev.ioTime) / float64(msElapsed) * 100)
|
||||||
|
|
||||||
|
// Weighted I/O: queue-depth weighted I/O time, normalized to interval (can exceed 100%).
|
||||||
|
// Linux kernel field 11: incremented by iops_in_progress × ms_since_last_update.
|
||||||
|
// Used to display queue depth. Multipled by 100 to increase accuracy of digit truncation (divided by 100 in UI).
|
||||||
|
diskWeightedIO := utils.TwoDecimals(float64(d.WeightedIO-prev.weightedIO) / float64(msElapsed) * 100)
|
||||||
|
|
||||||
|
// r_await / w_await: average time per read/write operation in milliseconds.
|
||||||
|
// Equivalent to r_await and w_await in iostat.
|
||||||
|
var rAwait, wAwait float64
|
||||||
|
if deltaReadCount := d.ReadCount - prev.readCount; deltaReadCount > 0 {
|
||||||
|
rAwait = utils.TwoDecimals(float64(d.ReadTime-prev.readTime) / float64(deltaReadCount))
|
||||||
|
}
|
||||||
|
if deltaWriteCount := d.WriteCount - prev.writeCount; deltaWriteCount > 0 {
|
||||||
|
wAwait = utils.TwoDecimals(float64(d.WriteTime-prev.writeTime) / float64(deltaWriteCount))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
stats.DiskIoStats[0] = diskReadTime
|
||||||
|
stats.DiskIoStats[1] = diskWriteTime
|
||||||
|
stats.DiskIoStats[2] = diskIoUtilPct
|
||||||
|
stats.DiskIoStats[3] = rAwait
|
||||||
|
stats.DiskIoStats[4] = wAwait
|
||||||
|
stats.DiskIoStats[5] = diskWeightedIO
|
||||||
|
|
||||||
|
if stats.Root {
|
||||||
|
systemStats.DiskReadPs = stats.DiskReadPs
|
||||||
|
systemStats.DiskWritePs = stats.DiskWritePs
|
||||||
|
systemStats.DiskIO[0] = diskIORead
|
||||||
|
systemStats.DiskIO[1] = diskIOWrite
|
||||||
|
systemStats.DiskIoStats[0] = diskReadTime
|
||||||
|
systemStats.DiskIoStats[1] = diskWriteTime
|
||||||
|
systemStats.DiskIoStats[2] = diskIoUtilPct
|
||||||
|
systemStats.DiskIoStats[3] = rAwait
|
||||||
|
systemStats.DiskIoStats[4] = wAwait
|
||||||
|
systemStats.DiskIoStats[5] = diskWeightedIO
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getRootMountPoint returns the appropriate root mount point for the system.
|
||||||
|
// On Windows it returns the system drive (e.g. "C:").
|
||||||
|
// For immutable systems like Fedora Silverblue, it returns /sysroot instead of /
|
||||||
|
func (a *Agent) getRootMountPoint() string {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
if sd := os.Getenv("SystemDrive"); sd != "" {
|
||||||
|
return sd
|
||||||
|
}
|
||||||
|
return "C:"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 "/"
|
||||||
|
}
|
||||||
|
|||||||
1046
agent/disk_test.go
Normal file
1046
agent/disk_test.go
Normal file
File diff suppressed because it is too large
Load Diff
807
agent/docker.go
807
agent/docker.go
@@ -1,37 +1,86 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"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"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/agent/deltatracker"
|
||||||
|
"github.com/henrygd/beszel/agent/utils"
|
||||||
"github.com/henrygd/beszel/internal/entities/container"
|
"github.com/henrygd/beszel/internal/entities/container"
|
||||||
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
|
||||||
"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\\-_]`)
|
||||||
|
var dockerContainerIDPattern = regexp.MustCompile(`^[a-fA-F0-9]{12,64}$`)
|
||||||
|
|
||||||
|
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 {
|
||||||
|
agent *Agent // Used to propagate system detail changes back to the agent
|
||||||
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)
|
||||||
|
dockerVersionChecked bool // Whether a version probe has completed successfully
|
||||||
isWindows bool // Whether the Docker Engine API is running on Windows
|
isWindows bool // Whether the Docker Engine API is running on Windows
|
||||||
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]
|
||||||
|
lastNetworkReadTime map[uint16]map[string]time.Time // cacheTimeMs -> containerId -> last network read time
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
||||||
@@ -40,6 +89,14 @@ type userAgentRoundTripper struct {
|
|||||||
userAgent string
|
userAgent string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dockerVersionResponse contains the /version fields used for engine checks.
|
||||||
|
type dockerVersionResponse struct {
|
||||||
|
Version string `json:"Version"`
|
||||||
|
Components []struct {
|
||||||
|
Name string `json:"Name"`
|
||||||
|
} `json:"Components"`
|
||||||
|
}
|
||||||
|
|
||||||
// RoundTrip implements the http.RoundTripper interface
|
// RoundTrip implements the http.RoundTripper interface
|
||||||
func (u *userAgentRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
func (u *userAgentRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
req.Header.Set("User-Agent", u.userAgent)
|
req.Header.Set("User-Agent", u.userAgent)
|
||||||
@@ -62,8 +119,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
|
||||||
@@ -74,7 +144,14 @@ func (dm *dockerManager) getDockerStats() ([]*container.Stats, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
dm.isWindows = strings.Contains(resp.Header.Get("Server"), "windows")
|
// Detect Podman and Windows from Server header
|
||||||
|
serverHeader := resp.Header.Get("Server")
|
||||||
|
if !dm.usingPodman && detectPodmanFromHeader(serverHeader) {
|
||||||
|
dm.setIsPodman()
|
||||||
|
}
|
||||||
|
dm.isWindows = strings.Contains(serverHeader, "windows")
|
||||||
|
|
||||||
|
dm.ensureDockerVersionChecked()
|
||||||
|
|
||||||
containersLength := len(dm.apiContainerList)
|
containersLength := len(dm.apiContainerList)
|
||||||
|
|
||||||
@@ -87,9 +164,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 +181,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 +191,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 +202,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 +222,280 @@ 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, 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 using per-cache-time read time to avoid
|
||||||
|
// interference between different cache intervals (e.g. 1000ms vs 60000ms)
|
||||||
|
var sent_delta, recv_delta uint64
|
||||||
|
if prevReadTime, ok := dm.lastNetworkReadTime[cacheTimeMs][ctr.IdShort]; ok {
|
||||||
|
millisecondsElapsed := uint64(time.Since(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 = utils.TwoDecimals(cpuPct)
|
||||||
|
stats.Mem = utils.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 = utils.BytesToMegabytes(float64(sent_delta))
|
||||||
|
stats.NetworkRecv = utils.BytesToMegabytes(float64(recv_delta))
|
||||||
|
stats.PrevReadTime = readTime
|
||||||
|
}
|
||||||
|
|
||||||
|
// convertContainerPortsToString formats the ports of a container into a sorted, deduplicated string.
|
||||||
|
// ctr.Ports is nilled out after processing so the slice is not accidentally reused.
|
||||||
|
func convertContainerPortsToString(ctr *container.ApiInfo) string {
|
||||||
|
if len(ctr.Ports) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
sort.Slice(ctr.Ports, func(i, j int) bool {
|
||||||
|
return ctr.Ports[i].PublicPort < ctr.Ports[j].PublicPort
|
||||||
|
})
|
||||||
|
var builder strings.Builder
|
||||||
|
seenPorts := make(map[uint16]struct{})
|
||||||
|
for _, p := range ctr.Ports {
|
||||||
|
_, ok := seenPorts[p.PublicPort]
|
||||||
|
if p.PublicPort == 0 || ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seenPorts[p.PublicPort] = struct{}{}
|
||||||
|
if builder.Len() > 0 {
|
||||||
|
builder.WriteString(", ")
|
||||||
|
}
|
||||||
|
switch p.IP {
|
||||||
|
case "0.0.0.0", "::":
|
||||||
|
default:
|
||||||
|
builder.WriteString(p.IP)
|
||||||
|
builder.WriteByte(':')
|
||||||
|
}
|
||||||
|
builder.WriteString(strconv.Itoa(int(p.PublicPort)))
|
||||||
|
}
|
||||||
|
// clear ports slice so it doesn't get reused and blend into next response
|
||||||
|
ctr.Ports = nil
|
||||||
|
return builder.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
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.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.ToLower(strings.TrimSpace(healthText[:colonIdx]))
|
||||||
|
if prefix == "health" || prefix == "health status" {
|
||||||
|
healthText = strings.TrimSpace(healthText[colonIdx+1:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if health, ok := parseDockerHealthStatus(healthText); ok {
|
||||||
|
return statusText, health
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed, container.DockerHealthNone
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseDockerHealthStatus maps Docker health status strings to container.DockerHealth values
|
||||||
|
func parseDockerHealthStatus(status string) (container.DockerHealth, bool) {
|
||||||
|
health, ok := container.DockerHealthStrings[strings.ToLower(strings.TrimSpace(status))]
|
||||||
|
return health, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// getPodmanContainerHealth fetches container health status from the container inspect endpoint.
|
||||||
|
// Used for Podman which doesn't provide health status in the /containers/json endpoint as of March 2026.
|
||||||
|
// https://github.com/containers/podman/issues/27786
|
||||||
|
func (dm *dockerManager) getPodmanContainerHealth(containerID string) (container.DockerHealth, error) {
|
||||||
|
resp, err := dm.client.Get(fmt.Sprintf("http://localhost/containers/%s/json", url.PathEscape(containerID)))
|
||||||
|
if err != nil {
|
||||||
|
return container.DockerHealthNone, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return container.DockerHealthNone, fmt.Errorf("container inspect request failed: %s", resp.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
var inspectInfo struct {
|
||||||
|
State struct {
|
||||||
|
Health struct {
|
||||||
|
Status string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&inspectInfo); err != nil {
|
||||||
|
return container.DockerHealthNone, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if health, ok := parseDockerHealthStatus(inspectInfo.State.Health.Status); ok {
|
||||||
|
return health, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return container.DockerHealthNone, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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()
|
|
||||||
|
statusText, health := parseDockerStatus(ctr.Status)
|
||||||
|
|
||||||
|
// Docker exposes Health.Status on /containers/json in API 1.52+.
|
||||||
|
// Podman currently requires falling back to the inspect endpoint as of March 2026.
|
||||||
|
// https://github.com/containers/podman/issues/27786
|
||||||
|
if ctr.Health.Status != "" {
|
||||||
|
if h, ok := parseDockerHealthStatus(ctr.Health.Status); ok {
|
||||||
|
health = h
|
||||||
|
}
|
||||||
|
} else if dm.usingPodman {
|
||||||
|
if podmanHealth, err := dm.getPodmanContainerHealth(ctr.IdShort); err == nil {
|
||||||
|
health = podmanHealth
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
dm.containerStatsMutex.Lock()
|
dm.containerStatsMutex.Lock()
|
||||||
defer dm.containerStatsMutex.Unlock()
|
defer dm.containerStatsMutex.Unlock()
|
||||||
@@ -159,82 +503,84 @@ 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
|
||||||
|
stats.Status = statusText
|
||||||
|
stats.Health = health
|
||||||
|
|
||||||
|
if len(ctr.Ports) > 0 {
|
||||||
|
stats.Ports = convertContainerPortsToString(ctr)
|
||||||
|
}
|
||||||
|
|
||||||
// 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, name, cacheTimeMs)
|
||||||
|
|
||||||
|
// Store per-cache-time network read time for next rate calculation
|
||||||
|
if dm.lastNetworkReadTime[cacheTimeMs] == nil {
|
||||||
|
dm.lastNetworkReadTime[cacheTimeMs] = make(map[string]time.Time)
|
||||||
|
}
|
||||||
|
dm.lastNetworkReadTime[cacheTimeMs][ctr.IdShort] = time.Now()
|
||||||
|
|
||||||
|
// 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,11 +590,23 @@ 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)
|
||||||
|
}
|
||||||
|
for ct := range dm.lastNetworkReadTime {
|
||||||
|
delete(dm.lastNetworkReadTime[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(agent *Agent) *dockerManager {
|
||||||
dockerHost, exists := GetEnv("DOCKER_HOST")
|
dockerHost, exists := utils.GetEnv("DOCKER_HOST")
|
||||||
if exists {
|
if exists {
|
||||||
// return nil if set to empty string
|
// return nil if set to empty string
|
||||||
if dockerHost == "" {
|
if dockerHost == "" {
|
||||||
@@ -283,8 +641,8 @@ 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 := utils.GetEnv("DOCKER_TIMEOUT"); set {
|
||||||
timeout, err = time.ParseDuration(t)
|
timeout, err = time.ParseDuration(t)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error(err.Error())
|
slog.Error(err.Error())
|
||||||
@@ -299,7 +657,21 @@ func newDockerManager(a *Agent) *dockerManager {
|
|||||||
userAgent: "Docker-Client/",
|
userAgent: "Docker-Client/",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Read container exclusion patterns from environment variable
|
||||||
|
var excludeContainers []string
|
||||||
|
if excludeStr, set := utils.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{
|
||||||
|
agent: agent,
|
||||||
client: &http.Client{
|
client: &http.Client{
|
||||||
Timeout: timeout,
|
Timeout: timeout,
|
||||||
Transport: userAgentTransport,
|
Transport: userAgentTransport,
|
||||||
@@ -308,37 +680,71 @@ 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]),
|
||||||
|
lastNetworkReadTime: make(map[uint16]map[string]time.Time),
|
||||||
}
|
}
|
||||||
|
|
||||||
// If using podman, return client
|
// Best-effort startup probe. If the engine is not ready yet, getDockerStats will
|
||||||
if strings.Contains(dockerHost, "podman") {
|
// retry after the first successful /containers/json request.
|
||||||
a.systemInfo.Podman = true
|
_, _ = manager.checkDockerVersion()
|
||||||
manager.goodDockerVersion = true
|
|
||||||
return manager
|
return manager
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check docker version
|
// 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)
|
// Versions before 25.0.0 have a bug with one-shot which requires all requests to be made in one batch.
|
||||||
var versionInfo struct {
|
func (dm *dockerManager) checkDockerVersion() (bool, error) {
|
||||||
Version string `json:"Version"`
|
resp, err := dm.client.Get("http://localhost/version")
|
||||||
}
|
|
||||||
resp, err := manager.client.Get("http://localhost/version")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return manager
|
return false, err
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
status := resp.Status
|
||||||
|
resp.Body.Close()
|
||||||
|
return false, fmt.Errorf("docker version request failed: %s", status)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := manager.decode(resp, &versionInfo); err != nil {
|
var versionInfo dockerVersionResponse
|
||||||
return manager
|
serverHeader := resp.Header.Get("Server")
|
||||||
|
if err := dm.decode(resp, &versionInfo); err != nil {
|
||||||
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dm.applyDockerVersionInfo(serverHeader, &versionInfo)
|
||||||
|
dm.dockerVersionChecked = true
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureDockerVersionChecked retries the version probe after a successful
|
||||||
|
// container list request.
|
||||||
|
func (dm *dockerManager) ensureDockerVersionChecked() {
|
||||||
|
if dm.dockerVersionChecked {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := dm.checkDockerVersion(); err != nil {
|
||||||
|
slog.Debug("Failed to get Docker version", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyDockerVersionInfo updates version-dependent behavior from engine metadata.
|
||||||
|
func (dm *dockerManager) applyDockerVersionInfo(serverHeader string, versionInfo *dockerVersionResponse) {
|
||||||
|
if detectPodmanEngine(serverHeader, versionInfo) {
|
||||||
|
dm.setIsPodman()
|
||||||
|
return
|
||||||
|
}
|
||||||
// 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 +774,234 @@ func getDockerHost() string {
|
|||||||
}
|
}
|
||||||
return scheme + socks[0]
|
return scheme + socks[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateContainerID(containerID string) error {
|
||||||
|
if !dockerContainerIDPattern.MatchString(containerID) {
|
||||||
|
return fmt.Errorf("invalid container id")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildDockerContainerEndpoint(containerID, action string, query url.Values) (string, error) {
|
||||||
|
if err := validateContainerID(containerID); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
u := &url.URL{
|
||||||
|
Scheme: "http",
|
||||||
|
Host: "localhost",
|
||||||
|
Path: fmt.Sprintf("/containers/%s/%s", url.PathEscape(containerID), action),
|
||||||
|
}
|
||||||
|
if len(query) > 0 {
|
||||||
|
u.RawQuery = query.Encode()
|
||||||
|
}
|
||||||
|
return u.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getContainerInfo fetches the inspection data for a container
|
||||||
|
func (dm *dockerManager) getContainerInfo(ctx context.Context, containerID string) ([]byte, error) {
|
||||||
|
endpoint, err := buildDockerContainerEndpoint(containerID, "json", nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
query := url.Values{
|
||||||
|
"stdout": []string{"1"},
|
||||||
|
"stderr": []string{"1"},
|
||||||
|
"tail": []string{fmt.Sprintf("%d", dockerLogsTail)},
|
||||||
|
}
|
||||||
|
endpoint, err := buildDockerContainerEndpoint(containerID, "logs", query)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
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
|
||||||
|
contentType := resp.Header.Get("Content-Type")
|
||||||
|
multiplexed := strings.HasSuffix(contentType, "multiplexed-stream")
|
||||||
|
logReader := io.Reader(resp.Body)
|
||||||
|
if !multiplexed {
|
||||||
|
// Podman may return multiplexed logs without Content-Type. Sniff the first frame header
|
||||||
|
// with a small buffered reader only when the header check fails.
|
||||||
|
bufferedReader := bufio.NewReaderSize(resp.Body, 8)
|
||||||
|
multiplexed = detectDockerMultiplexedStream(bufferedReader)
|
||||||
|
logReader = bufferedReader
|
||||||
|
}
|
||||||
|
if err := decodeDockerLogStream(logReader, &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 detectDockerMultiplexedStream(reader *bufio.Reader) bool {
|
||||||
|
const headerSize = 8
|
||||||
|
header, err := reader.Peek(headerSize)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if header[0] != 0x01 && header[0] != 0x02 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Docker's stream framing header reserves bytes 1-3 as zero.
|
||||||
|
if header[1] != 0 || header[2] != 0 || header[3] != 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
frameLen := binary.BigEndian.Uint32(header[4:])
|
||||||
|
return frameLen <= maxLogFrameSize
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// setIsPodman sets the manager to Podman mode and updates system details accordingly.
|
||||||
|
func (dm *dockerManager) setIsPodman() {
|
||||||
|
if dm.usingPodman {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dm.usingPodman = true
|
||||||
|
dm.goodDockerVersion = true
|
||||||
|
dm.dockerVersionChecked = true
|
||||||
|
// keep system details updated - this may be detected late if server isn't ready when
|
||||||
|
// agent starts, so make sure we notify the hub if this happens later.
|
||||||
|
if dm.agent != nil {
|
||||||
|
dm.agent.updateSystemDetails(func(details *system.Details) {
|
||||||
|
details.Podman = true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectPodmanFromHeader identifies Podman from the Docker API server header.
|
||||||
|
func detectPodmanFromHeader(server string) bool {
|
||||||
|
return strings.HasPrefix(server, "Libpod")
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectPodmanFromVersion identifies Podman from the version payload.
|
||||||
|
func detectPodmanFromVersion(versionInfo *dockerVersionResponse) bool {
|
||||||
|
if versionInfo == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, component := range versionInfo.Components {
|
||||||
|
if strings.HasPrefix(component.Name, "Podman") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectPodmanEngine checks both header and version metadata for Podman.
|
||||||
|
func detectPodmanEngine(serverHeader string, versionInfo *dockerVersionResponse) bool {
|
||||||
|
if detectPodmanFromHeader(serverHeader) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return detectPodmanFromVersion(versionInfo)
|
||||||
|
}
|
||||||
|
|||||||
1899
agent/docker_test.go
Normal file
1899
agent/docker_test.go
Normal file
File diff suppressed because it is too large
Load Diff
95
agent/emmc_common.go
Normal file
95
agent/emmc_common.go
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func isEmmcBlockName(name string) bool {
|
||||||
|
if !strings.HasPrefix(name, "mmcblk") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
suffix := strings.TrimPrefix(name, "mmcblk")
|
||||||
|
if suffix == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, c := range suffix {
|
||||||
|
if c < '0' || c > '9' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseHexOrDecByte(s string) (uint8, bool) {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
base := 10
|
||||||
|
if strings.HasPrefix(s, "0x") || strings.HasPrefix(s, "0X") {
|
||||||
|
base = 16
|
||||||
|
s = s[2:]
|
||||||
|
}
|
||||||
|
parsed, err := strconv.ParseUint(s, base, 8)
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return uint8(parsed), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseHexBytePair(s string) (uint8, uint8, bool) {
|
||||||
|
fields := strings.Fields(s)
|
||||||
|
if len(fields) < 2 {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
a, okA := parseHexOrDecByte(fields[0])
|
||||||
|
b, okB := parseHexOrDecByte(fields[1])
|
||||||
|
if !okA && !okB {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
return a, b, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func emmcSmartStatus(preEOL uint8) string {
|
||||||
|
switch preEOL {
|
||||||
|
case 0x01:
|
||||||
|
return "PASSED"
|
||||||
|
case 0x02:
|
||||||
|
return "WARNING"
|
||||||
|
case 0x03:
|
||||||
|
return "FAILED"
|
||||||
|
default:
|
||||||
|
return "UNKNOWN"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func emmcPreEOLString(preEOL uint8) string {
|
||||||
|
switch preEOL {
|
||||||
|
case 0x01:
|
||||||
|
return "0x01 (normal)"
|
||||||
|
case 0x02:
|
||||||
|
return "0x02 (warning)"
|
||||||
|
case 0x03:
|
||||||
|
return "0x03 (urgent)"
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("0x%02x", preEOL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func emmcLifeTimeString(v uint8) string {
|
||||||
|
// JEDEC eMMC: 0x01..0x0A => 0-100% used in 10% steps, 0x0B => exceeded.
|
||||||
|
switch {
|
||||||
|
case v == 0:
|
||||||
|
return "0x00 (not reported)"
|
||||||
|
case v >= 0x01 && v <= 0x0A:
|
||||||
|
low := int(v-1) * 10
|
||||||
|
high := int(v) * 10
|
||||||
|
return fmt.Sprintf("0x%02x (%d-%d%% used)", v, low, high)
|
||||||
|
case v == 0x0B:
|
||||||
|
return "0x0b (>100% used)"
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("0x%02x", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
78
agent/emmc_common_test.go
Normal file
78
agent/emmc_common_test.go
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestParseHexOrDecByte(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
in string
|
||||||
|
want uint8
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{"0x01", 1, true},
|
||||||
|
{"0X0b", 11, true},
|
||||||
|
{"01", 1, true},
|
||||||
|
{" 3 ", 3, true},
|
||||||
|
{"", 0, false},
|
||||||
|
{"0x", 0, false},
|
||||||
|
{"nope", 0, false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
got, ok := parseHexOrDecByte(tt.in)
|
||||||
|
if ok != tt.ok || got != tt.want {
|
||||||
|
t.Fatalf("parseHexOrDecByte(%q) = (%d,%v), want (%d,%v)", tt.in, got, ok, tt.want, tt.ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseHexBytePair(t *testing.T) {
|
||||||
|
a, b, ok := parseHexBytePair("0x01 0x02\n")
|
||||||
|
if !ok || a != 1 || b != 2 {
|
||||||
|
t.Fatalf("parseHexBytePair hex = (%d,%d,%v), want (1,2,true)", a, b, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
a, b, ok = parseHexBytePair("01 02")
|
||||||
|
if !ok || a != 1 || b != 2 {
|
||||||
|
t.Fatalf("parseHexBytePair dec = (%d,%d,%v), want (1,2,true)", a, b, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _, ok = parseHexBytePair("0x01")
|
||||||
|
if ok {
|
||||||
|
t.Fatalf("parseHexBytePair short input ok=true, want false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmmcSmartStatus(t *testing.T) {
|
||||||
|
if got := emmcSmartStatus(0x01); got != "PASSED" {
|
||||||
|
t.Fatalf("emmcSmartStatus(0x01) = %q, want PASSED", got)
|
||||||
|
}
|
||||||
|
if got := emmcSmartStatus(0x02); got != "WARNING" {
|
||||||
|
t.Fatalf("emmcSmartStatus(0x02) = %q, want WARNING", got)
|
||||||
|
}
|
||||||
|
if got := emmcSmartStatus(0x03); got != "FAILED" {
|
||||||
|
t.Fatalf("emmcSmartStatus(0x03) = %q, want FAILED", got)
|
||||||
|
}
|
||||||
|
if got := emmcSmartStatus(0x00); got != "UNKNOWN" {
|
||||||
|
t.Fatalf("emmcSmartStatus(0x00) = %q, want UNKNOWN", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsEmmcBlockName(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{"mmcblk0", true},
|
||||||
|
{"mmcblk1", true},
|
||||||
|
{"mmcblk10", true},
|
||||||
|
{"mmcblk0p1", false},
|
||||||
|
{"sda", false},
|
||||||
|
{"mmcblk", false},
|
||||||
|
{"mmcblkA", false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := isEmmcBlockName(c.name); got != c.ok {
|
||||||
|
t.Fatalf("isEmmcBlockName(%q) = %v, want %v", c.name, got, c.ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
215
agent/emmc_linux.go
Normal file
215
agent/emmc_linux.go
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/agent/utils"
|
||||||
|
"github.com/henrygd/beszel/internal/entities/smart"
|
||||||
|
)
|
||||||
|
|
||||||
|
// emmcSysfsRoot is a test hook; production value is "/sys".
|
||||||
|
var emmcSysfsRoot = "/sys"
|
||||||
|
|
||||||
|
type emmcHealth struct {
|
||||||
|
model string
|
||||||
|
serial string
|
||||||
|
revision string
|
||||||
|
capacity uint64
|
||||||
|
preEOL uint8
|
||||||
|
lifeA uint8
|
||||||
|
lifeB uint8
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanEmmcDevices() []*DeviceInfo {
|
||||||
|
blockDir := filepath.Join(emmcSysfsRoot, "class", "block")
|
||||||
|
entries, err := os.ReadDir(blockDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
devices := make([]*DeviceInfo, 0, 2)
|
||||||
|
for _, ent := range entries {
|
||||||
|
name := ent.Name()
|
||||||
|
if !isEmmcBlockName(name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
deviceDir := filepath.Join(blockDir, name, "device")
|
||||||
|
if !hasEmmcHealthFiles(deviceDir) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
devPath := filepath.Join("/dev", name)
|
||||||
|
devices = append(devices, &DeviceInfo{
|
||||||
|
Name: devPath,
|
||||||
|
Type: "emmc",
|
||||||
|
InfoName: devPath + " [eMMC]",
|
||||||
|
Protocol: "MMC",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return devices
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sm *SmartManager) collectEmmcHealth(deviceInfo *DeviceInfo) (bool, error) {
|
||||||
|
if deviceInfo == nil || deviceInfo.Name == "" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
base := filepath.Base(deviceInfo.Name)
|
||||||
|
if !isEmmcBlockName(base) && !strings.EqualFold(deviceInfo.Type, "emmc") && !strings.EqualFold(deviceInfo.Type, "mmc") {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
health, ok := readEmmcHealth(base)
|
||||||
|
if !ok {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize the device type to keep pruning logic stable across refreshes.
|
||||||
|
deviceInfo.Type = "emmc"
|
||||||
|
|
||||||
|
key := health.serial
|
||||||
|
if key == "" {
|
||||||
|
key = filepath.Join("/dev", base)
|
||||||
|
}
|
||||||
|
|
||||||
|
status := emmcSmartStatus(health.preEOL)
|
||||||
|
|
||||||
|
attrs := []*smart.SmartAttribute{
|
||||||
|
{
|
||||||
|
Name: "PreEOLInfo",
|
||||||
|
RawValue: uint64(health.preEOL),
|
||||||
|
RawString: emmcPreEOLString(health.preEOL),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "DeviceLifeTimeEstA",
|
||||||
|
RawValue: uint64(health.lifeA),
|
||||||
|
RawString: emmcLifeTimeString(health.lifeA),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "DeviceLifeTimeEstB",
|
||||||
|
RawValue: uint64(health.lifeB),
|
||||||
|
RawString: emmcLifeTimeString(health.lifeB),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
sm.Lock()
|
||||||
|
defer sm.Unlock()
|
||||||
|
|
||||||
|
if _, exists := sm.SmartDataMap[key]; !exists {
|
||||||
|
sm.SmartDataMap[key] = &smart.SmartData{}
|
||||||
|
}
|
||||||
|
|
||||||
|
data := sm.SmartDataMap[key]
|
||||||
|
data.ModelName = health.model
|
||||||
|
data.SerialNumber = health.serial
|
||||||
|
data.FirmwareVersion = health.revision
|
||||||
|
data.Capacity = health.capacity
|
||||||
|
data.Temperature = 0
|
||||||
|
data.SmartStatus = status
|
||||||
|
data.DiskName = filepath.Join("/dev", base)
|
||||||
|
data.DiskType = "emmc"
|
||||||
|
data.Attributes = attrs
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readEmmcHealth(blockName string) (emmcHealth, bool) {
|
||||||
|
var out emmcHealth
|
||||||
|
|
||||||
|
if !isEmmcBlockName(blockName) {
|
||||||
|
return out, false
|
||||||
|
}
|
||||||
|
|
||||||
|
deviceDir := filepath.Join(emmcSysfsRoot, "class", "block", blockName, "device")
|
||||||
|
preEOL, okPre := readHexByteFile(filepath.Join(deviceDir, "pre_eol_info"))
|
||||||
|
|
||||||
|
// Some kernels expose EXT_CSD lifetime via "life_time" (two bytes), others as
|
||||||
|
// separate files. Support both.
|
||||||
|
lifeA, lifeB, okLife := readLifeTime(deviceDir)
|
||||||
|
|
||||||
|
if !okPre && !okLife {
|
||||||
|
return out, false
|
||||||
|
}
|
||||||
|
|
||||||
|
out.preEOL = preEOL
|
||||||
|
out.lifeA = lifeA
|
||||||
|
out.lifeB = lifeB
|
||||||
|
|
||||||
|
out.model = utils.ReadStringFile(filepath.Join(deviceDir, "name"))
|
||||||
|
out.serial = utils.ReadStringFile(filepath.Join(deviceDir, "serial"))
|
||||||
|
out.revision = utils.ReadStringFile(filepath.Join(deviceDir, "prv"))
|
||||||
|
|
||||||
|
if capBytes, ok := readBlockCapacityBytes(blockName); ok {
|
||||||
|
out.capacity = capBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
return out, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func readLifeTime(deviceDir string) (uint8, uint8, bool) {
|
||||||
|
if content, ok := utils.ReadStringFileOK(filepath.Join(deviceDir, "life_time")); ok {
|
||||||
|
a, b, ok := parseHexBytePair(content)
|
||||||
|
return a, b, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
a, okA := readHexByteFile(filepath.Join(deviceDir, "device_life_time_est_typ_a"))
|
||||||
|
b, okB := readHexByteFile(filepath.Join(deviceDir, "device_life_time_est_typ_b"))
|
||||||
|
if okA || okB {
|
||||||
|
return a, b, true
|
||||||
|
}
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func readBlockCapacityBytes(blockName string) (uint64, bool) {
|
||||||
|
sizePath := filepath.Join(emmcSysfsRoot, "class", "block", blockName, "size")
|
||||||
|
lbsPath := filepath.Join(emmcSysfsRoot, "class", "block", blockName, "queue", "logical_block_size")
|
||||||
|
|
||||||
|
sizeStr, ok := utils.ReadStringFileOK(sizePath)
|
||||||
|
if !ok {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
sectors, err := strconv.ParseUint(sizeStr, 10, 64)
|
||||||
|
if err != nil || sectors == 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
lbsStr, ok := utils.ReadStringFileOK(lbsPath)
|
||||||
|
logicalBlockSize := uint64(512)
|
||||||
|
if ok {
|
||||||
|
if parsed, err := strconv.ParseUint(lbsStr, 10, 64); err == nil && parsed > 0 {
|
||||||
|
logicalBlockSize = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sectors * logicalBlockSize, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func readHexByteFile(path string) (uint8, bool) {
|
||||||
|
content, ok := utils.ReadStringFileOK(path)
|
||||||
|
if !ok {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
b, ok := parseHexOrDecByte(content)
|
||||||
|
return b, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasEmmcHealthFiles(deviceDir string) bool {
|
||||||
|
entries, err := os.ReadDir(deviceDir)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, ent := range entries {
|
||||||
|
switch ent.Name() {
|
||||||
|
case "pre_eol_info", "life_time", "device_life_time_est_typ_a", "device_life_time_est_typ_b":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
80
agent/emmc_linux_test.go
Normal file
80
agent/emmc_linux_test.go
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/entities/smart"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEmmcMockSysfsScanAndCollect(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
prev := emmcSysfsRoot
|
||||||
|
emmcSysfsRoot = tmp
|
||||||
|
t.Cleanup(func() { emmcSysfsRoot = prev })
|
||||||
|
|
||||||
|
// Fake: /sys/class/block/mmcblk0
|
||||||
|
mmcDeviceDir := filepath.Join(tmp, "class", "block", "mmcblk0", "device")
|
||||||
|
mmcQueueDir := filepath.Join(tmp, "class", "block", "mmcblk0", "queue")
|
||||||
|
if err := os.MkdirAll(mmcDeviceDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(mmcQueueDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
write := func(path, content string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
write(filepath.Join(mmcDeviceDir, "pre_eol_info"), "0x02\n")
|
||||||
|
write(filepath.Join(mmcDeviceDir, "life_time"), "0x04 0x05\n")
|
||||||
|
write(filepath.Join(mmcDeviceDir, "name"), "H26M52103FMR\n")
|
||||||
|
write(filepath.Join(mmcDeviceDir, "serial"), "01234567\n")
|
||||||
|
write(filepath.Join(mmcDeviceDir, "prv"), "0x08\n")
|
||||||
|
write(filepath.Join(mmcQueueDir, "logical_block_size"), "512\n")
|
||||||
|
write(filepath.Join(tmp, "class", "block", "mmcblk0", "size"), "1024\n") // sectors
|
||||||
|
|
||||||
|
devs := scanEmmcDevices()
|
||||||
|
if len(devs) != 1 {
|
||||||
|
t.Fatalf("scanEmmcDevices() = %d devices, want 1", len(devs))
|
||||||
|
}
|
||||||
|
if devs[0].Name != "/dev/mmcblk0" || devs[0].Type != "emmc" {
|
||||||
|
t.Fatalf("scanEmmcDevices()[0] = %+v, want Name=/dev/mmcblk0 Type=emmc", devs[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
sm := &SmartManager{SmartDataMap: map[string]*smart.SmartData{}}
|
||||||
|
ok, err := sm.collectEmmcHealth(devs[0])
|
||||||
|
if err != nil || !ok {
|
||||||
|
t.Fatalf("collectEmmcHealth() = (ok=%v, err=%v), want (true,nil)", ok, err)
|
||||||
|
}
|
||||||
|
if len(sm.SmartDataMap) != 1 {
|
||||||
|
t.Fatalf("SmartDataMap len=%d, want 1", len(sm.SmartDataMap))
|
||||||
|
}
|
||||||
|
var got *smart.SmartData
|
||||||
|
for _, v := range sm.SmartDataMap {
|
||||||
|
got = v
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if got == nil {
|
||||||
|
t.Fatalf("SmartDataMap value nil")
|
||||||
|
}
|
||||||
|
if got.DiskType != "emmc" || got.DiskName != "/dev/mmcblk0" {
|
||||||
|
t.Fatalf("disk fields = (type=%q name=%q), want (emmc,/dev/mmcblk0)", got.DiskType, got.DiskName)
|
||||||
|
}
|
||||||
|
if got.SmartStatus != "WARNING" {
|
||||||
|
t.Fatalf("SmartStatus=%q, want WARNING", got.SmartStatus)
|
||||||
|
}
|
||||||
|
if got.SerialNumber != "01234567" || got.ModelName == "" || got.Capacity == 0 {
|
||||||
|
t.Fatalf("identity fields = (model=%q serial=%q cap=%d), want non-empty model, serial 01234567, cap>0", got.ModelName, got.SerialNumber, got.Capacity)
|
||||||
|
}
|
||||||
|
if len(got.Attributes) < 3 {
|
||||||
|
t.Fatalf("attributes len=%d, want >= 3", len(got.Attributes))
|
||||||
|
}
|
||||||
|
}
|
||||||
14
agent/emmc_stub.go
Normal file
14
agent/emmc_stub.go
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
//go:build !linux
|
||||||
|
|
||||||
|
package agent
|
||||||
|
|
||||||
|
// Non-Linux builds: eMMC health via sysfs is not available.
|
||||||
|
|
||||||
|
func scanEmmcDevices() []*DeviceInfo {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sm *SmartManager) collectEmmcHealth(deviceInfo *DeviceInfo) (bool, error) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
87
agent/fingerprint.go
Normal file
87
agent/fingerprint.go
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/shirou/gopsutil/v4/cpu"
|
||||||
|
"github.com/shirou/gopsutil/v4/host"
|
||||||
|
)
|
||||||
|
|
||||||
|
const fingerprintFileName = "fingerprint"
|
||||||
|
|
||||||
|
// knownBadUUID is a commonly known "product_uuid" that is not unique across systems.
|
||||||
|
const knownBadUUID = "03000200-0400-0500-0006-000700080009"
|
||||||
|
|
||||||
|
// GetFingerprint returns the agent fingerprint. It first tries to read a saved
|
||||||
|
// fingerprint from the data directory. If not found (or dataDir is empty), it
|
||||||
|
// generates one from system properties. The hostname and cpuModel parameters are
|
||||||
|
// used as fallback material if host.HostID() fails. If either is empty, they
|
||||||
|
// are fetched from the system automatically.
|
||||||
|
//
|
||||||
|
// If a new fingerprint is generated and a dataDir is provided, it is saved.
|
||||||
|
func GetFingerprint(dataDir, hostname, cpuModel string) string {
|
||||||
|
if dataDir != "" {
|
||||||
|
if fp, err := readFingerprint(dataDir); err == nil {
|
||||||
|
return fp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fp := generateFingerprint(hostname, cpuModel)
|
||||||
|
if dataDir != "" {
|
||||||
|
_ = SaveFingerprint(dataDir, fp)
|
||||||
|
}
|
||||||
|
return fp
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateFingerprint creates a fingerprint from system properties.
|
||||||
|
// It tries host.HostID() first, falling back to hostname + cpuModel.
|
||||||
|
// If hostname or cpuModel are empty, they are fetched from the system.
|
||||||
|
func generateFingerprint(hostname, cpuModel string) string {
|
||||||
|
fingerprint, err := host.HostID()
|
||||||
|
if err != nil || fingerprint == "" || fingerprint == knownBadUUID {
|
||||||
|
if hostname == "" {
|
||||||
|
hostname, _ = os.Hostname()
|
||||||
|
}
|
||||||
|
if cpuModel == "" {
|
||||||
|
if info, err := cpu.Info(); err == nil && len(info) > 0 {
|
||||||
|
cpuModel = info[0].ModelName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fingerprint = hostname + cpuModel
|
||||||
|
}
|
||||||
|
|
||||||
|
sum := sha256.Sum256([]byte(fingerprint))
|
||||||
|
return hex.EncodeToString(sum[:24])
|
||||||
|
}
|
||||||
|
|
||||||
|
// readFingerprint reads the saved fingerprint from the data directory.
|
||||||
|
func readFingerprint(dataDir string) (string, error) {
|
||||||
|
fp, err := os.ReadFile(filepath.Join(dataDir, fingerprintFileName))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
s := strings.TrimSpace(string(fp))
|
||||||
|
if s == "" {
|
||||||
|
return "", errors.New("fingerprint file is empty")
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveFingerprint writes the fingerprint to the data directory.
|
||||||
|
func SaveFingerprint(dataDir, fingerprint string) error {
|
||||||
|
return os.WriteFile(filepath.Join(dataDir, fingerprintFileName), []byte(fingerprint), 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteFingerprint removes the saved fingerprint file from the data directory.
|
||||||
|
// Returns nil if the file does not exist (idempotent).
|
||||||
|
func DeleteFingerprint(dataDir string) error {
|
||||||
|
err := os.Remove(filepath.Join(dataDir, fingerprintFileName))
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
102
agent/fingerprint_test.go
Normal file
102
agent/fingerprint_test.go
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
//go:build testing
|
||||||
|
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetFingerprint(t *testing.T) {
|
||||||
|
t.Run("reads existing fingerprint from file", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
expected := "abc123def456"
|
||||||
|
err := os.WriteFile(filepath.Join(dir, fingerprintFileName), []byte(expected), 0644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
fp := GetFingerprint(dir, "", "")
|
||||||
|
assert.Equal(t, expected, fp)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("trims whitespace from file", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
err := os.WriteFile(filepath.Join(dir, fingerprintFileName), []byte(" abc123 \n"), 0644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
fp := GetFingerprint(dir, "", "")
|
||||||
|
assert.Equal(t, "abc123", fp)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("generates fingerprint when file does not exist", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
fp := GetFingerprint(dir, "", "")
|
||||||
|
assert.NotEmpty(t, fp)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("generates fingerprint when dataDir is empty", func(t *testing.T) {
|
||||||
|
fp := GetFingerprint("", "", "")
|
||||||
|
assert.NotEmpty(t, fp)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("generates consistent fingerprint for same inputs", func(t *testing.T) {
|
||||||
|
fp1 := GetFingerprint("", "myhost", "mycpu")
|
||||||
|
fp2 := GetFingerprint("", "myhost", "mycpu")
|
||||||
|
assert.Equal(t, fp1, fp2)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("prefers saved fingerprint over generated", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, SaveFingerprint(dir, "saved-fp"))
|
||||||
|
|
||||||
|
fp := GetFingerprint(dir, "anyhost", "anycpu")
|
||||||
|
assert.Equal(t, "saved-fp", fp)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveFingerprint(t *testing.T) {
|
||||||
|
t.Run("saves fingerprint to file", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
err := SaveFingerprint(dir, "abc123")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
content, err := os.ReadFile(filepath.Join(dir, fingerprintFileName))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "abc123", string(content))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("overwrites existing fingerprint", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, SaveFingerprint(dir, "old"))
|
||||||
|
require.NoError(t, SaveFingerprint(dir, "new"))
|
||||||
|
|
||||||
|
content, err := os.ReadFile(filepath.Join(dir, fingerprintFileName))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "new", string(content))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteFingerprint(t *testing.T) {
|
||||||
|
t.Run("deletes existing fingerprint", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
fp := filepath.Join(dir, fingerprintFileName)
|
||||||
|
err := os.WriteFile(fp, []byte("abc123"), 0644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = DeleteFingerprint(dir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify file is gone
|
||||||
|
_, err = os.Stat(fp)
|
||||||
|
assert.True(t, os.IsNotExist(err))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no error when file does not exist", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
err := DeleteFingerprint(dir)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
585
agent/gpu.go
585
agent/gpu.go
@@ -5,16 +5,18 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"maps"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"runtime"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/agent/utils"
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
|
||||||
"golang.org/x/exp/slog"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -22,11 +24,11 @@ const (
|
|||||||
nvidiaSmiCmd string = "nvidia-smi"
|
nvidiaSmiCmd string = "nvidia-smi"
|
||||||
rocmSmiCmd string = "rocm-smi"
|
rocmSmiCmd string = "rocm-smi"
|
||||||
tegraStatsCmd string = "tegrastats"
|
tegraStatsCmd string = "tegrastats"
|
||||||
|
nvtopCmd string = "nvtop"
|
||||||
|
powermetricsCmd string = "powermetrics"
|
||||||
|
macmonCmd string = "macmon"
|
||||||
|
noGPUFoundMsg string = "no GPU found - see https://beszel.dev/guide/gpu"
|
||||||
|
|
||||||
// Polling intervals
|
|
||||||
nvidiaSmiInterval string = "4" // in seconds
|
|
||||||
tegraStatsInterval string = "3700" // in milliseconds
|
|
||||||
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
|
||||||
@@ -39,11 +41,22 @@ const (
|
|||||||
// GPUManager manages data collection for GPUs (either Nvidia or AMD)
|
// GPUManager manages data collection for GPUs (either Nvidia or AMD)
|
||||||
type GPUManager struct {
|
type GPUManager struct {
|
||||||
sync.Mutex
|
sync.Mutex
|
||||||
nvidiaSmi bool
|
|
||||||
rocmSmi bool
|
|
||||||
tegrastats bool
|
|
||||||
intelGpuStats 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
|
||||||
@@ -69,6 +82,58 @@ type gpuCollector struct {
|
|||||||
|
|
||||||
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
|
||||||
|
|
||||||
|
// collectorSource identifies a selectable GPU collector in GPU_COLLECTOR.
|
||||||
|
type collectorSource string
|
||||||
|
|
||||||
|
const (
|
||||||
|
collectorSourceNVTop collectorSource = collectorSource(nvtopCmd)
|
||||||
|
collectorSourceNVML collectorSource = "nvml"
|
||||||
|
collectorSourceNvidiaSMI collectorSource = collectorSource(nvidiaSmiCmd)
|
||||||
|
collectorSourceIntelGpuTop collectorSource = collectorSource(intelGpuStatsCmd)
|
||||||
|
collectorSourceAmdSysfs collectorSource = "amd_sysfs"
|
||||||
|
collectorSourceRocmSMI collectorSource = collectorSource(rocmSmiCmd)
|
||||||
|
collectorSourceMacmon collectorSource = collectorSource(macmonCmd)
|
||||||
|
collectorSourcePowermetrics collectorSource = collectorSource(powermetricsCmd)
|
||||||
|
collectorGroupNvidia string = "nvidia"
|
||||||
|
collectorGroupIntel string = "intel"
|
||||||
|
collectorGroupAmd string = "amd"
|
||||||
|
collectorGroupApple string = "apple"
|
||||||
|
)
|
||||||
|
|
||||||
|
func isValidCollectorSource(source collectorSource) bool {
|
||||||
|
switch source {
|
||||||
|
case collectorSourceNVTop,
|
||||||
|
collectorSourceNVML,
|
||||||
|
collectorSourceNvidiaSMI,
|
||||||
|
collectorSourceIntelGpuTop,
|
||||||
|
collectorSourceAmdSysfs,
|
||||||
|
collectorSourceRocmSMI,
|
||||||
|
collectorSourceMacmon,
|
||||||
|
collectorSourcePowermetrics:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// gpuCapabilities describes detected GPU tooling and sysfs support on the host.
|
||||||
|
type gpuCapabilities struct {
|
||||||
|
hasNvidiaSmi bool
|
||||||
|
hasRocmSmi bool
|
||||||
|
hasAmdSysfs bool
|
||||||
|
hasTegrastats bool
|
||||||
|
hasIntelGpuTop bool
|
||||||
|
hasNvtop bool
|
||||||
|
hasMacmon bool
|
||||||
|
hasPowermetrics bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type collectorDefinition struct {
|
||||||
|
group string
|
||||||
|
available bool
|
||||||
|
start func(onFailure func()) bool
|
||||||
|
deprecationWarning string
|
||||||
|
}
|
||||||
|
|
||||||
// starts and manages the ongoing collection of GPU data for the specified GPU management utility
|
// starts and manages the ongoing collection of GPU data for the specified GPU management utility
|
||||||
func (c *gpuCollector) start() {
|
func (c *gpuCollector) start() {
|
||||||
for {
|
for {
|
||||||
@@ -120,10 +185,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"}
|
||||||
@@ -152,7 +217,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++
|
||||||
@@ -215,13 +286,14 @@ 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 = utils.BytesToMegabytes(memoryUsage)
|
||||||
gpu.MemoryTotal = bytesToMegabytes(totalMemory)
|
gpu.MemoryTotal = utils.BytesToMegabytes(totalMemory)
|
||||||
gpu.Usage += usage
|
gpu.Usage += usage
|
||||||
gpu.Power += power
|
gpu.Power += power
|
||||||
gpu.Count++
|
gpu.Count++
|
||||||
@@ -229,48 +301,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 {
|
||||||
// avoid division by zero
|
gpuAvg := gm.calculateGPUAverage(id, gpu, cacheKey)
|
||||||
count := max(gpu.Count, 1)
|
gm.updateInstantaneousValues(&gpuAvg, gpu)
|
||||||
|
gm.storeSnapshot(id, gpu, cacheKey)
|
||||||
|
|
||||||
// average the data
|
// Append id to name if there are multiple GPUs with the same name
|
||||||
gpuAvg := *gpu
|
|
||||||
gpuAvg.Temperature = twoDecimals(gpu.Temperature)
|
|
||||||
gpuAvg.Power = twoDecimals(gpu.Power / count)
|
|
||||||
|
|
||||||
// intel gpu stats doesn't provide usage, memory used, or memory total
|
|
||||||
if gpu.Engines != nil {
|
|
||||||
maxEngineUsage := 0.0
|
|
||||||
for name, engine := range gpu.Engines {
|
|
||||||
gpuAvg.Engines[name] = twoDecimals(engine / count)
|
|
||||||
maxEngineUsage = max(maxEngineUsage, engine/count)
|
|
||||||
}
|
|
||||||
gpuAvg.PowerPkg = twoDecimals(gpu.PowerPkg / count)
|
|
||||||
gpuAvg.Usage = twoDecimals(maxEngineUsage)
|
|
||||||
} else {
|
|
||||||
gpuAvg.Usage = twoDecimals(gpu.Usage / count)
|
|
||||||
gpuAvg.MemoryUsed = twoDecimals(gpu.MemoryUsed)
|
|
||||||
gpuAvg.MemoryTotal = twoDecimals(gpu.MemoryTotal)
|
|
||||||
}
|
|
||||||
|
|
||||||
// reset accumulators in the original gpu data for next collection
|
|
||||||
gpu.Usage, gpu.Power, gpu.PowerPkg, gpu.Count = gpuAvg.Usage, gpuAvg.Power, gpuAvg.PowerPkg, 1
|
|
||||||
gpu.Engines = gpuAvg.Engines
|
|
||||||
|
|
||||||
// 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)
|
||||||
}
|
}
|
||||||
@@ -280,38 +325,157 @@ func (gm *GPUManager) GetCurrentData() map[string]system.GPUData {
|
|||||||
return gpuData
|
return gpuData
|
||||||
}
|
}
|
||||||
|
|
||||||
// detectGPUs checks for the presence of GPU management tools (nvidia-smi, rocm-smi, tegrastats)
|
// initializeSnapshots ensures snapshot maps are initialized for the given cache key
|
||||||
// in the system path. It sets the corresponding flags in the GPUManager struct if any of these
|
func (gm *GPUManager) initializeSnapshots(cacheKey uint16) {
|
||||||
// tools are found. If none of the tools are found, it returns an error indicating that no GPU
|
if gm.lastAvgData == nil {
|
||||||
// management tools are available.
|
gm.lastAvgData = make(map[string]system.GPUData)
|
||||||
func (gm *GPUManager) detectGPUs() error {
|
|
||||||
if _, err := exec.LookPath(nvidiaSmiCmd); err == nil {
|
|
||||||
gm.nvidiaSmi = true
|
|
||||||
}
|
}
|
||||||
if _, err := exec.LookPath(rocmSmiCmd); err == nil {
|
if gm.lastSnapshots == nil {
|
||||||
gm.rocmSmi = true
|
gm.lastSnapshots = make(map[uint16]map[string]*gpuSnapshot)
|
||||||
}
|
}
|
||||||
if _, err := exec.LookPath(tegraStatsCmd); err == nil {
|
if gm.lastSnapshots[cacheKey] == nil {
|
||||||
gm.tegrastats = true
|
gm.lastSnapshots[cacheKey] = make(map[string]*gpuSnapshot)
|
||||||
gm.nvidiaSmi = false
|
|
||||||
}
|
}
|
||||||
if _, err := exec.LookPath(intelGpuStatsCmd); err == nil {
|
|
||||||
gm.intelGpuStats = true
|
|
||||||
}
|
|
||||||
if gm.nvidiaSmi || gm.rocmSmi || gm.tegrastats || gm.intelGpuStats {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return fmt.Errorf("no GPU found - install nvidia-smi, rocm-smi, tegrastats, or intel_gpu_top")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// startCollector starts the appropriate GPU data collector based on the command
|
// countGPUNames returns a map of GPU names to their occurrence count
|
||||||
func (gm *GPUManager) startCollector(command string) {
|
func (gm *GPUManager) countGPUNames() map[string]int {
|
||||||
collector := gpuCollector{
|
nameCounts := make(map[string]int)
|
||||||
name: command,
|
for _, gpu := range gm.GpuDataMap {
|
||||||
bufSize: 10 * 1024,
|
nameCounts[gpu.Name]++
|
||||||
}
|
}
|
||||||
switch command {
|
return nameCounts
|
||||||
case intelGpuStatsCmd:
|
}
|
||||||
|
|
||||||
|
// 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 = utils.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 = utils.TwoDecimals(deltaPowerPkg / float64(deltaCount))
|
||||||
|
} else {
|
||||||
|
gpuAvg.Usage = utils.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] = utils.TwoDecimals(deltaEngine / float64(deltaCount))
|
||||||
|
maxEngineUsage = max(maxEngineUsage, deltaEngine/float64(deltaCount))
|
||||||
|
}
|
||||||
|
return utils.TwoDecimals(maxEngineUsage)
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateInstantaneousValues updates values that should reflect current state, not averages
|
||||||
|
func (gm *GPUManager) updateInstantaneousValues(gpuAvg *system.GPUData, gpu *system.GPUData) {
|
||||||
|
gpuAvg.Temperature = utils.TwoDecimals(gpu.Temperature)
|
||||||
|
gpuAvg.MemoryUsed = utils.TwoDecimals(gpu.MemoryUsed)
|
||||||
|
gpuAvg.MemoryTotal = utils.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
|
||||||
|
}
|
||||||
|
|
||||||
|
// discoverGpuCapabilities checks for available GPU tooling and sysfs support.
|
||||||
|
// It only reports capability presence and does not apply policy decisions.
|
||||||
|
func (gm *GPUManager) discoverGpuCapabilities() gpuCapabilities {
|
||||||
|
caps := gpuCapabilities{
|
||||||
|
hasAmdSysfs: gm.hasAmdSysfs(),
|
||||||
|
}
|
||||||
|
if _, err := exec.LookPath(nvidiaSmiCmd); err == nil {
|
||||||
|
caps.hasNvidiaSmi = true
|
||||||
|
}
|
||||||
|
if _, err := exec.LookPath(rocmSmiCmd); err == nil {
|
||||||
|
caps.hasRocmSmi = true
|
||||||
|
}
|
||||||
|
if _, err := exec.LookPath(tegraStatsCmd); err == nil {
|
||||||
|
caps.hasTegrastats = true
|
||||||
|
}
|
||||||
|
if _, err := exec.LookPath(intelGpuStatsCmd); err == nil {
|
||||||
|
caps.hasIntelGpuTop = true
|
||||||
|
}
|
||||||
|
if _, err := exec.LookPath(nvtopCmd); err == nil {
|
||||||
|
caps.hasNvtop = true
|
||||||
|
}
|
||||||
|
if runtime.GOOS == "darwin" {
|
||||||
|
if _, err := utils.LookPathHomebrew(macmonCmd); err == nil {
|
||||||
|
caps.hasMacmon = true
|
||||||
|
}
|
||||||
|
if _, err := exec.LookPath(powermetricsCmd); err == nil {
|
||||||
|
caps.hasPowermetrics = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return caps
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasAnyGpuCollector(caps gpuCapabilities) bool {
|
||||||
|
return caps.hasNvidiaSmi || caps.hasRocmSmi || caps.hasAmdSysfs || caps.hasTegrastats || caps.hasIntelGpuTop || caps.hasNvtop || caps.hasMacmon || caps.hasPowermetrics
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gm *GPUManager) startIntelCollector() {
|
||||||
go func() {
|
go func() {
|
||||||
failures := 0
|
failures := 0
|
||||||
for {
|
for {
|
||||||
@@ -326,21 +490,39 @@ func (gm *GPUManager) startCollector(command string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
case nvidiaSmiCmd:
|
}
|
||||||
collector.cmdArgs = []string{
|
|
||||||
"-l", nvidiaSmiInterval,
|
func (gm *GPUManager) startNvidiaSmiCollector(intervalSeconds string) {
|
||||||
|
collector := gpuCollector{
|
||||||
|
name: nvidiaSmiCmd,
|
||||||
|
bufSize: 10 * 1024,
|
||||||
|
cmdArgs: []string{
|
||||||
|
"-l", intervalSeconds,
|
||||||
"--query-gpu=index,name,temperature.gpu,memory.used,memory.total,utilization.gpu,power.draw",
|
"--query-gpu=index,name,temperature.gpu,memory.used,memory.total,utilization.gpu,power.draw",
|
||||||
"--format=csv,noheader,nounits",
|
"--format=csv,noheader,nounits",
|
||||||
|
},
|
||||||
|
parse: gm.parseNvidiaData,
|
||||||
}
|
}
|
||||||
collector.parse = gm.parseNvidiaData
|
|
||||||
go collector.start()
|
go collector.start()
|
||||||
case tegraStatsCmd:
|
}
|
||||||
collector.cmdArgs = []string{"--interval", tegraStatsInterval}
|
|
||||||
collector.parse = gm.getJetsonParser()
|
func (gm *GPUManager) startTegraStatsCollector(intervalMilliseconds string) {
|
||||||
|
collector := gpuCollector{
|
||||||
|
name: tegraStatsCmd,
|
||||||
|
bufSize: 10 * 1024,
|
||||||
|
cmdArgs: []string{"--interval", intervalMilliseconds},
|
||||||
|
parse: gm.getJetsonParser(),
|
||||||
|
}
|
||||||
go collector.start()
|
go collector.start()
|
||||||
case rocmSmiCmd:
|
}
|
||||||
collector.cmdArgs = []string{"--showid", "--showtemp", "--showuse", "--showpower", "--showproductname", "--showmeminfo", "vram", "--json"}
|
|
||||||
collector.parse = gm.parseAmdData
|
func (gm *GPUManager) startRocmSmiCollector(pollInterval time.Duration) {
|
||||||
|
collector := gpuCollector{
|
||||||
|
name: rocmSmiCmd,
|
||||||
|
bufSize: 10 * 1024,
|
||||||
|
cmdArgs: []string{"--showid", "--showtemp", "--showuse", "--showpower", "--showproductname", "--showmeminfo", "vram", "--json"},
|
||||||
|
parse: gm.parseAmdData,
|
||||||
|
}
|
||||||
go func() {
|
go func() {
|
||||||
failures := 0
|
failures := 0
|
||||||
for {
|
for {
|
||||||
@@ -349,36 +531,233 @@ 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(pollInterval)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gm *GPUManager) collectorDefinitions(caps gpuCapabilities) map[collectorSource]collectorDefinition {
|
||||||
|
return map[collectorSource]collectorDefinition{
|
||||||
|
collectorSourceNVML: {
|
||||||
|
group: collectorGroupNvidia,
|
||||||
|
available: true,
|
||||||
|
start: func(_ func()) bool {
|
||||||
|
return gm.startNvmlCollector()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
collectorSourceNvidiaSMI: {
|
||||||
|
group: collectorGroupNvidia,
|
||||||
|
available: caps.hasNvidiaSmi,
|
||||||
|
start: func(_ func()) bool {
|
||||||
|
gm.startNvidiaSmiCollector("4") // seconds
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
},
|
||||||
|
collectorSourceIntelGpuTop: {
|
||||||
|
group: collectorGroupIntel,
|
||||||
|
available: caps.hasIntelGpuTop,
|
||||||
|
start: func(_ func()) bool {
|
||||||
|
gm.startIntelCollector()
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
},
|
||||||
|
collectorSourceAmdSysfs: {
|
||||||
|
group: collectorGroupAmd,
|
||||||
|
available: caps.hasAmdSysfs,
|
||||||
|
start: func(_ func()) bool {
|
||||||
|
return gm.startAmdSysfsCollector()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
collectorSourceRocmSMI: {
|
||||||
|
group: collectorGroupAmd,
|
||||||
|
available: caps.hasRocmSmi,
|
||||||
|
deprecationWarning: "rocm-smi is deprecated and may be removed in a future release",
|
||||||
|
start: func(_ func()) bool {
|
||||||
|
gm.startRocmSmiCollector(4300 * time.Millisecond)
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
},
|
||||||
|
collectorSourceNVTop: {
|
||||||
|
available: caps.hasNvtop,
|
||||||
|
start: func(onFailure func()) bool {
|
||||||
|
gm.startNvtopCollector("30", onFailure) // tens of milliseconds
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
},
|
||||||
|
collectorSourceMacmon: {
|
||||||
|
group: collectorGroupApple,
|
||||||
|
available: caps.hasMacmon,
|
||||||
|
start: func(_ func()) bool {
|
||||||
|
gm.startMacmonCollector()
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
},
|
||||||
|
collectorSourcePowermetrics: {
|
||||||
|
group: collectorGroupApple,
|
||||||
|
available: caps.hasPowermetrics,
|
||||||
|
start: func(_ func()) bool {
|
||||||
|
gm.startPowermetricsCollector()
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseCollectorPriority parses GPU_COLLECTOR and returns valid ordered entries.
|
||||||
|
func parseCollectorPriority(value string) []collectorSource {
|
||||||
|
parts := strings.Split(value, ",")
|
||||||
|
priorities := make([]collectorSource, 0, len(parts))
|
||||||
|
for _, raw := range parts {
|
||||||
|
name := collectorSource(strings.TrimSpace(strings.ToLower(raw)))
|
||||||
|
if !isValidCollectorSource(name) {
|
||||||
|
if name != "" {
|
||||||
|
slog.Warn("Ignoring unknown GPU collector", "collector", name)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
priorities = append(priorities, name)
|
||||||
|
}
|
||||||
|
return priorities
|
||||||
|
}
|
||||||
|
|
||||||
|
// startNvmlCollector initializes NVML and starts its polling loop.
|
||||||
|
func (gm *GPUManager) startNvmlCollector() bool {
|
||||||
|
collector := &nvmlCollector{gm: gm}
|
||||||
|
if err := collector.init(); err != nil {
|
||||||
|
slog.Warn("Failed to initialize NVML", "err", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
go collector.start()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// startAmdSysfsCollector starts AMD GPU collection via sysfs.
|
||||||
|
func (gm *GPUManager) startAmdSysfsCollector() bool {
|
||||||
|
go func() {
|
||||||
|
if err := gm.collectAmdStats(); err != nil {
|
||||||
|
slog.Warn("Error collecting AMD GPU data via sysfs", "err", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// startCollectorsByPriority starts collectors in order with one source per vendor group.
|
||||||
|
func (gm *GPUManager) startCollectorsByPriority(priorities []collectorSource, caps gpuCapabilities) int {
|
||||||
|
definitions := gm.collectorDefinitions(caps)
|
||||||
|
selectedGroups := make(map[string]bool, 3)
|
||||||
|
started := 0
|
||||||
|
for i, source := range priorities {
|
||||||
|
definition, ok := definitions[source]
|
||||||
|
if !ok || !definition.available {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// nvtop is not a vendor-specific collector, so should only be used if no other collectors are selected or it is first in GPU_COLLECTOR.
|
||||||
|
if source == collectorSourceNVTop {
|
||||||
|
if len(selectedGroups) > 0 {
|
||||||
|
slog.Warn("Skipping nvtop because other collectors are selected")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// if nvtop fails, fall back to remaining collectors.
|
||||||
|
remaining := append([]collectorSource(nil), priorities[i+1:]...)
|
||||||
|
if definition.start(func() {
|
||||||
|
gm.startCollectorsByPriority(remaining, caps)
|
||||||
|
}) {
|
||||||
|
started++
|
||||||
|
return started
|
||||||
|
}
|
||||||
|
}
|
||||||
|
group := definition.group
|
||||||
|
if group == "" || selectedGroups[group] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if definition.deprecationWarning != "" {
|
||||||
|
slog.Warn(definition.deprecationWarning)
|
||||||
|
}
|
||||||
|
if definition.start(nil) {
|
||||||
|
selectedGroups[group] = true
|
||||||
|
started++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return started
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveLegacyCollectorPriority builds the default collector order when GPU_COLLECTOR is unset.
|
||||||
|
func (gm *GPUManager) resolveLegacyCollectorPriority(caps gpuCapabilities) []collectorSource {
|
||||||
|
priorities := make([]collectorSource, 0, 4)
|
||||||
|
|
||||||
|
if caps.hasNvidiaSmi && !caps.hasTegrastats {
|
||||||
|
if nvml, _ := utils.GetEnv("NVML"); nvml == "true" {
|
||||||
|
priorities = append(priorities, collectorSourceNVML, collectorSourceNvidiaSMI)
|
||||||
|
} else {
|
||||||
|
priorities = append(priorities, collectorSourceNvidiaSMI)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if caps.hasRocmSmi {
|
||||||
|
if val, _ := utils.GetEnv("AMD_SYSFS"); val == "true" {
|
||||||
|
priorities = append(priorities, collectorSourceAmdSysfs)
|
||||||
|
} else {
|
||||||
|
priorities = append(priorities, collectorSourceRocmSMI)
|
||||||
|
}
|
||||||
|
} else if caps.hasAmdSysfs {
|
||||||
|
priorities = append(priorities, collectorSourceAmdSysfs)
|
||||||
|
}
|
||||||
|
|
||||||
|
if caps.hasIntelGpuTop {
|
||||||
|
priorities = append(priorities, collectorSourceIntelGpuTop)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apple collectors are currently opt-in only for testing.
|
||||||
|
// Enable them with GPU_COLLECTOR=macmon or GPU_COLLECTOR=powermetrics.
|
||||||
|
// TODO: uncomment below when Apple collectors are confirmed to be working.
|
||||||
|
//
|
||||||
|
// Prefer macmon on macOS (no sudo). Fall back to powermetrics if present.
|
||||||
|
// if caps.hasMacmon {
|
||||||
|
// priorities = append(priorities, collectorSourceMacmon)
|
||||||
|
// } else if caps.hasPowermetrics {
|
||||||
|
// priorities = append(priorities, collectorSourcePowermetrics)
|
||||||
|
// }
|
||||||
|
|
||||||
|
// Keep nvtop as a last resort only when no vendor collector exists.
|
||||||
|
if len(priorities) == 0 && caps.hasNvtop {
|
||||||
|
priorities = append(priorities, collectorSourceNVTop)
|
||||||
|
}
|
||||||
|
return priorities
|
||||||
|
}
|
||||||
|
|
||||||
// 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" {
|
if skipGPU, _ := utils.GetEnv("SKIP_GPU"); skipGPU == "true" {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
var gm GPUManager
|
var gm GPUManager
|
||||||
if err := gm.detectGPUs(); err != nil {
|
caps := gm.discoverGpuCapabilities()
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
gm.GpuDataMap = make(map[string]*system.GPUData)
|
gm.GpuDataMap = make(map[string]*system.GPUData)
|
||||||
|
|
||||||
if gm.nvidiaSmi {
|
// Jetson devices should always use tegrastats (ignore GPU_COLLECTOR).
|
||||||
gm.startCollector(nvidiaSmiCmd)
|
if caps.hasTegrastats {
|
||||||
|
gm.startTegraStatsCollector("3700")
|
||||||
|
return &gm, nil
|
||||||
}
|
}
|
||||||
if gm.rocmSmi {
|
|
||||||
gm.startCollector(rocmSmiCmd)
|
// Respect explicit collector selection before capability auto-detection.
|
||||||
|
if collectorConfig, ok := utils.GetEnv("GPU_COLLECTOR"); ok && strings.TrimSpace(collectorConfig) != "" {
|
||||||
|
priorities := parseCollectorPriority(collectorConfig)
|
||||||
|
if gm.startCollectorsByPriority(priorities, caps) == 0 {
|
||||||
|
return nil, fmt.Errorf("no configured GPU collectors are available")
|
||||||
}
|
}
|
||||||
if gm.tegrastats {
|
return &gm, nil
|
||||||
gm.startCollector(tegraStatsCmd)
|
|
||||||
}
|
}
|
||||||
if gm.intelGpuStats {
|
|
||||||
gm.startCollector(intelGpuStatsCmd)
|
if !hasAnyGpuCollector(caps) {
|
||||||
|
return nil, fmt.Errorf(noGPUFoundMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// auto-detect and start collectors when GPU_COLLECTOR is unset.
|
||||||
|
if gm.startCollectorsByPriority(gm.resolveLegacyCollectorPriority(caps), caps) == 0 {
|
||||||
|
return nil, fmt.Errorf(noGPUFoundMsg)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &gm, nil
|
return &gm, nil
|
||||||
|
|||||||
303
agent/gpu_amd_linux.go
Normal file
303
agent/gpu_amd_linux.go
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/agent/utils"
|
||||||
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
)
|
||||||
|
|
||||||
|
var amdgpuNameCache = struct {
|
||||||
|
sync.RWMutex
|
||||||
|
hits map[string]string
|
||||||
|
misses map[string]struct{}
|
||||||
|
}{
|
||||||
|
hits: make(map[string]string),
|
||||||
|
misses: make(map[string]struct{}),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 := utils.ReadStringFileLimited(vendorPath, 64)
|
||||||
|
if err == nil && 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 {
|
||||||
|
sysfsPollInterval := 3000 * time.Millisecond
|
||||||
|
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(sysfsPollInterval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isAmdGpu checks whether a DRM card path belongs to AMD vendor ID 0x1002.
|
||||||
|
func isAmdGpu(cardPath string) bool {
|
||||||
|
vendor, err := utils.ReadStringFileLimited(filepath.Join(cardPath, "device/vendor"), 64)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return 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"))
|
||||||
|
// if gtt is present, add it to the memory used and total (https://github.com/henrygd/beszel/issues/1569#issuecomment-3837640484)
|
||||||
|
if gttUsed, err := readSysfsFloat(filepath.Join(devicePath, "mem_info_gtt_used")); err == nil && gttUsed > 0 {
|
||||||
|
if gttTotal, err := readSysfsFloat(filepath.Join(devicePath, "mem_info_gtt_total")); err == nil {
|
||||||
|
memUsed += gttUsed
|
||||||
|
memTotal += gttTotal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = utils.BytesToMegabytes(memUsed)
|
||||||
|
gpu.MemoryTotal = utils.BytesToMegabytes(memTotal)
|
||||||
|
gpu.Temperature = temp
|
||||||
|
gpu.Power += power
|
||||||
|
gpu.Count++
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// readSysfsFloat reads and parses a numeric value from a sysfs file.
|
||||||
|
func readSysfsFloat(path string) (float64, error) {
|
||||||
|
val, err := utils.ReadStringFileLimited(path, 64)
|
||||||
|
if err != nil {
|
||||||
|
slog.Debug("Failed to read sysfs value", "path", path, "error", err)
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return strconv.ParseFloat(val, 64)
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeHexID normalizes hex IDs by trimming spaces, lowercasing, and dropping 0x.
|
||||||
|
func normalizeHexID(id string) string {
|
||||||
|
return strings.TrimPrefix(strings.ToLower(strings.TrimSpace(id)), "0x")
|
||||||
|
}
|
||||||
|
|
||||||
|
// cacheKeyForAmdgpu builds the cache key for a device and optional revision.
|
||||||
|
func cacheKeyForAmdgpu(deviceID, revisionID string) string {
|
||||||
|
if revisionID != "" {
|
||||||
|
return deviceID + ":" + revisionID
|
||||||
|
}
|
||||||
|
return deviceID
|
||||||
|
}
|
||||||
|
|
||||||
|
// lookupAmdgpuNameInFile resolves an AMDGPU name from amdgpu.ids by device/revision.
|
||||||
|
func lookupAmdgpuNameInFile(deviceID, revisionID, filePath string) (name string, exact bool, found bool) {
|
||||||
|
file, err := os.Open(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return "", false, false
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
var byDevice string
|
||||||
|
scanner := bufio.NewScanner(file)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(line, ",", 3)
|
||||||
|
if len(parts) != 3 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
dev := normalizeHexID(parts[0])
|
||||||
|
rev := normalizeHexID(parts[1])
|
||||||
|
productName := strings.TrimSpace(parts[2])
|
||||||
|
if dev == "" || productName == "" || dev != deviceID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if byDevice == "" {
|
||||||
|
byDevice = productName
|
||||||
|
}
|
||||||
|
if revisionID != "" && rev == revisionID {
|
||||||
|
return productName, true, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if byDevice != "" {
|
||||||
|
return byDevice, false, true
|
||||||
|
}
|
||||||
|
return "", false, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// getCachedAmdgpuName returns cached hit/miss status for the given device/revision.
|
||||||
|
func getCachedAmdgpuName(deviceID, revisionID string) (name string, found bool, done bool) {
|
||||||
|
// Build the list of cache keys to check. We always look up the exact device+revision key.
|
||||||
|
// When revisionID is set, we also look up deviceID alone, since the cache may store a
|
||||||
|
// device-only fallback when we couldn't resolve the exact revision.
|
||||||
|
keys := []string{cacheKeyForAmdgpu(deviceID, revisionID)}
|
||||||
|
if revisionID != "" {
|
||||||
|
keys = append(keys, deviceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
knownMisses := 0
|
||||||
|
amdgpuNameCache.RLock()
|
||||||
|
defer amdgpuNameCache.RUnlock()
|
||||||
|
for _, key := range keys {
|
||||||
|
if name, ok := amdgpuNameCache.hits[key]; ok {
|
||||||
|
return name, true, true
|
||||||
|
}
|
||||||
|
if _, ok := amdgpuNameCache.misses[key]; ok {
|
||||||
|
knownMisses++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// done=true means "don't bother doing slow lookup": we either found a name (above) or
|
||||||
|
// every key we checked was already a known miss, so we've tried before and failed.
|
||||||
|
return "", false, knownMisses == len(keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeAmdgpuName trims standard suffixes from AMDGPU product names.
|
||||||
|
func normalizeAmdgpuName(name string) string {
|
||||||
|
for _, suffix := range []string{" Graphics", " Series"} {
|
||||||
|
name = strings.TrimSuffix(name, suffix)
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
// cacheAmdgpuName stores a resolved AMDGPU name in the lookup cache.
|
||||||
|
func cacheAmdgpuName(deviceID, revisionID, name string, exact bool) {
|
||||||
|
name = normalizeAmdgpuName(name)
|
||||||
|
amdgpuNameCache.Lock()
|
||||||
|
defer amdgpuNameCache.Unlock()
|
||||||
|
if exact && revisionID != "" {
|
||||||
|
amdgpuNameCache.hits[cacheKeyForAmdgpu(deviceID, revisionID)] = name
|
||||||
|
}
|
||||||
|
amdgpuNameCache.hits[deviceID] = name
|
||||||
|
}
|
||||||
|
|
||||||
|
// cacheMissingAmdgpuName records unresolved device/revision lookups.
|
||||||
|
func cacheMissingAmdgpuName(deviceID, revisionID string) {
|
||||||
|
amdgpuNameCache.Lock()
|
||||||
|
defer amdgpuNameCache.Unlock()
|
||||||
|
amdgpuNameCache.misses[deviceID] = struct{}{}
|
||||||
|
if revisionID != "" {
|
||||||
|
amdgpuNameCache.misses[cacheKeyForAmdgpu(deviceID, revisionID)] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 := utils.ReadStringFileLimited(filepath.Join(devicePath, "product_name"), 128); err == nil {
|
||||||
|
return prod
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read PCI device ID and look it up
|
||||||
|
if deviceID, err := utils.ReadStringFileLimited(filepath.Join(devicePath, "device"), 64); err == nil {
|
||||||
|
id := normalizeHexID(deviceID)
|
||||||
|
revision := ""
|
||||||
|
if rev, revErr := utils.ReadStringFileLimited(filepath.Join(devicePath, "revision"), 64); revErr == nil {
|
||||||
|
revision = normalizeHexID(rev)
|
||||||
|
}
|
||||||
|
|
||||||
|
if name, found, done := getCachedAmdgpuName(id, revision); found {
|
||||||
|
return name
|
||||||
|
} else if !done {
|
||||||
|
if name, exact, ok := lookupAmdgpuNameInFile(id, revision, "/usr/share/libdrm/amdgpu.ids"); ok {
|
||||||
|
cacheAmdgpuName(id, revision, name, exact)
|
||||||
|
return normalizeAmdgpuName(name)
|
||||||
|
}
|
||||||
|
cacheMissingAmdgpuName(id, revision)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("AMD GPU (%s)", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return "AMD GPU"
|
||||||
|
}
|
||||||
265
agent/gpu_amd_linux_test.go
Normal file
265
agent/gpu_amd_linux_test.go
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/agent/utils"
|
||||||
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNormalizeHexID(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"0x1002", "1002"},
|
||||||
|
{"C2", "c2"},
|
||||||
|
{" 15BF ", "15bf"},
|
||||||
|
{"0x15bf", "15bf"},
|
||||||
|
{"", ""},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
subName := tt.in
|
||||||
|
if subName == "" {
|
||||||
|
subName = "empty_string"
|
||||||
|
}
|
||||||
|
t.Run(subName, func(t *testing.T) {
|
||||||
|
got := normalizeHexID(tt.in)
|
||||||
|
assert.Equal(t, tt.want, got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCacheKeyForAmdgpu(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
deviceID string
|
||||||
|
revisionID string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"1114", "c2", "1114:c2"},
|
||||||
|
{"15bf", "", "15bf"},
|
||||||
|
{"1506", "c1", "1506:c1"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
got := cacheKeyForAmdgpu(tt.deviceID, tt.revisionID)
|
||||||
|
assert.Equal(t, tt.want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadSysfsFloat(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
validPath := filepath.Join(dir, "val")
|
||||||
|
require.NoError(t, os.WriteFile(validPath, []byte(" 42.5 \n"), 0o644))
|
||||||
|
got, err := readSysfsFloat(validPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 42.5, got)
|
||||||
|
|
||||||
|
// Integer and scientific
|
||||||
|
sciPath := filepath.Join(dir, "sci")
|
||||||
|
require.NoError(t, os.WriteFile(sciPath, []byte("1e2"), 0o644))
|
||||||
|
got, err = readSysfsFloat(sciPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 100.0, got)
|
||||||
|
|
||||||
|
// Missing file
|
||||||
|
_, err = readSysfsFloat(filepath.Join(dir, "missing"))
|
||||||
|
require.Error(t, err)
|
||||||
|
|
||||||
|
// Invalid content
|
||||||
|
badPath := filepath.Join(dir, "bad")
|
||||||
|
require.NoError(t, os.WriteFile(badPath, []byte("not a number"), 0o644))
|
||||||
|
_, err = readSysfsFloat(badPath)
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsAmdGpu(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
deviceDir := filepath.Join(dir, "device")
|
||||||
|
require.NoError(t, os.MkdirAll(deviceDir, 0o755))
|
||||||
|
|
||||||
|
// AMD vendor 0x1002 -> true
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(deviceDir, "vendor"), []byte("0x1002\n"), 0o644))
|
||||||
|
assert.True(t, isAmdGpu(dir), "vendor 0x1002 should be AMD")
|
||||||
|
|
||||||
|
// Non-AMD vendor -> false
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(deviceDir, "vendor"), []byte("0x10de\n"), 0o644))
|
||||||
|
assert.False(t, isAmdGpu(dir), "vendor 0x10de should not be AMD")
|
||||||
|
|
||||||
|
// Missing vendor file -> false
|
||||||
|
require.NoError(t, os.Remove(filepath.Join(deviceDir, "vendor")))
|
||||||
|
assert.False(t, isAmdGpu(dir), "missing vendor file should be false")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAmdgpuNameCacheRoundTrip(t *testing.T) {
|
||||||
|
// Cache a name and retrieve it (unique key to avoid affecting other tests)
|
||||||
|
deviceID, revisionID := "cachedev99", "00"
|
||||||
|
cacheAmdgpuName(deviceID, revisionID, "AMD Test GPU 99 Graphics", true)
|
||||||
|
|
||||||
|
name, found, done := getCachedAmdgpuName(deviceID, revisionID)
|
||||||
|
assert.True(t, found)
|
||||||
|
assert.True(t, done)
|
||||||
|
assert.Equal(t, "AMD Test GPU 99", name)
|
||||||
|
|
||||||
|
// Device-only key also stored
|
||||||
|
name2, found2, _ := getCachedAmdgpuName(deviceID, "")
|
||||||
|
assert.True(t, found2)
|
||||||
|
assert.Equal(t, "AMD Test GPU 99", name2)
|
||||||
|
|
||||||
|
// Cache a miss
|
||||||
|
cacheMissingAmdgpuName("missedev99", "ab")
|
||||||
|
_, found3, done3 := getCachedAmdgpuName("missedev99", "ab")
|
||||||
|
assert.False(t, found3)
|
||||||
|
assert.True(t, done3, "done should be true so caller skips file lookup")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateAmdGpuDataWithFakeSysfs(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
writeGTT bool
|
||||||
|
wantMemoryUsed float64
|
||||||
|
wantMemoryTotal float64
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "sums vram and gtt when gtt is present",
|
||||||
|
writeGTT: true,
|
||||||
|
wantMemoryUsed: utils.BytesToMegabytes(1073741824 + 536870912),
|
||||||
|
wantMemoryTotal: utils.BytesToMegabytes(2147483648 + 4294967296),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "falls back to vram when gtt is missing",
|
||||||
|
writeGTT: false,
|
||||||
|
wantMemoryUsed: utils.BytesToMegabytes(1073741824),
|
||||||
|
wantMemoryTotal: utils.BytesToMegabytes(2147483648),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cardPath := filepath.Join(dir, "card0")
|
||||||
|
devicePath := filepath.Join(cardPath, "device")
|
||||||
|
hwmonPath := filepath.Join(devicePath, "hwmon", "hwmon0")
|
||||||
|
require.NoError(t, os.MkdirAll(hwmonPath, 0o755))
|
||||||
|
|
||||||
|
write := func(name, content string) {
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(devicePath, name), []byte(content), 0o644))
|
||||||
|
}
|
||||||
|
write("vendor", "0x1002")
|
||||||
|
write("device", "0x1506")
|
||||||
|
write("revision", "0xc1")
|
||||||
|
write("gpu_busy_percent", "25")
|
||||||
|
write("mem_info_vram_used", "1073741824")
|
||||||
|
write("mem_info_vram_total", "2147483648")
|
||||||
|
if tt.writeGTT {
|
||||||
|
write("mem_info_gtt_used", "536870912")
|
||||||
|
write("mem_info_gtt_total", "4294967296")
|
||||||
|
}
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(hwmonPath, "temp1_input"), []byte("45000"), 0o644))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(hwmonPath, "power1_input"), []byte("20000000"), 0o644))
|
||||||
|
|
||||||
|
// Pre-cache name so getAmdGpuName returns a known value (it uses system amdgpu.ids path)
|
||||||
|
cacheAmdgpuName("1506", "c1", "AMD Radeon 610M Graphics", true)
|
||||||
|
|
||||||
|
gm := &GPUManager{GpuDataMap: make(map[string]*system.GPUData)}
|
||||||
|
ok := gm.updateAmdGpuData(cardPath)
|
||||||
|
require.True(t, ok)
|
||||||
|
|
||||||
|
gpu, ok := gm.GpuDataMap["card0"]
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, "AMD Radeon 610M", gpu.Name)
|
||||||
|
assert.Equal(t, 25.0, gpu.Usage)
|
||||||
|
assert.Equal(t, tt.wantMemoryUsed, gpu.MemoryUsed)
|
||||||
|
assert.Equal(t, tt.wantMemoryTotal, gpu.MemoryTotal)
|
||||||
|
assert.Equal(t, 45.0, gpu.Temperature)
|
||||||
|
assert.Equal(t, 20.0, gpu.Power)
|
||||||
|
assert.Equal(t, 1.0, gpu.Count)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLookupAmdgpuNameInFile(t *testing.T) {
|
||||||
|
idsPath := filepath.Join("test-data", "amdgpu.ids")
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
deviceID string
|
||||||
|
revisionID string
|
||||||
|
wantName string
|
||||||
|
wantExact bool
|
||||||
|
wantFound bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "exact device and revision match",
|
||||||
|
deviceID: "1114",
|
||||||
|
revisionID: "c2",
|
||||||
|
wantName: "AMD Radeon 860M Graphics",
|
||||||
|
wantExact: true,
|
||||||
|
wantFound: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "exact match 15BF revision 01 returns 760M",
|
||||||
|
deviceID: "15bf",
|
||||||
|
revisionID: "01",
|
||||||
|
wantName: "AMD Radeon 760M Graphics",
|
||||||
|
wantExact: true,
|
||||||
|
wantFound: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "exact match 15BF revision 00 returns 780M",
|
||||||
|
deviceID: "15bf",
|
||||||
|
revisionID: "00",
|
||||||
|
wantName: "AMD Radeon 780M Graphics",
|
||||||
|
wantExact: true,
|
||||||
|
wantFound: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "device-only match returns first entry for device",
|
||||||
|
deviceID: "1506",
|
||||||
|
revisionID: "",
|
||||||
|
wantName: "AMD Radeon 610M",
|
||||||
|
wantExact: false,
|
||||||
|
wantFound: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown device not found",
|
||||||
|
deviceID: "dead",
|
||||||
|
revisionID: "00",
|
||||||
|
wantName: "",
|
||||||
|
wantExact: false,
|
||||||
|
wantFound: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
gotName, gotExact, gotFound := lookupAmdgpuNameInFile(tt.deviceID, tt.revisionID, idsPath)
|
||||||
|
assert.Equal(t, tt.wantName, gotName, "name")
|
||||||
|
assert.Equal(t, tt.wantExact, gotExact, "exact")
|
||||||
|
assert.Equal(t, tt.wantFound, gotFound, "found")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetAmdGpuNameFromIdsFile(t *testing.T) {
|
||||||
|
// Test that getAmdGpuName resolves a name when we can't inject the ids path.
|
||||||
|
// We only verify behavior when product_name is missing and device/revision
|
||||||
|
// would be read from sysfs; the actual lookup uses /usr/share/libdrm/amdgpu.ids.
|
||||||
|
// So this test focuses on normalizeAmdgpuName and that lookupAmdgpuNameInFile
|
||||||
|
// returns the expected name for our test-data file.
|
||||||
|
idsPath := filepath.Join("test-data", "amdgpu.ids")
|
||||||
|
name, exact, found := lookupAmdgpuNameInFile("1435", "ae", idsPath)
|
||||||
|
require.True(t, found)
|
||||||
|
require.True(t, exact)
|
||||||
|
assert.Equal(t, "AMD Custom GPU 0932", name)
|
||||||
|
assert.Equal(t, "AMD Custom GPU 0932", normalizeAmdgpuName(name))
|
||||||
|
|
||||||
|
// " Graphics" suffix is trimmed by normalizeAmdgpuName
|
||||||
|
name2 := "AMD Radeon 860M Graphics"
|
||||||
|
assert.Equal(t, "AMD Radeon 860M", normalizeAmdgpuName(name2))
|
||||||
|
}
|
||||||
15
agent/gpu_amd_unsupported.go
Normal file
15
agent/gpu_amd_unsupported.go
Normal 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
|
||||||
|
}
|
||||||
257
agent/gpu_darwin.go
Normal file
257
agent/gpu_darwin.go
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
//go:build darwin
|
||||||
|
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/agent/utils"
|
||||||
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// powermetricsSampleIntervalMs is the sampling interval passed to powermetrics (-i).
|
||||||
|
powermetricsSampleIntervalMs = 500
|
||||||
|
// powermetricsPollInterval is how often we run powermetrics to collect a new sample.
|
||||||
|
powermetricsPollInterval = 2 * time.Second
|
||||||
|
// macmonIntervalMs is the sampling interval passed to macmon pipe (-i), in milliseconds.
|
||||||
|
macmonIntervalMs = 2500
|
||||||
|
)
|
||||||
|
|
||||||
|
const appleGPUID = "0"
|
||||||
|
|
||||||
|
// startPowermetricsCollector runs powermetrics --samplers gpu_power in a loop and updates
|
||||||
|
// GPU usage and power. Requires root (sudo) on macOS. A single logical GPU is reported as id "0".
|
||||||
|
func (gm *GPUManager) startPowermetricsCollector() {
|
||||||
|
// Ensure single GPU entry for Apple GPU
|
||||||
|
if _, ok := gm.GpuDataMap[appleGPUID]; !ok {
|
||||||
|
gm.GpuDataMap[appleGPUID] = &system.GPUData{Name: "Apple GPU"}
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
failures := 0
|
||||||
|
for {
|
||||||
|
if err := gm.collectPowermetrics(); err != nil {
|
||||||
|
failures++
|
||||||
|
if failures > maxFailureRetries {
|
||||||
|
slog.Warn("powermetrics GPU collector failed repeatedly, stopping", "err", err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
slog.Warn("Error collecting macOS GPU data via powermetrics (may require sudo)", "err", err)
|
||||||
|
time.Sleep(retryWaitTime)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
failures = 0
|
||||||
|
time.Sleep(powermetricsPollInterval)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// collectPowermetrics runs powermetrics once and parses GPU usage and power from its output.
|
||||||
|
func (gm *GPUManager) collectPowermetrics() error {
|
||||||
|
interval := strconv.Itoa(powermetricsSampleIntervalMs)
|
||||||
|
cmd := exec.Command(powermetricsCmd, "--samplers", "gpu_power", "-i", interval, "-n", "1")
|
||||||
|
cmd.Stderr = nil
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !gm.parsePowermetricsData(out) {
|
||||||
|
return errNoValidData
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsePowermetricsData parses powermetrics gpu_power output and updates GpuDataMap["0"].
|
||||||
|
// Example output:
|
||||||
|
//
|
||||||
|
// **** GPU usage ****
|
||||||
|
// GPU HW active frequency: 444 MHz
|
||||||
|
// GPU HW active residency: 0.97% (444 MHz: .97% ...
|
||||||
|
// GPU idle residency: 99.03%
|
||||||
|
// GPU Power: 4 mW
|
||||||
|
func (gm *GPUManager) parsePowermetricsData(output []byte) bool {
|
||||||
|
var idleResidency, powerMW float64
|
||||||
|
var gotIdle, gotPower bool
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(bytes.NewReader(output))
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
if strings.HasPrefix(line, "GPU idle residency:") {
|
||||||
|
// "GPU idle residency: 99.03%"
|
||||||
|
fields := strings.Fields(strings.TrimPrefix(line, "GPU idle residency:"))
|
||||||
|
if len(fields) >= 1 {
|
||||||
|
pct := strings.TrimSuffix(fields[0], "%")
|
||||||
|
if v, err := strconv.ParseFloat(pct, 64); err == nil {
|
||||||
|
idleResidency = v
|
||||||
|
gotIdle = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if strings.HasPrefix(line, "GPU Power:") {
|
||||||
|
// "GPU Power: 4 mW"
|
||||||
|
fields := strings.Fields(strings.TrimPrefix(line, "GPU Power:"))
|
||||||
|
if len(fields) >= 1 {
|
||||||
|
if v, err := strconv.ParseFloat(fields[0], 64); err == nil {
|
||||||
|
powerMW = v
|
||||||
|
gotPower = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !gotIdle && !gotPower {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
gm.Lock()
|
||||||
|
defer gm.Unlock()
|
||||||
|
|
||||||
|
if _, ok := gm.GpuDataMap[appleGPUID]; !ok {
|
||||||
|
gm.GpuDataMap[appleGPUID] = &system.GPUData{Name: "Apple GPU"}
|
||||||
|
}
|
||||||
|
gpu := gm.GpuDataMap[appleGPUID]
|
||||||
|
|
||||||
|
if gotIdle {
|
||||||
|
// Usage = 100 - idle residency (e.g. 100 - 99.03 = 0.97%)
|
||||||
|
gpu.Usage += 100 - idleResidency
|
||||||
|
}
|
||||||
|
if gotPower {
|
||||||
|
// mW -> W
|
||||||
|
gpu.Power += powerMW / milliwattsInAWatt
|
||||||
|
}
|
||||||
|
gpu.Count++
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// startMacmonCollector runs `macmon pipe` in a loop and parses one JSON object per line.
|
||||||
|
// This collector does not require sudo. A single logical GPU is reported as id "0".
|
||||||
|
func (gm *GPUManager) startMacmonCollector() {
|
||||||
|
if _, ok := gm.GpuDataMap[appleGPUID]; !ok {
|
||||||
|
gm.GpuDataMap[appleGPUID] = &system.GPUData{Name: "Apple GPU"}
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
failures := 0
|
||||||
|
for {
|
||||||
|
if err := gm.collectMacmonPipe(); err != nil {
|
||||||
|
failures++
|
||||||
|
if failures > maxFailureRetries {
|
||||||
|
slog.Warn("macmon GPU collector failed repeatedly, stopping", "err", err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
slog.Warn("Error collecting macOS GPU data via macmon", "err", err)
|
||||||
|
time.Sleep(retryWaitTime)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
failures = 0
|
||||||
|
// `macmon pipe` is long-running; if it returns, wait a bit before restarting.
|
||||||
|
time.Sleep(retryWaitTime)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
type macmonTemp struct {
|
||||||
|
GPUTempAvg float64 `json:"gpu_temp_avg"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type macmonSample struct {
|
||||||
|
GPUPower float64 `json:"gpu_power"` // watts (macmon reports fractional values)
|
||||||
|
GPURAMPower float64 `json:"gpu_ram_power"` // watts
|
||||||
|
GPUUsage []float64 `json:"gpu_usage"` // [freq_mhz, usage] where usage is typically 0..1
|
||||||
|
Temp macmonTemp `json:"temp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gm *GPUManager) collectMacmonPipe() (err error) {
|
||||||
|
macmonPath, err := utils.LookPathHomebrew(macmonCmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cmd := exec.Command(macmonPath, "pipe", "-i", strconv.Itoa(macmonIntervalMs))
|
||||||
|
// Avoid blocking if macmon 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() {
|
||||||
|
_ = 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 hadSample bool
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := bytes.TrimSpace(scanner.Bytes())
|
||||||
|
if len(line) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if gm.parseMacmonLine(line) {
|
||||||
|
hadSample = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if scanErr := scanner.Err(); scanErr != nil {
|
||||||
|
return scanErr
|
||||||
|
}
|
||||||
|
if !hadSample {
|
||||||
|
return errNoValidData
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseMacmonLine parses a single macmon JSON line and updates Apple GPU metrics.
|
||||||
|
func (gm *GPUManager) parseMacmonLine(line []byte) bool {
|
||||||
|
var sample macmonSample
|
||||||
|
if err := json.Unmarshal(line, &sample); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
usage := 0.0
|
||||||
|
if len(sample.GPUUsage) >= 2 {
|
||||||
|
usage = sample.GPUUsage[1]
|
||||||
|
// Heuristic: macmon typically reports 0..1; convert to percentage.
|
||||||
|
if usage <= 1.0 {
|
||||||
|
usage *= 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Consider the line valid if it contains at least one GPU metric.
|
||||||
|
if usage == 0 && sample.GPUPower == 0 && sample.Temp.GPUTempAvg == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
gm.Lock()
|
||||||
|
defer gm.Unlock()
|
||||||
|
|
||||||
|
gpu, ok := gm.GpuDataMap[appleGPUID]
|
||||||
|
if !ok {
|
||||||
|
gpu = &system.GPUData{Name: "Apple GPU"}
|
||||||
|
gm.GpuDataMap[appleGPUID] = gpu
|
||||||
|
}
|
||||||
|
gpu.Temperature = sample.Temp.GPUTempAvg
|
||||||
|
gpu.Usage += usage
|
||||||
|
// macmon reports power in watts; include VRAM power if present.
|
||||||
|
gpu.Power += sample.GPUPower + sample.GPURAMPower
|
||||||
|
gpu.Count++
|
||||||
|
return true
|
||||||
|
}
|
||||||
81
agent/gpu_darwin_test.go
Normal file
81
agent/gpu_darwin_test.go
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
//go:build darwin
|
||||||
|
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParsePowermetricsData(t *testing.T) {
|
||||||
|
input := `
|
||||||
|
Machine model: Mac14,10
|
||||||
|
OS version: 25D125
|
||||||
|
|
||||||
|
*** Sampled system activity (Sat Feb 14 00:42:06 2026 -0500) (503.05ms elapsed) ***
|
||||||
|
|
||||||
|
**** GPU usage ****
|
||||||
|
|
||||||
|
GPU HW active frequency: 444 MHz
|
||||||
|
GPU HW active residency: 0.97% (444 MHz: .97% 612 MHz: 0% 808 MHz: 0% 968 MHz: 0% 1110 MHz: 0% 1236 MHz: 0% 1338 MHz: 0% 1398 MHz: 0%)
|
||||||
|
GPU SW requested state: (P1 : 100% P2 : 0% P3 : 0% P4 : 0% P5 : 0% P6 : 0% P7 : 0% P8 : 0%)
|
||||||
|
GPU idle residency: 99.03%
|
||||||
|
GPU Power: 4 mW
|
||||||
|
`
|
||||||
|
gm := &GPUManager{
|
||||||
|
GpuDataMap: make(map[string]*system.GPUData),
|
||||||
|
}
|
||||||
|
valid := gm.parsePowermetricsData([]byte(input))
|
||||||
|
require.True(t, valid)
|
||||||
|
|
||||||
|
g0, ok := gm.GpuDataMap["0"]
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, "Apple GPU", g0.Name)
|
||||||
|
// Usage = 100 - 99.03 = 0.97
|
||||||
|
assert.InDelta(t, 0.97, g0.Usage, 0.01)
|
||||||
|
// 4 mW -> 0.004 W
|
||||||
|
assert.InDelta(t, 0.004, g0.Power, 0.0001)
|
||||||
|
assert.Equal(t, 1.0, g0.Count)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParsePowermetricsDataPartial(t *testing.T) {
|
||||||
|
// Only power line (e.g. older macOS or different sampler output)
|
||||||
|
input := `
|
||||||
|
**** GPU usage ****
|
||||||
|
GPU Power: 120 mW
|
||||||
|
`
|
||||||
|
gm := &GPUManager{
|
||||||
|
GpuDataMap: make(map[string]*system.GPUData),
|
||||||
|
}
|
||||||
|
valid := gm.parsePowermetricsData([]byte(input))
|
||||||
|
require.True(t, valid)
|
||||||
|
|
||||||
|
g0, ok := gm.GpuDataMap["0"]
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, "Apple GPU", g0.Name)
|
||||||
|
assert.InDelta(t, 0.12, g0.Power, 0.001)
|
||||||
|
assert.Equal(t, 1.0, g0.Count)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseMacmonLine(t *testing.T) {
|
||||||
|
input := `{"all_power":0.6468324661254883,"ane_power":0.0,"cpu_power":0.6359732151031494,"ecpu_usage":[2061,0.1726151406764984],"gpu_power":0.010859241709113121,"gpu_ram_power":0.000965250947047025,"gpu_usage":[503,0.013633215799927711],"memory":{"ram_total":17179869184,"ram_usage":12322914304,"swap_total":0,"swap_usage":0},"pcpu_usage":[1248,0.11792058497667313],"ram_power":0.14885640144348145,"sys_power":10.4955415725708,"temp":{"cpu_temp_avg":23.041261672973633,"gpu_temp_avg":29.44516944885254},"timestamp":"2026-02-17T19:34:27.942556+00:00"}`
|
||||||
|
|
||||||
|
gm := &GPUManager{
|
||||||
|
GpuDataMap: make(map[string]*system.GPUData),
|
||||||
|
}
|
||||||
|
valid := gm.parseMacmonLine([]byte(input))
|
||||||
|
require.True(t, valid)
|
||||||
|
|
||||||
|
g0, ok := gm.GpuDataMap["0"]
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, "Apple GPU", g0.Name)
|
||||||
|
// macmon reports usage fraction 0..1; expect percent conversion.
|
||||||
|
assert.InDelta(t, 1.3633, g0.Usage, 0.05)
|
||||||
|
// power includes gpu_power + gpu_ram_power
|
||||||
|
assert.InDelta(t, 0.011824, g0.Power, 0.0005)
|
||||||
|
assert.InDelta(t, 29.445, g0.Temperature, 0.01)
|
||||||
|
assert.Equal(t, 1.0, g0.Count)
|
||||||
|
}
|
||||||
9
agent/gpu_darwin_unsupported.go
Normal file
9
agent/gpu_darwin_unsupported.go
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
//go:build !darwin
|
||||||
|
|
||||||
|
package agent
|
||||||
|
|
||||||
|
// startPowermetricsCollector is a no-op on non-darwin platforms; the real implementation is in gpu_darwin.go.
|
||||||
|
func (gm *GPUManager) startPowermetricsCollector() {}
|
||||||
|
|
||||||
|
// startMacmonCollector is a no-op on non-darwin platforms; the real implementation is in gpu_darwin.go.
|
||||||
|
func (gm *GPUManager) startMacmonCollector() {}
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/agent/utils"
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -27,10 +28,11 @@ func (gm *GPUManager) updateIntelFromStats(sample *intelGpuStats) bool {
|
|||||||
defer gm.Unlock()
|
defer gm.Unlock()
|
||||||
|
|
||||||
// only one gpu for now - cmd doesn't provide all by default
|
// only one gpu for now - cmd doesn't provide all by default
|
||||||
gpuData, ok := gm.GpuDataMap["0"]
|
id := "i0" // prefix with i to avoid conflicts with nvidia card ids
|
||||||
|
gpuData, ok := gm.GpuDataMap[id]
|
||||||
if !ok {
|
if !ok {
|
||||||
gpuData = &system.GPUData{Name: "GPU", Engines: make(map[string]float64)}
|
gpuData = &system.GPUData{Name: "GPU", Engines: make(map[string]float64)}
|
||||||
gm.GpuDataMap["0"] = gpuData
|
gm.GpuDataMap[id] = gpuData
|
||||||
}
|
}
|
||||||
|
|
||||||
gpuData.Power += sample.PowerGPU
|
gpuData.Power += sample.PowerGPU
|
||||||
@@ -49,7 +51,12 @@ func (gm *GPUManager) updateIntelFromStats(sample *intelGpuStats) bool {
|
|||||||
|
|
||||||
// collectIntelStats executes intel_gpu_top in text mode (-l) and parses the output
|
// collectIntelStats executes intel_gpu_top in text mode (-l) and parses the output
|
||||||
func (gm *GPUManager) collectIntelStats() (err error) {
|
func (gm *GPUManager) collectIntelStats() (err error) {
|
||||||
cmd := exec.Command(intelGpuStatsCmd, "-s", intelGpuStatsInterval, "-l")
|
// Build command arguments, optionally selecting a device via -d
|
||||||
|
args := []string{"-s", intelGpuStatsInterval, "-l"}
|
||||||
|
if dev, ok := utils.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
|
// Avoid blocking if intel_gpu_top writes to stderr
|
||||||
cmd.Stderr = io.Discard
|
cmd.Stderr = io.Discard
|
||||||
stdout, err := cmd.StdoutPipe()
|
stdout, err := cmd.StdoutPipe()
|
||||||
@@ -129,7 +136,9 @@ func (gm *GPUManager) parseIntelHeaders(header1 string, header2 string) (engineN
|
|||||||
powerIndex = -1 // Initialize to -1, will be set to actual index if found
|
powerIndex = -1 // Initialize to -1, will be set to actual index if found
|
||||||
// Collect engine names from header1
|
// Collect engine names from header1
|
||||||
for _, col := range h1 {
|
for _, col := range h1 {
|
||||||
key := strings.TrimRightFunc(col, func(r rune) bool { return r >= '0' && r <= '9' })
|
key := strings.TrimRightFunc(col, func(r rune) bool {
|
||||||
|
return (r >= '0' && r <= '9') || r == '/'
|
||||||
|
})
|
||||||
var friendly string
|
var friendly string
|
||||||
switch key {
|
switch key {
|
||||||
case "RCS":
|
case "RCS":
|
||||||
|
|||||||
224
agent/gpu_nvml.go
Normal file
224
agent/gpu_nvml.go
Normal 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
57
agent/gpu_nvml_linux.go
Normal 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
|
||||||
|
}
|
||||||
15
agent/gpu_nvml_unsupported.go
Normal file
15
agent/gpu_nvml_unsupported.go
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
//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() {}
|
||||||
25
agent/gpu_nvml_windows.go
Normal file
25
agent/gpu_nvml_windows.go
Normal 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
|
||||||
|
}
|
||||||
160
agent/gpu_nvtop.go
Normal file
160
agent/gpu_nvtop.go
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/agent/utils"
|
||||||
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
)
|
||||||
|
|
||||||
|
type nvtopSnapshot struct {
|
||||||
|
DeviceName string `json:"device_name"`
|
||||||
|
Temp *string `json:"temp"`
|
||||||
|
PowerDraw *string `json:"power_draw"`
|
||||||
|
GpuUtil *string `json:"gpu_util"`
|
||||||
|
MemTotal *string `json:"mem_total"`
|
||||||
|
MemUsed *string `json:"mem_used"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseNvtopNumber parses nvtop numeric strings with units (C/W/%).
|
||||||
|
func parseNvtopNumber(raw string) float64 {
|
||||||
|
cleaned := strings.TrimSpace(raw)
|
||||||
|
cleaned = strings.TrimSuffix(cleaned, "C")
|
||||||
|
cleaned = strings.TrimSuffix(cleaned, "W")
|
||||||
|
cleaned = strings.TrimSuffix(cleaned, "%")
|
||||||
|
val, _ := strconv.ParseFloat(cleaned, 64)
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseNvtopData parses a single nvtop JSON snapshot payload.
|
||||||
|
func (gm *GPUManager) parseNvtopData(output []byte) bool {
|
||||||
|
var snapshots []nvtopSnapshot
|
||||||
|
if err := json.Unmarshal(output, &snapshots); err != nil || len(snapshots) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return gm.updateNvtopSnapshots(snapshots)
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateNvtopSnapshots applies one decoded nvtop snapshot batch to GPU accumulators.
|
||||||
|
func (gm *GPUManager) updateNvtopSnapshots(snapshots []nvtopSnapshot) bool {
|
||||||
|
gm.Lock()
|
||||||
|
defer gm.Unlock()
|
||||||
|
|
||||||
|
valid := false
|
||||||
|
usedIDs := make(map[string]struct{}, len(snapshots))
|
||||||
|
for i, sample := range snapshots {
|
||||||
|
if sample.DeviceName == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
indexID := "n" + strconv.Itoa(i)
|
||||||
|
id := indexID
|
||||||
|
|
||||||
|
// nvtop ordering can change, so prefer reusing an existing slot with matching device name.
|
||||||
|
if existingByIndex, ok := gm.GpuDataMap[indexID]; ok && existingByIndex.Name != "" && existingByIndex.Name != sample.DeviceName {
|
||||||
|
for existingID, gpu := range gm.GpuDataMap {
|
||||||
|
if !strings.HasPrefix(existingID, "n") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, taken := usedIDs[existingID]; taken {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if gpu.Name == sample.DeviceName {
|
||||||
|
id = existingID
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := gm.GpuDataMap[id]; !ok {
|
||||||
|
gm.GpuDataMap[id] = &system.GPUData{Name: sample.DeviceName}
|
||||||
|
}
|
||||||
|
gpu := gm.GpuDataMap[id]
|
||||||
|
gpu.Name = sample.DeviceName
|
||||||
|
|
||||||
|
if sample.Temp != nil {
|
||||||
|
gpu.Temperature = parseNvtopNumber(*sample.Temp)
|
||||||
|
}
|
||||||
|
if sample.MemUsed != nil {
|
||||||
|
gpu.MemoryUsed = utils.BytesToMegabytes(parseNvtopNumber(*sample.MemUsed))
|
||||||
|
}
|
||||||
|
if sample.MemTotal != nil {
|
||||||
|
gpu.MemoryTotal = utils.BytesToMegabytes(parseNvtopNumber(*sample.MemTotal))
|
||||||
|
}
|
||||||
|
if sample.GpuUtil != nil {
|
||||||
|
gpu.Usage += parseNvtopNumber(*sample.GpuUtil)
|
||||||
|
}
|
||||||
|
if sample.PowerDraw != nil {
|
||||||
|
gpu.Power += parseNvtopNumber(*sample.PowerDraw)
|
||||||
|
}
|
||||||
|
gpu.Count++
|
||||||
|
usedIDs[id] = struct{}{}
|
||||||
|
valid = true
|
||||||
|
}
|
||||||
|
return valid
|
||||||
|
}
|
||||||
|
|
||||||
|
// collectNvtopStats runs nvtop loop mode and continuously decodes JSON snapshots.
|
||||||
|
func (gm *GPUManager) collectNvtopStats(interval string) error {
|
||||||
|
cmd := exec.Command(nvtopCmd, "-lP", "-d", interval)
|
||||||
|
stdout, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = stdout.Close()
|
||||||
|
if cmd.ProcessState == nil || !cmd.ProcessState.Exited() {
|
||||||
|
_ = cmd.Process.Kill()
|
||||||
|
}
|
||||||
|
_ = cmd.Wait()
|
||||||
|
}()
|
||||||
|
|
||||||
|
decoder := json.NewDecoder(stdout)
|
||||||
|
foundValid := false
|
||||||
|
for {
|
||||||
|
var snapshots []nvtopSnapshot
|
||||||
|
if err := decoder.Decode(&snapshots); err != nil {
|
||||||
|
if err == io.EOF {
|
||||||
|
if foundValid {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return errNoValidData
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if gm.updateNvtopSnapshots(snapshots) {
|
||||||
|
foundValid = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// startNvtopCollector starts nvtop collection with retry or fallback callback handling.
|
||||||
|
func (gm *GPUManager) startNvtopCollector(interval string, onFailure func()) {
|
||||||
|
go func() {
|
||||||
|
failures := 0
|
||||||
|
for {
|
||||||
|
if err := gm.collectNvtopStats(interval); err != nil {
|
||||||
|
if onFailure != nil {
|
||||||
|
slog.Warn("Error collecting GPU data via nvtop", "err", err)
|
||||||
|
onFailure()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
failures++
|
||||||
|
if failures > maxFailureRetries {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
slog.Warn("Error collecting GPU data via nvtop", "err", err)
|
||||||
|
time.Sleep(retryWaitTime)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
1057
agent/gpu_test.go
1057
agent/gpu_test.go
File diff suppressed because it is too large
Load Diff
205
agent/handlers.go
Normal file
205
agent/handlers.go
Normal 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)
|
||||||
|
}
|
||||||
111
agent/handlers_test.go
Normal file
111
agent/handlers_test.go
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
//go: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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
//go:build testing
|
//go:build testing
|
||||||
// +build testing
|
|
||||||
|
|
||||||
package health
|
package health
|
||||||
|
|
||||||
@@ -37,7 +36,6 @@ func TestHealth(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// This test uses synctest to simulate time passing.
|
// This test uses synctest to simulate time passing.
|
||||||
// NOTE: This test requires GOEXPERIMENT=synctest to run.
|
|
||||||
t.Run("check with simulated time", func(t *testing.T) {
|
t.Run("check with simulated time", func(t *testing.T) {
|
||||||
synctest.Test(t, func(t *testing.T) {
|
synctest.Test(t, func(t *testing.T) {
|
||||||
// Update the file to set the initial timestamp.
|
// Update the file to set the initial timestamp.
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.6" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
233
agent/mdraid_linux.go
Normal file
233
agent/mdraid_linux.go
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/agent/utils"
|
||||||
|
"github.com/henrygd/beszel/internal/entities/smart"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mdraidSysfsRoot is a test hook; production value is "/sys".
|
||||||
|
var mdraidSysfsRoot = "/sys"
|
||||||
|
|
||||||
|
type mdraidHealth struct {
|
||||||
|
level string
|
||||||
|
arrayState string
|
||||||
|
degraded uint64
|
||||||
|
raidDisks uint64
|
||||||
|
syncAction string
|
||||||
|
syncCompleted string
|
||||||
|
syncSpeed string
|
||||||
|
mismatchCnt uint64
|
||||||
|
capacity uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanMdraidDevices discovers Linux md arrays exposed in sysfs.
|
||||||
|
func scanMdraidDevices() []*DeviceInfo {
|
||||||
|
blockDir := filepath.Join(mdraidSysfsRoot, "block")
|
||||||
|
entries, err := os.ReadDir(blockDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
devices := make([]*DeviceInfo, 0, 2)
|
||||||
|
for _, ent := range entries {
|
||||||
|
name := ent.Name()
|
||||||
|
if !isMdraidBlockName(name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mdDir := filepath.Join(blockDir, name, "md")
|
||||||
|
if !utils.FileExists(filepath.Join(mdDir, "array_state")) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
devPath := filepath.Join("/dev", name)
|
||||||
|
devices = append(devices, &DeviceInfo{
|
||||||
|
Name: devPath,
|
||||||
|
Type: "mdraid",
|
||||||
|
InfoName: devPath + " [mdraid]",
|
||||||
|
Protocol: "MD",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return devices
|
||||||
|
}
|
||||||
|
|
||||||
|
// collectMdraidHealth reads mdraid health and stores it in SmartDataMap.
|
||||||
|
func (sm *SmartManager) collectMdraidHealth(deviceInfo *DeviceInfo) (bool, error) {
|
||||||
|
if deviceInfo == nil || deviceInfo.Name == "" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
base := filepath.Base(deviceInfo.Name)
|
||||||
|
if !isMdraidBlockName(base) && !strings.EqualFold(deviceInfo.Type, "mdraid") {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
health, ok := readMdraidHealth(base)
|
||||||
|
if !ok {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
deviceInfo.Type = "mdraid"
|
||||||
|
key := fmt.Sprintf("mdraid:%s", base)
|
||||||
|
status := mdraidSmartStatus(health)
|
||||||
|
|
||||||
|
attrs := make([]*smart.SmartAttribute, 0, 10)
|
||||||
|
if health.arrayState != "" {
|
||||||
|
attrs = append(attrs, &smart.SmartAttribute{Name: "ArrayState", RawString: health.arrayState})
|
||||||
|
}
|
||||||
|
if health.level != "" {
|
||||||
|
attrs = append(attrs, &smart.SmartAttribute{Name: "RaidLevel", RawString: health.level})
|
||||||
|
}
|
||||||
|
if health.raidDisks > 0 {
|
||||||
|
attrs = append(attrs, &smart.SmartAttribute{Name: "RaidDisks", RawValue: health.raidDisks})
|
||||||
|
}
|
||||||
|
if health.degraded > 0 {
|
||||||
|
attrs = append(attrs, &smart.SmartAttribute{Name: "Degraded", RawValue: health.degraded})
|
||||||
|
}
|
||||||
|
if health.syncAction != "" {
|
||||||
|
attrs = append(attrs, &smart.SmartAttribute{Name: "SyncAction", RawString: health.syncAction})
|
||||||
|
}
|
||||||
|
if health.syncCompleted != "" {
|
||||||
|
attrs = append(attrs, &smart.SmartAttribute{Name: "SyncCompleted", RawString: health.syncCompleted})
|
||||||
|
}
|
||||||
|
if health.syncSpeed != "" {
|
||||||
|
attrs = append(attrs, &smart.SmartAttribute{Name: "SyncSpeed", RawString: health.syncSpeed})
|
||||||
|
}
|
||||||
|
if health.mismatchCnt > 0 {
|
||||||
|
attrs = append(attrs, &smart.SmartAttribute{Name: "MismatchCount", RawValue: health.mismatchCnt})
|
||||||
|
}
|
||||||
|
|
||||||
|
sm.Lock()
|
||||||
|
defer sm.Unlock()
|
||||||
|
|
||||||
|
if _, exists := sm.SmartDataMap[key]; !exists {
|
||||||
|
sm.SmartDataMap[key] = &smart.SmartData{}
|
||||||
|
}
|
||||||
|
|
||||||
|
data := sm.SmartDataMap[key]
|
||||||
|
data.ModelName = "Linux MD RAID"
|
||||||
|
if health.level != "" {
|
||||||
|
data.ModelName = "Linux MD RAID (" + health.level + ")"
|
||||||
|
}
|
||||||
|
data.Capacity = health.capacity
|
||||||
|
data.SmartStatus = status
|
||||||
|
data.DiskName = filepath.Join("/dev", base)
|
||||||
|
data.DiskType = "mdraid"
|
||||||
|
data.Attributes = attrs
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readMdraidHealth reads md array health fields from sysfs.
|
||||||
|
func readMdraidHealth(blockName string) (mdraidHealth, bool) {
|
||||||
|
var out mdraidHealth
|
||||||
|
|
||||||
|
if !isMdraidBlockName(blockName) {
|
||||||
|
return out, false
|
||||||
|
}
|
||||||
|
|
||||||
|
mdDir := filepath.Join(mdraidSysfsRoot, "block", blockName, "md")
|
||||||
|
arrayState, okState := utils.ReadStringFileOK(filepath.Join(mdDir, "array_state"))
|
||||||
|
if !okState {
|
||||||
|
return out, false
|
||||||
|
}
|
||||||
|
|
||||||
|
out.arrayState = arrayState
|
||||||
|
out.level = utils.ReadStringFile(filepath.Join(mdDir, "level"))
|
||||||
|
out.syncAction = utils.ReadStringFile(filepath.Join(mdDir, "sync_action"))
|
||||||
|
out.syncCompleted = utils.ReadStringFile(filepath.Join(mdDir, "sync_completed"))
|
||||||
|
out.syncSpeed = utils.ReadStringFile(filepath.Join(mdDir, "sync_speed"))
|
||||||
|
|
||||||
|
if val, ok := utils.ReadUintFile(filepath.Join(mdDir, "raid_disks")); ok {
|
||||||
|
out.raidDisks = val
|
||||||
|
}
|
||||||
|
if val, ok := utils.ReadUintFile(filepath.Join(mdDir, "degraded")); ok {
|
||||||
|
out.degraded = val
|
||||||
|
}
|
||||||
|
if val, ok := utils.ReadUintFile(filepath.Join(mdDir, "mismatch_cnt")); ok {
|
||||||
|
out.mismatchCnt = val
|
||||||
|
}
|
||||||
|
|
||||||
|
if capBytes, ok := readMdraidBlockCapacityBytes(blockName, mdraidSysfsRoot); ok {
|
||||||
|
out.capacity = capBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
return out, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// mdraidSmartStatus maps md state/sync signals to a SMART-like status.
|
||||||
|
func mdraidSmartStatus(health mdraidHealth) string {
|
||||||
|
state := strings.ToLower(strings.TrimSpace(health.arrayState))
|
||||||
|
switch state {
|
||||||
|
case "inactive", "faulty", "broken", "stopped":
|
||||||
|
return "FAILED"
|
||||||
|
}
|
||||||
|
// During rebuild/recovery, arrays are often temporarily degraded; report as
|
||||||
|
// warning instead of hard failure while synchronization is in progress.
|
||||||
|
syncAction := strings.ToLower(strings.TrimSpace(health.syncAction))
|
||||||
|
switch syncAction {
|
||||||
|
case "resync", "recover", "reshape":
|
||||||
|
return "WARNING"
|
||||||
|
}
|
||||||
|
if health.degraded > 0 {
|
||||||
|
return "FAILED"
|
||||||
|
}
|
||||||
|
switch syncAction {
|
||||||
|
case "check", "repair":
|
||||||
|
return "WARNING"
|
||||||
|
}
|
||||||
|
switch state {
|
||||||
|
case "clean", "active", "active-idle", "write-pending", "read-auto", "readonly":
|
||||||
|
return "PASSED"
|
||||||
|
}
|
||||||
|
return "UNKNOWN"
|
||||||
|
}
|
||||||
|
|
||||||
|
// isMdraidBlockName matches /dev/mdN-style block device names.
|
||||||
|
func isMdraidBlockName(name string) bool {
|
||||||
|
if !strings.HasPrefix(name, "md") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
suffix := strings.TrimPrefix(name, "md")
|
||||||
|
if suffix == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, c := range suffix {
|
||||||
|
if c < '0' || c > '9' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// readMdraidBlockCapacityBytes converts block size metadata into bytes.
|
||||||
|
func readMdraidBlockCapacityBytes(blockName, root string) (uint64, bool) {
|
||||||
|
sizePath := filepath.Join(root, "block", blockName, "size")
|
||||||
|
lbsPath := filepath.Join(root, "block", blockName, "queue", "logical_block_size")
|
||||||
|
|
||||||
|
sizeStr, ok := utils.ReadStringFileOK(sizePath)
|
||||||
|
if !ok {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
sectors, err := strconv.ParseUint(sizeStr, 10, 64)
|
||||||
|
if err != nil || sectors == 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
logicalBlockSize := uint64(512)
|
||||||
|
if lbsStr, ok := utils.ReadStringFileOK(lbsPath); ok {
|
||||||
|
if parsed, err := strconv.ParseUint(lbsStr, 10, 64); err == nil && parsed > 0 {
|
||||||
|
logicalBlockSize = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sectors * logicalBlockSize, true
|
||||||
|
}
|
||||||
103
agent/mdraid_linux_test.go
Normal file
103
agent/mdraid_linux_test.go
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/entities/smart"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMdraidMockSysfsScanAndCollect(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
prev := mdraidSysfsRoot
|
||||||
|
mdraidSysfsRoot = tmp
|
||||||
|
t.Cleanup(func() { mdraidSysfsRoot = prev })
|
||||||
|
|
||||||
|
mdDir := filepath.Join(tmp, "block", "md0", "md")
|
||||||
|
queueDir := filepath.Join(tmp, "block", "md0", "queue")
|
||||||
|
if err := os.MkdirAll(mdDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(queueDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
write := func(path, content string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
write(filepath.Join(mdDir, "array_state"), "active\n")
|
||||||
|
write(filepath.Join(mdDir, "level"), "raid1\n")
|
||||||
|
write(filepath.Join(mdDir, "raid_disks"), "2\n")
|
||||||
|
write(filepath.Join(mdDir, "degraded"), "0\n")
|
||||||
|
write(filepath.Join(mdDir, "sync_action"), "resync\n")
|
||||||
|
write(filepath.Join(mdDir, "sync_completed"), "10%\n")
|
||||||
|
write(filepath.Join(mdDir, "sync_speed"), "100M\n")
|
||||||
|
write(filepath.Join(mdDir, "mismatch_cnt"), "0\n")
|
||||||
|
write(filepath.Join(queueDir, "logical_block_size"), "512\n")
|
||||||
|
write(filepath.Join(tmp, "block", "md0", "size"), "2048\n")
|
||||||
|
|
||||||
|
devs := scanMdraidDevices()
|
||||||
|
if len(devs) != 1 {
|
||||||
|
t.Fatalf("scanMdraidDevices() = %d devices, want 1", len(devs))
|
||||||
|
}
|
||||||
|
if devs[0].Name != "/dev/md0" || devs[0].Type != "mdraid" {
|
||||||
|
t.Fatalf("scanMdraidDevices()[0] = %+v, want Name=/dev/md0 Type=mdraid", devs[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
sm := &SmartManager{SmartDataMap: map[string]*smart.SmartData{}}
|
||||||
|
ok, err := sm.collectMdraidHealth(devs[0])
|
||||||
|
if err != nil || !ok {
|
||||||
|
t.Fatalf("collectMdraidHealth() = (ok=%v, err=%v), want (true,nil)", ok, err)
|
||||||
|
}
|
||||||
|
if len(sm.SmartDataMap) != 1 {
|
||||||
|
t.Fatalf("SmartDataMap len=%d, want 1", len(sm.SmartDataMap))
|
||||||
|
}
|
||||||
|
var got *smart.SmartData
|
||||||
|
for _, v := range sm.SmartDataMap {
|
||||||
|
got = v
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if got == nil {
|
||||||
|
t.Fatalf("SmartDataMap value nil")
|
||||||
|
}
|
||||||
|
if got.DiskType != "mdraid" || got.DiskName != "/dev/md0" {
|
||||||
|
t.Fatalf("disk fields = (type=%q name=%q), want (mdraid,/dev/md0)", got.DiskType, got.DiskName)
|
||||||
|
}
|
||||||
|
if got.SmartStatus != "WARNING" {
|
||||||
|
t.Fatalf("SmartStatus=%q, want WARNING", got.SmartStatus)
|
||||||
|
}
|
||||||
|
if got.ModelName == "" || got.Capacity == 0 {
|
||||||
|
t.Fatalf("identity fields = (model=%q cap=%d), want non-empty model and cap>0", got.ModelName, got.Capacity)
|
||||||
|
}
|
||||||
|
if len(got.Attributes) < 5 {
|
||||||
|
t.Fatalf("attributes len=%d, want >= 5", len(got.Attributes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMdraidSmartStatus(t *testing.T) {
|
||||||
|
if got := mdraidSmartStatus(mdraidHealth{arrayState: "inactive"}); got != "FAILED" {
|
||||||
|
t.Fatalf("mdraidSmartStatus(inactive) = %q, want FAILED", got)
|
||||||
|
}
|
||||||
|
if got := mdraidSmartStatus(mdraidHealth{arrayState: "active", degraded: 1, syncAction: "recover"}); got != "WARNING" {
|
||||||
|
t.Fatalf("mdraidSmartStatus(degraded+recover) = %q, want WARNING", got)
|
||||||
|
}
|
||||||
|
if got := mdraidSmartStatus(mdraidHealth{arrayState: "active", degraded: 1}); got != "FAILED" {
|
||||||
|
t.Fatalf("mdraidSmartStatus(degraded) = %q, want FAILED", got)
|
||||||
|
}
|
||||||
|
if got := mdraidSmartStatus(mdraidHealth{arrayState: "active", syncAction: "recover"}); got != "WARNING" {
|
||||||
|
t.Fatalf("mdraidSmartStatus(recover) = %q, want WARNING", got)
|
||||||
|
}
|
||||||
|
if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean"}); got != "PASSED" {
|
||||||
|
t.Fatalf("mdraidSmartStatus(clean) = %q, want PASSED", got)
|
||||||
|
}
|
||||||
|
if got := mdraidSmartStatus(mdraidHealth{arrayState: "unknown"}); got != "UNKNOWN" {
|
||||||
|
t.Fatalf("mdraidSmartStatus(unknown) = %q, want UNKNOWN", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
11
agent/mdraid_stub.go
Normal file
11
agent/mdraid_stub.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
//go:build !linux
|
||||||
|
|
||||||
|
package agent
|
||||||
|
|
||||||
|
func scanMdraidDevices() []*DeviceInfo {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sm *SmartManager) collectMdraidHealth(deviceInfo *DeviceInfo) (bool, error) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
225
agent/network.go
225
agent/network.go
@@ -8,12 +8,11 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/agent/deltatracker"
|
"github.com/henrygd/beszel/agent/deltatracker"
|
||||||
|
"github.com/henrygd/beszel/agent/utils"
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
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
|
// NicConfig controls inclusion/exclusion of network interfaces via the NICS env var
|
||||||
//
|
//
|
||||||
// Behavior mirrors SensorConfig's matching logic:
|
// Behavior mirrors SensorConfig's matching logic:
|
||||||
@@ -77,75 +76,17 @@ func isValidNic(nicName string, cfg *NicConfig) bool {
|
|||||||
return cfg.isBlacklist
|
return cfg.isBlacklist
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Agent) updateNetworkStats(systemStats *system.Stats) {
|
func (a *Agent) updateNetworkStats(cacheTimeMs uint16, systemStats *system.Stats) {
|
||||||
// network stats
|
// network stats
|
||||||
if len(a.netInterfaces) == 0 {
|
a.ensureNetInterfacesInitialized()
|
||||||
// if no network interfaces, initialize again
|
|
||||||
// this is a fix if agent started before network is online (#466)
|
|
||||||
// maybe refactor this in the future to not cache interface names at all so we
|
|
||||||
// don't miss an interface that's been added after agent started in any circumstance
|
|
||||||
a.initializeNetIoStats()
|
|
||||||
}
|
|
||||||
|
|
||||||
if systemStats.NetworkInterfaces == nil {
|
a.ensureNetworkInterfacesMap(systemStats)
|
||||||
systemStats.NetworkInterfaces = make(map[string][4]uint64, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
if netIO, err := psutilNet.IOCounters(true); err == nil {
|
if netIO, err := psutilNet.IOCounters(true); err == nil {
|
||||||
msElapsed := uint64(time.Since(a.netIoStats.Time).Milliseconds())
|
nis, msElapsed := a.loadAndTickNetBaseline(cacheTimeMs)
|
||||||
a.netIoStats.Time = time.Now()
|
totalBytesSent, totalBytesRecv := a.sumAndTrackPerNicDeltas(cacheTimeMs, msElapsed, netIO, systemStats)
|
||||||
totalBytesSent := uint64(0)
|
bytesSentPerSecond, bytesRecvPerSecond := a.computeBytesPerSecond(msElapsed, totalBytesSent, totalBytesRecv, nis)
|
||||||
totalBytesRecv := uint64(0)
|
a.applyNetworkTotals(cacheTimeMs, netIO, systemStats, nis, totalBytesSent, totalBytesRecv, bytesSentPerSecond, bytesRecvPerSecond)
|
||||||
netInterfaceDeltaTracker.Cycle()
|
|
||||||
// sum all bytes sent and received
|
|
||||||
for _, v := range netIO {
|
|
||||||
// skip if not in valid network interfaces list
|
|
||||||
if _, exists := a.netInterfaces[v.Name]; !exists {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
totalBytesSent += v.BytesSent
|
|
||||||
totalBytesRecv += v.BytesRecv
|
|
||||||
|
|
||||||
// track deltas for each network interface
|
|
||||||
var upDelta, downDelta uint64
|
|
||||||
upKey, downKey := fmt.Sprintf("%sup", v.Name), fmt.Sprintf("%sdown", v.Name)
|
|
||||||
netInterfaceDeltaTracker.Set(upKey, v.BytesSent)
|
|
||||||
netInterfaceDeltaTracker.Set(downKey, v.BytesRecv)
|
|
||||||
if msElapsed > 0 {
|
|
||||||
upDelta = netInterfaceDeltaTracker.Delta(upKey) * 1000 / msElapsed
|
|
||||||
downDelta = netInterfaceDeltaTracker.Delta(downKey) * 1000 / msElapsed
|
|
||||||
}
|
|
||||||
// add interface to systemStats
|
|
||||||
systemStats.NetworkInterfaces[v.Name] = [4]uint64{upDelta, downDelta, v.BytesSent, v.BytesRecv}
|
|
||||||
}
|
|
||||||
|
|
||||||
// add to systemStats
|
|
||||||
var bytesSentPerSecond, bytesRecvPerSecond uint64
|
|
||||||
if msElapsed > 0 {
|
|
||||||
bytesSentPerSecond = (totalBytesSent - a.netIoStats.BytesSent) * 1000 / msElapsed
|
|
||||||
bytesRecvPerSecond = (totalBytesRecv - a.netIoStats.BytesRecv) * 1000 / msElapsed
|
|
||||||
}
|
|
||||||
networkSentPs := bytesToMegabytes(float64(bytesSentPerSecond))
|
|
||||||
networkRecvPs := bytesToMegabytes(float64(bytesRecvPerSecond))
|
|
||||||
// add check for issue (#150) where sent is a massive number
|
|
||||||
if networkSentPs > 10_000 || networkRecvPs > 10_000 {
|
|
||||||
slog.Warn("Invalid net stats. Resetting.", "sent", networkSentPs, "recv", networkRecvPs)
|
|
||||||
for _, v := range netIO {
|
|
||||||
if _, exists := a.netInterfaces[v.Name]; !exists {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
slog.Info(v.Name, "recv", v.BytesRecv, "sent", v.BytesSent)
|
|
||||||
}
|
|
||||||
// reset network I/O stats
|
|
||||||
a.initializeNetIoStats()
|
|
||||||
} else {
|
|
||||||
systemStats.NetworkSent = networkSentPs
|
|
||||||
systemStats.NetworkRecv = networkRecvPs
|
|
||||||
systemStats.Bandwidth[0], systemStats.Bandwidth[1] = bytesSentPerSecond, bytesRecvPerSecond
|
|
||||||
// update netIoStats
|
|
||||||
a.netIoStats.BytesSent = totalBytesSent
|
|
||||||
a.netIoStats.BytesRecv = totalBytesRecv
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,36 +95,158 @@ func (a *Agent) initializeNetIoStats() {
|
|||||||
a.netInterfaces = make(map[string]struct{}, 0)
|
a.netInterfaces = make(map[string]struct{}, 0)
|
||||||
|
|
||||||
// parse NICS env var for whitelist / blacklist
|
// parse NICS env var for whitelist / blacklist
|
||||||
nicsEnvVal, nicsEnvExists := GetEnv("NICS")
|
nicsEnvVal, nicsEnvExists := utils.GetEnv("NICS")
|
||||||
var nicCfg *NicConfig
|
var nicCfg *NicConfig
|
||||||
if nicsEnvExists {
|
if nicsEnvExists {
|
||||||
nicCfg = newNicConfig(nicsEnvVal)
|
nicCfg = newNicConfig(nicsEnvVal)
|
||||||
}
|
}
|
||||||
|
|
||||||
// reset network I/O stats
|
// get current network I/O stats and record valid interfaces
|
||||||
a.netIoStats.BytesSent = 0
|
|
||||||
a.netIoStats.BytesRecv = 0
|
|
||||||
|
|
||||||
// get intial network I/O stats
|
|
||||||
if netIO, err := psutilNet.IOCounters(true); err == nil {
|
if netIO, err := psutilNet.IOCounters(true); err == nil {
|
||||||
a.netIoStats.Time = time.Now()
|
|
||||||
for _, v := range netIO {
|
for _, v := range netIO {
|
||||||
if nicsEnvExists && !isValidNic(v.Name, nicCfg) {
|
if skipNetworkInterface(v, nicCfg) {
|
||||||
continue
|
|
||||||
}
|
|
||||||
if a.skipNetworkInterface(v) {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
slog.Info("Detected network interface", "name", v.Name, "sent", v.BytesSent, "recv", v.BytesRecv)
|
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
|
// store as a valid network interface
|
||||||
a.netInterfaces[v.Name] = struct{}{}
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Agent) skipNetworkInterface(v psutilNet.IOCountersStat) bool {
|
// ensureNetInterfacesInitialized re-initializes NICs if none are currently tracked
|
||||||
|
func (a *Agent) ensureNetInterfacesInitialized() {
|
||||||
|
if len(a.netInterfaces) == 0 {
|
||||||
|
// if no network interfaces, initialize again
|
||||||
|
// this is a fix if agent started before network is online (#466)
|
||||||
|
// maybe refactor this in the future to not cache interface names at all so we
|
||||||
|
// don't miss an interface that's been added after agent started in any circumstance
|
||||||
|
a.initializeNetIoStats()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureNetworkInterfacesMap ensures systemStats.NetworkInterfaces map exists
|
||||||
|
func (a *Agent) ensureNetworkInterfacesMap(systemStats *system.Stats) {
|
||||||
|
if systemStats.NetworkInterfaces == nil {
|
||||||
|
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()
|
||||||
|
|
||||||
|
for _, v := range netIO {
|
||||||
|
if _, exists := a.netInterfaces[v.Name]; !exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
totalBytesSent += v.BytesSent
|
||||||
|
totalBytesRecv += v.BytesRecv
|
||||||
|
|
||||||
|
var upDelta, downDelta uint64
|
||||||
|
upKey, downKey := fmt.Sprintf("%sup", v.Name), fmt.Sprintf("%sdown", v.Name)
|
||||||
|
tracker.Set(upKey, v.BytesSent)
|
||||||
|
tracker.Set(downKey, v.BytesRecv)
|
||||||
|
if msElapsed > 0 {
|
||||||
|
if prevVal, ok := tracker.Previous(upKey); ok {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
systemStats.NetworkInterfaces[v.Name] = [4]uint64{upDelta, downDelta, v.BytesSent, v.BytesRecv}
|
||||||
|
}
|
||||||
|
|
||||||
|
return totalBytesSent, totalBytesRecv
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
) {
|
||||||
|
if bytesSentPerSecond > 10_000_000_000 || bytesRecvPerSecond > 10_000_000_000 {
|
||||||
|
slog.Warn("Invalid net stats. Resetting.", "sent", bytesSentPerSecond, "recv", bytesRecvPerSecond)
|
||||||
|
for _, v := range netIO {
|
||||||
|
if _, exists := a.netInterfaces[v.Name]; !exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
slog.Info(v.Name, "recv", v.BytesRecv, "sent", v.BytesSent)
|
||||||
|
}
|
||||||
|
a.initializeNetIoStats()
|
||||||
|
delete(a.netIoStats, cacheTimeMs)
|
||||||
|
delete(a.netInterfaceDeltaTrackers, cacheTimeMs)
|
||||||
|
systemStats.Bandwidth[0], systemStats.Bandwidth[1] = 0, 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
systemStats.Bandwidth[0], systemStats.Bandwidth[1] = bytesSentPerSecond, bytesRecvPerSecond
|
||||||
|
nis.BytesSent = totalBytesSent
|
||||||
|
nis.BytesRecv = totalBytesRecv
|
||||||
|
a.netIoStats[cacheTimeMs] = nis
|
||||||
|
}
|
||||||
|
|
||||||
|
// skipNetworkInterface returns true if the network interface should be ignored.
|
||||||
|
func skipNetworkInterface(v psutilNet.IOCountersStat, nicCfg *NicConfig) bool {
|
||||||
|
if nicCfg != nil {
|
||||||
|
if !isValidNic(v.Name, nicCfg) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// In whitelist mode, we honor explicit inclusion without auto-filtering.
|
||||||
|
if !nicCfg.isBlacklist {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// In blacklist mode, still apply the auto-filter below.
|
||||||
|
}
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case strings.HasPrefix(v.Name, "lo"),
|
case strings.HasPrefix(v.Name, "lo"),
|
||||||
strings.HasPrefix(v.Name, "docker"),
|
strings.HasPrefix(v.Name, "docker"),
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ package agent
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"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/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
@@ -257,3 +261,253 @@ func TestNewNicConfig(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
func TestSkipNetworkInterface(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
nic psutilNet.IOCountersStat
|
||||||
|
nicCfg *NicConfig
|
||||||
|
expectSkip bool
|
||||||
|
}{
|
||||||
|
{"loopback lo", psutilNet.IOCountersStat{Name: "lo", BytesSent: 100, BytesRecv: 100}, nil, true},
|
||||||
|
{"loopback lo0", psutilNet.IOCountersStat{Name: "lo0", BytesSent: 100, BytesRecv: 100}, nil, true},
|
||||||
|
{"docker prefix", psutilNet.IOCountersStat{Name: "docker0", BytesSent: 100, BytesRecv: 100}, nil, true},
|
||||||
|
{"br- prefix", psutilNet.IOCountersStat{Name: "br-lan", BytesSent: 100, BytesRecv: 100}, nil, true},
|
||||||
|
{"veth prefix", psutilNet.IOCountersStat{Name: "veth0abc", BytesSent: 100, BytesRecv: 100}, nil, true},
|
||||||
|
{"bond prefix", psutilNet.IOCountersStat{Name: "bond0", BytesSent: 100, BytesRecv: 100}, nil, true},
|
||||||
|
{"cali prefix", psutilNet.IOCountersStat{Name: "cali1234", BytesSent: 100, BytesRecv: 100}, nil, true},
|
||||||
|
{"zero BytesRecv", psutilNet.IOCountersStat{Name: "eth0", BytesSent: 100, BytesRecv: 0}, nil, true},
|
||||||
|
{"zero BytesSent", psutilNet.IOCountersStat{Name: "eth0", BytesSent: 0, BytesRecv: 100}, nil, true},
|
||||||
|
{"both zero", psutilNet.IOCountersStat{Name: "eth0", BytesSent: 0, BytesRecv: 0}, nil, true},
|
||||||
|
{"normal eth0", psutilNet.IOCountersStat{Name: "eth0", BytesSent: 100, BytesRecv: 200}, nil, false},
|
||||||
|
{"normal wlan0", psutilNet.IOCountersStat{Name: "wlan0", BytesSent: 1, BytesRecv: 1}, nil, false},
|
||||||
|
{"whitelist overrides skip (docker)", psutilNet.IOCountersStat{Name: "docker0", BytesSent: 100, BytesRecv: 100}, newNicConfig("docker0"), false},
|
||||||
|
{"whitelist overrides skip (lo)", psutilNet.IOCountersStat{Name: "lo", BytesSent: 100, BytesRecv: 100}, newNicConfig("lo"), false},
|
||||||
|
{"whitelist exclusion", psutilNet.IOCountersStat{Name: "eth1", BytesSent: 100, BytesRecv: 100}, newNicConfig("eth0"), true},
|
||||||
|
{"blacklist skip lo", psutilNet.IOCountersStat{Name: "lo", BytesSent: 100, BytesRecv: 100}, newNicConfig("-eth0"), true},
|
||||||
|
{"blacklist explicit eth0", psutilNet.IOCountersStat{Name: "eth0", BytesSent: 100, BytesRecv: 100}, newNicConfig("-eth0"), true},
|
||||||
|
{"blacklist allow eth1", psutilNet.IOCountersStat{Name: "eth1", BytesSent: 100, BytesRecv: 100}, newNicConfig("-eth0"), false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tt.expectSkip, skipNetworkInterface(tt.nic, tt.nicCfg))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
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,
|
||||||
|
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: "Zero values",
|
||||||
|
bytesSentPerSecond: 0,
|
||||||
|
bytesRecvPerSecond: 0,
|
||||||
|
totalBytesSent: 0,
|
||||||
|
totalBytesRecv: 0,
|
||||||
|
expectReset: false,
|
||||||
|
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.Bandwidth[0])
|
||||||
|
assert.Zero(t, systemStats.Bandwidth[1])
|
||||||
|
} else {
|
||||||
|
// Should have applied stats
|
||||||
|
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
31
agent/response.go
Normal 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
|
||||||
|
}
|
||||||
@@ -2,48 +2,67 @@ package agent
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"path"
|
"path"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/agent/utils"
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
|
||||||
"github.com/shirou/gopsutil/v4/common"
|
"github.com/shirou/gopsutil/v4/common"
|
||||||
"github.com/shirou/gopsutil/v4/sensors"
|
"github.com/shirou/gopsutil/v4/sensors"
|
||||||
)
|
)
|
||||||
|
|
||||||
type SensorConfig struct {
|
var errTemperatureFetchTimeout = errors.New("temperature collection timed out")
|
||||||
context context.Context
|
|
||||||
sensors map[string]struct{}
|
|
||||||
primarySensor string
|
|
||||||
isBlacklist bool
|
|
||||||
hasWildcards bool
|
|
||||||
skipCollection bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *Agent) newSensorConfig() *SensorConfig {
|
|
||||||
primarySensor, _ := GetEnv("PRIMARY_SENSOR")
|
|
||||||
sysSensors, _ := GetEnv("SYS_SENSORS")
|
|
||||||
sensorsEnvVal, sensorsSet := GetEnv("SENSORS")
|
|
||||||
skipCollection := sensorsSet && sensorsEnvVal == ""
|
|
||||||
|
|
||||||
return a.newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal, skipCollection)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Matches sensors.TemperaturesWithContext to allow for panic recovery (gopsutil/issues/1832)
|
// Matches sensors.TemperaturesWithContext to allow for panic recovery (gopsutil/issues/1832)
|
||||||
type getTempsFn func(ctx context.Context) ([]sensors.TemperatureStat, error)
|
type getTempsFn func(ctx context.Context) ([]sensors.TemperatureStat, error)
|
||||||
|
|
||||||
|
type SensorConfig struct {
|
||||||
|
context context.Context
|
||||||
|
sensors map[string]struct{}
|
||||||
|
primarySensor string
|
||||||
|
timeout time.Duration
|
||||||
|
isBlacklist bool
|
||||||
|
hasWildcards bool
|
||||||
|
skipCollection bool
|
||||||
|
firstRun bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Agent) newSensorConfig() *SensorConfig {
|
||||||
|
primarySensor, _ := utils.GetEnv("PRIMARY_SENSOR")
|
||||||
|
sysSensors, _ := utils.GetEnv("SYS_SENSORS")
|
||||||
|
sensorsEnvVal, sensorsSet := utils.GetEnv("SENSORS")
|
||||||
|
skipCollection := sensorsSet && sensorsEnvVal == ""
|
||||||
|
sensorsTimeout, _ := utils.GetEnv("SENSORS_TIMEOUT")
|
||||||
|
|
||||||
|
return a.newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal, sensorsTimeout, skipCollection)
|
||||||
|
}
|
||||||
|
|
||||||
// newSensorConfigWithEnv creates a SensorConfig with the provided environment variables
|
// newSensorConfigWithEnv creates a SensorConfig with the provided environment variables
|
||||||
// sensorsSet indicates if the SENSORS environment variable was explicitly set (even to empty string)
|
// sensorsSet indicates if the SENSORS environment variable was explicitly set (even to empty string)
|
||||||
func (a *Agent) newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal string, skipCollection bool) *SensorConfig {
|
func (a *Agent) newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal, sensorsTimeout string, skipCollection bool) *SensorConfig {
|
||||||
|
timeout := 2 * time.Second
|
||||||
|
if sensorsTimeout != "" {
|
||||||
|
if d, err := time.ParseDuration(sensorsTimeout); err == nil {
|
||||||
|
timeout = d
|
||||||
|
} else {
|
||||||
|
slog.Warn("Invalid SENSORS_TIMEOUT", "value", sensorsTimeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
config := &SensorConfig{
|
config := &SensorConfig{
|
||||||
context: context.Background(),
|
context: context.Background(),
|
||||||
primarySensor: primarySensor,
|
primarySensor: primarySensor,
|
||||||
|
timeout: timeout,
|
||||||
skipCollection: skipCollection,
|
skipCollection: skipCollection,
|
||||||
|
firstRun: true,
|
||||||
sensors: make(map[string]struct{}),
|
sensors: make(map[string]struct{}),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,10 +104,12 @@ func (a *Agent) updateTemperatures(systemStats *system.Stats) {
|
|||||||
// reset high temp
|
// reset high temp
|
||||||
a.systemInfo.DashboardTemp = 0
|
a.systemInfo.DashboardTemp = 0
|
||||||
|
|
||||||
temps, err := a.getTempsWithPanicRecovery(getSensorTemps)
|
temps, err := a.getTempsWithTimeout(getSensorTemps)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// retry once on panic (gopsutil/issues/1832)
|
// retry once on panic (gopsutil/issues/1832)
|
||||||
temps, err = a.getTempsWithPanicRecovery(getSensorTemps)
|
if !errors.Is(err, errTemperatureFetchTimeout) {
|
||||||
|
temps, err = a.getTempsWithTimeout(getSensorTemps)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("Error updating temperatures", "err", err)
|
slog.Warn("Error updating temperatures", "err", err)
|
||||||
if len(systemStats.Temperatures) > 0 {
|
if len(systemStats.Temperatures) > 0 {
|
||||||
@@ -135,7 +156,7 @@ func (a *Agent) updateTemperatures(systemStats *system.Stats) {
|
|||||||
case sensorName:
|
case sensorName:
|
||||||
a.systemInfo.DashboardTemp = sensor.Temperature
|
a.systemInfo.DashboardTemp = sensor.Temperature
|
||||||
}
|
}
|
||||||
systemStats.Temperatures[sensorName] = twoDecimals(sensor.Temperature)
|
systemStats.Temperatures[sensorName] = utils.TwoDecimals(sensor.Temperature)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,6 +172,34 @@ func (a *Agent) getTempsWithPanicRecovery(getTemps getTempsFn) (temps []sensors.
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *Agent) getTempsWithTimeout(getTemps getTempsFn) ([]sensors.TemperatureStat, error) {
|
||||||
|
type result struct {
|
||||||
|
temps []sensors.TemperatureStat
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use a longer timeout on the first run to allow for initialization
|
||||||
|
// (e.g. Windows LHM subprocess startup)
|
||||||
|
timeout := a.sensorConfig.timeout
|
||||||
|
if a.sensorConfig.firstRun {
|
||||||
|
a.sensorConfig.firstRun = false
|
||||||
|
timeout = 10 * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
resultCh := make(chan result, 1)
|
||||||
|
go func() {
|
||||||
|
temps, err := a.getTempsWithPanicRecovery(getTemps)
|
||||||
|
resultCh <- result{temps: temps, err: err}
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case res := <-resultCh:
|
||||||
|
return res.temps, res.err
|
||||||
|
case <-time.After(timeout):
|
||||||
|
return nil, errTemperatureFetchTimeout
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// isValidSensor checks if a sensor is valid based on the sensor name and the sensor config
|
// isValidSensor checks if a sensor is valid based on the sensor name and the sensor config
|
||||||
func isValidSensor(sensorName string, config *SensorConfig) bool {
|
func isValidSensor(sensorName string, config *SensorConfig) bool {
|
||||||
// if no sensors configured, everything is valid
|
// if no sensors configured, everything is valid
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
//go:build testing
|
//go:build testing
|
||||||
// +build testing
|
|
||||||
|
|
||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
|
||||||
@@ -169,6 +168,7 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
|
|||||||
primarySensor string
|
primarySensor string
|
||||||
sysSensors string
|
sysSensors string
|
||||||
sensors string
|
sensors string
|
||||||
|
sensorsTimeout string
|
||||||
skipCollection bool
|
skipCollection bool
|
||||||
expectedConfig *SensorConfig
|
expectedConfig *SensorConfig
|
||||||
}{
|
}{
|
||||||
@@ -180,12 +180,37 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
|
|||||||
expectedConfig: &SensorConfig{
|
expectedConfig: &SensorConfig{
|
||||||
context: context.Background(),
|
context: context.Background(),
|
||||||
primarySensor: "",
|
primarySensor: "",
|
||||||
|
timeout: 2 * time.Second,
|
||||||
sensors: map[string]struct{}{},
|
sensors: map[string]struct{}{},
|
||||||
isBlacklist: false,
|
isBlacklist: false,
|
||||||
hasWildcards: false,
|
hasWildcards: false,
|
||||||
skipCollection: false,
|
skipCollection: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "Custom timeout",
|
||||||
|
primarySensor: "",
|
||||||
|
sysSensors: "",
|
||||||
|
sensors: "",
|
||||||
|
sensorsTimeout: "5s",
|
||||||
|
expectedConfig: &SensorConfig{
|
||||||
|
context: context.Background(),
|
||||||
|
timeout: 5 * time.Second,
|
||||||
|
sensors: map[string]struct{}{},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Invalid timeout falls back to default",
|
||||||
|
primarySensor: "",
|
||||||
|
sysSensors: "",
|
||||||
|
sensors: "",
|
||||||
|
sensorsTimeout: "notaduration",
|
||||||
|
expectedConfig: &SensorConfig{
|
||||||
|
context: context.Background(),
|
||||||
|
timeout: 2 * time.Second,
|
||||||
|
sensors: map[string]struct{}{},
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "Explicitly set to empty string",
|
name: "Explicitly set to empty string",
|
||||||
primarySensor: "",
|
primarySensor: "",
|
||||||
@@ -195,6 +220,7 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
|
|||||||
expectedConfig: &SensorConfig{
|
expectedConfig: &SensorConfig{
|
||||||
context: context.Background(),
|
context: context.Background(),
|
||||||
primarySensor: "",
|
primarySensor: "",
|
||||||
|
timeout: 2 * time.Second,
|
||||||
sensors: map[string]struct{}{},
|
sensors: map[string]struct{}{},
|
||||||
isBlacklist: false,
|
isBlacklist: false,
|
||||||
hasWildcards: false,
|
hasWildcards: false,
|
||||||
@@ -209,6 +235,7 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
|
|||||||
expectedConfig: &SensorConfig{
|
expectedConfig: &SensorConfig{
|
||||||
context: context.Background(),
|
context: context.Background(),
|
||||||
primarySensor: "cpu_temp",
|
primarySensor: "cpu_temp",
|
||||||
|
timeout: 2 * time.Second,
|
||||||
sensors: map[string]struct{}{},
|
sensors: map[string]struct{}{},
|
||||||
isBlacklist: false,
|
isBlacklist: false,
|
||||||
hasWildcards: false,
|
hasWildcards: false,
|
||||||
@@ -222,6 +249,7 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
|
|||||||
expectedConfig: &SensorConfig{
|
expectedConfig: &SensorConfig{
|
||||||
context: context.Background(),
|
context: context.Background(),
|
||||||
primarySensor: "cpu_temp",
|
primarySensor: "cpu_temp",
|
||||||
|
timeout: 2 * time.Second,
|
||||||
sensors: map[string]struct{}{
|
sensors: map[string]struct{}{
|
||||||
"cpu_temp": {},
|
"cpu_temp": {},
|
||||||
"gpu_temp": {},
|
"gpu_temp": {},
|
||||||
@@ -238,6 +266,7 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
|
|||||||
expectedConfig: &SensorConfig{
|
expectedConfig: &SensorConfig{
|
||||||
context: context.Background(),
|
context: context.Background(),
|
||||||
primarySensor: "cpu_temp",
|
primarySensor: "cpu_temp",
|
||||||
|
timeout: 2 * time.Second,
|
||||||
sensors: map[string]struct{}{
|
sensors: map[string]struct{}{
|
||||||
"cpu_temp": {},
|
"cpu_temp": {},
|
||||||
"gpu_temp": {},
|
"gpu_temp": {},
|
||||||
@@ -254,6 +283,7 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
|
|||||||
expectedConfig: &SensorConfig{
|
expectedConfig: &SensorConfig{
|
||||||
context: context.Background(),
|
context: context.Background(),
|
||||||
primarySensor: "cpu_temp",
|
primarySensor: "cpu_temp",
|
||||||
|
timeout: 2 * time.Second,
|
||||||
sensors: map[string]struct{}{
|
sensors: map[string]struct{}{
|
||||||
"cpu_*": {},
|
"cpu_*": {},
|
||||||
"gpu_temp": {},
|
"gpu_temp": {},
|
||||||
@@ -270,6 +300,7 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
|
|||||||
expectedConfig: &SensorConfig{
|
expectedConfig: &SensorConfig{
|
||||||
context: context.Background(),
|
context: context.Background(),
|
||||||
primarySensor: "cpu_temp",
|
primarySensor: "cpu_temp",
|
||||||
|
timeout: 2 * time.Second,
|
||||||
sensors: map[string]struct{}{
|
sensors: map[string]struct{}{
|
||||||
"cpu_*": {},
|
"cpu_*": {},
|
||||||
"gpu_temp": {},
|
"gpu_temp": {},
|
||||||
@@ -285,6 +316,7 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
|
|||||||
sensors: "cpu_temp",
|
sensors: "cpu_temp",
|
||||||
expectedConfig: &SensorConfig{
|
expectedConfig: &SensorConfig{
|
||||||
primarySensor: "cpu_temp",
|
primarySensor: "cpu_temp",
|
||||||
|
timeout: 2 * time.Second,
|
||||||
sensors: map[string]struct{}{
|
sensors: map[string]struct{}{
|
||||||
"cpu_temp": {},
|
"cpu_temp": {},
|
||||||
},
|
},
|
||||||
@@ -296,7 +328,7 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
result := agent.newSensorConfigWithEnv(tt.primarySensor, tt.sysSensors, tt.sensors, tt.skipCollection)
|
result := agent.newSensorConfigWithEnv(tt.primarySensor, tt.sysSensors, tt.sensors, tt.sensorsTimeout, tt.skipCollection)
|
||||||
|
|
||||||
// Check primary sensor
|
// Check primary sensor
|
||||||
assert.Equal(t, tt.expectedConfig.primarySensor, result.primarySensor)
|
assert.Equal(t, tt.expectedConfig.primarySensor, result.primarySensor)
|
||||||
@@ -315,6 +347,7 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
|
|||||||
// Check flags
|
// Check flags
|
||||||
assert.Equal(t, tt.expectedConfig.isBlacklist, result.isBlacklist)
|
assert.Equal(t, tt.expectedConfig.isBlacklist, result.isBlacklist)
|
||||||
assert.Equal(t, tt.expectedConfig.hasWildcards, result.hasWildcards)
|
assert.Equal(t, tt.expectedConfig.hasWildcards, result.hasWildcards)
|
||||||
|
assert.Equal(t, tt.expectedConfig.timeout, result.timeout)
|
||||||
|
|
||||||
// Check context
|
// Check context
|
||||||
if tt.sysSensors != "" {
|
if tt.sysSensors != "" {
|
||||||
@@ -330,40 +363,18 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestNewSensorConfig(t *testing.T) {
|
func TestNewSensorConfig(t *testing.T) {
|
||||||
// Save original environment variables
|
|
||||||
originalPrimary, hasPrimary := os.LookupEnv("BESZEL_AGENT_PRIMARY_SENSOR")
|
|
||||||
originalSys, hasSys := os.LookupEnv("BESZEL_AGENT_SYS_SENSORS")
|
|
||||||
originalSensors, hasSensors := os.LookupEnv("BESZEL_AGENT_SENSORS")
|
|
||||||
|
|
||||||
// Restore environment variables after the test
|
|
||||||
defer func() {
|
|
||||||
// Clean up test environment variables
|
|
||||||
os.Unsetenv("BESZEL_AGENT_PRIMARY_SENSOR")
|
|
||||||
os.Unsetenv("BESZEL_AGENT_SYS_SENSORS")
|
|
||||||
os.Unsetenv("BESZEL_AGENT_SENSORS")
|
|
||||||
|
|
||||||
// Restore original values if they existed
|
|
||||||
if hasPrimary {
|
|
||||||
os.Setenv("BESZEL_AGENT_PRIMARY_SENSOR", originalPrimary)
|
|
||||||
}
|
|
||||||
if hasSys {
|
|
||||||
os.Setenv("BESZEL_AGENT_SYS_SENSORS", originalSys)
|
|
||||||
}
|
|
||||||
if hasSensors {
|
|
||||||
os.Setenv("BESZEL_AGENT_SENSORS", originalSensors)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Set test environment variables
|
// Set test environment variables
|
||||||
os.Setenv("BESZEL_AGENT_PRIMARY_SENSOR", "test_primary")
|
t.Setenv("BESZEL_AGENT_PRIMARY_SENSOR", "test_primary")
|
||||||
os.Setenv("BESZEL_AGENT_SYS_SENSORS", "/test/path")
|
t.Setenv("BESZEL_AGENT_SYS_SENSORS", "/test/path")
|
||||||
os.Setenv("BESZEL_AGENT_SENSORS", "test_sensor1,test_*,test_sensor3")
|
t.Setenv("BESZEL_AGENT_SENSORS", "test_sensor1,test_*,test_sensor3")
|
||||||
|
t.Setenv("BESZEL_AGENT_SENSORS_TIMEOUT", "7s")
|
||||||
|
|
||||||
agent := &Agent{}
|
agent := &Agent{}
|
||||||
result := agent.newSensorConfig()
|
result := agent.newSensorConfig()
|
||||||
|
|
||||||
// Verify results
|
// Verify results
|
||||||
assert.Equal(t, "test_primary", result.primarySensor)
|
assert.Equal(t, "test_primary", result.primarySensor)
|
||||||
|
assert.Equal(t, 7*time.Second, result.timeout)
|
||||||
assert.NotNil(t, result.sensors)
|
assert.NotNil(t, result.sensors)
|
||||||
assert.Equal(t, 3, len(result.sensors))
|
assert.Equal(t, 3, len(result.sensors))
|
||||||
assert.True(t, result.hasWildcards)
|
assert.True(t, result.hasWildcards)
|
||||||
@@ -552,3 +563,59 @@ func TestGetTempsWithPanicRecovery(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetTempsWithTimeout(t *testing.T) {
|
||||||
|
agent := &Agent{
|
||||||
|
sensorConfig: &SensorConfig{
|
||||||
|
context: context.Background(),
|
||||||
|
timeout: 10 * time.Millisecond,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("returns temperatures before timeout", func(t *testing.T) {
|
||||||
|
temps, err := agent.getTempsWithTimeout(func(ctx context.Context) ([]sensors.TemperatureStat, error) {
|
||||||
|
return []sensors.TemperatureStat{{SensorKey: "cpu_temp", Temperature: 42}}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, temps, 1)
|
||||||
|
assert.Equal(t, "cpu_temp", temps[0].SensorKey)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("returns timeout error when collector hangs", func(t *testing.T) {
|
||||||
|
temps, err := agent.getTempsWithTimeout(func(ctx context.Context) ([]sensors.TemperatureStat, error) {
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
return []sensors.TemperatureStat{{SensorKey: "cpu_temp", Temperature: 42}}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Nil(t, temps)
|
||||||
|
assert.ErrorIs(t, err, errTemperatureFetchTimeout)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateTemperaturesSkipsOnTimeout(t *testing.T) {
|
||||||
|
agent := &Agent{
|
||||||
|
systemInfo: system.Info{DashboardTemp: 99},
|
||||||
|
sensorConfig: &SensorConfig{
|
||||||
|
context: context.Background(),
|
||||||
|
timeout: 10 * time.Millisecond,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
getSensorTemps = sensors.TemperaturesWithContext
|
||||||
|
})
|
||||||
|
getSensorTemps = func(ctx context.Context) ([]sensors.TemperatureStat, error) {
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
stats := &system.Stats{
|
||||||
|
Temperatures: map[string]float64{"stale": 50},
|
||||||
|
}
|
||||||
|
|
||||||
|
agent.updateTemperatures(stats)
|
||||||
|
|
||||||
|
assert.Equal(t, 0.0, agent.systemInfo.DashboardTemp)
|
||||||
|
assert.Equal(t, map[string]float64{}, stats.Temperatures)
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel"
|
"github.com/henrygd/beszel"
|
||||||
|
"github.com/henrygd/beszel/agent/utils"
|
||||||
"github.com/henrygd/beszel/internal/common"
|
"github.com/henrygd/beszel/internal/common"
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
|
||||||
@@ -36,6 +37,9 @@ var hubVersions map[string]semver.Version
|
|||||||
// and begins listening for connections. Returns an error if the server
|
// and begins listening for connections. Returns an error if the server
|
||||||
// is already running or if there's an issue starting the server.
|
// is already running or if there's an issue starting the server.
|
||||||
func (a *Agent) StartServer(opts ServerOptions) error {
|
func (a *Agent) StartServer(opts ServerOptions) error {
|
||||||
|
if disableSSH, _ := utils.GetEnv("DISABLE_SSH"); disableSSH == "true" {
|
||||||
|
return errors.New("SSH disabled")
|
||||||
|
}
|
||||||
if a.server != nil {
|
if a.server != nil {
|
||||||
return errors.New("server already started")
|
return errors.New("server already started")
|
||||||
}
|
}
|
||||||
@@ -127,15 +131,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
|
||||||
s.Exit(0)
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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: defaultDataCacheTimeMs})
|
||||||
|
return a.writeToSession(w, stats, hubVersion)
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeToSession encodes and writes system statistics to the session.
|
// writeToSession encodes and writes system statistics to the session.
|
||||||
@@ -180,11 +239,11 @@ func ParseKeys(input string) ([]gossh.PublicKey, error) {
|
|||||||
// and finally defaults to ":45876".
|
// and finally defaults to ":45876".
|
||||||
func GetAddress(addr string) string {
|
func GetAddress(addr string) string {
|
||||||
if addr == "" {
|
if addr == "" {
|
||||||
addr, _ = GetEnv("LISTEN")
|
addr, _ = utils.GetEnv("LISTEN")
|
||||||
}
|
}
|
||||||
if addr == "" {
|
if addr == "" {
|
||||||
// Legacy PORT environment variable support
|
// Legacy PORT environment variable support
|
||||||
addr, _ = GetEnv("PORT")
|
addr, _ = utils.GetEnv("PORT")
|
||||||
}
|
}
|
||||||
if addr == "" {
|
if addr == "" {
|
||||||
return ":45876"
|
return ":45876"
|
||||||
@@ -200,7 +259,7 @@ func GetAddress(addr string) string {
|
|||||||
// It checks the NETWORK environment variable first, then infers from
|
// It checks the NETWORK environment variable first, then infers from
|
||||||
// the address format: addresses starting with "/" are "unix", others are "tcp".
|
// the address format: addresses starting with "/" are "unix", others are "tcp".
|
||||||
func GetNetwork(addr string) string {
|
func GetNetwork(addr string) string {
|
||||||
if network, ok := GetEnv("NETWORK"); ok && network != "" {
|
if network, ok := utils.GetEnv("NETWORK"); ok && network != "" {
|
||||||
return network
|
return network
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(addr, "/") {
|
if strings.HasPrefix(addr, "/") {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
//go:build testing
|
||||||
|
|
||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -180,6 +182,22 @@ func TestStartServer(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestStartServerDisableSSH(t *testing.T) {
|
||||||
|
t.Setenv("BESZEL_AGENT_DISABLE_SSH", "true")
|
||||||
|
|
||||||
|
agent, err := NewAgent("")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
opts := ServerOptions{
|
||||||
|
Network: "tcp",
|
||||||
|
Addr: ":45990",
|
||||||
|
}
|
||||||
|
|
||||||
|
err = agent.StartServer(opts)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "SSH disabled")
|
||||||
|
}
|
||||||
|
|
||||||
/////////////////////////////////////////////////////////////////
|
/////////////////////////////////////////////////////////////////
|
||||||
//////////////////// ParseKeys Tests ////////////////////////////
|
//////////////////// ParseKeys Tests ////////////////////////////
|
||||||
/////////////////////////////////////////////////////////////////
|
/////////////////////////////////////////////////////////////////
|
||||||
@@ -513,7 +531,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 +544,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 +568,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{
|
||||||
{
|
{
|
||||||
|
|||||||
1220
agent/smart.go
Normal file
1220
agent/smart.go
Normal file
File diff suppressed because it is too large
Load Diff
9
agent/smart_nonwindows.go
Normal file
9
agent/smart_nonwindows.go
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
func ensureEmbeddedSmartctl() (string, error) {
|
||||||
|
return "", errors.ErrUnsupported
|
||||||
|
}
|
||||||
1279
agent/smart_test.go
Normal file
1279
agent/smart_test.go
Normal file
File diff suppressed because it is too large
Load Diff
40
agent/smart_windows.go
Normal file
40
agent/smart_windows.go
Normal 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
|
||||||
|
}
|
||||||
244
agent/system.go
244
agent/system.go
@@ -2,86 +2,159 @@ package agent
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"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/agent/utils"
|
||||||
|
"github.com/henrygd/beszel/agent/zfs"
|
||||||
|
"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"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// zfs
|
// zfs
|
||||||
if _, err := getARCSize(); err != nil {
|
if _, err := zfs.ARCSize(); err != nil {
|
||||||
slog.Debug("Not monitoring ZFS ARC", "err", err)
|
slog.Debug("Not monitoring ZFS ARC", "err", err)
|
||||||
} else {
|
} else {
|
||||||
a.zfs = true
|
a.zfs = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// attachSystemDetails returns details only for fresh default-interval responses.
|
||||||
|
func (a *Agent) attachSystemDetails(data *system.CombinedData, cacheTimeMs uint16, includeRequested bool) *system.CombinedData {
|
||||||
|
if cacheTimeMs != defaultDataCacheTimeMs || (!includeRequested && !a.detailsDirty) {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// copy data to avoid adding details to the original cached struct
|
||||||
|
response := *data
|
||||||
|
response.Details = &a.systemDetails
|
||||||
|
a.detailsDirty = false
|
||||||
|
return &response
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateSystemDetails applies a mutation to the static details payload and marks
|
||||||
|
// it for inclusion on the next fresh default-interval response.
|
||||||
|
func (a *Agent) updateSystemDetails(updateFunc func(details *system.Details)) {
|
||||||
|
updateFunc(&a.systemDetails)
|
||||||
|
a.detailsDirty = true
|
||||||
|
}
|
||||||
|
|
||||||
// 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 = utils.TwoDecimals(cpuMetrics.Total)
|
||||||
} else if len(cpuPct) > 0 {
|
systemStats.CpuBreakdown = []float64{
|
||||||
systemStats.Cpu = twoDecimals(cpuPct[0])
|
utils.TwoDecimals(cpuMetrics.User),
|
||||||
|
utils.TwoDecimals(cpuMetrics.System),
|
||||||
|
utils.TwoDecimals(cpuMetrics.Iowait),
|
||||||
|
utils.TwoDecimals(cpuMetrics.Steal),
|
||||||
|
utils.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
|
||||||
@@ -97,8 +170,8 @@ func (a *Agent) getSystemStats() system.Stats {
|
|||||||
// memory
|
// memory
|
||||||
if v, err := mem.VirtualMemory(); err == nil {
|
if v, err := mem.VirtualMemory(); err == nil {
|
||||||
// swap
|
// swap
|
||||||
systemStats.Swap = bytesToGigabytes(v.SwapTotal)
|
systemStats.Swap = utils.BytesToGigabytes(v.SwapTotal)
|
||||||
systemStats.SwapUsed = bytesToGigabytes(v.SwapTotal - v.SwapFree - v.SwapCached)
|
systemStats.SwapUsed = utils.BytesToGigabytes(v.SwapTotal - v.SwapFree - v.SwapCached)
|
||||||
// cache + buffers value for default mem calculation
|
// cache + buffers value for default mem calculation
|
||||||
// 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
|
||||||
@@ -118,69 +191,26 @@ func (a *Agent) getSystemStats() system.Stats {
|
|||||||
// }
|
// }
|
||||||
// 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, _ := zfs.ARCSize(); arcSize > 0 && arcSize < v.Used {
|
||||||
v.Used = v.Used - arcSize
|
v.Used = v.Used - arcSize
|
||||||
v.UsedPercent = float64(v.Used) / float64(v.Total) * 100.0
|
v.UsedPercent = float64(v.Used) / float64(v.Total) * 100.0
|
||||||
systemStats.MemZfsArc = bytesToGigabytes(arcSize)
|
systemStats.MemZfsArc = utils.BytesToGigabytes(arcSize)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
systemStats.Mem = bytesToGigabytes(v.Total)
|
systemStats.Mem = utils.BytesToGigabytes(v.Total)
|
||||||
systemStats.MemBuffCache = bytesToGigabytes(cacheBuff)
|
systemStats.MemBuffCache = utils.BytesToGigabytes(cacheBuff)
|
||||||
systemStats.MemUsed = bytesToGigabytes(v.Used)
|
systemStats.MemUsed = utils.BytesToGigabytes(v.Used)
|
||||||
systemStats.MemPct = twoDecimals(v.UsedPercent)
|
systemStats.MemPct = utils.TwoDecimals(v.UsedPercent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
||||||
@@ -191,7 +221,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
|
||||||
@@ -219,47 +249,37 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns the size of the ZFS ARC memory cache in bytes
|
// getOsPrettyName attempts to get the pretty OS name from /etc/os-release on Linux systems
|
||||||
func getARCSize() (uint64, error) {
|
func getOsPrettyName() (string, error) {
|
||||||
file, err := os.Open("/proc/spl/kstat/zfs/arcstats")
|
file, err := os.Open("/etc/os-release")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return "", err
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
// Scan the lines
|
|
||||||
scanner := bufio.NewScanner(file)
|
scanner := bufio.NewScanner(file)
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
line := scanner.Text()
|
line := scanner.Text()
|
||||||
if strings.HasPrefix(line, "size") {
|
if after, ok := strings.CutPrefix(line, "PRETTY_NAME="); ok {
|
||||||
// Example line: size 4 15032385536
|
value := after
|
||||||
fields := strings.Fields(line)
|
value = strings.Trim(value, `"`)
|
||||||
if len(fields) < 3 {
|
return value, nil
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
// Return the size as uint64
|
|
||||||
return strconv.ParseUint(fields[2], 10, 64)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return 0, fmt.Errorf("failed to parse size field")
|
return "", errors.New("pretty name not found")
|
||||||
}
|
}
|
||||||
|
|||||||
61
agent/system_test.go
Normal file
61
agent/system_test.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/common"
|
||||||
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGatherStatsDoesNotAttachDetailsToCachedRequests(t *testing.T) {
|
||||||
|
agent := &Agent{
|
||||||
|
cache: NewSystemDataCache(),
|
||||||
|
systemDetails: system.Details{Hostname: "updated-host", Podman: true},
|
||||||
|
detailsDirty: true,
|
||||||
|
}
|
||||||
|
cached := &system.CombinedData{
|
||||||
|
Info: system.Info{Hostname: "cached-host"},
|
||||||
|
}
|
||||||
|
agent.cache.Set(cached, defaultDataCacheTimeMs)
|
||||||
|
|
||||||
|
response := agent.gatherStats(common.DataRequestOptions{CacheTimeMs: defaultDataCacheTimeMs})
|
||||||
|
|
||||||
|
assert.Same(t, cached, response)
|
||||||
|
assert.Nil(t, response.Details)
|
||||||
|
assert.True(t, agent.detailsDirty)
|
||||||
|
assert.Equal(t, "cached-host", response.Info.Hostname)
|
||||||
|
assert.Nil(t, cached.Details)
|
||||||
|
|
||||||
|
secondResponse := agent.gatherStats(common.DataRequestOptions{CacheTimeMs: defaultDataCacheTimeMs})
|
||||||
|
assert.Same(t, cached, secondResponse)
|
||||||
|
assert.Nil(t, secondResponse.Details)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateSystemDetailsMarksDetailsDirty(t *testing.T) {
|
||||||
|
agent := &Agent{}
|
||||||
|
|
||||||
|
agent.updateSystemDetails(func(details *system.Details) {
|
||||||
|
details.Hostname = "updated-host"
|
||||||
|
details.Podman = true
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.True(t, agent.detailsDirty)
|
||||||
|
assert.Equal(t, "updated-host", agent.systemDetails.Hostname)
|
||||||
|
assert.True(t, agent.systemDetails.Podman)
|
||||||
|
|
||||||
|
original := &system.CombinedData{}
|
||||||
|
realTimeResponse := agent.attachSystemDetails(original, 1000, true)
|
||||||
|
assert.Same(t, original, realTimeResponse)
|
||||||
|
assert.Nil(t, realTimeResponse.Details)
|
||||||
|
assert.True(t, agent.detailsDirty)
|
||||||
|
|
||||||
|
response := agent.attachSystemDetails(original, defaultDataCacheTimeMs, false)
|
||||||
|
require.NotNil(t, response.Details)
|
||||||
|
assert.NotSame(t, original, response)
|
||||||
|
assert.Equal(t, "updated-host", response.Details.Hostname)
|
||||||
|
assert.True(t, response.Details.Podman)
|
||||||
|
assert.False(t, agent.detailsDirty)
|
||||||
|
assert.Nil(t, original.Details)
|
||||||
|
}
|
||||||
314
agent/systemd.go
Normal file
314
agent/systemd.go
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
//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/agent/utils"
|
||||||
|
"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, _ := utils.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, _ := utils.GetEnv("SERVICE_PATTERNS"); envPatterns != "" {
|
||||||
|
for pattern := range strings.SplitSeq(envPatterns, ",") {
|
||||||
|
pattern = strings.TrimSpace(pattern)
|
||||||
|
if pattern == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(pattern, "timer") && !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
38
agent/systemd_nonlinux.go
Normal 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")
|
||||||
|
}
|
||||||
53
agent/systemd_nonlinux_test.go
Normal file
53
agent/systemd_nonlinux_test.go
Normal 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)
|
||||||
|
}
|
||||||
185
agent/systemd_test.go
Normal file
185
agent/systemd_test.go
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
//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,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "opt into timer monitoring",
|
||||||
|
prefixedEnv: "nginx.service,docker,apache.timer",
|
||||||
|
unprefixedEnv: "",
|
||||||
|
expected: []string{"nginx.service", "docker.service", "apache.timer"},
|
||||||
|
cleanupEnvVars: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// Set up environment variables
|
||||||
|
if tt.prefixedEnv != "" {
|
||||||
|
t.Setenv("BESZEL_AGENT_SERVICE_PATTERNS", tt.prefixedEnv)
|
||||||
|
}
|
||||||
|
if tt.unprefixedEnv != "" {
|
||||||
|
t.Setenv("SERVICE_PATTERNS", tt.unprefixedEnv)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run the function
|
||||||
|
result := getServicePatterns()
|
||||||
|
|
||||||
|
// Verify results
|
||||||
|
assert.Equal(t, tt.expected, result, "Patterns should match expected values")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
700
agent/test-data/amdgpu.ids
Normal file
700
agent/test-data/amdgpu.ids
Normal file
@@ -0,0 +1,700 @@
|
|||||||
|
# List of AMDGPU IDs
|
||||||
|
#
|
||||||
|
# Syntax:
|
||||||
|
# device_id, revision_id, product_name <-- single tab after comma
|
||||||
|
|
||||||
|
1.0.0
|
||||||
|
1114, C2, AMD Radeon 860M Graphics
|
||||||
|
1114, C3, AMD Radeon 840M Graphics
|
||||||
|
1114, D2, AMD Radeon 860M Graphics
|
||||||
|
1114, D3, AMD Radeon 840M Graphics
|
||||||
|
1309, 00, AMD Radeon R7 Graphics
|
||||||
|
130A, 00, AMD Radeon R6 Graphics
|
||||||
|
130B, 00, AMD Radeon R4 Graphics
|
||||||
|
130C, 00, AMD Radeon R7 Graphics
|
||||||
|
130D, 00, AMD Radeon R6 Graphics
|
||||||
|
130E, 00, AMD Radeon R5 Graphics
|
||||||
|
130F, 00, AMD Radeon R7 Graphics
|
||||||
|
130F, D4, AMD Radeon R7 Graphics
|
||||||
|
130F, D5, AMD Radeon R7 Graphics
|
||||||
|
130F, D6, AMD Radeon R7 Graphics
|
||||||
|
130F, D7, AMD Radeon R7 Graphics
|
||||||
|
1313, 00, AMD Radeon R7 Graphics
|
||||||
|
1313, D4, AMD Radeon R7 Graphics
|
||||||
|
1313, D5, AMD Radeon R7 Graphics
|
||||||
|
1313, D6, AMD Radeon R7 Graphics
|
||||||
|
1315, 00, AMD Radeon R5 Graphics
|
||||||
|
1315, D4, AMD Radeon R5 Graphics
|
||||||
|
1315, D5, AMD Radeon R5 Graphics
|
||||||
|
1315, D6, AMD Radeon R5 Graphics
|
||||||
|
1315, D7, AMD Radeon R5 Graphics
|
||||||
|
1316, 00, AMD Radeon R5 Graphics
|
||||||
|
1318, 00, AMD Radeon R5 Graphics
|
||||||
|
131B, 00, AMD Radeon R4 Graphics
|
||||||
|
131C, 00, AMD Radeon R7 Graphics
|
||||||
|
131D, 00, AMD Radeon R6 Graphics
|
||||||
|
1435, AE, AMD Custom GPU 0932
|
||||||
|
1506, C1, AMD Radeon 610M
|
||||||
|
1506, C2, AMD Radeon 610M
|
||||||
|
1506, C3, AMD Radeon 610M
|
||||||
|
1506, C4, AMD Radeon 610M
|
||||||
|
150E, C1, AMD Radeon 890M Graphics
|
||||||
|
150E, C4, AMD Radeon 890M Graphics
|
||||||
|
150E, C5, AMD Radeon 890M Graphics
|
||||||
|
150E, C6, AMD Radeon 890M Graphics
|
||||||
|
150E, D1, AMD Radeon 890M Graphics
|
||||||
|
150E, D2, AMD Radeon 890M Graphics
|
||||||
|
150E, D3, AMD Radeon 890M Graphics
|
||||||
|
1586, C1, Radeon 8060S Graphics
|
||||||
|
1586, C2, Radeon 8050S Graphics
|
||||||
|
1586, C4, Radeon 8050S Graphics
|
||||||
|
1586, D1, Radeon 8060S Graphics
|
||||||
|
1586, D2, Radeon 8050S Graphics
|
||||||
|
1586, D4, Radeon 8050S Graphics
|
||||||
|
1586, D5, Radeon 8040S Graphics
|
||||||
|
15BF, 00, AMD Radeon 780M Graphics
|
||||||
|
15BF, 01, AMD Radeon 760M Graphics
|
||||||
|
15BF, 02, AMD Radeon 780M Graphics
|
||||||
|
15BF, 03, AMD Radeon 760M Graphics
|
||||||
|
15BF, C1, AMD Radeon 780M Graphics
|
||||||
|
15BF, C2, AMD Radeon 780M Graphics
|
||||||
|
15BF, C3, AMD Radeon 760M Graphics
|
||||||
|
15BF, C4, AMD Radeon 780M Graphics
|
||||||
|
15BF, C5, AMD Radeon 740M Graphics
|
||||||
|
15BF, C6, AMD Radeon 780M Graphics
|
||||||
|
15BF, C7, AMD Radeon 780M Graphics
|
||||||
|
15BF, C8, AMD Radeon 760M Graphics
|
||||||
|
15BF, C9, AMD Radeon 780M Graphics
|
||||||
|
15BF, CA, AMD Radeon 740M Graphics
|
||||||
|
15BF, CB, AMD Radeon 760M Graphics
|
||||||
|
15BF, CC, AMD Radeon 740M Graphics
|
||||||
|
15BF, CD, AMD Radeon 760M Graphics
|
||||||
|
15BF, CF, AMD Radeon 780M Graphics
|
||||||
|
15BF, D0, AMD Radeon 780M Graphics
|
||||||
|
15BF, D1, AMD Radeon 780M Graphics
|
||||||
|
15BF, D2, AMD Radeon 780M Graphics
|
||||||
|
15BF, D3, AMD Radeon 780M Graphics
|
||||||
|
15BF, D4, AMD Radeon 780M Graphics
|
||||||
|
15BF, D5, AMD Radeon 760M Graphics
|
||||||
|
15BF, D6, AMD Radeon 760M Graphics
|
||||||
|
15BF, D7, AMD Radeon 780M Graphics
|
||||||
|
15BF, D8, AMD Radeon 740M Graphics
|
||||||
|
15BF, D9, AMD Radeon 780M Graphics
|
||||||
|
15BF, DA, AMD Radeon 780M Graphics
|
||||||
|
15BF, DB, AMD Radeon 760M Graphics
|
||||||
|
15BF, DC, AMD Radeon 760M Graphics
|
||||||
|
15BF, DD, AMD Radeon 780M Graphics
|
||||||
|
15BF, DE, AMD Radeon 740M Graphics
|
||||||
|
15BF, DF, AMD Radeon 760M Graphics
|
||||||
|
15BF, F0, AMD Radeon 760M Graphics
|
||||||
|
15C8, C1, AMD Radeon 740M Graphics
|
||||||
|
15C8, C2, AMD Radeon 740M Graphics
|
||||||
|
15C8, C3, AMD Radeon 740M Graphics
|
||||||
|
15C8, C4, AMD Radeon 740M Graphics
|
||||||
|
15C8, D1, AMD Radeon 740M Graphics
|
||||||
|
15C8, D2, AMD Radeon 740M Graphics
|
||||||
|
15C8, D3, AMD Radeon 740M Graphics
|
||||||
|
15C8, D4, AMD Radeon 740M Graphics
|
||||||
|
15D8, 00, AMD Radeon RX Vega 8 Graphics WS
|
||||||
|
15D8, 91, AMD Radeon Vega 3 Graphics
|
||||||
|
15D8, 91, AMD Ryzen Embedded R1606G with Radeon Vega Gfx
|
||||||
|
15D8, 92, AMD Radeon Vega 3 Graphics
|
||||||
|
15D8, 92, AMD Ryzen Embedded R1505G with Radeon Vega Gfx
|
||||||
|
15D8, 93, AMD Radeon Vega 1 Graphics
|
||||||
|
15D8, A1, AMD Radeon Vega 10 Graphics
|
||||||
|
15D8, A2, AMD Radeon Vega 8 Graphics
|
||||||
|
15D8, A3, AMD Radeon Vega 6 Graphics
|
||||||
|
15D8, A4, AMD Radeon Vega 3 Graphics
|
||||||
|
15D8, B1, AMD Radeon Vega 10 Graphics
|
||||||
|
15D8, B2, AMD Radeon Vega 8 Graphics
|
||||||
|
15D8, B3, AMD Radeon Vega 6 Graphics
|
||||||
|
15D8, B4, AMD Radeon Vega 3 Graphics
|
||||||
|
15D8, C1, AMD Radeon Vega 10 Graphics
|
||||||
|
15D8, C2, AMD Radeon Vega 8 Graphics
|
||||||
|
15D8, C3, AMD Radeon Vega 6 Graphics
|
||||||
|
15D8, C4, AMD Radeon Vega 3 Graphics
|
||||||
|
15D8, C5, AMD Radeon Vega 3 Graphics
|
||||||
|
15D8, C8, AMD Radeon Vega 11 Graphics
|
||||||
|
15D8, C9, AMD Radeon Vega 8 Graphics
|
||||||
|
15D8, CA, AMD Radeon Vega 11 Graphics
|
||||||
|
15D8, CB, AMD Radeon Vega 8 Graphics
|
||||||
|
15D8, CC, AMD Radeon Vega 3 Graphics
|
||||||
|
15D8, CE, AMD Radeon Vega 3 Graphics
|
||||||
|
15D8, CF, AMD Ryzen Embedded R1305G with Radeon Vega Gfx
|
||||||
|
15D8, D1, AMD Radeon Vega 10 Graphics
|
||||||
|
15D8, D2, AMD Radeon Vega 8 Graphics
|
||||||
|
15D8, D3, AMD Radeon Vega 6 Graphics
|
||||||
|
15D8, D4, AMD Radeon Vega 3 Graphics
|
||||||
|
15D8, D8, AMD Radeon Vega 11 Graphics
|
||||||
|
15D8, D9, AMD Radeon Vega 8 Graphics
|
||||||
|
15D8, DA, AMD Radeon Vega 11 Graphics
|
||||||
|
15D8, DB, AMD Radeon Vega 3 Graphics
|
||||||
|
15D8, DB, AMD Radeon Vega 8 Graphics
|
||||||
|
15D8, DC, AMD Radeon Vega 3 Graphics
|
||||||
|
15D8, DD, AMD Radeon Vega 3 Graphics
|
||||||
|
15D8, DE, AMD Radeon Vega 3 Graphics
|
||||||
|
15D8, DF, AMD Radeon Vega 3 Graphics
|
||||||
|
15D8, E3, AMD Radeon Vega 3 Graphics
|
||||||
|
15D8, E4, AMD Ryzen Embedded R1102G with Radeon Vega Gfx
|
||||||
|
15DD, 81, AMD Ryzen Embedded V1807B with Radeon Vega Gfx
|
||||||
|
15DD, 82, AMD Ryzen Embedded V1756B with Radeon Vega Gfx
|
||||||
|
15DD, 83, AMD Ryzen Embedded V1605B with Radeon Vega Gfx
|
||||||
|
15DD, 84, AMD Radeon Vega 6 Graphics
|
||||||
|
15DD, 85, AMD Ryzen Embedded V1202B with Radeon Vega Gfx
|
||||||
|
15DD, 86, AMD Radeon Vega 11 Graphics
|
||||||
|
15DD, 88, AMD Radeon Vega 8 Graphics
|
||||||
|
15DD, C1, AMD Radeon Vega 11 Graphics
|
||||||
|
15DD, C2, AMD Radeon Vega 8 Graphics
|
||||||
|
15DD, C3, AMD Radeon Vega 3 / 10 Graphics
|
||||||
|
15DD, C4, AMD Radeon Vega 8 Graphics
|
||||||
|
15DD, C5, AMD Radeon Vega 3 Graphics
|
||||||
|
15DD, C6, AMD Radeon Vega 11 Graphics
|
||||||
|
15DD, C8, AMD Radeon Vega 8 Graphics
|
||||||
|
15DD, C9, AMD Radeon Vega 11 Graphics
|
||||||
|
15DD, CA, AMD Radeon Vega 8 Graphics
|
||||||
|
15DD, CB, AMD Radeon Vega 3 Graphics
|
||||||
|
15DD, CC, AMD Radeon Vega 6 Graphics
|
||||||
|
15DD, CE, AMD Radeon Vega 3 Graphics
|
||||||
|
15DD, CF, AMD Radeon Vega 3 Graphics
|
||||||
|
15DD, D0, AMD Radeon Vega 10 Graphics
|
||||||
|
15DD, D1, AMD Radeon Vega 8 Graphics
|
||||||
|
15DD, D3, AMD Radeon Vega 11 Graphics
|
||||||
|
15DD, D5, AMD Radeon Vega 8 Graphics
|
||||||
|
15DD, D6, AMD Radeon Vega 11 Graphics
|
||||||
|
15DD, D7, AMD Radeon Vega 8 Graphics
|
||||||
|
15DD, D8, AMD Radeon Vega 3 Graphics
|
||||||
|
15DD, D9, AMD Radeon Vega 6 Graphics
|
||||||
|
15DD, E1, AMD Radeon Vega 3 Graphics
|
||||||
|
15DD, E2, AMD Radeon Vega 3 Graphics
|
||||||
|
163F, AE, AMD Custom GPU 0405
|
||||||
|
163F, E1, AMD Custom GPU 0405
|
||||||
|
164E, D8, AMD Radeon 610M
|
||||||
|
164E, D9, AMD Radeon 610M
|
||||||
|
164E, DA, AMD Radeon 610M
|
||||||
|
164E, DB, AMD Radeon 610M
|
||||||
|
164E, DC, AMD Radeon 610M
|
||||||
|
1681, 06, AMD Radeon 680M
|
||||||
|
1681, 07, AMD Radeon 660M
|
||||||
|
1681, 0A, AMD Radeon 680M
|
||||||
|
1681, 0B, AMD Radeon 660M
|
||||||
|
1681, C7, AMD Radeon 680M
|
||||||
|
1681, C8, AMD Radeon 680M
|
||||||
|
1681, C9, AMD Radeon 660M
|
||||||
|
1900, 01, AMD Radeon 780M Graphics
|
||||||
|
1900, 02, AMD Radeon 760M Graphics
|
||||||
|
1900, 03, AMD Radeon 780M Graphics
|
||||||
|
1900, 04, AMD Radeon 760M Graphics
|
||||||
|
1900, 05, AMD Radeon 780M Graphics
|
||||||
|
1900, 06, AMD Radeon 780M Graphics
|
||||||
|
1900, 07, AMD Radeon 760M Graphics
|
||||||
|
1900, B0, AMD Radeon 780M Graphics
|
||||||
|
1900, B1, AMD Radeon 780M Graphics
|
||||||
|
1900, B2, AMD Radeon 780M Graphics
|
||||||
|
1900, B3, AMD Radeon 780M Graphics
|
||||||
|
1900, B4, AMD Radeon 780M Graphics
|
||||||
|
1900, B5, AMD Radeon 780M Graphics
|
||||||
|
1900, B6, AMD Radeon 780M Graphics
|
||||||
|
1900, B7, AMD Radeon 760M Graphics
|
||||||
|
1900, B8, AMD Radeon 760M Graphics
|
||||||
|
1900, B9, AMD Radeon 780M Graphics
|
||||||
|
1900, BA, AMD Radeon 780M Graphics
|
||||||
|
1900, BB, AMD Radeon 780M Graphics
|
||||||
|
1900, C0, AMD Radeon 780M Graphics
|
||||||
|
1900, C1, AMD Radeon 760M Graphics
|
||||||
|
1900, C2, AMD Radeon 780M Graphics
|
||||||
|
1900, C3, AMD Radeon 760M Graphics
|
||||||
|
1900, C4, AMD Radeon 780M Graphics
|
||||||
|
1900, C5, AMD Radeon 780M Graphics
|
||||||
|
1900, C6, AMD Radeon 760M Graphics
|
||||||
|
1900, C7, AMD Radeon 780M Graphics
|
||||||
|
1900, C8, AMD Radeon 760M Graphics
|
||||||
|
1900, C9, AMD Radeon 780M Graphics
|
||||||
|
1900, CA, AMD Radeon 760M Graphics
|
||||||
|
1900, CB, AMD Radeon 780M Graphics
|
||||||
|
1900, CC, AMD Radeon 780M Graphics
|
||||||
|
1900, CD, AMD Radeon 760M Graphics
|
||||||
|
1900, CE, AMD Radeon 780M Graphics
|
||||||
|
1900, CF, AMD Radeon 760M Graphics
|
||||||
|
1900, D0, AMD Radeon 780M Graphics
|
||||||
|
1900, D1, AMD Radeon 760M Graphics
|
||||||
|
1900, D2, AMD Radeon 780M Graphics
|
||||||
|
1900, D3, AMD Radeon 760M Graphics
|
||||||
|
1900, D4, AMD Radeon 780M Graphics
|
||||||
|
1900, D5, AMD Radeon 780M Graphics
|
||||||
|
1900, D6, AMD Radeon 760M Graphics
|
||||||
|
1900, D7, AMD Radeon 780M Graphics
|
||||||
|
1900, D8, AMD Radeon 760M Graphics
|
||||||
|
1900, D9, AMD Radeon 780M Graphics
|
||||||
|
1900, DA, AMD Radeon 760M Graphics
|
||||||
|
1900, DB, AMD Radeon 780M Graphics
|
||||||
|
1900, DC, AMD Radeon 780M Graphics
|
||||||
|
1900, DD, AMD Radeon 760M Graphics
|
||||||
|
1900, DE, AMD Radeon 780M Graphics
|
||||||
|
1900, DF, AMD Radeon 760M Graphics
|
||||||
|
1900, F0, AMD Radeon 780M Graphics
|
||||||
|
1900, F1, AMD Radeon 780M Graphics
|
||||||
|
1900, F2, AMD Radeon 780M Graphics
|
||||||
|
1901, C1, AMD Radeon 740M Graphics
|
||||||
|
1901, C2, AMD Radeon 740M Graphics
|
||||||
|
1901, C3, AMD Radeon 740M Graphics
|
||||||
|
1901, C6, AMD Radeon 740M Graphics
|
||||||
|
1901, C7, AMD Radeon 740M Graphics
|
||||||
|
1901, C8, AMD Radeon 740M Graphics
|
||||||
|
1901, C9, AMD Radeon 740M Graphics
|
||||||
|
1901, CA, AMD Radeon 740M Graphics
|
||||||
|
1901, D1, AMD Radeon 740M Graphics
|
||||||
|
1901, D2, AMD Radeon 740M Graphics
|
||||||
|
1901, D3, AMD Radeon 740M Graphics
|
||||||
|
1901, D4, AMD Radeon 740M Graphics
|
||||||
|
1901, D5, AMD Radeon 740M Graphics
|
||||||
|
1901, D6, AMD Radeon 740M Graphics
|
||||||
|
1901, D7, AMD Radeon 740M Graphics
|
||||||
|
1901, D8, AMD Radeon 740M Graphics
|
||||||
|
6600, 00, AMD Radeon HD 8600 / 8700M
|
||||||
|
6600, 81, AMD Radeon R7 M370
|
||||||
|
6601, 00, AMD Radeon HD 8500M / 8700M
|
||||||
|
6604, 00, AMD Radeon R7 M265 Series
|
||||||
|
6604, 81, AMD Radeon R7 M350
|
||||||
|
6605, 00, AMD Radeon R7 M260 Series
|
||||||
|
6605, 81, AMD Radeon R7 M340
|
||||||
|
6606, 00, AMD Radeon HD 8790M
|
||||||
|
6607, 00, AMD Radeon R5 M240
|
||||||
|
6608, 00, AMD FirePro W2100
|
||||||
|
6610, 00, AMD Radeon R7 200 Series
|
||||||
|
6610, 81, AMD Radeon R7 350
|
||||||
|
6610, 83, AMD Radeon R5 340
|
||||||
|
6610, 87, AMD Radeon R7 200 Series
|
||||||
|
6611, 00, AMD Radeon R7 200 Series
|
||||||
|
6611, 87, AMD Radeon R7 200 Series
|
||||||
|
6613, 00, AMD Radeon R7 200 Series
|
||||||
|
6617, 00, AMD Radeon R7 240 Series
|
||||||
|
6617, 87, AMD Radeon R7 200 Series
|
||||||
|
6617, C7, AMD Radeon R7 240 Series
|
||||||
|
6640, 00, AMD Radeon HD 8950
|
||||||
|
6640, 80, AMD Radeon R9 M380
|
||||||
|
6646, 00, AMD Radeon R9 M280X
|
||||||
|
6646, 80, AMD Radeon R9 M385
|
||||||
|
6646, 80, AMD Radeon R9 M470X
|
||||||
|
6647, 00, AMD Radeon R9 M200X Series
|
||||||
|
6647, 80, AMD Radeon R9 M380
|
||||||
|
6649, 00, AMD FirePro W5100
|
||||||
|
6658, 00, AMD Radeon R7 200 Series
|
||||||
|
665C, 00, AMD Radeon HD 7700 Series
|
||||||
|
665D, 00, AMD Radeon R7 200 Series
|
||||||
|
665F, 81, AMD Radeon R7 360 Series
|
||||||
|
6660, 00, AMD Radeon HD 8600M Series
|
||||||
|
6660, 81, AMD Radeon R5 M335
|
||||||
|
6660, 83, AMD Radeon R5 M330
|
||||||
|
6663, 00, AMD Radeon HD 8500M Series
|
||||||
|
6663, 83, AMD Radeon R5 M320
|
||||||
|
6664, 00, AMD Radeon R5 M200 Series
|
||||||
|
6665, 00, AMD Radeon R5 M230 Series
|
||||||
|
6665, 83, AMD Radeon R5 M320
|
||||||
|
6665, C3, AMD Radeon R5 M435
|
||||||
|
6666, 00, AMD Radeon R5 M200 Series
|
||||||
|
6667, 00, AMD Radeon R5 M200 Series
|
||||||
|
666F, 00, AMD Radeon HD 8500M
|
||||||
|
66A1, 02, AMD Instinct MI60 / MI50
|
||||||
|
66A1, 06, AMD Radeon Pro VII
|
||||||
|
66AF, C1, AMD Radeon VII
|
||||||
|
6780, 00, AMD FirePro W9000
|
||||||
|
6784, 00, ATI FirePro V (FireGL V) Graphics Adapter
|
||||||
|
6788, 00, ATI FirePro V (FireGL V) Graphics Adapter
|
||||||
|
678A, 00, AMD FirePro W8000
|
||||||
|
6798, 00, AMD Radeon R9 200 / HD 7900 Series
|
||||||
|
6799, 00, AMD Radeon HD 7900 Series
|
||||||
|
679A, 00, AMD Radeon HD 7900 Series
|
||||||
|
679B, 00, AMD Radeon HD 7900 Series
|
||||||
|
679E, 00, AMD Radeon HD 7800 Series
|
||||||
|
67A0, 00, AMD Radeon FirePro W9100
|
||||||
|
67A1, 00, AMD Radeon FirePro W8100
|
||||||
|
67B0, 00, AMD Radeon R9 200 Series
|
||||||
|
67B0, 80, AMD Radeon R9 390 Series
|
||||||
|
67B1, 00, AMD Radeon R9 200 Series
|
||||||
|
67B1, 80, AMD Radeon R9 390 Series
|
||||||
|
67B9, 00, AMD Radeon R9 200 Series
|
||||||
|
67C0, 00, AMD Radeon Pro WX 7100 Graphics
|
||||||
|
67C0, 80, AMD Radeon E9550
|
||||||
|
67C2, 01, AMD Radeon Pro V7350x2
|
||||||
|
67C2, 02, AMD Radeon Pro V7300X
|
||||||
|
67C4, 00, AMD Radeon Pro WX 7100 Graphics
|
||||||
|
67C4, 80, AMD Radeon E9560 / E9565 Graphics
|
||||||
|
67C7, 00, AMD Radeon Pro WX 5100 Graphics
|
||||||
|
67C7, 80, AMD Radeon E9390 Graphics
|
||||||
|
67D0, 01, AMD Radeon Pro V7350x2
|
||||||
|
67D0, 02, AMD Radeon Pro V7300X
|
||||||
|
67DF, C0, AMD Radeon Pro 580X
|
||||||
|
67DF, C1, AMD Radeon RX 580 Series
|
||||||
|
67DF, C2, AMD Radeon RX 570 Series
|
||||||
|
67DF, C3, AMD Radeon RX 580 Series
|
||||||
|
67DF, C4, AMD Radeon RX 480 Graphics
|
||||||
|
67DF, C5, AMD Radeon RX 470 Graphics
|
||||||
|
67DF, C6, AMD Radeon RX 570 Series
|
||||||
|
67DF, C7, AMD Radeon RX 480 Graphics
|
||||||
|
67DF, CF, AMD Radeon RX 470 Graphics
|
||||||
|
67DF, D7, AMD Radeon RX 470 Graphics
|
||||||
|
67DF, E0, AMD Radeon RX 470 Series
|
||||||
|
67DF, E1, AMD Radeon RX 590 Series
|
||||||
|
67DF, E3, AMD Radeon RX Series
|
||||||
|
67DF, E7, AMD Radeon RX 580 Series
|
||||||
|
67DF, EB, AMD Radeon Pro 580X
|
||||||
|
67DF, EF, AMD Radeon RX 570 Series
|
||||||
|
67DF, F7, AMD Radeon RX P30PH
|
||||||
|
67DF, FF, AMD Radeon RX 470 Series
|
||||||
|
67E0, 00, AMD Radeon Pro WX Series
|
||||||
|
67E3, 00, AMD Radeon Pro WX 4100
|
||||||
|
67E8, 00, AMD Radeon Pro WX Series
|
||||||
|
67E8, 01, AMD Radeon Pro WX Series
|
||||||
|
67E8, 80, AMD Radeon E9260 Graphics
|
||||||
|
67EB, 00, AMD Radeon Pro V5300X
|
||||||
|
67EF, C0, AMD Radeon RX Graphics
|
||||||
|
67EF, C1, AMD Radeon RX 460 Graphics
|
||||||
|
67EF, C2, AMD Radeon Pro Series
|
||||||
|
67EF, C3, AMD Radeon RX Series
|
||||||
|
67EF, C5, AMD Radeon RX 460 Graphics
|
||||||
|
67EF, C7, AMD Radeon RX Graphics
|
||||||
|
67EF, CF, AMD Radeon RX 460 Graphics
|
||||||
|
67EF, E0, AMD Radeon RX 560 Series
|
||||||
|
67EF, E1, AMD Radeon RX Series
|
||||||
|
67EF, E2, AMD Radeon RX 560X
|
||||||
|
67EF, E3, AMD Radeon RX Series
|
||||||
|
67EF, E5, AMD Radeon RX 560 Series
|
||||||
|
67EF, E7, AMD Radeon RX 560 Series
|
||||||
|
67EF, EF, AMD Radeon 550 Series
|
||||||
|
67EF, FF, AMD Radeon RX 460 Graphics
|
||||||
|
67FF, C0, AMD Radeon Pro 465
|
||||||
|
67FF, C1, AMD Radeon RX 560 Series
|
||||||
|
67FF, CF, AMD Radeon RX 560 Series
|
||||||
|
67FF, EF, AMD Radeon RX 560 Series
|
||||||
|
67FF, FF, AMD Radeon RX 550 Series
|
||||||
|
6800, 00, AMD Radeon HD 7970M
|
||||||
|
6801, 00, AMD Radeon HD 8970M
|
||||||
|
6806, 00, AMD Radeon R9 M290X
|
||||||
|
6808, 00, AMD FirePro W7000
|
||||||
|
6808, 00, ATI FirePro V (FireGL V) Graphics Adapter
|
||||||
|
6809, 00, ATI FirePro W5000
|
||||||
|
6810, 00, AMD Radeon R9 200 Series
|
||||||
|
6810, 81, AMD Radeon R9 370 Series
|
||||||
|
6811, 00, AMD Radeon R9 200 Series
|
||||||
|
6811, 81, AMD Radeon R7 370 Series
|
||||||
|
6818, 00, AMD Radeon HD 7800 Series
|
||||||
|
6819, 00, AMD Radeon HD 7800 Series
|
||||||
|
6820, 00, AMD Radeon R9 M275X
|
||||||
|
6820, 81, AMD Radeon R9 M375
|
||||||
|
6820, 83, AMD Radeon R9 M375X
|
||||||
|
6821, 00, AMD Radeon R9 M200X Series
|
||||||
|
6821, 83, AMD Radeon R9 M370X
|
||||||
|
6821, 87, AMD Radeon R7 M380
|
||||||
|
6822, 00, AMD Radeon E8860
|
||||||
|
6823, 00, AMD Radeon R9 M200X Series
|
||||||
|
6825, 00, AMD Radeon HD 7800M Series
|
||||||
|
6826, 00, AMD Radeon HD 7700M Series
|
||||||
|
6827, 00, AMD Radeon HD 7800M Series
|
||||||
|
6828, 00, AMD FirePro W600
|
||||||
|
682B, 00, AMD Radeon HD 8800M Series
|
||||||
|
682B, 87, AMD Radeon R9 M360
|
||||||
|
682C, 00, AMD FirePro W4100
|
||||||
|
682D, 00, AMD Radeon HD 7700M Series
|
||||||
|
682F, 00, AMD Radeon HD 7700M Series
|
||||||
|
6830, 00, AMD Radeon 7800M Series
|
||||||
|
6831, 00, AMD Radeon 7700M Series
|
||||||
|
6835, 00, AMD Radeon R7 Series / HD 9000 Series
|
||||||
|
6837, 00, AMD Radeon HD 7700 Series
|
||||||
|
683D, 00, AMD Radeon HD 7700 Series
|
||||||
|
683F, 00, AMD Radeon HD 7700 Series
|
||||||
|
684C, 00, ATI FirePro V (FireGL V) Graphics Adapter
|
||||||
|
6860, 00, AMD Radeon Instinct MI25
|
||||||
|
6860, 01, AMD Radeon Instinct MI25
|
||||||
|
6860, 02, AMD Radeon Instinct MI25
|
||||||
|
6860, 03, AMD Radeon Pro V340
|
||||||
|
6860, 04, AMD Radeon Instinct MI25x2
|
||||||
|
6860, 07, AMD Radeon Pro V320
|
||||||
|
6861, 00, AMD Radeon Pro WX 9100
|
||||||
|
6862, 00, AMD Radeon Pro SSG
|
||||||
|
6863, 00, AMD Radeon Vega Frontier Edition
|
||||||
|
6864, 03, AMD Radeon Pro V340
|
||||||
|
6864, 04, AMD Radeon Instinct MI25x2
|
||||||
|
6864, 05, AMD Radeon Pro V340
|
||||||
|
6868, 00, AMD Radeon Pro WX 8200
|
||||||
|
686C, 00, AMD Radeon Instinct MI25 MxGPU
|
||||||
|
686C, 01, AMD Radeon Instinct MI25 MxGPU
|
||||||
|
686C, 02, AMD Radeon Instinct MI25 MxGPU
|
||||||
|
686C, 03, AMD Radeon Pro V340 MxGPU
|
||||||
|
686C, 04, AMD Radeon Instinct MI25x2 MxGPU
|
||||||
|
686C, 05, AMD Radeon Pro V340L MxGPU
|
||||||
|
686C, 06, AMD Radeon Instinct MI25 MxGPU
|
||||||
|
687F, 01, AMD Radeon RX Vega
|
||||||
|
687F, C0, AMD Radeon RX Vega
|
||||||
|
687F, C1, AMD Radeon RX Vega
|
||||||
|
687F, C3, AMD Radeon RX Vega
|
||||||
|
687F, C7, AMD Radeon RX Vega
|
||||||
|
6900, 00, AMD Radeon R7 M260
|
||||||
|
6900, 81, AMD Radeon R7 M360
|
||||||
|
6900, 83, AMD Radeon R7 M340
|
||||||
|
6900, C1, AMD Radeon R5 M465 Series
|
||||||
|
6900, C3, AMD Radeon R5 M445 Series
|
||||||
|
6900, D1, AMD Radeon 530 Series
|
||||||
|
6900, D3, AMD Radeon 530 Series
|
||||||
|
6901, 00, AMD Radeon R5 M255
|
||||||
|
6902, 00, AMD Radeon Series
|
||||||
|
6907, 00, AMD Radeon R5 M255
|
||||||
|
6907, 87, AMD Radeon R5 M315
|
||||||
|
6920, 00, AMD Radeon R9 M395X
|
||||||
|
6920, 01, AMD Radeon R9 M390X
|
||||||
|
6921, 00, AMD Radeon R9 M390X
|
||||||
|
6929, 00, AMD FirePro S7150
|
||||||
|
6929, 01, AMD FirePro S7100X
|
||||||
|
692B, 00, AMD FirePro W7100
|
||||||
|
6938, 00, AMD Radeon R9 200 Series
|
||||||
|
6938, F0, AMD Radeon R9 200 Series
|
||||||
|
6938, F1, AMD Radeon R9 380 Series
|
||||||
|
6939, 00, AMD Radeon R9 200 Series
|
||||||
|
6939, F0, AMD Radeon R9 200 Series
|
||||||
|
6939, F1, AMD Radeon R9 380 Series
|
||||||
|
694C, C0, AMD Radeon RX Vega M GH Graphics
|
||||||
|
694E, C0, AMD Radeon RX Vega M GL Graphics
|
||||||
|
6980, 00, AMD Radeon Pro WX 3100
|
||||||
|
6981, 00, AMD Radeon Pro WX 3200 Series
|
||||||
|
6981, 01, AMD Radeon Pro WX 3200 Series
|
||||||
|
6981, 10, AMD Radeon Pro WX 3200 Series
|
||||||
|
6985, 00, AMD Radeon Pro WX 3100
|
||||||
|
6986, 00, AMD Radeon Pro WX 2100
|
||||||
|
6987, 80, AMD Embedded Radeon E9171
|
||||||
|
6987, C0, AMD Radeon 550X Series
|
||||||
|
6987, C1, AMD Radeon RX 640
|
||||||
|
6987, C3, AMD Radeon 540X Series
|
||||||
|
6987, C7, AMD Radeon 540
|
||||||
|
6995, 00, AMD Radeon Pro WX 2100
|
||||||
|
6997, 00, AMD Radeon Pro WX 2100
|
||||||
|
699F, 81, AMD Embedded Radeon E9170 Series
|
||||||
|
699F, C0, AMD Radeon 500 Series
|
||||||
|
699F, C1, AMD Radeon 540 Series
|
||||||
|
699F, C3, AMD Radeon 500 Series
|
||||||
|
699F, C7, AMD Radeon RX 550 / 550 Series
|
||||||
|
699F, C9, AMD Radeon 540
|
||||||
|
6FDF, E7, AMD Radeon RX 590 GME
|
||||||
|
6FDF, EF, AMD Radeon RX 580 2048SP
|
||||||
|
7300, C1, AMD FirePro S9300 x2
|
||||||
|
7300, C8, AMD Radeon R9 Fury Series
|
||||||
|
7300, C9, AMD Radeon Pro Duo
|
||||||
|
7300, CA, AMD Radeon R9 Fury Series
|
||||||
|
7300, CB, AMD Radeon R9 Fury Series
|
||||||
|
7312, 00, AMD Radeon Pro W5700
|
||||||
|
731E, C6, AMD Radeon RX 5700XTB
|
||||||
|
731E, C7, AMD Radeon RX 5700B
|
||||||
|
731F, C0, AMD Radeon RX 5700 XT 50th Anniversary
|
||||||
|
731F, C1, AMD Radeon RX 5700 XT
|
||||||
|
731F, C2, AMD Radeon RX 5600M
|
||||||
|
731F, C3, AMD Radeon RX 5700M
|
||||||
|
731F, C4, AMD Radeon RX 5700
|
||||||
|
731F, C5, AMD Radeon RX 5700 XT
|
||||||
|
731F, CA, AMD Radeon RX 5600 XT
|
||||||
|
731F, CB, AMD Radeon RX 5600 OEM
|
||||||
|
7340, C1, AMD Radeon RX 5500M
|
||||||
|
7340, C3, AMD Radeon RX 5300M
|
||||||
|
7340, C5, AMD Radeon RX 5500 XT
|
||||||
|
7340, C7, AMD Radeon RX 5500
|
||||||
|
7340, C9, AMD Radeon RX 5500XTB
|
||||||
|
7340, CF, AMD Radeon RX 5300
|
||||||
|
7341, 00, AMD Radeon Pro W5500
|
||||||
|
7347, 00, AMD Radeon Pro W5500M
|
||||||
|
7360, 41, AMD Radeon Pro 5600M
|
||||||
|
7360, C3, AMD Radeon Pro V520
|
||||||
|
7362, C1, AMD Radeon Pro V540
|
||||||
|
7362, C3, AMD Radeon Pro V520
|
||||||
|
738C, 01, AMD Instinct MI100
|
||||||
|
73A1, 00, AMD Radeon Pro V620
|
||||||
|
73A3, 00, AMD Radeon Pro W6800
|
||||||
|
73A5, C0, AMD Radeon RX 6950 XT
|
||||||
|
73AE, 00, AMD Radeon Pro V620 MxGPU
|
||||||
|
73AF, C0, AMD Radeon RX 6900 XT
|
||||||
|
73BF, C0, AMD Radeon RX 6900 XT
|
||||||
|
73BF, C1, AMD Radeon RX 6800 XT
|
||||||
|
73BF, C3, AMD Radeon RX 6800
|
||||||
|
73DF, C0, AMD Radeon RX 6750 XT
|
||||||
|
73DF, C1, AMD Radeon RX 6700 XT
|
||||||
|
73DF, C2, AMD Radeon RX 6800M
|
||||||
|
73DF, C3, AMD Radeon RX 6800M
|
||||||
|
73DF, C5, AMD Radeon RX 6700 XT
|
||||||
|
73DF, CF, AMD Radeon RX 6700M
|
||||||
|
73DF, D5, AMD Radeon RX 6750 GRE 12GB
|
||||||
|
73DF, D7, AMD TDC-235
|
||||||
|
73DF, DF, AMD Radeon RX 6700
|
||||||
|
73DF, E5, AMD Radeon RX 6750 GRE 12GB
|
||||||
|
73DF, FF, AMD Radeon RX 6700
|
||||||
|
73E0, 00, AMD Radeon RX 6600M
|
||||||
|
73E1, 00, AMD Radeon Pro W6600M
|
||||||
|
73E3, 00, AMD Radeon Pro W6600
|
||||||
|
73EF, C0, AMD Radeon RX 6800S
|
||||||
|
73EF, C1, AMD Radeon RX 6650 XT
|
||||||
|
73EF, C2, AMD Radeon RX 6700S
|
||||||
|
73EF, C3, AMD Radeon RX 6650M
|
||||||
|
73EF, C4, AMD Radeon RX 6650M XT
|
||||||
|
73FF, C1, AMD Radeon RX 6600 XT
|
||||||
|
73FF, C3, AMD Radeon RX 6600M
|
||||||
|
73FF, C7, AMD Radeon RX 6600
|
||||||
|
73FF, CB, AMD Radeon RX 6600S
|
||||||
|
73FF, CF, AMD Radeon RX 6600 LE
|
||||||
|
73FF, DF, AMD Radeon RX 6750 GRE 10GB
|
||||||
|
7408, 00, AMD Instinct MI250X
|
||||||
|
740C, 01, AMD Instinct MI250X / MI250
|
||||||
|
740F, 02, AMD Instinct MI210
|
||||||
|
7421, 00, AMD Radeon Pro W6500M
|
||||||
|
7422, 00, AMD Radeon Pro W6400
|
||||||
|
7423, 00, AMD Radeon Pro W6300M
|
||||||
|
7423, 01, AMD Radeon Pro W6300
|
||||||
|
7424, 00, AMD Radeon RX 6300
|
||||||
|
743F, C1, AMD Radeon RX 6500 XT
|
||||||
|
743F, C3, AMD Radeon RX 6500
|
||||||
|
743F, C3, AMD Radeon RX 6500M
|
||||||
|
743F, C7, AMD Radeon RX 6400
|
||||||
|
743F, C8, AMD Radeon RX 6500M
|
||||||
|
743F, CC, AMD Radeon 6550S
|
||||||
|
743F, CE, AMD Radeon RX 6450M
|
||||||
|
743F, CF, AMD Radeon RX 6300M
|
||||||
|
743F, D3, AMD Radeon RX 6550M
|
||||||
|
743F, D7, AMD Radeon RX 6400
|
||||||
|
7448, 00, AMD Radeon Pro W7900
|
||||||
|
7449, 00, AMD Radeon Pro W7800 48GB
|
||||||
|
744A, 00, AMD Radeon Pro W7900 Dual Slot
|
||||||
|
744B, 00, AMD Radeon Pro W7900D
|
||||||
|
744C, C8, AMD Radeon RX 7900 XTX
|
||||||
|
744C, CC, AMD Radeon RX 7900 XT
|
||||||
|
744C, CE, AMD Radeon RX 7900 GRE
|
||||||
|
744C, CF, AMD Radeon RX 7900M
|
||||||
|
745E, CC, AMD Radeon Pro W7800
|
||||||
|
7460, 00, AMD Radeon Pro V710
|
||||||
|
7461, 00, AMD Radeon Pro V710 MxGPU
|
||||||
|
7470, 00, AMD Radeon Pro W7700
|
||||||
|
747E, C8, AMD Radeon RX 7800 XT
|
||||||
|
747E, D8, AMD Radeon RX 7800M
|
||||||
|
747E, DB, AMD Radeon RX 7700
|
||||||
|
747E, FF, AMD Radeon RX 7700 XT
|
||||||
|
7480, 00, AMD Radeon Pro W7600
|
||||||
|
7480, C0, AMD Radeon RX 7600 XT
|
||||||
|
7480, C1, AMD Radeon RX 7700S
|
||||||
|
7480, C2, AMD Radeon RX 7650 GRE
|
||||||
|
7480, C3, AMD Radeon RX 7600S
|
||||||
|
7480, C7, AMD Radeon RX 7600M XT
|
||||||
|
7480, CF, AMD Radeon RX 7600
|
||||||
|
7481, C7, AMD Steam Machine
|
||||||
|
7483, CF, AMD Radeon RX 7600M
|
||||||
|
7489, 00, AMD Radeon Pro W7500
|
||||||
|
7499, 00, AMD Radeon Pro W7400
|
||||||
|
7499, C0, AMD Radeon RX 7400
|
||||||
|
7499, C1, AMD Radeon RX 7300
|
||||||
|
74A0, 00, AMD Instinct MI300A
|
||||||
|
74A1, 00, AMD Instinct MI300X
|
||||||
|
74A2, 00, AMD Instinct MI308X
|
||||||
|
74A5, 00, AMD Instinct MI325X
|
||||||
|
74A8, 00, AMD Instinct MI308X HF
|
||||||
|
74A9, 00, AMD Instinct MI300X HF
|
||||||
|
74B5, 00, AMD Instinct MI300X VF
|
||||||
|
74B6, 00, AMD Instinct MI308X
|
||||||
|
74BD, 00, AMD Instinct MI300X HF
|
||||||
|
7550, C0, AMD Radeon RX 9070 XT
|
||||||
|
7550, C2, AMD Radeon RX 9070 GRE
|
||||||
|
7550, C3, AMD Radeon RX 9070
|
||||||
|
7551, C0, AMD Radeon AI PRO R9700
|
||||||
|
7590, C0, AMD Radeon RX 9060 XT
|
||||||
|
7590, C7, AMD Radeon RX 9060
|
||||||
|
75A0, C0, AMD Instinct MI350X
|
||||||
|
75A3, C0, AMD Instinct MI355X
|
||||||
|
75B0, C0, AMD Instinct MI350X VF
|
||||||
|
75B3, C0, AMD Instinct MI355X VF
|
||||||
|
9830, 00, AMD Radeon HD 8400 / R3 Series
|
||||||
|
9831, 00, AMD Radeon HD 8400E
|
||||||
|
9832, 00, AMD Radeon HD 8330
|
||||||
|
9833, 00, AMD Radeon HD 8330E
|
||||||
|
9834, 00, AMD Radeon HD 8210
|
||||||
|
9835, 00, AMD Radeon HD 8210E
|
||||||
|
9836, 00, AMD Radeon HD 8200 / R3 Series
|
||||||
|
9837, 00, AMD Radeon HD 8280E
|
||||||
|
9838, 00, AMD Radeon HD 8200 / R3 series
|
||||||
|
9839, 00, AMD Radeon HD 8180
|
||||||
|
983D, 00, AMD Radeon HD 8250
|
||||||
|
9850, 00, AMD Radeon R3 Graphics
|
||||||
|
9850, 03, AMD Radeon R3 Graphics
|
||||||
|
9850, 40, AMD Radeon R2 Graphics
|
||||||
|
9850, 45, AMD Radeon R3 Graphics
|
||||||
|
9851, 00, AMD Radeon R4 Graphics
|
||||||
|
9851, 01, AMD Radeon R5E Graphics
|
||||||
|
9851, 05, AMD Radeon R5 Graphics
|
||||||
|
9851, 06, AMD Radeon R5E Graphics
|
||||||
|
9851, 40, AMD Radeon R4 Graphics
|
||||||
|
9851, 45, AMD Radeon R5 Graphics
|
||||||
|
9852, 00, AMD Radeon R2 Graphics
|
||||||
|
9852, 40, AMD Radeon E1 Graphics
|
||||||
|
9853, 00, AMD Radeon R2 Graphics
|
||||||
|
9853, 01, AMD Radeon R4E Graphics
|
||||||
|
9853, 03, AMD Radeon R2 Graphics
|
||||||
|
9853, 05, AMD Radeon R1E Graphics
|
||||||
|
9853, 06, AMD Radeon R1E Graphics
|
||||||
|
9853, 07, AMD Radeon R1E Graphics
|
||||||
|
9853, 08, AMD Radeon R1E Graphics
|
||||||
|
9853, 40, AMD Radeon R2 Graphics
|
||||||
|
9854, 00, AMD Radeon R3 Graphics
|
||||||
|
9854, 01, AMD Radeon R3E Graphics
|
||||||
|
9854, 02, AMD Radeon R3 Graphics
|
||||||
|
9854, 05, AMD Radeon R2 Graphics
|
||||||
|
9854, 06, AMD Radeon R4 Graphics
|
||||||
|
9854, 07, AMD Radeon R3 Graphics
|
||||||
|
9855, 02, AMD Radeon R6 Graphics
|
||||||
|
9855, 05, AMD Radeon R4 Graphics
|
||||||
|
9856, 00, AMD Radeon R2 Graphics
|
||||||
|
9856, 01, AMD Radeon R2E Graphics
|
||||||
|
9856, 02, AMD Radeon R2 Graphics
|
||||||
|
9856, 05, AMD Radeon R1E Graphics
|
||||||
|
9856, 06, AMD Radeon R2 Graphics
|
||||||
|
9856, 07, AMD Radeon R1E Graphics
|
||||||
|
9856, 08, AMD Radeon R1E Graphics
|
||||||
|
9856, 13, AMD Radeon R1E Graphics
|
||||||
|
9874, 81, AMD Radeon R6 Graphics
|
||||||
|
9874, 84, AMD Radeon R7 Graphics
|
||||||
|
9874, 85, AMD Radeon R6 Graphics
|
||||||
|
9874, 87, AMD Radeon R5 Graphics
|
||||||
|
9874, 88, AMD Radeon R7E Graphics
|
||||||
|
9874, 89, AMD Radeon R6E Graphics
|
||||||
|
9874, C4, AMD Radeon R7 Graphics
|
||||||
|
9874, C5, AMD Radeon R6 Graphics
|
||||||
|
9874, C6, AMD Radeon R6 Graphics
|
||||||
|
9874, C7, AMD Radeon R5 Graphics
|
||||||
|
9874, C8, AMD Radeon R7 Graphics
|
||||||
|
9874, C9, AMD Radeon R7 Graphics
|
||||||
|
9874, CA, AMD Radeon R5 Graphics
|
||||||
|
9874, CB, AMD Radeon R5 Graphics
|
||||||
|
9874, CC, AMD Radeon R7 Graphics
|
||||||
|
9874, CD, AMD Radeon R7 Graphics
|
||||||
|
9874, CE, AMD Radeon R5 Graphics
|
||||||
|
9874, E1, AMD Radeon R7 Graphics
|
||||||
|
9874, E2, AMD Radeon R7 Graphics
|
||||||
|
9874, E3, AMD Radeon R7 Graphics
|
||||||
|
9874, E4, AMD Radeon R7 Graphics
|
||||||
|
9874, E5, AMD Radeon R5 Graphics
|
||||||
|
9874, E6, AMD Radeon R5 Graphics
|
||||||
|
98E4, 80, AMD Radeon R5E Graphics
|
||||||
|
98E4, 81, AMD Radeon R4E Graphics
|
||||||
|
98E4, 83, AMD Radeon R2E Graphics
|
||||||
|
98E4, 84, AMD Radeon R2E Graphics
|
||||||
|
98E4, 86, AMD Radeon R1E Graphics
|
||||||
|
98E4, C0, AMD Radeon R4 Graphics
|
||||||
|
98E4, C1, AMD Radeon R5 Graphics
|
||||||
|
98E4, C2, AMD Radeon R4 Graphics
|
||||||
|
98E4, C4, AMD Radeon R5 Graphics
|
||||||
|
98E4, C6, AMD Radeon R5 Graphics
|
||||||
|
98E4, C8, AMD Radeon R4 Graphics
|
||||||
|
98E4, C9, AMD Radeon R4 Graphics
|
||||||
|
98E4, CA, AMD Radeon R5 Graphics
|
||||||
|
98E4, D0, AMD Radeon R2 Graphics
|
||||||
|
98E4, D1, AMD Radeon R2 Graphics
|
||||||
|
98E4, D2, AMD Radeon R2 Graphics
|
||||||
|
98E4, D4, AMD Radeon R2 Graphics
|
||||||
|
98E4, D9, AMD Radeon R5 Graphics
|
||||||
|
98E4, DA, AMD Radeon R5 Graphics
|
||||||
|
98E4, DB, AMD Radeon R3 Graphics
|
||||||
|
98E4, E1, AMD Radeon R3 Graphics
|
||||||
|
98E4, E2, AMD Radeon R3 Graphics
|
||||||
|
98E4, E9, AMD Radeon R4 Graphics
|
||||||
|
98E4, EA, AMD Radeon R4 Graphics
|
||||||
|
98E4, EB, AMD Radeon R3 Graphics
|
||||||
|
98E4, EB, AMD Radeon R4 Graphics
|
||||||
24
agent/test-data/container.json
Normal file
24
agent/test-data/container.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
24
agent/test-data/container2.json
Normal file
24
agent/test-data/container2.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
34
agent/test-data/nvtop.json
Normal file
34
agent/test-data/nvtop.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"device_name": "NVIDIA GeForce RTX 3050 Ti Laptop GPU",
|
||||||
|
"gpu_clock": "1485MHz",
|
||||||
|
"mem_clock": "6001MHz",
|
||||||
|
"temp": "48C",
|
||||||
|
"fan_speed": null,
|
||||||
|
"power_draw": "13W",
|
||||||
|
"gpu_util": "5%",
|
||||||
|
"encode": "0%",
|
||||||
|
"decode": "0%",
|
||||||
|
"mem_util": "8%",
|
||||||
|
"mem_total": "4294967296",
|
||||||
|
"mem_used": "349372416",
|
||||||
|
"mem_free": "3945594880",
|
||||||
|
"processes" : []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"device_name": "AMD Radeon 680M",
|
||||||
|
"gpu_clock": "2200MHz",
|
||||||
|
"mem_clock": "2400MHz",
|
||||||
|
"temp": "48C",
|
||||||
|
"fan_speed": "CPU Fan",
|
||||||
|
"power_draw": "9W",
|
||||||
|
"gpu_util": "12%",
|
||||||
|
"encode": null,
|
||||||
|
"decode": "0%",
|
||||||
|
"mem_util": "7%",
|
||||||
|
"mem_total": "16929173504",
|
||||||
|
"mem_used": "1213784064",
|
||||||
|
"mem_free": "15715389440",
|
||||||
|
"processes" : []
|
||||||
|
}
|
||||||
|
]
|
||||||
51
agent/test-data/smart/apple_nvme.json
Normal file
51
agent/test-data/smart/apple_nvme.json
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
{
|
||||||
|
"json_format_version": [1, 0],
|
||||||
|
"smartctl": {
|
||||||
|
"version": [7, 4],
|
||||||
|
"argv": ["smartctl", "-aix", "-j", "IOService:/AppleARMPE/arm-io@10F00000/AppleT810xIO/ans@77400000/AppleASCWrapV4/iop-ans-nub/RTBuddy(ANS2)/RTBuddyService/AppleANS3NVMeController/NS_01@1"],
|
||||||
|
"exit_status": 4
|
||||||
|
},
|
||||||
|
"device": {
|
||||||
|
"name": "IOService:/AppleARMPE/arm-io@10F00000/AppleT810xIO/ans@77400000/AppleASCWrapV4/iop-ans-nub/RTBuddy(ANS2)/RTBuddyService/AppleANS3NVMeController/NS_01@1",
|
||||||
|
"info_name": "IOService:/AppleARMPE/arm-io@10F00000/AppleT810xIO/ans@77400000/AppleASCWrapV4/iop-ans-nub/RTBuddy(ANS2)/RTBuddyService/AppleANS3NVMeController/NS_01@1",
|
||||||
|
"type": "nvme",
|
||||||
|
"protocol": "NVMe"
|
||||||
|
},
|
||||||
|
"model_name": "APPLE SSD AP0256Q",
|
||||||
|
"serial_number": "0ba0147940253c15",
|
||||||
|
"firmware_version": "555",
|
||||||
|
"smart_support": {
|
||||||
|
"available": true,
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"smart_status": {
|
||||||
|
"passed": true,
|
||||||
|
"nvme": {
|
||||||
|
"value": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nvme_smart_health_information_log": {
|
||||||
|
"critical_warning": 0,
|
||||||
|
"temperature": 42,
|
||||||
|
"available_spare": 100,
|
||||||
|
"available_spare_threshold": 99,
|
||||||
|
"percentage_used": 1,
|
||||||
|
"data_units_read": 270189386,
|
||||||
|
"data_units_written": 166753862,
|
||||||
|
"host_reads": 7543766995,
|
||||||
|
"host_writes": 3761621926,
|
||||||
|
"controller_busy_time": 0,
|
||||||
|
"power_cycles": 366,
|
||||||
|
"power_on_hours": 2850,
|
||||||
|
"unsafe_shutdowns": 195,
|
||||||
|
"media_errors": 0,
|
||||||
|
"num_err_log_entries": 0
|
||||||
|
},
|
||||||
|
"temperature": {
|
||||||
|
"current": 42
|
||||||
|
},
|
||||||
|
"power_cycle_count": 366,
|
||||||
|
"power_on_time": {
|
||||||
|
"hours": 2850
|
||||||
|
}
|
||||||
|
}
|
||||||
272
agent/test-data/smart/nvme0.json
Normal file
272
agent/test-data/smart/nvme0.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
36
agent/test-data/smart/scan.json
Normal file
36
agent/test-data/smart/scan.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
125
agent/test-data/smart/scsi.json
Normal file
125
agent/test-data/smart/scsi.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1013
agent/test-data/smart/sda.json
Normal file
1013
agent/test-data/smart/sda.json
Normal file
File diff suppressed because it is too large
Load Diff
17
agent/test-data/system_info.json
Normal file
17
agent/test-data/system_info.json
Normal 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"
|
||||||
|
}
|
||||||
130
agent/tools/fetchsmartctl/main.go
Normal file
130
agent/tools/fetchsmartctl/main.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
)
|
)
|
||||||
@@ -81,7 +79,7 @@ func detectRestarter() restarter {
|
|||||||
func Update(useMirror bool) error {
|
func Update(useMirror bool) error {
|
||||||
exePath, _ := os.Executable()
|
exePath, _ := os.Executable()
|
||||||
|
|
||||||
dataDir, err := getDataDir()
|
dataDir, err := GetDataDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
dataDir = os.TempDir()
|
dataDir = os.TempDir()
|
||||||
}
|
}
|
||||||
@@ -108,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)
|
||||||
@@ -127,42 +125,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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
package agent
|
|
||||||
|
|
||||||
import "math"
|
|
||||||
|
|
||||||
func bytesToMegabytes(b float64) float64 {
|
|
||||||
return twoDecimals(b / 1048576)
|
|
||||||
}
|
|
||||||
|
|
||||||
func bytesToGigabytes(b uint64) float64 {
|
|
||||||
return twoDecimals(float64(b) / 1073741824)
|
|
||||||
}
|
|
||||||
|
|
||||||
func twoDecimals(value float64) float64 {
|
|
||||||
return math.Round(value*100) / 100
|
|
||||||
}
|
|
||||||
117
agent/utils/utils.go
Normal file
117
agent/utils/utils.go
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
// Package utils provides utility functions for the agent.
|
||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetEnv retrieves an environment variable with a "BESZEL_AGENT_" prefix, or falls back to the unprefixed key.
|
||||||
|
func GetEnv(key string) (value string, exists bool) {
|
||||||
|
if value, exists = os.LookupEnv("BESZEL_AGENT_" + key); exists {
|
||||||
|
return value, exists
|
||||||
|
}
|
||||||
|
return os.LookupEnv(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BytesToMegabytes converts bytes to megabytes and rounds to two decimal places.
|
||||||
|
func BytesToMegabytes(b float64) float64 {
|
||||||
|
return TwoDecimals(b / 1048576)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BytesToGigabytes converts bytes to gigabytes and rounds to two decimal places.
|
||||||
|
func BytesToGigabytes(b uint64) float64 {
|
||||||
|
return TwoDecimals(float64(b) / 1073741824)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TwoDecimals rounds a float64 value to two decimal places.
|
||||||
|
func TwoDecimals(value float64) float64 {
|
||||||
|
return math.Round(value*100) / 100
|
||||||
|
}
|
||||||
|
|
||||||
|
// func RoundFloat(val float64, precision uint) float64 {
|
||||||
|
// ratio := math.Pow(10, float64(precision))
|
||||||
|
// return math.Round(val*ratio) / ratio
|
||||||
|
// }
|
||||||
|
|
||||||
|
// ReadStringFile returns trimmed file contents or empty string on error.
|
||||||
|
func ReadStringFile(path string) string {
|
||||||
|
content, _ := ReadStringFileOK(path)
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadStringFileOK returns trimmed file contents and read success.
|
||||||
|
func ReadStringFileOK(path string) (string, bool) {
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(b)), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadStringFileLimited reads a file into a string with a maximum size (in bytes) to avoid
|
||||||
|
// allocating large buffers and potential panics with pseudo-files when the size is misreported.
|
||||||
|
func ReadStringFileLimited(path string, maxSize int) (string, error) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
buf := make([]byte, maxSize)
|
||||||
|
n, err := f.Read(buf)
|
||||||
|
if err != nil && err != io.EOF {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if n < 0 {
|
||||||
|
return "", fmt.Errorf("%s returned negative bytes: %d", path, n)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(buf[:n])), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileExists reports whether the given path exists.
|
||||||
|
func FileExists(path string) bool {
|
||||||
|
_, err := os.Stat(path)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadUintFile parses a decimal uint64 value from a file.
|
||||||
|
func ReadUintFile(path string) (uint64, bool) {
|
||||||
|
raw, ok := ReadStringFileOK(path)
|
||||||
|
if !ok {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
parsed, err := strconv.ParseUint(raw, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return parsed, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// LookPathHomebrew is like exec.LookPath but also checks Homebrew paths.
|
||||||
|
func LookPathHomebrew(file string) (string, error) {
|
||||||
|
foundPath, lookPathErr := exec.LookPath(file)
|
||||||
|
if lookPathErr == nil {
|
||||||
|
return foundPath, nil
|
||||||
|
}
|
||||||
|
var homebrewPath string
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "darwin":
|
||||||
|
homebrewPath = filepath.Join("/opt", "homebrew", "bin", file)
|
||||||
|
case "linux":
|
||||||
|
homebrewPath = filepath.Join("/home", "linuxbrew", ".linuxbrew", "bin", file)
|
||||||
|
}
|
||||||
|
if homebrewPath != "" {
|
||||||
|
if _, err := os.Stat(homebrewPath); err == nil {
|
||||||
|
return homebrewPath, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", lookPathErr
|
||||||
|
}
|
||||||
158
agent/utils/utils_test.go
Normal file
158
agent/utils/utils_test.go
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTwoDecimals(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input float64
|
||||||
|
expected float64
|
||||||
|
}{
|
||||||
|
{"round down", 1.234, 1.23},
|
||||||
|
{"round half up", 1.235, 1.24}, // math.Round rounds half up
|
||||||
|
{"no rounding needed", 1.23, 1.23},
|
||||||
|
{"negative number", -1.235, -1.24}, // math.Round rounds half up (more negative)
|
||||||
|
{"zero", 0.0, 0.0},
|
||||||
|
{"large number", 123.456, 123.46}, // rounds 5 up
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := TwoDecimals(tt.input)
|
||||||
|
assert.Equal(t, tt.expected, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBytesToMegabytes(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input float64
|
||||||
|
expected float64
|
||||||
|
}{
|
||||||
|
{"1 MB", 1048576, 1.0},
|
||||||
|
{"512 KB", 524288, 0.5},
|
||||||
|
{"zero", 0, 0},
|
||||||
|
{"large value", 1073741824, 1024}, // 1 GB = 1024 MB
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := BytesToMegabytes(tt.input)
|
||||||
|
assert.Equal(t, tt.expected, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBytesToGigabytes(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input uint64
|
||||||
|
expected float64
|
||||||
|
}{
|
||||||
|
{"1 GB", 1073741824, 1.0},
|
||||||
|
{"512 MB", 536870912, 0.5},
|
||||||
|
{"0 GB", 0, 0},
|
||||||
|
{"2 GB", 2147483648, 2.0},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := BytesToGigabytes(tt.input)
|
||||||
|
assert.Equal(t, tt.expected, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileFunctions(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
testFilePath := filepath.Join(tmpDir, "test.txt")
|
||||||
|
testContent := "hello world"
|
||||||
|
|
||||||
|
// Test FileExists (false)
|
||||||
|
assert.False(t, FileExists(testFilePath))
|
||||||
|
|
||||||
|
// Test ReadStringFileOK (false)
|
||||||
|
content, ok := ReadStringFileOK(testFilePath)
|
||||||
|
assert.False(t, ok)
|
||||||
|
assert.Empty(t, content)
|
||||||
|
|
||||||
|
// Test ReadStringFile (empty)
|
||||||
|
assert.Empty(t, ReadStringFile(testFilePath))
|
||||||
|
|
||||||
|
// Write file
|
||||||
|
err := os.WriteFile(testFilePath, []byte(testContent+"\n "), 0644)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Test FileExists (true)
|
||||||
|
assert.True(t, FileExists(testFilePath))
|
||||||
|
|
||||||
|
// Test ReadStringFileOK (true)
|
||||||
|
content, ok = ReadStringFileOK(testFilePath)
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Equal(t, testContent, content)
|
||||||
|
|
||||||
|
// Test ReadStringFile (content)
|
||||||
|
assert.Equal(t, testContent, ReadStringFile(testFilePath))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadUintFile(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
t.Run("valid uint", func(t *testing.T) {
|
||||||
|
path := filepath.Join(tmpDir, "uint.txt")
|
||||||
|
os.WriteFile(path, []byte(" 12345\n"), 0644)
|
||||||
|
val, ok := ReadUintFile(path)
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Equal(t, uint64(12345), val)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid uint", func(t *testing.T) {
|
||||||
|
path := filepath.Join(tmpDir, "invalid.txt")
|
||||||
|
os.WriteFile(path, []byte("abc"), 0644)
|
||||||
|
val, ok := ReadUintFile(path)
|
||||||
|
assert.False(t, ok)
|
||||||
|
assert.Equal(t, uint64(0), val)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("missing file", func(t *testing.T) {
|
||||||
|
path := filepath.Join(tmpDir, "missing.txt")
|
||||||
|
val, ok := ReadUintFile(path)
|
||||||
|
assert.False(t, ok)
|
||||||
|
assert.Equal(t, uint64(0), val)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetEnv(t *testing.T) {
|
||||||
|
key := "TEST_VAR"
|
||||||
|
prefixedKey := "BESZEL_AGENT_" + key
|
||||||
|
|
||||||
|
t.Run("prefixed variable exists", func(t *testing.T) {
|
||||||
|
t.Setenv(prefixedKey, "prefixed_val")
|
||||||
|
t.Setenv(key, "unprefixed_val")
|
||||||
|
|
||||||
|
val, exists := GetEnv(key)
|
||||||
|
assert.True(t, exists)
|
||||||
|
assert.Equal(t, "prefixed_val", val)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("only unprefixed variable exists", func(t *testing.T) {
|
||||||
|
t.Setenv(key, "unprefixed_val")
|
||||||
|
|
||||||
|
val, exists := GetEnv(key)
|
||||||
|
assert.True(t, exists)
|
||||||
|
assert.Equal(t, "unprefixed_val", val)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("neither variable exists", func(t *testing.T) {
|
||||||
|
val, exists := GetEnv(key)
|
||||||
|
assert.False(t, exists)
|
||||||
|
assert.Empty(t, val)
|
||||||
|
})
|
||||||
|
}
|
||||||
11
agent/zfs/zfs_freebsd.go
Normal file
11
agent/zfs/zfs_freebsd.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
//go:build freebsd
|
||||||
|
|
||||||
|
package zfs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"golang.org/x/sys/unix"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ARCSize() (uint64, error) {
|
||||||
|
return unix.SysctlUint64("kstat.zfs.misc.arcstats.size")
|
||||||
|
}
|
||||||
34
agent/zfs/zfs_linux.go
Normal file
34
agent/zfs/zfs_linux.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
// Package zfs provides functions to read ZFS statistics.
|
||||||
|
package zfs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ARCSize() (uint64, error) {
|
||||||
|
file, err := os.Open("/proc/spl/kstat/zfs/arcstats")
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(file)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
if strings.HasPrefix(line, "size") {
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) < 3 {
|
||||||
|
return 0, fmt.Errorf("unexpected arcstats size format: %s", line)
|
||||||
|
}
|
||||||
|
return strconv.ParseUint(fields[2], 10, 64)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0, fmt.Errorf("size field not found in arcstats")
|
||||||
|
}
|
||||||
9
agent/zfs/zfs_unsupported.go
Normal file
9
agent/zfs/zfs_unsupported.go
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
//go:build !linux && !freebsd
|
||||||
|
|
||||||
|
package zfs
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
func ARCSize() (uint64, error) {
|
||||||
|
return 0, errors.ErrUnsupported
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user