mirror of
https://github.com/henrygd/beszel.git
synced 2026-03-22 21:46:18 +01:00
Compare commits
203 Commits
top-proces
...
temp-pve
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5eca353429 | ||
|
|
d9e3c4678a | ||
|
|
1243a7bd8d | ||
|
|
bd74ab8d7b | ||
|
|
016d775675 | ||
|
|
bdbd135fdd | ||
|
|
48503f9f99 | ||
|
|
d34ef1ebe9 | ||
|
|
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 |
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,65 +29,29 @@ 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:
|
||||||
label: Motivation / Use Case
|
label: Motivation / Use Case
|
||||||
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:
|
|
||||||
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:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
42
.github/workflows/docker-images.yml
vendored
42
.github/workflows/docker-images.yml
vendored
@@ -10,6 +10,7 @@ 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
|
# henrygd/beszel
|
||||||
@@ -24,19 +25,18 @@ jobs:
|
|||||||
type=semver,pattern={{major}}.{{minor}}
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
type=semver,pattern={{major}}
|
type=semver,pattern={{major}}
|
||||||
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
|
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
|
||||||
|
|
||||||
# henrygd/beszel-agent
|
# henrygd/beszel-agent:alpine
|
||||||
- image: henrygd/beszel-agent
|
- image: henrygd/beszel-agent
|
||||||
dockerfile: ./internal/dockerfile_agent
|
dockerfile: ./internal/dockerfile_agent_alpine
|
||||||
registry: docker.io
|
registry: docker.io
|
||||||
username_secret: DOCKERHUB_USERNAME
|
username_secret: DOCKERHUB_USERNAME
|
||||||
password_secret: DOCKERHUB_TOKEN
|
password_secret: DOCKERHUB_TOKEN
|
||||||
tags: |
|
tags: |
|
||||||
type=raw,value=edge
|
type=raw,value=alpine
|
||||||
type=semver,pattern={{version}}
|
type=semver,pattern={{version}}-alpine
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
type=semver,pattern={{major}}.{{minor}}-alpine
|
||||||
type=semver,pattern={{major}}
|
type=semver,pattern={{major}}-alpine
|
||||||
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
|
|
||||||
|
|
||||||
# henrygd/beszel-agent-nvidia
|
# henrygd/beszel-agent-nvidia
|
||||||
- image: henrygd/beszel-agent-nvidia
|
- image: henrygd/beszel-agent-nvidia
|
||||||
@@ -66,18 +66,6 @@ jobs:
|
|||||||
type=semver,pattern={{major}}
|
type=semver,pattern={{major}}
|
||||||
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
|
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
|
||||||
|
|
||||||
# henrygd/beszel-agent:alpine
|
|
||||||
- image: henrygd/beszel-agent
|
|
||||||
dockerfile: ./internal/dockerfile_agent_alpine
|
|
||||||
registry: docker.io
|
|
||||||
username_secret: DOCKERHUB_USERNAME
|
|
||||||
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
|
|
||||||
|
|
||||||
# ghcr.io/henrygd/beszel
|
# ghcr.io/henrygd/beszel
|
||||||
- image: ghcr.io/${{ github.repository }}/beszel
|
- image: ghcr.io/${{ github.repository }}/beszel
|
||||||
dockerfile: ./internal/dockerfile_hub
|
dockerfile: ./internal/dockerfile_hub
|
||||||
@@ -99,6 +87,7 @@ jobs:
|
|||||||
password_secret: GITHUB_TOKEN
|
password_secret: GITHUB_TOKEN
|
||||||
tags: |
|
tags: |
|
||||||
type=raw,value=edge
|
type=raw,value=edge
|
||||||
|
type=raw,value=latest
|
||||||
type=semver,pattern={{version}}
|
type=semver,pattern={{version}}
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
type=semver,pattern={{major}}
|
type=semver,pattern={{major}}
|
||||||
@@ -144,6 +133,19 @@ jobs:
|
|||||||
type=semver,pattern={{major}}.{{minor}}-alpine
|
type=semver,pattern={{major}}.{{minor}}-alpine
|
||||||
type=semver,pattern={{major}}-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
|
||||||
packages: write
|
packages: write
|
||||||
|
|||||||
27
.github/workflows/inactivity-actions.yml
vendored
27
.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
|
|
||||||
});
|
|
||||||
}
|
|
||||||
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:
|
||||||
|
|||||||
117
agent/agent.go
117
agent/agent.go
@@ -5,19 +5,17 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"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/deltatracker"
|
||||||
|
"github.com/henrygd/beszel/internal/common"
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
"github.com/shirou/gopsutil/v4/host"
|
|
||||||
gossh "golang.org/x/crypto/ssh"
|
gossh "golang.org/x/crypto/ssh"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -29,12 +27,16 @@ type Agent struct {
|
|||||||
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
|
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 map[uint16]system.NetIoStats // Keeps track of bandwidth usage per cache interval
|
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
|
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
|
||||||
|
pveManager *pveManager // Manages Proxmox VE API requests
|
||||||
sensorConfig *SensorConfig // Sensors config
|
sensorConfig *SensorConfig // Sensors config
|
||||||
systemInfo system.Info // Host system info
|
systemInfo system.Info // Host system info (dynamic)
|
||||||
|
systemDetails system.Details // Host system details (static, once-per-connection)
|
||||||
gpuManager *GPUManager // Manages GPU data
|
gpuManager *GPUManager // Manages GPU data
|
||||||
cache *systemDataCache // Cache for system stats based on cache time
|
cache *systemDataCache // Cache for system stats based on cache time
|
||||||
connectionManager *ConnectionManager // Channel to signal connection events
|
connectionManager *ConnectionManager // Channel to signal connection events
|
||||||
@@ -43,6 +45,7 @@ type Agent struct {
|
|||||||
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
|
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.
|
||||||
@@ -59,7 +62,7 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
|
|||||||
agent.netIoStats = make(map[uint16]system.NetIoStats)
|
agent.netIoStats = make(map[uint16]system.NetIoStats)
|
||||||
agent.netInterfaceDeltaTrackers = make(map[uint16]*deltatracker.DeltaTracker[string, uint64])
|
agent.netInterfaceDeltaTrackers = make(map[uint16]*deltatracker.DeltaTracker[string, uint64])
|
||||||
|
|
||||||
agent.dataDir, err = getDataDir(dataDir...)
|
agent.dataDir, err = GetDataDir(dataDir...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("Data directory not found")
|
slog.Warn("Data directory not found")
|
||||||
} else {
|
} else {
|
||||||
@@ -68,6 +71,17 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
|
|||||||
|
|
||||||
agent.memCalc, _ = GetEnv("MEM_CALC")
|
agent.memCalc, _ = GetEnv("MEM_CALC")
|
||||||
agent.sensorConfig = agent.newSensorConfig()
|
agent.sensorConfig = agent.newSensorConfig()
|
||||||
|
|
||||||
|
// Parse disk usage cache duration (e.g., "15m", "1h") to avoid waking sleeping disks
|
||||||
|
if diskUsageCache, exists := GetEnv("DISK_USAGE_CACHE"); exists {
|
||||||
|
if duration, err := time.ParseDuration(diskUsageCache); err == nil {
|
||||||
|
agent.diskUsageCacheDuration = duration
|
||||||
|
slog.Info("DISK_USAGE_CACHE", "duration", duration)
|
||||||
|
} else {
|
||||||
|
slog.Warn("Invalid DISK_USAGE_CACHE", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Set up slog with a log level determined by the LOG_LEVEL env var
|
// Set up slog with a log level determined by the LOG_LEVEL env var
|
||||||
if logLevelStr, exists := GetEnv("LOG_LEVEL"); exists {
|
if logLevelStr, exists := GetEnv("LOG_LEVEL"); exists {
|
||||||
switch strings.ToLower(logLevelStr) {
|
switch strings.ToLower(logLevelStr) {
|
||||||
@@ -83,8 +97,24 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
|
|||||||
|
|
||||||
slog.Debug(beszel.Version)
|
slog.Debug(beszel.Version)
|
||||||
|
|
||||||
|
// initialize docker manager
|
||||||
|
agent.dockerManager = newDockerManager()
|
||||||
|
|
||||||
|
// initialize pve manager
|
||||||
|
agent.pveManager = newPVEManager()
|
||||||
|
|
||||||
// initialize system info
|
// initialize system info
|
||||||
agent.initializeSystemInfo()
|
agent.refreshSystemDetails()
|
||||||
|
|
||||||
|
// SMART_INTERVAL env var to update smart data at this interval
|
||||||
|
if smartIntervalEnv, exists := GetEnv("SMART_INTERVAL"); exists {
|
||||||
|
if duration, err := time.ParseDuration(smartIntervalEnv); err == nil && duration > 0 {
|
||||||
|
agent.systemDetails.SmartInterval = duration
|
||||||
|
slog.Info("SMART_INTERVAL", "duration", duration)
|
||||||
|
} else {
|
||||||
|
slog.Warn("Invalid SMART_INTERVAL", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// initialize connection manager
|
// initialize connection manager
|
||||||
agent.connectionManager = newConnectionManager(agent)
|
agent.connectionManager = newConnectionManager(agent)
|
||||||
@@ -98,8 +128,10 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
|
|||||||
// 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()
|
agent.smartManager, err = NewSmartManager()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -114,7 +146,7 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
|
|||||||
|
|
||||||
// if debugging, print stats
|
// if debugging, print stats
|
||||||
if agent.debug {
|
if agent.debug {
|
||||||
slog.Debug("Stats", "data", agent.gatherStats(0))
|
slog.Debug("Stats", "data", agent.gatherStats(common.DataRequestOptions{CacheTimeMs: 60_000, IncludeDetails: true}))
|
||||||
}
|
}
|
||||||
|
|
||||||
return agent, nil
|
return agent, nil
|
||||||
@@ -129,10 +161,11 @@ func GetEnv(key string) (value string, exists bool) {
|
|||||||
return os.LookupEnv(key)
|
return os.LookupEnv(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Agent) gatherStats(cacheTimeMs uint16) *system.CombinedData {
|
func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedData {
|
||||||
a.Lock()
|
a.Lock()
|
||||||
defer a.Unlock()
|
defer a.Unlock()
|
||||||
|
|
||||||
|
cacheTimeMs := options.CacheTimeMs
|
||||||
data, isCached := a.cache.Get(cacheTimeMs)
|
data, isCached := a.cache.Get(cacheTimeMs)
|
||||||
if isCached {
|
if isCached {
|
||||||
slog.Debug("Cached data", "cacheTimeMs", cacheTimeMs)
|
slog.Debug("Cached data", "cacheTimeMs", cacheTimeMs)
|
||||||
@@ -143,6 +176,12 @@ func (a *Agent) gatherStats(cacheTimeMs uint16) *system.CombinedData {
|
|||||||
Stats: a.getSystemStats(cacheTimeMs),
|
Stats: a.getSystemStats(cacheTimeMs),
|
||||||
Info: a.systemInfo,
|
Info: a.systemInfo,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Include static system details only when requested
|
||||||
|
if options.IncludeDetails {
|
||||||
|
data.Details = &a.systemDetails
|
||||||
|
}
|
||||||
|
|
||||||
// slog.Info("System data", "data", data, "cacheTimeMs", cacheTimeMs)
|
// slog.Info("System data", "data", data, "cacheTimeMs", cacheTimeMs)
|
||||||
|
|
||||||
if a.dockerManager != nil {
|
if a.dockerManager != nil {
|
||||||
@@ -154,7 +193,29 @@ func (a *Agent) gatherStats(cacheTimeMs uint16) *system.CombinedData {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if a.pveManager != nil {
|
||||||
|
if pveStats, err := a.pveManager.getPVEStats(); err == nil {
|
||||||
|
data.PVEStats = pveStats
|
||||||
|
slog.Debug("PVE", "data", data.PVEStats)
|
||||||
|
} else {
|
||||||
|
slog.Debug("PVE", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// skip updating systemd services if cache time is not the default 60sec interval
|
||||||
|
if a.systemdManager != nil && cacheTimeMs == 60_000 {
|
||||||
|
totalCount := uint16(a.systemdManager.getServiceStatsCount())
|
||||||
|
if totalCount > 0 {
|
||||||
|
numFailed := a.systemdManager.getFailedServiceCount()
|
||||||
|
data.Info.Services = []uint16{totalCount, numFailed}
|
||||||
|
}
|
||||||
|
if a.systemdManager.hasFreshStats {
|
||||||
|
data.SystemdServices = a.systemdManager.getServiceStats(nil, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
data.Stats.ExtraFs = make(map[string]*system.FsStats)
|
data.Stats.ExtraFs = make(map[string]*system.FsStats)
|
||||||
|
data.Info.ExtraFsPct = make(map[string]float64)
|
||||||
for name, stats := range a.fsStats {
|
for name, stats := range a.fsStats {
|
||||||
if !stats.Root && stats.DiskTotal > 0 {
|
if !stats.Root && stats.DiskTotal > 0 {
|
||||||
// Use custom name if available, otherwise use device name
|
// Use custom name if available, otherwise use device name
|
||||||
@@ -163,6 +224,11 @@ func (a *Agent) gatherStats(cacheTimeMs uint16) *system.CombinedData {
|
|||||||
key = stats.Name
|
key = stats.Name
|
||||||
}
|
}
|
||||||
data.Stats.ExtraFs[key] = stats
|
data.Stats.ExtraFs[key] = stats
|
||||||
|
// Add percentages to Info struct for dashboard
|
||||||
|
if stats.DiskTotal > 0 {
|
||||||
|
pct := twoDecimals((stats.DiskUsed / stats.DiskTotal) * 100)
|
||||||
|
data.Info.ExtraFsPct[key] = pct
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
slog.Debug("Extra FS", "data", data.Stats.ExtraFs)
|
slog.Debug("Extra FS", "data", data.Stats.ExtraFs)
|
||||||
@@ -171,37 +237,12 @@ func (a *Agent) gatherStats(cacheTimeMs uint16) *system.CombinedData {
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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,5 +1,4 @@
|
|||||||
//go:build testing
|
//go:build testing
|
||||||
// +build testing
|
|
||||||
|
|
||||||
package agent
|
package agent
|
||||||
|
|
||||||
@@ -15,6 +14,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func createTestCacheData() *system.CombinedData {
|
func createTestCacheData() *system.CombinedData {
|
||||||
|
var stats = container.Stats{}
|
||||||
|
stats.Name = "test-container"
|
||||||
|
stats.Cpu = 10.5
|
||||||
|
stats.Mem = 1073741824 // 1GB
|
||||||
return &system.CombinedData{
|
return &system.CombinedData{
|
||||||
Stats: system.Stats{
|
Stats: system.Stats{
|
||||||
Cpu: 50.5,
|
Cpu: 50.5,
|
||||||
@@ -22,13 +25,10 @@ func createTestCacheData() *system.CombinedData {
|
|||||||
DiskTotal: 100000,
|
DiskTotal: 100000,
|
||||||
},
|
},
|
||||||
Info: system.Info{
|
Info: system.Info{
|
||||||
Hostname: "test-host",
|
AgentVersion: "0.12.0",
|
||||||
},
|
},
|
||||||
Containers: []*container.Stats{
|
Containers: []*container.Stats{
|
||||||
{
|
&stats,
|
||||||
Name: "test-container",
|
|
||||||
Cpu: 25.0,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -128,7 +128,7 @@ func TestCacheMultipleIntervals(t *testing.T) {
|
|||||||
Mem: 16384,
|
Mem: 16384,
|
||||||
},
|
},
|
||||||
Info: system.Info{
|
Info: system.Info{
|
||||||
Hostname: "test-host-2",
|
AgentVersion: "0.12.0",
|
||||||
},
|
},
|
||||||
Containers: []*container.Stats{},
|
Containers: []*container.Stats{},
|
||||||
}
|
}
|
||||||
@@ -171,7 +171,7 @@ func TestCacheOverwrite(t *testing.T) {
|
|||||||
Mem: 32768,
|
Mem: 32768,
|
||||||
},
|
},
|
||||||
Info: system.Info{
|
Info: system.Info{
|
||||||
Hostname: "updated-host",
|
AgentVersion: "0.12.0",
|
||||||
},
|
},
|
||||||
Containers: []*container.Stats{},
|
Containers: []*container.Stats{},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
//go:build testing
|
//go:build testing
|
||||||
// +build testing
|
|
||||||
|
|
||||||
package agent
|
package agent
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ package battery
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"math"
|
||||||
|
|
||||||
"github.com/distatus/battery"
|
"github.com/distatus/battery"
|
||||||
)
|
)
|
||||||
@@ -51,6 +52,8 @@ func GetBatteryStats() (batteryPercent uint8, batteryState uint8, err error) {
|
|||||||
totalCharge := float64(0)
|
totalCharge := float64(0)
|
||||||
errs, partialErrs := err.(battery.Errors)
|
errs, partialErrs := err.(battery.Errors)
|
||||||
|
|
||||||
|
batteryState = math.MaxUint8
|
||||||
|
|
||||||
for i, bat := range batteries {
|
for i, bat := range batteries {
|
||||||
if partialErrs && errs[i] != nil {
|
if partialErrs && errs[i] != nil {
|
||||||
// if there were some errors, like missing data, skip it
|
// if there were some errors, like missing data, skip it
|
||||||
@@ -62,10 +65,13 @@ func GetBatteryStats() (batteryPercent uint8, batteryState uint8, err error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
totalCapacity += bat.Full
|
totalCapacity += bat.Full
|
||||||
totalCharge += bat.Current
|
totalCharge += min(bat.Current, bat.Full)
|
||||||
|
if bat.State.Raw >= 0 {
|
||||||
|
batteryState = uint8(bat.State.Raw)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if totalCapacity == 0 {
|
if totalCapacity == 0 || batteryState == math.MaxUint8 {
|
||||||
// for macs there's sometimes a ghost battery with 0 capacity
|
// for macs there's sometimes a ghost battery with 0 capacity
|
||||||
// https://github.com/distatus/battery/issues/34
|
// https://github.com/distatus/battery/issues/34
|
||||||
// Instead of skipping over those batteries, we'll check for total 0 capacity
|
// Instead of skipping over those batteries, we'll check for total 0 capacity
|
||||||
@@ -74,6 +80,5 @@ func GetBatteryStats() (batteryPercent uint8, batteryState uint8, err error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
batteryPercent = uint8(totalCharge / totalCapacity * 100)
|
batteryPercent = uint8(totalCharge / totalCapacity * 100)
|
||||||
batteryState = uint8(batteries[0].State.Raw)
|
|
||||||
return batteryPercent, batteryState, nil
|
return batteryPercent, batteryState, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,8 +15,6 @@ import (
|
|||||||
|
|
||||||
"github.com/henrygd/beszel"
|
"github.com/henrygd/beszel"
|
||||||
"github.com/henrygd/beszel/internal/common"
|
"github.com/henrygd/beszel/internal/common"
|
||||||
"github.com/henrygd/beszel/internal/entities/smart"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
|
||||||
|
|
||||||
"github.com/fxamacker/cbor/v2"
|
"github.com/fxamacker/cbor/v2"
|
||||||
"github.com/lxzan/gws"
|
"github.com/lxzan/gws"
|
||||||
@@ -200,7 +198,7 @@ func (client *WebSocketClient) handleAuthChallenge(msg *common.HubRequest[cbor.R
|
|||||||
|
|
||||||
if authRequest.NeedSysInfo {
|
if authRequest.NeedSysInfo {
|
||||||
response.Name, _ = GetEnv("SYSTEM_NAME")
|
response.Name, _ = GetEnv("SYSTEM_NAME")
|
||||||
response.Hostname = client.agent.systemInfo.Hostname
|
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)
|
||||||
}
|
}
|
||||||
@@ -258,38 +256,16 @@ func (client *WebSocketClient) sendMessage(data any) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// sendResponse sends a response with optional request ID for the new protocol
|
// 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 {
|
func (client *WebSocketClient) sendResponse(data any, requestID *uint32) error {
|
||||||
if requestID != nil {
|
if requestID != nil {
|
||||||
// New format with ID - use typed fields
|
response := newAgentResponse(data, requestID)
|
||||||
response := common.AgentResponse{
|
|
||||||
Id: requestID,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set the appropriate typed field based on data type
|
|
||||||
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 []byte:
|
|
||||||
// response.RawBytes = v
|
|
||||||
// case string:
|
|
||||||
// response.RawBytes = []byte(v)
|
|
||||||
default:
|
|
||||||
// For any other type, convert to error
|
|
||||||
response.Error = fmt.Sprintf("unsupported response type: %T", data)
|
|
||||||
}
|
|
||||||
|
|
||||||
return client.sendMessage(response)
|
return client.sendMessage(response)
|
||||||
} else {
|
|
||||||
// Legacy format - send data directly
|
|
||||||
return client.sendMessage(data)
|
|
||||||
}
|
}
|
||||||
|
// 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
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
@@ -91,8 +91,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,8 +109,8 @@ 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))
|
||||||
_ = c.agent.StopServer()
|
_ = c.agent.StopServer()
|
||||||
c.closeWebSocket()
|
c.closeWebSocket()
|
||||||
return health.CleanUp()
|
return health.CleanUp()
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
//go:build testing
|
//go:build testing
|
||||||
// +build testing
|
|
||||||
|
|
||||||
package agent
|
package agent
|
||||||
|
|
||||||
|
|||||||
128
agent/cpu.go
128
agent/cpu.go
@@ -2,39 +2,24 @@ package agent
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"math"
|
"math"
|
||||||
"path/filepath"
|
|
||||||
"runtime"
|
"runtime"
|
||||||
"time"
|
|
||||||
|
|
||||||
"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/process"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var lastCpuTimes = make(map[uint16]cpu.TimesStat)
|
var lastCpuTimes = make(map[uint16]cpu.TimesStat)
|
||||||
var lastPerCoreCpuTimes = make(map[uint16][]cpu.TimesStat)
|
var lastPerCoreCpuTimes = make(map[uint16][]cpu.TimesStat)
|
||||||
var lastProcessCpuTimes = make(map[uint16]map[int32]float64)
|
|
||||||
var lastProcessCpuSampleTime = make(map[uint16]time.Time)
|
|
||||||
|
|
||||||
// init initializes the CPU monitoring by storing the initial CPU times
|
// init initializes the CPU monitoring by storing the initial CPU times
|
||||||
// for the default 60-second cache interval.
|
// for the default 60-second cache interval.
|
||||||
func init() {
|
func init() {
|
||||||
if times, err := cpu.Times(false); err == nil {
|
if times, err := cpu.Times(false); err == nil && len(times) > 0 {
|
||||||
lastCpuTimes[60000] = times[0]
|
lastCpuTimes[60000] = times[0]
|
||||||
}
|
}
|
||||||
if perCoreTimes, err := cpu.Times(true); err == nil {
|
if perCoreTimes, err := cpu.Times(true); err == nil && len(perCoreTimes) > 0 {
|
||||||
lastPerCoreCpuTimes[60000] = perCoreTimes
|
lastPerCoreCpuTimes[60000] = perCoreTimes
|
||||||
}
|
}
|
||||||
if processes, err := process.Processes(); err == nil {
|
|
||||||
snapshot := make(map[int32]float64, len(processes))
|
|
||||||
for _, proc := range processes {
|
|
||||||
if times, err := proc.Times(); err == nil {
|
|
||||||
snapshot[proc.Pid] = times.Total()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
lastProcessCpuTimes[60000] = snapshot
|
|
||||||
lastProcessCpuSampleTime[60000] = time.Now()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// CpuMetrics contains detailed CPU usage breakdown
|
// CpuMetrics contains detailed CPU usage breakdown
|
||||||
@@ -104,10 +89,7 @@ func getPerCoreCpuUsage(cacheTimeMs uint16) (system.Uint8Slice, error) {
|
|||||||
lastTimes := lastPerCoreCpuTimes[cacheTimeMs]
|
lastTimes := lastPerCoreCpuTimes[cacheTimeMs]
|
||||||
|
|
||||||
// Limit to the number of cores available in both samples
|
// Limit to the number of cores available in both samples
|
||||||
length := len(perCoreTimes)
|
length := min(len(lastTimes), len(perCoreTimes))
|
||||||
if len(lastTimes) < length {
|
|
||||||
length = len(lastTimes)
|
|
||||||
}
|
|
||||||
|
|
||||||
usage := make([]uint8, length)
|
usage := make([]uint8, length)
|
||||||
for i := 0; i < length; i++ {
|
for i := 0; i < length; i++ {
|
||||||
@@ -120,110 +102,6 @@ func getPerCoreCpuUsage(cacheTimeMs uint16) (system.Uint8Slice, error) {
|
|||||||
return usage, nil
|
return usage, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// getTopCpuProcess returns the process with the highest CPU usage since the last run
|
|
||||||
// for the given cache interval. It returns nil if insufficient data is available.
|
|
||||||
func getTopCpuProcess(cacheTimeMs uint16) (*system.TopCpuProcess, error) {
|
|
||||||
processes, err := process.Processes()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
|
|
||||||
lastTimes, ok := lastProcessCpuTimes[cacheTimeMs]
|
|
||||||
if !ok {
|
|
||||||
if fallback := lastProcessCpuTimes[60000]; fallback != nil {
|
|
||||||
copied := make(map[int32]float64, len(fallback))
|
|
||||||
for pid, total := range fallback {
|
|
||||||
copied[pid] = total
|
|
||||||
}
|
|
||||||
lastTimes = copied
|
|
||||||
lastProcessCpuTimes[cacheTimeMs] = copied
|
|
||||||
} else {
|
|
||||||
lastTimes = make(map[int32]float64)
|
|
||||||
lastProcessCpuTimes[cacheTimeMs] = lastTimes
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
lastSample := lastProcessCpuSampleTime[cacheTimeMs]
|
|
||||||
if lastSample.IsZero() {
|
|
||||||
if fallback := lastProcessCpuSampleTime[60000]; !fallback.IsZero() {
|
|
||||||
lastSample = fallback
|
|
||||||
lastProcessCpuSampleTime[cacheTimeMs] = fallback
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
elapsed := now.Sub(lastSample).Seconds()
|
|
||||||
if lastSample.IsZero() || elapsed <= 0 {
|
|
||||||
snapshot := make(map[int32]float64, len(processes))
|
|
||||||
for _, proc := range processes {
|
|
||||||
if times, err := proc.Times(); err == nil {
|
|
||||||
snapshot[proc.Pid] = times.Total()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
lastProcessCpuTimes[cacheTimeMs] = snapshot
|
|
||||||
lastProcessCpuSampleTime[cacheTimeMs] = now
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
cpuCount := float64(runtime.NumCPU())
|
|
||||||
if cpuCount <= 0 {
|
|
||||||
cpuCount = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
snapshot := make(map[int32]float64, len(processes))
|
|
||||||
var topName string
|
|
||||||
var topPercent float64
|
|
||||||
|
|
||||||
for _, proc := range processes {
|
|
||||||
times, err := proc.Times()
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
total := times.Total()
|
|
||||||
pid := proc.Pid
|
|
||||||
snapshot[pid] = total
|
|
||||||
|
|
||||||
lastTotal, ok := lastTimes[pid]
|
|
||||||
if !ok || total <= lastTotal {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
percent := clampPercent((total - lastTotal) / (elapsed * cpuCount) * 100)
|
|
||||||
if percent <= 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
name, err := proc.Name()
|
|
||||||
if err != nil || name == "" {
|
|
||||||
if exe, exeErr := proc.Exe(); exeErr == nil && exe != "" {
|
|
||||||
name = filepath.Base(exe)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if name == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if percent > topPercent {
|
|
||||||
topPercent = percent
|
|
||||||
topName = name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
lastProcessCpuTimes[cacheTimeMs] = snapshot
|
|
||||||
lastProcessCpuSampleTime[cacheTimeMs] = now
|
|
||||||
|
|
||||||
if topName == "" {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return &system.TopCpuProcess{
|
|
||||||
Name: topName,
|
|
||||||
Percent: topPercent,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// calculateBusy calculates the CPU busy percentage between two time points.
|
// 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,
|
// It computes the ratio of busy time to total time elapsed between t1 and t2,
|
||||||
// returning a percentage clamped between 0 and 100.
|
// returning a percentage clamped between 0 and 100.
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ import (
|
|||||||
"runtime"
|
"runtime"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
@@ -52,7 +51,7 @@ func TestGetDataDir(t *testing.T) {
|
|||||||
|
|
||||||
os.Setenv("BESZEL_AGENT_DATA_DIR", tempDir)
|
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,7 +59,7 @@ 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)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -79,7 +78,7 @@ func TestGetDataDir(t *testing.T) {
|
|||||||
|
|
||||||
// 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 {
|
||||||
|
|||||||
279
agent/disk.go
279
agent/disk.go
@@ -26,6 +26,15 @@ func parseFilesystemEntry(entry string) (device, customName string) {
|
|||||||
return device, customName
|
return device, customName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isDockerSpecialMountpoint(mountpoint string) bool {
|
||||||
|
switch mountpoint {
|
||||||
|
case "/etc/hosts", "/etc/resolv.conf", "/etc/hostname":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Sets up the filesystems to monitor for disk usage and I/O.
|
// Sets up the filesystems to monitor for disk usage and I/O.
|
||||||
func (a *Agent) initializeDiskInfo() {
|
func (a *Agent) initializeDiskInfo() {
|
||||||
filesystem, _ := GetEnv("FILESYSTEM")
|
filesystem, _ := GetEnv("FILESYSTEM")
|
||||||
@@ -69,11 +78,22 @@ func (a *Agent) initializeDiskInfo() {
|
|||||||
if _, exists := a.fsStats[key]; !exists {
|
if _, exists := a.fsStats[key]; !exists {
|
||||||
if root {
|
if root {
|
||||||
slog.Info("Detected root device", "name", key)
|
slog.Info("Detected root device", "name", key)
|
||||||
// Check if root device is in /proc/diskstats, use fallback if not
|
// 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 the FILESYSTEM env var.
|
||||||
if _, ioMatch = diskIoCounters[key]; !ioMatch {
|
if _, ioMatch = diskIoCounters[key]; !ioMatch {
|
||||||
key, ioMatch = findIoDevice(filesystem, diskIoCounters, a.fsStats)
|
if matchedKey, match := findIoDevice(key, diskIoCounters); match {
|
||||||
|
key = matchedKey
|
||||||
|
ioMatch = true
|
||||||
|
} else if filesystem != "" {
|
||||||
|
if matchedKey, match := findIoDevice(filesystem, diskIoCounters); match {
|
||||||
|
key = matchedKey
|
||||||
|
ioMatch = true
|
||||||
|
}
|
||||||
|
}
|
||||||
if !ioMatch {
|
if !ioMatch {
|
||||||
slog.Info("Using I/O fallback", "device", device, "mountpoint", mountpoint, "fallback", key)
|
slog.Warn("Root I/O unmapped; set FILESYSTEM", "device", device, "mountpoint", mountpoint)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -95,23 +115,34 @@ func (a *Agent) initializeDiskInfo() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get the appropriate root mount point for this system
|
||||||
|
rootMountPoint := a.getRootMountPoint()
|
||||||
|
|
||||||
// Use FILESYSTEM env var to find root filesystem
|
// Use FILESYSTEM env var to find root filesystem
|
||||||
if filesystem != "" {
|
if filesystem != "" {
|
||||||
for _, p := range partitions {
|
for _, p := range partitions {
|
||||||
if strings.HasSuffix(p.Device, filesystem) || p.Mountpoint == filesystem {
|
if filesystemMatchesPartitionSetting(filesystem, p) {
|
||||||
addFsStat(p.Device, p.Mountpoint, true)
|
addFsStat(p.Device, p.Mountpoint, true)
|
||||||
hasRoot = true
|
hasRoot = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !hasRoot {
|
if !hasRoot {
|
||||||
slog.Warn("Partition details not found", "filesystem", filesystem)
|
// FILESYSTEM may name a physical disk absent from partitions (e.g.
|
||||||
|
// ZFS lists dataset paths like zroot/ROOT/default, not block devices).
|
||||||
|
// Try matching directly against diskIoCounters.
|
||||||
|
if ioKey, match := findIoDevice(filesystem, diskIoCounters); match {
|
||||||
|
a.fsStats[ioKey] = &system.FsStats{Root: true, Mountpoint: rootMountPoint}
|
||||||
|
hasRoot = true
|
||||||
|
} else {
|
||||||
|
slog.Warn("Partition details not found", "filesystem", filesystem)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add EXTRA_FILESYSTEMS env var values to fsStats
|
// Add EXTRA_FILESYSTEMS env var values to fsStats
|
||||||
if extraFilesystems, exists := GetEnv("EXTRA_FILESYSTEMS"); exists {
|
if extraFilesystems, exists := GetEnv("EXTRA_FILESYSTEMS"); exists {
|
||||||
for _, fsEntry := range strings.Split(extraFilesystems, ",") {
|
for fsEntry := range strings.SplitSeq(extraFilesystems, ",") {
|
||||||
// Parse custom name from format: device__customname
|
// Parse custom name from format: device__customname
|
||||||
fs, customName := parseFilesystemEntry(fsEntry)
|
fs, customName := parseFilesystemEntry(fsEntry)
|
||||||
|
|
||||||
@@ -138,8 +169,8 @@ func (a *Agent) initializeDiskInfo() {
|
|||||||
for _, p := range partitions {
|
for _, p := range partitions {
|
||||||
// fmt.Println(p.Device, p.Mountpoint)
|
// fmt.Println(p.Device, p.Mountpoint)
|
||||||
// Binary root fallback or docker root fallback
|
// Binary root fallback or docker root fallback
|
||||||
if !hasRoot && (p.Mountpoint == "/" || (p.Mountpoint == "/etc/hosts" && strings.HasPrefix(p.Device, "/dev"))) {
|
if !hasRoot && (p.Mountpoint == rootMountPoint || (isDockerSpecialMountpoint(p.Mountpoint) && strings.HasPrefix(p.Device, "/dev"))) {
|
||||||
fs, match := findIoDevice(filepath.Base(p.Device), diskIoCounters, a.fsStats)
|
fs, match := findIoDevice(filepath.Base(p.Device), diskIoCounters)
|
||||||
if match {
|
if match {
|
||||||
addFsStat(fs, p.Mountpoint, true)
|
addFsStat(fs, p.Mountpoint, true)
|
||||||
hasRoot = true
|
hasRoot = true
|
||||||
@@ -171,35 +202,180 @@ func (a *Agent) initializeDiskInfo() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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)
|
rootKey := mostActiveIoDevice(diskIoCounters)
|
||||||
slog.Info("Root disk", "mountpoint", "/", "io", rootDevice)
|
if rootKey != "" {
|
||||||
a.fsStats[rootDevice] = &system.FsStats{Root: true, Mountpoint: "/"}
|
slog.Warn("Using most active device for root I/O; set FILESYSTEM to override", "device", rootKey)
|
||||||
|
} else {
|
||||||
|
rootKey = filepath.Base(rootMountPoint)
|
||||||
|
if _, exists := a.fsStats[rootKey]; exists {
|
||||||
|
rootKey = "root"
|
||||||
|
}
|
||||||
|
slog.Warn("Root I/O device not detected; set FILESYSTEM to override")
|
||||||
|
}
|
||||||
|
a.fsStats[rootKey] = &system.FsStats{Root: true, Mountpoint: rootMountPoint}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
for _, d := range diskIoCounters {
|
break
|
||||||
if d.Name == filesystem || (d.Label != "" && d.Label == filesystem) {
|
|
||||||
return d.Name, true
|
|
||||||
}
|
|
||||||
if d.ReadBytes > maxReadBytes {
|
|
||||||
// don't use if device already exists in fsStats
|
|
||||||
if _, exists := fsStats[d.Name]; !exists {
|
|
||||||
maxReadBytes = d.ReadBytes
|
|
||||||
maxReadDevice = d.Name
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return maxReadDevice, false
|
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 {
|
||||||
|
if normalizeDeviceName(d.Name) == filesystem || (d.Label != "" && normalizeDeviceName(d.Label) == filesystem) {
|
||||||
|
return d.Name, true
|
||||||
|
}
|
||||||
|
if prefixRelated(normalizeDeviceName(d.Name), filesystem) ||
|
||||||
|
(d.Label != "" && prefixRelated(normalizeDeviceName(d.Label), filesystem)) {
|
||||||
|
candidates = append(candidates, ioMatchCandidate{
|
||||||
|
name: d.Name,
|
||||||
|
bytes: d.ReadBytes + d.WriteBytes,
|
||||||
|
ops: d.ReadCount + d.WriteCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
return "", 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.
|
||||||
@@ -222,8 +398,19 @@ func (a *Agent) initializeDiskIoStats(diskIoCounters map[string]disk.IOCountersS
|
|||||||
|
|
||||||
// Updates disk usage statistics for all monitored filesystems
|
// Updates disk usage statistics for all monitored filesystems
|
||||||
func (a *Agent) updateDiskUsage(systemStats *system.Stats) {
|
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
|
// disk usage
|
||||||
for _, stats := range a.fsStats {
|
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 {
|
if d, err := disk.Usage(stats.Mountpoint); err == nil {
|
||||||
stats.DiskTotal = bytesToGigabytes(d.Total)
|
stats.DiskTotal = bytesToGigabytes(d.Total)
|
||||||
stats.DiskUsed = bytesToGigabytes(d.Used)
|
stats.DiskUsed = bytesToGigabytes(d.Used)
|
||||||
@@ -241,6 +428,11 @@ func (a *Agent) updateDiskUsage(systemStats *system.Stats) {
|
|||||||
stats.TotalWrite = 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
|
// Updates disk I/O statistics for all monitored filesystems
|
||||||
@@ -312,3 +504,32 @@ func (a *Agent) updateDiskIo(cacheTimeMs uint16, systemStats *system.Stats) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getRootMountPoint returns the appropriate root mount point for the system
|
||||||
|
// For immutable systems like Fedora Silverblue, it returns /sysroot instead of /
|
||||||
|
func (a *Agent) getRootMountPoint() string {
|
||||||
|
// 1. Check if /etc/os-release contains indicators of an immutable system
|
||||||
|
if osReleaseContent, err := os.ReadFile("/etc/os-release"); err == nil {
|
||||||
|
content := string(osReleaseContent)
|
||||||
|
if strings.Contains(content, "fedora") && strings.Contains(content, "silverblue") ||
|
||||||
|
strings.Contains(content, "coreos") ||
|
||||||
|
strings.Contains(content, "flatcar") ||
|
||||||
|
strings.Contains(content, "rhel-atomic") ||
|
||||||
|
strings.Contains(content, "centos-atomic") {
|
||||||
|
// Verify that /sysroot exists before returning it
|
||||||
|
if _, err := os.Stat("/sysroot"); err == nil {
|
||||||
|
return "/sysroot"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check if /run/ostree is present (ostree-based systems like Silverblue)
|
||||||
|
if _, err := os.Stat("/run/ostree"); err == nil {
|
||||||
|
// Verify that /sysroot exists before returning it
|
||||||
|
if _, err := os.Stat("/sysroot"); err == nil {
|
||||||
|
return "/sysroot"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
//go:build testing
|
//go:build testing
|
||||||
// +build testing
|
|
||||||
|
|
||||||
package agent
|
package agent
|
||||||
|
|
||||||
@@ -7,6 +6,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"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"
|
||||||
@@ -93,6 +93,162 @@ func TestParseFilesystemEntry(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFindIoDevice(t *testing.T) {
|
||||||
|
t.Run("matches by device name", func(t *testing.T) {
|
||||||
|
ioCounters := map[string]disk.IOCountersStat{
|
||||||
|
"sda": {Name: "sda"},
|
||||||
|
"sdb": {Name: "sdb"},
|
||||||
|
}
|
||||||
|
|
||||||
|
device, ok := findIoDevice("sdb", ioCounters)
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Equal(t, "sdb", device)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("matches by device label", func(t *testing.T) {
|
||||||
|
ioCounters := map[string]disk.IOCountersStat{
|
||||||
|
"sda": {Name: "sda", Label: "rootfs"},
|
||||||
|
"sdb": {Name: "sdb"},
|
||||||
|
}
|
||||||
|
|
||||||
|
device, ok := findIoDevice("rootfs", ioCounters)
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Equal(t, "sda", device)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("returns no match when not found", func(t *testing.T) {
|
||||||
|
ioCounters := map[string]disk.IOCountersStat{
|
||||||
|
"sda": {Name: "sda"},
|
||||||
|
"sdb": {Name: "sdb"},
|
||||||
|
}
|
||||||
|
|
||||||
|
device, ok := findIoDevice("nvme0n1p1", ioCounters)
|
||||||
|
assert.False(t, ok)
|
||||||
|
assert.Equal(t, "", device)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("uses uncertain unique prefix fallback", func(t *testing.T) {
|
||||||
|
ioCounters := map[string]disk.IOCountersStat{
|
||||||
|
"nvme0n1": {Name: "nvme0n1"},
|
||||||
|
"sda": {Name: "sda"},
|
||||||
|
}
|
||||||
|
|
||||||
|
device, ok := findIoDevice("nvme0n1p2", ioCounters)
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Equal(t, "nvme0n1", device)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("uses dominant activity when prefix matches are ambiguous", func(t *testing.T) {
|
||||||
|
ioCounters := map[string]disk.IOCountersStat{
|
||||||
|
"sda": {Name: "sda", ReadBytes: 5000, WriteBytes: 5000, ReadCount: 100, WriteCount: 100},
|
||||||
|
"sdb": {Name: "sdb", ReadBytes: 1000, WriteBytes: 1000, ReadCount: 50, WriteCount: 50},
|
||||||
|
}
|
||||||
|
|
||||||
|
device, ok := findIoDevice("sd", ioCounters)
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Equal(t, "sda", device)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("uses highest activity when ambiguous without dominance", func(t *testing.T) {
|
||||||
|
ioCounters := map[string]disk.IOCountersStat{
|
||||||
|
"sda": {Name: "sda", ReadBytes: 3000, WriteBytes: 3000, ReadCount: 50, WriteCount: 50},
|
||||||
|
"sdb": {Name: "sdb", ReadBytes: 2500, WriteBytes: 2500, ReadCount: 40, WriteCount: 40},
|
||||||
|
}
|
||||||
|
|
||||||
|
device, ok := findIoDevice("sd", ioCounters)
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Equal(t, "sda", device)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("matches /dev/-prefixed partition to parent disk", func(t *testing.T) {
|
||||||
|
ioCounters := map[string]disk.IOCountersStat{
|
||||||
|
"nda0": {Name: "nda0", ReadBytes: 1000, WriteBytes: 1000},
|
||||||
|
}
|
||||||
|
|
||||||
|
device, ok := findIoDevice("/dev/nda0p2", ioCounters)
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Equal(t, "nda0", device)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("uses deterministic name tie-breaker", func(t *testing.T) {
|
||||||
|
ioCounters := map[string]disk.IOCountersStat{
|
||||||
|
"sdb": {Name: "sdb", ReadBytes: 2000, WriteBytes: 2000, ReadCount: 10, WriteCount: 10},
|
||||||
|
"sda": {Name: "sda", ReadBytes: 2000, WriteBytes: 2000, ReadCount: 10, WriteCount: 10},
|
||||||
|
}
|
||||||
|
|
||||||
|
device, ok := findIoDevice("sd", ioCounters)
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Equal(t, "sda", device)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilesystemMatchesPartitionSetting(t *testing.T) {
|
||||||
|
p := disk.PartitionStat{Device: "/dev/ada0p2", Mountpoint: "/"}
|
||||||
|
|
||||||
|
t.Run("matches mountpoint setting", func(t *testing.T) {
|
||||||
|
assert.True(t, filesystemMatchesPartitionSetting("/", p))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("matches exact partition setting", func(t *testing.T) {
|
||||||
|
assert.True(t, filesystemMatchesPartitionSetting("ada0p2", p))
|
||||||
|
assert.True(t, filesystemMatchesPartitionSetting("/dev/ada0p2", p))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("matches prefix-style parent setting", func(t *testing.T) {
|
||||||
|
assert.True(t, filesystemMatchesPartitionSetting("ada0", p))
|
||||||
|
assert.True(t, filesystemMatchesPartitionSetting("/dev/ada0", p))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("does not match unrelated device", func(t *testing.T) {
|
||||||
|
assert.False(t, filesystemMatchesPartitionSetting("sda", p))
|
||||||
|
assert.False(t, filesystemMatchesPartitionSetting("nvme0n1", p))
|
||||||
|
assert.False(t, filesystemMatchesPartitionSetting("", p))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMostActiveIoDevice(t *testing.T) {
|
||||||
|
t.Run("returns most active device", func(t *testing.T) {
|
||||||
|
ioCounters := map[string]disk.IOCountersStat{
|
||||||
|
"nda0": {Name: "nda0", ReadBytes: 5000, WriteBytes: 5000, ReadCount: 100, WriteCount: 100},
|
||||||
|
"nda1": {Name: "nda1", ReadBytes: 1000, WriteBytes: 1000, ReadCount: 50, WriteCount: 50},
|
||||||
|
}
|
||||||
|
assert.Equal(t, "nda0", mostActiveIoDevice(ioCounters))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("uses deterministic tie-breaker", func(t *testing.T) {
|
||||||
|
ioCounters := map[string]disk.IOCountersStat{
|
||||||
|
"sdb": {Name: "sdb", ReadBytes: 1000, WriteBytes: 1000, ReadCount: 10, WriteCount: 10},
|
||||||
|
"sda": {Name: "sda", ReadBytes: 1000, WriteBytes: 1000, ReadCount: 10, WriteCount: 10},
|
||||||
|
}
|
||||||
|
assert.Equal(t, "sda", mostActiveIoDevice(ioCounters))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("returns empty for empty map", func(t *testing.T) {
|
||||||
|
assert.Equal(t, "", mostActiveIoDevice(map[string]disk.IOCountersStat{}))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsDockerSpecialMountpoint(t *testing.T) {
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
mountpoint string
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{name: "hosts", mountpoint: "/etc/hosts", expected: true},
|
||||||
|
{name: "resolv", mountpoint: "/etc/resolv.conf", expected: true},
|
||||||
|
{name: "hostname", mountpoint: "/etc/hostname", expected: true},
|
||||||
|
{name: "root", mountpoint: "/", expected: false},
|
||||||
|
{name: "passwd", mountpoint: "/etc/passwd", expected: false},
|
||||||
|
{name: "extra-filesystem", mountpoint: "/extra-filesystems/sda1", expected: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tc.expected, isDockerSpecialMountpoint(tc.mountpoint))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestInitializeDiskInfoWithCustomNames(t *testing.T) {
|
func TestInitializeDiskInfoWithCustomNames(t *testing.T) {
|
||||||
// Set up environment variables
|
// Set up environment variables
|
||||||
oldEnv := os.Getenv("EXTRA_FILESYSTEMS")
|
oldEnv := os.Getenv("EXTRA_FILESYSTEMS")
|
||||||
@@ -233,3 +389,120 @@ func TestExtraFsKeyGeneration(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDiskUsageCaching(t *testing.T) {
|
||||||
|
t.Run("caching disabled updates all filesystems", func(t *testing.T) {
|
||||||
|
agent := &Agent{
|
||||||
|
fsStats: map[string]*system.FsStats{
|
||||||
|
"sda": {Root: true, Mountpoint: "/"},
|
||||||
|
"sdb": {Root: false, Mountpoint: "/mnt/storage"},
|
||||||
|
},
|
||||||
|
diskUsageCacheDuration: 0, // caching disabled
|
||||||
|
}
|
||||||
|
|
||||||
|
var stats system.Stats
|
||||||
|
agent.updateDiskUsage(&stats)
|
||||||
|
|
||||||
|
// Both should be updated (non-zero values from disk.Usage)
|
||||||
|
// Root stats should be populated in systemStats
|
||||||
|
assert.True(t, agent.lastDiskUsageUpdate.IsZero() || !agent.lastDiskUsageUpdate.IsZero(),
|
||||||
|
"lastDiskUsageUpdate should be set when caching is disabled")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("caching enabled always updates root filesystem", func(t *testing.T) {
|
||||||
|
agent := &Agent{
|
||||||
|
fsStats: map[string]*system.FsStats{
|
||||||
|
"sda": {Root: true, Mountpoint: "/", DiskTotal: 100, DiskUsed: 50},
|
||||||
|
"sdb": {Root: false, Mountpoint: "/mnt/storage", DiskTotal: 200, DiskUsed: 100},
|
||||||
|
},
|
||||||
|
diskUsageCacheDuration: 1 * time.Hour,
|
||||||
|
lastDiskUsageUpdate: time.Now(), // cache is fresh
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store original extra fs values
|
||||||
|
originalExtraTotal := agent.fsStats["sdb"].DiskTotal
|
||||||
|
originalExtraUsed := agent.fsStats["sdb"].DiskUsed
|
||||||
|
|
||||||
|
var stats system.Stats
|
||||||
|
agent.updateDiskUsage(&stats)
|
||||||
|
|
||||||
|
// Root should be updated (systemStats populated from disk.Usage call)
|
||||||
|
// We can't easily check if disk.Usage was called, but we verify the flow works
|
||||||
|
|
||||||
|
// Extra filesystem should retain cached values (not reset)
|
||||||
|
assert.Equal(t, originalExtraTotal, agent.fsStats["sdb"].DiskTotal,
|
||||||
|
"extra filesystem DiskTotal should be unchanged when cached")
|
||||||
|
assert.Equal(t, originalExtraUsed, agent.fsStats["sdb"].DiskUsed,
|
||||||
|
"extra filesystem DiskUsed should be unchanged when cached")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("first call always updates all filesystems", func(t *testing.T) {
|
||||||
|
agent := &Agent{
|
||||||
|
fsStats: map[string]*system.FsStats{
|
||||||
|
"sda": {Root: true, Mountpoint: "/"},
|
||||||
|
"sdb": {Root: false, Mountpoint: "/mnt/storage"},
|
||||||
|
},
|
||||||
|
diskUsageCacheDuration: 1 * time.Hour,
|
||||||
|
// lastDiskUsageUpdate is zero (first call)
|
||||||
|
}
|
||||||
|
|
||||||
|
var stats system.Stats
|
||||||
|
agent.updateDiskUsage(&stats)
|
||||||
|
|
||||||
|
// After first call, lastDiskUsageUpdate should be set
|
||||||
|
assert.False(t, agent.lastDiskUsageUpdate.IsZero(),
|
||||||
|
"lastDiskUsageUpdate should be set after first call")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("expired cache updates extra filesystems", func(t *testing.T) {
|
||||||
|
agent := &Agent{
|
||||||
|
fsStats: map[string]*system.FsStats{
|
||||||
|
"sda": {Root: true, Mountpoint: "/"},
|
||||||
|
"sdb": {Root: false, Mountpoint: "/mnt/storage"},
|
||||||
|
},
|
||||||
|
diskUsageCacheDuration: 1 * time.Millisecond,
|
||||||
|
lastDiskUsageUpdate: time.Now().Add(-1 * time.Second), // cache expired
|
||||||
|
}
|
||||||
|
|
||||||
|
var stats system.Stats
|
||||||
|
agent.updateDiskUsage(&stats)
|
||||||
|
|
||||||
|
// lastDiskUsageUpdate should be refreshed since cache expired
|
||||||
|
assert.True(t, time.Since(agent.lastDiskUsageUpdate) < time.Second,
|
||||||
|
"lastDiskUsageUpdate should be refreshed when cache expires")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHasSameDiskUsage(t *testing.T) {
|
||||||
|
const toleranceBytes uint64 = 16 * 1024 * 1024
|
||||||
|
|
||||||
|
t.Run("returns true when totals and usage are equal", func(t *testing.T) {
|
||||||
|
a := &disk.UsageStat{Total: 100 * 1024 * 1024 * 1024, Used: 42 * 1024 * 1024 * 1024}
|
||||||
|
b := &disk.UsageStat{Total: 100 * 1024 * 1024 * 1024, Used: 42 * 1024 * 1024 * 1024}
|
||||||
|
assert.True(t, hasSameDiskUsage(a, b))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("returns true within tolerance", func(t *testing.T) {
|
||||||
|
a := &disk.UsageStat{Total: 100 * 1024 * 1024 * 1024, Used: 42 * 1024 * 1024 * 1024}
|
||||||
|
b := &disk.UsageStat{
|
||||||
|
Total: a.Total + toleranceBytes - 1,
|
||||||
|
Used: a.Used - toleranceBytes + 1,
|
||||||
|
}
|
||||||
|
assert.True(t, hasSameDiskUsage(a, b))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("returns false when total exceeds tolerance", func(t *testing.T) {
|
||||||
|
a := &disk.UsageStat{Total: 100 * 1024 * 1024 * 1024, Used: 42 * 1024 * 1024 * 1024}
|
||||||
|
b := &disk.UsageStat{
|
||||||
|
Total: a.Total + toleranceBytes + 1,
|
||||||
|
Used: a.Used,
|
||||||
|
}
|
||||||
|
assert.False(t, hasSameDiskUsage(a, b))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("returns false for nil or zero total", func(t *testing.T) {
|
||||||
|
assert.False(t, hasSameDiskUsage(nil, &disk.UsageStat{Total: 1, Used: 1}))
|
||||||
|
assert.False(t, hasSameDiskUsage(&disk.UsageStat{Total: 1, Used: 1}, nil))
|
||||||
|
assert.False(t, hasSameDiskUsage(&disk.UsageStat{Total: 0, Used: 0}, &disk.UsageStat{Total: 1, Used: 1}))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
148
agent/docker.go
148
agent/docker.go
@@ -1,6 +1,7 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
@@ -14,6 +15,7 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -24,6 +26,13 @@ import (
|
|||||||
"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\\-_]`)
|
||||||
|
dockerContainerIDPattern = regexp.MustCompile(`^[a-fA-F0-9]{12,64}$`)
|
||||||
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// Docker API timeout in milliseconds
|
// Docker API timeout in milliseconds
|
||||||
dockerTimeoutMs = 2100
|
dockerTimeoutMs = 2100
|
||||||
@@ -55,6 +64,7 @@ type dockerManager struct {
|
|||||||
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
|
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)
|
// Cache-time-aware tracking for CPU stats (similar to cpu.go)
|
||||||
// Maps cache time intervals to container-specific CPU usage tracking
|
// Maps cache time intervals to container-specific CPU usage tracking
|
||||||
@@ -66,6 +76,7 @@ type dockerManager struct {
|
|||||||
// cacheTimeMs -> DeltaTracker for network bytes sent/received
|
// cacheTimeMs -> DeltaTracker for network bytes sent/received
|
||||||
networkSentTrackers map[uint16]*deltatracker.DeltaTracker[string, uint64]
|
networkSentTrackers map[uint16]*deltatracker.DeltaTracker[string, uint64]
|
||||||
networkRecvTrackers map[uint16]*deltatracker.DeltaTracker[string, uint64]
|
networkRecvTrackers map[uint16]*deltatracker.DeltaTracker[string, uint64]
|
||||||
|
retrySleep func(time.Duration)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
||||||
@@ -329,6 +340,8 @@ func validateCpuPercentage(cpuPct float64, containerName string) error {
|
|||||||
func updateContainerStatsValues(stats *container.Stats, cpuPct float64, usedMemory uint64, sent_delta, recv_delta uint64, readTime time.Time) {
|
func updateContainerStatsValues(stats *container.Stats, cpuPct float64, usedMemory uint64, sent_delta, recv_delta uint64, readTime time.Time) {
|
||||||
stats.Cpu = twoDecimals(cpuPct)
|
stats.Cpu = twoDecimals(cpuPct)
|
||||||
stats.Mem = bytesToMegabytes(float64(usedMemory))
|
stats.Mem = bytesToMegabytes(float64(usedMemory))
|
||||||
|
stats.Bandwidth = [2]uint64{sent_delta, recv_delta}
|
||||||
|
// TODO(0.19+): stop populating NetworkSent/NetworkRecv (deprecated in 0.18.3)
|
||||||
stats.NetworkSent = bytesToMegabytes(float64(sent_delta))
|
stats.NetworkSent = bytesToMegabytes(float64(sent_delta))
|
||||||
stats.NetworkRecv = bytesToMegabytes(float64(recv_delta))
|
stats.NetworkRecv = bytesToMegabytes(float64(recv_delta))
|
||||||
stats.PrevReadTime = readTime
|
stats.PrevReadTime = readTime
|
||||||
@@ -384,11 +397,12 @@ func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo, cacheTimeM
|
|||||||
// 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, Id: ctr.IdShort, Image: ctr.Image}
|
stats = &container.Stats{Image: ctr.Image}
|
||||||
dm.containerStatsMap[ctr.IdShort] = stats
|
dm.containerStatsMap[ctr.IdShort] = stats
|
||||||
}
|
}
|
||||||
|
|
||||||
stats.Id = ctr.IdShort
|
stats.Id = ctr.IdShort
|
||||||
|
stats.Name = name
|
||||||
|
|
||||||
statusText, health := parseDockerStatus(ctr.Status)
|
statusText, health := parseDockerStatus(ctr.Status)
|
||||||
stats.Status = statusText
|
stats.Status = statusText
|
||||||
@@ -397,6 +411,8 @@ func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo, cacheTimeM
|
|||||||
// 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
|
||||||
|
|
||||||
@@ -473,7 +489,7 @@ func (dm *dockerManager) deleteContainerStatsSync(id string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Creates a new http client for Docker or Podman API
|
// Creates a new http client for Docker or Podman API
|
||||||
func newDockerManager(a *Agent) *dockerManager {
|
func newDockerManager() *dockerManager {
|
||||||
dockerHost, exists := GetEnv("DOCKER_HOST")
|
dockerHost, exists := GetEnv("DOCKER_HOST")
|
||||||
if exists {
|
if exists {
|
||||||
// return nil if set to empty string
|
// return nil if set to empty string
|
||||||
@@ -555,16 +571,17 @@ func newDockerManager(a *Agent) *dockerManager {
|
|||||||
lastCpuReadTime: make(map[uint16]map[string]time.Time),
|
lastCpuReadTime: make(map[uint16]map[string]time.Time),
|
||||||
networkSentTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
|
networkSentTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
|
||||||
networkRecvTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
|
networkRecvTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
|
||||||
|
retrySleep: time.Sleep,
|
||||||
}
|
}
|
||||||
|
|
||||||
// If using podman, return client
|
// If using podman, return client
|
||||||
if strings.Contains(dockerHost, "podman") {
|
if strings.Contains(dockerHost, "podman") {
|
||||||
a.systemInfo.Podman = true
|
manager.usingPodman = true
|
||||||
manager.goodDockerVersion = true
|
manager.goodDockerVersion = true
|
||||||
return manager
|
return manager
|
||||||
}
|
}
|
||||||
|
|
||||||
// this can take up to 5 seconds with retry, so run in goroutine
|
// run version check in goroutine to avoid blocking (server may not be ready and requires retries)
|
||||||
go manager.checkDockerVersion()
|
go manager.checkDockerVersion()
|
||||||
|
|
||||||
// give version check a chance to complete before returning
|
// give version check a chance to complete before returning
|
||||||
@@ -584,18 +601,18 @@ func (dm *dockerManager) checkDockerVersion() {
|
|||||||
const versionMaxTries = 2
|
const versionMaxTries = 2
|
||||||
for i := 1; i <= versionMaxTries; i++ {
|
for i := 1; i <= versionMaxTries; i++ {
|
||||||
resp, err = dm.client.Get("http://localhost/version")
|
resp, err = dm.client.Get("http://localhost/version")
|
||||||
if err == nil {
|
if err == nil && resp.StatusCode == http.StatusOK {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if resp != nil {
|
if resp != nil {
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
}
|
}
|
||||||
if i < versionMaxTries {
|
if i < versionMaxTries {
|
||||||
slog.Debug("Failed to get Docker version; retrying", "attempt", i, "error", err)
|
slog.Debug("Failed to get Docker version; retrying", "attempt", i, "err", err, "response", resp)
|
||||||
time.Sleep(5 * time.Second)
|
dm.retrySleep(5 * time.Second)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil || resp.StatusCode != http.StatusOK {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := dm.decode(resp, &versionInfo); err != nil {
|
if err := dm.decode(resp, &versionInfo); err != nil {
|
||||||
@@ -637,9 +654,34 @@ 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
|
// getContainerInfo fetches the inspection data for a container
|
||||||
func (dm *dockerManager) getContainerInfo(ctx context.Context, containerID string) ([]byte, error) {
|
func (dm *dockerManager) getContainerInfo(ctx context.Context, containerID string) ([]byte, error) {
|
||||||
endpoint := fmt.Sprintf("http://localhost/containers/%s/json", containerID)
|
endpoint, err := buildDockerContainerEndpoint(containerID, "json", nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -670,7 +712,15 @@ func (dm *dockerManager) getContainerInfo(ctx context.Context, containerID strin
|
|||||||
|
|
||||||
// getLogs fetches the logs for a container
|
// getLogs fetches the logs for a container
|
||||||
func (dm *dockerManager) getLogs(ctx context.Context, containerID string) (string, error) {
|
func (dm *dockerManager) getLogs(ctx context.Context, containerID string) (string, error) {
|
||||||
endpoint := fmt.Sprintf("http://localhost/containers/%s/logs?stdout=1&stderr=1&tail=%d", containerID, dockerLogsTail)
|
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)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -688,17 +738,52 @@ func (dm *dockerManager) getLogs(ctx context.Context, containerID string) (strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
var builder strings.Builder
|
var builder strings.Builder
|
||||||
if err := decodeDockerLogStream(resp.Body, &builder); err != nil {
|
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
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return builder.String(), nil
|
// Strip ANSI escape sequences from logs for clean display in web UI
|
||||||
|
logs := builder.String()
|
||||||
|
if strings.Contains(logs, "\x1b") {
|
||||||
|
logs = ansiEscapePattern.ReplaceAllString(logs, "")
|
||||||
|
}
|
||||||
|
return logs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func decodeDockerLogStream(reader io.Reader, builder *strings.Builder) error {
|
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
|
const headerSize = 8
|
||||||
var header [headerSize]byte
|
var header [headerSize]byte
|
||||||
buf := make([]byte, 0, dockerLogsTail*200)
|
|
||||||
totalBytesRead := 0
|
totalBytesRead := 0
|
||||||
|
|
||||||
for {
|
for {
|
||||||
@@ -722,36 +807,37 @@ func decodeDockerLogStream(reader io.Reader, builder *strings.Builder) error {
|
|||||||
// Check if reading this frame would exceed total log size limit
|
// Check if reading this frame would exceed total log size limit
|
||||||
if totalBytesRead+int(frameLen) > maxTotalLogSize {
|
if totalBytesRead+int(frameLen) > maxTotalLogSize {
|
||||||
// Read and discard remaining data to avoid blocking
|
// Read and discard remaining data to avoid blocking
|
||||||
_, _ = io.Copy(io.Discard, io.LimitReader(reader, int64(frameLen)))
|
_, _ = io.CopyN(io.Discard, reader, int64(frameLen))
|
||||||
slog.Debug("Truncating logs: limit reached", "read", totalBytesRead, "limit", maxTotalLogSize)
|
slog.Debug("Truncating logs: limit reached", "read", totalBytesRead, "limit", maxTotalLogSize)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
buf = allocateBuffer(buf, int(frameLen))
|
n, err := io.CopyN(builder, reader, int64(frameLen))
|
||||||
if _, err := io.ReadFull(reader, buf[:frameLen]); err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
|
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
|
||||||
if len(buf) > 0 {
|
|
||||||
builder.Write(buf[:min(int(frameLen), len(buf))])
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
builder.Write(buf[:frameLen])
|
totalBytesRead += int(n)
|
||||||
totalBytesRead += int(frameLen)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func allocateBuffer(current []byte, needed int) []byte {
|
// GetHostInfo fetches the system info from Docker
|
||||||
if cap(current) >= needed {
|
func (dm *dockerManager) GetHostInfo() (info container.HostInfo, err error) {
|
||||||
return current[:needed]
|
resp, err := dm.client.Get("http://localhost/info")
|
||||||
|
if err != nil {
|
||||||
|
return info, err
|
||||||
}
|
}
|
||||||
return make([]byte, needed)
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
|
||||||
|
return info, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return info, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func min(a, b int) int {
|
func (dm *dockerManager) IsPodman() bool {
|
||||||
if a < b {
|
return dm.usingPodman
|
||||||
return a
|
|
||||||
}
|
|
||||||
return b
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
//go:build testing
|
//go:build testing
|
||||||
// +build testing
|
|
||||||
|
|
||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -19,6 +25,37 @@ import (
|
|||||||
|
|
||||||
var defaultCacheTimeMs = uint16(60_000)
|
var defaultCacheTimeMs = uint16(60_000)
|
||||||
|
|
||||||
|
type recordingRoundTripper struct {
|
||||||
|
statusCode int
|
||||||
|
body string
|
||||||
|
contentType string
|
||||||
|
called bool
|
||||||
|
lastPath string
|
||||||
|
lastQuery map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rt *recordingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
rt.called = true
|
||||||
|
rt.lastPath = req.URL.EscapedPath()
|
||||||
|
rt.lastQuery = map[string]string{}
|
||||||
|
for key, values := range req.URL.Query() {
|
||||||
|
if len(values) > 0 {
|
||||||
|
rt.lastQuery[key] = values[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resp := &http.Response{
|
||||||
|
StatusCode: rt.statusCode,
|
||||||
|
Status: "200 OK",
|
||||||
|
Header: make(http.Header),
|
||||||
|
Body: io.NopCloser(strings.NewReader(rt.body)),
|
||||||
|
Request: req,
|
||||||
|
}
|
||||||
|
if rt.contentType != "" {
|
||||||
|
resp.Header.Set("Content-Type", rt.contentType)
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
// cycleCpuDeltas cycles the CPU tracking data for a specific cache time interval
|
// cycleCpuDeltas cycles the CPU tracking data for a specific cache time interval
|
||||||
func (dm *dockerManager) cycleCpuDeltas(cacheTimeMs uint16) {
|
func (dm *dockerManager) cycleCpuDeltas(cacheTimeMs uint16) {
|
||||||
// Clear the CPU tracking maps for this cache time interval
|
// Clear the CPU tracking maps for this cache time interval
|
||||||
@@ -110,6 +147,72 @@ func TestCalculateMemoryUsage(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildDockerContainerEndpoint(t *testing.T) {
|
||||||
|
t.Run("valid container ID builds escaped endpoint", func(t *testing.T) {
|
||||||
|
endpoint, err := buildDockerContainerEndpoint("0123456789ab", "json", nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "http://localhost/containers/0123456789ab/json", endpoint)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid container ID is rejected", func(t *testing.T) {
|
||||||
|
_, err := buildDockerContainerEndpoint("../../version", "json", nil)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "invalid container id")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContainerDetailsRequestsValidateContainerID(t *testing.T) {
|
||||||
|
rt := &recordingRoundTripper{
|
||||||
|
statusCode: 200,
|
||||||
|
body: `{"Config":{"Env":["SECRET=1"]}}`,
|
||||||
|
}
|
||||||
|
dm := &dockerManager{
|
||||||
|
client: &http.Client{Transport: rt},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := dm.getContainerInfo(context.Background(), "../version")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "invalid container id")
|
||||||
|
assert.False(t, rt.called, "request should be rejected before dispatching to Docker API")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContainerDetailsRequestsUseExpectedDockerPaths(t *testing.T) {
|
||||||
|
t.Run("container info uses container json endpoint", func(t *testing.T) {
|
||||||
|
rt := &recordingRoundTripper{
|
||||||
|
statusCode: 200,
|
||||||
|
body: `{"Config":{"Env":["SECRET=1"]},"Name":"demo"}`,
|
||||||
|
}
|
||||||
|
dm := &dockerManager{
|
||||||
|
client: &http.Client{Transport: rt},
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := dm.getContainerInfo(context.Background(), "0123456789ab")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, rt.called)
|
||||||
|
assert.Equal(t, "/containers/0123456789ab/json", rt.lastPath)
|
||||||
|
assert.NotContains(t, string(body), "SECRET=1", "sensitive env vars should be removed")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("container logs uses expected endpoint and query params", func(t *testing.T) {
|
||||||
|
rt := &recordingRoundTripper{
|
||||||
|
statusCode: 200,
|
||||||
|
body: "line1\nline2\n",
|
||||||
|
}
|
||||||
|
dm := &dockerManager{
|
||||||
|
client: &http.Client{Transport: rt},
|
||||||
|
}
|
||||||
|
|
||||||
|
logs, err := dm.getLogs(context.Background(), "abcdef123456")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, rt.called)
|
||||||
|
assert.Equal(t, "/containers/abcdef123456/logs", rt.lastPath)
|
||||||
|
assert.Equal(t, "1", rt.lastQuery["stdout"])
|
||||||
|
assert.Equal(t, "1", rt.lastQuery["stderr"])
|
||||||
|
assert.Equal(t, "200", rt.lastQuery["tail"])
|
||||||
|
assert.Equal(t, "line1\nline2\n", logs)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestValidateCpuPercentage(t *testing.T) {
|
func TestValidateCpuPercentage(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -166,17 +269,16 @@ func TestValidateCpuPercentage(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestUpdateContainerStatsValues(t *testing.T) {
|
func TestUpdateContainerStatsValues(t *testing.T) {
|
||||||
stats := &container.Stats{
|
var stats = container.Stats{}
|
||||||
Name: "test-container",
|
stats.Name = "test-container"
|
||||||
Cpu: 0.0,
|
stats.Cpu = 0.0
|
||||||
Mem: 0.0,
|
stats.Mem = 0.0
|
||||||
NetworkSent: 0.0,
|
stats.NetworkSent = 0.0
|
||||||
NetworkRecv: 0.0,
|
stats.NetworkRecv = 0.0
|
||||||
PrevReadTime: time.Time{},
|
stats.PrevReadTime = time.Time{}
|
||||||
}
|
|
||||||
|
|
||||||
testTime := time.Now()
|
testTime := time.Now()
|
||||||
updateContainerStatsValues(stats, 75.5, 1048576, 524288, 262144, testTime)
|
updateContainerStatsValues(&stats, 75.5, 1048576, 524288, 262144, testTime)
|
||||||
|
|
||||||
// Check CPU percentage (should be rounded to 2 decimals)
|
// Check CPU percentage (should be rounded to 2 decimals)
|
||||||
assert.Equal(t, 75.5, stats.Cpu)
|
assert.Equal(t, 75.5, stats.Cpu)
|
||||||
@@ -184,11 +286,12 @@ func TestUpdateContainerStatsValues(t *testing.T) {
|
|||||||
// Check memory (should be converted to MB: 1048576 bytes = 1 MB)
|
// Check memory (should be converted to MB: 1048576 bytes = 1 MB)
|
||||||
assert.Equal(t, 1.0, stats.Mem)
|
assert.Equal(t, 1.0, stats.Mem)
|
||||||
|
|
||||||
// Check network sent (should be converted to MB: 524288 bytes = 0.5 MB)
|
// Check bandwidth (raw bytes)
|
||||||
assert.Equal(t, 0.5, stats.NetworkSent)
|
assert.Equal(t, [2]uint64{524288, 262144}, stats.Bandwidth)
|
||||||
|
|
||||||
// Check network recv (should be converted to MB: 262144 bytes = 0.25 MB)
|
// Deprecated fields still populated for backward compatibility with older hubs
|
||||||
assert.Equal(t, 0.25, stats.NetworkRecv)
|
assert.Equal(t, 0.5, stats.NetworkSent) // 524288 bytes = 0.5 MB
|
||||||
|
assert.Equal(t, 0.25, stats.NetworkRecv) // 262144 bytes = 0.25 MB
|
||||||
|
|
||||||
// Check read time
|
// Check read time
|
||||||
assert.Equal(t, testTime, stats.PrevReadTime)
|
assert.Equal(t, testTime, stats.PrevReadTime)
|
||||||
@@ -342,12 +445,11 @@ func TestCalculateNetworkStats(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
stats := &container.Stats{
|
var stats = container.Stats{}
|
||||||
PrevReadTime: time.Now().Add(-time.Second), // 1 second ago
|
stats.PrevReadTime = time.Now().Add(-time.Second) // 1 second ago
|
||||||
}
|
|
||||||
|
|
||||||
// Test with initialized container
|
// Test with initialized container
|
||||||
sent, recv := dm.calculateNetworkStats(ctr, apiStats, stats, true, "test-container", cacheTimeMs)
|
sent, recv := dm.calculateNetworkStats(ctr, apiStats, &stats, true, "test-container", cacheTimeMs)
|
||||||
|
|
||||||
// Should return calculated byte rates per second
|
// Should return calculated byte rates per second
|
||||||
assert.GreaterOrEqual(t, sent, uint64(0))
|
assert.GreaterOrEqual(t, sent, uint64(0))
|
||||||
@@ -356,7 +458,7 @@ func TestCalculateNetworkStats(t *testing.T) {
|
|||||||
// Cycle and test one-direction change (Tx only) is reflected independently
|
// Cycle and test one-direction change (Tx only) is reflected independently
|
||||||
dm.cycleNetworkDeltasForCacheTime(cacheTimeMs)
|
dm.cycleNetworkDeltasForCacheTime(cacheTimeMs)
|
||||||
apiStats.Networks["eth0"] = container.NetworkStats{TxBytes: 2500, RxBytes: 1800} // +500 Tx only
|
apiStats.Networks["eth0"] = container.NetworkStats{TxBytes: 2500, RxBytes: 1800} // +500 Tx only
|
||||||
sent, recv = dm.calculateNetworkStats(ctr, apiStats, stats, true, "test-container", cacheTimeMs)
|
sent, recv = dm.calculateNetworkStats(ctr, apiStats, &stats, true, "test-container", cacheTimeMs)
|
||||||
assert.Greater(t, sent, uint64(0))
|
assert.Greater(t, sent, uint64(0))
|
||||||
assert.Equal(t, uint64(0), recv)
|
assert.Equal(t, uint64(0), recv)
|
||||||
}
|
}
|
||||||
@@ -378,6 +480,117 @@ func TestDockerManagerCreation(t *testing.T) {
|
|||||||
assert.NotNil(t, dm.networkRecvTrackers)
|
assert.NotNil(t, dm.networkRecvTrackers)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCheckDockerVersion(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
responses []struct {
|
||||||
|
statusCode int
|
||||||
|
body string
|
||||||
|
}
|
||||||
|
expectedGood bool
|
||||||
|
expectedRequests int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "200 with good version on first try",
|
||||||
|
responses: []struct {
|
||||||
|
statusCode int
|
||||||
|
body string
|
||||||
|
}{
|
||||||
|
{http.StatusOK, `{"Version":"25.0.1"}`},
|
||||||
|
},
|
||||||
|
expectedGood: true,
|
||||||
|
expectedRequests: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "200 with old version on first try",
|
||||||
|
responses: []struct {
|
||||||
|
statusCode int
|
||||||
|
body string
|
||||||
|
}{
|
||||||
|
{http.StatusOK, `{"Version":"24.0.7"}`},
|
||||||
|
},
|
||||||
|
expectedGood: false,
|
||||||
|
expectedRequests: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non-200 then 200 with good version",
|
||||||
|
responses: []struct {
|
||||||
|
statusCode int
|
||||||
|
body string
|
||||||
|
}{
|
||||||
|
{http.StatusServiceUnavailable, `"not ready"`},
|
||||||
|
{http.StatusOK, `{"Version":"25.1.0"}`},
|
||||||
|
},
|
||||||
|
expectedGood: true,
|
||||||
|
expectedRequests: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non-200 on all retries",
|
||||||
|
responses: []struct {
|
||||||
|
statusCode int
|
||||||
|
body string
|
||||||
|
}{
|
||||||
|
{http.StatusInternalServerError, `"error"`},
|
||||||
|
{http.StatusUnauthorized, `"error"`},
|
||||||
|
},
|
||||||
|
expectedGood: false,
|
||||||
|
expectedRequests: 2,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
requestCount := 0
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
idx := requestCount
|
||||||
|
requestCount++
|
||||||
|
if idx >= len(tt.responses) {
|
||||||
|
idx = len(tt.responses) - 1
|
||||||
|
}
|
||||||
|
w.WriteHeader(tt.responses[idx].statusCode)
|
||||||
|
fmt.Fprint(w, tt.responses[idx].body)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
dm := &dockerManager{
|
||||||
|
client: &http.Client{
|
||||||
|
Transport: &http.Transport{
|
||||||
|
DialContext: func(_ context.Context, network, _ string) (net.Conn, error) {
|
||||||
|
return net.Dial(network, server.Listener.Addr().String())
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
retrySleep: func(time.Duration) {},
|
||||||
|
}
|
||||||
|
|
||||||
|
dm.checkDockerVersion()
|
||||||
|
|
||||||
|
assert.Equal(t, tt.expectedGood, dm.goodDockerVersion)
|
||||||
|
assert.Equal(t, tt.expectedRequests, requestCount)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("request error on all retries", func(t *testing.T) {
|
||||||
|
requestCount := 0
|
||||||
|
dm := &dockerManager{
|
||||||
|
client: &http.Client{
|
||||||
|
Transport: &http.Transport{
|
||||||
|
DialContext: func(_ context.Context, _, _ string) (net.Conn, error) {
|
||||||
|
requestCount++
|
||||||
|
return nil, errors.New("connection refused")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
retrySleep: func(time.Duration) {},
|
||||||
|
}
|
||||||
|
|
||||||
|
dm.checkDockerVersion()
|
||||||
|
|
||||||
|
assert.False(t, dm.goodDockerVersion)
|
||||||
|
assert.Equal(t, 2, requestCount)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestCycleCpuDeltas(t *testing.T) {
|
func TestCycleCpuDeltas(t *testing.T) {
|
||||||
dm := &dockerManager{
|
dm := &dockerManager{
|
||||||
lastCpuContainer: map[uint16]map[string]uint64{
|
lastCpuContainer: map[uint16]map[string]uint64{
|
||||||
@@ -511,7 +724,8 @@ func TestMemoryStatsEdgeCases(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestContainerStatsInitialization(t *testing.T) {
|
func TestContainerStatsInitialization(t *testing.T) {
|
||||||
stats := &container.Stats{Name: "test-container"}
|
var stats = container.Stats{}
|
||||||
|
stats.Name = "test-container"
|
||||||
|
|
||||||
// Verify initial values
|
// Verify initial values
|
||||||
assert.Equal(t, "test-container", stats.Name)
|
assert.Equal(t, "test-container", stats.Name)
|
||||||
@@ -523,12 +737,14 @@ func TestContainerStatsInitialization(t *testing.T) {
|
|||||||
|
|
||||||
// Test updating values
|
// Test updating values
|
||||||
testTime := time.Now()
|
testTime := time.Now()
|
||||||
updateContainerStatsValues(stats, 45.67, 2097152, 1048576, 524288, testTime)
|
updateContainerStatsValues(&stats, 45.67, 2097152, 1048576, 524288, testTime)
|
||||||
|
|
||||||
assert.Equal(t, 45.67, stats.Cpu)
|
assert.Equal(t, 45.67, stats.Cpu)
|
||||||
assert.Equal(t, 2.0, stats.Mem)
|
assert.Equal(t, 2.0, stats.Mem)
|
||||||
assert.Equal(t, 1.0, stats.NetworkSent)
|
assert.Equal(t, [2]uint64{1048576, 524288}, stats.Bandwidth)
|
||||||
assert.Equal(t, 0.5, stats.NetworkRecv)
|
// Deprecated fields still populated for backward compatibility with older hubs
|
||||||
|
assert.Equal(t, 1.0, stats.NetworkSent) // 1048576 bytes = 1 MB
|
||||||
|
assert.Equal(t, 0.5, stats.NetworkRecv) // 524288 bytes = 0.5 MB
|
||||||
assert.Equal(t, testTime, stats.PrevReadTime)
|
assert.Equal(t, testTime, stats.PrevReadTime)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -599,12 +815,11 @@ func TestNetworkStatsCalculationWithRealData(t *testing.T) {
|
|||||||
|
|
||||||
// Use exact timing for deterministic results
|
// Use exact timing for deterministic results
|
||||||
exactly1000msAgo := time.Now().Add(-1000 * time.Millisecond)
|
exactly1000msAgo := time.Now().Add(-1000 * time.Millisecond)
|
||||||
stats := &container.Stats{
|
var stats = container.Stats{}
|
||||||
PrevReadTime: exactly1000msAgo,
|
stats.PrevReadTime = exactly1000msAgo
|
||||||
}
|
|
||||||
|
|
||||||
// First call sets baseline
|
// First call sets baseline
|
||||||
sent1, recv1 := dm.calculateNetworkStats(ctr, apiStats1, stats, true, "test", cacheTimeMs)
|
sent1, recv1 := dm.calculateNetworkStats(ctr, apiStats1, &stats, true, "test", cacheTimeMs)
|
||||||
assert.Equal(t, uint64(0), sent1)
|
assert.Equal(t, uint64(0), sent1)
|
||||||
assert.Equal(t, uint64(0), recv1)
|
assert.Equal(t, uint64(0), recv1)
|
||||||
|
|
||||||
@@ -619,7 +834,7 @@ func TestNetworkStatsCalculationWithRealData(t *testing.T) {
|
|||||||
expectedRecvRate := deltaRecv * 1000 / expectedElapsedMs // Should be exactly 1000000
|
expectedRecvRate := deltaRecv * 1000 / expectedElapsedMs // Should be exactly 1000000
|
||||||
|
|
||||||
// Second call with changed data
|
// Second call with changed data
|
||||||
sent2, recv2 := dm.calculateNetworkStats(ctr, apiStats2, stats, true, "test", cacheTimeMs)
|
sent2, recv2 := dm.calculateNetworkStats(ctr, apiStats2, &stats, true, "test", cacheTimeMs)
|
||||||
|
|
||||||
// Should be exactly the expected rates (no tolerance needed)
|
// Should be exactly the expected rates (no tolerance needed)
|
||||||
assert.Equal(t, expectedSentRate, sent2)
|
assert.Equal(t, expectedSentRate, sent2)
|
||||||
@@ -630,9 +845,9 @@ func TestNetworkStatsCalculationWithRealData(t *testing.T) {
|
|||||||
stats.PrevReadTime = time.Now().Add(-1 * time.Millisecond)
|
stats.PrevReadTime = time.Now().Add(-1 * time.Millisecond)
|
||||||
apiStats1.Networks["eth0"] = container.NetworkStats{TxBytes: 0, RxBytes: 0}
|
apiStats1.Networks["eth0"] = container.NetworkStats{TxBytes: 0, RxBytes: 0}
|
||||||
apiStats2.Networks["eth0"] = container.NetworkStats{TxBytes: 10 * 1024 * 1024 * 1024, RxBytes: 0} // 10GB delta
|
apiStats2.Networks["eth0"] = container.NetworkStats{TxBytes: 10 * 1024 * 1024 * 1024, RxBytes: 0} // 10GB delta
|
||||||
_, _ = dm.calculateNetworkStats(ctr, apiStats1, stats, true, "test", cacheTimeMs) // baseline
|
_, _ = dm.calculateNetworkStats(ctr, apiStats1, &stats, true, "test", cacheTimeMs) // baseline
|
||||||
dm.cycleNetworkDeltasForCacheTime(cacheTimeMs)
|
dm.cycleNetworkDeltasForCacheTime(cacheTimeMs)
|
||||||
sent3, recv3 := dm.calculateNetworkStats(ctr, apiStats2, stats, true, "test", cacheTimeMs)
|
sent3, recv3 := dm.calculateNetworkStats(ctr, apiStats2, &stats, true, "test", cacheTimeMs)
|
||||||
assert.Equal(t, uint64(0), sent3)
|
assert.Equal(t, uint64(0), sent3)
|
||||||
assert.Equal(t, uint64(0), recv3)
|
assert.Equal(t, uint64(0), recv3)
|
||||||
}
|
}
|
||||||
@@ -666,8 +881,9 @@ func TestContainerStatsEndToEndWithRealData(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Initialize container stats
|
// Initialize container stats
|
||||||
stats := &container.Stats{Name: "jellyfin"}
|
var stats = container.Stats{}
|
||||||
dm.containerStatsMap[ctr.IdShort] = stats
|
stats.Name = "jellyfin"
|
||||||
|
dm.containerStatsMap[ctr.IdShort] = &stats
|
||||||
|
|
||||||
// Test individual components that we can verify
|
// Test individual components that we can verify
|
||||||
usedMemory, memErr := calculateMemoryUsage(&apiStats, false)
|
usedMemory, memErr := calculateMemoryUsage(&apiStats, false)
|
||||||
@@ -689,11 +905,49 @@ func TestContainerStatsEndToEndWithRealData(t *testing.T) {
|
|||||||
|
|
||||||
assert.Equal(t, cpuPct, testStats.Cpu)
|
assert.Equal(t, cpuPct, testStats.Cpu)
|
||||||
assert.Equal(t, bytesToMegabytes(float64(usedMemory)), testStats.Mem)
|
assert.Equal(t, bytesToMegabytes(float64(usedMemory)), testStats.Mem)
|
||||||
|
assert.Equal(t, [2]uint64{1000000, 500000}, testStats.Bandwidth)
|
||||||
|
// Deprecated fields still populated for backward compatibility with older hubs
|
||||||
assert.Equal(t, bytesToMegabytes(1000000), testStats.NetworkSent)
|
assert.Equal(t, bytesToMegabytes(1000000), testStats.NetworkSent)
|
||||||
assert.Equal(t, bytesToMegabytes(500000), testStats.NetworkRecv)
|
assert.Equal(t, bytesToMegabytes(500000), testStats.NetworkRecv)
|
||||||
assert.Equal(t, testTime, testStats.PrevReadTime)
|
assert.Equal(t, testTime, testStats.PrevReadTime)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetLogsDetectsMultiplexedWithoutContentType(t *testing.T) {
|
||||||
|
// Docker multiplexed frame: [stream][0,0,0][len(4 bytes BE)][payload]
|
||||||
|
frame := []byte{
|
||||||
|
0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x05,
|
||||||
|
'H', 'e', 'l', 'l', 'o',
|
||||||
|
}
|
||||||
|
rt := &recordingRoundTripper{
|
||||||
|
statusCode: 200,
|
||||||
|
body: string(frame),
|
||||||
|
// Intentionally omit content type to simulate Podman behavior.
|
||||||
|
}
|
||||||
|
dm := &dockerManager{
|
||||||
|
client: &http.Client{Transport: rt},
|
||||||
|
}
|
||||||
|
|
||||||
|
logs, err := dm.getLogs(context.Background(), "abcdef123456")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "Hello", logs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetLogsDoesNotMisclassifyRawStreamAsMultiplexed(t *testing.T) {
|
||||||
|
// Starts with 0x01, but doesn't match Docker frame signature (reserved bytes aren't all zero).
|
||||||
|
raw := []byte{0x01, 0x02, 0x03, 0x04, 'r', 'a', 'w'}
|
||||||
|
rt := &recordingRoundTripper{
|
||||||
|
statusCode: 200,
|
||||||
|
body: string(raw),
|
||||||
|
}
|
||||||
|
dm := &dockerManager{
|
||||||
|
client: &http.Client{Transport: rt},
|
||||||
|
}
|
||||||
|
|
||||||
|
logs, err := dm.getLogs(context.Background(), "abcdef123456")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, raw, []byte(logs))
|
||||||
|
}
|
||||||
|
|
||||||
func TestEdgeCasesWithRealData(t *testing.T) {
|
func TestEdgeCasesWithRealData(t *testing.T) {
|
||||||
// Test with minimal container stats
|
// Test with minimal container stats
|
||||||
minimalStats := &container.ApiStats{
|
minimalStats := &container.ApiStats{
|
||||||
@@ -802,6 +1056,24 @@ func TestNetworkRateCalculationFormula(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetHostInfo(t *testing.T) {
|
||||||
|
data, err := os.ReadFile("test-data/system_info.json")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var info container.HostInfo
|
||||||
|
err = json.Unmarshal(data, &info)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, "6.8.0-31-generic", info.KernelVersion)
|
||||||
|
assert.Equal(t, "Ubuntu 24.04 LTS", info.OperatingSystem)
|
||||||
|
// assert.Equal(t, "24.04", info.OSVersion)
|
||||||
|
// assert.Equal(t, "linux", info.OSType)
|
||||||
|
// assert.Equal(t, "x86_64", info.Architecture)
|
||||||
|
assert.EqualValues(t, 4, info.NCPU)
|
||||||
|
assert.EqualValues(t, 2095882240, info.MemTotal)
|
||||||
|
// assert.Equal(t, "27.0.1", info.ServerVersion)
|
||||||
|
}
|
||||||
|
|
||||||
func TestDeltaTrackerCacheTimeIsolation(t *testing.T) {
|
func TestDeltaTrackerCacheTimeIsolation(t *testing.T) {
|
||||||
// Test that different cache times have separate DeltaTracker instances
|
// Test that different cache times have separate DeltaTracker instances
|
||||||
dm := &dockerManager{
|
dm := &dockerManager{
|
||||||
@@ -932,6 +1204,7 @@ func TestDecodeDockerLogStream(t *testing.T) {
|
|||||||
input []byte
|
input []byte
|
||||||
expected string
|
expected string
|
||||||
expectError bool
|
expectError bool
|
||||||
|
multiplexed bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "simple log entry",
|
name: "simple log entry",
|
||||||
@@ -942,6 +1215,7 @@ func TestDecodeDockerLogStream(t *testing.T) {
|
|||||||
},
|
},
|
||||||
expected: "Hello World",
|
expected: "Hello World",
|
||||||
expectError: false,
|
expectError: false,
|
||||||
|
multiplexed: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "multiple frames",
|
name: "multiple frames",
|
||||||
@@ -955,6 +1229,7 @@ func TestDecodeDockerLogStream(t *testing.T) {
|
|||||||
},
|
},
|
||||||
expected: "HelloWorld",
|
expected: "HelloWorld",
|
||||||
expectError: false,
|
expectError: false,
|
||||||
|
multiplexed: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "zero length frame",
|
name: "zero length frame",
|
||||||
@@ -967,12 +1242,20 @@ func TestDecodeDockerLogStream(t *testing.T) {
|
|||||||
},
|
},
|
||||||
expected: "Hello",
|
expected: "Hello",
|
||||||
expectError: false,
|
expectError: false,
|
||||||
|
multiplexed: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "empty input",
|
name: "empty input",
|
||||||
input: []byte{},
|
input: []byte{},
|
||||||
expected: "",
|
expected: "",
|
||||||
expectError: false,
|
expectError: false,
|
||||||
|
multiplexed: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "raw stream (not multiplexed)",
|
||||||
|
input: []byte("raw log content"),
|
||||||
|
expected: "raw log content",
|
||||||
|
multiplexed: false,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -980,7 +1263,7 @@ func TestDecodeDockerLogStream(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
reader := bytes.NewReader(tt.input)
|
reader := bytes.NewReader(tt.input)
|
||||||
var builder strings.Builder
|
var builder strings.Builder
|
||||||
err := decodeDockerLogStream(reader, &builder)
|
err := decodeDockerLogStream(reader, &builder, tt.multiplexed)
|
||||||
|
|
||||||
if tt.expectError {
|
if tt.expectError {
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
@@ -1004,7 +1287,7 @@ func TestDecodeDockerLogStreamMemoryProtection(t *testing.T) {
|
|||||||
|
|
||||||
reader := bytes.NewReader(input)
|
reader := bytes.NewReader(input)
|
||||||
var builder strings.Builder
|
var builder strings.Builder
|
||||||
err := decodeDockerLogStream(reader, &builder)
|
err := decodeDockerLogStream(reader, &builder, true)
|
||||||
|
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "log frame size")
|
assert.Contains(t, err.Error(), "log frame size")
|
||||||
@@ -1038,7 +1321,7 @@ func TestDecodeDockerLogStreamMemoryProtection(t *testing.T) {
|
|||||||
|
|
||||||
reader := bytes.NewReader(input)
|
reader := bytes.NewReader(input)
|
||||||
var builder strings.Builder
|
var builder strings.Builder
|
||||||
err := decodeDockerLogStream(reader, &builder)
|
err := decodeDockerLogStream(reader, &builder, true)
|
||||||
|
|
||||||
// Should complete without error (graceful truncation)
|
// Should complete without error (graceful truncation)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
@@ -1053,53 +1336,6 @@ func TestDecodeDockerLogStreamMemoryProtection(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAllocateBuffer(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
currentCap int
|
|
||||||
needed int
|
|
||||||
expectedCap int
|
|
||||||
shouldRealloc bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "buffer has enough capacity",
|
|
||||||
currentCap: 1024,
|
|
||||||
needed: 512,
|
|
||||||
expectedCap: 1024,
|
|
||||||
shouldRealloc: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "buffer needs reallocation",
|
|
||||||
currentCap: 512,
|
|
||||||
needed: 1024,
|
|
||||||
expectedCap: 1024,
|
|
||||||
shouldRealloc: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "buffer needs exact size",
|
|
||||||
currentCap: 1024,
|
|
||||||
needed: 1024,
|
|
||||||
expectedCap: 1024,
|
|
||||||
shouldRealloc: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
current := make([]byte, 0, tt.currentCap)
|
|
||||||
result := allocateBuffer(current, tt.needed)
|
|
||||||
|
|
||||||
assert.Equal(t, tt.needed, len(result))
|
|
||||||
assert.GreaterOrEqual(t, cap(result), tt.expectedCap)
|
|
||||||
|
|
||||||
if tt.shouldRealloc {
|
|
||||||
// If reallocation was needed, capacity should be at least the needed size
|
|
||||||
assert.GreaterOrEqual(t, cap(result), tt.needed)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestShouldExcludeContainer(t *testing.T) {
|
func TestShouldExcludeContainer(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -1203,3 +1439,59 @@ func TestShouldExcludeContainer(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAnsiEscapePattern(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no ANSI codes",
|
||||||
|
input: "Hello, World!",
|
||||||
|
expected: "Hello, World!",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "simple color code",
|
||||||
|
input: "\x1b[34mINFO\x1b[0m client mode",
|
||||||
|
expected: "INFO client mode",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple color codes",
|
||||||
|
input: "\x1b[31mERROR\x1b[0m: \x1b[33mWarning\x1b[0m message",
|
||||||
|
expected: "ERROR: Warning message",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bold and color",
|
||||||
|
input: "\x1b[1;32mSUCCESS\x1b[0m",
|
||||||
|
expected: "SUCCESS",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "cursor movement codes",
|
||||||
|
input: "Line 1\x1b[KLine 2",
|
||||||
|
expected: "Line 1Line 2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "256 color code",
|
||||||
|
input: "\x1b[38;5;196mRed text\x1b[0m",
|
||||||
|
expected: "Red text",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "RGB/truecolor code",
|
||||||
|
input: "\x1b[38;2;255;0;0mRed text\x1b[0m",
|
||||||
|
expected: "Red text",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mixed content with newlines",
|
||||||
|
input: "\x1b[34m2024-01-01 12:00:00\x1b[0m INFO Starting\n\x1b[31m2024-01-01 12:00:01\x1b[0m ERROR Failed",
|
||||||
|
expected: "2024-01-01 12:00:00 INFO Starting\n2024-01-01 12:00:01 ERROR Failed",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := ansiEscapePattern.ReplaceAllString(tt.input, "")
|
||||||
|
assert.Equal(t, tt.expected, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
214
agent/emmc_linux.go
Normal file
214
agent/emmc_linux.go
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"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 = readStringFile(filepath.Join(deviceDir, "name"))
|
||||||
|
out.serial = readStringFile(filepath.Join(deviceDir, "serial"))
|
||||||
|
out.revision = 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 := 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 := readStringFileOK(sizePath)
|
||||||
|
if !ok {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
sectors, err := strconv.ParseUint(sizeStr, 10, 64)
|
||||||
|
if err != nil || sectors == 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
lbsStr, ok := 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 := 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
41
agent/fs_utils.go
Normal file
41
agent/fs_utils.go
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
461
agent/gpu.go
461
agent/gpu.go
@@ -5,29 +5,29 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"maps"
|
"maps"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"runtime"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
|
||||||
"golang.org/x/exp/slog"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// Commands
|
// Commands
|
||||||
nvidiaSmiCmd string = "nvidia-smi"
|
nvidiaSmiCmd string = "nvidia-smi"
|
||||||
rocmSmiCmd string = "rocm-smi"
|
rocmSmiCmd string = "rocm-smi"
|
||||||
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
|
||||||
@@ -40,11 +40,7 @@ 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
|
GpuDataMap map[string]*system.GPUData
|
||||||
rocmSmi bool
|
|
||||||
tegrastats bool
|
|
||||||
intelGpuStats bool
|
|
||||||
GpuDataMap map[string]*system.GPUData
|
|
||||||
// lastAvgData stores the last calculated averages for each GPU
|
// lastAvgData stores the last calculated averages for each GPU
|
||||||
// Used when a collection happens before new data arrives (Count == 0)
|
// Used when a collection happens before new data arrives (Count == 0)
|
||||||
lastAvgData map[string]system.GPUData
|
lastAvgData map[string]system.GPUData
|
||||||
@@ -85,6 +81,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 {
|
||||||
@@ -136,10 +184,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"}
|
||||||
@@ -168,7 +216,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++
|
||||||
@@ -231,10 +285,11 @@ func (gm *GPUManager) parseAmdData(output []byte) bool {
|
|||||||
totalMemory, _ := strconv.ParseFloat(v.MemoryTotal, 64)
|
totalMemory, _ := strconv.ParseFloat(v.MemoryTotal, 64)
|
||||||
usage, _ := strconv.ParseFloat(v.Usage, 64)
|
usage, _ := strconv.ParseFloat(v.Usage, 64)
|
||||||
|
|
||||||
if _, ok := gm.GpuDataMap[v.ID]; !ok {
|
id := v.ID
|
||||||
gm.GpuDataMap[v.ID] = &system.GPUData{Name: v.Name}
|
if _, ok := gm.GpuDataMap[id]; !ok {
|
||||||
|
gm.GpuDataMap[id] = &system.GPUData{Name: v.Name}
|
||||||
}
|
}
|
||||||
gpu := gm.GpuDataMap[v.ID]
|
gpu := gm.GpuDataMap[id]
|
||||||
gpu.Temperature, _ = strconv.ParseFloat(v.Temperature, 64)
|
gpu.Temperature, _ = strconv.ParseFloat(v.Temperature, 64)
|
||||||
gpu.MemoryUsed = bytesToMegabytes(memoryUsage)
|
gpu.MemoryUsed = bytesToMegabytes(memoryUsage)
|
||||||
gpu.MemoryTotal = bytesToMegabytes(totalMemory)
|
gpu.MemoryTotal = bytesToMegabytes(totalMemory)
|
||||||
@@ -297,8 +352,13 @@ func (gm *GPUManager) calculateGPUAverage(id string, gpu *system.GPUData, cacheK
|
|||||||
currentCount := uint32(gpu.Count)
|
currentCount := uint32(gpu.Count)
|
||||||
deltaCount := gm.calculateDeltaCount(currentCount, lastSnapshot)
|
deltaCount := gm.calculateDeltaCount(currentCount, lastSnapshot)
|
||||||
|
|
||||||
// If no new data arrived, use last known average
|
// If no new data arrived
|
||||||
if deltaCount == 0 {
|
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
|
return gm.lastAvgData[id] // zero value if not found
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -378,81 +438,292 @@ func (gm *GPUManager) storeSnapshot(id string, gpu *system.GPUData, cacheKey uin
|
|||||||
gm.lastSnapshots[cacheKey][id] = snapshot
|
gm.lastSnapshots[cacheKey][id] = snapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
// detectGPUs checks for the presence of GPU management tools (nvidia-smi, rocm-smi, tegrastats)
|
// discoverGpuCapabilities checks for available GPU tooling and sysfs support.
|
||||||
// in the system path. It sets the corresponding flags in the GPUManager struct if any of these
|
// It only reports capability presence and does not apply policy decisions.
|
||||||
// tools are found. If none of the tools are found, it returns an error indicating that no GPU
|
func (gm *GPUManager) discoverGpuCapabilities() gpuCapabilities {
|
||||||
// management tools are available.
|
caps := gpuCapabilities{
|
||||||
func (gm *GPUManager) detectGPUs() error {
|
hasAmdSysfs: gm.hasAmdSysfs(),
|
||||||
|
}
|
||||||
if _, err := exec.LookPath(nvidiaSmiCmd); err == nil {
|
if _, err := exec.LookPath(nvidiaSmiCmd); err == nil {
|
||||||
gm.nvidiaSmi = true
|
caps.hasNvidiaSmi = true
|
||||||
}
|
}
|
||||||
if _, err := exec.LookPath(rocmSmiCmd); err == nil {
|
if _, err := exec.LookPath(rocmSmiCmd); err == nil {
|
||||||
gm.rocmSmi = true
|
caps.hasRocmSmi = true
|
||||||
}
|
}
|
||||||
if _, err := exec.LookPath(tegraStatsCmd); err == nil {
|
if _, err := exec.LookPath(tegraStatsCmd); err == nil {
|
||||||
gm.tegrastats = true
|
caps.hasTegrastats = true
|
||||||
gm.nvidiaSmi = false
|
|
||||||
}
|
}
|
||||||
if _, err := exec.LookPath(intelGpuStatsCmd); err == nil {
|
if _, err := exec.LookPath(intelGpuStatsCmd); err == nil {
|
||||||
gm.intelGpuStats = true
|
caps.hasIntelGpuTop = true
|
||||||
}
|
}
|
||||||
if gm.nvidiaSmi || gm.rocmSmi || gm.tegrastats || gm.intelGpuStats {
|
if _, err := exec.LookPath(nvtopCmd); err == nil {
|
||||||
return nil
|
caps.hasNvtop = true
|
||||||
}
|
}
|
||||||
return fmt.Errorf("no GPU found - install nvidia-smi, rocm-smi, tegrastats, or intel_gpu_top")
|
if runtime.GOOS == "darwin" {
|
||||||
|
if _, err := exec.LookPath(macmonCmd); err == nil {
|
||||||
|
caps.hasMacmon = true
|
||||||
|
}
|
||||||
|
if _, err := exec.LookPath(powermetricsCmd); err == nil {
|
||||||
|
caps.hasPowermetrics = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return caps
|
||||||
}
|
}
|
||||||
|
|
||||||
// startCollector starts the appropriate GPU data collector based on the command
|
func hasAnyGpuCollector(caps gpuCapabilities) bool {
|
||||||
func (gm *GPUManager) startCollector(command string) {
|
return caps.hasNvidiaSmi || caps.hasRocmSmi || caps.hasAmdSysfs || caps.hasTegrastats || caps.hasIntelGpuTop || caps.hasNvtop || caps.hasMacmon || caps.hasPowermetrics
|
||||||
collector := gpuCollector{
|
}
|
||||||
name: command,
|
|
||||||
bufSize: 10 * 1024,
|
func (gm *GPUManager) startIntelCollector() {
|
||||||
}
|
go func() {
|
||||||
switch command {
|
failures := 0
|
||||||
case intelGpuStatsCmd:
|
for {
|
||||||
go func() {
|
if err := gm.collectIntelStats(); err != nil {
|
||||||
failures := 0
|
failures++
|
||||||
for {
|
if failures > maxFailureRetries {
|
||||||
if err := gm.collectIntelStats(); err != nil {
|
break
|
||||||
failures++
|
|
||||||
if failures > maxFailureRetries {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
slog.Warn("Error collecting Intel GPU data; see https://beszel.dev/guide/gpu", "err", err)
|
|
||||||
time.Sleep(retryWaitTime)
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
|
slog.Warn("Error collecting Intel GPU data; see https://beszel.dev/guide/gpu", "err", err)
|
||||||
|
time.Sleep(retryWaitTime)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
}()
|
}
|
||||||
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",
|
||||||
}
|
},
|
||||||
collector.parse = gm.parseNvidiaData
|
parse: gm.parseNvidiaData,
|
||||||
go collector.start()
|
|
||||||
case tegraStatsCmd:
|
|
||||||
collector.cmdArgs = []string{"--interval", tegraStatsInterval}
|
|
||||||
collector.parse = gm.getJetsonParser()
|
|
||||||
go collector.start()
|
|
||||||
case rocmSmiCmd:
|
|
||||||
collector.cmdArgs = []string{"--showid", "--showtemp", "--showuse", "--showpower", "--showproductname", "--showmeminfo", "vram", "--json"}
|
|
||||||
collector.parse = gm.parseAmdData
|
|
||||||
go func() {
|
|
||||||
failures := 0
|
|
||||||
for {
|
|
||||||
if err := collector.collect(); err != nil {
|
|
||||||
failures++
|
|
||||||
if failures > maxFailureRetries {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
slog.Warn("Error collecting AMD GPU data", "err", err)
|
|
||||||
}
|
|
||||||
time.Sleep(rocmSmiInterval)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
}
|
||||||
|
go collector.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gm *GPUManager) startTegraStatsCollector(intervalMilliseconds string) {
|
||||||
|
collector := gpuCollector{
|
||||||
|
name: tegraStatsCmd,
|
||||||
|
bufSize: 10 * 1024,
|
||||||
|
cmdArgs: []string{"--interval", intervalMilliseconds},
|
||||||
|
parse: gm.getJetsonParser(),
|
||||||
|
}
|
||||||
|
go collector.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
||||||
|
failures := 0
|
||||||
|
for {
|
||||||
|
if err := collector.collect(); err != nil {
|
||||||
|
failures++
|
||||||
|
if failures > maxFailureRetries {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
slog.Warn("Error collecting AMD GPU data via rocm-smi", "err", err)
|
||||||
|
}
|
||||||
|
time.Sleep(pollInterval)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gm *GPUManager) collectorDefinitions(caps gpuCapabilities) map[collectorSource]collectorDefinition {
|
||||||
|
return map[collectorSource]collectorDefinition{
|
||||||
|
collectorSourceNVML: {
|
||||||
|
group: collectorGroupNvidia,
|
||||||
|
available: caps.hasNvidiaSmi,
|
||||||
|
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, _ := GetEnv("NVML"); nvml == "true" {
|
||||||
|
priorities = append(priorities, collectorSourceNVML, collectorSourceNvidiaSMI)
|
||||||
|
} else {
|
||||||
|
priorities = append(priorities, collectorSourceNvidiaSMI)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if caps.hasRocmSmi {
|
||||||
|
if val, _ := 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
|
||||||
@@ -461,22 +732,30 @@ func NewGPUManager() (*GPUManager, error) {
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
var gm GPUManager
|
var gm GPUManager
|
||||||
if err := gm.detectGPUs(); err != nil {
|
caps := gm.discoverGpuCapabilities()
|
||||||
return nil, err
|
if !hasAnyGpuCollector(caps) {
|
||||||
|
return nil, fmt.Errorf(noGPUFoundMsg)
|
||||||
}
|
}
|
||||||
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)
|
// if GPU_COLLECTOR is set, start user-defined collectors.
|
||||||
|
if collectorConfig, ok := 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")
|
||||||
|
}
|
||||||
|
return &gm, nil
|
||||||
}
|
}
|
||||||
if gm.tegrastats {
|
|
||||||
gm.startCollector(tegraStatsCmd)
|
// auto-detect and start collectors when GPU_COLLECTOR is unset.
|
||||||
}
|
if gm.startCollectorsByPriority(gm.resolveLegacyCollectorPriority(caps), caps) == 0 {
|
||||||
if gm.intelGpuStats {
|
return nil, fmt.Errorf(noGPUFoundMsg)
|
||||||
gm.startCollector(intelGpuStatsCmd)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return &gm, nil
|
return &gm, nil
|
||||||
|
|||||||
302
agent/gpu_amd_linux.go
Normal file
302
agent/gpu_amd_linux.go
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"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 := os.ReadFile(vendorPath)
|
||||||
|
if err == nil && strings.TrimSpace(string(vendor)) == "0x1002" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// collectAmdStats collects AMD GPU metrics directly from sysfs to avoid the overhead of rocm-smi
|
||||||
|
func (gm *GPUManager) collectAmdStats() error {
|
||||||
|
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 {
|
||||||
|
vendorPath := filepath.Join(cardPath, "device/vendor")
|
||||||
|
vendor, err := os.ReadFile(vendorPath)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(vendor)) == "0x1002"
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateAmdGpuData reads GPU metrics from sysfs and updates the GPU data map.
|
||||||
|
// Returns true if at least some data was successfully read.
|
||||||
|
func (gm *GPUManager) updateAmdGpuData(cardPath string) bool {
|
||||||
|
devicePath := filepath.Join(cardPath, "device")
|
||||||
|
id := filepath.Base(cardPath)
|
||||||
|
|
||||||
|
// Read all sysfs values first (no lock needed - these can be slow)
|
||||||
|
usage, usageErr := readSysfsFloat(filepath.Join(devicePath, "gpu_busy_percent"))
|
||||||
|
memUsed, memUsedErr := readSysfsFloat(filepath.Join(devicePath, "mem_info_vram_used"))
|
||||||
|
memTotal, _ := readSysfsFloat(filepath.Join(devicePath, "mem_info_vram_total"))
|
||||||
|
// 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 = bytesToMegabytes(memUsed)
|
||||||
|
gpu.MemoryTotal = 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 := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return strconv.ParseFloat(strings.TrimSpace(string(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 := os.ReadFile(filepath.Join(devicePath, "product_name")); err == nil {
|
||||||
|
return strings.TrimSpace(string(prod))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read PCI device ID and look it up
|
||||||
|
if deviceID, err := os.ReadFile(filepath.Join(devicePath, "device")); err == nil {
|
||||||
|
id := normalizeHexID(string(deviceID))
|
||||||
|
revision := ""
|
||||||
|
if revBytes, revErr := os.ReadFile(filepath.Join(devicePath, "revision")); revErr == nil {
|
||||||
|
revision = normalizeHexID(string(revBytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
}
|
||||||
264
agent/gpu_amd_linux_test.go
Normal file
264
agent/gpu_amd_linux_test.go
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"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: bytesToMegabytes(1073741824 + 536870912),
|
||||||
|
wantMemoryTotal: bytesToMegabytes(2147483648 + 4294967296),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "falls back to vram when gtt is missing",
|
||||||
|
writeGTT: false,
|
||||||
|
wantMemoryUsed: bytesToMegabytes(1073741824),
|
||||||
|
wantMemoryTotal: 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
|
||||||
|
}
|
||||||
252
agent/gpu_darwin.go
Normal file
252
agent/gpu_darwin.go
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
//go:build darwin
|
||||||
|
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"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) {
|
||||||
|
cmd := exec.Command(macmonCmd, "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() {}
|
||||||
@@ -27,10 +27,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
|
||||||
@@ -134,7 +135,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
|
||||||
|
}
|
||||||
159
agent/gpu_nvtop.go
Normal file
159
agent/gpu_nvtop.go
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"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 = bytesToMegabytes(parseNvtopNumber(*sample.MemUsed))
|
||||||
|
}
|
||||||
|
if sample.MemTotal != nil {
|
||||||
|
gpu.MemoryTotal = 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
//go:build testing
|
//go:build testing
|
||||||
// +build testing
|
|
||||||
|
|
||||||
package agent
|
package agent
|
||||||
|
|
||||||
@@ -250,6 +249,100 @@ func TestParseAmdData(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseNvtopData(t *testing.T) {
|
||||||
|
input, err := os.ReadFile("test-data/nvtop.json")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
gm := &GPUManager{
|
||||||
|
GpuDataMap: make(map[string]*system.GPUData),
|
||||||
|
}
|
||||||
|
valid := gm.parseNvtopData(input)
|
||||||
|
require.True(t, valid)
|
||||||
|
|
||||||
|
g0, ok := gm.GpuDataMap["n0"]
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, "NVIDIA GeForce RTX 3050 Ti Laptop GPU", g0.Name)
|
||||||
|
assert.Equal(t, 48.0, g0.Temperature)
|
||||||
|
assert.Equal(t, 5.0, g0.Usage)
|
||||||
|
assert.Equal(t, 13.0, g0.Power)
|
||||||
|
assert.Equal(t, bytesToMegabytes(349372416), g0.MemoryUsed)
|
||||||
|
assert.Equal(t, bytesToMegabytes(4294967296), g0.MemoryTotal)
|
||||||
|
assert.Equal(t, 1.0, g0.Count)
|
||||||
|
|
||||||
|
g1, ok := gm.GpuDataMap["n1"]
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, "AMD Radeon 680M", g1.Name)
|
||||||
|
assert.Equal(t, 48.0, g1.Temperature)
|
||||||
|
assert.Equal(t, 12.0, g1.Usage)
|
||||||
|
assert.Equal(t, 9.0, g1.Power)
|
||||||
|
assert.Equal(t, bytesToMegabytes(1213784064), g1.MemoryUsed)
|
||||||
|
assert.Equal(t, bytesToMegabytes(16929173504), g1.MemoryTotal)
|
||||||
|
assert.Equal(t, 1.0, g1.Count)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateNvtopSnapshotsKeepsDeviceAssociationWhenOrderChanges(t *testing.T) {
|
||||||
|
strPtr := func(s string) *string { return &s }
|
||||||
|
|
||||||
|
gm := &GPUManager{
|
||||||
|
GpuDataMap: make(map[string]*system.GPUData),
|
||||||
|
}
|
||||||
|
|
||||||
|
firstBatch := []nvtopSnapshot{
|
||||||
|
{
|
||||||
|
DeviceName: "NVIDIA GeForce RTX 3050 Ti Laptop GPU",
|
||||||
|
GpuUtil: strPtr("20%"),
|
||||||
|
PowerDraw: strPtr("10W"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
DeviceName: "AMD Radeon 680M",
|
||||||
|
GpuUtil: strPtr("30%"),
|
||||||
|
PowerDraw: strPtr("20W"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
secondBatchSwapped := []nvtopSnapshot{
|
||||||
|
{
|
||||||
|
DeviceName: "AMD Radeon 680M",
|
||||||
|
GpuUtil: strPtr("40%"),
|
||||||
|
PowerDraw: strPtr("25W"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
DeviceName: "NVIDIA GeForce RTX 3050 Ti Laptop GPU",
|
||||||
|
GpuUtil: strPtr("50%"),
|
||||||
|
PowerDraw: strPtr("15W"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
require.True(t, gm.updateNvtopSnapshots(firstBatch))
|
||||||
|
require.True(t, gm.updateNvtopSnapshots(secondBatchSwapped))
|
||||||
|
|
||||||
|
nvidia := gm.GpuDataMap["n0"]
|
||||||
|
require.NotNil(t, nvidia)
|
||||||
|
assert.Equal(t, "NVIDIA GeForce RTX 3050 Ti Laptop GPU", nvidia.Name)
|
||||||
|
assert.Equal(t, 70.0, nvidia.Usage)
|
||||||
|
assert.Equal(t, 25.0, nvidia.Power)
|
||||||
|
assert.Equal(t, 2.0, nvidia.Count)
|
||||||
|
|
||||||
|
amd := gm.GpuDataMap["n1"]
|
||||||
|
require.NotNil(t, amd)
|
||||||
|
assert.Equal(t, "AMD Radeon 680M", amd.Name)
|
||||||
|
assert.Equal(t, 70.0, amd.Usage)
|
||||||
|
assert.Equal(t, 45.0, amd.Power)
|
||||||
|
assert.Equal(t, 2.0, amd.Count)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseCollectorPriority(t *testing.T) {
|
||||||
|
got := parseCollectorPriority(" nvml, nvidia-smi, intel_gpu_top, amd_sysfs, nvtop, rocm-smi, bad ")
|
||||||
|
want := []collectorSource{
|
||||||
|
collectorSourceNVML,
|
||||||
|
collectorSourceNvidiaSMI,
|
||||||
|
collectorSourceIntelGpuTop,
|
||||||
|
collectorSourceAmdSysfs,
|
||||||
|
collectorSourceNVTop,
|
||||||
|
collectorSourceRocmSMI,
|
||||||
|
}
|
||||||
|
assert.Equal(t, want, got)
|
||||||
|
}
|
||||||
|
|
||||||
func TestParseJetsonData(t *testing.T) {
|
func TestParseJetsonData(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -307,6 +400,19 @@ func TestParseJetsonData(t *testing.T) {
|
|||||||
Count: 1,
|
Count: 1,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "orin-style output with GPU@ temp and VDD_SYS_GPU power",
|
||||||
|
input: "RAM 3276/7859MB (lfb 5x4MB) SWAP 1626/12122MB (cached 181MB) CPU [44%@1421,49%@2031,67%@2034,17%@1420,25%@1419,8%@1420] EMC_FREQ 1%@1866 GR3D_FREQ 0%@114 APE 150 MTS fg 1% bg 1% PLL@42.5C MCPU@42.5C PMIC@50C Tboard@38C GPU@39.5C BCPU@42.5C thermal@41.3C Tdiode@39.25C VDD_SYS_GPU 182/182 VDD_SYS_SOC 730/730 VDD_4V0_WIFI 0/0 VDD_IN 5297/5297 VDD_SYS_CPU 1917/1917 VDD_SYS_DDR 1241/1241",
|
||||||
|
wantMetrics: &system.GPUData{
|
||||||
|
Name: "GPU",
|
||||||
|
MemoryUsed: 3276.0,
|
||||||
|
MemoryTotal: 7859.0,
|
||||||
|
Usage: 0.0,
|
||||||
|
Power: 0.182, // 182mW -> 0.182W
|
||||||
|
Temperature: 39.5,
|
||||||
|
Count: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
@@ -825,7 +931,7 @@ func TestInitializeSnapshots(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCalculateGPUAverage(t *testing.T) {
|
func TestCalculateGPUAverage(t *testing.T) {
|
||||||
t.Run("returns zero value when deltaCount is zero", func(t *testing.T) {
|
t.Run("returns cached average when deltaCount is zero", func(t *testing.T) {
|
||||||
gm := &GPUManager{
|
gm := &GPUManager{
|
||||||
lastSnapshots: map[uint16]map[string]*gpuSnapshot{
|
lastSnapshots: map[uint16]map[string]*gpuSnapshot{
|
||||||
5000: {
|
5000: {
|
||||||
@@ -838,9 +944,10 @@ func TestCalculateGPUAverage(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
gpu := &system.GPUData{
|
gpu := &system.GPUData{
|
||||||
Count: 10.0, // Same as snapshot, so delta = 0
|
Count: 10.0, // Same as snapshot, so delta = 0
|
||||||
Usage: 100.0,
|
Usage: 100.0,
|
||||||
Power: 200.0,
|
Power: 200.0,
|
||||||
|
Temperature: 50.0, // Non-zero to avoid "suspended" check
|
||||||
}
|
}
|
||||||
|
|
||||||
result := gm.calculateGPUAverage("0", gpu, 5000)
|
result := gm.calculateGPUAverage("0", gpu, 5000)
|
||||||
@@ -849,6 +956,31 @@ func TestCalculateGPUAverage(t *testing.T) {
|
|||||||
assert.Equal(t, 100.0, result.Power, "Should return cached average")
|
assert.Equal(t, 100.0, result.Power, "Should return cached average")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("returns zero value when GPU is suspended", func(t *testing.T) {
|
||||||
|
gm := &GPUManager{
|
||||||
|
lastSnapshots: map[uint16]map[string]*gpuSnapshot{
|
||||||
|
5000: {
|
||||||
|
"0": {count: 10, usage: 100, power: 200},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
lastAvgData: map[string]system.GPUData{
|
||||||
|
"0": {Usage: 50.0, Power: 100.0},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
gpu := &system.GPUData{
|
||||||
|
Name: "Test GPU",
|
||||||
|
Count: 10.0,
|
||||||
|
Temperature: 0,
|
||||||
|
MemoryUsed: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
result := gm.calculateGPUAverage("0", gpu, 5000)
|
||||||
|
|
||||||
|
assert.Equal(t, 0.0, result.Usage, "Should return zero usage")
|
||||||
|
assert.Equal(t, 0.0, result.Power, "Should return zero power")
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("calculates average for standard GPU", func(t *testing.T) {
|
t.Run("calculates average for standard GPU", func(t *testing.T) {
|
||||||
gm := &GPUManager{
|
gm := &GPUManager{
|
||||||
lastSnapshots: map[uint16]map[string]*gpuSnapshot{
|
lastSnapshots: map[uint16]map[string]*gpuSnapshot{
|
||||||
@@ -948,36 +1080,35 @@ func TestCalculateGPUAverage(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDetectGPUs(t *testing.T) {
|
func TestGPUCapabilitiesAndLegacyPriority(t *testing.T) {
|
||||||
// Save original PATH
|
// Save original PATH
|
||||||
origPath := os.Getenv("PATH")
|
origPath := os.Getenv("PATH")
|
||||||
defer os.Setenv("PATH", origPath)
|
defer os.Setenv("PATH", origPath)
|
||||||
|
hasAmdSysfs := (&GPUManager{}).hasAmdSysfs()
|
||||||
// Set up temp dir with the commands
|
|
||||||
tempDir := t.TempDir()
|
|
||||||
os.Setenv("PATH", tempDir)
|
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
setupCommands func() error
|
setupCommands func(string) error
|
||||||
wantNvidiaSmi bool
|
wantNvidiaSmi bool
|
||||||
wantRocmSmi bool
|
wantRocmSmi bool
|
||||||
wantTegrastats bool
|
wantTegrastats bool
|
||||||
|
wantNvtop bool
|
||||||
wantErr bool
|
wantErr bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "nvidia-smi not available",
|
name: "nvidia-smi not available",
|
||||||
setupCommands: func() error {
|
setupCommands: func(_ string) error {
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
wantNvidiaSmi: false,
|
wantNvidiaSmi: false,
|
||||||
wantRocmSmi: false,
|
wantRocmSmi: false,
|
||||||
wantTegrastats: false,
|
wantTegrastats: false,
|
||||||
|
wantNvtop: false,
|
||||||
wantErr: true,
|
wantErr: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "nvidia-smi available",
|
name: "nvidia-smi available",
|
||||||
setupCommands: func() error {
|
setupCommands: func(tempDir string) error {
|
||||||
path := filepath.Join(tempDir, "nvidia-smi")
|
path := filepath.Join(tempDir, "nvidia-smi")
|
||||||
script := `#!/bin/sh
|
script := `#!/bin/sh
|
||||||
echo "test"`
|
echo "test"`
|
||||||
@@ -989,29 +1120,14 @@ echo "test"`
|
|||||||
wantNvidiaSmi: true,
|
wantNvidiaSmi: true,
|
||||||
wantTegrastats: false,
|
wantTegrastats: false,
|
||||||
wantRocmSmi: false,
|
wantRocmSmi: false,
|
||||||
|
wantNvtop: false,
|
||||||
wantErr: false,
|
wantErr: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "rocm-smi available",
|
name: "rocm-smi available",
|
||||||
setupCommands: func() error {
|
setupCommands: func(tempDir string) error {
|
||||||
path := filepath.Join(tempDir, "rocm-smi")
|
path := filepath.Join(tempDir, "rocm-smi")
|
||||||
script := `#!/bin/sh
|
script := `#!/bin/sh
|
||||||
echo "test"`
|
|
||||||
if err := os.WriteFile(path, []byte(script), 0755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
wantNvidiaSmi: true,
|
|
||||||
wantRocmSmi: true,
|
|
||||||
wantTegrastats: false,
|
|
||||||
wantErr: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "tegrastats available",
|
|
||||||
setupCommands: func() error {
|
|
||||||
path := filepath.Join(tempDir, "tegrastats")
|
|
||||||
script := `#!/bin/sh
|
|
||||||
echo "test"`
|
echo "test"`
|
||||||
if err := os.WriteFile(path, []byte(script), 0755); err != nil {
|
if err := os.WriteFile(path, []byte(script), 0755); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -1020,12 +1136,47 @@ echo "test"`
|
|||||||
},
|
},
|
||||||
wantNvidiaSmi: false,
|
wantNvidiaSmi: false,
|
||||||
wantRocmSmi: true,
|
wantRocmSmi: true,
|
||||||
|
wantTegrastats: false,
|
||||||
|
wantNvtop: false,
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tegrastats available",
|
||||||
|
setupCommands: func(tempDir string) error {
|
||||||
|
path := filepath.Join(tempDir, "tegrastats")
|
||||||
|
script := `#!/bin/sh
|
||||||
|
echo "test"`
|
||||||
|
if err := os.WriteFile(path, []byte(script), 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
wantNvidiaSmi: false,
|
||||||
|
wantRocmSmi: false,
|
||||||
wantTegrastats: true,
|
wantTegrastats: true,
|
||||||
|
wantNvtop: false,
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nvtop available",
|
||||||
|
setupCommands: func(tempDir string) error {
|
||||||
|
path := filepath.Join(tempDir, "nvtop")
|
||||||
|
script := `#!/bin/sh
|
||||||
|
echo "[]"`
|
||||||
|
if err := os.WriteFile(path, []byte(script), 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
wantNvidiaSmi: false,
|
||||||
|
wantRocmSmi: false,
|
||||||
|
wantTegrastats: false,
|
||||||
|
wantNvtop: true,
|
||||||
wantErr: false,
|
wantErr: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "no gpu tools available",
|
name: "no gpu tools available",
|
||||||
setupCommands: func() error {
|
setupCommands: func(_ string) error {
|
||||||
os.Setenv("PATH", "")
|
os.Setenv("PATH", "")
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
@@ -1035,29 +1186,53 @@ echo "test"`
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
if err := tt.setupCommands(); err != nil {
|
tempDir := t.TempDir()
|
||||||
|
os.Setenv("PATH", tempDir)
|
||||||
|
if err := tt.setupCommands(tempDir); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
gm := &GPUManager{}
|
gm := &GPUManager{}
|
||||||
err := gm.detectGPUs()
|
caps := gm.discoverGpuCapabilities()
|
||||||
|
var err error
|
||||||
|
if !hasAnyGpuCollector(caps) {
|
||||||
|
err = fmt.Errorf(noGPUFoundMsg)
|
||||||
|
}
|
||||||
|
priorities := gm.resolveLegacyCollectorPriority(caps)
|
||||||
|
hasPriority := func(source collectorSource) bool {
|
||||||
|
for _, s := range priorities {
|
||||||
|
if s == source {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
gotNvidiaSmi := hasPriority(collectorSourceNvidiaSMI)
|
||||||
|
gotRocmSmi := hasPriority(collectorSourceRocmSMI)
|
||||||
|
gotTegrastats := caps.hasTegrastats
|
||||||
|
gotNvtop := caps.hasNvtop
|
||||||
|
|
||||||
t.Logf("nvidiaSmi: %v, rocmSmi: %v, tegrastats: %v", gm.nvidiaSmi, gm.rocmSmi, gm.tegrastats)
|
t.Logf("nvidiaSmi: %v, rocmSmi: %v, tegrastats: %v", gotNvidiaSmi, gotRocmSmi, gotTegrastats)
|
||||||
|
|
||||||
if tt.wantErr {
|
wantErr := tt.wantErr
|
||||||
|
if hasAmdSysfs && (tt.name == "nvidia-smi not available" || tt.name == "no gpu tools available") {
|
||||||
|
wantErr = false
|
||||||
|
}
|
||||||
|
if wantErr {
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, tt.wantNvidiaSmi, gm.nvidiaSmi)
|
assert.Equal(t, tt.wantNvidiaSmi, gotNvidiaSmi)
|
||||||
assert.Equal(t, tt.wantRocmSmi, gm.rocmSmi)
|
assert.Equal(t, tt.wantRocmSmi, gotRocmSmi)
|
||||||
assert.Equal(t, tt.wantTegrastats, gm.tegrastats)
|
assert.Equal(t, tt.wantTegrastats, gotTegrastats)
|
||||||
|
assert.Equal(t, tt.wantNvtop, gotNvtop)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestStartCollector(t *testing.T) {
|
func TestCollectorStartHelpers(t *testing.T) {
|
||||||
// Save original PATH
|
// Save original PATH
|
||||||
origPath := os.Getenv("PATH")
|
origPath := os.Getenv("PATH")
|
||||||
defer os.Setenv("PATH", origPath)
|
defer os.Setenv("PATH", origPath)
|
||||||
@@ -1142,6 +1317,27 @@ echo "11-14-2024 22:54:33 RAM 1024/4096MB GR3D_FREQ 80% tj@70C VDD_GPU_SOC 1000m
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "nvtop collector",
|
||||||
|
command: "nvtop",
|
||||||
|
setup: func(t *testing.T) error {
|
||||||
|
path := filepath.Join(dir, "nvtop")
|
||||||
|
script := `#!/bin/sh
|
||||||
|
echo '[{"device_name":"NVIDIA Test GPU","temp":"52C","power_draw":"31W","gpu_util":"37%","mem_total":"4294967296","mem_used":"536870912","processes":[]}]'`
|
||||||
|
if err := os.WriteFile(path, []byte(script), 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
validate: func(t *testing.T, gm *GPUManager) {
|
||||||
|
gpu, exists := gm.GpuDataMap["n0"]
|
||||||
|
assert.True(t, exists)
|
||||||
|
if exists {
|
||||||
|
assert.Equal(t, "NVIDIA Test GPU", gpu.Name)
|
||||||
|
assert.Equal(t, 52.0, gpu.Temperature)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
@@ -1154,13 +1350,157 @@ echo "11-14-2024 22:54:33 RAM 1024/4096MB GR3D_FREQ 80% tj@70C VDD_GPU_SOC 1000m
|
|||||||
GpuDataMap: make(map[string]*system.GPUData),
|
GpuDataMap: make(map[string]*system.GPUData),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tt.gm.startCollector(tt.command)
|
switch tt.command {
|
||||||
|
case nvidiaSmiCmd:
|
||||||
|
tt.gm.startNvidiaSmiCollector("4")
|
||||||
|
case rocmSmiCmd:
|
||||||
|
tt.gm.startRocmSmiCollector(4300 * time.Millisecond)
|
||||||
|
case tegraStatsCmd:
|
||||||
|
tt.gm.startTegraStatsCollector("3700")
|
||||||
|
case nvtopCmd:
|
||||||
|
tt.gm.startNvtopCollector("30", nil)
|
||||||
|
default:
|
||||||
|
t.Fatalf("unknown test command %q", tt.command)
|
||||||
|
}
|
||||||
time.Sleep(50 * time.Millisecond) // Give collector time to run
|
time.Sleep(50 * time.Millisecond) // Give collector time to run
|
||||||
tt.validate(t, tt.gm)
|
tt.validate(t, tt.gm)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNewGPUManagerPriorityNvtopFallback(t *testing.T) {
|
||||||
|
origPath := os.Getenv("PATH")
|
||||||
|
defer os.Setenv("PATH", origPath)
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
os.Setenv("PATH", dir)
|
||||||
|
t.Setenv("BESZEL_AGENT_GPU_COLLECTOR", "nvtop,nvidia-smi")
|
||||||
|
|
||||||
|
nvtopPath := filepath.Join(dir, "nvtop")
|
||||||
|
nvtopScript := `#!/bin/sh
|
||||||
|
echo 'not-json'`
|
||||||
|
require.NoError(t, os.WriteFile(nvtopPath, []byte(nvtopScript), 0755))
|
||||||
|
|
||||||
|
nvidiaPath := filepath.Join(dir, "nvidia-smi")
|
||||||
|
nvidiaScript := `#!/bin/sh
|
||||||
|
echo "0, NVIDIA Priority GPU, 45, 512, 2048, 12, 25"`
|
||||||
|
require.NoError(t, os.WriteFile(nvidiaPath, []byte(nvidiaScript), 0755))
|
||||||
|
|
||||||
|
gm, err := NewGPUManager()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, gm)
|
||||||
|
|
||||||
|
time.Sleep(150 * time.Millisecond)
|
||||||
|
gpu, ok := gm.GpuDataMap["0"]
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, "Priority GPU", gpu.Name)
|
||||||
|
assert.Equal(t, 45.0, gpu.Temperature)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewGPUManagerPriorityMixedCollectors(t *testing.T) {
|
||||||
|
origPath := os.Getenv("PATH")
|
||||||
|
defer os.Setenv("PATH", origPath)
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
os.Setenv("PATH", dir)
|
||||||
|
t.Setenv("BESZEL_AGENT_GPU_COLLECTOR", "intel_gpu_top,rocm-smi")
|
||||||
|
|
||||||
|
intelPath := filepath.Join(dir, "intel_gpu_top")
|
||||||
|
intelScript := `#!/bin/sh
|
||||||
|
echo "Freq MHz IRQ RC6 Power W IMC MiB/s RCS VCS"
|
||||||
|
echo " req act /s % gpu pkg rd wr % se wa % se wa"
|
||||||
|
echo "226 223 338 58 2.00 2.69 1820 965 0.00 0 0 0.00 0 0"
|
||||||
|
echo "189 187 412 67 1.80 2.45 1950 823 8.50 2 1 15.00 1 0"
|
||||||
|
`
|
||||||
|
require.NoError(t, os.WriteFile(intelPath, []byte(intelScript), 0755))
|
||||||
|
|
||||||
|
rocmPath := filepath.Join(dir, "rocm-smi")
|
||||||
|
rocmScript := `#!/bin/sh
|
||||||
|
echo '{"card0": {"Temperature (Sensor edge) (C)": "49.0", "Current Socket Graphics Package Power (W)": "28.159", "GPU use (%)": "0", "VRAM Total Memory (B)": "536870912", "VRAM Total Used Memory (B)": "445550592", "Card Series": "Rembrandt [Radeon 680M]", "GUID": "34756"}}'
|
||||||
|
`
|
||||||
|
require.NoError(t, os.WriteFile(rocmPath, []byte(rocmScript), 0755))
|
||||||
|
|
||||||
|
gm, err := NewGPUManager()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, gm)
|
||||||
|
|
||||||
|
time.Sleep(150 * time.Millisecond)
|
||||||
|
_, intelOk := gm.GpuDataMap["i0"]
|
||||||
|
_, amdOk := gm.GpuDataMap["34756"]
|
||||||
|
assert.True(t, intelOk)
|
||||||
|
assert.True(t, amdOk)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewGPUManagerPriorityNvmlFallbackToNvidiaSmi(t *testing.T) {
|
||||||
|
origPath := os.Getenv("PATH")
|
||||||
|
defer os.Setenv("PATH", origPath)
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
os.Setenv("PATH", dir)
|
||||||
|
t.Setenv("BESZEL_AGENT_GPU_COLLECTOR", "nvml,nvidia-smi")
|
||||||
|
|
||||||
|
nvidiaPath := filepath.Join(dir, "nvidia-smi")
|
||||||
|
nvidiaScript := `#!/bin/sh
|
||||||
|
echo "0, NVIDIA Fallback GPU, 41, 256, 1024, 8, 14"`
|
||||||
|
require.NoError(t, os.WriteFile(nvidiaPath, []byte(nvidiaScript), 0755))
|
||||||
|
|
||||||
|
gm, err := NewGPUManager()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, gm)
|
||||||
|
|
||||||
|
time.Sleep(150 * time.Millisecond)
|
||||||
|
gpu, ok := gm.GpuDataMap["0"]
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, "Fallback GPU", gpu.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewGPUManagerConfiguredCollectorsMustStart(t *testing.T) {
|
||||||
|
origPath := os.Getenv("PATH")
|
||||||
|
defer os.Setenv("PATH", origPath)
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
os.Setenv("PATH", dir)
|
||||||
|
|
||||||
|
t.Run("configured valid collector unavailable", func(t *testing.T) {
|
||||||
|
t.Setenv("BESZEL_AGENT_GPU_COLLECTOR", "nvidia-smi")
|
||||||
|
gm, err := NewGPUManager()
|
||||||
|
require.Nil(t, gm)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "no configured GPU collectors are available")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("configured collector list has only unknown entries", func(t *testing.T) {
|
||||||
|
t.Setenv("BESZEL_AGENT_GPU_COLLECTOR", "bad,unknown")
|
||||||
|
gm, err := NewGPUManager()
|
||||||
|
require.Nil(t, gm)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "no configured GPU collectors are available")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewGPUManagerJetsonIgnoresCollectorConfig(t *testing.T) {
|
||||||
|
origPath := os.Getenv("PATH")
|
||||||
|
defer os.Setenv("PATH", origPath)
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
os.Setenv("PATH", dir)
|
||||||
|
t.Setenv("BESZEL_AGENT_GPU_COLLECTOR", "nvidia-smi")
|
||||||
|
|
||||||
|
tegraPath := filepath.Join(dir, "tegrastats")
|
||||||
|
tegraScript := `#!/bin/sh
|
||||||
|
echo "11-14-2024 22:54:33 RAM 1024/4096MB GR3D_FREQ 80% tj@70C VDD_GPU_SOC 1000mW"`
|
||||||
|
require.NoError(t, os.WriteFile(tegraPath, []byte(tegraScript), 0755))
|
||||||
|
|
||||||
|
gm, err := NewGPUManager()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, gm)
|
||||||
|
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
gpu, ok := gm.GpuDataMap["0"]
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, "GPU", gpu.Name)
|
||||||
|
}
|
||||||
|
|
||||||
// TestAccumulationTableDriven tests the accumulation behavior for all three GPU types
|
// TestAccumulationTableDriven tests the accumulation behavior for all three GPU types
|
||||||
func TestAccumulation(t *testing.T) {
|
func TestAccumulation(t *testing.T) {
|
||||||
type expectedGPUValues struct {
|
type expectedGPUValues struct {
|
||||||
@@ -1346,7 +1686,7 @@ func TestIntelUpdateFromStats(t *testing.T) {
|
|||||||
ok := gm.updateIntelFromStats(&sample1)
|
ok := gm.updateIntelFromStats(&sample1)
|
||||||
assert.True(t, ok)
|
assert.True(t, ok)
|
||||||
|
|
||||||
gpu := gm.GpuDataMap["0"]
|
gpu := gm.GpuDataMap["i0"]
|
||||||
require.NotNil(t, gpu)
|
require.NotNil(t, gpu)
|
||||||
assert.Equal(t, "GPU", gpu.Name)
|
assert.Equal(t, "GPU", gpu.Name)
|
||||||
assert.EqualValues(t, 10.5, gpu.Power)
|
assert.EqualValues(t, 10.5, gpu.Power)
|
||||||
@@ -1368,7 +1708,7 @@ func TestIntelUpdateFromStats(t *testing.T) {
|
|||||||
ok = gm.updateIntelFromStats(&sample2)
|
ok = gm.updateIntelFromStats(&sample2)
|
||||||
assert.True(t, ok)
|
assert.True(t, ok)
|
||||||
|
|
||||||
gpu = gm.GpuDataMap["0"]
|
gpu = gm.GpuDataMap["i0"]
|
||||||
require.NotNil(t, gpu)
|
require.NotNil(t, gpu)
|
||||||
assert.EqualValues(t, 10.5, gpu.Power)
|
assert.EqualValues(t, 10.5, gpu.Power)
|
||||||
assert.EqualValues(t, 30.0, gpu.Engines["Render/3D"]) // 20 + 10
|
assert.EqualValues(t, 30.0, gpu.Engines["Render/3D"]) // 20 + 10
|
||||||
@@ -1407,7 +1747,7 @@ echo "298 295 278 51 2.20 3.12 1675 942 5.75 1 2 9.50
|
|||||||
t.Fatalf("collectIntelStats error: %v", err)
|
t.Fatalf("collectIntelStats error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
gpu := gm.GpuDataMap["0"]
|
gpu := gm.GpuDataMap["i0"]
|
||||||
require.NotNil(t, gpu)
|
require.NotNil(t, gpu)
|
||||||
// Power should be sum of samples 2-4 (first is skipped): 2.0 + 1.8 + 2.2 = 6.0
|
// Power should be sum of samples 2-4 (first is skipped): 2.0 + 1.8 + 2.2 = 6.0
|
||||||
assert.EqualValues(t, 6.0, gpu.Power)
|
assert.EqualValues(t, 6.0, gpu.Power)
|
||||||
@@ -1439,6 +1779,15 @@ func TestParseIntelHeaders(t *testing.T) {
|
|||||||
wantPowerIndex: 4, // "gpu" is at index 4
|
wantPowerIndex: 4, // "gpu" is at index 4
|
||||||
wantPreEngineCols: 8, // 17 total cols - 3*3 = 8
|
wantPreEngineCols: 8, // 17 total cols - 3*3 = 8
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "basic headers with RCS BCS VCS using index in name",
|
||||||
|
header1: "Freq MHz IRQ RC6 Power W IMC MiB/s RCS/0 BCS/1 VCS/2",
|
||||||
|
header2: " req act /s % gpu pkg rd wr % se wa % se wa % se wa",
|
||||||
|
wantEngineNames: []string{"RCS", "BCS", "VCS"},
|
||||||
|
wantFriendlyNames: []string{"Render/3D", "Blitter", "Video"},
|
||||||
|
wantPowerIndex: 4, // "gpu" is at index 4
|
||||||
|
wantPreEngineCols: 8, // 17 total cols - 3*3 = 8
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "headers with only RCS",
|
name: "headers with only RCS",
|
||||||
header1: "Freq MHz IRQ RC6 Power W IMC MiB/s RCS",
|
header1: "Freq MHz IRQ RC6 Power W IMC MiB/s RCS",
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"github.com/henrygd/beszel/internal/common"
|
"github.com/henrygd/beszel/internal/common"
|
||||||
"github.com/henrygd/beszel/internal/entities/smart"
|
"github.com/henrygd/beszel/internal/entities/smart"
|
||||||
|
|
||||||
"golang.org/x/exp/slog"
|
"log/slog"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HandlerContext provides context for request handlers
|
// HandlerContext provides context for request handlers
|
||||||
@@ -50,6 +50,7 @@ func NewHandlerRegistry() *HandlerRegistry {
|
|||||||
registry.Register(common.GetContainerLogs, &GetContainerLogsHandler{})
|
registry.Register(common.GetContainerLogs, &GetContainerLogsHandler{})
|
||||||
registry.Register(common.GetContainerInfo, &GetContainerInfoHandler{})
|
registry.Register(common.GetContainerInfo, &GetContainerInfoHandler{})
|
||||||
registry.Register(common.GetSmartData, &GetSmartDataHandler{})
|
registry.Register(common.GetSmartData, &GetSmartDataHandler{})
|
||||||
|
registry.Register(common.GetSystemdInfo, &GetSystemdInfoHandler{})
|
||||||
|
|
||||||
return registry
|
return registry
|
||||||
}
|
}
|
||||||
@@ -93,7 +94,7 @@ func (h *GetDataHandler) Handle(hctx *HandlerContext) error {
|
|||||||
var options common.DataRequestOptions
|
var options common.DataRequestOptions
|
||||||
_ = cbor.Unmarshal(hctx.Request.Data, &options)
|
_ = cbor.Unmarshal(hctx.Request.Data, &options)
|
||||||
|
|
||||||
sysStats := hctx.Agent.gatherStats(options.CacheTimeMs)
|
sysStats := hctx.Agent.gatherStats(options)
|
||||||
return hctx.SendResponse(sysStats, hctx.RequestID)
|
return hctx.SendResponse(sysStats, hctx.RequestID)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,3 +175,31 @@ func (h *GetSmartDataHandler) Handle(hctx *HandlerContext) error {
|
|||||||
data := hctx.Agent.smartManager.GetCurrentData()
|
data := hctx.Agent.smartManager.GetCurrentData()
|
||||||
return hctx.SendResponse(data, hctx.RequestID)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
//go:build testing
|
//go:build testing
|
||||||
// +build testing
|
|
||||||
|
|
||||||
package agent
|
package agent
|
||||||
|
|
||||||
|
|||||||
@@ -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.5" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
225
agent/mdraid_linux.go
Normal file
225
agent/mdraid_linux.go
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"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 !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 := readStringFileOK(filepath.Join(mdDir, "array_state"))
|
||||||
|
if !okState {
|
||||||
|
return out, false
|
||||||
|
}
|
||||||
|
|
||||||
|
out.arrayState = arrayState
|
||||||
|
out.level = readStringFile(filepath.Join(mdDir, "level"))
|
||||||
|
out.syncAction = readStringFile(filepath.Join(mdDir, "sync_action"))
|
||||||
|
out.syncCompleted = readStringFile(filepath.Join(mdDir, "sync_completed"))
|
||||||
|
out.syncSpeed = readStringFile(filepath.Join(mdDir, "sync_speed"))
|
||||||
|
|
||||||
|
if val, ok := readUintFile(filepath.Join(mdDir, "raid_disks")); ok {
|
||||||
|
out.raidDisks = val
|
||||||
|
}
|
||||||
|
if val, ok := readUintFile(filepath.Join(mdDir, "degraded")); ok {
|
||||||
|
out.degraded = val
|
||||||
|
}
|
||||||
|
if val, ok := 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"
|
||||||
|
}
|
||||||
|
if health.degraded > 0 {
|
||||||
|
return "FAILED"
|
||||||
|
}
|
||||||
|
switch strings.ToLower(strings.TrimSpace(health.syncAction)) {
|
||||||
|
case "resync", "recover", "reshape", "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 := 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 := readStringFileOK(lbsPath); ok {
|
||||||
|
if parsed, err := strconv.ParseUint(lbsStr, 10, 64); err == nil && parsed > 0 {
|
||||||
|
logicalBlockSize = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sectors * logicalBlockSize, true
|
||||||
|
}
|
||||||
100
agent/mdraid_linux_test.go
Normal file
100
agent/mdraid_linux_test.go
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
//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}); 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
|
||||||
|
}
|
||||||
177
agent/pve.go
Normal file
177
agent/pve.go
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/entities/container"
|
||||||
|
|
||||||
|
"github.com/luthermonson/go-proxmox"
|
||||||
|
)
|
||||||
|
|
||||||
|
type pveManager struct {
|
||||||
|
client *proxmox.Client // Client to query PVE API
|
||||||
|
nodeName string // Cluster node name
|
||||||
|
cpuCount int // CPU count on node
|
||||||
|
nodeStatsMap map[string]*container.PveNodeStats // Keeps track of pve node stats
|
||||||
|
lastInitTry time.Time // Last time node initialization was attempted
|
||||||
|
}
|
||||||
|
|
||||||
|
// newPVEManager creates a new PVE manager - may return nil if required environment variables
|
||||||
|
// are not set or if there is an error connecting to the API
|
||||||
|
func newPVEManager() *pveManager {
|
||||||
|
url, exists := GetEnv("PROXMOX_URL")
|
||||||
|
if !exists {
|
||||||
|
url = "https://localhost:8006/api2/json"
|
||||||
|
}
|
||||||
|
const nodeEnvVar = "PROXMOX_NODE"
|
||||||
|
const tokenIDEnvVar = "PROXMOX_TOKENID"
|
||||||
|
const secretEnvVar = "PROXMOX_SECRET"
|
||||||
|
|
||||||
|
nodeName, nodeNameExists := GetEnv(nodeEnvVar)
|
||||||
|
tokenID, tokenIDExists := GetEnv(tokenIDEnvVar)
|
||||||
|
secret, secretExists := GetEnv(secretEnvVar)
|
||||||
|
|
||||||
|
if !nodeNameExists || !tokenIDExists || !secretExists {
|
||||||
|
slog.Debug("Proxmox env vars unset", nodeEnvVar, nodeNameExists, tokenIDEnvVar, tokenIDExists, secretEnvVar, secretExists)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PROXMOX_INSECURE_TLS defaults to true; set to "false" to enable TLS verification
|
||||||
|
insecureTLS := true
|
||||||
|
if val, exists := GetEnv("PROXMOX_INSECURE_TLS"); exists {
|
||||||
|
insecureTLS = val != "false"
|
||||||
|
}
|
||||||
|
|
||||||
|
httpClient := http.Client{
|
||||||
|
Transport: &http.Transport{
|
||||||
|
TLSClientConfig: &tls.Config{
|
||||||
|
InsecureSkipVerify: insecureTLS,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
client := proxmox.NewClient(url,
|
||||||
|
proxmox.WithHTTPClient(&httpClient),
|
||||||
|
proxmox.WithAPIToken(tokenID, secret),
|
||||||
|
)
|
||||||
|
|
||||||
|
pveManager := pveManager{
|
||||||
|
client: client,
|
||||||
|
nodeName: nodeName,
|
||||||
|
nodeStatsMap: make(map[string]*container.PveNodeStats),
|
||||||
|
}
|
||||||
|
|
||||||
|
return &pveManager
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureInitialized checks if the PVE manager is initialized and attempts to initialize it if not.
|
||||||
|
// It returns an error if initialization fails or if a retry is pending.
|
||||||
|
func (pm *pveManager) ensureInitialized(ctx context.Context) error {
|
||||||
|
if pm.client == nil {
|
||||||
|
return errors.New("PVE client not configured")
|
||||||
|
}
|
||||||
|
if pm.cpuCount > 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if time.Since(pm.lastInitTry) < 30*time.Second {
|
||||||
|
return errors.New("PVE initialization retry pending")
|
||||||
|
}
|
||||||
|
pm.lastInitTry = time.Now()
|
||||||
|
|
||||||
|
node, err := pm.client.Node(ctx, pm.nodeName)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if node.CPUInfo.CPUs <= 0 {
|
||||||
|
return errors.New("node returned zero CPUs")
|
||||||
|
}
|
||||||
|
|
||||||
|
pm.cpuCount = node.CPUInfo.CPUs
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getPVEStats returns stats for all running VMs/LXCs
|
||||||
|
func (pm *pveManager) getPVEStats() ([]*container.PveNodeStats, error) {
|
||||||
|
if err := pm.ensureInitialized(context.Background()); err != nil {
|
||||||
|
slog.Warn("Proxmox API unavailable", "err", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cluster, err := pm.client.Cluster(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Error getting cluster", "err", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resources, err := cluster.Resources(context.Background(), "vm")
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Error getting resources", "err", err, "resources", resources)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
containersLength := len(resources)
|
||||||
|
resourceIds := make(map[string]struct{}, containersLength)
|
||||||
|
|
||||||
|
// only include running vms and lxcs on selected node
|
||||||
|
for _, resource := range resources {
|
||||||
|
if resource.Node == pm.nodeName && resource.Status == "running" {
|
||||||
|
resourceIds[resource.ID] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// remove invalid container stats
|
||||||
|
for id := range pm.nodeStatsMap {
|
||||||
|
if _, exists := resourceIds[id]; !exists {
|
||||||
|
delete(pm.nodeStatsMap, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// populate stats
|
||||||
|
stats := make([]*container.PveNodeStats, 0, len(resourceIds))
|
||||||
|
for _, resource := range resources {
|
||||||
|
if _, exists := resourceIds[resource.ID]; !exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
resourceStats, initialized := pm.nodeStatsMap[resource.ID]
|
||||||
|
if !initialized {
|
||||||
|
resourceStats = &container.PveNodeStats{}
|
||||||
|
pm.nodeStatsMap[resource.ID] = resourceStats
|
||||||
|
}
|
||||||
|
resourceStats.Name = resource.Name
|
||||||
|
resourceStats.Id = resource.ID
|
||||||
|
resourceStats.Type = resource.Type
|
||||||
|
resourceStats.MaxCPU = resource.MaxCPU
|
||||||
|
resourceStats.MaxMem = resource.MaxMem
|
||||||
|
resourceStats.Uptime = resource.Uptime
|
||||||
|
resourceStats.DiskRead = resource.DiskRead
|
||||||
|
resourceStats.DiskWrite = resource.DiskWrite
|
||||||
|
resourceStats.Disk = resource.MaxDisk
|
||||||
|
|
||||||
|
// prevent first run from sending all prev sent/recv bytes
|
||||||
|
total_sent := resource.NetOut
|
||||||
|
total_recv := resource.NetIn
|
||||||
|
var sent_delta, recv_delta float64
|
||||||
|
if initialized {
|
||||||
|
secondsElapsed := time.Since(resourceStats.PrevReadTime).Seconds()
|
||||||
|
if secondsElapsed > 0 {
|
||||||
|
sent_delta = float64(total_sent-resourceStats.PrevNet.Sent) / secondsElapsed
|
||||||
|
recv_delta = float64(total_recv-resourceStats.PrevNet.Recv) / secondsElapsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resourceStats.PrevNet.Sent = total_sent
|
||||||
|
resourceStats.PrevNet.Recv = total_recv
|
||||||
|
resourceStats.PrevReadTime = time.Now()
|
||||||
|
|
||||||
|
// Update final stats values
|
||||||
|
resourceStats.Cpu = twoDecimals(100.0 * resource.CPU * float64(resource.MaxCPU) / float64(pm.cpuCount))
|
||||||
|
resourceStats.Mem = bytesToMegabytes(float64(resource.Mem))
|
||||||
|
resourceStats.Bandwidth = [2]uint64{uint64(sent_delta), uint64(recv_delta)}
|
||||||
|
resourceStats.NetOut = total_sent
|
||||||
|
resourceStats.NetIn = total_recv
|
||||||
|
|
||||||
|
stats = append(stats, resourceStats)
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
92
agent/pve_test.go
Normal file
92
agent/pve_test.go
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/entities/container"
|
||||||
|
"github.com/luthermonson/go-proxmox"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewPVEManagerDoesNotConnectAtStartup(t *testing.T) {
|
||||||
|
t.Setenv("BESZEL_AGENT_PROXMOX_URL", "https://127.0.0.1:1/api2/json")
|
||||||
|
t.Setenv("BESZEL_AGENT_PROXMOX_NODE", "pve")
|
||||||
|
t.Setenv("BESZEL_AGENT_PROXMOX_TOKENID", "root@pam!test")
|
||||||
|
t.Setenv("BESZEL_AGENT_PROXMOX_SECRET", "secret")
|
||||||
|
|
||||||
|
pm := newPVEManager()
|
||||||
|
require.NotNil(t, pm)
|
||||||
|
assert.Zero(t, pm.cpuCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPVEManagerRetriesInitialization(t *testing.T) {
|
||||||
|
var nodeRequests atomic.Int32
|
||||||
|
var clusterRequests atomic.Int32
|
||||||
|
|
||||||
|
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/api2/json/nodes/pve/status":
|
||||||
|
nodeRequests.Add(1)
|
||||||
|
fmt.Fprint(w, `{"data":{"cpuinfo":{"cpus":8}}}`)
|
||||||
|
case "/api2/json/cluster/status":
|
||||||
|
fmt.Fprint(w, `{"data":[{"type":"cluster","name":"test-cluster","id":"test-cluster","version":1,"quorate":1}]}`)
|
||||||
|
case "/api2/json/cluster/resources":
|
||||||
|
clusterRequests.Add(1)
|
||||||
|
fmt.Fprint(w, `{"data":[{"id":"qemu/101","type":"qemu","node":"pve","status":"running","name":"vm-101","cpu":0.5,"maxcpu":4,"maxmem":4096,"mem":2048,"netin":1024,"netout":2048,"diskread":10,"diskwrite":20,"maxdisk":8192,"uptime":60}]}`)
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
pm := &pveManager{
|
||||||
|
client: proxmox.NewClient(server.URL+"/api2/json",
|
||||||
|
proxmox.WithHTTPClient(&http.Client{
|
||||||
|
Transport: &failOnceRoundTripper{
|
||||||
|
base: server.Client().Transport,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
proxmox.WithAPIToken("root@pam!test", "secret"),
|
||||||
|
),
|
||||||
|
nodeName: "pve",
|
||||||
|
nodeStatsMap: make(map[string]*container.PveNodeStats),
|
||||||
|
}
|
||||||
|
|
||||||
|
stats, err := pm.getPVEStats()
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Nil(t, stats)
|
||||||
|
assert.Zero(t, pm.cpuCount)
|
||||||
|
|
||||||
|
pm.lastInitTry = time.Now().Add(-31 * time.Second)
|
||||||
|
stats, err = pm.getPVEStats()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, stats, 1)
|
||||||
|
assert.Equal(t, int32(1), nodeRequests.Load())
|
||||||
|
assert.Equal(t, int32(1), clusterRequests.Load())
|
||||||
|
assert.Equal(t, 8, pm.cpuCount)
|
||||||
|
assert.Equal(t, "qemu/101", stats[0].Id)
|
||||||
|
assert.Equal(t, 25.0, stats[0].Cpu)
|
||||||
|
assert.Equal(t, uint64(1024), stats[0].NetIn)
|
||||||
|
assert.Equal(t, uint64(2048), stats[0].NetOut)
|
||||||
|
}
|
||||||
|
|
||||||
|
type failOnceRoundTripper struct {
|
||||||
|
base http.RoundTripper
|
||||||
|
failed atomic.Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rt *failOnceRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
if req.URL.Path == "/api2/json/nodes/pve/status" && !rt.failed.Swap(true) {
|
||||||
|
return nil, errors.New("dial tcp 127.0.0.1:8006: connect: connection refused")
|
||||||
|
}
|
||||||
|
return rt.base.RoundTrip(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ http.RoundTripper = (*failOnceRoundTripper)(nil)
|
||||||
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
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
//go:build testing
|
//go:build testing
|
||||||
// +build testing
|
|
||||||
|
|
||||||
package agent
|
package agent
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import (
|
|||||||
|
|
||||||
"github.com/henrygd/beszel"
|
"github.com/henrygd/beszel"
|
||||||
"github.com/henrygd/beszel/internal/common"
|
"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/system"
|
||||||
|
|
||||||
"github.com/blang/semver"
|
"github.com/blang/semver"
|
||||||
@@ -37,6 +36,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, _ := 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")
|
||||||
}
|
}
|
||||||
@@ -164,18 +166,9 @@ func (a *Agent) handleSSHRequest(w io.Writer, req *common.HubRequest[cbor.RawMes
|
|||||||
}
|
}
|
||||||
|
|
||||||
// responder that writes AgentResponse to stdout
|
// responder that writes AgentResponse to stdout
|
||||||
|
// Uses legacy typed fields for backward compatibility with <= 0.17
|
||||||
sshResponder := func(data any, requestID *uint32) error {
|
sshResponder := func(data any, requestID *uint32) error {
|
||||||
response := common.AgentResponse{Id: requestID}
|
response := newAgentResponse(data, requestID)
|
||||||
switch v := data.(type) {
|
|
||||||
case *system.CombinedData:
|
|
||||||
response.SystemData = v
|
|
||||||
case string:
|
|
||||||
response.String = &v
|
|
||||||
case map[string]smart.SmartData:
|
|
||||||
response.SmartData = v
|
|
||||||
default:
|
|
||||||
response.Error = fmt.Sprintf("unsupported response type: %T", data)
|
|
||||||
}
|
|
||||||
return cbor.NewEncoder(w).Encode(response)
|
return cbor.NewEncoder(w).Encode(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,7 +192,7 @@ func (a *Agent) handleSSHRequest(w io.Writer, req *common.HubRequest[cbor.RawMes
|
|||||||
|
|
||||||
// handleLegacyStats serves the legacy one-shot stats payload for older hubs
|
// handleLegacyStats serves the legacy one-shot stats payload for older hubs
|
||||||
func (a *Agent) handleLegacyStats(w io.Writer, hubVersion semver.Version) error {
|
func (a *Agent) handleLegacyStats(w io.Writer, hubVersion semver.Version) error {
|
||||||
stats := a.gatherStats(60_000)
|
stats := a.gatherStats(common.DataRequestOptions{CacheTimeMs: 60_000})
|
||||||
return a.writeToSession(w, stats, hubVersion)
|
return a.writeToSession(w, stats, hubVersion)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
//go:build testing
|
||||||
|
|
||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -180,6 +182,23 @@ func TestStartServer(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestStartServerDisableSSH(t *testing.T) {
|
||||||
|
os.Setenv("BESZEL_AGENT_DISABLE_SSH", "true")
|
||||||
|
defer os.Unsetenv("BESZEL_AGENT_DISABLE_SSH")
|
||||||
|
|
||||||
|
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 +532,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 +545,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)
|
||||||
@@ -540,6 +559,10 @@ func TestWriteToSessionEncoding(t *testing.T) {
|
|||||||
|
|
||||||
// Helper function to create test data for encoding tests
|
// Helper function to create test data for encoding tests
|
||||||
func createTestCombinedData() *system.CombinedData {
|
func createTestCombinedData() *system.CombinedData {
|
||||||
|
var stats = container.Stats{}
|
||||||
|
stats.Name = "test-container"
|
||||||
|
stats.Cpu = 10.5
|
||||||
|
stats.Mem = 1073741824 // 1GB
|
||||||
return &system.CombinedData{
|
return &system.CombinedData{
|
||||||
Stats: system.Stats{
|
Stats: system.Stats{
|
||||||
Cpu: 25.5,
|
Cpu: 25.5,
|
||||||
@@ -550,20 +573,15 @@ func createTestCombinedData() *system.CombinedData {
|
|||||||
DiskUsed: 549755813888, // 512GB
|
DiskUsed: 549755813888, // 512GB
|
||||||
DiskPct: 50.0,
|
DiskPct: 50.0,
|
||||||
},
|
},
|
||||||
|
Details: &system.Details{
|
||||||
|
Hostname: "test-host",
|
||||||
|
},
|
||||||
Info: system.Info{
|
Info: system.Info{
|
||||||
Hostname: "test-host",
|
|
||||||
Cores: 8,
|
|
||||||
CpuModel: "Test CPU Model",
|
|
||||||
Uptime: 3600,
|
Uptime: 3600,
|
||||||
AgentVersion: "0.12.0",
|
AgentVersion: "0.12.0",
|
||||||
Os: system.Linux,
|
|
||||||
},
|
},
|
||||||
Containers: []*container.Stats{
|
Containers: []*container.Stats{
|
||||||
{
|
&stats,
|
||||||
Name: "test-container",
|
|
||||||
Cpu: 10.5,
|
|
||||||
Mem: 1073741824, // 1GB
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
411
agent/smart.go
411
agent/smart.go
@@ -1,3 +1,6 @@
|
|||||||
|
//go:generate -command fetchsmartctl go run ./tools/fetchsmartctl
|
||||||
|
//go:generate fetchsmartctl -out ./smartmontools/smartctl.exe -url https://static.beszel.dev/bin/smartctl/smartctl-nc.exe -sha 3912249c3b329249aa512ce796fd1b64d7cbd8378b68ad2756b39163d9c30b47
|
||||||
|
|
||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -5,24 +8,28 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/smart"
|
"github.com/henrygd/beszel/internal/entities/smart"
|
||||||
|
|
||||||
"golang.org/x/exp/slog"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// SmartManager manages data collection for SMART devices
|
// SmartManager manages data collection for SMART devices
|
||||||
type SmartManager struct {
|
type SmartManager struct {
|
||||||
sync.Mutex
|
sync.Mutex
|
||||||
SmartDataMap map[string]*smart.SmartData
|
SmartDataMap map[string]*smart.SmartData
|
||||||
SmartDevices []*DeviceInfo
|
SmartDevices []*DeviceInfo
|
||||||
refreshMutex sync.Mutex
|
refreshMutex sync.Mutex
|
||||||
lastScanTime time.Time
|
lastScanTime time.Time
|
||||||
|
smartctlPath string
|
||||||
|
excludedDevices map[string]struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
type scanOutput struct {
|
type scanOutput struct {
|
||||||
@@ -46,6 +53,12 @@ type DeviceInfo struct {
|
|||||||
parserType string
|
parserType string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// deviceKey is a composite key for a device, used to identify a device uniquely.
|
||||||
|
type deviceKey struct {
|
||||||
|
name string
|
||||||
|
deviceType string
|
||||||
|
}
|
||||||
|
|
||||||
var errNoValidSmartData = fmt.Errorf("no valid SMART data found") // Error for missing data
|
var errNoValidSmartData = fmt.Errorf("no valid SMART data found") // Error for missing data
|
||||||
|
|
||||||
// Refresh updates SMART data for all known devices
|
// Refresh updates SMART data for all known devices
|
||||||
@@ -157,28 +170,44 @@ func (sm *SmartManager) ScanDevices(force bool) error {
|
|||||||
configuredDevices = parsedDevices
|
configuredDevices = parsedDevices
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
cmd := exec.CommandContext(ctx, "smartctl", "--scan", "-j")
|
|
||||||
output, err := cmd.Output()
|
|
||||||
|
|
||||||
var (
|
var (
|
||||||
scanErr error
|
scanErr error
|
||||||
scannedDevices []*DeviceInfo
|
scannedDevices []*DeviceInfo
|
||||||
hasValidScan bool
|
hasValidScan bool
|
||||||
)
|
)
|
||||||
|
|
||||||
if err != nil {
|
if sm.smartctlPath != "" {
|
||||||
scanErr = err
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
} else {
|
defer cancel()
|
||||||
scannedDevices, hasValidScan = sm.parseScan(output)
|
|
||||||
if !hasValidScan {
|
cmd := exec.CommandContext(ctx, sm.smartctlPath, "--scan", "-j")
|
||||||
scanErr = errNoValidSmartData
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
scanErr = err
|
||||||
|
} else {
|
||||||
|
scannedDevices, hasValidScan = sm.parseScan(output)
|
||||||
|
if !hasValidScan {
|
||||||
|
scanErr = errNoValidSmartData
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add eMMC devices (Linux only) by reading sysfs health fields. This does not
|
||||||
|
// require smartctl and does not scan the whole device.
|
||||||
|
if emmcDevices := scanEmmcDevices(); len(emmcDevices) > 0 {
|
||||||
|
scannedDevices = append(scannedDevices, emmcDevices...)
|
||||||
|
hasValidScan = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add Linux mdraid arrays by reading sysfs health fields. This does not
|
||||||
|
// require smartctl and does not scan the whole device.
|
||||||
|
if raidDevices := scanMdraidDevices(); len(raidDevices) > 0 {
|
||||||
|
scannedDevices = append(scannedDevices, raidDevices...)
|
||||||
|
hasValidScan = true
|
||||||
|
}
|
||||||
|
|
||||||
finalDevices := mergeDeviceLists(currentDevices, scannedDevices, configuredDevices)
|
finalDevices := mergeDeviceLists(currentDevices, scannedDevices, configuredDevices)
|
||||||
|
finalDevices = sm.filterExcludedDevices(finalDevices)
|
||||||
sm.updateSmartDevices(finalDevices)
|
sm.updateSmartDevices(finalDevices)
|
||||||
|
|
||||||
if len(finalDevices) == 0 {
|
if len(finalDevices) == 0 {
|
||||||
@@ -193,7 +222,11 @@ func (sm *SmartManager) ScanDevices(force bool) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (sm *SmartManager) parseConfiguredDevices(config string) ([]*DeviceInfo, error) {
|
func (sm *SmartManager) parseConfiguredDevices(config string) ([]*DeviceInfo, error) {
|
||||||
entries := strings.Split(config, ",")
|
splitChar := os.Getenv("SMART_DEVICES_SEPARATOR")
|
||||||
|
if splitChar == "" {
|
||||||
|
splitChar = ","
|
||||||
|
}
|
||||||
|
entries := strings.Split(config, splitChar)
|
||||||
devices := make([]*DeviceInfo, 0, len(entries))
|
devices := make([]*DeviceInfo, 0, len(entries))
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
entry = strings.TrimSpace(entry)
|
entry = strings.TrimSpace(entry)
|
||||||
@@ -226,6 +259,47 @@ func (sm *SmartManager) parseConfiguredDevices(config string) ([]*DeviceInfo, er
|
|||||||
return devices, nil
|
return devices, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (sm *SmartManager) refreshExcludedDevices() {
|
||||||
|
rawValue, _ := GetEnv("EXCLUDE_SMART")
|
||||||
|
sm.excludedDevices = make(map[string]struct{})
|
||||||
|
|
||||||
|
for entry := range strings.SplitSeq(rawValue, ",") {
|
||||||
|
device := strings.TrimSpace(entry)
|
||||||
|
if device == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sm.excludedDevices[device] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sm *SmartManager) isExcludedDevice(deviceName string) bool {
|
||||||
|
_, exists := sm.excludedDevices[deviceName]
|
||||||
|
return exists
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sm *SmartManager) filterExcludedDevices(devices []*DeviceInfo) []*DeviceInfo {
|
||||||
|
if devices == nil {
|
||||||
|
return []*DeviceInfo{}
|
||||||
|
}
|
||||||
|
|
||||||
|
excluded := sm.excludedDevices
|
||||||
|
if len(excluded) == 0 {
|
||||||
|
return devices
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered := make([]*DeviceInfo, 0, len(devices))
|
||||||
|
for _, device := range devices {
|
||||||
|
if device == nil || device.Name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, skip := excluded[device.Name]; skip {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filtered = append(filtered, device)
|
||||||
|
}
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|
||||||
// detectSmartOutputType inspects sections that are unique to each smartctl
|
// detectSmartOutputType inspects sections that are unique to each smartctl
|
||||||
// JSON schema (NVMe, ATA/SATA, SCSI) to determine which parser should be used
|
// JSON schema (NVMe, ATA/SATA, SCSI) to determine which parser should be used
|
||||||
// when the reported device type is ambiguous or missing.
|
// when the reported device type is ambiguous or missing.
|
||||||
@@ -276,6 +350,13 @@ func normalizeParserType(value string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// makeDeviceKey creates a composite key from device name and type.
|
||||||
|
// This allows multiple drives under the same device path (e.g., RAID controllers)
|
||||||
|
// to be tracked separately.
|
||||||
|
func makeDeviceKey(name, deviceType string) deviceKey {
|
||||||
|
return deviceKey{name: name, deviceType: deviceType}
|
||||||
|
}
|
||||||
|
|
||||||
// parseSmartOutput attempts each SMART parser, optionally detecting the type when
|
// parseSmartOutput attempts each SMART parser, optionally detecting the type when
|
||||||
// it is not provided, and updates the device info when a parser succeeds.
|
// it is not provided, and updates the device info when a parser succeeds.
|
||||||
func (sm *SmartManager) parseSmartOutput(deviceInfo *DeviceInfo, output []byte) bool {
|
func (sm *SmartManager) parseSmartOutput(deviceInfo *DeviceInfo, output []byte) bool {
|
||||||
@@ -372,41 +453,91 @@ func (sm *SmartManager) parseSmartOutput(deviceInfo *DeviceInfo, output []byte)
|
|||||||
// Uses -n standby to avoid waking up sleeping disks, but bypasses standby mode
|
// Uses -n standby to avoid waking up sleeping disks, but bypasses standby mode
|
||||||
// for initial data collection when no cached data exists
|
// for initial data collection when no cached data exists
|
||||||
func (sm *SmartManager) CollectSmart(deviceInfo *DeviceInfo) error {
|
func (sm *SmartManager) CollectSmart(deviceInfo *DeviceInfo) error {
|
||||||
|
if deviceInfo != nil && sm.isExcludedDevice(deviceInfo.Name) {
|
||||||
|
return errNoValidSmartData
|
||||||
|
}
|
||||||
|
|
||||||
|
// mdraid health is not exposed via SMART; Linux exposes array state in sysfs.
|
||||||
|
if deviceInfo != nil {
|
||||||
|
if ok, err := sm.collectMdraidHealth(deviceInfo); ok {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// eMMC health is not exposed via SMART on Linux, but the kernel provides
|
||||||
|
// wear / EOL indicators via sysfs. Prefer that path when available.
|
||||||
|
if deviceInfo != nil {
|
||||||
|
if ok, err := sm.collectEmmcHealth(deviceInfo); ok {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if sm.smartctlPath == "" {
|
||||||
|
return errNoValidSmartData
|
||||||
|
}
|
||||||
|
|
||||||
// slog.Info("collecting SMART data", "device", deviceInfo.Name, "type", deviceInfo.Type, "has_existing_data", sm.hasDataForDevice(deviceInfo.Name))
|
// slog.Info("collecting SMART data", "device", deviceInfo.Name, "type", deviceInfo.Type, "has_existing_data", sm.hasDataForDevice(deviceInfo.Name))
|
||||||
|
|
||||||
// Check if we have any existing data for this device
|
// Check if we have any existing data for this device
|
||||||
hasExistingData := sm.hasDataForDevice(deviceInfo.Name)
|
hasExistingData := sm.hasDataForDevice(deviceInfo.Name)
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
// Try with -n standby first if we have existing data
|
// Try with -n standby first if we have existing data
|
||||||
args := sm.smartctlArgs(deviceInfo, true)
|
args := sm.smartctlArgs(deviceInfo, hasExistingData)
|
||||||
cmd := exec.CommandContext(ctx, "smartctl", args...)
|
cmd := exec.CommandContext(ctx, sm.smartctlPath, args...)
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
|
|
||||||
// Check if device is in standby (exit status 2)
|
// Check if device is in standby (exit status 2)
|
||||||
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 2 {
|
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok && exitErr.ExitCode() == 2 {
|
||||||
if hasExistingData {
|
if hasExistingData {
|
||||||
// Device is in standby and we have cached data, keep using cache
|
// Device is in standby and we have cached data, keep using cache
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
// No cached data, need to collect initial data by bypassing standby
|
// No cached data, need to collect initial data by bypassing standby
|
||||||
ctx2, cancel2 := context.WithTimeout(context.Background(), 2*time.Second)
|
ctx2, cancel2 := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
defer cancel2()
|
defer cancel2()
|
||||||
args = sm.smartctlArgs(deviceInfo, false)
|
args = sm.smartctlArgs(deviceInfo, false)
|
||||||
cmd = exec.CommandContext(ctx2, "smartctl", args...)
|
cmd = exec.CommandContext(ctx2, sm.smartctlPath, args...)
|
||||||
output, err = cmd.CombinedOutput()
|
output, err = cmd.CombinedOutput()
|
||||||
}
|
}
|
||||||
|
|
||||||
hasValidData := sm.parseSmartOutput(deviceInfo, output)
|
hasValidData := sm.parseSmartOutput(deviceInfo, output)
|
||||||
|
|
||||||
|
// If NVMe controller path failed, try namespace path as fallback.
|
||||||
|
// NVMe controllers (/dev/nvme0) don't always support SMART queries. See github.com/henrygd/beszel/issues/1504
|
||||||
|
if !hasValidData && err != nil && isNvmeControllerPath(deviceInfo.Name) {
|
||||||
|
controllerPath := deviceInfo.Name
|
||||||
|
namespacePath := controllerPath + "n1"
|
||||||
|
if !sm.isExcludedDevice(namespacePath) {
|
||||||
|
deviceInfo.Name = namespacePath
|
||||||
|
|
||||||
|
ctx3, cancel3 := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
|
defer cancel3()
|
||||||
|
args = sm.smartctlArgs(deviceInfo, false)
|
||||||
|
cmd = exec.CommandContext(ctx3, sm.smartctlPath, args...)
|
||||||
|
output, err = cmd.CombinedOutput()
|
||||||
|
hasValidData = sm.parseSmartOutput(deviceInfo, output)
|
||||||
|
|
||||||
|
// Auto-exclude the controller path so future scans don't re-add it
|
||||||
|
if hasValidData {
|
||||||
|
sm.Lock()
|
||||||
|
if sm.excludedDevices == nil {
|
||||||
|
sm.excludedDevices = make(map[string]struct{})
|
||||||
|
}
|
||||||
|
sm.excludedDevices[controllerPath] = struct{}{}
|
||||||
|
sm.Unlock()
|
||||||
|
slog.Debug("auto-excluded NVMe controller path", "path", controllerPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if !hasValidData {
|
if !hasValidData {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Debug("smartctl failed", "device", deviceInfo.Name, "err", err)
|
slog.Info("smartctl failed", "device", deviceInfo.Name, "err", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
slog.Debug("no valid SMART data found", "device", deviceInfo.Name)
|
slog.Info("no valid SMART data found", "device", deviceInfo.Name)
|
||||||
return errNoValidSmartData
|
return errNoValidSmartData
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -416,10 +547,12 @@ func (sm *SmartManager) CollectSmart(deviceInfo *DeviceInfo) error {
|
|||||||
// smartctlArgs returns the arguments for the smartctl command
|
// smartctlArgs returns the arguments for the smartctl command
|
||||||
// based on the device type and whether to include standby mode
|
// based on the device type and whether to include standby mode
|
||||||
func (sm *SmartManager) smartctlArgs(deviceInfo *DeviceInfo, includeStandby bool) []string {
|
func (sm *SmartManager) smartctlArgs(deviceInfo *DeviceInfo, includeStandby bool) []string {
|
||||||
args := make([]string, 0, 7)
|
args := make([]string, 0, 9)
|
||||||
|
var deviceType, parserType string
|
||||||
|
|
||||||
if deviceInfo != nil {
|
if deviceInfo != nil {
|
||||||
deviceType := strings.ToLower(deviceInfo.Type)
|
deviceType = strings.ToLower(deviceInfo.Type)
|
||||||
|
parserType = strings.ToLower(deviceInfo.parserType)
|
||||||
// types sometimes misidentified in scan; see github.com/henrygd/beszel/issues/1345
|
// types sometimes misidentified in scan; see github.com/henrygd/beszel/issues/1345
|
||||||
if deviceType != "" && deviceType != "scsi" && deviceType != "ata" {
|
if deviceType != "" && deviceType != "scsi" && deviceType != "ata" {
|
||||||
args = append(args, "-d", deviceInfo.Type)
|
args = append(args, "-d", deviceInfo.Type)
|
||||||
@@ -427,6 +560,13 @@ func (sm *SmartManager) smartctlArgs(deviceInfo *DeviceInfo, includeStandby bool
|
|||||||
}
|
}
|
||||||
|
|
||||||
args = append(args, "-a", "--json=c")
|
args = append(args, "-a", "--json=c")
|
||||||
|
effectiveType := parserType
|
||||||
|
if effectiveType == "" {
|
||||||
|
effectiveType = deviceType
|
||||||
|
}
|
||||||
|
if effectiveType == "sat" || effectiveType == "ata" {
|
||||||
|
args = append(args, "-l", "devstat")
|
||||||
|
}
|
||||||
|
|
||||||
if includeStandby {
|
if includeStandby {
|
||||||
args = append(args, "-n", "standby")
|
args = append(args, "-n", "standby")
|
||||||
@@ -487,6 +627,28 @@ func mergeDeviceLists(existing, scanned, configured []*DeviceInfo) []*DeviceInfo
|
|||||||
return existing
|
return existing
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildUniqueNameIndex returns devices that appear exactly once by name.
|
||||||
|
// It is used to safely apply name-only fallbacks without RAID ambiguity.
|
||||||
|
buildUniqueNameIndex := func(devices []*DeviceInfo) map[string]*DeviceInfo {
|
||||||
|
counts := make(map[string]int, len(devices))
|
||||||
|
for _, dev := range devices {
|
||||||
|
if dev == nil || dev.Name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
counts[dev.Name]++
|
||||||
|
}
|
||||||
|
unique := make(map[string]*DeviceInfo, len(counts))
|
||||||
|
for _, dev := range devices {
|
||||||
|
if dev == nil || dev.Name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if counts[dev.Name] == 1 {
|
||||||
|
unique[dev.Name] = dev
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return unique
|
||||||
|
}
|
||||||
|
|
||||||
// preserveVerifiedType copies the verified type/parser metadata from an existing
|
// preserveVerifiedType copies the verified type/parser metadata from an existing
|
||||||
// device record so that subsequent scans/config updates never downgrade a
|
// device record so that subsequent scans/config updates never downgrade a
|
||||||
// previously verified device.
|
// previously verified device.
|
||||||
@@ -499,69 +661,90 @@ func mergeDeviceLists(existing, scanned, configured []*DeviceInfo) []*DeviceInfo
|
|||||||
target.parserType = prev.parserType
|
target.parserType = prev.parserType
|
||||||
}
|
}
|
||||||
|
|
||||||
existingIndex := make(map[string]*DeviceInfo, len(existing))
|
// applyConfiguredMetadata updates a matched device with any configured
|
||||||
|
// overrides, preserving verified type data when present.
|
||||||
|
applyConfiguredMetadata := func(existingDev, configuredDev *DeviceInfo) {
|
||||||
|
// Only update the type if it has not been verified yet; otherwise we
|
||||||
|
// keep the existing verified metadata intact.
|
||||||
|
if configuredDev.Type != "" && !existingDev.typeVerified {
|
||||||
|
newType := strings.TrimSpace(configuredDev.Type)
|
||||||
|
existingDev.Type = newType
|
||||||
|
existingDev.typeVerified = false
|
||||||
|
existingDev.parserType = normalizeParserType(newType)
|
||||||
|
}
|
||||||
|
if configuredDev.InfoName != "" {
|
||||||
|
existingDev.InfoName = configuredDev.InfoName
|
||||||
|
}
|
||||||
|
if configuredDev.Protocol != "" {
|
||||||
|
existingDev.Protocol = configuredDev.Protocol
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
existingIndex := make(map[deviceKey]*DeviceInfo, len(existing))
|
||||||
for _, dev := range existing {
|
for _, dev := range existing {
|
||||||
if dev == nil || dev.Name == "" {
|
if dev == nil || dev.Name == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
existingIndex[dev.Name] = dev
|
existingIndex[makeDeviceKey(dev.Name, dev.Type)] = dev
|
||||||
}
|
}
|
||||||
|
existingByName := buildUniqueNameIndex(existing)
|
||||||
|
|
||||||
finalDevices := make([]*DeviceInfo, 0, len(scanned)+len(configured))
|
finalDevices := make([]*DeviceInfo, 0, len(scanned)+len(configured))
|
||||||
deviceIndex := make(map[string]*DeviceInfo, len(scanned)+len(configured))
|
deviceIndex := make(map[deviceKey]*DeviceInfo, len(scanned)+len(configured))
|
||||||
|
|
||||||
// Start with the newly scanned devices so we always surface fresh metadata,
|
// Start with the newly scanned devices so we always surface fresh metadata,
|
||||||
// but ensure we retain any previously verified parser assignment.
|
// but ensure we retain any previously verified parser assignment.
|
||||||
for _, dev := range scanned {
|
for _, scannedDevice := range scanned {
|
||||||
if dev == nil || dev.Name == "" {
|
if scannedDevice == nil || scannedDevice.Name == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Work on a copy so we can safely adjust metadata without mutating the
|
// Work on a copy so we can safely adjust metadata without mutating the
|
||||||
// input slices that may be reused elsewhere.
|
// input slices that may be reused elsewhere.
|
||||||
copyDev := *dev
|
copyDev := *scannedDevice
|
||||||
if prev := existingIndex[copyDev.Name]; prev != nil {
|
key := makeDeviceKey(copyDev.Name, copyDev.Type)
|
||||||
|
if prev := existingIndex[key]; prev != nil {
|
||||||
|
preserveVerifiedType(©Dev, prev)
|
||||||
|
} else if prev := existingByName[copyDev.Name]; prev != nil {
|
||||||
preserveVerifiedType(©Dev, prev)
|
preserveVerifiedType(©Dev, prev)
|
||||||
}
|
}
|
||||||
|
|
||||||
finalDevices = append(finalDevices, ©Dev)
|
finalDevices = append(finalDevices, ©Dev)
|
||||||
deviceIndex[copyDev.Name] = finalDevices[len(finalDevices)-1]
|
copyKey := makeDeviceKey(copyDev.Name, copyDev.Type)
|
||||||
|
deviceIndex[copyKey] = finalDevices[len(finalDevices)-1]
|
||||||
}
|
}
|
||||||
|
deviceIndexByName := buildUniqueNameIndex(finalDevices)
|
||||||
|
|
||||||
// Merge configured devices on top so users can override scan results (except
|
// Merge configured devices on top so users can override scan results (except
|
||||||
// for verified type information).
|
// for verified type information).
|
||||||
for _, dev := range configured {
|
for _, configuredDevice := range configured {
|
||||||
if dev == nil || dev.Name == "" {
|
if configuredDevice == nil || configuredDevice.Name == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if existingDev, ok := deviceIndex[dev.Name]; ok {
|
key := makeDeviceKey(configuredDevice.Name, configuredDevice.Type)
|
||||||
// Only update the type if it has not been verified yet; otherwise we
|
if existingDev, ok := deviceIndex[key]; ok {
|
||||||
// keep the existing verified metadata intact.
|
applyConfiguredMetadata(existingDev, configuredDevice)
|
||||||
if dev.Type != "" && !existingDev.typeVerified {
|
continue
|
||||||
newType := strings.TrimSpace(dev.Type)
|
}
|
||||||
existingDev.Type = newType
|
if existingDev := deviceIndexByName[configuredDevice.Name]; existingDev != nil {
|
||||||
existingDev.typeVerified = false
|
applyConfiguredMetadata(existingDev, configuredDevice)
|
||||||
existingDev.parserType = normalizeParserType(newType)
|
|
||||||
}
|
|
||||||
if dev.InfoName != "" {
|
|
||||||
existingDev.InfoName = dev.InfoName
|
|
||||||
}
|
|
||||||
if dev.Protocol != "" {
|
|
||||||
existingDev.Protocol = dev.Protocol
|
|
||||||
}
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
copyDev := *dev
|
copyDev := *configuredDevice
|
||||||
if prev := existingIndex[copyDev.Name]; prev != nil {
|
key = makeDeviceKey(copyDev.Name, copyDev.Type)
|
||||||
|
if prev := existingIndex[key]; prev != nil {
|
||||||
|
preserveVerifiedType(©Dev, prev)
|
||||||
|
} else if prev := existingByName[copyDev.Name]; prev != nil {
|
||||||
preserveVerifiedType(©Dev, prev)
|
preserveVerifiedType(©Dev, prev)
|
||||||
} else if copyDev.Type != "" {
|
} else if copyDev.Type != "" {
|
||||||
copyDev.parserType = normalizeParserType(copyDev.Type)
|
copyDev.parserType = normalizeParserType(copyDev.Type)
|
||||||
}
|
}
|
||||||
|
|
||||||
finalDevices = append(finalDevices, ©Dev)
|
finalDevices = append(finalDevices, ©Dev)
|
||||||
deviceIndex[copyDev.Name] = finalDevices[len(finalDevices)-1]
|
copyKey := makeDeviceKey(copyDev.Name, copyDev.Type)
|
||||||
|
deviceIndex[copyKey] = finalDevices[len(finalDevices)-1]
|
||||||
}
|
}
|
||||||
|
|
||||||
return finalDevices
|
return finalDevices
|
||||||
@@ -579,12 +762,14 @@ func (sm *SmartManager) updateSmartDevices(devices []*DeviceInfo) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
validNames := make(map[string]struct{}, len(devices))
|
validKeys := make(map[deviceKey]struct{}, len(devices))
|
||||||
|
nameCounts := make(map[string]int, len(devices))
|
||||||
for _, device := range devices {
|
for _, device := range devices {
|
||||||
if device == nil || device.Name == "" {
|
if device == nil || device.Name == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
validNames[device.Name] = struct{}{}
|
validKeys[makeDeviceKey(device.Name, device.Type)] = struct{}{}
|
||||||
|
nameCounts[device.Name]++
|
||||||
}
|
}
|
||||||
|
|
||||||
for key, data := range sm.SmartDataMap {
|
for key, data := range sm.SmartDataMap {
|
||||||
@@ -593,7 +778,11 @@ func (sm *SmartManager) updateSmartDevices(devices []*DeviceInfo) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, ok := validNames[data.DiskName]; ok {
|
if data.DiskType == "" {
|
||||||
|
if nameCounts[data.DiskName] == 1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
} else if _, ok := validKeys[makeDeviceKey(data.DiskName, data.DiskType)]; ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -681,6 +870,11 @@ func (sm *SmartManager) parseSmartForSata(output []byte) (bool, int) {
|
|||||||
smartData.FirmwareVersion = data.FirmwareVersion
|
smartData.FirmwareVersion = data.FirmwareVersion
|
||||||
smartData.Capacity = data.UserCapacity.Bytes
|
smartData.Capacity = data.UserCapacity.Bytes
|
||||||
smartData.Temperature = data.Temperature.Current
|
smartData.Temperature = data.Temperature.Current
|
||||||
|
if smartData.Temperature == 0 {
|
||||||
|
if temp, ok := temperatureFromAtaDeviceStatistics(data.AtaDeviceStatistics); ok {
|
||||||
|
smartData.Temperature = temp
|
||||||
|
}
|
||||||
|
}
|
||||||
smartData.SmartStatus = getSmartStatus(smartData.Temperature, data.SmartStatus.Passed)
|
smartData.SmartStatus = getSmartStatus(smartData.Temperature, data.SmartStatus.Passed)
|
||||||
smartData.DiskName = data.Device.Name
|
smartData.DiskName = data.Device.Name
|
||||||
smartData.DiskType = data.Device.Type
|
smartData.DiskType = data.Device.Type
|
||||||
@@ -719,6 +913,36 @@ func getSmartStatus(temperature uint8, passed bool) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func temperatureFromAtaDeviceStatistics(stats smart.AtaDeviceStatistics) (uint8, bool) {
|
||||||
|
entry := findAtaDeviceStatisticsEntry(stats, 5, "Current Temperature")
|
||||||
|
if entry == nil || entry.Value == nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
if *entry.Value > 255 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return uint8(*entry.Value), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// findAtaDeviceStatisticsEntry centralizes ATA devstat lookups so additional
|
||||||
|
// metrics can be pulled from the same structure in the future.
|
||||||
|
func findAtaDeviceStatisticsEntry(stats smart.AtaDeviceStatistics, pageNumber uint8, entryName string) *smart.AtaDeviceStatisticsEntry {
|
||||||
|
for pageIdx := range stats.Pages {
|
||||||
|
page := &stats.Pages[pageIdx]
|
||||||
|
if page.Number != pageNumber {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for entryIdx := range page.Table {
|
||||||
|
entry := &page.Table[entryIdx]
|
||||||
|
if !strings.EqualFold(entry.Name, entryName) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (sm *SmartManager) parseSmartForScsi(output []byte) (bool, int) {
|
func (sm *SmartManager) parseSmartForScsi(output []byte) (bool, int) {
|
||||||
var data smart.SmartInfoForScsi
|
var data smart.SmartInfoForScsi
|
||||||
|
|
||||||
@@ -875,13 +1099,54 @@ func (sm *SmartManager) parseSmartForNvme(output []byte) (bool, int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// detectSmartctl checks if smartctl is installed, returns an error if not
|
// detectSmartctl checks if smartctl is installed, returns an error if not
|
||||||
func (sm *SmartManager) detectSmartctl() error {
|
func (sm *SmartManager) detectSmartctl() (string, error) {
|
||||||
if _, err := exec.LookPath("smartctl"); err == nil {
|
isWindows := runtime.GOOS == "windows"
|
||||||
slog.Debug("smartctl found")
|
|
||||||
return nil
|
// Load embedded smartctl.exe for Windows amd64 builds.
|
||||||
|
if isWindows && runtime.GOARCH == "amd64" {
|
||||||
|
if path, err := ensureEmbeddedSmartctl(); err == nil {
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
slog.Debug("smartctl not found")
|
|
||||||
return errors.New("smartctl not found")
|
if path, err := exec.LookPath("smartctl"); err == nil {
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
locations := []string{}
|
||||||
|
if isWindows {
|
||||||
|
locations = append(locations,
|
||||||
|
"C:\\Program Files\\smartmontools\\bin\\smartctl.exe",
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
locations = append(locations, "/opt/homebrew/bin/smartctl")
|
||||||
|
}
|
||||||
|
for _, location := range locations {
|
||||||
|
if _, err := os.Stat(location); err == nil {
|
||||||
|
return location, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", errors.New("smartctl not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// isNvmeControllerPath checks if the path matches an NVMe controller pattern
|
||||||
|
// like /dev/nvme0, /dev/nvme1, etc. (without namespace suffix like n1)
|
||||||
|
func isNvmeControllerPath(path string) bool {
|
||||||
|
base := filepath.Base(path)
|
||||||
|
if !strings.HasPrefix(base, "nvme") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
suffix := strings.TrimPrefix(base, "nvme")
|
||||||
|
if suffix == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Controller paths are just "nvme" + digits (e.g., nvme0, nvme1)
|
||||||
|
// Namespace paths have "n" after the controller number (e.g., nvme0n1)
|
||||||
|
for _, c := range suffix {
|
||||||
|
if c < '0' || c > '9' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSmartManager creates and initializes a new SmartManager
|
// NewSmartManager creates and initializes a new SmartManager
|
||||||
@@ -889,9 +1154,19 @@ func NewSmartManager() (*SmartManager, error) {
|
|||||||
sm := &SmartManager{
|
sm := &SmartManager{
|
||||||
SmartDataMap: make(map[string]*smart.SmartData),
|
SmartDataMap: make(map[string]*smart.SmartData),
|
||||||
}
|
}
|
||||||
if err := sm.detectSmartctl(); err != nil {
|
sm.refreshExcludedDevices()
|
||||||
|
path, err := sm.detectSmartctl()
|
||||||
|
slog.Debug("smartctl", "path", path, "err", err)
|
||||||
|
if err != nil {
|
||||||
|
// Keep the previous fail-fast behavior unless this Linux host exposes
|
||||||
|
// eMMC or mdraid health via sysfs, in which case smartctl is optional.
|
||||||
|
if runtime.GOOS == "linux" {
|
||||||
|
if len(scanEmmcDevices()) > 0 || len(scanMdraidDevices()) > 0 {
|
||||||
|
return sm, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
sm.smartctlPath = path
|
||||||
return sm, nil
|
return sm, nil
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
//go:build testing
|
//go:build testing
|
||||||
// +build testing
|
|
||||||
|
|
||||||
package agent
|
package agent
|
||||||
|
|
||||||
@@ -89,6 +88,39 @@ func TestParseSmartForSata(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseSmartForSataDeviceStatisticsTemperature(t *testing.T) {
|
||||||
|
jsonPayload := []byte(`{
|
||||||
|
"smartctl": {"exit_status": 0},
|
||||||
|
"device": {"name": "/dev/sdb", "type": "sat"},
|
||||||
|
"model_name": "SanDisk SSD U110 16GB",
|
||||||
|
"serial_number": "DEVSTAT123",
|
||||||
|
"firmware_version": "U21B001",
|
||||||
|
"user_capacity": {"bytes": 16013942784},
|
||||||
|
"smart_status": {"passed": true},
|
||||||
|
"ata_smart_attributes": {"table": []},
|
||||||
|
"ata_device_statistics": {
|
||||||
|
"pages": [
|
||||||
|
{
|
||||||
|
"number": 5,
|
||||||
|
"name": "Temperature Statistics",
|
||||||
|
"table": [
|
||||||
|
{"name": "Current Temperature", "value": 22, "flags": {"valid": true}}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}`)
|
||||||
|
|
||||||
|
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
|
||||||
|
hasData, exitStatus := sm.parseSmartForSata(jsonPayload)
|
||||||
|
require.True(t, hasData)
|
||||||
|
assert.Equal(t, 0, exitStatus)
|
||||||
|
|
||||||
|
deviceData, ok := sm.SmartDataMap["DEVSTAT123"]
|
||||||
|
require.True(t, ok, "expected smart data entry for serial DEVSTAT123")
|
||||||
|
assert.Equal(t, uint8(22), deviceData.Temperature)
|
||||||
|
}
|
||||||
|
|
||||||
func TestParseSmartForSataParentheticalRawValue(t *testing.T) {
|
func TestParseSmartForSataParentheticalRawValue(t *testing.T) {
|
||||||
jsonPayload := []byte(`{
|
jsonPayload := []byte(`{
|
||||||
"smartctl": {"exit_status": 0},
|
"smartctl": {"exit_status": 0},
|
||||||
@@ -195,6 +227,24 @@ func TestDevicesSnapshotReturnsCopy(t *testing.T) {
|
|||||||
assert.Len(t, snapshot, 2)
|
assert.Len(t, snapshot, 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestScanDevicesWithEnvOverrideAndSeparator(t *testing.T) {
|
||||||
|
t.Setenv("SMART_DEVICES_SEPARATOR", "|")
|
||||||
|
t.Setenv("SMART_DEVICES", "/dev/sda:jmb39x-q,0|/dev/nvme0:nvme")
|
||||||
|
|
||||||
|
sm := &SmartManager{
|
||||||
|
SmartDataMap: make(map[string]*smart.SmartData),
|
||||||
|
}
|
||||||
|
|
||||||
|
err := sm.ScanDevices(true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.Len(t, sm.SmartDevices, 2)
|
||||||
|
assert.Equal(t, "/dev/sda", sm.SmartDevices[0].Name)
|
||||||
|
assert.Equal(t, "jmb39x-q,0", sm.SmartDevices[0].Type)
|
||||||
|
assert.Equal(t, "/dev/nvme0", sm.SmartDevices[1].Name)
|
||||||
|
assert.Equal(t, "nvme", sm.SmartDevices[1].Type)
|
||||||
|
}
|
||||||
|
|
||||||
func TestScanDevicesWithEnvOverride(t *testing.T) {
|
func TestScanDevicesWithEnvOverride(t *testing.T) {
|
||||||
t.Setenv("SMART_DEVICES", "/dev/sda:sat, /dev/nvme0:nvme")
|
t.Setenv("SMART_DEVICES", "/dev/sda:sat, /dev/nvme0:nvme")
|
||||||
|
|
||||||
@@ -249,15 +299,21 @@ func TestSmartctlArgs(t *testing.T) {
|
|||||||
|
|
||||||
sataDevice := &DeviceInfo{Name: "/dev/sda", Type: "sat"}
|
sataDevice := &DeviceInfo{Name: "/dev/sda", Type: "sat"}
|
||||||
assert.Equal(t,
|
assert.Equal(t,
|
||||||
[]string{"-d", "sat", "-a", "--json=c", "-n", "standby", "/dev/sda"},
|
[]string{"-d", "sat", "-a", "--json=c", "-l", "devstat", "-n", "standby", "/dev/sda"},
|
||||||
sm.smartctlArgs(sataDevice, true),
|
sm.smartctlArgs(sataDevice, true),
|
||||||
)
|
)
|
||||||
|
|
||||||
assert.Equal(t,
|
assert.Equal(t,
|
||||||
[]string{"-d", "sat", "-a", "--json=c", "/dev/sda"},
|
[]string{"-d", "sat", "-a", "--json=c", "-l", "devstat", "/dev/sda"},
|
||||||
sm.smartctlArgs(sataDevice, false),
|
sm.smartctlArgs(sataDevice, false),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
nvmeDevice := &DeviceInfo{Name: "/dev/nvme0", Type: "nvme"}
|
||||||
|
assert.Equal(t,
|
||||||
|
[]string{"-d", "nvme", "-a", "--json=c", "-n", "standby", "/dev/nvme0"},
|
||||||
|
sm.smartctlArgs(nvmeDevice, true),
|
||||||
|
)
|
||||||
|
|
||||||
assert.Equal(t,
|
assert.Equal(t,
|
||||||
[]string{"-a", "--json=c", "-n", "standby"},
|
[]string{"-a", "--json=c", "-n", "standby"},
|
||||||
sm.smartctlArgs(nil, true),
|
sm.smartctlArgs(nil, true),
|
||||||
@@ -442,6 +498,88 @@ func TestMergeDeviceListsUpdatesTypeWhenUnverified(t *testing.T) {
|
|||||||
assert.Equal(t, "", device.parserType)
|
assert.Equal(t, "", device.parserType)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMergeDeviceListsHandlesDevicesWithSameNameAndDifferentTypes(t *testing.T) {
|
||||||
|
// There are use cases where the same device name is re-used,
|
||||||
|
// for example, a RAID controller with multiple drives.
|
||||||
|
scanned := []*DeviceInfo{
|
||||||
|
{Name: "/dev/sda", Type: "megaraid,0"},
|
||||||
|
{Name: "/dev/sda", Type: "megaraid,1"},
|
||||||
|
{Name: "/dev/sda", Type: "megaraid,2"},
|
||||||
|
}
|
||||||
|
|
||||||
|
merged := mergeDeviceLists(nil, scanned, nil)
|
||||||
|
require.Len(t, merged, 3, "should have 3 separate devices for RAID controller")
|
||||||
|
|
||||||
|
byKey := make(map[string]*DeviceInfo, len(merged))
|
||||||
|
for _, dev := range merged {
|
||||||
|
key := dev.Name + "|" + dev.Type
|
||||||
|
byKey[key] = dev
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Contains(t, byKey, "/dev/sda|megaraid,0")
|
||||||
|
assert.Contains(t, byKey, "/dev/sda|megaraid,1")
|
||||||
|
assert.Contains(t, byKey, "/dev/sda|megaraid,2")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeDeviceListsHandlesMixedRAIDAndRegular(t *testing.T) {
|
||||||
|
// Test mixing RAID drives with regular devices
|
||||||
|
scanned := []*DeviceInfo{
|
||||||
|
{Name: "/dev/sda", Type: "megaraid,0"},
|
||||||
|
{Name: "/dev/sda", Type: "megaraid,1"},
|
||||||
|
{Name: "/dev/sdb", Type: "sat"},
|
||||||
|
{Name: "/dev/nvme0", Type: "nvme"},
|
||||||
|
}
|
||||||
|
|
||||||
|
merged := mergeDeviceLists(nil, scanned, nil)
|
||||||
|
require.Len(t, merged, 4, "should have 4 separate devices")
|
||||||
|
|
||||||
|
byKey := make(map[string]*DeviceInfo, len(merged))
|
||||||
|
for _, dev := range merged {
|
||||||
|
key := dev.Name + "|" + dev.Type
|
||||||
|
byKey[key] = dev
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Contains(t, byKey, "/dev/sda|megaraid,0")
|
||||||
|
assert.Contains(t, byKey, "/dev/sda|megaraid,1")
|
||||||
|
assert.Contains(t, byKey, "/dev/sdb|sat")
|
||||||
|
assert.Contains(t, byKey, "/dev/nvme0|nvme")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateSmartDevicesPreservesRAIDDrives(t *testing.T) {
|
||||||
|
// Test that updateSmartDevices correctly validates RAID drives using composite keys
|
||||||
|
sm := &SmartManager{
|
||||||
|
SmartDevices: []*DeviceInfo{
|
||||||
|
{Name: "/dev/sda", Type: "megaraid,0"},
|
||||||
|
{Name: "/dev/sda", Type: "megaraid,1"},
|
||||||
|
},
|
||||||
|
SmartDataMap: map[string]*smart.SmartData{
|
||||||
|
"serial-0": {
|
||||||
|
DiskName: "/dev/sda",
|
||||||
|
DiskType: "megaraid,0",
|
||||||
|
SerialNumber: "serial-0",
|
||||||
|
},
|
||||||
|
"serial-1": {
|
||||||
|
DiskName: "/dev/sda",
|
||||||
|
DiskType: "megaraid,1",
|
||||||
|
SerialNumber: "serial-1",
|
||||||
|
},
|
||||||
|
"serial-stale": {
|
||||||
|
DiskName: "/dev/sda",
|
||||||
|
DiskType: "megaraid,2",
|
||||||
|
SerialNumber: "serial-stale",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
sm.updateSmartDevices(sm.SmartDevices)
|
||||||
|
|
||||||
|
// serial-0 and serial-1 should be preserved (matching devices exist)
|
||||||
|
assert.Contains(t, sm.SmartDataMap, "serial-0")
|
||||||
|
assert.Contains(t, sm.SmartDataMap, "serial-1")
|
||||||
|
// serial-stale should be removed (no matching device)
|
||||||
|
assert.NotContains(t, sm.SmartDataMap, "serial-stale")
|
||||||
|
}
|
||||||
|
|
||||||
func TestParseSmartOutputMarksVerified(t *testing.T) {
|
func TestParseSmartOutputMarksVerified(t *testing.T) {
|
||||||
fixturePath := filepath.Join("test-data", "smart", "nvme0.json")
|
fixturePath := filepath.Join("test-data", "smart", "nvme0.json")
|
||||||
data, err := os.ReadFile(fixturePath)
|
data, err := os.ReadFile(fixturePath)
|
||||||
@@ -588,3 +726,228 @@ func TestIsVirtualDeviceScsi(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRefreshExcludedDevices(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
envValue string
|
||||||
|
expectedDevs map[string]struct{}
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty env",
|
||||||
|
envValue: "",
|
||||||
|
expectedDevs: map[string]struct{}{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single device",
|
||||||
|
envValue: "/dev/sda",
|
||||||
|
expectedDevs: map[string]struct{}{
|
||||||
|
"/dev/sda": {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple devices",
|
||||||
|
envValue: "/dev/sda,/dev/sdb,/dev/nvme0",
|
||||||
|
expectedDevs: map[string]struct{}{
|
||||||
|
"/dev/sda": {},
|
||||||
|
"/dev/sdb": {},
|
||||||
|
"/dev/nvme0": {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "devices with whitespace",
|
||||||
|
envValue: " /dev/sda , /dev/sdb , /dev/nvme0 ",
|
||||||
|
expectedDevs: map[string]struct{}{
|
||||||
|
"/dev/sda": {},
|
||||||
|
"/dev/sdb": {},
|
||||||
|
"/dev/nvme0": {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "duplicate devices",
|
||||||
|
envValue: "/dev/sda,/dev/sdb,/dev/sda",
|
||||||
|
expectedDevs: map[string]struct{}{
|
||||||
|
"/dev/sda": {},
|
||||||
|
"/dev/sdb": {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty entries and whitespace",
|
||||||
|
envValue: "/dev/sda,, /dev/sdb , , ",
|
||||||
|
expectedDevs: map[string]struct{}{
|
||||||
|
"/dev/sda": {},
|
||||||
|
"/dev/sdb": {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if tt.envValue != "" {
|
||||||
|
t.Setenv("EXCLUDE_SMART", tt.envValue)
|
||||||
|
} else {
|
||||||
|
// Ensure env var is not set for empty test
|
||||||
|
os.Unsetenv("EXCLUDE_SMART")
|
||||||
|
}
|
||||||
|
|
||||||
|
sm := &SmartManager{}
|
||||||
|
sm.refreshExcludedDevices()
|
||||||
|
|
||||||
|
assert.Equal(t, tt.expectedDevs, sm.excludedDevices)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsExcludedDevice(t *testing.T) {
|
||||||
|
sm := &SmartManager{
|
||||||
|
excludedDevices: map[string]struct{}{
|
||||||
|
"/dev/sda": {},
|
||||||
|
"/dev/nvme0": {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
deviceName string
|
||||||
|
expectedBool bool
|
||||||
|
}{
|
||||||
|
{"excluded device sda", "/dev/sda", true},
|
||||||
|
{"excluded device nvme0", "/dev/nvme0", true},
|
||||||
|
{"non-excluded device sdb", "/dev/sdb", false},
|
||||||
|
{"non-excluded device nvme1", "/dev/nvme1", false},
|
||||||
|
{"empty device name", "", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := sm.isExcludedDevice(tt.deviceName)
|
||||||
|
assert.Equal(t, tt.expectedBool, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilterExcludedDevices(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
excludedDevs map[string]struct{}
|
||||||
|
inputDevices []*DeviceInfo
|
||||||
|
expectedDevs []*DeviceInfo
|
||||||
|
expectedLength int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no exclusions",
|
||||||
|
excludedDevs: map[string]struct{}{},
|
||||||
|
inputDevices: []*DeviceInfo{
|
||||||
|
{Name: "/dev/sda"},
|
||||||
|
{Name: "/dev/sdb"},
|
||||||
|
{Name: "/dev/nvme0"},
|
||||||
|
},
|
||||||
|
expectedDevs: []*DeviceInfo{
|
||||||
|
{Name: "/dev/sda"},
|
||||||
|
{Name: "/dev/sdb"},
|
||||||
|
{Name: "/dev/nvme0"},
|
||||||
|
},
|
||||||
|
expectedLength: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "some devices excluded",
|
||||||
|
excludedDevs: map[string]struct{}{
|
||||||
|
"/dev/sda": {},
|
||||||
|
"/dev/nvme0": {},
|
||||||
|
},
|
||||||
|
inputDevices: []*DeviceInfo{
|
||||||
|
{Name: "/dev/sda"},
|
||||||
|
{Name: "/dev/sdb"},
|
||||||
|
{Name: "/dev/nvme0"},
|
||||||
|
{Name: "/dev/nvme1"},
|
||||||
|
},
|
||||||
|
expectedDevs: []*DeviceInfo{
|
||||||
|
{Name: "/dev/sdb"},
|
||||||
|
{Name: "/dev/nvme1"},
|
||||||
|
},
|
||||||
|
expectedLength: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "all devices excluded",
|
||||||
|
excludedDevs: map[string]struct{}{
|
||||||
|
"/dev/sda": {},
|
||||||
|
"/dev/sdb": {},
|
||||||
|
},
|
||||||
|
inputDevices: []*DeviceInfo{
|
||||||
|
{Name: "/dev/sda"},
|
||||||
|
{Name: "/dev/sdb"},
|
||||||
|
},
|
||||||
|
expectedDevs: []*DeviceInfo{},
|
||||||
|
expectedLength: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nil devices",
|
||||||
|
excludedDevs: map[string]struct{}{},
|
||||||
|
inputDevices: nil,
|
||||||
|
expectedDevs: []*DeviceInfo{},
|
||||||
|
expectedLength: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "filter nil and empty name devices",
|
||||||
|
excludedDevs: map[string]struct{}{
|
||||||
|
"/dev/sda": {},
|
||||||
|
},
|
||||||
|
inputDevices: []*DeviceInfo{
|
||||||
|
{Name: "/dev/sda"},
|
||||||
|
nil,
|
||||||
|
{Name: ""},
|
||||||
|
{Name: "/dev/sdb"},
|
||||||
|
},
|
||||||
|
expectedDevs: []*DeviceInfo{
|
||||||
|
{Name: "/dev/sdb"},
|
||||||
|
},
|
||||||
|
expectedLength: 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
sm := &SmartManager{
|
||||||
|
excludedDevices: tt.excludedDevs,
|
||||||
|
}
|
||||||
|
|
||||||
|
result := sm.filterExcludedDevices(tt.inputDevices)
|
||||||
|
|
||||||
|
assert.Len(t, result, tt.expectedLength)
|
||||||
|
assert.Equal(t, tt.expectedDevs, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsNvmeControllerPath(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
path string
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
// Controller paths (should return true)
|
||||||
|
{"/dev/nvme0", true},
|
||||||
|
{"/dev/nvme1", true},
|
||||||
|
{"/dev/nvme10", true},
|
||||||
|
{"nvme0", true},
|
||||||
|
|
||||||
|
// Namespace paths (should return false)
|
||||||
|
{"/dev/nvme0n1", false},
|
||||||
|
{"/dev/nvme1n1", false},
|
||||||
|
{"/dev/nvme0n1p1", false},
|
||||||
|
{"nvme0n1", false},
|
||||||
|
|
||||||
|
// Non-NVMe paths (should return false)
|
||||||
|
{"/dev/sda", false},
|
||||||
|
{"/dev/sda1", false},
|
||||||
|
{"/dev/hda", false},
|
||||||
|
{"", false},
|
||||||
|
{"/dev/nvme", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.path, func(t *testing.T) {
|
||||||
|
result := isNvmeControllerPath(tt.path)
|
||||||
|
assert.Equal(t, tt.expected, result, "path: %s", tt.path)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
40
agent/smart_windows.go
Normal file
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
|
||||||
|
}
|
||||||
132
agent/system.go
132
agent/system.go
@@ -2,15 +2,18 @@ package agent
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel"
|
"github.com/henrygd/beszel"
|
||||||
"github.com/henrygd/beszel/agent/battery"
|
"github.com/henrygd/beszel/agent/battery"
|
||||||
|
"github.com/henrygd/beszel/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"
|
||||||
@@ -27,46 +30,84 @@ type prevDisk struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 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 {
|
||||||
|
a.systemDetails.OsName = "FreeBSD"
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
a.systemInfo.Os = system.Linux
|
a.systemDetails.Os = system.Linux
|
||||||
}
|
a.systemDetails.OsName = hostInfo.OperatingSystem
|
||||||
|
if a.systemDetails.OsName == "" {
|
||||||
if a.systemInfo.KernelVersion == "" {
|
if prettyName, err := getOsPrettyName(); err == nil {
|
||||||
a.systemInfo.KernelVersion, _ = host.KernelVersion()
|
a.systemDetails.OsName = prettyName
|
||||||
|
} else {
|
||||||
|
a.systemDetails.OsName = platform
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.systemDetails.Kernel = hostInfo.KernelVersion
|
||||||
|
if a.systemDetails.Kernel == "" {
|
||||||
|
a.systemDetails.Kernel, _ = 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
|
||||||
@@ -98,15 +139,6 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
|
|||||||
slog.Error("Error getting cpu metrics", "err", err)
|
slog.Error("Error getting cpu metrics", "err", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if topProcess, err := getTopCpuProcess(cacheTimeMs); err == nil {
|
|
||||||
if topProcess != nil {
|
|
||||||
topProcess.Percent = twoDecimals(topProcess.Percent)
|
|
||||||
systemStats.TopCpuProcess = topProcess
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
slog.Error("Error getting top cpu process", "err", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// per-core cpu usage
|
// per-core cpu usage
|
||||||
if perCoreUsage, err := getPerCoreCpuUsage(cacheTimeMs); err == nil {
|
if perCoreUsage, err := getPerCoreCpuUsage(cacheTimeMs); err == nil {
|
||||||
systemStats.CpuCoresUsage = perCoreUsage
|
systemStats.CpuCoresUsage = perCoreUsage
|
||||||
@@ -146,7 +178,7 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) 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 = bytesToGigabytes(arcSize)
|
||||||
@@ -204,47 +236,37 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) 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")
|
||||||
}
|
}
|
||||||
|
|||||||
313
agent/systemd.go
Normal file
313
agent/systemd.go
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"maps"
|
||||||
|
"math"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/coreos/go-systemd/v22/dbus"
|
||||||
|
"github.com/henrygd/beszel/internal/entities/systemd"
|
||||||
|
)
|
||||||
|
|
||||||
|
var errNoActiveTime = errors.New("no active time")
|
||||||
|
|
||||||
|
// systemdManager manages the collection of systemd service statistics.
|
||||||
|
type systemdManager struct {
|
||||||
|
sync.Mutex
|
||||||
|
serviceStatsMap map[string]*systemd.Service
|
||||||
|
isRunning bool
|
||||||
|
hasFreshStats bool
|
||||||
|
patterns []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// isSystemdAvailable checks if systemd is used on the system to avoid unnecessary connection attempts (#1548)
|
||||||
|
func isSystemdAvailable() bool {
|
||||||
|
paths := []string{
|
||||||
|
"/run/systemd/system",
|
||||||
|
"/run/dbus/system_bus_socket",
|
||||||
|
"/var/run/dbus/system_bus_socket",
|
||||||
|
}
|
||||||
|
for _, path := range paths {
|
||||||
|
if _, err := os.Stat(path); err == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if data, err := os.ReadFile("/proc/1/comm"); err == nil {
|
||||||
|
return strings.TrimSpace(string(data)) == "systemd"
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// newSystemdManager creates a new systemdManager.
|
||||||
|
func newSystemdManager() (*systemdManager, error) {
|
||||||
|
if skipSystemd, _ := GetEnv("SKIP_SYSTEMD"); skipSystemd == "true" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if systemd is available on the system before attempting connection
|
||||||
|
if !isSystemdAvailable() {
|
||||||
|
slog.Debug("Systemd not available")
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := dbus.NewSystemConnectionContext(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
slog.Debug("Error connecting to systemd", "err", err, "ref", "https://beszel.dev/guide/systemd")
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
manager := &systemdManager{
|
||||||
|
serviceStatsMap: make(map[string]*systemd.Service),
|
||||||
|
patterns: getServicePatterns(),
|
||||||
|
}
|
||||||
|
|
||||||
|
manager.startWorker(conn)
|
||||||
|
|
||||||
|
return manager, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sm *systemdManager) startWorker(conn *dbus.Conn) {
|
||||||
|
if sm.isRunning {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sm.isRunning = true
|
||||||
|
// prime the service stats map with the current services
|
||||||
|
_ = sm.getServiceStats(conn, true)
|
||||||
|
// update the services every 10 minutes
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
time.Sleep(time.Minute * 10)
|
||||||
|
_ = sm.getServiceStats(nil, true)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// getServiceStatsCount returns the number of systemd services.
|
||||||
|
func (sm *systemdManager) getServiceStatsCount() int {
|
||||||
|
return len(sm.serviceStatsMap)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getFailedServiceCount returns the number of systemd services in a failed state.
|
||||||
|
func (sm *systemdManager) getFailedServiceCount() uint16 {
|
||||||
|
sm.Lock()
|
||||||
|
defer sm.Unlock()
|
||||||
|
count := uint16(0)
|
||||||
|
for _, service := range sm.serviceStatsMap {
|
||||||
|
if service.State == systemd.StatusFailed {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
// getServiceStats collects statistics for all running systemd services.
|
||||||
|
func (sm *systemdManager) getServiceStats(conn *dbus.Conn, refresh bool) []*systemd.Service {
|
||||||
|
// start := time.Now()
|
||||||
|
// defer func() {
|
||||||
|
// slog.Info("systemdManager.getServiceStats", "duration", time.Since(start))
|
||||||
|
// }()
|
||||||
|
|
||||||
|
var services []*systemd.Service
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if !refresh {
|
||||||
|
// return nil
|
||||||
|
sm.Lock()
|
||||||
|
defer sm.Unlock()
|
||||||
|
for _, service := range sm.serviceStatsMap {
|
||||||
|
services = append(services, service)
|
||||||
|
}
|
||||||
|
sm.hasFreshStats = false
|
||||||
|
return services
|
||||||
|
}
|
||||||
|
|
||||||
|
if conn == nil || !conn.Connected() {
|
||||||
|
conn, err = dbus.NewSystemConnectionContext(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
units, err := conn.ListUnitsByPatternsContext(context.Background(), []string{"loaded"}, sm.patterns)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Error listing systemd service units", "err", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track which units are currently present to remove stale entries
|
||||||
|
currentUnits := make(map[string]struct{}, len(units))
|
||||||
|
|
||||||
|
for _, unit := range units {
|
||||||
|
currentUnits[unit.Name] = struct{}{}
|
||||||
|
service, err := sm.updateServiceStats(conn, unit)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
services = append(services, service)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove services that no longer exist in systemd
|
||||||
|
sm.Lock()
|
||||||
|
for unitName := range sm.serviceStatsMap {
|
||||||
|
if _, exists := currentUnits[unitName]; !exists {
|
||||||
|
delete(sm.serviceStatsMap, unitName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sm.Unlock()
|
||||||
|
|
||||||
|
sm.hasFreshStats = true
|
||||||
|
return services
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateServiceStats updates the statistics for a single systemd service.
|
||||||
|
func (sm *systemdManager) updateServiceStats(conn *dbus.Conn, unit dbus.UnitStatus) (*systemd.Service, error) {
|
||||||
|
sm.Lock()
|
||||||
|
defer sm.Unlock()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// if service has never been active (no active since time), skip it
|
||||||
|
if activeEnterTsProp, err := conn.GetUnitTypePropertyContext(ctx, unit.Name, "Unit", "ActiveEnterTimestamp"); err == nil {
|
||||||
|
if ts, ok := activeEnterTsProp.Value.Value().(uint64); !ok || ts == 0 || ts == math.MaxUint64 {
|
||||||
|
return nil, errNoActiveTime
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
service, serviceExists := sm.serviceStatsMap[unit.Name]
|
||||||
|
if !serviceExists {
|
||||||
|
service = &systemd.Service{Name: unescapeServiceName(strings.TrimSuffix(unit.Name, ".service"))}
|
||||||
|
sm.serviceStatsMap[unit.Name] = service
|
||||||
|
}
|
||||||
|
|
||||||
|
memPeak := service.MemPeak
|
||||||
|
if memPeakProp, err := conn.GetUnitTypePropertyContext(ctx, unit.Name, "Service", "MemoryPeak"); err == nil {
|
||||||
|
// If memPeak is MaxUint64 the api is saying it's not available
|
||||||
|
if v, ok := memPeakProp.Value.Value().(uint64); ok && v != math.MaxUint64 {
|
||||||
|
memPeak = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var memUsage uint64
|
||||||
|
if memProp, err := conn.GetUnitTypePropertyContext(ctx, unit.Name, "Service", "MemoryCurrent"); err == nil {
|
||||||
|
// If memUsage is MaxUint64 the api is saying it's not available
|
||||||
|
if v, ok := memProp.Value.Value().(uint64); ok && v != math.MaxUint64 {
|
||||||
|
memUsage = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
service.State = systemd.ParseServiceStatus(unit.ActiveState)
|
||||||
|
service.Sub = systemd.ParseServiceSubState(unit.SubState)
|
||||||
|
|
||||||
|
// some systems always return 0 for mem peak, so we should update the peak if the current usage is greater
|
||||||
|
if memUsage > memPeak {
|
||||||
|
memPeak = memUsage
|
||||||
|
}
|
||||||
|
|
||||||
|
var cpuUsage uint64
|
||||||
|
if cpuProp, err := conn.GetUnitTypePropertyContext(ctx, unit.Name, "Service", "CPUUsageNSec"); err == nil {
|
||||||
|
if v, ok := cpuProp.Value.Value().(uint64); ok {
|
||||||
|
cpuUsage = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
service.Mem = memUsage
|
||||||
|
if memPeak > service.MemPeak {
|
||||||
|
service.MemPeak = memPeak
|
||||||
|
}
|
||||||
|
service.UpdateCPUPercent(cpuUsage)
|
||||||
|
|
||||||
|
return service, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getServiceDetails collects extended information for a specific systemd service.
|
||||||
|
func (sm *systemdManager) getServiceDetails(serviceName string) (systemd.ServiceDetails, error) {
|
||||||
|
conn, err := dbus.NewSystemConnectionContext(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
unitName := serviceName
|
||||||
|
if !strings.HasSuffix(unitName, ".service") {
|
||||||
|
unitName += ".service"
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
props, err := conn.GetUnitPropertiesContext(ctx, unitName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start with all unit properties
|
||||||
|
details := make(systemd.ServiceDetails)
|
||||||
|
maps.Copy(details, props)
|
||||||
|
|
||||||
|
// // Add service-specific properties
|
||||||
|
servicePropNames := []string{
|
||||||
|
"MainPID", "ExecMainPID", "TasksCurrent", "TasksMax",
|
||||||
|
"MemoryCurrent", "MemoryPeak", "MemoryLimit", "CPUUsageNSec",
|
||||||
|
"NRestarts", "ExecMainStartTimestampRealtime", "Result",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, propName := range servicePropNames {
|
||||||
|
if variant, err := conn.GetUnitTypePropertyContext(ctx, unitName, "Service", propName); err == nil {
|
||||||
|
value := variant.Value.Value()
|
||||||
|
// Check if the value is MaxUint64, which indicates unlimited/infinite
|
||||||
|
if uint64Value, ok := value.(uint64); ok && uint64Value == math.MaxUint64 {
|
||||||
|
// Set to nil to indicate unlimited - frontend will handle this appropriately
|
||||||
|
details[propName] = nil
|
||||||
|
} else {
|
||||||
|
details[propName] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return details, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// unescapeServiceName unescapes systemd service names that contain C-style escape sequences like \x2d
|
||||||
|
func unescapeServiceName(name string) string {
|
||||||
|
if !strings.Contains(name, "\\x") {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
unescaped, err := strconv.Unquote("\"" + name + "\"")
|
||||||
|
if err != nil {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
return unescaped
|
||||||
|
}
|
||||||
|
|
||||||
|
// getServicePatterns returns the list of service patterns to match.
|
||||||
|
// It reads from the SERVICE_PATTERNS environment variable if set,
|
||||||
|
// otherwise defaults to "*service".
|
||||||
|
func getServicePatterns() []string {
|
||||||
|
patterns := []string{}
|
||||||
|
if envPatterns, _ := GetEnv("SERVICE_PATTERNS"); envPatterns != "" {
|
||||||
|
for pattern := range strings.SplitSeq(envPatterns, ",") {
|
||||||
|
pattern = strings.TrimSpace(pattern)
|
||||||
|
if pattern == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(pattern, ".service") {
|
||||||
|
pattern += ".service"
|
||||||
|
}
|
||||||
|
patterns = append(patterns, pattern)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(patterns) == 0 {
|
||||||
|
patterns = []string{"*.service"}
|
||||||
|
}
|
||||||
|
return patterns
|
||||||
|
}
|
||||||
38
agent/systemd_nonlinux.go
Normal file
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)
|
||||||
|
}
|
||||||
188
agent/systemd_test.go
Normal file
188
agent/systemd_test.go
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
//go:build linux && testing
|
||||||
|
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUnescapeServiceName(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"nginx.service", "nginx.service"}, // No escaping needed
|
||||||
|
{"test\\x2dwith\\x2ddashes.service", "test-with-dashes.service"}, // \x2d is dash
|
||||||
|
{"service\\x20with\\x20spaces.service", "service with spaces.service"}, // \x20 is space
|
||||||
|
{"mixed\\x2dand\\x2dnormal", "mixed-and-normal"}, // Mixed escaped and normal
|
||||||
|
{"no-escape-here", "no-escape-here"}, // No escape sequences
|
||||||
|
{"", ""}, // Empty string
|
||||||
|
{"\\x2d\\x2d", "--"}, // Multiple escapes
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.input, func(t *testing.T) {
|
||||||
|
result := unescapeServiceName(test.input)
|
||||||
|
assert.Equal(t, test.expected, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnescapeServiceNameInvalid(t *testing.T) {
|
||||||
|
// Test invalid escape sequences - should return original string
|
||||||
|
invalidInputs := []string{
|
||||||
|
"invalid\\x", // Incomplete escape
|
||||||
|
"invalid\\xZZ", // Invalid hex
|
||||||
|
"invalid\\x2", // Incomplete hex
|
||||||
|
"invalid\\xyz", // Not a valid escape
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, input := range invalidInputs {
|
||||||
|
t.Run(input, func(t *testing.T) {
|
||||||
|
result := unescapeServiceName(input)
|
||||||
|
assert.Equal(t, input, result, "Invalid escape sequences should return original string")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsSystemdAvailable(t *testing.T) {
|
||||||
|
// Note: This test's result will vary based on the actual system running the tests
|
||||||
|
// On systems with systemd, it should return true
|
||||||
|
// On systems without systemd, it should return false
|
||||||
|
result := isSystemdAvailable()
|
||||||
|
|
||||||
|
// Check if either the /run/systemd/system directory exists or PID 1 is systemd
|
||||||
|
runSystemdExists := false
|
||||||
|
if _, err := os.Stat("/run/systemd/system"); err == nil {
|
||||||
|
runSystemdExists = true
|
||||||
|
}
|
||||||
|
|
||||||
|
pid1IsSystemd := false
|
||||||
|
if data, err := os.ReadFile("/proc/1/comm"); err == nil {
|
||||||
|
pid1IsSystemd = strings.TrimSpace(string(data)) == "systemd"
|
||||||
|
}
|
||||||
|
|
||||||
|
expected := runSystemdExists || pid1IsSystemd
|
||||||
|
|
||||||
|
assert.Equal(t, expected, result, "isSystemdAvailable should correctly detect systemd presence")
|
||||||
|
|
||||||
|
// Log the result for informational purposes
|
||||||
|
if result {
|
||||||
|
t.Log("Systemd is available on this system")
|
||||||
|
} else {
|
||||||
|
t.Log("Systemd is not available on this system")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetServicePatterns(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
prefixedEnv string
|
||||||
|
unprefixedEnv string
|
||||||
|
expected []string
|
||||||
|
cleanupEnvVars bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "default when no env var set",
|
||||||
|
prefixedEnv: "",
|
||||||
|
unprefixedEnv: "",
|
||||||
|
expected: []string{"*.service"},
|
||||||
|
cleanupEnvVars: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single pattern with prefixed env",
|
||||||
|
prefixedEnv: "nginx",
|
||||||
|
unprefixedEnv: "",
|
||||||
|
expected: []string{"nginx.service"},
|
||||||
|
cleanupEnvVars: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single pattern with unprefixed env",
|
||||||
|
prefixedEnv: "",
|
||||||
|
unprefixedEnv: "nginx",
|
||||||
|
expected: []string{"nginx.service"},
|
||||||
|
cleanupEnvVars: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "prefixed env takes precedence",
|
||||||
|
prefixedEnv: "nginx",
|
||||||
|
unprefixedEnv: "apache",
|
||||||
|
expected: []string{"nginx.service"},
|
||||||
|
cleanupEnvVars: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple patterns",
|
||||||
|
prefixedEnv: "nginx,apache,postgresql",
|
||||||
|
unprefixedEnv: "",
|
||||||
|
expected: []string{"nginx.service", "apache.service", "postgresql.service"},
|
||||||
|
cleanupEnvVars: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "patterns with .service suffix",
|
||||||
|
prefixedEnv: "nginx.service,apache.service",
|
||||||
|
unprefixedEnv: "",
|
||||||
|
expected: []string{"nginx.service", "apache.service"},
|
||||||
|
cleanupEnvVars: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mixed patterns with and without suffix",
|
||||||
|
prefixedEnv: "nginx.service,apache,postgresql.service",
|
||||||
|
unprefixedEnv: "",
|
||||||
|
expected: []string{"nginx.service", "apache.service", "postgresql.service"},
|
||||||
|
cleanupEnvVars: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "patterns with whitespace",
|
||||||
|
prefixedEnv: " nginx , apache , postgresql ",
|
||||||
|
unprefixedEnv: "",
|
||||||
|
expected: []string{"nginx.service", "apache.service", "postgresql.service"},
|
||||||
|
cleanupEnvVars: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty patterns are skipped",
|
||||||
|
prefixedEnv: "nginx,,apache, ,postgresql",
|
||||||
|
unprefixedEnv: "",
|
||||||
|
expected: []string{"nginx.service", "apache.service", "postgresql.service"},
|
||||||
|
cleanupEnvVars: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wildcard pattern",
|
||||||
|
prefixedEnv: "*nginx*,*apache*",
|
||||||
|
unprefixedEnv: "",
|
||||||
|
expected: []string{"*nginx*.service", "*apache*.service"},
|
||||||
|
cleanupEnvVars: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// Clean up any existing env vars
|
||||||
|
os.Unsetenv("BESZEL_AGENT_SERVICE_PATTERNS")
|
||||||
|
os.Unsetenv("SERVICE_PATTERNS")
|
||||||
|
|
||||||
|
// Set up environment variables
|
||||||
|
if tt.prefixedEnv != "" {
|
||||||
|
os.Setenv("BESZEL_AGENT_SERVICE_PATTERNS", tt.prefixedEnv)
|
||||||
|
}
|
||||||
|
if tt.unprefixedEnv != "" {
|
||||||
|
os.Setenv("SERVICE_PATTERNS", tt.unprefixedEnv)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run the function
|
||||||
|
result := getServicePatterns()
|
||||||
|
|
||||||
|
// Verify results
|
||||||
|
assert.Equal(t, tt.expected, result, "Patterns should match expected values")
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
if tt.cleanupEnvVars {
|
||||||
|
os.Unsetenv("BESZEL_AGENT_SERVICE_PATTERNS")
|
||||||
|
os.Unsetenv("SERVICE_PATTERNS")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
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
|
||||||
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" : []
|
||||||
|
}
|
||||||
|
]
|
||||||
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"
|
||||||
)
|
)
|
||||||
@@ -65,9 +63,9 @@ func detectRestarter() restarter {
|
|||||||
if path, err := exec.LookPath("rc-service"); err == nil {
|
if path, err := exec.LookPath("rc-service"); err == nil {
|
||||||
return &openRCRestarter{cmd: path}
|
return &openRCRestarter{cmd: path}
|
||||||
}
|
}
|
||||||
if path, err := exec.LookPath("procd"); err == nil {
|
if path, err := exec.LookPath("procd"); err == nil {
|
||||||
return &openWRTRestarter{cmd: path}
|
return &openWRTRestarter{cmd: path}
|
||||||
}
|
}
|
||||||
if path, err := exec.LookPath("service"); err == nil {
|
if path, err := exec.LookPath("service"); err == nil {
|
||||||
if runtime.GOOS == "freebsd" {
|
if runtime.GOOS == "freebsd" {
|
||||||
return &freeBSDRestarter{cmd: path}
|
return &freeBSDRestarter{cmd: path}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import "github.com/blang/semver"
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// Version is the current version of the application.
|
// Version is the current version of the application.
|
||||||
Version = "0.15.3"
|
Version = "0.18.4"
|
||||||
// AppName is the name of the application.
|
// AppName is the name of the application.
|
||||||
AppName = "beszel"
|
AppName = "beszel"
|
||||||
)
|
)
|
||||||
|
|||||||
57
go.mod
57
go.mod
@@ -1,66 +1,75 @@
|
|||||||
module github.com/henrygd/beszel
|
module github.com/henrygd/beszel
|
||||||
|
|
||||||
go 1.25.3
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/blang/semver v3.5.1+incompatible
|
github.com/blang/semver v3.5.1+incompatible
|
||||||
|
github.com/coreos/go-systemd/v22 v22.7.0
|
||||||
github.com/distatus/battery v0.11.0
|
github.com/distatus/battery v0.11.0
|
||||||
|
github.com/ebitengine/purego v0.9.1
|
||||||
github.com/fxamacker/cbor/v2 v2.9.0
|
github.com/fxamacker/cbor/v2 v2.9.0
|
||||||
github.com/gliderlabs/ssh v0.3.8
|
github.com/gliderlabs/ssh v0.3.8
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/luthermonson/go-proxmox v0.4.0
|
||||||
github.com/lxzan/gws v1.8.9
|
github.com/lxzan/gws v1.8.9
|
||||||
github.com/nicholas-fedor/shoutrrr v0.11.1
|
github.com/nicholas-fedor/shoutrrr v0.13.2
|
||||||
github.com/pocketbase/dbx v1.11.0
|
github.com/pocketbase/dbx v1.12.0
|
||||||
github.com/pocketbase/pocketbase v0.31.0
|
github.com/pocketbase/pocketbase v0.36.4
|
||||||
github.com/shirou/gopsutil/v4 v4.25.10
|
github.com/shirou/gopsutil/v4 v4.26.1
|
||||||
github.com/spf13/cast v1.10.0
|
github.com/spf13/cast v1.10.0
|
||||||
github.com/spf13/cobra v1.10.1
|
github.com/spf13/cobra v1.10.2
|
||||||
github.com/spf13/pflag v1.0.10
|
github.com/spf13/pflag v1.0.10
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
golang.org/x/crypto v0.43.0
|
golang.org/x/crypto v0.48.0
|
||||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546
|
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa
|
||||||
|
golang.org/x/sys v0.41.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
|
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
|
||||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
|
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
|
||||||
|
github.com/buger/goterm v1.0.4 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
github.com/disintegration/imaging v1.6.2 // indirect
|
github.com/disintegration/imaging v1.6.2 // indirect
|
||||||
|
github.com/diskfs/go-diskfs v1.7.0 // indirect
|
||||||
|
github.com/djherbis/times v1.6.0 // indirect
|
||||||
github.com/dolthub/maphash v0.1.0 // indirect
|
github.com/dolthub/maphash v0.1.0 // indirect
|
||||||
github.com/domodwyer/mailyak/v3 v3.6.2 // indirect
|
github.com/domodwyer/mailyak/v3 v3.6.2 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/ebitengine/purego v0.9.0 // indirect
|
|
||||||
github.com/fatih/color v1.18.0 // indirect
|
github.com/fatih/color v1.18.0 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.11 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||||
github.com/ganigeorgiev/fexpr v0.5.0 // indirect
|
github.com/ganigeorgiev/fexpr v0.5.0 // indirect
|
||||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||||
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect
|
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect
|
||||||
github.com/go-sql-driver/mysql v1.9.1 // indirect
|
github.com/go-sql-driver/mysql v1.9.1 // indirect
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
|
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||||
|
github.com/gorilla/websocket v1.4.2 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/klauspost/compress v1.18.1 // indirect
|
github.com/jinzhu/copier v0.3.4 // indirect
|
||||||
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect
|
github.com/klauspost/compress v1.18.4 // indirect
|
||||||
|
github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 // indirect
|
||||||
|
github.com/magefile/mage v1.14.0 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/tklauser/go-sysconf v0.3.15 // indirect
|
github.com/tklauser/go-sysconf v0.3.16 // indirect
|
||||||
github.com/tklauser/numcpus v0.10.0 // indirect
|
github.com/tklauser/numcpus v0.11.0 // indirect
|
||||||
github.com/x448/float16 v0.8.4 // indirect
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
golang.org/x/image v0.32.0 // indirect
|
golang.org/x/image v0.36.0 // indirect
|
||||||
golang.org/x/net v0.46.0 // indirect
|
golang.org/x/net v0.50.0 // indirect
|
||||||
golang.org/x/oauth2 v0.32.0 // indirect
|
golang.org/x/oauth2 v0.35.0 // indirect
|
||||||
golang.org/x/sync v0.17.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.37.0 // indirect
|
golang.org/x/term v0.40.0 // indirect
|
||||||
golang.org/x/term v0.36.0 // indirect
|
golang.org/x/text v0.34.0 // indirect
|
||||||
golang.org/x/text v0.30.0 // indirect
|
|
||||||
howett.net/plist v1.0.1 // indirect
|
howett.net/plist v1.0.1 // indirect
|
||||||
modernc.org/libc v1.66.10 // indirect
|
modernc.org/libc v1.67.6 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // indirect
|
modernc.org/memory v1.11.0 // indirect
|
||||||
modernc.org/sqlite v1.39.1 // indirect
|
modernc.org/sqlite v1.45.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
170
go.sum
170
go.sum
@@ -2,6 +2,8 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
|||||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||||
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
|
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
|
||||||
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||||
|
github.com/anchore/go-lzo v0.1.0 h1:NgAacnzqPeGH49Ky19QKLBZEuFRqtTG9cdaucc3Vncs=
|
||||||
|
github.com/anchore/go-lzo v0.1.0/go.mod h1:3kLx0bve2oN1iDwgM1U5zGku1Tfbdb0No5qp1eL1fIk=
|
||||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
|
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
|
||||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
|
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
|
||||||
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=
|
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=
|
||||||
@@ -9,30 +11,40 @@ github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3d
|
|||||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
|
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
|
||||||
github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ=
|
github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ=
|
||||||
github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
|
github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
|
||||||
|
github.com/buger/goterm v1.0.4 h1:Z9YvGmOih81P0FbVtEYTFF6YsSgxSUKEhf/f9bTMXbY=
|
||||||
|
github.com/buger/goterm v1.0.4/go.mod h1:HiFWV3xnkolgrBV3mY8m0X0Pumt4zg4QhbdOzQtB8tE=
|
||||||
|
github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA=
|
||||||
|
github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||||
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||||
|
github.com/diskfs/go-diskfs v1.7.0 h1:vonWmt5CMowXwUc79jWyGrf2DIMeoOjkLlMnQYGVOs8=
|
||||||
|
github.com/diskfs/go-diskfs v1.7.0/go.mod h1:LhQyXqOugWFRahYUSw47NyZJPezFzB9UELwhpszLP/k=
|
||||||
github.com/distatus/battery v0.11.0 h1:KJk89gz90Iq/wJtbjjM9yUzBXV+ASV/EG2WOOL7N8lc=
|
github.com/distatus/battery v0.11.0 h1:KJk89gz90Iq/wJtbjjM9yUzBXV+ASV/EG2WOOL7N8lc=
|
||||||
github.com/distatus/battery v0.11.0/go.mod h1:KmVkE8A8hpIX4T78QRdMktYpEp35QfOL8A8dwZBxq2k=
|
github.com/distatus/battery v0.11.0/go.mod h1:KmVkE8A8hpIX4T78QRdMktYpEp35QfOL8A8dwZBxq2k=
|
||||||
|
github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=
|
||||||
|
github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0=
|
||||||
github.com/dolthub/maphash v0.1.0 h1:bsQ7JsF4FkkWyrP3oCnFJgrCUAFbFf3kOl4L/QxPDyQ=
|
github.com/dolthub/maphash v0.1.0 h1:bsQ7JsF4FkkWyrP3oCnFJgrCUAFbFf3kOl4L/QxPDyQ=
|
||||||
github.com/dolthub/maphash v0.1.0/go.mod h1:gkg4Ch4CdCDu5h6PMriVLawB7koZ+5ijb9puGMV50a4=
|
github.com/dolthub/maphash v0.1.0/go.mod h1:gkg4Ch4CdCDu5h6PMriVLawB7koZ+5ijb9puGMV50a4=
|
||||||
github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8=
|
github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8=
|
||||||
github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c=
|
github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/ebitengine/purego v0.9.0 h1:mh0zpKBIXDceC63hpvPuGLiJ8ZAa3DfrFTudmfi8A4k=
|
github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
|
||||||
github.com/ebitengine/purego v0.9.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||||
|
github.com/elliotwutingfeng/asciiset v0.0.0-20230602022725-51bbb787efab h1:h1UgjJdAAhj+uPL68n7XASS6bU+07ZX1WJvVS2eyoeY=
|
||||||
|
github.com/elliotwutingfeng/asciiset v0.0.0-20230602022725-51bbb787efab/go.mod h1:GLo/8fDswSAniFG+BFIaiSPcK610jyzgEhWYPQwuQdw=
|
||||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||||
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
|
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||||
github.com/ganigeorgiev/fexpr v0.5.0 h1:XA9JxtTE/Xm+g/JFI6RfZEHSiQlk+1glLvRK1Lpv/Tk=
|
github.com/ganigeorgiev/fexpr v0.5.0 h1:XA9JxtTE/Xm+g/JFI6RfZEHSiQlk+1glLvRK1Lpv/Tk=
|
||||||
github.com/ganigeorgiev/fexpr v0.5.0/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
|
github.com/ganigeorgiev/fexpr v0.5.0/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
|
||||||
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
||||||
@@ -49,49 +61,71 @@ github.com/go-sql-driver/mysql v1.9.1 h1:FrjNGn/BsJQjVRuSa8CBrM5BWA9BWoXXat3KrtS
|
|||||||
github.com/go-sql-driver/mysql v1.9.1/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
github.com/go-sql-driver/mysql v1.9.1/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||||
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||||
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||||
|
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
||||||
|
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/pprof v0.0.0-20251007162407-5df77e3f7d1d h1:KJIErDwbSHjnp/SGzE5ed8Aol7JsKiI5X7yWKAtzhM0=
|
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc=
|
||||||
github.com/google/pprof v0.0.0-20251007162407-5df77e3f7d1d/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U=
|
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
|
||||||
|
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
|
github.com/h2non/gock v1.2.0 h1:K6ol8rfrRkUOefooBC8elXoaNGYkpp7y2qcxGG6BzUE=
|
||||||
|
github.com/h2non/gock v1.2.0/go.mod h1:tNhoxHYW2W42cYkYb1WqzdbYIieALC99kpYr7rH/BQk=
|
||||||
|
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw=
|
||||||
|
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
github.com/jarcoal/httpmock v1.4.1 h1:0Ju+VCFuARfFlhVXFc2HxlcQkfB+Xq12/EotHko+x2A=
|
github.com/jarcoal/httpmock v1.4.1 h1:0Ju+VCFuARfFlhVXFc2HxlcQkfB+Xq12/EotHko+x2A=
|
||||||
github.com/jarcoal/httpmock v1.4.1/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0=
|
github.com/jarcoal/httpmock v1.4.1/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0=
|
||||||
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||||
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
|
github.com/jinzhu/copier v0.3.4 h1:mfU6jI9PtCeUjkjQ322dlff9ELjGDu975C2p/nrubVI=
|
||||||
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
|
github.com/jinzhu/copier v0.3.4/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg=
|
||||||
|
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
|
||||||
|
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 h1:PwQumkgq4/acIiZhtifTV5OUqqiP82UAl0h87xj/l9k=
|
github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 h1:PTw+yKnXcOFCR6+8hHTyWBeQ/P4Nb7dd4/0ohEcWQuM=
|
||||||
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||||
|
github.com/luthermonson/go-proxmox v0.4.0 h1:LKXpG9d64zTaQF79wV0kfOnnSwIcdG39m7sc4ga+XZs=
|
||||||
|
github.com/luthermonson/go-proxmox v0.4.0/go.mod h1:U6dAkJ+iiwaeb1g/LMWpWuWN4nmvWeXhmoMuYJMumS4=
|
||||||
github.com/lxzan/gws v1.8.9 h1:VU3SGUeWlQrEwfUSfokcZep8mdg/BrUF+y73YYshdBM=
|
github.com/lxzan/gws v1.8.9 h1:VU3SGUeWlQrEwfUSfokcZep8mdg/BrUF+y73YYshdBM=
|
||||||
github.com/lxzan/gws v1.8.9/go.mod h1:d9yHaR1eDTBHagQC6KY7ycUOaz5KWeqQtP3xu7aMK8Y=
|
github.com/lxzan/gws v1.8.9/go.mod h1:d9yHaR1eDTBHagQC6KY7ycUOaz5KWeqQtP3xu7aMK8Y=
|
||||||
|
github.com/magefile/mage v1.14.0 h1:6QDX3g6z1YvJ4olPhT1wksUcSa/V0a1B+pJb73fBjyo=
|
||||||
|
github.com/magefile/mage v1.14.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
|
||||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
github.com/nicholas-fedor/shoutrrr v0.11.1 h1:DND1gW8UM8GYG8c0bUZ5fPFAnm3id8noPdfaFBUmezk=
|
github.com/nicholas-fedor/shoutrrr v0.13.2 h1:hfsYBIqSFYGg92pZP5CXk/g7/OJIkLYmiUnRl+AD1IA=
|
||||||
github.com/nicholas-fedor/shoutrrr v0.11.1/go.mod h1:RZuSZSEaSimS47zTOLXb6HJDwLjDHiuJ9SrzxsDcWaQ=
|
github.com/nicholas-fedor/shoutrrr v0.13.2/go.mod h1:ZqzV3gY/Wj6AvWs1etlO7+yKbh4iptSbeL8avBpMQbA=
|
||||||
github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns=
|
github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI=
|
||||||
github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
|
github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE=
|
||||||
github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
|
github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28=
|
||||||
github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
|
github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg=
|
||||||
|
github.com/pierrec/lz4/v4 v4.1.17 h1:kV4Ip+/hUBC+8T6+2EgburRtkE9ef4nbY3f4dFhGjMc=
|
||||||
|
github.com/pierrec/lz4/v4 v4.1.17/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||||
|
github.com/pkg/xattr v0.4.9 h1:5883YPCtkSd8LFbs13nXplj9g9tlrwoJRjgpgMu1/fE=
|
||||||
|
github.com/pkg/xattr v0.4.9/go.mod h1:di8WF84zAKk8jzR1UBTEWh9AUlIZZ7M/JNt8e9B6ktU=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/pocketbase/dbx v1.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU=
|
github.com/pocketbase/dbx v1.12.0 h1:/oLErM+A0b4xI0PWTGPqSDVjzix48PqI/bng2l0PzoA=
|
||||||
github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
github.com/pocketbase/dbx v1.12.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
||||||
github.com/pocketbase/pocketbase v0.31.0 h1:JaOtSDytdA+a0r4689Mrjda4rmq+BaHgEJkPeOIydms=
|
github.com/pocketbase/pocketbase v0.36.4 h1:zTjRZbp2WfTOJJfb+pFRWa200UaQwxZYt8RzkFMlAZ4=
|
||||||
github.com/pocketbase/pocketbase v0.31.0/go.mod h1:p4a83n+DlBcTvvqhC7QDy0KDmQ2la2c6dgxdIBWwKiE=
|
github.com/pocketbase/pocketbase v0.36.4/go.mod h1:9CiezhRudd9FZGa5xZa53QZBTNxc5vvw/FGG+diAECI=
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
@@ -99,12 +133,14 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qq
|
|||||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/shirou/gopsutil/v4 v4.25.10 h1:at8lk/5T1OgtuCp+AwrDofFRjnvosn0nkN2OLQ6g8tA=
|
github.com/shirou/gopsutil/v4 v4.26.1 h1:TOkEyriIXk2HX9d4isZJtbjXbEjf5qyKPAzbzY0JWSo=
|
||||||
github.com/shirou/gopsutil/v4 v4.25.10/go.mod h1:+kSwyC8DRUD9XXEHCAFjK+0nuArFJM0lva+StQAcskM=
|
github.com/shirou/gopsutil/v4 v4.26.1/go.mod h1:medLI9/UNAb0dOI9Q3/7yWSqKkj00u+1tgY8nvv41pc=
|
||||||
|
github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af h1:Sp5TG9f7K39yfB+If0vjp97vuT74F72r8hfRpP8jLU0=
|
||||||
|
github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||||
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
|
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||||
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
|
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
@@ -112,10 +148,12 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
|
|||||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
|
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
|
||||||
github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
|
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
|
||||||
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
|
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
|
||||||
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
|
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
|
||||||
|
github.com/ulikunitz/xz v0.5.11 h1:kpFauv27b6ynzBNT/Xy+1k+fK4WswhN/6PN5WhFAGw8=
|
||||||
|
github.com/ulikunitz/xz v0.5.11/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||||
@@ -123,62 +161,66 @@ github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQ
|
|||||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
|
||||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
|
||||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||||
golang.org/x/image v0.32.0 h1:6lZQWq75h7L5IWNk0r+SCpUJ6tUVd3v4ZHnbRKLkUDQ=
|
golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
|
||||||
golang.org/x/image v0.32.0/go.mod h1:/R37rrQmKXtO6tYXAjtDLwQgFLHmhW+V6ayXlxzP2Pc=
|
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
|
||||||
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
|
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||||
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
|
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||||
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||||
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||||
golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
|
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
||||||
golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210331175145-43e1dd70ce54/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
|
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||||
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
|
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||||
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||||
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
|
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
|
||||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM=
|
howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM=
|
||||||
howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
|
howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
|
||||||
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
|
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||||
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
|
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
|
||||||
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
|
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
|
||||||
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
||||||
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
|
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
|
||||||
|
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
|
modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI=
|
||||||
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
|
modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=
|
||||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
@@ -187,8 +229,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
|||||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
modernc.org/sqlite v1.39.1 h1:H+/wGFzuSCIEVCvXYVHX5RQglwhMOvtHSv+VtidL2r4=
|
modernc.org/sqlite v1.45.0 h1:r51cSGzKpbptxnby+EIIz5fop4VuE4qFoVEjNvWoObs=
|
||||||
modernc.org/sqlite v1.39.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
|
modernc.org/sqlite v1.45.0/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
|
||||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ type AlertManager struct {
|
|||||||
|
|
||||||
type AlertMessageData struct {
|
type AlertMessageData struct {
|
||||||
UserID string
|
UserID string
|
||||||
|
SystemID string
|
||||||
Title string
|
Title string
|
||||||
Message string
|
Message string
|
||||||
Link string
|
Link string
|
||||||
@@ -40,13 +41,19 @@ type UserNotificationSettings struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type SystemAlertStats struct {
|
type SystemAlertStats struct {
|
||||||
Cpu float64 `json:"cpu"`
|
Cpu float64 `json:"cpu"`
|
||||||
Mem float64 `json:"mp"`
|
Mem float64 `json:"mp"`
|
||||||
Disk float64 `json:"dp"`
|
Disk float64 `json:"dp"`
|
||||||
NetSent float64 `json:"ns"`
|
NetSent float64 `json:"ns"`
|
||||||
NetRecv float64 `json:"nr"`
|
NetRecv float64 `json:"nr"`
|
||||||
Temperatures map[string]float32 `json:"t"`
|
GPU map[string]SystemAlertGPUData `json:"g"`
|
||||||
LoadAvg [3]float64 `json:"la"`
|
Temperatures map[string]float32 `json:"t"`
|
||||||
|
LoadAvg [3]float64 `json:"la"`
|
||||||
|
Battery [2]uint8 `json:"bat"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SystemAlertGPUData struct {
|
||||||
|
Usage float64 `json:"u"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SystemAlertData struct {
|
type SystemAlertData struct {
|
||||||
@@ -72,7 +79,6 @@ var supportsTitle = map[string]struct{}{
|
|||||||
"ifttt": {},
|
"ifttt": {},
|
||||||
"join": {},
|
"join": {},
|
||||||
"lark": {},
|
"lark": {},
|
||||||
"matrix": {},
|
|
||||||
"ntfy": {},
|
"ntfy": {},
|
||||||
"opsgenie": {},
|
"opsgenie": {},
|
||||||
"pushbullet": {},
|
"pushbullet": {},
|
||||||
@@ -99,10 +105,84 @@ func NewAlertManager(app hubLike) *AlertManager {
|
|||||||
func (am *AlertManager) bindEvents() {
|
func (am *AlertManager) bindEvents() {
|
||||||
am.hub.OnRecordAfterUpdateSuccess("alerts").BindFunc(updateHistoryOnAlertUpdate)
|
am.hub.OnRecordAfterUpdateSuccess("alerts").BindFunc(updateHistoryOnAlertUpdate)
|
||||||
am.hub.OnRecordAfterDeleteSuccess("alerts").BindFunc(resolveHistoryOnAlertDelete)
|
am.hub.OnRecordAfterDeleteSuccess("alerts").BindFunc(resolveHistoryOnAlertDelete)
|
||||||
|
am.hub.OnRecordAfterUpdateSuccess("smart_devices").BindFunc(am.handleSmartDeviceAlert)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsNotificationSilenced checks if a notification should be silenced based on configured quiet hours
|
||||||
|
func (am *AlertManager) IsNotificationSilenced(userID, systemID string) bool {
|
||||||
|
// Query for quiet hours windows that match this user and system
|
||||||
|
// Include both global windows (system is null/empty) and system-specific windows
|
||||||
|
var filter string
|
||||||
|
var params dbx.Params
|
||||||
|
|
||||||
|
if systemID == "" {
|
||||||
|
// If no systemID provided, only check global windows
|
||||||
|
filter = "user={:user} AND system=''"
|
||||||
|
params = dbx.Params{"user": userID}
|
||||||
|
} else {
|
||||||
|
// Check both global and system-specific windows
|
||||||
|
filter = "user={:user} AND (system='' OR system={:system})"
|
||||||
|
params = dbx.Params{
|
||||||
|
"user": userID,
|
||||||
|
"system": systemID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
quietHourWindows, err := am.hub.FindAllRecords("quiet_hours", dbx.NewExp(filter, params))
|
||||||
|
if err != nil || len(quietHourWindows) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
for _, window := range quietHourWindows {
|
||||||
|
windowType := window.GetString("type")
|
||||||
|
start := window.GetDateTime("start").Time()
|
||||||
|
end := window.GetDateTime("end").Time()
|
||||||
|
|
||||||
|
if windowType == "daily" {
|
||||||
|
// For daily recurring windows, extract just the time portion and compare
|
||||||
|
// The start/end are stored as full datetime but we only care about HH:MM
|
||||||
|
startHour, startMin, _ := start.Clock()
|
||||||
|
endHour, endMin, _ := end.Clock()
|
||||||
|
nowHour, nowMin, _ := now.Clock()
|
||||||
|
|
||||||
|
// Convert to minutes since midnight for easier comparison
|
||||||
|
startMinutes := startHour*60 + startMin
|
||||||
|
endMinutes := endHour*60 + endMin
|
||||||
|
nowMinutes := nowHour*60 + nowMin
|
||||||
|
|
||||||
|
// Handle case where window crosses midnight
|
||||||
|
if endMinutes < startMinutes {
|
||||||
|
// Window crosses midnight (e.g., 23:00 - 01:00)
|
||||||
|
if nowMinutes >= startMinutes || nowMinutes < endMinutes {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Normal case (e.g., 09:00 - 17:00)
|
||||||
|
if nowMinutes >= startMinutes && nowMinutes < endMinutes {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// One-time window: check if current time is within the date range
|
||||||
|
if (now.After(start) || now.Equal(start)) && now.Before(end) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendAlert sends an alert to the user
|
// SendAlert sends an alert to the user
|
||||||
func (am *AlertManager) SendAlert(data AlertMessageData) error {
|
func (am *AlertManager) SendAlert(data AlertMessageData) error {
|
||||||
|
// Check if alert is silenced
|
||||||
|
if am.IsNotificationSilenced(data.UserID, data.SystemID) {
|
||||||
|
am.hub.Logger().Info("Notification silenced", "user", data.UserID, "system", data.SystemID, "title", data.Title)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// get user settings
|
// get user settings
|
||||||
record, err := am.hub.FindFirstRecordByFilter(
|
record, err := am.hub.FindFirstRecordByFilter(
|
||||||
"user_settings", "user={:user}",
|
"user_settings", "user={:user}",
|
||||||
|
|||||||
386
internal/alerts/alerts_battery_test.go
Normal file
386
internal/alerts/alerts_battery_test.go
Normal file
@@ -0,0 +1,386 @@
|
|||||||
|
//go:build testing
|
||||||
|
|
||||||
|
package alerts_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
beszelTests "github.com/henrygd/beszel/internal/tests"
|
||||||
|
|
||||||
|
"github.com/pocketbase/dbx"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/types"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestBatteryAlertLogic tests that battery alerts trigger when value drops BELOW threshold
|
||||||
|
// (opposite of other alerts like CPU, Memory, etc. which trigger when exceeding threshold)
|
||||||
|
func TestBatteryAlertLogic(t *testing.T) {
|
||||||
|
hub, user := beszelTests.GetHubWithUser(t)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
// Create a system
|
||||||
|
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
|
||||||
|
require.NoError(t, err)
|
||||||
|
systemRecord := systems[0]
|
||||||
|
|
||||||
|
// Create a battery alert with threshold of 20% and min of 1 minute (immediate trigger)
|
||||||
|
batteryAlert, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{
|
||||||
|
"name": "Battery",
|
||||||
|
"system": systemRecord.Id,
|
||||||
|
"user": user.Id,
|
||||||
|
"value": 20, // threshold: 20%
|
||||||
|
"min": 1, // 1 minute (immediate trigger for testing)
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify alert is not triggered initially
|
||||||
|
assert.False(t, batteryAlert.GetBool("triggered"), "Alert should not be triggered initially")
|
||||||
|
|
||||||
|
// Create system stats with battery at 50% (above threshold - should NOT trigger)
|
||||||
|
statsHigh := system.Stats{
|
||||||
|
Cpu: 10,
|
||||||
|
MemPct: 30,
|
||||||
|
DiskPct: 40,
|
||||||
|
Battery: [2]uint8{50, 1}, // 50% battery, discharging
|
||||||
|
}
|
||||||
|
statsHighJSON, _ := json.Marshal(statsHigh)
|
||||||
|
_, err = beszelTests.CreateRecord(hub, "system_stats", map[string]any{
|
||||||
|
"system": systemRecord.Id,
|
||||||
|
"type": "1m",
|
||||||
|
"stats": string(statsHighJSON),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Create CombinedData for the alert handler
|
||||||
|
combinedDataHigh := &system.CombinedData{
|
||||||
|
Stats: statsHigh,
|
||||||
|
Info: system.Info{
|
||||||
|
AgentVersion: "0.12.0",
|
||||||
|
Cpu: 10,
|
||||||
|
MemPct: 30,
|
||||||
|
DiskPct: 40,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate system update time
|
||||||
|
systemRecord.Set("updated", time.Now().UTC())
|
||||||
|
err = hub.SaveNoValidate(systemRecord)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Handle system alerts with high battery
|
||||||
|
am := hub.GetAlertManager()
|
||||||
|
err = am.HandleSystemAlerts(systemRecord, combinedDataHigh)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify alert is still NOT triggered (battery 50% is above threshold 20%)
|
||||||
|
batteryAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": batteryAlert.Id})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, batteryAlert.GetBool("triggered"), "Alert should NOT be triggered when battery (50%%) is above threshold (20%%)")
|
||||||
|
|
||||||
|
// Now create stats with battery at 15% (below threshold - should trigger)
|
||||||
|
statsLow := system.Stats{
|
||||||
|
Cpu: 10,
|
||||||
|
MemPct: 30,
|
||||||
|
DiskPct: 40,
|
||||||
|
Battery: [2]uint8{15, 1}, // 15% battery, discharging
|
||||||
|
}
|
||||||
|
statsLowJSON, _ := json.Marshal(statsLow)
|
||||||
|
_, err = beszelTests.CreateRecord(hub, "system_stats", map[string]any{
|
||||||
|
"system": systemRecord.Id,
|
||||||
|
"type": "1m",
|
||||||
|
"stats": string(statsLowJSON),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
combinedDataLow := &system.CombinedData{
|
||||||
|
Stats: statsLow,
|
||||||
|
Info: system.Info{
|
||||||
|
AgentVersion: "0.12.0",
|
||||||
|
Cpu: 10,
|
||||||
|
MemPct: 30,
|
||||||
|
DiskPct: 40,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update system timestamp
|
||||||
|
systemRecord.Set("updated", time.Now().UTC())
|
||||||
|
err = hub.SaveNoValidate(systemRecord)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Handle system alerts with low battery
|
||||||
|
err = am.HandleSystemAlerts(systemRecord, combinedDataLow)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Wait for the alert to be processed
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
|
||||||
|
// Verify alert IS triggered (battery 15% is below threshold 20%)
|
||||||
|
batteryAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": batteryAlert.Id})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, batteryAlert.GetBool("triggered"), "Alert SHOULD be triggered when battery (15%%) drops below threshold (20%%)")
|
||||||
|
|
||||||
|
// Now test resolution: battery goes back above threshold
|
||||||
|
statsRecovered := system.Stats{
|
||||||
|
Cpu: 10,
|
||||||
|
MemPct: 30,
|
||||||
|
DiskPct: 40,
|
||||||
|
Battery: [2]uint8{25, 1}, // 25% battery, discharging
|
||||||
|
}
|
||||||
|
statsRecoveredJSON, _ := json.Marshal(statsRecovered)
|
||||||
|
_, err = beszelTests.CreateRecord(hub, "system_stats", map[string]any{
|
||||||
|
"system": systemRecord.Id,
|
||||||
|
"type": "1m",
|
||||||
|
"stats": string(statsRecoveredJSON),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
combinedDataRecovered := &system.CombinedData{
|
||||||
|
Stats: statsRecovered,
|
||||||
|
Info: system.Info{
|
||||||
|
AgentVersion: "0.12.0",
|
||||||
|
Cpu: 10,
|
||||||
|
MemPct: 30,
|
||||||
|
DiskPct: 40,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update system timestamp
|
||||||
|
systemRecord.Set("updated", time.Now().UTC())
|
||||||
|
err = hub.SaveNoValidate(systemRecord)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Handle system alerts with recovered battery
|
||||||
|
err = am.HandleSystemAlerts(systemRecord, combinedDataRecovered)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Wait for the alert to be processed
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
|
||||||
|
// Verify alert is now resolved (battery 25% is above threshold 20%)
|
||||||
|
batteryAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": batteryAlert.Id})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, batteryAlert.GetBool("triggered"), "Alert should be resolved when battery (25%%) goes above threshold (20%%)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBatteryAlertNoBattery verifies that systems without battery data don't trigger alerts
|
||||||
|
func TestBatteryAlertNoBattery(t *testing.T) {
|
||||||
|
hub, user := beszelTests.GetHubWithUser(t)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
// Create a system
|
||||||
|
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
|
||||||
|
require.NoError(t, err)
|
||||||
|
systemRecord := systems[0]
|
||||||
|
|
||||||
|
// Create a battery alert
|
||||||
|
batteryAlert, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{
|
||||||
|
"name": "Battery",
|
||||||
|
"system": systemRecord.Id,
|
||||||
|
"user": user.Id,
|
||||||
|
"value": 20,
|
||||||
|
"min": 1,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Create stats with NO battery data (Battery[0] = 0)
|
||||||
|
statsNoBattery := system.Stats{
|
||||||
|
Cpu: 10,
|
||||||
|
MemPct: 30,
|
||||||
|
DiskPct: 40,
|
||||||
|
Battery: [2]uint8{0, 0}, // No battery
|
||||||
|
}
|
||||||
|
|
||||||
|
combinedData := &system.CombinedData{
|
||||||
|
Stats: statsNoBattery,
|
||||||
|
Info: system.Info{
|
||||||
|
AgentVersion: "0.12.0",
|
||||||
|
Cpu: 10,
|
||||||
|
MemPct: 30,
|
||||||
|
DiskPct: 40,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate system update time
|
||||||
|
systemRecord.Set("updated", time.Now().UTC())
|
||||||
|
err = hub.SaveNoValidate(systemRecord)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Handle system alerts
|
||||||
|
am := hub.GetAlertManager()
|
||||||
|
err = am.HandleSystemAlerts(systemRecord, combinedData)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Wait a moment for processing
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
|
||||||
|
// Verify alert is NOT triggered (no battery data should skip the alert)
|
||||||
|
batteryAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": batteryAlert.Id})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, batteryAlert.GetBool("triggered"), "Alert should NOT be triggered when system has no battery")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBatteryAlertAveragedSamples tests battery alerts with min > 1 (averaging multiple samples)
|
||||||
|
// This ensures the inverted threshold logic works correctly across averaged time windows
|
||||||
|
func TestBatteryAlertAveragedSamples(t *testing.T) {
|
||||||
|
hub, user := beszelTests.GetHubWithUser(t)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
// Create a system
|
||||||
|
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
|
||||||
|
require.NoError(t, err)
|
||||||
|
systemRecord := systems[0]
|
||||||
|
|
||||||
|
// Create a battery alert with threshold of 25% and min of 2 minutes (requires averaging)
|
||||||
|
batteryAlert, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{
|
||||||
|
"name": "Battery",
|
||||||
|
"system": systemRecord.Id,
|
||||||
|
"user": user.Id,
|
||||||
|
"value": 25, // threshold: 25%
|
||||||
|
"min": 2, // 2 minutes - requires averaging
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify alert is not triggered initially
|
||||||
|
assert.False(t, batteryAlert.GetBool("triggered"), "Alert should not be triggered initially")
|
||||||
|
|
||||||
|
am := hub.GetAlertManager()
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
// Create system_stats records with low battery (below threshold)
|
||||||
|
// The alert has min=2 minutes, so alert.time = now - 2 minutes
|
||||||
|
// For the alert to be valid, alert.time must be AFTER the oldest record's created time
|
||||||
|
// So we need records older than (now - 2 min), plus records within the window
|
||||||
|
// Records at: now-3min (oldest, before window), now-90s, now-60s, now-30s
|
||||||
|
recordTimes := []time.Duration{
|
||||||
|
-180 * time.Second, // 3 min ago - this makes the oldest record before alert.time
|
||||||
|
-90 * time.Second,
|
||||||
|
-60 * time.Second,
|
||||||
|
-30 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, offset := range recordTimes {
|
||||||
|
statsLow := system.Stats{
|
||||||
|
Cpu: 10,
|
||||||
|
MemPct: 30,
|
||||||
|
DiskPct: 40,
|
||||||
|
Battery: [2]uint8{15, 1}, // 15% battery (below 25% threshold)
|
||||||
|
}
|
||||||
|
statsLowJSON, _ := json.Marshal(statsLow)
|
||||||
|
|
||||||
|
recordTime := now.Add(offset)
|
||||||
|
record, err := beszelTests.CreateRecord(hub, "system_stats", map[string]any{
|
||||||
|
"system": systemRecord.Id,
|
||||||
|
"type": "1m",
|
||||||
|
"stats": string(statsLowJSON),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
// Update created time to simulate historical records - use SetRaw with formatted string
|
||||||
|
record.SetRaw("created", recordTime.Format(types.DefaultDateLayout))
|
||||||
|
err = hub.SaveNoValidate(record)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create combined data with low battery
|
||||||
|
combinedDataLow := &system.CombinedData{
|
||||||
|
Stats: system.Stats{
|
||||||
|
Cpu: 10,
|
||||||
|
MemPct: 30,
|
||||||
|
DiskPct: 40,
|
||||||
|
Battery: [2]uint8{15, 1},
|
||||||
|
},
|
||||||
|
Info: system.Info{
|
||||||
|
AgentVersion: "0.12.0",
|
||||||
|
Cpu: 10,
|
||||||
|
MemPct: 30,
|
||||||
|
DiskPct: 40,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update system timestamp
|
||||||
|
systemRecord.Set("updated", now)
|
||||||
|
err = hub.SaveNoValidate(systemRecord)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Handle system alerts - should trigger because average battery is below threshold
|
||||||
|
err = am.HandleSystemAlerts(systemRecord, combinedDataLow)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Wait for alert processing
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
|
||||||
|
// Verify alert IS triggered (average battery 15% is below threshold 25%)
|
||||||
|
batteryAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": batteryAlert.Id})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, batteryAlert.GetBool("triggered"),
|
||||||
|
"Alert SHOULD be triggered when average battery (15%%) is below threshold (25%%) over min period")
|
||||||
|
|
||||||
|
// Now add records with high battery to test resolution
|
||||||
|
// Use a new time window 2 minutes later
|
||||||
|
newNow := now.Add(2 * time.Minute)
|
||||||
|
// Records need to span before the alert time window (newNow - 2 min)
|
||||||
|
recordTimesHigh := []time.Duration{
|
||||||
|
-180 * time.Second, // 3 min before newNow - makes oldest record before alert.time
|
||||||
|
-90 * time.Second,
|
||||||
|
-60 * time.Second,
|
||||||
|
-30 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, offset := range recordTimesHigh {
|
||||||
|
statsHigh := system.Stats{
|
||||||
|
Cpu: 10,
|
||||||
|
MemPct: 30,
|
||||||
|
DiskPct: 40,
|
||||||
|
Battery: [2]uint8{50, 1}, // 50% battery (above 25% threshold)
|
||||||
|
}
|
||||||
|
statsHighJSON, _ := json.Marshal(statsHigh)
|
||||||
|
|
||||||
|
recordTime := newNow.Add(offset)
|
||||||
|
record, err := beszelTests.CreateRecord(hub, "system_stats", map[string]any{
|
||||||
|
"system": systemRecord.Id,
|
||||||
|
"type": "1m",
|
||||||
|
"stats": string(statsHighJSON),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
record.SetRaw("created", recordTime.Format(types.DefaultDateLayout))
|
||||||
|
err = hub.SaveNoValidate(record)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create combined data with high battery
|
||||||
|
combinedDataHigh := &system.CombinedData{
|
||||||
|
Stats: system.Stats{
|
||||||
|
Cpu: 10,
|
||||||
|
MemPct: 30,
|
||||||
|
DiskPct: 40,
|
||||||
|
Battery: [2]uint8{50, 1},
|
||||||
|
},
|
||||||
|
Info: system.Info{
|
||||||
|
AgentVersion: "0.12.0",
|
||||||
|
Cpu: 10,
|
||||||
|
MemPct: 30,
|
||||||
|
DiskPct: 40,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update system timestamp to the new time window
|
||||||
|
systemRecord.Set("updated", newNow)
|
||||||
|
err = hub.SaveNoValidate(systemRecord)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Handle system alerts - should resolve because average battery is now above threshold
|
||||||
|
err = am.HandleSystemAlerts(systemRecord, combinedDataHigh)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Wait for alert processing
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
|
||||||
|
// Verify alert is resolved (average battery 50% is above threshold 25%)
|
||||||
|
batteryAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": batteryAlert.Id})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, batteryAlert.GetBool("triggered"),
|
||||||
|
"Alert should be resolved when average battery (50%%) is above threshold (25%%) over min period")
|
||||||
|
}
|
||||||
425
internal/alerts/alerts_quiet_hours_test.go
Normal file
425
internal/alerts/alerts_quiet_hours_test.go
Normal file
@@ -0,0 +1,425 @@
|
|||||||
|
//go:build testing
|
||||||
|
|
||||||
|
package alerts_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"testing/synctest"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/alerts"
|
||||||
|
beszelTests "github.com/henrygd/beszel/internal/tests"
|
||||||
|
|
||||||
|
"github.com/pocketbase/dbx"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAlertSilencedOneTime(t *testing.T) {
|
||||||
|
hub, user := beszelTests.GetHubWithUser(t)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
// Create a system
|
||||||
|
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
system := systems[0]
|
||||||
|
|
||||||
|
// Create an alert
|
||||||
|
alert, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"system": system.Id,
|
||||||
|
"user": user.Id,
|
||||||
|
"value": 80,
|
||||||
|
"min": 1,
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Create a one-time quiet hours window (current time - 1 hour to current time + 1 hour)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
startTime := now.Add(-1 * time.Hour)
|
||||||
|
endTime := now.Add(1 * time.Hour)
|
||||||
|
|
||||||
|
_, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{
|
||||||
|
"user": user.Id,
|
||||||
|
"system": system.Id,
|
||||||
|
"type": "one-time",
|
||||||
|
"start": startTime,
|
||||||
|
"end": endTime,
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Get alert manager
|
||||||
|
am := alerts.NewAlertManager(hub)
|
||||||
|
defer am.StopWorker()
|
||||||
|
|
||||||
|
// Test that alert is silenced
|
||||||
|
silenced := am.IsNotificationSilenced(user.Id, system.Id)
|
||||||
|
assert.True(t, silenced, "Alert should be silenced during active one-time window")
|
||||||
|
|
||||||
|
// Create a window that has already ended
|
||||||
|
pastStart := now.Add(-3 * time.Hour)
|
||||||
|
pastEnd := now.Add(-2 * time.Hour)
|
||||||
|
|
||||||
|
_, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{
|
||||||
|
"user": user.Id,
|
||||||
|
"system": system.Id,
|
||||||
|
"type": "one-time",
|
||||||
|
"start": pastStart,
|
||||||
|
"end": pastEnd,
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Should still be silenced because of the first window
|
||||||
|
silenced = am.IsNotificationSilenced(user.Id, system.Id)
|
||||||
|
assert.True(t, silenced, "Alert should still be silenced (past window doesn't affect active window)")
|
||||||
|
|
||||||
|
// Clear all windows and create a future window
|
||||||
|
_, err = hub.DB().NewQuery("DELETE FROM quiet_hours").Execute()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
futureStart := now.Add(2 * time.Hour)
|
||||||
|
futureEnd := now.Add(3 * time.Hour)
|
||||||
|
|
||||||
|
_, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{
|
||||||
|
"user": user.Id,
|
||||||
|
"system": system.Id,
|
||||||
|
"type": "one-time",
|
||||||
|
"start": futureStart,
|
||||||
|
"end": futureEnd,
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Alert should NOT be silenced (window hasn't started yet)
|
||||||
|
silenced = am.IsNotificationSilenced(user.Id, system.Id)
|
||||||
|
assert.False(t, silenced, "Alert should not be silenced (window hasn't started)")
|
||||||
|
|
||||||
|
_ = alert
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertSilencedDaily(t *testing.T) {
|
||||||
|
hub, user := beszelTests.GetHubWithUser(t)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
// Create a system
|
||||||
|
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
system := systems[0]
|
||||||
|
|
||||||
|
// Get alert manager
|
||||||
|
am := alerts.NewAlertManager(hub)
|
||||||
|
defer am.StopWorker()
|
||||||
|
|
||||||
|
// Get current hour and create a window that includes current time
|
||||||
|
now := time.Now().UTC()
|
||||||
|
currentHour := now.Hour()
|
||||||
|
currentMin := now.Minute()
|
||||||
|
|
||||||
|
// Create a window from 1 hour ago to 1 hour from now
|
||||||
|
startHour := (currentHour - 1 + 24) % 24
|
||||||
|
endHour := (currentHour + 1) % 24
|
||||||
|
|
||||||
|
// Create times with just the hours/minutes we want (date doesn't matter for daily)
|
||||||
|
startTime := time.Date(2000, 1, 1, startHour, currentMin, 0, 0, time.UTC)
|
||||||
|
endTime := time.Date(2000, 1, 1, endHour, currentMin, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
_, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{
|
||||||
|
"user": user.Id,
|
||||||
|
"system": system.Id,
|
||||||
|
"type": "daily",
|
||||||
|
"start": startTime,
|
||||||
|
"end": endTime,
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Alert should be silenced (current time is within the daily window)
|
||||||
|
silenced := am.IsNotificationSilenced(user.Id, system.Id)
|
||||||
|
assert.True(t, silenced, "Alert should be silenced during active daily window")
|
||||||
|
|
||||||
|
// Clear windows and create one that doesn't include current time
|
||||||
|
_, err = hub.DB().NewQuery("DELETE FROM quiet_hours").Execute()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Create a window from 6-12 hours from now
|
||||||
|
futureStartHour := (currentHour + 6) % 24
|
||||||
|
futureEndHour := (currentHour + 12) % 24
|
||||||
|
|
||||||
|
startTime = time.Date(2000, 1, 1, futureStartHour, 0, 0, 0, time.UTC)
|
||||||
|
endTime = time.Date(2000, 1, 1, futureEndHour, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
_, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{
|
||||||
|
"user": user.Id,
|
||||||
|
"system": system.Id,
|
||||||
|
"type": "daily",
|
||||||
|
"start": startTime,
|
||||||
|
"end": endTime,
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Alert should NOT be silenced
|
||||||
|
silenced = am.IsNotificationSilenced(user.Id, system.Id)
|
||||||
|
assert.False(t, silenced, "Alert should not be silenced (outside daily window)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertSilencedDailyMidnightCrossing(t *testing.T) {
|
||||||
|
hub, user := beszelTests.GetHubWithUser(t)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
// Create a system
|
||||||
|
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
system := systems[0]
|
||||||
|
|
||||||
|
// Get alert manager
|
||||||
|
am := alerts.NewAlertManager(hub)
|
||||||
|
defer am.StopWorker()
|
||||||
|
|
||||||
|
// Create a window that crosses midnight: 22:00 - 02:00
|
||||||
|
startTime := time.Date(2000, 1, 1, 22, 0, 0, 0, time.UTC)
|
||||||
|
endTime := time.Date(2000, 1, 1, 2, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
_, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{
|
||||||
|
"user": user.Id,
|
||||||
|
"system": system.Id,
|
||||||
|
"type": "daily",
|
||||||
|
"start": startTime,
|
||||||
|
"end": endTime,
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Test with a time at 23:00 (should be silenced)
|
||||||
|
// We can't control the actual current time, but we can verify the logic
|
||||||
|
// by checking if the window was created correctly
|
||||||
|
windows, err := hub.FindAllRecords("quiet_hours", dbx.HashExp{
|
||||||
|
"user": user.Id,
|
||||||
|
"system": system.Id,
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Len(t, windows, 1, "Should have created 1 window")
|
||||||
|
|
||||||
|
window := windows[0]
|
||||||
|
assert.Equal(t, "daily", window.GetString("type"))
|
||||||
|
assert.Equal(t, 22, window.GetDateTime("start").Time().Hour())
|
||||||
|
assert.Equal(t, 2, window.GetDateTime("end").Time().Hour())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertSilencedGlobal(t *testing.T) {
|
||||||
|
hub, user := beszelTests.GetHubWithUser(t)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
// Create multiple systems
|
||||||
|
systems, err := beszelTests.CreateSystems(hub, 3, user.Id, "up")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Get alert manager
|
||||||
|
am := alerts.NewAlertManager(hub)
|
||||||
|
defer am.StopWorker()
|
||||||
|
|
||||||
|
// Create a global quiet hours window (no system specified)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
startTime := now.Add(-1 * time.Hour)
|
||||||
|
endTime := now.Add(1 * time.Hour)
|
||||||
|
|
||||||
|
_, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{
|
||||||
|
"user": user.Id,
|
||||||
|
"type": "one-time",
|
||||||
|
"start": startTime,
|
||||||
|
"end": endTime,
|
||||||
|
// system field is empty/null for global windows
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// All systems should be silenced
|
||||||
|
for _, system := range systems {
|
||||||
|
silenced := am.IsNotificationSilenced(user.Id, system.Id)
|
||||||
|
assert.True(t, silenced, "Alert should be silenced for system %s (global window)", system.Id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Even with a systemID that doesn't exist, should be silenced
|
||||||
|
silenced := am.IsNotificationSilenced(user.Id, "nonexistent-system")
|
||||||
|
assert.True(t, silenced, "Alert should be silenced for any system (global window)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertSilencedSystemSpecific(t *testing.T) {
|
||||||
|
hub, user := beszelTests.GetHubWithUser(t)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
// Create multiple systems
|
||||||
|
systems, err := beszelTests.CreateSystems(hub, 2, user.Id, "up")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
system1 := systems[0]
|
||||||
|
system2 := systems[1]
|
||||||
|
|
||||||
|
// Get alert manager
|
||||||
|
am := alerts.NewAlertManager(hub)
|
||||||
|
defer am.StopWorker()
|
||||||
|
|
||||||
|
// Create a system-specific quiet hours window for system1 only
|
||||||
|
now := time.Now().UTC()
|
||||||
|
startTime := now.Add(-1 * time.Hour)
|
||||||
|
endTime := now.Add(1 * time.Hour)
|
||||||
|
|
||||||
|
_, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{
|
||||||
|
"user": user.Id,
|
||||||
|
"system": system1.Id,
|
||||||
|
"type": "one-time",
|
||||||
|
"start": startTime,
|
||||||
|
"end": endTime,
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// System1 should be silenced
|
||||||
|
silenced := am.IsNotificationSilenced(user.Id, system1.Id)
|
||||||
|
assert.True(t, silenced, "Alert should be silenced for system1")
|
||||||
|
|
||||||
|
// System2 should NOT be silenced
|
||||||
|
silenced = am.IsNotificationSilenced(user.Id, system2.Id)
|
||||||
|
assert.False(t, silenced, "Alert should not be silenced for system2")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertSilencedMultiUser(t *testing.T) {
|
||||||
|
hub, _ := beszelTests.GetHubWithUser(t)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
// Create two users
|
||||||
|
user1, err := beszelTests.CreateUser(hub, "user1@example.com", "password")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
user2, err := beszelTests.CreateUser(hub, "user2@example.com", "password")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Create a system accessible to both users
|
||||||
|
system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{
|
||||||
|
"name": "shared-system",
|
||||||
|
"users": []string{user1.Id, user2.Id},
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Get alert manager
|
||||||
|
am := alerts.NewAlertManager(hub)
|
||||||
|
defer am.StopWorker()
|
||||||
|
|
||||||
|
// Create a quiet hours window for user1 only
|
||||||
|
now := time.Now().UTC()
|
||||||
|
startTime := now.Add(-1 * time.Hour)
|
||||||
|
endTime := now.Add(1 * time.Hour)
|
||||||
|
|
||||||
|
_, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{
|
||||||
|
"user": user1.Id,
|
||||||
|
"system": system.Id,
|
||||||
|
"type": "one-time",
|
||||||
|
"start": startTime,
|
||||||
|
"end": endTime,
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// User1 should be silenced
|
||||||
|
silenced := am.IsNotificationSilenced(user1.Id, system.Id)
|
||||||
|
assert.True(t, silenced, "Alert should be silenced for user1")
|
||||||
|
|
||||||
|
// User2 should NOT be silenced
|
||||||
|
silenced = am.IsNotificationSilenced(user2.Id, system.Id)
|
||||||
|
assert.False(t, silenced, "Alert should not be silenced for user2")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertSilencedWithActualAlert(t *testing.T) {
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
hub, user := beszelTests.GetHubWithUser(t)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
// Create a system
|
||||||
|
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
system := systems[0]
|
||||||
|
|
||||||
|
// Create a status alert
|
||||||
|
_, err = beszelTests.CreateRecord(hub, "alerts", map[string]any{
|
||||||
|
"name": "Status",
|
||||||
|
"system": system.Id,
|
||||||
|
"user": user.Id,
|
||||||
|
"min": 1,
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Create user settings with email
|
||||||
|
userSettings, err := hub.FindFirstRecordByFilter("user_settings", "user={:user}", dbx.Params{"user": user.Id})
|
||||||
|
if err != nil || userSettings == nil {
|
||||||
|
userSettings, err = beszelTests.CreateRecord(hub, "user_settings", map[string]any{
|
||||||
|
"user": user.Id,
|
||||||
|
"settings": map[string]any{
|
||||||
|
"emails": []string{"test@example.com"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a quiet hours window
|
||||||
|
now := time.Now().UTC()
|
||||||
|
startTime := now.Add(-1 * time.Hour)
|
||||||
|
endTime := now.Add(1 * time.Hour)
|
||||||
|
|
||||||
|
_, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{
|
||||||
|
"user": user.Id,
|
||||||
|
"system": system.Id,
|
||||||
|
"type": "one-time",
|
||||||
|
"start": startTime,
|
||||||
|
"end": endTime,
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Get initial email count
|
||||||
|
initialEmailCount := hub.TestMailer.TotalSend()
|
||||||
|
|
||||||
|
// Trigger an alert by setting system to down
|
||||||
|
system.Set("status", "down")
|
||||||
|
err = hub.SaveNoValidate(system)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Wait for the alert to be processed (1 minute + buffer)
|
||||||
|
time.Sleep(time.Second * 75)
|
||||||
|
synctest.Wait()
|
||||||
|
|
||||||
|
// Check that no email was sent (because alert is silenced)
|
||||||
|
finalEmailCount := hub.TestMailer.TotalSend()
|
||||||
|
assert.Equal(t, initialEmailCount, finalEmailCount, "No emails should be sent when alert is silenced")
|
||||||
|
|
||||||
|
// Clear quiet hours windows
|
||||||
|
_, err = hub.DB().NewQuery("DELETE FROM quiet_hours").Execute()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Reset system to up, then down again
|
||||||
|
system.Set("status", "up")
|
||||||
|
err = hub.SaveNoValidate(system)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
system.Set("status", "down")
|
||||||
|
err = hub.SaveNoValidate(system)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Wait for the alert to be processed
|
||||||
|
time.Sleep(time.Second * 75)
|
||||||
|
synctest.Wait()
|
||||||
|
|
||||||
|
// Now an email should be sent
|
||||||
|
newEmailCount := hub.TestMailer.TotalSend()
|
||||||
|
assert.Greater(t, newEmailCount, finalEmailCount, "Email should be sent when not silenced")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertSilencedNoWindows(t *testing.T) {
|
||||||
|
hub, user := beszelTests.GetHubWithUser(t)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
// Create a system
|
||||||
|
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
system := systems[0]
|
||||||
|
|
||||||
|
// Get alert manager
|
||||||
|
am := alerts.NewAlertManager(hub)
|
||||||
|
defer am.StopWorker()
|
||||||
|
|
||||||
|
// Without any quiet hours windows, alert should NOT be silenced
|
||||||
|
silenced := am.IsNotificationSilenced(user.Id, system.Id)
|
||||||
|
assert.False(t, silenced, "Alert should not be silenced when no windows exist")
|
||||||
|
}
|
||||||
107
internal/alerts/alerts_smart.go
Normal file
107
internal/alerts/alerts_smart.go
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
package alerts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
// handleSmartDeviceAlert sends alerts when a SMART device state worsens into WARNING/FAILED.
|
||||||
|
// This is automatic and does not require user opt-in.
|
||||||
|
func (am *AlertManager) handleSmartDeviceAlert(e *core.RecordEvent) error {
|
||||||
|
oldState := e.Record.Original().GetString("state")
|
||||||
|
newState := e.Record.GetString("state")
|
||||||
|
|
||||||
|
if !shouldSendSmartDeviceAlert(oldState, newState) {
|
||||||
|
return e.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
systemID := e.Record.GetString("system")
|
||||||
|
if systemID == "" {
|
||||||
|
return e.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the system record to get the name and users
|
||||||
|
systemRecord, err := e.App.FindRecordById("systems", systemID)
|
||||||
|
if err != nil {
|
||||||
|
e.App.Logger().Error("Failed to find system for SMART alert", "err", err, "systemID", systemID)
|
||||||
|
return e.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
systemName := systemRecord.GetString("name")
|
||||||
|
deviceName := e.Record.GetString("name")
|
||||||
|
model := e.Record.GetString("model")
|
||||||
|
statusLabel := smartStateLabel(newState)
|
||||||
|
|
||||||
|
// Build alert message
|
||||||
|
title := fmt.Sprintf("SMART %s on %s: %s %s", statusLabel, systemName, deviceName, smartStateEmoji(newState))
|
||||||
|
var message string
|
||||||
|
if model != "" {
|
||||||
|
message = fmt.Sprintf("Disk %s (%s) SMART status changed to %s", deviceName, model, newState)
|
||||||
|
} else {
|
||||||
|
message = fmt.Sprintf("Disk %s SMART status changed to %s", deviceName, newState)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get users associated with the system
|
||||||
|
userIDs := systemRecord.GetStringSlice("users")
|
||||||
|
if len(userIDs) == 0 {
|
||||||
|
return e.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send alert to each user
|
||||||
|
for _, userID := range userIDs {
|
||||||
|
if err := am.SendAlert(AlertMessageData{
|
||||||
|
UserID: userID,
|
||||||
|
SystemID: systemID,
|
||||||
|
Title: title,
|
||||||
|
Message: message,
|
||||||
|
Link: am.hub.MakeLink("system", systemID),
|
||||||
|
LinkText: "View " + systemName,
|
||||||
|
}); err != nil {
|
||||||
|
e.App.Logger().Error("Failed to send SMART alert", "err", err, "userID", userID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return e.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldSendSmartDeviceAlert(oldState, newState string) bool {
|
||||||
|
oldSeverity := smartStateSeverity(oldState)
|
||||||
|
newSeverity := smartStateSeverity(newState)
|
||||||
|
|
||||||
|
// Ignore unknown states and recoveries; only alert on worsening transitions
|
||||||
|
// from known-good/degraded states into WARNING/FAILED.
|
||||||
|
return oldSeverity >= 1 && newSeverity > oldSeverity
|
||||||
|
}
|
||||||
|
|
||||||
|
func smartStateSeverity(state string) int {
|
||||||
|
switch state {
|
||||||
|
case "PASSED":
|
||||||
|
return 1
|
||||||
|
case "WARNING":
|
||||||
|
return 2
|
||||||
|
case "FAILED":
|
||||||
|
return 3
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func smartStateEmoji(state string) string {
|
||||||
|
switch state {
|
||||||
|
case "WARNING":
|
||||||
|
return "\U0001F7E0"
|
||||||
|
default:
|
||||||
|
return "\U0001F534"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func smartStateLabel(state string) string {
|
||||||
|
switch state {
|
||||||
|
case "FAILED":
|
||||||
|
return "failure"
|
||||||
|
default:
|
||||||
|
return strings.ToLower(state)
|
||||||
|
}
|
||||||
|
}
|
||||||
264
internal/alerts/alerts_smart_test.go
Normal file
264
internal/alerts/alerts_smart_test.go
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
//go:build testing
|
||||||
|
|
||||||
|
package alerts_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
beszelTests "github.com/henrygd/beszel/internal/tests"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSmartDeviceAlert(t *testing.T) {
|
||||||
|
hub, user := beszelTests.GetHubWithUser(t)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
// Create a system for the user
|
||||||
|
system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{
|
||||||
|
"name": "test-system",
|
||||||
|
"users": []string{user.Id},
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Create a smart_device with state PASSED
|
||||||
|
smartDevice, err := beszelTests.CreateRecord(hub, "smart_devices", map[string]any{
|
||||||
|
"system": system.Id,
|
||||||
|
"name": "/dev/sda",
|
||||||
|
"model": "Samsung SSD 970 EVO",
|
||||||
|
"state": "PASSED",
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify no emails sent initially
|
||||||
|
assert.Zero(t, hub.TestMailer.TotalSend(), "should have 0 emails sent initially")
|
||||||
|
|
||||||
|
// Re-fetch the record so PocketBase can properly track original values
|
||||||
|
smartDevice, err = hub.FindRecordById("smart_devices", smartDevice.Id)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Update the smart device state to FAILED
|
||||||
|
smartDevice.Set("state", "FAILED")
|
||||||
|
err = hub.Save(smartDevice)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Wait for the alert to be processed
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
|
||||||
|
// Verify that an email was sent
|
||||||
|
assert.EqualValues(t, 1, hub.TestMailer.TotalSend(), "should have 1 email sent after state changed to FAILED")
|
||||||
|
|
||||||
|
// Check the email content
|
||||||
|
lastMessage := hub.TestMailer.LastMessage()
|
||||||
|
assert.Contains(t, lastMessage.Subject, "SMART failure on test-system")
|
||||||
|
assert.Contains(t, lastMessage.Subject, "/dev/sda")
|
||||||
|
assert.Contains(t, lastMessage.Text, "Samsung SSD 970 EVO")
|
||||||
|
assert.Contains(t, lastMessage.Text, "FAILED")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSmartDeviceAlertPassedToWarning(t *testing.T) {
|
||||||
|
hub, user := beszelTests.GetHubWithUser(t)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{
|
||||||
|
"name": "test-system",
|
||||||
|
"users": []string{user.Id},
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
smartDevice, err := beszelTests.CreateRecord(hub, "smart_devices", map[string]any{
|
||||||
|
"system": system.Id,
|
||||||
|
"name": "/dev/mmcblk0",
|
||||||
|
"model": "eMMC",
|
||||||
|
"state": "PASSED",
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
smartDevice, err = hub.FindRecordById("smart_devices", smartDevice.Id)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
smartDevice.Set("state", "WARNING")
|
||||||
|
err = hub.Save(smartDevice)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
|
||||||
|
assert.EqualValues(t, 1, hub.TestMailer.TotalSend(), "should have 1 email sent after state changed to WARNING")
|
||||||
|
lastMessage := hub.TestMailer.LastMessage()
|
||||||
|
assert.Contains(t, lastMessage.Subject, "SMART warning on test-system")
|
||||||
|
assert.Contains(t, lastMessage.Text, "WARNING")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSmartDeviceAlertWarningToFailed(t *testing.T) {
|
||||||
|
hub, user := beszelTests.GetHubWithUser(t)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{
|
||||||
|
"name": "test-system",
|
||||||
|
"users": []string{user.Id},
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
smartDevice, err := beszelTests.CreateRecord(hub, "smart_devices", map[string]any{
|
||||||
|
"system": system.Id,
|
||||||
|
"name": "/dev/mmcblk0",
|
||||||
|
"model": "eMMC",
|
||||||
|
"state": "WARNING",
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
smartDevice, err = hub.FindRecordById("smart_devices", smartDevice.Id)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
smartDevice.Set("state", "FAILED")
|
||||||
|
err = hub.Save(smartDevice)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
|
||||||
|
assert.EqualValues(t, 1, hub.TestMailer.TotalSend(), "should have 1 email sent after state changed from WARNING to FAILED")
|
||||||
|
lastMessage := hub.TestMailer.LastMessage()
|
||||||
|
assert.Contains(t, lastMessage.Subject, "SMART failure on test-system")
|
||||||
|
assert.Contains(t, lastMessage.Text, "FAILED")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSmartDeviceAlertNoAlertOnNonPassedToFailed(t *testing.T) {
|
||||||
|
hub, user := beszelTests.GetHubWithUser(t)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
// Create a system for the user
|
||||||
|
system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{
|
||||||
|
"name": "test-system",
|
||||||
|
"users": []string{user.Id},
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Create a smart_device with state UNKNOWN
|
||||||
|
smartDevice, err := beszelTests.CreateRecord(hub, "smart_devices", map[string]any{
|
||||||
|
"system": system.Id,
|
||||||
|
"name": "/dev/sda",
|
||||||
|
"model": "Samsung SSD 970 EVO",
|
||||||
|
"state": "UNKNOWN",
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Re-fetch the record so PocketBase can properly track original values
|
||||||
|
smartDevice, err = hub.FindRecordById("smart_devices", smartDevice.Id)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Update the state from UNKNOWN to FAILED - should NOT trigger alert.
|
||||||
|
// We only alert from known healthy/degraded states.
|
||||||
|
smartDevice.Set("state", "FAILED")
|
||||||
|
err = hub.Save(smartDevice)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
|
||||||
|
// Verify no email was sent (only PASSED -> FAILED triggers alert)
|
||||||
|
assert.Zero(t, hub.TestMailer.TotalSend(), "should have 0 emails when changing from UNKNOWN to FAILED")
|
||||||
|
|
||||||
|
// Re-fetch the record again
|
||||||
|
smartDevice, err = hub.FindRecordById("smart_devices", smartDevice.Id)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Update state from FAILED to PASSED - should NOT trigger alert
|
||||||
|
smartDevice.Set("state", "PASSED")
|
||||||
|
err = hub.Save(smartDevice)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
|
||||||
|
// Verify no email was sent
|
||||||
|
assert.Zero(t, hub.TestMailer.TotalSend(), "should have 0 emails when changing from FAILED to PASSED")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSmartDeviceAlertMultipleUsers(t *testing.T) {
|
||||||
|
hub, user1 := beszelTests.GetHubWithUser(t)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
// Create a second user
|
||||||
|
user2, err := beszelTests.CreateUser(hub, "test2@example.com", "password")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Create user settings for the second user
|
||||||
|
_, err = beszelTests.CreateRecord(hub, "user_settings", map[string]any{
|
||||||
|
"user": user2.Id,
|
||||||
|
"settings": `{"emails":["test2@example.com"],"webhooks":[]}`,
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Create a system with both users
|
||||||
|
system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{
|
||||||
|
"name": "shared-system",
|
||||||
|
"users": []string{user1.Id, user2.Id},
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Create a smart_device with state PASSED
|
||||||
|
smartDevice, err := beszelTests.CreateRecord(hub, "smart_devices", map[string]any{
|
||||||
|
"system": system.Id,
|
||||||
|
"name": "/dev/nvme0n1",
|
||||||
|
"model": "WD Black SN850",
|
||||||
|
"state": "PASSED",
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Re-fetch the record so PocketBase can properly track original values
|
||||||
|
smartDevice, err = hub.FindRecordById("smart_devices", smartDevice.Id)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Update the smart device state to FAILED
|
||||||
|
smartDevice.Set("state", "FAILED")
|
||||||
|
err = hub.Save(smartDevice)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
|
||||||
|
// Verify that two emails were sent (one for each user)
|
||||||
|
assert.EqualValues(t, 2, hub.TestMailer.TotalSend(), "should have 2 emails sent for 2 users")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSmartDeviceAlertWithoutModel(t *testing.T) {
|
||||||
|
hub, user := beszelTests.GetHubWithUser(t)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
// Create a system for the user
|
||||||
|
system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{
|
||||||
|
"name": "test-system",
|
||||||
|
"users": []string{user.Id},
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Create a smart_device with state PASSED but no model
|
||||||
|
smartDevice, err := beszelTests.CreateRecord(hub, "smart_devices", map[string]any{
|
||||||
|
"system": system.Id,
|
||||||
|
"name": "/dev/sdb",
|
||||||
|
"state": "PASSED",
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Re-fetch the record so PocketBase can properly track original values
|
||||||
|
smartDevice, err = hub.FindRecordById("smart_devices", smartDevice.Id)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Update the smart device state to FAILED
|
||||||
|
smartDevice.Set("state", "FAILED")
|
||||||
|
err = hub.Save(smartDevice)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
|
||||||
|
// Verify that an email was sent
|
||||||
|
assert.EqualValues(t, 1, hub.TestMailer.TotalSend(), "should have 1 email sent")
|
||||||
|
|
||||||
|
// Check that the email doesn't have empty parentheses for missing model
|
||||||
|
lastMessage := hub.TestMailer.LastMessage()
|
||||||
|
assert.NotContains(t, lastMessage.Text, "()", "should not have empty parentheses for missing model")
|
||||||
|
assert.Contains(t, lastMessage.Text, "/dev/sdb")
|
||||||
|
}
|
||||||
@@ -161,19 +161,15 @@ func (am *AlertManager) sendStatusAlert(alertStatus string, systemName string, a
|
|||||||
title := fmt.Sprintf("Connection to %s is %s %v", systemName, alertStatus, emoji)
|
title := fmt.Sprintf("Connection to %s is %s %v", systemName, alertStatus, emoji)
|
||||||
message := strings.TrimSuffix(title, emoji)
|
message := strings.TrimSuffix(title, emoji)
|
||||||
|
|
||||||
// if errs := am.hub.ExpandRecord(alertRecord, []string{"user"}, nil); len(errs) > 0 {
|
// Get system ID for the link
|
||||||
// return errs["user"]
|
systemID := alertRecord.GetString("system")
|
||||||
// }
|
|
||||||
// user := alertRecord.ExpandedOne("user")
|
|
||||||
// if user == nil {
|
|
||||||
// return nil
|
|
||||||
// }
|
|
||||||
|
|
||||||
return am.SendAlert(AlertMessageData{
|
return am.SendAlert(AlertMessageData{
|
||||||
UserID: alertRecord.GetString("user"),
|
UserID: alertRecord.GetString("user"),
|
||||||
|
SystemID: systemID,
|
||||||
Title: title,
|
Title: title,
|
||||||
Message: message,
|
Message: message,
|
||||||
Link: am.hub.MakeLink("system", systemName),
|
Link: am.hub.MakeLink("system", systemID),
|
||||||
LinkText: "View " + systemName,
|
LinkText: "View " + systemName,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
|
|||||||
case "Memory":
|
case "Memory":
|
||||||
val = data.Info.MemPct
|
val = data.Info.MemPct
|
||||||
case "Bandwidth":
|
case "Bandwidth":
|
||||||
val = data.Info.Bandwidth
|
val = float64(data.Info.BandwidthBytes) / (1024 * 1024)
|
||||||
unit = " MB/s"
|
unit = " MB/s"
|
||||||
case "Disk":
|
case "Disk":
|
||||||
maxUsedPct := data.Info.DiskPct
|
maxUsedPct := data.Info.DiskPct
|
||||||
@@ -64,17 +64,32 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
|
|||||||
case "LoadAvg15":
|
case "LoadAvg15":
|
||||||
val = data.Info.LoadAvg[2]
|
val = data.Info.LoadAvg[2]
|
||||||
unit = ""
|
unit = ""
|
||||||
|
case "GPU":
|
||||||
|
val = data.Info.GpuPct
|
||||||
|
case "Battery":
|
||||||
|
if data.Stats.Battery[0] == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val = float64(data.Stats.Battery[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
triggered := alertRecord.GetBool("triggered")
|
triggered := alertRecord.GetBool("triggered")
|
||||||
threshold := alertRecord.GetFloat("value")
|
threshold := alertRecord.GetFloat("value")
|
||||||
|
|
||||||
|
// Battery alert has inverted logic: trigger when value is BELOW threshold
|
||||||
|
lowAlert := isLowAlert(name)
|
||||||
|
|
||||||
// CONTINUE
|
// CONTINUE
|
||||||
// IF alert is not triggered and curValue is less than threshold
|
// For normal alerts: IF not triggered and curValue <= threshold, OR triggered and curValue > threshold
|
||||||
// OR alert is triggered and curValue is greater than threshold
|
// For low alerts (Battery): IF not triggered and curValue >= threshold, OR triggered and curValue < threshold
|
||||||
if (!triggered && val <= threshold) || (triggered && val > threshold) {
|
if lowAlert {
|
||||||
// log.Printf("Skipping alert %s: val %f | threshold %f | triggered %v\n", name, val, threshold, triggered)
|
if (!triggered && val >= threshold) || (triggered && val < threshold) {
|
||||||
continue
|
continue
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!triggered && val <= threshold) || (triggered && val > threshold) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
min := max(1, cast.ToUint8(alertRecord.Get("min")))
|
min := max(1, cast.ToUint8(alertRecord.Get("min")))
|
||||||
@@ -92,7 +107,11 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
|
|||||||
|
|
||||||
// send alert immediately if min is 1 - no need to sum up values.
|
// send alert immediately if min is 1 - no need to sum up values.
|
||||||
if min == 1 {
|
if min == 1 {
|
||||||
alert.triggered = val > threshold
|
if lowAlert {
|
||||||
|
alert.triggered = val < threshold
|
||||||
|
} else {
|
||||||
|
alert.triggered = val > threshold
|
||||||
|
}
|
||||||
go am.sendSystemAlert(alert)
|
go am.sendSystemAlert(alert)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -206,6 +225,19 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
|
|||||||
alert.val += stats.LoadAvg[1]
|
alert.val += stats.LoadAvg[1]
|
||||||
case "LoadAvg15":
|
case "LoadAvg15":
|
||||||
alert.val += stats.LoadAvg[2]
|
alert.val += stats.LoadAvg[2]
|
||||||
|
case "GPU":
|
||||||
|
if len(stats.GPU) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
maxUsage := 0.0
|
||||||
|
for _, gpu := range stats.GPU {
|
||||||
|
if gpu.Usage > maxUsage {
|
||||||
|
maxUsage = gpu.Usage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
alert.val += maxUsage
|
||||||
|
case "Battery":
|
||||||
|
alert.val += float64(stats.Battery[0])
|
||||||
default:
|
default:
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -243,12 +275,24 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
|
|||||||
// log.Printf("%s: val %f | count %d | min-count %f | threshold %f\n", alert.name, alert.val, alert.count, minCount, alert.threshold)
|
// log.Printf("%s: val %f | count %d | min-count %f | threshold %f\n", alert.name, alert.val, alert.count, minCount, alert.threshold)
|
||||||
// pass through alert if count is greater than or equal to minCount
|
// pass through alert if count is greater than or equal to minCount
|
||||||
if float32(alert.count) >= minCount {
|
if float32(alert.count) >= minCount {
|
||||||
if !alert.triggered && alert.val > alert.threshold {
|
// Battery alert has inverted logic: trigger when value is BELOW threshold
|
||||||
alert.triggered = true
|
lowAlert := isLowAlert(alert.name)
|
||||||
go am.sendSystemAlert(alert)
|
if lowAlert {
|
||||||
} else if alert.triggered && alert.val <= alert.threshold {
|
if !alert.triggered && alert.val < alert.threshold {
|
||||||
alert.triggered = false
|
alert.triggered = true
|
||||||
go am.sendSystemAlert(alert)
|
go am.sendSystemAlert(alert)
|
||||||
|
} else if alert.triggered && alert.val >= alert.threshold {
|
||||||
|
alert.triggered = false
|
||||||
|
go am.sendSystemAlert(alert)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if !alert.triggered && alert.val > alert.threshold {
|
||||||
|
alert.triggered = true
|
||||||
|
go am.sendSystemAlert(alert)
|
||||||
|
} else if alert.triggered && alert.val <= alert.threshold {
|
||||||
|
alert.triggered = false
|
||||||
|
go am.sendSystemAlert(alert)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -268,17 +312,26 @@ func (am *AlertManager) sendSystemAlert(alert SystemAlertData) {
|
|||||||
alert.name = after + "m Load"
|
alert.name = after + "m Load"
|
||||||
}
|
}
|
||||||
|
|
||||||
// make title alert name lowercase if not CPU
|
// make title alert name lowercase if not CPU or GPU
|
||||||
titleAlertName := alert.name
|
titleAlertName := alert.name
|
||||||
if titleAlertName != "CPU" {
|
if titleAlertName != "CPU" && titleAlertName != "GPU" {
|
||||||
titleAlertName = strings.ToLower(titleAlertName)
|
titleAlertName = strings.ToLower(titleAlertName)
|
||||||
}
|
}
|
||||||
|
|
||||||
var subject string
|
var subject string
|
||||||
|
lowAlert := isLowAlert(alert.name)
|
||||||
if alert.triggered {
|
if alert.triggered {
|
||||||
subject = fmt.Sprintf("%s %s above threshold", systemName, titleAlertName)
|
if lowAlert {
|
||||||
|
subject = fmt.Sprintf("%s %s below threshold", systemName, titleAlertName)
|
||||||
|
} else {
|
||||||
|
subject = fmt.Sprintf("%s %s above threshold", systemName, titleAlertName)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
subject = fmt.Sprintf("%s %s below threshold", systemName, titleAlertName)
|
if lowAlert {
|
||||||
|
subject = fmt.Sprintf("%s %s above threshold", systemName, titleAlertName)
|
||||||
|
} else {
|
||||||
|
subject = fmt.Sprintf("%s %s below threshold", systemName, titleAlertName)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
minutesLabel := "minute"
|
minutesLabel := "minute"
|
||||||
if alert.min > 1 {
|
if alert.min > 1 {
|
||||||
@@ -296,9 +349,14 @@ func (am *AlertManager) sendSystemAlert(alert SystemAlertData) {
|
|||||||
}
|
}
|
||||||
am.SendAlert(AlertMessageData{
|
am.SendAlert(AlertMessageData{
|
||||||
UserID: alert.alertRecord.GetString("user"),
|
UserID: alert.alertRecord.GetString("user"),
|
||||||
|
SystemID: alert.systemRecord.Id,
|
||||||
Title: subject,
|
Title: subject,
|
||||||
Message: body,
|
Message: body,
|
||||||
Link: am.hub.MakeLink("system", systemName),
|
Link: am.hub.MakeLink("system", alert.systemRecord.Id),
|
||||||
LinkText: "View " + systemName,
|
LinkText: "View " + systemName,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isLowAlert(name string) bool {
|
||||||
|
return name == "Battery"
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
//go:build testing
|
//go:build testing
|
||||||
// +build testing
|
|
||||||
|
|
||||||
package alerts_test
|
package alerts_test
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
//go:build testing
|
//go:build testing
|
||||||
// +build testing
|
|
||||||
|
|
||||||
package alerts
|
package alerts
|
||||||
|
|
||||||
|
|||||||
@@ -17,9 +17,8 @@ import (
|
|||||||
type cmdOptions struct {
|
type cmdOptions struct {
|
||||||
key string // key is the public key(s) for SSH authentication.
|
key string // key is the public key(s) for SSH authentication.
|
||||||
listen string // listen is the address or port to listen on.
|
listen string // listen is the address or port to listen on.
|
||||||
// TODO: add hubURL and token
|
hubURL string // hubURL is the URL of the Beszel hub.
|
||||||
// hubURL string // hubURL is the URL of the hub to use.
|
token string // token is the token to use for authentication.
|
||||||
// token string // token is the token to use for authentication.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// parse parses the command line flags and populates the config struct.
|
// parse parses the command line flags and populates the config struct.
|
||||||
@@ -32,9 +31,6 @@ func (opts *cmdOptions) parse() bool {
|
|||||||
|
|
||||||
// Subcommands that don't require any pflag parsing
|
// Subcommands that don't require any pflag parsing
|
||||||
switch subcommand {
|
switch subcommand {
|
||||||
case "-v", "version":
|
|
||||||
fmt.Println(beszel.AppName+"-agent", beszel.Version)
|
|
||||||
return true
|
|
||||||
case "health":
|
case "health":
|
||||||
err := health.Check()
|
err := health.Check()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -42,18 +38,22 @@ func (opts *cmdOptions) parse() bool {
|
|||||||
}
|
}
|
||||||
fmt.Print("ok")
|
fmt.Print("ok")
|
||||||
return true
|
return true
|
||||||
|
case "fingerprint":
|
||||||
|
handleFingerprint()
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// pflag.CommandLine.ParseErrorsWhitelist.UnknownFlags = true
|
// pflag.CommandLine.ParseErrorsWhitelist.UnknownFlags = true
|
||||||
pflag.StringVarP(&opts.key, "key", "k", "", "Public key(s) for SSH authentication")
|
pflag.StringVarP(&opts.key, "key", "k", "", "Public key(s) for SSH authentication")
|
||||||
pflag.StringVarP(&opts.listen, "listen", "l", "", "Address or port to listen on")
|
pflag.StringVarP(&opts.listen, "listen", "l", "", "Address or port to listen on")
|
||||||
// pflag.StringVarP(&opts.hubURL, "hub-url", "u", "", "URL of the hub to use")
|
pflag.StringVarP(&opts.hubURL, "url", "u", "", "URL of the Beszel hub")
|
||||||
// pflag.StringVarP(&opts.token, "token", "t", "", "Token to use for authentication")
|
pflag.StringVarP(&opts.token, "token", "t", "", "Token to use for authentication")
|
||||||
chinaMirrors := pflag.BoolP("china-mirrors", "c", false, "Use mirror for update (gh.beszel.dev) instead of GitHub")
|
chinaMirrors := pflag.BoolP("china-mirrors", "c", false, "Use mirror for update (gh.beszel.dev) instead of GitHub")
|
||||||
|
version := pflag.BoolP("version", "v", false, "Show version information")
|
||||||
help := pflag.BoolP("help", "h", false, "Show this help message")
|
help := pflag.BoolP("help", "h", false, "Show this help message")
|
||||||
|
|
||||||
// Convert old single-dash long flags to double-dash for backward compatibility
|
// Convert old single-dash long flags to double-dash for backward compatibility
|
||||||
flagsToConvert := []string{"key", "listen"}
|
flagsToConvert := []string{"key", "listen", "url", "token"}
|
||||||
for i, arg := range os.Args {
|
for i, arg := range os.Args {
|
||||||
for _, flag := range flagsToConvert {
|
for _, flag := range flagsToConvert {
|
||||||
singleDash := "-" + flag
|
singleDash := "-" + flag
|
||||||
@@ -74,9 +74,9 @@ func (opts *cmdOptions) parse() bool {
|
|||||||
builder.WriteString(os.Args[0])
|
builder.WriteString(os.Args[0])
|
||||||
builder.WriteString(" [command] [flags]\n")
|
builder.WriteString(" [command] [flags]\n")
|
||||||
builder.WriteString("\nCommands:\n")
|
builder.WriteString("\nCommands:\n")
|
||||||
builder.WriteString(" health Check if the agent is running\n")
|
builder.WriteString(" fingerprint View or reset the agent fingerprint\n")
|
||||||
// builder.WriteString(" help Display this help message\n")
|
builder.WriteString(" health Check if the agent is running\n")
|
||||||
builder.WriteString(" update Update to the latest version\n")
|
builder.WriteString(" update Update to the latest version\n")
|
||||||
builder.WriteString("\nFlags:\n")
|
builder.WriteString("\nFlags:\n")
|
||||||
fmt.Print(builder.String())
|
fmt.Print(builder.String())
|
||||||
pflag.PrintDefaults()
|
pflag.PrintDefaults()
|
||||||
@@ -87,6 +87,9 @@ func (opts *cmdOptions) parse() bool {
|
|||||||
|
|
||||||
// Must run after pflag.Parse()
|
// Must run after pflag.Parse()
|
||||||
switch {
|
switch {
|
||||||
|
case *version:
|
||||||
|
fmt.Println(beszel.AppName+"-agent", beszel.Version)
|
||||||
|
return true
|
||||||
case *help || subcommand == "help":
|
case *help || subcommand == "help":
|
||||||
pflag.Usage()
|
pflag.Usage()
|
||||||
return true
|
return true
|
||||||
@@ -95,6 +98,13 @@ func (opts *cmdOptions) parse() bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set environment variables from CLI flags (if provided)
|
||||||
|
if opts.hubURL != "" {
|
||||||
|
os.Setenv("HUB_URL", opts.hubURL)
|
||||||
|
}
|
||||||
|
if opts.token != "" {
|
||||||
|
os.Setenv("TOKEN", opts.token)
|
||||||
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,6 +137,38 @@ func (opts *cmdOptions) getAddress() string {
|
|||||||
return agent.GetAddress(opts.listen)
|
return agent.GetAddress(opts.listen)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleFingerprint handles the "fingerprint" command with subcommands "view" and "reset".
|
||||||
|
func handleFingerprint() {
|
||||||
|
subCmd := ""
|
||||||
|
if len(os.Args) > 2 {
|
||||||
|
subCmd = os.Args[2]
|
||||||
|
}
|
||||||
|
|
||||||
|
switch subCmd {
|
||||||
|
case "", "view":
|
||||||
|
dataDir, _ := agent.GetDataDir()
|
||||||
|
fp := agent.GetFingerprint(dataDir, "", "")
|
||||||
|
fmt.Println(fp)
|
||||||
|
case "help", "-h", "--help":
|
||||||
|
fmt.Print(fingerprintUsage())
|
||||||
|
case "reset":
|
||||||
|
dataDir, err := agent.GetDataDir()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := agent.DeleteFingerprint(dataDir); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
fmt.Println("Fingerprint reset. A new one will be generated on next start.")
|
||||||
|
default:
|
||||||
|
log.Fatalf("Unknown command: %q\n\n%s", subCmd, fingerprintUsage())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fingerprintUsage() string {
|
||||||
|
return fmt.Sprintf("Usage: %s fingerprint [view|reset]\n\nCommands:\n view Print fingerprint (default)\n reset Reset saved fingerprint\n", os.Args[0])
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
var opts cmdOptions
|
var opts cmdOptions
|
||||||
subcommandHandled := opts.parse()
|
subcommandHandled := opts.parse()
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
package common
|
package common
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/fxamacker/cbor/v2"
|
||||||
"github.com/henrygd/beszel/internal/entities/smart"
|
"github.com/henrygd/beszel/internal/entities/smart"
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
"github.com/henrygd/beszel/internal/entities/systemd"
|
||||||
)
|
)
|
||||||
|
|
||||||
type WebSocketAction = uint8
|
type WebSocketAction = uint8
|
||||||
@@ -18,6 +20,8 @@ const (
|
|||||||
GetContainerInfo
|
GetContainerInfo
|
||||||
// Request SMART data from agent
|
// Request SMART data from agent
|
||||||
GetSmartData
|
GetSmartData
|
||||||
|
// Request detailed systemd service info from agent
|
||||||
|
GetSystemdInfo
|
||||||
// Add new actions here...
|
// Add new actions here...
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -31,13 +35,14 @@ type HubRequest[T any] struct {
|
|||||||
// AgentResponse defines the structure for responses sent from agent to hub.
|
// AgentResponse defines the structure for responses sent from agent to hub.
|
||||||
type AgentResponse struct {
|
type AgentResponse struct {
|
||||||
Id *uint32 `cbor:"0,keyasint,omitempty"`
|
Id *uint32 `cbor:"0,keyasint,omitempty"`
|
||||||
SystemData *system.CombinedData `cbor:"1,keyasint,omitempty,omitzero"`
|
SystemData *system.CombinedData `cbor:"1,keyasint,omitempty,omitzero"` // Legacy (<= 0.17)
|
||||||
Fingerprint *FingerprintResponse `cbor:"2,keyasint,omitempty,omitzero"`
|
Fingerprint *FingerprintResponse `cbor:"2,keyasint,omitempty,omitzero"` // Legacy (<= 0.17)
|
||||||
Error string `cbor:"3,keyasint,omitempty,omitzero"`
|
Error string `cbor:"3,keyasint,omitempty,omitzero"`
|
||||||
String *string `cbor:"4,keyasint,omitempty,omitzero"`
|
String *string `cbor:"4,keyasint,omitempty,omitzero"` // Legacy (<= 0.17)
|
||||||
SmartData map[string]smart.SmartData `cbor:"5,keyasint,omitempty,omitzero"`
|
SmartData map[string]smart.SmartData `cbor:"5,keyasint,omitempty,omitzero"` // Legacy (<= 0.17)
|
||||||
// Logs *LogsPayload `cbor:"4,keyasint,omitempty,omitzero"`
|
ServiceInfo systemd.ServiceDetails `cbor:"6,keyasint,omitempty,omitzero"` // Legacy (<= 0.17)
|
||||||
// RawBytes []byte `cbor:"4,keyasint,omitempty,omitzero"`
|
// Data is the generic response payload for new endpoints (0.18+)
|
||||||
|
Data cbor.RawMessage `cbor:"7,keyasint,omitempty,omitzero"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type FingerprintRequest struct {
|
type FingerprintRequest struct {
|
||||||
@@ -54,8 +59,8 @@ type FingerprintResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type DataRequestOptions struct {
|
type DataRequestOptions struct {
|
||||||
CacheTimeMs uint16 `cbor:"0,keyasint"`
|
CacheTimeMs uint16 `cbor:"0,keyasint"`
|
||||||
// ResourceType uint8 `cbor:"1,keyasint,omitempty,omitzero"`
|
IncludeDetails bool `cbor:"1,keyasint"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ContainerLogsRequest struct {
|
type ContainerLogsRequest struct {
|
||||||
@@ -65,3 +70,7 @@ type ContainerLogsRequest struct {
|
|||||||
type ContainerInfoRequest struct {
|
type ContainerInfoRequest struct {
|
||||||
ContainerID string `cbor:"0,keyasint"`
|
ContainerID string `cbor:"0,keyasint"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SystemdInfoRequest struct {
|
||||||
|
ServiceName string `cbor:"0,keyasint"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ COPY --from=builder /agent /agent
|
|||||||
# this is so we don't need to create the /tmp directory in the scratch container
|
# this is so we don't need to create the /tmp directory in the scratch container
|
||||||
COPY --from=builder /tmp /tmp
|
COPY --from=builder /tmp /tmp
|
||||||
|
|
||||||
|
# AMD GPU name lookup (used by agent on Linux when /usr/share/libdrm/amdgpu.ids is read)
|
||||||
|
COPY --from=builder /app/agent/test-data/amdgpu.ids /usr/share/libdrm/amdgpu.ids
|
||||||
|
|
||||||
# Ensure data persistence across container recreations
|
# Ensure data persistence across container recreations
|
||||||
VOLUME ["/var/lib/beszel-agent"]
|
VOLUME ["/var/lib/beszel-agent"]
|
||||||
|
|
||||||
|
|||||||
@@ -17,9 +17,12 @@ RUN rm -rf /tmp/*
|
|||||||
# --------------------------
|
# --------------------------
|
||||||
# Final image: default scratch-based agent
|
# Final image: default scratch-based agent
|
||||||
# --------------------------
|
# --------------------------
|
||||||
FROM alpine:latest
|
FROM alpine:3.23
|
||||||
COPY --from=builder /agent /agent
|
COPY --from=builder /agent /agent
|
||||||
|
|
||||||
|
# AMD GPU name lookup (used by agent on Linux when /usr/share/libdrm/amdgpu.ids is read)
|
||||||
|
COPY --from=builder /app/agent/test-data/amdgpu.ids /usr/share/libdrm/amdgpu.ids
|
||||||
|
|
||||||
RUN apk add --no-cache smartmontools
|
RUN apk add --no-cache smartmontools
|
||||||
|
|
||||||
# Ensure data persistence across container recreations
|
# Ensure data persistence across container recreations
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ RUN CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-
|
|||||||
# Final image
|
# Final image
|
||||||
# Note: must cap_add: [CAP_PERFMON] and mount /dev/dri/ as volume
|
# Note: must cap_add: [CAP_PERFMON] and mount /dev/dri/ as volume
|
||||||
# --------------------------
|
# --------------------------
|
||||||
FROM alpine:edge
|
FROM alpine:3.23
|
||||||
|
|
||||||
COPY --from=builder /agent /agent
|
COPY --from=builder /agent /agent
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM --platform=$BUILDPLATFORM golang:alpine AS builder
|
FROM --platform=$BUILDPLATFORM golang:bookworm AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -10,7 +10,7 @@ COPY . ./
|
|||||||
|
|
||||||
# Build
|
# Build
|
||||||
ARG TARGETOS TARGETARCH
|
ARG TARGETOS TARGETARCH
|
||||||
RUN CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /agent ./internal/cmd/agent
|
RUN CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -tags glibc -ldflags "-w -s" -o /agent ./internal/cmd/agent
|
||||||
|
|
||||||
# --------------------------
|
# --------------------------
|
||||||
# Smartmontools builder stage
|
# Smartmontools builder stage
|
||||||
@@ -37,6 +37,9 @@ RUN apt-get update && apt-get install -y \
|
|||||||
FROM nvidia/cuda:12.2.2-base-ubuntu22.04
|
FROM nvidia/cuda:12.2.2-base-ubuntu22.04
|
||||||
COPY --from=builder /agent /agent
|
COPY --from=builder /agent /agent
|
||||||
|
|
||||||
|
# AMD GPU name lookup (used by agent on hybrid laptops when /usr/share/libdrm/amdgpu.ids is read)
|
||||||
|
COPY --from=builder /app/agent/test-data/amdgpu.ids /usr/share/libdrm/amdgpu.ids
|
||||||
|
|
||||||
# Copy smartmontools binaries and config files
|
# Copy smartmontools binaries and config files
|
||||||
COPY --from=smartmontools-builder /usr/sbin/smartctl /usr/sbin/smartctl
|
COPY --from=smartmontools-builder /usr/sbin/smartctl /usr/sbin/smartctl
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,14 @@ type ApiStats struct {
|
|||||||
MemoryStats MemoryStats `json:"memory_stats"`
|
MemoryStats MemoryStats `json:"memory_stats"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Docker system info from /info API endpoint
|
||||||
|
type HostInfo struct {
|
||||||
|
OperatingSystem string `json:"OperatingSystem"`
|
||||||
|
KernelVersion string `json:"KernelVersion"`
|
||||||
|
NCPU int `json:"NCPU"`
|
||||||
|
MemTotal uint64 `json:"MemTotal"`
|
||||||
|
}
|
||||||
|
|
||||||
func (s *ApiStats) CalculateCpuPercentLinux(prevCpuContainer uint64, prevCpuSystem uint64) float64 {
|
func (s *ApiStats) CalculateCpuPercentLinux(prevCpuContainer uint64, prevCpuSystem uint64) float64 {
|
||||||
cpuDelta := s.CPUStats.CPUUsage.TotalUsage - prevCpuContainer
|
cpuDelta := s.CPUStats.CPUUsage.TotalUsage - prevCpuContainer
|
||||||
systemDelta := s.CPUStats.SystemUsage - prevCpuSystem
|
systemDelta := s.CPUStats.SystemUsage - prevCpuSystem
|
||||||
@@ -119,21 +127,43 @@ var DockerHealthStrings = map[string]DockerHealth{
|
|||||||
"unhealthy": DockerHealthUnhealthy,
|
"unhealthy": DockerHealthUnhealthy,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Docker container stats
|
// SharedCoreMetrics contains fields that are common to both container Stats and PveNodeStats
|
||||||
type Stats struct {
|
type SharedCoreMetrics struct {
|
||||||
Name string `json:"n" cbor:"0,keyasint"`
|
Name string `json:"n" cbor:"0,keyasint"`
|
||||||
Cpu float64 `json:"c" cbor:"1,keyasint"`
|
Cpu float64 `json:"c" cbor:"1,keyasint"`
|
||||||
Mem float64 `json:"m" cbor:"2,keyasint"`
|
Mem float64 `json:"m" cbor:"2,keyasint"`
|
||||||
NetworkSent float64 `json:"ns" cbor:"3,keyasint"`
|
NetworkSent float64 `json:"ns,omitzero" cbor:"3,keyasint,omitzero"` // deprecated 0.18.3 (MB) - keep field for old agents/records
|
||||||
NetworkRecv float64 `json:"nr" cbor:"4,keyasint"`
|
NetworkRecv float64 `json:"nr,omitzero" cbor:"4,keyasint,omitzero"` // deprecated 0.18.3 (MB) - keep field for old agents/records
|
||||||
|
Id string `json:"-" cbor:"7,keyasint"`
|
||||||
Health DockerHealth `json:"-" cbor:"5,keyasint"`
|
Bandwidth [2]uint64 `json:"b,omitzero" cbor:"9,keyasint,omitzero"` // [sent bytes, recv bytes]
|
||||||
Status string `json:"-" cbor:"6,keyasint"`
|
|
||||||
Id string `json:"-" cbor:"7,keyasint"`
|
|
||||||
Image string `json:"-" cbor:"8,keyasint"`
|
|
||||||
// PrevCpu [2]uint64 `json:"-"`
|
|
||||||
CpuSystem uint64 `json:"-"`
|
|
||||||
CpuContainer uint64 `json:"-"`
|
|
||||||
PrevNet prevNetStats `json:"-"`
|
PrevNet prevNetStats `json:"-"`
|
||||||
PrevReadTime time.Time `json:"-"`
|
PrevReadTime time.Time `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stats holds data specific to docker containers for the containers table
|
||||||
|
type Stats struct {
|
||||||
|
SharedCoreMetrics // used to populate stats field in container_stats
|
||||||
|
|
||||||
|
// fields used for containers table
|
||||||
|
|
||||||
|
Health DockerHealth `json:"-" cbor:"5,keyasint"`
|
||||||
|
Status string `json:"-" cbor:"6,keyasint"`
|
||||||
|
Image string `json:"-" cbor:"8,keyasint"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PveNodeStats holds data specific to PVE nodes for the pve_vms table
|
||||||
|
type PveNodeStats struct {
|
||||||
|
SharedCoreMetrics // used to populate stats field in pve_stats
|
||||||
|
|
||||||
|
// fields used for pve_vms table
|
||||||
|
|
||||||
|
MaxCPU uint64 `json:"-" cbor:"10,keyasint,omitzero"` // PVE: max vCPU count
|
||||||
|
MaxMem uint64 `json:"-" cbor:"11,keyasint,omitzero"` // PVE: max memory bytes
|
||||||
|
Uptime uint64 `json:"-" cbor:"12,keyasint,omitzero"` // PVE: uptime in seconds
|
||||||
|
Type string `json:"-" cbor:"13,keyasint,omitzero"` // PVE: resource type (e.g. "qemu" or "lxc")
|
||||||
|
DiskRead uint64 `json:"-" cbor:"14,keyasint,omitzero"` // PVE: cumulative disk read bytes
|
||||||
|
DiskWrite uint64 `json:"-" cbor:"15,keyasint,omitzero"` // PVE: cumulative disk write bytes
|
||||||
|
Disk uint64 `json:"-" cbor:"16,keyasint,omitzero"` // PVE: allocated disk size in bytes
|
||||||
|
NetOut uint64 `json:"-" cbor:"17,keyasint,omitzero"` // PVE: cumulative bytes sent by VM
|
||||||
|
NetIn uint64 `json:"-" cbor:"18,keyasint,omitzero"` // PVE: cumulative bytes received by VM
|
||||||
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user