Compare commits
389 Commits
old-docker
...
2e14315e3b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e14315e3b | ||
|
|
da3543017d | ||
|
|
e2b9144cef | ||
|
|
d897325933 | ||
|
|
3382ff89e7 | ||
|
|
d07dd668e6 | ||
|
|
652c6f3303 | ||
|
|
05134cb354 | ||
|
|
397fdfd68b | ||
|
|
278e066c6f | ||
|
|
6573a599f6 | ||
|
|
059575dc83 | ||
|
|
40f55f6959 | ||
|
|
e586aeb17a | ||
|
|
dc3f2fdb51 | ||
|
|
a8bcb2022b | ||
|
|
9db2daa640 | ||
|
|
0364c59ae2 | ||
|
|
741c83d476 | ||
|
|
f061352ddc | ||
|
|
fa77ddfffe | ||
|
|
27f4a648d1 | ||
|
|
d0ff1ccc16 | ||
|
|
8edb928e50 | ||
|
|
97e31dd5a9 | ||
|
|
181bdab303 | ||
|
|
30756bb079 | ||
|
|
a0540821db | ||
|
|
1779a056c3 | ||
|
|
258c70f2d8 | ||
|
|
ea50c711b2 | ||
|
|
b30d957b38 | ||
|
|
e6abfc6a5d | ||
|
|
7d356d6edb | ||
|
|
30766df9fa | ||
|
|
eaf992f3f1 | ||
|
|
27b81aa489 | ||
|
|
5a903f472e | ||
|
|
4985474b0f | ||
|
|
0632db3c19 | ||
|
|
6980a4ef86 | ||
|
|
ee22bf8bcb | ||
|
|
ae4c402b99 | ||
|
|
7281094dc3 | ||
|
|
699e81eb1c | ||
|
|
a6065b6a19 | ||
|
|
b278c92e54 | ||
|
|
83d41ed1f9 | ||
|
|
7fa39a120f | ||
|
|
4b59748c0c | ||
|
|
ab2ef5bcfc | ||
|
|
2524b0d359 | ||
|
|
f84b159309 | ||
|
|
d5e5f8678a | ||
|
|
c91e0a9faa | ||
|
|
c6a6a353a5 | ||
|
|
7d1f8f5cae | ||
|
|
64a5ef4dfe | ||
|
|
030d2b6684 | ||
|
|
938e87db8b | ||
|
|
31ed898a58 | ||
|
|
1b8ad81046 | ||
|
|
ff3874491c | ||
|
|
fe81553c6a | ||
|
|
6e8b3f6265 | ||
|
|
9c87911a7f | ||
|
|
082cbb36e7 | ||
|
|
8347cf0e5c | ||
|
|
abfafae0d9 | ||
|
|
6496f35915 | ||
|
|
7fee3da2e8 | ||
|
|
443e203d9c | ||
|
|
24875b5733 | ||
|
|
66eaf45c79 | ||
|
|
2b9fb8b1ec | ||
|
|
06e0ae4733 | ||
|
|
4cc0bfeb0f | ||
|
|
320d0e5d97 | ||
|
|
67953bb6af | ||
|
|
123c57ded9 | ||
|
|
a7991ca184 | ||
|
|
1b01410533 | ||
|
|
1687919f31 | ||
|
|
6046dbb727 | ||
|
|
2505b16faa | ||
|
|
c4352e65fb | ||
|
|
7b2e9ccdcb | ||
|
|
75a87cddbf | ||
|
|
bda7b9b48d | ||
|
|
65d6ec1918 | ||
|
|
0e16fca07f | ||
|
|
b7e574f379 | ||
|
|
113f1833e1 | ||
|
|
a0d3e60d29 | ||
|
|
df7f12bfec | ||
|
|
02e88d99e5 | ||
|
|
a11ceb36d1 | ||
|
|
d3a373338d | ||
|
|
e1ef05da8c | ||
|
|
c01bfd9332 | ||
|
|
209e82aed2 | ||
|
|
c66e740d52 | ||
|
|
551ae49c2e | ||
|
|
906daa6f2a | ||
|
|
49e55943c1 | ||
|
|
9e75cbc1ef | ||
|
|
79190a2c51 | ||
|
|
32960c7c35 | ||
|
|
cdff974a8a | ||
|
|
20a6ef129c | ||
|
|
d1e5310f83 | ||
|
|
152173eab5 | ||
|
|
c2aec69638 | ||
|
|
16c97fe77b | ||
|
|
bed5bc2791 | ||
|
|
424c7aceff | ||
|
|
8eb101e714 | ||
|
|
f2e69e2a80 | ||
|
|
41b4dbce98 | ||
|
|
c3432fc7b5 | ||
|
|
b167244e28 | ||
|
|
bcca6a5a9d | ||
|
|
6b661b4878 | ||
|
|
bc537edb73 | ||
|
|
561a3e8aaf | ||
|
|
f173ea37da | ||
|
|
0d9f2ba06a | ||
|
|
4e772e6008 | ||
|
|
b959d97502 | ||
|
|
735cbe2cf0 | ||
|
|
0f5a470495 | ||
|
|
a42e99370e | ||
|
|
abfae78af1 | ||
|
|
e36c40c4a9 | ||
|
|
bf7b2ae598 | ||
|
|
f71ee4b058 | ||
|
|
f2466eb37d | ||
|
|
7244c7130b | ||
|
|
f2e84a9d3e | ||
|
|
04a1ee5e4e | ||
|
|
9b83088897 | ||
|
|
13b30aa255 | ||
|
|
2bd25e9e8d | ||
|
|
4fe192bf28 | ||
|
|
2056ae285f | ||
|
|
a02e7a0a69 | ||
|
|
b1a9e90034 | ||
|
|
20916fab3e | ||
|
|
0c777cca72 | ||
|
|
44e30ad429 | ||
|
|
9e30786dda | ||
|
|
1f677773e7 | ||
|
|
882289da91 | ||
|
|
fbbc4eff27 | ||
|
|
b78231f677 | ||
|
|
332e2d14a9 | ||
|
|
e088b88c84 | ||
|
|
09840f95d9 | ||
|
|
de03bb658a | ||
|
|
bcadfeb729 | ||
|
|
ec582a9171 | ||
|
|
0c3eaefc90 | ||
|
|
d6b5866f90 | ||
|
|
2ba629e4b4 | ||
|
|
6e9341b7ff | ||
|
|
89499a341b | ||
|
|
9c88845798 | ||
|
|
c9f65f63e6 | ||
|
|
996620c8e0 | ||
|
|
626c1358d9 | ||
|
|
19dd39b7db | ||
|
|
d482b50e31 | ||
|
|
f61ec49c76 | ||
|
|
2b43ba3cbe | ||
|
|
b2b1a0b6ea | ||
|
|
b11d0aae61 | ||
|
|
2b73d8845a | ||
|
|
41e3e3d760 | ||
|
|
c22b57ce67 | ||
|
|
23bee0aa7c | ||
|
|
0c2629f57e | ||
|
|
a4b689e8f1 | ||
|
|
0c5841133b | ||
|
|
78a645fa05 | ||
|
|
f1208a9f00 | ||
|
|
aeb5f1424b | ||
|
|
4a1fb513c5 | ||
|
|
e5a66cc156 | ||
|
|
aef3b3e610 | ||
|
|
1d13ecd8ec | ||
|
|
6404895a47 | ||
|
|
ba7db28e80 | ||
|
|
6b41a98338 | ||
|
|
b958e9eefe | ||
|
|
baf56fe83b | ||
|
|
96f9128d1a | ||
|
|
1fb60e05d7 | ||
|
|
a9a9a932a6 | ||
|
|
e0a1a49a8f | ||
|
|
79eb42d04d | ||
|
|
25b70af196 | ||
|
|
c12b27afb5 | ||
|
|
7485f79071 | ||
|
|
d170e7a00d | ||
|
|
1a6a2a64f2 | ||
|
|
646b899851 | ||
|
|
821e2e3a78 | ||
|
|
9be3fcb8ca | ||
|
|
f271b5a56c | ||
|
|
4f80a58929 | ||
|
|
2ab2cc83de | ||
|
|
3376a97bea | ||
|
|
0c54f95546 | ||
|
|
5ea6eb08a1 | ||
|
|
6b2a9463ca | ||
|
|
a94cfff965 | ||
|
|
1f69937572 | ||
|
|
aa3de511b9 | ||
|
|
3afab00937 | ||
|
|
e6054058b9 | ||
|
|
31d52d5e15 | ||
|
|
44d930a700 | ||
|
|
d7ada1b1c5 | ||
|
|
1daf35406a | ||
|
|
2216e40f04 | ||
|
|
f4480c7aa7 | ||
|
|
5b478c11eb | ||
|
|
58085bf300 | ||
|
|
ce171cf375 | ||
|
|
e689f547ef | ||
|
|
5a8e8c1512 | ||
|
|
ff5eb07716 | ||
|
|
fdbbbc77b0 | ||
|
|
20cba1b695 | ||
|
|
207d58a07e | ||
|
|
0759a3607c | ||
|
|
0b4742d064 | ||
|
|
4557f18195 | ||
|
|
83668e5727 | ||
|
|
120aff0d18 | ||
|
|
7170b24160 | ||
|
|
3441b39a02 | ||
|
|
31d306f8be | ||
|
|
76347f25e5 | ||
|
|
c157f38957 | ||
|
|
d185dfdef8 | ||
|
|
319a9895b0 | ||
|
|
68dae3967d | ||
|
|
0a331524cc | ||
|
|
5b625db57c | ||
|
|
0943e01b71 | ||
|
|
cfda7d0740 | ||
|
|
4e3d198b7b | ||
|
|
a6a9719565 | ||
|
|
ddb4f1c8f8 | ||
|
|
55d13c551a | ||
|
|
fef30b1750 | ||
|
|
0cee9e4e4b | ||
|
|
90378d09a3 | ||
|
|
7adf7ef549 | ||
|
|
ee6a456b66 | ||
|
|
4789f48ad0 | ||
|
|
ea098fd61c | ||
|
|
e7c214799a | ||
|
|
9cabc103e5 | ||
|
|
be955e0122 | ||
|
|
8a0f2d61a8 | ||
|
|
48ed4abc02 | ||
|
|
e8c680bda7 | ||
|
|
37c7a32c10 | ||
|
|
1c4533f1f2 | ||
|
|
5d88599c9a | ||
|
|
73427306d1 | ||
|
|
f24a7313d6 | ||
|
|
ad55d1ca88 | ||
|
|
9c669d8833 | ||
|
|
81fa4f16d6 | ||
|
|
40cc1a875e | ||
|
|
1ac165d7d3 | ||
|
|
9619e6cf89 | ||
|
|
fc31cefd4c | ||
|
|
5fd9010b39 | ||
|
|
c2e3dd5ab1 | ||
|
|
8e531e6b3c | ||
|
|
527e6b57d5 | ||
|
|
245fa538e9 | ||
|
|
e14a851398 | ||
|
|
0c9bc47a3a | ||
|
|
19b4477a75 | ||
|
|
558d051c42 | ||
|
|
9c8528bae1 | ||
|
|
229ef19376 | ||
|
|
e5fb4d611a | ||
|
|
bc9dc9704c | ||
|
|
e88eb1a884 | ||
|
|
d8f3206e8b | ||
|
|
729d306157 | ||
|
|
c35df48754 | ||
|
|
0f97f37a79 | ||
|
|
b08219dacf | ||
|
|
dd10fb97c0 | ||
|
|
87354df2de | ||
|
|
1bd04498b9 | ||
|
|
52394bc99b | ||
|
|
add85e9747 | ||
|
|
e82986adff | ||
|
|
f201267e4e | ||
|
|
9db41f8830 | ||
|
|
ba64c59632 | ||
|
|
d2626d8337 | ||
|
|
ded1090190 | ||
|
|
1114baaaa0 | ||
|
|
cf13c1c671 | ||
|
|
e70de6a59e | ||
|
|
5110eaf10f | ||
|
|
0234682720 | ||
|
|
80a7322fa1 | ||
|
|
59bdc0ce0d | ||
|
|
a288d0925b | ||
|
|
e7d2f0d82b | ||
|
|
825d8269ff | ||
|
|
f7775d173a | ||
|
|
58bced5f09 | ||
|
|
6e08507dde | ||
|
|
617a03fc15 | ||
|
|
f86bda304d | ||
|
|
1d414e659b | ||
|
|
87f7390eca | ||
|
|
ed01752546 | ||
|
|
46002a2171 | ||
|
|
14716d36a6 | ||
|
|
b4bc8a31aa | ||
|
|
b01fc316c3 | ||
|
|
4479249ac7 | ||
|
|
0529837ac8 | ||
|
|
d51ffa17ed | ||
|
|
c434a44bc4 | ||
|
|
7b5ac23a4b | ||
|
|
87ef769086 | ||
|
|
bcefb8e43c | ||
|
|
a1641c5bcc | ||
|
|
e6839480d9 | ||
|
|
4e64d9efad | ||
|
|
d68f4514cc | ||
|
|
8a69c09939 | ||
|
|
e87af81db4 | ||
|
|
6043c59da8 | ||
|
|
4cb7b97416 | ||
|
|
b1db450e00 | ||
|
|
2e8ac98924 | ||
|
|
529a628368 | ||
|
|
8a2e821c8f | ||
|
|
3cd11d6bc4 | ||
|
|
db092d2440 | ||
|
|
a4a7c91fc1 | ||
|
|
543fd44cb2 | ||
|
|
eab262c3f7 | ||
|
|
52bde8ea6d | ||
|
|
03de73560c | ||
|
|
bcb7de1b9a | ||
|
|
ca94bd32f2 | ||
|
|
cd10727795 | ||
|
|
8262a9a45b | ||
|
|
b433437636 | ||
|
|
02825ed109 | ||
|
|
a97e6149bb | ||
|
|
946b1e7f54 | ||
|
|
b5ed7cd555 | ||
|
|
233349fb2a | ||
|
|
c54e6ff0ea | ||
|
|
98c4102f72 | ||
|
|
640ee7a88e | ||
|
|
8a85246a0b | ||
|
|
655bfc95ca | ||
|
|
37a066e6bd | ||
|
|
9e959a6b7b | ||
|
|
2b6560b9e1 | ||
|
|
d8836d53bf | ||
|
|
aa15876aa2 | ||
|
|
7ca960b521 | ||
|
|
4eaedcf825 | ||
|
|
b337ba1d7f | ||
|
|
c9b72f724f | ||
|
|
35d8996e00 | ||
|
|
6e61c5f1e4 | ||
|
|
6bb147c349 | ||
|
|
3668aa4e8e | ||
|
|
4c324bff73 | ||
|
|
741575df15 |
61
.github/DISCUSSION_TEMPLATE/support.yml
vendored
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
### Before opening a discussion:
|
||||||
|
|
||||||
|
- Check the [common issues guide](https://beszel.dev/guide/common-issues).
|
||||||
|
- Search existing [issues](https://github.com/henrygd/beszel/issues) and [discussions](https://github.com/henrygd/beszel/discussions) (including closed).
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: description
|
||||||
|
attributes:
|
||||||
|
label: Description
|
||||||
|
description: A clear and concise description of the issue or question. If applicable, add screenshots to help explain your problem.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
id: system
|
||||||
|
attributes:
|
||||||
|
label: OS / Architecture
|
||||||
|
placeholder: linux/amd64 (agent), freebsd/arm64 (hub)
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
id: version
|
||||||
|
attributes:
|
||||||
|
label: Beszel version
|
||||||
|
placeholder: 0.9.1
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
id: install-method
|
||||||
|
attributes:
|
||||||
|
label: Installation method
|
||||||
|
options:
|
||||||
|
- Docker
|
||||||
|
- Binary
|
||||||
|
- Nix
|
||||||
|
- Unraid
|
||||||
|
- Coolify
|
||||||
|
- Other (please describe above)
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: config
|
||||||
|
attributes:
|
||||||
|
label: Configuration
|
||||||
|
description: Please provide any relevant service configuration
|
||||||
|
render: yaml
|
||||||
|
- type: textarea
|
||||||
|
id: hub-logs
|
||||||
|
attributes:
|
||||||
|
label: Hub Logs
|
||||||
|
description: Check the logs page in PocketBase (`/_/#/logs`) for relevant errors (copy JSON).
|
||||||
|
render: json
|
||||||
|
- type: textarea
|
||||||
|
id: agent-logs
|
||||||
|
attributes:
|
||||||
|
label: Agent Logs
|
||||||
|
description: Please provide any logs from the agent, if relevant. Use `LOG_LEVEL=debug` for more info.
|
||||||
|
render: shell
|
||||||
91
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
name: 🐛 Bug report
|
||||||
|
description: Report a new bug or issue.
|
||||||
|
title: '[Bug]: '
|
||||||
|
labels: ['bug']
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
### Thanks for taking the time to fill out this bug report!
|
||||||
|
|
||||||
|
- For more general support, please [start a support thread](https://github.com/henrygd/beszel/discussions/new?category=support).
|
||||||
|
- To request a change or feature, use the [feature request form](https://github.com/henrygd/beszel/issues/new?template=feature_request.yml).
|
||||||
|
- Please do not submit bugs that are specific to ZFS. We plan to add integration with ZFS utilities in the near future.
|
||||||
|
|
||||||
|
### Before submitting a bug report:
|
||||||
|
|
||||||
|
- Check the [common issues guide](https://beszel.dev/guide/common-issues).
|
||||||
|
- Search existing [issues](https://github.com/henrygd/beszel/issues) and [discussions](https://github.com/henrygd/beszel/discussions) (including closed).
|
||||||
|
- type: textarea
|
||||||
|
id: description
|
||||||
|
attributes:
|
||||||
|
label: Description
|
||||||
|
description: Explain the issue you experienced clearly and concisely.
|
||||||
|
placeholder: I went to the coffee pot and it was empty.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: expected-behavior
|
||||||
|
attributes:
|
||||||
|
label: Expected Behavior
|
||||||
|
description: In a perfect world, what should have happened?
|
||||||
|
placeholder: When I got to the coffee pot, it should have been full.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: steps-to-reproduce
|
||||||
|
attributes:
|
||||||
|
label: Steps to Reproduce
|
||||||
|
description: Describe how to reproduce the issue in repeatable steps.
|
||||||
|
placeholder: |
|
||||||
|
1. Go to the coffee pot.
|
||||||
|
2. Make more coffee.
|
||||||
|
3. Pour it into a cup.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
id: system
|
||||||
|
attributes:
|
||||||
|
label: OS / Architecture
|
||||||
|
placeholder: linux/amd64 (agent), freebsd/arm64 (hub)
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
id: version
|
||||||
|
attributes:
|
||||||
|
label: Beszel version
|
||||||
|
placeholder: 0.9.1
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
id: install-method
|
||||||
|
attributes:
|
||||||
|
label: Installation method
|
||||||
|
default: 0
|
||||||
|
options:
|
||||||
|
- Docker
|
||||||
|
- Binary
|
||||||
|
- Nix
|
||||||
|
- Unraid
|
||||||
|
- Coolify
|
||||||
|
- Other (please describe above)
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: config
|
||||||
|
attributes:
|
||||||
|
label: Configuration
|
||||||
|
description: Please provide any relevant service configuration
|
||||||
|
render: yaml
|
||||||
|
- type: textarea
|
||||||
|
id: hub-logs
|
||||||
|
attributes:
|
||||||
|
label: Hub Logs
|
||||||
|
description: Check the logs page in PocketBase (`/_/#/logs`) for relevant errors (copy JSON).
|
||||||
|
render: json
|
||||||
|
- type: textarea
|
||||||
|
id: agent-logs
|
||||||
|
attributes:
|
||||||
|
label: Agent Logs
|
||||||
|
description: Please provide any logs from the agent, if relevant. Use `LOG_LEVEL=debug` for more info.
|
||||||
|
render: shell
|
||||||
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
|
contact_links:
|
||||||
|
- name: 💬 Support and questions
|
||||||
|
url: https://github.com/henrygd/beszel/discussions
|
||||||
|
about: Ask and answer questions here.
|
||||||
|
- name: ℹ️ View the Common Issues page
|
||||||
|
url: https://beszel.dev/guide/common-issues
|
||||||
|
about: Find information about commonly encountered problems.
|
||||||
18
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
name: 🚀 Feature request
|
||||||
|
description: Request a new feature or change.
|
||||||
|
title: "[Feature]: "
|
||||||
|
labels: ["enhancement"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: Before submitting, please search existing [issues](https://github.com/henrygd/beszel/issues) and [discussions](https://github.com/henrygd/beszel/discussions) (including closed).
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Describe the feature you would like to see
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Describe how you would like to see this feature implemented
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
1
.github/funding.yml
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
buy_me_a_coffee: henrygd
|
||||||
23
.github/workflows/docker-images.yml
vendored
@@ -15,9 +15,27 @@ jobs:
|
|||||||
- image: henrygd/beszel
|
- image: henrygd/beszel
|
||||||
context: ./beszel
|
context: ./beszel
|
||||||
dockerfile: ./beszel/dockerfile_Hub
|
dockerfile: ./beszel/dockerfile_Hub
|
||||||
|
registry: docker.io
|
||||||
|
username_secret: DOCKERHUB_USERNAME
|
||||||
|
password_secret: DOCKERHUB_TOKEN
|
||||||
- image: henrygd/beszel-agent
|
- image: henrygd/beszel-agent
|
||||||
context: ./beszel
|
context: ./beszel
|
||||||
dockerfile: ./beszel/dockerfile_Agent
|
dockerfile: ./beszel/dockerfile_Agent
|
||||||
|
registry: docker.io
|
||||||
|
username_secret: DOCKERHUB_USERNAME
|
||||||
|
password_secret: DOCKERHUB_TOKEN
|
||||||
|
- image: ghcr.io/${{ github.repository }}/beszel
|
||||||
|
context: ./beszel
|
||||||
|
dockerfile: ./beszel/dockerfile_Hub
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password_secret: GITHUB_TOKEN
|
||||||
|
- image: ghcr.io/${{ github.repository }}/beszel-agent
|
||||||
|
context: ./beszel
|
||||||
|
dockerfile: ./beszel/dockerfile_Agent
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password_secret: GITHUB_TOKEN
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
packages: write
|
packages: write
|
||||||
@@ -57,8 +75,9 @@ jobs:
|
|||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ matrix.username || secrets[matrix.username_secret] }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets[matrix.password_secret] }}
|
||||||
|
registry: ${{ matrix.registry }}
|
||||||
|
|
||||||
# Build and push Docker image with Buildx (don't push on PR)
|
# Build and push Docker image with Buildx (don't push on PR)
|
||||||
# https://github.com/docker/build-push-action
|
# https://github.com/docker/build-push-action
|
||||||
|
|||||||
3
.gitignore
vendored
@@ -13,3 +13,6 @@ beszel/cmd/agent/agent
|
|||||||
node_modules
|
node_modules
|
||||||
beszel/build
|
beszel/build
|
||||||
*timestamp*
|
*timestamp*
|
||||||
|
.swc
|
||||||
|
beszel/site/src/locales/**/*.ts
|
||||||
|
*.bak
|
||||||
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
## Reporting a Vulnerability
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
If you find a vulnerability in the latest version, please email me directly at hank@henrygd.me, or [submit a private advisory](https://github.com/henrygd/beszel/security/advisories/new).
|
If you find a vulnerability in the latest version, please [submit a private advisory](https://github.com/henrygd/beszel/security/advisories/new).
|
||||||
|
|
||||||
If you submit an advisory, open an empty issue as well to let me know that you did (or email me), as I'm not sure if I get notifications for that.
|
If it's low severity (use best judgement) you may open an issue instead of an advisory.
|
||||||
|
|
||||||
If the issue is low severity (use best judgement) you may open an issue for it instead of contacting me directly.
|
|
||||||
|
|||||||
@@ -29,14 +29,22 @@ builds:
|
|||||||
- linux
|
- linux
|
||||||
- darwin
|
- darwin
|
||||||
- freebsd
|
- freebsd
|
||||||
|
- windows
|
||||||
goarch:
|
goarch:
|
||||||
- amd64
|
- amd64
|
||||||
- arm64
|
- arm64
|
||||||
- arm
|
- arm
|
||||||
- mips64
|
- mips64
|
||||||
|
- riscv64
|
||||||
ignore:
|
ignore:
|
||||||
- goos: freebsd
|
- goos: freebsd
|
||||||
goarch: arm
|
goarch: arm
|
||||||
|
- goos: windows
|
||||||
|
goarch: arm
|
||||||
|
- goos: darwin
|
||||||
|
goarch: riscv64
|
||||||
|
- goos: windows
|
||||||
|
goarch: riscv64
|
||||||
|
|
||||||
archives:
|
archives:
|
||||||
- id: beszel
|
- id: beszel
|
||||||
@@ -47,6 +55,10 @@ archives:
|
|||||||
{{ .Binary }}_
|
{{ .Binary }}_
|
||||||
{{- .Os }}_
|
{{- .Os }}_
|
||||||
{{- .Arch }}
|
{{- .Arch }}
|
||||||
|
format_overrides:
|
||||||
|
- goos: windows
|
||||||
|
format: zip
|
||||||
|
|
||||||
- id: beszel-agent
|
- id: beszel-agent
|
||||||
format: tar.gz
|
format: tar.gz
|
||||||
builds:
|
builds:
|
||||||
@@ -55,10 +67,52 @@ archives:
|
|||||||
{{ .Binary }}_
|
{{ .Binary }}_
|
||||||
{{- .Os }}_
|
{{- .Os }}_
|
||||||
{{- .Arch }}
|
{{- .Arch }}
|
||||||
# use zip for windows archives
|
|
||||||
# format_overrides:
|
nfpms:
|
||||||
# - goos: windows
|
- id: beszel-agent
|
||||||
# format: zip
|
package_name: beszel-agent
|
||||||
|
description: |-
|
||||||
|
Agent for Beszel
|
||||||
|
Beszel is a lightweight server monitoring platform that includes Docker
|
||||||
|
statistics, historical data, and alert functions. It has a friendly web
|
||||||
|
interface, simple configuration, and is ready to use out of the box.
|
||||||
|
It supports automatic backup, multi-user, OAuth authentication, and
|
||||||
|
API access.
|
||||||
|
maintainer: henrygd <hank@henrygd.me>
|
||||||
|
section: net
|
||||||
|
builds:
|
||||||
|
- beszel-agent
|
||||||
|
formats:
|
||||||
|
- deb
|
||||||
|
# don't think this is needed with CGO_ENABLED=0
|
||||||
|
# dependencies:
|
||||||
|
# - libc6
|
||||||
|
contents:
|
||||||
|
- src: ../supplemental/debian/beszel-agent.service
|
||||||
|
dst: lib/systemd/system/beszel-agent.service
|
||||||
|
packager: deb
|
||||||
|
- src: ../supplemental/debian/copyright
|
||||||
|
dst: usr/share/doc/beszel-agent/copyright
|
||||||
|
packager: deb
|
||||||
|
- src: ../supplemental/debian/lintian-overrides
|
||||||
|
dst: usr/share/lintian/overrides/beszel-agent
|
||||||
|
packager: deb
|
||||||
|
scripts:
|
||||||
|
postinstall: ../supplemental/debian/postinstall.sh
|
||||||
|
preremove: ../supplemental/debian/prerm.sh
|
||||||
|
postremove: ../supplemental/debian/postrm.sh
|
||||||
|
deb:
|
||||||
|
predepends:
|
||||||
|
- adduser
|
||||||
|
- debconf
|
||||||
|
scripts:
|
||||||
|
templates: ../supplemental/debian/templates
|
||||||
|
# Currently broken due to a bug in goreleaser
|
||||||
|
# https://github.com/goreleaser/goreleaser/issues/5487
|
||||||
|
#config: ../supplemental/debian/config.sh
|
||||||
|
|
||||||
|
release:
|
||||||
|
draft: true
|
||||||
|
|
||||||
changelog:
|
changelog:
|
||||||
disable: true
|
disable: true
|
||||||
|
|||||||
@@ -4,9 +4,16 @@ 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
|
||||||
|
|
||||||
.PHONY: tidy build-agent build-hub build clean lint
|
.PHONY: tidy build-agent build-hub build clean lint dev-server dev-agent dev-hub dev generate-locales
|
||||||
.DEFAULT_GOAL := build
|
.DEFAULT_GOAL := build
|
||||||
|
|
||||||
|
clean:
|
||||||
|
go clean
|
||||||
|
rm -rf ./build
|
||||||
|
|
||||||
|
lint:
|
||||||
|
golangci-lint run
|
||||||
|
|
||||||
tidy:
|
tidy:
|
||||||
go mod tidy
|
go mod tidy
|
||||||
|
|
||||||
@@ -20,16 +27,42 @@ build-web-ui:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
build-agent: tidy
|
build-agent: tidy
|
||||||
CGO_ENABLED=0 GOOS=$(OS) GOARCH=$(ARCH) go build -o ./build/beszel-agent_$(OS)_$(ARCH) -ldflags "-w -s" beszel/cmd/agent
|
GOOS=$(OS) GOARCH=$(ARCH) go build -o ./build/beszel-agent_$(OS)_$(ARCH) -ldflags "-w -s" beszel/cmd/agent
|
||||||
|
|
||||||
build-hub: tidy $(if $(filter false,$(SKIP_WEB)),build-web-ui)
|
build-hub: tidy $(if $(filter false,$(SKIP_WEB)),build-web-ui)
|
||||||
CGO_ENABLED=0 GOOS=$(OS) GOARCH=$(ARCH) go build -o ./build/beszel_$(OS)_$(ARCH) -ldflags "-w -s" beszel/cmd/hub
|
GOOS=$(OS) GOARCH=$(ARCH) go build -o ./build/beszel_$(OS)_$(ARCH) -ldflags "-w -s" beszel/cmd/hub
|
||||||
|
|
||||||
build: build-agent build-hub
|
build: build-agent build-hub
|
||||||
|
|
||||||
clean:
|
generate-locales:
|
||||||
go clean
|
@if [ ! -f ./site/src/locales/en/en.ts ]; then \
|
||||||
rm -rf ./build
|
echo "Generating locales..."; \
|
||||||
|
command -v bun >/dev/null 2>&1 && cd ./site && bun install && bun run sync || cd ./site && npm install && npm run sync; \
|
||||||
|
fi
|
||||||
|
|
||||||
lint:
|
dev-server: generate-locales
|
||||||
golangci-lint run
|
cd ./site
|
||||||
|
@if command -v bun >/dev/null 2>&1; then \
|
||||||
|
cd ./site && bun run dev; \
|
||||||
|
else \
|
||||||
|
cd ./site && npm run dev; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
dev-hub: export ENV=dev
|
||||||
|
dev-hub:
|
||||||
|
mkdir -p ./site/dist && touch ./site/dist/index.html
|
||||||
|
@if command -v entr >/dev/null 2>&1; then \
|
||||||
|
find ./cmd/hub/*.go ./internal/{alerts,hub,records,users}/*.go | entr -r -s "cd ./cmd/hub && go run . serve"; \
|
||||||
|
else \
|
||||||
|
cd ./cmd/hub && go run . serve; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
dev-agent:
|
||||||
|
@if command -v entr >/dev/null 2>&1; then \
|
||||||
|
find ./cmd/agent/*.go ./internal/agent/*.go | entr -r go run beszel/cmd/agent; \
|
||||||
|
else \
|
||||||
|
go run beszel/cmd/agent; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
# KEY="..." make -j dev
|
||||||
|
dev: dev-server dev-hub dev-agent
|
||||||
|
|||||||
@@ -3,39 +3,134 @@ package main
|
|||||||
import (
|
import (
|
||||||
"beszel"
|
"beszel"
|
||||||
"beszel/internal/agent"
|
"beszel/internal/agent"
|
||||||
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
// cli options
|
||||||
// handle flags / subcommands
|
type cmdOptions struct {
|
||||||
if len(os.Args) > 1 {
|
key string // key is the public key(s) for SSH authentication.
|
||||||
switch os.Args[1] {
|
addr string // addr is the address or port to listen on.
|
||||||
case "-v":
|
}
|
||||||
fmt.Println(beszel.AppName+"-agent", beszel.Version)
|
|
||||||
case "update":
|
// parseFlags parses the command line flags and populates the config struct.
|
||||||
agent.Update()
|
func (opts *cmdOptions) parseFlags() {
|
||||||
}
|
flag.StringVar(&opts.key, "key", "", "Public key(s) for SSH authentication")
|
||||||
|
flag.StringVar(&opts.addr, "addr", "", "Address or port to listen on")
|
||||||
|
|
||||||
|
flag.Usage = func() {
|
||||||
|
fmt.Printf("Usage: %s [options] [subcommand]\n", os.Args[0])
|
||||||
|
fmt.Println("\nOptions:")
|
||||||
|
flag.PrintDefaults()
|
||||||
|
fmt.Println("\nSubcommands:")
|
||||||
|
fmt.Println(" version Display the version")
|
||||||
|
fmt.Println(" help Display this help message")
|
||||||
|
fmt.Println(" update Update the agent to the latest version")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSubcommand handles subcommands such as version, help, and update.
|
||||||
|
// It returns true if a subcommand was handled, false otherwise.
|
||||||
|
func handleSubcommand() bool {
|
||||||
|
if len(os.Args) <= 1 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch os.Args[1] {
|
||||||
|
case "version", "-v":
|
||||||
|
fmt.Println(beszel.AppName+"-agent", beszel.Version)
|
||||||
|
os.Exit(0)
|
||||||
|
case "help":
|
||||||
|
flag.Usage()
|
||||||
|
os.Exit(0)
|
||||||
|
case "update":
|
||||||
|
agent.Update()
|
||||||
os.Exit(0)
|
os.Exit(0)
|
||||||
}
|
}
|
||||||
|
return false
|
||||||
var pubKey []byte
|
}
|
||||||
if pubKeyEnv, exists := os.LookupEnv("KEY"); exists {
|
|
||||||
pubKey = []byte(pubKeyEnv)
|
// loadPublicKeys loads the public keys from the command line flag, environment variable, or key file.
|
||||||
} else {
|
func (opts *cmdOptions) loadPublicKeys() ([]ssh.PublicKey, error) {
|
||||||
log.Fatal("KEY environment variable is not set")
|
// Try command line flag first
|
||||||
}
|
if opts.key != "" {
|
||||||
|
return agent.ParseKeys(opts.key)
|
||||||
addr := ":45876"
|
}
|
||||||
if portEnvVar, exists := os.LookupEnv("PORT"); exists {
|
|
||||||
// allow passing an address in the form of "127.0.0.1:45876"
|
// Try environment variable
|
||||||
if !strings.Contains(portEnvVar, ":") {
|
if key, ok := agent.GetEnv("KEY"); ok && key != "" {
|
||||||
portEnvVar = ":" + portEnvVar
|
return agent.ParseKeys(key)
|
||||||
}
|
}
|
||||||
addr = portEnvVar
|
|
||||||
}
|
// Try key file
|
||||||
|
keyFile, ok := agent.GetEnv("KEY_FILE")
|
||||||
agent.NewAgent().Run(pubKey, addr)
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("no key provided: must set -key flag, KEY env var, or KEY_FILE env var. Use 'beszel-agent help' for usage")
|
||||||
|
}
|
||||||
|
|
||||||
|
pubKey, err := os.ReadFile(keyFile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read key file: %w", err)
|
||||||
|
}
|
||||||
|
return agent.ParseKeys(string(pubKey))
|
||||||
|
}
|
||||||
|
|
||||||
|
// getAddress gets the address to listen on from the command line flag, environment variable, or default value.
|
||||||
|
func (opts *cmdOptions) getAddress() string {
|
||||||
|
// Try command line flag first
|
||||||
|
if opts.addr != "" {
|
||||||
|
return opts.addr
|
||||||
|
}
|
||||||
|
// Try environment variables
|
||||||
|
if addr, ok := agent.GetEnv("ADDR"); ok && addr != "" {
|
||||||
|
return addr
|
||||||
|
}
|
||||||
|
// Legacy PORT environment variable support
|
||||||
|
if port, ok := agent.GetEnv("PORT"); ok && port != "" {
|
||||||
|
return port
|
||||||
|
}
|
||||||
|
return ":45876"
|
||||||
|
}
|
||||||
|
|
||||||
|
// getNetwork returns the network type to use for the server.
|
||||||
|
func (opts *cmdOptions) getNetwork() string {
|
||||||
|
if network, _ := agent.GetEnv("NETWORK"); network != "" {
|
||||||
|
return network
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(opts.addr, "/") {
|
||||||
|
return "unix"
|
||||||
|
}
|
||||||
|
return "tcp"
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
var opts cmdOptions
|
||||||
|
opts.parseFlags()
|
||||||
|
|
||||||
|
if handleSubcommand() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
opts.addr = opts.getAddress()
|
||||||
|
|
||||||
|
var serverConfig agent.ServerOptions
|
||||||
|
var err error
|
||||||
|
serverConfig.Keys, err = opts.loadPublicKeys()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("Failed to load public keys:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
serverConfig.Addr = opts.addr
|
||||||
|
serverConfig.Network = opts.getNetwork()
|
||||||
|
|
||||||
|
agent := agent.NewAgent()
|
||||||
|
if err := agent.StartServer(serverConfig); err != nil {
|
||||||
|
log.Fatal("Failed to start server:", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
302
beszel/cmd/agent/agent_test.go
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ed25519"
|
||||||
|
"flag"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetAddress(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
opts cmdOptions
|
||||||
|
envVars map[string]string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "default port when no config",
|
||||||
|
opts: cmdOptions{},
|
||||||
|
expected: ":45876",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "use address from flag",
|
||||||
|
opts: cmdOptions{
|
||||||
|
addr: "8080",
|
||||||
|
},
|
||||||
|
expected: "8080",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "use unix socket from flag",
|
||||||
|
opts: cmdOptions{
|
||||||
|
addr: "/tmp/beszel.sock",
|
||||||
|
},
|
||||||
|
expected: "/tmp/beszel.sock",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "use ADDR env var",
|
||||||
|
opts: cmdOptions{},
|
||||||
|
envVars: map[string]string{
|
||||||
|
"ADDR": "1.2.3.4:9090",
|
||||||
|
},
|
||||||
|
expected: "1.2.3.4:9090",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "use legacy PORT env var",
|
||||||
|
opts: cmdOptions{},
|
||||||
|
envVars: map[string]string{
|
||||||
|
"PORT": "7070",
|
||||||
|
},
|
||||||
|
expected: "7070",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "use unix socket from env var",
|
||||||
|
opts: cmdOptions{
|
||||||
|
addr: "",
|
||||||
|
},
|
||||||
|
envVars: map[string]string{
|
||||||
|
"ADDR": "/tmp/beszel.sock",
|
||||||
|
},
|
||||||
|
expected: "/tmp/beszel.sock",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "flag takes precedence over env vars",
|
||||||
|
opts: cmdOptions{
|
||||||
|
addr: ":8080",
|
||||||
|
},
|
||||||
|
envVars: map[string]string{
|
||||||
|
"ADDR": ":9090",
|
||||||
|
"PORT": "7070",
|
||||||
|
},
|
||||||
|
expected: ":8080",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// Setup environment
|
||||||
|
for k, v := range tt.envVars {
|
||||||
|
t.Setenv(k, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
addr := tt.opts.getAddress()
|
||||||
|
assert.Equal(t, tt.expected, addr)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadPublicKeys(t *testing.T) {
|
||||||
|
// Generate a test key
|
||||||
|
_, priv, err := ed25519.GenerateKey(nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
signer, err := ssh.NewSignerFromKey(priv)
|
||||||
|
require.NoError(t, err)
|
||||||
|
pubKey := ssh.MarshalAuthorizedKey(signer.PublicKey())
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
opts cmdOptions
|
||||||
|
envVars map[string]string
|
||||||
|
setupFiles map[string][]byte
|
||||||
|
wantErr bool
|
||||||
|
errContains string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "load key from flag",
|
||||||
|
opts: cmdOptions{
|
||||||
|
key: string(pubKey),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "load key from env var",
|
||||||
|
envVars: map[string]string{
|
||||||
|
"KEY": string(pubKey),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "load key from file",
|
||||||
|
envVars: map[string]string{
|
||||||
|
"KEY_FILE": "testkey.pub",
|
||||||
|
},
|
||||||
|
setupFiles: map[string][]byte{
|
||||||
|
"testkey.pub": pubKey,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "error when no key provided",
|
||||||
|
wantErr: true,
|
||||||
|
errContains: "no key provided",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "error on invalid key file",
|
||||||
|
envVars: map[string]string{
|
||||||
|
"KEY_FILE": "nonexistent.pub",
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
errContains: "failed to read key file",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "error on invalid key data",
|
||||||
|
opts: cmdOptions{
|
||||||
|
key: "invalid-key-data",
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// Create a temporary directory for test files
|
||||||
|
if len(tt.setupFiles) > 0 {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
for name, content := range tt.setupFiles {
|
||||||
|
path := filepath.Join(tmpDir, name)
|
||||||
|
err := os.WriteFile(path, content, 0600)
|
||||||
|
require.NoError(t, err)
|
||||||
|
if tt.envVars != nil {
|
||||||
|
tt.envVars["KEY_FILE"] = path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up environment
|
||||||
|
for k, v := range tt.envVars {
|
||||||
|
t.Setenv(k, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
keys, err := tt.opts.loadPublicKeys()
|
||||||
|
if tt.wantErr {
|
||||||
|
assert.Error(t, err)
|
||||||
|
if tt.errContains != "" {
|
||||||
|
assert.Contains(t, err.Error(), tt.errContains)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, keys, 1)
|
||||||
|
assert.Equal(t, signer.PublicKey().Type(), keys[0].Type())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetNetwork(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
opts cmdOptions
|
||||||
|
envVars map[string]string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "NETWORK env var",
|
||||||
|
envVars: map[string]string{
|
||||||
|
"NETWORK": "tcp4",
|
||||||
|
},
|
||||||
|
expected: "tcp4",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "only port",
|
||||||
|
opts: cmdOptions{addr: "8080"},
|
||||||
|
expected: "tcp",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ipv4 address",
|
||||||
|
opts: cmdOptions{addr: "1.2.3.4:8080"},
|
||||||
|
expected: "tcp",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ipv6 address",
|
||||||
|
opts: cmdOptions{addr: "[2001:db8::1]:8080"},
|
||||||
|
expected: "tcp",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unix network",
|
||||||
|
opts: cmdOptions{addr: "/tmp/beszel.sock"},
|
||||||
|
expected: "unix",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "env var network",
|
||||||
|
opts: cmdOptions{addr: ":8080"},
|
||||||
|
envVars: map[string]string{"NETWORK": "tcp4"},
|
||||||
|
expected: "tcp4",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// Setup environment
|
||||||
|
for k, v := range tt.envVars {
|
||||||
|
t.Setenv(k, v)
|
||||||
|
}
|
||||||
|
network := tt.opts.getNetwork()
|
||||||
|
assert.Equal(t, tt.expected, network)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseFlags(t *testing.T) {
|
||||||
|
// Save original command line arguments and restore after test
|
||||||
|
oldArgs := os.Args
|
||||||
|
defer func() {
|
||||||
|
os.Args = oldArgs
|
||||||
|
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
|
||||||
|
}()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
args []string
|
||||||
|
expected cmdOptions
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no flags",
|
||||||
|
args: []string{"cmd"},
|
||||||
|
expected: cmdOptions{
|
||||||
|
key: "",
|
||||||
|
addr: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "key flag only",
|
||||||
|
args: []string{"cmd", "-key", "testkey"},
|
||||||
|
expected: cmdOptions{
|
||||||
|
key: "testkey",
|
||||||
|
addr: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "addr flag only",
|
||||||
|
args: []string{"cmd", "-addr", ":8080"},
|
||||||
|
expected: cmdOptions{
|
||||||
|
key: "",
|
||||||
|
addr: ":8080",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "both flags",
|
||||||
|
args: []string{"cmd", "-key", "testkey", "-addr", ":8080"},
|
||||||
|
expected: cmdOptions{
|
||||||
|
key: "testkey",
|
||||||
|
addr: ":8080",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// Reset flags for each test
|
||||||
|
flag.CommandLine = flag.NewFlagSet(tt.args[0], flag.ExitOnError)
|
||||||
|
os.Args = tt.args
|
||||||
|
|
||||||
|
var opts cmdOptions
|
||||||
|
opts.parseFlags()
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
assert.Equal(t, tt.expected, opts)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,28 +1,10 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"beszel"
|
|
||||||
"beszel/internal/hub"
|
"beszel/internal/hub"
|
||||||
_ "beszel/migrations"
|
_ "beszel/migrations"
|
||||||
|
|
||||||
"github.com/pocketbase/pocketbase"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
app := pocketbase.NewWithConfig(pocketbase.Config{
|
hub.NewHub().Run()
|
||||||
DefaultDataDir: beszel.AppName + "_data",
|
|
||||||
})
|
|
||||||
app.RootCmd.Version = beszel.Version
|
|
||||||
app.RootCmd.Use = beszel.AppName
|
|
||||||
app.RootCmd.Short = ""
|
|
||||||
|
|
||||||
// add update command
|
|
||||||
app.RootCmd.AddCommand(&cobra.Command{
|
|
||||||
Use: "update",
|
|
||||||
Short: "Update " + beszel.AppName + " to the latest version",
|
|
||||||
Run: hub.Update,
|
|
||||||
})
|
|
||||||
|
|
||||||
hub.NewHub(app).Run()
|
|
||||||
}
|
}
|
||||||
|
|||||||
117
beszel/go.mod
@@ -1,104 +1,93 @@
|
|||||||
module beszel
|
module beszel
|
||||||
|
|
||||||
go 1.22.4
|
go 1.24.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/blang/semver v3.5.1+incompatible
|
github.com/blang/semver v3.5.1+incompatible
|
||||||
github.com/containrrr/shoutrrr v0.8.0
|
github.com/containrrr/shoutrrr v0.8.0
|
||||||
github.com/gliderlabs/ssh v0.3.7
|
github.com/gliderlabs/ssh v0.3.8
|
||||||
github.com/goccy/go-json v0.10.3
|
github.com/goccy/go-json v0.10.5
|
||||||
github.com/labstack/echo/v5 v5.0.0-20230722203903-ec5b858dab61
|
github.com/pocketbase/dbx v1.11.0
|
||||||
github.com/pocketbase/dbx v1.10.1
|
github.com/pocketbase/pocketbase v0.25.0
|
||||||
github.com/pocketbase/pocketbase v0.22.22
|
|
||||||
github.com/rhysd/go-github-selfupdate v1.2.3
|
github.com/rhysd/go-github-selfupdate v1.2.3
|
||||||
github.com/shirou/gopsutil/v4 v4.24.9
|
github.com/shirou/gopsutil/v4 v4.25.1
|
||||||
github.com/spf13/cast v1.7.0
|
github.com/spf13/cast v1.7.1
|
||||||
github.com/spf13/cobra v1.8.1
|
github.com/spf13/cobra v1.8.1
|
||||||
golang.org/x/crypto v0.28.0
|
github.com/stretchr/testify v1.10.0
|
||||||
|
golang.org/x/crypto v0.32.0
|
||||||
|
golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/AlecAivazis/survey/v2 v2.3.7 // indirect
|
|
||||||
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/aws/aws-sdk-go-v2 v1.32.2 // indirect
|
github.com/aws/aws-sdk-go-v2 v1.36.1 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.6 // indirect
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.8 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.28.0 // indirect
|
github.com/aws/aws-sdk-go-v2/config v1.29.6 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.41 // indirect
|
github.com/aws/aws-sdk-go-v2/credentials v1.17.59 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.17 // indirect
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.28 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.33 // indirect
|
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.59 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.21 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.32 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.21 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.32 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.21 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.32 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.0 // indirect
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.2 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.2 // indirect
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.5.6 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.2 // indirect
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.13 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.2 // indirect
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.13 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.66.0 // indirect
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.75.4 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/sso v1.24.2 // indirect
|
github.com/aws/aws-sdk-go-v2/service/sso v1.24.15 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.2 // indirect
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.14 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/sts v1.32.2 // indirect
|
github.com/aws/aws-sdk-go-v2/service/sts v1.33.14 // indirect
|
||||||
github.com/aws/smithy-go v1.22.0 // indirect
|
github.com/aws/smithy-go v1.22.2 // 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/domodwyer/mailyak/v3 v3.6.2 // indirect
|
github.com/domodwyer/mailyak/v3 v3.6.2 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/ebitengine/purego v0.8.0 // indirect
|
github.com/ebitengine/purego v0.8.2 // indirect
|
||||||
github.com/fatih/color v1.17.0 // indirect
|
github.com/fatih/color v1.18.0 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.6 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||||
github.com/ganigeorgiev/fexpr v0.4.1 // indirect
|
github.com/ganigeorgiev/fexpr v0.4.1 // 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/golang-jwt/jwt/v4 v4.5.0 // indirect
|
github.com/golang-jwt/jwt/v5 v5.2.1 // indirect
|
||||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||||
github.com/google/go-github/v30 v30.1.0 // indirect
|
github.com/google/go-github/v30 v30.1.0 // indirect
|
||||||
github.com/google/go-querystring v1.1.0 // indirect
|
github.com/google/go-querystring v1.1.0 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/googleapis/gax-go/v2 v2.13.0 // indirect
|
github.com/googleapis/gax-go/v2 v2.14.1 // indirect
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
|
||||||
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf // indirect
|
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
|
||||||
github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 // indirect
|
github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.13 // 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/mattn/go-sqlite3 v1.14.24 // indirect
|
|
||||||
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
|
|
||||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
github.com/ncruces/go-strftime v0.1.9 // 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/spf13/pflag v1.0.5 // indirect
|
github.com/spf13/pflag v1.0.6 // indirect
|
||||||
github.com/tcnksm/go-gitconfig v0.1.2 // indirect
|
github.com/tcnksm/go-gitconfig v0.1.2 // indirect
|
||||||
github.com/tklauser/go-sysconf v0.3.14 // indirect
|
github.com/tklauser/go-sysconf v0.3.14 // indirect
|
||||||
github.com/tklauser/numcpus v0.8.0 // indirect
|
github.com/tklauser/numcpus v0.9.0 // indirect
|
||||||
github.com/ulikunitz/xz v0.5.12 // indirect
|
github.com/ulikunitz/xz v0.5.12 // indirect
|
||||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
|
||||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
go.opencensus.io v0.24.0 // indirect
|
go.opencensus.io v0.24.0 // indirect
|
||||||
gocloud.dev v0.40.0 // indirect
|
gocloud.dev v0.40.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c // indirect
|
golang.org/x/image v0.24.0 // indirect
|
||||||
golang.org/x/image v0.21.0 // indirect
|
golang.org/x/net v0.34.0 // indirect
|
||||||
golang.org/x/net v0.30.0 // indirect
|
golang.org/x/oauth2 v0.26.0 // indirect
|
||||||
golang.org/x/oauth2 v0.23.0 // indirect
|
golang.org/x/sync v0.11.0 // indirect
|
||||||
golang.org/x/sync v0.8.0 // indirect
|
golang.org/x/sys v0.30.0 // indirect
|
||||||
golang.org/x/sys v0.26.0 // indirect
|
golang.org/x/term v0.29.0 // indirect
|
||||||
golang.org/x/term v0.25.0 // indirect
|
golang.org/x/text v0.22.0 // indirect
|
||||||
golang.org/x/text v0.19.0 // indirect
|
|
||||||
golang.org/x/time v0.7.0 // indirect
|
|
||||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
|
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
|
||||||
google.golang.org/api v0.201.0 // indirect
|
google.golang.org/api v0.220.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20241015192408-796eee8c2d53 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20250204164813-702378808489 // indirect
|
||||||
google.golang.org/grpc v1.67.1 // indirect
|
google.golang.org/grpc v1.70.0 // indirect
|
||||||
google.golang.org/protobuf v1.35.1 // indirect
|
google.golang.org/protobuf v1.36.4 // indirect
|
||||||
modernc.org/gc/v3 v3.0.0-20241004144649-1aea3fae8852 // indirect
|
modernc.org/libc v1.55.3 // indirect
|
||||||
modernc.org/libc v1.61.0 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/mathutil v1.6.0 // indirect
|
modernc.org/memory v1.8.2 // indirect
|
||||||
modernc.org/memory v1.8.0 // indirect
|
modernc.org/sqlite v1.34.5 // indirect
|
||||||
modernc.org/sqlite v1.33.1 // indirect
|
|
||||||
modernc.org/strutil v1.2.0 // indirect
|
|
||||||
modernc.org/token v1.1.0 // indirect
|
|
||||||
)
|
)
|
||||||
|
|||||||
334
beszel/go.sum
@@ -1,24 +1,20 @@
|
|||||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||||
cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE=
|
cloud.google.com/go v0.115.0 h1:CnFSK6Xo3lDYRoBKEcAtia6VSC837/ZkJuRduSFnr14=
|
||||||
cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U=
|
cloud.google.com/go v0.115.0/go.mod h1:8jIM5vVgoAEoiVxQ/O4BFTfHqulPZgs/ufEzMcFMdWU=
|
||||||
cloud.google.com/go/auth v0.9.8 h1:+CSJ0Gw9iVeSENVCKJoLHhdUykDgXSc4Qn+gu2BRtR8=
|
cloud.google.com/go/auth v0.14.1 h1:AwoJbzUdxA/whv1qj3TLKwh3XX5sikny2fc40wUl+h0=
|
||||||
cloud.google.com/go/auth v0.9.8/go.mod h1:xxA5AqpDrvS+Gkmo9RqrGGRh6WSNKKOXhY3zNOr38tI=
|
cloud.google.com/go/auth v0.14.1/go.mod h1:4JHUxlGXisL0AW8kXPtUF6ztuOksyfUQNFjfsOCXkPM=
|
||||||
cloud.google.com/go/auth/oauth2adapt v0.2.4 h1:0GWE/FUsXhf6C+jAkWgYm7X9tK8cuEIfy19DBn6B6bY=
|
cloud.google.com/go/auth/oauth2adapt v0.2.7 h1:/Lc7xODdqcEw8IrZ9SvwnlLX6j9FHQM74z6cBk9Rw6M=
|
||||||
cloud.google.com/go/auth/oauth2adapt v0.2.4/go.mod h1:jC/jOpwFP6JBxhB3P5Rr0a9HLMC/Pe3eaL4NmdvqPtc=
|
cloud.google.com/go/auth/oauth2adapt v0.2.7/go.mod h1:NTbTTzfvPl1Y3V1nPpOgl2w6d/FjO7NNUQaWSox6ZMc=
|
||||||
cloud.google.com/go/compute v1.14.0 h1:hfm2+FfxVmnRlh6LpB7cg1ZNU+5edAHmW679JePztk0=
|
cloud.google.com/go/compute v1.14.0 h1:hfm2+FfxVmnRlh6LpB7cg1ZNU+5edAHmW679JePztk0=
|
||||||
cloud.google.com/go/compute/metadata v0.5.2 h1:UxK4uu/Tn+I3p2dYWTfiX4wva7aYlKixAHn3fyqngqo=
|
cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I=
|
||||||
cloud.google.com/go/compute/metadata v0.5.2/go.mod h1:C66sj2AluDcIqakBq/M8lw8/ybHgOZqin2obFxa/E5k=
|
cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg=
|
||||||
cloud.google.com/go/iam v1.1.13 h1:7zWBXG9ERbMLrzQBRhFliAV+kjcRToDTgQT3CTwYyv4=
|
cloud.google.com/go/iam v1.1.13 h1:7zWBXG9ERbMLrzQBRhFliAV+kjcRToDTgQT3CTwYyv4=
|
||||||
cloud.google.com/go/iam v1.1.13/go.mod h1:K8mY0uSXwEXS30KrnVb+j54LB/ntfZu1dr+4zFMNbus=
|
cloud.google.com/go/iam v1.1.13/go.mod h1:K8mY0uSXwEXS30KrnVb+j54LB/ntfZu1dr+4zFMNbus=
|
||||||
cloud.google.com/go/storage v1.43.0 h1:CcxnSohZwizt4LCzQHWvBf1/kvtHUn7gk9QERXPyXFs=
|
cloud.google.com/go/storage v1.43.0 h1:CcxnSohZwizt4LCzQHWvBf1/kvtHUn7gk9QERXPyXFs=
|
||||||
cloud.google.com/go/storage v1.43.0/go.mod h1:ajvxEa7WmZS1PxvKRq4bq0tFT3vMd502JwstCcYv0Q0=
|
cloud.google.com/go/storage v1.43.0/go.mod h1:ajvxEa7WmZS1PxvKRq4bq0tFT3vMd502JwstCcYv0Q0=
|
||||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||||
github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ=
|
|
||||||
github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo=
|
|
||||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||||
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s=
|
|
||||||
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w=
|
|
||||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
|
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be 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=
|
||||||
@@ -26,44 +22,44 @@ 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/aws/aws-sdk-go v1.55.5 h1:KKUZBfBoyqy5d3swXyiC7Q76ic40rYcbqH7qjh59kzU=
|
github.com/aws/aws-sdk-go v1.55.5 h1:KKUZBfBoyqy5d3swXyiC7Q76ic40rYcbqH7qjh59kzU=
|
||||||
github.com/aws/aws-sdk-go v1.55.5/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
|
github.com/aws/aws-sdk-go v1.55.5/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
|
||||||
github.com/aws/aws-sdk-go-v2 v1.32.2 h1:AkNLZEyYMLnx/Q/mSKkcMqwNFXMAvFto9bNsHqcTduI=
|
github.com/aws/aws-sdk-go-v2 v1.36.1 h1:iTDl5U6oAhkNPba0e1t1hrwAo02ZMqbrGq4k5JBWM5E=
|
||||||
github.com/aws/aws-sdk-go-v2 v1.32.2/go.mod h1:2SK5n0a2karNTv5tbP1SjsX0uhttou00v/HpXKM1ZUo=
|
github.com/aws/aws-sdk-go-v2 v1.36.1/go.mod h1:5PMILGVKiW32oDzjj6RU52yrNrDPUHcbZQYr1sM7qmM=
|
||||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.6 h1:pT3hpW0cOHRJx8Y0DfJUEQuqPild8jRGmSFmBgvydr0=
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.8 h1:zAxi9p3wsZMIaVCdoiQp2uZ9k1LsZvmAnoTBeZPXom0=
|
||||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.6/go.mod h1:j/I2++U0xX+cr44QjHay4Cvxj6FUbnxrgmqN3H1jTZA=
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.8/go.mod h1:3XkePX5dSaxveLAYY7nsbsZZrKxCyEuE5pM4ziFxyGg=
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.28.0 h1:FosVYWcqEtWNxHn8gB/Vs6jOlNwSoyOCA/g/sxyySOQ=
|
github.com/aws/aws-sdk-go-v2/config v1.29.6 h1:fqgqEKK5HaZVWLQoLiC9Q+xDlSp+1LYidp6ybGE2OGg=
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.28.0/go.mod h1:pYhbtvg1siOOg8h5an77rXle9tVG8T+BWLWAo7cOukc=
|
github.com/aws/aws-sdk-go-v2/config v1.29.6/go.mod h1:Ft+WLODzDQmCTHDvqAH1JfC2xxbZ0MxpZAcJqmE1LTQ=
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.41 h1:7gXo+Axmp+R4Z+AK8YFQO0ZV3L0gizGINCOWxSLY9W8=
|
github.com/aws/aws-sdk-go-v2/credentials v1.17.59 h1:9btwmrt//Q6JcSdgJOLI98sdr5p7tssS9yAsGe8aKP4=
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.41/go.mod h1:u4Eb8d3394YLubphT4jLEwN1rLNq2wFOlT6OuxFwPzU=
|
github.com/aws/aws-sdk-go-v2/credentials v1.17.59/go.mod h1:NM8fM6ovI3zak23UISdWidyZuI1ghNe2xjzUZAyT+08=
|
||||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.17 h1:TMH3f/SCAWdNtXXVPPu5D6wrr4G5hI1rAxbcocKfC7Q=
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.28 h1:KwsodFKVQTlI5EyhRSugALzsV6mG/SGrdjlMXSZSdso=
|
||||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.17/go.mod h1:1ZRXLdTpzdJb9fwTMXiLipENRxkGMTn1sfKexGllQCw=
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.28/go.mod h1:EY3APf9MzygVhKuPXAc5H+MkGb8k/DOSQjWS0LgkKqI=
|
||||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.33 h1:X+4YY5kZRI/cOoSMVMGTqFXHAMg1bvvay7IBcqHpybQ=
|
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.59 h1:5Vsrfdlf9KQP3leGX1dD7VwZq/3HAerEFoXAII4t6zo=
|
||||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.33/go.mod h1:DPynzu+cn92k5UQ6tZhX+wfTB4ah6QDU/NgdHqatmvk=
|
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.59/go.mod h1:7XTNs3NYApJjkx6A2Fk9qq23qBuBnIU58k3fKC2Fr1I=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.21 h1:UAsR3xA31QGf79WzpG/ixT9FZvQlh5HY1NRqSHBNOCk=
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.32 h1:BjUcr3X3K0wZPGFg2bxOWW3VPN8rkE3/61zhP+IHviA=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.21/go.mod h1:JNr43NFf5L9YaG3eKTm7HQzls9J+A9YYcGI5Quh1r2Y=
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.32/go.mod h1:80+OGC/bgzzFFTUmcuwD0lb4YutwQeKLFpmt6hoWapU=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.21 h1:6jZVETqmYCadGFvrYEQfC5fAQmlo80CeL5psbno6r0s=
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.32 h1:m1GeXHVMJsRsUAqG6HjZWx9dj7F5TR+cF1bjyfYyBd4=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.21/go.mod h1:1SR0GbLlnN3QUmYaflZNiH1ql+1qrSiB2vwcJ+4UM60=
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.32/go.mod h1:IitoQxGfaKdVLNg0hD8/DXmAqNy0H4K2H2Sf91ti8sI=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 h1:VaRN3TlFdd6KxX1x3ILT5ynH6HvKgqdiXoTxAF4HQcQ=
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2 h1:Pg9URiobXy85kgFev3og2CuOZ8JZUBENF+dcgWBaYNk=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc=
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.21 h1:7edmS3VOBDhK00b/MwGtGglCm7hhwNYnjJs/PgFdMQE=
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.32 h1:OIHj/nAhVzIXGzbAE+4XmZ8FPvro3THr6NlqErJc3wY=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.21/go.mod h1:Q9o5h4HoIWG8XfzxqiuK/CGUbepCJ8uTlaE3bAbxytQ=
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.32/go.mod h1:LiBEsDo34OJXqdDlRGsilhlIiXR7DL+6Cx2f4p1EgzI=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.0 h1:TToQNkvGguu209puTojY/ozlqy2d/SFNcoLIqTFi42g=
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.2 h1:D4oz8/CzT9bAEYtVhSBmFj2dNOtaHOtMKc2vHBwYizA=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.0/go.mod h1:0jp+ltwkf+SwG2fm/PKo8t4y8pJSgOCO4D8Lz3k0aHQ=
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.2/go.mod h1:Za3IHqTQ+yNcRHxu1OFucBh0ACZT4j4VQFF0BqpZcLY=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.2 h1:4FMHqLfk0efmTqhXVRL5xYRqlEBNBiRI7N6w4jsEdd4=
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.5.6 h1:cCBJaT7EeEojpJ4s7wTDbhZlHVJOgNHN7iw6qVurGaw=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.2/go.mod h1:LWoqeWlK9OZeJxsROW2RqrSPvQHKTpp69r/iDjwsSaw=
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.5.6/go.mod h1:WYH1ABybY7JK9TITPnk6ZlP7gQB8psI4c9qDmMsnLSA=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.2 h1:s7NA1SOw8q/5c0wr8477yOPp0z+uBaXBnLE0XYb0POA=
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.13 h1:SYVGSFQHlchIcy6e7x12bsrxClCXSP5et8cqVhL8cuw=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.2/go.mod h1:fnjjWyAW/Pj5HYOxl9LJqWtEwS7W2qgcRLWP+uWbss0=
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.13/go.mod h1:kizuDaLX37bG5WZaoxGPQR/LNFXpxp0vsUnqfkWXfNE=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.2 h1:t7iUP9+4wdc5lt3E41huP+GvQZJD38WLsgVp4iOtAjg=
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.13 h1:OBsrtam3rk8NfBEq7OLOMm5HtQ9Yyw32X4UQMya/wjw=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.2/go.mod h1:/niFCtmuQNxqx9v8WAPq5qh7EH25U4BF6tjoyq9bObM=
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.13/go.mod h1:3U4gFA5pmoCOja7aq4nSaIAGbaOHv2Yl2ug018cmC+Q=
|
||||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.66.0 h1:xA6XhTF7PE89BCNHJbQi8VvPzcgMtmGC5dr8S8N7lHk=
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.75.4 h1:DJYjOvNgC30JAcDCRmtQHoYK4trc7XetDXRTEAReGKA=
|
||||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.66.0/go.mod h1:cB6oAuus7YXRZhWCc1wIwPywwZ1XwweNp2TVAEGYeB8=
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.75.4/go.mod h1:KuLNrwYJFaC2AVZ+CVVc12k9NyqwgWsoNNHjwqF6QNk=
|
||||||
github.com/aws/aws-sdk-go-v2/service/sso v1.24.2 h1:bSYXVyUzoTHoKalBmwaZxs97HU9DWWI3ehHSAMa7xOk=
|
github.com/aws/aws-sdk-go-v2/service/sso v1.24.15 h1:/eE3DogBjYlvlbhd2ssWyeuovWunHLxfgw3s/OJa4GQ=
|
||||||
github.com/aws/aws-sdk-go-v2/service/sso v1.24.2/go.mod h1:skMqY7JElusiOUjMJMOv1jJsP7YUg7DrhgqZZWuzu1U=
|
github.com/aws/aws-sdk-go-v2/service/sso v1.24.15/go.mod h1:2PCJYpi7EKeA5SkStAmZlF6fi0uUABuhtF8ILHjGc3Y=
|
||||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.2 h1:AhmO1fHINP9vFYUE0LHzCWg/LfUWUF+zFPEcY9QXb7o=
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.14 h1:M/zwXiL2iXUrHputuXgmO94TVNmcenPHxgLXLutodKE=
|
||||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.2/go.mod h1:o8aQygT2+MVP0NaV6kbdE1YnnIM8RRVQzoeUH45GOdI=
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.14/go.mod h1:RVwIw3y/IqxC2YEXSIkAzRDdEU1iRabDPaYjpGCbCGQ=
|
||||||
github.com/aws/aws-sdk-go-v2/service/sts v1.32.2 h1:CiS7i0+FUe+/YY1GvIBLLrR/XNGZ4CtM1Ll0XavNuVo=
|
github.com/aws/aws-sdk-go-v2/service/sts v1.33.14 h1:TzeR06UCMUq+KA3bDkujxK1GVGy+G8qQN/QVYzGLkQE=
|
||||||
github.com/aws/aws-sdk-go-v2/service/sts v1.32.2/go.mod h1:HtaiBI8CjYoNVde8arShXb94UbQQi9L4EMr6D+xGBwo=
|
github.com/aws/aws-sdk-go-v2/service/sts v1.33.14/go.mod h1:dspXf/oYWGWo6DEvj98wpaTeqt5+DMidZD0A9BYTizc=
|
||||||
github.com/aws/smithy-go v1.22.0 h1:uunKnWlcoL3zO7q+gG2Pk53joueEOsnNB28QdMsmiMM=
|
github.com/aws/smithy-go v1.22.2 h1:6D9hW43xKFrRx/tXXfAlIZc4JI+yQe6snnWcQyxSyLQ=
|
||||||
github.com/aws/smithy-go v1.22.0/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg=
|
github.com/aws/smithy-go v1.22.2/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg=
|
||||||
github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ=
|
github.com/blang/semver v3.5.1+incompatible 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/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||||
@@ -72,8 +68,6 @@ github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGX
|
|||||||
github.com/containrrr/shoutrrr v0.8.0 h1:mfG2ATzIS7NR2Ec6XL+xyoHzN97H8WPjir8aYzJUSec=
|
github.com/containrrr/shoutrrr v0.8.0 h1:mfG2ATzIS7NR2Ec6XL+xyoHzN97H8WPjir8aYzJUSec=
|
||||||
github.com/containrrr/shoutrrr v0.8.0/go.mod h1:ioyQAyu1LJY6sILuNyKaQaw+9Ttik5QePU8atnAdO2o=
|
github.com/containrrr/shoutrrr v0.8.0/go.mod h1:ioyQAyu1LJY6sILuNyKaQaw+9Ttik5QePU8atnAdO2o=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||||
github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI=
|
|
||||||
github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
|
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
@@ -84,25 +78,25 @@ github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCO
|
|||||||
github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c=
|
github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/ebitengine/purego v0.8.0 h1:JbqvnEzRvPpxhCJzJJ2y0RbiZ8nyjccVUrSM3q+GvvE=
|
github.com/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I=
|
||||||
github.com/ebitengine/purego v0.8.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||||
github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4=
|
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||||
github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI=
|
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
github.com/frankban/quicktest v1.14.6 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/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.6 h1:3+PzJTKLkvgjeTbts6msPJt4DixhT4YtFNf1gtGe3zc=
|
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.6/go.mod h1:JX1qVKqZd40hUPpAfiNTe0Sne7hdfKSbOqqmkq8GCXc=
|
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||||
github.com/ganigeorgiev/fexpr v0.4.1 h1:hpUgbUEEWIZhSDBtf4M9aUNfQQ0BZkGRaMePy7Gcx5k=
|
github.com/ganigeorgiev/fexpr v0.4.1 h1:hpUgbUEEWIZhSDBtf4M9aUNfQQ0BZkGRaMePy7Gcx5k=
|
||||||
github.com/ganigeorgiev/fexpr v0.4.1/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
|
github.com/ganigeorgiev/fexpr v0.4.1/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
|
||||||
github.com/gliderlabs/ssh v0.3.7 h1:iV3Bqi942d9huXnzEF2Mt+CY9gLu8DNM4Obd+8bODRE=
|
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
||||||
github.com/gliderlabs/ssh v0.3.7/go.mod h1:zpHEXBstFnQYtGnB8k8kQLol82umzn/2/snG7alWVD8=
|
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
||||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
@@ -117,14 +111,14 @@ github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpv
|
|||||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||||
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=
|
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=
|
||||||
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls=
|
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls=
|
||||||
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
|
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||||
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=
|
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||||
github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||||
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
|
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
|
||||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
|
||||||
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
@@ -152,10 +146,10 @@ github.com/google/go-github/v30 v30.1.0/go.mod h1:n8jBpHl45a/rlBUtRJMOG4GhNADUQF
|
|||||||
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
|
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
|
||||||
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
|
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
|
||||||
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
|
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
|
||||||
github.com/google/pprof v0.0.0-20240827171923-fa2c70bbbfe5 h1:5iH8iuqE5apketRbSFBy+X1V0o+l+8NF1avt4HWl7cA=
|
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 h1:FKHo8hFI3A+7w0aUQuYXQ+6EN5stWmeY/AZqtM8xk9k=
|
||||||
github.com/google/pprof v0.0.0-20240827171923-fa2c70bbbfe5/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
|
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo=
|
||||||
github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM=
|
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
|
||||||
github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA=
|
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
|
||||||
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 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=
|
||||||
@@ -163,12 +157,8 @@ github.com/google/wire v0.6.0 h1:HBkoIh4BdSxoyo9PveV8giw7ZsaBOvzWKfcg/6MrVwI=
|
|||||||
github.com/google/wire v0.6.0/go.mod h1:F4QhpQ9EDIdJ1Mbop/NZBRB+5yrR6qg3BnctaoUk6NA=
|
github.com/google/wire v0.6.0/go.mod h1:F4QhpQ9EDIdJ1Mbop/NZBRB+5yrR6qg3BnctaoUk6NA=
|
||||||
github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw=
|
github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw=
|
||||||
github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA=
|
github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA=
|
||||||
github.com/googleapis/gax-go/v2 v2.13.0 h1:yitjD5f7jQHhyDsnhKEBU52NdvvdSeGzlAnDPT0hH1s=
|
github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q=
|
||||||
github.com/googleapis/gax-go/v2 v2.13.0/go.mod h1:Z/fvTZXF8/uw7Xu5GuslPw+bplx6SS338j1Is2S+B7A=
|
github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
|
||||||
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog=
|
|
||||||
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68=
|
|
||||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||||
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf h1:WfD7VjIE6z8dIvMsI4/s+1qr5EL+zoIGev1BQj1eoJ8=
|
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf h1:WfD7VjIE6z8dIvMsI4/s+1qr5EL+zoIGev1BQj1eoJ8=
|
||||||
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf/go.mod h1:hyb9oH7vZsitZCiBt0ZvifOrB+qc8PS5IiilCIb87rg=
|
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf/go.mod h1:hyb9oH7vZsitZCiBt0ZvifOrB+qc8PS5IiilCIb87rg=
|
||||||
@@ -178,8 +168,6 @@ github.com/jarcoal/httpmock v1.3.0 h1:2RJ8GP0IIaWwcC9Fp2BmVi8Kog3v2Hn7VXM3fTd+nu
|
|||||||
github.com/jarcoal/httpmock v1.3.0/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg=
|
github.com/jarcoal/httpmock v1.3.0/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg=
|
||||||
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
|
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
|
||||||
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
|
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
|
||||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
|
|
||||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
|
|
||||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
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=
|
||||||
@@ -187,22 +175,12 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
|||||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 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/labstack/echo/v5 v5.0.0-20230722203903-ec5b858dab61 h1:FwuzbVh87iLiUQj1+uQUsuw9x5t9m5n5g7rG7o4svW4=
|
|
||||||
github.com/labstack/echo/v5 v5.0.0-20230722203903-ec5b858dab61/go.mod h1:paQfF1YtHe+GrGg5fOgjsjoCX/UKDr9bc1DoWpZfns8=
|
|
||||||
github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 h1:7UMa6KCCMjZEMDtTVdcGu0B1GmmC7QJKiCCjyTAWQy0=
|
github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 h1:7UMa6KCCMjZEMDtTVdcGu0B1GmmC7QJKiCCjyTAWQy0=
|
||||||
github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k=
|
github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k=
|
||||||
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
|
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
|
||||||
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
|
|
||||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 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/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
|
|
||||||
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
|
||||||
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
|
|
||||||
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI=
|
|
||||||
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
|
|
||||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
github.com/onsi/ginkgo v1.6.0 h1:Ix8l273rp3QzYgXSR+c8d1fTG7UPgYkOSELPhiY/YGw=
|
github.com/onsi/ginkgo v1.6.0 h1:Ix8l273rp3QzYgXSR+c8d1fTG7UPgYkOSELPhiY/YGw=
|
||||||
@@ -215,10 +193,10 @@ github.com/onsi/gomega v1.27.6/go.mod h1:PIQNjfQwkP3aQAH7lf7j87O/5FiNr+ZR8+ipb+q
|
|||||||
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.10.1 h1:cw+vsyfCJD8YObOVeqb93YErnlxwYMkNZ4rwN0G0AaA=
|
github.com/pocketbase/dbx v1.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU=
|
||||||
github.com/pocketbase/dbx v1.10.1/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
||||||
github.com/pocketbase/pocketbase v0.22.22 h1:iA128U+cmM9euxPpuCN7blmQ2FZNzOix2aUUcnbbQu8=
|
github.com/pocketbase/pocketbase v0.25.0 h1:/4YQq1hd0muvhzbERyUTVNh88N0BCj5diqK0jtLN6k8=
|
||||||
github.com/pocketbase/pocketbase v0.22.22/go.mod h1:u+l7T04g7eBXetoodXLch3WoV/QonRf1qYq+2vuTKuI=
|
github.com/pocketbase/pocketbase v0.25.0/go.mod h1:tOtOv7f3vJhAiyUluIwV9JPuKeknZRQ9F6uJE3W/ntI=
|
||||||
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/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||||
@@ -229,72 +207,71 @@ github.com/rhysd/go-github-selfupdate v1.2.3/go.mod h1:mp/N8zj6jFfBQy/XMYoWsmfzx
|
|||||||
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.24.9 h1:KIV+/HaHD5ka5f570RZq+2SaeFsb/pq+fp2DGNWYoOI=
|
github.com/shirou/gopsutil/v4 v4.25.1 h1:QSWkTc+fu9LTAWfkZwZ6j8MSUk4A2LV7rbH0ZqmLjXs=
|
||||||
github.com/shirou/gopsutil/v4 v4.24.9/go.mod h1:3fkaHNeYsUFCGZ8+9vZVWtbyM1k2eRnlL+bWO8Bxa/Q=
|
github.com/shirou/gopsutil/v4 v4.25.1/go.mod h1:RoUCUpndaJFtT+2zsZzzmhvbfGoDCJ7nFXKJf8GqJbI=
|
||||||
github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w=
|
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
|
||||||
github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||||
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
|
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
|
||||||
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
|
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
|
||||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
|
||||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||||
|
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
|
||||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/tcnksm/go-gitconfig v0.1.2 h1:iiDhRitByXAEyjgBqsKi9QU4o2TNtv9kPP3RgPgXBPw=
|
github.com/tcnksm/go-gitconfig v0.1.2 h1:iiDhRitByXAEyjgBqsKi9QU4o2TNtv9kPP3RgPgXBPw=
|
||||||
github.com/tcnksm/go-gitconfig v0.1.2/go.mod h1:/8EhP4H7oJZdIPyT+/UIsG87kTzrzM4UsLGSItWYCpE=
|
github.com/tcnksm/go-gitconfig v0.1.2/go.mod h1:/8EhP4H7oJZdIPyT+/UIsG87kTzrzM4UsLGSItWYCpE=
|
||||||
github.com/tklauser/go-sysconf v0.3.14 h1:g5vzr9iPFFz24v2KZXs/pvpvh8/V9Fw6vQK5ZZb78yU=
|
github.com/tklauser/go-sysconf v0.3.14 h1:g5vzr9iPFFz24v2KZXs/pvpvh8/V9Fw6vQK5ZZb78yU=
|
||||||
github.com/tklauser/go-sysconf v0.3.14/go.mod h1:1ym4lWMLUOhuBOPGtRcJm7tEGX4SCYNEEEtghGG/8uY=
|
github.com/tklauser/go-sysconf v0.3.14/go.mod h1:1ym4lWMLUOhuBOPGtRcJm7tEGX4SCYNEEEtghGG/8uY=
|
||||||
github.com/tklauser/numcpus v0.8.0 h1:Mx4Wwe/FjZLeQsK/6kt2EOepwwSl7SmJrK5bV/dXYgY=
|
github.com/tklauser/numcpus v0.9.0 h1:lmyCHtANi8aRUgkckBgoDk1nHCux3n2cgkJLXdQGPDo=
|
||||||
github.com/tklauser/numcpus v0.8.0/go.mod h1:ZJZlAY+dmR4eut8epnzf0u/VwodKmryxR8txiloSqBE=
|
github.com/tklauser/numcpus v0.9.0/go.mod h1:SN6Nq1O3VychhC1npsWostA+oW+VOQTxZrS604NSRyI=
|
||||||
github.com/ulikunitz/xz v0.5.9/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
github.com/ulikunitz/xz v0.5.9/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||||
github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc=
|
github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc=
|
||||||
github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
|
||||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
|
||||||
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
|
||||||
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||||
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
|
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
|
||||||
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
|
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
|
||||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 h1:r6I7RJCN86bpD/FQwedZ0vSixDpwuWREjW9oRMsmqDc=
|
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0/go.mod h1:B9yO6b04uB80CzjedvewuqDhxJxi11s7/GtiGa8bAjI=
|
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
|
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.58.0 h1:PS8wXpbyaDJQ2VDHHncMe9Vct0Zn1fEjpsjrLxGJoSc=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
|
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.58.0/go.mod h1:HDBUsEjOuRC0EzKZ1bSaRGZWUBAzo+MhAcUUORSr4D0=
|
||||||
go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 h1:yd02MEjBdJkG3uabWP9apV+OuWRIXGDuJEUJbOHmCFU=
|
||||||
go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8=
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0/go.mod h1:umTcuxiv1n/s/S6/c2AT/g2CQ7u5C59sHDNmfSwgz7Q=
|
||||||
go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc=
|
go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY=
|
||||||
go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8=
|
go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI=
|
||||||
go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4=
|
go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ=
|
||||||
go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ=
|
go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.32.0 h1:RNxepc9vK59A8XsgZQouW8ue8Gkb4jpWtJm9ge5lEG4=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.32.0/go.mod h1:LqgegDBjKMmb2GC6/PrTnteJG39I8/vJCAP9LlJXEjU=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.32.0 h1:rZvFnvmvawYb0alrYkjraqJq0Z4ZUJAiyYCU9snn1CU=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.32.0/go.mod h1:PWeZlq0zt9YkYAp3gjKZ0eicRYvOh1Gd+X99x6GHpCQ=
|
||||||
|
go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k=
|
||||||
|
go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE=
|
||||||
gocloud.dev v0.40.0 h1:f8LgP+4WDqOG/RXoUcyLpeIAGOcAbZrZbDQCUee10ng=
|
gocloud.dev v0.40.0 h1:f8LgP+4WDqOG/RXoUcyLpeIAGOcAbZrZbDQCUee10ng=
|
||||||
gocloud.dev v0.40.0/go.mod h1:drz+VyYNBvrMTW0KZiBAYEdl8lbNZx+OQ7oQvdrFmSQ=
|
gocloud.dev v0.40.0/go.mod h1:drz+VyYNBvrMTW0KZiBAYEdl8lbNZx+OQ7oQvdrFmSQ=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
|
golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
|
||||||
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
|
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
||||||
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
|
|
||||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c h1:7dEasQXItcW1xKJ2+gg5VOiBnqWrJc+rq0DPKyvvdbY=
|
golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c h1:KL/ZBHXgKGVmuZBZ01Lt57yE5ws8ZPSkkihmEyq7FXc=
|
||||||
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c/go.mod h1:NQtJDoLvd6faHhE7m4T/1IY708gDefGGjR/iUW8yQQ8=
|
golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU=
|
||||||
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.21.0 h1:c5qV36ajHpdj4Qi0GnE0jUc/yuo33OLFaa0d+crTD5s=
|
golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
|
||||||
golang.org/x/image v0.21.0/go.mod h1:vUbsLavqK/W303ZroQQVKQ+Af3Yl6Uz1Ppu5J/cLz78=
|
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
|
||||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4=
|
||||||
golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0=
|
golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
|
||||||
golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
|
|
||||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
@@ -302,70 +279,53 @@ golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73r
|
|||||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
|
||||||
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
|
||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
|
||||||
golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
|
|
||||||
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
|
|
||||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||||
golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||||
golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs=
|
golang.org/x/oauth2 v0.26.0 h1:afQXWNNaeC4nvZ0Ed9XvCCzXM6UHJG7iCg0W4fPqSBE=
|
||||||
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
golang.org/x/oauth2 v0.26.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
|
||||||
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
|
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
|
||||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
|
||||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.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.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
|
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||||
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
|
||||||
golang.org/x/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24=
|
|
||||||
golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M=
|
|
||||||
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.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||||
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||||
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
|
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
|
||||||
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||||
golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ=
|
|
||||||
golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||||
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.29.0 h1:Xx0h3TtM9rzQpQuR4dKLrdglAmCEN5Oi+P74JdhdzXE=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.29.0/go.mod h1:KMQVMRsVxU6nHCFXrBPhDB8XncLNLM0lIy/F14RP588=
|
||||||
golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ=
|
|
||||||
golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0=
|
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
|
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
|
||||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
|
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
|
||||||
google.golang.org/api v0.201.0 h1:+7AD9JNM3tREtawRMu8sOjSbb8VYcYXJG/2eEOmfDu0=
|
google.golang.org/api v0.220.0 h1:3oMI4gdBgB72WFVwE1nerDD8W3HUOS4kypK6rRLbGns=
|
||||||
google.golang.org/api v0.201.0/go.mod h1:HVY0FCHVs89xIW9fzf/pBvOEm+OolHa86G/txFezyq4=
|
google.golang.org/api v0.220.0/go.mod h1:26ZAlY6aN/8WgpCzjPNy18QpYaz7Zgg1h0qe1GkZEmY=
|
||||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||||
google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||||
@@ -373,19 +333,19 @@ google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCID
|
|||||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||||
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
||||||
google.golang.org/genproto v0.0.0-20241007155032-5fefd90f89a9 h1:nFS3IivktIU5Mk6KQa+v6RKkHUpdQpphqGNLxqNnbEk=
|
google.golang.org/genproto v0.0.0-20240812133136-8ffd90a71988 h1:CT2Thj5AuPV9phrYMtzX11k+XkzMGfRAet42PmoTATM=
|
||||||
google.golang.org/genproto v0.0.0-20241007155032-5fefd90f89a9/go.mod h1:tEzYTYZxbmVNOu0OAFH9HzdJtLn6h4Aj89zzlBCdHms=
|
google.golang.org/genproto v0.0.0-20240812133136-8ffd90a71988/go.mod h1:7uvplUBj4RjHAxIZ//98LzOvrQ04JBkaixRmCMI29hc=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20240814211410-ddb44dafa142 h1:wKguEg1hsxI2/L3hUYrpo1RVi48K+uTyzKqprwLXsb8=
|
google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 h1:CkkIfIt50+lT6NHAVoRYEyAvQGFM7xEwXUUywFvEb3Q=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20240814211410-ddb44dafa142/go.mod h1:d6be+8HhtEtucleCbxpPW9PA9XwISACu8nvpPqF0BVo=
|
google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576/go.mod h1:1R3kvZ1dtP3+4p4d3G8uJ8rFk/fWlScl38vanWACI08=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20241015192408-796eee8c2d53 h1:X58yt85/IXCx0Y3ZwN6sEIKZzQtDEYaBWrDvErdXrRE=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20250204164813-702378808489 h1:5bKytslY8ViY0Cj/ewmRtrWHW64bNF03cAatUUFCdFI=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20241015192408-796eee8c2d53/go.mod h1:GX3210XPVPUjJbTUbvwI8f2IpZDMZuPJWDzDuebbviI=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20250204164813-702378808489/go.mod h1:8BS3B93F/U1juMFq9+EDk+qOT5CO1R9IzXxG3PTqiRk=
|
||||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||||
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||||
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||||
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
|
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
|
||||||
google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E=
|
google.golang.org/grpc v1.70.0 h1:pWFv03aZoHzlRKHWicjsZytKAiYCtNS0dHbXnIdq7jQ=
|
||||||
google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA=
|
google.golang.org/grpc v1.70.0/go.mod h1:ofIJqVKDXx/JiXrwr2IG4/zwdH9txy3IlF40RmcJSQw=
|
||||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||||
@@ -395,8 +355,8 @@ google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2
|
|||||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||||
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||||
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
||||||
google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA=
|
google.golang.org/protobuf v1.36.4 h1:6A3ZDJHn/eNqc1i+IdefRzy/9PokBTPvcqMySR7NNIM=
|
||||||
google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
google.golang.org/protobuf v1.36.4/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
|
||||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
@@ -411,26 +371,24 @@ honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWh
|
|||||||
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||||
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
|
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
|
||||||
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
||||||
modernc.org/ccgo/v4 v4.21.0 h1:kKPI3dF7RIag8YcToh5ZwDcVMIv6VGa0ED5cvh0LMW4=
|
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
|
||||||
modernc.org/ccgo/v4 v4.21.0/go.mod h1:h6kt6H/A2+ew/3MW/p6KEoQmrq/i3pr0J/SiwiaF/g0=
|
modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
|
||||||
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
||||||
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
||||||
modernc.org/gc/v2 v2.5.0 h1:bJ9ChznK1L1mUtAQtxi0wi5AtAs5jQuw4PrPHO5pb6M=
|
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
|
||||||
modernc.org/gc/v2 v2.5.0/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
|
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
|
||||||
modernc.org/gc/v3 v3.0.0-20241004144649-1aea3fae8852 h1:IYXPPTTjjoSHvUClZIYexDiO7g+4x+XveKT4gCIAwiY=
|
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
|
||||||
modernc.org/gc/v3 v3.0.0-20241004144649-1aea3fae8852/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
|
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
|
||||||
modernc.org/libc v1.61.0 h1:eGFcvWpqlnoGwzZeZe3PWJkkKbM/3SUGyk1DVZQ0TpE=
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
modernc.org/libc v1.61.0/go.mod h1:DvxVX89wtGTu+r72MLGhygpfi3aUGgZRdAYGCAVVud0=
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
modernc.org/memory v1.8.2 h1:cL9L4bcoAObu4NkxOlKWBWtNHIsnnACGF/TbqQ6sbcI=
|
||||||
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
modernc.org/memory v1.8.2/go.mod h1:ZbjSvMO5NQ1A2i3bWeDiVMxIorXwdClKE/0SZ+BMotU=
|
||||||
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
|
|
||||||
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
|
|
||||||
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
|
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
|
||||||
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||||
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
|
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
|
||||||
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
|
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
|
||||||
modernc.org/sqlite v1.33.1 h1:trb6Z3YYoeM9eDL1O8do81kP+0ejv+YzgyFo+Gwy0nM=
|
modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g=
|
||||||
modernc.org/sqlite v1.33.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k=
|
modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE=
|
||||||
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
|
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
|
||||||
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
|
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
|
||||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
|||||||
@@ -8,11 +8,13 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"github.com/shirou/gopsutil/v4/common"
|
"github.com/shirou/gopsutil/v4/common"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Agent struct {
|
type Agent struct {
|
||||||
|
sync.Mutex // Used to lock agent while collecting data
|
||||||
debug bool // true if LOG_LEVEL is set to debug
|
debug bool // true if LOG_LEVEL is set to debug
|
||||||
zfs bool // true if system has arcstats
|
zfs bool // true if system has arcstats
|
||||||
memCalc string // Memory calculation formula
|
memCalc string // Memory calculation formula
|
||||||
@@ -24,22 +26,20 @@ type Agent struct {
|
|||||||
sensorsContext context.Context // Sensors context to override sys location
|
sensorsContext context.Context // Sensors context to override sys location
|
||||||
sensorsWhitelist map[string]struct{} // List of sensors to monitor
|
sensorsWhitelist map[string]struct{} // List of sensors to monitor
|
||||||
systemInfo system.Info // Host system info
|
systemInfo system.Info // Host system info
|
||||||
|
gpuManager *GPUManager // Manages GPU data
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAgent() *Agent {
|
func NewAgent() *Agent {
|
||||||
return &Agent{
|
agent := &Agent{
|
||||||
sensorsContext: context.Background(),
|
fsStats: make(map[string]*system.FsStats),
|
||||||
memCalc: os.Getenv("MEM_CALC"),
|
|
||||||
fsStats: make(map[string]*system.FsStats),
|
|
||||||
}
|
}
|
||||||
}
|
agent.memCalc, _ = GetEnv("MEM_CALC")
|
||||||
|
|
||||||
func (a *Agent) Run(pubKey []byte, addr string) {
|
|
||||||
// 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 := os.LookupEnv("LOG_LEVEL"); exists {
|
if logLevelStr, exists := GetEnv("LOG_LEVEL"); exists {
|
||||||
switch strings.ToLower(logLevelStr) {
|
switch strings.ToLower(logLevelStr) {
|
||||||
case "debug":
|
case "debug":
|
||||||
a.debug = true
|
agent.debug = true
|
||||||
slog.SetLogLoggerLevel(slog.LevelDebug)
|
slog.SetLogLoggerLevel(slog.LevelDebug)
|
||||||
case "warn":
|
case "warn":
|
||||||
slog.SetLogLoggerLevel(slog.LevelWarn)
|
slog.SetLogLoggerLevel(slog.LevelWarn)
|
||||||
@@ -51,55 +51,71 @@ func (a *Agent) Run(pubKey []byte, addr string) {
|
|||||||
slog.Debug(beszel.Version)
|
slog.Debug(beszel.Version)
|
||||||
|
|
||||||
// Set sensors context (allows overriding sys location for sensors)
|
// Set sensors context (allows overriding sys location for sensors)
|
||||||
if sysSensors, exists := os.LookupEnv("SYS_SENSORS"); exists {
|
if sysSensors, exists := GetEnv("SYS_SENSORS"); exists {
|
||||||
slog.Info("SYS_SENSORS", "path", sysSensors)
|
slog.Info("SYS_SENSORS", "path", sysSensors)
|
||||||
a.sensorsContext = context.WithValue(a.sensorsContext,
|
agent.sensorsContext = context.WithValue(agent.sensorsContext,
|
||||||
common.EnvKey, common.EnvMap{common.HostSysEnvKey: sysSensors},
|
common.EnvKey, common.EnvMap{common.HostSysEnvKey: sysSensors},
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
agent.sensorsContext = context.Background()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set sensors whitelist
|
// Set sensors whitelist
|
||||||
if sensors, exists := os.LookupEnv("SENSORS"); exists {
|
if sensors, exists := GetEnv("SENSORS"); exists {
|
||||||
a.sensorsWhitelist = make(map[string]struct{})
|
agent.sensorsWhitelist = make(map[string]struct{})
|
||||||
for _, sensor := range strings.Split(sensors, ",") {
|
for _, sensor := range strings.Split(sensors, ",") {
|
||||||
if sensor != "" {
|
if sensor != "" {
|
||||||
a.sensorsWhitelist[sensor] = struct{}{}
|
agent.sensorsWhitelist[sensor] = struct{}{}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// initialize system info / docker manager
|
// initialize system info / docker manager
|
||||||
a.initializeSystemInfo()
|
agent.initializeSystemInfo()
|
||||||
a.initializeDiskInfo()
|
agent.initializeDiskInfo()
|
||||||
a.initializeNetIoStats()
|
agent.initializeNetIoStats()
|
||||||
a.dockerManager = newDockerManager()
|
agent.dockerManager = newDockerManager(agent)
|
||||||
|
|
||||||
// if debugging, print stats
|
// initialize GPU manager
|
||||||
if a.debug {
|
if gm, err := NewGPUManager(); err != nil {
|
||||||
slog.Debug("Stats", "data", a.gatherStats())
|
slog.Debug("GPU", "err", err)
|
||||||
|
} else {
|
||||||
|
agent.gpuManager = gm
|
||||||
}
|
}
|
||||||
|
|
||||||
a.startServer(pubKey, addr)
|
// if debugging, print stats
|
||||||
|
if agent.debug {
|
||||||
|
slog.Debug("Stats", "data", agent.gatherStats())
|
||||||
|
}
|
||||||
|
|
||||||
|
return agent
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetEnv retrieves an environment variable with a "BESZEL_AGENT_" prefix, or falls back to the unprefixed key.
|
||||||
|
func GetEnv(key string) (value string, exists bool) {
|
||||||
|
if value, exists = os.LookupEnv("BESZEL_AGENT_" + key); exists {
|
||||||
|
return value, exists
|
||||||
|
}
|
||||||
|
// Fallback to the old unprefixed key
|
||||||
|
return os.LookupEnv(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Agent) gatherStats() system.CombinedData {
|
func (a *Agent) gatherStats() system.CombinedData {
|
||||||
|
a.Lock()
|
||||||
|
defer a.Unlock()
|
||||||
slog.Debug("Getting stats")
|
slog.Debug("Getting stats")
|
||||||
// systemData := system.CombinedData{
|
systemData := system.CombinedData{
|
||||||
// Stats: a.getSystemStats(),
|
Stats: a.getSystemStats(),
|
||||||
// Info: a.systemInfo,
|
Info: a.systemInfo,
|
||||||
// }
|
}
|
||||||
systemData := system.CombinedData{}
|
slog.Debug("System stats", "data", systemData)
|
||||||
// add docker stats (testing doing this first for docker 24)
|
// add docker stats
|
||||||
if containerStats, err := a.dockerManager.getDockerStats(); err == nil {
|
if containerStats, err := a.dockerManager.getDockerStats(); err == nil {
|
||||||
systemData.Containers = containerStats
|
systemData.Containers = containerStats
|
||||||
slog.Debug("Docker stats", "data", systemData.Containers)
|
slog.Debug("Docker stats", "data", systemData.Containers)
|
||||||
} else {
|
} else {
|
||||||
slog.Debug("Error getting docker stats", "err", err)
|
slog.Debug("Error getting docker stats", "err", err)
|
||||||
}
|
}
|
||||||
systemData.Stats = a.getSystemStats()
|
|
||||||
systemData.Info = a.systemInfo
|
|
||||||
slog.Debug("System stats", "data", systemData)
|
|
||||||
|
|
||||||
// add extra filesystems
|
// add extra filesystems
|
||||||
systemData.Stats.ExtraFs = make(map[string]*system.FsStats)
|
systemData.Stats.ExtraFs = make(map[string]*system.FsStats)
|
||||||
for name, stats := range a.fsStats {
|
for name, stats := range a.fsStats {
|
||||||
|
|||||||
@@ -3,18 +3,17 @@ package agent
|
|||||||
import (
|
import (
|
||||||
"beszel/internal/entities/system"
|
"beszel/internal/entities/system"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"time"
|
|
||||||
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/shirou/gopsutil/v4/disk"
|
"github.com/shirou/gopsutil/v4/disk"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 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 := os.Getenv("FILESYSTEM")
|
filesystem, _ := GetEnv("FILESYSTEM")
|
||||||
efPath := "/extra-filesystems"
|
efPath := "/extra-filesystems"
|
||||||
hasRoot := false
|
hasRoot := false
|
||||||
|
|
||||||
@@ -38,14 +37,26 @@ func (a *Agent) initializeDiskInfo() {
|
|||||||
// Helper function to add a filesystem to fsStats if it doesn't exist
|
// Helper function to add a filesystem to fsStats if it doesn't exist
|
||||||
addFsStat := func(device, mountpoint string, root bool) {
|
addFsStat := func(device, mountpoint string, root bool) {
|
||||||
key := filepath.Base(device)
|
key := filepath.Base(device)
|
||||||
|
var ioMatch bool
|
||||||
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
|
// Check if root device is in /proc/diskstats, use fallback if not
|
||||||
if _, exists := diskIoCounters[key]; !exists {
|
if _, ioMatch = diskIoCounters[key]; !ioMatch {
|
||||||
slog.Warn("Device not found in diskstats", "name", key)
|
key, ioMatch = findIoDevice(filesystem, diskIoCounters, a.fsStats)
|
||||||
key = findFallbackIoDevice(filesystem, diskIoCounters, a.fsStats)
|
if !ioMatch {
|
||||||
slog.Info("Using I/O fallback", "name", key)
|
slog.Info("Using I/O fallback", "device", device, "mountpoint", mountpoint, "fallback", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Check if non-root has diskstats and fall back to folder name if not
|
||||||
|
// Scenario: device is encrypted and named luks-2bcb02be-999d-4417-8d18-5c61e660fb6e - not in /proc/diskstats.
|
||||||
|
// However, the device can be specified by mounting folder from luks device at /extra-filesystems/sda1
|
||||||
|
if _, ioMatch = diskIoCounters[key]; !ioMatch {
|
||||||
|
efBase := filepath.Base(mountpoint)
|
||||||
|
if _, ioMatch = diskIoCounters[efBase]; ioMatch {
|
||||||
|
key = efBase
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
a.fsStats[key] = &system.FsStats{Root: root, Mountpoint: mountpoint}
|
a.fsStats[key] = &system.FsStats{Root: root, Mountpoint: mountpoint}
|
||||||
@@ -67,7 +78,7 @@ func (a *Agent) initializeDiskInfo() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add EXTRA_FILESYSTEMS env var values to fsStats
|
// Add EXTRA_FILESYSTEMS env var values to fsStats
|
||||||
if extraFilesystems, exists := os.LookupEnv("EXTRA_FILESYSTEMS"); exists {
|
if extraFilesystems, exists := GetEnv("EXTRA_FILESYSTEMS"); exists {
|
||||||
for _, fs := range strings.Split(extraFilesystems, ",") {
|
for _, fs := range strings.Split(extraFilesystems, ",") {
|
||||||
found := false
|
found := false
|
||||||
for _, p := range partitions {
|
for _, p := range partitions {
|
||||||
@@ -92,9 +103,12 @@ 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") && !strings.Contains(p.Device, "mapper"))) {
|
if !hasRoot && (p.Mountpoint == "/" || (p.Mountpoint == "/etc/hosts" && strings.HasPrefix(p.Device, "/dev"))) {
|
||||||
addFsStat(p.Device, "/", true)
|
fs, match := findIoDevice(filepath.Base(p.Device), diskIoCounters, a.fsStats)
|
||||||
hasRoot = true
|
if match {
|
||||||
|
addFsStat(fs, p.Mountpoint, true)
|
||||||
|
hasRoot = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if device is in /extra-filesystems
|
// Check if device is in /extra-filesystems
|
||||||
@@ -114,7 +128,7 @@ func (a *Agent) initializeDiskInfo() {
|
|||||||
mountpoint := filepath.Join(efPath, folder.Name())
|
mountpoint := filepath.Join(efPath, folder.Name())
|
||||||
slog.Debug("/extra-filesystems", "mountpoint", mountpoint)
|
slog.Debug("/extra-filesystems", "mountpoint", mountpoint)
|
||||||
if !existingMountpoints[mountpoint] {
|
if !existingMountpoints[mountpoint] {
|
||||||
a.fsStats[folder.Name()] = &system.FsStats{Mountpoint: mountpoint}
|
addFsStat(folder.Name(), mountpoint, false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -122,7 +136,7 @@ func (a *Agent) initializeDiskInfo() {
|
|||||||
|
|
||||||
// If no root filesystem set, use fallback
|
// If no root filesystem set, use fallback
|
||||||
if !hasRoot {
|
if !hasRoot {
|
||||||
rootDevice := findFallbackIoDevice(filepath.Base(filesystem), diskIoCounters, a.fsStats)
|
rootDevice, _ := findIoDevice(filepath.Base(filesystem), diskIoCounters, a.fsStats)
|
||||||
slog.Info("Root disk", "mountpoint", "/", "io", rootDevice)
|
slog.Info("Root disk", "mountpoint", "/", "io", rootDevice)
|
||||||
a.fsStats[rootDevice] = &system.FsStats{Root: true, Mountpoint: "/"}
|
a.fsStats[rootDevice] = &system.FsStats{Root: true, Mountpoint: "/"}
|
||||||
}
|
}
|
||||||
@@ -130,14 +144,15 @@ func (a *Agent) initializeDiskInfo() {
|
|||||||
a.initializeDiskIoStats(diskIoCounters)
|
a.initializeDiskIoStats(diskIoCounters)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns the device with the most reads in /proc/diskstats,
|
// Returns matching device from /proc/diskstats,
|
||||||
// or the device specified by the filesystem argument if it exists
|
// or the device with the most reads if no match is found.
|
||||||
func findFallbackIoDevice(filesystem string, diskIoCounters map[string]disk.IOCountersStat, fsStats map[string]*system.FsStats) string {
|
// bool is true if a match was found.
|
||||||
|
func findIoDevice(filesystem string, diskIoCounters map[string]disk.IOCountersStat, fsStats map[string]*system.FsStats) (string, bool) {
|
||||||
var maxReadBytes uint64
|
var maxReadBytes uint64
|
||||||
maxReadDevice := "/"
|
maxReadDevice := "/"
|
||||||
for _, d := range diskIoCounters {
|
for _, d := range diskIoCounters {
|
||||||
if d.Name == filesystem {
|
if d.Name == filesystem || (d.Label != "" && d.Label == filesystem) {
|
||||||
return d.Name
|
return d.Name, true
|
||||||
}
|
}
|
||||||
if d.ReadBytes > maxReadBytes {
|
if d.ReadBytes > maxReadBytes {
|
||||||
// don't use if device already exists in fsStats
|
// don't use if device already exists in fsStats
|
||||||
@@ -147,7 +162,7 @@ func findFallbackIoDevice(filesystem string, diskIoCounters map[string]disk.IOCo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return maxReadDevice
|
return maxReadDevice, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sets start values for disk I/O stats.
|
// Sets start values for disk I/O stats.
|
||||||
|
|||||||
@@ -52,11 +52,6 @@ func (dm *dockerManager) getDockerStats() ([]*container.Stats, error) {
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
// test sleeping for 1 second if docker 24
|
|
||||||
if !dm.goodDockerVersion {
|
|
||||||
time.Sleep(time.Millisecond * 1100)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&dm.apiContainerList); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&dm.apiContainerList); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -85,6 +80,7 @@ func (dm *dockerManager) getDockerStats() ([]*container.Stats, error) {
|
|||||||
go func() {
|
go func() {
|
||||||
defer dm.dequeue()
|
defer dm.dequeue()
|
||||||
err := dm.updateContainerStats(ctr)
|
err := dm.updateContainerStats(ctr)
|
||||||
|
// if error, delete from map and add to failed list to retry
|
||||||
if err != nil {
|
if err != nil {
|
||||||
dm.containerStatsMutex.Lock()
|
dm.containerStatsMutex.Lock()
|
||||||
delete(dm.containerStatsMap, ctr.IdShort)
|
delete(dm.containerStatsMap, ctr.IdShort)
|
||||||
@@ -99,7 +95,6 @@ func (dm *dockerManager) getDockerStats() ([]*container.Stats, error) {
|
|||||||
// retry failed containers separately so we can run them in parallel (docker 24 bug)
|
// retry failed containers separately so we can run them in parallel (docker 24 bug)
|
||||||
if len(failedContainters) > 0 {
|
if len(failedContainters) > 0 {
|
||||||
slog.Debug("Retrying failed containers", "count", len(failedContainters))
|
slog.Debug("Retrying failed containers", "count", len(failedContainters))
|
||||||
time.Sleep(time.Millisecond * 1100) // this is a test for docker 24 bug
|
|
||||||
for _, ctr := range failedContainters {
|
for _, ctr := range failedContainters {
|
||||||
dm.queue()
|
dm.queue()
|
||||||
go func() {
|
go func() {
|
||||||
@@ -211,12 +206,13 @@ func (dm *dockerManager) deleteContainerStatsSync(id string) {
|
|||||||
delete(dm.containerStatsMap, id)
|
delete(dm.containerStatsMap, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Creates a new http client for Docker API
|
// Creates a new http client for Docker or Podman API
|
||||||
func newDockerManager() *dockerManager {
|
func newDockerManager(a *Agent) *dockerManager {
|
||||||
dockerHost := "unix:///var/run/docker.sock"
|
dockerHost, exists := GetEnv("DOCKER_HOST")
|
||||||
if dockerHostEnv, exists := os.LookupEnv("DOCKER_HOST"); exists {
|
if exists {
|
||||||
slog.Info("DOCKER_HOST", "host", dockerHostEnv)
|
slog.Info("DOCKER_HOST", "host", dockerHost)
|
||||||
dockerHost = dockerHostEnv
|
} else {
|
||||||
|
dockerHost = getDockerHost()
|
||||||
}
|
}
|
||||||
|
|
||||||
parsedURL, err := url.Parse(dockerHost)
|
parsedURL, err := url.Parse(dockerHost)
|
||||||
@@ -246,7 +242,7 @@ func newDockerManager() *dockerManager {
|
|||||||
|
|
||||||
// configurable timeout
|
// configurable timeout
|
||||||
timeout := time.Millisecond * 2100
|
timeout := time.Millisecond * 2100
|
||||||
if t, set := os.LookupEnv("DOCKER_TIMEOUT"); set {
|
if t, set := GetEnv("DOCKER_TIMEOUT"); set {
|
||||||
timeout, err = time.ParseDuration(t)
|
timeout, err = time.ParseDuration(t)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error(err.Error())
|
slog.Error(err.Error())
|
||||||
@@ -264,6 +260,13 @@ func newDockerManager() *dockerManager {
|
|||||||
sem: make(chan struct{}, 5),
|
sem: make(chan struct{}, 5),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If using podman, return client
|
||||||
|
if strings.Contains(dockerHost, "podman") {
|
||||||
|
a.systemInfo.Podman = true
|
||||||
|
dockerClient.goodDockerVersion = true
|
||||||
|
return dockerClient
|
||||||
|
}
|
||||||
|
|
||||||
// Check docker version
|
// Check docker version
|
||||||
// (versions before 25.0.0 have a bug with one-shot which requires all requests to be made in one batch)
|
// (versions before 25.0.0 have a bug with one-shot which requires all requests to be made in one batch)
|
||||||
var versionInfo struct {
|
var versionInfo struct {
|
||||||
@@ -287,3 +290,15 @@ func newDockerManager() *dockerManager {
|
|||||||
|
|
||||||
return dockerClient
|
return dockerClient
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Test docker / podman sockets and return if one exists
|
||||||
|
func getDockerHost() string {
|
||||||
|
scheme := "unix://"
|
||||||
|
socks := []string{"/var/run/docker.sock", fmt.Sprintf("/run/user/%v/podman/podman.sock", os.Getuid())}
|
||||||
|
for _, sock := range socks {
|
||||||
|
if _, err := os.Stat(sock); err == nil {
|
||||||
|
return scheme + sock
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return scheme + socks[0]
|
||||||
|
}
|
||||||
|
|||||||
321
beszel/internal/agent/gpu.go
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"beszel/internal/entities/system"
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/exp/slog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GPUManager manages data collection for GPUs (either Nvidia or AMD)
|
||||||
|
type GPUManager struct {
|
||||||
|
sync.Mutex
|
||||||
|
nvidiaSmi bool
|
||||||
|
rocmSmi bool
|
||||||
|
tegrastats bool
|
||||||
|
GpuDataMap map[string]*system.GPUData
|
||||||
|
}
|
||||||
|
|
||||||
|
// RocmSmiJson represents the JSON structure of rocm-smi output
|
||||||
|
type RocmSmiJson struct {
|
||||||
|
ID string `json:"GUID"`
|
||||||
|
Name string `json:"Card series"`
|
||||||
|
Temperature string `json:"Temperature (Sensor edge) (C)"`
|
||||||
|
MemoryUsed string `json:"VRAM Total Used Memory (B)"`
|
||||||
|
MemoryTotal string `json:"VRAM Total Memory (B)"`
|
||||||
|
Usage string `json:"GPU use (%)"`
|
||||||
|
PowerPackage string `json:"Average Graphics Package Power (W)"`
|
||||||
|
PowerSocket string `json:"Current Socket Graphics Package Power (W)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// gpuCollector defines a collector for a specific GPU management utility (nvidia-smi or rocm-smi)
|
||||||
|
type gpuCollector struct {
|
||||||
|
name string
|
||||||
|
cmdArgs []string
|
||||||
|
parse func([]byte) bool // returns true if valid data was found
|
||||||
|
buf []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
var errNoValidData = fmt.Errorf("no valid GPU data found") // Error for missing data
|
||||||
|
|
||||||
|
// starts and manages the ongoing collection of GPU data for the specified GPU management utility
|
||||||
|
func (c *gpuCollector) start() {
|
||||||
|
for {
|
||||||
|
err := c.collect()
|
||||||
|
if err != nil {
|
||||||
|
if err == errNoValidData {
|
||||||
|
slog.Warn(c.name + " found no valid GPU data, stopping")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
slog.Warn(c.name+" failed, restarting", "err", err)
|
||||||
|
time.Sleep(time.Second * 5)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// collect executes the command, parses output with the assigned parser function
|
||||||
|
func (c *gpuCollector) collect() error {
|
||||||
|
cmd := exec.Command(c.name, c.cmdArgs...)
|
||||||
|
stdout, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(stdout)
|
||||||
|
if c.buf == nil {
|
||||||
|
c.buf = make([]byte, 0, 4*1024)
|
||||||
|
}
|
||||||
|
scanner.Buffer(c.buf, bufio.MaxScanTokenSize)
|
||||||
|
|
||||||
|
for scanner.Scan() {
|
||||||
|
hasValidData := c.parse(scanner.Bytes())
|
||||||
|
if !hasValidData {
|
||||||
|
return errNoValidData
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return fmt.Errorf("scanner error: %w", err)
|
||||||
|
}
|
||||||
|
return cmd.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// getJetsonParser returns a function to parse the output of tegrastats and update the GPUData map
|
||||||
|
func (gm *GPUManager) getJetsonParser() func(output []byte) bool {
|
||||||
|
// use closure to avoid recompiling the regex
|
||||||
|
ramPattern := regexp.MustCompile(`RAM (\d+)/(\d+)MB`)
|
||||||
|
gr3dPattern := regexp.MustCompile(`GR3D_FREQ (\d+)%`)
|
||||||
|
tempPattern := regexp.MustCompile(`tj@(\d+\.?\d*)C`)
|
||||||
|
// Orin Nano / NX do not have GPU specific power monitor
|
||||||
|
// TODO: Maybe use VDD_IN for Nano / NX and add a total system power chart
|
||||||
|
powerPattern := regexp.MustCompile(`(GPU_SOC|CPU_GPU_CV) (\d+)mW`)
|
||||||
|
|
||||||
|
return func(output []byte) bool {
|
||||||
|
gm.Lock()
|
||||||
|
defer gm.Unlock()
|
||||||
|
// we get gpu name from the intitial run of nvidia-smi, so return if it hasn't been initialized
|
||||||
|
gpuData, ok := gm.GpuDataMap["0"]
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
data := string(output)
|
||||||
|
// Parse RAM usage
|
||||||
|
ramMatches := ramPattern.FindStringSubmatch(data)
|
||||||
|
if ramMatches != nil {
|
||||||
|
gpuData.MemoryUsed, _ = strconv.ParseFloat(ramMatches[1], 64)
|
||||||
|
gpuData.MemoryTotal, _ = strconv.ParseFloat(ramMatches[2], 64)
|
||||||
|
}
|
||||||
|
// Parse GR3D (GPU) usage
|
||||||
|
gr3dMatches := gr3dPattern.FindStringSubmatch(data)
|
||||||
|
if gr3dMatches != nil {
|
||||||
|
gpuData.Usage, _ = strconv.ParseFloat(gr3dMatches[1], 64)
|
||||||
|
}
|
||||||
|
// Parse temperature
|
||||||
|
tempMatches := tempPattern.FindStringSubmatch(data)
|
||||||
|
if tempMatches != nil {
|
||||||
|
gpuData.Temperature, _ = strconv.ParseFloat(tempMatches[1], 64)
|
||||||
|
}
|
||||||
|
// Parse power usage
|
||||||
|
powerMatches := powerPattern.FindStringSubmatch(data)
|
||||||
|
if powerMatches != nil {
|
||||||
|
power, _ := strconv.ParseFloat(powerMatches[2], 64)
|
||||||
|
gpuData.Power = power / 1000
|
||||||
|
}
|
||||||
|
gpuData.Count++
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseNvidiaData parses the output of nvidia-smi and updates the GPUData map
|
||||||
|
func (gm *GPUManager) parseNvidiaData(output []byte) bool {
|
||||||
|
gm.Lock()
|
||||||
|
defer gm.Unlock()
|
||||||
|
var valid bool
|
||||||
|
for line := range strings.Lines(string(output)) {
|
||||||
|
fields := strings.Split(strings.TrimSpace(line), ", ")
|
||||||
|
if len(fields) < 7 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
valid = true
|
||||||
|
id := fields[0]
|
||||||
|
temp, _ := strconv.ParseFloat(fields[2], 64)
|
||||||
|
memoryUsage, _ := strconv.ParseFloat(fields[3], 64)
|
||||||
|
totalMemory, _ := strconv.ParseFloat(fields[4], 64)
|
||||||
|
usage, _ := strconv.ParseFloat(fields[5], 64)
|
||||||
|
power, _ := strconv.ParseFloat(fields[6], 64)
|
||||||
|
// add gpu if not exists
|
||||||
|
if _, ok := gm.GpuDataMap[id]; !ok {
|
||||||
|
name := strings.TrimPrefix(fields[1], "NVIDIA ")
|
||||||
|
gm.GpuDataMap[id] = &system.GPUData{Name: strings.TrimSuffix(name, " Laptop GPU")}
|
||||||
|
// check if tegrastats is active - if so we will only use nvidia-smi to get gpu name
|
||||||
|
// - nvidia-smi does not provide metrics for tegra / jetson devices
|
||||||
|
// this will end the nvidia-smi collector
|
||||||
|
if gm.tegrastats {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// update gpu data
|
||||||
|
gpu := gm.GpuDataMap[id]
|
||||||
|
gpu.Temperature = temp
|
||||||
|
gpu.MemoryUsed = memoryUsage / 1.024
|
||||||
|
gpu.MemoryTotal = totalMemory / 1.024
|
||||||
|
gpu.Usage += usage
|
||||||
|
gpu.Power += power
|
||||||
|
gpu.Count++
|
||||||
|
}
|
||||||
|
return valid
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseAmdData parses the output of rocm-smi and updates the GPUData map
|
||||||
|
func (gm *GPUManager) parseAmdData(output []byte) bool {
|
||||||
|
var rocmSmiInfo map[string]RocmSmiJson
|
||||||
|
if err := json.Unmarshal(output, &rocmSmiInfo); err != nil || len(rocmSmiInfo) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
gm.Lock()
|
||||||
|
defer gm.Unlock()
|
||||||
|
for _, v := range rocmSmiInfo {
|
||||||
|
var power float64
|
||||||
|
if v.PowerPackage != "" {
|
||||||
|
power, _ = strconv.ParseFloat(v.PowerPackage, 64)
|
||||||
|
} else {
|
||||||
|
power, _ = strconv.ParseFloat(v.PowerSocket, 64)
|
||||||
|
}
|
||||||
|
memoryUsage, _ := strconv.ParseFloat(v.MemoryUsed, 64)
|
||||||
|
totalMemory, _ := strconv.ParseFloat(v.MemoryTotal, 64)
|
||||||
|
usage, _ := strconv.ParseFloat(v.Usage, 64)
|
||||||
|
|
||||||
|
if _, ok := gm.GpuDataMap[v.ID]; !ok {
|
||||||
|
gm.GpuDataMap[v.ID] = &system.GPUData{Name: v.Name}
|
||||||
|
}
|
||||||
|
gpu := gm.GpuDataMap[v.ID]
|
||||||
|
gpu.Temperature, _ = strconv.ParseFloat(v.Temperature, 64)
|
||||||
|
gpu.MemoryUsed = bytesToMegabytes(memoryUsage)
|
||||||
|
gpu.MemoryTotal = bytesToMegabytes(totalMemory)
|
||||||
|
gpu.Usage += usage
|
||||||
|
gpu.Power += power
|
||||||
|
gpu.Count++
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// sums and resets the current GPU utilization data since the last update
|
||||||
|
func (gm *GPUManager) GetCurrentData() map[string]system.GPUData {
|
||||||
|
gm.Lock()
|
||||||
|
defer gm.Unlock()
|
||||||
|
|
||||||
|
// check for GPUs with the same name
|
||||||
|
nameCounts := make(map[string]int)
|
||||||
|
for _, gpu := range gm.GpuDataMap {
|
||||||
|
nameCounts[gpu.Name]++
|
||||||
|
}
|
||||||
|
|
||||||
|
// copy / reset the data
|
||||||
|
gpuData := make(map[string]system.GPUData, len(gm.GpuDataMap))
|
||||||
|
for id, gpu := range gm.GpuDataMap {
|
||||||
|
// sum the data
|
||||||
|
gpu.Temperature = twoDecimals(gpu.Temperature)
|
||||||
|
gpu.MemoryUsed = twoDecimals(gpu.MemoryUsed)
|
||||||
|
gpu.MemoryTotal = twoDecimals(gpu.MemoryTotal)
|
||||||
|
gpu.Usage = twoDecimals(gpu.Usage / gpu.Count)
|
||||||
|
gpu.Power = twoDecimals(gpu.Power / gpu.Count)
|
||||||
|
// reset the count
|
||||||
|
gpu.Count = 1
|
||||||
|
// dereference to avoid overwriting anything else
|
||||||
|
gpuCopy := *gpu
|
||||||
|
// append id to the name if there are multiple GPUs with the same name
|
||||||
|
if nameCounts[gpu.Name] > 1 {
|
||||||
|
gpuCopy.Name = fmt.Sprintf("%s %s", gpu.Name, id)
|
||||||
|
}
|
||||||
|
gpuData[id] = gpuCopy
|
||||||
|
}
|
||||||
|
return gpuData
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectGPUs checks for the presence of GPU management tools (nvidia-smi, rocm-smi, tegrastats)
|
||||||
|
// in the system path. It sets the corresponding flags in the GPUManager struct if any of these
|
||||||
|
// tools are found. If none of the tools are found, it returns an error indicating that no GPU
|
||||||
|
// management tools are available.
|
||||||
|
func (gm *GPUManager) detectGPUs() error {
|
||||||
|
if _, err := exec.LookPath("nvidia-smi"); err == nil {
|
||||||
|
gm.nvidiaSmi = true
|
||||||
|
}
|
||||||
|
if _, err := exec.LookPath("rocm-smi"); err == nil {
|
||||||
|
gm.rocmSmi = true
|
||||||
|
}
|
||||||
|
if _, err := exec.LookPath("tegrastats"); err == nil {
|
||||||
|
gm.tegrastats = true
|
||||||
|
}
|
||||||
|
if gm.nvidiaSmi || gm.rocmSmi || gm.tegrastats {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("no GPU found - install nvidia-smi, rocm-smi, or tegrastats")
|
||||||
|
}
|
||||||
|
|
||||||
|
// startCollector starts the appropriate GPU data collector based on the command
|
||||||
|
func (gm *GPUManager) startCollector(command string) {
|
||||||
|
collector := gpuCollector{
|
||||||
|
name: command,
|
||||||
|
}
|
||||||
|
switch command {
|
||||||
|
case "nvidia-smi":
|
||||||
|
collector.cmdArgs = []string{"-l", "4",
|
||||||
|
"--query-gpu=index,name,temperature.gpu,memory.used,memory.total,utilization.gpu,power.draw",
|
||||||
|
"--format=csv,noheader,nounits"}
|
||||||
|
collector.parse = gm.parseNvidiaData
|
||||||
|
go collector.start()
|
||||||
|
case "tegrastats":
|
||||||
|
collector.cmdArgs = []string{"--interval", "3000"}
|
||||||
|
collector.parse = gm.getJetsonParser()
|
||||||
|
go collector.start()
|
||||||
|
case "rocm-smi":
|
||||||
|
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 > 5 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
slog.Warn("Error collecting AMD GPU data", "err", err)
|
||||||
|
}
|
||||||
|
time.Sleep(4300 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewGPUManager creates and initializes a new GPUManager
|
||||||
|
func NewGPUManager() (*GPUManager, error) {
|
||||||
|
var gm GPUManager
|
||||||
|
if err := gm.detectGPUs(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
gm.GpuDataMap = make(map[string]*system.GPUData)
|
||||||
|
|
||||||
|
if gm.nvidiaSmi {
|
||||||
|
gm.startCollector("nvidia-smi")
|
||||||
|
}
|
||||||
|
if gm.rocmSmi {
|
||||||
|
gm.startCollector("rocm-smi")
|
||||||
|
}
|
||||||
|
if gm.tegrastats {
|
||||||
|
gm.startCollector("tegrastats")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &gm, nil
|
||||||
|
}
|
||||||
525
beszel/internal/agent/gpu_test.go
Normal file
@@ -0,0 +1,525 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"beszel/internal/entities/system"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseNvidiaData(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
wantData map[string]system.GPUData
|
||||||
|
wantValid bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid multi-gpu data",
|
||||||
|
input: "0, NVIDIA GeForce RTX 3050 Ti Laptop GPU, 48, 12, 4096, 26.3, 12.73\n1, NVIDIA A100-PCIE-40GB, 38, 74, 40960, [N/A], 36.79",
|
||||||
|
wantData: map[string]system.GPUData{
|
||||||
|
"0": {
|
||||||
|
Name: "GeForce RTX 3050 Ti",
|
||||||
|
Temperature: 48.0,
|
||||||
|
MemoryUsed: 12.0 / 1.024,
|
||||||
|
MemoryTotal: 4096.0 / 1.024,
|
||||||
|
Usage: 26.3,
|
||||||
|
Power: 12.73,
|
||||||
|
Count: 1,
|
||||||
|
},
|
||||||
|
"1": {
|
||||||
|
Name: "A100-PCIE-40GB",
|
||||||
|
Temperature: 38.0,
|
||||||
|
MemoryUsed: 74.0 / 1.024,
|
||||||
|
MemoryTotal: 40960.0 / 1.024,
|
||||||
|
Usage: 0.0,
|
||||||
|
Power: 36.79,
|
||||||
|
Count: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantValid: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty input",
|
||||||
|
input: "",
|
||||||
|
wantData: map[string]system.GPUData{},
|
||||||
|
wantValid: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "malformed data",
|
||||||
|
input: "bad, data, here",
|
||||||
|
wantData: map[string]system.GPUData{},
|
||||||
|
wantValid: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
gm := &GPUManager{
|
||||||
|
GpuDataMap: make(map[string]*system.GPUData),
|
||||||
|
}
|
||||||
|
valid := gm.parseNvidiaData([]byte(tt.input))
|
||||||
|
assert.Equal(t, tt.wantValid, valid)
|
||||||
|
|
||||||
|
if tt.wantValid {
|
||||||
|
for id, want := range tt.wantData {
|
||||||
|
got := gm.GpuDataMap[id]
|
||||||
|
require.NotNil(t, got)
|
||||||
|
assert.Equal(t, want.Name, got.Name)
|
||||||
|
assert.InDelta(t, want.Temperature, got.Temperature, 0.01)
|
||||||
|
assert.InDelta(t, want.MemoryUsed, got.MemoryUsed, 0.01)
|
||||||
|
assert.InDelta(t, want.MemoryTotal, got.MemoryTotal, 0.01)
|
||||||
|
assert.InDelta(t, want.Usage, got.Usage, 0.01)
|
||||||
|
assert.InDelta(t, want.Power, got.Power, 0.01)
|
||||||
|
assert.Equal(t, want.Count, got.Count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseAmdData(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
wantData map[string]system.GPUData
|
||||||
|
wantValid bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid single gpu data",
|
||||||
|
input: `{
|
||||||
|
"card0": {
|
||||||
|
"GUID": "34756",
|
||||||
|
"Temperature (Sensor edge) (C)": "47.0",
|
||||||
|
"Current Socket Graphics Package Power (W)": "9.215",
|
||||||
|
"GPU use (%)": "0",
|
||||||
|
"VRAM Total Memory (B)": "536870912",
|
||||||
|
"VRAM Total Used Memory (B)": "482263040",
|
||||||
|
"Card Series": "Rembrandt [Radeon 680M]"
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
wantData: map[string]system.GPUData{
|
||||||
|
"34756": {
|
||||||
|
Name: "Rembrandt [Radeon 680M]",
|
||||||
|
Temperature: 47.0,
|
||||||
|
MemoryUsed: 482263040.0 / (1024 * 1024),
|
||||||
|
MemoryTotal: 536870912.0 / (1024 * 1024),
|
||||||
|
Usage: 0.0,
|
||||||
|
Power: 9.215,
|
||||||
|
Count: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantValid: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid multi gpu data",
|
||||||
|
input: `{
|
||||||
|
"card0": {
|
||||||
|
"GUID": "34756",
|
||||||
|
"Temperature (Sensor edge) (C)": "47.0",
|
||||||
|
"Current Socket Graphics Package Power (W)": "9.215",
|
||||||
|
"GPU use (%)": "0",
|
||||||
|
"VRAM Total Memory (B)": "536870912",
|
||||||
|
"VRAM Total Used Memory (B)": "482263040",
|
||||||
|
"Card Series": "Rembrandt [Radeon 680M]"
|
||||||
|
},
|
||||||
|
"card1": {
|
||||||
|
"GUID": "38294",
|
||||||
|
"Temperature (Sensor edge) (C)": "49.0",
|
||||||
|
"Temperature (Sensor junction) (C)": "49.0",
|
||||||
|
"Temperature (Sensor memory) (C)": "62.0",
|
||||||
|
"Average Graphics Package Power (W)": "19.0",
|
||||||
|
"GPU use (%)": "20.3",
|
||||||
|
"VRAM Total Memory (B)": "25753026560",
|
||||||
|
"VRAM Total Used Memory (B)": "794341376",
|
||||||
|
"Card Series": "Navi 31 [Radeon RX 7900 XT]"
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
wantData: map[string]system.GPUData{
|
||||||
|
"34756": {
|
||||||
|
Name: "Rembrandt [Radeon 680M]",
|
||||||
|
Temperature: 47.0,
|
||||||
|
MemoryUsed: 482263040.0 / (1024 * 1024),
|
||||||
|
MemoryTotal: 536870912.0 / (1024 * 1024),
|
||||||
|
Usage: 0.0,
|
||||||
|
Power: 9.215,
|
||||||
|
Count: 1,
|
||||||
|
},
|
||||||
|
"38294": {
|
||||||
|
Name: "Navi 31 [Radeon RX 7900 XT]",
|
||||||
|
Temperature: 49.0,
|
||||||
|
MemoryUsed: 794341376.0 / (1024 * 1024),
|
||||||
|
MemoryTotal: 25753026560.0 / (1024 * 1024),
|
||||||
|
Usage: 20.3,
|
||||||
|
Power: 19.0,
|
||||||
|
Count: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantValid: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid json",
|
||||||
|
input: "{bad json",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid json",
|
||||||
|
input: "{bad json",
|
||||||
|
wantData: map[string]system.GPUData{},
|
||||||
|
wantValid: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
gm := &GPUManager{
|
||||||
|
GpuDataMap: make(map[string]*system.GPUData),
|
||||||
|
}
|
||||||
|
valid := gm.parseAmdData([]byte(tt.input))
|
||||||
|
assert.Equal(t, tt.wantValid, valid)
|
||||||
|
|
||||||
|
if tt.wantValid {
|
||||||
|
for id, want := range tt.wantData {
|
||||||
|
got := gm.GpuDataMap[id]
|
||||||
|
require.NotNil(t, got)
|
||||||
|
assert.Equal(t, want.Name, got.Name)
|
||||||
|
assert.InDelta(t, want.Temperature, got.Temperature, 0.01)
|
||||||
|
assert.InDelta(t, want.MemoryUsed, got.MemoryUsed, 0.01)
|
||||||
|
assert.InDelta(t, want.MemoryTotal, got.MemoryTotal, 0.01)
|
||||||
|
assert.InDelta(t, want.Usage, got.Usage, 0.01)
|
||||||
|
assert.InDelta(t, want.Power, got.Power, 0.01)
|
||||||
|
assert.Equal(t, want.Count, got.Count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseJetsonData(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
gm *GPUManager
|
||||||
|
wantMetrics *system.GPUData
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid data",
|
||||||
|
input: "RAM 4300/30698MB GR3D_FREQ 45% tj@52.468C VDD_GPU_SOC 2171mW",
|
||||||
|
wantMetrics: &system.GPUData{
|
||||||
|
Name: "Jetson",
|
||||||
|
MemoryUsed: 4300.0,
|
||||||
|
MemoryTotal: 30698.0,
|
||||||
|
Usage: 45.0,
|
||||||
|
Temperature: 52.468,
|
||||||
|
Power: 2.171,
|
||||||
|
Count: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing temperature",
|
||||||
|
input: "RAM 4300/30698MB GR3D_FREQ 45% VDD_GPU_SOC 2171mW",
|
||||||
|
wantMetrics: &system.GPUData{
|
||||||
|
Name: "Jetson",
|
||||||
|
MemoryUsed: 4300.0,
|
||||||
|
MemoryTotal: 30698.0,
|
||||||
|
Usage: 45.0,
|
||||||
|
Power: 2.171,
|
||||||
|
Count: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no gpu defined by nvidia-smi",
|
||||||
|
input: "RAM 4300/30698MB GR3D_FREQ 45% VDD_GPU_SOC 2171mW",
|
||||||
|
gm: &GPUManager{
|
||||||
|
GpuDataMap: map[string]*system.GPUData{},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if tt.gm != nil {
|
||||||
|
// should return if no gpu set by nvidia-smi
|
||||||
|
assert.Empty(t, tt.gm.GpuDataMap)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tt.gm = &GPUManager{
|
||||||
|
GpuDataMap: map[string]*system.GPUData{
|
||||||
|
"0": {Name: "Jetson"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
parser := tt.gm.getJetsonParser()
|
||||||
|
valid := parser([]byte(tt.input))
|
||||||
|
assert.Equal(t, true, valid)
|
||||||
|
|
||||||
|
got := tt.gm.GpuDataMap["0"]
|
||||||
|
require.NotNil(t, got)
|
||||||
|
assert.Equal(t, tt.wantMetrics.Name, got.Name)
|
||||||
|
assert.InDelta(t, tt.wantMetrics.MemoryUsed, got.MemoryUsed, 0.01)
|
||||||
|
assert.InDelta(t, tt.wantMetrics.MemoryTotal, got.MemoryTotal, 0.01)
|
||||||
|
assert.InDelta(t, tt.wantMetrics.Usage, got.Usage, 0.01)
|
||||||
|
if tt.wantMetrics.Temperature > 0 {
|
||||||
|
assert.InDelta(t, tt.wantMetrics.Temperature, got.Temperature, 0.01)
|
||||||
|
}
|
||||||
|
assert.InDelta(t, tt.wantMetrics.Power, got.Power, 0.01)
|
||||||
|
assert.Equal(t, tt.wantMetrics.Count, got.Count)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetCurrentData(t *testing.T) {
|
||||||
|
gm := &GPUManager{
|
||||||
|
GpuDataMap: map[string]*system.GPUData{
|
||||||
|
"0": {
|
||||||
|
Name: "GPU1",
|
||||||
|
Temperature: 50,
|
||||||
|
MemoryUsed: 2048,
|
||||||
|
MemoryTotal: 4096,
|
||||||
|
Usage: 100, // 100 over 2 counts = 50 avg
|
||||||
|
Power: 200, // 200 over 2 counts = 100 avg
|
||||||
|
Count: 2,
|
||||||
|
},
|
||||||
|
"1": {
|
||||||
|
Name: "GPU1",
|
||||||
|
Temperature: 60,
|
||||||
|
MemoryUsed: 3072,
|
||||||
|
MemoryTotal: 8192,
|
||||||
|
Usage: 30,
|
||||||
|
Power: 60,
|
||||||
|
Count: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := gm.GetCurrentData()
|
||||||
|
|
||||||
|
// Verify name disambiguation
|
||||||
|
assert.Equal(t, "GPU1 0", result["0"].Name)
|
||||||
|
assert.Equal(t, "GPU1 1", result["1"].Name)
|
||||||
|
|
||||||
|
// Check averaged values
|
||||||
|
assert.InDelta(t, 50.0, result["0"].Usage, 0.01)
|
||||||
|
assert.InDelta(t, 100.0, result["0"].Power, 0.01)
|
||||||
|
assert.InDelta(t, 30.0, result["1"].Usage, 0.01)
|
||||||
|
assert.InDelta(t, 60.0, result["1"].Power, 0.01)
|
||||||
|
|
||||||
|
// Verify reset counts
|
||||||
|
assert.Equal(t, float64(1), gm.GpuDataMap["0"].Count)
|
||||||
|
assert.Equal(t, float64(1), gm.GpuDataMap["1"].Count)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectGPUs(t *testing.T) {
|
||||||
|
// Save original PATH
|
||||||
|
origPath := os.Getenv("PATH")
|
||||||
|
defer os.Setenv("PATH", origPath)
|
||||||
|
|
||||||
|
// Set up temp dir with the commands
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
os.Setenv("PATH", tempDir)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
setupCommands func() error
|
||||||
|
wantNvidiaSmi bool
|
||||||
|
wantRocmSmi bool
|
||||||
|
wantTegrastats bool
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "nvidia-smi not available",
|
||||||
|
setupCommands: func() error {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
wantNvidiaSmi: false,
|
||||||
|
wantRocmSmi: false,
|
||||||
|
wantTegrastats: false,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nvidia-smi available",
|
||||||
|
setupCommands: func() error {
|
||||||
|
path := filepath.Join(tempDir, "nvidia-smi")
|
||||||
|
script := `#!/bin/sh
|
||||||
|
echo "test"`
|
||||||
|
if err := os.WriteFile(path, []byte(script), 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
wantNvidiaSmi: true,
|
||||||
|
wantTegrastats: false,
|
||||||
|
wantRocmSmi: false,
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "rocm-smi available",
|
||||||
|
setupCommands: func() error {
|
||||||
|
path := filepath.Join(tempDir, "rocm-smi")
|
||||||
|
script := `#!/bin/sh
|
||||||
|
echo "test"`
|
||||||
|
if err := os.WriteFile(path, []byte(script), 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
wantNvidiaSmi: true,
|
||||||
|
wantRocmSmi: true,
|
||||||
|
wantTegrastats: false,
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tegrastats available",
|
||||||
|
setupCommands: func() error {
|
||||||
|
path := filepath.Join(tempDir, "tegrastats")
|
||||||
|
script := `#!/bin/sh
|
||||||
|
echo "test"`
|
||||||
|
if err := os.WriteFile(path, []byte(script), 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
wantNvidiaSmi: true,
|
||||||
|
wantRocmSmi: true,
|
||||||
|
wantTegrastats: true,
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no gpu tools available",
|
||||||
|
setupCommands: func() error {
|
||||||
|
os.Setenv("PATH", "")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if err := tt.setupCommands(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gm := &GPUManager{}
|
||||||
|
err := gm.detectGPUs()
|
||||||
|
|
||||||
|
t.Logf("nvidiaSmi: %v, rocmSmi: %v, tegrastats: %v", gm.nvidiaSmi, gm.rocmSmi, gm.tegrastats)
|
||||||
|
|
||||||
|
if tt.wantErr {
|
||||||
|
assert.Error(t, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, tt.wantNvidiaSmi, gm.nvidiaSmi)
|
||||||
|
assert.Equal(t, tt.wantRocmSmi, gm.rocmSmi)
|
||||||
|
assert.Equal(t, tt.wantTegrastats, gm.tegrastats)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartCollector(t *testing.T) {
|
||||||
|
// Save original PATH
|
||||||
|
origPath := os.Getenv("PATH")
|
||||||
|
defer os.Setenv("PATH", origPath)
|
||||||
|
|
||||||
|
// Set up temp dir with the commands
|
||||||
|
dir := t.TempDir()
|
||||||
|
os.Setenv("PATH", dir)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
command string
|
||||||
|
setup func(t *testing.T) error
|
||||||
|
validate func(t *testing.T, gm *GPUManager)
|
||||||
|
gm *GPUManager
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "nvidia-smi collector",
|
||||||
|
command: "nvidia-smi",
|
||||||
|
setup: func(t *testing.T) error {
|
||||||
|
path := filepath.Join(dir, "nvidia-smi")
|
||||||
|
script := `#!/bin/sh
|
||||||
|
echo "0, NVIDIA Test GPU, 50, 1024, 4096, 25, 100"`
|
||||||
|
if err := os.WriteFile(path, []byte(script), 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
validate: func(t *testing.T, gm *GPUManager) {
|
||||||
|
gpu, exists := gm.GpuDataMap["0"]
|
||||||
|
assert.True(t, exists)
|
||||||
|
if exists {
|
||||||
|
assert.Equal(t, "Test GPU", gpu.Name)
|
||||||
|
assert.Equal(t, 50.0, gpu.Temperature)
|
||||||
|
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "rocm-smi collector",
|
||||||
|
command: "rocm-smi",
|
||||||
|
setup: func(t *testing.T) error {
|
||||||
|
path := filepath.Join(dir, "rocm-smi")
|
||||||
|
script := `#!/bin/sh
|
||||||
|
echo '{"card0": {"Temperature (Sensor edge) (C)": "49.0", "Current Socket Graphics Package Power (W)": "28.159", "GPU use (%)": "0", "VRAM Total Memory (B)": "536870912", "VRAM Total Used Memory (B)": "445550592", "Card Series": "Rembrandt [Radeon 680M]", "Card Model": "0x1681", "Card Vendor": "Advanced Micro Devices, Inc. [AMD/ATI]", "Card SKU": "REMBRANDT", "Subsystem ID": "0x8a22", "Device Rev": "0xc8", "Node ID": "1", "GUID": "34756", "GFX Version": "gfx1035"}}'`
|
||||||
|
if err := os.WriteFile(path, []byte(script), 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
validate: func(t *testing.T, gm *GPUManager) {
|
||||||
|
gpu, exists := gm.GpuDataMap["34756"]
|
||||||
|
assert.True(t, exists)
|
||||||
|
if exists {
|
||||||
|
assert.Equal(t, "Rembrandt [Radeon 680M]", gpu.Name)
|
||||||
|
assert.InDelta(t, 49.0, gpu.Temperature, 0.01)
|
||||||
|
assert.InDelta(t, 28.159, gpu.Power, 0.01)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tegrastats collector",
|
||||||
|
command: "tegrastats",
|
||||||
|
setup: func(t *testing.T) error {
|
||||||
|
path := filepath.Join(dir, "tegrastats")
|
||||||
|
script := `#!/bin/sh
|
||||||
|
echo "RAM 1024/4096MB GR3D_FREQ 80% tj@70C VDD_GPU_SOC 1000mW"`
|
||||||
|
if err := os.WriteFile(path, []byte(script), 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
validate: func(t *testing.T, gm *GPUManager) {
|
||||||
|
gpu, exists := gm.GpuDataMap["0"]
|
||||||
|
assert.True(t, exists)
|
||||||
|
if exists {
|
||||||
|
assert.InDelta(t, 70.0, gpu.Temperature, 0.1)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
gm: &GPUManager{
|
||||||
|
GpuDataMap: map[string]*system.GPUData{
|
||||||
|
"0": {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if err := tt.setup(t); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if tt.gm == nil {
|
||||||
|
tt.gm = &GPUManager{
|
||||||
|
GpuDataMap: make(map[string]*system.GPUData),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tt.gm.startCollector(tt.command)
|
||||||
|
time.Sleep(50 * time.Millisecond) // Give collector time to run
|
||||||
|
tt.validate(t, tt.gm)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,6 @@ package agent
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -15,7 +14,7 @@ func (a *Agent) initializeNetIoStats() {
|
|||||||
|
|
||||||
// map of network interface names passed in via NICS env var
|
// map of network interface names passed in via NICS env var
|
||||||
var nicsMap map[string]struct{}
|
var nicsMap map[string]struct{}
|
||||||
nics, nicsEnvExists := os.LookupEnv("NICS")
|
nics, nicsEnvExists := GetEnv("NICS")
|
||||||
if nicsEnvExists {
|
if nicsEnvExists {
|
||||||
nicsMap = make(map[string]struct{}, 0)
|
nicsMap = make(map[string]struct{}, 0)
|
||||||
for _, nic := range strings.Split(nics, ",") {
|
for _, nic := range strings.Split(nics, ",") {
|
||||||
|
|||||||
@@ -2,33 +2,96 @@ package agent
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
sshServer "github.com/gliderlabs/ssh"
|
sshServer "github.com/gliderlabs/ssh"
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (a *Agent) startServer(pubKey []byte, addr string) {
|
type ServerOptions struct {
|
||||||
|
Addr string
|
||||||
|
Network string
|
||||||
|
Keys []ssh.PublicKey
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Agent) StartServer(opts ServerOptions) error {
|
||||||
sshServer.Handle(a.handleSession)
|
sshServer.Handle(a.handleSession)
|
||||||
|
|
||||||
slog.Info("Starting SSH server", "address", addr)
|
slog.Info("Starting SSH server", "addr", opts.Addr, "network", opts.Network)
|
||||||
if err := sshServer.ListenAndServe(addr, nil, sshServer.NoPty(),
|
|
||||||
sshServer.PublicKeyAuth(func(ctx sshServer.Context, key sshServer.PublicKey) bool {
|
switch opts.Network {
|
||||||
allowed, _, _, _, _ := sshServer.ParseAuthorizedKey(pubKey)
|
case "unix":
|
||||||
return sshServer.KeysEqual(key, allowed)
|
// remove existing socket file if it exists
|
||||||
}),
|
if err := os.Remove(opts.Addr); err != nil && !os.IsNotExist(err) {
|
||||||
); err != nil {
|
return err
|
||||||
slog.Error("Error starting SSH server", "err", err)
|
}
|
||||||
os.Exit(1)
|
default:
|
||||||
|
// prefix with : if only port was provided
|
||||||
|
if !strings.Contains(opts.Addr, ":") {
|
||||||
|
opts.Addr = ":" + opts.Addr
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Listen on the address
|
||||||
|
ln, err := net.Listen(opts.Network, opts.Addr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer ln.Close()
|
||||||
|
|
||||||
|
// Start SSH server on the listener
|
||||||
|
err = sshServer.Serve(ln, nil, sshServer.NoPty(),
|
||||||
|
sshServer.PublicKeyAuth(func(ctx sshServer.Context, key sshServer.PublicKey) bool {
|
||||||
|
for _, pubKey := range opts.Keys {
|
||||||
|
if sshServer.KeysEqual(key, pubKey) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Agent) handleSession(s sshServer.Session) {
|
func (a *Agent) handleSession(s sshServer.Session) {
|
||||||
|
// slog.Debug("connection", "remoteaddr", s.RemoteAddr(), "user", s.User())
|
||||||
stats := a.gatherStats()
|
stats := a.gatherStats()
|
||||||
if err := json.NewEncoder(s).Encode(stats); err != nil {
|
if err := json.NewEncoder(s).Encode(stats); err != nil {
|
||||||
slog.Error("Error encoding stats", "err", err)
|
slog.Error("Error encoding stats", "err", err, "stats", stats)
|
||||||
s.Exit(1)
|
s.Exit(1)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
s.Exit(0)
|
s.Exit(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ParseKeys parses a string containing SSH public keys in authorized_keys format.
|
||||||
|
// It returns a slice of ssh.PublicKey and an error if any key fails to parse.
|
||||||
|
func ParseKeys(input string) ([]ssh.PublicKey, error) {
|
||||||
|
var parsedKeys []ssh.PublicKey
|
||||||
|
|
||||||
|
for line := range strings.Lines(input) {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
|
||||||
|
// Skip empty lines or comments
|
||||||
|
if len(line) == 0 || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the key
|
||||||
|
parsedKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(line))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse key: %s, error: %w", line, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append the parsed key to the list
|
||||||
|
parsedKeys = append(parsedKeys, parsedKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsedKeys, nil
|
||||||
|
}
|
||||||
|
|||||||
289
beszel/internal/agent/server_test.go
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ed25519"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStartServer(t *testing.T) {
|
||||||
|
// Generate a test key pair
|
||||||
|
pubKey, privKey, err := ed25519.GenerateKey(nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
signer, err := ssh.NewSignerFromKey(privKey)
|
||||||
|
require.NoError(t, err)
|
||||||
|
sshPubKey, err := ssh.NewPublicKey(pubKey)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Generate a different key pair for bad key test
|
||||||
|
badPubKey, badPrivKey, err := ed25519.GenerateKey(nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
badSigner, err := ssh.NewSignerFromKey(badPrivKey)
|
||||||
|
require.NoError(t, err)
|
||||||
|
sshBadPubKey, err := ssh.NewPublicKey(badPubKey)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
socketFile := filepath.Join(t.TempDir(), "beszel-test.sock")
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
config ServerOptions
|
||||||
|
wantErr bool
|
||||||
|
errContains string
|
||||||
|
setup func() error
|
||||||
|
cleanup func() error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "tcp port only",
|
||||||
|
config: ServerOptions{
|
||||||
|
Network: "tcp",
|
||||||
|
Addr: "45987",
|
||||||
|
Keys: []ssh.PublicKey{sshPubKey},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tcp with ipv4",
|
||||||
|
config: ServerOptions{
|
||||||
|
Network: "tcp4",
|
||||||
|
Addr: "127.0.0.1:45988",
|
||||||
|
Keys: []ssh.PublicKey{sshPubKey},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tcp with ipv6",
|
||||||
|
config: ServerOptions{
|
||||||
|
Network: "tcp6",
|
||||||
|
Addr: "[::1]:45989",
|
||||||
|
Keys: []ssh.PublicKey{sshPubKey},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unix socket",
|
||||||
|
config: ServerOptions{
|
||||||
|
Network: "unix",
|
||||||
|
Addr: socketFile,
|
||||||
|
Keys: []ssh.PublicKey{sshPubKey},
|
||||||
|
},
|
||||||
|
setup: func() error {
|
||||||
|
// Create a socket file that should be removed
|
||||||
|
f, err := os.Create(socketFile)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return f.Close()
|
||||||
|
},
|
||||||
|
cleanup: func() error {
|
||||||
|
return os.Remove(socketFile)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bad key should fail",
|
||||||
|
config: ServerOptions{
|
||||||
|
Network: "tcp",
|
||||||
|
Addr: "45987",
|
||||||
|
Keys: []ssh.PublicKey{sshBadPubKey},
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
errContains: "ssh: handshake failed",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "good key still good",
|
||||||
|
config: ServerOptions{
|
||||||
|
Network: "tcp",
|
||||||
|
Addr: "45987",
|
||||||
|
Keys: []ssh.PublicKey{sshPubKey},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if tt.setup != nil {
|
||||||
|
err := tt.setup()
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tt.cleanup != nil {
|
||||||
|
defer tt.cleanup()
|
||||||
|
}
|
||||||
|
|
||||||
|
agent := NewAgent()
|
||||||
|
|
||||||
|
// Start server in a goroutine since it blocks
|
||||||
|
errChan := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
errChan <- agent.StartServer(tt.config)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Add a short delay to allow the server to start
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
// Try to connect to verify server is running
|
||||||
|
var client *ssh.Client
|
||||||
|
var err error
|
||||||
|
|
||||||
|
// Choose the appropriate signer based on the test case
|
||||||
|
testSigner := signer
|
||||||
|
if tt.name == "bad key should fail" {
|
||||||
|
testSigner = badSigner
|
||||||
|
}
|
||||||
|
|
||||||
|
sshClientConfig := &ssh.ClientConfig{
|
||||||
|
User: "a",
|
||||||
|
Auth: []ssh.AuthMethod{
|
||||||
|
ssh.PublicKeys(testSigner),
|
||||||
|
},
|
||||||
|
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||||
|
Timeout: 4 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
switch tt.config.Network {
|
||||||
|
case "unix":
|
||||||
|
client, err = ssh.Dial("unix", tt.config.Addr, sshClientConfig)
|
||||||
|
default:
|
||||||
|
if !strings.Contains(tt.config.Addr, ":") {
|
||||||
|
tt.config.Addr = ":" + tt.config.Addr
|
||||||
|
}
|
||||||
|
client, err = ssh.Dial("tcp", tt.config.Addr, sshClientConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tt.wantErr {
|
||||||
|
assert.Error(t, err)
|
||||||
|
if tt.errContains != "" {
|
||||||
|
assert.Contains(t, err.Error(), tt.errContains)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, client)
|
||||||
|
client.Close()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/////////////////////////////////////////////////////////////////
|
||||||
|
//////////////////// ParseKeys Tests ////////////////////////////
|
||||||
|
/////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
// Helper function to generate a temporary file with content
|
||||||
|
func createTempFile(content string) (string, error) {
|
||||||
|
tmpFile, err := os.CreateTemp("", "ssh_keys_*.txt")
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create temp file: %w", err)
|
||||||
|
}
|
||||||
|
defer tmpFile.Close()
|
||||||
|
|
||||||
|
if _, err := tmpFile.WriteString(content); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to write to temp file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tmpFile.Name(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test case 1: String with a single SSH key
|
||||||
|
func TestParseSingleKeyFromString(t *testing.T) {
|
||||||
|
input := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKCBM91kukN7hbvFKtbpEeo2JXjCcNxXcdBH7V7ADMBo"
|
||||||
|
keys, err := ParseKeys(input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Expected no error, got: %v", err)
|
||||||
|
}
|
||||||
|
if len(keys) != 1 {
|
||||||
|
t.Fatalf("Expected 1 key, got %d keys", len(keys))
|
||||||
|
}
|
||||||
|
if keys[0].Type() != "ssh-ed25519" {
|
||||||
|
t.Fatalf("Expected key type 'ssh-ed25519', got '%s'", keys[0].Type())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test case 2: String with multiple SSH keys
|
||||||
|
func TestParseMultipleKeysFromString(t *testing.T) {
|
||||||
|
input := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKCBM91kukN7hbvFKtbpEeo2JXjCcNxXcdBH7V7ADMBo\nssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJDMtAOQfxDlCxe+A5lVbUY/DHxK1LAF2Z3AV0FYv36D \n #comment\n ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJDMtAOQfxDlCxe+A5lVbUY/DHxK1LAF2Z3AV0FYv36D"
|
||||||
|
keys, err := ParseKeys(input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Expected no error, got: %v", err)
|
||||||
|
}
|
||||||
|
if len(keys) != 3 {
|
||||||
|
t.Fatalf("Expected 3 keys, got %d keys", len(keys))
|
||||||
|
}
|
||||||
|
if keys[0].Type() != "ssh-ed25519" || keys[1].Type() != "ssh-ed25519" || keys[2].Type() != "ssh-ed25519" {
|
||||||
|
t.Fatalf("Unexpected key types: %s, %s, %s", keys[0].Type(), keys[1].Type(), keys[2].Type())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test case 3: File with a single SSH key
|
||||||
|
func TestParseSingleKeyFromFile(t *testing.T) {
|
||||||
|
content := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKCBM91kukN7hbvFKtbpEeo2JXjCcNxXcdBH7V7ADMBo"
|
||||||
|
filePath, err := createTempFile(content)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp file: %v", err)
|
||||||
|
}
|
||||||
|
defer os.Remove(filePath) // Clean up the file after the test
|
||||||
|
|
||||||
|
// Read the file content
|
||||||
|
fileContent, err := os.ReadFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to read temp file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the keys
|
||||||
|
keys, err := ParseKeys(string(fileContent))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Expected no error, got: %v", err)
|
||||||
|
}
|
||||||
|
if len(keys) != 1 {
|
||||||
|
t.Fatalf("Expected 1 key, got %d keys", len(keys))
|
||||||
|
}
|
||||||
|
if keys[0].Type() != "ssh-ed25519" {
|
||||||
|
t.Fatalf("Expected key type 'ssh-ed25519', got '%s'", keys[0].Type())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test case 4: File with multiple SSH keys
|
||||||
|
func TestParseMultipleKeysFromFile(t *testing.T) {
|
||||||
|
content := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKCBM91kukN7hbvFKtbpEeo2JXjCcNxXcdBH7V7ADMBo\nssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJDMtAOQfxDlCxe+A5lVbUY/DHxK1LAF2Z3AV0FYv36D \n #comment\n ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJDMtAOQfxDlCxe+A5lVbUY/DHxK1LAF2Z3AV0FYv36D"
|
||||||
|
filePath, err := createTempFile(content)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp file: %v", err)
|
||||||
|
}
|
||||||
|
// defer os.Remove(filePath) // Clean up the file after the test
|
||||||
|
|
||||||
|
// Read the file content
|
||||||
|
fileContent, err := os.ReadFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to read temp file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the keys
|
||||||
|
keys, err := ParseKeys(string(fileContent))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Expected no error, got: %v", err)
|
||||||
|
}
|
||||||
|
if len(keys) != 3 {
|
||||||
|
t.Fatalf("Expected 3 keys, got %d keys", len(keys))
|
||||||
|
}
|
||||||
|
if keys[0].Type() != "ssh-ed25519" || keys[1].Type() != "ssh-ed25519" || keys[2].Type() != "ssh-ed25519" {
|
||||||
|
t.Fatalf("Unexpected key types: %s, %s, %s", keys[0].Type(), keys[1].Type(), keys[2].Type())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test case 5: Invalid SSH key input
|
||||||
|
func TestParseInvalidKey(t *testing.T) {
|
||||||
|
input := "invalid-key-data"
|
||||||
|
_, err := ParseKeys(input)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("Expected an error for invalid key, got nil")
|
||||||
|
}
|
||||||
|
expectedErrMsg := "failed to parse key"
|
||||||
|
if !strings.Contains(err.Error(), expectedErrMsg) {
|
||||||
|
t.Fatalf("Expected error message to contain '%s', got: %v", expectedErrMsg, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -116,11 +116,17 @@ func (a *Agent) getSystemStats() system.Stats {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
secondsElapsed := time.Since(stats.Time).Seconds()
|
secondsElapsed := time.Since(stats.Time).Seconds()
|
||||||
readPerSecond := float64(d.ReadBytes-stats.TotalRead) / secondsElapsed
|
readPerSecond := bytesToMegabytes(float64(d.ReadBytes-stats.TotalRead) / secondsElapsed)
|
||||||
writePerSecond := float64(d.WriteBytes-stats.TotalWrite) / secondsElapsed
|
writePerSecond := bytesToMegabytes(float64(d.WriteBytes-stats.TotalWrite) / secondsElapsed)
|
||||||
|
// check for invalid values and reset stats if so
|
||||||
|
if readPerSecond < 0 || writePerSecond < 0 || readPerSecond > 50_000 || writePerSecond > 50_000 {
|
||||||
|
slog.Warn("Invalid disk I/O. Resetting.", "name", d.Name, "read", readPerSecond, "write", writePerSecond)
|
||||||
|
a.initializeDiskIoStats(ioCounters)
|
||||||
|
break
|
||||||
|
}
|
||||||
stats.Time = time.Now()
|
stats.Time = time.Now()
|
||||||
stats.DiskReadPs = bytesToMegabytes(readPerSecond)
|
stats.DiskReadPs = readPerSecond
|
||||||
stats.DiskWritePs = bytesToMegabytes(writePerSecond)
|
stats.DiskWritePs = writePerSecond
|
||||||
stats.TotalRead = d.ReadBytes
|
stats.TotalRead = d.ReadBytes
|
||||||
stats.TotalWrite = d.WriteBytes
|
stats.TotalWrite = d.WriteBytes
|
||||||
// if root filesystem, update system stats
|
// if root filesystem, update system stats
|
||||||
@@ -132,6 +138,13 @@ func (a *Agent) getSystemStats() system.Stats {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// network stats
|
// network stats
|
||||||
|
if len(a.netInterfaces) == 0 {
|
||||||
|
// if no network interfaces, initialize again
|
||||||
|
// this is a fix if agent started before network is online (#466)
|
||||||
|
// maybe refactor this in the future to not cache interface names at all so we
|
||||||
|
// don't miss an interface that's been added after agent started in any circumstance
|
||||||
|
a.initializeNetIoStats()
|
||||||
|
}
|
||||||
if netIO, err := psutilNet.IOCounters(true); err == nil {
|
if netIO, err := psutilNet.IOCounters(true); err == nil {
|
||||||
secondsElapsed := time.Since(a.netIoStats.Time).Seconds()
|
secondsElapsed := time.Since(a.netIoStats.Time).Seconds()
|
||||||
a.netIoStats.Time = time.Now()
|
a.netIoStats.Time = time.Now()
|
||||||
@@ -153,7 +166,7 @@ func (a *Agent) getSystemStats() system.Stats {
|
|||||||
networkRecvPs := bytesToMegabytes(recvPerSecond)
|
networkRecvPs := bytesToMegabytes(recvPerSecond)
|
||||||
// add check for issue (#150) where sent is a massive number
|
// add check for issue (#150) where sent is a massive number
|
||||||
if networkSentPs > 10_000 || networkRecvPs > 10_000 {
|
if networkSentPs > 10_000 || networkRecvPs > 10_000 {
|
||||||
slog.Warn("Invalid network stats. Resetting.", "sent", networkSentPs, "recv", networkRecvPs)
|
slog.Warn("Invalid net stats. Resetting.", "sent", networkSentPs, "recv", networkRecvPs)
|
||||||
for _, v := range netIO {
|
for _, v := range netIO {
|
||||||
if _, exists := a.netInterfaces[v.Name]; !exists {
|
if _, exists := a.netInterfaces[v.Name]; !exists {
|
||||||
continue
|
continue
|
||||||
@@ -172,36 +185,29 @@ func (a *Agent) getSystemStats() system.Stats {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// temperatures (skip if sensors whitelist is set to empty string)
|
// temperatures (skip if sensors whitelist is set to empty string)
|
||||||
if a.sensorsWhitelist != nil && len(a.sensorsWhitelist) == 0 {
|
err = a.updateTemperatures(&systemStats)
|
||||||
slog.Debug("Skipping temperature collection")
|
if err != nil {
|
||||||
} else {
|
slog.Error("Error getting temperatures", "err", err)
|
||||||
temps, err := sensors.TemperaturesWithContext(a.sensorsContext)
|
}
|
||||||
if err != nil {
|
|
||||||
slog.Debug("Sensor error", "err", err)
|
// GPU data
|
||||||
}
|
if a.gpuManager != nil {
|
||||||
slog.Debug("Temperature", "sensors", temps)
|
// reset high gpu percent
|
||||||
if len(temps) > 0 {
|
a.systemInfo.GpuPct = 0
|
||||||
systemStats.Temperatures = make(map[string]float64, len(temps))
|
// get current GPU data
|
||||||
for i, sensor := range temps {
|
if gpuData := a.gpuManager.GetCurrentData(); len(gpuData) > 0 {
|
||||||
// skip if temperature is 0
|
systemStats.GPUData = gpuData
|
||||||
if sensor.Temperature <= 0 || sensor.Temperature >= 200 {
|
|
||||||
continue
|
// add temperatures
|
||||||
}
|
if systemStats.Temperatures == nil {
|
||||||
if _, ok := systemStats.Temperatures[sensor.SensorKey]; ok {
|
systemStats.Temperatures = make(map[string]float64, len(gpuData))
|
||||||
// if key already exists, append int to key
|
|
||||||
systemStats.Temperatures[sensor.SensorKey+"_"+strconv.Itoa(i)] = twoDecimals(sensor.Temperature)
|
|
||||||
} else {
|
|
||||||
systemStats.Temperatures[sensor.SensorKey] = twoDecimals(sensor.Temperature)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// remove sensors from systemStats if whitelist exists and sensor is not in whitelist
|
for _, gpu := range gpuData {
|
||||||
// (do this here instead of in initial loop so we have correct keys if int was appended)
|
if gpu.Temperature > 0 {
|
||||||
if a.sensorsWhitelist != nil {
|
systemStats.Temperatures[gpu.Name] = gpu.Temperature
|
||||||
for key := range systemStats.Temperatures {
|
|
||||||
if _, nameInWhitelist := a.sensorsWhitelist[key]; !nameInWhitelist {
|
|
||||||
delete(systemStats.Temperatures, key)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
// update high gpu percent for dashboard
|
||||||
|
a.systemInfo.GpuPct = max(a.systemInfo.GpuPct, gpu.Usage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -217,6 +223,60 @@ func (a *Agent) getSystemStats() system.Stats {
|
|||||||
return systemStats
|
return systemStats
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *Agent) updateTemperatures(systemStats *system.Stats) error {
|
||||||
|
// skip if sensors whitelist is set to empty string
|
||||||
|
if a.sensorsWhitelist != nil && len(a.sensorsWhitelist) == 0 {
|
||||||
|
slog.Debug("Skipping temperature collection")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
primarySensor, primarySensorIsDefined := GetEnv("PRIMARY_SENSOR")
|
||||||
|
|
||||||
|
// reset high temp
|
||||||
|
a.systemInfo.DashboardTemp = 0
|
||||||
|
|
||||||
|
// get sensor data
|
||||||
|
temps, err := sensors.TemperaturesWithContext(a.sensorsContext)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
slog.Debug("Temperature", "sensors", temps)
|
||||||
|
|
||||||
|
// return if no sensors
|
||||||
|
if len(temps) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
systemStats.Temperatures = make(map[string]float64, len(temps))
|
||||||
|
for i, sensor := range temps {
|
||||||
|
// skip if temperature is unreasonable
|
||||||
|
if sensor.Temperature <= 0 || sensor.Temperature >= 200 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sensorName := sensor.SensorKey
|
||||||
|
if _, ok := systemStats.Temperatures[sensorName]; ok {
|
||||||
|
// if key already exists, append int to key
|
||||||
|
sensorName = sensorName + "_" + strconv.Itoa(i)
|
||||||
|
}
|
||||||
|
// skip if not in whitelist
|
||||||
|
if a.sensorsWhitelist != nil {
|
||||||
|
if _, nameInWhitelist := a.sensorsWhitelist[sensorName]; !nameInWhitelist {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// set dashboard temperature
|
||||||
|
if primarySensorIsDefined {
|
||||||
|
if sensorName == primarySensor {
|
||||||
|
a.systemInfo.DashboardTemp = sensor.Temperature
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
a.systemInfo.DashboardTemp = max(a.systemInfo.DashboardTemp, sensor.Temperature)
|
||||||
|
}
|
||||||
|
systemStats.Temperatures[sensorName] = twoDecimals(sensor.Temperature)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Returns the size of the ZFS ARC memory cache in bytes
|
// Returns the size of the ZFS ARC memory cache in bytes
|
||||||
func getARCSize() (uint64, error) {
|
func getARCSize() (uint64, error) {
|
||||||
file, err := os.Open("/proc/spl/kstat/zfs/arcstats")
|
file, err := os.Open("/proc/spl/kstat/zfs/arcstats")
|
||||||
|
|||||||
@@ -2,27 +2,24 @@
|
|||||||
package alerts
|
package alerts
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"beszel/internal/entities/system"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/mail"
|
"net/mail"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/containrrr/shoutrrr"
|
"github.com/containrrr/shoutrrr"
|
||||||
"github.com/goccy/go-json"
|
|
||||||
"github.com/labstack/echo/v5"
|
|
||||||
"github.com/pocketbase/dbx"
|
"github.com/pocketbase/dbx"
|
||||||
"github.com/pocketbase/pocketbase"
|
|
||||||
"github.com/pocketbase/pocketbase/apis"
|
"github.com/pocketbase/pocketbase/apis"
|
||||||
"github.com/pocketbase/pocketbase/models"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
"github.com/pocketbase/pocketbase/tools/mailer"
|
"github.com/pocketbase/pocketbase/tools/mailer"
|
||||||
"github.com/pocketbase/pocketbase/tools/types"
|
|
||||||
"github.com/spf13/cast"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type AlertManager struct {
|
type AlertManager struct {
|
||||||
app *pocketbase.PocketBase
|
app core.App
|
||||||
|
alertQueue chan alertTask
|
||||||
|
stopChan chan struct{}
|
||||||
|
pendingAlerts sync.Map
|
||||||
}
|
}
|
||||||
|
|
||||||
type AlertMessageData struct {
|
type AlertMessageData struct {
|
||||||
@@ -48,8 +45,8 @@ type SystemAlertStats struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type SystemAlertData struct {
|
type SystemAlertData struct {
|
||||||
systemRecord *models.Record
|
systemRecord *core.Record
|
||||||
alertRecord *models.Record
|
alertRecord *core.Record
|
||||||
name string
|
name string
|
||||||
unit string
|
unit string
|
||||||
val float64
|
val float64
|
||||||
@@ -62,350 +59,43 @@ type SystemAlertData struct {
|
|||||||
descriptor string // override descriptor in notification body (for temp sensor, disk partition, etc)
|
descriptor string // override descriptor in notification body (for temp sensor, disk partition, etc)
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAlertManager(app *pocketbase.PocketBase) *AlertManager {
|
// notification services that support title param
|
||||||
return &AlertManager{
|
var supportsTitle = map[string]struct{}{
|
||||||
app: app,
|
"bark": {},
|
||||||
}
|
"discord": {},
|
||||||
|
"gotify": {},
|
||||||
|
"ifttt": {},
|
||||||
|
"join": {},
|
||||||
|
"matrix": {},
|
||||||
|
"ntfy": {},
|
||||||
|
"opsgenie": {},
|
||||||
|
"pushbullet": {},
|
||||||
|
"pushover": {},
|
||||||
|
"slack": {},
|
||||||
|
"teams": {},
|
||||||
|
"telegram": {},
|
||||||
|
"zulip": {},
|
||||||
}
|
}
|
||||||
|
|
||||||
func (am *AlertManager) HandleSystemAlerts(systemRecord *models.Record, systemInfo system.Info, temperatures map[string]float64, extraFs map[string]*system.FsStats) error {
|
// NewAlertManager creates a new AlertManager instance.
|
||||||
// start := time.Now()
|
func NewAlertManager(app core.App) *AlertManager {
|
||||||
// defer func() {
|
am := &AlertManager{
|
||||||
// log.Println("alert stats took", time.Since(start))
|
app: app,
|
||||||
// }()
|
alertQueue: make(chan alertTask),
|
||||||
alertRecords, err := am.app.Dao().FindRecordsByExpr("alerts",
|
stopChan: make(chan struct{}),
|
||||||
dbx.NewExp("system={:system}", dbx.Params{"system": systemRecord.Id}),
|
|
||||||
)
|
|
||||||
if err != nil || len(alertRecords) == 0 {
|
|
||||||
// log.Println("no alerts found for system")
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
go am.startWorker()
|
||||||
var validAlerts []SystemAlertData
|
return am
|
||||||
now := systemRecord.Updated.Time().UTC()
|
|
||||||
oldestTime := now
|
|
||||||
|
|
||||||
for _, alertRecord := range alertRecords {
|
|
||||||
name := alertRecord.GetString("name")
|
|
||||||
var val float64
|
|
||||||
unit := "%"
|
|
||||||
|
|
||||||
switch name {
|
|
||||||
case "CPU":
|
|
||||||
val = systemInfo.Cpu
|
|
||||||
case "Memory":
|
|
||||||
val = systemInfo.MemPct
|
|
||||||
case "Bandwidth":
|
|
||||||
val = systemInfo.Bandwidth
|
|
||||||
unit = " MB/s"
|
|
||||||
case "Disk":
|
|
||||||
maxUsedPct := systemInfo.DiskPct
|
|
||||||
for _, fs := range extraFs {
|
|
||||||
usedPct := fs.DiskUsed / fs.DiskTotal * 100
|
|
||||||
if usedPct > maxUsedPct {
|
|
||||||
maxUsedPct = usedPct
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val = maxUsedPct
|
|
||||||
case "Temperature":
|
|
||||||
if temperatures == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, temp := range temperatures {
|
|
||||||
if temp > val {
|
|
||||||
val = temp
|
|
||||||
}
|
|
||||||
}
|
|
||||||
unit = "°C"
|
|
||||||
}
|
|
||||||
|
|
||||||
triggered := alertRecord.GetBool("triggered")
|
|
||||||
threshold := alertRecord.GetFloat("value")
|
|
||||||
|
|
||||||
// CONTINUE
|
|
||||||
// IF alert is not triggered and curValue is less than threshold
|
|
||||||
// OR alert is triggered and curValue is greater than threshold
|
|
||||||
if (!triggered && val <= threshold) || (triggered && val > threshold) {
|
|
||||||
// log.Printf("Skipping alert %s: val %f | threshold %f | triggered %v\n", name, val, threshold, triggered)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
min := max(1, cast.ToUint8(alertRecord.Get("min")))
|
|
||||||
// add time to alert time to make sure it's slighty after record creation
|
|
||||||
time := now.Add(-time.Duration(min) * time.Minute)
|
|
||||||
if time.Before(oldestTime) {
|
|
||||||
oldestTime = time
|
|
||||||
}
|
|
||||||
|
|
||||||
validAlerts = append(validAlerts, SystemAlertData{
|
|
||||||
systemRecord: systemRecord,
|
|
||||||
alertRecord: alertRecord,
|
|
||||||
name: name,
|
|
||||||
unit: unit,
|
|
||||||
val: val,
|
|
||||||
threshold: threshold,
|
|
||||||
triggered: triggered,
|
|
||||||
time: time,
|
|
||||||
min: min,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
systemStats := []struct {
|
|
||||||
Stats []byte `db:"stats"`
|
|
||||||
Created types.DateTime `db:"created"`
|
|
||||||
}{}
|
|
||||||
|
|
||||||
err = am.app.Dao().DB().
|
|
||||||
Select("stats", "created").
|
|
||||||
From("system_stats").
|
|
||||||
Where(dbx.NewExp(
|
|
||||||
"system={:system} AND type='1m' AND created > {:created}",
|
|
||||||
dbx.Params{
|
|
||||||
"system": systemRecord.Id,
|
|
||||||
// subtract some time to give us a bit of buffer
|
|
||||||
"created": oldestTime.Add(-time.Second * 90),
|
|
||||||
},
|
|
||||||
)).
|
|
||||||
OrderBy("created").
|
|
||||||
All(&systemStats)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// get oldest record creation time from first record in the slice
|
|
||||||
oldestRecordTime := systemStats[0].Created.Time()
|
|
||||||
// log.Println("oldestRecordTime", oldestRecordTime.String())
|
|
||||||
|
|
||||||
// delete from validAlerts if time is older than oldestRecord
|
|
||||||
for i := 0; i < len(validAlerts); i++ {
|
|
||||||
if validAlerts[i].time.Before(oldestRecordTime) {
|
|
||||||
// log.Println("deleting alert - time is older than oldestRecord", validAlerts[i].name, oldestRecordTime, validAlerts[i].time)
|
|
||||||
validAlerts = append(validAlerts[:i], validAlerts[i+1:]...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(validAlerts) == 0 {
|
|
||||||
// log.Println("no valid alerts found")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var stats SystemAlertStats
|
|
||||||
|
|
||||||
// we can skip the latest systemStats record since it's the current value
|
|
||||||
for i := 0; i < len(systemStats); i++ {
|
|
||||||
stat := systemStats[i]
|
|
||||||
// subtract 10 seconds to give a small time buffer
|
|
||||||
systemStatsCreation := stat.Created.Time().Add(-time.Second * 10)
|
|
||||||
if err := json.Unmarshal(stat.Stats, &stats); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
// log.Println("stats", stats)
|
|
||||||
for j := range validAlerts {
|
|
||||||
alert := &validAlerts[j]
|
|
||||||
// reset alert val on first iteration
|
|
||||||
if i == 0 {
|
|
||||||
alert.val = 0
|
|
||||||
}
|
|
||||||
// continue if system_stats is older than alert time range
|
|
||||||
if systemStatsCreation.Before(alert.time) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// add to alert value
|
|
||||||
switch alert.name {
|
|
||||||
case "CPU":
|
|
||||||
alert.val += stats.Cpu
|
|
||||||
case "Memory":
|
|
||||||
alert.val += stats.Mem
|
|
||||||
case "Bandwidth":
|
|
||||||
alert.val += stats.NetSent + stats.NetRecv
|
|
||||||
case "Disk":
|
|
||||||
if alert.mapSums == nil {
|
|
||||||
alert.mapSums = make(map[string]float32, len(extraFs)+1)
|
|
||||||
}
|
|
||||||
// add root disk
|
|
||||||
if _, ok := alert.mapSums["root"]; !ok {
|
|
||||||
alert.mapSums["root"] = 0.0
|
|
||||||
}
|
|
||||||
alert.mapSums["root"] += float32(stats.Disk)
|
|
||||||
// add extra disks
|
|
||||||
for key, fs := range extraFs {
|
|
||||||
if _, ok := alert.mapSums[key]; !ok {
|
|
||||||
alert.mapSums[key] = 0.0
|
|
||||||
}
|
|
||||||
alert.mapSums[key] += float32(fs.DiskUsed / fs.DiskTotal * 100)
|
|
||||||
}
|
|
||||||
case "Temperature":
|
|
||||||
if alert.mapSums == nil {
|
|
||||||
alert.mapSums = make(map[string]float32, len(stats.Temperatures))
|
|
||||||
}
|
|
||||||
for key, temp := range stats.Temperatures {
|
|
||||||
if _, ok := alert.mapSums[key]; !ok {
|
|
||||||
alert.mapSums[key] = float32(0)
|
|
||||||
}
|
|
||||||
alert.mapSums[key] += temp
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
alert.count++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// sum up vals for each alert
|
|
||||||
for _, alert := range validAlerts {
|
|
||||||
switch alert.name {
|
|
||||||
case "Disk":
|
|
||||||
maxPct := float32(0)
|
|
||||||
for key, value := range alert.mapSums {
|
|
||||||
sumPct := float32(value)
|
|
||||||
if sumPct > maxPct {
|
|
||||||
maxPct = sumPct
|
|
||||||
alert.descriptor = fmt.Sprintf("Usage of %s", key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
alert.val = float64(maxPct / float32(alert.count))
|
|
||||||
case "Temperature":
|
|
||||||
maxTemp := float32(0)
|
|
||||||
for key, value := range alert.mapSums {
|
|
||||||
sumTemp := float32(value) / float32(alert.count)
|
|
||||||
if sumTemp > maxTemp {
|
|
||||||
maxTemp = sumTemp
|
|
||||||
alert.descriptor = fmt.Sprintf("Highest sensor %s", key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
alert.val = float64(maxTemp)
|
|
||||||
default:
|
|
||||||
alert.val = alert.val / float64(alert.count)
|
|
||||||
}
|
|
||||||
minCount := float32(alert.min) / 1.2
|
|
||||||
// log.Println("alert", alert.name, "val", alert.val, "threshold", alert.threshold, "triggered", alert.triggered)
|
|
||||||
// log.Printf("%s: val %f | count %d | min-count %f | threshold %f\n", alert.name, alert.val, alert.count, minCount, alert.threshold)
|
|
||||||
// pass through alert if count is greater than or equal to minCount
|
|
||||||
if float32(alert.count) >= minCount {
|
|
||||||
if !alert.triggered && alert.val > alert.threshold {
|
|
||||||
alert.triggered = true
|
|
||||||
go am.sendSystemAlert(alert)
|
|
||||||
} else if alert.triggered && alert.val <= alert.threshold {
|
|
||||||
alert.triggered = false
|
|
||||||
go am.sendSystemAlert(alert)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (am *AlertManager) sendSystemAlert(alert SystemAlertData) {
|
func (am *AlertManager) SendAlert(data AlertMessageData) error {
|
||||||
// log.Printf("Sending alert %s: val %f | count %d | threshold %f\n", alert.name, alert.val, alert.count, alert.threshold)
|
|
||||||
systemName := alert.systemRecord.GetString("name")
|
|
||||||
|
|
||||||
// change Disk to Disk usage
|
|
||||||
if alert.name == "Disk" {
|
|
||||||
alert.name += " usage"
|
|
||||||
}
|
|
||||||
|
|
||||||
// make title alert name lowercase if not CPU
|
|
||||||
titleAlertName := alert.name
|
|
||||||
if titleAlertName != "CPU" {
|
|
||||||
titleAlertName = strings.ToLower(titleAlertName)
|
|
||||||
}
|
|
||||||
|
|
||||||
var subject string
|
|
||||||
if alert.triggered {
|
|
||||||
subject = fmt.Sprintf("%s %s above threshold", systemName, titleAlertName)
|
|
||||||
} else {
|
|
||||||
subject = fmt.Sprintf("%s %s below threshold", systemName, titleAlertName)
|
|
||||||
}
|
|
||||||
minutesLabel := "minute"
|
|
||||||
if alert.min > 1 {
|
|
||||||
minutesLabel += "s"
|
|
||||||
}
|
|
||||||
if alert.descriptor == "" {
|
|
||||||
alert.descriptor = alert.name
|
|
||||||
}
|
|
||||||
body := fmt.Sprintf("%s averaged %.2f%s for the previous %v %s.", alert.descriptor, alert.val, alert.unit, alert.min, minutesLabel)
|
|
||||||
|
|
||||||
alert.alertRecord.Set("triggered", alert.triggered)
|
|
||||||
if err := am.app.Dao().SaveRecord(alert.alertRecord); err != nil {
|
|
||||||
// app.Logger().Error("failed to save alert record", "err", err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// expand the user relation and send the alert
|
|
||||||
if errs := am.app.Dao().ExpandRecord(alert.alertRecord, []string{"user"}, nil); len(errs) > 0 {
|
|
||||||
// app.Logger().Error("failed to expand user relation", "errs", errs)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if user := alert.alertRecord.ExpandedOne("user"); user != nil {
|
|
||||||
am.sendAlert(AlertMessageData{
|
|
||||||
UserID: user.GetId(),
|
|
||||||
Title: subject,
|
|
||||||
Message: body,
|
|
||||||
Link: am.app.Settings().Meta.AppUrl + "/system/" + url.PathEscape(systemName),
|
|
||||||
LinkText: "View " + systemName,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (am *AlertManager) HandleStatusAlerts(newStatus string, oldSystemRecord *models.Record) error {
|
|
||||||
var alertStatus string
|
|
||||||
switch newStatus {
|
|
||||||
case "up":
|
|
||||||
if oldSystemRecord.GetString("status") == "down" {
|
|
||||||
alertStatus = "up"
|
|
||||||
}
|
|
||||||
case "down":
|
|
||||||
if oldSystemRecord.GetString("status") == "up" {
|
|
||||||
alertStatus = "down"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if alertStatus == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
// check if use
|
|
||||||
alertRecords, err := am.app.Dao().FindRecordsByExpr("alerts",
|
|
||||||
dbx.HashExp{
|
|
||||||
"system": oldSystemRecord.GetId(),
|
|
||||||
"name": "Status",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if err != nil || len(alertRecords) == 0 {
|
|
||||||
// log.Println("no alerts found for system")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
for _, alertRecord := range alertRecords {
|
|
||||||
// expand the user relation
|
|
||||||
if errs := am.app.Dao().ExpandRecord(alertRecord, []string{"user"}, nil); len(errs) > 0 {
|
|
||||||
return fmt.Errorf("failed to expand: %v", errs)
|
|
||||||
}
|
|
||||||
user := alertRecord.ExpandedOne("user")
|
|
||||||
if user == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
emoji := "\U0001F534"
|
|
||||||
if alertStatus == "up" {
|
|
||||||
emoji = "\u2705"
|
|
||||||
}
|
|
||||||
// send alert
|
|
||||||
systemName := oldSystemRecord.GetString("name")
|
|
||||||
am.sendAlert(AlertMessageData{
|
|
||||||
UserID: user.GetId(),
|
|
||||||
Title: fmt.Sprintf("Connection to %s is %s %v", systemName, alertStatus, emoji),
|
|
||||||
Message: fmt.Sprintf("Connection to %s is %s", systemName, alertStatus),
|
|
||||||
Link: am.app.Settings().Meta.AppUrl + "/system/" + url.PathEscape(systemName),
|
|
||||||
LinkText: "View " + systemName,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (am *AlertManager) sendAlert(data AlertMessageData) {
|
|
||||||
// get user settings
|
// get user settings
|
||||||
record, err := am.app.Dao().FindFirstRecordByFilter(
|
record, err := am.app.FindFirstRecordByFilter(
|
||||||
"user_settings", "user={:user}",
|
"user_settings", "user={:user}",
|
||||||
dbx.Params{"user": data.UserID},
|
dbx.Params{"user": data.UserID},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
am.app.Logger().Error("Failed to get user settings", "err", err.Error())
|
return err
|
||||||
return
|
|
||||||
}
|
}
|
||||||
// unmarshal user settings
|
// unmarshal user settings
|
||||||
userAlertSettings := UserNotificationSettings{
|
userAlertSettings := UserNotificationSettings{
|
||||||
@@ -423,8 +113,7 @@ func (am *AlertManager) sendAlert(data AlertMessageData) {
|
|||||||
}
|
}
|
||||||
// send alerts via email
|
// send alerts via email
|
||||||
if len(userAlertSettings.Emails) == 0 {
|
if len(userAlertSettings.Emails) == 0 {
|
||||||
// log.Println("No email addresses found")
|
return nil
|
||||||
return
|
|
||||||
}
|
}
|
||||||
addresses := []mail.Address{}
|
addresses := []mail.Address{}
|
||||||
for _, email := range userAlertSettings.Emails {
|
for _, email := range userAlertSettings.Emails {
|
||||||
@@ -439,18 +128,16 @@ func (am *AlertManager) sendAlert(data AlertMessageData) {
|
|||||||
Name: am.app.Settings().Meta.SenderName,
|
Name: am.app.Settings().Meta.SenderName,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if err := am.app.NewMailClient().Send(&message); err != nil {
|
err = am.app.NewMailClient().Send(&message)
|
||||||
am.app.Logger().Error("Failed to send alert: ", "err", err.Error())
|
if err != nil {
|
||||||
} else {
|
return err
|
||||||
am.app.Logger().Info("Sent email alert", "to", message.To, "subj", message.Subject)
|
|
||||||
}
|
}
|
||||||
|
am.app.Logger().Info("Sent email alert", "to", message.To, "subj", message.Subject)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendShoutrrrAlert sends an alert via a Shoutrrr URL
|
// SendShoutrrrAlert sends an alert via a Shoutrrr URL
|
||||||
func (am *AlertManager) SendShoutrrrAlert(notificationUrl, title, message, link, linkText string) error {
|
func (am *AlertManager) SendShoutrrrAlert(notificationUrl, title, message, link, linkText string) error {
|
||||||
// services that support title param
|
|
||||||
supportsTitle := []string{"bark", "discord", "gotify", "ifttt", "join", "matrix", "ntfy", "opsgenie", "pushbullet", "pushover", "slack", "teams", "telegram", "zulip"}
|
|
||||||
|
|
||||||
// Parse the URL
|
// Parse the URL
|
||||||
parsedURL, err := url.Parse(notificationUrl)
|
parsedURL, err := url.Parse(notificationUrl)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -460,7 +147,7 @@ func (am *AlertManager) SendShoutrrrAlert(notificationUrl, title, message, link,
|
|||||||
queryParams := parsedURL.Query()
|
queryParams := parsedURL.Query()
|
||||||
|
|
||||||
// Add title
|
// Add title
|
||||||
if sliceContains(supportsTitle, scheme) {
|
if _, ok := supportsTitle[scheme]; ok {
|
||||||
queryParams.Add("title", title)
|
queryParams.Add("title", title)
|
||||||
} else if scheme == "mattermost" {
|
} else if scheme == "mattermost" {
|
||||||
// use markdown title for mattermost
|
// use markdown title for mattermost
|
||||||
@@ -501,29 +188,19 @@ func (am *AlertManager) SendShoutrrrAlert(notificationUrl, title, message, link,
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Contains checks if a string is present in a slice of strings
|
func (am *AlertManager) SendTestNotification(e *core.RequestEvent) error {
|
||||||
func sliceContains(slice []string, item string) bool {
|
info, _ := e.RequestInfo()
|
||||||
for _, v := range slice {
|
if info.Auth == nil {
|
||||||
if v == item {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (am *AlertManager) SendTestNotification(c echo.Context) error {
|
|
||||||
requestData := apis.RequestInfo(c)
|
|
||||||
if requestData.AuthRecord == nil {
|
|
||||||
return apis.NewForbiddenError("Forbidden", nil)
|
return apis.NewForbiddenError("Forbidden", nil)
|
||||||
}
|
}
|
||||||
url := c.QueryParam("url")
|
url := e.Request.URL.Query().Get("url")
|
||||||
// log.Println("url", url)
|
// log.Println("url", url)
|
||||||
if url == "" {
|
if url == "" {
|
||||||
return c.JSON(200, map[string]string{"err": "URL is required"})
|
return e.JSON(200, map[string]string{"err": "URL is required"})
|
||||||
}
|
}
|
||||||
err := am.SendShoutrrrAlert(url, "Test Alert", "This is a notification from Beszel.", am.app.Settings().Meta.AppUrl, "View Beszel")
|
err := am.SendShoutrrrAlert(url, "Test Alert", "This is a notification from Beszel.", am.app.Settings().Meta.AppURL, "View Beszel")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(200, map[string]string{"err": err.Error()})
|
return e.JSON(200, map[string]string{"err": err.Error()})
|
||||||
}
|
}
|
||||||
return c.JSON(200, map[string]bool{"err": false})
|
return e.JSON(200, map[string]bool{"err": false})
|
||||||
}
|
}
|
||||||
|
|||||||
175
beszel/internal/alerts/alerts_status.go
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
package alerts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pocketbase/dbx"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
type alertTask struct {
|
||||||
|
action string // "schedule" or "cancel"
|
||||||
|
systemName string
|
||||||
|
alertRecord *core.Record
|
||||||
|
delay time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
type alertInfo struct {
|
||||||
|
systemName string
|
||||||
|
alertRecord *core.Record
|
||||||
|
expireTime time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// startWorker is a long-running goroutine that processes alert tasks
|
||||||
|
// every x seconds. It must be running to process status alerts.
|
||||||
|
func (am *AlertManager) startWorker() {
|
||||||
|
// no special reason for 13 seconds
|
||||||
|
tick := time.Tick(13 * time.Second)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-am.stopChan:
|
||||||
|
return
|
||||||
|
case task := <-am.alertQueue:
|
||||||
|
switch task.action {
|
||||||
|
case "schedule":
|
||||||
|
am.pendingAlerts.Store(task.alertRecord.Id, &alertInfo{
|
||||||
|
systemName: task.systemName,
|
||||||
|
alertRecord: task.alertRecord,
|
||||||
|
expireTime: time.Now().Add(task.delay),
|
||||||
|
})
|
||||||
|
case "cancel":
|
||||||
|
am.pendingAlerts.Delete(task.alertRecord.Id)
|
||||||
|
}
|
||||||
|
case <-tick:
|
||||||
|
// Check for expired alerts every tick
|
||||||
|
now := time.Now()
|
||||||
|
for key, value := range am.pendingAlerts.Range {
|
||||||
|
info := value.(*alertInfo)
|
||||||
|
if now.After(info.expireTime) {
|
||||||
|
// Downtime delay has passed, process alert
|
||||||
|
am.sendStatusAlert("down", info.systemName, info.alertRecord)
|
||||||
|
am.pendingAlerts.Delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopWorker shuts down the AlertManager.worker goroutine
|
||||||
|
func (am *AlertManager) StopWorker() {
|
||||||
|
close(am.stopChan)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleStatusAlerts manages the logic when system status changes.
|
||||||
|
func (am *AlertManager) HandleStatusAlerts(newStatus string, oldSystemRecord *core.Record) error {
|
||||||
|
switch newStatus {
|
||||||
|
case "up":
|
||||||
|
if oldSystemRecord.GetString("status") != "down" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
case "down":
|
||||||
|
if oldSystemRecord.GetString("status") != "up" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
alertRecords, err := am.getSystemStatusAlerts(oldSystemRecord.Id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(alertRecords) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
systemName := oldSystemRecord.GetString("name")
|
||||||
|
if newStatus == "down" {
|
||||||
|
am.handleSystemDown(systemName, alertRecords)
|
||||||
|
} else {
|
||||||
|
am.handleSystemUp(systemName, alertRecords)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getSystemStatusAlerts retrieves all "Status" alert records for a given system ID.
|
||||||
|
func (am *AlertManager) getSystemStatusAlerts(systemID string) ([]*core.Record, error) {
|
||||||
|
alertRecords, err := am.app.FindAllRecords("alerts", dbx.HashExp{
|
||||||
|
"system": systemID,
|
||||||
|
"name": "Status",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return alertRecords, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedules delayed "down" alerts for each alert record.
|
||||||
|
func (am *AlertManager) handleSystemDown(systemName string, alertRecords []*core.Record) {
|
||||||
|
for _, alertRecord := range alertRecords {
|
||||||
|
// Continue if alert is already scheduled
|
||||||
|
if _, exists := am.pendingAlerts.Load(alertRecord.Id); exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Schedule by adding to queue
|
||||||
|
min := max(1, alertRecord.GetInt("min"))
|
||||||
|
am.alertQueue <- alertTask{
|
||||||
|
action: "schedule",
|
||||||
|
systemName: systemName,
|
||||||
|
alertRecord: alertRecord,
|
||||||
|
delay: time.Duration(min) * time.Minute,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSystemUp manages the logic when a system status changes to "up".
|
||||||
|
// It cancels any pending alerts and sends "up" alerts.
|
||||||
|
func (am *AlertManager) handleSystemUp(systemName string, alertRecords []*core.Record) {
|
||||||
|
for _, alertRecord := range alertRecords {
|
||||||
|
alertRecordID := alertRecord.Id
|
||||||
|
// If alert exists for record, delete and continue (down alert not sent)
|
||||||
|
if _, exists := am.pendingAlerts.Load(alertRecordID); exists {
|
||||||
|
am.alertQueue <- alertTask{
|
||||||
|
action: "cancel",
|
||||||
|
alertRecord: alertRecord,
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// No alert scheduled for this record, send "up" alert
|
||||||
|
if err := am.sendStatusAlert("up", systemName, alertRecord); err != nil {
|
||||||
|
am.app.Logger().Error("Failed to send alert", "err", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendStatusAlert sends a status alert ("up" or "down") to the users associated with the alert records.
|
||||||
|
func (am *AlertManager) sendStatusAlert(alertStatus string, systemName string, alertRecord *core.Record) error {
|
||||||
|
var emoji string
|
||||||
|
if alertStatus == "up" {
|
||||||
|
emoji = "\u2705" // Green checkmark emoji
|
||||||
|
} else {
|
||||||
|
emoji = "\U0001F534" // Red alert emoji
|
||||||
|
}
|
||||||
|
|
||||||
|
title := fmt.Sprintf("Connection to %s is %s %v", systemName, alertStatus, emoji)
|
||||||
|
message := strings.TrimSuffix(title, emoji)
|
||||||
|
|
||||||
|
if errs := am.app.ExpandRecord(alertRecord, []string{"user"}, nil); len(errs) > 0 {
|
||||||
|
return errs["user"]
|
||||||
|
}
|
||||||
|
user := alertRecord.ExpandedOne("user")
|
||||||
|
if user == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return am.SendAlert(AlertMessageData{
|
||||||
|
UserID: user.Id,
|
||||||
|
Title: title,
|
||||||
|
Message: message,
|
||||||
|
Link: am.app.Settings().Meta.AppURL + "/system/" + url.PathEscape(systemName),
|
||||||
|
LinkText: "View " + systemName,
|
||||||
|
})
|
||||||
|
}
|
||||||
288
beszel/internal/alerts/alerts_system.go
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
package alerts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"beszel/internal/entities/system"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/goccy/go-json"
|
||||||
|
"github.com/pocketbase/dbx"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/types"
|
||||||
|
"github.com/spf13/cast"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, systemInfo system.Info, temperatures map[string]float64, extraFs map[string]*system.FsStats) error {
|
||||||
|
alertRecords, err := am.app.FindAllRecords("alerts",
|
||||||
|
dbx.NewExp("system={:system}", dbx.Params{"system": systemRecord.Id}),
|
||||||
|
)
|
||||||
|
if err != nil || len(alertRecords) == 0 {
|
||||||
|
// log.Println("no alerts found for system")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var validAlerts []SystemAlertData
|
||||||
|
now := systemRecord.GetDateTime("updated").Time().UTC()
|
||||||
|
oldestTime := now
|
||||||
|
|
||||||
|
for _, alertRecord := range alertRecords {
|
||||||
|
name := alertRecord.GetString("name")
|
||||||
|
var val float64
|
||||||
|
unit := "%"
|
||||||
|
|
||||||
|
switch name {
|
||||||
|
case "CPU":
|
||||||
|
val = systemInfo.Cpu
|
||||||
|
case "Memory":
|
||||||
|
val = systemInfo.MemPct
|
||||||
|
case "Bandwidth":
|
||||||
|
val = systemInfo.Bandwidth
|
||||||
|
unit = " MB/s"
|
||||||
|
case "Disk":
|
||||||
|
maxUsedPct := systemInfo.DiskPct
|
||||||
|
for _, fs := range extraFs {
|
||||||
|
usedPct := fs.DiskUsed / fs.DiskTotal * 100
|
||||||
|
if usedPct > maxUsedPct {
|
||||||
|
maxUsedPct = usedPct
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val = maxUsedPct
|
||||||
|
case "Temperature":
|
||||||
|
if temperatures == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, temp := range temperatures {
|
||||||
|
if temp > val {
|
||||||
|
val = temp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unit = "°C"
|
||||||
|
}
|
||||||
|
|
||||||
|
triggered := alertRecord.GetBool("triggered")
|
||||||
|
threshold := alertRecord.GetFloat("value")
|
||||||
|
|
||||||
|
// CONTINUE
|
||||||
|
// IF alert is not triggered and curValue is less than threshold
|
||||||
|
// OR alert is triggered and curValue is greater than threshold
|
||||||
|
if (!triggered && val <= threshold) || (triggered && val > threshold) {
|
||||||
|
// log.Printf("Skipping alert %s: val %f | threshold %f | triggered %v\n", name, val, threshold, triggered)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
min := max(1, cast.ToUint8(alertRecord.Get("min")))
|
||||||
|
// add time to alert time to make sure it's slighty after record creation
|
||||||
|
time := now.Add(-time.Duration(min) * time.Minute)
|
||||||
|
if time.Before(oldestTime) {
|
||||||
|
oldestTime = time
|
||||||
|
}
|
||||||
|
|
||||||
|
validAlerts = append(validAlerts, SystemAlertData{
|
||||||
|
systemRecord: systemRecord,
|
||||||
|
alertRecord: alertRecord,
|
||||||
|
name: name,
|
||||||
|
unit: unit,
|
||||||
|
val: val,
|
||||||
|
threshold: threshold,
|
||||||
|
triggered: triggered,
|
||||||
|
time: time,
|
||||||
|
min: min,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
systemStats := []struct {
|
||||||
|
Stats []byte `db:"stats"`
|
||||||
|
Created types.DateTime `db:"created"`
|
||||||
|
}{}
|
||||||
|
|
||||||
|
err = am.app.DB().
|
||||||
|
Select("stats", "created").
|
||||||
|
From("system_stats").
|
||||||
|
Where(dbx.NewExp(
|
||||||
|
"system={:system} AND type='1m' AND created > {:created}",
|
||||||
|
dbx.Params{
|
||||||
|
"system": systemRecord.Id,
|
||||||
|
// subtract some time to give us a bit of buffer
|
||||||
|
"created": oldestTime.Add(-time.Second * 90),
|
||||||
|
},
|
||||||
|
)).
|
||||||
|
OrderBy("created").
|
||||||
|
All(&systemStats)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// get oldest record creation time from first record in the slice
|
||||||
|
oldestRecordTime := systemStats[0].Created.Time()
|
||||||
|
// log.Println("oldestRecordTime", oldestRecordTime.String())
|
||||||
|
|
||||||
|
// delete from validAlerts if time is older than oldestRecord
|
||||||
|
for i := range validAlerts {
|
||||||
|
if validAlerts[i].time.Before(oldestRecordTime) {
|
||||||
|
// log.Println("deleting alert - time is older than oldestRecord", validAlerts[i].name, oldestRecordTime, validAlerts[i].time)
|
||||||
|
validAlerts = slices.Delete(validAlerts, i, i+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(validAlerts) == 0 {
|
||||||
|
// log.Println("no valid alerts found")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var stats SystemAlertStats
|
||||||
|
|
||||||
|
// we can skip the latest systemStats record since it's the current value
|
||||||
|
for i := range systemStats {
|
||||||
|
stat := systemStats[i]
|
||||||
|
// subtract 10 seconds to give a small time buffer
|
||||||
|
systemStatsCreation := stat.Created.Time().Add(-time.Second * 10)
|
||||||
|
if err := json.Unmarshal(stat.Stats, &stats); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// log.Println("stats", stats)
|
||||||
|
for j := range validAlerts {
|
||||||
|
alert := &validAlerts[j]
|
||||||
|
// reset alert val on first iteration
|
||||||
|
if i == 0 {
|
||||||
|
alert.val = 0
|
||||||
|
}
|
||||||
|
// continue if system_stats is older than alert time range
|
||||||
|
if systemStatsCreation.Before(alert.time) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// add to alert value
|
||||||
|
switch alert.name {
|
||||||
|
case "CPU":
|
||||||
|
alert.val += stats.Cpu
|
||||||
|
case "Memory":
|
||||||
|
alert.val += stats.Mem
|
||||||
|
case "Bandwidth":
|
||||||
|
alert.val += stats.NetSent + stats.NetRecv
|
||||||
|
case "Disk":
|
||||||
|
if alert.mapSums == nil {
|
||||||
|
alert.mapSums = make(map[string]float32, len(extraFs)+1)
|
||||||
|
}
|
||||||
|
// add root disk
|
||||||
|
if _, ok := alert.mapSums["root"]; !ok {
|
||||||
|
alert.mapSums["root"] = 0.0
|
||||||
|
}
|
||||||
|
alert.mapSums["root"] += float32(stats.Disk)
|
||||||
|
// add extra disks
|
||||||
|
for key, fs := range extraFs {
|
||||||
|
if _, ok := alert.mapSums[key]; !ok {
|
||||||
|
alert.mapSums[key] = 0.0
|
||||||
|
}
|
||||||
|
alert.mapSums[key] += float32(fs.DiskUsed / fs.DiskTotal * 100)
|
||||||
|
}
|
||||||
|
case "Temperature":
|
||||||
|
if alert.mapSums == nil {
|
||||||
|
alert.mapSums = make(map[string]float32, len(stats.Temperatures))
|
||||||
|
}
|
||||||
|
for key, temp := range stats.Temperatures {
|
||||||
|
if _, ok := alert.mapSums[key]; !ok {
|
||||||
|
alert.mapSums[key] = float32(0)
|
||||||
|
}
|
||||||
|
alert.mapSums[key] += temp
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
alert.count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// sum up vals for each alert
|
||||||
|
for _, alert := range validAlerts {
|
||||||
|
switch alert.name {
|
||||||
|
case "Disk":
|
||||||
|
maxPct := float32(0)
|
||||||
|
for key, value := range alert.mapSums {
|
||||||
|
sumPct := float32(value)
|
||||||
|
if sumPct > maxPct {
|
||||||
|
maxPct = sumPct
|
||||||
|
alert.descriptor = fmt.Sprintf("Usage of %s", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
alert.val = float64(maxPct / float32(alert.count))
|
||||||
|
case "Temperature":
|
||||||
|
maxTemp := float32(0)
|
||||||
|
for key, value := range alert.mapSums {
|
||||||
|
sumTemp := float32(value) / float32(alert.count)
|
||||||
|
if sumTemp > maxTemp {
|
||||||
|
maxTemp = sumTemp
|
||||||
|
alert.descriptor = fmt.Sprintf("Highest sensor %s", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
alert.val = float64(maxTemp)
|
||||||
|
default:
|
||||||
|
alert.val = alert.val / float64(alert.count)
|
||||||
|
}
|
||||||
|
minCount := float32(alert.min) / 1.2
|
||||||
|
// log.Println("alert", alert.name, "val", alert.val, "threshold", alert.threshold, "triggered", alert.triggered)
|
||||||
|
// log.Printf("%s: val %f | count %d | min-count %f | threshold %f\n", alert.name, alert.val, alert.count, minCount, alert.threshold)
|
||||||
|
// pass through alert if count is greater than or equal to minCount
|
||||||
|
if float32(alert.count) >= minCount {
|
||||||
|
if !alert.triggered && alert.val > alert.threshold {
|
||||||
|
alert.triggered = true
|
||||||
|
go am.sendSystemAlert(alert)
|
||||||
|
} else if alert.triggered && alert.val <= alert.threshold {
|
||||||
|
alert.triggered = false
|
||||||
|
go am.sendSystemAlert(alert)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (am *AlertManager) sendSystemAlert(alert SystemAlertData) {
|
||||||
|
// log.Printf("Sending alert %s: val %f | count %d | threshold %f\n", alert.name, alert.val, alert.count, alert.threshold)
|
||||||
|
systemName := alert.systemRecord.GetString("name")
|
||||||
|
|
||||||
|
// change Disk to Disk usage
|
||||||
|
if alert.name == "Disk" {
|
||||||
|
alert.name += " usage"
|
||||||
|
}
|
||||||
|
|
||||||
|
// make title alert name lowercase if not CPU
|
||||||
|
titleAlertName := alert.name
|
||||||
|
if titleAlertName != "CPU" {
|
||||||
|
titleAlertName = strings.ToLower(titleAlertName)
|
||||||
|
}
|
||||||
|
|
||||||
|
var subject string
|
||||||
|
if alert.triggered {
|
||||||
|
subject = fmt.Sprintf("%s %s above threshold", systemName, titleAlertName)
|
||||||
|
} else {
|
||||||
|
subject = fmt.Sprintf("%s %s below threshold", systemName, titleAlertName)
|
||||||
|
}
|
||||||
|
minutesLabel := "minute"
|
||||||
|
if alert.min > 1 {
|
||||||
|
minutesLabel += "s"
|
||||||
|
}
|
||||||
|
if alert.descriptor == "" {
|
||||||
|
alert.descriptor = alert.name
|
||||||
|
}
|
||||||
|
body := fmt.Sprintf("%s averaged %.2f%s for the previous %v %s.", alert.descriptor, alert.val, alert.unit, alert.min, minutesLabel)
|
||||||
|
|
||||||
|
alert.alertRecord.Set("triggered", alert.triggered)
|
||||||
|
if err := am.app.Save(alert.alertRecord); err != nil {
|
||||||
|
// app.Logger().Error("failed to save alert record", "err", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// expand the user relation and send the alert
|
||||||
|
if errs := am.app.ExpandRecord(alert.alertRecord, []string{"user"}, nil); len(errs) > 0 {
|
||||||
|
// app.Logger().Error("failed to expand user relation", "errs", errs)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if user := alert.alertRecord.ExpandedOne("user"); user != nil {
|
||||||
|
am.SendAlert(AlertMessageData{
|
||||||
|
UserID: user.Id,
|
||||||
|
Title: subject,
|
||||||
|
Message: body,
|
||||||
|
Link: am.app.Settings().Meta.AppURL + "/system/" + url.PathEscape(systemName),
|
||||||
|
LinkText: "View " + systemName,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,17 @@ type Stats struct {
|
|||||||
MaxNetworkRecv float64 `json:"nrm,omitempty"`
|
MaxNetworkRecv float64 `json:"nrm,omitempty"`
|
||||||
Temperatures map[string]float64 `json:"t,omitempty"`
|
Temperatures map[string]float64 `json:"t,omitempty"`
|
||||||
ExtraFs map[string]*FsStats `json:"efs,omitempty"`
|
ExtraFs map[string]*FsStats `json:"efs,omitempty"`
|
||||||
|
GPUData map[string]GPUData `json:"g,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GPUData struct {
|
||||||
|
Name string `json:"n"`
|
||||||
|
Temperature float64 `json:"-"`
|
||||||
|
MemoryUsed float64 `json:"mu,omitempty"`
|
||||||
|
MemoryTotal float64 `json:"mt,omitempty"`
|
||||||
|
Usage float64 `json:"u"`
|
||||||
|
Power float64 `json:"p,omitempty"`
|
||||||
|
Count float64 `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type FsStats struct {
|
type FsStats struct {
|
||||||
@@ -63,6 +74,9 @@ type Info struct {
|
|||||||
DiskPct float64 `json:"dp"`
|
DiskPct float64 `json:"dp"`
|
||||||
Bandwidth float64 `json:"b"`
|
Bandwidth float64 `json:"b"`
|
||||||
AgentVersion string `json:"v"`
|
AgentVersion string `json:"v"`
|
||||||
|
Podman bool `json:"p,omitempty"`
|
||||||
|
GpuPct float64 `json:"g,omitempty"`
|
||||||
|
DashboardTemp float64 `json:"dt,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Final data structure to return to the hub
|
// Final data structure to return to the hub
|
||||||
|
|||||||
@@ -8,10 +8,9 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/labstack/echo/v5"
|
|
||||||
"github.com/pocketbase/dbx"
|
"github.com/pocketbase/dbx"
|
||||||
"github.com/pocketbase/pocketbase/apis"
|
"github.com/pocketbase/pocketbase/apis"
|
||||||
"github.com/pocketbase/pocketbase/models"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
"github.com/spf13/cast"
|
"github.com/spf13/cast"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
@@ -29,7 +28,7 @@ type SystemConfig struct {
|
|||||||
|
|
||||||
// Syncs systems with the config.yml file
|
// Syncs systems with the config.yml file
|
||||||
func (h *Hub) syncSystemsWithConfig() error {
|
func (h *Hub) syncSystemsWithConfig() error {
|
||||||
configPath := filepath.Join(h.app.DataDir(), "config.yml")
|
configPath := filepath.Join(h.DataDir(), "config.yml")
|
||||||
configData, err := os.ReadFile(configPath)
|
configData, err := os.ReadFile(configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -46,11 +45,11 @@ func (h *Hub) syncSystemsWithConfig() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var firstUser *models.Record
|
var firstUser *core.Record
|
||||||
|
|
||||||
// Create a map of email to user ID
|
// Create a map of email to user ID
|
||||||
userEmailToID := make(map[string]string)
|
userEmailToID := make(map[string]string)
|
||||||
users, err := h.app.Dao().FindRecordsByExpr("users", dbx.NewExp("id != ''"))
|
users, err := h.FindAllRecords("users", dbx.NewExp("id != ''"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -85,13 +84,13 @@ func (h *Hub) syncSystemsWithConfig() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get existing systems
|
// Get existing systems
|
||||||
existingSystems, err := h.app.Dao().FindRecordsByExpr("systems", dbx.NewExp("id != ''"))
|
existingSystems, err := h.FindAllRecords("systems", dbx.NewExp("id != ''"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a map of existing systems for easy lookup
|
// Create a map of existing systems for easy lookup
|
||||||
existingSystemsMap := make(map[string]*models.Record)
|
existingSystemsMap := make(map[string]*core.Record)
|
||||||
for _, system := range existingSystems {
|
for _, system := range existingSystems {
|
||||||
key := system.GetString("host") + ":" + system.GetString("port")
|
key := system.GetString("host") + ":" + system.GetString("port")
|
||||||
existingSystemsMap[key] = system
|
existingSystemsMap[key] = system
|
||||||
@@ -105,24 +104,24 @@ func (h *Hub) syncSystemsWithConfig() error {
|
|||||||
existingSystem.Set("name", sysConfig.Name)
|
existingSystem.Set("name", sysConfig.Name)
|
||||||
existingSystem.Set("users", sysConfig.Users)
|
existingSystem.Set("users", sysConfig.Users)
|
||||||
existingSystem.Set("port", sysConfig.Port)
|
existingSystem.Set("port", sysConfig.Port)
|
||||||
if err := h.app.Dao().SaveRecord(existingSystem); err != nil {
|
if err := h.Save(existingSystem); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
delete(existingSystemsMap, key)
|
delete(existingSystemsMap, key)
|
||||||
} else {
|
} else {
|
||||||
// Create new system
|
// Create new system
|
||||||
systemsCollection, err := h.app.Dao().FindCollectionByNameOrId("systems")
|
systemsCollection, err := h.FindCollectionByNameOrId("systems")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to find systems collection: %v", err)
|
return fmt.Errorf("failed to find systems collection: %v", err)
|
||||||
}
|
}
|
||||||
newSystem := models.NewRecord(systemsCollection)
|
newSystem := core.NewRecord(systemsCollection)
|
||||||
newSystem.Set("name", sysConfig.Name)
|
newSystem.Set("name", sysConfig.Name)
|
||||||
newSystem.Set("host", sysConfig.Host)
|
newSystem.Set("host", sysConfig.Host)
|
||||||
newSystem.Set("port", sysConfig.Port)
|
newSystem.Set("port", sysConfig.Port)
|
||||||
newSystem.Set("users", sysConfig.Users)
|
newSystem.Set("users", sysConfig.Users)
|
||||||
newSystem.Set("info", system.Info{})
|
newSystem.Set("info", system.Info{})
|
||||||
newSystem.Set("status", "pending")
|
newSystem.Set("status", "pending")
|
||||||
if err := h.app.Dao().SaveRecord(newSystem); err != nil {
|
if err := h.Save(newSystem); err != nil {
|
||||||
return fmt.Errorf("failed to create new system: %v", err)
|
return fmt.Errorf("failed to create new system: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -130,7 +129,7 @@ func (h *Hub) syncSystemsWithConfig() error {
|
|||||||
|
|
||||||
// Delete systems not in config
|
// Delete systems not in config
|
||||||
for _, system := range existingSystemsMap {
|
for _, system := range existingSystemsMap {
|
||||||
if err := h.app.Dao().DeleteRecord(system); err != nil {
|
if err := h.Delete(system); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -142,7 +141,7 @@ func (h *Hub) syncSystemsWithConfig() error {
|
|||||||
// Generates content for the config.yml file as a YAML string
|
// Generates content for the config.yml file as a YAML string
|
||||||
func (h *Hub) generateConfigYAML() (string, error) {
|
func (h *Hub) generateConfigYAML() (string, error) {
|
||||||
// Fetch all systems from the database
|
// Fetch all systems from the database
|
||||||
systems, err := h.app.Dao().FindRecordsByFilter("systems", "id != ''", "name", -1, 0)
|
systems, err := h.FindRecordsByFilter("systems", "id != ''", "name", -1, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -195,7 +194,7 @@ func (h *Hub) generateConfigYAML() (string, error) {
|
|||||||
|
|
||||||
// New helper function to get a map of user IDs to emails
|
// New helper function to get a map of user IDs to emails
|
||||||
func (h *Hub) getUserEmailMap(userIDs []string) (map[string]string, error) {
|
func (h *Hub) getUserEmailMap(userIDs []string) (map[string]string, error) {
|
||||||
users, err := h.app.Dao().FindRecordsByIds("users", userIDs)
|
users, err := h.FindRecordsByIds("users", userIDs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -209,14 +208,14 @@ func (h *Hub) getUserEmailMap(userIDs []string) (map[string]string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Returns the current config.yml file as a JSON object
|
// Returns the current config.yml file as a JSON object
|
||||||
func (h *Hub) getYamlConfig(c echo.Context) error {
|
func (h *Hub) getYamlConfig(e *core.RequestEvent) error {
|
||||||
requestData := apis.RequestInfo(c)
|
info, _ := e.RequestInfo()
|
||||||
if requestData.AuthRecord == nil || requestData.AuthRecord.GetString("role") != "admin" {
|
if info.Auth == nil || info.Auth.GetString("role") != "admin" {
|
||||||
return apis.NewForbiddenError("Forbidden", nil)
|
return apis.NewForbiddenError("Forbidden", nil)
|
||||||
}
|
}
|
||||||
configContent, err := h.generateConfigYAML()
|
configContent, err := h.generateConfigYAML()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return c.JSON(200, map[string]string{"config": configContent})
|
return e.JSON(200, map[string]string{"config": configContent})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,193 +8,248 @@ import (
|
|||||||
"beszel/internal/records"
|
"beszel/internal/records"
|
||||||
"beszel/internal/users"
|
"beszel/internal/users"
|
||||||
"beszel/site"
|
"beszel/site"
|
||||||
|
|
||||||
"context"
|
"context"
|
||||||
"crypto/ed25519"
|
"crypto/ed25519"
|
||||||
"encoding/pem"
|
"encoding/pem"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httputil"
|
"net/http/httputil"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/goccy/go-json"
|
"github.com/goccy/go-json"
|
||||||
"github.com/labstack/echo/v5"
|
|
||||||
"github.com/pocketbase/pocketbase"
|
"github.com/pocketbase/pocketbase"
|
||||||
"github.com/pocketbase/pocketbase/apis"
|
"github.com/pocketbase/pocketbase/apis"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
"github.com/pocketbase/pocketbase/models"
|
|
||||||
"github.com/pocketbase/pocketbase/plugins/migratecmd"
|
"github.com/pocketbase/pocketbase/plugins/migratecmd"
|
||||||
"github.com/pocketbase/pocketbase/tools/cron"
|
"github.com/spf13/cobra"
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Hub struct {
|
type Hub struct {
|
||||||
app *pocketbase.PocketBase
|
*pocketbase.PocketBase
|
||||||
connectionLock *sync.Mutex
|
sshClientConfig *ssh.ClientConfig
|
||||||
systemConnections map[string]*ssh.Client
|
pubKey string
|
||||||
sshClientConfig *ssh.ClientConfig
|
am *alerts.AlertManager
|
||||||
pubKey string
|
um *users.UserManager
|
||||||
am *alerts.AlertManager
|
rm *records.RecordManager
|
||||||
um *users.UserManager
|
systemStats *core.Collection
|
||||||
rm *records.RecordManager
|
containerStats *core.Collection
|
||||||
systemStats *models.Collection
|
appURL string
|
||||||
containerStats *models.Collection
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHub(app *pocketbase.PocketBase) *Hub {
|
// NewHub creates a new Hub instance with default configuration
|
||||||
return &Hub{
|
func NewHub() *Hub {
|
||||||
app: app,
|
var hub Hub
|
||||||
connectionLock: &sync.Mutex{},
|
hub.PocketBase = pocketbase.NewWithConfig(pocketbase.Config{
|
||||||
systemConnections: make(map[string]*ssh.Client),
|
DefaultDataDir: beszel.AppName + "_data",
|
||||||
am: alerts.NewAlertManager(app),
|
})
|
||||||
um: users.NewUserManager(app),
|
|
||||||
rm: records.NewRecordManager(app),
|
hub.RootCmd.Version = beszel.Version
|
||||||
|
hub.RootCmd.Use = beszel.AppName
|
||||||
|
hub.RootCmd.Short = ""
|
||||||
|
// add update command
|
||||||
|
hub.RootCmd.AddCommand(&cobra.Command{
|
||||||
|
Use: "update",
|
||||||
|
Short: "Update " + beszel.AppName + " to the latest version",
|
||||||
|
Run: Update,
|
||||||
|
})
|
||||||
|
|
||||||
|
hub.am = alerts.NewAlertManager(hub)
|
||||||
|
hub.um = users.NewUserManager(hub)
|
||||||
|
hub.rm = records.NewRecordManager(hub)
|
||||||
|
hub.appURL, _ = GetEnv("APP_URL")
|
||||||
|
return &hub
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetEnv retrieves an environment variable with a "BESZEL_HUB_" prefix, or falls back to the unprefixed key.
|
||||||
|
func GetEnv(key string) (value string, exists bool) {
|
||||||
|
if value, exists = os.LookupEnv("BESZEL_HUB_" + key); exists {
|
||||||
|
return value, exists
|
||||||
}
|
}
|
||||||
|
// Fallback to the old unprefixed key
|
||||||
|
return os.LookupEnv(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Hub) Run() {
|
func (h *Hub) Run() {
|
||||||
// loosely check if it was executed using "go run"
|
isDev := os.Getenv("ENV") == "dev"
|
||||||
isGoRun := strings.HasPrefix(os.Args[0], os.TempDir())
|
|
||||||
|
|
||||||
// enable auto creation of migration files when making collection changes in the Admin UI
|
// enable auto creation of migration files when making collection changes in the Admin UI
|
||||||
migratecmd.MustRegister(h.app, h.app.RootCmd, migratecmd.Config{
|
migratecmd.MustRegister(h, h.RootCmd, migratecmd.Config{
|
||||||
// (the isGoRun check is to enable it only during development)
|
// (the isDev check is to enable it only during development)
|
||||||
Automigrate: isGoRun,
|
Automigrate: isDev,
|
||||||
Dir: "../../migrations",
|
Dir: "../../migrations",
|
||||||
})
|
})
|
||||||
|
|
||||||
// initial setup
|
// initial setup
|
||||||
h.app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
|
h.OnServe().BindFunc(func(se *core.ServeEvent) error {
|
||||||
// create ssh client config
|
// create ssh client config
|
||||||
err := h.createSSHClientConfig()
|
err := h.createSSHClientConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
// set general settings
|
||||||
|
settings := h.Settings()
|
||||||
|
// batch requests (for global alerts)
|
||||||
|
settings.Batch.Enabled = true
|
||||||
|
// set URL if BASE_URL env is set
|
||||||
|
if h.appURL != "" {
|
||||||
|
settings.Meta.AppURL = h.appURL
|
||||||
|
}
|
||||||
// set auth settings
|
// set auth settings
|
||||||
usersCollection, err := h.app.Dao().FindCollectionByNameOrId("users")
|
usersCollection, err := h.FindCollectionByNameOrId("users")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
usersAuthOptions := usersCollection.AuthOptions()
|
// disable email auth if DISABLE_PASSWORD_AUTH env var is set
|
||||||
usersAuthOptions.AllowUsernameAuth = false
|
disablePasswordAuth, _ := GetEnv("DISABLE_PASSWORD_AUTH")
|
||||||
if os.Getenv("DISABLE_PASSWORD_AUTH") == "true" {
|
usersCollection.PasswordAuth.Enabled = disablePasswordAuth != "true"
|
||||||
usersAuthOptions.AllowEmailAuth = false
|
usersCollection.PasswordAuth.IdentityFields = []string{"email"}
|
||||||
} else {
|
// disable oauth if no providers are configured (todo: remove this in post 0.9.0 release)
|
||||||
usersAuthOptions.AllowEmailAuth = true
|
if usersCollection.OAuth2.Enabled {
|
||||||
|
usersCollection.OAuth2.Enabled = len(usersCollection.OAuth2.Providers) > 0
|
||||||
}
|
}
|
||||||
usersCollection.SetOptions(usersAuthOptions)
|
// allow oauth user creation if USER_CREATION is set
|
||||||
if err := h.app.Dao().SaveCollection(usersCollection); err != nil {
|
if userCreation, _ := GetEnv("USER_CREATION"); userCreation == "true" {
|
||||||
|
cr := "@request.context = 'oauth2'"
|
||||||
|
usersCollection.CreateRule = &cr
|
||||||
|
} else {
|
||||||
|
usersCollection.CreateRule = nil
|
||||||
|
}
|
||||||
|
if err := h.Save(usersCollection); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// sync systems with config
|
// sync systems with config
|
||||||
return h.syncSystemsWithConfig()
|
h.syncSystemsWithConfig()
|
||||||
|
return se.Next()
|
||||||
})
|
})
|
||||||
|
|
||||||
// serve web ui
|
// serve web ui
|
||||||
h.app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
|
h.OnServe().BindFunc(func(se *core.ServeEvent) error {
|
||||||
switch isGoRun {
|
switch isDev {
|
||||||
case true:
|
case true:
|
||||||
proxy := httputil.NewSingleHostReverseProxy(&url.URL{
|
proxy := httputil.NewSingleHostReverseProxy(&url.URL{
|
||||||
Scheme: "http",
|
Scheme: "http",
|
||||||
Host: "localhost:5173",
|
Host: "localhost:5173",
|
||||||
})
|
})
|
||||||
e.Router.Any("/*", echo.WrapHandler(proxy))
|
se.Router.GET("/{path...}", func(e *core.RequestEvent) error {
|
||||||
default:
|
proxy.ServeHTTP(e.Response, e.Request)
|
||||||
csp, cspExists := os.LookupEnv("CSP")
|
return nil
|
||||||
e.Router.Any("/*", func(c echo.Context) error {
|
|
||||||
if cspExists {
|
|
||||||
c.Response().Header().Del("X-Frame-Options")
|
|
||||||
c.Response().Header().Set("Content-Security-Policy", csp)
|
|
||||||
}
|
|
||||||
indexFallback := !strings.HasPrefix(c.Request().URL.Path, "/static/")
|
|
||||||
return apis.StaticDirectoryHandler(site.Dist, indexFallback)(c)
|
|
||||||
})
|
})
|
||||||
}
|
default:
|
||||||
return nil
|
// parse app url
|
||||||
})
|
parsedURL, err := url.Parse(h.appURL)
|
||||||
|
|
||||||
// set up scheduled jobs / ticker for system updates
|
|
||||||
h.app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
|
|
||||||
// 15 second ticker for system updates
|
|
||||||
go h.startSystemUpdateTicker()
|
|
||||||
// set up cron jobs
|
|
||||||
scheduler := cron.New()
|
|
||||||
// delete old records once every hour
|
|
||||||
scheduler.MustAdd("delete old records", "8 * * * *", h.rm.DeleteOldRecords)
|
|
||||||
// create longer records every 10 minutes
|
|
||||||
scheduler.MustAdd("create longer records", "*/10 * * * *", func() {
|
|
||||||
if systemStats, containerStats, err := h.getCollections(); err == nil {
|
|
||||||
h.rm.CreateLongerRecords([]*models.Collection{systemStats, containerStats})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
scheduler.Start()
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
// custom api routes
|
|
||||||
h.app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
|
|
||||||
// returns public key
|
|
||||||
e.Router.GET("/api/beszel/getkey", func(c echo.Context) error {
|
|
||||||
requestData := apis.RequestInfo(c)
|
|
||||||
if requestData.AuthRecord == nil {
|
|
||||||
return apis.NewForbiddenError("Forbidden", nil)
|
|
||||||
}
|
|
||||||
return c.JSON(http.StatusOK, map[string]string{"key": h.pubKey, "v": beszel.Version})
|
|
||||||
})
|
|
||||||
// check if first time setup on login page
|
|
||||||
e.Router.GET("/api/beszel/first-run", func(c echo.Context) error {
|
|
||||||
adminNum, err := h.app.Dao().TotalAdmins()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return c.JSON(http.StatusOK, map[string]bool{"firstRun": adminNum == 0})
|
// fix base paths in html if using subpath
|
||||||
|
basePath := strings.TrimSuffix(parsedURL.Path, "/") + "/"
|
||||||
|
indexFile, _ := fs.ReadFile(site.DistDirFS, "index.html")
|
||||||
|
indexContent := strings.ReplaceAll(string(indexFile), "./", basePath)
|
||||||
|
// set up static asset serving
|
||||||
|
staticPaths := [2]string{"/static/", "/assets/"}
|
||||||
|
serveStatic := apis.Static(site.DistDirFS, false)
|
||||||
|
// get CSP configuration
|
||||||
|
csp, cspExists := GetEnv("CSP")
|
||||||
|
// add route
|
||||||
|
se.Router.GET("/{path...}", func(e *core.RequestEvent) error {
|
||||||
|
// serve static assets if path is in staticPaths
|
||||||
|
for i := range staticPaths {
|
||||||
|
if strings.Contains(e.Request.URL.Path, staticPaths[i]) {
|
||||||
|
e.Response.Header().Set("Cache-Control", "public, max-age=2592000")
|
||||||
|
return serveStatic(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cspExists {
|
||||||
|
e.Response.Header().Del("X-Frame-Options")
|
||||||
|
e.Response.Header().Set("Content-Security-Policy", csp)
|
||||||
|
}
|
||||||
|
return e.HTML(http.StatusOK, indexContent)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return se.Next()
|
||||||
|
})
|
||||||
|
|
||||||
|
// set up scheduled jobs / ticker for system updates
|
||||||
|
h.OnServe().BindFunc(func(se *core.ServeEvent) error {
|
||||||
|
// 15 second ticker for system updates
|
||||||
|
go h.startSystemUpdateTicker()
|
||||||
|
// set up cron jobs
|
||||||
|
// delete old records once every hour
|
||||||
|
h.Cron().MustAdd("delete old records", "8 * * * *", h.rm.DeleteOldRecords)
|
||||||
|
// create longer records every 10 minutes
|
||||||
|
h.Cron().MustAdd("create longer records", "*/10 * * * *", func() {
|
||||||
|
if systemStats, containerStats, err := h.getCollections(); err == nil {
|
||||||
|
h.rm.CreateLongerRecords([]*core.Collection{systemStats, containerStats})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return se.Next()
|
||||||
|
})
|
||||||
|
|
||||||
|
// custom api routes
|
||||||
|
h.OnServe().BindFunc(func(se *core.ServeEvent) error {
|
||||||
|
// returns public key
|
||||||
|
se.Router.GET("/api/beszel/getkey", func(e *core.RequestEvent) error {
|
||||||
|
info, _ := e.RequestInfo()
|
||||||
|
if info.Auth == nil {
|
||||||
|
return apis.NewForbiddenError("Forbidden", nil)
|
||||||
|
}
|
||||||
|
return e.JSON(http.StatusOK, map[string]string{"key": h.pubKey, "v": beszel.Version})
|
||||||
|
})
|
||||||
|
// check if first time setup on login page
|
||||||
|
se.Router.GET("/api/beszel/first-run", func(e *core.RequestEvent) error {
|
||||||
|
total, err := h.CountRecords("users")
|
||||||
|
return e.JSON(http.StatusOK, map[string]bool{"firstRun": err == nil && total == 0})
|
||||||
})
|
})
|
||||||
// send test notification
|
// send test notification
|
||||||
e.Router.GET("/api/beszel/send-test-notification", h.am.SendTestNotification)
|
se.Router.GET("/api/beszel/send-test-notification", h.am.SendTestNotification)
|
||||||
// API endpoint to get config.yml content
|
// API endpoint to get config.yml content
|
||||||
e.Router.GET("/api/beszel/config-yaml", h.getYamlConfig)
|
se.Router.GET("/api/beszel/config-yaml", h.getYamlConfig)
|
||||||
return nil
|
// create first user endpoint only needed if no users exist
|
||||||
|
if totalUsers, _ := h.CountRecords("users"); totalUsers == 0 {
|
||||||
|
se.Router.POST("/api/beszel/create-user", h.um.CreateFirstUser)
|
||||||
|
}
|
||||||
|
return se.Next()
|
||||||
})
|
})
|
||||||
|
|
||||||
// system creation defaults
|
// system creation defaults
|
||||||
h.app.OnModelBeforeCreate("systems").Add(func(e *core.ModelEvent) error {
|
h.OnRecordCreate("systems").BindFunc(func(e *core.RecordEvent) error {
|
||||||
record := e.Model.(*models.Record)
|
e.Record.Set("info", system.Info{})
|
||||||
record.Set("info", system.Info{})
|
e.Record.Set("status", "pending")
|
||||||
record.Set("status", "pending")
|
return e.Next()
|
||||||
return nil
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// immediately create connection for new systems
|
// immediately create connection for new systems
|
||||||
h.app.OnModelAfterCreate("systems").Add(func(e *core.ModelEvent) error {
|
h.OnRecordAfterCreateSuccess("systems").BindFunc(func(e *core.RecordEvent) error {
|
||||||
go h.updateSystem(e.Model.(*models.Record))
|
go h.updateSystem(e.Record)
|
||||||
return nil
|
return e.Next()
|
||||||
})
|
})
|
||||||
|
|
||||||
// handle default values for user / user_settings creation
|
// handle default values for user / user_settings creation
|
||||||
h.app.OnModelBeforeCreate("users").Add(h.um.InitializeUserRole)
|
h.OnRecordCreate("users").BindFunc(h.um.InitializeUserRole)
|
||||||
h.app.OnModelBeforeCreate("user_settings").Add(h.um.InitializeUserSettings)
|
h.OnRecordCreate("user_settings").BindFunc(h.um.InitializeUserSettings)
|
||||||
|
|
||||||
// empty info for systems that are paused
|
// empty info for systems that are paused
|
||||||
h.app.OnModelBeforeUpdate("systems").Add(func(e *core.ModelEvent) error {
|
h.OnRecordUpdate("systems").BindFunc(func(e *core.RecordEvent) error {
|
||||||
if e.Model.(*models.Record).GetString("status") == "paused" {
|
if e.Record.GetString("status") == "paused" {
|
||||||
e.Model.(*models.Record).Set("info", system.Info{})
|
e.Record.Set("info", system.Info{})
|
||||||
}
|
}
|
||||||
return nil
|
return e.Next()
|
||||||
})
|
})
|
||||||
|
|
||||||
// do things after a systems record is updated
|
// do things after a systems record is updated
|
||||||
h.app.OnModelAfterUpdate("systems").Add(func(e *core.ModelEvent) error {
|
h.OnRecordAfterUpdateSuccess("systems").BindFunc(func(e *core.RecordEvent) error {
|
||||||
newRecord := e.Model.(*models.Record)
|
newRecord := e.Record.Fresh()
|
||||||
oldRecord := newRecord.OriginalCopy()
|
oldRecord := newRecord.Original()
|
||||||
newStatus := newRecord.GetString("status")
|
newStatus := newRecord.GetString("status")
|
||||||
|
|
||||||
// if system is disconnected and connection exists, remove it
|
// if system is not up and connection exists, remove it
|
||||||
if newStatus == "down" || newStatus == "paused" {
|
if newStatus != "up" {
|
||||||
h.deleteSystemConnection(newRecord)
|
h.deleteSystemConnection(newRecord)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,33 +258,30 @@ func (h *Hub) Run() {
|
|||||||
go h.updateSystem(newRecord)
|
go h.updateSystem(newRecord)
|
||||||
} else {
|
} else {
|
||||||
h.am.HandleStatusAlerts(newStatus, oldRecord)
|
h.am.HandleStatusAlerts(newStatus, oldRecord)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
return e.Next()
|
||||||
return nil
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// do things after a systems record is deleted
|
// if system is deleted, close connection
|
||||||
h.app.OnModelAfterDelete("systems").Add(func(e *core.ModelEvent) error {
|
h.OnRecordAfterDeleteSuccess("systems").BindFunc(func(e *core.RecordEvent) error {
|
||||||
// if system connection exists, close it
|
h.deleteSystemConnection(e.Record)
|
||||||
h.deleteSystemConnection(e.Model.(*models.Record))
|
return e.Next()
|
||||||
return nil
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if err := h.app.Start(); err != nil {
|
if err := h.Start(); err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Hub) startSystemUpdateTicker() {
|
func (h *Hub) startSystemUpdateTicker() {
|
||||||
ticker := time.NewTicker(15 * time.Second)
|
c := time.Tick(15 * time.Second)
|
||||||
for range ticker.C {
|
for range c {
|
||||||
h.updateSystems()
|
h.updateSystems()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Hub) updateSystems() {
|
func (h *Hub) updateSystems() {
|
||||||
records, err := h.app.Dao().FindRecordsByFilter(
|
records, err := h.FindRecordsByFilter(
|
||||||
"2hz5ncl8tizk5nx", // systems collection
|
"2hz5ncl8tizk5nx", // systems collection
|
||||||
"status != 'paused'", // filter
|
"status != 'paused'", // filter
|
||||||
"updated", // sort
|
"updated", // sort
|
||||||
@@ -238,7 +290,7 @@ func (h *Hub) updateSystems() {
|
|||||||
)
|
)
|
||||||
// log.Println("records", len(records))
|
// log.Println("records", len(records))
|
||||||
if err != nil || len(records) == 0 {
|
if err != nil || len(records) == 0 {
|
||||||
// h.app.Logger().Error("Failed to query systems")
|
// h.Logger().Error("Failed to query systems")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
fiftySecondsAgo := time.Now().UTC().Add(-50 * time.Second)
|
fiftySecondsAgo := time.Now().UTC().Add(-50 * time.Second)
|
||||||
@@ -258,89 +310,87 @@ func (h *Hub) updateSystems() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Hub) updateSystem(record *models.Record) {
|
func (h *Hub) updateSystem(record *core.Record) {
|
||||||
var client *ssh.Client
|
var client *ssh.Client
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
// check if system connection data exists
|
// check if system connection exists
|
||||||
if _, ok := h.systemConnections[record.Id]; ok {
|
if existingClient, ok := h.Store().GetOk(record.Id); ok {
|
||||||
client = h.systemConnections[record.Id]
|
client = existingClient.(*ssh.Client)
|
||||||
} else {
|
} else {
|
||||||
// create system connection
|
// create system connection
|
||||||
client, err = h.createSystemConnection(record)
|
client, err = h.createSystemConnection(record)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if record.GetString("status") != "down" {
|
if record.GetString("status") != "down" {
|
||||||
h.app.Logger().Error("Failed to connect:", "err", err.Error(), "system", record.GetString("host"), "port", record.GetString("port"))
|
h.Logger().Error("Failed to connect:", "err", err.Error(), "system", record.GetString("host"), "port", record.GetString("port"))
|
||||||
h.updateSystemStatus(record, "down")
|
h.updateSystemStatus(record, "down")
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.connectionLock.Lock()
|
h.Store().Set(record.Id, client)
|
||||||
h.systemConnections[record.Id] = client
|
|
||||||
h.connectionLock.Unlock()
|
|
||||||
}
|
}
|
||||||
// get system stats from agent
|
// get system stats from agent
|
||||||
var systemData system.CombinedData
|
var systemData system.CombinedData
|
||||||
if err := h.requestJsonFromAgent(client, &systemData); err != nil {
|
if err := h.requestJsonFromAgent(client, &systemData); err != nil {
|
||||||
if err.Error() == "bad client" {
|
if err.Error() == "bad client" {
|
||||||
// if previous connection was closed, try again
|
// if previous connection was closed, try again
|
||||||
h.app.Logger().Error("Existing SSH connection closed. Retrying...", "host", record.GetString("host"), "port", record.GetString("port"))
|
h.Logger().Error("Existing SSH connection closed. Retrying...", "host", record.GetString("host"), "port", record.GetString("port"))
|
||||||
h.deleteSystemConnection(record)
|
h.deleteSystemConnection(record)
|
||||||
|
time.Sleep(time.Millisecond * 100)
|
||||||
h.updateSystem(record)
|
h.updateSystem(record)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.app.Logger().Error("Failed to get system stats: ", "err", err.Error())
|
h.Logger().Error("Failed to get system stats: ", "err", err.Error())
|
||||||
h.updateSystemStatus(record, "down")
|
h.updateSystemStatus(record, "down")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// update system record
|
// update system record
|
||||||
dao := h.app.Dao()
|
|
||||||
record.Set("status", "up")
|
record.Set("status", "up")
|
||||||
record.Set("info", systemData.Info)
|
record.Set("info", systemData.Info)
|
||||||
if err := dao.SaveRecord(record); err != nil {
|
if err := h.SaveNoValidate(record); err != nil {
|
||||||
h.app.Logger().Error("Failed to update record: ", "err", err.Error())
|
h.Logger().Error("Failed to update record: ", "err", err.Error())
|
||||||
}
|
}
|
||||||
// add system_stats and container_stats records
|
// add system_stats and container_stats records
|
||||||
if systemStats, containerStats, err := h.getCollections(); err != nil {
|
if systemStats, containerStats, err := h.getCollections(); err != nil {
|
||||||
h.app.Logger().Error("Failed to get collections: ", "err", err.Error())
|
h.Logger().Error("Failed to get collections: ", "err", err.Error())
|
||||||
} else {
|
} else {
|
||||||
// add new system_stats record
|
// add new system_stats record
|
||||||
systemStatsRecord := models.NewRecord(systemStats)
|
systemStatsRecord := core.NewRecord(systemStats)
|
||||||
systemStatsRecord.Set("system", record.Id)
|
systemStatsRecord.Set("system", record.Id)
|
||||||
systemStatsRecord.Set("stats", systemData.Stats)
|
systemStatsRecord.Set("stats", systemData.Stats)
|
||||||
systemStatsRecord.Set("type", "1m")
|
systemStatsRecord.Set("type", "1m")
|
||||||
if err := dao.SaveRecord(systemStatsRecord); err != nil {
|
if err := h.SaveNoValidate(systemStatsRecord); err != nil {
|
||||||
h.app.Logger().Error("Failed to save record: ", "err", err.Error())
|
h.Logger().Error("Failed to save record: ", "err", err.Error())
|
||||||
}
|
}
|
||||||
// add new container_stats record
|
// add new container_stats record
|
||||||
if len(systemData.Containers) > 0 {
|
if len(systemData.Containers) > 0 {
|
||||||
containerStatsRecord := models.NewRecord(containerStats)
|
containerStatsRecord := core.NewRecord(containerStats)
|
||||||
containerStatsRecord.Set("system", record.Id)
|
containerStatsRecord.Set("system", record.Id)
|
||||||
containerStatsRecord.Set("stats", systemData.Containers)
|
containerStatsRecord.Set("stats", systemData.Containers)
|
||||||
containerStatsRecord.Set("type", "1m")
|
containerStatsRecord.Set("type", "1m")
|
||||||
if err := dao.SaveRecord(containerStatsRecord); err != nil {
|
if err := h.SaveNoValidate(containerStatsRecord); err != nil {
|
||||||
h.app.Logger().Error("Failed to save record: ", "err", err.Error())
|
h.Logger().Error("Failed to save record: ", "err", err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// system info alerts (todo: extra fs alerts)
|
// system info alerts
|
||||||
if err := h.am.HandleSystemAlerts(record, systemData.Info, systemData.Stats.Temperatures, systemData.Stats.ExtraFs); err != nil {
|
if err := h.am.HandleSystemAlerts(record, systemData.Info, systemData.Stats.Temperatures, systemData.Stats.ExtraFs); err != nil {
|
||||||
h.app.Logger().Error("System alerts error", "err", err.Error())
|
h.Logger().Error("System alerts error", "err", err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// return system_stats and container_stats collections
|
// return system_stats and container_stats collections
|
||||||
func (h *Hub) getCollections() (*models.Collection, *models.Collection, error) {
|
func (h *Hub) getCollections() (*core.Collection, *core.Collection, error) {
|
||||||
if h.systemStats == nil {
|
if h.systemStats == nil {
|
||||||
systemStats, err := h.app.Dao().FindCollectionByNameOrId("system_stats")
|
systemStats, err := h.FindCollectionByNameOrId("system_stats")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
h.systemStats = systemStats
|
h.systemStats = systemStats
|
||||||
}
|
}
|
||||||
if h.containerStats == nil {
|
if h.containerStats == nil {
|
||||||
containerStats, err := h.app.Dao().FindCollectionByNameOrId("container_stats")
|
containerStats, err := h.FindCollectionByNameOrId("container_stats")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
@@ -350,28 +400,34 @@ func (h *Hub) getCollections() (*models.Collection, *models.Collection, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// set system to specified status and save record
|
// set system to specified status and save record
|
||||||
func (h *Hub) updateSystemStatus(record *models.Record, status string) {
|
func (h *Hub) updateSystemStatus(record *core.Record, status string) {
|
||||||
if record.GetString("status") != status {
|
if record.Fresh().GetString("status") != status {
|
||||||
record.Set("status", status)
|
record.Set("status", status)
|
||||||
if err := h.app.Dao().SaveRecord(record); err != nil {
|
if err := h.SaveNoValidate(record); err != nil {
|
||||||
h.app.Logger().Error("Failed to update record: ", "err", err.Error())
|
h.Logger().Error("Failed to update record: ", "err", err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Hub) deleteSystemConnection(record *models.Record) {
|
// delete system connection from map and close connection
|
||||||
if _, ok := h.systemConnections[record.Id]; ok {
|
func (h *Hub) deleteSystemConnection(record *core.Record) {
|
||||||
if h.systemConnections[record.Id] != nil {
|
if client, ok := h.Store().GetOk(record.Id); ok {
|
||||||
h.systemConnections[record.Id].Close()
|
if sshClient := client.(*ssh.Client); sshClient != nil {
|
||||||
|
sshClient.Close()
|
||||||
}
|
}
|
||||||
h.connectionLock.Lock()
|
h.Store().Remove(record.Id)
|
||||||
defer h.connectionLock.Unlock()
|
|
||||||
delete(h.systemConnections, record.Id)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Hub) createSystemConnection(record *models.Record) (*ssh.Client, error) {
|
func (h *Hub) createSystemConnection(record *core.Record) (*ssh.Client, error) {
|
||||||
client, err := ssh.Dial("tcp", fmt.Sprintf("%s:%s", record.GetString("host"), record.GetString("port")), h.sshClientConfig)
|
network := "tcp"
|
||||||
|
host := record.GetString("host")
|
||||||
|
if strings.HasPrefix(host, "/") {
|
||||||
|
network = "unix"
|
||||||
|
} else {
|
||||||
|
host = net.JoinHostPort(host, record.GetString("port"))
|
||||||
|
}
|
||||||
|
client, err := ssh.Dial(network, host, h.sshClientConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -381,7 +437,7 @@ func (h *Hub) createSystemConnection(record *models.Record) (*ssh.Client, error)
|
|||||||
func (h *Hub) createSSHClientConfig() error {
|
func (h *Hub) createSSHClientConfig() error {
|
||||||
key, err := h.getSSHKey()
|
key, err := h.getSSHKey()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.app.Logger().Error("Failed to get SSH key: ", "err", err.Error())
|
h.Logger().Error("Failed to get SSH key: ", "err", err.Error())
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,14 +453,14 @@ func (h *Hub) createSSHClientConfig() error {
|
|||||||
ssh.PublicKeys(signer),
|
ssh.PublicKeys(signer),
|
||||||
},
|
},
|
||||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||||
Timeout: 5 * time.Second,
|
Timeout: 4 * time.Second,
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetches system stats from the agent and decodes the json data into the provided struct
|
// Fetches system stats from the agent and decodes the json data into the provided struct
|
||||||
func (h *Hub) requestJsonFromAgent(client *ssh.Client, systemData *system.CombinedData) error {
|
func (h *Hub) requestJsonFromAgent(client *ssh.Client, systemData *system.CombinedData) error {
|
||||||
session, err := newSessionWithTimeout(client, 5*time.Second)
|
session, err := newSessionWithTimeout(client, 4*time.Second)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("bad client")
|
return fmt.Errorf("bad client")
|
||||||
}
|
}
|
||||||
@@ -458,11 +514,11 @@ func newSessionWithTimeout(client *ssh.Client, timeout time.Duration) (*ssh.Sess
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Hub) getSSHKey() ([]byte, error) {
|
func (h *Hub) getSSHKey() ([]byte, error) {
|
||||||
dataDir := h.app.DataDir()
|
dataDir := h.DataDir()
|
||||||
// check if the key pair already exists
|
// check if the key pair already exists
|
||||||
existingKey, err := os.ReadFile(dataDir + "/id_ed25519")
|
existingKey, err := os.ReadFile(dataDir + "/id_ed25519")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
if pubKey, err := os.ReadFile(h.app.DataDir() + "/id_ed25519.pub"); err == nil {
|
if pubKey, err := os.ReadFile(h.DataDir() + "/id_ed25519.pub"); err == nil {
|
||||||
h.pubKey = strings.TrimSuffix(string(pubKey), "\n")
|
h.pubKey = strings.TrimSuffix(string(pubKey), "\n")
|
||||||
}
|
}
|
||||||
// return existing private key
|
// return existing private key
|
||||||
@@ -472,27 +528,27 @@ func (h *Hub) getSSHKey() ([]byte, error) {
|
|||||||
// Generate the Ed25519 key pair
|
// Generate the Ed25519 key pair
|
||||||
pubKey, privKey, err := ed25519.GenerateKey(nil)
|
pubKey, privKey, err := ed25519.GenerateKey(nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// h.app.Logger().Error("Error generating key pair:", "err", err.Error())
|
// h.Logger().Error("Error generating key pair:", "err", err.Error())
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the private key in OpenSSH format
|
// Get the private key in OpenSSH format
|
||||||
privKeyBytes, err := ssh.MarshalPrivateKey(privKey, "")
|
privKeyBytes, err := ssh.MarshalPrivateKey(privKey, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// h.app.Logger().Error("Error marshaling private key:", "err", err.Error())
|
// h.Logger().Error("Error marshaling private key:", "err", err.Error())
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save the private key to a file
|
// Save the private key to a file
|
||||||
privateFile, err := os.Create(dataDir + "/id_ed25519")
|
privateFile, err := os.Create(dataDir + "/id_ed25519")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// h.app.Logger().Error("Error creating private key file:", "err", err.Error())
|
// h.Logger().Error("Error creating private key file:", "err", err.Error())
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer privateFile.Close()
|
defer privateFile.Close()
|
||||||
|
|
||||||
if err := pem.Encode(privateFile, privKeyBytes); err != nil {
|
if err := pem.Encode(privateFile, privKeyBytes); err != nil {
|
||||||
// h.app.Logger().Error("Error writing private key to file:", "err", err.Error())
|
// h.Logger().Error("Error writing private key to file:", "err", err.Error())
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -516,9 +572,9 @@ func (h *Hub) getSSHKey() ([]byte, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
h.app.Logger().Info("ed25519 SSH key pair generated successfully.")
|
h.Logger().Info("ed25519 SSH key pair generated successfully.")
|
||||||
h.app.Logger().Info("Private key saved to: " + dataDir + "/id_ed25519")
|
h.Logger().Info("Private key saved to: " + dataDir + "/id_ed25519")
|
||||||
h.app.Logger().Info("Public key saved to: " + dataDir + "/id_ed25519.pub")
|
h.Logger().Info("Public key saved to: " + dataDir + "/id_ed25519.pub")
|
||||||
|
|
||||||
existingKey, err = os.ReadFile(dataDir + "/id_ed25519")
|
existingKey, err = os.ReadFile(dataDir + "/id_ed25519")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|||||||
@@ -10,14 +10,12 @@ import (
|
|||||||
|
|
||||||
"github.com/goccy/go-json"
|
"github.com/goccy/go-json"
|
||||||
"github.com/pocketbase/dbx"
|
"github.com/pocketbase/dbx"
|
||||||
"github.com/pocketbase/pocketbase"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
"github.com/pocketbase/pocketbase/daos"
|
|
||||||
"github.com/pocketbase/pocketbase/models"
|
|
||||||
"github.com/pocketbase/pocketbase/tools/types"
|
"github.com/pocketbase/pocketbase/tools/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
type RecordManager struct {
|
type RecordManager struct {
|
||||||
app *pocketbase.PocketBase
|
app core.App
|
||||||
}
|
}
|
||||||
|
|
||||||
type LongerRecordData struct {
|
type LongerRecordData struct {
|
||||||
@@ -36,12 +34,12 @@ type RecordStats []struct {
|
|||||||
Stats []byte `db:"stats"`
|
Stats []byte `db:"stats"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRecordManager(app *pocketbase.PocketBase) *RecordManager {
|
func NewRecordManager(app core.App) *RecordManager {
|
||||||
return &RecordManager{app}
|
return &RecordManager{app}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create longer records by averaging shorter records
|
// Create longer records by averaging shorter records
|
||||||
func (rm *RecordManager) CreateLongerRecords(collections []*models.Collection) {
|
func (rm *RecordManager) CreateLongerRecords(collections []*core.Collection) {
|
||||||
// start := time.Now()
|
// start := time.Now()
|
||||||
longerRecordData := []LongerRecordData{
|
longerRecordData := []LongerRecordData{
|
||||||
{
|
{
|
||||||
@@ -71,8 +69,8 @@ func (rm *RecordManager) CreateLongerRecords(collections []*models.Collection) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
// wrap the operations in a transaction
|
// wrap the operations in a transaction
|
||||||
rm.app.Dao().RunInTransaction(func(txDao *daos.Dao) error {
|
rm.app.RunInTransaction(func(txApp core.App) error {
|
||||||
activeSystems, err := txDao.FindRecordsByExpr("systems", dbx.NewExp("status = 'up'"))
|
activeSystems, err := txApp.FindAllRecords("systems", dbx.NewExp("status = 'up'"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println("failed to get active systems", "err", err.Error())
|
log.Println("failed to get active systems", "err", err.Error())
|
||||||
return err
|
return err
|
||||||
@@ -92,7 +90,7 @@ func (rm *RecordManager) CreateLongerRecords(collections []*models.Collection) {
|
|||||||
for _, collection := range collections {
|
for _, collection := range collections {
|
||||||
// check creation time of last longer record if not 10m, since 10m is created every run
|
// check creation time of last longer record if not 10m, since 10m is created every run
|
||||||
if recordData.longerType != "10m" {
|
if recordData.longerType != "10m" {
|
||||||
lastLongerRecord, err := txDao.FindFirstRecordByFilter(
|
lastLongerRecord, err := txApp.FindFirstRecordByFilter(
|
||||||
collection.Id,
|
collection.Id,
|
||||||
"type = {:type} && system = {:system} && created > {:created}",
|
"type = {:type} && system = {:system} && created > {:created}",
|
||||||
dbx.Params{"type": recordData.longerType, "system": system.Id, "created": longerRecordPeriod},
|
dbx.Params{"type": recordData.longerType, "system": system.Id, "created": longerRecordPeriod},
|
||||||
@@ -106,7 +104,7 @@ func (rm *RecordManager) CreateLongerRecords(collections []*models.Collection) {
|
|||||||
// get shorter records from the past x minutes
|
// get shorter records from the past x minutes
|
||||||
var stats RecordStats
|
var stats RecordStats
|
||||||
|
|
||||||
err := txDao.DB().
|
err := txApp.DB().
|
||||||
Select("stats").
|
Select("stats").
|
||||||
From(collection.Name).
|
From(collection.Name).
|
||||||
AndWhere(dbx.NewExp(
|
AndWhere(dbx.NewExp(
|
||||||
@@ -125,7 +123,7 @@ func (rm *RecordManager) CreateLongerRecords(collections []*models.Collection) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// average the shorter records and create longer record
|
// average the shorter records and create longer record
|
||||||
longerRecord := models.NewRecord(collection)
|
longerRecord := core.NewRecord(collection)
|
||||||
longerRecord.Set("system", system.Id)
|
longerRecord.Set("system", system.Id)
|
||||||
longerRecord.Set("type", recordData.longerType)
|
longerRecord.Set("type", recordData.longerType)
|
||||||
switch collection.Name {
|
switch collection.Name {
|
||||||
@@ -134,7 +132,7 @@ func (rm *RecordManager) CreateLongerRecords(collections []*models.Collection) {
|
|||||||
case "container_stats":
|
case "container_stats":
|
||||||
longerRecord.Set("stats", rm.AverageContainerStats(stats))
|
longerRecord.Set("stats", rm.AverageContainerStats(stats))
|
||||||
}
|
}
|
||||||
if err := txDao.SaveRecord(longerRecord); err != nil {
|
if err := txApp.SaveNoValidate(longerRecord); err != nil {
|
||||||
log.Println("failed to save longer record", "err", err.Error())
|
log.Println("failed to save longer record", "err", err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -149,17 +147,14 @@ func (rm *RecordManager) CreateLongerRecords(collections []*models.Collection) {
|
|||||||
|
|
||||||
// Calculate the average stats of a list of system_stats records without reflect
|
// Calculate the average stats of a list of system_stats records without reflect
|
||||||
func (rm *RecordManager) AverageSystemStats(records RecordStats) system.Stats {
|
func (rm *RecordManager) AverageSystemStats(records RecordStats) system.Stats {
|
||||||
sum := system.Stats{
|
sum := system.Stats{}
|
||||||
Temperatures: make(map[string]float64),
|
|
||||||
ExtraFs: make(map[string]*system.FsStats),
|
|
||||||
}
|
|
||||||
|
|
||||||
count := float64(len(records))
|
count := float64(len(records))
|
||||||
// use different counter for temps in case some records don't have them
|
// use different counter for temps in case some records don't have them
|
||||||
tempCount := float64(0)
|
tempCount := float64(0)
|
||||||
|
|
||||||
var stats system.Stats
|
var stats system.Stats
|
||||||
for i := range records {
|
for i := range records {
|
||||||
|
stats = system.Stats{} // Zero the struct before unmarshalling
|
||||||
json.Unmarshal(records[i].Stats, &stats)
|
json.Unmarshal(records[i].Stats, &stats)
|
||||||
sum.Cpu += stats.Cpu
|
sum.Cpu += stats.Cpu
|
||||||
sum.Mem += stats.Mem
|
sum.Mem += stats.Mem
|
||||||
@@ -184,6 +179,9 @@ func (rm *RecordManager) AverageSystemStats(records RecordStats) system.Stats {
|
|||||||
sum.MaxDiskWritePs = max(sum.MaxDiskWritePs, stats.MaxDiskWritePs, stats.DiskWritePs)
|
sum.MaxDiskWritePs = max(sum.MaxDiskWritePs, stats.MaxDiskWritePs, stats.DiskWritePs)
|
||||||
// add temps to sum
|
// add temps to sum
|
||||||
if stats.Temperatures != nil {
|
if stats.Temperatures != nil {
|
||||||
|
if sum.Temperatures == nil {
|
||||||
|
sum.Temperatures = make(map[string]float64, len(stats.Temperatures))
|
||||||
|
}
|
||||||
tempCount++
|
tempCount++
|
||||||
for key, value := range stats.Temperatures {
|
for key, value := range stats.Temperatures {
|
||||||
if _, ok := sum.Temperatures[key]; !ok {
|
if _, ok := sum.Temperatures[key]; !ok {
|
||||||
@@ -194,6 +192,9 @@ func (rm *RecordManager) AverageSystemStats(records RecordStats) system.Stats {
|
|||||||
}
|
}
|
||||||
// add extra fs to sum
|
// add extra fs to sum
|
||||||
if stats.ExtraFs != nil {
|
if stats.ExtraFs != nil {
|
||||||
|
if sum.ExtraFs == nil {
|
||||||
|
sum.ExtraFs = make(map[string]*system.FsStats, len(stats.ExtraFs))
|
||||||
|
}
|
||||||
for key, value := range stats.ExtraFs {
|
for key, value := range stats.ExtraFs {
|
||||||
if _, ok := sum.ExtraFs[key]; !ok {
|
if _, ok := sum.ExtraFs[key]; !ok {
|
||||||
sum.ExtraFs[key] = &system.FsStats{}
|
sum.ExtraFs[key] = &system.FsStats{}
|
||||||
@@ -207,6 +208,25 @@ func (rm *RecordManager) AverageSystemStats(records RecordStats) system.Stats {
|
|||||||
sum.ExtraFs[key].MaxDiskWritePS = max(sum.ExtraFs[key].MaxDiskWritePS, value.MaxDiskWritePS, value.DiskWritePs)
|
sum.ExtraFs[key].MaxDiskWritePS = max(sum.ExtraFs[key].MaxDiskWritePS, value.MaxDiskWritePS, value.DiskWritePs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// add GPU data
|
||||||
|
if stats.GPUData != nil {
|
||||||
|
if sum.GPUData == nil {
|
||||||
|
sum.GPUData = make(map[string]system.GPUData, len(stats.GPUData))
|
||||||
|
}
|
||||||
|
for id, value := range stats.GPUData {
|
||||||
|
if _, ok := sum.GPUData[id]; !ok {
|
||||||
|
sum.GPUData[id] = system.GPUData{Name: value.Name}
|
||||||
|
}
|
||||||
|
gpu := sum.GPUData[id]
|
||||||
|
gpu.Temperature += value.Temperature
|
||||||
|
gpu.MemoryUsed += value.MemoryUsed
|
||||||
|
gpu.MemoryTotal += value.MemoryTotal
|
||||||
|
gpu.Usage += value.Usage
|
||||||
|
gpu.Power += value.Power
|
||||||
|
gpu.Count += value.Count
|
||||||
|
sum.GPUData[id] = gpu
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stats = system.Stats{
|
stats = system.Stats{
|
||||||
@@ -232,14 +252,14 @@ func (rm *RecordManager) AverageSystemStats(records RecordStats) system.Stats {
|
|||||||
MaxNetworkRecv: sum.MaxNetworkRecv,
|
MaxNetworkRecv: sum.MaxNetworkRecv,
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(sum.Temperatures) != 0 {
|
if sum.Temperatures != nil {
|
||||||
stats.Temperatures = make(map[string]float64, len(sum.Temperatures))
|
stats.Temperatures = make(map[string]float64, len(sum.Temperatures))
|
||||||
for key, value := range sum.Temperatures {
|
for key, value := range sum.Temperatures {
|
||||||
stats.Temperatures[key] = twoDecimals(value / tempCount)
|
stats.Temperatures[key] = twoDecimals(value / tempCount)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(sum.ExtraFs) != 0 {
|
if sum.ExtraFs != nil {
|
||||||
stats.ExtraFs = make(map[string]*system.FsStats, len(sum.ExtraFs))
|
stats.ExtraFs = make(map[string]*system.FsStats, len(sum.ExtraFs))
|
||||||
for key, value := range sum.ExtraFs {
|
for key, value := range sum.ExtraFs {
|
||||||
stats.ExtraFs[key] = &system.FsStats{
|
stats.ExtraFs[key] = &system.FsStats{
|
||||||
@@ -253,6 +273,21 @@ func (rm *RecordManager) AverageSystemStats(records RecordStats) system.Stats {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if sum.GPUData != nil {
|
||||||
|
stats.GPUData = make(map[string]system.GPUData, len(sum.GPUData))
|
||||||
|
for id, value := range sum.GPUData {
|
||||||
|
stats.GPUData[id] = system.GPUData{
|
||||||
|
Name: value.Name,
|
||||||
|
Temperature: twoDecimals(value.Temperature / count),
|
||||||
|
MemoryUsed: twoDecimals(value.MemoryUsed / count),
|
||||||
|
MemoryTotal: twoDecimals(value.MemoryTotal / count),
|
||||||
|
Usage: twoDecimals(value.Usage / count),
|
||||||
|
Power: twoDecimals(value.Power / count),
|
||||||
|
Count: twoDecimals(value.Count / count),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return stats
|
return stats
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,7 +353,7 @@ func (rm *RecordManager) DeleteOldRecords() {
|
|||||||
retention: 30 * 24 * time.Hour,
|
retention: 30 * 24 * time.Hour,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
db := rm.app.Dao().NonconcurrentDB()
|
db := rm.app.NonconcurrentDB()
|
||||||
for _, recordData := range recordData {
|
for _, recordData := range recordData {
|
||||||
for _, collectionSlug := range collections {
|
for _, collectionSlug := range collections {
|
||||||
formattedDate := time.Now().UTC().Add(-recordData.retention).Format(types.DefaultDateLayout)
|
formattedDate := time.Now().UTC().Add(-recordData.retention).Format(types.DefaultDateLayout)
|
||||||
|
|||||||
@@ -2,15 +2,15 @@
|
|||||||
package users
|
package users
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"beszel/migrations"
|
||||||
"log"
|
"log"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
"github.com/pocketbase/pocketbase"
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
"github.com/pocketbase/pocketbase/models"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type UserManager struct {
|
type UserManager struct {
|
||||||
app *pocketbase.PocketBase
|
app core.App
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserSettings struct {
|
type UserSettings struct {
|
||||||
@@ -20,22 +20,23 @@ type UserSettings struct {
|
|||||||
// Language string `json:"lang"`
|
// Language string `json:"lang"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewUserManager(app *pocketbase.PocketBase) *UserManager {
|
func NewUserManager(app core.App) *UserManager {
|
||||||
return &UserManager{
|
return &UserManager{
|
||||||
app: app,
|
app: app,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (um *UserManager) InitializeUserRole(e *core.ModelEvent) error {
|
// Initialize user role if not set
|
||||||
user := e.Model.(*models.Record)
|
func (um *UserManager) InitializeUserRole(e *core.RecordEvent) error {
|
||||||
if user.GetString("role") == "" {
|
if e.Record.GetString("role") == "" {
|
||||||
user.Set("role", "user")
|
e.Record.Set("role", "user")
|
||||||
}
|
}
|
||||||
return nil
|
return e.Next()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (um *UserManager) InitializeUserSettings(e *core.ModelEvent) error {
|
// Initialize user settings with defaults if not set
|
||||||
record := e.Model.(*models.Record)
|
func (um *UserManager) InitializeUserSettings(e *core.RecordEvent) error {
|
||||||
|
record := e.Record
|
||||||
// intialize settings with defaults
|
// intialize settings with defaults
|
||||||
settings := UserSettings{
|
settings := UserSettings{
|
||||||
// Language: "en",
|
// Language: "en",
|
||||||
@@ -46,7 +47,7 @@ func (um *UserManager) InitializeUserSettings(e *core.ModelEvent) error {
|
|||||||
record.UnmarshalJSONField("settings", &settings)
|
record.UnmarshalJSONField("settings", &settings)
|
||||||
if len(settings.NotificationEmails) == 0 {
|
if len(settings.NotificationEmails) == 0 {
|
||||||
// get user email from auth record
|
// get user email from auth record
|
||||||
if errs := um.app.Dao().ExpandRecord(record, []string{"user"}, nil); len(errs) == 0 {
|
if errs := um.app.ExpandRecord(record, []string{"user"}, nil); len(errs) == 0 {
|
||||||
// app.Logger().Error("failed to expand user relation", "errs", errs)
|
// app.Logger().Error("failed to expand user relation", "errs", errs)
|
||||||
if user := record.ExpandedOne("user"); user != nil {
|
if user := record.ExpandedOne("user"); user != nil {
|
||||||
settings.NotificationEmails = []string{user.GetString("email")}
|
settings.NotificationEmails = []string{user.GetString("email")}
|
||||||
@@ -61,5 +62,54 @@ func (um *UserManager) InitializeUserSettings(e *core.ModelEvent) error {
|
|||||||
// settings.NotificationWebhooks = []string{""}
|
// settings.NotificationWebhooks = []string{""}
|
||||||
// }
|
// }
|
||||||
record.Set("settings", settings)
|
record.Set("settings", settings)
|
||||||
return nil
|
return e.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom API endpoint to create the first user.
|
||||||
|
// Mimics previous default behavior in PocketBase < 0.23.0 allowing user to be created through the Beszel UI.
|
||||||
|
func (um *UserManager) CreateFirstUser(e *core.RequestEvent) error {
|
||||||
|
// check that there are no users
|
||||||
|
totalUsers, err := um.app.CountRecords("users")
|
||||||
|
if err != nil || totalUsers > 0 {
|
||||||
|
return e.JSON(http.StatusForbidden, map[string]string{"err": "Forbidden"})
|
||||||
|
}
|
||||||
|
// check that there is only one superuser and the email matches the email of the superuser we set up in initial-settings.go
|
||||||
|
adminUsers, err := um.app.FindAllRecords(core.CollectionNameSuperusers)
|
||||||
|
if err != nil || len(adminUsers) != 1 || adminUsers[0].GetString("email") != migrations.TempAdminEmail {
|
||||||
|
return e.JSON(http.StatusForbidden, map[string]string{"err": "Forbidden"})
|
||||||
|
}
|
||||||
|
// create first user using supplied email and password in request body
|
||||||
|
data := struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}{}
|
||||||
|
if err := e.BindBody(&data); err != nil {
|
||||||
|
return e.JSON(http.StatusBadRequest, map[string]string{"err": err.Error()})
|
||||||
|
}
|
||||||
|
if data.Email == "" || data.Password == "" {
|
||||||
|
return e.JSON(http.StatusBadRequest, map[string]string{"err": "Bad request"})
|
||||||
|
}
|
||||||
|
|
||||||
|
collection, _ := um.app.FindCollectionByNameOrId("users")
|
||||||
|
user := core.NewRecord(collection)
|
||||||
|
user.SetEmail(data.Email)
|
||||||
|
user.SetPassword(data.Password)
|
||||||
|
user.Set("role", "admin")
|
||||||
|
user.Set("verified", true)
|
||||||
|
if err := um.app.Save(user); err != nil {
|
||||||
|
return e.JSON(http.StatusInternalServerError, map[string]string{"err": err.Error()})
|
||||||
|
}
|
||||||
|
// create superuser using the email of the first user
|
||||||
|
collection, _ = um.app.FindCollectionByNameOrId(core.CollectionNameSuperusers)
|
||||||
|
adminUser := core.NewRecord(collection)
|
||||||
|
adminUser.SetEmail(data.Email)
|
||||||
|
adminUser.SetPassword(data.Password)
|
||||||
|
if err := um.app.Save(adminUser); err != nil {
|
||||||
|
return e.JSON(http.StatusInternalServerError, map[string]string{"err": err.Error()})
|
||||||
|
}
|
||||||
|
// delete the intial superuser
|
||||||
|
if err := um.app.Delete(adminUsers[0]); err != nil {
|
||||||
|
return e.JSON(http.StatusInternalServerError, map[string]string{"err": err.Error()})
|
||||||
|
}
|
||||||
|
return e.JSON(http.StatusOK, map[string]string{"msg": "User created"})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,481 +0,0 @@
|
|||||||
package migrations
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
|
|
||||||
"github.com/pocketbase/dbx"
|
|
||||||
"github.com/pocketbase/pocketbase/daos"
|
|
||||||
m "github.com/pocketbase/pocketbase/migrations"
|
|
||||||
"github.com/pocketbase/pocketbase/models"
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
m.Register(func(db dbx.Builder) error {
|
|
||||||
jsonData := `[
|
|
||||||
{
|
|
||||||
"id": "2hz5ncl8tizk5nx",
|
|
||||||
"created": "2024-07-07 16:08:20.979Z",
|
|
||||||
"updated": "2024-10-12 18:55:51.623Z",
|
|
||||||
"name": "systems",
|
|
||||||
"type": "base",
|
|
||||||
"system": false,
|
|
||||||
"schema": [
|
|
||||||
{
|
|
||||||
"system": false,
|
|
||||||
"id": "7xloxkwk",
|
|
||||||
"name": "name",
|
|
||||||
"type": "text",
|
|
||||||
"required": true,
|
|
||||||
"presentable": false,
|
|
||||||
"unique": false,
|
|
||||||
"options": {
|
|
||||||
"min": null,
|
|
||||||
"max": null,
|
|
||||||
"pattern": ""
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"system": false,
|
|
||||||
"id": "waj7seaf",
|
|
||||||
"name": "status",
|
|
||||||
"type": "select",
|
|
||||||
"required": false,
|
|
||||||
"presentable": false,
|
|
||||||
"unique": false,
|
|
||||||
"options": {
|
|
||||||
"maxSelect": 1,
|
|
||||||
"values": [
|
|
||||||
"up",
|
|
||||||
"down",
|
|
||||||
"paused",
|
|
||||||
"pending"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"system": false,
|
|
||||||
"id": "ve781smf",
|
|
||||||
"name": "host",
|
|
||||||
"type": "text",
|
|
||||||
"required": true,
|
|
||||||
"presentable": false,
|
|
||||||
"unique": false,
|
|
||||||
"options": {
|
|
||||||
"min": null,
|
|
||||||
"max": null,
|
|
||||||
"pattern": ""
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"system": false,
|
|
||||||
"id": "pij0k2jk",
|
|
||||||
"name": "port",
|
|
||||||
"type": "text",
|
|
||||||
"required": true,
|
|
||||||
"presentable": false,
|
|
||||||
"unique": false,
|
|
||||||
"options": {
|
|
||||||
"min": null,
|
|
||||||
"max": null,
|
|
||||||
"pattern": ""
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"system": false,
|
|
||||||
"id": "qoq64ntl",
|
|
||||||
"name": "info",
|
|
||||||
"type": "json",
|
|
||||||
"required": false,
|
|
||||||
"presentable": false,
|
|
||||||
"unique": false,
|
|
||||||
"options": {
|
|
||||||
"maxSize": 2000000
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"system": false,
|
|
||||||
"id": "jcarjnjj",
|
|
||||||
"name": "users",
|
|
||||||
"type": "relation",
|
|
||||||
"required": true,
|
|
||||||
"presentable": false,
|
|
||||||
"unique": false,
|
|
||||||
"options": {
|
|
||||||
"collectionId": "_pb_users_auth_",
|
|
||||||
"cascadeDelete": true,
|
|
||||||
"minSelect": null,
|
|
||||||
"maxSelect": null,
|
|
||||||
"displayFields": null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"indexes": [],
|
|
||||||
"listRule": "@request.auth.id != \"\" && users.id ?= @request.auth.id",
|
|
||||||
"viewRule": "@request.auth.id != \"\" && users.id ?= @request.auth.id",
|
|
||||||
"createRule": "@request.auth.id != \"\" && users.id ?= @request.auth.id && @request.auth.role != \"readonly\"",
|
|
||||||
"updateRule": "@request.auth.id != \"\" && users.id ?= @request.auth.id && @request.auth.role != \"readonly\"",
|
|
||||||
"deleteRule": "@request.auth.id != \"\" && users.id ?= @request.auth.id && @request.auth.role != \"readonly\"",
|
|
||||||
"options": {}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "ej9oowivz8b2mht",
|
|
||||||
"created": "2024-07-07 16:09:09.179Z",
|
|
||||||
"updated": "2024-10-12 18:55:51.623Z",
|
|
||||||
"name": "system_stats",
|
|
||||||
"type": "base",
|
|
||||||
"system": false,
|
|
||||||
"schema": [
|
|
||||||
{
|
|
||||||
"system": false,
|
|
||||||
"id": "h9sg148r",
|
|
||||||
"name": "system",
|
|
||||||
"type": "relation",
|
|
||||||
"required": true,
|
|
||||||
"presentable": false,
|
|
||||||
"unique": false,
|
|
||||||
"options": {
|
|
||||||
"collectionId": "2hz5ncl8tizk5nx",
|
|
||||||
"cascadeDelete": true,
|
|
||||||
"minSelect": null,
|
|
||||||
"maxSelect": 1,
|
|
||||||
"displayFields": null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"system": false,
|
|
||||||
"id": "azftn0be",
|
|
||||||
"name": "stats",
|
|
||||||
"type": "json",
|
|
||||||
"required": true,
|
|
||||||
"presentable": false,
|
|
||||||
"unique": false,
|
|
||||||
"options": {
|
|
||||||
"maxSize": 2000000
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"system": false,
|
|
||||||
"id": "m1ekhli3",
|
|
||||||
"name": "type",
|
|
||||||
"type": "select",
|
|
||||||
"required": true,
|
|
||||||
"presentable": false,
|
|
||||||
"unique": false,
|
|
||||||
"options": {
|
|
||||||
"maxSelect": 1,
|
|
||||||
"values": [
|
|
||||||
"1m",
|
|
||||||
"10m",
|
|
||||||
"20m",
|
|
||||||
"120m",
|
|
||||||
"480m"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"indexes": [
|
|
||||||
"CREATE INDEX ` + "`" + `idx_GxIee0j` + "`" + ` ON ` + "`" + `system_stats` + "`" + ` (` + "`" + `system` + "`" + `)"
|
|
||||||
],
|
|
||||||
"listRule": "@request.auth.id != \"\"",
|
|
||||||
"viewRule": null,
|
|
||||||
"createRule": null,
|
|
||||||
"updateRule": null,
|
|
||||||
"deleteRule": null,
|
|
||||||
"options": {}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "juohu4jipgc13v7",
|
|
||||||
"created": "2024-07-07 16:09:57.976Z",
|
|
||||||
"updated": "2024-10-12 18:55:51.623Z",
|
|
||||||
"name": "container_stats",
|
|
||||||
"type": "base",
|
|
||||||
"system": false,
|
|
||||||
"schema": [
|
|
||||||
{
|
|
||||||
"system": false,
|
|
||||||
"id": "hutcu6ps",
|
|
||||||
"name": "system",
|
|
||||||
"type": "relation",
|
|
||||||
"required": true,
|
|
||||||
"presentable": false,
|
|
||||||
"unique": false,
|
|
||||||
"options": {
|
|
||||||
"collectionId": "2hz5ncl8tizk5nx",
|
|
||||||
"cascadeDelete": true,
|
|
||||||
"minSelect": null,
|
|
||||||
"maxSelect": 1,
|
|
||||||
"displayFields": null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"system": false,
|
|
||||||
"id": "r39hhnil",
|
|
||||||
"name": "stats",
|
|
||||||
"type": "json",
|
|
||||||
"required": true,
|
|
||||||
"presentable": false,
|
|
||||||
"unique": false,
|
|
||||||
"options": {
|
|
||||||
"maxSize": 2000000
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"system": false,
|
|
||||||
"id": "vo7iuj96",
|
|
||||||
"name": "type",
|
|
||||||
"type": "select",
|
|
||||||
"required": true,
|
|
||||||
"presentable": false,
|
|
||||||
"unique": false,
|
|
||||||
"options": {
|
|
||||||
"maxSelect": 1,
|
|
||||||
"values": [
|
|
||||||
"1m",
|
|
||||||
"10m",
|
|
||||||
"20m",
|
|
||||||
"120m",
|
|
||||||
"480m"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"indexes": [],
|
|
||||||
"listRule": "@request.auth.id != \"\"",
|
|
||||||
"viewRule": null,
|
|
||||||
"createRule": null,
|
|
||||||
"updateRule": null,
|
|
||||||
"deleteRule": null,
|
|
||||||
"options": {}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "_pb_users_auth_",
|
|
||||||
"created": "2024-07-14 16:25:18.226Z",
|
|
||||||
"updated": "2024-10-12 22:27:19.081Z",
|
|
||||||
"name": "users",
|
|
||||||
"type": "auth",
|
|
||||||
"system": false,
|
|
||||||
"schema": [
|
|
||||||
{
|
|
||||||
"system": false,
|
|
||||||
"id": "qkbp58ae",
|
|
||||||
"name": "role",
|
|
||||||
"type": "select",
|
|
||||||
"required": false,
|
|
||||||
"presentable": false,
|
|
||||||
"unique": false,
|
|
||||||
"options": {
|
|
||||||
"maxSelect": 1,
|
|
||||||
"values": [
|
|
||||||
"user",
|
|
||||||
"admin",
|
|
||||||
"readonly"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"system": false,
|
|
||||||
"id": "users_avatar",
|
|
||||||
"name": "avatar",
|
|
||||||
"type": "file",
|
|
||||||
"required": false,
|
|
||||||
"presentable": false,
|
|
||||||
"unique": false,
|
|
||||||
"options": {
|
|
||||||
"mimeTypes": [
|
|
||||||
"image/jpeg",
|
|
||||||
"image/png",
|
|
||||||
"image/svg+xml",
|
|
||||||
"image/gif",
|
|
||||||
"image/webp"
|
|
||||||
],
|
|
||||||
"thumbs": null,
|
|
||||||
"maxSelect": 1,
|
|
||||||
"maxSize": 5242880,
|
|
||||||
"protected": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"indexes": [],
|
|
||||||
"listRule": "id = @request.auth.id",
|
|
||||||
"viewRule": "id = @request.auth.id",
|
|
||||||
"createRule": null,
|
|
||||||
"updateRule": null,
|
|
||||||
"deleteRule": null,
|
|
||||||
"options": {
|
|
||||||
"allowEmailAuth": true,
|
|
||||||
"allowOAuth2Auth": true,
|
|
||||||
"allowUsernameAuth": false,
|
|
||||||
"exceptEmailDomains": null,
|
|
||||||
"manageRule": null,
|
|
||||||
"minPasswordLength": 8,
|
|
||||||
"onlyEmailDomains": null,
|
|
||||||
"onlyVerified": true,
|
|
||||||
"requireEmail": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "elngm8x1l60zi2v",
|
|
||||||
"created": "2024-07-15 01:16:04.044Z",
|
|
||||||
"updated": "2024-10-12 22:27:29.128Z",
|
|
||||||
"name": "alerts",
|
|
||||||
"type": "base",
|
|
||||||
"system": false,
|
|
||||||
"schema": [
|
|
||||||
{
|
|
||||||
"system": false,
|
|
||||||
"id": "hn5ly3vi",
|
|
||||||
"name": "user",
|
|
||||||
"type": "relation",
|
|
||||||
"required": true,
|
|
||||||
"presentable": false,
|
|
||||||
"unique": false,
|
|
||||||
"options": {
|
|
||||||
"collectionId": "_pb_users_auth_",
|
|
||||||
"cascadeDelete": true,
|
|
||||||
"minSelect": null,
|
|
||||||
"maxSelect": 1,
|
|
||||||
"displayFields": null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"system": false,
|
|
||||||
"id": "g5sl3jdg",
|
|
||||||
"name": "system",
|
|
||||||
"type": "relation",
|
|
||||||
"required": true,
|
|
||||||
"presentable": false,
|
|
||||||
"unique": false,
|
|
||||||
"options": {
|
|
||||||
"collectionId": "2hz5ncl8tizk5nx",
|
|
||||||
"cascadeDelete": true,
|
|
||||||
"minSelect": null,
|
|
||||||
"maxSelect": 1,
|
|
||||||
"displayFields": null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"system": false,
|
|
||||||
"id": "zj3ingrv",
|
|
||||||
"name": "name",
|
|
||||||
"type": "select",
|
|
||||||
"required": true,
|
|
||||||
"presentable": false,
|
|
||||||
"unique": false,
|
|
||||||
"options": {
|
|
||||||
"maxSelect": 1,
|
|
||||||
"values": [
|
|
||||||
"Status",
|
|
||||||
"CPU",
|
|
||||||
"Memory",
|
|
||||||
"Disk",
|
|
||||||
"Temperature",
|
|
||||||
"Bandwidth"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"system": false,
|
|
||||||
"id": "o2ablxvn",
|
|
||||||
"name": "value",
|
|
||||||
"type": "number",
|
|
||||||
"required": false,
|
|
||||||
"presentable": false,
|
|
||||||
"unique": false,
|
|
||||||
"options": {
|
|
||||||
"min": null,
|
|
||||||
"max": null,
|
|
||||||
"noDecimal": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"system": false,
|
|
||||||
"id": "fstdehcq",
|
|
||||||
"name": "min",
|
|
||||||
"type": "number",
|
|
||||||
"required": false,
|
|
||||||
"presentable": false,
|
|
||||||
"unique": false,
|
|
||||||
"options": {
|
|
||||||
"min": null,
|
|
||||||
"max": 60,
|
|
||||||
"noDecimal": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"system": false,
|
|
||||||
"id": "6hgdf6hs",
|
|
||||||
"name": "triggered",
|
|
||||||
"type": "bool",
|
|
||||||
"required": false,
|
|
||||||
"presentable": false,
|
|
||||||
"unique": false,
|
|
||||||
"options": {}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"indexes": [],
|
|
||||||
"listRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
|
|
||||||
"viewRule": "",
|
|
||||||
"createRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
|
|
||||||
"updateRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
|
|
||||||
"deleteRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
|
|
||||||
"options": {}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "4afacsdnlu8q8r2",
|
|
||||||
"created": "2024-09-12 17:42:55.324Z",
|
|
||||||
"updated": "2024-10-12 18:55:51.624Z",
|
|
||||||
"name": "user_settings",
|
|
||||||
"type": "base",
|
|
||||||
"system": false,
|
|
||||||
"schema": [
|
|
||||||
{
|
|
||||||
"system": false,
|
|
||||||
"id": "d5vztyxa",
|
|
||||||
"name": "user",
|
|
||||||
"type": "relation",
|
|
||||||
"required": true,
|
|
||||||
"presentable": false,
|
|
||||||
"unique": false,
|
|
||||||
"options": {
|
|
||||||
"collectionId": "_pb_users_auth_",
|
|
||||||
"cascadeDelete": false,
|
|
||||||
"minSelect": null,
|
|
||||||
"maxSelect": 1,
|
|
||||||
"displayFields": null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"system": false,
|
|
||||||
"id": "xcx4qgqq",
|
|
||||||
"name": "settings",
|
|
||||||
"type": "json",
|
|
||||||
"required": false,
|
|
||||||
"presentable": false,
|
|
||||||
"unique": false,
|
|
||||||
"options": {
|
|
||||||
"maxSize": 2000000
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"indexes": [
|
|
||||||
"CREATE UNIQUE INDEX ` + "`" + `idx_30Lwgf2` + "`" + ` ON ` + "`" + `user_settings` + "`" + ` (` + "`" + `user` + "`" + `)"
|
|
||||||
],
|
|
||||||
"listRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
|
|
||||||
"viewRule": null,
|
|
||||||
"createRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
|
|
||||||
"updateRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
|
|
||||||
"deleteRule": null,
|
|
||||||
"options": {}
|
|
||||||
}
|
|
||||||
]`
|
|
||||||
|
|
||||||
collections := []*models.Collection{}
|
|
||||||
if err := json.Unmarshal([]byte(jsonData), &collections); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return daos.New(db).ImportCollections(collections, true, nil)
|
|
||||||
}, func(db dbx.Builder) error {
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
1258
beszel/migrations/1732489917_collections_snapshot.go
Normal file
98
beszel/migrations/1738624382_updated_users.go
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
m "github.com/pocketbase/pocketbase/migrations"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
m.Register(func(app core.App) error {
|
||||||
|
collection, err := app.FindCollectionByNameOrId("_pb_users_auth_")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// update collection data
|
||||||
|
if err := json.Unmarshal([]byte(`{
|
||||||
|
"indexes": [
|
||||||
|
"CREATE UNIQUE INDEX ` + "`" + `__pb_users_auth__email_idx` + "`" + ` ON ` + "`" + `users` + "`" + ` (` + "`" + `email` + "`" + `) WHERE ` + "`" + `email` + "`" + ` != ''",
|
||||||
|
"CREATE UNIQUE INDEX ` + "`" + `__pb_users_auth__tokenKey_idx` + "`" + ` ON ` + "`" + `users` + "`" + ` (` + "`" + `tokenKey` + "`" + `)"
|
||||||
|
]
|
||||||
|
}`), &collection); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// remove field
|
||||||
|
collection.Fields.RemoveById("text4166911607")
|
||||||
|
|
||||||
|
// update field
|
||||||
|
if err := collection.Fields.AddMarshaledJSONAt(3, []byte(`{
|
||||||
|
"exceptDomains": null,
|
||||||
|
"hidden": false,
|
||||||
|
"id": "email3885137012",
|
||||||
|
"name": "email",
|
||||||
|
"onlyDomains": null,
|
||||||
|
"presentable": false,
|
||||||
|
"required": true,
|
||||||
|
"system": true,
|
||||||
|
"type": "email"
|
||||||
|
}`)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return app.Save(collection)
|
||||||
|
}, func(app core.App) error {
|
||||||
|
collection, err := app.FindCollectionByNameOrId("_pb_users_auth_")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// update collection data
|
||||||
|
if err := json.Unmarshal([]byte(`{
|
||||||
|
"indexes": [
|
||||||
|
"CREATE UNIQUE INDEX ` + "`" + `__pb_users_auth__username_idx` + "`" + ` ON ` + "`" + `users` + "`" + ` (username COLLATE NOCASE)",
|
||||||
|
"CREATE UNIQUE INDEX ` + "`" + `__pb_users_auth__email_idx` + "`" + ` ON ` + "`" + `users` + "`" + ` (` + "`" + `email` + "`" + `) WHERE ` + "`" + `email` + "`" + ` != ''",
|
||||||
|
"CREATE UNIQUE INDEX ` + "`" + `__pb_users_auth__tokenKey_idx` + "`" + ` ON ` + "`" + `users` + "`" + ` (` + "`" + `tokenKey` + "`" + `)"
|
||||||
|
]
|
||||||
|
}`), &collection); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// add field
|
||||||
|
if err := collection.Fields.AddMarshaledJSONAt(6, []byte(`{
|
||||||
|
"autogeneratePattern": "users[0-9]{6}",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text4166911607",
|
||||||
|
"max": 150,
|
||||||
|
"min": 3,
|
||||||
|
"name": "username",
|
||||||
|
"pattern": "^[\\w][\\w\\.\\-]*$",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
}`)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// update field
|
||||||
|
if err := collection.Fields.AddMarshaledJSONAt(3, []byte(`{
|
||||||
|
"exceptDomains": null,
|
||||||
|
"hidden": false,
|
||||||
|
"id": "email3885137012",
|
||||||
|
"name": "email",
|
||||||
|
"onlyDomains": null,
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": true,
|
||||||
|
"type": "email"
|
||||||
|
}`)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return app.Save(collection)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,19 +1,29 @@
|
|||||||
package migrations
|
package migrations
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/pocketbase/dbx"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
"github.com/pocketbase/pocketbase/daos"
|
|
||||||
m "github.com/pocketbase/pocketbase/migrations"
|
m "github.com/pocketbase/pocketbase/migrations"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
var (
|
||||||
m.Register(func(db dbx.Builder) error {
|
TempAdminEmail = "_@b.b"
|
||||||
dao := daos.New(db)
|
)
|
||||||
|
|
||||||
settings, _ := dao.FindSettings()
|
func init() {
|
||||||
|
m.Register(func(app core.App) error {
|
||||||
|
// initial settings
|
||||||
|
settings := app.Settings()
|
||||||
settings.Meta.AppName = "Beszel"
|
settings.Meta.AppName = "Beszel"
|
||||||
settings.Meta.HideControls = true
|
settings.Meta.HideControls = true
|
||||||
|
settings.Logs.MinLevel = 4
|
||||||
return dao.SaveSettings(settings)
|
if err := app.Save(settings); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// create superuser
|
||||||
|
collection, _ := app.FindCollectionByNameOrId(core.CollectionNameSuperusers)
|
||||||
|
user := core.NewRecord(collection)
|
||||||
|
user.SetEmail(TempAdminEmail)
|
||||||
|
user.SetRandomPassword()
|
||||||
|
return app.Save(user)
|
||||||
}, nil)
|
}, nil)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ package site
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"embed"
|
"embed"
|
||||||
|
"io/fs"
|
||||||
"github.com/labstack/echo/v5"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed all:dist
|
//go:embed all:dist
|
||||||
var assets embed.FS
|
var distDir embed.FS
|
||||||
|
|
||||||
var Dist = echo.MustSubFS(assets, "dist")
|
// DistDirFS contains the embedded dist directory files (without the "dist" prefix)
|
||||||
|
var DistDirFS, _ = fs.Sub(distDir, "dist")
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
<!DOCTYPE html>
|
<!doctype html>
|
||||||
<html lang="en" dir="ltr">
|
<html lang="en" dir="ltr">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
|
<link rel="manifest" href="./static/manifest.json" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="./static/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Beszel</title>
|
<title>Beszel</title>
|
||||||
|
<script>window.BASE_PATH = "%BASE_URL%"</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -1,7 +1,36 @@
|
|||||||
import type { LinguiConfig } from "@lingui/conf"
|
import type { LinguiConfig } from "@lingui/conf"
|
||||||
|
|
||||||
const config: LinguiConfig = {
|
const config: LinguiConfig = {
|
||||||
locales: ["en", "ar", "de", "es", "fr", "it", "ja", "ko", "pt", "tr", "ru", "uk", "vi", "zh-CN", "zh-HK"],
|
locales: [
|
||||||
|
"en",
|
||||||
|
"ar",
|
||||||
|
"bg",
|
||||||
|
"cs",
|
||||||
|
"da",
|
||||||
|
"de",
|
||||||
|
"es",
|
||||||
|
"fa",
|
||||||
|
"fr",
|
||||||
|
"hr",
|
||||||
|
"hu",
|
||||||
|
"it",
|
||||||
|
"is",
|
||||||
|
"ja",
|
||||||
|
"ko",
|
||||||
|
"nl",
|
||||||
|
"no",
|
||||||
|
"pl",
|
||||||
|
"pt",
|
||||||
|
"tr",
|
||||||
|
"ru",
|
||||||
|
"sl",
|
||||||
|
"sv",
|
||||||
|
"uk",
|
||||||
|
"vi",
|
||||||
|
"zh",
|
||||||
|
"zh-CN",
|
||||||
|
"zh-HK",
|
||||||
|
],
|
||||||
sourceLocale: "en",
|
sourceLocale: "en",
|
||||||
compileNamespace: "ts",
|
compileNamespace: "ts",
|
||||||
catalogs: [
|
catalogs: [
|
||||||
|
|||||||
1503
beszel/site/package-lock.json
generated
@@ -5,61 +5,61 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "lingui extract --overwrite && lingui compile && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"sync": "lingui extract --overwrite && lingui compile",
|
"sync": "lingui extract --overwrite && lingui compile",
|
||||||
"sync_and_purge": "lingui extract --overwrite --clean && lingui compile"
|
"sync_and_purge": "lingui extract --overwrite --clean && lingui compile"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@henrygd/queue": "^1.0.7",
|
"@henrygd/queue": "^1.0.7",
|
||||||
"@lingui/detect-locale": "^4.13.0",
|
"@lingui/detect-locale": "^4.14.1",
|
||||||
"@lingui/macro": "^4.13.0",
|
"@lingui/macro": "^4.14.1",
|
||||||
"@lingui/react": "^4.13.0",
|
"@lingui/react": "^4.14.1",
|
||||||
"@nanostores/react": "^0.7.3",
|
"@nanostores/react": "^0.7.3",
|
||||||
"@nanostores/router": "^0.11.0",
|
"@nanostores/router": "^0.11.0",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.2",
|
"@radix-ui/react-alert-dialog": "^1.1.6",
|
||||||
"@radix-ui/react-checkbox": "^1.1.2",
|
"@radix-ui/react-checkbox": "^1.1.4",
|
||||||
"@radix-ui/react-dialog": "^1.1.2",
|
"@radix-ui/react-dialog": "^1.1.6",
|
||||||
"@radix-ui/react-direction": "^1.1.0",
|
"@radix-ui/react-direction": "^1.1.0",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
"@radix-ui/react-dropdown-menu": "^2.1.6",
|
||||||
"@radix-ui/react-label": "^2.1.0",
|
"@radix-ui/react-label": "^2.1.2",
|
||||||
"@radix-ui/react-select": "^2.1.2",
|
"@radix-ui/react-select": "^2.1.6",
|
||||||
"@radix-ui/react-separator": "^1.1.0",
|
"@radix-ui/react-separator": "^1.1.2",
|
||||||
"@radix-ui/react-slider": "^1.2.1",
|
"@radix-ui/react-slider": "^1.2.3",
|
||||||
"@radix-ui/react-slot": "^1.1.0",
|
"@radix-ui/react-slot": "^1.1.2",
|
||||||
"@radix-ui/react-switch": "^1.1.1",
|
"@radix-ui/react-switch": "^1.1.3",
|
||||||
"@radix-ui/react-tabs": "^1.1.1",
|
"@radix-ui/react-tabs": "^1.1.3",
|
||||||
"@radix-ui/react-toast": "^1.2.2",
|
"@radix-ui/react-toast": "^1.2.6",
|
||||||
"@radix-ui/react-tooltip": "^1.1.3",
|
"@radix-ui/react-tooltip": "^1.1.8",
|
||||||
"@tanstack/react-table": "^8.20.5",
|
"@tanstack/react-table": "^8.20.6",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.0.0",
|
"cmdk": "^1.0.4",
|
||||||
"d3-time": "^3.1.0",
|
"d3-time": "^3.1.0",
|
||||||
"lucide-react": "^0.452.0",
|
"lucide-react": "^0.452.0",
|
||||||
"nanostores": "^0.11.3",
|
"nanostores": "^0.11.3",
|
||||||
"pocketbase": "^0.21.5",
|
"pocketbase": "^0.25.1",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"recharts": "^2.13.0",
|
"recharts": "^2.15.1",
|
||||||
"tailwind-merge": "^2.5.4",
|
"tailwind-merge": "^2.6.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"valibot": "^0.36.0"
|
"valibot": "^0.36.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@lingui/cli": "^4.13.0",
|
"@lingui/cli": "^4.14.1",
|
||||||
"@lingui/swc-plugin": "^4.1.0",
|
"@lingui/swc-plugin": "^4.1.0",
|
||||||
"@lingui/vite-plugin": "^4.13.0",
|
"@lingui/vite-plugin": "^4.14.1",
|
||||||
"@types/bun": "^1.1.11",
|
"@types/bun": "^1.2.2",
|
||||||
"@types/react": "^18.3.11",
|
"@types/react": "^18.3.18",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.5",
|
||||||
"@vitejs/plugin-react-swc": "^3.7.1",
|
"@vitejs/plugin-react-swc": "^3.7.2",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"postcss": "^8.4.47",
|
"postcss": "^8.5.1",
|
||||||
"tailwindcss": "^3.4.14",
|
"tailwindcss": "^3.4.17",
|
||||||
"tailwindcss-rtl": "^0.9.0",
|
"tailwindcss-rtl": "^0.9.0",
|
||||||
"typescript": "^5.6.3",
|
"typescript": "^5.7.3",
|
||||||
"vite": "^5.4.9"
|
"vite": "^5.4.14"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@nanostores/router": {
|
"@nanostores/router": {
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12.2 6.9c-1 0-2.5-1-4-1-2 0-4 1.1-5 3-2 3.6-.5 9 1.5 12 1 1.5 2.3 3.2 3.8 3.1 1.6 0 2.1-1 4-1 1.8 0 2.3 1 4 1 1.6 0 2.6-1.5 3.6-3a13 13 0 0 0 1.7-3.4 5.3 5.3 0 0 1-.6-9.4 5.6 5.6 0 0 0-4.4-2.4C14.8 5.6 13 7 12.2 7zm3.3-3c.9-1 1.4-2.5 1.3-3.9-1.2 0-2.7.8-3.6 1.8A5 5 0 0 0 12 5.5c1.3.1 2.7-.7 3.5-1.7"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 378 B |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M.8 1.2a.8.8 0 0 0-.8 1l3.3 19.7c0 .5.5.9 1 .9h15.6a.8.8 0 0 0 .8-.7l3.3-20a.8.8 0 0 0-.8-.9zm13.7 14.3h-5l-1.3-7h7.5z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 196 B |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M20.3 4.4a19.8 19.8 0 0 0-4.9-1.5L14.7 4C13 4 11.1 4 9.3 4.1L8.6 3a19.7 19.7 0 0 0-5 1.5C.6 9-.4 13.6.1 18.1c2 1.5 4 2.4 6 3h.1c.5-.6.9-1.3 1.2-2l-1.9-1V18l.4-.3c4 1.8 8.2 1.8 12.1 0h.1l.4.3v.1a12.3 12.3 0 0 1-2 1l1.3 2c2-.6 4-1.5 6-3h.1c.5-5.2-.8-9.7-3.6-13.7zM8 15.4c-1.2 0-2.1-1.2-2.1-2.5s1-2.4 2.1-2.4c1.2 0 2.2 1 2.2 2.4 0 1.3-1 2.4-2.2 2.4zm8 0c-1.2 0-2.2-1.2-2.2-2.5s1-2.4 2.2-2.4c1.2 0 2.2 1 2.2 2.4 0 1.3-1 2.4-2.2 2.4Z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 506 B |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M9.1 23.7v-8H6.6V12h2.5v-1.5c0-4.1 1.8-6 5.9-6h1.4a8.7 8.7 0 0 1 1.2.3V8a8.6 8.6 0 0 0-.7 0 26.8 26.8 0 0 0-.7 0c-.7 0-1.3 0-1.7.3a1.7 1.7 0 0 0-.7.6c-.2.4-.3 1-.3 1.7V12h3.9l-.4 2.1-.3 1.6h-3.2V24a12 12 0 1 0-4.4-.3Z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 295 B |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M4.2 4.6a4.2 4.2 0 0 0-2.9 1.1C-.4 7.3 0 9.7.1 10.1c0 .4.3 1.6 1.2 2.7C3 15 6.8 15 6.8 15S7.3 16 8 17c1 1.3 2 2.3 2.9 2.4H18s.4 0 1-.4c.6-.3 1-.9 1-.9s.6-.5 1.3-1.7l.5-1s2.1-4.6 2.1-9c0-1.2-.4-1.5-.4-1.5l-.4-.2s-4.5.3-6.8.3h-1.5v4.5l-.6-.3V5h-3.5l-6-.4h-.6zm.4 1.8s.3 2.3.7 3.6c.2 1.1 1 3 1 3l-1.7-.3c-1-.4-1.4-.8-1.4-.8s-.8-.5-1.1-1.5c-.7-1.7 0-2.7 0-2.7s.2-.9 1.4-1.1c.4-.2.9-.2 1-.2zM12.9 9l.5.1.9.4-.6 1.1a.7.7 0 0 0-.6.4.7.7 0 0 0 .1.7l-1 2a.7.7 0 0 0-.6.5.7.7 0 0 0 .3.7.7.7 0 0 0 1-.2.7.7 0 0 0-.2-.8l1-2a.7.7 0 0 0 .2 0 .7.7 0 0 0 .3 0 8.8 8.8 0 0 1 1 .4.8.8 0 0 1 .3.3l-.1.6c0 .3-.7 1.5-.7 1.5a.7.7 0 0 0-.7.5.7.7 0 1 0 1.2-.2l.2-.5.5-1.1c0-.1.2-.4.1-.8a1 1 0 0 0-.5-.7l-1-.6-.1-.2a.7.7 0 0 0-.2-.3l.5-1 3 1.4s.4.2.5.6v.6L16 16.8s-.2.5-.7.5a1 1 0 0 1-.4 0h-.2L10.4 15s-.4-.2-.5-.6l.1-.7 2-4.2s.3-.4.5-.5A.9.9 0 0 1 13 9z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 907 B |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0zm6 5.3c.4 0 .7.3.7.6v1.5a.6.6 0 0 1-.6.6H9.8C8.8 8 8 8.8 8 9.8v5.6c0 .3.3.6.6.6h5.6c1 0 1.8-.8 1.8-1.8V14a.6.6 0 0 0-.6-.6h-4.1a.6.6 0 0 1-.6-.6v-1.4a.6.6 0 0 1 .6-.6H18c.3 0 .6.2.6.6v3.4a4 4 0 0 1-4 4H5.9a.6.6 0 0 1-.6-.6V9.8a4.4 4.4 0 0 1 4.5-4.5H18Z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 406 B |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 .3a12 12 0 0 0-3.8 23.4c.6.1.8-.3.8-.6v-2c-3.3.7-4-1.6-4-1.6-.6-1.4-1.4-1.8-1.4-1.8-1-.7.1-.7.1-.7 1.2 0 1.9 1.2 1.9 1.2 1 1.8 2.8 1.3 3.5 1 0-.8.4-1.3.7-1.6-2.7-.3-5.5-1.3-5.5-6 0-1.2.5-2.3 1.3-3.1-.2-.4-.6-1.6 0-3.2 0 0 1-.3 3.4 1.2a11.5 11.5 0 0 1 6 0c2.3-1.5 3.3-1.2 3.3-1.2.6 1.6.2 2.8 0 3.2.9.8 1.3 1.9 1.3 3.2 0 4.6-2.8 5.6-5.5 5.9.5.4.9 1 .9 2.2v3.3c0 .3.1.7.8.6A12 12 0 0 0 12 .3"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 470 B |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M23.6 9.6 20.3 1a.9.9 0 0 0-.3-.4.9.9 0 0 0-1 0 .9.9 0 0 0-.3.5l-2.2 6.7h-9L5.3 1.1A.9.9 0 0 0 5 .6a.9.9 0 0 0-1 0 .9.9 0 0 0-.3.4L.4 9.5a6 6 0 0 0 2 7.1l5 3.8 2.5 1.8 1.5 1.1a1 1 0 0 0 1.2 0l1.5-1 2.5-2 5-3.7a6 6 0 0 0 2-7z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 302 B |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12.5 11v3.2h7.8a7 7 0 0 1-1.8 4.1 8 8 0 0 1-6 2.4c-4.8 0-8.6-3.9-8.6-8.7a8.6 8.6 0 0 1 14.5-6.4l2.3-2.3C18.7 1.4 16 0 12.5 0 5.9 0 .3 5.4.3 12S6 24 12.5 24a11 11 0 0 0 8.4-3.4c2.1-2.1 2.8-5.2 2.8-7.6 0-.8 0-1.5-.2-2h-11z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 299 B |
BIN
beszel/site/public/static/icon.png
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M7 0C5.8.2 5 .4 4.1.7 3.3 1 2.7 1.4 2 2c-.7.7-1 1.4-1.4 2.2C.3 4.9.1 5.8.1 7a84.6 84.6 0 0 0 .5 12.8c.4.8.8 1.4 1.4 2.1.7.7 1.4 1 2.2 1.4.7.3 1.6.5 2.9.5a85 85 0 0 0 12.8-.5c.8-.4 1.4-.8 2.1-1.4.7-.7 1-1.4 1.4-2.2.3-.7.5-1.6.5-2.9a85 85 0 0 0-.5-12.8C23 3.3 22.6 2.7 22 2c-.7-.7-1.4-1-2.2-1.4-.7-.3-1.6-.5-2.9-.5A85.5 85.5 0 0 0 7 0m.2 21.7c-1.2 0-1.8-.3-2.3-.4-.5-.2-1-.5-1.3-1-.5-.3-.7-.7-1-1.3-.1-.4-.3-1-.4-2.2a84.8 84.8 0 0 1 .4-12c.2-.5.5-1 1-1.3.3-.5.7-.7 1.3-1 .4-.1 1-.3 2.2-.4a84.4 84.4 0 0 1 12 .4c.5.3 1 .5 1.3 1 .5.3.7.7 1 1.3.1.4.3 1 .4 2.2a82.7 82.7 0 0 1-.4 12c-.2.5-.5 1-1 1.3-.3.5-.7.7-1.3 1-.4.1-1 .3-2.2.4a84.9 84.9 0 0 1-9.7 0M17 5.6A1.4 1.4 0 1 0 18.4 4 1.4 1.4 0 0 0 17 5.6M5.8 12a6.2 6.2 0 1 0 12.4 0 6.2 6.2 0 0 0-12.4 0M8 12a4 4 0 1 1 4 4 4 4 0 0 1-4-4"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 856 B |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 257 B |
14
beszel/site/public/static/manifest.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "Beszel",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "icon.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"start_url": "../",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#202225",
|
||||||
|
"theme_color": "#202225"
|
||||||
|
}
|
||||||
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M14.5.9 11 2.7v18.1c-4.1-.5-7.3-2.7-7.3-5.5 0-2.5 2.8-4.7 6.7-5.4V7.6C4.4 8.3 0 11.5 0 15.3c0 4 4.7 7.3 11 7.8l3.5-1.7V.9m.7 6.7V10c1.4.3 2.7.7 3.7 1.3l-2 1.1L24 14l-.5-5.2-1.9 1c-1.7-1-4-1.8-6.4-2z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 276 B |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M23 7.2c0-3-2.4-5.6-5.2-6.5-3.5-1.1-8.1-1-11.4.6-4 2-5.3 6-5.4 10.2C1 15 1.3 24 6.4 24c3.8 0 4.3-4.8 6-7.1 1.3-1.7 3-2.2 4.9-2.7a7.1 7.1 0 0 0 5.7-7Z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 227 B |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.7 0 12 0zm5.5 17.3c-.2.4-.6.5-1 .3-2.8-1.8-6.4-2.1-10.6-1.2-.4.2-.7-.1-.9-.5 0-.4.2-.8.6-.9 4.5-1 8.5-.6 11.6 1.3.4.2.5.7.3 1zM19 14c-.3.5-.9.6-1.3.3-3.2-2-8.2-2.5-12-1.3-.4 0-1-.2-1-.6-.2-.5 0-1 .5-1.2 4.4-1.3 9.8-.6 13.5 1.6.4.2.6.8.3 1.2zm0-3.3A19.9 19.9 0 0 0 5.3 9.3c-.6.2-1.2-.2-1.4-.7-.2-.6.2-1.2.7-1.4 4.3-1.3 11.3-1 15.7 1.6.6.3.7 1 .4 1.6-.3.4-1 .6-1.5.3z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 495 B |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="m15.4 18-2.1-4.2h-3l5 10.2 5.2-10.2h-3m-7-5.6 2.8 5.6h4.2L10.5 0l-7 13.8h4.1"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 154 B |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M11.6 4.7h1.7V10h-1.7zm4.7 0H18V10h-1.7zM6 0 1.7 4.3v15.4H7V24l4.2-4.3h3.5l7.7-7.7V0zm14.6 11.1L17 14.6h-3.4l-3 3v-3H7V1.7h13.7Z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 206 B |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M22.5 6c-.8.3-1.6.6-2.5.7.9-.5 1.6-1.4 1.9-2.4-.8.5-1.8.9-2.7 1a4.3 4.3 0 0 0-7.3 4C8.2 9 5 7.3 3 4.8a4.2 4.2 0 0 0 1.3 5.7c-.7 0-1.3-.2-2-.5 0 2.1 1.6 3.8 3.5 4.2a4.2 4.2 0 0 1-2 .1 4.3 4.3 0 0 0 4 3A8.5 8.5 0 0 1 2.7 19h-1A12.1 12.1 0 0 0 20.3 8.8v-.6c.8-.6 1.5-1.3 2-2.2"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 371 B |
@@ -8,59 +8,27 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/components/ui/dialog"
|
} from "@/components/ui/dialog"
|
||||||
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
||||||
|
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { $publicKey, pb } from "@/lib/stores"
|
import { $publicKey, pb } from "@/lib/stores"
|
||||||
import { Copy, PlusIcon } from "lucide-react"
|
|
||||||
import { useState, useRef, MutableRefObject } from "react"
|
|
||||||
import { useStore } from "@nanostores/react"
|
|
||||||
import { cn, copyToClipboard, isReadOnlyUser } from "@/lib/utils"
|
import { cn, copyToClipboard, isReadOnlyUser } from "@/lib/utils"
|
||||||
import { navigate } from "./router"
|
import { i18n } from "@lingui/core"
|
||||||
import { Trans } from "@lingui/macro"
|
import { t, Trans } from "@lingui/macro"
|
||||||
|
import { useStore } from "@nanostores/react"
|
||||||
|
import { ChevronDownIcon, Copy, PlusIcon } from "lucide-react"
|
||||||
|
import { memo, useRef, useState } from "react"
|
||||||
|
import { basePath, navigate } from "./router"
|
||||||
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "./ui/dropdown-menu"
|
||||||
|
import { SystemRecord } from "@/types"
|
||||||
|
|
||||||
export function AddSystemButton({ className }: { className?: string }) {
|
export function AddSystemButton({ className }: { className?: string }) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const port = useRef() as MutableRefObject<HTMLInputElement>
|
let opened = useRef(false)
|
||||||
const publicKey = useStore($publicKey)
|
if (open) {
|
||||||
|
opened.current = true
|
||||||
function copyDockerCompose(port: string) {
|
|
||||||
copyToClipboard(`services:
|
|
||||||
beszel-agent:
|
|
||||||
image: "henrygd/beszel-agent"
|
|
||||||
container_name: "beszel-agent"
|
|
||||||
restart: unless-stopped
|
|
||||||
network_mode: host
|
|
||||||
volumes:
|
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
|
||||||
# monitor other disks / partitions by mounting a folder in /extra-filesystems
|
|
||||||
# - /mnt/disk1/.beszel:/extra-filesystems/disk1:ro
|
|
||||||
environment:
|
|
||||||
PORT: ${port}
|
|
||||||
KEY: "${publicKey}"`)
|
|
||||||
}
|
|
||||||
|
|
||||||
function copyInstallCommand(port: string) {
|
|
||||||
copyToClipboard(
|
|
||||||
`curl -sL https://raw.githubusercontent.com/henrygd/beszel/main/supplemental/scripts/install-agent.sh -o install-agent.sh && chmod +x install-agent.sh && ./install-agent.sh -p ${port} -k "${publicKey}"`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSubmit(e: SubmitEvent) {
|
|
||||||
e.preventDefault()
|
|
||||||
const formData = new FormData(e.target as HTMLFormElement)
|
|
||||||
const data = Object.fromEntries(formData) as Record<string, any>
|
|
||||||
data.users = pb.authStore.model!.id
|
|
||||||
try {
|
|
||||||
setOpen(false)
|
|
||||||
await pb.collection("systems").create(data)
|
|
||||||
navigate("/")
|
|
||||||
// console.log(record)
|
|
||||||
} catch (e) {
|
|
||||||
console.log(e)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -76,112 +44,230 @@ export function AddSystemButton({ className }: { className?: string }) {
|
|||||||
</Trans>
|
</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="w-[90%] sm:max-w-[440px] rounded-lg">
|
{opened.current && <SystemDialog setOpen={setOpen} />}
|
||||||
<Tabs defaultValue="docker">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="mb-2">
|
|
||||||
<Trans>Add New System</Trans>
|
|
||||||
</DialogTitle>
|
|
||||||
<TabsList className="grid w-full grid-cols-2">
|
|
||||||
<TabsTrigger value="docker">Docker</TabsTrigger>
|
|
||||||
<TabsTrigger value="binary">
|
|
||||||
<Trans>Binary</Trans>
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
</DialogHeader>
|
|
||||||
{/* Docker */}
|
|
||||||
<TabsContent value="docker">
|
|
||||||
<DialogDescription className="mb-4 leading-normal">
|
|
||||||
<Trans>
|
|
||||||
The agent must be running on the system to connect. Copy the
|
|
||||||
<code className="bg-muted px-1 rounded-sm leading-3">docker-compose.yml</code> for the agent below.
|
|
||||||
</Trans>
|
|
||||||
</DialogDescription>
|
|
||||||
</TabsContent>
|
|
||||||
{/* Binary */}
|
|
||||||
<TabsContent value="binary">
|
|
||||||
<DialogDescription className="mb-4 leading-normal">
|
|
||||||
<Trans>
|
|
||||||
The agent must be running on the system to connect. Copy the installation command for the agent below.
|
|
||||||
</Trans>
|
|
||||||
</DialogDescription>
|
|
||||||
</TabsContent>
|
|
||||||
<form onSubmit={handleSubmit as any}>
|
|
||||||
<div className="grid gap-3 mt-1 mb-4">
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<Label htmlFor="name" className="text-end">
|
|
||||||
<Trans>Name</Trans>
|
|
||||||
</Label>
|
|
||||||
<Input id="name" name="name" className="col-span-3" required />
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<Label htmlFor="host" className="text-end">
|
|
||||||
<Trans>Host / IP</Trans>
|
|
||||||
</Label>
|
|
||||||
<Input id="host" name="host" className="col-span-3" required />
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<Label htmlFor="port" className="text-end">
|
|
||||||
<Trans>Port</Trans>
|
|
||||||
</Label>
|
|
||||||
<Input ref={port} name="port" id="port" defaultValue="45876" className="col-span-3" required />
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4 relative">
|
|
||||||
<Label htmlFor="pkey" className="text-end whitespace-pre">
|
|
||||||
<Trans comment="Use 'Key' if your language requires many more characters">Public Key</Trans>
|
|
||||||
</Label>
|
|
||||||
<Input readOnly id="pkey" value={publicKey} className="col-span-3" required></Input>
|
|
||||||
<div
|
|
||||||
className={
|
|
||||||
"h-6 w-24 bg-gradient-to-r rtl:bg-gradient-to-l from-transparent to-background to-65% absolute end-1 pointer-events-none"
|
|
||||||
}
|
|
||||||
></div>
|
|
||||||
<TooltipProvider delayDuration={100}>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant={"link"}
|
|
||||||
className="absolute end-0"
|
|
||||||
onClick={() => copyToClipboard(publicKey)}
|
|
||||||
>
|
|
||||||
<Copy className="h-4 w-4 " />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>
|
|
||||||
<Trans>Click to copy</Trans>
|
|
||||||
</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* Docker */}
|
|
||||||
<TabsContent value="docker">
|
|
||||||
<DialogFooter className="flex justify-end gap-2 sm:w-[calc(100%+20px)] sm:-ms-[20px]">
|
|
||||||
<Button type="button" variant={"ghost"} onClick={() => copyDockerCompose(port.current.value)}>
|
|
||||||
<Trans>Copy</Trans> docker compose
|
|
||||||
</Button>
|
|
||||||
<Button>
|
|
||||||
<Trans>Add system</Trans>
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</TabsContent>
|
|
||||||
{/* Binary */}
|
|
||||||
<TabsContent value="binary">
|
|
||||||
<DialogFooter className="flex justify-end gap-2 sm:w-[calc(100%+20px)] sm:-ms-[20px]">
|
|
||||||
<Button type="button" variant={"ghost"} onClick={() => copyInstallCommand(port.current.value)}>
|
|
||||||
<Trans>Copy Linux command</Trans>
|
|
||||||
</Button>
|
|
||||||
<Button>
|
|
||||||
<Trans>Add system</Trans>
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</TabsContent>
|
|
||||||
</form>
|
|
||||||
</Tabs>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function copyDockerCompose(port = "45876", publicKey: string) {
|
||||||
|
copyToClipboard(`services:
|
||||||
|
beszel-agent:
|
||||||
|
image: "henrygd/beszel-agent"
|
||||||
|
container_name: "beszel-agent"
|
||||||
|
restart: unless-stopped
|
||||||
|
network_mode: host
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
# monitor other disks / partitions by mounting a folder in /extra-filesystems
|
||||||
|
# - /mnt/disk/.beszel:/extra-filesystems/sda1:ro
|
||||||
|
environment:
|
||||||
|
PORT: ${port}
|
||||||
|
KEY: "${publicKey}"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyDockerRun(port = "45876", publicKey: string) {
|
||||||
|
copyToClipboard(
|
||||||
|
`docker run -d --name beszel-agent --network host --restart unless-stopped -v /var/run/docker.sock:/var/run/docker.sock:ro -e KEY="${publicKey}" -e PORT=${port} henrygd/beszel-agent:latest`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyInstallCommand(port = "45876", publicKey: string) {
|
||||||
|
let cmd = `curl -sL https://raw.githubusercontent.com/henrygd/beszel/main/supplemental/scripts/install-agent.sh -o install-agent.sh && chmod +x install-agent.sh && ./install-agent.sh -p ${port} -k "${publicKey}"`
|
||||||
|
// add china mirrors flag if zh-CN
|
||||||
|
if ((i18n.locale + navigator.language).includes("zh-CN")) {
|
||||||
|
cmd += ` --china-mirrors`
|
||||||
|
}
|
||||||
|
copyToClipboard(cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SystemDialog component for adding or editing a system.
|
||||||
|
* @param {Object} props - The component props.
|
||||||
|
* @param {function} props.setOpen - Function to set the open state of the dialog.
|
||||||
|
* @param {SystemRecord} [props.system] - Optional system record for editing an existing system.
|
||||||
|
*/
|
||||||
|
export const SystemDialog = memo(({ setOpen, system }: { setOpen: (open: boolean) => void; system?: SystemRecord }) => {
|
||||||
|
const publicKey = useStore($publicKey)
|
||||||
|
const port = useRef<HTMLInputElement>(null)
|
||||||
|
const [hostValue, setHostValue] = useState(system?.host ?? "")
|
||||||
|
const isUnixSocket = hostValue.startsWith("/")
|
||||||
|
|
||||||
|
async function handleSubmit(e: SubmitEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
const formData = new FormData(e.target as HTMLFormElement)
|
||||||
|
const data = Object.fromEntries(formData) as Record<string, any>
|
||||||
|
data.users = pb.authStore.record!.id
|
||||||
|
try {
|
||||||
|
setOpen(false)
|
||||||
|
if (system) {
|
||||||
|
await pb.collection("systems").update(system.id, { ...data, status: "pending" })
|
||||||
|
} else {
|
||||||
|
await pb.collection("systems").create(data)
|
||||||
|
}
|
||||||
|
navigate(basePath)
|
||||||
|
// console.log(record)
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogContent
|
||||||
|
className="w-[90%] sm:w-auto sm:ns-dialog max-w-full rounded-lg"
|
||||||
|
onCloseAutoFocus={() => {
|
||||||
|
setHostValue(system?.host ?? "")
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tabs defaultValue="docker">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="mb-2">
|
||||||
|
{system ? `${t`Edit`} ${system?.name}` : <Trans>Add New System</Trans>}
|
||||||
|
</DialogTitle>
|
||||||
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
|
<TabsTrigger value="docker">Docker</TabsTrigger>
|
||||||
|
<TabsTrigger value="binary">
|
||||||
|
<Trans>Binary</Trans>
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</DialogHeader>
|
||||||
|
{/* Docker (set tab index to prevent auto focusing content in edit system dialog) */}
|
||||||
|
<TabsContent value="docker" tabIndex={-1}>
|
||||||
|
<DialogDescription className="mb-4 leading-normal w-0 min-w-full">
|
||||||
|
<Trans>
|
||||||
|
The agent must be running on the system to connect. Copy the
|
||||||
|
<code className="bg-muted px-1 rounded-sm leading-3">docker-compose.yml</code> for the agent below.
|
||||||
|
</Trans>
|
||||||
|
</DialogDescription>
|
||||||
|
</TabsContent>
|
||||||
|
{/* Binary */}
|
||||||
|
<TabsContent value="binary">
|
||||||
|
<DialogDescription className="mb-4 leading-normal w-0 min-w-full">
|
||||||
|
<Trans>
|
||||||
|
The agent must be running on the system to connect. Copy the installation command for the agent below.
|
||||||
|
</Trans>
|
||||||
|
</DialogDescription>
|
||||||
|
</TabsContent>
|
||||||
|
<form onSubmit={handleSubmit as any}>
|
||||||
|
<div className="grid xs:grid-cols-[auto_1fr] gap-y-3 gap-x-4 items-center mt-1 mb-4">
|
||||||
|
<Label htmlFor="name" className="xs:text-end">
|
||||||
|
<Trans>Name</Trans>
|
||||||
|
</Label>
|
||||||
|
<Input id="name" name="name" defaultValue={system?.name} required />
|
||||||
|
<Label htmlFor="host" className="xs:text-end">
|
||||||
|
<Trans>Host / IP</Trans>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="host"
|
||||||
|
name="host"
|
||||||
|
value={hostValue}
|
||||||
|
required
|
||||||
|
onChange={(e) => {
|
||||||
|
setHostValue(e.target.value)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="port" className={cn("xs:text-end", isUnixSocket && "hidden")}>
|
||||||
|
<Trans>Port</Trans>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
ref={port}
|
||||||
|
name="port"
|
||||||
|
id="port"
|
||||||
|
defaultValue={system?.port || "45876"}
|
||||||
|
required={!isUnixSocket}
|
||||||
|
className={cn(isUnixSocket && "hidden")}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="pkey" className="xs:text-end whitespace-pre">
|
||||||
|
<Trans comment="Use 'Key' if your language requires many more characters">Public Key</Trans>
|
||||||
|
</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input readOnly id="pkey" value={publicKey} required></Input>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
"h-6 w-24 bg-gradient-to-r rtl:bg-gradient-to-l from-transparent to-background to-65% absolute top-2 end-1 pointer-events-none"
|
||||||
|
}
|
||||||
|
></div>
|
||||||
|
<TooltipProvider delayDuration={100}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={"link"}
|
||||||
|
className="absolute end-0 top-0"
|
||||||
|
onClick={() => copyToClipboard(publicKey)}
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4 " />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>
|
||||||
|
<Trans>Click to copy</Trans>
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="flex justify-end gap-x-2 gap-y-3 flex-col mt-5">
|
||||||
|
{/* Docker */}
|
||||||
|
<TabsContent value="docker" className="contents">
|
||||||
|
<CopyButton
|
||||||
|
text={t`Copy` + " docker compose"}
|
||||||
|
onClick={() => copyDockerCompose(isUnixSocket ? hostValue : port.current?.value, publicKey)}
|
||||||
|
dropdownText={t`Copy` + " docker run"}
|
||||||
|
dropdownOnClick={() => copyDockerRun(isUnixSocket ? hostValue : port.current?.value, publicKey)}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
{/* Binary */}
|
||||||
|
<TabsContent value="binary" className="contents">
|
||||||
|
<CopyButton
|
||||||
|
text={t`Copy Linux command`}
|
||||||
|
onClick={() => copyInstallCommand(isUnixSocket ? hostValue : port.current?.value, publicKey)}
|
||||||
|
dropdownText={t`Manual setup instructions`}
|
||||||
|
dropdownUrl="https://beszel.dev/guide/agent-installation#binary"
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
{/* Save */}
|
||||||
|
<Button>{system ? <Trans>Save system</Trans> : <Trans>Add system</Trans>}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Tabs>
|
||||||
|
</DialogContent>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
interface CopyButtonProps {
|
||||||
|
text: string
|
||||||
|
onClick: () => void
|
||||||
|
dropdownText: string
|
||||||
|
dropdownOnClick?: () => void
|
||||||
|
dropdownUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const CopyButton = memo((props: CopyButtonProps) => {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-0 rounded-lg">
|
||||||
|
<Button type="button" variant="outline" onClick={props.onClick} className="rounded-e-none dark:border-e-0 grow">
|
||||||
|
{props.text}
|
||||||
|
</Button>
|
||||||
|
<div className="w-px h-full bg-muted"></div>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline" className={"px-2 rounded-s-none border-s-0"}>
|
||||||
|
<ChevronDownIcon />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
{props.dropdownUrl ? (
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<a href={props.dropdownUrl} target="_blank" rel="noopener noreferrer">
|
||||||
|
{props.dropdownText}
|
||||||
|
</a>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : (
|
||||||
|
<DropdownMenuItem onClick={props.dropdownOnClick}>{props.dropdownText}</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { AlertInfo, AlertRecord, SystemRecord } from "@/types"
|
|||||||
import { lazy, Suspense, useRef, useState } from "react"
|
import { lazy, Suspense, useRef, useState } from "react"
|
||||||
import { toast } from "../ui/use-toast"
|
import { toast } from "../ui/use-toast"
|
||||||
import { RecordOptions } from "pocketbase"
|
import { RecordOptions } from "pocketbase"
|
||||||
import { newQueue, Queue } from "@henrygd/queue"
|
|
||||||
import { Trans, t, Plural } from "@lingui/macro"
|
import { Trans, t, Plural } from "@lingui/macro"
|
||||||
|
|
||||||
interface AlertData {
|
interface AlertData {
|
||||||
@@ -20,8 +19,6 @@ interface AlertData {
|
|||||||
|
|
||||||
const Slider = lazy(() => import("@/components/ui/slider"))
|
const Slider = lazy(() => import("@/components/ui/slider"))
|
||||||
|
|
||||||
let queue: Queue
|
|
||||||
|
|
||||||
const failedUpdateToast = () =>
|
const failedUpdateToast = () =>
|
||||||
toast({
|
toast({
|
||||||
title: t`Failed to update alert`,
|
title: t`Failed to update alert`,
|
||||||
@@ -49,7 +46,7 @@ export function SystemAlert({
|
|||||||
} else if (checked) {
|
} else if (checked) {
|
||||||
pb.collection("alerts").create({
|
pb.collection("alerts").create({
|
||||||
system: system.id,
|
system: system.id,
|
||||||
user: pb.authStore.model!.id,
|
user: pb.authStore.record!.id,
|
||||||
name: data.key,
|
name: data.key,
|
||||||
value: value,
|
value: value,
|
||||||
min: min,
|
min: min,
|
||||||
@@ -88,11 +85,7 @@ export function SystemAlertGlobal({
|
|||||||
data.checked = false
|
data.checked = false
|
||||||
data.val = data.min = 0
|
data.val = data.min = 0
|
||||||
|
|
||||||
data.updateAlert = (checked: boolean, value: number, min: number) => {
|
data.updateAlert = async (checked: boolean, value: number, min: number) => {
|
||||||
if (!queue) {
|
|
||||||
queue = newQueue(5)
|
|
||||||
}
|
|
||||||
|
|
||||||
const { set, populatedSet } = systemsWithExistingAlerts.current
|
const { set, populatedSet } = systemsWithExistingAlerts.current
|
||||||
|
|
||||||
// if overwrite checked, make sure all alerts will be overwritten
|
// if overwrite checked, make sure all alerts will be overwritten
|
||||||
@@ -105,48 +98,57 @@ export function SystemAlertGlobal({
|
|||||||
min,
|
min,
|
||||||
triggered: false,
|
triggered: false,
|
||||||
}
|
}
|
||||||
for (let system of systems) {
|
|
||||||
// if overwrite is false and system is in set (alert existed), skip
|
|
||||||
if (!overwrite && set.has(system.id)) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// find matching existing alert
|
|
||||||
const existingAlert = alerts.find((alert) => alert.system === system.id && data.key === alert.name)
|
|
||||||
// if first run, add system to set (alert already existed when global panel was opened)
|
|
||||||
if (existingAlert && !populatedSet && !overwrite) {
|
|
||||||
set.add(system.id)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
const requestOptions: RecordOptions = {
|
|
||||||
requestKey: system.id,
|
|
||||||
}
|
|
||||||
|
|
||||||
// checked - make sure alert is created or updated
|
// we can only send 50 in one batch
|
||||||
if (checked) {
|
let done = 0
|
||||||
if (existingAlert) {
|
|
||||||
// console.log('updating', system.name)
|
while (done < systems.length) {
|
||||||
queue
|
const batch = pb.createBatch()
|
||||||
.add(() => pb.collection("alerts").update(existingAlert.id, recordData, requestOptions))
|
let batchSize = 0
|
||||||
.catch(failedUpdateToast)
|
|
||||||
} else {
|
for (let i = done; i < Math.min(done + 50, systems.length); i++) {
|
||||||
// console.log('creating', system.name)
|
const system = systems[i]
|
||||||
queue
|
// if overwrite is false and system is in set (alert existed), skip
|
||||||
.add(() =>
|
if (!overwrite && set.has(system.id)) {
|
||||||
pb.collection("alerts").create(
|
continue
|
||||||
{
|
|
||||||
system: system.id,
|
|
||||||
user: pb.authStore.model!.id,
|
|
||||||
name: data.key,
|
|
||||||
...recordData,
|
|
||||||
},
|
|
||||||
requestOptions
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.catch(failedUpdateToast)
|
|
||||||
}
|
}
|
||||||
} else if (existingAlert) {
|
// find matching existing alert
|
||||||
// console.log('deleting', system.name)
|
const existingAlert = alerts.find((alert) => alert.system === system.id && data.key === alert.name)
|
||||||
queue.add(() => pb.collection("alerts").delete(existingAlert.id)).catch(failedUpdateToast)
|
// if first run, add system to set (alert already existed when global panel was opened)
|
||||||
|
if (existingAlert && !populatedSet && !overwrite) {
|
||||||
|
set.add(system.id)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
batchSize++
|
||||||
|
const requestOptions: RecordOptions = {
|
||||||
|
requestKey: system.id,
|
||||||
|
}
|
||||||
|
|
||||||
|
// checked - make sure alert is created or updated
|
||||||
|
if (checked) {
|
||||||
|
if (existingAlert) {
|
||||||
|
batch.collection("alerts").update(existingAlert.id, recordData, requestOptions)
|
||||||
|
} else {
|
||||||
|
batch.collection("alerts").create(
|
||||||
|
{
|
||||||
|
system: system.id,
|
||||||
|
user: pb.authStore.record!.id,
|
||||||
|
name: data.key,
|
||||||
|
...recordData,
|
||||||
|
},
|
||||||
|
requestOptions
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else if (existingAlert) {
|
||||||
|
batch.collection("alerts").delete(existingAlert.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
batchSize && batch.send()
|
||||||
|
} catch (e) {
|
||||||
|
failedUpdateToast()
|
||||||
|
} finally {
|
||||||
|
done += 50
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
systemsWithExistingAlerts.current.populatedSet = true
|
systemsWithExistingAlerts.current.populatedSet = true
|
||||||
@@ -158,13 +160,11 @@ export function SystemAlertGlobal({
|
|||||||
function AlertContent({ data }: { data: AlertData }) {
|
function AlertContent({ data }: { data: AlertData }) {
|
||||||
const { key } = data
|
const { key } = data
|
||||||
|
|
||||||
const hasSliders = !("single" in data.alert)
|
const singleDescription = data.alert.singleDesc?.()
|
||||||
|
|
||||||
const [checked, setChecked] = useState(data.checked || false)
|
const [checked, setChecked] = useState(data.checked || false)
|
||||||
const [min, setMin] = useState(data.min || (hasSliders ? 10 : 0))
|
const [min, setMin] = useState(data.min || 10)
|
||||||
const [value, setValue] = useState(data.val || (hasSliders ? 80 : 0))
|
const [value, setValue] = useState(data.val || (singleDescription ? 0 : 80))
|
||||||
|
|
||||||
const showSliders = checked && hasSliders
|
|
||||||
|
|
||||||
const newMin = useRef(min)
|
const newMin = useRef(min)
|
||||||
const newValue = useRef(value)
|
const newValue = useRef(value)
|
||||||
@@ -178,14 +178,14 @@ function AlertContent({ data }: { data: AlertData }) {
|
|||||||
<label
|
<label
|
||||||
htmlFor={`s${key}`}
|
htmlFor={`s${key}`}
|
||||||
className={cn("flex flex-row items-center justify-between gap-4 cursor-pointer p-4", {
|
className={cn("flex flex-row items-center justify-between gap-4 cursor-pointer p-4", {
|
||||||
"pb-0": showSliders,
|
"pb-0": checked,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<div className="grid gap-1 select-none">
|
<div className="grid gap-1 select-none">
|
||||||
<p className="font-semibold flex gap-3 items-center">
|
<p className="font-semibold flex gap-3 items-center">
|
||||||
<Icon className="h-4 w-4 opacity-85" /> {data.alert.name()}
|
<Icon className="h-4 w-4 opacity-85" /> {data.alert.name()}
|
||||||
</p>
|
</p>
|
||||||
{!showSliders && <span className="block text-sm text-muted-foreground">{data.alert.desc()}</span>}
|
{!checked && <span className="block text-sm text-muted-foreground">{data.alert.desc()}</span>}
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
id={`s${key}`}
|
id={`s${key}`}
|
||||||
@@ -196,9 +196,10 @@ function AlertContent({ data }: { data: AlertData }) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
{showSliders && (
|
{checked && (
|
||||||
<div className="grid sm:grid-cols-2 mt-1.5 gap-5 px-4 pb-5 tabular-nums text-muted-foreground">
|
<div className="grid sm:grid-cols-2 mt-1.5 gap-5 px-4 pb-5 tabular-nums text-muted-foreground">
|
||||||
<Suspense fallback={<div className="h-10" />}>
|
<Suspense fallback={<div className="h-10" />}>
|
||||||
|
{!singleDescription && (
|
||||||
<div>
|
<div>
|
||||||
<p id={`v${key}`} className="text-sm block h-8">
|
<p id={`v${key}`} className="text-sm block h-8">
|
||||||
<Trans>
|
<Trans>
|
||||||
@@ -220,8 +221,12 @@ function AlertContent({ data }: { data: AlertData }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
)}
|
||||||
<p id={`t${key}`} className="text-sm block h-8">
|
<div className={cn(singleDescription && "col-span-full lowercase")}>
|
||||||
|
<p id={`t${key}`} className="text-sm block h-8 first-letter:uppercase">
|
||||||
|
{singleDescription && (
|
||||||
|
<>{singleDescription}{` `}</>
|
||||||
|
)}
|
||||||
<Trans>
|
<Trans>
|
||||||
For <strong className="text-foreground">{min}</strong>{" "}
|
For <strong className="text-foreground">{min}</strong>{" "}
|
||||||
<Plural value={min} one=" minute" other=" minutes" />
|
<Plural value={min} one=" minute" other=" minutes" />
|
||||||
|
|||||||
@@ -33,11 +33,15 @@ export default memo(function AreaChartDefault({
|
|||||||
unit = " MB/s",
|
unit = " MB/s",
|
||||||
chartName,
|
chartName,
|
||||||
chartData,
|
chartData,
|
||||||
|
max,
|
||||||
|
tickFormatter,
|
||||||
}: {
|
}: {
|
||||||
maxToggled?: boolean
|
maxToggled?: boolean
|
||||||
unit?: string
|
unit?: string
|
||||||
chartName: string
|
chartName: string
|
||||||
chartData: ChartData
|
chartData: ChartData
|
||||||
|
max?: number
|
||||||
|
tickFormatter?: (value: number) => string
|
||||||
}) {
|
}) {
|
||||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||||
const { i18n } = useLingui()
|
const { i18n } = useLingui()
|
||||||
@@ -52,19 +56,21 @@ export default memo(function AreaChartDefault({
|
|||||||
return [[t`CPU Usage`, "cpu", 1, 0.4]]
|
return [[t`CPU Usage`, "cpu", 1, 0.4]]
|
||||||
} else if (chartName === "dio") {
|
} else if (chartName === "dio") {
|
||||||
return [
|
return [
|
||||||
[t({ message: "Write", comment: "Context is disk write" }), "dw", 3, 0.3],
|
[t({ message: "Write", comment: "Disk write" }), "dw", 3, 0.3],
|
||||||
[t({ message: "Read", comment: "Context is disk read" }), "dr", 1, 0.3],
|
[t({ message: "Read", comment: "Disk read" }), "dr", 1, 0.3],
|
||||||
]
|
]
|
||||||
} else if (chartName === "bw") {
|
} else if (chartName === "bw") {
|
||||||
return [
|
return [
|
||||||
[t({ message: "Sent", comment: "Context is network bytes sent (upload)" }), "ns", 5, 0.2],
|
[t({ message: "Sent", comment: "Network bytes sent (upload)" }), "ns", 5, 0.2],
|
||||||
[t({ message: "Received", comment: "Context is network bytes received (download)" }), "nr", 2, 0.2],
|
[t({ message: "Received", comment: "Network bytes received (download)" }), "nr", 2, 0.2],
|
||||||
]
|
]
|
||||||
} else if (chartName.startsWith("efs")) {
|
} else if (chartName.startsWith("efs")) {
|
||||||
return [
|
return [
|
||||||
[t`Read`, `${chartName}.w`, 3, 0.3],
|
[t`Write`, `${chartName}.w`, 3, 0.3],
|
||||||
[t`Write`, `${chartName}.r`, 1, 0.3],
|
[t`Read`, `${chartName}.r`, 1, 0.3],
|
||||||
]
|
]
|
||||||
|
} else if (chartName.startsWith("g.")) {
|
||||||
|
return [chartName.includes("mu") ? [t`Used`, chartName, 2, 0.25] : [t`Usage`, chartName, 1, 0.4]]
|
||||||
}
|
}
|
||||||
return []
|
return []
|
||||||
}, [chartName, i18n.locale])
|
}, [chartName, i18n.locale])
|
||||||
@@ -89,8 +95,14 @@ export default memo(function AreaChartDefault({
|
|||||||
orientation={chartData.orientation}
|
orientation={chartData.orientation}
|
||||||
className="tracking-tighter"
|
className="tracking-tighter"
|
||||||
width={yAxisWidth}
|
width={yAxisWidth}
|
||||||
|
domain={[0, max ?? "auto"]}
|
||||||
tickFormatter={(value) => {
|
tickFormatter={(value) => {
|
||||||
const val = toFixedWithoutTrailingZeros(value, 2) + unit
|
let val: string
|
||||||
|
if (tickFormatter) {
|
||||||
|
val = tickFormatter(value)
|
||||||
|
} else {
|
||||||
|
val = toFixedWithoutTrailingZeros(value, 2) + unit
|
||||||
|
}
|
||||||
return updateYAxisWidth(val)
|
return updateYAxisWidth(val)
|
||||||
}}
|
}}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
|
|||||||
@@ -116,9 +116,9 @@ export default memo(function ContainerChart({
|
|||||||
}
|
}
|
||||||
// data function
|
// data function
|
||||||
if (isNetChart) {
|
if (isNetChart) {
|
||||||
obj.dataFunction = (key: string, data: any) => (data[key]?.nr ?? 0) + (data[key]?.ns ?? 0)
|
obj.dataFunction = (key: string, data: any) => (data[key] ? data[key].nr + data[key].ns : null)
|
||||||
} else {
|
} else {
|
||||||
obj.dataFunction = (key: string, data: any) => data[key]?.[dataKey] ?? 0
|
obj.dataFunction = (key: string, data: any) => data[key]?.[dataKey] ?? null
|
||||||
}
|
}
|
||||||
return obj
|
return obj
|
||||||
}, [])
|
}, [])
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ export default memo(function DiskChart({
|
|||||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||||
const { _ } = useLingui()
|
const { _ } = useLingui()
|
||||||
|
|
||||||
|
// round to nearest GB
|
||||||
|
if (diskSize >= 100) {
|
||||||
|
diskSize = Math.round(diskSize)
|
||||||
|
}
|
||||||
|
|
||||||
if (chartData.systemStats.length === 0) {
|
if (chartData.systemStats.length === 0) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
112
beszel/site/src/components/charts/gpu-power-chart.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { CartesianGrid, Line, LineChart, YAxis } from "recharts"
|
||||||
|
|
||||||
|
import {
|
||||||
|
ChartContainer,
|
||||||
|
ChartLegend,
|
||||||
|
ChartLegendContent,
|
||||||
|
ChartTooltip,
|
||||||
|
ChartTooltipContent,
|
||||||
|
xAxis,
|
||||||
|
} from "@/components/ui/chart"
|
||||||
|
import {
|
||||||
|
useYAxisWidth,
|
||||||
|
cn,
|
||||||
|
formatShortDate,
|
||||||
|
toFixedWithoutTrailingZeros,
|
||||||
|
decimalString,
|
||||||
|
chartMargin,
|
||||||
|
} from "@/lib/utils"
|
||||||
|
import { ChartData } from "@/types"
|
||||||
|
import { memo, useMemo } from "react"
|
||||||
|
|
||||||
|
export default memo(function GpuPowerChart({ chartData }: { chartData: ChartData }) {
|
||||||
|
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||||
|
|
||||||
|
if (chartData.systemStats.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format temperature data for chart and assign colors */
|
||||||
|
const newChartData = useMemo(() => {
|
||||||
|
const newChartData = { data: [], colors: {} } as {
|
||||||
|
data: Record<string, number | string>[]
|
||||||
|
colors: Record<string, string>
|
||||||
|
}
|
||||||
|
const powerSums = {} as Record<string, number>
|
||||||
|
for (let data of chartData.systemStats) {
|
||||||
|
let newData = { created: data.created } as Record<string, number | string>
|
||||||
|
|
||||||
|
for (let gpu of Object.values(data.stats?.g ?? {})) {
|
||||||
|
if (gpu.p) {
|
||||||
|
const name = gpu.n
|
||||||
|
newData[name] = gpu.p
|
||||||
|
powerSums[name] = (powerSums[name] ?? 0) + newData[name]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
newChartData.data.push(newData)
|
||||||
|
}
|
||||||
|
const keys = Object.keys(powerSums).sort((a, b) => powerSums[b] - powerSums[a])
|
||||||
|
for (let key of keys) {
|
||||||
|
newChartData.colors[key] = `hsl(${((keys.indexOf(key) * 360) / keys.length) % 360}, 60%, 55%)`
|
||||||
|
}
|
||||||
|
return newChartData
|
||||||
|
}, [chartData])
|
||||||
|
|
||||||
|
const colors = Object.keys(newChartData.colors)
|
||||||
|
|
||||||
|
// console.log('rendered at', new Date())
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<ChartContainer
|
||||||
|
className={cn("h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity", {
|
||||||
|
"opacity-100": yAxisWidth,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<LineChart accessibilityLayer data={newChartData.data} margin={chartMargin}>
|
||||||
|
<CartesianGrid vertical={false} />
|
||||||
|
<YAxis
|
||||||
|
direction="ltr"
|
||||||
|
orientation={chartData.orientation}
|
||||||
|
className="tracking-tighter"
|
||||||
|
domain={[0, "auto"]}
|
||||||
|
width={yAxisWidth}
|
||||||
|
tickFormatter={(value) => {
|
||||||
|
const val = toFixedWithoutTrailingZeros(value, 2)
|
||||||
|
return updateYAxisWidth(val + "W")
|
||||||
|
}}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
/>
|
||||||
|
{xAxis(chartData)}
|
||||||
|
<ChartTooltip
|
||||||
|
animationEasing="ease-out"
|
||||||
|
animationDuration={150}
|
||||||
|
// @ts-ignore
|
||||||
|
itemSorter={(a, b) => b.value - a.value}
|
||||||
|
content={
|
||||||
|
<ChartTooltipContent
|
||||||
|
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
||||||
|
contentFormatter={(item) => decimalString(item.value) + "W"}
|
||||||
|
// indicator="line"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{colors.map((key) => (
|
||||||
|
<Line
|
||||||
|
key={key}
|
||||||
|
dataKey={key}
|
||||||
|
name={key}
|
||||||
|
type="monotoneX"
|
||||||
|
dot={false}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
stroke={newChartData.colors[key]}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{colors.length > 1 && <ChartLegend content={<ChartLegendContent />} />}
|
||||||
|
</LineChart>
|
||||||
|
</ChartContainer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
|
BookIcon,
|
||||||
DatabaseBackupIcon,
|
DatabaseBackupIcon,
|
||||||
Github,
|
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
LockKeyholeIcon,
|
|
||||||
LogsIcon,
|
LogsIcon,
|
||||||
MailIcon,
|
MailIcon,
|
||||||
Server,
|
Server,
|
||||||
@@ -23,9 +22,10 @@ import {
|
|||||||
import { useEffect } from "react"
|
import { useEffect } from "react"
|
||||||
import { useStore } from "@nanostores/react"
|
import { useStore } from "@nanostores/react"
|
||||||
import { $systems } from "@/lib/stores"
|
import { $systems } from "@/lib/stores"
|
||||||
import { isAdmin } from "@/lib/utils"
|
import { getHostDisplayValue, isAdmin } from "@/lib/utils"
|
||||||
import { navigate } from "./router"
|
import { $router, basePath, navigate } from "./router"
|
||||||
import { Trans, t } from "@lingui/macro"
|
import { Trans, t } from "@lingui/macro"
|
||||||
|
import { getPagePath } from "@nanostores/router"
|
||||||
|
|
||||||
export default function CommandPalette({ open, setOpen }: { open: boolean; setOpen: (open: boolean) => void }) {
|
export default function CommandPalette({ open, setOpen }: { open: boolean; setOpen: (open: boolean) => void }) {
|
||||||
const systems = useStore($systems)
|
const systems = useStore($systems)
|
||||||
@@ -56,13 +56,13 @@ export default function CommandPalette({ open, setOpen }: { open: boolean; setOp
|
|||||||
<CommandItem
|
<CommandItem
|
||||||
key={system.id}
|
key={system.id}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
navigate(`/system/${encodeURIComponent(system.name)}`)
|
navigate(getPagePath($router, "system", { name: system.name }))
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Server className="me-2 h-4 w-4" />
|
<Server className="me-2 h-4 w-4" />
|
||||||
<span>{system.name}</span>
|
<span>{system.name}</span>
|
||||||
<CommandShortcut>{system.host}</CommandShortcut>
|
<CommandShortcut>{getHostDisplayValue(system)}</CommandShortcut>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
))}
|
))}
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
@@ -73,7 +73,7 @@ export default function CommandPalette({ open, setOpen }: { open: boolean; setOp
|
|||||||
<CommandItem
|
<CommandItem
|
||||||
keywords={["home"]}
|
keywords={["home"]}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
navigate("/")
|
navigate(basePath)
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -87,7 +87,7 @@ export default function CommandPalette({ open, setOpen }: { open: boolean; setOp
|
|||||||
</CommandItem>
|
</CommandItem>
|
||||||
<CommandItem
|
<CommandItem
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
navigate("/settings/general")
|
navigate(getPagePath($router, "settings", { name: "general" }))
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -102,7 +102,7 @@ export default function CommandPalette({ open, setOpen }: { open: boolean; setOp
|
|||||||
<CommandItem
|
<CommandItem
|
||||||
keywords={["alerts"]}
|
keywords={["alerts"]}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
navigate("/settings/notifications")
|
navigate(getPagePath($router, "settings", { name: "notifications" }))
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -115,16 +115,16 @@ export default function CommandPalette({ open, setOpen }: { open: boolean; setOp
|
|||||||
</CommandShortcut>
|
</CommandShortcut>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
<CommandItem
|
<CommandItem
|
||||||
keywords={["github"]}
|
keywords={["help", "oauth", "oidc"]}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
window.location.href = "https://github.com/henrygd/beszel/blob/main/readme.md"
|
window.location.href = "https://beszel.dev/guide/what-is-beszel"
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Github className="me-2 h-4 w-4" />
|
<BookIcon className="me-2 h-4 w-4" />
|
||||||
<span>
|
<span>
|
||||||
<Trans>Documentation</Trans>
|
<Trans>Documentation</Trans>
|
||||||
</span>
|
</span>
|
||||||
<CommandShortcut>GitHub</CommandShortcut>
|
<CommandShortcut>beszel.dev</CommandShortcut>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
{isAdmin() && (
|
{isAdmin() && (
|
||||||
@@ -174,21 +174,6 @@ export default function CommandPalette({ open, setOpen }: { open: boolean; setOp
|
|||||||
<Trans>Admin</Trans>
|
<Trans>Admin</Trans>
|
||||||
</CommandShortcut>
|
</CommandShortcut>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
<CommandItem
|
|
||||||
keywords={["oauth", "oicd"]}
|
|
||||||
onSelect={() => {
|
|
||||||
setOpen(false)
|
|
||||||
window.open("/_/#/settings/auth-providers", "_blank")
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<LockKeyholeIcon className="me-2 h-4 w-4" />
|
|
||||||
<span>
|
|
||||||
<Trans>Auth Providers</Trans>
|
|
||||||
</span>
|
|
||||||
<CommandShortcut>
|
|
||||||
<Trans>Admin</Trans>
|
|
||||||
</CommandShortcut>
|
|
||||||
</CommandItem>
|
|
||||||
<CommandItem
|
<CommandItem
|
||||||
keywords={["email"]}
|
keywords={["email"]}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { LanguagesIcon } from "lucide-react"
|
|||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||||
import languages from "../lib/languages.json"
|
import languages from "@/lib/languages"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { useLingui } from "@lingui/react"
|
import { useLingui } from "@lingui/react"
|
||||||
import { dynamicActivate } from "@/lib/i18n"
|
import { dynamicActivate } from "@/lib/i18n"
|
||||||
@@ -22,7 +22,7 @@ export function LangToggle() {
|
|||||||
{languages.map(({ lang, label, e }) => (
|
{languages.map(({ lang, label, e }) => (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={lang}
|
key={lang}
|
||||||
className={cn("px-3 flex gap-2.5", lang === i18n.locale && "font-semibold")}
|
className={cn("px-2.5 flex gap-2.5", lang === i18n.locale && "font-semibold")}
|
||||||
onClick={() => dynamicActivate(lang)}
|
onClick={() => dynamicActivate(lang)}
|
||||||
>
|
>
|
||||||
<span>{e}</span> {label}
|
<span>{e}</span> {label}
|
||||||
|
|||||||
@@ -2,19 +2,24 @@ import { cn } from "@/lib/utils"
|
|||||||
import { buttonVariants } from "@/components/ui/button"
|
import { buttonVariants } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { LoaderCircle, LockIcon, LogInIcon, MailIcon, UserIcon } from "lucide-react"
|
import { LoaderCircle, LockIcon, LogInIcon, MailIcon } from "lucide-react"
|
||||||
import { $authenticated, pb } from "@/lib/stores"
|
import { $authenticated, pb } from "@/lib/stores"
|
||||||
import * as v from "valibot"
|
import * as v from "valibot"
|
||||||
import { toast } from "../ui/use-toast"
|
import { toast } from "../ui/use-toast"
|
||||||
import { Dialog, DialogContent, DialogTrigger, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogTrigger, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||||
import { useCallback, useState } from "react"
|
import { useCallback, useEffect, useState } from "react"
|
||||||
import { AuthMethodsList, OAuth2AuthConfig } from "pocketbase"
|
import { AuthMethodsList, AuthProviderInfo, OAuth2AuthConfig } from "pocketbase"
|
||||||
import { Link } from "../router"
|
import { $router, Link, prependBasePath } from "../router"
|
||||||
import { Trans, t } from "@lingui/macro"
|
import { Trans, t } from "@lingui/macro"
|
||||||
|
import { getPagePath } from "@nanostores/router"
|
||||||
|
|
||||||
const honeypot = v.literal("")
|
const honeypot = v.literal("")
|
||||||
const emailSchema = v.pipe(v.string(), v.email(t`Invalid email address.`))
|
const emailSchema = v.pipe(v.string(), v.email(t`Invalid email address.`))
|
||||||
const passwordSchema = v.pipe(v.string(), v.minLength(10, t`Password must be at least 10 characters.`))
|
const passwordSchema = v.pipe(
|
||||||
|
v.string(),
|
||||||
|
v.minLength(8, t`Password must be at least 8 characters.`),
|
||||||
|
v.maxBytes(72, t`Password must be less than 72 bytes.`)
|
||||||
|
)
|
||||||
|
|
||||||
const LoginSchema = v.looseObject({
|
const LoginSchema = v.looseObject({
|
||||||
name: honeypot,
|
name: honeypot,
|
||||||
@@ -24,14 +29,6 @@ const LoginSchema = v.looseObject({
|
|||||||
|
|
||||||
const RegisterSchema = v.looseObject({
|
const RegisterSchema = v.looseObject({
|
||||||
name: honeypot,
|
name: honeypot,
|
||||||
username: v.pipe(
|
|
||||||
v.string(),
|
|
||||||
v.regex(
|
|
||||||
/^(?=.*[a-zA-Z])[a-zA-Z0-9_-]+$/,
|
|
||||||
"Invalid username. You may use alphanumeric characters, underscores, and hyphens."
|
|
||||||
),
|
|
||||||
v.minLength(3, "Username must be at least 3 characters long.")
|
|
||||||
),
|
|
||||||
email: emailSchema,
|
email: emailSchema,
|
||||||
password: passwordSchema,
|
password: passwordSchema,
|
||||||
passwordConfirm: passwordSchema,
|
passwordConfirm: passwordSchema,
|
||||||
@@ -63,6 +60,8 @@ export function UserAuthForm({
|
|||||||
async (e: React.FormEvent<HTMLFormElement>) => {
|
async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
|
// store email for later use if mfa is enabled
|
||||||
|
let email = ""
|
||||||
try {
|
try {
|
||||||
const formData = new FormData(e.target as HTMLFormElement)
|
const formData = new FormData(e.target as HTMLFormElement)
|
||||||
const data = Object.fromEntries(formData) as Record<string, any>
|
const data = Object.fromEntries(formData) as Record<string, any>
|
||||||
@@ -78,7 +77,8 @@ export function UserAuthForm({
|
|||||||
setErrors(errors)
|
setErrors(errors)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const { email, password, passwordConfirm, username } = result.output
|
const { password, passwordConfirm } = result.output
|
||||||
|
email = result.output.email
|
||||||
if (isFirstRun) {
|
if (isFirstRun) {
|
||||||
// check that passwords match
|
// check that passwords match
|
||||||
if (password !== passwordConfirm) {
|
if (password !== passwordConfirm) {
|
||||||
@@ -86,27 +86,27 @@ export function UserAuthForm({
|
|||||||
setErrors({ passwordConfirm: msg })
|
setErrors({ passwordConfirm: msg })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await pb.admins.create({
|
await pb.send("/api/beszel/create-user", {
|
||||||
email,
|
method: "POST",
|
||||||
password,
|
body: JSON.stringify({ email, password }),
|
||||||
passwordConfirm: password,
|
|
||||||
})
|
|
||||||
await pb.admins.authWithPassword(email, password)
|
|
||||||
await pb.collection("users").create({
|
|
||||||
username,
|
|
||||||
email,
|
|
||||||
password,
|
|
||||||
passwordConfirm: password,
|
|
||||||
role: "admin",
|
|
||||||
verified: true,
|
|
||||||
})
|
})
|
||||||
await pb.collection("users").authWithPassword(email, password)
|
await pb.collection("users").authWithPassword(email, password)
|
||||||
} else {
|
} else {
|
||||||
await pb.collection("users").authWithPassword(email, password)
|
await pb.collection("users").authWithPassword(email, password)
|
||||||
}
|
}
|
||||||
$authenticated.set(true)
|
$authenticated.set(true)
|
||||||
} catch (e) {
|
} catch (err: any) {
|
||||||
showLoginFaliedToast()
|
showLoginFaliedToast()
|
||||||
|
// todo: implement MFA
|
||||||
|
// const mfaId = err.response?.mfaId
|
||||||
|
// if (!mfaId) {
|
||||||
|
// showLoginFaliedToast()
|
||||||
|
// throw err
|
||||||
|
// }
|
||||||
|
// the user needs to authenticate again with another auth method, for example OTP
|
||||||
|
// const result = await pb.collection("users").requestOTP(email)
|
||||||
|
// ... show a modal for users to check their email and to enter the received code ...
|
||||||
|
// await pb.collection("users").authWithOTP(result.otpId, "EMAIL_CODE", { mfaId: mfaId })
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
@@ -118,34 +118,55 @@ export function UserAuthForm({
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const authProviders = authMethods.oauth2.providers ?? []
|
||||||
|
const oauthEnabled = authMethods.oauth2.enabled && authProviders.length > 0
|
||||||
|
const passwordEnabled = authMethods.password.enabled
|
||||||
|
|
||||||
|
function loginWithOauth(provider: AuthProviderInfo, forcePopup = false) {
|
||||||
|
setIsOauthLoading(true)
|
||||||
|
const oAuthOpts: OAuth2AuthConfig = {
|
||||||
|
provider: provider.name,
|
||||||
|
}
|
||||||
|
// https://github.com/pocketbase/pocketbase/discussions/2429#discussioncomment-5943061
|
||||||
|
if (forcePopup || navigator.userAgent.match(/iPhone|iPad|iPod/i)) {
|
||||||
|
const authWindow = window.open()
|
||||||
|
if (!authWindow) {
|
||||||
|
setIsOauthLoading(false)
|
||||||
|
toast({
|
||||||
|
title: t`Error`,
|
||||||
|
description: t`Please enable pop-ups for this site`,
|
||||||
|
variant: "destructive",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
oAuthOpts.urlCallback = (url) => {
|
||||||
|
authWindow.location.href = url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pb.collection("users")
|
||||||
|
.authWithOAuth2(oAuthOpts)
|
||||||
|
.then(() => {
|
||||||
|
$authenticated.set(pb.authStore.isValid)
|
||||||
|
})
|
||||||
|
.catch(showLoginFaliedToast)
|
||||||
|
.finally(() => {
|
||||||
|
setIsOauthLoading(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// auto login if password disabled and only one auth provider
|
||||||
|
if (!passwordEnabled && authProviders.length === 1) {
|
||||||
|
loginWithOauth(authProviders[0], true)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("grid gap-6", className)} {...props}>
|
<div className={cn("grid gap-6", className)} {...props}>
|
||||||
{authMethods.emailPassword && (
|
{passwordEnabled && (
|
||||||
<>
|
<>
|
||||||
<form onSubmit={handleSubmit} onChange={() => setErrors({})}>
|
<form onSubmit={handleSubmit} onChange={() => setErrors({})}>
|
||||||
<div className="grid gap-2.5">
|
<div className="grid gap-2.5">
|
||||||
{isFirstRun && (
|
|
||||||
<div className="grid gap-1 relative">
|
|
||||||
<UserIcon className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
|
||||||
<Label className="sr-only" htmlFor="username">
|
|
||||||
<Trans>Username</Trans>
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
autoFocus={true}
|
|
||||||
id="username"
|
|
||||||
name="username"
|
|
||||||
required
|
|
||||||
placeholder={t`username`}
|
|
||||||
type="username"
|
|
||||||
autoCapitalize="none"
|
|
||||||
autoComplete="username"
|
|
||||||
autoCorrect="off"
|
|
||||||
disabled={isLoading || isOauthLoading}
|
|
||||||
className="ps-9"
|
|
||||||
/>
|
|
||||||
{errors?.username && <p className="px-1 text-xs text-red-600">{errors.username}</p>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="grid gap-1 relative">
|
<div className="grid gap-1 relative">
|
||||||
<MailIcon className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
<MailIcon className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||||
<Label className="sr-only" htmlFor="email">
|
<Label className="sr-only" htmlFor="email">
|
||||||
@@ -155,13 +176,13 @@ export function UserAuthForm({
|
|||||||
id="email"
|
id="email"
|
||||||
name="email"
|
name="email"
|
||||||
required
|
required
|
||||||
placeholder={isFirstRun ? t`email` : "name@example.com"}
|
placeholder="name@example.com"
|
||||||
type="email"
|
type="text"
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
autoCorrect="off"
|
autoCorrect="off"
|
||||||
disabled={isLoading || isOauthLoading}
|
disabled={isLoading || isOauthLoading}
|
||||||
className="ps-9"
|
className={cn("ps-9", errors?.email && "border-red-500")}
|
||||||
/>
|
/>
|
||||||
{errors?.email && <p className="px-1 text-xs text-red-600">{errors.email}</p>}
|
{errors?.email && <p className="px-1 text-xs text-red-600">{errors.email}</p>}
|
||||||
</div>
|
</div>
|
||||||
@@ -178,7 +199,7 @@ export function UserAuthForm({
|
|||||||
type="password"
|
type="password"
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
disabled={isLoading || isOauthLoading}
|
disabled={isLoading || isOauthLoading}
|
||||||
className="ps-9 lowercase"
|
className={cn("ps-9", errors?.password && "border-red-500")}
|
||||||
/>
|
/>
|
||||||
{errors?.password && <p className="px-1 text-xs text-red-600">{errors.password}</p>}
|
{errors?.password && <p className="px-1 text-xs text-red-600">{errors.password}</p>}
|
||||||
</div>
|
</div>
|
||||||
@@ -196,7 +217,7 @@ export function UserAuthForm({
|
|||||||
type="password"
|
type="password"
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
disabled={isLoading || isOauthLoading}
|
disabled={isLoading || isOauthLoading}
|
||||||
className="ps-9 lowercase"
|
className={cn("ps-9", errors?.password && "border-red-500")}
|
||||||
/>
|
/>
|
||||||
{errors?.passwordConfirm && <p className="px-1 text-xs text-red-600">{errors.passwordConfirm}</p>}
|
{errors?.passwordConfirm && <p className="px-1 text-xs text-red-600">{errors.passwordConfirm}</p>}
|
||||||
</div>
|
</div>
|
||||||
@@ -204,7 +225,7 @@ export function UserAuthForm({
|
|||||||
<div className="sr-only">
|
<div className="sr-only">
|
||||||
{/* honeypot */}
|
{/* honeypot */}
|
||||||
<label htmlFor="name"></label>
|
<label htmlFor="name"></label>
|
||||||
<input id="name" type="text" name="name" tabIndex={-1} />
|
<input id="name" type="text" name="name" tabIndex={-1} autoComplete="off" />
|
||||||
</div>
|
</div>
|
||||||
<button className={cn(buttonVariants())} disabled={isLoading}>
|
<button className={cn(buttonVariants())} disabled={isLoading}>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
@@ -216,7 +237,7 @@ export function UserAuthForm({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{(isFirstRun || authMethods.authProviders.length > 0) && (
|
{(isFirstRun || oauthEnabled) && (
|
||||||
// only show 'continue with' during onboarding or if we have auth providers
|
// only show 'continue with' during onboarding or if we have auth providers
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="absolute inset-0 flex items-center">
|
<div className="absolute inset-0 flex items-center">
|
||||||
@@ -232,59 +253,29 @@ export function UserAuthForm({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{authMethods.authProviders.length > 0 && (
|
{oauthEnabled && (
|
||||||
<div className="grid gap-2 -mt-1">
|
<div className="grid gap-2 -mt-1">
|
||||||
{authMethods.authProviders.map((provider) => (
|
{authMethods.oauth2.providers.map((provider) => (
|
||||||
<button
|
<button
|
||||||
key={provider.name}
|
key={provider.name}
|
||||||
type="button"
|
type="button"
|
||||||
className={cn(buttonVariants({ variant: "outline" }), {
|
className={cn(buttonVariants({ variant: "outline" }), {
|
||||||
"justify-self-center": !authMethods.emailPassword,
|
"justify-self-center": !passwordEnabled,
|
||||||
"px-5": !authMethods.emailPassword,
|
"px-5": !passwordEnabled,
|
||||||
})}
|
})}
|
||||||
onClick={() => {
|
onClick={() => loginWithOauth(provider)}
|
||||||
setIsOauthLoading(true)
|
|
||||||
const oAuthOpts: OAuth2AuthConfig = {
|
|
||||||
provider: provider.name,
|
|
||||||
}
|
|
||||||
// https://github.com/pocketbase/pocketbase/discussions/2429#discussioncomment-5943061
|
|
||||||
if (navigator.userAgent.match(/iPhone|iPad|iPod/i)) {
|
|
||||||
const authWindow = window.open()
|
|
||||||
if (!authWindow) {
|
|
||||||
setIsOauthLoading(false)
|
|
||||||
toast({
|
|
||||||
title: t`Error`,
|
|
||||||
description: t`Please enable pop-ups for this site`,
|
|
||||||
variant: "destructive",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
oAuthOpts.urlCallback = (url) => {
|
|
||||||
authWindow.location.href = url
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pb.collection("users")
|
|
||||||
.authWithOAuth2(oAuthOpts)
|
|
||||||
.then(() => {
|
|
||||||
$authenticated.set(pb.authStore.isValid)
|
|
||||||
})
|
|
||||||
.catch(showLoginFaliedToast)
|
|
||||||
.finally(() => {
|
|
||||||
setIsOauthLoading(false)
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
disabled={isLoading || isOauthLoading}
|
disabled={isLoading || isOauthLoading}
|
||||||
>
|
>
|
||||||
{isOauthLoading ? (
|
{isOauthLoading ? (
|
||||||
<LoaderCircle className="me-2 h-4 w-4 animate-spin" />
|
<LoaderCircle className="me-2 h-4 w-4 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<img
|
<img
|
||||||
className="me-2 h-4 w-4 dark:invert"
|
className="me-2 h-4 w-4 dark:brightness-0 dark:invert"
|
||||||
src={`/static/${provider.name}.svg`}
|
src={prependBasePath(`/_/images/oauth2/${provider.name}.svg`)}
|
||||||
alt=""
|
alt=""
|
||||||
onError={(e) => {
|
// onError={(e) => {
|
||||||
e.currentTarget.src = "/static/lock.svg"
|
// e.currentTarget.src = "/static/lock.svg"
|
||||||
}}
|
// }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<span className="translate-y-[1px]">{provider.displayName}</span>
|
<span className="translate-y-[1px]">{provider.displayName}</span>
|
||||||
@@ -293,12 +284,12 @@ export function UserAuthForm({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!authMethods.authProviders.length && isFirstRun && (
|
{!oauthEnabled && isFirstRun && (
|
||||||
// only show GitHub button / dialog during onboarding
|
// only show GitHub button / dialog during onboarding
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<button type="button" className={cn(buttonVariants({ variant: "outline" }))}>
|
<button type="button" className={cn(buttonVariants({ variant: "outline" }))}>
|
||||||
<img className="me-2 h-4 w-4 dark:invert" src="/static/github.svg" alt="" />
|
<img className="me-2 h-4 w-4 dark:invert" src={prependBasePath("/_/images/oauth2/github.svg")} alt="" />
|
||||||
<span className="translate-y-[1px]">GitHub</span>
|
<span className="translate-y-[1px]">GitHub</span>
|
||||||
</button>
|
</button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
@@ -316,7 +307,7 @@ export function UserAuthForm({
|
|||||||
<Trans>
|
<Trans>
|
||||||
Please see{" "}
|
Please see{" "}
|
||||||
<a
|
<a
|
||||||
href="https://github.com/henrygd/beszel/blob/main/readme.md#oauth--oidc-integration"
|
href="https://beszel.dev/guide/oauth"
|
||||||
className={cn(buttonVariants({ variant: "link" }), "p-0 h-auto")}
|
className={cn(buttonVariants({ variant: "link" }), "p-0 h-auto")}
|
||||||
>
|
>
|
||||||
the documentation
|
the documentation
|
||||||
@@ -329,9 +320,9 @@ export function UserAuthForm({
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{authMethods.emailPassword && !isFirstRun && (
|
{passwordEnabled && !isFirstRun && (
|
||||||
<Link
|
<Link
|
||||||
href="/forgot-password"
|
href={getPagePath($router, "forgot_password")}
|
||||||
className="text-sm mx-auto hover:text-brand underline underline-offset-4 opacity-70 hover:opacity-100 transition-opacity"
|
className="text-sm mx-auto hover:text-brand underline underline-offset-4 opacity-70 hover:opacity-100 transition-opacity"
|
||||||
>
|
>
|
||||||
<Trans>Forgot password?</Trans>
|
<Trans>Forgot password?</Trans>
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ export default function ForgotPassword() {
|
|||||||
<Trans>Command line instructions</Trans>
|
<Trans>Command line instructions</Trans>
|
||||||
</button>
|
</button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="max-w-[33em]">
|
<DialogContent className="max-w-[41em]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
<Trans>Command line instructions</Trans>
|
<Trans>Command line instructions</Trans>
|
||||||
@@ -98,7 +98,10 @@ export default function ForgotPassword() {
|
|||||||
<Trans>Then log into the backend and reset your user account password in the users table.</Trans>
|
<Trans>Then log into the backend and reset your user account password in the users table.</Trans>
|
||||||
</p>
|
</p>
|
||||||
<code className="bg-muted rounded-sm py-0.5 px-2.5 me-auto text-sm">
|
<code className="bg-muted rounded-sm py-0.5 px-2.5 me-auto text-sm">
|
||||||
beszel admin update youremail@example.com newpassword
|
./beszel superuser upsert user@example.com password
|
||||||
|
</code>
|
||||||
|
<code className="bg-muted rounded-sm py-0.5 px-2.5 me-auto text-sm">
|
||||||
|
docker exec beszel /beszel superuser upsert name@example.com password
|
||||||
</code>
|
</code>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ import ForgotPassword from "./forgot-pass-form"
|
|||||||
import { $router } from "../router"
|
import { $router } from "../router"
|
||||||
import { AuthMethodsList } from "pocketbase"
|
import { AuthMethodsList } from "pocketbase"
|
||||||
import { t } from "@lingui/macro"
|
import { t } from "@lingui/macro"
|
||||||
|
import { useTheme } from "../theme-provider"
|
||||||
|
|
||||||
export default function () {
|
export default function () {
|
||||||
const page = useStore($router)
|
const page = useStore($router)
|
||||||
const [isFirstRun, setFirstRun] = useState(false)
|
const [isFirstRun, setFirstRun] = useState(false)
|
||||||
const [authMethods, setAuthMethods] = useState<AuthMethodsList>()
|
const [authMethods, setAuthMethods] = useState<AuthMethodsList>()
|
||||||
|
const { theme } = useTheme()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = t`Login` + " / Beszel"
|
document.title = t`Login` + " / Beszel"
|
||||||
@@ -32,7 +34,7 @@ export default function () {
|
|||||||
const subtitle = useMemo(() => {
|
const subtitle = useMemo(() => {
|
||||||
if (isFirstRun) {
|
if (isFirstRun) {
|
||||||
return t`Please create an admin account`
|
return t`Please create an admin account`
|
||||||
} else if (page?.path === "/forgot-password") {
|
} else if (page?.route === "forgot_password") {
|
||||||
return t`Enter email address to reset password`
|
return t`Enter email address to reset password`
|
||||||
} else {
|
} else {
|
||||||
return t`Please sign in to your account`
|
return t`Please sign in to your account`
|
||||||
@@ -45,7 +47,11 @@ export default function () {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-svh grid items-center py-12">
|
<div className="min-h-svh grid items-center py-12">
|
||||||
<div className="grid gap-5 w-full px-4 mx-auto" style={{ maxWidth: "22em" }}>
|
<div
|
||||||
|
className="grid gap-5 w-full px-4 mx-auto"
|
||||||
|
// @ts-ignore
|
||||||
|
style={{ maxWidth: "22em", "--border": theme == "light" ? "30 8% 80%" : "220 3% 20%" }}
|
||||||
|
>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h1 className="mb-3">
|
<h1 className="mb-3">
|
||||||
<Logo className="h-7 fill-foreground mx-auto" />
|
<Logo className="h-7 fill-foreground mx-auto" />
|
||||||
@@ -53,7 +59,7 @@ export default function () {
|
|||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-muted-foreground">{subtitle}</p>
|
<p className="text-sm text-muted-foreground">{subtitle}</p>
|
||||||
</div>
|
</div>
|
||||||
{page?.path === "/forgot-password" ? (
|
{page?.route === "forgot_password" ? (
|
||||||
<ForgotPassword />
|
<ForgotPassword />
|
||||||
) : (
|
) : (
|
||||||
<UserAuthForm isFirstRun={isFirstRun} authMethods={authMethods} />
|
<UserAuthForm isFirstRun={isFirstRun} authMethods={authMethods} />
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { useState, lazy, Suspense } from "react"
|
|||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
import {
|
import {
|
||||||
DatabaseBackupIcon,
|
DatabaseBackupIcon,
|
||||||
LockKeyholeIcon,
|
|
||||||
LogOutIcon,
|
LogOutIcon,
|
||||||
LogsIcon,
|
LogsIcon,
|
||||||
SearchIcon,
|
SearchIcon,
|
||||||
@@ -11,12 +10,12 @@ import {
|
|||||||
UserIcon,
|
UserIcon,
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { Link } from "./router"
|
import { $router, basePath, Link, prependBasePath } from "./router"
|
||||||
import { LangToggle } from "./lang-toggle"
|
import { LangToggle } from "./lang-toggle"
|
||||||
import { ModeToggle } from "./mode-toggle"
|
import { ModeToggle } from "./mode-toggle"
|
||||||
import { Logo } from "./logo"
|
import { Logo } from "./logo"
|
||||||
import { pb } from "@/lib/stores"
|
import { pb } from "@/lib/stores"
|
||||||
import { cn, isReadOnlyUser, isAdmin } from "@/lib/utils"
|
import { cn, isReadOnlyUser, isAdmin, logOut } from "@/lib/utils"
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
@@ -28,6 +27,7 @@ import {
|
|||||||
} from "@/components/ui/dropdown-menu"
|
} from "@/components/ui/dropdown-menu"
|
||||||
import { AddSystemButton } from "./add-system"
|
import { AddSystemButton } from "./add-system"
|
||||||
import { Trans } from "@lingui/macro"
|
import { Trans } from "@lingui/macro"
|
||||||
|
import { getPagePath } from "@nanostores/router"
|
||||||
|
|
||||||
const CommandPalette = lazy(() => import("./command-palette"))
|
const CommandPalette = lazy(() => import("./command-palette"))
|
||||||
|
|
||||||
@@ -35,8 +35,8 @@ const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0
|
|||||||
|
|
||||||
export default function Navbar() {
|
export default function Navbar() {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center h-14 md:h-16 bg-card px-4 pe-3 sm:px-6 border bt-0 rounded-md my-4">
|
<div className="flex items-center h-14 md:h-16 bg-card px-4 pe-3 sm:px-6 border border-border/60 bt-0 rounded-md my-4">
|
||||||
<Link href="/" aria-label="Home" className="p-2 ps-0 me-3">
|
<Link href={basePath} aria-label="Home" className="p-2 ps-0 me-3">
|
||||||
<Logo className="h-[1.1rem] md:h-5 fill-foreground" />
|
<Logo className="h-[1.1rem] md:h-5 fill-foreground" />
|
||||||
</Link>
|
</Link>
|
||||||
<SearchButton />
|
<SearchButton />
|
||||||
@@ -45,7 +45,7 @@ export default function Navbar() {
|
|||||||
<LangToggle />
|
<LangToggle />
|
||||||
<ModeToggle />
|
<ModeToggle />
|
||||||
<Link
|
<Link
|
||||||
href="/settings/general"
|
href={getPagePath($router, "settings", { name: "general" })}
|
||||||
aria-label="Settings"
|
aria-label="Settings"
|
||||||
className={cn("", buttonVariants({ variant: "ghost", size: "icon" }))}
|
className={cn("", buttonVariants({ variant: "ghost", size: "icon" }))}
|
||||||
>
|
>
|
||||||
@@ -58,13 +58,13 @@ export default function Navbar() {
|
|||||||
</button>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align={isReadOnlyUser() ? "end" : "center"} className="min-w-44">
|
<DropdownMenuContent align={isReadOnlyUser() ? "end" : "center"} className="min-w-44">
|
||||||
<DropdownMenuLabel>{pb.authStore.model?.email}</DropdownMenuLabel>
|
<DropdownMenuLabel>{pb.authStore.record?.email}</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
{isAdmin() && (
|
{isAdmin() && (
|
||||||
<>
|
<>
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<a href="/_/" target="_blank">
|
<a href={prependBasePath("/_/")} target="_blank">
|
||||||
<UsersIcon className="me-2.5 h-4 w-4" />
|
<UsersIcon className="me-2.5 h-4 w-4" />
|
||||||
<span>
|
<span>
|
||||||
<Trans>Users</Trans>
|
<Trans>Users</Trans>
|
||||||
@@ -72,7 +72,7 @@ export default function Navbar() {
|
|||||||
</a>
|
</a>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<a href="/_/#/collections?collectionId=2hz5ncl8tizk5nx" target="_blank">
|
<a href={prependBasePath("/_/#/collections?collection=systems")} target="_blank">
|
||||||
<ServerIcon className="me-2.5 h-4 w-4" />
|
<ServerIcon className="me-2.5 h-4 w-4" />
|
||||||
<span>
|
<span>
|
||||||
<Trans>Systems</Trans>
|
<Trans>Systems</Trans>
|
||||||
@@ -80,7 +80,7 @@ export default function Navbar() {
|
|||||||
</a>
|
</a>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<a href="/_/#/logs" target="_blank">
|
<a href={prependBasePath("/_/#/logs")} target="_blank">
|
||||||
<LogsIcon className="me-2.5 h-4 w-4" />
|
<LogsIcon className="me-2.5 h-4 w-4" />
|
||||||
<span>
|
<span>
|
||||||
<Trans>Logs</Trans>
|
<Trans>Logs</Trans>
|
||||||
@@ -88,26 +88,18 @@ export default function Navbar() {
|
|||||||
</a>
|
</a>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<a href="/_/#/settings/backups" target="_blank">
|
<a href={prependBasePath("/_/#/settings/backups")} target="_blank">
|
||||||
<DatabaseBackupIcon className="me-2.5 h-4 w-4" />
|
<DatabaseBackupIcon className="me-2.5 h-4 w-4" />
|
||||||
<span>
|
<span>
|
||||||
<Trans>Backups</Trans>
|
<Trans>Backups</Trans>
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem asChild>
|
|
||||||
<a href="/_/#/settings/auth-providers" target="_blank">
|
|
||||||
<LockKeyholeIcon className="me-2.5 h-4 w-4" />
|
|
||||||
<span>
|
|
||||||
<Trans>Auth Providers</Trans>
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
<DropdownMenuItem onSelect={() => pb.authStore.clear()}>
|
<DropdownMenuItem onSelect={logOut}>
|
||||||
<LogOutIcon className="me-2.5 h-4 w-4" />
|
<LogOutIcon className="me-2.5 h-4 w-4" />
|
||||||
<span>
|
<span>
|
||||||
<Trans>Log Out</Trans>
|
<Trans>Log Out</Trans>
|
||||||
|
|||||||
@@ -1,16 +1,36 @@
|
|||||||
import { createRouter } from "@nanostores/router"
|
import { createRouter } from "@nanostores/router"
|
||||||
|
|
||||||
export const $router = createRouter(
|
const routes = {
|
||||||
{
|
home: "/",
|
||||||
home: "/",
|
system: `/system/:name`,
|
||||||
server: "/system/:name",
|
settings: `/settings/:name?`,
|
||||||
settings: "/settings/:name?",
|
forgot_password: `/forgot-password`,
|
||||||
forgot_password: "/forgot-password",
|
} as const
|
||||||
},
|
|
||||||
{ links: false }
|
|
||||||
)
|
|
||||||
|
|
||||||
/** Navigate to url using router */
|
/**
|
||||||
|
* The base path of the application.
|
||||||
|
* This is used to prepend the base path to all routes.
|
||||||
|
*/
|
||||||
|
export const basePath = window.BASE_PATH || ""
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prepends the base path to the given path.
|
||||||
|
* @param path The path to prepend the base path to.
|
||||||
|
* @returns The path with the base path prepended.
|
||||||
|
*/
|
||||||
|
export const prependBasePath = (path: string) => (basePath + path).replaceAll("//", "/")
|
||||||
|
|
||||||
|
// prepend base path to routes
|
||||||
|
for (const route in routes) {
|
||||||
|
// @ts-ignore need as const above to get nanostores to parse types properly
|
||||||
|
routes[route] = prependBasePath(routes[route])
|
||||||
|
}
|
||||||
|
|
||||||
|
export const $router = createRouter(routes, { links: false })
|
||||||
|
|
||||||
|
/** Navigate to url using router
|
||||||
|
* Base path is automatically prepended if serving from subpath
|
||||||
|
*/
|
||||||
export const navigate = (urlString: string) => {
|
export const navigate = (urlString: string) => {
|
||||||
$router.open(urlString)
|
$router.open(urlString)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ import { Separator } from "../ui/separator"
|
|||||||
import { alertInfo, updateRecordList, updateSystemList } from "@/lib/utils"
|
import { alertInfo, updateRecordList, updateSystemList } from "@/lib/utils"
|
||||||
import { AlertRecord, SystemRecord } from "@/types"
|
import { AlertRecord, SystemRecord } from "@/types"
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||||
import { Link } from "../router"
|
import { $router, Link } from "../router"
|
||||||
import { Plural, t, Trans } from "@lingui/macro"
|
import { Plural, t, Trans } from "@lingui/macro"
|
||||||
|
import { getPagePath } from "@nanostores/router"
|
||||||
|
|
||||||
const SystemsTable = lazy(() => import("../systems-table/systems-table"))
|
const SystemsTable = lazy(() => import("../systems-table/systems-table"))
|
||||||
|
|
||||||
@@ -18,7 +19,6 @@ export default function Home() {
|
|||||||
const alerts = useStore($alerts)
|
const alerts = useStore($alerts)
|
||||||
const systems = useStore($systems)
|
const systems = useStore($systems)
|
||||||
|
|
||||||
// todo: maybe remove active alert if changed
|
|
||||||
const activeAlerts = useMemo(() => {
|
const activeAlerts = useMemo(() => {
|
||||||
const activeAlerts = alerts.filter((alert) => {
|
const activeAlerts = alerts.filter((alert) => {
|
||||||
const active = alert.triggered && alert.name in alertInfo
|
const active = alert.triggered && alert.name in alertInfo
|
||||||
@@ -84,7 +84,7 @@ export default function Home() {
|
|||||||
</Trans>
|
</Trans>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
<Link
|
<Link
|
||||||
href={`/system/${encodeURIComponent(alert.sysname!)}`}
|
href={getPagePath($router, "system", { name: alert.sysname! })}
|
||||||
className="absolute inset-0 w-full h-full"
|
className="absolute inset-0 w-full h-full"
|
||||||
aria-label="View system"
|
aria-label="View system"
|
||||||
></Link>
|
></Link>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { UserSettings } from "@/types"
|
|||||||
import { saveSettings } from "./layout"
|
import { saveSettings } from "./layout"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { Trans } from "@lingui/macro"
|
import { Trans } from "@lingui/macro"
|
||||||
import languages from "../../../lib/languages.json"
|
import languages from "@/lib/languages"
|
||||||
import { dynamicActivate } from "@/lib/i18n"
|
import { dynamicActivate } from "@/lib/i18n"
|
||||||
import { useLingui } from "@lingui/react"
|
import { useLingui } from "@lingui/react"
|
||||||
// import { setLang } from "@/lib/i18n"
|
// import { setLang } from "@/lib/i18n"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { SidebarNav } from "./sidebar-nav.tsx"
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card.tsx"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card.tsx"
|
||||||
import { useStore } from "@nanostores/react"
|
import { useStore } from "@nanostores/react"
|
||||||
import { $router } from "@/components/router.tsx"
|
import { $router } from "@/components/router.tsx"
|
||||||
import { redirectPage } from "@nanostores/router"
|
import { getPagePath, redirectPage } from "@nanostores/router"
|
||||||
import { BellIcon, FileSlidersIcon, SettingsIcon } from "lucide-react"
|
import { BellIcon, FileSlidersIcon, SettingsIcon } from "lucide-react"
|
||||||
import { $userSettings, pb } from "@/lib/stores.ts"
|
import { $userSettings, pb } from "@/lib/stores.ts"
|
||||||
import { toast } from "@/components/ui/use-toast.ts"
|
import { toast } from "@/components/ui/use-toast.ts"
|
||||||
@@ -49,17 +49,17 @@ export default function SettingsLayout() {
|
|||||||
const sidebarNavItems = [
|
const sidebarNavItems = [
|
||||||
{
|
{
|
||||||
title: _(t({ message: `General`, comment: "Context: General settings" })),
|
title: _(t({ message: `General`, comment: "Context: General settings" })),
|
||||||
href: "/settings/general",
|
href: getPagePath($router, "settings", { name: "general" }),
|
||||||
icon: SettingsIcon,
|
icon: SettingsIcon,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t`Notifications`,
|
title: t`Notifications`,
|
||||||
href: "/settings/notifications",
|
href: getPagePath($router, "settings", { name: "notifications" }),
|
||||||
icon: BellIcon,
|
icon: BellIcon,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t`YAML Config`,
|
title: t`YAML Config`,
|
||||||
href: "/settings/config",
|
href: getPagePath($router, "settings", { name: "config" }),
|
||||||
icon: FileSlidersIcon,
|
icon: FileSlidersIcon,
|
||||||
admin: true,
|
admin: true,
|
||||||
},
|
},
|
||||||
@@ -69,8 +69,8 @@ export default function SettingsLayout() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = t`Settings` + " / Beszel"
|
document.title = t`Settings` + " / Beszel"
|
||||||
// redirect to account page if no page is specified
|
// @ts-ignore redirect to account page if no page is specified
|
||||||
if (page?.path === "/settings") {
|
if (!page?.params?.name) {
|
||||||
redirectPage($router, "settings", { name: "general" })
|
redirectPage($router, "settings", { name: "general" })
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { saveSettings } from "./layout"
|
|||||||
import * as v from "valibot"
|
import * as v from "valibot"
|
||||||
import { isAdmin } from "@/lib/utils"
|
import { isAdmin } from "@/lib/utils"
|
||||||
import { Trans, t } from "@lingui/macro"
|
import { Trans, t } from "@lingui/macro"
|
||||||
|
import { prependBasePath } from "@/components/router"
|
||||||
|
|
||||||
interface ShoutrrrUrlCardProps {
|
interface ShoutrrrUrlCardProps {
|
||||||
url: string
|
url: string
|
||||||
@@ -94,7 +95,7 @@ const SettingsNotificationsPage = ({ userSettings }: { userSettings: UserSetting
|
|||||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||||
<Trans>
|
<Trans>
|
||||||
Please{" "}
|
Please{" "}
|
||||||
<a href="/_/#/settings/mail" className="link" target="_blank">
|
<a href={prependBasePath("/_/#/settings/mail")} className="link" target="_blank">
|
||||||
configure an SMTP server
|
configure an SMTP server
|
||||||
</a>{" "}
|
</a>{" "}
|
||||||
to ensure alerts are delivered.
|
to ensure alerts are delivered.
|
||||||
@@ -193,7 +194,7 @@ const ShoutrrrUrlCard = ({ url, onUrlChange, onRemove }: ShoutrrrUrlCardProps) =
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="bg-muted/30 p-2 md:p-3">
|
<Card className="bg-muted/40 p-2 md:p-3">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Input
|
<Input
|
||||||
type="url"
|
type="url"
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export function SidebarNav({ className, items, ...props }: SidebarNavProps) {
|
|||||||
<>
|
<>
|
||||||
{/* Mobile View */}
|
{/* Mobile View */}
|
||||||
<div className="md:hidden">
|
<div className="md:hidden">
|
||||||
<Select onValueChange={(value: string) => navigate(value)} value={page?.path}>
|
<Select onValueChange={navigate} value={page?.path}>
|
||||||
<SelectTrigger className="w-full my-3.5">
|
<SelectTrigger className="w-full my-3.5">
|
||||||
<SelectValue placeholder="Select page" />
|
<SelectValue placeholder="Select page" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -45,21 +45,26 @@ export function SidebarNav({ className, items, ...props }: SidebarNavProps) {
|
|||||||
|
|
||||||
{/* Desktop View */}
|
{/* Desktop View */}
|
||||||
<nav className={cn("hidden md:grid gap-1", className)} {...props}>
|
<nav className={cn("hidden md:grid gap-1", className)} {...props}>
|
||||||
{items.map((item) => (
|
{items.map((item) => {
|
||||||
<Link
|
if (item.admin && !isAdmin()) {
|
||||||
key={item.href}
|
return null
|
||||||
href={item.href}
|
}
|
||||||
className={cn(
|
return (
|
||||||
buttonVariants({ variant: "ghost" }),
|
<Link
|
||||||
"flex items-center gap-3",
|
key={item.href}
|
||||||
page?.path === item.href ? "bg-muted hover:bg-muted" : "hover:bg-muted/50",
|
href={item.href}
|
||||||
"justify-start"
|
className={cn(
|
||||||
)}
|
buttonVariants({ variant: "ghost" }),
|
||||||
>
|
"flex items-center gap-3",
|
||||||
{item.icon && <item.icon className="h-4 w-4" />}
|
page?.path === item.href ? "bg-muted hover:bg-muted" : "hover:bg-muted/50",
|
||||||
{item.title}
|
"justify-start"
|
||||||
</Link>
|
)}
|
||||||
))}
|
>
|
||||||
|
{item.icon && <item.icon className="h-4 w-4" />}
|
||||||
|
{item.title}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
import { $systems, pb, $chartTime, $containerFilter, $userSettings, $direction } from "@/lib/stores"
|
import { $systems, pb, $chartTime, $containerFilter, $userSettings, $direction, $maxValues } from "@/lib/stores"
|
||||||
import { ChartData, ChartTimes, ContainerStatsRecord, SystemRecord, SystemStatsRecord } from "@/types"
|
import { ChartData, ChartTimes, ContainerStatsRecord, GPUData, SystemRecord, SystemStatsRecord } from "@/types"
|
||||||
import React, { lazy, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
import React, { lazy, memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||||
import { Card, CardHeader, CardTitle, CardDescription } from "../ui/card"
|
import { Card, CardHeader, CardTitle, CardDescription } from "../ui/card"
|
||||||
import { useStore } from "@nanostores/react"
|
import { useStore } from "@nanostores/react"
|
||||||
import Spinner from "../spinner"
|
import Spinner from "../spinner"
|
||||||
import { ClockArrowUp, CpuIcon, GlobeIcon, LayoutGridIcon, MonitorIcon, XIcon } from "lucide-react"
|
import { ClockArrowUp, CpuIcon, GlobeIcon, LayoutGridIcon, MonitorIcon, XIcon } from "lucide-react"
|
||||||
import ChartTimeSelect from "../charts/chart-time-select"
|
import ChartTimeSelect from "../charts/chart-time-select"
|
||||||
import { chartTimeData, cn, getPbTimestamp, useLocalStorage } from "@/lib/utils"
|
import {
|
||||||
|
chartTimeData,
|
||||||
|
cn,
|
||||||
|
getHostDisplayValue,
|
||||||
|
getPbTimestamp,
|
||||||
|
getSizeAndUnit,
|
||||||
|
toFixedFloat,
|
||||||
|
useLocalStorage,
|
||||||
|
} from "@/lib/utils"
|
||||||
import { Separator } from "../ui/separator"
|
import { Separator } from "../ui/separator"
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip"
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip"
|
||||||
import { Button } from "../ui/button"
|
import { Button } from "../ui/button"
|
||||||
@@ -24,6 +32,7 @@ const MemChart = lazy(() => import("../charts/mem-chart"))
|
|||||||
const DiskChart = lazy(() => import("../charts/disk-chart"))
|
const DiskChart = lazy(() => import("../charts/disk-chart"))
|
||||||
const SwapChart = lazy(() => import("../charts/swap-chart"))
|
const SwapChart = lazy(() => import("../charts/swap-chart"))
|
||||||
const TemperatureChart = lazy(() => import("../charts/temperature-chart"))
|
const TemperatureChart = lazy(() => import("../charts/temperature-chart"))
|
||||||
|
const GpuPowerChart = lazy(() => import("../charts/gpu-power-chart"))
|
||||||
|
|
||||||
const cache = new Map<string, any>()
|
const cache = new Map<string, any>()
|
||||||
|
|
||||||
@@ -85,15 +94,19 @@ async function getStats<T>(collection: string, system: SystemRecord, chartTime:
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function dockerOrPodman(str: string, system: SystemRecord) {
|
||||||
|
if (system.info.p) {
|
||||||
|
str = str.replace("docker", "podman").replace("Docker", "Podman")
|
||||||
|
}
|
||||||
|
return str
|
||||||
|
}
|
||||||
|
|
||||||
export default function SystemDetail({ name }: { name: string }) {
|
export default function SystemDetail({ name }: { name: string }) {
|
||||||
const direction = useStore($direction)
|
const direction = useStore($direction)
|
||||||
const { _ } = useLingui()
|
const { _ } = useLingui()
|
||||||
const systems = useStore($systems)
|
const systems = useStore($systems)
|
||||||
const chartTime = useStore($chartTime)
|
const chartTime = useStore($chartTime)
|
||||||
/** Max CPU toggle value */
|
const maxValues = useStore($maxValues)
|
||||||
const cpuMaxStore = useState(false)
|
|
||||||
const bandwidthMaxStore = useState(false)
|
|
||||||
const diskIoMaxStore = useState(false)
|
|
||||||
const [grid, setGrid] = useLocalStorage("grid", true)
|
const [grid, setGrid] = useLocalStorage("grid", true)
|
||||||
const [system, setSystem] = useState({} as SystemRecord)
|
const [system, setSystem] = useState({} as SystemRecord)
|
||||||
const [systemStats, setSystemStats] = useState([] as SystemStatsRecord[])
|
const [systemStats, setSystemStats] = useState([] as SystemStatsRecord[])
|
||||||
@@ -113,9 +126,6 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
setContainerData([])
|
setContainerData([])
|
||||||
setContainerFilterBar(null)
|
setContainerFilterBar(null)
|
||||||
$containerFilter.set("")
|
$containerFilter.set("")
|
||||||
cpuMaxStore[1](false)
|
|
||||||
bandwidthMaxStore[1](false)
|
|
||||||
diskIoMaxStore[1](false)
|
|
||||||
}
|
}
|
||||||
}, [name])
|
}, [name])
|
||||||
|
|
||||||
@@ -242,7 +252,7 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
uptime = <Plural value={Math.trunc(system.info?.u / 86400)} one="# day" other="# days" />
|
uptime = <Plural value={Math.trunc(system.info?.u / 86400)} one="# day" other="# days" />
|
||||||
}
|
}
|
||||||
return [
|
return [
|
||||||
{ value: system.host, Icon: GlobeIcon },
|
{ value: getHostDisplayValue(system), Icon: GlobeIcon },
|
||||||
{
|
{
|
||||||
value: system.info.h,
|
value: system.info.h,
|
||||||
Icon: MonitorIcon,
|
Icon: MonitorIcon,
|
||||||
@@ -283,15 +293,28 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// select field for switching between avg and max values
|
||||||
|
const maxValSelect = isLongerChart ? <SelectAvgMax max={maxValues} /> : null
|
||||||
|
|
||||||
// if no data, show empty message
|
// if no data, show empty message
|
||||||
const dataEmpty = !chartLoading && chartData.systemStats.length === 0
|
const dataEmpty = !chartLoading && chartData.systemStats.length === 0
|
||||||
|
const lastGpuVals = Object.values(systemStats.at(-1)?.stats.g ?? {})
|
||||||
|
const hasGpuData = lastGpuVals.length > 0
|
||||||
|
const hasGpuPowerData = lastGpuVals.some((gpu) => gpu.p !== undefined)
|
||||||
|
|
||||||
|
let translatedStatus: string = system.status
|
||||||
|
if (system.status === "up") {
|
||||||
|
translatedStatus = t({ message: "Up", comment: "Context: System is up" })
|
||||||
|
} else if (system.status === "down") {
|
||||||
|
translatedStatus = t({ message: "Down", comment: "Context: System is down" })
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div id="chartwrap" className="grid gap-4 mb-10 overflow-x-clip">
|
<div id="chartwrap" className="grid gap-4 mb-10 overflow-x-clip">
|
||||||
{/* system info */}
|
{/* system info */}
|
||||||
<Card>
|
<Card>
|
||||||
<div className="grid lg:flex gap-4 px-4 sm:px-6 pt-3 sm:pt-4 pb-5">
|
<div className="grid xl:flex gap-4 px-4 sm:px-6 pt-3 sm:pt-4 pb-5">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-[1.6rem] font-semibold mb-1.5">{system.name}</h1>
|
<h1 className="text-[1.6rem] font-semibold mb-1.5">{system.name}</h1>
|
||||||
<div className="flex flex-wrap items-center gap-3 gap-y-2 text-sm opacity-90">
|
<div className="flex flex-wrap items-center gap-3 gap-y-2 text-sm opacity-90">
|
||||||
@@ -312,7 +335,7 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
})}
|
})}
|
||||||
></span>
|
></span>
|
||||||
</span>
|
</span>
|
||||||
{system.status}
|
{translatedStatus}
|
||||||
</div>
|
</div>
|
||||||
{systemInfo.map(({ value, label, Icon, hide }, i) => {
|
{systemInfo.map(({ value, label, Icon, hide }, i) => {
|
||||||
if (hide || !value) {
|
if (hide || !value) {
|
||||||
@@ -341,8 +364,8 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="lg:ms-auto flex items-center gap-2 max-sm:-mb-1">
|
<div className="xl:ms-auto flex items-center gap-2 max-sm:-mb-1">
|
||||||
<ChartTimeSelect className="w-full lg:w-40" />
|
<ChartTimeSelect className="w-full xl:w-40" />
|
||||||
<TooltipProvider delayDuration={100}>
|
<TooltipProvider delayDuration={100}>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
@@ -350,7 +373,7 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
aria-label={t`Toggle grid`}
|
aria-label={t`Toggle grid`}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="hidden lg:flex p-0 text-primary"
|
className="hidden xl:flex p-0 text-primary"
|
||||||
onClick={() => setGrid(!grid)}
|
onClick={() => setGrid(!grid)}
|
||||||
>
|
>
|
||||||
{grid ? (
|
{grid ? (
|
||||||
@@ -374,16 +397,16 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
grid={grid}
|
grid={grid}
|
||||||
title={_(t`CPU Usage`)}
|
title={_(t`CPU Usage`)}
|
||||||
description={t`Average system-wide CPU utilization`}
|
description={t`Average system-wide CPU utilization`}
|
||||||
cornerEl={isLongerChart ? <SelectAvgMax store={cpuMaxStore} /> : null}
|
cornerEl={maxValSelect}
|
||||||
>
|
>
|
||||||
<AreaChartDefault chartData={chartData} chartName="CPU Usage" maxToggled={cpuMaxStore[0]} unit="%" />
|
<AreaChartDefault chartData={chartData} chartName="CPU Usage" maxToggled={maxValues} unit="%" />
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
|
|
||||||
{containerFilterBar && (
|
{containerFilterBar && (
|
||||||
<ChartCard
|
<ChartCard
|
||||||
empty={dataEmpty}
|
empty={dataEmpty}
|
||||||
grid={grid}
|
grid={grid}
|
||||||
title={t`Docker CPU Usage`}
|
title={dockerOrPodman(t`Docker CPU Usage`, system)}
|
||||||
description={t`Average CPU utilization of containers`}
|
description={t`Average CPU utilization of containers`}
|
||||||
cornerEl={containerFilterBar}
|
cornerEl={containerFilterBar}
|
||||||
>
|
>
|
||||||
@@ -395,7 +418,7 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
empty={dataEmpty}
|
empty={dataEmpty}
|
||||||
grid={grid}
|
grid={grid}
|
||||||
title={t`Memory Usage`}
|
title={t`Memory Usage`}
|
||||||
description={t`Triggers when memory usage exceeds a threshold.`}
|
description={t`Precise utilization at the recorded time`}
|
||||||
>
|
>
|
||||||
<MemChart chartData={chartData} />
|
<MemChart chartData={chartData} />
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
@@ -404,8 +427,8 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
<ChartCard
|
<ChartCard
|
||||||
empty={dataEmpty}
|
empty={dataEmpty}
|
||||||
grid={grid}
|
grid={grid}
|
||||||
title={t`Docker Memory Usage`}
|
title={dockerOrPodman(t`Docker Memory Usage`, system)}
|
||||||
description={t`Memory usage of docker containers`}
|
description={dockerOrPodman(t`Memory usage of docker containers`, system)}
|
||||||
cornerEl={containerFilterBar}
|
cornerEl={containerFilterBar}
|
||||||
>
|
>
|
||||||
<ContainerChart chartData={chartData} chartName="mem" dataKey="m" unit=" MB" />
|
<ContainerChart chartData={chartData} chartName="mem" dataKey="m" unit=" MB" />
|
||||||
@@ -413,11 +436,7 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<ChartCard empty={dataEmpty} grid={grid} title={t`Disk Usage`} description={t`Usage of root partition`}>
|
<ChartCard empty={dataEmpty} grid={grid} title={t`Disk Usage`} description={t`Usage of root partition`}>
|
||||||
<DiskChart
|
<DiskChart chartData={chartData} dataKey="stats.du" diskSize={systemStats.at(-1)?.stats.d ?? NaN} />
|
||||||
chartData={chartData}
|
|
||||||
dataKey="stats.du"
|
|
||||||
diskSize={Math.round(systemStats.at(-1)?.stats.d ?? NaN)}
|
|
||||||
/>
|
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
|
|
||||||
<ChartCard
|
<ChartCard
|
||||||
@@ -425,19 +444,19 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
grid={grid}
|
grid={grid}
|
||||||
title={t`Disk I/O`}
|
title={t`Disk I/O`}
|
||||||
description={t`Throughput of root filesystem`}
|
description={t`Throughput of root filesystem`}
|
||||||
cornerEl={isLongerChart ? <SelectAvgMax store={diskIoMaxStore} /> : null}
|
cornerEl={maxValSelect}
|
||||||
>
|
>
|
||||||
<AreaChartDefault chartData={chartData} maxToggled={diskIoMaxStore[0]} chartName="dio" />
|
<AreaChartDefault chartData={chartData} chartName="dio" maxToggled={maxValues} />
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
|
|
||||||
<ChartCard
|
<ChartCard
|
||||||
empty={dataEmpty}
|
empty={dataEmpty}
|
||||||
grid={grid}
|
grid={grid}
|
||||||
title={t`Bandwidth`}
|
title={t`Bandwidth`}
|
||||||
cornerEl={isLongerChart ? <SelectAvgMax store={bandwidthMaxStore} /> : null}
|
cornerEl={maxValSelect}
|
||||||
description={t`Network traffic of public interfaces`}
|
description={t`Network traffic of public interfaces`}
|
||||||
>
|
>
|
||||||
<AreaChartDefault chartData={chartData} maxToggled={bandwidthMaxStore[0]} chartName="bw" />
|
<AreaChartDefault chartData={chartData} chartName="bw" maxToggled={maxValues} />
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
|
|
||||||
{containerFilterBar && containerData.length > 0 && (
|
{containerFilterBar && containerData.length > 0 && (
|
||||||
@@ -449,8 +468,8 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
>
|
>
|
||||||
<ChartCard
|
<ChartCard
|
||||||
empty={dataEmpty}
|
empty={dataEmpty}
|
||||||
title={t`Docker Network I/O`}
|
title={dockerOrPodman(t`Docker Network I/O`, system)}
|
||||||
description={t`Network traffic of docker containers`}
|
description={dockerOrPodman(t`Network traffic of docker containers`, system)}
|
||||||
cornerEl={containerFilterBar}
|
cornerEl={containerFilterBar}
|
||||||
>
|
>
|
||||||
{/* @ts-ignore */}
|
{/* @ts-ignore */}
|
||||||
@@ -459,6 +478,7 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Swap chart */}
|
||||||
{(systemStats.at(-1)?.stats.su ?? 0) > 0 && (
|
{(systemStats.at(-1)?.stats.su ?? 0) > 0 && (
|
||||||
<ChartCard
|
<ChartCard
|
||||||
empty={dataEmpty}
|
empty={dataEmpty}
|
||||||
@@ -470,6 +490,7 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
</ChartCard>
|
</ChartCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Temperature chart */}
|
||||||
{systemStats.at(-1)?.stats.t && (
|
{systemStats.at(-1)?.stats.t && (
|
||||||
<ChartCard
|
<ChartCard
|
||||||
empty={dataEmpty}
|
empty={dataEmpty}
|
||||||
@@ -480,11 +501,61 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
<TemperatureChart chartData={chartData} />
|
<TemperatureChart chartData={chartData} />
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* GPU power draw chart */}
|
||||||
|
{hasGpuPowerData && (
|
||||||
|
<ChartCard
|
||||||
|
empty={dataEmpty}
|
||||||
|
grid={grid}
|
||||||
|
title={t`GPU Power Draw`}
|
||||||
|
description={t`Average power consumption of GPUs`}
|
||||||
|
>
|
||||||
|
<GpuPowerChart chartData={chartData} />
|
||||||
|
</ChartCard>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* GPU charts */}
|
||||||
|
{hasGpuData && (
|
||||||
|
<div className="grid xl:grid-cols-2 gap-4">
|
||||||
|
{Object.keys(systemStats.at(-1)?.stats.g ?? {}).map((id) => {
|
||||||
|
const gpu = systemStats.at(-1)?.stats.g?.[id] as GPUData
|
||||||
|
return (
|
||||||
|
<div key={id} className="contents">
|
||||||
|
<ChartCard
|
||||||
|
empty={dataEmpty}
|
||||||
|
grid={grid}
|
||||||
|
title={`${gpu.n} ${t`Usage`}`}
|
||||||
|
description={t`Average utilization of ${gpu.n}`}
|
||||||
|
>
|
||||||
|
<AreaChartDefault chartData={chartData} chartName={`g.${id}.u`} unit="%" />
|
||||||
|
</ChartCard>
|
||||||
|
<ChartCard
|
||||||
|
empty={dataEmpty}
|
||||||
|
grid={grid}
|
||||||
|
title={`${gpu.n} VRAM`}
|
||||||
|
description={t`Precise utilization at the recorded time`}
|
||||||
|
>
|
||||||
|
<AreaChartDefault
|
||||||
|
chartData={chartData}
|
||||||
|
chartName={`g.${id}.mu`}
|
||||||
|
unit=" MB"
|
||||||
|
max={gpu.mt}
|
||||||
|
tickFormatter={(value) => {
|
||||||
|
const { v, u } = getSizeAndUnit(value, false)
|
||||||
|
return toFixedFloat(v, 1) + u
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ChartCard>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* extra filesystem charts */}
|
{/* extra filesystem charts */}
|
||||||
{Object.keys(systemStats.at(-1)?.stats.efs ?? {}).length > 0 && (
|
{Object.keys(systemStats.at(-1)?.stats.efs ?? {}).length > 0 && (
|
||||||
<div className="grid lg:grid-cols-2 gap-4">
|
<div className="grid xl:grid-cols-2 gap-4">
|
||||||
{Object.keys(systemStats.at(-1)?.stats.efs ?? {}).map((extraFsName) => {
|
{Object.keys(systemStats.at(-1)?.stats.efs ?? {}).map((extraFsName) => {
|
||||||
return (
|
return (
|
||||||
<div key={extraFsName} className="contents">
|
<div key={extraFsName} className="contents">
|
||||||
@@ -497,7 +568,7 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
<DiskChart
|
<DiskChart
|
||||||
chartData={chartData}
|
chartData={chartData}
|
||||||
dataKey={`stats.efs.${extraFsName}.du`}
|
dataKey={`stats.efs.${extraFsName}.du`}
|
||||||
diskSize={Math.round(systemStats.at(-1)?.stats.efs?.[extraFsName].d ?? NaN)}
|
diskSize={systemStats.at(-1)?.stats.efs?.[extraFsName].d ?? NaN}
|
||||||
/>
|
/>
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
<ChartCard
|
<ChartCard
|
||||||
@@ -505,13 +576,9 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
grid={grid}
|
grid={grid}
|
||||||
title={`${extraFsName} I/O`}
|
title={`${extraFsName} I/O`}
|
||||||
description={t`Throughput of ${extraFsName}`}
|
description={t`Throughput of ${extraFsName}`}
|
||||||
cornerEl={isLongerChart ? <SelectAvgMax store={diskIoMaxStore} /> : null}
|
cornerEl={maxValSelect}
|
||||||
>
|
>
|
||||||
<AreaChartDefault
|
<AreaChartDefault chartData={chartData} chartName={`efs.${extraFsName}`} maxToggled={maxValues} />
|
||||||
chartData={chartData}
|
|
||||||
maxToggled={diskIoMaxStore[0]}
|
|
||||||
chartName={`efs.${extraFsName}`}
|
|
||||||
/>
|
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -553,12 +620,10 @@ function ContainerFilterBar() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectAvgMax({ store }: { store: [boolean, React.Dispatch<React.SetStateAction<boolean>>] }) {
|
const SelectAvgMax = memo(({ max }: { max: boolean }) => {
|
||||||
const [max, setMax] = store
|
|
||||||
const Icon = max ? ChartMax : ChartAverage
|
const Icon = max ? ChartMax : ChartAverage
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Select value={max ? "max" : "avg"} onValueChange={(e) => setMax(e === "max")}>
|
<Select value={max ? "max" : "avg"} onValueChange={(e) => $maxValues.set(e === "max")}>
|
||||||
<SelectTrigger className="relative ps-10 pe-5">
|
<SelectTrigger className="relative ps-10 pe-5">
|
||||||
<Icon className="h-4 w-4 absolute start-4 top-1/2 -translate-y-1/2 opacity-85" />
|
<Icon className="h-4 w-4 absolute start-4 top-1/2 -translate-y-1/2 opacity-85" />
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
@@ -573,7 +638,7 @@ function SelectAvgMax({ store }: { store: [boolean, React.Dispatch<React.SetStat
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|
||||||
function ChartCard({
|
function ChartCard({
|
||||||
title,
|
title,
|
||||||
@@ -599,8 +664,14 @@ function ChartCard({
|
|||||||
<CardDescription>{description}</CardDescription>
|
<CardDescription>{description}</CardDescription>
|
||||||
{cornerEl && <div className="relative py-1 block sm:w-44 sm:absolute sm:top-2.5 sm:end-3.5">{cornerEl}</div>}
|
{cornerEl && <div className="relative py-1 block sm:w-44 sm:absolute sm:top-2.5 sm:end-3.5">{cornerEl}</div>}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<div className="ps-0 w-[calc(100%-1.6em)] h-52 relative">
|
<div className="ps-0 w-[calc(100%-1.5em)] h-48 md:h-52 relative group">
|
||||||
{<Spinner msg={empty ? t`Waiting for enough records to display` : undefined} />}
|
{
|
||||||
|
<Spinner
|
||||||
|
msg={empty ? t`Waiting for enough records to display` : undefined}
|
||||||
|
// className="group-has-[.opacity-100]:opacity-0 transition-opacity"
|
||||||
|
className="group-has-[.opacity-100]:invisible duration-100"
|
||||||
|
/>
|
||||||
|
}
|
||||||
{isIntersecting && children}
|
{isIntersecting && children}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
|
import { cn } from "@/lib/utils"
|
||||||
import { LoaderCircleIcon } from "lucide-react"
|
import { LoaderCircleIcon } from "lucide-react"
|
||||||
|
|
||||||
export default function ({ msg }: { msg?: string }) {
|
export default function ({ msg, className }: { msg?: string; className?: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center h-full absolute inset-0">
|
<div className={cn(className, "flex flex-col items-center justify-center h-full absolute inset-0")}>
|
||||||
{msg ? (
|
{msg ? (
|
||||||
<p className={"opacity-60 mb-2 text-center px-4"}>{msg}</p>
|
<p className={"opacity-60 mb-2 text-center text-sm px-4"}>{msg}</p>
|
||||||
) : (
|
) : (
|
||||||
<LoaderCircleIcon className="animate-spin h-10 w-10 opacity-60" />
|
<LoaderCircleIcon className="animate-spin h-10 w-10 opacity-60" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
VisibilityState,
|
VisibilityState,
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
useReactTable,
|
useReactTable,
|
||||||
Column,
|
HeaderContext,
|
||||||
} from "@tanstack/react-table"
|
} from "@tanstack/react-table"
|
||||||
|
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
@@ -21,6 +21,9 @@ import {
|
|||||||
DropdownMenuCheckboxItem,
|
DropdownMenuCheckboxItem,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu"
|
} from "@/components/ui/dropdown-menu"
|
||||||
@@ -34,7 +37,6 @@ import {
|
|||||||
AlertDialogFooter,
|
AlertDialogFooter,
|
||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
AlertDialogTrigger,
|
|
||||||
} from "@/components/ui/alert-dialog"
|
} from "@/components/ui/alert-dialog"
|
||||||
|
|
||||||
import { SystemRecord } from "@/types"
|
import { SystemRecord } from "@/types"
|
||||||
@@ -50,48 +52,68 @@ import {
|
|||||||
HardDriveIcon,
|
HardDriveIcon,
|
||||||
ServerIcon,
|
ServerIcon,
|
||||||
CpuIcon,
|
CpuIcon,
|
||||||
ChevronDownIcon,
|
LayoutGridIcon,
|
||||||
|
LayoutListIcon,
|
||||||
|
ArrowDownIcon,
|
||||||
|
ArrowUpIcon,
|
||||||
|
Settings2Icon,
|
||||||
|
EyeIcon,
|
||||||
|
PenBoxIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { useEffect, useMemo, useState } from "react"
|
import { memo, useEffect, useMemo, useRef, useState } from "react"
|
||||||
import { $hubVersion, $systems, pb } from "@/lib/stores"
|
import { $hubVersion, $systems, pb } from "@/lib/stores"
|
||||||
import { useStore } from "@nanostores/react"
|
import { useStore } from "@nanostores/react"
|
||||||
import { cn, copyToClipboard, decimalString, isReadOnlyUser, useLocalStorage } from "@/lib/utils"
|
import { cn, copyToClipboard, decimalString, isReadOnlyUser, useLocalStorage } from "@/lib/utils"
|
||||||
import AlertsButton from "../alerts/alert-button"
|
import AlertsButton from "../alerts/alert-button"
|
||||||
import { navigate } from "../router"
|
import { $router, Link, navigate } from "../router"
|
||||||
import { EthernetIcon } from "../ui/icons"
|
import { EthernetIcon, GpuIcon, ThermometerIcon } from "../ui/icons"
|
||||||
import { Trans, t } from "@lingui/macro"
|
import { Trans, t } from "@lingui/macro"
|
||||||
import { useLingui } from "@lingui/react"
|
import { useLingui } from "@lingui/react"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card"
|
||||||
import { Input } from "../ui/input"
|
import { Input } from "../ui/input"
|
||||||
|
import { ClassValue } from "clsx"
|
||||||
|
import { getPagePath } from "@nanostores/router"
|
||||||
|
import { SystemDialog } from "../add-system"
|
||||||
|
import { Dialog } from "../ui/dialog"
|
||||||
|
|
||||||
|
type ViewMode = "table" | "grid"
|
||||||
|
|
||||||
function CellFormatter(info: CellContext<SystemRecord, unknown>) {
|
function CellFormatter(info: CellContext<SystemRecord, unknown>) {
|
||||||
const val = info.getValue() as number
|
const val = (info.getValue() as number) || 0
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-1 items-center tabular-nums tracking-tight">
|
<div className="flex gap-2 items-center tabular-nums tracking-tight">
|
||||||
<span className="min-w-[3.5em]">{decimalString(val, 1)}%</span>
|
<span className="min-w-[3.3em]">{decimalString(val, 1)}%</span>
|
||||||
<span className="grow min-w-10 block bg-muted h-[1em] relative rounded-sm overflow-hidden">
|
<span className="grow min-w-10 block bg-muted h-[1em] relative rounded-sm overflow-hidden">
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute inset-0 w-full h-full origin-left",
|
"absolute inset-0 w-full h-full origin-left",
|
||||||
(val < 65 && "bg-green-500") || (val < 90 && "bg-yellow-500") || "bg-red-600"
|
(info.row.original.status !== "up" && "bg-primary/30") ||
|
||||||
|
(val < 65 && "bg-green-500") ||
|
||||||
|
(val < 90 && "bg-yellow-500") ||
|
||||||
|
"bg-red-600"
|
||||||
)}
|
)}
|
||||||
style={{ transform: `scalex(${val}%)` }}
|
style={{
|
||||||
|
transform: `scalex(${val / 100})`,
|
||||||
|
}}
|
||||||
></span>
|
></span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function sortableHeader(column: Column<SystemRecord, unknown>, Icon: any, hideSortIcon = false) {
|
function sortableHeader(context: HeaderContext<SystemRecord, unknown>) {
|
||||||
|
const { column } = context
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="h-9 px-3 flex"
|
className="h-9 px-3 flex"
|
||||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||||
>
|
>
|
||||||
<Icon className="me-2 h-4 w-4" />
|
{/* @ts-ignore */}
|
||||||
|
{column.columnDef.icon && <column.columnDef.icon className="me-2 size-4" />}
|
||||||
{column.id}
|
{column.id}
|
||||||
{!hideSortIcon && <ArrowUpDownIcon className="ms-2 h-4 w-4" />}
|
{/* @ts-ignore */}
|
||||||
|
{column.columnDef.hideSort || <ArrowUpDownIcon className="ms-2 size-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -100,9 +122,10 @@ export default function SystemsTable() {
|
|||||||
const data = useStore($systems)
|
const data = useStore($systems)
|
||||||
const hubVersion = useStore($hubVersion)
|
const hubVersion = useStore($hubVersion)
|
||||||
const [filter, setFilter] = useState<string>()
|
const [filter, setFilter] = useState<string>()
|
||||||
const [sorting, setSorting] = useState<SortingState>([])
|
const [sorting, setSorting] = useState<SortingState>([{ id: t`System`, desc: false }])
|
||||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
||||||
const [columnVisibility, setColumnVisibility] = useLocalStorage<VisibilityState>("cols", {})
|
const [columnVisibility, setColumnVisibility] = useLocalStorage<VisibilityState>("cols", {})
|
||||||
|
const [viewMode, setViewMode] = useLocalStorage<ViewMode>("viewMode", window.innerWidth > 1024 ? "table" : "grid")
|
||||||
const { i18n } = useLingui()
|
const { i18n } = useLingui()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -120,64 +143,101 @@ export default function SystemsTable() {
|
|||||||
accessorKey: "name",
|
accessorKey: "name",
|
||||||
id: t`System`,
|
id: t`System`,
|
||||||
enableHiding: false,
|
enableHiding: false,
|
||||||
cell: (info) => {
|
icon: ServerIcon,
|
||||||
const { status } = info.row.original
|
cell: (info) => (
|
||||||
return (
|
<span className="flex gap-0.5 items-center text-base md:pe-5">
|
||||||
<span className="flex gap-0.5 items-center text-base md:pe-5">
|
<IndicatorDot system={info.row.original} />
|
||||||
<span
|
<Button
|
||||||
className={cn("w-2 h-2 left-0 rounded-full", {
|
data-nolink
|
||||||
"bg-green-500": status === "up",
|
variant={"ghost"}
|
||||||
"bg-red-500": status === "down",
|
className="text-primary/90 h-7 px-1.5 gap-1.5"
|
||||||
"bg-primary/40": status === "paused",
|
onClick={() => copyToClipboard(info.getValue() as string)}
|
||||||
"bg-yellow-500": status === "pending",
|
>
|
||||||
})}
|
{info.getValue() as string}
|
||||||
style={{ marginBottom: "-1px" }}
|
<CopyIcon className="h-2.5 w-2.5" />
|
||||||
></span>
|
</Button>
|
||||||
<Button
|
</span>
|
||||||
data-nolink
|
),
|
||||||
variant={"ghost"}
|
header: sortableHeader,
|
||||||
className="text-primary/90 h-7 px-1.5 gap-1.5"
|
|
||||||
onClick={() => copyToClipboard(info.getValue() as string)}
|
|
||||||
>
|
|
||||||
{info.getValue() as string}
|
|
||||||
<CopyIcon className="h-2.5 w-2.5" />
|
|
||||||
</Button>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
header: ({ column }) => sortableHeader(column, ServerIcon),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "info.cpu",
|
accessorKey: "info.cpu",
|
||||||
id: t`CPU`,
|
id: t`CPU`,
|
||||||
invertSorting: true,
|
invertSorting: true,
|
||||||
cell: CellFormatter,
|
cell: CellFormatter,
|
||||||
header: ({ column }) => sortableHeader(column, CpuIcon),
|
icon: CpuIcon,
|
||||||
|
header: sortableHeader,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "info.mp",
|
accessorKey: "info.mp",
|
||||||
id: t`Memory`,
|
id: t`Memory`,
|
||||||
invertSorting: true,
|
invertSorting: true,
|
||||||
cell: CellFormatter,
|
cell: CellFormatter,
|
||||||
header: ({ column }) => sortableHeader(column, MemoryStickIcon),
|
icon: MemoryStickIcon,
|
||||||
|
header: sortableHeader,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "info.dp",
|
accessorKey: "info.dp",
|
||||||
id: t`Disk`,
|
id: t`Disk`,
|
||||||
invertSorting: true,
|
invertSorting: true,
|
||||||
cell: CellFormatter,
|
cell: CellFormatter,
|
||||||
header: ({ column }) => sortableHeader(column, HardDriveIcon),
|
icon: HardDriveIcon,
|
||||||
|
header: sortableHeader,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorFn: (originalRow) => originalRow.info.g,
|
||||||
|
id: "GPU",
|
||||||
|
invertSorting: true,
|
||||||
|
sortUndefined: -1,
|
||||||
|
cell: CellFormatter,
|
||||||
|
icon: GpuIcon,
|
||||||
|
header: sortableHeader,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorFn: (originalRow) => originalRow.info.b || 0,
|
accessorFn: (originalRow) => originalRow.info.b || 0,
|
||||||
id: t`Net`,
|
id: t`Net`,
|
||||||
invertSorting: true,
|
invertSorting: true,
|
||||||
size: 115,
|
size: 50,
|
||||||
header: ({ column }) => sortableHeader(column, EthernetIcon),
|
icon: EthernetIcon,
|
||||||
cell: (info) => {
|
header: sortableHeader,
|
||||||
|
cell(info) {
|
||||||
const val = info.getValue() as number
|
const val = info.getValue() as number
|
||||||
return (
|
return (
|
||||||
<span className="tabular-nums whitespace-nowrap ps-1">{decimalString(val, val >= 100 ? 1 : 2)} MB/s</span>
|
<span
|
||||||
|
className={cn("tabular-nums whitespace-nowrap", {
|
||||||
|
"ps-1": viewMode === "table",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{decimalString(val, val >= 100 ? 1 : 2)} MB/s
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorFn: (originalRow) => originalRow.info.dt,
|
||||||
|
id: t({
|
||||||
|
message: "Temp",
|
||||||
|
comment: "Temperature label in systems table",
|
||||||
|
}),
|
||||||
|
invertSorting: true,
|
||||||
|
sortUndefined: -1,
|
||||||
|
size: 50,
|
||||||
|
hideSort: true,
|
||||||
|
icon: ThermometerIcon,
|
||||||
|
header: sortableHeader,
|
||||||
|
cell(info) {
|
||||||
|
const val = info.getValue() as number
|
||||||
|
if (!val) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn("tabular-nums whitespace-nowrap", {
|
||||||
|
"ps-1.5": viewMode === "table",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{decimalString(val)} °C
|
||||||
|
</span>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -186,18 +246,29 @@ export default function SystemsTable() {
|
|||||||
id: t`Agent`,
|
id: t`Agent`,
|
||||||
invertSorting: true,
|
invertSorting: true,
|
||||||
size: 50,
|
size: 50,
|
||||||
header: ({ column }) => sortableHeader(column, WifiIcon, true),
|
icon: WifiIcon,
|
||||||
cell: (info) => {
|
hideSort: true,
|
||||||
|
header: sortableHeader,
|
||||||
|
cell(info) {
|
||||||
const version = info.getValue() as string
|
const version = info.getValue() as string
|
||||||
if (!version || !hubVersion) {
|
if (!version || !hubVersion) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
const system = info.row.original
|
||||||
return (
|
return (
|
||||||
<span className="flex gap-2 items-center md:pe-5 tabular-nums ps-1">
|
<span
|
||||||
<span
|
className={cn("flex gap-2 items-center md:pe-5 tabular-nums", {
|
||||||
className={cn("w-2 h-2 left-0 rounded-full", version === hubVersion ? "bg-green-500" : "bg-yellow-500")}
|
"ps-1": viewMode === "table",
|
||||||
style={{ marginBottom: "-1px" }}
|
})}
|
||||||
></span>
|
>
|
||||||
|
<IndicatorDot
|
||||||
|
system={system}
|
||||||
|
className={
|
||||||
|
(system.status !== "up" && "bg-primary/30") ||
|
||||||
|
(version === hubVersion && "bg-green-500") ||
|
||||||
|
"bg-yellow-500"
|
||||||
|
}
|
||||||
|
/>
|
||||||
<span>{info.getValue() as string}</span>
|
<span>{info.getValue() as string}</span>
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
@@ -205,84 +276,13 @@ export default function SystemsTable() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: t({ message: "Actions", comment: "Table column" }),
|
id: t({ message: "Actions", comment: "Table column" }),
|
||||||
size: 120,
|
size: 50,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => (
|
||||||
const { id, name, status, host } = row.original
|
<div className="flex justify-end items-center gap-1">
|
||||||
return (
|
<AlertsButton system={row.original} />
|
||||||
<div className={"flex justify-end items-center gap-1"}>
|
<ActionsButton system={row.original} />
|
||||||
<AlertsButton system={row.original} />
|
</div>
|
||||||
<AlertDialog>
|
),
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="ghost" size={"icon"} data-nolink>
|
|
||||||
<span className="sr-only">
|
|
||||||
<Trans>Open menu</Trans>
|
|
||||||
</span>
|
|
||||||
<MoreHorizontalIcon className="w-5" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem
|
|
||||||
className={cn(isReadOnlyUser() && "hidden")}
|
|
||||||
onClick={() => {
|
|
||||||
pb.collection("systems").update(id, {
|
|
||||||
status: status === "paused" ? "pending" : "paused",
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{status === "paused" ? (
|
|
||||||
<>
|
|
||||||
<PlayCircleIcon className="me-2.5 h-4 w-4" />
|
|
||||||
<Trans>Resume</Trans>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<PauseCircleIcon className="me-2.5 h-4 w-4" />
|
|
||||||
<Trans>Pause</Trans>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={() => copyToClipboard(host)}>
|
|
||||||
<CopyIcon className="me-2.5 h-4 w-4" />
|
|
||||||
<Trans>Copy host</Trans>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator className={cn(isReadOnlyUser() && "hidden")} />
|
|
||||||
<AlertDialogTrigger asChild>
|
|
||||||
<DropdownMenuItem className={cn(isReadOnlyUser() && "hidden")}>
|
|
||||||
<Trash2Icon className="me-2.5 h-4 w-4" />
|
|
||||||
<Trans>Delete</Trans>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</AlertDialogTrigger>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>
|
|
||||||
<Trans>Are you sure you want to delete {name}?</Trans>
|
|
||||||
</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
<Trans>
|
|
||||||
This action cannot be undone. This will permanently delete all current records for {name} from
|
|
||||||
the database.
|
|
||||||
</Trans>
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>
|
|
||||||
<Trans>Cancel</Trans>
|
|
||||||
</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
className={cn(buttonVariants({ variant: "destructive" }))}
|
|
||||||
onClick={() => pb.collection("systems").delete(id)}
|
|
||||||
>
|
|
||||||
<Trans>Continue</Trans>
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
] as ColumnDef<SystemRecord>[]
|
] as ColumnDef<SystemRecord>[]
|
||||||
}, [hubVersion, i18n.locale])
|
}, [hubVersion, i18n.locale])
|
||||||
@@ -308,6 +308,8 @@ export default function SystemsTable() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const rows = table.getRowModel().rows
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-5 px-2 sm:px-6 max-sm:pt-5 max-sm:pb-1">
|
<CardHeader className="pb-5 px-2 sm:px-6 max-sm:pt-5 max-sm:pb-1">
|
||||||
@@ -325,86 +327,340 @@ export default function SystemsTable() {
|
|||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="outline">
|
<Button variant="outline">
|
||||||
<Trans comment="Context: table columns">Columns</Trans>{" "}
|
<Settings2Icon className="me-1.5 size-4 opacity-80" />
|
||||||
<ChevronDownIcon className="ms-1.5 h-4 w-4 opacity-90" />
|
<Trans>View</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end" className="h-72 md:h-auto min-w-48 md:min-w-auto overflow-y-auto">
|
||||||
{table
|
<div className="grid grid-cols-1 md:grid-cols-3 divide-y md:divide-s md:divide-y-0">
|
||||||
.getAllColumns()
|
<div>
|
||||||
.filter((column) => column.getCanHide())
|
<DropdownMenuLabel className="pt-2 px-3.5 flex items-center gap-2">
|
||||||
.map((column) => {
|
<LayoutGridIcon className="size-4" />
|
||||||
return (
|
<Trans>Layout</Trans>
|
||||||
<DropdownMenuCheckboxItem
|
</DropdownMenuLabel>
|
||||||
key={column.id}
|
<DropdownMenuSeparator />
|
||||||
checked={column.getIsVisible()}
|
<DropdownMenuRadioGroup
|
||||||
onCheckedChange={(value) => column.toggleVisibility(!!value)}
|
className="px-1 pb-1"
|
||||||
>
|
value={viewMode}
|
||||||
{column.id}
|
onValueChange={(view) => setViewMode(view as ViewMode)}
|
||||||
</DropdownMenuCheckboxItem>
|
>
|
||||||
)
|
<DropdownMenuRadioItem value="table" onSelect={(e) => e.preventDefault()} className="gap-2">
|
||||||
})}
|
<LayoutListIcon className="size-4" />
|
||||||
|
<Trans>Table</Trans>
|
||||||
|
</DropdownMenuRadioItem>
|
||||||
|
<DropdownMenuRadioItem value="grid" onSelect={(e) => e.preventDefault()} className="gap-2">
|
||||||
|
<LayoutGridIcon className="size-4" />
|
||||||
|
<Trans>Grid</Trans>
|
||||||
|
</DropdownMenuRadioItem>
|
||||||
|
</DropdownMenuRadioGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<DropdownMenuLabel className="pt-2 px-3.5 flex items-center gap-2">
|
||||||
|
<ArrowUpDownIcon className="size-4" />
|
||||||
|
<Trans>Sort By</Trans>
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<div className="px-1 pb-1">
|
||||||
|
{table.getAllColumns().map((column) => {
|
||||||
|
if (column.id === t`Actions` || !column.getCanSort()) return null
|
||||||
|
let Icon = <span className="w-6"></span>
|
||||||
|
// if current sort column, show sort direction
|
||||||
|
if (sorting[0]?.id === column.id) {
|
||||||
|
if (sorting[0]?.desc) {
|
||||||
|
Icon = <ArrowUpIcon className="me-2 size-4" />
|
||||||
|
} else {
|
||||||
|
Icon = <ArrowDownIcon className="me-2 size-4" />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setSorting([{ id: column.id, desc: sorting[0]?.id === column.id && !sorting[0]?.desc }])
|
||||||
|
}}
|
||||||
|
key={column.id}
|
||||||
|
>
|
||||||
|
{Icon}
|
||||||
|
{column.id}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<DropdownMenuLabel className="pt-2 px-3.5 flex items-center gap-2">
|
||||||
|
<EyeIcon className="size-4" />
|
||||||
|
<Trans>Visible Fields</Trans>
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<div className="px-1.5 pb-1">
|
||||||
|
{table
|
||||||
|
.getAllColumns()
|
||||||
|
.filter((column) => column.getCanHide())
|
||||||
|
.map((column) => {
|
||||||
|
return (
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
key={column.id}
|
||||||
|
onSelect={(e) => e.preventDefault()}
|
||||||
|
checked={column.getIsVisible()}
|
||||||
|
onCheckedChange={(value) => column.toggleVisibility(!!value)}
|
||||||
|
>
|
||||||
|
{column.id}
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="max-sm:p-2">
|
<div className="p-6 pt-0 max-sm:py-3 max-sm:px-2">
|
||||||
<div className="rounded-md border overflow-hidden">
|
{viewMode === "table" ? (
|
||||||
<Table>
|
// table layout
|
||||||
<TableHeader className="bg-muted/40">
|
<div className="rounded-md border overflow-hidden">
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
<Table>
|
||||||
<TableRow key={headerGroup.id}>
|
<TableHeader>
|
||||||
{headerGroup.headers.map((header) => {
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
return (
|
<TableRow key={headerGroup.id}>
|
||||||
<TableHead className="px-2" key={header.id}>
|
{headerGroup.headers.map((header) => {
|
||||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
return (
|
||||||
</TableHead>
|
<TableHead className="px-2" key={header.id}>
|
||||||
)
|
{header.isPlaceholder
|
||||||
})}
|
? null
|
||||||
</TableRow>
|
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
))}
|
</TableHead>
|
||||||
</TableHeader>
|
)
|
||||||
<TableBody>
|
|
||||||
{table.getRowModel().rows?.length ? (
|
|
||||||
table.getRowModel().rows.map((row) => (
|
|
||||||
<TableRow
|
|
||||||
key={row.original.id}
|
|
||||||
data-state={row.getIsSelected() && "selected"}
|
|
||||||
className={cn("cursor-pointer transition-opacity", {
|
|
||||||
"opacity-50": row.original.status === "paused",
|
|
||||||
})}
|
})}
|
||||||
onClick={(e) => {
|
|
||||||
const target = e.target as HTMLElement
|
|
||||||
if (!target.closest("[data-nolink]") && e.currentTarget.contains(target)) {
|
|
||||||
navigate(`/system/${encodeURIComponent(row.original.name)}`)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{row.getVisibleCells().map((cell) => (
|
|
||||||
<TableCell
|
|
||||||
key={cell.id}
|
|
||||||
style={{
|
|
||||||
width: cell.column.getSize() === Number.MAX_SAFE_INTEGER ? "auto" : cell.column.getSize(),
|
|
||||||
}}
|
|
||||||
className={cn("overflow-hidden relative", data.length > 10 ? "py-2" : "py-2.5")}
|
|
||||||
>
|
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
||||||
</TableCell>
|
|
||||||
))}
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))}
|
||||||
) : (
|
</TableHeader>
|
||||||
<TableRow>
|
<TableBody>
|
||||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
{rows.length ? (
|
||||||
<Trans>No systems found.</Trans>
|
table.getRowModel().rows.map((row) => (
|
||||||
</TableCell>
|
<TableRow
|
||||||
</TableRow>
|
key={row.original.id}
|
||||||
)}
|
data-state={row.getIsSelected() && "selected"}
|
||||||
</TableBody>
|
className={cn("cursor-pointer transition-opacity", {
|
||||||
</Table>
|
"opacity-50": row.original.status === "paused",
|
||||||
</div>
|
})}
|
||||||
</CardContent>
|
onClick={(e) => {
|
||||||
|
const target = e.target as HTMLElement
|
||||||
|
if (!target.closest("[data-nolink]") && e.currentTarget.contains(target)) {
|
||||||
|
navigate(getPagePath($router, "system", { name: row.original.name }))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<TableCell
|
||||||
|
key={cell.id}
|
||||||
|
style={{
|
||||||
|
width: cell.column.getSize() === Number.MAX_SAFE_INTEGER ? "auto" : cell.column.getSize(),
|
||||||
|
}}
|
||||||
|
className={cn("overflow-hidden relative", data.length > 10 ? "py-2" : "py-2.5")}
|
||||||
|
>
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||||
|
<Trans>No systems found.</Trans>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// grid layout
|
||||||
|
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{table.getRowModel().rows?.length ? (
|
||||||
|
table.getRowModel().rows.map((row) => {
|
||||||
|
const system = row.original
|
||||||
|
const { status } = system
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={system.id}
|
||||||
|
className={cn(
|
||||||
|
"cursor-pointer hover:shadow-md transition-all bg-transparent w-full dark:border-border duration-200 relative",
|
||||||
|
{
|
||||||
|
"opacity-50": status === "paused",
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CardHeader className="py-1 ps-5 pe-3 bg-muted/30 border-b border-border/60">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<CardTitle className="text-base tracking-normal shrink-1 text-primary/90 flex items-center min-h-10 gap-2.5 min-w-0">
|
||||||
|
<div className="flex items-center gap-2.5 min-w-0">
|
||||||
|
<IndicatorDot system={system} />
|
||||||
|
<CardTitle className="text-[.95em]/normal tracking-normal truncate text-primary/90">
|
||||||
|
{system.name}
|
||||||
|
</CardTitle>
|
||||||
|
</div>
|
||||||
|
</CardTitle>
|
||||||
|
{table.getColumn(t`Actions`)?.getIsVisible() && (
|
||||||
|
<div className="flex gap-1 flex-shrink-0 relative z-10">
|
||||||
|
<AlertsButton system={system} />
|
||||||
|
<ActionsButton system={system} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2.5 text-sm px-5 pt-3.5 pb-4">
|
||||||
|
{table.getAllColumns().map((column) => {
|
||||||
|
if (!column.getIsVisible() || column.id === t`System` || column.id === t`Actions`) return null
|
||||||
|
const cell = row.getAllCells().find((cell) => cell.column.id === column.id)
|
||||||
|
if (!cell) return null
|
||||||
|
return (
|
||||||
|
<div key={column.id} className="flex items-center gap-3">
|
||||||
|
{/* @ts-ignore */}
|
||||||
|
{column.columnDef?.icon && (
|
||||||
|
// @ts-ignore
|
||||||
|
<column.columnDef.icon className="size-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-3 flex-1">
|
||||||
|
<span className="text-muted-foreground min-w-16">{column.id}:</span>
|
||||||
|
<div className="flex-1">{flexRender(cell.column.columnDef.cell, cell.getContext())}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</CardContent>
|
||||||
|
<Link
|
||||||
|
href={getPagePath($router, "system", { name: row.original.name })}
|
||||||
|
className="inset-0 absolute w-full h-full"
|
||||||
|
>
|
||||||
|
<span className="sr-only">{row.original.name}</span>
|
||||||
|
</Link>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<div className="col-span-full text-center py-8">
|
||||||
|
<Trans>No systems found.</Trans>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function IndicatorDot({ system, className }: { system: SystemRecord; className?: ClassValue }) {
|
||||||
|
className ||= {
|
||||||
|
"bg-green-500": system.status === "up",
|
||||||
|
"bg-red-500": system.status === "down",
|
||||||
|
"bg-primary/40": system.status === "paused",
|
||||||
|
"bg-yellow-500": system.status === "pending",
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn("flex-shrink-0 size-2 rounded-full", className)}
|
||||||
|
// style={{ marginBottom: "-1px" }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ActionsButton = memo(({ system }: { system: SystemRecord }) => {
|
||||||
|
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||||
|
const [editOpen, setEditOpen] = useState(false)
|
||||||
|
let editOpened = useRef(false)
|
||||||
|
|
||||||
|
const { id, status, host, name } = system
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size={"icon"} data-nolink>
|
||||||
|
<span className="sr-only">
|
||||||
|
<Trans>Open menu</Trans>
|
||||||
|
</span>
|
||||||
|
<MoreHorizontalIcon className="w-5" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
{!isReadOnlyUser() && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={() => {
|
||||||
|
editOpened.current = true
|
||||||
|
setEditOpen(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PenBoxIcon className="me-2.5 size-4" />
|
||||||
|
<Trans>Edit</Trans>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
<DropdownMenuItem
|
||||||
|
className={cn(isReadOnlyUser() && "hidden")}
|
||||||
|
onClick={() => {
|
||||||
|
pb.collection("systems").update(id, {
|
||||||
|
status: status === "paused" ? "pending" : "paused",
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{status === "paused" ? (
|
||||||
|
<>
|
||||||
|
<PlayCircleIcon className="me-2.5 size-4" />
|
||||||
|
<Trans>Resume</Trans>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<PauseCircleIcon className="me-2.5 size-4" />
|
||||||
|
<Trans>Pause</Trans>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => copyToClipboard(host)}>
|
||||||
|
<CopyIcon className="me-2.5 size-4" />
|
||||||
|
<Trans>Copy host</Trans>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator className={cn(isReadOnlyUser() && "hidden")} />
|
||||||
|
<DropdownMenuItem className={cn(isReadOnlyUser() && "hidden")} onSelect={() => setDeleteOpen(true)}>
|
||||||
|
<Trash2Icon className="me-2.5 size-4" />
|
||||||
|
<Trans>Delete</Trans>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
{/* edit dialog */}
|
||||||
|
<Dialog open={editOpen} onOpenChange={setEditOpen}>
|
||||||
|
{editOpened.current && <SystemDialog system={system} setOpen={setEditOpen} />}
|
||||||
|
</Dialog>
|
||||||
|
{/* deletion dialog */}
|
||||||
|
<AlertDialog open={deleteOpen} onOpenChange={(open) => setDeleteOpen(open)}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
<Trans>Are you sure you want to delete {name}?</Trans>
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
<Trans>
|
||||||
|
This action cannot be undone. This will permanently delete all current records for {name} from the
|
||||||
|
database.
|
||||||
|
</Trans>
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
className={cn(buttonVariants({ variant: "destructive" }))}
|
||||||
|
onClick={() => pb.collection("systems").delete(id)}
|
||||||
|
>
|
||||||
|
<Trans>Continue</Trans>
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const AlertDialogOverlay = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<AlertDialogPrimitive.Overlay
|
<AlertDialogPrimitive.Overlay
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
"fixed inset-0 z-50 bg-black/40 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const buttonVariants = cva(
|
|||||||
variant: {
|
variant: {
|
||||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||||
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
outline: "border bg-background hover:bg-accent/70 dark:hover:bg-accent/50 hover:text-accent-foreground",
|
||||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
|||||||
@@ -3,7 +3,11 @@ import * as React from "react"
|
|||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
|
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
|
||||||
<div ref={ref} className={cn("rounded-lg border bg-card text-card-foreground shadow-sm", className)} {...props} />
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("rounded-lg border border-border/60 bg-card text-card-foreground shadow-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
))
|
))
|
||||||
Card.displayName = "Card"
|
Card.displayName = "Card"
|
||||||
|
|
||||||
|
|||||||
@@ -334,7 +334,7 @@ const xAxis = function ({ domain, ticks, chartTime }: ChartData) {
|
|||||||
allowDataOverflow
|
allowDataOverflow
|
||||||
type="number"
|
type="number"
|
||||||
scale="time"
|
scale="time"
|
||||||
minTickGap={15}
|
minTickGap={12}
|
||||||
tickMargin={8}
|
tickMargin={8}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickFormatter={chartTimeData[chartTime].format}
|
tickFormatter={chartTimeData[chartTime].format}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const DialogOverlay = React.forwardRef<
|
|||||||
<DialogPrimitive.Overlay
|
<DialogPrimitive.Overlay
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
"fixed inset-0 z-50 bg-black/40 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -63,3 +63,13 @@ export function ThermometerIcon(props: SVGProps<SVGSVGElement>) {
|
|||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Huge icons (MIT)
|
||||||
|
export function GpuIcon(props: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" {...props} stroke="currentColor" fill="none" strokeWidth="2">
|
||||||
|
<path d="M4 21V4.1a1.5 1.5 0 0 0-1.1-1L2 3m2 2h13c2.4 0 3.5 0 4.3.7s.7 2 .7 4.3v4.5c0 2.4 0 3.5-.7 4.3-.8.7-2 .7-4.3.7h-4.9a1.8 1.8 0 0 1-1.6-1c-.3-.6-1-1-1.6-1H4" />
|
||||||
|
<path d="M19 11.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0m-11.5-3h2m-2 3h2m-2 3h2" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const InputTags = React.forwardRef<HTMLInputElement, InputTagsProps>(
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-background min-h-10 flex w-full flex-wrap gap-2 rounded-md border border-input px-3 py-2 text-sm placeholder:text-muted-foreground has-[:focus-visible]:outline-none ring-offset-background has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring has-[:focus-visible]:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
"bg-background min-h-10 flex w-full flex-wrap gap-2 rounded-md border px-3 py-2 text-sm placeholder:text-muted-foreground has-[:focus-visible]:outline-none ring-offset-background has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring has-[:focus-visible]:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, type,
|
|||||||
<input
|
<input
|
||||||
type={type}
|
type={type}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
"flex h-10 w-full rounded-md border bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const SelectTrigger = React.forwardRef<
|
|||||||
<SelectPrimitive.Trigger
|
<SelectPrimitive.Trigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
"flex h-10 w-full items-center justify-between rounded-md border bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableE
|
|||||||
Table.displayName = "Table"
|
Table.displayName = "Table"
|
||||||
|
|
||||||
const TableHeader = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
|
const TableHeader = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
|
||||||
({ className, ...props }, ref) => <thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
({ className, ...props }, ref) => (
|
||||||
|
<thead ref={ref} className={cn("bg-muted/30 [&_tr]:border-b", className)} {...props} />
|
||||||
|
)
|
||||||
)
|
)
|
||||||
TableHeader.displayName = "TableHeader"
|
TableHeader.displayName = "TableHeader"
|
||||||
|
|
||||||
@@ -34,7 +36,10 @@ const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTML
|
|||||||
({ className, ...props }, ref) => (
|
({ className, ...props }, ref) => (
|
||||||
<tr
|
<tr
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("border-b hover:bg-muted/40 dark:hover:bg-muted/30 data-[state=selected]:bg-muted", className)}
|
className={cn(
|
||||||
|
"border-b border-border/60 hover:bg-muted/40 dark:hover:bg-muted/20 data-[state=selected]:bg-muted",
|
||||||
|
className
|
||||||
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(({ classNa
|
|||||||
return (
|
return (
|
||||||
<textarea
|
<textarea
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex min-h-14 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
"flex min-h-14 w-full rounded-md border bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
@@ -34,25 +34,25 @@
|
|||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
--background: 240 10% 6.2%;
|
--background: 220 5.5% 9%;
|
||||||
--foreground: 0 0% 98.04%;
|
--foreground: 220 2% 97%;
|
||||||
--card: 240 8.57% 8%;
|
--card: 220 5.5% 10.5%;
|
||||||
--card-foreground: 0 0% 98.04%;
|
--card-foreground: 220 2% 97%;
|
||||||
--popover: 240 10% 6.2%;
|
--popover: 220 5.5% 9%;
|
||||||
--popover-foreground: 0 0% 98.04%;
|
--popover-foreground: 220 2% 97%;
|
||||||
--primary: 0 0% 98.04%;
|
--primary: 220 2% 96%;
|
||||||
--primary-foreground: 240 5.88% 10%;
|
--primary-foreground: 220 4% 10%;
|
||||||
--secondary: 240 3.7% 15.88%;
|
--secondary: 220 4% 16%;
|
||||||
--secondary-foreground: 0 0% 98.04%;
|
--secondary-foreground: 220 0% 98%;
|
||||||
--muted: 240 3.7% 15.88%;
|
--muted: 220 6% 16%;
|
||||||
--muted-foreground: 240 5.03% 64.9%;
|
--muted-foreground: 220 4% 67%;
|
||||||
--accent: 240 3.7% 15.88%;
|
--accent: 220 5% 15.5%;
|
||||||
--accent-foreground: 0 0% 98.04%;
|
--accent-foreground: 220 2% 98%;
|
||||||
--destructive: 0 59% 46%;
|
--destructive: 0 62% 46%;
|
||||||
--destructive-foreground: 0 0% 98.04%;
|
--destructive-foreground: 0 0% 97%;
|
||||||
--border: 240 2.86% 12%;
|
--border: 220 3% 16%;
|
||||||
--input: 240 3.7% 15.88%;
|
--input: 220 4% 22%;
|
||||||
--ring: 240 4.88% 86%;
|
--ring: 220 4% 80%;
|
||||||
--radius: 0.8rem;
|
--radius: 0.8rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -78,9 +78,19 @@
|
|||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
.link {
|
.link {
|
||||||
@apply text-primary font-medium underline-offset-4 hover:underline;
|
@apply text-primary font-medium underline-offset-4 hover:underline;
|
||||||
}
|
}
|
||||||
|
/* New system dialog width */
|
||||||
|
.ns-dialog {
|
||||||
|
min-width: 30.3rem;
|
||||||
|
}
|
||||||
|
:where(:lang(zh), :lang(zh-CN), :lang(ko)) .ns-dialog {
|
||||||
|
min-width: 27.9rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.recharts-tooltip-wrapper {
|
.recharts-tooltip-wrapper {
|
||||||
|
|||||||
@@ -1,46 +1,57 @@
|
|||||||
import { $direction } from "./stores"
|
import { $direction } from "./stores"
|
||||||
import { i18n } from "@lingui/core"
|
import { i18n } from "@lingui/core"
|
||||||
import { detect, fromUrl, fromStorage, fromNavigator } from "@lingui/detect-locale"
|
import type { Messages } from "@lingui/core"
|
||||||
import { messages as enMessages } from "../locales/en/en.ts"
|
import languages from "@/lib/languages"
|
||||||
|
import { detect, fromStorage, fromNavigator } from "@lingui/detect-locale"
|
||||||
|
import { messages as enMessages } from "@/locales/en/en.ts"
|
||||||
|
|
||||||
// const locale = detect(fromUrl("lang"), fromStorage("lang"), fromNavigator(), "en")
|
// let locale = detect(fromUrl("lang"), fromStorage("lang"), fromNavigator(), "en")
|
||||||
const locale = detect(fromStorage("lang"), fromNavigator(), "en")
|
let locale = detect(fromStorage("lang"), fromNavigator(), "en")
|
||||||
|
|
||||||
// log if dev
|
// log if dev
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
console.log("detected locale", locale)
|
console.log("detected locale", locale)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// activates locale
|
||||||
|
function activateLocale(locale: string, messages: Messages = enMessages) {
|
||||||
|
i18n.load(locale, messages)
|
||||||
|
i18n.activate(locale)
|
||||||
|
document.documentElement.lang = locale
|
||||||
|
localStorage.setItem("lang", locale)
|
||||||
|
$direction.set(locale.startsWith("ar") || locale.startsWith("fa") ? "rtl" : "ltr")
|
||||||
|
}
|
||||||
|
|
||||||
|
// dynamically loads translations for the given locale
|
||||||
export async function dynamicActivate(locale: string) {
|
export async function dynamicActivate(locale: string) {
|
||||||
try {
|
if (locale == "en") {
|
||||||
const { messages } = await import(`../locales/${locale}/${locale}.ts`)
|
activateLocale(locale)
|
||||||
i18n.load(locale, messages)
|
} else {
|
||||||
i18n.activate(locale)
|
try {
|
||||||
document.documentElement.lang = locale
|
const { messages }: { messages: Messages } = await import(`../locales/${locale}/${locale}.ts`)
|
||||||
$direction.set(locale.startsWith("ar") ? "rtl" : "ltr")
|
activateLocale(locale, messages)
|
||||||
localStorage.setItem("lang", locale)
|
} catch (error) {
|
||||||
} catch (error) {
|
console.error(`Error loading ${locale}`, error)
|
||||||
console.error(`Error loading ${locale}`, error)
|
activateLocale("en")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handle zh variants
|
||||||
if (locale?.startsWith("zh-")) {
|
if (locale?.startsWith("zh-")) {
|
||||||
// map zh variants to zh-CN
|
// map zh variants to zh-CN
|
||||||
const zhVariantMap: Record<string, string> = {
|
const zhVariantMap: Record<string, string> = {
|
||||||
"zh-CN": "zh-CN",
|
|
||||||
"zh-SG": "zh-CN",
|
|
||||||
"zh-MY": "zh-CN",
|
|
||||||
zh: "zh-CN",
|
|
||||||
"zh-Hans": "zh-CN",
|
|
||||||
"zh-HK": "zh-HK",
|
"zh-HK": "zh-HK",
|
||||||
"zh-TW": "zh-HK",
|
"zh-TW": "zh",
|
||||||
"zh-MO": "zh-HK",
|
"zh-MO": "zh",
|
||||||
"zh-Hant": "zh-HK",
|
"zh-Hant": "zh",
|
||||||
}
|
}
|
||||||
dynamicActivate(zhVariantMap[locale] || "zh-CN")
|
dynamicActivate(zhVariantMap[locale] || "zh-CN")
|
||||||
} else if (locale && !locale.startsWith("en")) {
|
|
||||||
dynamicActivate(locale.split("-")[0])
|
|
||||||
} else {
|
} else {
|
||||||
i18n.load("en", enMessages)
|
locale = (locale || "en").split("-")[0]
|
||||||
i18n.activate("en")
|
// use en if locale is not in languages
|
||||||
|
if (!languages.some((l) => l.lang === locale)) {
|
||||||
|
locale = "en"
|
||||||
|
}
|
||||||
|
dynamicActivate(locale)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"lang": "ar",
|
|
||||||
"label": "العربية",
|
|
||||||
"e": "🇵🇸"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"lang": "de",
|
|
||||||
"label": "Deutsch",
|
|
||||||
"e": "🇩🇪"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"lang": "en",
|
|
||||||
"label": "English",
|
|
||||||
"e": "🇺🇸"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"lang": "es",
|
|
||||||
"label": "Español",
|
|
||||||
"e": "🇲🇽"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"lang": "fr",
|
|
||||||
"label": "Français",
|
|
||||||
"e": "🇫🇷"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"lang": "it",
|
|
||||||
"label": "Italiano",
|
|
||||||
"e": "🇮🇹"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"lang": "ja",
|
|
||||||
"label": "日本語",
|
|
||||||
"e": "🇯🇵"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"lang": "ko",
|
|
||||||
"label": "한국어",
|
|
||||||
"e": "🇰🇷"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"lang": "pt",
|
|
||||||
"label": "Português",
|
|
||||||
"e": "🇧🇷"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"lang": "tr",
|
|
||||||
"label": "Türkçe",
|
|
||||||
"e": "🇹🇷"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"lang": "ru",
|
|
||||||
"label": "Русский",
|
|
||||||
"e": "🇷🇺"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"lang": "uk",
|
|
||||||
"label": "Українська",
|
|
||||||
"e": "🇺🇦"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"lang": "vi",
|
|
||||||
"label": "Tiếng Việt",
|
|
||||||
"e": "🇻🇳"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"lang": "zh-CN",
|
|
||||||
"label": "简体中文",
|
|
||||||
"e": "🇨🇳"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"lang": "zh-HK",
|
|
||||||
"label": "繁體中文",
|
|
||||||
"e": "🇭🇰"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
137
beszel/site/src/lib/languages.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
export default [
|
||||||
|
{
|
||||||
|
lang: "ar",
|
||||||
|
label: "العربية",
|
||||||
|
e: "🇵🇸",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lang: "bg",
|
||||||
|
label: "Български",
|
||||||
|
e: "🇧🇬",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lang: "cs",
|
||||||
|
label: "Čeština",
|
||||||
|
e: "🇨🇿",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lang: "da",
|
||||||
|
label: "Dansk",
|
||||||
|
e: "🇩🇰",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lang: "de",
|
||||||
|
label: "Deutsch",
|
||||||
|
e: "🇩🇪",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lang: "en",
|
||||||
|
label: "English",
|
||||||
|
e: "🇺🇸",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lang: "es",
|
||||||
|
label: "Español",
|
||||||
|
e: "🇲🇽",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lang: "fa",
|
||||||
|
label: "فارسی",
|
||||||
|
e: "🇮🇷",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lang: "fr",
|
||||||
|
label: "Français",
|
||||||
|
e: "🇫🇷",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lang: "hr",
|
||||||
|
label: "Hrvatski",
|
||||||
|
e: "🇭🇷",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lang: "hu",
|
||||||
|
label: "Magyar",
|
||||||
|
e: "🇭🇺",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lang: "it",
|
||||||
|
label: "Italiano",
|
||||||
|
e: "🇮🇹",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lang: "ja",
|
||||||
|
label: "日本語",
|
||||||
|
e: "🇯🇵",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lang: "ko",
|
||||||
|
label: "한국어",
|
||||||
|
e: "🇰🇷",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lang: "nl",
|
||||||
|
label: "Nederlands",
|
||||||
|
e: "🇳🇱",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lang: "no",
|
||||||
|
label: "Norsk",
|
||||||
|
e: "🇳🇴",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lang: "pl",
|
||||||
|
label: "Polski",
|
||||||
|
e: "🇵🇱",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lang: "pt",
|
||||||
|
label: "Português",
|
||||||
|
e: "🇧🇷",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lang: "tr",
|
||||||
|
label: "Türkçe",
|
||||||
|
e: "🇹🇷",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lang: "ru",
|
||||||
|
label: "Русский",
|
||||||
|
e: "🇷🇺",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lang: "sl",
|
||||||
|
label: "Slovenščina",
|
||||||
|
e: "🇸🇮",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lang: "sv",
|
||||||
|
label: "Svenska",
|
||||||
|
e: "🇸🇪",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lang: "uk",
|
||||||
|
label: "Українська",
|
||||||
|
e: "🇺🇦",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lang: "vi",
|
||||||
|
label: "Tiếng Việt",
|
||||||
|
e: "🇻🇳",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lang: "zh-CN",
|
||||||
|
label: "简体中文",
|
||||||
|
e: "🇨🇳",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lang: "zh-HK",
|
||||||
|
label: "繁體中文",
|
||||||
|
e: "🇭🇰",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lang: "zh",
|
||||||
|
label: "繁體中文",
|
||||||
|
e: "🇹🇼",
|
||||||
|
},
|
||||||
|
] as const
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import PocketBase from "pocketbase"
|
import PocketBase from "pocketbase"
|
||||||
import { atom, map, WritableAtom } from "nanostores"
|
import { atom, map, PreinitializedWritableAtom } from "nanostores"
|
||||||
import { AlertRecord, ChartTimes, SystemRecord, UserSettings } from "@/types"
|
import { AlertRecord, ChartTimes, SystemRecord, UserSettings } from "@/types"
|
||||||
|
import { basePath } from "@/components/router"
|
||||||
|
|
||||||
/** PocketBase JS Client */
|
/** PocketBase JS Client */
|
||||||
export const pb = new PocketBase("/")
|
export const pb = new PocketBase(basePath)
|
||||||
|
|
||||||
/** Store if user is authenticated */
|
/** Store if user is authenticated */
|
||||||
export const $authenticated = atom(pb.authStore.isValid)
|
export const $authenticated = atom(pb.authStore.isValid)
|
||||||
@@ -21,12 +22,15 @@ export const $publicKey = atom("")
|
|||||||
export const $hubVersion = atom("")
|
export const $hubVersion = atom("")
|
||||||
|
|
||||||
/** Chart time period */
|
/** Chart time period */
|
||||||
export const $chartTime = atom("1h") as WritableAtom<ChartTimes>
|
export const $chartTime = atom("1h") as PreinitializedWritableAtom<ChartTimes>
|
||||||
|
|
||||||
|
/** Whether to display average or max chart values */
|
||||||
|
export const $maxValues = atom(false)
|
||||||
|
|
||||||
/** User settings */
|
/** User settings */
|
||||||
export const $userSettings = map<UserSettings>({
|
export const $userSettings = map<UserSettings>({
|
||||||
chartTime: "1h",
|
chartTime: "1h",
|
||||||
emails: [pb.authStore.model?.email || ""],
|
emails: [pb.authStore.record?.email || ""],
|
||||||
})
|
})
|
||||||
// update local storage on change
|
// update local storage on change
|
||||||
$userSettings.subscribe((value) => {
|
$userSettings.subscribe((value) => {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { useEffect, useState } from "react"
|
|||||||
import { CpuIcon, HardDriveIcon, MemoryStickIcon, ServerIcon } from "lucide-react"
|
import { CpuIcon, HardDriveIcon, MemoryStickIcon, ServerIcon } from "lucide-react"
|
||||||
import { EthernetIcon, ThermometerIcon } from "@/components/ui/icons"
|
import { EthernetIcon, ThermometerIcon } from "@/components/ui/icons"
|
||||||
import { t } from "@lingui/macro"
|
import { t } from "@lingui/macro"
|
||||||
|
import { prependBasePath } from "@/components/router"
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs))
|
||||||
@@ -33,7 +34,7 @@ const verifyAuth = () => {
|
|||||||
pb.collection("users")
|
pb.collection("users")
|
||||||
.authRefresh()
|
.authRefresh()
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
pb.authStore.clear()
|
logOut()
|
||||||
toast({
|
toast({
|
||||||
title: t`Failed to authenticate`,
|
title: t`Failed to authenticate`,
|
||||||
description: t`Please log in again`,
|
description: t`Please log in again`,
|
||||||
@@ -42,15 +43,33 @@ const verifyAuth = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const updateSystemList = async () => {
|
export const updateSystemList = (() => {
|
||||||
const records = await pb
|
let isFetchingSystems = false
|
||||||
.collection<SystemRecord>("systems")
|
return async () => {
|
||||||
.getFullList({ sort: "+name", fields: "id,name,host,info,status" })
|
if (isFetchingSystems) {
|
||||||
if (records.length) {
|
return
|
||||||
$systems.set(records)
|
}
|
||||||
} else {
|
isFetchingSystems = true
|
||||||
verifyAuth()
|
try {
|
||||||
|
const records = await pb
|
||||||
|
.collection<SystemRecord>("systems")
|
||||||
|
.getFullList({ sort: "+name", fields: "id,name,host,port,info,status" })
|
||||||
|
|
||||||
|
if (records.length) {
|
||||||
|
$systems.set(records)
|
||||||
|
} else {
|
||||||
|
verifyAuth()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
isFetchingSystems = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
/** Logs the user out by clearing the auth store and unsubscribing from realtime updates. */
|
||||||
|
export async function logOut() {
|
||||||
|
pb.authStore.clear()
|
||||||
|
pb.realtime.unsubscribe()
|
||||||
}
|
}
|
||||||
|
|
||||||
export const updateAlerts = () => {
|
export const updateAlerts = () => {
|
||||||
@@ -88,11 +107,11 @@ export const formatDay = (timestamp: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const updateFavicon = (newIcon: string) => {
|
export const updateFavicon = (newIcon: string) => {
|
||||||
;(document.querySelector("link[rel='icon']") as HTMLLinkElement).href = `/static/${newIcon}`
|
;(document.querySelector("link[rel='icon']") as HTMLLinkElement).href = prependBasePath(`/static/${newIcon}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const isAdmin = () => pb.authStore.model?.role === "admin"
|
export const isAdmin = () => pb.authStore.record?.role === "admin"
|
||||||
export const isReadOnlyUser = () => pb.authStore.model?.role === "readonly"
|
export const isReadOnlyUser = () => pb.authStore.record?.role === "readonly"
|
||||||
|
|
||||||
/** Update systems / alerts list when records change */
|
/** Update systems / alerts list when records change */
|
||||||
export function updateRecordList<T extends RecordModel>(e: RecordSubscription<T>, $store: WritableAtom<T[]>) {
|
export function updateRecordList<T extends RecordModel>(e: RecordSubscription<T>, $store: WritableAtom<T[]>) {
|
||||||
@@ -251,7 +270,7 @@ export async function updateUserSettings() {
|
|||||||
}
|
}
|
||||||
// create user settings if error fetching existing
|
// create user settings if error fetching existing
|
||||||
try {
|
try {
|
||||||
const createdSettings = await pb.collection("user_settings").create({ user: pb.authStore.model!.id })
|
const createdSettings = await pb.collection("user_settings").create({ user: pb.authStore.record!.id })
|
||||||
$userSettings.set(createdSettings.settings)
|
$userSettings.set(createdSettings.settings)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log("create settings", e)
|
console.log("create settings", e)
|
||||||
@@ -272,7 +291,7 @@ export const getSizeAndUnit = (n: number, isGigabytes = true) => {
|
|||||||
} else if (sizeInGB >= 1) {
|
} else if (sizeInGB >= 1) {
|
||||||
return { v: sizeInGB, u: " GB" }
|
return { v: sizeInGB, u: " GB" }
|
||||||
}
|
}
|
||||||
return { v: n, u: " MB" }
|
return { v: isGigabytes ? sizeInGB * 1_000 : n, u: " MB" }
|
||||||
}
|
}
|
||||||
|
|
||||||
export const chartMargin = { top: 12 }
|
export const chartMargin = { top: 12 }
|
||||||
@@ -283,7 +302,8 @@ export const alertInfo: Record<string, AlertInfo> = {
|
|||||||
unit: "",
|
unit: "",
|
||||||
icon: ServerIcon,
|
icon: ServerIcon,
|
||||||
desc: () => t`Triggers when status switches between up and down`,
|
desc: () => t`Triggers when status switches between up and down`,
|
||||||
single: true,
|
/** "for x minutes" is appended to desc when only one value */
|
||||||
|
singleDesc: () => t`System` + " " + t`Down`,
|
||||||
},
|
},
|
||||||
CPU: {
|
CPU: {
|
||||||
name: () => t`CPU Usage`,
|
name: () => t`CPU Usage`,
|
||||||
@@ -317,3 +337,11 @@ export const alertInfo: Record<string, AlertInfo> = {
|
|||||||
desc: () => t`Triggers when any sensor exceeds a threshold`,
|
desc: () => t`Triggers when any sensor exceeds a threshold`,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retuns value of system host, truncating full path if socket.
|
||||||
|
* @example
|
||||||
|
* // Assuming system.host is "/var/run/beszel.sock"
|
||||||
|
* const hostname = getHostDisplayValue(system) // hostname will be "beszel.sock"
|
||||||
|
*/
|
||||||
|
export const getHostDisplayValue = (system: SystemRecord): string => system.host.slice(system.host.lastIndexOf("/") + 1)
|
||||||
|
|||||||